1d23bdf0a3
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>
258 lines
7.3 KiB
Go
258 lines
7.3 KiB
Go
package rag
|
||
|
||
import (
|
||
"strings"
|
||
)
|
||
|
||
// 切块参数(针对中文 + embedding token 上限调过;后续可配置化)。
|
||
const (
|
||
chunkTargetRunes = 500 // 目标大小:贪心打包到接近此值就在句界收口
|
||
chunkOverlapRunes = 80 // 块间重叠:保跨块上下文连续
|
||
chunkMinRunes = 100 // 最小块:低于则并入相邻,避免碎块稀释向量
|
||
chunkMaxRunes = 1000 // 原子硬上限:超大无标点段落兜底窗口切
|
||
)
|
||
|
||
// chunk 把文本切成检索友好的语义块。Markdown 文档走「标题感知」切块:
|
||
// 按 #/## 层级切段,块不跨章节,且每块前缀完整标题路径(如「部署 > 环境要求」)补全上下文;
|
||
// 非 Markdown 走纯语义切块。全程按 rune 操作,杜绝中文 UTF-8 被字节切碎。
|
||
func chunk(text string) []string {
|
||
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 把文本切成"原子"(句子/行):在换行与句末标点处断开,去空白;
|
||
// 超大无标点原子按 rune 窗口兜底切到 ≤ 目标大小。原子是打包的最小不可分单元。
|
||
func splitToAtoms(text string) []string {
|
||
runes := []rune(strings.ReplaceAll(text, "\r\n", "\n"))
|
||
var atoms []string
|
||
start := 0
|
||
flush := func(end int) {
|
||
if s := strings.TrimSpace(string(runes[start:end])); s != "" {
|
||
atoms = append(atoms, s)
|
||
}
|
||
start = end
|
||
}
|
||
for i := 0; i < len(runes); i++ {
|
||
r := runes[i]
|
||
switch {
|
||
case r == '\n' || isCJKEnd(r):
|
||
flush(i + 1)
|
||
case r == '.' || r == '!' || r == '?' || r == ';':
|
||
// ASCII 句末:仅当其后为空白/行尾才断(避开缩写、小数点)。
|
||
if i+1 >= len(runes) || runes[i+1] == ' ' || runes[i+1] == '\n' {
|
||
flush(i + 1)
|
||
}
|
||
}
|
||
}
|
||
flush(len(runes))
|
||
|
||
out := make([]string, 0, len(atoms))
|
||
for _, a := range atoms {
|
||
if runeLen(a) <= chunkMaxRunes {
|
||
out = append(out, a)
|
||
} else {
|
||
out = append(out, splitByRuneWindow(a, chunkTargetRunes)...)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// packAtoms 贪心打包:把原子拼进当前块,直到再加会超 target 就收口(块在句界结束)。
|
||
// 末尾过小的块(< min)并入前一块,避免碎块。
|
||
func packAtoms(atoms []string, target, min int) []string {
|
||
var chunks []string
|
||
var cur strings.Builder
|
||
curLen := 0
|
||
closeCur := func() {
|
||
if curLen > 0 {
|
||
chunks = append(chunks, cur.String())
|
||
cur.Reset()
|
||
curLen = 0
|
||
}
|
||
}
|
||
for _, a := range atoms {
|
||
al := runeLen(a)
|
||
if curLen > 0 && curLen+al > target {
|
||
closeCur()
|
||
}
|
||
if curLen > 0 {
|
||
cur.WriteByte('\n')
|
||
curLen++
|
||
}
|
||
cur.WriteString(a)
|
||
curLen += al
|
||
}
|
||
if curLen > 0 {
|
||
if n := len(chunks); n > 0 && curLen < min {
|
||
chunks[n-1] = chunks[n-1] + "\n" + cur.String() // 尾块太小 → 并入上一块
|
||
} else {
|
||
chunks = append(chunks, cur.String())
|
||
}
|
||
}
|
||
return chunks
|
||
}
|
||
|
||
// addOverlap 给每块前缀上一块尾部的 overlap 个 rune,保跨块上下文连续(首块不加)。
|
||
func addOverlap(chunks []string, overlap int) []string {
|
||
if overlap <= 0 || len(chunks) <= 1 {
|
||
return chunks
|
||
}
|
||
out := make([]string, len(chunks))
|
||
out[0] = chunks[0]
|
||
for i := 1; i < len(chunks); i++ {
|
||
prev := []rune(chunks[i-1])
|
||
tail := prev
|
||
if len(prev) > overlap {
|
||
tail = prev[len(prev)-overlap:]
|
||
}
|
||
out[i] = strings.TrimSpace(string(tail)) + "\n" + chunks[i]
|
||
}
|
||
return out
|
||
}
|
||
|
||
func isCJKEnd(r rune) bool {
|
||
switch r {
|
||
case '。', '!', '?', ';', '…':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// splitByRuneWindow 按 rune 窗口硬切(兜底:超大无标点原子),保证不切碎多字节字符。
|
||
func splitByRuneWindow(s string, size int) []string {
|
||
r := []rune(s)
|
||
var out []string
|
||
for len(r) > size {
|
||
if t := strings.TrimSpace(string(r[:size])); t != "" {
|
||
out = append(out, t)
|
||
}
|
||
r = r[size:]
|
||
}
|
||
if t := strings.TrimSpace(string(r)); t != "" {
|
||
out = append(out, t)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func runeLen(s string) int { return len([]rune(s)) }
|