WeClaw_85|时间衰减的第二层:修好误杀之后,它开始悄悄毁掉排序
系列文章第 85 篇 - 召回管道归因 · 验收指标盲区 · 排序信号越权 · 逐层剥离实验 · 考古层位第三层
📚 专栏信息
《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏
专栏定位:面向开发者和技术决策者的实战专栏,用真实案例和完整代码带你理解如何构建生产级 AI 应用
本文是一次意外的续集。第 72 篇里我们修好了「时间衰减在阈值过滤之前」导致的大面积误杀,验收从 80% 一次拉到 100%,收工。三个月后,一次为评估外部框架而做的检索基线测量里,同一个函数第二次浮出水面——这次它不再杀掉召回,而是把最该排第一的经验挤到第二、第三。非空返回率 100% 与 Hit@1 45.8% 同时成立,而当年的验收标准恰好只看前一个数字。
👨💻 作者与项目
作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"
- 💻 项目地址:https://github.com/wyg5208/weclaw.git
- 🌐 官网地址:https://weclaw.link
- 📝 作者 CSDN:https://blog.csdn.net/yweng18
- ⭐ 欢迎 Star⭐、Fork🍴、贡献代码🤝
📝 摘要
本文结构概览: 一个为选型而做的基线测量意外撞见 50 个百分点的落差 → 用逐层剥离实验把落差精确归因到某一个函数 → 推导「相似度 80% 翻转带」解释机制 → 剖析上一次修复为何留下了这个缺口(验收指标只看非空率)→ 三个候选修复方案的取舍 → 沉淀验收指标设计的三条原则。
核心问题:一个管道缺陷,如何在「已修复、已验收、已上线」之后继续存活三个月?以及更普遍的一问——你的验收指标测的是「系统有没有回答」,还是「系统回答得对不对」?
关键成果:
- 逐层剥离实测:四步过滤中前三步对 Hit@1 的影响均为 +0.0%,第四步
_apply_time_decay−50.0% - 候选数 3.5 → 3.5 不变,证明这是纯重排造成的损失,不是过滤
- 推导出翻转条件
b/a > 0.8:次优结果只要相似度在最优的 80% 以上且更新,就会反超 - 定位验收盲区:非空返回率 100% 与 Hit@1 45.8% 可以同时成立
- 诚实标注状态:已定位、已归因、尚未修复
适合读者:做 RAG / 检索 / 推荐排序的工程师;负责定义验收标准的技术负责人;对「修复后为什么还有问题」这类考古现场感兴趣的人
阅读时长:约 14 分钟
关键词:时间衰减、召回排序、Hit@1、归因实验、验收指标、EBEAC、排序信号越权
一、它是怎么被撞见的
这个缺陷不是被找出来的,是被撞见的。
上一篇(第 84 篇)记录了一次框架选型:为了判断要不要引入一个记忆层框架,我们写了几个探针脚本,其中一个的任务只是建立现有检索的基线——因为方案里那句「精度未知(无基准)」不能用别人的 benchmark 来填。
探针跑了三路对比:生产 recall() 完整流水线、裸向量检索、SQLite LIKE 降级路径。
| 方案 | lex R@3 | sem R@3 | ALL H@1 | ALL MRR |
|---|---|---|---|---|
A. recall() 完整流水线 | 100.0% | 100.0% | 45.8% | 0.701 |
| B. 裸向量检索(无任何过滤) | 100.0% | 100.0% | 95.8% | 0.979 |
| C. SQLite LIKE 词面检索 | 0.0% | 0.0% | 0.0% | 0.000 |
C 行的 0.0% 是另一个故事(一个用 LIKE '%整条查询%' 做整串匹配的失效降级兵)。真正让人坐直的是 A 和 B:
Recall@3 都是 100%——正确答案总在前三名里。但 Hit@1 一个 95.8%,一个 45.8%。
也就是说:向量检索本身几乎每次都把正确答案排在第一位,而生产管道在拿到这个正确排序之后,有一半的情况把它挪走了。
A 和 B 之间隔着 recall() 的四步过滤。到这里只知道「落差在这四步里」,不知道是哪一步。
二、不要推理,做逐层剥离实验
我当时的第一反应是推理:_deduplicate_by_pattern 的 docstring 写着保留每个 pattern 的最新一条,可能把 top-1 删掉了?_filter_by_similarity 只删低分项,不该动 top-1?
推理是廉价的,也是不可靠的——尤其当四个函数会互相影响时(去重的"保留哪一条"依赖入参顺序,而顺序又被前一步影响)。所以写了第六个探针,把管道逐层剥开,每加一步测一次:
stages = ["S0 原始向量顺序", "S1 +outcome过滤", "S2 +pattern去重",
"S3 +阈值过滤", "S4 +时间衰减(=生产)"]
for q in queries:
base = store._vector_recall(q.text, CANDIDATES) or []
# 每一步都在上一步结果的深拷贝上继续,避免 similarity 被就地修改串味
s0 = copy.deepcopy(base)
s1 = store._filter_by_outcome(copy.deepcopy(s0))
s2 = store._deduplicate_by_pattern(copy.deepcopy(s1))
s3 = store._filter_by_similarity(copy.deepcopy(s2), DEFAULT_MIN_SIMILARITY)
s4 = store._apply_time_decay(copy.deepcopy(s3))
copy.deepcopy 那行不是洁癖。_apply_time_decay 是原地修改 exp.similarity 的:
exp.similarity = exp.similarity * decay
如果各阶段共享同一批对象,后面阶段的乘法会回头污染前面阶段已记录的结果,测出来的"逐层"就是假的。
除了指标,脚本还记录了每一步的平均候选数——这一列是全篇的关键,它能区分「被过滤掉」和「被重排」:
候选数下降说明该步在"过滤";候选数不变而指标变化说明该步在"重排"。
重排步骤若造成 H@1 下降,即为排序信号越权损害相关性。
三、归因结果:一个函数吃掉全部 50 个百分点
175 条真实经验、24 条评测查询,跑出来是这样:
| 阶段 | 平均候选数 | H@1 | H@1 增量 | MRR |
|---|---|---|---|---|
| S0 原始向量顺序 | 9.0 | 95.8% | 0.979 | |
S1 +_filter_by_outcome | 9.0 | 95.8% | +0.0% | 0.979 |
S2 +_deduplicate_by_pattern | 3.6 | 95.8% | +0.0% | 0.979 |
S3 +_filter_by_similarity(0.55) | 3.5 | 95.8% | +0.0% | 0.979 |
S4 +_apply_time_decay(= 生产) | 3.5 | 45.8% | −50.0% | 0.701 |
三个事实一次到位:
第一,落差 100% 来自 _apply_time_decay。 前三步各自 +0.0%,一个百分点都没吃。
第二,它是纯重排,不是过滤。 S3 → S4 的候选数 3.5 → 3.5,一条都没少。所有丢失的 top-1 都还在结果集里,只是不在第一位了。这一点非常重要——它排除了「阈值把好结果压到线下」这个解释(那才是第 72 篇修掉的那个问题),确认了这一次是排序本身出错。
第三,前三步为什么是空转,各有各的原因:
-
_filter_by_outcome过滤outcome=failure的经验,而语料诊断显示库里 175 条全部是success——它当前是个彻底的空操作。 -
_deduplicate_by_pattern把候选从 9.0 砍到 3.6(语料只有 24 个 distinct pattern,重复度极高),但它不动 top-1。这里有个顺手挖出的小发现:def _deduplicate_by_pattern(self, experiences): """E13-(2): 按 abstract_pattern 去重,相同 pattern 只保留最新一条。""" seen_patterns: set[str] = set() result = [] for exp in experiences: pattern = exp.abstract_pattern.strip() if pattern and pattern in seen_patterns: continue # ← 保留的是遍历遇到的第一条 ...docstring 写的是"保留最新一条",实现保留的是"遍历中最先遇到的那一条"。 而入参顺序是向量检索的相似度降序,所以它实际保留的是每个 pattern 里相似度最高的那条。当前行为恰好是我们想要的(也因此 S2 是 +0.0%),但它靠的是上游排序的隐式契约,不是文档宣称的语义——一旦未来有人根据 docstring 把它搬到排序之前,行为就会默默改变。
-
_filter_by_similarity只砍掉 3.6 → 3.5 的尾部低分项,天然不影响首位。
四、机制:为什么 ×0.8 足以翻转第一名
_apply_time_decay 的实现很朴素:
def _apply_time_decay(self, experiences):
now = datetime.now()
for exp in experiences:
days_ago = (now - datetime.fromisoformat(exp.created_at)).days
if days_ago <= 30: decay = 1.0
elif days_ago <= 90: decay = 0.8
else: decay = 0.6
exp.similarity = exp.similarity * decay
experiences.sort(key=lambda e: e.similarity, reverse=True)
return experiences
看起来温和:最坏也就打个六折。但把它放到排序语境里算一下就不温和了。
设最优结果 A 的相似度为 a(年龄 31~90 天,系数 0.8),次优结果 B 的相似度为 b(b < a,年龄 ≤30 天,系数 1.0)。衰减后 B 反超 A 的条件是:
b > 0.8 · a 即 b / a > 0.8
换句话说:只要次优结果的相似度达到最优结果的 80%,并且更新一档,它就会篡位。
这个门槛低得可怕。语义向量检索的 top-3 邻域天然密集——同一个任务的相关经验,相似度往往彼此相差不到两成。b/a > 0.8 在这种分布里几乎是默认满足的。
再加上库里的年龄构成,条件的另一半也现成:
经验年龄分布: {'<=30d': 53, '31-90d': 122, '>90d': 0}
衰减系数: <=30d ×1.0 / 31-90d ×0.8 / >90d ×0.6
122 条(69.7%)在 ×0.8 档,53 条(30.3%)在 ×1.0 档。 两档混编,任何一次 top-k 检索的候选里几乎必然同时出现两种系数——翻转的两个条件同时具备。实测 −50.0% 不是巧合,是这个机制的必然产出。
这里值得停一下,因为它揭示了一个比"少乘个系数"更本质的问题:衰减系数的量纲和相似度的量纲没有可比性。
×0.8 想表达的语义是"这条经验旧一点,同等条件下让新的先来"。但它落地成了"在相似度这个连续量上扣掉 20%",而 20% 的相似度在语义空间里可能意味着从"高度相关"跌到"勉强相关"。用一个乘数去换算「新旧」和「相关」,等于宣称一档新鲜度值 0.2 的相似度——这个汇率从来没有人论证过。
五、上一次修复为什么没修到这里
这是本文最值得写的部分。
第 72 篇的现场是:修复前的顺序是「衰减 → 阈值」,0.649 × 0.8 = 0.519 < 0.55,本应召回的经验被误杀。修复是把阈值挪到衰减之前,让门控看原始分数:
results = self._filter_by_outcome(results) # (1)
results = self._deduplicate_by_pattern(results) # (2)
# (3) 阈值过滤基于原始相似度(相关性门控),必须在时间衰减之前,
# 否则旧经验的等效阈值被抬高至 0.69~0.92,导致大面积误杀
results = self._filter_by_similarity(results, min_similarity)
results = self._apply_time_decay(results) # (4) 时间衰减仅用于排序加权
修复当时提炼的原则是**「相关性门控与排序加权必须职责分离」**,并且明确写下"时间衰减仅用于排序加权"。原则完全正确,代码也严格落实了它。
问题出在最后半句:我们把"仅用于排序加权"当成了安全区。
潜台词是——排序加权是无害的,它只影响优先级,不影响正确性。而这次的数据证明:排序加权可以毁掉排序本身。当"谁优先"的信号强度足以覆盖"谁更相关"的差异时,加权就不再是加权,它变成了另一种形式的越权。第 72 篇说"排序信号不得行使否决权",本轮补上后半句:排序信号也不得压倒相关性信号——否决权和优先权都是权。
更要紧的是验收口径。第 72 篇的验收标准原文是:
15 条评估集查询打真实生产向量库,走完整的
ExperienceStore.recall()生产路径,非空返回率 ≥90%。
修复后 15/15 = 100%,一次通过。
非空返回率只问「有没有返回」,不问「返回得对不对」。 而这次的缺陷恰好只损害后者:它一条候选都不删,只是把顺序搅乱。所以在 100% 非空的验收报告下,它安然通过、上线、并存活了三个月。
真实的用户价值链条是这样的:
非空返回 → 返回里包含正确答案 → 正确答案排在第一位 → 注入提示词的是它
recall() 的调用方取 top_k 条注入系统提示词,排第一的那条权重最高、最可能被模型采纳。所以链条的最后一环才是价值兑现的地方。而当年的验收指标停在第一环。
第 72 篇自己给出了这个现象的名字:
修好一个大 Bug,经常会让它身后的小 Bug 第一次暴露出来。这不是回归,是"考古层位"——每一层缺陷只有在上一层清除后才可见。
原文说的第一层是模型:MiniLM 时代相似度整体塌在 0.387,连名义阈值都过不去,顺序缺陷没有表演机会。第二层是顺序:换 bge 后分数抬升,误杀第一次现形。
现在有了第三层,而它的存活机制不同于前两层。 前两层是被上游缺陷掩盖的,这一层是被上一次修复的验收口径掩盖的——量它的尺子测不出它。
这是一种更隐蔽的层位:你不仅要担心"上游缺陷挡住了下游缺陷",还要担心"上一次验收的指标口径本身构成了盲区"。
顺带一提,第 72 篇的思考题第 1 题是:
如果业务上确实希望"90 天以上的经验更难被召回",正确的实现方式是什么?(提示:调的应该是门槛本身,而不是让排序系数越权)
本轮的数据把这道题的答案又推进了一步——不只是"不该让排序系数行使否决权",而是"新鲜度这种偏好,根本不该用乘法作用在相似度上"。
六、修复方向:三个候选与取舍
必须先说清状态:本轮授权范围只有取证,不含改生产代码。这个缺陷已定位、已归因,尚未修复。 下面是设计层面的取舍分析,三个方案都还没有实测数据支撑。
方案一:只在相似度接近时才让新鲜度介入(推荐起点)
先按原始相似度排序;仅当相邻两条的相似度差 < ε 时,才用年龄做次级排序键
它把新鲜度从"连续量上的乘法"降级为"平局裁决",彻底消除量纲换算问题。代价是引入一个新参数 ε,需要用现有探针扫一遍取值。这是目前最符合"新鲜度只是偏好而非相关性"这一定位的做法。
方案二:大幅压缩衰减幅度
把 0.8 / 0.6 改成 0.97 / 0.93 之类。改动最小,但它只是把翻转门槛从 b/a > 0.8 抬到 b/a > 0.97,机制没变,只是概率降低。同类缺陷会在语料分布变化后重新出现,属于治标。
方案三:把衰减移出 recall(),交给调用方
recall() 只负责"给出最相关的 k 条",注入提示词时若需要偏好新经验,由上层排。职责最干净,但改动面最大,且当前只有一个主要调用方,收益与成本不匹配。留作后续架构演进的方向,不作为本次修复。
无论选哪个,验收指标必须换:从「非空返回率」换成「Hit@1 + MRR@5」,并保留非空返回率作为回归护栏(防止修排序时把第 72 篇的误杀问题带回来)。好消息是,度量工具已经现成——本轮的六个探针脚本本身就是这套指标的实现,修完直接重跑 06 看 S4 那一行能不能回到 95.8%。
七、可迁移的三条经验
7.1 验收指标必须落在价值链的末端
「非空返回率」是一个便利指标:好测、好达标、看起来直接。但它测的是链条第一环,而价值在最后一环兑现。
判断一个验收指标够不够,问一句:这个指标满分时,用户体验有没有可能仍然是坏的? 如果答案是"有可能",那它就不是验收指标,只是一个健康检查。
非空返回率 100% + Hit@1 45.8% 就是这句反问的具体形态。
7.2 修复一个缺陷时,要给"修法本身"设一个反向假设
第 72 篇的修法是把"衰减"归类为"仅用于排序加权",然后就不再审视它了。分类动作本身建立了一个未经检验的假设:排序加权是安全的。
更稳的做法是在修复时顺手写下这个假设,并给它一个证伪条件:
假设:
_apply_time_decay只影响优先级,不影响正确性。 证伪方式:测一次 Hit@1,看排序步骤前后是否变化。
这个测试当时就能做,成本是几行代码。它没做,是因为验收标准没要求,而验收标准没要求是因为我们相信那个假设。假设不写下来,就不会被测试。
7.3 归因要用剥离实验,不要用推理
管道类缺陷最容易被"看代码想一想"骗过去——四个函数彼此有顺序耦合,人脑推不准。逐层剥离实验的成本极低(本轮 145 行),产出的是归因而不是猜测。
尤其记得多测一列候选数。指标告诉你"变差了",候选数告诉你"是被删了还是被挪了"——这两种结论指向完全不同的修法。本轮如果只看指标不看候选数,很容易误判成"又是阈值问题",然后去调阈值,而阈值根本无辜。
八、总结
3 个关键点:
- 排序信号也会越权:不只是"不得行使否决权",当加权强度足以覆盖相关性差异时,加权本身就在毁坏正确性。翻转门槛
b/a > 0.8在密集的向量邻域里几乎默认满足 - 验收口径可以掩盖缺陷:非空返回率 100% 与 Hit@1 45.8% 同时成立。指标必须落在价值链末端,否则修复报告会给缺陷发一张通行证
- 归因用实验不用推理:逐层剥离 + 记录候选数,能区分"被过滤"与"被重排",而这两者的修法完全不同
1 个核心公式:
召回质量 = 检索出来(非空) × 检索得对(在结果集里) × 排在最前(能被采纳)
(验收指标必须覆盖三项,任何一项缺测,缺陷都能带着"已验收"的标签存活)
互动环节
思考题:
- 你的系统里有哪个「便利指标」正在充当验收标准?把它设为满分,再想一遍——用户体验有没有可能仍然是坏的?
- 本文的翻转条件是
b/a > 0.8。如果你的管道里也有"乘系数排序"的环节,你的系数对应的翻转门槛是多少?这个门槛在你的真实分数分布里是罕见还是常见?
讨论话题:
你有没有遇到过「这个 Bug 明明修过」的时刻,最后发现是当初的验收指标压根测不到它?
下期预告:《24 个 pattern 的图书馆:为什么检索优化到头了,问题其实在采集端》
- 175 条经验只有 24 个 distinct pattern,去重坍缩率 86.3% 意味着什么
outcome全是 success:一个从未生效的过滤器说明了什么- LRU 上限 500 从未触发,而"扩容"曾经被列为一项收益
敬请期待!
版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。