diff --git a/sundynix-mcp-go/internal/rag/chunk.go b/sundynix-mcp-go/internal/rag/chunk.go index 3a9615a..2096a7b 100644 --- a/sundynix-mcp-go/internal/rag/chunk.go +++ b/sundynix-mcp-go/internal/rag/chunk.go @@ -12,12 +12,132 @@ const ( chunkMaxRunes = 1000 // 原子硬上限:超大无标点段落兜底窗口切 ) -// chunk 把文本切成检索友好的语义块:按段落/句界切成原子 → 贪心打包到目标大小(句末收口) -// → 块间加重叠。全程按 rune 操作,杜绝中文 UTF-8 被字节切碎。 +// chunk 把文本切成检索友好的语义块。Markdown 文档走「标题感知」切块: +// 按 #/## 层级切段,块不跨章节,且每块前缀完整标题路径(如「部署 > 环境要求」)补全上下文; +// 非 Markdown 走纯语义切块。全程按 rune 操作,杜绝中文 UTF-8 被字节切碎。 func chunk(text string) []string { - atoms := splitToAtoms(text) - packed := packAtoms(atoms, chunkTargetRunes, chunkMinRunes) - return addOverlap(packed, chunkOverlapRunes) + if hasMarkdownHeadings(text) { + return chunkMarkdown(text) + } + return packSection(text) +} + +// packSection 对一段纯文本做语义切块:原子 → 贪心打包(句末收口)→ 块间重叠。 +func packSection(text string) []string { + return addOverlap(packAtoms(splitToAtoms(text), chunkTargetRunes, chunkMinRunes), chunkOverlapRunes) +} + +// section 是一个标题层级下的正文段:crumb 为标题路径面包屑(顶到本节,可空=前言)。 +type section struct { + crumb string + body string +} + +// chunkMarkdown 标题感知切块:按标题层级切成 section(块绝不跨章节),每节内部再语义切块, +// 并给每块前缀该节的标题路径——使「## Redis 下的『端口配置』」这类块自带章节语境,显著提升检索命中。 +func chunkMarkdown(text string) []string { + var out []string + for _, s := range splitMarkdownSections(text) { + for _, body := range packSection(s.body) { + if s.crumb != "" { + out = append(out, s.crumb+"\n"+body) // 面包屑作为上下文前缀 + } else { + out = append(out, body) + } + } + } + return out +} + +// splitMarkdownSections 按 ATX 标题(# ~ ######)把文档切成带面包屑的章节,正确跳过代码围栏内的 #。 +func splitMarkdownSections(text string) []section { + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + type heading struct { + level int + title string + } + var stack []heading + var sections []section + var body []string + inFence, fenceMarker := false, "" + + crumb := func() string { + parts := make([]string, len(stack)) + for i, h := range stack { + parts[i] = h.title + } + return strings.Join(parts, " > ") + } + flush := func() { + if b := strings.TrimSpace(strings.Join(body, "\n")); b != "" { + sections = append(sections, section{crumb: crumb(), body: b}) + } + body = body[:0] + } + + for _, ln := range lines { + t := strings.TrimSpace(ln) + if m := fenceMark(t); m != "" { // 代码围栏开/合:围栏内的 # 不当标题 + if !inFence { + inFence, fenceMarker = true, m + } else if m == fenceMarker { + inFence, fenceMarker = false, "" + } + body = append(body, ln) + continue + } + if !inFence { + if lvl, title, ok := parseHeading(t); ok { + flush() // 上一节正文收口 + for len(stack) > 0 && stack[len(stack)-1].level >= lvl { + stack = stack[:len(stack)-1] // 同级/更浅的标题出栈 + } + stack = append(stack, heading{lvl, title}) + continue + } + } + body = append(body, ln) + } + flush() + return sections +} + +// parseHeading 解析 ATX 标题行:返回层级(1-6)、标题文本、是否命中。要求 # 后跟空格(排除 #define 之类)。 +func parseHeading(line string) (level int, title string, ok bool) { + n := 0 + for n < len(line) && line[n] == '#' { + n++ + } + if n == 0 || n > 6 || n >= len(line) || line[n] != ' ' { + return 0, "", false + } + title = strings.TrimRight(strings.TrimSpace(line[n:]), "# ") // 去掉闭合式 ATX 尾部的 # + if title == "" { + return 0, "", false + } + return n, title, true +} + +// fenceMark 返回代码围栏标记(``` 或 ~~~),非围栏行返回空。 +func fenceMark(t string) string { + switch { + case strings.HasPrefix(t, "```"): + return "```" + case strings.HasPrefix(t, "~~~"): + return "~~~" + default: + return "" + } +} + +// hasMarkdownHeadings 快速判断文本是否含 ATX 标题(决定走标题感知还是纯语义切块)。 +func hasMarkdownHeadings(text string) bool { + for _, ln := range strings.Split(text, "\n") { + if _, _, ok := parseHeading(strings.TrimSpace(ln)); ok { + return true + } + } + return false } // splitToAtoms 把文本切成"原子"(句子/行):在换行与句末标点处断开,去空白; diff --git a/sundynix-mcp-go/internal/rag/chunk_test.go b/sundynix-mcp-go/internal/rag/chunk_test.go index be247f1..7e6ef1c 100644 --- a/sundynix-mcp-go/internal/rag/chunk_test.go +++ b/sundynix-mcp-go/internal/rag/chunk_test.go @@ -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