起因
第一次做 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。
踩过的坑
-
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) -
PDF 提取文字时表格变乱:纯文本提取(pdfplumber)保留不了表格结构。
表格密集的 PDF 用unstructured库专门处理表格,或者 PDF → HTML → 切。 -
重复内容污染检索:每篇文档都有相同的 "本文档版权所有…" 页脚 →
embedding 集中在这个 footer,检索结果偏向有 footer 的文档。
切之前先 strip 这些 boilerplate。 -
多语言文档:中英文混排时 RecursiveCharacterTextSplitter 默认
separators 不包含中文标点。手动加"。"、"!"、"?"、";"。
登录后参与评论。