Files
HomeAgent/internal/knowledge/dense_recall_quality_test.go
JianFeeeee 5187a62f47 feat(knowledge)!: 检索改为纯稠密向量路,去掉三路融合
按「完全改用向量方案」的要求,SearchIn 现在只走多模态稠密路,
不再与稀疏语义路 / 词法路融合。

## 为什么必须去掉融合(实测,不是判断)

生产知识库(192 条)上,10 条带标注查询的命中率:

    改前(三路融合)  top1= 1/10  top3= 2/10
    改后(纯稠密路)  top1= 6/10  top3= 7/10

改前连条目**自己的全名**都搜不到:
`plugin_dev_sdk` 搜不到 `plugin_dev_sdk`、`homeagent_identity` 搜不到
`homeagent_identity`,用户看到的是「查什么都返回同样的几个无关条目」。

而稠密路单独打分时**全库排名 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

### 根因:逐路归一化把噪声抬到与信号同权

`addPath` 把每路都按**查询内最大值**归一化到 1.0 再加权:

    scores[h.id] += weight * h.score / max

于是词法路原始分只有 **0.027** 的弱信号归一化后也变成 1.0,与稠密路的
1.0 等权相加。叠加两个因素:

- jieba 把英文标识符切碎:`the_jet_engine_rolls-royce` →
  `[t h e _ j n g i r o l s - y c]`,两条**无关**文档 token 重叠 50%
- `SetMinScore(0)` 导致 186/192 条都进候选,几乎全库打分

余弦分数**本身就可比**(同空间同维度),根本不需要逐路归一化 ——
那一步当初是为「量级不同的三路」服务的,现在只剩一路,它反而成了
破坏排序的东西。

## 历史结论为何在此翻转

旧注释引的实测(仅稠密路 top-1 15% / MRR 0.271;融合后 21% / 0.376)
建立在这两个**已不成立的前提**上:

- 33 条的小规模 KB(现 192 条,含大量英文标识符)
- 稀疏 fastText 词向量(现 chineseclip 512 维多模态空间)

那段结论我没删,而是在 Search 的注释里写明「前提已变、结论翻转」,
免得后来者看到旧数字又把它改回去。

## 退化路径有意保留

未注入多模态空间时(provider 留空)仍走稀疏两路。不保留的话,
那些部署的知识库会直接变成**完全不可用** —— 比排序差更严重。
该路径行为与接入稠密路之前逐字一致(TestNoDenseSpaceKeepsLegacyBehavior)。

## 判据(都可杀变异,逐个实测)

新增 `dense_recall_quality_test.go`:

- `TestDenseRecallQuality`——带标注 10 条查询,门槛 top1≥4 且 top3≥7。
  把 denseEnabled 改成恒假 → FAIL「top1 仅 1/10」(正是退化态)
- `TestDensePathIsSoleRecall`——钉形状:稠密路必须在稀疏路之前且
  **结果真的参与组装**。第一版只查「有没有调 denseHits」,把结果赋给 `_`
  仍能骗过它;补上 `s.items[h.id]` 断言后,变异(结果不参与组装)才被杀
- `TestSearchDocReflectsDenseOnly`——防文档漂移。第一版简单搜「三路」
  会误伤(新文档解释「为何不再融合」时正当地提到它),改为检查只在
  *声称当前如此* 时才会出现的句子

## 顺带修的

- `TestDenseCrossModalRecall` 的桩把**所有未知文本**映射到同一向量,
  导致 `other` 与 `cat-photo` 正文同向、「猫」查询下 `other` 以余弦 1.0
  压过 `cat-photo` 的 0.707。那是**桩的假象不是产品缺陷**。给 other
  一个真正正交的方向(且 key 必须是 denseFor 实际传入的
  `Name + " " + Content`,写成纯 Content 会静默回落到默认向量)。
- 清掉随融合退场的死代码 `denseSpaceWeight` / `hasAnyDense`,
  并把 `vec`/`lex` 字段注释改准(它们现在只服务退化路径)。
- `rankdiag_test.go` 现在测的是**退化路径**(它不注入多模态空间),
  注释与门槛说明据此改准:实测 top-1 24% / MRR 0.358,
  别拿它当知识库的检索水平。

全仓 `go test ./...` → 48 包 ok / 0 FAIL。
2026-10-08 21:34:30 +08:00

251 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}