系列「企业级 AI Agent 实现拆解」E54 篇,Part 13 RAG 篇第三章。上一篇 拆了 Document 组件的接口设计。这篇不看源码看效果:同一份制度文档,四种切法,答案完整率从 0/4 到 4/4。全部数据真机跑出来,不用 API Key 你也能复现。
读完这篇你会知道
- 一个不用花钱的评测方法:怎么量化「切得好不好」
- 四种切法 × 两种文档形态的实测数据表
- 为什么「章节标题」是 RAG 里最容易丢、丢了最要命的信息
- Markdown 切片器的三个源码级行为(一个贴心设计 + 两个坑)
- 生产上该怎么切:标题优先 + 超长兜底的组合拳(12 行代码)
- semantic 语义切分的原理,和它真实的成本账
先解决一个问题:怎么算「切得好」
「这样切效果更好」------大部分切片教程说到这儿就没了。好在哪,好多少,不知道。
要比较就得先定义指标。我用的是答案完整率,规则很简单:
一个问题 = 一个「提问词」+ 一个「答案词」。 只有当某一片同时装下这两个词,这一片被检索到才可能答对。 如果两个词被切到了不同片里,那这个问题就废了------检索到哪片都答不全。
举例:
| 问题 | 提问词 | 答案词 |
|---|---|---|
| 年假能休几天 | 年假 |
5 天 |
| 病假要什么材料 | 病假 |
医院证明 |
用户问「年假能休几天」,检索系统靠「年假」去找。找到的那片如果没有「5 天」,模型就只能说「手册里没写」,或者编一个。
这个指标的好处是纯字符串判定,不调 API,不花钱,结果确定。同一份文档谁跑都是同一个数。
它的局限也得说清楚:**它只衡量「信息有没有被劈开」,不衡量排序质量。**真实检索里还有向量算得准不准、TopK 取多少的问题。但「被劈开」是最底层的故障------一旦发生,后面所有优化都白搭。先把这一层守住。
四种切法
go
// A 固定长度,无重叠
recursive.NewSplitter(ctx, &recursive.Config{
ChunkSize: 200, OverlapSize: 0,
Separators: []string{"\n\n", "。"},
LenFunc: runeLen, // 按字数,见 E52 的坑
})
// B 固定长度 + 重叠 60
recursive.NewSplitter(ctx, &recursive.Config{
ChunkSize: 200, OverlapSize: 60,
Separators: []string{"\n\n", "。"},
LenFunc: runeLen,
})
// C 按 Markdown 标题
markdown.NewHeaderSplitter(ctx, &markdown.HeaderConfig{
Headers: map[string]string{"#": "h1", "##": "h2", "###": "h3"},
TrimHeaders: false,
})
// D 组合拳:标题切 + 超长二次切(后面细说)
判定时有个口径要交代:C 和 D 把标题存进了 MetaData,我把这些标题也算进这片的「可检索文本」。因为真实系统里标题要么拼进 Content,要么当过滤条件用,本来就参与检索。
实验一:短章节文档,四种切法打平
第一份测试文档是常见的《员工手册》,1153 个字,## / ### 两级标题,每节 100~200 字。
css
原文 ./testdata/handbook.md:1153 个字
A 固定长度 200,无重叠 7 片 最短 133 / 最长 196 / 平均 162 字
B 固定长度 200,重叠 60 8 片 最短 133 / 最长 200 / 平均 163 字
C 按 Markdown 标题 12 片 最短 7 / 最长 151 / 平均 93 字
D 标题切 + 超长二次切 12 片 最短 7 / 最长 151 / 平均 93 字
答案完整率:
问题 A 无重叠 B 有重叠 C 标题切 D 组合
年假能休几天 命中 命中 命中 命中
病假要什么材料 命中 命中 命中 命中
加班怎么调休 命中 命中 命中 命中
报销有时限吗 命中 命中 命中 命中
离职提前多久 命中 命中 命中 命中
命中数 5/5 5/5 5/5 5/5
全部满分。
这个结果我一开始没料到,但它是个真结论,而且很有用:
章节短、术语在正文里反复出现的文档,怎么切都不太会错。
原因是这份手册的正文里,「年假」「病假」「报销」这些词反复出现,不是只在标题里露一面。每一片无论怎么断,都自带主题词。
**如果你的文档长这样,别折腾了,recursive 配好 LenFunc 就够用。**省下来的时间去优化别的环节。
实验二:长条款文档,0/4 vs 4/4
第二份换成《休假管理办法》------正式的制度文本写法,569 个字,但每节都是三段以上的长条款,而且正文里几乎不重复标题词,用「本项福利」「该项休假」这类指代。
这不是我为了做实验瞎编的写法。翻翻你手边任何一份公司制度、法律条文、产品规格书,都是这个调调。
css
原文 ./testdata/policy.md:569 个字
A 固定长度 200,无重叠 4 片 最短 121 / 最长 184 / 平均 140 字
B 固定长度 200,重叠 60 4 片 最短 121 / 最长 192 / 平均 142 字
C 按 Markdown 标题 4 片 最短 8 / 最长 345 / 平均 139 字
D 标题切 + 超长二次切 5 片 最短 8 / 最长 225 / 平均 122 字
答案完整率:
问题 A 无重叠 B 有重叠 C 标题切 D 组合
年假能休几天 劈开了 劈开了 命中 命中
年假能不能结转 劈开了 劈开了 命中 命中
病假要什么材料 劈开了 命中 命中 命中
病假超三天呢 劈开了 命中 命中 命中
命中数 0/4 2/4 4/4 4/4
A 全军覆没。
同样的文档、同样的问题、同样的检索系统,切法一换,从 0 分到满分。
A 为什么全错
拆开看「年假能休几天」这个问题。原文结构是:
markdown
### 年假
本项福利面向入职满十二个月的正式员工......(第一段 90 字,全段不出现「年假」)
具体额度按下列标准执行:连续服务满一年不满十年的,每个自然年度可享受 5 天......
recursive 按 200 字切:
- 片 N :
### 年假+ 第一段。有「年假」,没有「5 天」 - 片 N+1:第二段。有「5 天」,没有「年假」
用户问「年假能休几天」,向量检索拿「年假」去匹配 → 命中片 N → 片 N 里只有「面向入职满十二个月的正式员工」→ 答不上来。
答案就在隔壁那一片,但检索找不到它,因为它不带主题词。
这就是 RAG 里最典型、最隐蔽的故障:不报错、不崩溃,只是安静地答错。
B 靠重叠救回两个,但那是运气
重叠 60 字让「病假」两题过了。为什么年假两题还是不行?
因为重叠是机械的,它只带过去固定字数。病假那节第一段短,60 字的重叠恰好覆盖到了标题;年假那节第一段长,60 字不够,标题没带过去。
重叠能不能救回来,取决于「标题到答案的距离」是否小于
OverlapSize。 这是巧合,不是设计。
想靠调大 OverlapSize 解决?那等于每片都在重复前一片的内容------存储、向量化成本线性上涨,检索时还会返回一堆内容雷同的片,把 TopK 名额占光。
标题为什么这么金贵
这是这篇文章最想说的一句:
标题是「这段在讲什么」的唯一线索,而正文用指代词是人类写作的常态。
人写东西,标题写了「年假」,正文就不会每段都重复「年假年假年假」------那读起来像机器人。人类靠上下文理解「本项福利」指的是年假。
但切片是机械的。切完那一刻,每一片就成了孤儿------它不知道自己的上文是什么。上下文靠标题传递,标题一丢,这片就失忆了。
markdown 切片器解决的正是这个问题。看它的产出:
css
=== 标题层级进了 MetaData(前 4 片)===
片0 h1=员工手册 h2=<nil> h3=<nil>
片1 h1=员工手册 h2=考勤与休假 h3=<nil>
片2 h1=员工手册 h2=考勤与休假 h3=年假
片3 h1=员工手册 h2=考勤与休假 h3=病假
每一片都知道自己是「员工手册 > 考勤与休假 > 年假」下面的内容。
这个层级路径有三种用法,一种比一种值钱:
- 拼进 Content,让向量带上主题信息(下面 D 切法就是这么干的)
- 给用户显示来源:「本回答出自《员工手册》第二章 年假」
- 做过滤 :用户说「只查考勤相关的」,直接
WHERE h2 = '考勤与休假'------向量检索干不了的精确过滤,元数据能干
维护这个层级的源码逻辑是个标准的栈操作:
go
newLevel := len(header) // "###" → 3
for i := len(recordedMetaList) - 1; i >= 0; i-- {
if recordedMetaList[i].level >= newLevel { // 同级或更深的
delete(recordedMetaMap, recordedMetaList[i].name) // 全部弹出
recordedMetaList = recordedMetaList[:i]
} else {
break
}
}
遇到新的 ###,把之前记录的所有 ### 及更深层级清掉,## 保留。所以片 3 的 h3 是「病假」而不是残留的「年假」。
C 的短板:它不管长度
看实验二里 C 那行数据:
C 按 Markdown 标题 4 片 最短 8 / 最长 345 / 平均 139 字
最长 345 字,而我设的上限是 200。
markdown.HeaderSplitter 的配置里根本没有 ChunkSize------它只认标题,不认长度。一节写了 3000 字,切出来就是一片 3000 字。
两个后果:
- 塞爆上下文。检索 TopK=3,三片各 3000 字,光资料就 9000 字
- 向量被稀释。一片里混了太多主题,算出来的向量是个「四不像」,跟哪个具体问题都不够像
另一头也不对:最短的片只有 8 个字(就是个光秃秃的标题行 ## 考勤与休假)。这种片存进向量库纯属占地方。
所以标题切不能单独用。
D:组合拳,生产上就用这个
思路直白:先按标题切,切完谁太长就再按长度切,切完把标题贴回去。
go
func splitCombo(ctx context.Context, docs []*schema.Document, maxSize int) []*schema.Document {
sections := splitMarkdown(ctx, docs) // 第一刀:按标题
var out []*schema.Document
for _, sec := range sections {
if runeLen(sec.Content) <= maxSize {
out = append(out, sec) // 不长,原样保留
continue
}
pieces := splitRecursive(ctx, []*schema.Document{sec}, maxSize, 0) // 第二刀:按长度
for _, p := range pieces {
p.Content = titleOf(sec) + p.Content // ← 关键:每片都贴回标题路径
out = append(out, p)
}
}
return out
}
func titleOf(d *schema.Document) string {
var parts []string
for _, k := range []string{"h1", "h2", "h3"} {
if v, ok := d.MetaData[k].(string); ok && v != "" {
parts = append(parts, v)
}
}
if len(parts) == 0 {
return ""
}
return "[" + strings.Join(parts, " > ") + "]\n"
}
p.Content = titleOf(sec) + p.Content 这一行是全篇的重点。第二刀切出来的碎片,每一片开头都被贴上 [员工手册 > 考勤与休假 > 年假]。
于是那个「答案在隔壁片」的问题不存在了------隔壁片自己也带着「年假」两个字。
效果:
C 按 Markdown 标题 4 片 最短 8 / 最长 345 / 平均 139 字 命中 4/4
D 标题切 + 超长二次切 5 片 最短 8 / 最长 225 / 平均 122 字 命中 4/4
最长片从 345 压到 225,命中率不变。
(225 > 200 是因为回填的标题本身占了字数。真要严格卡上限,把 maxSize 减去标题长度再传给第二刀就行。)
recursive 里的 IDGenerator 这时候也该用上,给二次切的片编上 年假#0、年假#1,方便排查问题时定位。
十来行代码,换 0/4 → 4/4。这是我在生产里的默认切法。
Markdown 切片器的三个源码级行为
用之前得知道它的脾气。都是我实测出来的。
① 贴心:代码块里的 # 不会被当标题
手册末尾有段脚本:
markdown
```bash
# 查询年假余额
hr-cli leave query --type annual --user $USER
```
那两行 # 注释如果被当成一级标题,这段代码就被劈成三截了。实测没有:
swift
=== 代码块里的 # 注释没被当成标题 ===
片11(130 字)h2=附录:常用脚本
"## 附录:常用脚本\n查询本人剩余年假的命令如下:\n```bash\n# 查询年假余额\n
hr-cli leave query --type annual --user $USER\n# 输出示例: remaining: 5 days\n```
\n以上脚本仅供内部使用。"
整段完好。源码里专门维护了一个状态位:
go
const (
codeSep1 = "```"
codeSep2 = "~~~"
)
if !bInCodeBlock {
if strings.HasPrefix(line, codeSep1) && strings.Count(line, codeSep1) == 1 {
bInCodeBlock = true
openingFence = codeSep1
}
// ...
}
if bInCodeBlock {
currentLines = append(currentLines, line)
continue // ← 代码块内一律不判标题
}
技术文档、API 手册用它切,这个设计能省不少事。
② 坑:# 后面没空格,不算标题
ini
=== 坑:# 后面没空格就不算标题 ===
片0 content="开头\n##有空格吗\n正文一" h2=<nil>
片1 content="## 有空格\n正文二" h2=有空格
##有空格吗 没被识别,跟前面的正文黏成了一片。源码判断:
go
if strings.HasPrefix(line, header) && (len(line) == len(header) || line[len(header)] == ' ') {
必须是「## 后面紧跟空格」或者「整行就是 ##」。
值得一提的是,HeaderConfig 的官方注释里举的例子恰好踩了这个坑:
go
// originDoc := &schema.Document{
// Content: "hell\n##Title 2\n hello world",
// }
##Title 2 没空格------按现在的实现,这个例子切不出注释里描述的结果。注释和实现对不上,以实现为准。
实际影响:从 Word、Notion、飞书文档导出的 Markdown,标题格式不一定规范。入库前先跑个规范化,别指望切片器兜底。
③ 坑:空行被吃掉
ini
=== 坑:空行被吃掉 ===
片0 content="## 标题\n段落一\n段落二"
输入是 "## 标题\n段落一\n\n段落二",输出里那个空行没了。源码第一行就是:
go
for _, line := range lines {
if len(line) == 0 {
continue // ← 空行直接丢
}
line = strings.TrimSpace(line)
顺带 TrimSpace 还把每行的缩进也削了。
两个实际影响:
- 段落边界消失 。想在第二刀里用
"\n\n"当分隔符?没了,得改用"\n"或标点 - 代码缩进丢失 。虽然代码块内容被保留,但每行前面的缩进被
TrimSpace削掉了------如果你要把代码原样展示给用户,这是个问题
semantic:按「意思」切
前面三种都是按符号切。还有一种按意思切的:splitter/semantic。
算法读下来是这样的:
markdown
1. 用所有分隔符把全文切成句子 ← 注意是"全部依次切",不是像 recursive 挑一个
2. 每个句子前后各拼 BufferSize 句,让它带上下文
3. 把这些"带上下文的句子"全部送去向量化
4. 算相邻两句的余弦距离 → 距离大 = 话题变了
5. 取所有距离的 Percentile 分位数(默认 0.9)当阈值,超过就在这儿断开
6. 小于 MinChunkSize 的碎片,合并到下一块
核心那几行:
go
distances := make([]float64, len(texts))
for i := 1; i < len(texts); i++ {
distances[i] = 1 - cosine(vectors[i-1], vectors[i])
}
threshold := calThreshold(distances[1:], s.percentile)
for i := 1; i < len(distances); i++ {
if distances[i] >= threshold {
splitIndexes = append(splitIndexes, i) // 在话题跳变处下刀
}
}
**注意 Percentile 是相对阈值,不是绝对阈值。**默认 0.9 意思是「距离最大的那 10% 的位置断开」------不管文章内部话题变化是剧烈还是平缓,都按比例切。所以片数大致可控,但不保证每片语义一定完整。
用它前先想清楚两件事:
成本。句子数 = 向量化的条数。一份 1 万字的文档切成 300 句,就是 300 条 embedding 请求(还是带 buffer 拼接后的更长文本)。而 A/B/C/D 四种切法都是零 API 调用。切一次是一次性成本,但如果你要重建索引、要试参数,这笔账得算。
两个默认值对中文不友好,跟 E52 那个坑同源:
go
seps = []string{"\n", ".", "?", "!"} // 中文句号「。」不在里面
lenFunc = func(s string) int { return len(s) } // 又是字节
中文文档必须显式覆盖,否则整篇切不出句子,等于没切。
什么时候值得用 :没有标题结构的长文------会议转写、访谈稿、爬下来的网页正文、客服对话记录。这类文本没有 # 可依,按固定长度切又会把一个话题拦腰截断,语义切分是少数几个能work的选择。
有标题就别用它,性价比不划算:花 300 次 API 调用,去猜一个文档里已经明明白白写着的东西。
说明:semantic 需要真实的 embedding 服务,这一节我没有实跑,只做了源码解读。上面四种切法的所有数据都是真机跑的。
怎么选
| 你的文档 | 用什么 | 关键配置 |
|---|---|---|
| 短章节、术语在正文重复 | recursive |
LenFunc 换 rune,Separators 从大到小 |
| Markdown / 有标题结构 | 标题切 + 超长二次切 + 标题回填 | TrimHeaders: false,回填标题路径 |
| PDF / Word 转出来的纯文本 | recursive + 适度 overlap |
先看一眼解析结果(E52 坑 1) |
| 会议转写、访谈、爬取正文 | semantic |
必须覆盖 Separators 和 LenFunc |
| HTML 原文 | splitter/html |
按标签切,配合 parser/html 的 Selector |
一个务实的顺序:
- 先看文档形态,再选切法。别一上来就问「ChunkSize 设多少最好」------这个问题没有脱离文档的答案
- 搭个答案完整率的评测,二十行代码,不花钱。有了数就不用凭感觉吵架
- 切完打印几片出来看看。看到「片 N 是标题+废话、片 N+1 是干货」这种,就知道该上组合拳了
小结
- 切得好不好要能量化:答案完整率------提问词和答案词在不在同一片。纯字符串判定,零成本,可复现
- 短章节文档四种切法打平 5/5:文档形态友好就别过度设计
- 长条款文档 0/4 vs 4/4:正文用指代词、主题词只在标题里时,固定长度切法会安静地全线失败
- 重叠是运气不是设计 :能不能救回来,取决于标题到答案的距离碰巧小不小于
OverlapSize - 标题是切片后唯一的上下文线索 ,
MetaData里的层级路径能拿来拼 Content、显示来源、做精确过滤 - markdown 切片器不限长度 ,得配合二次切;它保护代码块,但吃空行,且
#后必须有空格 - semantic 适合无结构长文,代价是句子数量级的 embedding 调用,两个默认值对中文不友好
下一篇 E69 拆切片器的源码:recursive 的 mergeSplits 怎么把碎片粘回目标长度、重叠到底是怎么实现的、semantic 的阈值计算,以及 splitter/html 是怎么按标签切的。
代码状态说明 :本文四种切法在两份文档上的全部数据、Markdown 切片器三个行为的验证输出,均在
eino v0.9.13+eino-ext(2026-07-24 版本)下真机运行、原样粘贴。semantic切分需要 embedding 服务,本文未实跑,相关内容为源码解读。评测脚本约 200 行,核心就是hit()那个字符串共现判定。