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;停用缓存 ⇒ 重复嵌入。
This commit is contained in:
AskaEth committed 2026-10-05 08:04:47 +08:00
1 parent 6cb41a8794
commit cb564fe981
5 files changed
+532 -66

No files matched your search

+51 -63
View File
@@ -73,12 +73,23 @@ type Retriever struct {
recencyHalfLife time.Duration
log *slog.Logger
// seedMu 保护一次性索引回填状态(seeded 与 fallbackWarned):
// 回填每进程最多一次,而语义回退的告警也只报一次,避免每个
// Retrieve 都写日志。
// seedMu 保护一次性索引回填状态与回退路径的一次性告警位
// (seeded / fallbackWarned / budgetWarned):回填每进程最多一次,
// 语义回退与其预算超限的告警也只报一次,避免每个 Retrieve
// 都写日志。
seedMu sync.Mutex
seeded bool
fallbackWarned bool
budgetWarned bool
// fallbackBudget 是内存余弦回退把候选正文向量化时允许花掉的
// 时间上限(见 WithFallbackBudget)。非正时取 defaultFallbackBudget。
fallbackBudget time.Duration
// fallbackMu 保护回退路径的向量缓存指针,使它可按需初始化
// (缓存本体自带锁,见 vectorCache)。
fallbackMu sync.Mutex
fallbackCache *vectorCache
}
// RetrieverOption 定制由 NewRetriever 构建的 Retriever。
@@ -163,6 +174,22 @@ func WithRecencyHalfLife(days float64) RetrieverOption {
}
}
// WithFallbackBudget 设置内存余弦回退为「本次候选」向量化时允许花掉的
// 时间上限。非正值被忽略,保留默认值(defaultFallbackBudget)。
//
// 它是性能护栏而不是功能开关:持久索引正常作答时这条路径根本不执行,
// 一旦执行(索引确实缺候选向量),它把「整库候选依次向量化」的代价
// 封在预算内 —— 超预算就用手上已有的向量打分,绝不为了补齐语义分
// 让一个回合继续等下去(真机事故:302 条候选 = 39.3 秒,见
// retrieve_fallback.go 的文件注释)。测试用短预算来验证这条护栏。
func WithFallbackBudget(d time.Duration) RetrieverOption {
return func(r *Retriever) {
if d > 0 {
r.fallbackBudget = d
}
}
}
// NewRetriever 在 store 之上构建 Retriever。nil store 非法,会在
// 调用时 panic;调用方应传入非 nil 的 store。
//
@@ -302,40 +329,25 @@ func (r *Retriever) visibleCandidates(ctx context.Context, personaID string) ([]
return items, nil
}
// keywordScores 用内存内 TF-IDF 倒排索引(embedding.Index)为每条条目
// 与 query 打分,复用 embedding 包的分词器。得分
// 以最大值为基准归一化到 [0,1],以便与语义
// 得分可比。
func (r *Retriever) keywordScores(items []MemoryItem, query string) map[string]float64 {
scores := make(map[string]float64, len(items))
index := embedding.NewIndex[string]()
for _, it := range items {
index.Add(keywordText(it), it.ID)
}
hits := index.Search(query)
if len(hits) == 0 {
return scores
}
max := hits[0].Score
if max <= 0 {
return scores
}
for _, h := range hits {
scores[h.ID] = h.Score / max
}
return scores
}
// semanticScores 计算每个条目对 query 的语义相关性。接入了向量
// 索引(WithVectorIndex)时,它经 idx.Search 从持久化索引
// 作答 —— 真正的向量路径(design §3)。索引没有给出任何候选命中时
// (索引空、索引报错或 query 为空),它**回退**到内存余弦:配置了
// (索引空或索引报错),它**回退**到内存余弦:配置了
// 增强却仍拿到一片 0 分,等于增强既无效又遮住了可用的路径
// (审计 P0-3;design/03 §3「真正启用向量检索」、design/04 §7)。
// 两条路径都没有时返回空 map,因此语义相关性
// 不贡献任何分数。
//
// query 为空(无任何可向量化的文本)时整条语义路径**直接跳过**:
// 没有 query 就没有可比的语义方向,拿空串去向量化既问不出东西,
// 又要把整库候选逐条 embed 一遍 —— 真机实测这条弯路是 39.3 秒的来源
// (见 retrieve_fallback.go)。此时排名由关键词与新近度决定,
// 与 Retrieve 的既有口径一致。
func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
scores := make(map[string]float64, len(items))
if !hasSemanticQuery(query) {
return scores
}
if r.vindex != nil {
// 持久索引为空时先做一次性回填(每进程最多一次),使既有
// 条目也进入语义路径,而不是只有新写的条目可被检索。
@@ -350,32 +362,14 @@ func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, quer
if r.embedder == nil {
return scores
}
return r.inMemorySemanticScores(ctx, items, query)
}
texts := make([]string, len(items))
for i, it := range items {
texts[i] = it.Content
}
vectors, err := r.embedder.Embed(ctx, texts)
if err != nil {
r.log.Warn("memory: semantic embed of candidates failed, semantic scores skipped", "error", err)
return scores
}
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]
for i, vec := range vectors {
if i >= len(items) {
break
}
sim := cosineSim(qvec, vec)
if sim > 0 {
scores[items[i].ID] = sim
}
}
return scores
// hasSemanticQuery 报告 query 是否含有可供向量化的语义内容:
// 非空且非纯空白。附件类回合(主人只发文件、正文为空)正是空 query,
// 它不该触发任何向量化工作。
func hasSemanticQuery(query string) bool {
return strings.TrimSpace(query) != ""
}
// vectorIndexScores 从持久化向量索引作答语义相关性:
@@ -385,8 +379,10 @@ func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, quer
// 索引是**全局**的(承载所有角色/作用域的向量),而本次检索的候选
// 只是其中一个子集:不属于候选的命中不参与排名,也不得充当
// 归一化基准(也不得因占满 topK 名额而把候选挤出命中)。因此
// 这里只保留候选集内的命中。query 为空、索引报错或候选内
// 无命中时退化为空 map —— 调用方据此回退内存余弦。
// 这里只保留候选集内的命中。索引报错或候选内无命中时退化为
// 空 map —— 调用方据此回退内存余弦。query 为空的早退只是防御:
// 语义路径的调用方(semanticScores)已经在更外层拦掉了空 query,
// 不会再把「没问过索引」误当成「索引答不出」。
func (r *Retriever) vectorIndexScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
scores := make(map[string]float64, len(items))
if strings.TrimSpace(query) == "" {
@@ -447,14 +443,6 @@ func (r *Retriever) projectFor(ctx context.Context, it MemoryItem) *projectindex
return p
}
// keywordText 是用于关键词匹配的索引文本:content 加 tags。
func keywordText(it MemoryItem) string {
if len(it.Tags) == 0 {
return it.Content
}
return it.Content + " " + strings.Join(it.Tags, " ")
}
// Inject 为 personaID 检索最多 topK 条相关记忆,并把它们格式化为
// 紧凑的上下文块,可供注入提示词。没有检索到任何内容时
// 返回空字符串,因此没有相关记忆时调用方可以忽略