mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +00:00
fix(knowledge): 知识库检索改为「稠密 + 词法」两路融合(真实 KB 自检索 MRR 0.271→0.376)
追「实例看起来没更新」时发现知识库检索本身也不可信,先把病因查清再动手: - **两段式召回不是瓶颈**: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`(同名覆盖必须摘掉旧向量, 生产改动当时已提交,测试一直未入库)。
This commit is contained in:
154
internal/knowledge/rankdiag_test.go
Normal file
154
internal/knowledge/rankdiag_test.go
Normal file
@ -0,0 +1,154 @@
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user