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,192 @@
package knowledge
import (
"strings"
"testing"
"time"
)
// 分层必须真正参与召回:SearchIn 把范围限定在分类子树内。
//
// 此前分层只是**存储布局**——检索一律全库平铺,而 SearchTree /
// SearchCategories 两个想按分类聚合的函数是死代码(且停留在 Search 修复
// 前的单路口径)。分类存在却对召回零影响。
func TestSearchInCategoryScope(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
for _, e := range []struct{ name, body string }{
{"tech/go/并发", "goroutine 调度 GMP 抢占 通道"},
{"tech/rust/所有权", "borrow checker move 语义"},
{"life/sleep", "作息 褪黑素 深睡 咖啡因"},
{"cook/coffee", "手冲 烘焙 水温 粉水比"},
} {
if err := s.Add(e.name, e.body); err != nil {
t.Fatal(err)
}
}
// 全库:能跨分类召回
if got := s.Search("调度 语义 作息 手冲", 10); len(got) < 4 {
t.Fatalf("全库检索应召回全部 4 条,实为 %v", namesOf(got))
}
// 限定 tech:只剩 tech 子树两条
got := s.SearchIn("调度 语义 作息 手冲", "tech", 10)
if len(got) != 2 {
t.Fatalf("限定 tech 应命中 2 条,实为 %v", namesOf(got))
}
for _, k := range got {
if !hasPrefix(k.Name, "tech/") {
t.Errorf("限定 tech 却返回了 %q", k.Name)
}
}
// 前缀匹配整棵子树:查 "tech/go" 只命中其下
if got := s.SearchIn("调度 语义 作息 手冲", "tech/go", 10); len(got) != 1 || got[0].Name != "tech/go/并发" {
t.Errorf("限定 tech/go 应只命中并发,实为 %v", namesOf(got))
}
// 不存在的分类:空结果,且不报错
if got := s.SearchIn("调度", "no/such/cat", 5); len(got) != 0 {
t.Errorf("不存在的分类应返回空,实为 %v", namesOf(got))
}
// 空 category ≡ 全库(与 Search 等价)
a, b := s.Search("调度 语义", 10), s.SearchIn("调度 语义", "", 10)
if strings.Join(namesOf(a), ",") != strings.Join(namesOf(b), ",") {
t.Errorf("空 category 应等价于全库:%v vs %v", namesOf(a), namesOf(b))
}
// 前后斜杠不应影响(界面上很容易带上)
for _, c := range []string{"/tech", "tech/", "/tech/"} {
if got := s.SearchIn("调度 语义 作息 手冲", c, 10); len(got) != 2 {
t.Errorf("category=%q 应归一化后命中 2 条,实为 %v", c, namesOf(got))
}
}
}
// 分类过滤下,归一化必须取**作用域内**的最大值。
//
// 否则:作用域内只有一条弱命中,范围外却有个强命中把全局最大值拉高,
// 作用域内的分数被压到接近 0,排序与阈值语义全失真。
func TestSearchInNormalizesWithinScope(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
// 故意让范围外的条目在词法路上分数更高
if err := s.Add("outside", "zzzz 罕见词zzz 独有"); err != nil {
t.Fatal(err)
}
if err := s.Add("cat/in", "zebra 条目"); err != nil {
t.Fatal(err)
}
if err := s.Add("cat/in2", "zebra 条目"); err != nil {
t.Fatal(err)
}
if err := s.Add("cat/in3", "zebra 条目"); err != nil {
t.Fatal(err)
}
// 全库时 outside 应因独有词而排前
if got := s.Search("罕见词zzz", 4); len(got) == 0 || got[0].Name != "outside" {
t.Logf("注:全库首位为 %v(稀疏语义路可能改写排序),仅作观察", namesOf(got))
}
// 限定 cat:outside 必须被排除,且 cat 下的条目仍要正常返回(分数不能被压成 0)
got := s.SearchIn("zebra", "cat", 4)
if len(got) != 3 {
t.Fatalf("限定 cat 应返回 3 条,实为 %v", namesOf(got))
}
for _, k := range got {
if k.Name == "outside" {
t.Error("作用域过滤失效:范围外条目被返回")
}
}
// 关键:范围外的强信号不得把作用域内的分数压掉——
// 三个 cat 条目必须都在(而不是只留 0 个)
if len(got) == 0 {
t.Error("作用域内分数被范围外最大值压没了")
}
}
// 分类过滤对稠密路同样生效(不能只过滤稀疏两路)。
func TestSearchInFiltersDensePath(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": {0, 1}},
img: map[string][]float64{"__default": {0, 1}},
}
s.SetDenseSpace(mm)
if err := s.Add("cat/a", "甲"); err != nil {
t.Fatal(err)
}
if err := s.Add("other/b", "乙"); err != nil {
t.Fatal(err)
}
qv := []float64{0, 1}
if hits := s.denseHits(qv); len(hits) != 2 {
t.Fatalf("稠密路应命中 2 条,实为 %d", len(hits))
}
// SearchIn 走真实查询路径,验证范围外那条被排除
if got := s.SearchIn("甲乙", "cat", 5); len(got) != 1 || got[0].Name != "cat/a" {
t.Errorf("稠密路未被分类过滤,实为 %v", namesOf(got))
}
}
// 性能:分类过滤不应拖慢全库检索(早退守卫 + 作用域判定开销可忽略)。
func TestSearchInOverhead(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 < 200; i++ {
if err := s.Add(catName(i), "内容 关键词 内容"); err != nil {
t.Fatal(err)
}
}
q := "关键词 内容"
// 预热
for i := 0; i < 20; i++ {
s.Search(q, 5)
s.SearchIn(q, "g0", 5)
}
start := time.Now()
for i := 0; i < 50; i++ {
s.Search(q, 5)
}
full := time.Since(start)
start = time.Now()
for i := 0; i < 50; i++ {
s.SearchIn(q, "g0", 5)
}
scoped := time.Since(start)
t.Logf("50 次:全库 %v 限定 g0 %v", full.Round(time.Microsecond), scoped.Round(time.Microsecond))
// 限定范围命中数更少,理应更快;即便持平也不该慢太多
if scoped > full*3 {
t.Errorf("分类过滤带来 %0.1f× 开销(全库 %v → 限定 %v)",
float64(scoped)/float64(full), full, scoped)
}
}
func catName(i int) string {
return "g" + string(rune('0'+i/100)) + "/item" + string(rune('a'+i/10)) + string(rune('0'+i%10))
}
func hasPrefix(s, p string) bool {
return len(s) >= len(p) && s[:len(p)] == p
}

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
}

View File

@ -0,0 +1,299 @@
package knowledge
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
// 本轮加固的回归测试。每条都对应一次实测复现,注释里留了复现现象,
// 免得后来者以为这些分支是过度防御而删掉。
// 知识名必须与盘上目录逐字一致,且重启前后不变。
//
// 复现(修复前):Add("tech/ Go /Note") 得到 id="tech/_go_/note",
// 而建目录时逐段 sanitize 得到 "tech/_go/note" —— 内存键与盘上目录从
// 第一次落盘起就不一致。重启后 scanDir 读回 "tech/_go/note",
// knowledge_list / knowledge_delete 的 key 全部对不上。
func TestNameStableAcrossRestart(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
inputs := []string{"tech/ Go /Note", "Tech/Go/并发", "coffee"}
for _, in := range inputs {
if err := s.Add(in, "正文 "+in); err != nil {
t.Fatalf("Add(%q): %v", in, err)
}
}
before := s.List()
s.Stop()
s2 := NewStore(dir)
if err := s2.Start(); err != nil {
t.Fatal(err)
}
defer s2.Stop()
after := s2.List()
if strings.Join(before, "\x00") != strings.Join(after, "\x00") {
t.Fatalf("重启前后知识名不一致:\n 前 %v\n 后 %v", before, after)
}
// 内存键必须能在盘上找到同名目录
for _, id := range after {
p := filepath.Join(dir, filepath.FromSlash(id), "content.md")
if _, err := os.Stat(p); err != nil {
t.Errorf("知识 %q 的盘上路径不存在: %v", id, err)
}
}
}
// 知识名不得逃出知识根。
//
// 复现(修复前):Add("../../escaped") 无报错写到根外,重启 scanAll 扫不到
// => 幽灵条目;Remove("..") 直接 RemoveAll 掉整个数据目录(实测返回 nil)。
func TestNameRejectsEscape(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "data", "knowledge")
s := NewStore(root)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
bad := []string{"", " ", "..", "../..", "../../escaped", "a/../../..", ".hidden", "a//b", "a/./b"}
for _, name := range bad {
if err := s.Add(name, "不该被写入"); !errors.Is(err, ErrInvalidName) {
t.Errorf("Add(%q) 应返回 ErrInvalidName,实为 %v", name, err)
}
}
if n := len(s.List()); n != 0 {
t.Fatalf("非法名不应产生条目,实有 %d 条: %v", n, s.List())
}
// 根外不得出现任何东西
if _, err := os.Stat(filepath.Join(base, "data", "escaped")); err == nil {
t.Error("发生了根外写入")
}
// Remove 同样不得越界,且知识根必须还在
if err := s.Remove("../.."); !errors.Is(err, ErrInvalidName) {
t.Errorf("Remove(\"../..\") 应返回 ErrInvalidName,实为 %v", err)
}
if _, err := os.Stat(root); err != nil {
t.Fatalf("知识根被删掉了: %v", err)
}
}
// 分类条目必须能用全名删掉,且文件真的消失。
//
// 复现(修复前):List() 给 "tech/go/并发",Remove 却拼出根下 "tech/go/并发"
// 之外的路径,os.RemoveAll 删空目录返 nil => 工具回报"已删除",
// content.md 与索引条目都还在。
func TestRemoveCategorizedByFullName(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
if err := s.Add("tech/go/并发", "goroutine 调度 GMP 抢占"); err != nil {
t.Fatal(err)
}
if err := s.Add("coffee", "手冲 烘焙 水温"); err != nil {
t.Fatal(err)
}
if err := s.Remove("tech/go/并发"); err != nil {
t.Fatalf("Remove 全名失败: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "tech", "go", "并发", "content.md")); err == nil {
t.Error("报成功但 content.md 仍在盘上")
}
if got := s.List(); len(got) != 1 || got[0] != "coffee" {
t.Errorf("删除后 List 应为 [coffee],实为 %v", got)
}
// 空掉的分类目录应被清掉,不留一片空壳
if _, err := os.Stat(filepath.Join(dir, "tech")); err == nil {
t.Error("分类空目录 tech/ 未清理")
}
// 知识根绝不能被一起删掉
if _, err := os.Stat(dir); err != nil {
t.Fatalf("知识根被删掉了: %v", err)
}
}
// 叶名寻址:唯一命中时接受(界面历史数据兼容),多条同名时拒绝而不是猜。
func TestRemoveByLeafName(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
if err := s.Add("tech/go/并发", "A"); err != nil {
t.Fatal(err)
}
if err := s.Remove("并发"); err != nil {
t.Fatalf("唯一叶名应可删除,实为 %v", err)
}
if n := len(s.List()); n != 0 {
t.Fatalf("叶名删除后应为空,实为 %v", s.List())
}
// 两条同叶名 => 拒绝,且都不能被误删
if err := s.Add("a/dup", "A"); err != nil {
t.Fatal(err)
}
if err := s.Add("b/dup", "B"); err != nil {
t.Fatal(err)
}
if err := s.Remove("dup"); !errors.Is(err, ErrNotFound) {
t.Errorf("歧义叶名应拒绝,实为 %v", err)
}
if n := len(s.List()); n != 2 {
t.Errorf("歧义叶名不得误删,实剩 %v", s.List())
}
}
// 删除不存在的条目必须报错(webui 的 DELETE 把 error 映射成 404)。
func TestRemoveNotFound(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
if err := s.Remove("nonexistent"); !errors.Is(err, ErrNotFound) {
t.Errorf("删除不存在条目应返回 ErrNotFound,实为 %v", err)
}
// 幂等:再删一次仍是同一个错,不 panic 不误删
if err := s.Remove("nonexistent"); !errors.Is(err, ErrNotFound) {
t.Errorf("重复删除应仍是 ErrNotFound,实为 %v", err)
}
}
// 遗留盘上布局(大写、空格)必须仍可寻址与删除。
//
// 这是一次真实回归:scanDir 按盘上目录**原样**建键,所以修复前 Add 留下的
// "Tech/Upper" 在 items 里的键就是 "Tech/Upper",而 normalizeName 会
// 小写化+替换空格。若 resolve 只查规范名,这些老条目就会"删不掉、除不尽"
// ——List() 看得到、Remove() 报不存在。
func TestLegacyLayoutIsResolvable(t *testing.T) {
dir := t.TempDir()
legacy := []string{"Tech/Upper", "a/b with space", "tech/_go_/note"}
for _, d := range legacy {
p := filepath.Join(dir, filepath.FromSlash(d), "content.md")
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("遗留正文 "+d), 0644); err != nil {
t.Fatal(err)
}
}
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
names := s.List()
if len(names) != len(legacy) {
t.Fatalf("应载入 %d 条遗留条目,实为 %v", len(legacy), names)
}
for _, id := range names {
if err := s.Remove(id); err != nil {
t.Errorf("遗留条目 %q 删不掉: %v", id, err)
}
}
if rest := s.List(); len(rest) != 0 {
t.Errorf("遗留条目未清空,实剩 %v", rest)
}
}
// 空目录清理绝不能往上走到知识根:root 被删 = 整个知识库连同索引一起没了。
// 单段名(直接挂在 root 下)是最容易越界的一种——filepath.Dir 恰好等于 root,
// 若前缀判断写松一格就会命中。
func TestRemoveNeverDeletesRoot(t *testing.T) {
for _, name := range []string{"single", "a/b", "a/b/c/d"} {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
if err := s.Add(name, "正文"); err != nil {
t.Fatalf("Add(%q): %v", name, err)
}
if err := s.Remove(name); err != nil {
t.Fatalf("Remove(%q): %v", name, err)
}
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
t.Fatalf("name=%q 删除后知识根没了: err=%v", name, err)
}
// 哨兵:删除后**必须还能写入知识根**。不能用 .index.json 作哨兵——
// 索引写入已改为标脏延迟到 Flush/Stop,删除后它本就不该立刻存在
// (那样反而会把「延迟」这个行为给测漏)。
probe := filepath.Join(dir, "sentinel", "content.md")
if err := os.MkdirAll(filepath.Dir(probe), 0755); err != nil {
t.Fatalf("name=%q 删除后知识根不可写(已被破坏): %v", name, err)
}
if err := os.WriteFile(probe, []byte("x"), 0644); err != nil {
t.Fatalf("name=%q 删除后无法在根内建目录: %v", name, err)
}
s.Stop()
}
}
// 分类是 Add 从名字里声明的,不是检索时现推的;重启后必须一致。
func TestCategorySurvivesRestart(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if err := s.Start(); err != nil {
t.Fatal(err)
}
if err := s.Add("tech/go/并发", "正文"); err != nil {
t.Fatal(err)
}
k := s.items["tech/go/并发"]
if k == nil {
t.Fatalf("未按规范名建条目,实有 %v", s.List())
}
if k.Category != "tech/go" {
t.Errorf("Add 后 Category 应为 tech/go,实为 %q", k.Category)
}
s.Stop()
s2 := NewStore(dir)
if err := s2.Start(); err != nil {
t.Fatal(err)
}
defer s2.Stop()
k2 := s2.items["tech/go/并发"]
if k2 == nil {
t.Fatalf("重启后条目名漂移,实有 %v", s2.List())
}
if k2.Category != k.Category {
t.Errorf("重启后 Category 变了:%q -> %q", k.Category, k2.Category)
}
// 树里的挂载点应与 Category 一致
tr := s2.BuildTree()
node := tr
for _, part := range strings.Split(k.Category, "/") {
node = node.Children[part]
if node == nil {
t.Fatalf("树里缺少分类节点 %q", part)
}
}
if len(node.Items) != 1 || node.Items[0].Name != "tech/go/并发" {
t.Errorf("分类节点下应有该条目,实为 %+v", node.Items)
}
}

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 行为异常")
}
}

File diff suppressed because it is too large Load Diff

View File

@ -131,21 +131,10 @@ func TestRemove(t *testing.T) {
}
}
func TestRemoveNotFound(t *testing.T) {
dir, err := os.MkdirTemp("", "know_notfound_*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(dir)
s := NewStore(dir)
s.Start()
defer s.Stop()
if err := s.Remove("nonexistent"); err != nil {
t.Errorf("remove nonexistent should not error, got: %v", err)
}
}
// TestRemoveNotFound 原断言"删除不存在的条目不应报错",该契约已作废:
// webui 的 DELETE 处理器把 error 映射成 404,说明调用方本来就期望 ErrNotFound;
// 宽松版本只会让工具层对一次什么都没删的操作回报"已删除"。
// 新契约见 hardening_test.go 的 TestRemoveNotFound。
func TestStats(t *testing.T) {
dir, err := os.MkdirTemp("", "know_stats_*")

View File

@ -0,0 +1,155 @@
package knowledge
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// 存量目录名迁移。
//
// 背景:旧版 Add 对名字**整串** sanitize 却对路径**逐段** sanitize,
// 于是知识名(内存键 / LLM 可见的名字)与盘上目录从第一次落盘起就对不上。
// 典型残留:
//
// tech/_go_/note 分类段内的空格未被 TrimSpace 掉
// Tech/Upper 未小写化
// a/b with space 空格未替换成下划线
//
// 修复后 normalizeName 要求二者逐字一致,故需要一次性把存量目录改名。
//
// 本文件的函数刻意**不依赖 Store 实例**:迁移要在 store 扫盘之前跑,
// 且必须能在不带任何索引/内存状态的前提下作用于任意知识根。
// MigrationItem 是一条待迁移(或已规范/非法)的知识条目。
type MigrationItem struct {
OldName string
NewName string // 空串表示已规范,无需改动
Illegal bool // 名称含 .. / 点段 / 隐藏段:拒绝读写,需人工处理
}
// PlanMigration 扫描知识根,给出规范名迁移清单。**只读,不修改任何文件。**
//
// 遍历口径与 scanDir 一致:含 content.md 的目录是条目,否则是分类目录、
// 继续递归。单个目录不可读时跳过该目录而不是整体失败——一个坏目录
// 不该让整次迁移计划落空。
func PlanMigration(root string) ([]MigrationItem, error) {
root = filepath.Clean(root)
var out []MigrationItem
var walk func(dirName string)
walk = func(dirName string) {
dir := filepath.Join(root, filepath.FromSlash(dirName))
if _, err := os.Stat(filepath.Join(dir, "content.md")); err == nil {
it := MigrationItem{OldName: dirName}
norm, err := normalizeName(dirName)
switch {
case err != nil:
it.Illegal = true
case norm != dirName:
it.NewName = norm
}
out = append(out, it)
return
}
ents, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range ents {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
walk(dirName + "/" + e.Name())
}
}
}
ents, err := os.ReadDir(root)
if err != nil {
return nil, err
}
for _, e := range ents {
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
walk(e.Name())
}
}
sort.Slice(out, func(i, j int) bool { return out[i].OldName < out[j].OldName })
return out, nil
}
// migrationConflicts 找出会互相覆盖的目标名(含目标已存在于盘上的情况)。
//
// 不改名是最好的选择:一旦"前一条改好了、后一条失败"就成了半迁移状态,
// 比完全没迁移更难收拾。所以检测到冲突就整批拒绝。
func migrationConflicts(root string, items []MigrationItem) []string {
byTarget := map[string][]string{}
for _, it := range items {
if it.NewName != "" {
byTarget[it.NewName] = append(byTarget[it.NewName], it.OldName)
}
}
var conflicts []string
for name, srcs := range byTarget {
if len(srcs) > 1 {
conflicts = append(conflicts, fmt.Sprintf("%s ← %s", name, strings.Join(srcs, ", ")))
}
// 目标已在盘上(既有条目或分类目录)也算冲突
full := filepath.Join(root, filepath.FromSlash(name))
if _, err := os.Stat(full); err == nil {
conflicts = append(conflicts, name+" ← 盘上已存在同名路径")
}
}
sort.Strings(conflicts)
return conflicts
}
// ApplyMigration 执行迁移计划,返回成功/失败条数。
//
// limit <= 0 表示不限制。执行前会重新做一次冲突检测(计划生成与执行
// 之间可能有人改过盘上状态),有冲突则一条都不改。
func ApplyMigration(root string, items []MigrationItem, limit int) (applied, failed int) {
root = filepath.Clean(root)
if cs := migrationConflicts(root, items); len(cs) > 0 {
return 0, len(items) // 全部算失败,调用方据 failed 判定中止
}
// 深的目标先改:父子目录同时重命名时,先动子避免父被移走导致子路径失效。
todo := make([]MigrationItem, 0, len(items))
for _, it := range items {
if it.NewName != "" && !it.Illegal {
todo = append(todo, it)
}
}
sort.Slice(todo, func(i, j int) bool {
di, dj := strings.Count(todo[i].NewName, "/"), strings.Count(todo[j].NewName, "/")
if di != dj {
return di > dj
}
// 同深度按旧名倒序:同层内避免「父先变子还在」的瞬时状态
return todo[i].OldName > todo[j].OldName
})
for i, it := range todo {
if limit > 0 && i >= limit {
break
}
src := filepath.Join(root, filepath.FromSlash(it.OldName))
dst := filepath.Join(root, filepath.FromSlash(it.NewName))
// 硬保险:目标必须在知识根内
if !strings.HasPrefix(filepath.Clean(dst), root+string(filepath.Separator)) {
failed++
continue
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
failed++
continue
}
if err := os.Rename(src, dst); err != nil {
failed++
continue
}
applied++
}
return applied, failed
}

View File

@ -0,0 +1,193 @@
package knowledge
import (
"os"
"path/filepath"
"testing"
)
// makeLegacy 造出"修复前 Add 留下的脏目录布局"。
func makeLegacy(t *testing.T, dirs ...string) string {
t.Helper()
root := t.TempDir()
for _, d := range dirs {
p := filepath.Join(root, filepath.FromSlash(d), "content.md")
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("遗留正文 "+d), 0644); err != nil {
t.Fatal(err)
}
}
return root
}
// PlanMigration 只读,不得改动任何文件。
func TestPlanMigrationIsReadOnly(t *testing.T) {
root := makeLegacy(t, "Tech/Upper", "a/b with space", "good", "tech/_go_/note")
before := snapshotTree(t, root)
items, err := PlanMigration(root)
if err != nil {
t.Fatal(err)
}
if len(items) != 4 {
t.Fatalf("应发现 4 条,实为 %+v", items)
}
if after := snapshotTree(t, root); after != before {
t.Errorf("PlanMigration 改动了盘上状态:\n前 %s\n后 %s", before, after)
}
// 清单内容正确
byOld := map[string]MigrationItem{}
for _, it := range items {
byOld[it.OldName] = it
}
for old, want := range map[string]string{
"Tech/Upper": "tech/upper",
"a/b with space": "a/b_with_space",
// 段内无空格/大写 ⇒ 已是规范名,NewName 为空(无需改动)
"tech/_go_/note": "",
"good": "",
} {
it, ok := byOld[old]
if !ok {
t.Fatalf("清单缺 %q", old)
}
if it.NewName != want {
t.Errorf("%q 的 NewName 应为 %q,实为 %q", old, want, it.NewName)
}
}
}
// 迁移后:Store 能载入全部条目,且能按规范名删除。
func TestApplyMigrationThenUsable(t *testing.T) {
root := makeLegacy(t, "Tech/Upper", "a/b with space", "good")
items, err := PlanMigration(root)
if err != nil {
t.Fatal(err)
}
applied, failed := ApplyMigration(root, items, 0)
if applied != 2 || failed != 0 {
t.Fatalf("应成功 2 失败 0,实为 %d/%d", applied, failed)
}
s := NewStore(root)
if err := s.Start(); err != nil {
t.Fatal(err)
}
defer s.Stop()
if got := len(s.List()); got != 3 {
t.Fatalf("迁移后应载入 3 条,实为 %d 条:%v", got, s.List())
}
for _, id := range s.List() {
if err := s.Remove(id); err != nil {
t.Errorf("迁移后条目 %q 删不掉: %v", id, err)
}
}
}
// 冲突必须整批拒绝,且盘上零改动(半迁移比不迁移更难收拾)。
func TestApplyMigrationRefusesOnConflict(t *testing.T) {
root := makeLegacy(t, "A/b", "a/B", "keep/me")
before := snapshotTree(t, root)
items, err := PlanMigration(root)
if err != nil {
t.Fatal(err)
}
applied, failed := ApplyMigration(root, items, 0)
if applied != 0 {
t.Errorf("有冲突时不应改名任何一条,实为成功 %d", applied)
}
if failed == 0 {
t.Error("冲突应被报告为失败")
}
if after := snapshotTree(t, root); after != before {
t.Errorf("冲突路径下盘上被改动:\n前 %s\n后 %s", before, after)
}
}
// 目标名已存在于盘上(与既有条目撞名)也算冲突。
func TestApplyMigrationRefusesWhenTargetExists(t *testing.T) {
root := makeLegacy(t, "Tech/Upper", "tech/upper")
before := snapshotTree(t, root)
items, err := PlanMigration(root)
if err != nil {
t.Fatal(err)
}
applied, _ := ApplyMigration(root, items, 0)
if applied != 0 {
t.Errorf("目标已存在时应拒绝,实为成功 %d", applied)
}
if after := snapshotTree(t, root); after != before {
t.Error("盘上被改动")
}
}
// limit 必须真的限住条数。
func TestApplyMigrationRespectsLimit(t *testing.T) {
root := makeLegacy(t, "A/one", "B/two", "C/three", "D/four")
items, err := PlanMigration(root)
if err != nil {
t.Fatal(err)
}
applied, _ := ApplyMigration(root, items, 2)
if applied != 2 {
t.Errorf("limit=2 应只改 2 条,实为 %d", applied)
}
// 剩下两条仍可被再次迁移(幂等续跑)
items2, _ := PlanMigration(root)
rest := 0
for _, it := range items2 {
if it.NewName != "" {
rest++
}
}
if rest != 2 {
t.Errorf("剩余待迁移应为 2 条,实为 %d", rest)
}
applied2, _ := ApplyMigration(root, items2, 0)
if applied2 != 2 {
t.Errorf("续跑应再改 2 条,实为 %d", applied2)
}
}
// 迁移不得越出知识根(回归:sanitize 不过滤 .. 时的老问题)。
func TestApplyMigrationStaysInRoot(t *testing.T) {
base := t.TempDir()
root := filepath.Join(base, "data", "knowledge")
if err := os.MkdirAll(root, 0755); err != nil {
t.Fatal(err)
}
// 人为构造一条越界计划
items := []MigrationItem{{OldName: "x", NewName: "../escaped"}}
applied, failed := ApplyMigration(root, items, 0)
if applied != 0 || failed != 1 {
t.Errorf("越界目标必须被拒(applied=%d failed=%d)", applied, failed)
}
if _, err := os.Stat(filepath.Join(base, "data", "escaped")); err == nil {
t.Error("发生了根外写入")
}
}
func snapshotTree(t *testing.T, root string) string {
t.Helper()
var sb []byte
err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
rel, _ := filepath.Rel(root, p)
sb = append(sb, rel...)
if !info.IsDir() {
sb = append(sb, ' ')
}
sb = append(sb, '\n')
return nil
})
if err != nil {
t.Fatal(err)
}
return string(sb)
}

View File

@ -0,0 +1,125 @@
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
}

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)
}
}

View File

@ -106,10 +106,11 @@ func TestRankingQualityOnRealKB(t *testing.T) {
}
if os.Getenv("KB_DIAG_SWEEP") != "" {
fmt.Printf("\n === 融合权重扫描(1.0 = 只用稠密路,0.0 = 只用词法路)===\n")
saved := densePathWeight
fmt.Printf("\n === 稀疏融合权重扫描(1.0 = 只用语义路,0.0 = 只用词法路)===\n")
fmt.Printf(" (无多模态稠密路接入时,本扫描直接对应历史 densePathWeight 的语义)\n")
saved := sparseSemWeight
for _, w := range []float64{1.0, 0.8, 0.7, 0.5, 0.3, 0.0} {
densePathWeight = w
sparseSemWeight = w
t1, m := 0, 0.0
for _, name := range names {
hits := st.Search(name, len(names))
@ -126,7 +127,7 @@ func TestRankingQualityOnRealKB(t *testing.T) {
fmt.Printf(" 权重 %.1f:top-1 %2d/%d = %3.0f%% MRR %.3f\n",
w, t1, len(names), 100*float64(t1)/float64(len(names)), m/float64(len(names)))
}
densePathWeight = saved
sparseSemWeight = saved
}
if os.Getenv("KB_DIAG_ASSERT") != "" {

View File

@ -254,22 +254,87 @@ func (v *TFIDFVectorizer) Train(docs []string) {
defer v.mu.Unlock()
v.docFreq = make(map[string]float64)
v.totalDocs = len(docs)
v.totalDocs = 0
seen := make(map[string]map[string]bool)
for _, doc := range docs {
features := v.tokenizer(doc)
key := doc
if seen[key] == nil {
seen[key] = make(map[string]bool)
}
for _, f := range features {
if !seen[key][f] {
seen[key][f] = true
v.docFreq[f]++
v.addDocLocked(doc, seen)
}
}
// AddDoc 把一篇新文档计入 DF 统计(增量)。
//
// 存在的理由:Train 是全量重训,而知识库的 Add 是逐条发生的。此前 Add 只把
// 文本追进一个 summaries 切片、不更新 DF,于是**新引入的词 df=0**,
// 而 Vectorize 会跳过 df<=0 的特征 —— 运行时新增的知识当场搜不到,
// 重启(重新 Train)后才恢复。这不是优化项,是功能缺陷。
//
// 注意:document 是**文档级去重**的(同一词在同篇里多次出现只记 1 次 df),
// 与 Train 里那份 seen 表的语义必须一致,否则 IDF 会随写入路径不同而漂移。
func (v *TFIDFVectorizer) AddDoc(doc string) {
v.mu.Lock()
defer v.mu.Unlock()
v.addDocLocked(doc, nil)
}
// RemoveDoc 把一篇文档从 DF 统计中移出(AddDoc 的逆操作)。
//
// totalDocs 可能减到 0;此后 Vectorize 会走 totalDocs < 3 的退化分支
// (直接给 tf,不乘 IDF),这是可接受的行为 —— 库里都没东西了,
// IDF 本来也无从谈起。采用**下界守卫**:减到 0 后即使 RemoveDoc 被多调
// 一次,也不会变成负数。
func (v *TFIDFVectorizer) RemoveDoc(doc string) {
v.mu.Lock()
defer v.mu.Unlock()
for f := range v.seenFeatures(doc) {
if v.docFreq[f] > 0 {
v.docFreq[f]--
if v.docFreq[f] == 0 {
// 删掉零频条目:否则 DF 表会被"曾经出现过一次"的词永久撑大,
// 而这正是 summaries 只增不减之外的第二处泄漏。
delete(v.docFreq, f)
}
}
}
if v.totalDocs > 0 {
v.totalDocs--
}
}
// addDocLocked 是 AddDoc/Train 共用的记账内核。seen 非 nil 时复用调用方的表
// (Train 的整轮去重),nil 时本函数内使用自己的表。
//
// 调用方必须已持写锁。
func (v *TFIDFVectorizer) addDocLocked(doc string, seen map[string]map[string]bool) {
var local map[string]bool
if seen != nil {
if seen[doc] == nil {
seen[doc] = make(map[string]bool)
}
local = seen[doc]
} else {
local = make(map[string]bool)
}
for _, f := range v.tokenizer(doc) {
if local[f] {
continue
}
local[f] = true
v.docFreq[f]++
}
v.totalDocs++
}
// seenFeatures 返回一篇文档的**去重**特征集(与 addDocLocked 的口径一致)。
// 调用方必须已持写锁。
func (v *TFIDFVectorizer) seenFeatures(doc string) map[string]bool {
out := make(map[string]bool)
for _, f := range v.tokenizer(doc) {
out[f] = true
}
return out
}
func (v *TFIDFVectorizer) Vectorize(text string) Vector {