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:
1 parent
6cb41a8794
commit
cb564fe981
5 files changed
+532
-66
No files matched your search
@@ -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 条相关记忆,并把它们格式化为
|
||||
// 紧凑的上下文块,可供注入提示词。没有检索到任何内容时
|
||||
// 返回空字符串,因此没有相关记忆时调用方可以忽略
|
||||
|
||||
Reference in new issue
Block a user