Files
HomeAgent/internal/knowledge/category_test.go
JianFeeeee 9f2ec31cb0 fix(knowledge): 名称规范化/路径安全 + IDF 增量维护 + 多模态稠密路 + 派生数据落盘
一次知识库子系统的集中加固,四类缺陷各有实测复现:

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 不可逆),
冲突整批拒绝以免半迁移。
2026-09-26 14:20:18 +08:00

193 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}