Files
HomeAgent/internal/knowledge/rankdiag_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

167 lines
5.3 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 (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/JianFeeeee/HomeAgent/internal/memory"
)
// 知识库检索质量判据(**退化路径**:稀疏两路,无多模态空间)。
//
// KB_DIAG=1 → 跑并打印指标
// KB_DIAG=1 KB_DIAG_ASSERT=1 → 额外断言门槛(CI/回归用)
// KB_DIAG_ROOT / KB_DIAG_MODELS → 覆盖数据与词向量路径
//
// # 这条判据现在测的是什么(2026-10-08 改)
//
// 主路径已改为**纯稠密向量检索**(见 knowledge.go 的 Search 注释),
// 质量判据在 dense_recall_quality_test.go。本诊断不注入多模态空间,
// 因此它走的是**退化路径**——即把 core.memory.multimodal_space.provider
// 留空的部署所用的稀疏两路。
//
// 保留它的理由:退化路径仍要能检索(否则那些部署直接变成知识库不可用),
// 而这条路的质量历史上就没好过——实测(生产 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", "/home/newqqagent/knowledge"))
models := envOr("KB_DIAG_MODELS", "/data/cc.zh.top200k.vec,/data/cc.en.top200k.vec")
emb := memory.NewStaticEmbedder(strings.Split(models, ",")...)
// 拷贝到临时目录跑:Start() 会重写 .index.json,不能动线上数据
tmp := t.TempDir()
entries, err := os.ReadDir(srcRoot)
if err != nil {
t.Fatalf("读取 %s: %v", srcRoot, err)
}
names := []string{}
for _, e := range entries {
if !e.IsDir() {
continue
}
src := filepath.Join(srcRoot, e.Name(), "content.md")
in, err := os.Open(src)
if err != nil {
continue
}
dst := filepath.Join(tmp, e.Name(), "content.md")
os.MkdirAll(filepath.Dir(dst), 0755)
out, _ := os.Create(dst)
io.Copy(out, in)
out.Close()
in.Close()
names = append(names, e.Name())
}
if len(names) == 0 {
t.Fatal("没有可用的知识条目")
}
st := NewStore(tmp)
st.SetVectorizer(emb)
if err := st.Start(); err != nil {
t.Fatalf("start: %v", err)
}
top1, mrr, missed := 0, 0.0, []string{}
for _, name := range names {
// 取全量排名:MRR 的定义用到真实名次,只取 top-2 会把 rank>2 的全都记 0
// (我第一版就是这么写的,把 0.376 误报成 0.197)
hits := st.Search(name, len(names))
if len(hits) == 0 {
missed = append(missed, name+"(无结果)")
continue
}
if hits[0].Name == name {
top1++
} else {
missed = append(missed, fmt.Sprintf("%s→%s", name, hits[0].Name))
}
for i, h := range hits {
if h.Name == name {
mrr += 1.0 / float64(i+1)
break
}
}
}
n := float64(len(names))
rate := 100 * float64(top1) / n
fmt.Printf("\n === 知识库检索质量(%d 条,自检索判据)===\n", len(names))
fmt.Printf(" top-1 %d/%d = %.0f%% MRR %.3f\n", top1, len(names), rate, mrr/n)
if len(missed) > 0 {
fmt.Printf(" 未命中 top-1(前 10):%v\n", firstN(missed, 10))
}
for _, q := range []string{"最近更新", "首启人格门禁", "插件怎么开发和部署", "统一多模态向量空间 ONNX", "隐私政策"} {
hits := st.Search(q, 2)
got := []string{}
for _, h := range hits {
got = append(got, h.Name)
}
fmt.Printf(" 查询「%s」→ %v\n", q, got)
}
if os.Getenv("KB_DIAG_SWEEP") != "" {
fmt.Printf("\n === 稀疏融合权重扫描(1.0 = 只用语义路,0.0 = 只用词法路)===\n")
fmt.Printf(" (无多模态稠密路接入时,本扫描直接对应历史 densePathWeight 的语义)\n")
saved := sparseSemWeight
for _, w := range []float64{1.0, 0.8, 0.7, 0.5, 0.3, 0.0} {
sparseSemWeight = w
t1, m := 0, 0.0
for _, name := range names {
hits := st.Search(name, len(names))
for i, h := range hits {
if h.Name == name {
if i == 0 {
t1++
}
m += 1.0 / float64(i+1)
break
}
}
}
fmt.Printf(" 权重 %.1f:top-1 %2d/%d = %3.0f%% MRR %.3f\n",
w, t1, len(names), 100*float64(t1)/float64(len(names)), m/float64(len(names)))
}
sparseSemWeight = saved
}
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.358)", mrr/n)
}
if rate < 18 {
t.Fatalf("退化路径检索质量又变差了:top-1 %.0f%% < 18%%(实测 24%%)", rate)
}
}
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func firstN(s []string, n int) []string {
if len(s) <= n {
return s
}
return s[:n]
}