package knowledge import ( "encoding/json" "errors" "fmt" "log" "os" "path/filepath" "sort" "strings" "sync" "time" "gitcode.com/JianFeeeee/HomeAgent/internal/memory" "gitcode.com/JianFeeeee/HomeAgent/internal/memory/vector" ) type Knowledge struct { Name string `json:"name"` Content string `json:"content"` Path string `json:"path"` Category string `json:"category,omitempty"` // 父路径,如 "tech/go" 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"` Preview string `json:"preview"` // 前 200 字摘要 Tags []string `json:"tags"` Vector map[string]float64 `json:"vector"` // TF-IDF 特征向量(top-N 特征) Size int `json:"size"` // 内容总字节数 } // TreeIndex — 树状索引节点 type TreeIndex struct { Name string `json:"name"` Children map[string]*TreeIndex `json:"children,omitempty"` Items []IndexItem `json:"items,omitempty"` // 此节点下的知识条目(含向量) } func newTreeIndex(name string) *TreeIndex { return &TreeIndex{Name: name, Children: make(map[string]*TreeIndex)} } // compressVector 压缩向量:保留 topN 个权重最高的特征 func compressVector(v vector.Vector, topN int) map[string]float64 { if len(v) <= topN { out := make(map[string]float64, len(v)) for k, w := range v { out[k] = w } return out } type kv struct { k string v float64 } sorted := make([]kv, 0, len(v)) for k, w := range v { sorted = append(sorted, kv{k, w}) } sort.Slice(sorted, func(i, j int) bool { return sorted[i].v > sorted[j].v }) if topN > len(sorted) { topN = len(sorted) } sorted = sorted[:topN] out := make(map[string]float64, topN) for _, kv := range sorted { out[kv.k] = kv.v } return out } type Store struct { root string vec *vector.Store veczer *vector.TFIDFVectorizer // lex 是**词法路**索引(TF-IDF),与 vec(稠密路:词向量/多模态空间)相互独立。 // // 为何要两路:词向量取平均后各向异性明显——所有文档都挤在语料均值方向附近, // 真实 KB(33 条)上自检索 top-1 只有 15%、前两名平均只差 0.013,排序基本是噪声。 // 融合后 MRR 0.271→0.376、前两名差距 0.013→0.128(同一份数据实测), // 且「词都在停用词里」的查询(稠密路给空向量)能靠词法路救回来。 lex *vector.Store mu sync.RWMutex items map[string]*Knowledge 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"), denseCachePath: filepath.Join(root, ".dense.json"), vec: vector.NewStore(), lex: newLexicalStore(), veczer: vector.NewTFIDFVectorizer(memory.TokenizeWords), items: make(map[string]*Knowledge), } } // newLexicalStore 造词法路存储。阈值设为 0:TF-IDF 余弦量级只有 0.0~0.2, // 沿用稠密路的 0.05 会把大量有效候选静默砍掉(实测 MRR 0.307→0.193)。 func newLexicalStore() *vector.Store { st := vector.NewStore() st.SetMinScore(0) 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 用给定的向量化器重建稀疏两路索引。 // 稠密路不在此重建:它由独立的 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() // 词法路的 IDF 必须建在全语料上(否则 IDF 没意义) 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, }) } 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 { return s.vectorizer.Vectorize(text) } return s.veczer.Vectorize(text) } func (s *Store) Start() error { if err := os.MkdirAll(s.root, 0755); err != nil { return fmt.Errorf("knowledge root: %w", err) } 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) } log.Printf("[knowledge] started with %d items, %d vectors", len(s.items), s.vec.Size()) return nil } // Stop 落盘未持久化的派生数据(稠密向量缓存)。 // 正文与媒体引用在写入时已落盘,这里只是补上派生缓存。 func (s *Store) Stop() { if err := s.Flush(); err != nil { log.Printf("[knowledge] flush on stop: %v", err) } } // 三路权重。 // // 总预算先分给稠密路 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 && !s.hasAnyDense() { return nil } if category != "" && !s.hasInScopeLocked(category) { return nil // 该分类下没有任何条目,省掉三路全量打分 } // 各路分别打分,再按查询内最大值归一化加权融合。 // 三个来源形状不同(稀疏路给 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 && inScope(h.id) { max = h.score } } if max <= 0 { return // 该路对这条查询(在作用域内)没有信号,全量让给其余路 } for _, h := range hits { if !inScope(h.id) { continue } scores[h.id] += weight * h.score / max } } // 路 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 { ids = append(ids, id) } sort.Slice(ids, func(i, j int) bool { if scores[ids[i]] != scores[ids[j]] { return scores[ids[i]] > scores[ids[j]] } return ids[i] < ids[j] // 分数相同时按名字定序(保证结果可重复) }) var out []*Knowledge for _, id := range ids { if k, ok := s.items[id]; ok { out = append(out, k) } if len(out) >= topK { break } } 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() // id 同时是三样东西:内存 map 的键、LLM 可见的知识名、盘上相对目录。 // 三者必须逐字相同——扫盘重建(scanDir)读回的是真实目录名,若与 Add 时的 // 键不一致,重启那一刻知识名就变了,knowledge_list / knowledge_delete 的 // key 全部对不上,删除还会静默失败(详见 Remove)。 id, err := normalizeName(in.Name) if err != nil { return err } 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(in.Content), 0644); err != nil { return fmt.Errorf("write knowledge: %w", err) } now := time.Now() k := &Knowledge{ Name: id, Content: in.Content, Path: path, Category: category, Tags: memory.ExtractKeywords(filepath.ToSlash(id) + " " + in.Content), UpdatedAt: now, Media: in.Media, } s.items[id] = k // 覆盖同名条目时必须先摘掉旧向量。 // // vector.Store.Insert 是**追加**语义(s.docs = append + index.Add),不按 id // 去重。少了这一步,更新一条知识会在向量索引里留下上一版的副本:条目数看起来 // 是对的,只有向量数比条目数多——而检索可能因此命中已被替换掉的旧内容。 s.vec.Remove(id) text := in.Name + " " + in.Content vec := s.vectorize(text) s.vec.Insert(id, in.Name+": "+in.Content, vec, map[string]string{ "name": in.Name, "path": path, }) // 词法路:重建本条索引 + 增量维护 IDF(覆盖写时先摘掉旧文本的贡献) s.indexDocLocked(id, k) // 媒体引用是作者数据,必须落盘(放条目目录内,随条目生灭)。 if err := writeMediaSidecar(dir, in.Media); err != nil { // 侧车写失败不阻断知识本身:正文已落盘,媒体丢了只影响跨模态召回, // 且下次 AddWithMedia/AttachMedia 会补写。但要留下痕迹。 log.Printf("[knowledge] media sidecar write error for %s: %v", in.Name, err) } // 稠密路:算完就挂上,使新写入的条目立即可被跨模态召回命中 // (不必等下次 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 } // 树状检索与分类检索已于 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, k, err := s.resolve(name) if err != nil { return err } itemDir := filepath.Dir(k.Path) if err := os.RemoveAll(itemDir); err != nil { return err } // 顺带清掉空掉的分类目录:名字去掉最后一段就是分类路径,分类下最后一条 // 被删后目录会空留在盘上,越积越多。只往上到 s.root 为止,**绝不动 root** // (root 被删 = 整个知识库连同索引一起没了)。 rootClean := filepath.Clean(s.root) 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.indexDirty = true return nil } func (s *Store) Stats() map[string]interface{} { s.mu.RLock() defer s.mu.RUnlock() return map[string]interface{}{ "knowledge_count": len(s.items), "vector_count": s.vec.Size(), "root": s.root, "index_file": s.indexPath, } } func (s *Store) List() []string { s.mu.RLock() defer s.mu.RUnlock() var names []string for _, k := range s.items { names = append(names, k.Name) } sort.Strings(names) return names } // BuildTree 从当前知识库构建树状索引(含向量特征) func (s *Store) BuildTree() *TreeIndex { s.mu.RLock() defer s.mu.RUnlock() return s.buildTreeLocked() } // buildTreeLocked 与 BuildTree 同义,但**不取锁**——供已持写锁的路径调用。 // 为什么需要: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 if k.Category != "" { parts := strings.Split(k.Category, "/") for _, part := range parts { if part == "" { continue } if _, ok := node.Children[part]; !ok { node.Children[part] = newTreeIndex(part) } node = node.Children[part] } } // 取该条目的向量并压缩(命中不到就留空向量,不再回退去重算—— // 那会把本函数重新拖回 O(N × 分词)) vec := vecByID[k.Name] preview := []rune(k.Content) previewStr := "" if len(preview) > 200 { previewStr = string(preview[:200]) + "..." } else { previewStr = string(preview) } item := IndexItem{ Name: k.Name, Preview: previewStr, Tags: k.Tags, Vector: compressVector(vec, 20), Size: len(k.Content), } node.Items = append(node.Items, item) } return root } // writeIndex 写入 .index.json 树状索引文件(含向量和摘要) func (s *Store) writeIndex() error { 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 同义但**不取锁**(调用方已持锁)。 func (s *Store) writeIndexLocked() error { tree := s.buildTreeLocked() data, err := json.MarshalIndent(tree, "", " ") if err != nil { return err } // 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 ——— func (s *Store) scanAll() error { entries, err := os.ReadDir(s.root) if err != nil { return err } for _, entry := range entries { if !entry.IsDir() { continue } // skip hidden dirs if strings.HasPrefix(entry.Name(), ".") { continue } s.scanDir("", entry.Name()) } 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.scanned = true // 若接线早于扫盘(测试与部分调用方会这么做),这里补一次缓存恢复。 s.maybeLoadDenseCacheLocked() return nil } // scanDir 递归扫描目录 // category: 父级路径(从知识库根目录算起),如 "tech/go" // dirName: 当前目录相对路径(从知识库根目录算起) func (s *Store) scanDir(category, dirName string) { dir := filepath.Join(s.root, dirName) contentPath := filepath.Join(dir, "content.md") data, err := os.ReadFile(contentPath) if err == nil { name := dirName content := string(data) now := time.Now() k := &Knowledge{ Name: name, Content: content, Path: contentPath, Category: category, Tags: memory.ExtractKeywords(dirName + " " + content), UpdatedAt: now, } k.Media = readMediaSidecar(dir) s.items[name] = k return } // 无 content.md => 是分类目录,递归子目录 subEntries, err := os.ReadDir(dir) if err != nil { return } for _, sub := range subEntries { if !sub.IsDir() || strings.HasPrefix(sub.Name(), ".") { continue } childDir := dirName + "/" + sub.Name() s.scanDir(dirName, childDir) } } // ErrInvalidName 表示知识名不合法:空段、`.`、`..` 或以点开头的段。 var ErrInvalidName = errors.New("knowledge: 知识名不合法") // ErrNotFound 表示要删除/读取的知识不存在。 var ErrNotFound = errors.New("knowledge: 知识不存在") // normalizeName 把外部传入的知识名规范成**唯一**的规范名。 // // 规范名同时充当三样东西:内存 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("%w: 空名", ErrInvalidName) } 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 found, n } func sanitize(name string) string { name = strings.ToLower(name) name = strings.TrimSpace(name) name = strings.ReplaceAll(name, " ", "_") 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) }