package memory // 本文件收拢语义路径的**内存余弦回退**:持久向量索引没给出候选命中时, // 用内存余弦补齐语义分(索引正常作答时这里一行都不执行)。 // // 它为什么必须是有界的(2026-10-05 真机事故):这条路径原先一次性把 // **整库候选正文**塞进一个 embedding 请求,再接一次 query。主人真机 // 只发了一个不带正文的附件(空 query),索引因此「没答上」,回退于是 // 把 302 条候选正文全部向量化 —— 单个回合的 memory.recall 花了 39.3 秒, // 而模型本身只用 2.6 秒。因此这里给每一处代价都封了顶: // // - 单请求文本条数上限(fallbackEmbedBatch):既不把一个巨大的 // 请求丢给远端接口,也让「回退一共嵌入了多少条」可数; // - 每次回退的时间预算(defaultFallbackBudget / WithFallbackBudget): // 超预算就用手上已有的向量打分,其余条目本轮没有语义分 —— // 回退是兜底,绝不允许它把回合拖住; // - 正文→向量的进程内缓存(fallbackVectorCacheMax):同一批正文 // 不再每回合重复向量化(embedder 对相同输入是确定性的)。 // // 空 query 在更外层就被拦掉(semanticScores 的 hasSemanticQuery 门), // 因此真机那次「整库向量化」不会再发生:没有 query 就没有语义方向, // 排名交给关键词与新近度。 import ( "context" "sync" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding" ) const ( // fallbackEmbedBatch 是回退路径单次 Embed 携带的候选条数上限。 // 取 32:常见的 OpenAI 兼容 /embeddings 接口容许的批量都远大于它, // 而它小到不会让单次请求本身成为长尾。 fallbackEmbedBatch = 32 // defaultFallbackBudget 是单次回退把候选正文向量化时允许花掉的 // 时间上限。取 1 秒:主人对「一个回合的记忆召回」的要求是亚秒级, // 回退作为兜底绝不能把这笔代价变成无界的等待 —— 宁可本轮少补一些 // 语义分(并留下 Warn),也不让回合变慢。索引正常作答时用不到它。 defaultFallbackBudget = 1 * time.Second // fallbackVectorCacheMax 是回退向量缓存的条目上限。1024 条的 // 最坏内存占用约 1024 × 1024 维 × 4 字节 ≈ 4 MiB(维度由 // embedder 决定),对常驻进程可接受;超出按 FIFO 淘汰。 fallbackVectorCacheMax = 1024 ) // inMemorySemanticScores 是索引答不出候选命中时的语义兜底:先取 query // 向量(代价与索引路径相同,都是一次 embed),再用候选正文与它的余弦 // 相似度打分。候选向量的取得是**有界**的(见 candidateVectors)。 // // query 向量embed失败时返回空分值:没有 query 向量,余弦无从谈起 —— // 但候选正文一个字节都没有发出去。 func (r *Retriever) inMemorySemanticScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 { scores := make(map[string]float64, len(items)) queryVectors, err := r.embedder.Embed(ctx, []string{query}) if err != nil || len(queryVectors) == 0 { r.log.Warn("memory: semantic embed of query failed, semantic scores skipped", "error", err) return scores } qvec := queryVectors[0] vectors := r.candidateVectors(ctx, items) for _, it := range items { vec, ok := vectors[it.Content] if !ok { continue } if sim := cosineSim(qvec, vec); sim > 0 { scores[it.ID] = sim } } return scores } // candidateVectors 返回候选条目的「正文 → 向量」映射:正文命中进程内 // 缓存的条目直接复用,只有未命中的才在预算内分批向量化。因此同一批 // 候选在第二回合起几乎不需要再调 embedder(缓存未满时),真机事故里 // 那种「每回合把整库重算一遍」不再发生。 func (r *Retriever) candidateVectors(ctx context.Context, items []MemoryItem) map[string]embedding.Vector { cache := r.vectorCache() out := make(map[string]embedding.Vector, len(items)) missing := make([]MemoryItem, 0, len(items)) for _, it := range items { if v, ok := cache.get(it.Content); ok { out[it.Content] = v continue } missing = append(missing, it) } if len(missing) > 0 { r.embedCandidates(ctx, missing, out, cache) } return out } // embedCandidates 分批向量化 missing 里的正文,把结果写进 out 与缓存。 // // 预算用尽(或调用方取消)时它**就地收手**:out 里已有的向量照旧参与 // 本轮打分,剩下的条目只是没有语义分 —— 绝不为了补齐它们继续阻塞检索。 // 单批失败按整段失败处理(把半批结果当完整结果用,会让同一份语义分 // 随批次边界漂移),并记一条带来源的日志。 func (r *Retriever) embedCandidates(ctx context.Context, missing []MemoryItem, out map[string]embedding.Vector, cache *vectorCache) { bctx, cancel := context.WithTimeout(ctx, r.embedBudget()) defer cancel() embedded := 0 for start := 0; start < len(missing); start += fallbackEmbedBatch { end := min(start+fallbackEmbedBatch, len(missing)) batch := missing[start:end] texts := make([]string, len(batch)) for i, it := range batch { texts[i] = it.Content } vecs, err := r.embedder.Embed(bctx, texts) if err != nil { if ctx.Err() != nil { return } if bctx.Err() != nil { r.warnFallbackBudget(len(missing), embedded) return } r.log.Warn("memory: semantic embed of candidates failed, semantic scores skipped", "error", err, "candidates", len(missing)) return } for i, v := range vecs { if i >= len(batch) { break } out[batch[i].Content] = v cache.put(batch[i].Content, v) embedded++ } } } // vectorCache 返回回退路径的向量缓存,按需初始化(零值 Retriever 也能用)。 func (r *Retriever) vectorCache() *vectorCache { r.fallbackMu.Lock() defer r.fallbackMu.Unlock() if r.fallbackCache == nil { r.fallbackCache = newVectorCache(fallbackVectorCacheMax) } return r.fallbackCache } // embedBudget 返回回退路径的时间预算,未配置时取默认值。 func (r *Retriever) embedBudget() time.Duration { if r.fallbackBudget > 0 { return r.fallbackBudget } return defaultFallbackBudget } // vectorCache 是「正文 → 向量」的有界 FIFO 缓存。键就是将要发给 // embedder 的那段文本本身,因此内容变了自然换键,不会命中旧向量 // (embedder 对相同输入确定性,缓存因此与实时计算等价)。 type vectorCache struct { mu sync.Mutex vecs map[string]embedding.Vector order []string limit int } // newVectorCache 构建容量为 limit 的缓存;非正 limit 取默认上限。 func newVectorCache(limit int) *vectorCache { if limit <= 0 { limit = fallbackVectorCacheMax } return &vectorCache{vecs: make(map[string]embedding.Vector, limit), limit: limit} } // get 返回 key 的向量;未命中返回 false。 func (c *vectorCache) get(key string) (embedding.Vector, bool) { c.mu.Lock() defer c.mu.Unlock() v, ok := c.vecs[key] return v, ok } // put 写入 key 的向量,已存在则保留先写入的那份(同一正文的向量相同, // 重写只会白费一次淘汰)。超过上限时按写入顺序淘汰最旧的一条。 func (c *vectorCache) put(key string, v embedding.Vector) { c.mu.Lock() defer c.mu.Unlock() if _, ok := c.vecs[key]; ok { return } if len(c.order) >= c.limit { delete(c.vecs, c.order[0]) c.order = c.order[1:] } c.vecs[key] = v c.order = append(c.order, key) }