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 6c33bfdb82
commit 9f2ec31cb0
12 changed files with 2651 additions and 209 deletions

View File

@ -0,0 +1,252 @@
package knowledge
import (
"fmt"
"strings"
"testing"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector"
)
// 运行时新增的知识必须**当场**可检索,不依赖重启。
//
// 这是一次真实功能缺陷:TFIDFVectorizer.Vectorize 会跳过 df<=0 的特征,
// 而 Add 此前只把文本追进一个 summaries 切片、不更新 DF。于是
// 「重启后(已 Train 过 ≥3 篇)→ 新增一条含全新词的知识 → 查它」
// 返回空结果,重启一次才恢复。实测曾得到 Search("量子纠缠") == []。
//
// 之所以容易漏:全新 Store 语料不足 3 篇时 Vectorize 走
// 「totalDocs < 3 不乘 IDF」的退化分支,新词照样能搜到 —— 缺陷只在
// 「库已满、且用的是全新词」时显形。
func TestNewTermSearchableImmediatelyAfterAdd(t *testing.T) {
dir := t.TempDir()
// 先用 5 篇把语料喂到 totalDocs >= 3(走真实 IDF 分支)
{
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
for i := 0; i < 5; i++ {
if err := s.Add(fmt.Sprintf("旧知识%d", i), "咖啡 睡眠 架构 记忆 插件 内核 事件 总线 索引"); err != nil {
t.Fatal(err)
}
}
s.Stop()
}
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
// 运行时新增一条含**全新词**的知识
if err := s.Add("新知识", "量子纠缠 拓扑绝缘体 简并态 莫尔条纹"); err != nil {
t.Fatal(err)
}
q := "量子纠缠"
var names []string
for _, k := range s.Search(q, 5) {
names = append(names, k.Name)
}
if !contains(names, "新知识") {
t.Fatalf("Add 后新知识应立刻可检索(query=%q),实得 %v —— IDF 未随写入更新", q, names)
}
// 新词向量不应为空(空 = 被 df<=0 过滤掉)
v := s.veczer.Vectorize(q)
if len(v) == 0 {
t.Errorf("新词向量为空:df=0 被过滤,query=%q", q)
}
// 另一路(稀疏语义路)也应能命中
if got := s.vec.SearchScored(s.vectorize(q), s.vec.Size()); len(got) == 0 {
t.Logf("注:稀疏语义路对 %q 无命中(取决于是否注入了词向量),不影响本用例结论", q)
}
}
// 覆盖写同名条目时,IDF 不能把同一篇重复计入,否则 df 虚高、IDF 虚低。
func TestOverwriteDoesNotDoubleCountDF(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 < 5; i++ {
if err := s.Add(fmt.Sprintf("k%d", i), "共同词"+strings.Repeat("x", i)); err != nil {
t.Fatal(err)
}
}
// 记录覆盖前 totalDocs 语料状态
before := len(s.List())
if err := s.Add("k0", "改写后的内容 独特词9z"); err != nil {
t.Fatal(err)
}
if after := len(s.List()); after != before {
t.Fatalf("覆盖写不应改变条目数:%d → %d", before, after)
}
// 旧内容里的 "共同词" 不应因为被覆盖而消失(旧版正文里也有它?不,
// 这里断言的是:新版内容里的词能被搜到,即 DF 已切到新版)
if !s.hasLexHit("k0", "独特词9z") {
t.Error("覆盖写后新版内容的词应可检索")
}
}
// 删除条目后,IDF 语料必须同步收缩(否则 DF 表与真实条目脱钩,越用越偏)。
func TestRemoveShrinksIDFCorpus(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
// 造 3 篇共享词,删掉其中唯一含某词的篇
for i := 0; i < 3; i++ {
body := "共享词"
if i == 0 {
body += " 独占词zzz"
}
if err := s.Add(fmt.Sprintf("k%d", i), body); err != nil {
t.Fatal(err)
}
}
// 未删前,k0 在词法路里
if !s.hasLexHit("k0", "独占词zzz") {
t.Fatal("前置条件不成立:k0 应命中独占词")
}
if err := s.Remove("k0"); err != nil {
t.Fatal(err)
}
// 删除后,k0 不得再出现在任何一路
if s.hasLexHit("k0", "共享词") {
t.Error("已删除条目仍在词法路索引里")
}
// 剩余条目的检索必须仍工作(IDF 没被清成空)
if got := s.Search("共享词", 5); len(got) != 2 {
t.Errorf("删除后其余条目应仍可检索 2 条,实为 %d", len(got))
}
}
// 词法路与 IDF 必须始终以 items 的**规范名**为准,与重启后的重建一致。
// 否则运行时增量维护的 DF 与重启后 Train 的 DF 不同,IDF 悄悄漂移。
func TestLexTextUsesCanonicalName(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
// 用带空格/大写的名字,看 DF 记账用的是不是规范名
if err := s.Add("Tech/Upper", "共享语料词"); err != nil {
t.Fatal(err)
}
if err := s.Add("b", "共享语料词"); err != nil {
t.Fatal(err)
}
if err := s.Add("c", "共享语料词"); err != nil {
t.Fatal(err)
}
// 覆盖写:规范名一致才能正确 RemoveDoc 旧文本
if err := s.Add("Tech/Upper", "改写 共享语料词"); err != nil {
t.Fatal(err)
}
s.Stop()
// 重启后应与运行时增量维护的 DF 数值一致(若不一致,说明 lexText 口径漂了)
s2 := NewStore(dir)
if err := s2.Start(); err != nil {
t.Fatal(err)
}
defer s2.Stop()
// 三篇都含"共享语料词" ⇒ df=3 ⇒ IDF = log((3+1)/(3+1)) = 0 ⇒ 被丢弃
// (与删改无关,是 IDF 本身的定义)。改为查一个只在一篇里出现的词。
if err := s2.Add("d", "绝无仅有词qqq"); err != nil {
t.Fatal(err)
}
if !s2.hasLexHit("d", "绝无仅有词qqq") {
t.Error("重启后新词仍不可检索,IDF 记账有问题")
}
}
// 大量增删后,词法路索引与 items 数量必须始终一致(不漏删、不留孤儿)。
func TestLexIndexStaysInSyncWithItems(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 < 30; i++ {
if err := s.Add(fmt.Sprintf("k%02d", i), fmt.Sprintf("内容 %d", i)); err != nil {
t.Fatal(err)
}
}
// 删一半
for i := 0; i < 30; i += 2 {
if err := s.Remove(fmt.Sprintf("k%02d", i)); err != nil {
t.Fatal(err)
}
}
if lexN, itemN := s.lex.Size(), len(s.items); lexN != itemN {
t.Errorf("词法路索引与条目数不一致:lex=%d items=%d", lexN, itemN)
}
}
// hasLexHit 报某条目在词法路里是否含有给定词的向量。
func (s *Store) hasLexHit(id, probe string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
for _, h := range s.lex.SearchScored(s.veczer.Vectorize(probe), s.lex.Size()) {
if h.Doc.ID == id && h.Score > 0 {
return true
}
}
return false
}
func contains(xs []string, want string) bool {
for _, x := range xs {
if x == want {
return true
}
}
return false
}
// TFIDFVectorizer 的增量接口本身也要守规矩:AddDoc/RemoveDoc 必须与
// Train 给出**相同**的 DF(文档级去重),否则两条路径会漂移。
func TestTFIDFIncrementalMatchesTrain(t *testing.T) {
tok := func(s string) []string { return strings.Fields(s) }
full := vector.NewTFIDFVectorizer(tok)
full.Train([]string{"a b c", "b c d", "c d e"})
inc := vector.NewTFIDFVectorizer(tok)
inc.AddDoc("a b c")
inc.AddDoc("b c d")
inc.AddDoc("c d e")
for _, probe := range []string{"a", "b", "c", "d", "e"} {
fv, iv := full.Vectorize(probe), inc.Vectorize(probe)
if len(fv) != len(iv) {
t.Errorf("探针 %q:Train 得 %d 维,AddDoc 得 %d 维(DF 不一致)", probe, len(fv), len(iv))
continue
}
for f, w := range fv {
if diff := w - iv[f]; diff > 1e-9 || diff < -1e-9 {
t.Errorf("探针 %q 特征 %q 权重不一致:Train=%g AddDoc=%g", probe, f, w, iv[f])
}
}
}
// RemoveDoc 后 totalDocs 不得为负
inc.RemoveDoc("a b c")
inc.RemoveDoc("a b c")
inc.RemoveDoc("a b c")
inc.AddDoc("x y")
if got := inc.Vectorize("x"); len(got) == 0 {
t.Error("totalDocs 变负后 Vectorize 行为异常")
}
}