附录E:RecursiveCharacterTextSplitter 递归切分算法¶
为什么需要这讲¶
本项目的文档切分(第 3 讲和第 16 讲)使用 LangChain 的 RecursiveCharacterTextSplitter,这是整个入库链路中最关键的参数决策点。主讲义只展示了分隔符列表,没有解释递归细分切分的核心算法。理解这个算法才能理解为什么 chunk_size 和 overlap 的设置会影响检索质量。
一、朴素切分的问题¶
最直观的切分方式是定长切分:每 N 个字符切一刀。
问题: - 句子被从中间切断,语义不完整 - LLM 看到"签订劳动"和"合同和保密协议"两个碎片,不如看到一个完整的"签订劳动合同和保密协议"
二、递归细分切分算法¶
RecursiveCharacterTextSplitter 的核心思想:用一组分隔符,从粗到细递归尝试。
flowchart TD
Start["开始切分一段文本<br/>长度 2500 字符<br/>chunk_size=500"] --> Try1
Try1["尝试分隔符 '\n\n'<br/>按段落切分"] --> Check1{"每个段落<br/>都 ≤ 500?"}
Check1 -->|"✅"| Done1["完成!得到若干段落块"]
Check1 -->|"❌ 某段 800 字符"| Try2
Try2["对该段尝试 '\n'<br/>按换行切分"] --> Check2{"每行都 ≤ 500?"}
Check2 -->|"✅"| Done2["完成!"]
Check2 -->|"❌ 某行 650 字符"| Try3
Try3["对该行尝试 '。'<br/>按中文句号切分"] --> Check3{"每句都 ≤ 500?"}
Check3 -->|"✅"| Done3["完成!"]
Check3 -->|"❌ 某句 550 字符"| Try4
Try4["尝试 ','<br/>按逗号切分"] --> Check4{"每段都 ≤ 500?"}
Check4 -->|"✅"| Done4["完成!"]
Check4 -->|"❌"| Try5
Try5["最后手段:按字符切<br/>强制在 500 字符处截断"] --> Done5["完成"]
style Done1 fill:#ECFDF5,stroke:#059669,stroke-width:2px
style Done2 fill:#ECFDF5,stroke:#059669,stroke-width:2px
style Done3 fill:#ECFDF5,stroke:#059669,stroke-width:2px
style Try5 fill:#FEF2F2,stroke:#DC2626,stroke-width:2px
三、具体例子¶
假设有如下文本(chunk_size=200):
第一轮:尝试 \n\n(段落)
全部在 200 以内 → 完成!三个按段落划分的 chunk,语义边界完美。
但如果 chunk_size=50:
四、Overlap 的作用¶
chunk_overlap 让相邻 chunk 之间有重叠内容:
对于中文,本项目配置:
五、Markdown 文件的特殊处理¶
对于 .md 文件,LangChain 提供了一个增强切分器:
这使得后续 LLM 生成的答案可以引用"来源:入职管理 > 入职流程"这样的层级结构。
六、表格文件的特殊保护¶
这是本项目的一个重要设计:表格行的内容(表头+行号+单元格键值对)是一个完整的语义单元。递归切分会破坏这个完整性。
七、chunk_size 调优的权衡¶
| chunk_size | 优点 | 缺点 |
|---|---|---|
| 小(200-300) | 检索精确,匹配粒度细 | 上下文不完整,chunk 数量多 |
| 中(500-800) | 平衡精度和上下文 | 需要 overlap 来保证连续性 |
| 大(1000-2000) | 上下文完整 | 检索不精确,语义信号被稀释 |
本项目的选择(父块 2000 + 子块 500)是一种折中: - 用子块(500)做检索 → 精确 - 用父块(2000)给 LLM → 完整
小结¶
- 递归细分:从段落 → 换行 → 句号 → 逗号 → 字符,逐步收窄
- 优先级:优先在语义边界切分,只在必要时才强制截断
- Overlap:防止切分边界切断关键信息
- 父子块策略:子块检索 + 父块生成,兼顾精度和完整性
- 表格保护:表格行不参与递归切分,保持语义单元完整