From ddc2265c5b88122cc302b8f8547be88ad19fc346 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 23 Jun 2026 15:21:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp-go):=20RAG=20=E8=AF=AD=E4=B9=89?= =?UTF-8?q?=E5=88=87=E5=9D=97=20=E2=80=94=E2=80=94=20=E9=80=92=E5=BD=92=20?= =?UTF-8?q?+=20=E5=8F=A5=E7=95=8C=20+=20=E9=87=8D=E5=8F=A0=20+=20rune=20?= =?UTF-8?q?=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 替换朴素切块(按行切 + 字节硬截)为工业级语义切块: - 修真 bug:旧版 s[:2000] 按字节切,中文 UTF-8(3 字节/字)会被切碎成乱码; 新版全程按 rune 操作。 - 算法:splitToAtoms(换行/中英句末标点切原子,超大无标点原子按 rune 窗口兜底) → packAtoms(贪心打包到 target=500 字、句末收口,尾块 <100 字并入相邻) → addOverlap(块间 80 字重叠,保跨块上下文)。硬上限 1000。 - chunk.go 独立成文件 + chunk_test.go(空/短/rune安全/大小上界/句界/重叠/超大无标点)。 收益:检索片段语义完整(不再断句)、中文不乱码、跨块上下文不丢 → RAG 召回质量。 make test-go 全绿。后续可加 Markdown 标题路径前缀(结构化文档增强)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- sundynix-mcp-go/internal/rag/chunk.go | 137 +++++++++++++++++++++ sundynix-mcp-go/internal/rag/chunk_test.go | 122 ++++++++++++++++++ sundynix-mcp-go/internal/rag/rag.go | 18 +-- 3 files changed, 260 insertions(+), 17 deletions(-) create mode 100644 sundynix-mcp-go/internal/rag/chunk.go create mode 100644 sundynix-mcp-go/internal/rag/chunk_test.go diff --git a/sundynix-mcp-go/internal/rag/chunk.go b/sundynix-mcp-go/internal/rag/chunk.go new file mode 100644 index 0000000..3a9615a --- /dev/null +++ b/sundynix-mcp-go/internal/rag/chunk.go @@ -0,0 +1,137 @@ +package rag + +import ( + "strings" +) + +// 切块参数(针对中文 + embedding token 上限调过;后续可配置化)。 +const ( + chunkTargetRunes = 500 // 目标大小:贪心打包到接近此值就在句界收口 + chunkOverlapRunes = 80 // 块间重叠:保跨块上下文连续 + chunkMinRunes = 100 // 最小块:低于则并入相邻,避免碎块稀释向量 + chunkMaxRunes = 1000 // 原子硬上限:超大无标点段落兜底窗口切 +) + +// chunk 把文本切成检索友好的语义块:按段落/句界切成原子 → 贪心打包到目标大小(句末收口) +// → 块间加重叠。全程按 rune 操作,杜绝中文 UTF-8 被字节切碎。 +func chunk(text string) []string { + atoms := splitToAtoms(text) + packed := packAtoms(atoms, chunkTargetRunes, chunkMinRunes) + return addOverlap(packed, chunkOverlapRunes) +} + +// 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)) } diff --git a/sundynix-mcp-go/internal/rag/chunk_test.go b/sundynix-mcp-go/internal/rag/chunk_test.go new file mode 100644 index 0000000..be247f1 --- /dev/null +++ b/sundynix-mcp-go/internal/rag/chunk_test.go @@ -0,0 +1,122 @@ +package rag + +import ( + "strings" + "testing" + "unicode/utf8" +) + +func TestChunkEmpty(t *testing.T) { + if got := chunk(" \n\n "); len(got) != 0 { + t.Fatalf("空白文本应切出 0 块,得 %d", len(got)) + } +} + +func TestChunkShort(t *testing.T) { + got := chunk("这是一句很短的话。") + if len(got) != 1 || !strings.Contains(got[0], "很短") { + t.Fatalf("短文本应为单块,得 %v", got) + } +} + +// TestChunkRuneSafe 是核心:中文必须按 rune 切,绝不能切碎出乱码(旧版按字节切的 bug)。 +func TestChunkRuneSafe(t *testing.T) { + // 1500 个汉字、无标点 → 触发窗口兜底切;每块必须是合法 UTF-8。 + text := strings.Repeat("中", 1500) + for _, c := range chunk(text) { + if !utf8.ValidString(c) { + t.Fatalf("切出非法 UTF-8(中文被字节切碎):%q", c) + } + for _, r := range c { + if r != '中' && !strings.ContainsRune("\n ", r) { + t.Fatalf("出现意外字符 %q,疑似切碎", r) + } + } + } +} + +func TestChunkSizeBounds(t *testing.T) { + // 多段中文,每段以句号结尾。 + var sb strings.Builder + for i := 0; i < 60; i++ { + sb.WriteString("这是用于测试切块大小上界的一个中文句子片段。") + } + for i, c := range chunk(sb.String()) { + if n := runeLen(c); n > chunkTargetRunes+chunkOverlapRunes+1 { + t.Fatalf("第 %d 块 %d 字,超过 target+overlap=%d", i, n, chunkTargetRunes+chunkOverlapRunes+1) + } + } +} + +func TestChunkSentenceBoundary(t *testing.T) { + var sb strings.Builder + for i := 0; i < 80; i++ { + sb.WriteString("第一句话在这里。第二句话也在这里。") + } + chunks := chunk(sb.String()) + if len(chunks) < 2 { + t.Fatalf("长文本应切出多块,得 %d", len(chunks)) + } + // 多数块应以句号收口(允许重叠导致的少量例外)。 + endsWell := 0 + for _, c := range chunks { + if strings.HasSuffix(strings.TrimSpace(c), "。") { + endsWell++ + } + } + if endsWell < len(chunks)/2 { + t.Fatalf("多数块应在句末收口,仅 %d/%d", endsWell, len(chunks)) + } +} + +func TestChunkOverlap(t *testing.T) { + var sb strings.Builder + for i := 0; i < 60; i++ { + sb.WriteString("用于验证相邻块之间存在上下文重叠的中文句子。") + } + chunks := chunk(sb.String()) + if len(chunks) < 2 { + t.Skip("未切出多块,跳过重叠校验") + } + // 第 2 块开头应包含第 1 块尾部的一小段(重叠)。 + prevTail := []rune(chunks[0]) + tail := string(prevTail[max0(len(prevTail)-chunkOverlapRunes):]) + // 取尾部一小片做包含判断(去掉可能的换行)。 + probe := strings.TrimSpace(tail) + if len(probe) > 10 { + probe = probe[len(probe)-10:] + } + if probe != "" && !strings.Contains(chunks[1], strings.TrimSpace(string([]rune(probe)))) { + // 重叠是按 rune 尾部,probe 是字节尾部,宽松校验:第二块前缀应与首块尾部有交集 + if !strings.HasPrefix(strings.TrimSpace(chunks[1]), strings.TrimSpace(tail)) { + t.Logf("重叠片段:%q\n块2前缀:%q", tail, []rune(chunks[1])[:min0(40, runeLen(chunks[1]))]) + t.Fatalf("相邻块未见重叠") + } + } +} + +func TestChunkOversizedNoPunct(t *testing.T) { + text := strings.Repeat("x", 3000) // 无标点超大块 + chunks := chunk(text) + if len(chunks) < 3 { + t.Fatalf("3000 字无标点应窗口切成多块,得 %d", len(chunks)) + } + for _, c := range chunks { + if runeLen(c) > chunkTargetRunes+chunkOverlapRunes+1 { + t.Fatalf("窗口切块超界:%d", runeLen(c)) + } + } +} + +func max0(n int) int { + if n < 0 { + return 0 + } + return n +} +func min0(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/sundynix-mcp-go/internal/rag/rag.go b/sundynix-mcp-go/internal/rag/rag.go index e99f34a..f535fde 100644 --- a/sundynix-mcp-go/internal/rag/rag.go +++ b/sundynix-mcp-go/internal/rag/rag.go @@ -6,7 +6,6 @@ import ( "context" "errors" "log" - "strings" "sync" "github.com/sundynix/sundynix-shared/contract" @@ -252,19 +251,4 @@ func (e *Engine) Close() { e.graph.close(context.Background()) } -// chunk 朴素切块:按行切,去空白;过长再按长度切。真实系统应做版面/语义切块。 -func chunk(text string) []string { - var out []string - for _, line := range strings.Split(text, "\n") { - s := strings.TrimSpace(line) - if s == "" { - continue - } - for len(s) > 2000 { - out = append(out, s[:2000]) - s = s[2000:] - } - out = append(out, s) - } - return out -} +// chunk 的实现已移到 chunk.go(递归 + 句界 + 重叠 + rune 安全的语义切块)。