让 RAG 记忆学会「忘掉过期的自己」
用户的抱怨很朴素:「分身记住的很多都是死的——同一件事聊了好几次,每次结论都不一样,它怎么还拿最早那个旧的来答?」这不是模型笨,是存储和检索的设计里压根没有「时间」和「作废」这两个概念。本文复盘 v0.5.2 是怎么把陈旧信息压下去的。
根因:检索打平,存储无状态
顺着调用链一路读下来,问题出在三个地方:
- 检索只看相似度。记忆召回和知识库检索最终都按余弦相似度排序,时间从不进打分函数。同一主题的新旧结论,相似度差不多,就被一视同仁地喂给大模型。
- 存储没有「作废 / 版本 / 主题」字段。记忆表和分块表只有
created_at,没有updated_at / status / superseded_by。写入就是一条INSERT——同主题不会被更新或作废,只会多插一行。 - 旧的去重逻辑反而「留旧丢新」。原
check_memory_duplicate会按语义相似度跳过保存:新结论和旧结论语义相近 → 判为重复 → 不保存新结论。结果旧结论被留着,新结论被拦在门外。
三条叠在一起,就是用户看到的「死记忆」:旧结论既没被覆盖、也没被遗忘,还和新结论抢召回。
方案:三层,从只读路径到写入路径
① 检索时效加权(最先落地,零风险)
在记忆召回与 KB 检索里把时间纳入打分:最终得分 = 相似度 + 时效权重(越新权重越高),status='active' 才参与召回。纯只读改动,立刻让最新结论优先、过期信息自然沉底。这一层不动写入,先止血。
② 写入最新优先 / 作废旧版
这是根治「留旧丢新」的关键。给记忆加上 topic_key / superseded_by / status:写入新结论后,把同主题旧的 active 行置为 superseded(软删除,保留历史可审计)。同时把 check_memory_duplicate 改为只做逐字匹配——语义相近但表述不同的新结论不再被拦,交由写入环节去标记旧版作废。这样召回永远只拿回最新那一条。
③ 智能过期 GC
原本的清理是「按年龄硬删」:超过 N 天就删,既不理解「这条已被新结论取代」,也可能误删仍相关的内容。改为两层:先删所有 superseded 行,再按主题分组保留最新 N 条、删其余超龄的。知识库侧同步清理作废分块并重建索引。过期信息被真正「忘记」,而不是被粗暴砍掉。
一张对照表
| 维度 | 改之前 | 改之后 |
|---|---|---|
| 检索排序 | 纯相似度,新旧打平 | 相似度 + 时效权重,最新优先 |
| 同主题新结论 | 被语义去重拦掉,旧结论保留 | 写入即作废旧版,召回只返回最新 |
| 文档重投 | 可能新建重复文档 / 残留僵尸分块 | 同 doc 原地替换分块 + 版本号 +1 |
| 过期清理 | 按年龄硬删,可能误删 | 先删作废、再删超龄,按主题留最新 |
验证
Schema 迁移全部走已有的 _ensure_column 模式,对旧库向后兼容。改动经 ruff、pyright(0 error)、类型基线、依赖检查和受影响单测全绿;并补了回归测试:写入语义相近新结论后,旧版应被标 superseded、召回只返回最新。对外接口与配置项不变,升级无感。
一句话总结:让记忆有「时间感」和「作废权」,它就从「越记越乱」变成了「越记越准」。
发布说明见 Linkora v0.5.2 发布记录,完整变更见 CHANGELOG。