RAG 文档切块的 3 种策略对比:固定长度 / 语义边界 / 父子层级

起因

第一次做 RAG 时按"每 500 字符一刀"切了 1 万份文档进向量库,
召回出来的 chunk 经常是半句话开头、半句话结尾。LLM 看着这种残缺片段
回答经常张冠李戴:"根据文档 ...(被截断)"。
解决回答质量问题,70% 在切块上下功夫,30% 在检索算法。

三种切块策略

A. 固定长度(最简单)

def chunk_fixed(text, size=500, overlap=50):
    chunks = []
    for i in range(0, len(text), size - overlap):
        chunks.append(text[i:i+size])
    return chunks

overlap 让相邻 chunk 有 50 字符重叠,避免句子被切两半时只有半句进
任一 chunk。优点:实现简单,长度均匀;缺点:仍可能在标点 / 段落中间截断。

B. 语义边界(推荐)

按段落、句子、Markdown 标题切。LangChain 的 RecursiveCharacterTextSplitter
最常用:

from langchain_text_splitters import RecursiveCharacterTextSplitter

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

separators 顺序很重要:先按双换行切(段落),不够再按单换行(行),
还不够按句号,最后才硬切。中文要加 "。""!""?"
英文版本默认有 "."

代码 / 表格 / Markdown 文档:

from langchain_text_splitters import MarkdownHeaderTextSplitter

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

按 Markdown 标题切的好处:chunk 自带"它属于哪一节"的元数据,可以在
prompt 里附带给 LLM 当上下文。

C. 父子层级(parent-child retrieval)

切两份:

  • 小 chunk(200-300 字符)用于 embedding + 检索(细颗粒,语义匹配准)
  • 大 chunk(1000-2000 字符)用于喂给 LLM(提供完整上下文)

存储时小 chunk 通过 metadata 指向所属大 chunk:

from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore

parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=400)

retriever = ParentDocumentRetriever(
    vectorstore=Chroma(embedding=emb),
    docstore=InMemoryStore(),
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)
retriever.add_documents(docs)

# 查询时:小 chunk 匹配 → 返回对应大 chunk
chunks = retriever.invoke('问题')

效果对比

我在一个内部知识库(5000 篇文档)做过 A/B:

策略 检索准确率 LLM 回答完整度 实现复杂度
固定长度 62% 70%(多被截断) 极简
语义边界 78% 88% 简单
父子层级 81% 94%

结论:起步用语义边界(RecursiveCharacterTextSplitter),效果不够好
再升级到父子。固定长度只在快速 POC 时用。

其它要诀

1. chunk size 不是越小越好

300 字符的 chunk 召回时上下文太少;2000 字符的 chunk embedding 时
语义被"稀释",匹配不准。400-800 字符是甜点(英文)/ 200-400 中文字符
(中文每字承载语义比英文 token 多)。

2. metadata 一起存

chunk = {
    'text': '...',
    'metadata': {
        'source': 'manual.md',
        'section': 'Installation',
        'date': '2024-01-15',
    }
}

检索时可以 filter(只查"最近 30 天的内容"),rerank 时可以加权。

3. 长文档加 summary chunk

文档开头加一个"全文摘要" chunk(用 LLM 生成)作为 top-level entry。
检索时 summary chunk 匹配 → 暗示整篇文档相关 → 扩展取相邻 chunk。

踩过的坑

  1. Tokenizer 不匹配:embedding model 是按 token 计长,但我按字符切
    500,可能实际是 800-1200 token,超过 model 上限被截。改成 from_huggingface_tokenizer
    按真实 token 切:
    python splitter = RecursiveCharacterTextSplitter.from_huggingface_tokenizer( tokenizer, chunk_size=200, chunk_overlap=20)

  2. PDF 提取文字时表格变乱:纯文本提取(pdfplumber)保留不了表格结构。
    表格密集的 PDF 用 unstructured 库专门处理表格,或者 PDF → HTML → 切。

  3. 重复内容污染检索:每篇文档都有相同的 "本文档版权所有…" 页脚 →
    embedding 集中在这个 footer,检索结果偏向有 footer 的文档。
    切之前先 strip 这些 boilerplate。

  4. 多语言文档:中英文混排时 RecursiveCharacterTextSplitter 默认
    separators 不包含中文标点。手动加 "。""!""?"";"

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。