WeClaw_72|时间衰减的隐形杀手:离线评估 93%,上线为什么只有 80%?
第五季系列文章第 3 篇(总第 72 篇) - 过滤顺序缺陷 · 等效阈值抬升 · 相关性门控与排序加权分离 · 端到端验证的必要性
📚 专栏信息
《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏 · 第五季
专栏定位:面向开发者和技术决策者的实战专栏,用真实案例和完整代码带你理解如何构建生产级 AI 应用
本文记录一次"最后一公里"翻车与修复:嵌入模型替换在离线评估中拿到 93.3% 非空返回率,向量库重建顺利,一切按剧本推进——直到端到端验证只跑出 80%,差 10 个点够不到验收线。三个落空的查询把我们引向一个潜伏已久的设计缺陷:时间衰减在阈值过滤之前执行,旧经验的等效召回阈值被悄悄抬高到 0.69~0.92。修复只改了两行代码的顺序,但背后的设计原则值得写一篇。
👨💻 作者与项目
作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"
- 💻 项目地址:https://github.com/wyg5208/weclaw.git
- 🌐 官网地址:https://weclaw.link
- 📝 作者 CSDN:https://blog.csdn.net/yweng18
- ⭐ 欢迎 Star⭐、Fork🍴、贡献代码🤝
📝 摘要
本文结构概览: 端到端验证结果与离线评估出现 10 个点缺口 → 用诊断脚本剥开召回管道逐层定位 → 发现时间衰减与阈值过滤的顺序缺陷及其"等效阈值抬升"效应 → 两行修复 + 职责分离原则 → 复盘为什么离线评估注定发现不了这个问题。
核心问题:召回管道里两个各自正确的机制(相似度阈值过滤、时间衰减加权),因为执行顺序错误产生了破坏性的复合效应——衰减系数把已过阈的原始相似度拉到阈值之下,31~90 天的经验(占库存 58%)被系统性误杀。
关键成果:
- 非空返回率 80% → 100%(15/15),修复仅调整过滤顺序
- 提炼"相关性门控 vs 排序加权"职责分离原则:阈值判断必须基于原始相似度
- 量化缺陷影响面:×0.8 衰减档等效阈值 0.69,×0.6 档等效阈值 0.92(形同封杀)
- 建立"离线评估 + 端到端验证"双层验收的必要性论证
适合读者:做 RAG / 记忆系统 / 推荐排序的工程师;所有在管道里串联多个过滤器的人
阅读时长:约 12 分钟
关键词:时间衰减、阈值过滤、召回管道、等效阈值、端到端验证、经验召回
一、10 个点的缺口:93.3% vs 80%
背景交代(详见上一篇):WeClaw 的跨会话经验召回因英文模型处理中文语料而名存实亡,我们用四轮离线评估选定 bge-base-zh-v1.5,离线模拟的非空返回率 93.3%。模型切换、向量库全量重建(169 条经验)、阈值 0.6→0.55,全部按方案落地。
验收标准写得很明确:15 条评估集查询打真实生产向量库,走完整的 ExperienceStore.recall() 生产路径,非空返回率 ≥90%。
跑出来:12/15 = 80%。
三条查询空手而归。离线评估同样的模型、同样的语料、同样的阈值,是 93.3%——中间差的这 10 个点在哪?
二、剥开管道:三个落空查询的诊断
召回管道长这样:
query → 向量检索(top_k×3) → outcome过滤 → pattern去重
→ 时间衰减(×1.0/×0.8/×0.6) → 阈值过滤(≥0.55) → top_k截断
我们给验证脚本加了一个诊断分支:召回落空时,绕过 recall() 直接调用内部的 _vector_recall(),把原始相似度和经验年龄打出来:
if not results: # recall() 返回空,下钻诊断
raw = store._vector_recall(query, top_k=9)
for exp in raw or []:
age = (now - datetime.fromisoformat(exp.created_at)).days
print(f" raw_sim={exp.similarity:.3f} age={age}d {exp.trigger[:40]}")
三个落空查询的诊断输出把答案直接拍在脸上:
query: "浏览器自动化执行失败怎么处理"
raw_sim=0.649 age=42d ← 原始相似度过阈(0.55),为什么没返回?
raw_sim=0.601 age=35d
query: "股票数据查询返回为空"
raw_sim=0.625 age=50d
原始相似度 0.6250.649,全部高于 0.55 阈值,本应召回。共同点只有一个:年龄都在 3190 天区间。
再看 recall() 的过滤顺序(改动前):
# E13: 三层过滤(改动前的顺序)
results = self._filter_by_outcome(results)
results = self._deduplicate_by_pattern(results)
results = self._apply_time_decay(results) # 先衰减:31-90天 ×0.8
results = self._filter_by_similarity(results, min_similarity) # 后过滤:≥0.55
真相大白:_apply_time_decay 把 31~90 天经验的相似度先乘了 0.8,然后才做阈值过滤。0.649 × 0.8 = 0.519 < 0.55,出局;0.625 × 0.8 = 0.500,出局。
三、等效阈值:一个被顺序放大的设计缺陷
把这个效应一般化。当衰减发生在阈值过滤之前,对不同年龄的经验,实际生效的召回门槛是:
| 经验年龄 | 衰减系数 | 名义阈值 | 等效阈值(名义÷系数) |
|---|---|---|---|
| ≤30 天 | ×1.0 | 0.55 | 0.55 |
| 31~90 天 | ×0.8 | 0.55 | 0.6875 |
| >90 天 | ×0.6 | 0.55 | 0.9167 |
回想上一篇的数据:bge-base 的正样本相似度均值是 0.619。也就是说:
- 31~90 天的经验,等效阈值 0.69——高于正样本均值,一半以上的相关经验被挡在门外;
- 90 天以上的经验,等效阈值 0.92——在真实相似度分布里几乎不存在,形同永久封杀。
而当时库里的年龄分布是:≤30 天 71 条,31~90 天 98 条(58%),>90 天 0 条。过半库存被系统性加税,这就是 10 个点缺口的全部来源。
更值得玩味的是:这个缺陷在 MiniLM 时代就存在,但从未被发现。因为 MiniLM 的正样本均值只有 0.387,绝大多数查询连 0.6 的名义阈值都过不了——管道上游已经全灭,下游的顺序缺陷根本没有表演机会。换了强模型,相似度分布整体抬升,这个缺陷才第一次拦截到"本应活着"的召回。
修好一个大 Bug,经常会让它身后的小 Bug 第一次暴露出来。这不是回归,是"考古层位"——每一层缺陷只有在上一层清除后才可见。
四、修复:两行顺序 + 一条设计原则
修复本身极简单,把阈值过滤挪到衰减之前:
# E13: 三层过滤(修复后)
results = self._filter_by_outcome(results) # (1) 过滤 outcome=failure
results = self._deduplicate_by_pattern(results) # (2) 按 abstract_pattern 去重
# (3) 阈值过滤基于原始相似度(相关性门控),必须在时间衰减之前,
# 否则旧经验的等效阈值被抬高至 0.69~0.92,导致大面积误杀
results = self._filter_by_similarity(results, min_similarity)
results = self._apply_time_decay(results) # (4) 时间衰减仅用于排序加权
但两行代码背后是一条值得沉淀的设计原则——相关性门控与排序加权必须职责分离:
- 阈值过滤回答的是"相关吗?"——这是语义问题,判据只能是原始相似度。一条经验和当前任务相关,不会因为它是两个月前记录的就变得不相关。
- 时间衰减回答的是"谁优先?"——这是排序问题,同样相关的两条经验,新的排前面(工具行为可能变化、新经验更贴近现状),合情合理。
顺序颠倒的本质,是让一个排序信号越权行使了否决权。类似的越权在各种管道里反复出现:推荐系统里新鲜度权重把相关结果压出截断线、搜索引擎里质量分衰减误杀长尾正解——模式同构,都值得用"等效阈值"这把尺子量一量。
修复后复测:15/15 = 100% 非空返回,一次通过验收线。
五、复盘:为什么离线评估注定发现不了它?
这个问题必须诚实回答,否则下次还会栽在同一类坑里。
离线评估脚本模拟线上召回时,复刻了模型、语料、阈值、top_k——唯独没有复刻时间维度。评估语料在脚本里是"无年龄"的(或者说全部视为新鲜数据),_apply_time_decay 在模拟中等价于乘 1.0,顺序缺陷自然隐形。
而真实库里 58% 的经验落在 ×0.8 衰减档。离线评估的保真度缺口,恰好就是数据的时间属性。
由此提炼本次迭代最重要的一条流程经验:
离线评估回答"模型选得对不对"
端到端验证回答"系统合在一起对不对"
两层都过,才算过
如果当时图省事,看到离线 93.3% 就跳过打真实库的端到端验证,这个缺陷会带着"已验收"的标签上线,继续悄无声息地杀掉过半召回——而且没人会怀疑刚换的新模型。
避坑 Checklist
- 管道里每个"乘系数"的环节,检查它下游有没有基于绝对值的判断(阈值/截断/分级)
- 对每个衰减档位算一次等效阈值,对照真实分数分布看影响面
- 离线评估清单里显式列出"未覆盖的线上因素"(时间、并发、脏数据……),逐项决定是否需要端到端补测
- 修复大缺陷后,预期会有次级缺陷首次暴露,验收标准不因"已经修了一个"而放松
六、总结
3 个关键点:
- 等效阈值:衰减系数 × 位置错误 = 隐形加税,×0.8 的温和衰减在阈值前执行就变成 0.69 的凶狠门槛
- 职责分离:门控看原始分数(相关性),加权只管排序(优先级),排序信号不得行使否决权
- 双层验收:离线评估的保真度总有缺口,端到端验证是唯一的"合并测试"
1 个核心公式:
召回管道正确性 = 过滤器各自正确 + 顺序正确(门控在前,加权在后)
互动环节
思考题:
- 如果业务上确实希望"90 天以上的经验更难被召回",正确的实现方式是什么?(提示:调的应该是门槛本身,而不是让排序系数越权)
- 你的管道里,去重放在阈值过滤之前还是之后?两种顺序各会产生什么行为差异?
讨论话题:
你遇到过"修好一个大 Bug 之后,另一个老 Bug 才第一次现身"的考古时刻吗?
下期预告:《向量库重建实战:维度变更、10000 字符截断陷阱与文件锁》
- 384→768 维为什么必须全量重建
- 从 stored_path 重新解析,381 chunk 变 1311 chunk 的意外收获
- ChromaDB 文件锁与 git stash 对照归因法
敬请期待!
版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。