tipsy-memory · retrieve

记忆检索流程与评分标准

POST /v1/memory/retrieve 的召回、七维打分与预算截断,附 usage 权重的自我强化陷阱

日期 2026-07-02 来源 源码核对 文件 memory_service.py · vector.py · config.py
📖

本文说明 retrieve 的完整链路与七维打分公式,并解释一个实测陷阱:把 usage 权重调大后,无论请求什么 query 都会返回相同的 mempoints——因为 usage 分与 query 无关,且被检索会自我强化。所有数字均来自源码核对。

01检索全流程

入口 MemoryService.retrieve。请求参数:user_id / character_id / session_id / query / limit / max_tokens / min_msg_id / max_msg_id / prefix_only

  1. Embed query — 对 query 调 embedding(bailian text-embedding-v4)。失败/超时/限流则跳过向量检索。
  2. 向量召回 search_similar — cosine KNN,limit = min(limit×20, 100)。SQL 按 session_id 硬过滤,无 distance 阈值,只 ORDER BY 距离 + LIMIT。
  3. 重要性兜底 search_recent_important — 不依赖语义,按 importance DESC 取高价值候选补充;同样按 session_id 硬过滤,distance 记 0。
  4. 合并去重 — 两路候选并进 candidate_by_id,向量分优先,兜底补位。
  5. 七维打分 _score_retrieve_candidate — 对每个候选算出 score(见第二节)。
  6. 过滤 — 一般情况仅保留 score > 0;prefix_only / 无 query 带范围时另按 msg 范围过滤。
  7. 排序 — 按 (−score, −updated_at, −id)。
  8. rerank — 满足条件时对 top-8 交叉编码器重排并混合。
  9. 预算截断 — 贪心选取,受 limit 与 max_tokens 双重约束。
  10. 回写 usage 陷阱根源 — 被选中的 mempoint retrieve_count += 1、刷新 last_retrieved_at。
ℹ️

召回唯一硬闸门是 session_id,没有 distance 阈值。跨会话记忆靠调用方 tipsy-backend 沿父会话链对每个 scope 分别 retrieve 再 merge(最多 10 级),memory 服务本身只看单 session。

02七维评分公式

基础分是七个维度的加权和,再叠加三个微调项,最后 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)
七维评分权重(默认)
七维之和 = 1.0 · 蓝=随 query 变化,灰=与 query 无关
随 query 变化 与 query 无关

只有 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 且有词面命中)。

03各维度打分细节

维度公式魔数
semantic1 − cosine 距离,clamp[0,1]兜底候选记 0
lexicalquery 词集 ∩ content 词集 / query 词数≥2 字符 token + CJK 2/3-gram
importanceimportance / 100生产均值 76 / 中位 80
recency1 − days_since_updated / 3030 天线性归零
range区间重叠 1.0;不重叠 1 − 边距/20边距阈值 20
type固定权重表(见下图)0.45 ~ 1.0
usagelast_used×0.8 + count×0.214 天 / log1p(20) 饱和
type 维度:各记忆类型的固定权重
单序列 · 值越高越优先注入

usage 维度的构成

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 在每次命中后写回递增。这是下一节陷阱的机制核心。

04rerank 与预算截断

参数默认作用
candidate_multiplier20候选量 = limit×20
candidate_max100候选量上限
rerank_top_k8重排候选数
rerank_blend_weight0.5base 与 rerank 分混合比
rerank_confident_margin0.08分差足够则跳过 rerank
total_budget_seconds1.35单次 retrieve 总时间预算

05usage 权重陷阱

⚠️

现象(实测):把 usage 权重调到很大后,无论 query 是什么,retrieve 总是返回同一批 mempoints。

原因是 usage 维度有两个致命特性叠加:

1)usage 分与 query 完全无关

usage = f(retrieve_count, last_retrieved_at),两个输入都是 mempoint 的历史统计,跟本次 query 文本、跟语义/词面都没关系。usage 权重占主导时,排序几乎只由「这条历史上被查过多少次 / 最近查过没有」决定——不管你搜什么,排前面的永远是那几条「曾经的热门记忆」。本该负责相关性的 semantic(0.38)+lexical(0.18) 被挤到无足轻重。

2)自我强化正反馈(富者愈富)

_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 分,被永久压制)。

自我强化闭环
被选中 → usage 分升高 → 更靠前 → 更易再被选中;其余记忆被永久压制

为什么默认权重是 0.05

正因上述两点,代码默认把 retrieve_score_usage_weight 设为 0.05——作为「轻微加成」参与排序,但占比很小,不会喧宾夺主。它更适合做「轻微加成」(几个百分点),绝不能作为主判据。只有 semantic(0.38)+lexical(0.18)=0.56 是真正「跟着 query 走」的权重,它们才应占主导。

06调参建议


源码位置:打分 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。