Files
sundynix-agentix/sundynix-mcp-go/internal/rag/chunk_test.go
T
Blizzard ddc2265c5b feat(mcp-go): RAG 语义切块 —— 递归 + 句界 + 重叠 + rune 安全
替换朴素切块(按行切 + 字节硬截)为工业级语义切块:
- 修真 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) <noreply@anthropic.com>
2026-06-23 15:21:17 +08:00

123 lines
3.5 KiB
Go

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
}