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:
@@ -12,12 +12,132 @@ const (
|
|||||||
chunkMaxRunes = 1000 // 原子硬上限:超大无标点段落兜底窗口切
|
chunkMaxRunes = 1000 // 原子硬上限:超大无标点段落兜底窗口切
|
||||||
)
|
)
|
||||||
|
|
||||||
// chunk 把文本切成检索友好的语义块:按段落/句界切成原子 → 贪心打包到目标大小(句末收口)
|
// chunk 把文本切成检索友好的语义块。Markdown 文档走「标题感知」切块:
|
||||||
// → 块间加重叠。全程按 rune 操作,杜绝中文 UTF-8 被字节切碎。
|
// 按 #/## 层级切段,块不跨章节,且每块前缀完整标题路径(如「部署 > 环境要求」)补全上下文;
|
||||||
|
// 非 Markdown 走纯语义切块。全程按 rune 操作,杜绝中文 UTF-8 被字节切碎。
|
||||||
func chunk(text string) []string {
|
func chunk(text string) []string {
|
||||||
atoms := splitToAtoms(text)
|
if hasMarkdownHeadings(text) {
|
||||||
packed := packAtoms(atoms, chunkTargetRunes, chunkMinRunes)
|
return chunkMarkdown(text)
|
||||||
return addOverlap(packed, chunkOverlapRunes)
|
}
|
||||||
|
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 把文本切成"原子"(句子/行):在换行与句末标点处断开,去空白;
|
// splitToAtoms 把文本切成"原子"(句子/行):在换行与句末标点处断开,去空白;
|
||||||
|
|||||||
@@ -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 {
|
func max0(n int) int {
|
||||||
if n < 0 {
|
if n < 0 {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
Reference in New Issue
Block a user