RAG 里 80% 的召回问题最后都追到这里

切分为什么是 RAG 最容易翻车的环节

embedding 模型选对了之后,下一个最关键的决定是怎么切片。一份 100 页的 PDF,是按句子切、按段落切、按章节切?每片多长?要不要重叠?这些选择直接决定了你 RAG 系统的召回质量。

切分这件事被两个相互拉扯的目标约束。一边是单片信息量要够——太短的切片(比如一句话)信息密度太低,1024 维向量基本是噪声,召回完全不可信;另一边是单片话题要单一——一片里塞了多个不相关的话题,embedding 会把它们的语义"平均"掉,每个话题都没法被准确召回。所有切分技术都是在这两端之间寻找平衡。

实战里 RAG 召回不准、答非所问的问题,80% 最后能追到"切片切坏了"这一步。下面这一节按"每个新方法解决了上一个的什么问题"的顺序,把主流切分技术串一遍。

切分方法的演化:每一步都是上一步的"补丁"

最朴素的做法叫固定长度切分——每 N 个字符或 token 切一刀,代码两行:

def fixed_split(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
    chunks = []
    start = 0
    while start < len(text):
        chunks.append(text[start:start + chunk_size])
        start += chunk_size - overlap
    return chunks

这个东西几乎不要直接用,但它定义了两个绕不开的参数——chunk_sizeoverlap,下面所有切分方法都还在调它俩。它最致命的问题是野蛮:可能在句子中间下刀,英文按字符切还会断词。要解决这个问题,下一档把"在哪里切"换成有语义意义的位置。

按分隔符切分 选句号、换行、标题这种有语义意义的边界下刀。中文常用 "。"、"!"、"?"、"\n\n":

import re

def split_by_sentence(text: str) -> list[str]:
    return re.split(r'(?<=[。!?.!?])\s*', text)

这样保证了不会把一句话切成两半,但代价是每段长短不一——有时一句话特别短、有时一段特别长,最后还是要做"短的合并、长的再切"的二次处理。长度不齐这件事,又把人逼回了 chunk_size 这个老问题。

把上面两条思路合起来,就有了 RAG 圈现在的事实标准——递归切分。LangChain 的 RecursiveCharacterTextSplitter 把分隔符做成一个优先级列表,从大粒度往小粒度递归切:

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,
    separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
    keep_separator=True,
)
chunks = splitter.split_text(long_text)

它先尝试用 \n\n(段落级)切,切完某段还超 chunk_size 就在那段里用 \n(行级)再切,再不行用句号、逗号、空格、字符。优先在大粒度的语义边界下刀,不得已才动小粒度——长度大致均匀,又不会随便切坏一句话。

90% 的项目用到这一档就够了。但它仍然有个盲区——它不知道话题在哪里切换。一段技术博客里可能从"性能优化"突然跳到"安全注意事项",递归切分有可能恰好把这个跳跃点切在某段中间,召回时拿到一个"半性能优化半安全建议"的混合 chunk,向量在两个话题中间挤成噪声。

要让模型自己识别话题边界,得用 embedding 的几何性质——

语义切分(semantic chunking) 把文档按句子切成小块,给每个句子算 embedding,计算相邻句子的余弦相似度,在相似度突降的位置下刀——通常这就是话题切换处。LangChain / LlamaIndex 都有现成的 SemanticChunker

from langchain_experimental.text_splitter import SemanticChunker
from langchain_huggingface import HuggingFaceEmbeddings

splitter = SemanticChunker(
    HuggingFaceEmbeddings(model_name="BAAI/bge-m3"),
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=95,  # 相似度差超过 95 分位时切
)
chunks = splitter.create_documents([long_text])

语义切分的召回质量明显高一档,代价是慢——每个句子都要过一遍 embedding 模型,10 万级以上文档量没 GPU 撑不住。实战策略是先用递归切跑通 baseline,看到话题混合带来的召回问题再升级。一上来就上语义切分往往是过早优化。

到这里讲的四种都是把文档当成"无结构的纯文本"。但很多实际场景里,文档自带结构——Markdown 有标题层级、HTML 有 section 标签、代码有函数边界、解析过的 PDF 也有章节。这种情况完全不必再去推断切点,直接按结构切就行:

from langchain_text_splitters import MarkdownHeaderTextSplitter

splitter = MarkdownHeaderTextSplitter(
    headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")],
)
chunks = splitter.split_text(markdown_doc)
# 每个 chunk 自带 metadata:{"h1": "...", "h2": "...", ...}

结构感知切分 最大的好处不是切得更准,而是每个 chunk 自带层级元数据——它知道自己属于哪一章哪一节。这个信息在检索时能用来做章节过滤、在回答时能给用户标出来源、在 debug 时能帮你定位 chunk 来自原文哪里。零成本的副产品,价值非常高。代码场景里同理,用 tree-sitter 按函数 / 类切,每个 chunk 自带文件路径 + 函数签名作为 metadata。

chunk_size 和 overlap:两个数字怎么选才不挨打

新手最纠结的两个参数。先给一个能直接用的起点——

chunk_size = 400 (token)
overlap    = 50  (≈ 12% of chunk_size)

——然后理解这两个数字背后的取舍,根据自己业务调。

chunk_size 选多大,本质是在信息量话题纯度之间找平衡,前面讲过的两端在这里又冒出来了。chunk 太小,向量在嵌入空间里基本是噪声;chunk 太大,单 chunk 里塞了多个话题,embedding 把它们平均成一个向量,每个话题都被掩盖。两端都要避开,那中间在哪?三个因素决定甜区位置:

embedding 模型的上下文长度 是硬上限。bge-large-zh-v1.5 是 512,超过就被静默截掉,没有报错——这是 RAG 里非常常见的隐形 bug,切片时一定要先确认你的 chunk 都在模型上下文之内。bge-m3 是 8K,最舒服。

内容密度 决定甜区。技术文档、API reference 这种信息密度极高,300 token 一段就够;散文、小说、行业报告内容密度低,600~800 token 一段才能装下一个完整的论述。

查询粒度 决定召回边界。如果用户的问题通常是细节级("重置密码的具体步骤"),chunk_size 偏小召回更精准;如果是概括级("这章在讲什么"),chunk_size 偏大效果更好。

特别强调一件事:容量不等于建议值。即使模型支持 8K 上下文,把整篇 8K 文档作为单个 chunk 是个糟糕选择——理由跟"chunk 太大"那条一样:信息被平均、用户问的问题通常只涉及某一部分而不是整篇、整篇 chunk 在 LLM 上下文里占的份额太大。看到 8K 上下文不要兴奋地拉满。

overlap 的作用相对单一——避免在边界处切断关键信息。一句话刚好被切到两个 chunk 边界上,没有 overlap 就丢了;有 50 token 的 overlap,前后两个 chunk 都包含完整的这句话,至少一边能命中。但 overlap 也不能太大,50% overlap 意味着每段内容存了两次,向量库膨胀一倍。10%~15% 是经验上的甜区。

最后一条最重要:真正决定参数的不是这篇文章,是你的评测。起手 400/50,准备 50~100 个真实查询 + 期望召回的文档(番外 15 讲怎么造),调 chunk_size 试 300/500/700 三档,调 overlap 试 30/80 两档,看 Recall@5 和 MRR 哪个组合最好。这两个参数在不同业务上甜区差几倍——拍脑袋选就是在赌博,靠测出来才靠谱。

不同文档类型的特殊处理

PDF 是最麻烦的

PDF 的 layout 是给人看的、不是给程序读的。直接用 pypdf 提取文字,会得到一堆乱序的文字块——双栏排版会被读成左右交叉、表格会变成一行行没结构的文字、页眉页脚混在内容里。

主流方案:

  • pypdf / pdfplumber:纯文字 PDF 还行,扫描版不行。
  • unstructured:自动识别标题、段落、表格、列表,输出结构化 element 列表。比 pypdf 强一档,但慢。
  • Marker:把 PDF 直接转 Markdown,用了 OCR 和 layout 模型,效果接近 LLM 抽取但便宜得多。
  • GPT-4o vision / Claude vision:最强但最贵,每页 PDF 几分钱。重要文档(合同、论文)值得用。
  • MinerU、PaddleOCR:国产方案,对中文 + 复杂版式效果好。

实战经验:先看你的 PDF 是什么形态——纯文字(pypdf 就够)、扫描版(必须 OCR)、复杂版式(unstructured / Marker / vision LLM)。盲目用一种工具会翻车。

Markdown 用 MarkdownHeaderTextSplitter 加层级

按上面讲过的方式切,每个 chunk 自带 h1/h2/h3 路径作为元数据。检索召回时把这个路径拼到 chunk 内容前面再喂给 embedding 和 LLM——给召回器和生成器都加了上下文,效果显著提升:

def enrich_chunk_with_path(chunk: dict) -> str:
    path = " > ".join(filter(None, [chunk.get("h1"), chunk.get("h2"), chunk.get("h3")]))
    return f"[{path}]\n{chunk['content']}"

代码按函数 / 类切

tree-sitter 是各语言通用的语法解析器,能精确切到函数 / 类粒度。LangChain 也有 Language.PYTHON 之类的预设:

from langchain_text_splitters import RecursiveCharacterTextSplitter, Language

splitter = RecursiveCharacterTextSplitter.from_language(
    language=Language.PYTHON, chunk_size=1000, chunk_overlap=100,
)

代码场景里 chunk_size 通常比文本大(500~1500 token),因为单个函数/类是不可拆分的逻辑单元。

表格不要切碎

表格是高度结构化的,按行切会丢列上下文,按列切会丢行上下文。最好把整张表作为一个 chunk(如果表不大);表大的话按"表头 + 若干行"切。unstructured 库会把表格识别成单独的 element,便于这种处理。

如果表格特别大(财报里上百行),可以让 LLM 先读一遍生成"表格摘要",把摘要 + 原始表格一起作为多个 chunk 存进去——召回时能命中"摘要"或"具体行"两条路。

元数据:被低估的"第二召回通道"

每个 chunk 不只是"一段文字 + 一个向量"。给它附上结构化元数据,能在检索时大幅提升精度:

chunk = {
    "content": "重置密码的步骤是...",
    "embedding": [0.1, 0.2, ...],
    "metadata": {
        "source": "user_manual_v2.pdf",
        "page": 23,
        "section": "账户管理 > 密码相关",
        "doc_type": "用户手册",
        "language": "zh",
        "updated_at": "2026-04-15",
    }
}

元数据在检索时能做两件事:

过滤——"只在这个 doc_type 里检索"、"只看 2026 年之后的内容"。Qdrant 等向量库都支持高效的 metadata filter,番外 14 会展开。

增强 prompt——召回到 chunk 后,把 metadata 也拼到 LLM 上下文里:"以下内容来自《用户手册 v2》第 23 页 [账户管理 > 密码相关] 章节",让 LLM 回答时知道引用来源。

让 LLM 帮你生成 chunk 摘要也是个好套路——预处理时给每个 chunk 加一个一句话摘要,作为额外的 metadata。检索时既可以拿摘要去匹配 query(适合粗召),也可以拿原文去匹配(适合精召)。这种"双向量"的策略在 LlamaIndex 里叫 hierarchical chunking,复杂但召回质量高一档。

一个能用的完整切分函数

把上面所有套路合一,给一个生产环境能用的 starter:

from langchain_text_splitters import (
    RecursiveCharacterTextSplitter,
    MarkdownHeaderTextSplitter,
)
from typing import Iterator


def smart_split(
    text: str,
    doc_type: str = "plain",      # plain / markdown / code
    source: str = "",
    chunk_size: int = 400,
    chunk_overlap: int = 50,
) -> Iterator[dict]:
    """通用切分入口,按文档类型自动选策略。"""

    # 1. 按结构粗切
    if doc_type == "markdown":
        md_splitter = MarkdownHeaderTextSplitter(
            headers_to_split_on=[("#", "h1"), ("##", "h2"), ("###", "h3")]
        )
        sections = md_splitter.split_text(text)
    else:
        sections = [{"page_content": text, "metadata": {}}]

    # 2. 在结构内按递归字符切
    char_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", "。", "!", "?", ",", " ", ""],
    )

    # 3. 输出每个 chunk + metadata
    for section in sections:
        section_text = (
            section.page_content if hasattr(section, "page_content") else section["page_content"]
        )
        section_meta = (
            section.metadata if hasattr(section, "metadata") else section.get("metadata", {})
        )
        for sub in char_splitter.split_text(section_text):
            path = " > ".join(filter(None, [
                section_meta.get("h1"), section_meta.get("h2"), section_meta.get("h3"),
            ]))
            yield {
                "content": sub if not path else f"[{path}]\n{sub}",
                "metadata": {
                    "source": source,
                    "doc_type": doc_type,
                    "section_path": path,
                    **section_meta,
                },
            }

实际项目里在这之上加个 PDF 解析(用 unstructured 或 Marker)、加个 chunk_id 生成、加个去重,就是一个堪用的预处理 pipeline。

收尾

切分这件事看起来不起眼,但 RAG 真正能不能用就在这一步——再好的 embedding 模型也救不回切坏的片子。两条建议:

默认 RecursiveCharacterTextSplitter + chunk_size 400 + overlap 50 起手,根据评测调整。不要一上来追求语义切分等花式技术。

重视元数据。每个 chunk 带上来源、章节、更新时间这些信息——检索时能过滤、回答时能溯源、debug 时能定位,几乎零成本但实战回报大。

下一篇番外 14 我们把切好的 chunk 喂进 Qdrant,并把向量检索 + BM25 + Reranker 串成一条完整的检索栈。

参考资料

版权声明: 如无特别声明,本文版权归 sshipanoo 所有,转载请注明本文链接。

(采用 CC BY-NC-SA 4.0 许可协议进行授权)

本文标题:番外 13:文本切分策略与 chunk_size 选择

本文链接:https://www.sshipanoo.com/blog/ai/ai-for-python/番外13-文本切分策略/

本文最后一次更新为 天前,文章中的某些内容可能已过时!