Files
Cyrene_v2/internal/runtime/memory/retrieve_fallback.go
T
AskaEth cb564fe981 fix(memory): 空 query 不再触发语义回退 + 回退有界(修真机 39 秒召回)
真因(agent-197,文件:行):只有附件的消息正文为空 ⇒ queryFromMessages 返回空串 ⇒
retrieve.go 的 vectorIndexScores 在问索引之前就 return ⇒ 上层分不清「没问过索引」与
「索引无命中」⇒ 打出误导的 no-candidate-hit ⇒ 转内存回退,把全库 302 条候选正文塞进
一个 embedding 请求(302 × 约 130ms = 39.3 秒,与实测 duration_ns 吻合)。
索引/维度/表/写入其实全是好的(PG vec_items 302 行全 dim=1024,与 SQLite 302 个 id 集合相同)。
修法:空/纯空白 query 整条语义路径跳过(不问索引、不 embed);回退改为有界
(分批 32 + 1 秒时间预算 + 正文到向量 FIFO 缓存 1024,不再每回合重算)。
文件:retrieve.go / retrieve_semantic.go + 新增 retrieve_fallback.go / retrieve_keyword.go /
retrieve_fallback_test.go(retrieve.go 曾达 501 行,按 ≤500 拆分)。
变异:去掉空 query 门 ⇒ 向量化 43 条文本(want 0);整库单请求 ⇒ 69 条超上限 32;停用缓存 ⇒ 重复嵌入。
2026-10-05 08:04:47 +08:00

200 lines
7.5 KiB
Go

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)
}