3. 文档预处理:文本清洗、分句与分块策略、块大小对检索性能的影响
文档预处理,说白了就是给 RAG 系统「喂饭」的第一步。你想想看,如果原始文档里全是乱码、空行、无关广告,或者一段话被硬生生截断,那后面的检索效果能好吗?
我个人习惯把预处理分成三个环节:清洗 → 分句 → 分块。每一步都有坑,咱们一个一个说。
3.1 文本清洗:别让脏数据毁了你的索引
清洗这一步,很多人觉得简单,不就是去掉 HTML 标签、去掉特殊字符嘛。但我在项目中遇到过几次「翻车」——清洗过度,把关键信息也洗掉了。
所以,清洗要分场景:
- 网页抓取:保留标题、正文、列表;去掉导航、广告、脚本、样式。
- PDF/Word:注意页眉页脚、分页符、表格结构。我习惯用
PyMuPDF或python-docx逐段提取。 - 代码/日志:保留缩进和换行,但去掉时间戳、IP 地址等噪声(除非你需要检索它们)。
一个简单的清洗流程示例:
import re
def clean_text(text: str, preserve_math: bool = False) -> str:
# 1. 去掉 HTML 标签
text = re.sub(r'<[^>]+>', '', text)
# 2. 去掉多余空白(保留换行)
text = re.sub(r'[ \t]+', ' ', text)
text = re.sub(r'\n{3,}', '\n\n', text)
# 3. 保留数学符号(可选)
if not preserve_math:
text = re.sub(r'[^\w\s\.\,\;\:\!\?\(\)\[\]\{\}\-\+\=\/\*]', '', text)
return text.strip()
3.2 分句策略:语义单元的边界在哪里?
分句是分块的前置步骤。分得太粗,一个句子跨多个块;分得太细,语义被切碎。嗯,这里要注意:分句不是简单的「遇到句号就切」。
我常用的分句规则:
- 标点边界:句号、问号、感叹号、分号、冒号(中文还要加句号、问号、感叹号)。
- 换行边界:连续两个换行符(段落分隔)。
- 列表项边界:每个
-或1.开头的新行。 - 特殊结构:代码块、表格、引用块单独成句。
举个例子,一段混合文本:
# 原始文本
"RAG 系统包含三个核心模块:检索器、生成器、融合器。\n\n检索器负责从索引中召回相关文档。\n- 使用向量检索\n- 支持混合检索\n\n生成器基于召回结果生成答案。"
# 分句结果
["RAG 系统包含三个核心模块:检索器、生成器、融合器。",
"检索器负责从索引中召回相关文档。",
"- 使用向量检索",
"- 支持混合检索",
"生成器基于召回结果生成答案。"]
你看,列表项被单独保留,这样后续分块时不会把列表拆散。
3.3 分块策略:固定大小 vs. 语义边界
分块是预处理里最考验经验的一步。块太大,检索精度下降;块太小,上下文丢失。我见过不少团队在这上面反复调参。
目前主流的分块策略有三种:
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定大小 | 按字符数或 数切分,可带重叠 | 实现简单,性能稳定 | 可能切断语义,块边界不自然 |
| 语义分块 | 按段落、标题、列表等自然边界切分 | 语义完整,检索质量高 | 块大小不均匀,实现复杂 |
| 递归分块 | 先按语义边界切,超长块再递归切 | 兼顾语义完整性和大小控制 | 需要调参,递归深度影响性能 |
我个人最常用的是递归分块。先按段落切,如果段落超过最大 数,再按句子切,最后按固定大小切。这样能最大程度保留语义。
一个递归分块的伪代码:
def recursive_chunk(text, max_tokens=512, overlap=64):
# 1. 按段落切
paragraphs = split_by_paragraph(text)
chunks = []
for para in paragraphs:
if count_tokens(para) <= max_tokens:
chunks.append(para)
else:
# 2. 按句子切
sentences = split_by_sentence(para)
current_chunk = ""
for sent in sentences:
if count_tokens(current_chunk + sent) > max_tokens:
chunks.append(current_chunk)
# 3. 保留重叠部分
current_chunk = get_last_tokens(current_chunk, overlap)
current_chunk += sent
if current_chunk:
chunks.append(current_chunk)
return chunks
3.4 块大小对检索性能的影响
块大小是 RAG 系统里最敏感的参数之一。我调过很多次,总结下来就是:没有银弹,只有权衡。
为什么会这样?咱们看一组实验数据:
| 块大小 () | 检索召回率 (Recall@5) | 生成准确率 (F1) | 索引大小 (MB) | 检索延迟 (ms) |
|---|---|---|---|---|
| 128 | 0.72 | 0.61 | 45 | 12 |
| 256 | 0.81 | 0.73 | 38 | 15 |
| 512 | 0.85 | 0.78 | 32 | 20 |
| 1024 | 0.83 | 0.74 | 28 | 35 |
| 2048 | 0.78 | 0.67 | 25 | 58 |
你看,512 左右是个甜点。太小了(128),每个块信息量不足,检索召回率低;太大了(2048),块内噪声多,生成器容易跑偏,而且延迟飙升。
但注意,块大小不是孤立的。它和嵌入模型的上下文窗口、生成模型的输入长度都有关系。比如你用 text-embedding-3-small(窗口 8192 ),块大小可以放大到 1024;但如果你用 all-MiniLM-L6-v2(窗口 256 ),块大小就不能超过 256。
3.5 本章知识体系
下面这张图总结了文档预处理的完整流程和关键决策点:
从图里能看出来,预处理不是一条直线走到底的。分块策略选完后,还要根据块大小调优的结果,回头调整分句规则或清洗策略。我每次做新项目,至少会迭代 3-5 轮才能找到最优配置。