mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +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 不可逆),
冲突整批拒绝以免半迁移。
193 lines
6.0 KiB
Go
193 lines
6.0 KiB
Go
package knowledge
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// 分层必须真正参与召回:SearchIn 把范围限定在分类子树内。
|
||
//
|
||
// 此前分层只是**存储布局**——检索一律全库平铺,而 SearchTree /
|
||
// SearchCategories 两个想按分类聚合的函数是死代码(且停留在 Search 修复
|
||
// 前的单路口径)。分类存在却对召回零影响。
|
||
func TestSearchInCategoryScope(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
for _, e := range []struct{ name, body string }{
|
||
{"tech/go/并发", "goroutine 调度 GMP 抢占 通道"},
|
||
{"tech/rust/所有权", "borrow checker move 语义"},
|
||
{"life/sleep", "作息 褪黑素 深睡 咖啡因"},
|
||
{"cook/coffee", "手冲 烘焙 水温 粉水比"},
|
||
} {
|
||
if err := s.Add(e.name, e.body); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// 全库:能跨分类召回
|
||
if got := s.Search("调度 语义 作息 手冲", 10); len(got) < 4 {
|
||
t.Fatalf("全库检索应召回全部 4 条,实为 %v", namesOf(got))
|
||
}
|
||
|
||
// 限定 tech:只剩 tech 子树两条
|
||
got := s.SearchIn("调度 语义 作息 手冲", "tech", 10)
|
||
if len(got) != 2 {
|
||
t.Fatalf("限定 tech 应命中 2 条,实为 %v", namesOf(got))
|
||
}
|
||
for _, k := range got {
|
||
if !hasPrefix(k.Name, "tech/") {
|
||
t.Errorf("限定 tech 却返回了 %q", k.Name)
|
||
}
|
||
}
|
||
|
||
// 前缀匹配整棵子树:查 "tech/go" 只命中其下
|
||
if got := s.SearchIn("调度 语义 作息 手冲", "tech/go", 10); len(got) != 1 || got[0].Name != "tech/go/并发" {
|
||
t.Errorf("限定 tech/go 应只命中并发,实为 %v", namesOf(got))
|
||
}
|
||
|
||
// 不存在的分类:空结果,且不报错
|
||
if got := s.SearchIn("调度", "no/such/cat", 5); len(got) != 0 {
|
||
t.Errorf("不存在的分类应返回空,实为 %v", namesOf(got))
|
||
}
|
||
|
||
// 空 category ≡ 全库(与 Search 等价)
|
||
a, b := s.Search("调度 语义", 10), s.SearchIn("调度 语义", "", 10)
|
||
if strings.Join(namesOf(a), ",") != strings.Join(namesOf(b), ",") {
|
||
t.Errorf("空 category 应等价于全库:%v vs %v", namesOf(a), namesOf(b))
|
||
}
|
||
// 前后斜杠不应影响(界面上很容易带上)
|
||
for _, c := range []string{"/tech", "tech/", "/tech/"} {
|
||
if got := s.SearchIn("调度 语义 作息 手冲", c, 10); len(got) != 2 {
|
||
t.Errorf("category=%q 应归一化后命中 2 条,实为 %v", c, namesOf(got))
|
||
}
|
||
}
|
||
}
|
||
|
||
// 分类过滤下,归一化必须取**作用域内**的最大值。
|
||
//
|
||
// 否则:作用域内只有一条弱命中,范围外却有个强命中把全局最大值拉高,
|
||
// 作用域内的分数被压到接近 0,排序与阈值语义全失真。
|
||
func TestSearchInNormalizesWithinScope(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
// 故意让范围外的条目在词法路上分数更高
|
||
if err := s.Add("outside", "zzzz 罕见词zzz 独有"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("cat/in", "zebra 条目"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("cat/in2", "zebra 条目"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("cat/in3", "zebra 条目"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// 全库时 outside 应因独有词而排前
|
||
if got := s.Search("罕见词zzz", 4); len(got) == 0 || got[0].Name != "outside" {
|
||
t.Logf("注:全库首位为 %v(稀疏语义路可能改写排序),仅作观察", namesOf(got))
|
||
}
|
||
// 限定 cat:outside 必须被排除,且 cat 下的条目仍要正常返回(分数不能被压成 0)
|
||
got := s.SearchIn("zebra", "cat", 4)
|
||
if len(got) != 3 {
|
||
t.Fatalf("限定 cat 应返回 3 条,实为 %v", namesOf(got))
|
||
}
|
||
for _, k := range got {
|
||
if k.Name == "outside" {
|
||
t.Error("作用域过滤失效:范围外条目被返回")
|
||
}
|
||
}
|
||
// 关键:范围外的强信号不得把作用域内的分数压掉——
|
||
// 三个 cat 条目必须都在(而不是只留 0 个)
|
||
if len(got) == 0 {
|
||
t.Error("作用域内分数被范围外最大值压没了")
|
||
}
|
||
}
|
||
|
||
// 分类过滤对稠密路同样生效(不能只过滤稀疏两路)。
|
||
func TestSearchInFiltersDensePath(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
mm := &fakeMM{dim: 2, fp: "fp", loaded: true,
|
||
text: map[string][]float64{"__default": {0, 1}},
|
||
img: map[string][]float64{"__default": {0, 1}},
|
||
}
|
||
s.SetDenseSpace(mm)
|
||
if err := s.Add("cat/a", "甲"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("other/b", "乙"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
qv := []float64{0, 1}
|
||
if hits := s.denseHits(qv); len(hits) != 2 {
|
||
t.Fatalf("稠密路应命中 2 条,实为 %d", len(hits))
|
||
}
|
||
// SearchIn 走真实查询路径,验证范围外那条被排除
|
||
if got := s.SearchIn("甲乙", "cat", 5); len(got) != 1 || got[0].Name != "cat/a" {
|
||
t.Errorf("稠密路未被分类过滤,实为 %v", namesOf(got))
|
||
}
|
||
}
|
||
|
||
// 性能:分类过滤不应拖慢全库检索(早退守卫 + 作用域判定开销可忽略)。
|
||
func TestSearchInOverhead(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
for i := 0; i < 200; i++ {
|
||
if err := s.Add(catName(i), "内容 关键词 内容"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
q := "关键词 内容"
|
||
// 预热
|
||
for i := 0; i < 20; i++ {
|
||
s.Search(q, 5)
|
||
s.SearchIn(q, "g0", 5)
|
||
}
|
||
start := time.Now()
|
||
for i := 0; i < 50; i++ {
|
||
s.Search(q, 5)
|
||
}
|
||
full := time.Since(start)
|
||
start = time.Now()
|
||
for i := 0; i < 50; i++ {
|
||
s.SearchIn(q, "g0", 5)
|
||
}
|
||
scoped := time.Since(start)
|
||
t.Logf("50 次:全库 %v 限定 g0 %v", full.Round(time.Microsecond), scoped.Round(time.Microsecond))
|
||
// 限定范围命中数更少,理应更快;即便持平也不该慢太多
|
||
if scoped > full*3 {
|
||
t.Errorf("分类过滤带来 %0.1f× 开销(全库 %v → 限定 %v)",
|
||
float64(scoped)/float64(full), full, scoped)
|
||
}
|
||
}
|
||
|
||
func catName(i int) string {
|
||
return "g" + string(rune('0'+i/100)) + "/item" + string(rune('a'+i/10)) + string(rune('0'+i%10))
|
||
}
|
||
|
||
func hasPrefix(s, p string) bool {
|
||
return len(s) >= len(p) && s[:len(p)] == p
|
||
}
|