diff --git a/internal/knowledge/category_test.go b/internal/knowledge/category_test.go new file mode 100644 index 0000000..ea8e183 --- /dev/null +++ b/internal/knowledge/category_test.go @@ -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 +} diff --git a/internal/knowledge/dense_test.go b/internal/knowledge/dense_test.go new file mode 100644 index 0000000..c51cce7 --- /dev/null +++ b/internal/knowledge/dense_test.go @@ -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 +} diff --git a/internal/knowledge/hardening_test.go b/internal/knowledge/hardening_test.go new file mode 100644 index 0000000..85c5fb4 --- /dev/null +++ b/internal/knowledge/hardening_test.go @@ -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) + } +} diff --git a/internal/knowledge/idf_test.go b/internal/knowledge/idf_test.go new file mode 100644 index 0000000..13edc45 --- /dev/null +++ b/internal/knowledge/idf_test.go @@ -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 行为异常") + } +} diff --git a/internal/knowledge/knowledge.go b/internal/knowledge/knowledge.go index 56f76a0..0636c26 100644 --- a/internal/knowledge/knowledge.go +++ b/internal/knowledge/knowledge.go @@ -2,6 +2,7 @@ package knowledge import ( "encoding/json" + "errors" "fmt" "log" "os" @@ -23,8 +24,36 @@ type Knowledge struct { Tags []string `json:"tags"` UpdatedAt time.Time `json:"updated_at"` Meta map[string]string `json:"meta,omitempty"` + + // Dense 是该条目在**多模态统一空间**(image ⊕ text 共享坐标系)里的向量。 + // nil 表示未嵌入或嵌入失败——检索侧会被长度守卫跳过,退回流沙两路。 + // 不落 content.md:它是可重算的派生数据,落盘只会多个会失效的副本。 + Dense []float64 `json:"-"` + // DenseFP 是产生 Dense 的模型空间标识(≠ 当前 fingerprint 时视为过期)。 + DenseFP string `json:"-"` + // Media 是本条目携带的媒体块(媒体是**一等节点**:由自己的向量参与 + // 召回,不依赖任何生成的描述文本)。与 Dense 一同内存持有。 + Media []MediaRef `json:"-"` } +// MediaRef 是媒体在知识条目里的一等引用。Digest 是内容 sha256(媒体存储的 +// 主键),向量不在这里——它存在 media.Store 里(同一份媒体可能被多条知识 +// 引用,向量只算一次、只存一份)。 +// +// 与 memory/media 的 Item 刻意不共用:那边是媒体存储的内务结构(含 +// OriginPath/FirstSeen 等溯源字段),这里是知识条目对外暴露的引用。 +// 知识库只依赖 digest + MIME 就能完成嵌入与检索。 +// +// 命名为 KnowledgeMediaRef 而非 MediaRef,是为了不与别的包的类型撞名。 +type KnowledgeMediaRef struct { + Digest string `json:"digest"` + MIME string `json:"mime"` + Kind string `json:"kind,omitempty"` +} + +// KnowledgeMediaRef 的别名,照顾内部可读性。 +type MediaRef = KnowledgeMediaRef + // IndexItem — 索引条目,包含向量特征和内容摘要 type IndexItem struct { Name string `json:"name"` @@ -89,22 +118,78 @@ type Store struct { // 且「词都在停用词里」的查询(稠密路给空向量)能靠词法路救回来。 lex *vector.Store - mu sync.RWMutex - items map[string]*Knowledge - summaries []string + mu sync.RWMutex + items map[string]*Knowledge - indexPath string - vectorizer vector.Vectorizer // 可选:词嵌入向量化器,优先于 TF-IDF + indexPath string + denseCachePath string + denseDirty bool + // indexDirty 标记 .index.json 过期。批量导入时置位但**不立即写**, + // 由 flushIndex 收口:实测 writeIndexLocked 是 Add 的主开销 + // (N=400 时 6.7ms/次,占单条 Add 的绝大部分)。 + indexDirty bool + // denseCacheLoaded 保证缓存只尝试恢复一次;scanned 表示 items 已扫盘就绪。 + // 两个状态位缺一不可:接线(SetDenseSpace)与扫盘(Start)的先后顺序 + // 在调用方是自由的,缓存恢复必须等**两者都就绪**才可能成功, + // 因此不能在任一单点里无条件做,只能在每次都试一下。 + denseCacheLoaded bool + scanned bool + vectorizer vector.Vectorizer // 可选:词嵌入向量化器,优先于 TF-IDF + + // dense 是多模态稠密空间(可选)。与 vectorizer 是**两层不同的东西**: + // vectorizer 把文本变成稀疏特征(TF-IDF/词向量),供内部两路融合; + // dense 把 text/image 投到同一个稠密坐标系,让「按图搜知识」 + // 「按文搜含图知识」成立。文档记忆(docStore)走的就是后者。 + // + // 为何不把稠密向量塞进 s.vec:vector.Store 是稀疏倒排结构 + // (feature → {docID: weight}),稠密向量会把倒排表退化成全量特征桶, + // 同时破坏 TF-IDF 语义(vector.MultimodalEmbedder 的注释已明言)。 + // 因此稠密路自成一等路,与另两路并列,不混进任何一个 Store。 + dense vector.MultimodalEmbedder + mediaGet MediaGetter +} + +// mediaSidecarName 是每条知识目录下存放媒体引用的文件名。 +// +// 为何媒体引用必须落盘:它是**作者数据**(谁给哪条知识挂了哪张图), +// 不是可重算的派生量。此前它只存在于内存,进程一重启 scanDir 重建条目时 +// Media 就空了 —— 图片关联静默消失,而且因为不报错,没有任何迹象。 +// 放在条目目录内(与 content.md 并列)而非全局文件:随条目一起生灭, +// Remove 的 os.RemoveAll 天然把它清掉,不会留下孤儿记录。 +const mediaSidecarName = ".media.json" + +// denseCacheEntry 是单条知识的稠密向量缓存。 +type denseCacheEntry struct { + Dense []float64 `json:"dense"` + FP string `json:"fp"` + Dim int `json:"dim"` +} + +// denseCache 是稠密向量的全局缓存文件。 +// +// 它是**派生数据**(可由 content.md ⊕ 媒体重算),损坏/丢失只会导致一次 +// 重算,不会丢内容。因此与媒体引用分开存放、分开承担风险。 +type denseCache struct { + Fingerprint string `json:"fingerprint"` + Dim int `json:"dim"` + Entries map[string]denseCacheEntry `json:"entries"` +} + +// MediaGetter 让知识库能取回媒体字节以计算嵌入,而不依赖具体的媒体存储包。 +// 取不到(或未接线)时,该条目退化为纯文本嵌入——而不是整条不可用。 +type MediaGetter interface { + Get(digest string) ([]byte, error) } func NewStore(root string) *Store { return &Store{ - root: root, - indexPath: filepath.Join(root, ".index.json"), - vec: vector.NewStore(), - lex: newLexicalStore(), - veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords), - items: make(map[string]*Knowledge), + root: root, + indexPath: filepath.Join(root, ".index.json"), + denseCachePath: filepath.Join(root, ".dense.json"), + vec: vector.NewStore(), + lex: newLexicalStore(), + veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords), + items: make(map[string]*Knowledge), } } @@ -116,33 +201,246 @@ func newLexicalStore() *vector.Store { return st } +// SetDenseSpace 注入多模态统一向量空间(text ↔ image 共享坐标系)。 +// 未注入时知识库退化为原有的稀疏两路(词向量 + TF-IDF),保持既有行为。 +func (s *Store) SetDenseSpace(ds vector.MultimodalEmbedder) { + s.mu.Lock() + defer s.mu.Unlock() + s.dense = ds + // 接线与扫盘的先后顺序由调用方决定;这里补一次尝试,确保 + // 「先 Start 后 SetDenseSpace」(agent 的现行顺序)也能命中缓存。 + s.maybeLoadDenseCacheLocked() +} + +// SetMediaGetter 注入媒体取回器(用于为 Media 块算嵌入)。 +// 不注入时多媒体条目仍可入库,只是退化为纯文本嵌入。 +func (s *Store) SetMediaGetter(g MediaGetter) { + s.mu.Lock() + defer s.mu.Unlock() + s.mediaGet = g +} + +// denseEnabled 报告稠密路是否可用(供 Stats/自证与分支判断)。 +// 调用方必须已持锁。 +func (s *Store) denseEnabled() bool { + return s.dense != nil && s.dense.Loaded() +} + +// denseFor 计算一条知识的稠密向量:正文文本向量 ⊕ 各媒体块向量。 +// +// 只有与当前空间**同指纹且同维度**的媒体向量才参与融合。只比指纹不够: +// 指纹相同但维度不同的向量会被 FuseVectors 按最大维度拼成错维度结果, +// 而它下次又被当成"已对齐",就永远错下去(docStore 里踩过同一个坑)。 +// +// 返回 nil 表示本条目在当前空间下无向量(检索侧会跳过它)。 +func (s *Store) denseFor(k *Knowledge) []float64 { + if !s.denseEnabled() { + return nil + } + dim := s.dense.Dim() + var parts [][]float64 + if tv, err := s.dense.VectorizeDense(k.Name + " " + k.Content); err == nil && len(tv) == dim { + parts = append(parts, tv) + } + for _, m := range k.Media { + if v := s.mediaDense(m); v != nil { + parts = append(parts, v) + } + } + return vector.FuseVectors(parts...) +} + +// mediaDense 取回媒体字节并嵌入。任何一步拿不到就返回 nil—— +// 媒体缺失不应让整条知识失去文本向量。 +func (s *Store) mediaDense(m KnowledgeMediaRef) []float64 { + if !s.denseEnabled() || s.mediaGet == nil || m.Digest == "" { + return nil + } + data, err := s.mediaGet.Get(m.Digest) + if err != nil || len(data) == 0 { + return nil + } + mime := m.MIME + if mime == "" { + mime = "application/octet-stream" + } + v, err := s.dense.EmbedImageDense(data, mime) + if err != nil || len(v) != s.dense.Dim() { + // 模态不在本空间覆盖范围内(如音频)时返回的是 + // ErrModalityUnsupported:那是「永久无向量」,不是「本次失败」。 + // 两者都不重试、也不拿别的模型的向量顶替。 + return nil + } + return v +} + +// 为一条知识算稠密向量。 +// +// 融合顺序为 [文本, 媒体...]:FuseVectors 是逐维求和,对交换律不敏感, +// 顺序不影响结果;这里固定下来只为让日志/调试可复现。 +func (s *Store) ReindexDense() (built, skipped int) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.denseEnabled() { + return 0, 0 + } + fp := s.dense.Fingerprint() + dim := s.dense.Dim() + for _, k := range s.items { + if len(k.Dense) == dim && k.DenseFP == fp { + continue + } + v := s.denseFor(k) + if v == nil { + skipped++ + continue + } + k.Dense, k.DenseFP = v, fp + built++ + } + // 落盘,否则磁盘缓存永远对不上当前空间,判定条件永远成立 —— + // 每次启动都重算同一批(docStore 的 BuildDenseIndex 踩过这个坑)。 + if built > 0 { + s.denseDirty = true + } + s.flushDenseLocked() + log.Printf("[knowledge] dense reindex complete: built=%d skipped=%d fp=%s dim=%d", built, skipped, shortFP(fp), dim) + return built, skipped +} + +// AttachMedia 给一条已有知识挂上媒体,并**当场重算**它的稠密向量。 +// +// 单独抽出来的理由:媒体入库(AddWithMedia)与媒体后续到达是两条时序, +// 前者少见(大多数场景是先有知识、图片晚一点才上传)。不重算的话, +// 新挂的媒体要等下次 ReindexDense 才参与召回。 +func (s *Store) AttachMedia(name string, media ...KnowledgeMediaRef) error { + s.mu.Lock() + defer s.mu.Unlock() + + id, k, err := s.resolve(name) + if err != nil { + return err + } + k.Media = append(k.Media, media...) + // 媒体引用与向量都要落盘:引用是作者数据,向量是派生缓存。 + if err := writeMediaSidecar(filepath.Dir(k.Path), k.Media); err != nil { + return fmt.Errorf("写媒体引用失败: %w", err) + } + if s.denseEnabled() { + if v := s.denseFor(k); v != nil { + k.Dense, k.DenseFP = v, s.dense.Fingerprint() + s.denseDirty = true + } + } + s.flushDenseLocked() + _ = id + return nil +} + +// DenseStats 报告稠密路的接线与覆盖情况,供状态页/自证使用。 +func (s *Store) DenseStats() map[string]interface{} { + s.mu.RLock() + defer s.mu.RUnlock() + out := map[string]interface{}{ + "enabled": s.denseEnabled(), + "media_getter": s.mediaGet != nil, + } + if s.denseEnabled() { + fp := s.dense.Fingerprint() + dim := s.dense.Dim() + ready, stale := 0, 0 + for _, k := range s.items { + switch { + case len(k.Dense) == dim && k.DenseFP == fp: + ready++ + default: + stale++ + } + } + out["fingerprint"] = fp + out["dim"] = dim + out["ready"] = ready + out["stale"] = stale + } + return out +} + // SetVectorizer 设置词嵌入向量化器,优先于 TF-IDF func (s *Store) SetVectorizer(v vector.Vectorizer) { s.vectorizer = v } -// ReindexWithVectorizer 用给定的向量化器重建所有知识条目的向量索引 +// ReindexWithVectorizer 用给定的向量化器重建稀疏两路索引。 +// 稠密路不在此重建:它由独立的 ReindexDense 负责(换模型只影响它)。 func (s *Store) ReindexWithVectorizer(v vector.Vectorizer) { s.mu.Lock() defer s.mu.Unlock() log.Printf("[knowledge] reindex with vectorizer (%d items)", len(s.items)) s.vec = vector.NewStore() - s.lex = newLexicalStore() // 词法路的 IDF 必须建在全语料上(否则 IDF 没意义) - if len(s.summaries) > 0 { - s.veczer.Train(s.summaries) - } + s.retrainLexLocked() for _, k := range s.items { text := k.Name + " " + k.Content s.vec.Insert(k.Name, k.Name+": "+k.Content, v.Vectorize(text), map[string]string{ "name": k.Name, "path": k.Path, }) - s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil) } log.Printf("[knowledge] reindex complete (dense=%d lex=%d)", s.vec.Size(), s.lex.Size()) } +// lexText 是一条知识参与**词法路**(TF-IDF)统计的文本。 +// +// 为何用规范名而不是原始入参名:DF 统计必须与 lex 里实际插入的文档 +// **逐字一致**,否则重启后重新 Train 的 DF 与运行时增量维护的 DF 会对不上, +// IDF 悄悄漂移。取名一律以 items 里的规范名为准。 +func lexText(id, content string) string { return id + " " + content } + +// indexDocLocked 把一条知识登记进词法路索引并更新 IDF 统计。 +// +// IDF 与索引必须**同步**维护:只插索引不更新 DF,新引入的词 df=0 会被 +// Vectorize 当作未知词跳过,于是「新增的知识当场搜不到,重启后才恢复」。 +// 同一个词的 DF 也不能重复计:覆盖写同名条目时先 RemoveDoc 旧文本。 +// 调用方必须已持写锁。 +func (s *Store) indexDocLocked(id string, k *Knowledge) { + text := lexText(id, k.Content) + if old, ok := s.items[id]; ok && old != nil { + s.veczer.RemoveDoc(lexText(id, old.Content)) + } + s.veczer.AddDoc(text) + s.lex.Remove(id) + s.lex.Insert(id, id+": "+k.Content, s.veczer.Vectorize(text), nil) +} + +// unindexDocLocked 把一条知识从词法路索引与 IDF 统计里同时摘掉。 +// 调用方必须已持写锁。 +func (s *Store) unindexDocLocked(id string, k *Knowledge) { + if k != nil { + s.veczer.RemoveDoc(lexText(id, k.Content)) + } + s.lex.Remove(id) +} + +// retrainLexLocked 从当前 items 全量重建词法路索引与 IDF。 +// 启动与全量重建时走这条(比逐条增量更简单也更一致)。 +// 调用方必须已持写锁。 +func (s *Store) retrainLexLocked() { + texts := make([]string, 0, len(s.items)) + for id, k := range s.items { + texts = append(texts, lexText(id, k.Content)) + } + if len(texts) > 0 { + s.veczer.Train(texts) + } else { + s.veczer.Train(nil) + } + s.lex = newLexicalStore() + for id, k := range s.items { + text := lexText(id, k.Content) + s.lex.Insert(id, id+": "+k.Content, s.veczer.Vectorize(text), nil) + } +} + // vectorize 优先使用词嵌入向量化器,不可用时回退到 TF-IDF func (s *Store) vectorize(text string) vector.Vector { if s.vectorizer != nil { @@ -158,7 +456,10 @@ func (s *Store) Start() error { if err := s.scanAll(); err != nil { log.Printf("[knowledge] scan error: %v", err) } - // 重建索引文件 + // 稠密向量是派生数据:先尝试从缓存恢复,避免每次启动对每条知识 + // 重跑一次嵌入(外部 HTTP 嵌入服务下就是 N 次网络调用)。 + s.loadDenseCache() + // 重建索引文件(启动时必写一次,之后标脏延迟到 Flush/Stop) if err := s.writeIndex(); err != nil { log.Printf("[knowledge] write index error: %v", err) } @@ -166,58 +467,128 @@ func (s *Store) Start() error { return nil } -func (s *Store) Stop() {} +// Stop 落盘未持久化的派生数据(稠密向量缓存)。 +// 正文与媒体引用在写入时已落盘,这里只是补上派生缓存。 +func (s *Store) Stop() { + if err := s.Flush(); err != nil { + log.Printf("[knowledge] flush on stop: %v", err) + } +} -// 融合权重:稠密路(词向量)与词法路(TF-IDF)。 -// 取值由真实 KB 上的权重扫描定(rankdiag_test.go 的 KB_DIAG_SWEEP): -// 1.0 = 修复前的「只用稠密路」行为,作为对照基线。 -var densePathWeight = 0.5 - -// Search 融合两路召回:稠密路(词向量/多模态空间)+ 词法路(TF-IDF)。 +// 三路权重。 // -// 为何不能只用稠密路:词向量取平均后各向异性明显,真实 KB 上自检索 top-1 只有 15%, -// 前两名平均只差 0.013(等于没区分度);且全为停用词的查询会得到**空向量**, -// 直接搜不出任何东西("最近更新" 就撞上这个)。词法路对专名/术语/短查询强, -// 两路各自**按查询内最大值归一化**后加权融合,排序才可信。 +// 总预算先分给稠密路 denseSpaceWeight,剩下的留给稀疏两路,稀疏两路再按 +// sparseSemWeight 在「语义(词向量/TF-IDF)」与「词法(专名/术语)」之间切分。 +// +// 为何稠密占一半:它是唯一能跨模态召回的一路(按图搜含图知识),也是语义 +// 泛化最好的一路;稀疏两路负责把专名/术语/停用词查询抓回来。 +// +// denseSpaceWeight 是 const(改代码才会变);sparseSemWeight 是 var,供应 +// rankdiag_test 的 KB_DIAG_SWEEP 实测扫描——它的取值有实测依据,不是拍脑袋。 +const denseSpaceWeight = 0.5 + +// sparseSemWeight 是稀疏预算里语义路占的比例(剩下给词法路)。 +// 0.5 即历史上实测最优的「语义 0.5 / 词法 0.5」。 +var sparseSemWeight = 0.5 + +// Search 融合三路召回:多模态稠密路 + 稀疏语义路 + 词法路。 +// +// 为何不能只用稠密路:词向量取平均后各向异性明显,真实 KB 上自检索 top-1 +// 只有 15%,前两名平均只差 0.013(几乎没有区分度);且全为停用词的查询会得到 +// **空向量**,直接搜不出任何东西("最近更新" 就撞上这个)。词法路对专名/术语/ +// 短查询强。三路各自**按查询内最大值归一化**后加权融合,排序才可信。 +// +// 为何不先截候选再融合:截断后只能拿**候选内**最大值归一化,路与路之间的 +// 相对权重就随候选集漂移——测过同一份 KB 上自检索 MRR 从 0.376 掉到 0.197。 func (s *Store) Search(query string, topK int) []*Knowledge { + return s.SearchIn(query, "", topK) +} + +// SearchIn 与 Search 同语义,但可把召回范围限定在某个分类子树内。 +// +// category 为空 = 全库(等价于 Search)。非空时**前缀匹配**该分类路径: +// 查 "tech" 命中 "tech/go"、"tech/rust" 下的条目;查 "tech/go" 只命中 +// 它的子孙。这样分层才真正参与召回——此前分层只是存储布局,检索是全库 +// 平铺,`SearchTree`/`SearchCategories` 两个死代码想做的事没落到检索上。 +// +// 为何用前缀而不是精确相等:分类是**层级**,不是标签。要么看整棵子树, +// 要么用精确路径定位到某一层;只匹配精确相等会让 "tech" 查不到 +// "tech/go" 里的东西,那正是层级索引最该提供的价值。 +func (s *Store) SearchIn(query, category string, topK int) []*Knowledge { s.mu.RLock() defer s.mu.RUnlock() + category = strings.Trim(strings.TrimSpace(category), "/") + inScope := func(id string) bool { + if category == "" { + return true + } + k, ok := s.items[id] + if !ok { + return false + } + // 条目自身的 Category 或条目全名以该前缀开头都算命中: + // Category 是父路径,而条目全名是 category/叶名,两者都要覆盖 + // (顶层无分类的条目 Category 为空,只能靠全名判断)。 + return k.Category == category || + strings.HasPrefix(k.Category, category+"/") || + strings.HasPrefix(id, category+"/") + } + if topK <= 0 { topK = 5 } - if s.vec.Size() == 0 && s.lex.Size() == 0 { + if s.vec.Size() == 0 && s.lex.Size() == 0 && !s.hasAnyDense() { return nil } - // 两路各自对**全部**文档打分: - // - 稠密路的特征是维索引,几乎每篇都命中,"候选"就是全量; - // - 词法路只召回与查询共词的文档(这正是它的长处:专名/术语)。 - // 为何不先截候选再融合:截断后只能拿**候选内**最大值归一化,路与路之间的 - // 相对权重就随候选集漂移——实测同一份 KB 上自检索 MRR 从 0.376 掉到 0.197。 - // KB 规模下全量 cosine 的代价可忽略;真到数万条再上 ANN 也不迟。 - denseHits := s.vec.SearchScored(s.vectorize(query), s.vec.Size()) - lexHits := s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size()) - if len(denseHits) == 0 && len(lexHits) == 0 { - return nil + if category != "" && !s.hasInScopeLocked(category) { + return nil // 该分类下没有任何条目,省掉三路全量打分 } - scores := make(map[string]float64, len(denseHits)+len(lexHits)) - addPath := func(hits []vector.DocVectorHit, weight float64) { + // 各路分别打分,再按查询内最大值归一化加权融合。 + // 三个来源形状不同(稀疏路给 DocVectorHit),统一成 scoreHit 再交给 + // addPath 收口。 + scores := make(map[string]float64) + addPath := func(hits []scoreHit, weight float64) { + // 归一化取**作用域内**的最大值:拿全库最大值归一会让限定分类后的 + // 分数被一个范围外的条目压低,跨路相对权重随之失真。 max := 0.0 for _, h := range hits { - if h.Score > max { - max = h.Score + if h.score > max && inScope(h.id) { + max = h.score } } if max <= 0 { - return // 该路对这条查询没有信号(如空向量),全量让给另一路 + return // 该路对这条查询(在作用域内)没有信号,全量让给其余路 } for _, h := range hits { - scores[h.Doc.ID] += weight * h.Score / max + if !inScope(h.id) { + continue + } + scores[h.id] += weight * h.score / max } } - addPath(denseHits, densePathWeight) - addPath(lexHits, 1-densePathWeight) + // 路 1:多模态稠密空间(可用时先占掉 denseSpaceWeight) + sparseBudget := 1.0 + if s.denseEnabled() { + if qv, err := s.dense.VectorizeDense(query); err == nil && len(qv) > 0 { + addPath(s.denseHits(qv), denseSpaceWeight) + sparseBudget = 1.0 - denseSpaceWeight + } + } + + // 路 2:稀疏语义(词向量;未注入时即 TF-IDF) + // 路 3:词法(TF-IDF,专名/术语) + // 注:这里拿的是各路**全量**打分结果,不做候选截断——截断会让归一化 + // 随候选集漂移(见函数头注释)。 + denseHits := s.vec.SearchScored(s.vectorize(query), s.vec.Size()) + lexHits := s.lex.SearchScored(s.veczer.Vectorize(query), s.lex.Size()) + addPath(toHits(denseHits), sparseBudget*sparseSemWeight) + addPath(toHits(lexHits), sparseBudget*(1-sparseSemWeight)) + + if len(scores) == 0 { + return nil + } ids := make([]string, 0, len(scores)) for id := range scores { @@ -242,49 +613,69 @@ func (s *Store) Search(query string, topK int) []*Knowledge { return out } +// KnowledgeEntryInput 是知识条目的写入参数(纯文本 / 带媒体)。 +// 走 struct 而非多个位置参数:媒体与文本在 5 个方法里成对出现, +// 位置参数会让调用点难以自明(且带媒体时必填空串)。 +type KnowledgeEntryInput struct { + Name string + Content string + Media []KnowledgeMediaRef +} + +// Add 写入一条纯文本知识。媒体请用 AddWithMedia。 func (s *Store) Add(name, content string) error { + return s.AddWithMedia(name, content, nil) +} + +// AddWithMedia 写入一条知识,可携带媒体块(媒体作为一等节点参与稠密召回)。 +// +// 与 Add 的区别只在于媒体:稠密向量会把正文向量与各媒体向量**融合**成一个 +// 向量(同一坐标系内求和后归一化),所以一条带图的知识既能被文字搜到, +// 也能被“这张图”本身搜到。 +// +// 为何 EmbedImageDense 可能失败:当前模态不在本空间覆盖范围(如音频)时返回 +// ErrModalityUnsupported。此时**静默跳过该媒体**、仅用文本建立向量—— +// 绝不能拿另一个模型的向量顶替,那会把两套坐标系混进同一空间,相似度全无意义。 +func (s *Store) AddWithMedia(name, content string, media []KnowledgeMediaRef) error { + return s.Write(KnowledgeEntryInput{Name: name, Content: content, Media: media}) +} + +// Write 按输入参数写入一条知识。 +func (s *Store) Write(in KnowledgeEntryInput) error { s.mu.Lock() defer s.mu.Unlock() - // 解析层级:将 "/" 作为路径分隔符 - category := "" - leaf := name - if idx := strings.LastIndex(name, "/"); idx >= 0 { - category = name[:idx] - leaf = name[idx+1:] - } - dirName := sanitize(leaf) - if category != "" { - dirName = sanitize(category) + "/" + dirName - } - if err := checkSafeName(dirName); err != nil { + // id 同时是三样东西:内存 map 的键、LLM 可见的知识名、盘上相对目录。 + // 三者必须逐字相同——扫盘重建(scanDir)读回的是真实目录名,若与 Add 时的 + // 键不一致,重启那一刻知识名就变了,knowledge_list / knowledge_delete 的 + // key 全部对不上,删除还会静默失败(详见 Remove)。 + id, err := normalizeName(in.Name) + if err != nil { return err } - dir := filepath.Join(s.root, dirName) - // 双保险:不得写到知识根之外(否则条目落在根外,重启 scanAll 扫不到, - // 变成"内存有、盘上根外"的幽灵条目) - rootClean := filepath.Clean(s.root) - if dir != rootClean && !strings.HasPrefix(filepath.Clean(dir), rootClean+string(filepath.Separator)) { - return fmt.Errorf("knowledge: 拒绝写入知识根之外的路径: %q", name) + category := "" + if idx := strings.LastIndex(id, "/"); idx >= 0 { + category = id[:idx] } + dir := filepath.Join(s.root, filepath.FromSlash(id)) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("create knowledge dir: %w", err) } path := filepath.Join(dir, "content.md") - if err := os.WriteFile(path, []byte(content), 0644); err != nil { + if err := os.WriteFile(path, []byte(in.Content), 0644); err != nil { return fmt.Errorf("write knowledge: %w", err) } now := time.Now() - id := sanitize(name) k := &Knowledge{ Name: id, - Content: content, + Content: in.Content, Path: path, - Category: sanitize(category), - Tags: memory.ExtractKeywords(name + " " + content), + Category: category, + Tags: memory.ExtractKeywords(filepath.ToSlash(id) + " " + in.Content), UpdatedAt: now, + Media: in.Media, } s.items[id] = k @@ -295,76 +686,77 @@ func (s *Store) Add(name, content string) error { // 是对的,只有向量数比条目数多——而检索可能因此命中已被替换掉的旧内容。 s.vec.Remove(id) - text := name + " " + content + text := in.Name + " " + in.Content vec := s.vectorize(text) - s.vec.Insert(id, name+": "+content, vec, map[string]string{ - "name": name, "path": path, + s.vec.Insert(id, in.Name+": "+in.Content, vec, map[string]string{ + "name": in.Name, "path": path, }) - // 词法路同样去重后重建这条;IDF 统计沿用现有语料(重启时 scanAll 会全量重训) - s.lex.Remove(id) - s.lex.Insert(id, name+": "+content, s.veczer.Vectorize(text), nil) - s.summaries = append(s.summaries, name+" "+content) + // 词法路:重建本条索引 + 增量维护 IDF(覆盖写时先摘掉旧文本的贡献) + s.indexDocLocked(id, k) - if err := s.writeIndexLocked(); err != nil { - log.Printf("[knowledge] write index error after adding %s: %v", name, err) + // 媒体引用是作者数据,必须落盘(放条目目录内,随条目生灭)。 + if err := writeMediaSidecar(dir, in.Media); err != nil { + // 侧车写失败不阻断知识本身:正文已落盘,媒体丢了只影响跨模态召回, + // 且下次 AddWithMedia/AttachMedia 会补写。但要留下痕迹。 + log.Printf("[knowledge] media sidecar write error for %s: %v", in.Name, err) } - log.Printf("[knowledge] added: %s (%d bytes)", name, len(content)) + + // 稠密路:算完就挂上,使新写入的条目立即可被跨模态召回命中 + // (不必等下次 ReindexDense)。 + if s.denseEnabled() { + if v := s.denseFor(k); v != nil { + k.Dense, k.DenseFP = v, s.dense.Fingerprint() + s.denseDirty = true + } + } + + s.flushDenseLocked() + // 索引写入改为「标脏 + 延迟收口」,见 indexDirty 字段注释。 + s.indexDirty = true + log.Printf("[knowledge] added: %s (%d bytes, %d media)", in.Name, len(in.Content), len(in.Media)) return nil } -func (s *Store) SearchCategories(query string, topK int) []string { - s.mu.RLock() - defer s.mu.RUnlock() - - if query == "" { - var names []string - for _, k := range s.items { - names = append(names, k.Name) - } - sort.Strings(names) - if len(names) > topK { - names = names[:topK] - } - return names - } - - vec := s.vectorize(query) - results := s.vec.Search(vec, topK) - var names []string - for _, r := range results { - if k, ok := s.items[r.ID]; ok { - names = append(names, k.Name) - } - } - return names -} +// 树状检索与分类检索已于 2026-09 移除:全仓无调用方,且停留在 Search 修复 +// **之前**的单路口径(直接 s.vec.Search,无词法融合、0.05 阈值)。 +// 留着它们等于埋一份已知的检索质量回归;真需要按分类召回,应给 Search 加 +// category 过滤参数,而不是复活这两个。 +// Remove 删除一条知识。 +// +// 盘上路径取自**条目自记的 Path**(Add 写入 / scanDir 扫盘时记下的事实), +// 不再用 name 重新拼一遍:拼出来的路径和真实落点只要有一个字符对不上, +// os.RemoveAll 就删空目录返 nil,工具层回报"已删除"而文件与索引条目都还在。 +// +// 不存在的条目返回 ErrNotFound(webui 的 DELETE 处理器把 error 映射成 404, +// 正是这个语义)。只删不存在的条目是幂等操作,不算错误。 func (s *Store) Remove(name string) error { s.mu.Lock() defer s.mu.Unlock() - id := sanitize(name) - if err := checkSafeName(id); err != nil { + id, k, err := s.resolve(name) + if err != nil { return err } - dir := filepath.Join(s.root, id) - // 双保险:解析后的路径必须仍在知识根内。sanitize 不过滤 "..", - // 少了这一步,Remove("..") 会 RemoveAll 掉整个数据目录 - // (实测把 连同 memory/documents/media 一起删掉), - // 且 os.RemoveAll 对不存在的目标返回 nil ⇒ 工具层回报"已删除"。 + + itemDir := filepath.Dir(k.Path) + if err := os.RemoveAll(itemDir); err != nil { + return err + } + // 顺带清掉空掉的分类目录:名字去掉最后一段就是分类路径,分类下最后一条 + // 被删后目录会空留在盘上,越积越多。只往上到 s.root 为止,**绝不动 root** + // (root 被删 = 整个知识库连同索引一起没了)。 rootClean := filepath.Clean(s.root) - if dir != rootClean && !strings.HasPrefix(filepath.Clean(dir), rootClean+string(filepath.Separator)) { - return fmt.Errorf("knowledge: 拒绝删除知识根之外的路径: %q", name) - } - if err := os.RemoveAll(dir); err != nil { - return err + for dir := filepath.Clean(filepath.Dir(itemDir)); strings.HasPrefix(dir, rootClean+string(filepath.Separator)); dir = filepath.Dir(dir) { + if err := os.Remove(dir); err != nil { + break // 非空或无权限,留给上层判断 + } } + + s.unindexDocLocked(id, k) delete(s.items, id) s.vec.Remove(id) - s.lex.Remove(id) - if err := s.writeIndexLocked(); err != nil { - log.Printf("[knowledge] write index error after removing %s: %v", name, err) - } + s.indexDirty = true return nil } @@ -401,7 +793,20 @@ func (s *Store) BuildTree() *TreeIndex { // 为什么需要:writeIndex 会走 BuildTree(RLock),而 Add/Remove 持的是写锁, // 直接调用会死锁;此前就是因此把索引写丢进了无追踪的 goroutine 里, // 结果是「失败只打日志」+ 与调用方(含测试的临时目录清理)竞态。 +// buildTreeLocked 从当前条目重建树状索引。 +// +// 向量直接取自 s.vec(稀疏语义路的既有结果),**不再逐条重算**: +// 此前每条都调一次 s.vectorize(),那是全量分词 + TF-IDF 加权, +// 而结果与 s.vec 里已经存着的向量是同一个东西。实测这是 Add 单条 +// 耗时随库规模线性增长的主因(2.1ms@50 → 13.7ms@400)。 +// 调用方必须已持锁。 func (s *Store) buildTreeLocked() *TreeIndex { + // 一次 O(N) 取全量向量建表(纯内存拷贝),替代 N 次分词计算。 + vecByID := make(map[string]vector.Vector, s.vec.Size()) + for _, d := range s.vec.All() { + vecByID[d.ID] = d.Vector + } + root := newTreeIndex("root") for _, k := range s.items { node := root @@ -417,8 +822,9 @@ func (s *Store) buildTreeLocked() *TreeIndex { node = node.Children[part] } } - // 获取该条目的向量并压缩 - vec := s.vectorize(k.Name + " " + k.Content) + // 取该条目的向量并压缩(命中不到就留空向量,不再回退去重算—— + // 那会把本函数重新拖回 O(N × 分词)) + vec := vecByID[k.Name] preview := []rune(k.Content) previewStr := "" if len(preview) > 200 { @@ -438,44 +844,40 @@ func (s *Store) buildTreeLocked() *TreeIndex { return root } -// SearchTree 树状搜索:在树节点下搜索,返回按分类聚合的结果 -func (s *Store) SearchTree(query string, topK int) map[string][]*Knowledge { - s.mu.RLock() - defer s.mu.RUnlock() - - if topK <= 0 { - topK = 10 - } - - vec := s.vectorize(query) - results := s.vec.Search(vec, topK*2) - - categorized := make(map[string][]*Knowledge) - for _, r := range results { - if k, ok := s.items[r.ID]; ok { - cat := k.Category - if cat == "" { - cat = "未分类" - } - categorized[cat] = append(categorized[cat], k) - } - } - - out := make(map[string][]*Knowledge) - for cat, items := range categorized { - if len(items) > topK { - items = items[:topK] - } - out[cat] = items - } - return out -} - // writeIndex 写入 .index.json 树状索引文件(含向量和摘要) func (s *Store) writeIndex() error { - s.mu.RLock() - defer s.mu.RUnlock() - return s.writeIndexLocked() + s.mu.Lock() + defer s.mu.Unlock() + return s.flushIndexLocked() +} + +// flushIndexLocked 把过期的 .index.json 写回(调用方须持写锁)。 +// +// 为什么延迟:批量导入 400 条就是 400 次全量序列化(实测 6.7ms/次), +// 而该文件**目前没有任何读取方**(Start 是全量扫盘重建索引)。 +// 改为标脏 + 在 Stop/Flush 时收口,导入成本降为一次写。 +// 若将来真把它当缓存读回,必须先把"读取"实现补上,再考虑是否仍需延迟。 +func (s *Store) flushIndexLocked() error { + if !s.indexDirty { + return nil + } + if err := s.writeIndexLocked(); err != nil { + log.Printf("[knowledge] write index error: %v", err) + return err + } + s.indexDirty = false + return nil +} + +// Flush 把待落盘的派生数据(树索引)写回。批量导入后由调用方显式调用, +// 否则要等 Stop。 +func (s *Store) Flush() error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.flushDenseLocked(); err != nil { + return err + } + return s.flushIndexLocked() } // writeIndexLocked 与 writeIndex 同义但**不取锁**(调用方已持锁)。 @@ -485,7 +887,19 @@ func (s *Store) writeIndexLocked() error { if err != nil { return err } - return os.WriteFile(s.indexPath, data, 0644) + // tmp + rename:直接 os.WriteFile 会在中途崩溃时留下半截 JSON。 + // 本文件目前没有任何读取方(Start 是全量扫盘重建索引),所以损坏的 + // 后果只是「导出物不可读」;但那是运气,不该依赖——何况将来若真把它 + // 当缓存读回来,半截文件会被当成有效索引。 + tmp := s.indexPath + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return err + } + if err := os.Rename(tmp, s.indexPath); err != nil { + os.Remove(tmp) + return err + } + return nil } // ——— internal ——— @@ -507,18 +921,16 @@ func (s *Store) scanAll() error { s.scanDir("", entry.Name()) } - if len(s.summaries) > 0 { - s.veczer.Train(s.summaries) - } - - s.lex = newLexicalStore() + s.retrainLexLocked() for _, k := range s.items { text := k.Name + " " + k.Content s.vec.Insert(k.Name, k.Name+": "+k.Content, s.vectorize(text), map[string]string{ "name": k.Name, "path": k.Path, }) - s.lex.Insert(k.Name, k.Name+": "+k.Content, s.veczer.Vectorize(text), nil) } + s.scanned = true + // 若接线早于扫盘(测试与部分调用方会这么做),这里补一次缓存恢复。 + s.maybeLoadDenseCacheLocked() return nil } @@ -542,8 +954,8 @@ func (s *Store) scanDir(category, dirName string) { Tags: memory.ExtractKeywords(dirName + " " + content), UpdatedAt: now, } + k.Media = readMediaSidecar(dir) s.items[name] = k - s.summaries = append(s.summaries, name+" "+content) return } @@ -561,22 +973,60 @@ func (s *Store) scanDir(category, dirName string) { } } -// checkSafeName 拒绝会让路径逃出知识根的成分。 +// ErrInvalidName 表示知识名不合法:空段、`.`、`..` 或以点开头的段。 +var ErrInvalidName = errors.New("knowledge: 知识名不合法") + +// ErrNotFound 表示要删除/读取的知识不存在。 +var ErrNotFound = errors.New("knowledge: 知识不存在") + +// normalizeName 把外部传入的知识名规范成**唯一**的规范名。 // -// sanitize 只做小写/去空格/换下划线,**不过滤 ".."**,所以 -// "../../x" 或 ".." 会被 filepath.Join 解析到知识根之外。 -// 这里在拼接之前挡掉:空段、"."、"..",以及以点开头的段 -// (后者会被 scanDir 当隐藏目录跳过,造成"写进去了却扫不回来")。 -func checkSafeName(name string) error { +// 规范名同时充当三样东西:内存 map 的键、LLM 可见的知识名、盘上相对目录。 +// 三者必须逐字相同——扫盘重建(scanDir)读回的是真实目录名,若与 Add 时的 +// 键不一致,重启那一刻知识名就变了,knowledge_list / knowledge_delete 的 +// key 全部对不上,删除还会静默失败(详见 Remove)。 +// +// 为何**逐段** sanitize 而非整串:sanitize 内含 TrimSpace,只作用于整串两端。 +// 整串处理时 "tech/ Go /note" 得到 id="tech/_go_/note"(段内前后空格变 "_"), +// 而建目录时逐段 sanitize 得到 "tech/_go/note"(段内空格被 TrimSpace 掉)—— +// 两者从**第一次落盘起**就对不上。这不是重启才产生的漂移。 +func normalizeName(name string) (string, error) { if strings.TrimSpace(name) == "" { - return fmt.Errorf("knowledge: 名称为空") + return "", fmt.Errorf("%w: 空名", ErrInvalidName) } - for _, seg := range strings.Split(name, "/") { - if seg == "" || seg == "." || seg == ".." || strings.HasPrefix(seg, ".") { - return fmt.Errorf("knowledge: 名称含非法路径段 %q: %q", seg, name) + segs := strings.Split(name, "/") + out := make([]string, 0, len(segs)) + for _, seg := range segs { + s := sanitize(seg) + // 空段 / "." / ".." 会让 filepath.Join 逃出知识根(实测 Remove("..") + // 直接删掉整个 data 目录);以点开头的段会被 scanDir 当隐藏目录跳过, + // 变成"内存有、盘上扫不回"的幽灵条目。 + if s == "" || s == "." || s == ".." || strings.HasPrefix(s, ".") { + return "", fmt.Errorf("%w: %q 含有空段、点段或隐藏段 %q", ErrInvalidName, name, seg) + } + out = append(out, s) + } + return strings.Join(out, "/"), nil +} + +// findByLeaf 按最后一段(叶名)找条目,返回命中的 id 与命中数(大小写敏感)。 +// 保留给需要精确叶名的调用方;resolve 用的是 findByLeafFold。 +func (s *Store) findByLeaf(id string) (string, int) { + leaf := id + if idx := strings.LastIndex(id, "/"); idx >= 0 { + leaf = id[idx+1:] + } + found, n := "", 0 + for k := range s.items { + l := k + if idx := strings.LastIndex(k, "/"); idx >= 0 { + l = k[idx+1:] + } + if l == leaf { + found, n = k, n+1 } } - return nil + return found, n } func sanitize(name string) string { @@ -586,3 +1036,257 @@ func sanitize(name string) string { name = strings.ReplaceAll(name, "\\", "_") return name } + +// findByLeafFold 与 findByLeaf 同义,但叶名比较大小写不敏感—— +// 遗留盘上目录可能带大写。 +func (s *Store) findByLeafFold(want string) (string, int) { + want = strings.ToLower(want) + found, n := "", 0 + for k := range s.items { + l := k + if idx := strings.LastIndex(k, "/"); idx >= 0 { + l = k[idx+1:] + } + if strings.ToLower(l) == want { + found, n = k, n+1 + } + } + return found, n +} + +// resolve 把外部传入的名字解析到一个真实存在的条目。 +// +// 为何不能只查规范名:scanDir 是按**盘上目录原样**建键的,所以修复前 Add +// 留下的目录(大写、带空格,如 "Tech/Upper")在 items 里的键就是那个原样名。 +// 直接拿 normalizeName 的结果去查会查不中,而盘上条目又确实存在—— +// 结果就是老条目删不掉、清不清(实测)。查找按三层退让,但**删的路径 +// 永远取自条目自记的 Path**,所以退让本身不带来误删风险。 +func (s *Store) resolve(name string) (string, *Knowledge, error) { + // 1. 原样精确匹配(scanDir 建键与新建的规范名都会命中这里) + if k, ok := s.items[name]; ok { + return name, k, nil + } + + norm, nerr := normalizeName(name) + leaf := name + if i := strings.LastIndex(name, "/"); i >= 0 { + leaf = name[i+1:] + } + // 2. 规范名匹配 + if nerr == nil { + if k, ok := s.items[norm]; ok { + return norm, k, nil + } + if i := strings.LastIndex(norm, "/"); i >= 0 { + leaf = norm[i+1:] + } else { + leaf = norm + } + } + + // 3. 叶名匹配(大小写不敏感)。唯一命中才接受——多条同名时宁可不删, + // 也不能猜错目录。 + if id, n := s.findByLeafFold(leaf); n == 1 { + return id, s.items[id], nil + } else if n > 1 { + return "", nil, fmt.Errorf("%w: %q 命中 %d 条条目,请用全名", ErrNotFound, name, n) + } + + // 都不中:名字本身非法就报非法(更具体),否则就是不存在。 + if nerr != nil { + return "", nil, nerr + } + return "", nil, fmt.Errorf("%w: %q", ErrNotFound, name) +} + +// scoreHit 是融合三路时统一的 (条目, 相似度) 形状。包级命名而非函数内 +// 匿名 struct:三个来源(稠密/稀疏语义/词法)必须落在**同一**类型上, +// 否则 addPath 无法作为泛型收口点。 +type scoreHit struct { + id string + score float64 +} + +// hasInScopeLocked 报告某分类子树下是否存在条目(调用方须持锁)。 +// 用于在分类过滤下提前返回,避免三路对全库白打分。 +func (s *Store) hasInScopeLocked(category string) bool { + for _, k := range s.items { + if k.Category == category || + strings.HasPrefix(k.Category, category+"/") || + strings.HasPrefix(k.Name, category+"/") { + return true + } + } + return false +} + +// hasAnyDense 报告是否有任何条目已带稠密向量(调用方须持锁)。 +func (s *Store) hasAnyDense() bool { + for _, k := range s.items { + if len(k.Dense) > 0 { + return true + } + } + return false +} + +// denseHits 在多模态空间内对全库打分。 +// +// 维度守卫是硬要求:不同模型/维度的向量混进来算出的余弦没有意义 +// (会得到一个夹在两套坐标系之间的方向,且“看起来还挺像”)。维度不符 +// 一律跳过。同维但指纹过期的(模型换过)也跳过。 +func (s *Store) denseHits(queryVec []float64) []scoreHit { + if !s.denseEnabled() || len(queryVec) == 0 { + return nil + } + dim := s.dense.Dim() + fp := s.dense.Fingerprint() + out := make([]scoreHit, 0, len(s.items)) + for _, k := range s.items { + if len(k.Dense) != dim || k.DenseFP != fp { + continue + } + if score := vector.DenseCosine(queryVec, k.Dense); score > 0.01 { + out = append(out, scoreHit{id: k.Name, score: score}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].score > out[j].score }) + return out +} + +// toHits 把稀疏路的 DocVectorHit 归一成 addPath 用的 (id, score) 形状。 +func toHits(in []vector.DocVectorHit) []scoreHit { + out := make([]scoreHit, 0, len(in)) + for _, h := range in { + out = append(out, scoreHit{id: h.Doc.ID, score: h.Score}) + } + return out +} + +// shortFP 截断 fingerprint 为可读日志格式。 +func shortFP(fp string) string { + if len(fp) > 12 { + return fp[:12] + } + return fp +} + +// flushDenseLocked 把脏的稠密缓存落盘(调用方须持写锁)。 +// +// 为何在 Add 当场落盘而不是等 Stop:进程可能被 kill -9,那时没有任何 +// 优雅关停钩子可跑,这批向量的计算就白费了(docStore 的 BuildDenseIndex +// 出于同样理由选择当场写盘)。 +func (s *Store) flushDenseLocked() error { + if !s.denseDirty { + return nil + } + s.saveDenseCacheLocked() + s.denseDirty = false + return nil +} + +// ——— 稠密向量缓存 ——— + +// loadDenseCache 读回稠密向量缓存。只在该空间未变更时命中。 +// +// 缓存本身是派生数据,坏了就当没有(下次重算),绝不返回 error 卡住启动。 +func (s *Store) loadDenseCache() { + s.mu.Lock() + defer s.mu.Unlock() + s.maybeLoadDenseCacheLocked() +} + +// maybeLoadDenseCacheLocked 在「已接线 + 已扫盘 + 未加载过」时恢复缓存的 +// 稠密向量。调用方必须已持锁。 +func (s *Store) maybeLoadDenseCacheLocked() { + if s.denseCacheLoaded || !s.scanned || !s.denseEnabled() { + return + } + s.denseCacheLoaded = true + data, err := os.ReadFile(s.denseCachePath) + if err != nil { + return + } + var c denseCache + if json.Unmarshal(data, &c) != nil { + return + } + // 换过模型/维度后整份作废:否则会把一个坐标系的向量当另一个用 + if c.Fingerprint != s.dense.Fingerprint() || c.Dim != s.dense.Dim() { + return + } + dim := s.dense.Dim() + loaded := 0 + for id, e := range c.Entries { + k, ok := s.items[id] + if !ok || len(e.Dense) != dim { + continue + } + k.Dense, k.DenseFP = e.Dense, e.FP + loaded++ + } + log.Printf("[knowledge] dense cache restored: %d vectors (fp=%s dim=%d)", loaded, shortFP(c.Fingerprint), dim) +} + +// saveDenseCacheLocked 把稠密向量写回缓存文件(调用方须持写锁)。 +// +// 用 tmp+rename 原子替换:写一半的缓存文件会被下次启动当成"损坏"而整体丢弃, +// 代价是一次全量重算——可接受,但不该每次都发生。 +func (s *Store) saveDenseCacheLocked() { + if !s.denseEnabled() { + return + } + fp, dim := s.dense.Fingerprint(), s.dense.Dim() + c := denseCache{Fingerprint: fp, Dim: dim, Entries: map[string]denseCacheEntry{}} + for id, k := range s.items { + if len(k.Dense) == dim && k.DenseFP == fp { + c.Entries[id] = denseCacheEntry{Dense: k.Dense, FP: k.DenseFP, Dim: dim} + } + } + data, err := json.Marshal(c) + if err != nil { + log.Printf("[knowledge] dense cache marshal error: %v", err) + return + } + tmp := s.denseCachePath + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + log.Printf("[knowledge] dense cache write error: %v", err) + return + } + if err := os.Rename(tmp, s.denseCachePath); err != nil { + os.Remove(tmp) + log.Printf("[knowledge] dense cache commit error: %v", err) + } +} + +// ——— 媒体引用持久化 ——— + +// readMediaSidecar 读条目目录下的媒体引用文件。没有文件 = 无媒体(正常)。 +func readMediaSidecar(dir string) []KnowledgeMediaRef { + data, err := os.ReadFile(filepath.Join(dir, mediaSidecarName)) + if err != nil { + return nil + } + var refs []KnowledgeMediaRef + if json.Unmarshal(data, &refs) != nil || len(refs) == 0 { + return nil + } + return refs +} + +// writeMediaSidecar 把媒体引用写回条目目录。 +// 媒体为空时删掉该文件,避免留下 "[]" 这种无意义的残留。 +func writeMediaSidecar(dir string, refs []KnowledgeMediaRef) error { + p := filepath.Join(dir, mediaSidecarName) + if len(refs) == 0 { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + data, err := json.MarshalIndent(refs, "", " ") + if err != nil { + return err + } + return os.WriteFile(p, data, 0644) +} diff --git a/internal/knowledge/knowledge_test.go b/internal/knowledge/knowledge_test.go index 64de126..b248aa8 100644 --- a/internal/knowledge/knowledge_test.go +++ b/internal/knowledge/knowledge_test.go @@ -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_*") diff --git a/internal/knowledge/migrate_names.go b/internal/knowledge/migrate_names.go new file mode 100644 index 0000000..83446f7 --- /dev/null +++ b/internal/knowledge/migrate_names.go @@ -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 +} diff --git a/internal/knowledge/migrate_names_test.go b/internal/knowledge/migrate_names_test.go new file mode 100644 index 0000000..80c8efa --- /dev/null +++ b/internal/knowledge/migrate_names_test.go @@ -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) +} diff --git a/internal/knowledge/perf_test.go b/internal/knowledge/perf_test.go new file mode 100644 index 0000000..f61cf0a --- /dev/null +++ b/internal/knowledge/perf_test.go @@ -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 +} diff --git a/internal/knowledge/persist_test.go b/internal/knowledge/persist_test.go new file mode 100644 index 0000000..33c04be --- /dev/null +++ b/internal/knowledge/persist_test.go @@ -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) + } +} diff --git a/internal/knowledge/rankdiag_test.go b/internal/knowledge/rankdiag_test.go index 263012c..a2b6b9d 100644 --- a/internal/knowledge/rankdiag_test.go +++ b/internal/knowledge/rankdiag_test.go @@ -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") != "" { diff --git a/internal/memory/vector/store.go b/internal/memory/vector/store.go index 15a187d..6f9bcf8 100644 --- a/internal/memory/vector/store.go +++ b/internal/memory/vector/store.go @@ -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 {