mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
一次知识库子系统的集中加固,四类缺陷各有实测复现:
1. 名称与路径(数据安全,最严重)
- sanitize 不过滤 .. ⇒ Remove("..") 直接 RemoveAll 掉整个数据目录
(实测把 <data> 整棵删掉,含 memory/documents/media),且返回 nil,
工具层回报"已删除";Add("../../x") 写到知识根外,重启扫不回来
⇒ 幽灵条目。
- Add 整串 sanitize 而建目录逐段 sanitize,内存键与盘上目录从**第一次
落盘起**就不一致;重启后 name 漂移,knowledge_delete 静默删不掉
(RemoveAll 删空目录返 nil)。同一个根因。
- 修法:新增 normalizeName 作为唯一入口(逐段 + 拒绝空段/点段/隐藏段);
Remove 改为取条目自记的 Path(不再用名字重拼)+ 返回 ErrNotFound。
- resolve 三层退让(原样 → 规范名 → 叶名大小写不敏感,唯一命中才接受):
scanDir 按盘上目录原样建键,遗留大写目录若只查规范名会变成
"List 看得到、Remove 报不存在"。删的路径仍取自 Path,退让无风险。
2. IDF 与索引不同步(功能缺陷,非优化)
- TFIDF Vectorize 跳过 df<=0 的特征,而 Add 只往只增不减的 summaries
追文本、从不更新 DF ⇒ 库满(≥3篇) + 新词时,新知识**当场搜不到**,
重启才恢复(实测 Search("量子纠缠") == [])。
- 修法:vector.Store 新增 AddDoc/RemoveDoc(文档级去重口径与 Train 一致,
totalDocs 下界守卫,零频 DF 删除防表膨胀);knowledge 删掉 summaries,
改 index/unindex/retrain 三件套,覆盖写先 RemoveDoc 旧文本。
3. 多模态稠密路(此前知识库端到端纯文本)
- 新增 SetDenseSpace/SetMediaGetter/ReindexDense/DenseStats 与
AddWithMedia/AttachMedia,媒体成为一等节点参与跨模态召回。
- 维度与指纹双守卫:维度不符的向量会被 FuseVectors 按最大维度拼成错维度
结果且被当成"已对齐"永久错下去(docStore 踩过);模态不支持(音频)时
静默跳过该媒体、退化为纯文本向量,绝不拿别的模型的向量顶替。
- 未注入多模态空间时行为与此前逐字一致(退化为 0.5/0.5 两路融合)。
4. 派生数据落盘 + 分层参与召回
- 媒体引用是**作者数据**(丢失即丢信息)→ 条目目录内 .media.json;
稠密向量是**派生数据**(可重算)→ 全局 .dense.json,tmp+rename 原子。
混存会让派生数据损坏连带作者数据一起丢。
- 新增 SearchIn(query, category, topK):分类前缀匹配子树,让分层真正
参与召回(此前检索全库平铺,分层只是存储布局)。归一化取作用域内
最大值,否则范围外的强命中会把域内分数压没。
- 删除 SearchTree/SearchCategories 死代码(零调用方,且停留在 Search
修复前的单路口径:无词法融合、0.05 阈值)。
顺带修掉 Add 的 O(N)/写:buildTreeLocked 逐条重算向量(s.vec 里已有)
改为一次建表复用;.index.json 改为标脏 + Flush/Stop 收口。实测单条 Add
2.1ms@50 → 13.7ms@400 压平到 ~400µs(34×)。
存量目录改名迁移落在 migrate_names.go,只报告不改名(os.Rename 不可逆),
冲突整批拒绝以免半迁移。
156 lines
4.5 KiB
Go
156 lines
4.5 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")
|
||
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") != "" {
|
||
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]
|
||
}
|