feat(rag): Markdown 标题感知切块(块带章节面包屑、不跨章节、跳过代码围栏)

chunk() 分流:检测到 ATX 标题(#~######)走 chunkMarkdown,否则走原纯语义
切块(packSection)——纯文本行为不变,向后兼容。

- splitMarkdownSections:按标题层级切段,维护标题栈生成面包屑路径
  (如「部署指南 > 环境要求 > 端口」);块绝不跨章节边界。
- 正确跳过代码围栏(``` / ~~~)内的 #,避免 #define、bash 注释被误判为标题。
- 每块前缀完整标题路径 → 检索到的块自带章节语境,提升命中质量与 LLM 理解。
- 节内仍复用原语义打包(句界收口)+ 块间重叠,rune 安全不变。

测试 12/12(7 原有 + 5 新):面包屑、不跨章节、层级出栈重置、代码围栏忽略、
纯文本与语义切块一致。实测 ingest 一篇 Markdown 切出正确三级面包屑,
kb_search「Gateway 监听哪个端口」top 命中即带面包屑的块。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-24 12:49:41 +08:00
parent 218fba559c
commit 1d23bdf0a3
2 changed files with 224 additions and 5 deletions
@@ -108,6 +108,105 @@ func TestChunkOversizedNoPunct(t *testing.T) {
}
}
// ---- Markdown 标题感知切块 ----
const mdDoc = `# 部署指南
本文介绍如何部署本系统的前言段落。
## 环境要求
需要 Go 1.25 与 Docker 运行环境。
### 内存
建议至少 8GB 内存以保证向量库稳定。
## 启动
执行 docker compose up 即可拉起全部服务。
`
func TestChunkMarkdownBreadcrumb(t *testing.T) {
chunks := chunk(mdDoc)
// 「内存」节的块应带完整三级面包屑。
var memChunk string
for _, c := range chunks {
if strings.Contains(c, "8GB") {
memChunk = c
}
}
if memChunk == "" {
t.Fatal("未找到内存节的块")
}
if !strings.HasPrefix(memChunk, "部署指南 > 环境要求 > 内存\n") {
t.Fatalf("内存块应以三级面包屑开头,得:%q", memChunk)
}
}
func TestChunkMarkdownNoCrossSection(t *testing.T) {
for _, c := range chunk(mdDoc) {
// 任一块都不应同时含两个不同章节的正文(块不跨章节)。
if strings.Contains(c, "8GB") && strings.Contains(c, "docker compose") {
t.Fatalf("块跨越了章节边界:%q", c)
}
}
}
func TestChunkMarkdownHierarchyReset(t *testing.T) {
// 同级 ## 启动 应把更深的 ### 内存 出栈,面包屑回到「部署指南 > 启动」。
var startChunk string
for _, c := range chunk(mdDoc) {
if strings.Contains(c, "docker compose") {
startChunk = c
}
}
if !strings.HasPrefix(startChunk, "部署指南 > 启动\n") {
t.Fatalf("启动块面包屑应为两级(内存已出栈),得:%q", startChunk)
}
}
func TestChunkMarkdownCodeFenceIgnored(t *testing.T) {
doc := strings.Join([]string{
"# 脚本说明",
"下面给出示例脚本:",
"",
"```bash",
"# 这是注释不是标题",
"echo hi",
"```",
"",
"脚本到此结束。",
}, "\n")
chunks := chunk(doc)
for _, c := range chunks {
// 围栏内的「# 这是注释不是标题」绝不能被当成标题(不会成为面包屑首行)。
if strings.HasPrefix(c, "这是注释不是标题") {
t.Fatalf("代码围栏内的 # 被误判为标题:%q", c)
}
if !strings.HasPrefix(c, "脚本说明\n") {
t.Fatalf("块应统一挂在「脚本说明」下,得:%q", c)
}
}
if !strings.Contains(strings.Join(chunks, "\n"), "echo hi") {
t.Fatal("代码块正文应被保留")
}
}
func TestChunkPlainTextUnaffected(t *testing.T) {
// 无标题文本应与纯语义切块完全一致(向后兼容,不引入面包屑)。
plain := strings.Repeat("这是一段没有任何标题的普通中文文本。", 40)
got := chunk(plain)
want := packSection(plain)
if len(got) != len(want) {
t.Fatalf("纯文本切块数变了:got %d want %d", len(got), len(want))
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("纯文本第 %d 块与语义切块不一致", i)
}
if strings.Contains(got[i], " > ") {
t.Fatalf("纯文本不应出现面包屑:%q", got[i])
}
}
}
func max0(n int) int {
if n < 0 {
return 0