文档切分策略
RAG 的第一步,通常不是“检索”,而是先把长文档切成一批可检索的小块(chunks),再对每个块做 Embedding 存入索引。切分策略会直接影响后面的检索质量:切太大,无关内容混进来;切太小,语义被割裂、上下文丢失。LangChain 官方文档也把 text splitting 定义为:把大文档拆成既能单独被检索、又能放进模型上下文窗口的小块。(docs.langchain.com)
延伸阅读
- LangChain 的 Text Splitters 文档把 RecursiveCharacterTextSplitter 作为通用文本的推荐起点。(docs.langchain.com)
- LlamaIndex 的 Ingestion / Node Parser 文档长期强调:切分要尽量保留段落和句子边界。(LlamaIndex OSS Documentation)
- 一些近年的研究也表明,基于结构或语义的切分,常常比纯固定长度切分更利于检索质量。(arXiv)
为什么 Chunking 重要
flowchart LR
A["文档切分策略"]
A --> B["分类:检索增强 (RAG)"]
A --> C["关键词:RAG"]
A --> D["关键词:Chunking"]
A --> E["关键词:文档切分"]
A --> F["关键词:Embedding"]
先看定义:Chunk 是检索的粒度,也是注入 LLM 的上下文单元。切分策略不是一个预处理小细节,而是整个 RAG 系统的地基。
很多人刚做 RAG 时,会把主要精力放在:
- 用哪个 Embedding 模型
- 用哪个向量数据库
- top-k 取多少
- 要不要 rerank
但在真实系统里,一个很常见的现象是:
切分不好,后面全链路都在“补救”。
因为 RAG 里的大多数组件,其实都默认你已经把知识拆成了“合理的检索单元”。如果 chunk 本身就不合理,那么:
- 再好的 embedding 也只能表示一个糟糕的片段
- 再强的检索也只能召回糟糕的片段
- 再贵的 LLM 也只能基于糟糕的片段作答
所以 chunking 的本质不是“把长文切短”,而是:
把文档改造成适合检索和生成协作的知识单元。
切太大和切太小分别会怎样
| 维度 | 切太大 | 切太小 |
|---|---|---|
| 检索 | 容易匹配到一大块里其实不相关的部分 | 语义碎片化,召回不稳定 |
| 生成 | 噪声多,模型难以抓住重点 | 上下文不足,模型难以推理和引用 |
| 成本 | 每个 chunk 更重,注入上下文更贵 | chunk 数暴增,索引和检索成本上升 |
| 引用 | 一次引用范围太大,不够精确 | 一次引用太碎,不利于阅读和追溯 |
目标不是“尽量大”或“尽量小”,而是在语义完整和检索粒度之间找到平衡。
一个先建立起来的核心原则
在具体策略之前,先记住三个非常实用的原则。
原则 1:切分首先服务检索,而不是服务阅读
人读文档时喜欢看完整页面、完整章节。 但检索系统不一定需要这么大的单位。
一个好的 chunk,不一定是“最适合人从头到尾读”的,而是:
- 足够自洽
- 足够可匹配
- 足够短,便于进入 prompt
- 足够长,不会失去关键前后文
原则 2:尽量沿自然边界切,切不动再硬切
这是 LangChain RecursiveCharacterTextSplitter 背后的核心思想:先按段落、再按行、再按空格、最后才按字符硬切。LangChain 文档明确说明,这个 splitter 会按给定分隔符列表依次尝试切分,默认分隔符就是 ["\n\n", "\n", " ", ""]。(docs.langchain.com)
原则 3:没有通用最优 chunk size,只有“对你的文档和 query 更合适”的 size
这也是为什么 chunking 一定要结合评估,而不是只靠经验值。研究和实践都表明,不同文档结构、不同任务目标、不同检索方式,对粒度的敏感度都很高。(arXiv)
1. 固定大小切分(Fixed-size Chunking)
先看定义:按固定字符数或 token 数切分,是最简单、最容易实现的基线方案。
最朴素的做法,就是给文档设定一个固定长度,例如:
- 每块 500 字符
- 或每块 256 tokens
- 或每块 512 tokens
然后机械切分。
# 伪代码
chunks = [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]
优点
- 实现简单
- 行为可预测
- 容易做 baseline
- 适合快速原型
缺点
- 可能在句子中间切断
- 可能把同一段的上下文拆开
- 对结构化文档不友好
- 对代码、表格、标题层级等内容破坏较大
LangChain 的 character splitter 文档也明确说明,字符切分是最简单的方法,但它本质上只是按字符分隔符和长度来拆,不保证语义边界。(docs.langchain.com)
适用场景
固定大小切分适合:
- 非结构化纯文本
- 快速验证 RAG 原型
- 暂时没有更好的结构信息
- 需要先建立一个可对照的 baseline
它不一定是最优方案,但经常是最好的起点。
2. 滑动窗口(Sliding Window)
先看定义:固定大小切分的最大问题是边界断裂,滑动窗口通过 overlap 来缓解这个问题。
滑动窗口本质上还是固定大小切分,只是相邻 chunk 之间保留一定重叠。例如:
chunk_size = 500overlap = 50
那么前一个 chunk 的结尾 50 个 token,会出现在下一个 chunk 的开头。
为什么 overlap 有用
因为很多信息恰好卡在边界上。 如果没有 overlap,就可能发生这种情况:
- 问题关键词在 chunk A 末尾
- 真正答案在 chunk B 开头
这样单个 chunk 看都不完整,检索和生成都容易失真。
Overlap 的作用,就是给边界加一点缓冲,让跨边界信息更容易被保留下来。
优点
- 实现仍然简单
- 对边界断裂有明显缓解
- 常常比纯 fixed-size 稳
缺点
- 数据冗余增加
- 索引体积更大
- 检索时可能召回很多高度相似块
- 后处理时更容易出现重复内容
overlap 设多大合适
没有绝对标准,但一个非常常见的经验区间是:
- overlap 取 chunk_size 的 10%–20%
它通常足够提供边界连续性,又不至于冗余太夸张。
先看定义:Overlap 是保险,不是越多越好。
3. 语义切分(Semantic Chunking)
先看定义:与其按长度硬切,不如尽量按段落、标题、语义单元来切。
固定大小切分的最大问题,是它不关心内容结构。 语义切分则反过来:优先尊重内容本来的组织方式。
常见做法包括:
- 按段落切
- 按标题层级切
- 按句子切,再合并成语义完整块
- 按文档元素类型切(例如标题、正文、表格、列表、代码块)
为什么语义切分通常更优
因为一个好的 chunk,最重要的是:
本身就能独立表达一个相对完整的意思。
如果一个 chunk 刚好就是:
- 一个定义
- 一个 FAQ 问答
- 一个政策条款
- 一个函数说明
- 一个小节的核心内容
那么它就更容易:
- 被正确检索
- 被准确引用
- 被模型正确理解
一些针对金融报告和复杂文档的研究也发现,基于元素类型或更结构化的切分方式,往往能带来更好的检索和回答效果。(arXiv)
缺点
- 实现更复杂
- 依赖文档解析质量
- 对格式混乱的原始资料不一定稳定
- 很难有一个统一策略覆盖所有文档类型
所以语义切分通常更强,但也更“工程化”。
4. Recursive Splitting:为什么它成了通用默认选项
先看定义:Recursive splitting 的思路不是追求“最聪明”,而是追求“在简单和效果之间取得最好平衡”。
LangChain 官方文档把 RecursiveCharacterTextSplitter 作为 generic text 的推荐起点。它会优先尝试较自然的边界来切:
- 先按段落
\n\n - 再按行
\n - 再按空格
- 最后才按字符硬切
LangChain 官方文档明确写了这一点。(docs.langchain.com)
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", " ", ""]
)
chunks = splitter.split_text(document)
为什么它经常是个好起点
因为它同时满足几件事:
- 比纯 fixed-size 更尊重自然边界
- 比复杂语义分块更容易落地
- 可调参数少,原型阶段很好用
- 文档结构一般时也能工作
所以如果你没有特别强的文档结构信息,或者想先做一个比较稳的 baseline,recursive splitting 往往是很合理的默认方案。
5. 按文档类型选择策略,比“统一策略打天下”更重要
先看定义:不同文档的天然结构差异很大,chunking 最有效的优化之一,不是微调参数,而是按文档类型分策略。
LlamaIndex 的 node parser 文档就明确展示了按 HTML、JSON、Sentence Window 等不同内容类型选择不同 parser 的思路。(LlamaIndex OSS Documentation)
常见文档类型与推荐思路
| 文档类型 | 推荐策略 | 原因 |
|---|---|---|
| 按页 + 按段落 / 元素切分 | 页码便于溯源,段落便于检索 | |
| HTML | 按 DOM 结构切分 | section、p、标题标签本身就有语义 |
| Markdown | 按标题层级切分 | #、##、### 是天然边界 |
| 代码 | 按函数、类、模块切分 | 保持代码块完整比长度一致更重要 |
| 对话 / 日志 | 按轮次或时间窗口切分 | 保持上下轮关联 |
| FAQ | 一问一答为最小单元 | 每组 QA 都是天然 chunk |
一个很实用的经验
不要追求“一个 splitter 处理所有文档”。 真实系统里,更常见也更有效的做法是:
- Markdown 一套
- HTML 一套
- 代码一套
- PDF / Office 文档一套
- 聊天记录 / 工单一套
这会比试图用一个统一 chunk_size 更靠谱。
6. Chunk Size 到底该设多大
先看定义:大多数通用场景的有效区间,通常落在几百 token 到一千 token 左右,但真正的最佳值仍然要靠你的文档和 query 评估出来。
LangChain 的 token splitting 文档明确建议:切分时最好按 token 计数,并使用与目标模型一致的 tokenizer,因为模型上下文限制是按 token 而不是按字符算的。(docs.langchain.com)
一个实用的经验区间
| 场景 | 常见起步范围 | 说明 |
|---|---|---|
| 通用问答 | 256–512 tokens | 平衡召回与噪声 |
| 长文档知识问答 | 512–1024 tokens | 需要更多上下文 |
| 代码检索 | 200–400 tokens | 更接近函数级或逻辑块级粒度 |
| 高结构文档 | 不只看长度,更看边界 | 标题和段落比固定长度更重要 |
为什么不要迷信“大一点上下文更全”
因为 chunk 越大,越容易把无关内容一起嵌进去。 这会带来两个后果:
- 检索命中一个主题时,同时带进很多无关段落
- LLM 读到的参考资料更杂,回答容易被干扰
所以 chunk size 不是越大越好,而是:
只大到足以保留必要语义,不大到明显引入噪声。
7. Token 和字符:为什么不要只按字符拍脑袋
很多人刚做 chunking,会直接按字符数切分。 这在原型阶段未尝不可,但正式系统里最好还是按 token 设计和校验。
因为:
- 模型上下文限制按 token 算
- embedding 模型输入限制也按 token 算
- 不同语言 token 密度差异明显
- 字符数相同,不代表 token 数相同
LangChain 官方文档也明确提醒:如果按 token 切分,应使用与模型一致的 tokenizer 来计数。(docs.langchain.com)
一个很实用的结论
按字符切可以作为粗略近似,但上线前最好用 tokenizer 验证真实 token 长度。
尤其在:
- 中文
- 混合中英文本
- 代码
- 表格型文本
这件事会更重要。
8. Metadata Enrichment:chunk 不只是一段文本
先看定义:一个好的 chunk,除了内容本身,还应该带足够的 metadata,用于过滤、引用和审计。
很多初学者只存:
chunk_textembedding_vector
但生产里通常还会一起存:
| 元数据 | 常见用途 |
|---|---|
| source | 来源文档、URL、文件名 |
| page_number | 页码引用 |
| section_title | 所属章节 |
| doc_type | 文档类型 |
| created_at / updated_at | 新旧版本控制 |
| tenant_id | 多租户隔离 |
| language | 多语言过滤 |
这些 metadata 的价值至少体现在三方面:
1. 检索过滤
例如只搜某类文档、某个时间范围、某个租户。
2. 引用展示
回答时能显示“来自第几页、第几节”。
3. 审计溯源
知道模型到底参考了什么材料。
所以从系统角度看,chunk 不是一段裸文本,而是一个知识单元对象。
9. Parent-Child Chunking:为什么很多高质量 RAG 不只存一层粒度
先看定义:Parent-Child 的核心思想是“用小块匹配,用大块生成”,兼顾检索精准度和上下文完整性。
一个很典型的问题是:
- 小块检索更精准
- 大块生成更好理解
Parent-Child Chunking 就是在解决这个矛盾。
基本思路
- 存储时,把文档切成更小的 child chunks
- 检索时,用 child chunks 做更精确匹配
- 生成时,不一定只把 child chunk 给模型,而是返回它所属的 parent chunk
例如:
| 层级 | 粒度 |
|---|---|
| Child | 200 tokens |
| Parent | 800 tokens |
查询时命中 child,但送给模型时给 parent,或者给 parent 的裁剪版本。
为什么这有价值
因为很多问题需要的不是“命中一个关键词的小片段”,而是:
- 该片段前后的上下文
- 同一条规则的完整表述
- 同一函数或同一段说明的整体语义
所以 Parent-Child 很适合那些对“检索准确”和“生成有上下文”都要求高的场景。
代价
- 实现更复杂
- 需要维护 chunk 之间的父子关系
- 存储和召回逻辑更重
但在复杂知识库场景里,这类多粒度策略常常非常值得。
10. Sentence Window、Late Chunking、Contextual Retrieval:更高级的方向
除了常见的 fixed-size、recursive、semantic、parent-child,近两年还出现了一些更高级的变体。
Sentence Window
LlamaIndex 的 SentenceWindowNodeParser 就是一个典型例子:
它把单句作为基本节点,同时给每个节点附带左右句窗口。这样既能用句级粒度做精确匹配,又能保留邻近上下文。(LlamaIndex OSS Documentation)
Late Chunking / Contextual Retrieval
近年的研究开始尝试把“先整段编码、后局部取用”或“让 chunk 带更多全局背景”的方式纳入 RAG 流程。相关研究表明,这类方法有时能比传统早切分更好地保持全局语义,但代价通常更高。(arXiv)
Mix-of-Granularity
还有研究专门讨论“混合粒度”策略,也就是不同粒度 chunk 同时存在,按问题类型选用。相关论文的动机就是:单一粒度很难适配所有知识源和问题类型。(arXiv)
这些方向说明一件事:
Chunking 已经不只是预处理技巧,而是在逐步演化成检索系统本身的重要设计层。
11. 切分后的去重与过滤:别把垃圾一起送进索引
切分完成后,通常还建议做一轮简单后处理:
1. 去掉过短块
例如只有几个字、只有标点、只有页眉页脚。
2. 去掉高度重复块
尤其是 sliding window 或 PDF 解析后,常常会产生非常相似的 chunk。
3. 二次切分超长块
有些结构切分后仍然过长,需要再拆。
4. 过滤无意义内容
例如导航栏、版权声明、页脚说明、目录项。
这些步骤看起来不起眼,但对索引质量非常有帮助。
12. 一条真正实用的选择流程
如果你不想一开始就陷入“无限优化 chunking”的焦虑,可以按下面这条路线来:
第一步:先用一个稳妥 baseline
比如:
- Recursive splitter
- chunk_size 500 左右
- overlap 50 左右
这通常足够让系统先跑起来。LangChain 官方也把 recursive splitter 作为多数场景的起点。(docs.langchain.com)
第二步:拿真实 query 做采样评估
不要只看“切出来像不像样”,要看:
- 能不能检索到对的片段
- 检索到的片段是否自洽
- 最终回答有没有引用对内容
第三步:按文档类型拆策略
如果发现 Markdown 效果很好、PDF 很差,就不要再用统一策略硬扛。
第四步:再决定要不要上更复杂方法
例如:
- parent-child
- sentence window
- hybrid granularity
- contextual retrieval
先看定义:
先让 chunking 足够好,再决定有没有必要让它变得更复杂。
小结
Chunking 决定了检索粒度,也决定了模型最终能看到什么样的上下文。固定大小切分简单但容易破坏语义;滑动窗口通过 overlap 缓解边界问题;语义切分和 recursive splitting 更尊重自然边界;parent-child 和 sentence window 则是在更高要求场景下兼顾检索精度和上下文完整性。LangChain 官方推荐从 RecursiveCharacterTextSplitter 起步,LlamaIndex 也长期强调按句子、段落和结构保留自然边界。(docs.langchain.com)
最重要的是: Chunking 没有银弹,只有适不适合你的文档、问题和系统目标。 它不是一个纯“预处理参数”,而是整个 RAG 系统质量的基础。
核心概念速查
| 概念 | 一句话 |
|---|---|
| Fixed-size Chunking | 按固定字符或 token 数切分,实现简单但可能断句 |
| Sliding Window | 固定大小 + 块间重叠,减少边界语义丢失 |
| Semantic Chunking | 按段落、标题、主题等语义边界切分 |
| Recursive Splitting | 先按自然分隔符切,不够再逐级细化,是通用起点 |
| Parent-Child Chunking | 用小块命中、用大块生成,兼顾精准与上下文 |
| Sentence Window | 用句子做细粒度匹配,同时保留邻近句窗口 |
| Metadata | 每个 chunk 附带来源、页码、章节等结构信息 |
| Chunk Size | 决定检索粒度和噪声水平,没有通用最优值 |