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,135 @@
package knowledge
import (
"os"
"path/filepath"
"testing"
)
// 稠密向量缓存:第二次启动不得重算。
func TestDenseCacheAvoidsRecompute(t *testing.T) {
dir := t.TempDir()
mm := &fakeMM{dim: 3, fp: "fp-x", loaded: true,
text: map[string][]float64{"__default": {1, 1, 0}},
img: map[string][]float64{"__default": {0, 1, 1}},
}
s := NewStore(dir)
_ = s.Start()
s.SetDenseSpace(mm)
for _, n := range []string{"a", "b", "c"} {
_ = s.Add(n, "正文"+n)
}
if built, _ := s.ReindexDense(); built != 0 {
t.Fatalf("Add 已算过,首次 Reindex 应为 0,实为 %d", built)
}
fi, err := os.Stat(filepath.Join(dir, ".dense.json"))
if err != nil {
t.Fatalf("稠密缓存未落盘: %v", err)
}
t.Logf(".dense.json = %d 字节", fi.Size())
s.Stop()
// 重启:条目 + 缓存都在,Reindex 不该新建任何向量
s2 := NewStore(dir)
_ = s2.Start()
s2.SetDenseSpace(mm)
s2.SetMediaGetter(fakeMedia{})
for _, id := range s2.List() {
if len(s2.items[id].Dense) == 0 {
t.Fatalf("重启后 %s 未从缓存恢复稠密向量", id)
}
}
if built, _ := s2.ReindexDense(); built != 0 {
t.Errorf("重启后应命中缓存(built 应为 0),实为 %d", built)
}
s2.Stop()
}
// 换模型/维度后缓存必须整体作废并重算。
func TestDenseCacheInvalidatedOnModelChange(t *testing.T) {
dir := t.TempDir()
old := &fakeMM{dim: 3, fp: "fp-old", loaded: true,
text: map[string][]float64{"__default": {1, 1, 0}},
img: map[string][]float64{"__default": {0, 1, 1}},
}
s := NewStore(dir)
_ = s.Start()
s.SetDenseSpace(old)
_ = s.Add("a", "A")
s.Stop()
// 新空间:不同 fp 与维度
newMM := &fakeMM{dim: 5, fp: "fp-new", loaded: true,
text: map[string][]float64{"__default": {1, 1, 1, 0, 0}},
img: map[string][]float64{"__default": {0, 1, 1, 0, 0}},
}
s2 := NewStore(dir)
_ = s2.Start()
s2.SetDenseSpace(newMM)
defer s2.Stop()
if len(s2.items["a"].Dense) != 0 {
t.Error("旧空间的缓存不得被新空间采用")
}
if built, _ := s2.ReindexDense(); built != 1 {
t.Errorf("换空间后应重算 1 条,实为 %d", built)
}
if len(s2.items["a"].Dense) != 5 {
t.Errorf("重算后应为 5 维,实为 %d", len(s2.items["a"].Dense))
}
}
// 媒体引用必须跨重启存活(此前只在内存,重启即静默丢失)。
func TestMediaRefsSurviveRestart(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
_ = s.Start()
if err := s.AddWithMedia("cat", "猫的图", []KnowledgeMediaRef{
{Digest: "d1", MIME: "image/png", Kind: "image"},
}); err != nil {
t.Fatal(err)
}
s.Stop()
s2 := NewStore(dir)
_ = s2.Start()
defer s2.Stop()
k := s2.items["cat"]
if k == nil {
t.Fatalf("条目未载入,实为 %v", s2.List())
}
if len(k.Media) != 1 || k.Media[0].Digest != "d1" {
t.Fatalf("媒体引用未跨重启存活:%+v", k.Media)
}
}
// AttachMedia 新挂的媒体也必须落盘。
func TestAttachMediaPersists(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
_ = s.Start()
_ = s.Add("k", "正文")
if err := s.AttachMedia("k", KnowledgeMediaRef{Digest: "d9", MIME: "image/jpeg"}); err != nil {
t.Fatal(err)
}
s.Stop()
s2 := NewStore(dir)
_ = s2.Start()
defer s2.Stop()
if got := s2.items["k"].Media; len(got) != 1 || got[0].Digest != "d9" {
t.Fatalf("AttachMedia 未落盘:%+v", got)
}
}
// 无媒体的条目不应产生 .media.json 残留。
func TestNoMediaSidecarForPlainEntry(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
_ = s.Start()
defer s.Stop()
_ = s.Add("plain", "正文")
if _, err := os.Stat(filepath.Join(dir, "plain", mediaSidecarName)); err == nil {
t.Errorf("纯文本条目不该有 %s", mediaSidecarName)
}
}