案例 #1700:深层标题 breadcrumb 与分块上下文
PR:#1700 | Issue #1674 | 2026-06-16 合入 | 2 文件 +107/-3 | 作者 adawang1210
结论先行
在 heading 分块策略下,当一个浅层标题(如 ## 5 Classification)下的整段内容超过 ChunkSize 时,chunker 会用 SplitText 做二次切分。修复前,所有子 chunk 共用同一段 section 级 breadcrumb,章内更深的 ### / #### 标题不会进入 Chunk.ContextHeader,导致检索命中正文却 丢失小节语义,问答失败。
修复:在 section 内部按 rune 偏移 追踪子标题层级,为每个 sub-chunk 分配其起始位置处生效的 最深 breadcrumb。
适合谁读:改 internal/infrastructure/chunker/ 的贡献者;做规范/标准类文档 ingest 的 RAG 工程师。
链 Ch27 分块源码 · Ch40 分块挑战 · 与 case-1725 同属「下游 chunker 假设被上游格式打破」。
1. 问题现象(What)
典型文档形状
Issue #1674 来自 国家标准 / 行业规范 类 Markdown:浅层 ## 作为主干章节,下面密集嵌套 ###、#### 条款表。例如:
# Standard XYZ
## 5 Classification
### 5.9 Special cases
#### 5.9.2 Parameter defaults
(此处正文很长,超过 ChunkSize…)
用户可见现象
- 知识库 ingest 成功,向量写入正常
- 用户问:「5.9.2 节的参数默认值是多少?」
-
检索 能命中 含答案正文的 chunk,但 chunk 的
ContextHeader只有:# Standard XYZ ## 5 Classification没有
#### 5.9.2— LLM 看到的上下文缺少条款编号,容易答错或答「文中未提及」
复现条件
chunking_config.strategy: "heading"- 某一 DominantHeadingLevel(重复出现的浅层标题)下 section 长度 > ChunkSize
- section 内部存在 level 更深 的 Markdown 标题(
###/####) - 触发
splitByHeadingsImpl→ legacySplitText二次切分
定位步骤
rg "splitByHeadings|ContextHeader|sectionBreadcrumbs" internal/infrastructure/chunker/
go test ./internal/infrastructure/chunker/ -run TestSplitByHeadings_DeepSubHeading -v
2. 根因分析(Why)
类比
一本书按「章」装订,但某一章太厚,印刷厂强行从中间撕开成多叠。修复前:每一叠外面只贴「第 5 章」标签;修复后:每一叠贴「第 5 章 > 5.9 节 > 5.9.2 款」标签。读者(检索 + LLM)靠标签定位,而不只是靠中间某一段正文。
数据流
flowchart TD
A[Markdown 文档] --> B[splitByHeadingsImpl]
B --> C{section 长度 > ChunkSize?}
C -->|否| D[单 chunk<br/>ContextHeader = section breadcrumb]
C -->|是| E[SplitText 切成 sub-chunks]
E --> F{修复前}
E --> G{修复后}
F --> H[每个 sub-chunk<br/>ContextHeader = 同一 section breadcrumb ❌]
G --> I[sectionBreadcrumbs 扫描章内 ###/####]
I --> J[breadcrumbAtOffset 按 sub.Start 取最深标题 ✅]
D --> K[embed + 检索 + Generate]
H --> K
J --> K
代码级根因
observeSubHeadings 只在 划分 section 边界 时维护 heading hierarchy。当 section 过大进入 SplitText 分支时:
// 修复前:所有 sub-chunk 共用 section 入口时的 breadcrumb
ContextHeader: breadcrumb,
没有在 section 文本内部 为每个 sub-chunk 的 Start 偏移重新解析当前生效的子标题栈。
PR 作者提供的失败断言(修复前):
- 深标记 chunk 的
ContextHeader="# Standard XYZ\n## 5 Classification" - 期望包含
"#### 5.9.2"
关键文件
| 文件 | 职责 |
|---|---|
internal/infrastructure/chunker/heading_splitter.go |
splitByHeadingsImpl、sectionBreadcrumbs、breadcrumbAtOffset |
internal/infrastructure/chunker/heading_splitter_test.go |
TestSplitByHeadings_DeepSubHeadingInLargeSection |
设计约束(PR 明确未改动的部分):
- 单 chunk 路径不变
- sub-chunk 的
Start/Endrune 偏移不变 - code fence 内的
#行 不 当作标题(避免代码块误切)
3. 解决方案(How)
算法(两步)
sectionBreadcrumbs:在 section 的 rune 序列内扫描level > primaryLevel的 Markdown 标题,产出(runeStart, breadcrumb)列表;offset 0 处 seed 为 section 入口 hierarchybreadcrumbAtOffset(sub.Start, fallback):取 最后一个runeStart ≤ sub.Start的 breadcrumb;若无则回退 section 级 breadcrumb
subBreadcrumbs := sectionBreadcrumbs(sectionRunes, primaryLevel, sectionStart)
// 每个 sub-chunk:
ContextHeader: breadcrumbAtOffset(subBreadcrumbs, sub.Start, breadcrumb),
为何不用「按 ### 先切 section 再切 chunk」?
- DominantHeadingLevel 由 文档整体结构 决定,不能把每个
###都提升为 split backbone(会破坏现有 heading 策略语义) - 本修复是 heading 策略内的局部增强,不改变 DominantHeadingLevel 算法 — review 面小,当天合入
测试
TestSplitByHeadings_DeepSubHeadingInLargeSection:
- 构造
## 5 → ### 5.9 → #### 5.9.2长 section - 断言落在
#### 5.9.2正文区的 sub-chunk,其ContextHeader含#### 5.9.2 - PR 注明:该测试在修复前 故意失败,修复后通过
验证命令:
go test ./internal/infrastructure/chunker/ -run TestSplitByHeadings_DeepSubHeadingInLargeSection -v
go vet ./internal/infrastructure/chunker/
4. 与 RAG 管线
| 层 | 影响 |
|---|---|
| Chunk | ContextHeader 前缀进入 chunk 文本 / metadata |
| Embed | 向量含更多层级语义(「5.9.2 款」而非仅「第 5 章」) |
| Retrieve | 规范编号类 query 的语义匹配改善 |
| Generate | LLM prompt 中 citation 上下文更可读 |
未解决:query-dependent 最优分块(Ch40)— 本 PR 仍是 离线 ingest 策略改进。
与 #1248:feedback 归因到 chunk_id;若 chunk 的展示上下文(ContextHeader)错误,用户点踩时看到的 snippet 也会 误导 — metadata 完整性是归因前提。
5. 可复用模式
- 改 chunker 必问:sub-chunk 继承哪些 metadata?
Start/End偏移能否支撑「按位置查状态」? - 单测用「修复前必失败」:维护者一眼看懂 regression 保护什么(PR 描述直接写了 fail/pass 对比)
- 2 文件 +107 行 可成为高价值 RAG PR — 对比 case-1752 排除项 的纯 lint
6. 常见误区
错误认知:heading 策略下每个 ### 都会单独成 chunk。
正确理解:只有 DominantHeadingLevel 决定 section 边界;更 deep 的标题只在 section 内部 提供语义,section 过大时仍会被 SplitText 切开。
错误认知:检索命中正文就够了,ContextHeader 只是装饰。
正确理解:ContextHeader 进入 embed 与展示;规范类文档条款号常在标题里。
错误认知:在 code fence 里识别 # 标题更「完整」。
正确理解:fence 内 # 是代码语法,识别会误切 section。
错误认知:改 chunker 只影响展示不影响向量。
正确理解:ContextHeader 通常与正文一起参与 embed,层级语义会进向量空间。
7. 实验建议
- 取 lab/corpus/weknora-arch.md,人工加长某一
###小节至 > ChunkSize - ingest 两次(修复前/后 commit 或本地 patch)
- hybrid-search query:「{小节标题} 中的具体配置项」
- 对比 top-1 chunk 的
ContextHeader是否含该###标题
8. 思考点
- 若文档用 编号列表 而非 Markdown 标题(
1.1.2纯文本),本算法能否扩展?需要改哪一层? breadcrumbAtOffset取「最后一个 ≤ Start」— 若 sub-chunk 跨越 两个###边界,应继承哪一个?当前行为合理吗?- 画一张从「用户 query 含条款号」到「LLM 答案」的图,标出 ContextHeader 在哪一步注入。
- case-1725 在 docparser 层归一化 HTML 表;本 PR 在 chunker 层补 breadcrumb — 两者如何组合成「ingest 质量 checklist」?
章末自检
- 说清 section 级 vs sub-chunk 级 breadcrumb 差异,并举 #1674 的
#### 5.9.2例子 - 解释
sectionBreadcrumbs为何要 skip code fence - 写出
go test命令与期望看到的 pass 输出