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>
222 lines
6.3 KiB
Go
222 lines
6.3 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))
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- 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
|
|
}
|
|
return n
|
|
}
|
|
func min0(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|