3. 文档预处理:文本清洗、分句与分块策略、块大小对检索性能的影响

文档预处理,说白了就是给 RAG 系统「喂饭」的第一步。你想想看,如果原始文档里全是乱码、空行、无关广告,或者一段话被硬生生截断,那后面的检索效果能好吗?

我个人习惯把预处理分成三个环节:清洗 → 分句 → 分块。每一步都有坑,咱们一个一个说。

3.1 文本清洗:别让脏数据毁了你的索引

清洗这一步,很多人觉得简单,不就是去掉 HTML 标签、去掉特殊字符嘛。但我在项目中遇到过几次「翻车」——清洗过度,把关键信息也洗掉了。

⚠️ 避坑指南:我曾经把一个 PDF 里的数学公式当乱码删了,结果检索「梯度下降」时死活找不到那篇。后来才发现,公式里的希腊字母被正则表达式误杀了。

所以,清洗要分场景:

  • 网页抓取:保留标题、正文、列表;去掉导航、广告、脚本、样式。
  • 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()
💡 小技巧:清洗后,建议抽样 10% 的文档人工检查一遍。我每次上线前都会跑一个「清洗报告」,统计被删除字符的类型和数量,防止误伤。

3.2 分句策略:语义单元的边界在哪里?

分句是分块的前置步骤。分得太粗,一个句子跨多个块;分得太细,语义被切碎。嗯,这里要注意:分句不是简单的「遇到句号就切」。

我常用的分句规则:

  1. 标点边界:句号、问号、感叹号、分号、冒号(中文还要加句号、问号、感叹号)。
  2. 换行边界:连续两个换行符(段落分隔)。
  3. 列表项边界:每个 - 或 1. 开头的新行。
  4. 特殊结构:代码块、表格、引用块单独成句。

举个例子,一段混合文本:

# 原始文本
"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
💡 重叠的作用:重叠是为了避免「边界截断」导致的信息丢失。我一般设 10%-20% 的重叠率。比如块大小 512 ,重叠 64 。这样即使一个句子被切到两个块里,两个块都能覆盖到它。

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),块内噪声多,生成器容易跑偏,而且延迟飙升。

📌 我的经验:对于通用文档(新闻、、技术博客),512-768 是安全区间。对于代码或结构化数据,可以适当缩小到 256-384 。对于长文档(书籍、报告),建议用 1024 配合语义分块。

但注意,块大小不是孤立的。它和嵌入模型的上下文窗口、生成模型的输入长度都有关系。比如你用 text-embedding-3-small(窗口 8192 ),块大小可以放大到 1024;但如果你用 all-MiniLM-L6-v2(窗口 256 ),块大小就不能超过 256。

⚠️ 避坑指南:我曾经在一个项目里,把块大小从 512 调到 1024,以为能提高召回率。结果生成准确率反而下降了。后来发现,是因为块变大了,每个块里包含多个主题,生成器不知道该以哪个主题为准。所以,块内主题一致性比块大小更重要。

3.5 本章知识体系

下面这张图总结了文档预处理的完整流程和关键决策点:

文档预处理流程与关键决策点 文档预处理流程与关键决策点 原始文档 文本清洗 分句 分块策略选择 固定大小分块 语义分块 递归分块 块大小调优 高质量索引块 块大小建议:512-768 重叠率:10%-20%

从图里能看出来,预处理不是一条直线走到底的。分块策略选完后,还要根据块大小调优的结果,回头调整分句规则或清洗策略。我每次做新项目,至少会迭代 3-5 轮才能找到最优配置。

💡 最后一个小建议:不要迷信某个固定的块大小。拿你的真实数据跑一组对比实验,用 Recall@K 和生成 F1 两个指标来选。我见过太多团队直接抄别人的参数,结果效果惨不忍睹。