mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 04:43:11 +00:00
一次知识库子系统的集中加固,四类缺陷各有实测复现:
1. 名称与路径(数据安全,最严重)
- sanitize 不过滤 .. ⇒ Remove("..") 直接 RemoveAll 掉整个数据目录
(实测把 <data> 整棵删掉,含 memory/documents/media),且返回 nil,
工具层回报"已删除";Add("../../x") 写到知识根外,重启扫不回来
⇒ 幽灵条目。
- Add 整串 sanitize 而建目录逐段 sanitize,内存键与盘上目录从**第一次
落盘起**就不一致;重启后 name 漂移,knowledge_delete 静默删不掉
(RemoveAll 删空目录返 nil)。同一个根因。
- 修法:新增 normalizeName 作为唯一入口(逐段 + 拒绝空段/点段/隐藏段);
Remove 改为取条目自记的 Path(不再用名字重拼)+ 返回 ErrNotFound。
- resolve 三层退让(原样 → 规范名 → 叶名大小写不敏感,唯一命中才接受):
scanDir 按盘上目录原样建键,遗留大写目录若只查规范名会变成
"List 看得到、Remove 报不存在"。删的路径仍取自 Path,退让无风险。
2. IDF 与索引不同步(功能缺陷,非优化)
- TFIDF Vectorize 跳过 df<=0 的特征,而 Add 只往只增不减的 summaries
追文本、从不更新 DF ⇒ 库满(≥3篇) + 新词时,新知识**当场搜不到**,
重启才恢复(实测 Search("量子纠缠") == [])。
- 修法:vector.Store 新增 AddDoc/RemoveDoc(文档级去重口径与 Train 一致,
totalDocs 下界守卫,零频 DF 删除防表膨胀);knowledge 删掉 summaries,
改 index/unindex/retrain 三件套,覆盖写先 RemoveDoc 旧文本。
3. 多模态稠密路(此前知识库端到端纯文本)
- 新增 SetDenseSpace/SetMediaGetter/ReindexDense/DenseStats 与
AddWithMedia/AttachMedia,媒体成为一等节点参与跨模态召回。
- 维度与指纹双守卫:维度不符的向量会被 FuseVectors 按最大维度拼成错维度
结果且被当成"已对齐"永久错下去(docStore 踩过);模态不支持(音频)时
静默跳过该媒体、退化为纯文本向量,绝不拿别的模型的向量顶替。
- 未注入多模态空间时行为与此前逐字一致(退化为 0.5/0.5 两路融合)。
4. 派生数据落盘 + 分层参与召回
- 媒体引用是**作者数据**(丢失即丢信息)→ 条目目录内 .media.json;
稠密向量是**派生数据**(可重算)→ 全局 .dense.json,tmp+rename 原子。
混存会让派生数据损坏连带作者数据一起丢。
- 新增 SearchIn(query, category, topK):分类前缀匹配子树,让分层真正
参与召回(此前检索全库平铺,分层只是存储布局)。归一化取作用域内
最大值,否则范围外的强命中会把域内分数压没。
- 删除 SearchTree/SearchCategories 死代码(零调用方,且停留在 Search
修复前的单路口径:无词法融合、0.05 阈值)。
顺带修掉 Add 的 O(N)/写:buildTreeLocked 逐条重算向量(s.vec 里已有)
改为一次建表复用;.index.json 改为标脏 + Flush/Stop 收口。实测单条 Add
2.1ms@50 → 13.7ms@400 压平到 ~400µs(34×)。
存量目录改名迁移落在 migrate_names.go,只报告不改名(os.Rename 不可逆),
冲突整批拒绝以免半迁移。
156 lines
4.8 KiB
Go
156 lines
4.8 KiB
Go
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
|
||
}
|