Files
HomeAgent/internal/knowledge/knowledge.go
JianFeeeee ce694bc1a1 test(knowledge): 端到端验证多模态与分层链路 + 修稠密路同分次序不确定
一、修缺陷:denseHits 同分次序随机
  稠密路用 map 遍历 + 只按分数排序,**没有 tie-break**:同分条目的相对
  次序随每次调用变化 ⇒ 同样的查询两次可能给出不同首位(用户看到结果在跳,
  测试偶发变红)。Search 的主排序早就有「分数相同时按名字定序」,这条漏了。
  补上同分按 id 定序,并加 TestDenseHitsTieIsDeterministic 反向守住
  (撤掉 tie-break 后该测试在 5 次运行里稳定报出首位跳变)。

二、端到端验证(两个新文件)
- TestMultimodalEndToEndWithRealProvider:走**真实 embedding provider**
  (内置 http provider + 一个符合内核契约的最小服务),覆盖
  embedding.Open → AdaptProvider → media CAS → SetDenseSpace/SetMediaGetter
  → AddWithMedia(文本⊕图片融合)→ 以图搜知识 → .dense.json 落盘 →
  重启命中缓存(ReindexDense built=0)。
  不用 ONNX provider 是因为真模型 200MB 权重 + 3 分钟加载,进不了 CI;
  该链路是 provider 无关的(AdaptProvider 之后内核只认 MultimodalEmbedder)。

- TestHierarchicalIndexEndToEnd:多层分类(tech/go/两段、tech/rust/两段)
  的 Category 推导、树导出结构与挂载点、三级前缀过滤检索、范围外排除、
  索引落盘、重启后不漂移、树在重启后仍可用。
  反向验证:把 inScope 改成恒真后该测试稳定变红(报出范围外条目混入),
  确认它真能抓到「分层不参与召回」这一退化。

三、额外实测(本机,非 CI)
带 onnxruntime tag(生产构建形态)下用真实 Qwen3-VL 模型跑通全链路:
  provider dim=2048 modalities=[text image] fp=e43381246264...
  photo.Dense = 文本⊕图片融合结果
  以图搜知识 top1=photo score=0.757   ← 真正的跨模态召回
  重启后 ReindexDense built=0(命中缓存)
另确认默认构建(无 tag)下 qwen3vl 是 stub、Open 明确报错,不会静默降级成
"看似可用"。Makefile 的 HOMED_TAGS 默认即 onnxruntime,故发行版默认启用。
2026-09-26 14:20:19 +08:00

1301 lines
45 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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})
}
}
// 分数相同时按名字定序:map 迭代顺序随机,缺了这一步同分条目的
// 相对次序会随每次调用变化(Search 的主排序早有这条,denseHits 漏了),
// 表现为「同样的查询两次给出不同首位」——测试偶发、用户看到结果在跳。
sort.Slice(out, func(i, j int) bool {
if out[i].score != out[j].score {
return out[i].score > out[j].score
}
return out[i].id < out[j].id
})
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)
}