POST /v1/memory/retrieve 的召回、七维打分与预算截断,附 usage 权重的自我强化陷阱
本文说明 retrieve 的完整链路与七维打分公式,并解释一个实测陷阱:把 usage 权重调大后,无论请求什么 query 都会返回相同的 mempoints——因为 usage 分与 query 无关,且被检索会自我强化。所有数字均来自源码核对。
入口 MemoryService.retrieve。请求参数:user_id / character_id / session_id / query / limit / max_tokens / min_msg_id / max_msg_id / prefix_only。
search_similar — cosine KNN,limit = min(limit×20, 100)。SQL 按 session_id 硬过滤,无 distance 阈值,只 ORDER BY 距离 + LIMIT。search_recent_important — 不依赖语义,按 importance DESC 取高价值候选补充;同样按 session_id 硬过滤,distance 记 0。_score_retrieve_candidate — 对每个候选算出 score(见第二节)。score > 0;prefix_only / 无 query 带范围时另按 msg 范围过滤。召回唯一硬闸门是 session_id,没有 distance 阈值。跨会话记忆靠调用方 tipsy-backend 沿父会话链对每个 scope 分别 retrieve 再 merge(最多 10 级),memory 服务本身只看单 session。
基础分是七个维度的加权和,再叠加三个微调项,最后 clamp 到 [0,1]:
base_score = Σ 维度分 × 权重base = semantic×0.38 + lexical×0.18 + importance×0.22
+ recency×0.04 + range×0.06 + type×0.12 + usage×0.05
score = clamp(base + identity_penalty + intent_boost + important_match_boost, 0, 1)
只有 semantic + lexical(=0.56)随 query 内容变化,range 随请求 msg 范围变化,其余四维只由 mempoint 自身属性决定——这决定了第五节的陷阱。
微调项:identity_penalty −0.08(非身份类 query 命中身份类记忆)· intent_boost +0.04(event/relationship 且有词面命中)· important_match_boost +0.03(importance≥0.9 且有词面命中)。
| 维度 | 公式 | 魔数 |
|---|---|---|
| semantic | 1 − cosine 距离,clamp[0,1] | 兜底候选记 0 |
| lexical | query 词集 ∩ content 词集 / query 词数 | ≥2 字符 token + CJK 2/3-gram |
| importance | importance / 100 | 生产均值 76 / 中位 80 |
| recency | 1 − days_since_updated / 30 | 30 天线性归零 |
| range | 区间重叠 1.0;不重叠 1 − 边距/20 | 边距阈值 20 |
| type | 固定权重表(见下图) | 0.45 ~ 1.0 |
| usage | last_used×0.8 + count×0.2 | 14 天 / log1p(20) 饱和 |
memory_service.py:1007-1021usage = last_used_score×0.8 + count_score×0.2
# last_used_score = 1 − days_since_last_retrieved / 14 (14 天线性归零)
# count_score = log1p(retrieve_count) / log1p(20) (count=20 时饱和为 1)
usage 的两个输入 retrieve_count 与 last_retrieved_at 都与当前 query 无关,纯粹是「这条 mempoint 历史上被检索了多少次、最近一次多久以前」。而且它们由 _record_retrieve_usage 在每次命中后写回递增。这是下一节陷阱的机制核心。
final = base×0.5 + relevance×0.5。以下跳过:无 query / 关闭 / embedding 失败 / 候选≤1 / 预算不足(总 1.35s,剩余 < 0.40s)/ 置信度足够(top1 − top2 ≥ 0.08)。token = ceil(len/4),累计 ≤ max_tokens,条数 ≤ limit。选中即触发 usage 回写。| 参数 | 默认 | 作用 |
|---|---|---|
| candidate_multiplier | 20 | 候选量 = limit×20 |
| candidate_max | 100 | 候选量上限 |
| rerank_top_k | 8 | 重排候选数 |
| rerank_blend_weight | 0.5 | base 与 rerank 分混合比 |
| rerank_confident_margin | 0.08 | 分差足够则跳过 rerank |
| total_budget_seconds | 1.35 | 单次 retrieve 总时间预算 |
现象(实测):把 usage 权重调到很大后,无论 query 是什么,retrieve 总是返回同一批 mempoints。
原因是 usage 维度有两个致命特性叠加:
usage = f(retrieve_count, last_retrieved_at),两个输入都是 mempoint 的历史统计,跟本次 query 文本、跟语义/词面都没关系。usage 权重占主导时,排序几乎只由「这条历史上被查过多少次 / 最近查过没有」决定——不管你搜什么,排前面的永远是那几条「曾经的热门记忆」。本该负责相关性的 semantic(0.38)+lexical(0.18) 被挤到无足轻重。
_record_retrieve_usage · memory_service.py:1361 — 每次命中即递增retrieve_count = retrieve_count + 1
last_retrieved_at = now # last_used_score 立刻回到 1.0(14 天才衰减)
一条 mempoint 被选中 → count+1、last_retrieved_at 刷新到现在 → usage 分更高 → 下次更容易再被选中 → count 再 +1……形成闭环。usage 权重越大,收敛越快:几次检索后头部若干条把所有人甩开,此后无论什么 query 都锁定这批,其他记忆再也没机会被选中(也就永远拿不到 usage 分,被永久压制)。
正因上述两点,代码默认把 retrieve_score_usage_weight 设为 0.05——作为「轻微加成」参与排序,但占比很小,不会喧宾夺主。它更适合做「轻微加成」(几个百分点),绝不能作为主判据。只有 semantic(0.38)+lexical(0.18)=0.56 是真正「跟着 query 走」的权重,它们才应占主导。
源码位置:打分 app/services/memory_service.py(_score_retrieve_candidate / _usage_score / _record_retrieve_usage)· 召回 app/infra/vector.py · 权重默认值 app/config.py:121-139。文档生成 2026-07-02。