返回博客列表
最佳实践2026-08-1319 分钟阅读

时间衰减的第二层:修好误杀之后,它开始悄悄毁掉排序

召回管道归因 · 验收指标盲区 · 排序信号越权 · 逐层剥离实验 · 考古层位第三层

WeClaw_85|时间衰减的第二层:修好误杀之后,它开始悄悄毁掉排序

系列文章第 85 篇 - 召回管道归因 · 验收指标盲区 · 排序信号越权 · 逐层剥离实验 · 考古层位第三层


📚 专栏信息

《从零到一构建跨平台 AI 助手:WeClaw 实战指南》专栏

专栏定位:面向开发者和技术决策者的实战专栏,用真实案例和完整代码带你理解如何构建生产级 AI 应用

本文是一次意外的续集。第 72 篇里我们修好了「时间衰减在阈值过滤之前」导致的大面积误杀,验收从 80% 一次拉到 100%,收工。三个月后,一次为评估外部框架而做的检索基线测量里,同一个函数第二次浮出水面——这次它不再杀掉召回,而是把最该排第一的经验挤到第二、第三。非空返回率 100% 与 Hit@1 45.8% 同时成立,而当年的验收标准恰好只看前一个数字。


👨‍💻 作者与项目

作者简介:翁勇刚 WENG YONGGANG 新概念龙虾-WeClaw 开发团队负责人,一群专注于跨平台 AI 应用的实践者 理念:"再复杂的技术,也能用代码讲清楚"


📝 摘要

本文结构概览: 一个为选型而做的基线测量意外撞见 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@3sem R@3ALL H@1ALL 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@1H@1 增量MRR
S0 原始向量顺序9.095.8%0.979
S1 +_filter_by_outcome9.095.8%+0.0%0.979
S2 +_deduplicate_by_pattern3.695.8%+0.0%0.979
S3 +_filter_by_similarity(0.55)3.595.8%+0.0%0.979
S4 +_apply_time_decay(= 生产)3.545.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 的相似度为 bb < 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 个关键点

  1. 排序信号也会越权:不只是"不得行使否决权",当加权强度足以覆盖相关性差异时,加权本身就在毁坏正确性。翻转门槛 b/a > 0.8 在密集的向量邻域里几乎默认满足
  2. 验收口径可以掩盖缺陷:非空返回率 100% 与 Hit@1 45.8% 同时成立。指标必须落在价值链末端,否则修复报告会给缺陷发一张通行证
  3. 归因用实验不用推理:逐层剥离 + 记录候选数,能区分"被过滤"与"被重排",而这两者的修法完全不同

1 个核心公式

召回质量 = 检索出来(非空) × 检索得对(在结果集里) × 排在最前(能被采纳)
(验收指标必须覆盖三项,任何一项缺测,缺陷都能带着"已验收"的标签存活)

互动环节

思考题

  1. 你的系统里有哪个「便利指标」正在充当验收标准?把它设为满分,再想一遍——用户体验有没有可能仍然是坏的?
  2. 本文的翻转条件是 b/a > 0.8。如果你的管道里也有"乘系数排序"的环节,你的系数对应的翻转门槛是多少?这个门槛在你的真实分数分布里是罕见还是常见?

讨论话题

你有没有遇到过「这个 Bug 明明修过」的时刻,最后发现是当初的验收指标压根测不到它?


下期预告:《24 个 pattern 的图书馆:为什么检索优化到头了,问题其实在采集端》

  • 175 条经验只有 24 个 distinct pattern,去重坍缩率 86.3% 意味着什么
  • outcome 全是 success:一个从未生效的过滤器说明了什么
  • LRU 上限 500 从未触发,而"扩容"曾经被列为一项收益

敬请期待!


版权声明:本文为 CSDN 博主「翁勇刚」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。