mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +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 不可逆),
冲突整批拒绝以免半迁移。
253 lines
7.7 KiB
Go
253 lines
7.7 KiB
Go
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 行为异常")
|
||
}
|
||
}
|