mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 04:43:11 +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 不可逆),
冲突整批拒绝以免半迁移。
180 lines
4.3 KiB
Go
180 lines
4.3 KiB
Go
package knowledge
|
||
|
||
import (
|
||
"os"
|
||
"testing"
|
||
)
|
||
|
||
func TestNewStore(t *testing.T) {
|
||
dir, err := os.MkdirTemp("", "know_test_*")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer os.RemoveAll(dir)
|
||
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
|
||
if s == nil {
|
||
t.Fatal("store should not be nil")
|
||
}
|
||
}
|
||
|
||
func TestAddAndSearch(t *testing.T) {
|
||
dir, err := os.MkdirTemp("", "know_add_*")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer os.RemoveAll(dir)
|
||
|
||
s := NewStore(dir)
|
||
s.Start()
|
||
defer s.Stop()
|
||
|
||
if err := s.Add("coffee", "咖啡是一种饮品,含有咖啡因"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
results := s.Search("咖啡", 5)
|
||
if len(results) == 0 {
|
||
t.Fatal("expected results for '咖啡'")
|
||
}
|
||
}
|
||
|
||
// 覆盖同名条目必须把旧向量摘掉,而不是再插一份。
|
||
//
|
||
// 这条是从一次真实的知识库更新里发现的:在线上实例更新一个已有条目后,
|
||
// knowledge_count=32 但 vector_count=33 ——多出来的那一条是上一版的副本。
|
||
// 成因是 vector.Store.Insert 为追加语义(s.docs = append + index.Add),不按 id 去重。
|
||
// 危害不在于多占一份内存:检索可能命中**已被替换掉的旧内容**。
|
||
func TestAddOverwriteReplacesVector(t *testing.T) {
|
||
dir, err := os.MkdirTemp("", "know_overwrite_*")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer os.RemoveAll(dir)
|
||
|
||
s := NewStore(dir)
|
||
s.Start()
|
||
defer s.Stop()
|
||
|
||
if err := s.Add("recent", "第一版内容:旧的多模态描述式索引"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if got := s.Stats()["vector_count"].(int); got != 1 {
|
||
t.Fatalf("首次写入后 vector_count 应为 1,实为 %d", got)
|
||
}
|
||
|
||
if err := s.Add("recent", "第二版内容:媒体已成为图记忆的一等节点"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if n := s.Stats()["knowledge_count"].(int); n != 1 {
|
||
t.Fatalf("同名覆盖后 knowledge_count 应为 1,实为 %d", n)
|
||
}
|
||
if n := s.Stats()["vector_count"].(int); n != 1 {
|
||
t.Fatalf("同名覆盖后 vector_count 应为 1(多了就是旧版没被摘掉),实为 %d", n)
|
||
}
|
||
|
||
// 目录里也只应有一份内容,且是新的那份
|
||
b, err := os.ReadFile(dir + "/recent/content.md")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if string(b) != "第二版内容:媒体已成为图记忆的一等节点" {
|
||
t.Fatalf("content.md 未被新内容覆盖,实为 %q", string(b))
|
||
}
|
||
}
|
||
|
||
func TestList(t *testing.T) {
|
||
dir, err := os.MkdirTemp("", "know_list_*")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer os.RemoveAll(dir)
|
||
|
||
s := NewStore(dir)
|
||
s.Start()
|
||
defer s.Stop()
|
||
|
||
s.Add("topic1", "内容一")
|
||
s.Add("topic2", "内容二")
|
||
|
||
list := s.List()
|
||
if len(list) != 2 {
|
||
t.Errorf("expected 2 items, got %d", len(list))
|
||
}
|
||
}
|
||
|
||
func TestRemove(t *testing.T) {
|
||
dir, err := os.MkdirTemp("", "know_rm_*")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer os.RemoveAll(dir)
|
||
|
||
s := NewStore(dir)
|
||
s.Start()
|
||
defer s.Stop()
|
||
|
||
s.Add("test", "测试内容")
|
||
if err := s.Remove("test"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
list := s.List()
|
||
if len(list) != 0 {
|
||
t.Errorf("expected 0 items after remove, got %d", len(list))
|
||
}
|
||
}
|
||
|
||
// TestRemoveNotFound 原断言"删除不存在的条目不应报错",该契约已作废:
|
||
// webui 的 DELETE 处理器把 error 映射成 404,说明调用方本来就期望 ErrNotFound;
|
||
// 宽松版本只会让工具层对一次什么都没删的操作回报"已删除"。
|
||
// 新契约见 hardening_test.go 的 TestRemoveNotFound。
|
||
|
||
func TestStats(t *testing.T) {
|
||
dir, err := os.MkdirTemp("", "know_stats_*")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer os.RemoveAll(dir)
|
||
|
||
s := NewStore(dir)
|
||
s.Start()
|
||
defer s.Stop()
|
||
|
||
s.Add("a", "内容A")
|
||
s.Add("b", "内容B")
|
||
|
||
stats := s.Stats()
|
||
if stats["knowledge_count"].(int) != 2 {
|
||
t.Errorf("expected knowledge_count 2, got %v", stats["knowledge_count"])
|
||
}
|
||
if stats["vector_count"] == nil {
|
||
t.Error("expected vector_count in stats")
|
||
}
|
||
}
|
||
|
||
func TestSearchNoMatch(t *testing.T) {
|
||
dir, err := os.MkdirTemp("", "know_nomatch_*")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer os.RemoveAll(dir)
|
||
|
||
s := NewStore(dir)
|
||
s.Start()
|
||
defer s.Stop()
|
||
|
||
s.Add("math", "加减乘除是基本运算")
|
||
// "电电电电电" 中的字符 "电" 不在文档 "math 加减乘除是基本运算" 的任意 unigram 中
|
||
results := s.Search("电电电电电", 5)
|
||
if len(results) != 0 {
|
||
t.Errorf("expected 0 results for non-matching query, got %d", len(results))
|
||
}
|
||
}
|