文档切分:RAG 的第一步,也是最容易踩坑的一步
上一篇文章建立了 RAG 的全局认知。从这篇开始,我们深入 RAG 管线的每个环节,从第一步——文档切分开始。
文档切分是 RAG 中最容易被忽视、但影响效果最大的环节之一。很多人把精力花在选 Embedding 模型和调检索算法上,却忽略了:如果切分做得不好,后面的环节再怎么优化也无济于事。
垃圾进,垃圾出——这个原则在 RAG 中尤其适用。
一、为什么切分这么重要
RAG 的检索粒度就是 chunk。用户问一个问题,系统返回的是最相似的 k 个 chunk。如果 chunk 本身有问题,检索结果就不会好。
切分太粗的问题: 一个 chunk 里包含多个不相关内容。检索时,chunk 里只有一小部分和查询相关,其余都是噪声。Embedding 向量被这些噪声"稀释",导致语义不明确,检索精度下降。
切分太碎的问题: 一个 chunk 里只有几句话,缺乏上下文。模型拿到这个 chunk 时,不知道"如上所述"指的是什么,不知道"这个功能"说的是哪个功能。上下文断裂导致生成质量下降。
打个比方:你要在一本书里找到某个知识点。如果按页切分(太粗),一页里有十个知识点,你很难定位到具体那个。如果按句切分(太碎),一句话脱离了上下文,你根本不知道它在说什么。最好的方式是按章节或段落切分——每个片段语义完整,又不包含太多无关内容。
切分的核心矛盾:精准 vs 上下文。 越小的 chunk 检索越精准,但上下文越不完整。越大的 chunk 上下文越完整,但检索越不精准。切分策略的本质就是在这个矛盾中找到平衡。
二、五种切分策略
2.1 固定长度切分(Fixed-Size Splitting)
最简单的策略:按固定的字符数或 token 数切分,相邻 chunk 之间保留一定的 overlap。
from langchain.text_splitter import CharacterTextSplitter
splitter = CharacterTextSplitter(
chunk_size=1000, # 每个 chunk 1000 个字符
chunk_overlap=200, # 相邻 chunk 重叠 200 个字符
separator="\n\n" # 优先在段落边界切分
)
chunks = splitter.split_text(document)
优点:实现简单,计算快,不需要额外的模型调用。
缺点:完全忽略语义边界。一个段落可能被从中间切断,一个 chunk 可能包含两个不相关的段落。
适用场景:
- 快速原型验证
- 文档结构不重要、内容连续的场景(如小说、连续文本)
- 对效果要求不高的内部工具
参数选择:
chunk_size:常见范围 256-2048 字符。技术文档建议 500-1000,论文建议 1000-2000。chunk_overlap:通常为 chunk_size 的 10%-25%。太小的 overlap 会导致边界信息丢失,太大的 overlap 会产生大量重复。
2.2 递归切分(Recursive Splitting)
LangChain 默认推荐的策略。按层级递归切分:先按段落(\n\n)切,太长的段落再按句子(\n)切,还太长就按空格切,直到每个 chunk 满足大小要求。
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", ". ", " ", ""] # 按优先级依次尝试
)
chunks = splitter.split_text(document)
优点:尽量保留语义边界。段落、句子这些自然的分隔符被优先使用,不会从段落中间硬切。
缺点:仍然依赖分隔符。如果文档没有清晰的分隔符(比如没有换行符的长文本),效果会退化到接近固定长度切分。
适用场景:
- 大多数结构化文档(技术文档、博客文章、论文)
- 通用场景的首选方案
参数选择:
separators的顺序很重要。越靠前的分隔符优先级越高。- 中文文档建议把
"。"和"!"等标点加入分隔符列表。 chunk_size建议比固定长度切分稍大,因为递归切分更尊重语义边界。
2.3 语义切分(Semantic Splitting)
基于 Embedding 相似度检测主题边界。先对每个句子做 Embedding,然后计算相邻句子的相似度,相似度突然下降的地方就是主题切换的边界。
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
splitter = SemanticChunker(
OpenAIEmbeddings(),
breakpoint_threshold_type="percentile", # 或 "standard_deviation", "interquartile"
breakpoint_threshold_amount=95 # 相似度低于第 95 百分位时切分
)
chunks = splitter.split_text(document)
优点:真正按语义边界切分。每个 chunk 在主题上是自洽的,不会把两个不同的话题混在一起。
缺点:
- 需要调用 Embedding 模型,有额外成本
- 计算量大,不适合超大规模文档
- 阈值选择需要调参
适用场景:
- 主题多样的长文档(如会议纪要、百科全书)
- 对检索精度要求高的生产系统
- 文档结构不规律、传统切分策略效果不好的场景
参数选择:
breakpoint_threshold_type:percentile:基于百分位数,最直观standard_deviation:基于标准差,适合相似度分布均匀的场景interquartile:基于四分位距,对异常值更鲁棒
breakpoint_threshold_amount:值越大,切分越少(chunk 越大);值越小,切分越多(chunk 越小)。
2.4 父子 Chunk(Parent-Child Chunks)
解决"精准 vs 上下文"矛盾的核心方案。小块(child chunk)用于检索,大块(parent chunk)用于生成。
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import Chroma
from langchain_text_splitter import RecursiveCharacterTextSplitter
# 小块用于检索
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
# 大块用于生成
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=InMemoryStore(),
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# 检索时:用小块匹配(精准),返回大块给模型(有上下文)
results = retriever.invoke("公司的请假流程是什么?")
优点:同时获得精准检索和完整上下文。检索阶段用小 chunk 提高匹配精度,生成阶段用大 chunk 提供充足上下文。
缺点:
- 需要维护两份 chunk(parent 和 child),存储成本翻倍
- 实现复杂度更高
- 需要额外的 docstore 存储 parent chunk
适用场景:
- 生产级 RAG 系统
- 文档段落间关联性强的场景(如法律文档、技术规范)
- 检索精度和生成质量都要求高的场景
2.5 滑动窗口(Sentence Window)
检索单个句子,但返回该句子的上下文窗口。和父子 chunk 类似,但粒度更细——以句子为单位。
from langchain.retrievers import SentenceWindowRetriever
retriever = SentenceWindowRetriever(
vectorstore=vectorstore,
window_size=3, # 检索句子前后各 3 个句子作为上下文
)
# 检索时:匹配单个句子,返回上下文窗口
results = retriever.invoke("违约条款的赔偿标准是什么?")
优点:比父子 chunk 更灵活,上下文窗口大小可调。
缺点:实现更复杂,需要维护句子级别的索引。
适用场景:
- 需要精确到句子级别的检索
- 文档中关键信息分散在不同句子的场景
三、不同文档类型的最佳实践
没有万能的切分策略。不同类型的文档需要不同的切分方式。
3.1 技术文档(API 文档、教程、README)
特点:结构清晰,有标题层级,段落间相对独立。
推荐策略:递归切分,按标题层级切分。
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=150,
separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "]
)
要点:
- 优先按标题(
##、###)切分,每个 chunk 对应一个章节 - 在标题分隔符前切分,确保 chunk 包含章节标题(标题是重要的上下文信息)
- chunk_size 不宜太大,技术文档的段落通常较短
3.2 论文 / 长文
特点:段落长,上下文关联紧密,关键信息可能跨段落。
推荐策略:递归切分 + 较大的 overlap,或者语义切分。
splitter = RecursiveCharacterTextSplitter(
chunk_size=1500,
chunk_overlap=400, # 较大的 overlap 保持上下文连贯
separators=["\n\n", "\n", ". ", " "]
)
要点:
- 论文的段落通常较长,chunk_size 需要相应增大
- overlap 要足够大,避免关键论证被切断
- 如果论文涉及多个主题切换,考虑语义切分
3.3 对话记录(聊天记录、会议纪要)
特点:按轮次组织,每轮相对独立,但上下文可能跨轮次。
推荐策略:按轮次切分,保留说话人信息。
# 按对话轮次切分
def split_conversations(text, max_turns_per_chunk=5):
turns = text.split("\n") # 假设每行是一轮对话
chunks = []
for i in range(0, len(turns), max_turns_per_chunk):
chunk = "\n".join(turns[i:i + max_turns_per_chunk])
chunks.append(chunk)
return chunks
要点:
- 按轮次切分,每个 chunk 包含若干轮对话
- 保留说话人信息("张三:..."),这对上下文理解很重要
- 不要从一轮对话的中间切断
3.4 代码文件
特点:有明确的语法结构(类、函数、注释),上下文跨行。
推荐策略:按函数或类切分。
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
separators=["\n\ndef ", "\n\nclass ", "\n\n", "\n", " "]
)
要点:
- 优先按函数(
def)和类(class)切分 - 保留函数签名和 docstring
- 代码的上下文关联紧密,overlap 不要太小
3.5 PDF / 扫描件
特点:格式不可控,可能有表格、图表、页眉页脚、OCR 错误。
推荐策略:先清洗,再切分。
# 1. 提取文本
raw_text = extract_pdf_text("document.pdf")
# 2. 清洗:去除页眉页脚、页码、OCR 噪声
cleaned_text = clean_pdf_text(raw_text)
# 3. 切分
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separators=["\n\n", "\n", ". ", " "]
)
chunks = splitter.split_text(cleaned_text)
要点:
- PDF 的文本提取质量参差不齐,先清洗再切分
- 表格内容需要特殊处理(转换为文本描述或保留结构化格式)
- 扫描件的 OCR 质量直接影响切分效果
四、Overlap 怎么选
Overlap(相邻 chunk 之间的重叠区域)是切分中最容易被忽视的参数。
Overlap 太小:边界信息丢失。一个段落的最后一句话和下一段的第一句话可能有关联,如果没有 overlap,这个关联就丢了。
Overlap 太大:大量重复内容。不仅浪费存储和计算,还可能导致检索结果中大量重复的 chunk,降低多样性。
经验法则:
| chunk_size | 推荐 overlap | 说明 |
|---|---|---|
| 256 | 50-64 | 约 20%-25% |
| 512 | 100-128 | 约 20%-25% |
| 1024 | 150-250 | 约 15%-25% |
| 2048 | 200-400 | 约 10%-20% |
chunk_size 越大,overlap 的比例可以越小。因为大 chunk 本身已经包含了足够的上下文,不需要太多重叠来保持连贯。
五、切分效果的评估
怎么知道你的切分策略好不好?几个实用的评估方法:
5.1 人工检查
最简单也最有效的方法:随机抽 10 个 chunk,人工判断:
- 每个 chunk 是否语义完整?(能脱离上下文理解吗?)
- 每个 chunk 是否只包含一个主题?
- 相邻 chunk 之间有没有重要的信息被切断?
5.2 检索命中率
准备一批测试问题,每个问题标注了"正确答案所在的文档段落"。然后检查:
- 检索结果中是否包含了正确段落?
- 包含正确段落的比例是多少?
如果命中率低,可能是切分太粗(chunk 里噪声太多)或太碎(关键信息被切断)。
5.3 Chunk 大小分布
import matplotlib.pyplot as plt
sizes = [len(chunk) for chunk in chunks]
plt.hist(sizes, bins=30)
plt.xlabel("Chunk Size (characters)")
plt.ylabel("Count")
plt.title("Chunk Size Distribution")
plt.show()
如果分布过于分散(有的 chunk 50 字符,有的 5000 字符),说明切分策略没有很好地控制 chunk 大小,需要调整。
六、常见误区
误区 1:chunk_size 越大越好。 错。chunk 越大,检索精度越低。一个 5000 字符的 chunk 里可能包含十个不同的主题,检索时很难精准匹配。
误区 2:chunk_size 越小越好。 错。chunk 越小,上下文越不完整。一个 50 字符的 chunk 可能只有一句话,模型无法理解它在说什么。
误区 3:overlap 越大越好。 错。overlap 太大会产生大量重复内容,浪费存储和计算,还会降低检索结果的多样性。
误区 4:一种策略打天下。 错。不同类型的文档需要不同的切分策略。技术文档按标题切分,对话按轮次切分,代码按函数切分。
误区 5:切分是一次性的。 错。切分策略需要根据实际效果持续调整。建议建立评估流程,定期检查切分质量。
七、总结
文档切分是 RAG 管线的第一步,也是最影响效果的环节之一。
核心要点:
- 没有万能的切分策略,根据文档类型选择合适的策略
- 核心矛盾是精准 vs 上下文,父子 chunk 是解决这个矛盾的最佳方案
- overlap 很重要,通常为 chunk_size 的 15%-25%
- 评估切分质量,不要靠猜
下一篇文章深入第二个环节——向量检索。Embedding 模型怎么选?向量数据库怎么选?混合检索怎么做?
系列: