切片策略选错了,检索效果天差地别(第68篇-E54)

系列「企业级 AI Agent 实现拆解」E54 篇,Part 13 RAG 篇第三章。上一篇 拆了 Document 组件的接口设计。这篇不看源码看效果:同一份制度文档,四种切法,答案完整率从 0/44/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=病假

每一片都知道自己是「员工手册 > 考勤与休假 > 年假」下面的内容。

这个层级路径有三种用法,一种比一种值钱:

  1. 拼进 Content,让向量带上主题信息(下面 D 切法就是这么干的)
  2. 给用户显示来源:「本回答出自《员工手册》第二章 年假」
  3. 做过滤 :用户说「只查考勤相关的」,直接 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 字。

两个后果:

  1. 塞爆上下文。检索 TopK=3,三片各 3000 字,光资料就 9000 字
  2. 向量被稀释。一片里混了太多主题,算出来的向量是个「四不像」,跟哪个具体问题都不够像

另一头也不对:最短的片只有 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 还把每行的缩进也削了。

两个实际影响:

  1. 段落边界消失 。想在第二刀里用 "\n\n" 当分隔符?没了,得改用 "\n" 或标点
  2. 代码缩进丢失 。虽然代码块内容被保留,但每行前面的缩进被 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 必须覆盖 SeparatorsLenFunc
HTML 原文 splitter/html 按标签切,配合 parser/html 的 Selector

一个务实的顺序:

  1. 先看文档形态,再选切法。别一上来就问「ChunkSize 设多少最好」------这个问题没有脱离文档的答案
  2. 搭个答案完整率的评测,二十行代码,不花钱。有了数就不用凭感觉吵架
  3. 切完打印几片出来看看。看到「片 N 是标题+废话、片 N+1 是干货」这种,就知道该上组合拳了

小结

  • 切得好不好要能量化:答案完整率------提问词和答案词在不在同一片。纯字符串判定,零成本,可复现
  • 短章节文档四种切法打平 5/5:文档形态友好就别过度设计
  • 长条款文档 0/4 vs 4/4:正文用指代词、主题词只在标题里时,固定长度切法会安静地全线失败
  • 重叠是运气不是设计 :能不能救回来,取决于标题到答案的距离碰巧小不小于 OverlapSize
  • 标题是切片后唯一的上下文线索MetaData 里的层级路径能拿来拼 Content、显示来源、做精确过滤
  • markdown 切片器不限长度 ,得配合二次切;它保护代码块,但吃空行,且 # 后必须有空格
  • semantic 适合无结构长文,代价是句子数量级的 embedding 调用,两个默认值对中文不友好

下一篇 E69 拆切片器的源码:recursivemergeSplits 怎么把碎片粘回目标长度、重叠到底是怎么实现的、semantic 的阈值计算,以及 splitter/html 是怎么按标签切的。


代码状态说明 :本文四种切法在两份文档上的全部数据、Markdown 切片器三个行为的验证输出,均在 eino v0.9.13 + eino-ext(2026-07-24 版本)下真机运行、原样粘贴。semantic 切分需要 embedding 服务,本文未实跑,相关内容为源码解读。评测脚本约 200 行,核心就是 hit() 那个字符串共现判定。

相关推荐
9i编程1 小时前
AI BI Helper 开发实录 02:Graph 工作流编排——SQL 生成、执行与邮件推送
人工智能·openai·ai编程
1名持续学习的码农2 小时前
Codex 任务总中断:ChatGPT Plus 用户该升级 Pro,还是先把需求写清?
人工智能·gpt·ai·ai编程·codex
微学AI2 小时前
一款童年游戏对超级智能体应用开发的启示 — 从《武林群侠传》看 Agent 架构设计的“江湖智慧“
人工智能·游戏·agent
狂师2 小时前
AI 测试提效 | 告别手动抓取元素,分享我的 ui-page-parser + Skill UI 自动化提效方案
人工智能·agent·测试
怕浪猫2 小时前
第11章 实战项目二:自动化研发运维Agent
aigc·agent·ai编程
咖啡星人k2 小时前
国产大模型+国产AI编程工具:Qwen/GLM/Kimi接入MonkeyCode完整指南
ai编程
狂师3 小时前
AI Agent评测体系怎么搭:90% 的团队都漏了这几环...
人工智能·agent·测试
AINative软件工程3 小时前
LLM 应用的 Graceful Degradation 工程实践:5 层降级策略让你的 AI 功能永不完全崩溃
架构·llm·ai编程
花椒技术13 小时前
我们把 26 个接口自动化场景接进了 Agent,效果真是没想到!!
agent·ai编程·测试