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 不可逆),
冲突整批拒绝以免半迁移。
This commit is contained in:
JianFeeeee
2026-09-26 11:36:39 +08:00
parent b381f34259
commit e2b96e0686
12 changed files with 2651 additions and 209 deletions

View File

@ -0,0 +1,332 @@
package knowledge
import (
"errors"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// fakeMM 是可控的多模态嵌入器:文本与图片各自查表,缺省给同一个兜底向量。
// Dim/Fingerprint 可变,用来验证维度与指纹守卫。
type fakeMM struct {
dim int
fp string
text map[string][]float64
img map[string][]float64
// imgErr 按 digest 注入错误(验证 ErrModalityUnsupported 的静默跳过)
imgErr map[string]error
loaded bool
}
func (f *fakeMM) VectorizeDense(t string) ([]float64, error) {
if v, ok := f.text[t]; ok {
return v, nil
}
return f.text["__default"], nil
}
func (f *fakeMM) EmbedImageDense(img []byte, mime string) ([]float64, error) {
key := string(img)
if err, ok := f.imgErr[key]; ok {
return nil, err
}
if v, ok := f.img[key]; ok {
return v, nil
}
return f.img["__default"], nil
}
func (f *fakeMM) Fingerprint() string { return f.fp }
func (f *fakeMM) Dim() int { return f.dim }
func (f *fakeMM) Loaded() bool { return f.loaded }
func (f *fakeMM) Close() {}
// fakeMedia 是仅按 digest 查表的 MediaGetter。
type fakeMedia struct{ byDigest map[string][]byte }
func (m fakeMedia) Get(digest string) ([]byte, error) {
if b, ok := m.byDigest[digest]; ok {
return b, nil
}
return nil, errors.New("media: not found")
}
// 跨模态召回:一条知识的“图”与查询向量相近,就该被召回——即便它的正文
// 与查询毫无词面重叠。这是稠密路存在的全部理由。
func TestDenseCrossModalRecall(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
// 两个正交方向:eax 表示“猫的图”,eby 表示“别的”
eax := []float64{1, 0, 0, 0}
eby := []float64{0, 1, 0, 0}
mm := &fakeMM{
dim: 4, fp: "fake-v1", loaded: true,
text: map[string][]float64{"__default": eby, "猫 图片 说明": eby},
img: map[string][]float64{"__default": eby, "d-cat": eax},
}
s.SetDenseSpace(mm)
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-cat": []byte("d-cat")}})
if err := s.AddWithMedia("cat-photo", "猫 图片 说明", []KnowledgeMediaRef{
{Digest: "d-cat", MIME: "image/png", Kind: "image"},
}); err != nil {
t.Fatal(err)
}
if err := s.Add("other", "完全无关的正文"); err != nil {
t.Fatal(err)
}
// 查询向量直接命中“猫图”方向(模拟以图搜知识)
qv := eax
hits := s.denseHits(qv)
if len(hits) == 0 {
t.Fatal("稠密路无命中")
}
if hits[0].id != "cat-photo" {
t.Fatalf("稠密路首位应为 cat-photo,实为 %v", hits)
}
// 分数应显著高于正交基线(0.707 是文本⊕猫图两个正交方向融合的必然值,
// 不是噪声——单模态文档在同查询下只会有 ~0)
if !(hits[0].score > 0.5) {
t.Errorf("cat-photo 与查询向量应显著同向,实为 %f", hits[0].score)
}
// 正文里的“猫”字查询也应召回它(文本路 + 稠密路共同作用)
if got := s.Search("猫", 3); len(got) == 0 || got[0].Name != "cat-photo" {
t.Errorf("按正文查询应召回 cat-photo,实为 %v", namesOf(got))
}
}
// 维度守卫:同指纹但维度不同的向量必须被跳过,否则会算出错维度余弦。
func TestDenseRejectsWrongDim(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
mm := &fakeMM{dim: 4, fp: "fp", loaded: true,
text: map[string][]float64{"__default": {1, 0, 0, 0}},
img: map[string][]float64{"__default": {1, 0, 0, 0}},
}
s.SetDenseSpace(mm)
if err := s.Add("k", "正文"); err != nil {
t.Fatal(err)
}
// 手动塞一个维度不符的向量(模拟换模型后的残留)
s.mu.Lock()
s.items["k"].Dense = []float64{1, 0, 0, 0, 0, 0, 0, 0}
s.mu.Unlock()
if hits := s.denseHits([]float64{1, 0, 0, 0}); len(hits) != 0 {
t.Errorf("维度不符的条目必须被跳过,实为 %v", hits)
}
}
// 指纹守卫:模型换过后,旧向量在重算前不得参与召回。
func TestDenseRejectsStaleFingerprint(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
mm := &fakeMM{dim: 4, fp: "fp-new", loaded: true,
text: map[string][]float64{"__default": {1, 0, 0, 0}},
img: map[string][]float64{"__default": {1, 0, 0, 0}},
}
s.SetDenseSpace(mm)
if err := s.Add("k", "正文"); err != nil {
t.Fatal(err)
}
s.mu.Lock()
s.items["k"].Dense = []float64{1, 0, 0, 0}
s.items["k"].DenseFP = "fp-old"
s.mu.Unlock()
if hits := s.denseHits([]float64{1, 0, 0, 0}); len(hits) != 0 {
t.Errorf("指纹过期的条目在重算前不得参与召回,实为 %v", hits)
}
// ReindexDense 应把它修好
if built, _ := s.ReindexDense(); built != 1 {
t.Errorf("ReindexDense 应重算 1 条,实为 %d", built)
}
if hits := s.denseHits([]float64{1, 0, 0, 0}); len(hits) != 1 {
t.Errorf("重算后应可召回,实为 %v", hits)
}
}
// ReindexDense 幂等:第二次不该再做任何事。
func TestReindexDenseIdempotent(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
mm := &fakeMM{dim: 3, fp: "fp", loaded: true,
text: map[string][]float64{"__default": {1, 1, 0}},
img: map[string][]float64{"__default": {0, 1, 1}},
}
s.SetDenseSpace(mm)
for _, n := range []string{"a", "b", "c"} {
if err := s.Add(n, "正文"+n); err != nil {
t.Fatal(err)
}
}
// Add 已当场算过,故首次 Reindex 应为 0 新建
if built, _ := s.ReindexDense(); built != 0 {
t.Errorf("Add 已算过稠密向量,Reindex 应为 0,实为 %d", built)
}
if built, _ := s.ReindexDense(); built != 0 {
t.Errorf("重复 Reindex 应幂等,实为 %d", built)
}
}
// 媒体模态不受支持时必须**静默跳过该媒体**,但整条知识的文本向量仍要成立。
func TestDenseUnsupportedModalityStillTextEmbedded(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
etext := []float64{1, 0}
mm := &fakeMM{
dim: 2, fp: "fp", loaded: true,
text: map[string][]float64{"__default": etext},
img: map[string][]float64{"__default": {0, 1}},
imgErr: map[string]error{"d-audio": vector.ErrModalityUnsupported},
}
s.SetDenseSpace(mm)
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-audio": []byte("d-audio")}})
if err := s.AddWithMedia("song", "一首歌", []KnowledgeMediaRef{{Digest: "d-audio", MIME: "audio/mpeg"}}); err != nil {
t.Fatal(err)
}
k := s.items["song"]
if k == nil {
t.Fatal("条目未写入")
}
if len(k.Dense) != 2 {
t.Fatalf("音频不支持时应退化为纯文本向量,实为 %v", k.Dense)
}
// 纯文本向量应与文本方向同向(没被音频的错向量污染)
if k.Dense[0] <= 0 {
t.Errorf("文本向量方向被污染:%v", k.Dense)
}
}
// 未注入稠密空间时,行为必须与加入稠密路之前逐字一致。
func TestNoDenseSpaceKeepsLegacyBehavior(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
if s.denseEnabled() {
t.Fatal("未注入时稠密路应为禁用")
}
for _, n := range []string{"coffee", "sleep", "arch"} {
if err := s.Add(n, "正文 "+n); err != nil {
t.Fatal(err)
}
}
// 稀疏两路仍要工作
if got := s.Search("coffee", 3); len(got) == 0 || got[0].Name != "coffee" {
t.Errorf("无稠密路时稀疏两路应正常召回,实为 %v", namesOf(got))
}
// Add 不应试图算稠密向量
for _, k := range s.items {
if len(k.Dense) != 0 {
t.Errorf("无稠密路时不应产生稠密向量:%s", k.Name)
}
}
if st := s.DenseStats(); st["enabled"] != false {
t.Errorf("DenseStats 应报告未启用,实为 %v", st)
}
}
// AttachMedia 挂上媒体后必须当场重算稠密向量(否则要等下次 Reindex)。
func TestAttachMediaRecomputesDense(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
eax := []float64{1, 0}
mm := &fakeMM{dim: 2, fp: "fp", loaded: true,
text: map[string][]float64{"__default": {0, 1}},
img: map[string][]float64{"__default": {0, 1}, "d-x": eax},
}
s.SetDenseSpace(mm)
s.SetMediaGetter(fakeMedia{byDigest: map[string][]byte{"d-x": []byte("d-x")}})
if err := s.Add("k", "正文"); err != nil {
t.Fatal(err)
}
before := append([]float64{}, s.items["k"].Dense...)
if err := s.AttachMedia("k", KnowledgeMediaRef{Digest: "d-x", MIME: "image/png"}); err != nil {
t.Fatal(err)
}
after := s.items["k"].Dense
if len(after) != 2 {
t.Fatalf("AttachMedia 后应有 2 维稠密向量,实为 %v", after)
}
// 融合了 eax 后方向应偏向第一维,与 before 不同
if before[0] == after[0] && before[1] == after[1] {
t.Errorf("AttachMedia 未重算稠密向量:%v", after)
}
}
// DenseStats 的计数应与实际状态一致。
func TestDenseStatsCounts(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": {1, 0}},
img: map[string][]float64{"__default": {1, 0}},
}
s.SetDenseSpace(mm)
if err := s.Add("a", "A"); err != nil {
t.Fatal(err)
}
if err := s.Add("b", "B"); err != nil {
t.Fatal(err)
}
s.mu.Lock()
s.items["b"].DenseFP = "stale"
s.mu.Unlock()
st := s.DenseStats()
if st["ready"] != 1 || st["stale"] != 1 {
t.Errorf("DenseStats 应为 ready=1 stale=1,实为 %v", st)
}
if st["dim"] != 2 || st["fingerprint"] != "fp" {
t.Errorf("DenseStats 维度/指纹不符:%v", st)
}
}
func namesOf(ks []*Knowledge) []string {
out := make([]string, len(ks))
for i, k := range ks {
out[i] = k.Name
}
return out
}