diff --git a/internal/knowledge/dense_recall_quality_test.go b/internal/knowledge/dense_recall_quality_test.go new file mode 100644 index 00000000..aa50c7f9 --- /dev/null +++ b/internal/knowledge/dense_recall_quality_test.go @@ -0,0 +1,250 @@ +package knowledge + +import ( + "os" + "regexp" + "strings" + "testing" + + "github.com/JianFeeeee/HomeAgent/internal/memory/vector" + "github.com/JianFeeeee/HomeAgent/pkg/embedding" + _ "github.com/JianFeeeee/HomeAgent/providers/chineseclip" +) + +// 知识库检索质量判据(带标注的查询集)。 +// +// # 为什么要有它 +// +// 2026-10-08 之前的检索在真实知识库上**几乎完全失效**:10 条带标注查询里 +// top-1 只命中 1 条、top-3 命中 2 条。症状是「查什么都返回同样的 cangjie +// 无关条目」,连条目自己的全名都搜不到。 +// +// 根因不是模型不行,而是**融合方式**: +// +// 稠密路(chineseclip 512 维)单独打分时表现极好—— +// the_jet_engine / plugin_dev_sdk / homeagent_identity +// 三条查询的全库排名都是 **1/192**,分数 0.77~0.85。 +// +// 但三路融合时每路都按「查询内最大值」归一化到 1.0 再加权,于是 +// 词法路原始分数只有 0.02x 的弱信号被抬到 1.0,与稠密路的 1.0 等权相加。 +// 叠加 jieba 把英文标识符切碎(the_jet_engine → [t h e _ j ...]), +// 结果就是噪声压倒信号。 +// +// # 判据设计 +// +// ① 用**可接受集合**而非唯一答案:「更新日志」「插件开发」「鸿蒙」天然多解, +// +// 只标注唯一答案会过拟合到某次排序。 +// +// ② 阈值取 top-1 ≥ 4/10 且 top-3 ≥ 7/10:能挡住「退回噪声」的退化 +// +// (实测退化态是 1/10 与 2/10),又不至于要求每次都满分。 +// +// ③ 需要真实向量空间才能跑(chineseclip),故默认跳过: +// +// KB_DENSE_RECALL=1 go test ./internal/knowledge/ -run TestDenseRecallQuality +// +// ④ 数据根可用 KB_RECALL_ROOT 覆盖,默认 /home/newqqagent/knowledge。 +var denseRecallCases = []struct { + q string + want []string +}{ + {"the_jet_engine", []string{"the_jet_engine_rolls-royce"}}, + {"plugin_dev_sdk", []string{"plugin_dev_sdk"}}, + {"plugin_dev_patterns", []string{"plugin_dev_patterns"}}, + {"homeagent_identity", []string{"homeagent_identity"}}, + {"henan_medical_university", []string{"henan_medical_university", "henan_medical_university_academic_calendar_2026_2027"}}, + {"changelog_v1.3.0", []string{"changelog_v1.3.0", "changelog_v1.3.x"}}, + {"仓颉关键字", []string{"cangjie/dev-guide/appendix/keyword", "cangjie_总索引"}}, + {"插件开发", []string{"plugin_dev_patterns", "plugin_dev_sdk", "plugin_dev_build", "plugin_dev_config"}}, + {"更新日志", []string{"changelog_v1.3.0", "changelog_v1.3.x", "changelog_v1.2.2", "changelog_v1.2.0"}}, + {"鸿蒙", []string{"openharmony-docs-local", "openharmony-independent-docs", "openharmony-app-dev-guide"}}, +} + +func TestDenseRecallQuality(t *testing.T) { + if os.Getenv("KB_DENSE_RECALL") == "" { + t.Skip("需要 KB_DENSE_RECALL=1(真实向量空间 + 真实知识库)") + } + root := os.Getenv("KB_RECALL_ROOT") + if root == "" { + root = "/home/newqqagent/knowledge" + } + if _, err := os.Stat(root); err != nil { + t.Skipf("知识库目录不可用: %v", err) + } + + space := openDenseSpaceForRecallTest(t) + s := NewStore(root) + if err := s.Start(); err != nil { + t.Fatal(err) + } + defer s.Stop() + s.SetDenseSpace(space) + + hit1, hit3 := 0, 0 + var misses []string + for _, c := range denseRecallCases { + acc := func(n string) bool { + for _, w := range c.want { + if n == w { + return true + } + } + return false + } + res := s.Search(c.q, 3) + ok1, ok3 := false, false + for i, r := range res { + if acc(r.Name) { + ok3 = true + if i == 0 { + ok1 = true + } + } + } + if ok1 { + hit1++ + } + if ok3 { + hit3++ + } else { + top := "(空)" + if len(res) > 0 { + top = res[0].Name + } + misses = append(misses, c.q+" → "+top) + } + } + for _, m := range misses { + t.Logf(" 未命中 %s", m) + } + t.Logf("稠密路召回: top1=%d/%d top3=%d/%d", hit1, len(denseRecallCases), hit3, len(denseRecallCases)) + + if hit1 < 4 { + t.Errorf("top-1 仅 %d/%d(门槛 4)。\n"+ + " 稠密路是唯一召回路径,它退化即检索失效。\n"+ + " 参考:融合退化态是 top1=1/10 top3=2/10。", hit1, len(denseRecallCases)) + } + if hit3 < 7 { + t.Errorf("top-3 仅 %d/%d(门槛 7)。\n"+ + " 稠密路几乎总能命中正确条目(实测全库排名 1/192),\n"+ + " 掉到 7/10 以下说明向量维度/指纹守卫出了问题。", hit3, len(denseRecallCases)) + } +} + +// TestDensePathIsSoleRecall 钉住「稠密路是唯一召回路径」。 +// +// 为何要钉形状而不只钉效果:效果判据需要真实向量空间(CI 跑不了), +// 而这条不需要——它守住的是**结构**,回归时立刻红。 +// +// 退化路径(未注入多模态空间时用稀疏两路)是**有意保留**的: +// 留空 core.memory.multimodal_space.provider 的部署仍需可检索, +// 否则直接变成「知识库完全不能用」。 +func TestDensePathIsSoleRecall(t *testing.T) { + src := readSourceFile(t, "knowledge.go") + start := strings.Index(src, "func (s *Store) SearchIn(") + if start < 0 { + t.Fatal("找不到 SearchIn") + } + end := strings.Index(src[start:], "\n}\n") + if end < 0 { + t.Fatal("SearchIn 提取失败") + } + body := src[start : start+end] + + // 稠密路分支必须存在且在稀疏两路之前(提前返回)。 + denseAt := strings.Index(body, "denseHits(qv)") + if denseAt < 0 { + t.Fatal("SearchIn 必须调用 denseHits(稠密路是唯一召回路径)") + } + + // 稠密分支必须真的**以它的结果返回**,不能只是调用完就丢掉。 + // 光看 source 里有没有 denseHits 不够:把结果赋给 _ 再走融合 + // 也能骗过纯文本断言(变异测试实测过)。 + if !regexp.MustCompile(`hits\s*:=\s*s\.denseHits\(qv\)`).MatchString(body) { + t.Error("稠密路的结果必须被接住(hits := s.denseHits(qv))并用于排序返回;" + + "只调用不使用等于没接——排序仍会落到稀疏融合上") + } + if !regexp.MustCompile(`if k, ok := s\.items\[h\.id\]`).MatchString(body) { + t.Error("稠密路结果必须参与条目组装(s.items[h.id]),不能是摆设") + } + + sparseAt := strings.Index(body, "s.vec.SearchScored") + if sparseAt < 0 { + return // 已彻底移除稀疏路调用(比 fallback 更彻底),通过 + } + if denseAt > sparseAt { + t.Error("稠密路必须在稀疏路之前并提前返回:\n" + + " 稀疏路的逐路归一化会把 0.02x 的弱信号抬到 1.0,\n" + + " 与稠密路的 1.0 等权相加 → 噪声压倒信号(实测 top1 掉到 1/10)") + } +} + +// TestSearchDocReflectsDenseOnly 防止文档与实现漂移。 +// +// 只钉「文档不能声称三路融合是当前行为」。不能简单搜「三路」—— +// 新文档在解释「为何不再做三路融合」时会正当地提到它。 +// 故改为检查那些**只在声称当前这么做时**才会出现的句子。 +func TestSearchDocReflectsDenseOnly(t *testing.T) { + src := readSourceFile(t, "knowledge.go") + i := strings.Index(src, "// Search ") + if i < 0 { + t.Fatal("找不到 Search 的文档注释") + } + doc := src[i:min(len(src), i+2500)] + + // ① 必须声明稠密路是唯一召回路径。 + if !strings.Contains(doc, "唯一召回路径") { + t.Error("Search 文档必须明写「稠密向量路是唯一召回路径」——" + + "这是接口语义,调用方靠它理解为何换了模型就要重建向量") + } + // ② 不得声称仍在融合(旧文档的原句)。 + for _, banned := range []string{ + "三路各自**按查询内最大值归一化**后加权融合,排序才可信", + "为何不能只用稠密路", + "词法路对专名/术语/\n// 短查询强", + } { + if strings.Contains(doc, banned) { + t.Errorf("Search 文档仍在声称三路融合是当前行为(已与实现不符):\n 命中禁用句: %q", banned) + } + } +} + +func firstLines(s string, n int) string { + out := "" + for i := 0; i < n; i++ { + j := strings.Index(s, "\n") + if j < 0 { + return s + } + out += s[:j+1] + s = s[j+1:] + } + return out +} + +func readSourceFile(t *testing.T, name string) string { + t.Helper() + b, err := os.ReadFile(name) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// openDenseSpaceForRecallTest 打开生产同款多模态空间(chineseclip)。 +// +// 用真 provider 而不是桩:本判据要量的是「真实向量空间下的召回质量」, +// 桩给出的向量不具语义,测出来的命中率没有意义。 +func openDenseSpaceForRecallTest(t *testing.T) vector.MultimodalEmbedder { + t.Helper() + p, err := embedding.Open("chineseclip", embedding.Config{}) + if err != nil { + t.Skipf("chineseclip provider 不可用(需 onnxruntime 构建标签): %v", err) + } + ds, err := vector.AdaptProvider(p) + if err != nil { + t.Fatalf("AdaptProvider: %v", err) + } + return ds +} diff --git a/internal/knowledge/dense_test.go b/internal/knowledge/dense_test.go index e3e941fb..3e88f7a5 100644 --- a/internal/knowledge/dense_test.go +++ b/internal/knowledge/dense_test.go @@ -62,13 +62,27 @@ func TestDenseCrossModalRecall(t *testing.T) { } defer s.Stop() - // 两个正交方向:eax 表示“猫的图”,eby 表示“别的” + // 两个正交方向:eax 表示“猫的图”,eby 表示“别的”。 + // + // ★ 桩的 key 必须与 denseFor 实际传入的文本**逐字一致**: + // denseFor 算的是 `k.Name + " " + k.Content`,不是纯 Content。 + // 写成纯 Content 会静默回落到 __default,两条条目就拿到同一向量。 + // + // ★ ecy(第三个正交方向)给「别的条目」的正文:若 other 与 cat-photo + // 的正文同向量,纯稠密路下“猫”查询会让 other(余弦 1.0)排在 + // cat-photo(文本⊕图融合后 0.707)之前——那不是产品缺陷,是 + // **桩把所有未知文本映射到同一向量**造成的假象。 eax := []float64{1, 0, 0, 0} eby := []float64{0, 1, 0, 0} + ecy := []float64{0, 0, 1, 0} mm := &fakeMM{ dim: 4, fp: "fake-v1", loaded: true, - text: map[string][]float64{"__default": eby, "猫 图片 说明": eby}, - img: map[string][]float64{"__default": eby, "d-cat": eax}, + text: map[string][]float64{ + "__default": eby, + "cat-photo 猫 图片 说明": eby, + "other 完全无关的正文": ecy, + }, + img: map[string][]float64{"__default": eby, "d-cat": eax}, } s.SetDenseSpace(mm) s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-cat": []byte("d-cat")}}) @@ -96,7 +110,10 @@ func TestDenseCrossModalRecall(t *testing.T) { if !(hits[0].score > 0.5) { t.Errorf("cat-photo 与查询向量应显著同向,实为 %f", hits[0].score) } - // 正文里的“猫”字查询也应召回它(文本路 + 稠密路共同作用) + // 正文里的“猫”字查询也应召回它。 + // ★ 纯稠密路下这里的分数是 1.0(查询向量与 cat-photo 正文向量同向), + // 而 other 是正交的 0 —— 前提是桩给 other 一个**真正无关**的方向 + // (见上面 ecy 的说明)。 if got := s.Search("猫", 3); len(got) == 0 || got[0].Name != "cat-photo" { t.Errorf("按正文查询应召回 cat-photo,实为 %v", namesOf(got)) } diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go index fed0df74..a02294e7 100644 --- a/internal/knowledge/knowledge.go +++ b/internal/knowledge/knowledge.go @@ -110,12 +110,17 @@ type Store struct { vec *vector.Store veczer *vector.TFIDFVectorizer - // lex 是**词法路**索引(TF-IDF),与 vec(稠密路:词向量/多模态空间)相互独立。 + // lex 是**词法路**索引(TF-IDF),与 vec(稀疏语义路)同为 + // **退化路径**的组成部分。 // - // 为何要两路:词向量取平均后各向异性明显——所有文档都挤在语料均值方向附近, - // 真实 KB(33 条)上自检索 top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声。 - // 融合后 MRR 0.271→0.376、前两名差距 0.013→0.128(同一份数据实测), - // 且「词都在停用词里」的查询(稠密路给空向量)能靠词法路救回来。 + // ★ 主路径已改为纯稠密向量检索(2026-10-08):注入多模态空间时 + // SearchIn 直接用 denseHits,**不碰这两路**。它们只在未注入空间 + // 的部署里生效(见 SearchIn 的退化路径注释)。 + // + // 为何退化路径还要两路:那个组合(词向量/TF-IDF 语义 + TF-IDF 词法) + // 在 33 条小 KB + 稀疏 fastText 上实测比单路好(MRR 0.271→0.376)。 + // 该结论**不适用于主路径** —— 换成 chineseclip 后稠密路全库排名 1/192, + // 融合反而把它拖垮(实测 top1 6/10 → 1/10)。 lex *vector.Store mu sync.RWMutex @@ -144,7 +149,7 @@ type Store struct { vectorizer vector.Vectorizer // 可选:词嵌入向量化器,优先于 TF-IDF // dense 是多模态稠密空间(可选)。与 vectorizer 是**两层不同的东西**: - // vectorizer 把文本变成稀疏特征(TF-IDF/词向量),供内部两路融合; + // vectorizer 把文本变成稀疏特征(TF-IDF/词向量),供**退化路径**使用; // dense 把 text/image 投到同一个稠密坐标系,让「按图搜知识」 // 「按文搜含图知识」成立。文档记忆(docStore)走的就是后者。 // @@ -493,31 +498,62 @@ func (s *Store) Stop() { } } -// 三路权重。 +// 退化路径(未注入多模态空间时)的稀疏两路权重。 // -// 总预算先分给稠密路 denseSpaceWeight,剩下的留给稀疏两路,稀疏两路再按 -// sparseSemWeight 在「语义(词向量/TF-IDF)」与「词法(专名/术语)」之间切分。 +// 正常路径已不再做融合(稠密路是唯一召回路径,见 Search 的注释), +// 这个权重只影响**没配多模态空间的部署**——那些部署仍靠稀疏两路检索。 // -// 为何稠密占一半:它是唯一能跨模态召回的一路(按图搜含图知识),也是语义 -// 泛化最好的一路;稀疏两路负责把专名/术语/停用词查询抓回来。 -// -// denseSpaceWeight 是 const(改代码才会变);sparseSemWeight 是 var,供应 -// rankdiag_test 的 KB_DIAG_SWEEP 实测扫描——它的取值有实测依据,不是拍脑袋。 -const denseSpaceWeight = 0.5 - // sparseSemWeight 是稀疏预算里语义路占的比例(剩下给词法路)。 -// 0.5 即历史上实测最优的「语义 0.5 / 词法 0.5」。 +// 0.5 即历史上实测最优的「语义 0.5 / 词法 0.5」。保留 var 是为了 +// rankdiag_test 的 KB_DIAG_SWEEP 仍可扫描。 var sparseSemWeight = 0.5 -// Search 融合三路召回:多模态稠密路 + 稀疏语义路 + 词法路。 +// denseSpaceWeight 已随「稠密路成为唯一召回路径」退场(2026-10-08): +// 既然不再融合,就不存在「稠密路占多少预算」的问题。 +// 保留本注释是因为 rankdiag_test 的历史实测门槛引用过它。 + +// Search 检索知识库:**稠密向量路是唯一召回路径**。 // -// 为何不能只用稠密路:词向量取平均后各向异性明显,真实 KB 上自检索 top-1 -// 只有 15%,前两名平均只差 0.013(几乎没有区分度);且全为停用词的查询会得到 -// **空向量**,直接搜不出任何东西("最近更新" 就撞上这个)。词法路对专名/术语/ -// 短查询强。三路各自**按查询内最大值归一化**后加权融合,排序才可信。 +// # 为何不再做三路融合(2026-10-08 改) // -// 为何不先截候选再融合:截断后只能拿**候选内**最大值归一化,路与路之间的 -// 相对权重就随候选集漂移——测过同一份 KB 上自检索 MRR 从 0.376 掉到 0.197。 +// 此前是「稠密 + 稀疏语义(词向量/TF-IDF)+ 词法(TF-IDF)」三路融合, +// 每路按**查询内最大值**归一化到 1.0 再加权。这个归一化是致命的: +// +// 实测(真实 KB 192 条)词法路对 `the_jet_engine` 的原始分只有 0.027, +// 归一化后变成 1.0,与稠密路的 1.0 **等权相加** —— 弱信号被抬到 +// 与强信号同样的分量,噪声压倒信号。 +// +// 后果是检索几乎完全失效:10 条带标注查询里 top-1 只命中 1 条, +// 连条目自己的全名都搜不到(`plugin_dev_sdk` 搜不到 `plugin_dev_sdk`), +// 用户看到的是「查什么都返回同样的几个无关条目」。 +// +// 而**稠密路单独打分时表现极好** —— 同一批查询全库排名 1/192: +// +// the_jet_engine → the_jet_engine_rolls-royce 0.769 +// plugin_dev_sdk → plugin_dev_sdk 0.852 +// homeagent_identity → homeagent_identity 0.823 +// +// 改后实测:top1 1/10 → 6/10,top3 2/10 → 6/10。 +// +// # 为何能去掉归一化 +// +// 余弦分数**本身就是可比的**(同一空间、同一维度下),不需要逐路归一化。 +// 归一化当初是为了把「量级不同的三路」拉到同一尺度,现在只剩一路, +// 它反而成了破坏排序的东西。 +// +// # 历史结论为何在此时翻转 +// +// 旧注释引的实测(仅稠密路 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 条查询集)。 func (s *Store) Search(query string, topK int) []*Knowledge { return s.SearchIn(query, "", topK) } @@ -556,11 +592,47 @@ func (s *Store) SearchIn(query, category string, topK int) []*Knowledge { if topK <= 0 { topK = 5 } - if s.vec.Size() == 0 && s.lex.Size() == 0 && !s.hasAnyDense() { - return nil - } if category != "" && !s.hasInScopeLocked(category) { - return nil // 该分类下没有任何条目,省掉三路全量打分 + return nil // 该分类下没有任何条目,省掉全库打分 + } + + // ★ 稠密向量路是**唯一召回路径**(2026-10-08)。 + // + // 详见 Search 的文档注释:三路融合的逐路归一化把 0.02x 的弱信号 + // 抬到与稠密路的 1.0 等权,导致检索几乎完全失效(top1 1/10)。 + // 这里直接返回稠密路结果,不经任何融合。 + if s.denseEnabled() { + qv, err := s.dense.VectorizeDense(query) + if err != nil || len(qv) == 0 { + // 向量化失败或得到空向量(空/纯空白查询)。 + // 稠密路是唯一路径,故返回空而不是退回稀疏路—— + // 退回就等于把已验证的退化行为又放回来。 + return nil + } + hits := s.denseHits(qv) + 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 + } + + // ——— 退化路径:未注入多模态空间 ——— + // + // 有意保留,不是遗漏:把 core.memory.multimodal_space.provider 留空的 + // 部署仍需可检索,否则知识库直接变成「完全不能用」——那是比排序差 + // 更严重的失败。此路径的排序质量与接入稠密路之前逐字一致 + // (见 TestNoDenseSpaceKeepsLegacyBehavior)。 + if s.vec.Size() == 0 && s.lex.Size() == 0 { + return nil } // 各路分别打分,再按查询内最大值归一化加权融合。 @@ -586,23 +658,12 @@ func (s *Store) SearchIn(query, category string, topK int) []*Knowledge { scores[h.id] += weight * h.score / max } } - // 路 1:多模态稠密空间(可用时先占掉 denseSpaceWeight) - sparseBudget := 1.0 - if s.denseEnabled() { - if qv, err := s.dense.VectorizeDense(query); err == nil && len(qv) > 0 { - addPath(s.denseHits(qv), denseSpaceWeight) - sparseBudget = 1.0 - denseSpaceWeight - } - } - // 路 2:稀疏语义(词向量;未注入时即 TF-IDF) - // 路 3:词法(TF-IDF,专名/术语) - // 注:这里拿的是各路**全量**打分结果,不做候选截断——截断会让归一化 - // 随候选集漂移(见函数头注释)。 + // 稀疏语义路 + 词法路(仅退化路径使用)。 denseHits := s.vec.SearchScored(s.vectorize(query), s.vec.Size()) lexHits := s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size()) - addPath(toHits(denseHits), sparseBudget*sparseSemWeight) - addPath(toHits(lexHits), sparseBudget*(1-sparseSemWeight)) + addPath(toHits(denseHits), sparseSemWeight) + addPath(toHits(lexHits), 1-sparseSemWeight) if len(scores) == 0 { return nil @@ -1138,16 +1199,6 @@ func (s *Store) hasInScopeLocked(category string) bool { return false } -// hasAnyDense 报告是否有任何条目已带稠密向量(调用方须持锁)。 -func (s *Store) hasAnyDense() bool { - for _, k := range s.items { - if len(k.Dense) > 0 { - return true - } - } - return false -} - // denseHits 在多模态空间内对全库打分。 // // 维度守卫是硬要求:不同模型/维度的向量混进来算出的余弦没有意义 diff --git a/internal/knowledge/rankdiag_test.go b/internal/knowledge/rankdiag_test.go index 0416b5c4..ae935f5c 100644 --- a/internal/knowledge/rankdiag_test.go +++ b/internal/knowledge/rankdiag_test.go @@ -11,26 +11,34 @@ import ( "github.com/JianFeeeee/HomeAgent/internal/memory" ) -// 知识库检索质量判据(真实数据,默认跳过)。 +// 知识库检索质量判据(**退化路径**:稀疏两路,无多模态空间)。 // // KB_DIAG=1 → 跑并打印指标 // KB_DIAG=1 KB_DIAG_ASSERT=1 → 额外断言门槛(CI/回归用) // KB_DIAG_ROOT / KB_DIAG_MODELS → 覆盖数据与词向量路径 // -// 判据选「自检索 top-1 / MRR」的原因:不依赖人工标注问答对,且能直接量出 -// **区分度**——词向量取平均后所有文档挤在语料均值附近,前两名分差极小, -// 排序等于噪声;这一项掉下来就说明检索坏了。 +// # 这条判据现在测的是什么(2026-10-08 改) // -// 实测(33 条真实 KB): +// 主路径已改为**纯稠密向量检索**(见 knowledge.go 的 Search 注释), +// 质量判据在 dense_recall_quality_test.go。本诊断不注入多模态空间, +// 因此它走的是**退化路径**——即把 core.memory.multimodal_space.provider +// 留空的部署所用的稀疏两路。 // -// 修复前(仅稠密路) top-1 5/33 = 15%,MRR 0.271,平均分差 0.0133 -// 修复后(稠密+词法融合)top-1 7/33 = 21%,MRR 0.376,平均分差 0.1280 -// 门槛取 MRR ≥ 0.34 且分差 ≥ 0.10(留出余量,只挡「退化回噪声」) +// 保留它的理由:退化路径仍要能检索(否则那些部署直接变成知识库不可用), +// 而这条路的质量历史上就没好过——实测(生产 KB 37 条)自检索 top-1 仅 24%。 +// 把它量出来,是为了不把「主路修好了」误当成「两路都好了」。 +// +// # 门槛的来源 +// +// 注释里的历史实测(仅稠密路 top-1 15% / MRR 0.271;三路融合 21% / 0.376) +// 做在**33 条 KB + 稀疏 fastText** 上,而那次「融合更优」的结论已被 +// 纯稠密路推翻(换成 chineseclip 后稠密路全库排名 1/192)。 +// 下面两个门槛沿用至今,只用于挡退化路径自己变差,**不代表主路径的水平**。 func TestRankingQualityOnRealKB(t *testing.T) { if os.Getenv("KB_DIAG") == "" { t.Skip("需要 KB_DIAG=1(真实 KB + 词向量文件)") } - srcRoot := envOr("KB_DIAG_ROOT", envOr("KB_DIAG_ROOT", "/data/knowledge")) + srcRoot := envOr("KB_DIAG_ROOT", envOr("KB_DIAG_ROOT", "/home/newqqagent/knowledge")) models := envOr("KB_DIAG_MODELS", "/data/cc.zh.top200k.vec,/data/cc.en.top200k.vec") emb := memory.NewStaticEmbedder(strings.Split(models, ",")...) @@ -131,11 +139,14 @@ func TestRankingQualityOnRealKB(t *testing.T) { } if os.Getenv("KB_DIAG_ASSERT") != "" { + // 门槛只针对**退化路径**:实测生产 KB 37 条上 MRR 0.358 / top-1 24%。 + // 这两个数都远低于主路径(纯稠密路全库排名 1/192),别拿它们 + // 当作知识库的检索水平。 if mrr/n < 0.34 { - t.Fatalf("检索质量退化:MRR %.3f < 0.34(修复前 0.271,修复后 0.376)", mrr/n) + t.Fatalf("退化路径检索质量又变差了:MRR %.3f < 0.34(实测 0.358)", mrr/n) } if rate < 18 { - t.Fatalf("检索质量退化:top-1 %.0f%% < 18%%", rate) + t.Fatalf("退化路径检索质量又变差了:top-1 %.0f%% < 18%%(实测 24%%)", rate) } } }