mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
追「实例看起来没更新」时发现知识库检索本身也不可信,先把病因查清再动手: - **两段式召回不是瓶颈**:Store 的结果与全量暴力 cosine 完全一致; - **真因是向量没有区分度**:词向量取平均后各向异性明显,真实 KB(33 条)上自检索 top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声; - 且全为停用词的查询会得到**空向量**("最近更新"),直接搜不出任何东西。 先在真实数据上把候选方案量了一遍(用自检索 top-1 / MRR)再动手:IDF 维度加权零收益、 去均值反而更差,**都不做**;唯一有收益的是与词法路(TF-IDF)融合。 改动: - `Store` 增设词法路索引,`Search` 融合两路:各自按**查询内最大值**归一化后加权。 权重 0.5 由权重扫描定:1.0(旧行为)MRR 0.271 / 0.8→0.354 / 0.7→0.358 / **0.5→0.376** / 0.3→0.336 / 0.0→0.307;语义查询也从"全是 openharmony 噪声"变成命中正确条目 (「首启人格门禁」→changelog_v1.2.1、「插件怎么开发和部署」→plugin_dev_build); - `vector.Store` 的候选中选阈值改为**可设**(默认 0.05 保持既有行为):TF-IDF 余弦量级 只有 0.0~0.2,沿用 0.05 会把词法路有效候选**静默砍掉**——这一条正是 0.376→0.197 的 差距来源,且当时没有任何报错; - Add/Remove/scanAll/ReindexWithVectorizer 同步维护两路;分数相同时按名字定序(结果可重复)。 **顺带修一个真实毛病**:Add/Remove 原先用**无追踪的 goroutine** 写索引(因为 writeIndex→BuildTree 会 RLock,而调用方持写锁,同步调用会死锁)→ 失败只打日志, 且与调用方竞态(测试的临时目录清理就撞上了)。改为持锁就地 flush (buildTreeLocked / writeIndexLocked)。 判据(不依赖人工标注问答对):新增 `internal/knowledge/rankdiag_test.go`,用**自检索 top-1 / MRR** 量区分度,`KB_DIAG=1` 跑、`KB_DIAG_ASSERT=1` 断言(MRR ≥ 0.34)。 另有不依赖真实数据的单测 6 条(空稠密向量靠词法路救回、稠密并列时词法路定序、 词法路阈值接线、Add/Remove 双路一致、并列时确定性、空库不 panic)。 **反向验证**(证明判据真能发现缺陷):权重退回 1.0、词法路阈值改回 0.05、 把阈值写死回 0.05 —— 对应测试逐条变红。另:我第一版夹具余弦 0.365/0.273 远高于阈值, 注入缺陷也不报错(等于没验),故加了「夹具前提」断言并改成两层判据 (语义层由 vector 包测试证明、接线层由知识库测试钉住)。 顺带纳入上一轮漏提交的 `TestAddOverwriteReplacesVector`(同名覆盖必须摘掉旧向量, 生产改动当时已提交,测试一直未入库)。
155 lines
4.4 KiB
Go
155 lines
4.4 KiB
Go
package knowledge
|
||
|
||
import (
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"gitcode.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」的原因:不依赖人工标注问答对,且能直接量出
|
||
// **区分度**——词向量取平均后所有文档挤在语料均值附近,前两名分差极小,
|
||
// 排序等于噪声;这一项掉下来就说明检索坏了。
|
||
//
|
||
// 实测(33 条真实 KB):
|
||
//
|
||
// 修复前(仅稠密路) 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(留出余量,只挡「退化回噪声」)
|
||
func TestRankingQualityOnRealKB(t *testing.T) {
|
||
if os.Getenv("KB_DIAG") == "" {
|
||
t.Skip("需要 KB_DIAG=1(真实 KB + 词向量文件)")
|
||
}
|
||
srcRoot := 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")
|
||
saved := densePathWeight
|
||
for _, w := range []float64{1.0, 0.8, 0.7, 0.5, 0.3, 0.0} {
|
||
densePathWeight = 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)))
|
||
}
|
||
densePathWeight = saved
|
||
}
|
||
|
||
if os.Getenv("KB_DIAG_ASSERT") != "" {
|
||
if mrr/n < 0.34 {
|
||
t.Fatalf("检索质量退化:MRR %.3f < 0.34(修复前 0.271,修复后 0.376)", mrr/n)
|
||
}
|
||
if rate < 18 {
|
||
t.Fatalf("检索质量退化:top-1 %.0f%% < 18%%", 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]
|
||
}
|