From 0ca9e962901962739200e3bad0b7322056b69b57 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 31 Oct 2026 16:42:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(knowledge):=20=E4=BF=AE=E5=A4=8D=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E6=A0=87=E8=AF=86=E7=AC=A6=E6=A3=80=E7=B4=A2=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=EF=BC=88=E5=88=86=E8=AF=8D=E5=99=A8=20+=20RRF=20?= =?UTF-8?q?=E8=9E=8D=E5=90=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 故障 真实库 196 条里 154 条是 cangjie 文档(1.53MB / 88.6% 字节)。用户反馈 「语义检索命中偏弱,cangjie 霸榜」。实测三条带精确标识符的查询: PruneOldBackups → top5 全是 cangjie,目标条目一次没出现 minSceneEvidence → 同上 strip_path → 同上 ## 真因不是 cangjie,是分词器 cangjie 只是把问题放大的体量。真正的洞在 `memory.TokenizeWords`(jieba) 对英文标识符的处理 —— 实测: PruneOldBackups → [P r u n e O l d B a c k p s] ← 切成单字符还去重 strip_path → [s t r i p _ a h] 于是任意两篇含英文字母的文档 token 重叠率都能到 50%,词法路彻底失效。 而稠密路(chineseclip)对「只在文中出现一次的专名」本就没有分辨力。 这解释了 2026-10-08 那次决策:当时把词法路从融合里去掉,理由是 「词法路对英文标识符失效」—— 观察对,**归因错**:失效的是分词器。 ## 改法 1. `identtoken.go`(新):知识库词法路专用分词器 - 补标识符**整词**:`PruneOldBackups` → `pruneoldbackups` - 补**分隔符部件**:`strip_path` → `strip` / `path`(仅当下划线等存在时) - **不拆 camelCase**:拆了会产出 `old`/`backups`,而 `old` 在 `cangjie/tools/cmd-tools/cjcompat_manual` 里是 CLI 参数名 `--old`, 出现 31 次 —— 实测评测中该文档得分 0.54 > 目标的 0.20,正好把无关文档顶上来 - 丢掉 jieba 切出来的单字母残渣 - 中文分词完全沿用 jieba,行为不变 - 只注入知识库的 veczer,**不动** memory 索引/关键词提取 2. `fusion.go`(新):RRF 倒数排名融合 - `score = 1.0/(60+rank_dense) + 0.5/(60+rank_lex)` - 不用当初那套「逐路按查询内最大值归一化」:那个做法把 0.02 的噪声抬到 与 1.0 等权,正是 2026-10-08 淘汰融合的真凶。RRF 只看名次,与量纲无关 - 稠密权重 1.0(语义主线)/ 词法 0.5(精确命名救助) 3. `Search` 文档注释重写:把两段历史都写清(2026-10-08 为何淘汰、2026-10-31 为何加回但换成 RRF),避免下一个人重复踩。 ## 判据 - `TestIdentifierTokenFeedsLex`:钉分词结果(必须产出整词、不得拆出 old) - `TestIdentifierQueryBeatsVolume`:60 篇英文词密集的噪声文档 + 一条只在 目标条目出现一次的标识符,top1 必须命中目标 —— 分词器一退化就红 - `TestSeparatorIdentifierNotBuried`:带 `_` 的标识符,整词要压过部件噪声 - `TestDensePathFeedsRanking`(原 `TestDensePathIsSoleRecall`):保留它真正的 意图 —— 防「调用了 denseHits 却把结果丢掉」这个反模式,形状改为喂给 fuseRRF - `TestSearchDocReflectsRRF`(原 `..DenseOnly`):钉文档与实现不漂移 ## 验证 go build ./... ✓ go vet ./internal/knowledge/ ✓ gofmt -l ✓ go test ./internal/knowledge/ ✓(含 -race) go test ./... -count=1 全绿 ⚠️ 生产实例需重启才能生效(lex 索引在启动时重建,无数据迁移)。 --- .../knowledge/dense_recall_quality_test.go | 103 ++++++++++++----- internal/knowledge/fusion.go | 87 +++++++++++++++ internal/knowledge/identifier_recall_test.go | 101 +++++++++++++++++ internal/knowledge/identtoken.go | 96 ++++++++++++++++ internal/knowledge/knowledge.go | 105 +++++++++++------- 5 files changed, 427 insertions(+), 65 deletions(-) create mode 100644 internal/knowledge/fusion.go create mode 100644 internal/knowledge/identifier_recall_test.go create mode 100644 internal/knowledge/identtoken.go diff --git a/internal/knowledge/dense_recall_quality_test.go b/internal/knowledge/dense_recall_quality_test.go index fb053362..0923154b 100644 --- a/internal/knowledge/dense_recall_quality_test.go +++ b/internal/knowledge/dense_recall_quality_test.go @@ -140,7 +140,7 @@ func TestDenseRecallQuality(t *testing.T) { // 退化路径(未注入多模态空间时用稀疏两路)是**有意保留**的: // 留空 core.memory.multimodal_space.provider 的部署仍需可检索, // 否则直接变成「知识库完全不能用」。 -func TestDensePathIsSoleRecall(t *testing.T) { +func TestDensePathFeedsRanking(t *testing.T) { src := readSourceFile(t, "knowledge.go") start := strings.Index(src, "func (s *Store) SearchIn(") if start < 0 { @@ -152,40 +152,88 @@ func TestDensePathIsSoleRecall(t *testing.T) { } body := src[start : start+end] - // 稠密路分支必须存在且在稀疏两路之前(提前返回)。 + // 稠密路分支必须存在且在两路收集之前。 denseAt := strings.Index(body, "denseHits(qv)") if denseAt < 0 { - t.Fatal("SearchIn 必须调用 denseHits(稠密路是唯一召回路径)") + t.Fatal("SearchIn 必须调用 denseHits(稠密路是语义主线)") } - // 稠密分支必须真的**以它的结果返回**,不能只是调用完就丢掉。 - // 光看 source 里有没有 denseHits 不够:把结果赋给 _ 再走融合 - // 也能骗过纯文本断言(变异测试实测过)。 - if !regexp.MustCompile(`hits\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) { - t.Error("稠密路的结果必须被接住(hits := s.denseHits(qv))并用于排序返回;" + - "只调用不使用等于没接——排序仍会落到稀疏融合上") + // ★ 本条判据真正要防的是「调用了 denseHits 却把结果丢掉」—— + // 这个反模式实测踩过:把结果赋给 _ 再走别的路径,排序完全落到另一路上, + // 而表面上「稠密路接了」看起来是好的。 + // + // 2026-10-31 起稠密路不再是唯一路径(改成与词法路 RRF 融合), + // 但「结果必须喂进排序」这条不变,只是喂给 fuseRRF 而不是直接返回。 + if !regexp.MustCompile(`(\w+)\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) { + t.Fatal("稠密路的结果必须被接住(x := s.denseHits(qv))") + } + if !regexp.MustCompile(`fuseRRF\(\s*(\w+)`).MatchString(body) { + t.Error("稠密路与词法路必须经 fuseRRF 融合;稠密结果不能只接不用") + } + // 稠密路产物必须真的作为融合输入之一(而不是另外造一份空的)。 + if !regexp.MustCompile(`dense\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) || + !regexp.MustCompile(`fuseRRF\(\s*dense\s*,`).MatchString(body) { + t.Error("必须写成 dense := s.denseHits(qv) 然后 fuseRRF(dense, ...)") } if !regexp.MustCompile(`if k, ok := s\.items\[h\.id\]`).MatchString(body) { - t.Error("稠密路结果必须参与条目组装(s.items[h.id]),不能是摆设") + t.Error("融合结果必须参与条目组装(s.items[h.id]),不能是摆设") } - sparseAt := strings.Index(body, "s.vec.SearchScored") - if sparseAt < 0 { - return // 已彻底移除稀疏路调用(比 fallback 更彻底),通过 + // 词法路也必须真的接上,「稀疏语义路」仍然不该回来。 + if !regexp.MustCompile(`lex\s*:=\s*s\.lexHits\(query\)`).MatchString(body) { + t.Error("词法路必须接上(lex := s.lexHits(query))——" + + "它是 PruneOldBackups / strip_path 这类标识符查询的唯一命中路径") } - if denseAt > sparseAt { - t.Error("稠密路必须在稀疏路之前并提前返回:\n" + - " 稀疏路的逐路归一化会把 0.02x 的弱信号抬到 1.0,\n" + - " 与稠密路的 1.0 等权相加 → 噪声压倒信号(实测 top1 掉到 1/10)") + // 稀疏语义路不得出现在**稠密分支**里(退化路径用它是对的)。 + if deg := strings.Index(body, "退化路径"); deg > 0 { + if strings.Contains(body[:deg], "s.vec.SearchScored") { + t.Error("稀疏语义路不该回到稠密分支:它与稠密路高度冗余," + + "而逐路归一化的坑见 Search 的文档注释") + } } } -// TestSearchDocReflectsDenseOnly 防止文档与实现漂移。 +// TestIdentifierTokenFeedsLex 钉住「分词器修好了」这个前提。 // -// 只钉「文档不能声称三路融合是当前行为」。不能简单搜「三路」—— -// 新文档在解释「为何不再做三路融合」时会正当地提到它。 -// 故改为检查那些**只在声称当前这么做时**才会出现的句子。 -func TestSearchDocReflectsDenseOnly(t *testing.T) { +// 若 identtoken.go 退化回 jieba 单字符切分,上面那条 RRF 判据仍会绿, +// 但 PruneOldBackups 又搜不到了 —— 所以必须单独钉分词结果。 +func TestIdentifierTokenFeedsLex(t *testing.T) { + cases := []struct{ in, want string }{ + {"PruneOldBackups", "pruneoldbackups"}, // 整词(不拆 camelCase) + {"strip_path", "strip_path"}, // 整词保留分隔符 + {"strip_path", "strip"}, // 部件 + {"coldcontext_query", "coldcontext"}, // 部件 + } + for _, c := range cases { + got := identifierTokenizer(c.in) + found := false + for _, g := range got { + if g == c.want { + found = true + break + } + } + if !found { + t.Errorf("identifierTokenizer(%q) 必须产出 %q,实际 %v", c.in, c.want, got) + } + } + // 反向:不能拆出 old —— 那会命中 cjcompat 的 --old(31 次)把无关文档顶上来 + for _, g := range identifierTokenizer("PruneOldBackups") { + if g == "old" || g == "backups" { + t.Errorf("不该拆 camelCase:产出了 %q(会把 --old 密集的无关文档顶上来)", g) + } + } + // 中文行为不变 + if toks := identifierTokenizer("知识库检索"); len(toks) == 0 { + t.Error("中文分词不该被影响") + } +} + +// TestSearchDocReflectsRRF 防止文档与实现漂移。 +// +// 2026-10-31 起语义变为「稠密 + 词法 RRF 融合」,本判据随之更新: +// 文档必须声明 RRF,且不得再声称「稠密路是唯一召回路径」。 +func TestSearchDocReflectsRRF(t *testing.T) { src := readSourceFile(t, "knowledge.go") i := strings.Index(src, "// Search ") if i < 0 { @@ -193,10 +241,13 @@ func TestSearchDocReflectsDenseOnly(t *testing.T) { } doc := src[i:min(len(src), i+2500)] - // ① 必须声明稠密路是唯一召回路径。 - if !strings.Contains(doc, "唯一召回路径") { - t.Error("Search 文档必须明写「稠密向量路是唯一召回路径」——" + - "这是接口语义,调用方靠它理解为何换了模型就要重建向量") + // ① 必须声明 RRF 融合(这是现行接口语义)。 + if !strings.Contains(doc, "RRF") { + t.Error("Search 文档必须写明「稠密路 + 词法路按 RRF 融合」") + } + // ①' 不得再声称稠密路是唯一路径(已与实现不符)。 + if strings.Contains(doc, "稠密向量路是**唯一召回路径**") { + t.Error("Search 文档仍在声称「稠密向量路是唯一召回路径」,与实现不符") } // ② 不得声称仍在融合(旧文档的原句)。 for _, banned := range []string{ diff --git a/internal/knowledge/fusion.go b/internal/knowledge/fusion.go new file mode 100644 index 00000000..fe316252 --- /dev/null +++ b/internal/knowledge/fusion.go @@ -0,0 +1,87 @@ +package knowledge + +import "sort" + +// rrfK 是 RRF(Reciprocal Rank Fusion)的平滑常数。 +// +// 60 是 RRF 的通用取值(Cormack et al. 2009):它让「第 1 名」与「第 2 名」 +// 的贡献足够接近(1/61 vs 1/62),又让第 100 名的贡献掉到 1/160 —— 既压住 +// 长尾噪声,又不至于让名次之间的差异被抹平。调大它更平权,调小更信头部。 +const rrfK = 60 + +// fuseRRF 按倒数排名融合两路命中。 +// +// 输入必须是**已按分数降序**的候选(SearchScored / denseHits 都保证这一点), +// 因为 RRF 只用名次、不看分数。 +// +// 为什么用名次而不做分数归一化:两路量纲完全不可比(稠密余弦 0~1,TF-IDF 余弦 +// 可以低到 0.02)。2026-10-08 淘汰「三路融合」时踩的坑正是逐路按查询内最大值 +// 归一到 1.0 —— 那会把 0.02 的噪声抬到与 1.0 的信号等权。RRF 没有这个问题: +// 它只问「你在这路里排第几」,权重由调用方显式给。 +// +// 任一路为空时,另一路的贡献不受影响(缺席的路径只是不加分)。 +func fuseRRF(dense, lex []scoreHit, wDense, wLex float64, k int) []scoreHit { + if k <= 0 { + k = rrfK + } + scores := make(map[string]float64, len(dense)+len(lex)) + addRanks := func(hits []scoreHit, w float64) { + if w <= 0 { + return + } + for i, h := range hits { + // i 从 0 起,名次从 1 起 + scores[h.id] += w / float64(k+i+1) + } + } + addRanks(dense, wDense) + addRanks(lex, wLex) + + out := make([]scoreHit, 0, len(scores)) + for id, s := range scores { + out = append(out, scoreHit{id: id, score: s}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].score != out[j].score { + return out[i].score > out[j].score + } + // 同分按 id 稳定排序,避免 map 迭代顺序导致结果不可复现 + return out[i].id < out[j].id + }) + return out +} + +// lexHits 用词法路(TF-IDF 索引)给查询打分,返回**按分数降序**的候选。 +// +// 调用方必须已持有 s.mu(读锁即可):这里只读 s.lex 与 s.veczer。 +// s.lex 自己另有一把锁,与 s.mu 不同,不存在锁序问题。 +func (s *Store) lexHits(query string) []scoreHit { + if s.lex == nil || s.lex.Size() == 0 || s.veczer == nil { + return nil + } + return toHits(s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size())) +} + +// searchLexOnly 是**只有词法路**时的检索入口。 +// +// 用在稠密路向量化为空/失败的场合(空查询、模型未就绪):此时词法路不依赖模型, +// 仍能按字面命中。它不取 s.mu —— 唯一调用方 SearchIn 已持读锁。 +func (s *Store) searchLexOnly(query string, inScope func(string) bool, topK int) []*Knowledge { + hits := s.lexHits(query) + if len(hits) == 0 { + return nil + } + out := make([]*Knowledge, 0, topK) + for _, h := range hits { + if !inScope(h.id) { + continue + } + if k, ok := s.items[h.id]; ok { + out = append(out, k) + } + if len(out) >= topK { + break + } + } + return out +} diff --git a/internal/knowledge/identifier_recall_test.go b/internal/knowledge/identifier_recall_test.go new file mode 100644 index 00000000..2b52f582 --- /dev/null +++ b/internal/knowledge/identifier_recall_test.go @@ -0,0 +1,101 @@ +package knowledge + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestIdentifierQueryBeatsVolume 复现并钉住 2026-10-31 报的那个故障形态: +// +// 库里 79% 的条目是某一种文档(实测 196 条里 154 条 cangjie), +// 查一个**只在目标条目里出现一次**的英文标识符,结果被那批文档淹没, +// 目标条目一次都进不了前五。 +// +// 这里用退化路径(不注入稠密空间)来验,因为它只依赖词法路 —— 而词法路 +// 正是当年被误判为"对标识符失效"的那条路。若分词器退回 jieba 单字符切分, +// 本判据会红(噪声文档里全是英文字母,token 重叠率足以盖过目标)。 +func TestIdentifierQueryBeatsVolume(t *testing.T) { + root := t.TempDir() + + // 目标条目:标识符只出现一次,符合真实文档形态(2872 字节里出现一次)。 + target := "# 内核装配\n\n回滚前的清理由 `PruneOldBackups` 负责,保留最近 3 个备份。\n" + writeKB(t, root, "hologramagent_kernel_assembly_upgrade", target) + + // 噪声:60 篇英文词密集的文档,模拟 cangjie 那批(含大量 old/path/strip 之类 + // 普通英文词,正是误命中来源)。 + filler := strings.Repeat("The old path is stripped and the old file path is cleaned. "+ + "Use the strip command with the old path argument. ", 8) + for i := 0; i < 60; i++ { + writeKB(t, root, fmt.Sprintf("cangjie/dev-guide/noise_%02d", i), filler) + } + + s := NewStore(root) + if err := s.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer s.Stop() + + for _, q := range []string{"PruneOldBackups", "pruneoldbackups"} { + res := s.Search(q, 5) + if len(res) == 0 { + t.Fatalf("查询 %q 无任何结果", q) + } + if res[0].Name != "hologramagent_kernel_assembly_upgrade" { + var names []string + for _, r := range res { + names = append(names, r.Name) + } + t.Errorf("查询 %q 的 top1 是 %q,应为目标条目。\n 实际前五: %v\n"+ + " 常见原因:分词器把标识符切成单字符(见 identtoken.go)", + q, res[0].Name, names) + } + } +} + +// TestSeparatorIdentifierNotBuried 钉住带分隔符的标识符(`strip_path`)。 +// +// 与上一条分开的原因:带 `_` 的标识符会被拆成部件(strip / path), +// 而这两个都是普通英文词 —— 部件拆分既是命中来源,也是误命中来源。 +// 所以这条单独验:整词命中必须压过部件噪声。 +func TestSeparatorIdentifierNotBuried(t *testing.T) { + root := t.TempDir() + writeKB(t, root, "changelog_v1.4.0", + "# 更新日志\n\n- 通用反向代理:路径挂载 `strip_path`,按子域反代出去。\n") + filler := strings.Repeat("Path handling: strip the path prefix, then strip again. ", 10) + for i := 0; i < 60; i++ { + writeKB(t, root, fmt.Sprintf("cangjie/deploy/page_%02d", i), filler) + } + + s := NewStore(root) + if err := s.Start(); err != nil { + t.Fatalf("Start: %v", err) + } + defer s.Stop() + + res := s.Search("strip_path", 5) + if len(res) == 0 { + t.Fatal("无结果") + } + if res[0].Name != "changelog_v1.4.0" { + var names []string + for _, r := range res { + names = append(names, r.Name) + } + t.Errorf("top1 是 %q,应为 changelog_v1.4.0(实际前五 %v)", res[0].Name, names) + } +} + +// writeKB 在 root//content.md 写一条知识。 +func writeKB(t *testing.T, root, name, content string) { + t.Helper() + dir := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "content.md"), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/knowledge/identtoken.go b/internal/knowledge/identtoken.go new file mode 100644 index 00000000..da464a8d --- /dev/null +++ b/internal/knowledge/identtoken.go @@ -0,0 +1,96 @@ +package knowledge + +import ( + "regexp" + "strings" + + "github.com/JianFeeeee/HologramAgent/internal/memory" +) + +// asciiRunRe 匹配一段连续的 ASCII 标识符/单词(允许 . _ - / 作分隔符)。 +// +// 为什么用正则扫原文而不是复用 jieba 的结果:jieba 对英文标识符是**按字符切**的 +// (实测 `PruneOldBackups` → `[P r u n e O l d B a c k p s]`,还顺带去重), +// 词法路因此对标识符完全失效 —— 任意两篇含英文字母的文档 token 重叠率都能到 50%。 +var asciiRunRe = regexp.MustCompile(`[A-Za-z][A-Za-z0-9_.\-/]*`) + +// identSepRe 是标识符内的分隔符集合(不含空格:空格是词边界,不是标识符内部分隔)。 +var identSepRe = regexp.MustCompile(`[_.\-/]+`) + +// identifierTokenizer 是知识库**词法路专用**的分词器。 +// +// # 它解决的问题(2026-10-31 实测) +// +// 2026-10-08 把检索改成「稠密路唯一」,理由是词法路对英文标识符失效、而 +// 逐路归一化又会把弱信号抬到与稠密路等权。前一半是**分词器的缺陷**,不是 +// 词法路本身的属性。实测(真实 196 条库): +// +// query=PruneOldBackups → 目标条目从未进前五,返回的是 cangjie 文档 +// query=minSceneEvidence → 同样从未命中 +// +// 因为查询被切成单字符,而 `hologramagent_kernel_assembly_upgrade` 里 +// 那个唯一的 `PruneOldBackups` 在单字符空间里与其他文档没有区别。 +// +// # 做法 +// +// 在 jieba 词之外,**额外**补两类 token: +// +// · 标识符整词:`PruneOldBackups` → `pruneoldbackups`、`strip_path` → `strip_path` +// · 分隔符部件:`strip_path` → `strip`、`path`(仅当下划线/点/斜杠存在时) +// +// 刻意**不拆 camelCase**:拆了会把 `PruneOldBackups` 拆出 `old`/`backups`, +// 而 `old` 在 `cangjie/tools/cmd-tools/cjcompat_manual` 里是 CLI 参数名 `--old`, +// 出现 31 次 —— 拆 camelCase 反而让无关文档拿到高分(实测该文档 0.54 > 目标 0.20)。 +// +// 同时丢掉**单个 ASCII 字母**的 token:那是 jieba 字符切分的残渣,只制造噪声。 +// +// 中文部分完全沿用 jieba,行为不变。 +func identifierTokenizer(text string) []string { + base := memory.TokenizeWords(text) + + out := make([]string, 0, len(base)+8) + seen := make(map[string]bool, len(base)+8) + add := func(tok string) { + if tok == "" || seen[tok] { + return + } + seen[tok] = true + out = append(out, tok) + } + + for _, t := range base { + if isSingleASCIILetter(t) { + continue // jieba 把标识符切碎的残渣 + } + add(t) + } + + for _, run := range asciiRunRe.FindAllString(text, -1) { + lower := strings.ToLower(run) + // 整词:长度 >= 3 才收(`go`/`io` 这类太泛,收进来只增噪声)。 + if len(lower) >= 3 { + add(lower) + } + // 部件:只有真的带分隔符才拆;`PruneOldBackups` 这种无分隔符的整词 + // 已经由上一行覆盖,拆 camelCase 反而有害(见函数注释)。 + parts := identSepRe.Split(lower, -1) + if len(parts) < 2 { + continue + } + for _, p := range parts { + if len(p) >= 3 { + add(p) + } + } + } + return out +} + +// isSingleASCIILetter 报告 token 是不是「单个 ASCII 字母」。 +func isSingleASCIILetter(t string) bool { + if len(t) != 1 { + return false + } + c := t[0] + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go index bf55abe8..e1350249 100644 --- a/internal/knowledge/knowledge.go +++ b/internal/knowledge/knowledge.go @@ -196,8 +196,11 @@ func NewStore(root string) *Store { denseCachePath: filepath.Join(root, ".dense.json"), vec: vector.NewStore(), lex: newLexicalStore(), - veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords), - items: make(map[string]*Knowledge), + // 词法路用**标识符感知**分词器(见 identtoken.go),不是 memory.TokenizeWords: + // jieba 会把 `PruneOldBackups` 切成单字符,词法路对标识符因此完全失效。 + // 只改知识库这一条路,内存索引/关键词提取仍用原分词器(行为不变)。 + veczer: vector.NewTFIDFVectorizer(identifierTokenizer), + items: make(map[string]*Knowledge), } } @@ -491,48 +494,49 @@ var sparseSemWeight = 0.5 // 既然不再融合,就不存在「稠密路占多少预算」的问题。 // 保留本注释是因为 rankdiag_test 的历史实测门槛引用过它。 -// Search 检索知识库:**稠密向量路是唯一召回路径**。 +// Search 检索知识库:**稠密路 + 词法路按 RRF 融合**。 // -// # 为何不再做三路融合(2026-10-08 改) +// # 现行为(2026-10-31 改) // -// 此前是「稠密 + 稀疏语义(词向量/TF-IDF)+ 词法(TF-IDF)」三路融合, -// 每路按**查询内最大值**归一化到 1.0 再加权。这个归一化是致命的: +// 两路各自给候选排序,再用倒数排名融合(RRF)合并: +// +// score(d) = 1.0/(60+rank_dense) + 0.5/(60+rank_lex) +// +// 稠密路是语义主线(权重 1.0),词法路只做**精确命名**的救助(权重 0.5)。 +// +// # 为什么不回到三路融合(2026-10-08 那次为什么淘汰它) +// +// 当时是「稠密 + 稀疏语义 + 词法」三路,每路按**查询内最大值**归一化到 1.0 +// 再加权相加。那个归一化是致命的: // // 实测(真实 KB 192 条)词法路对 `the_jet_engine` 的原始分只有 0.027, // 归一化后变成 1.0,与稠密路的 1.0 **等权相加** —— 弱信号被抬到 // 与强信号同样的分量,噪声压倒信号。 // -// 后果是检索几乎完全失效:10 条带标注查询里 top-1 只命中 1 条, -// 连条目自己的全名都搜不到(`plugin_dev_sdk` 搜不到 `plugin_dev_sdk`), -// 用户看到的是「查什么都返回同样的几个无关条目」。 +// 于是改成「稠密路唯一」,top1 从 1/10 提到 6/10。 // -// 而**稠密路单独打分时表现极好** —— 同一批查询全库排名 1/192: +// # 为什么现在要加回词法路(2026-10-31) // -// the_jet_engine → the_jet_engine_rolls-royce 0.769 -// plugin_dev_sdk → plugin_dev_sdk 0.852 -// homeagent_identity → homeagent_identity 0.823 +// 「稠密路唯一」在**英文标识符**这类查询上留下了洞。实测(真实 196 条库): // -// 改后实测:top1 1/10 → 6/10,top3 2/10 → 6/10。 +// query=PruneOldBackups → 目标条目从未进前五 +// query=minSceneEvidence → 同上 +// query=strip_path → 同上 // -// # 为何能去掉归一化 +// 返回的全是 154 条 cangjie 文档里反复霸榜的那几篇。原因是词法路当年被淘汰 +// 时,把**分词器的缺陷**误判成了词法路的属性:jieba 对英文标识符是 +// **按字符切**的(`PruneOldBackups` → `[P r u n e O l d B a c k p s]`), +// 任意两篇含英文字母的文档 token 重叠率都能到 50%。 // -// 余弦分数**本身就是可比的**(同一空间、同一维度下),不需要逐路归一化。 -// 归一化当初是为了把「量级不同的三路」拉到同一尺度,现在只剩一路, -// 它反而成了破坏排序的东西。 +// 修掉分词器(identtoken.go:标识符整词 + 分隔符部件,且不拆 camelCase)后, +// 同三条查询词法路**唯一命中**目标条目。而稠密路对「只在文中出现一次专名」 +// 本就无能为力(chineseclip 的纯文本语义弱,见 multimodal-space 文档的已知代价)。 // -// # 历史结论为何在此时翻转 +// 用 RRF 而不是当初那套归一化:RRF 只看**名次**、不看分数,两路量纲不可比 +// 的问题自动消失,也不会把 0.02 的弱信号抬到与 1.0 等权(第 100 名只贡献 +// 1/160,第 1 名贡献 1/61)。 // -// 旧注释引的实测(仅稠密路 top-1 15%、MRR 0.271;融合后 21% / 0.376) -// 做在两处**已不再成立的前提**上: -// - 33 条的小规模 KB(现在是 192 条,且含大量英文标识符) -// - 稀疏 fastText 词向量(现在是 chineseclip 512 维多模态空间) -// -// 换成真正的多模态向量空间后,稠密路的语义能力与当时不可同日而语, -// 而稀疏路的「对专名/术语强」在**英文标识符**上反而因 jieba 把 -// the_jet_engine 切成 [t h e _ j n g ...] 而完全失效——两条无关文档 -// 的 token 重叠率高达 50%。 -// -// 回归判据:dense_recall_quality_test.go(带标注的 10 条查询集)。 +// 回归判据:dense_recall_quality_test.go(带标注的 10 条查询集 + 标识符用例)。 func (s *Store) Search(query string, topK int) []*Knowledge { return s.SearchIn(query, "", topK) } @@ -575,22 +579,45 @@ func (s *Store) SearchIn(query, category string, topK int) []*Knowledge { return nil // 该分类下没有任何条目,省掉全库打分 } - // ★ 稠密向量路是**唯一召回路径**(2026-10-08)。 + // ★ 稠密路 + 词法路按 **RRF(倒数排名融合)** 合并(2026-10-31 改)。 // - // 详见 Search 的文档注释:三路融合的逐路归一化把 0.02x 的弱信号 - // 抬到与稠密路的 1.0 等权,导致检索几乎完全失效(top1 1/10)。 - // 这里直接返回稠密路结果,不经任何融合。 + // ## 为什么不再是「稠密路唯一」 + // + // 2026-10-08 去掉词法路的理由是「词法路对英文标识符失效」。那个观察是对的, + // 但归因错了 —— 失效的是**分词器**(jieba 把 `PruneOldBackups` 切成 + // `[P r u n e O l d B a c k p s]`),不是词法路本身。修掉分词器 + // (见 identtoken.go)后,同一批查询词法路能精确命中: + // + // PruneOldBackups → hologramagent_kernel_assembly_upgrade(唯一命中) + // minSceneEvidence → hologramagent_kernel_facts + // strip_path → changelog_v1.4.0 + // + // 而稠密路对这些**只在文中出现一次的专名**无能为力(chineseclip 纯文本语义弱), + // 实测这三条查询的目标条目一次都进不了前五,返回的是 154 条 cangjie 文档里 + // 反复霸榜的那几篇。 + // + // ## 为什么用 RRF 而不是 2026-10-08 淘汰的那套 + // + // 被淘汰的是「逐路按查询内最大值归一化再加权相加」。那个做法把词法路 0.027 的 + // 原始分抬成 1.0,与稠密路的 1.0 等权 —— 是**归一化**破坏了排序,不是词法路没用。 + // RRF 只用**名次**不用分数,天然不受两路量纲差异影响,也不会放大弱信号: + // 第 100 名贡献 1/(60+100),与第 1 名的 1/61 差 2.6 倍,不会等权。 + // + // 两路权重:稠密 1.0、词法 0.5 —— 语义仍是主线,词法只做精确命名的救助。 if s.denseEnabled() { qv, err := s.dense.VectorizeDense(query) if err != nil || len(qv) == 0 { // 向量化失败或得到空向量(空/纯空白查询)。 - // 稠密路是唯一路径,故返回空而不是退回稀疏路—— - // 退回就等于把已验证的退化行为又放回来。 - return nil + // 退回词法路:它不依赖模型,至少还能按字面命中。 + return s.searchLexOnly(query, inScope, topK) } - hits := s.denseHits(qv) + dense := s.denseHits(qv) + lex := s.lexHits(query) + + // 任一路为空都不影响另一路(RRF 对缺席的路径就是不加分)。 + fused := fuseRRF(dense, lex, 1.0, 0.5, rrfK) out := make([]*Knowledge, 0, topK) - for _, h := range hits { + for _, h := range fused { if !inScope(h.id) { continue }