mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 04:43:11 +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 不可逆),
冲突整批拒绝以免半迁移。
126 lines
3.3 KiB
Go
126 lines
3.3 KiB
Go
package knowledge
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// Add 不得随库规模线性变慢。
|
||
//
|
||
// 修复前单条 Add 的耗时曲线是 2.1ms@50 → 7.2ms@200 → 13.7ms@400(O(N)/写),
|
||
// 两个成因:
|
||
// 1. buildTreeLocked 对每条调 s.vectorize() 重算向量,而 s.vec 里已经有
|
||
// 现成的同一个向量(分词 + TF-IDF 加权,白算一遍)。
|
||
// 2. 每次 Add/Remove 都全量重写 .index.json(整棵树 JSON 序列化)。
|
||
//
|
||
// 现在改为:复用 s.vec 的向量 + 索引标脏延迟到 Flush/Stop。
|
||
func TestAddDoesNotScaleWithLibrarySize(t *testing.T) {
|
||
body := strings.Repeat("知识库条目内容,用于压测写入路径的开销。", 40)
|
||
per := func(n int) time.Duration {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer s.Stop()
|
||
start := time.Now()
|
||
for i := 0; i < n; i++ {
|
||
if err := s.Add(fmt.Sprintf("条目%04d", i), body); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
return time.Since(start) / time.Duration(n)
|
||
}
|
||
|
||
// 预热,避免首次训练分词器的开销计入小规模那一次
|
||
_ = per(20)
|
||
|
||
small := per(50)
|
||
large := per(400)
|
||
t.Logf("单条 Add 均耗时: N=50 %v N=400 %v", small.Round(time.Microsecond), large.Round(time.Microsecond))
|
||
|
||
// 允许 4 倍余量:机器噪声、GC、以及未来合理的小幅回退都不该卡住这条。
|
||
// 修复前是 6.5 倍(2.1ms → 13.7ms),会稳稳越界。
|
||
if large > small*4 {
|
||
t.Errorf("单条 Add 耗时随库规模放大过多:N=50 %v → N=400 %v(%0.1f×)",
|
||
small.Round(time.Microsecond), large.Round(time.Microsecond),
|
||
float64(large)/float64(small))
|
||
}
|
||
}
|
||
|
||
// 索引必须最终落盘(延迟不等于丢失)。
|
||
func TestIndexEventuallyPersistedOnFlush(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 延迟窗口内:Add 之后立刻看,不该有更新的索引内容
|
||
if err := s.Add("later", "正文"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Flush(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
data, err := readFileString(dir + "/.index.json")
|
||
if err != nil {
|
||
t.Fatalf("Flush 后索引未落盘: %v", err)
|
||
}
|
||
if !strings.Contains(data, "later") {
|
||
t.Errorf("索引内容不含新增条目:%s", truncForLog(data))
|
||
}
|
||
|
||
// 二次 Flush 无脏可写时不应报错(幂等)
|
||
if err := s.Flush(); err != nil {
|
||
t.Errorf("重复 Flush 应幂等,实为 %v", err)
|
||
}
|
||
s.Stop()
|
||
}
|
||
|
||
// Remove 之后索引同样要能被 Flush 收口。
|
||
func TestIndexPersistedAfterRemove(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s := NewStore(dir)
|
||
if err := s.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("gone", "将被删除"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Add("kept", "保留"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Remove("gone"); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := s.Flush(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
data, err := readFileString(dir + "/.index.json")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(data, `"name": "gone"`) {
|
||
t.Error("已删除条目仍在索引里")
|
||
}
|
||
if !strings.Contains(data, "kept") {
|
||
t.Error("保留条目不在索引里")
|
||
}
|
||
s.Stop()
|
||
}
|
||
|
||
func readFileString(p string) (string, error) {
|
||
b, err := os.ReadFile(p)
|
||
return string(b), err
|
||
}
|
||
|
||
func truncForLog(s string) string {
|
||
if len(s) > 200 {
|
||
return s[:200] + "..."
|
||
}
|
||
return s
|
||
}
|