mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
按「完全改用向量方案」的要求,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。
167 lines
5.3 KiB
Go
167 lines
5.3 KiB
Go
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]
|
||
}
|