mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
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:
192
internal/knowledge/category_test.go
Normal file
192
internal/knowledge/category_test.go
Normal 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
|
||||
}
|
||||
332
internal/knowledge/dense_test.go
Normal file
332
internal/knowledge/dense_test.go
Normal 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
|
||||
}
|
||||
299
internal/knowledge/hardening_test.go
Normal file
299
internal/knowledge/hardening_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
252
internal/knowledge/idf_test.go
Normal file
252
internal/knowledge/idf_test.go
Normal 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
@ -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_*")
|
||||
|
||||
155
internal/knowledge/migrate_names.go
Normal file
155
internal/knowledge/migrate_names.go
Normal 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
|
||||
}
|
||||
193
internal/knowledge/migrate_names_test.go
Normal file
193
internal/knowledge/migrate_names_test.go
Normal 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)
|
||||
}
|
||||
125
internal/knowledge/perf_test.go
Normal file
125
internal/knowledge/perf_test.go
Normal 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
|
||||
}
|
||||
135
internal/knowledge/persist_test.go
Normal file
135
internal/knowledge/persist_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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") != "" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user