RAG 效果差?先看看你的语料清洗和 Chunk 做对了吗
很多人聊 RAG,张口就是向量模型、Embedding 精度、检索排序算法。诚然这些很重要,但还有一个被严重低估的环节——语料清洗与 Chunk。
我见过太多项目,花了大价钱调优向量模型,结果召回的内容还是乱七八糟。问题往往不在检索端,而在最上游:脏数据、混乱的文档结构、拍脑袋定的 chunk 大小。
今天从实战角度,聊一套完整的高质量语料清洗与 Chunk 方案。
一、语料清洗:磨刀不误砍柴工
1. 去除”脏”内容
原始语料常见的噪音:
- HTML 标签、Markdown 语法残留
- 页眉页脚、版权声明、导航栏
- 重复的标题、广告水印
- 乱码、异常字符
- 连续的空白符和无意义换行
import re
def clean_text(text: str) -> str:
# 去除 HTML 标签
text = re.sub(r'<[^>]+>', '', text)
# 去除 Markdown 图片和链接
text = re.sub(r'!\[.*?\]\(.*?\)', '', text)
text = re.sub(r'\[.*?\]\(.*?\)', r'\1', text)
# 去除多余空白
text = re.sub(r'\n{3,}', '\n\n', text)
text = re.sub(r'[ \t]+', ' ', text)
return text.strip()
2. 结构识别与保留
清洗不是把所有格式抹平,文档结构信息同样重要。我们需要在清洗时保留:
- 标题层级(H1/H2/H3)
- 表格结构
- 代码块的语法标记
- 列表项的层级关系
from dataclasses import dataclass
from typing import List, Optional
@dataclass
class DocumentBlock:
type: str # 'heading', 'paragraph', 'table', 'code', 'list'
level: int = 0 # 标题层级
content: str
metadata: dict = None
3. 内容质量过滤
不是所有文字都值得进入知识库:
def is_quality_content(text: str) -> bool:
# 过滤纯数字、纯符号
if re.match(r'^[\d\s\W]+$', text):
return False
# 过滤过短的内容
if len(text) < 50:
return False
# 过滤代码行过多的情况(保留技术文档,但排除纯日志)
code_ratio = text.count('`') / len(text)
if code_ratio > 0.3:
return False
return True
二、Chunk 策略:不是切完就完事
1. Chunk 大小的选择
这是个技术活,没有银弹:
| 场景 | 推荐 Chunk Size | 理由 |
|---|---|---|
| 问答系统 | 300-500 tokens | 答案通常短小精悍 |
| 文档摘要 | 800-1200 tokens | 需要足够的上下文 |
| 代码检索 | 50-200 tokens | 函数/类级别,语义独立 |
| 复杂长文 | 1000-2000 tokens | 需要段落完整性 |
2. 语义感知的切分策略
不要简单地按字符数切分,会切断语义。
推荐按自然语义单元切分:
from typing import Iterator
def semantic_chunk(text: str, max_tokens: int = 500) -> Iterator[str]:
"""
按语义单元切分,优先保持段落完整
"""
# 优先在 H2/H3 标题处切分
sections = re.split(r'(?=\n##\s)', text)
chunks = []
current_chunk = []
current_size = 0
for section in sections:
section_size = estimate_tokens(section)
if current_size + section_size <= max_tokens:
current_chunk.append(section)
current_size += section_size
else:
if current_chunk:
yield '\n'.join(current_chunk)
# 如果单个 section 就超过限制,按段落切
if section_size > max_tokens:
for para in section.split('\n\n'):
if len(para) < max_tokens:
yield para
else:
yield para # 仍需返回,让下游处理
else:
current_chunk = [section]
current_size = section_size
if current_chunk:
yield '\n'.join(current_chunk)
3. 带上下文的 Chunk
纯粹的切分会导致边界信息丢失。重叠 Chunk 和 元信息注入 是两个有效手段:
def chunk_with_context(
text: str,
max_tokens: int = 500,
overlap_tokens: int = 100
) -> List[dict]:
chunks = []
start = 0
while start < len(text):
end = start + max_tokens
chunk_text = text[start:end]
# 提取该 Chunk 所在章节的标题,作为上下文前缀
section_title = extract_nearest_heading(text, start)
chunks.append({
'content': chunk_text,
'metadata': {
'section': section_title,
'position': start,
'doc_title': get_doc_title(text)
}
})
start += max_tokens - overlap_tokens
return chunks
三、实战流程总结
原始文档
↓
HTML/ PDF 解析 → 结构化提取
↓
内容清洗 → 去噪 + 格式规范化
↓
质量过滤 → 短文本/噪音过滤
↓
语义 Chunk → 按语义单元切分
↓
上下文增强 → 添加章节标题/文档标题
↓
向量化 → 存入向量数据库
四、避坑指南
别贪大:很多人觉得 chunk 越大信息越全,实际上大 chunk 会稀释核心信息,召回精度反而下降。
保留表格:表格被转成纯文本后语义丢失严重。有条件的话,用 Markdown 表格格式保留结构,或者单独处理。
代码块单独存:代码和技术解释文字混在一起时,Embedding 容易被带偏。建议代码块独立存储,检索时可以单独召回。
中文分词注意:基于 token 的 chunk 要考虑中文分词器,Character-level 切分对中文不太友好。
建立评估集:用真实问答对测试不同 chunk 策略的效果,而不是拍脑袋选参数。
写在最后
RAG 是个系统工程,检索只是最后一环。高质量的语料处理,是一切的基础。
与其花时间调参,不如先把数据清洗干净、把 chunk 策略做对。这往往能带来 50% 以上的效果提升,比换什么向量模型都管用。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 1056615746@qq.com