尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

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

WeClaw_85|时间衰减的第二层:修好误杀之后,它开始悄悄毁掉排序 Hi带娃的我热爱AI 大模型应用落地、意识解码与 AI 开发工具链。 创业路上用技术换时间一起把 AI 变成生产力 WeClaw_85时间衰减的第二层修好误杀之后它开始悄悄毁掉排序系列文章第 85 篇- 召回管道归因 · 验收指标盲区 · 排序信号越权 · 逐层剥离实验 · 考古层位第三层 专栏信息《从零到一构建跨平台 AI 助手WeClaw 实战指南》专栏专栏定位面向开发者和技术决策者的实战专栏用真实案例和完整代码带你理解如何构建生产级 AI 应用本文是一次意外的续集。第 72 篇里我们修好了「时间衰减在阈值过滤之前」导致的大面积误杀验收从 80% 一次拉到 100%收工。三个月后一次为评估外部框架而做的检索基线测量里同一个函数第二次浮出水面——这次它不再杀掉召回而是把最该排第一的经验挤到第二、第三。非空返回率 100% 与 Hit1 45.8% 同时成立而当年的验收标准恰好只看前一个数字。‍ 作者与项目作者简介翁勇刚 WENG YONGGANG新概念龙虾-WeClaw 开发团队负责人一群专注于跨平台 AI 应用的实践者理念“再复杂的技术也能用代码讲清楚” 项目地址https://github.com/wyg5208/weclaw.git 官网地址https://weclaw.link 作者 CSDNhttps://blog.csdn.net/yweng18⭐ 欢迎 Star⭐、Fork、贡献代码 摘要本文结构概览一个为选型而做的基线测量意外撞见 50 个百分点的落差 → 用逐层剥离实验把落差精确归因到某一个函数 → 推导「相似度 80% 翻转带」解释机制 → 剖析上一次修复为何留下了这个缺口验收指标只看非空率→ 三个候选修复方案的取舍 → 沉淀验收指标设计的三条原则。核心问题一个管道缺陷如何在「已修复、已验收、已上线」之后继续存活三个月以及更普遍的一问——你的验收指标测的是「系统有没有回答」还是「系统回答得对不对」关键成果逐层剥离实测四步过滤中前三步对 Hit1 的影响均为0.0%第四步_apply_time_decay−50.0%候选数 3.5 → 3.5 不变证明这是纯重排造成的损失不是过滤推导出翻转条件b/a 0.8次优结果只要相似度在最优的 80% 以上且更新就会反超定位验收盲区非空返回率 100% 与 Hit1 45.8% 可以同时成立诚实标注状态已定位、已归因、尚未修复适合读者做 RAG / 检索 / 推荐排序的工程师负责定义验收标准的技术负责人对「修复后为什么还有问题」这类考古现场感兴趣的人阅读时长约 14 分钟关键词时间衰减、召回排序、Hit1、归因实验、验收指标、EBEAC、排序信号越权一、它是怎么被撞见的这个缺陷不是被找出来的是被撞见的。上一篇第 84 篇记录了一次框架选型为了判断要不要引入一个记忆层框架我们写了几个探针脚本其中一个的任务只是建立现有检索的基线——因为方案里那句「精度未知无基准」不能用别人的 benchmark 来填。探针跑了三路对比生产recall()完整流水线、裸向量检索、SQLite LIKE 降级路径。方案lex R3sem R3ALL H1ALL MRRA.recall()完整流水线100.0%100.0%45.8%0.701B. 裸向量检索无任何过滤100.0%100.0%95.8%0.979C. SQLite LIKE 词面检索0.0%0.0%0.0%0.000C 行的 0.0% 是另一个故事一个用LIKE %整条查询%做整串匹配的失效降级兵。真正让人坐直的是 A 和 BRecall3 都是 100%——正确答案总在前三名里。但 Hit1 一个 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 时间衰减(生产)]forqinqueries:basestore._vector_recall(q.text,CANDIDATES)or[]# 每一步都在上一步结果的深拷贝上继续避免 similarity 被就地修改串味s0copy.deepcopy(base)s1store._filter_by_outcome(copy.deepcopy(s0))s2store._deduplicate_by_pattern(copy.deepcopy(s1))s3store._filter_by_similarity(copy.deepcopy(s2),DEFAULT_MIN_SIMILARITY)s4store._apply_time_decay(copy.deepcopy(s3))copy.deepcopy那行不是洁癖。_apply_time_decay是原地修改exp.similarity的exp.similarityexp.similarity*decay如果各阶段共享同一批对象后面阶段的乘法会回头污染前面阶段已记录的结果测出来的逐层就是假的。除了指标脚本还记录了每一步的平均候选数——这一列是全篇的关键它能区分「被过滤掉」和「被重排」候选数下降说明该步在过滤候选数不变而指标变化说明该步在重排。 重排步骤若造成 H1 下降即为排序信号越权损害相关性。三、归因结果一个函数吃掉全部 50 个百分点175 条真实经验、24 条评测查询跑出来是这样阶段平均候选数H1H1 增量MRRS0 原始向量顺序9.095.8%0.979S1_filter_by_outcome9.095.8%0.0%0.979S2_deduplicate_by_pattern3.695.8%0.0%0.979S3_filter_by_similarity(0.55)3.595.8%0.0%0.979S4_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过滤outcomefailure的经验而语料诊断显示库里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[]forexpinexperiences:patternexp.abstract_pattern.strip()ifpatternandpatterninseen_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):nowdatetime.now()forexpinexperiences:days_ago(now-datetime.fromisoformat(exp.created_at)).daysifdays_ago30:decay1.0elifdays_ago90:decay0.8else:decay0.6exp.similarityexp.similarity*decay experiences.sort(keylambdae:e.similarity,reverseTrue)returnexperiences看起来温和最坏也就打个六折。但把它放到排序语境里算一下就不温和了。设最优结果 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.6122 条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本应召回的经验被误杀。修复是把阈值挪到衰减之前让门控看原始分数resultsself._filter_by_outcome(results)# (1)resultsself._deduplicate_by_pattern(results)# (2)# (3) 阈值过滤基于原始相似度相关性门控必须在时间衰减之前# 否则旧经验的等效阈值被抬高至 0.69~0.92导致大面积误杀resultsself._filter_by_similarity(results,min_similarity)resultsself._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 条注入提示词时若需要偏好新经验由上层排。职责最干净但改动面最大且当前只有一个主要调用方收益与成本不匹配。留作后续架构演进的方向不作为本次修复。无论选哪个验收指标必须换从「非空返回率」换成「Hit1 MRR5」并保留非空返回率作为回归护栏防止修排序时把第 72 篇的误杀问题带回来。好消息是度量工具已经现成——本轮的六个探针脚本本身就是这套指标的实现修完直接重跑 06 看 S4 那一行能不能回到 95.8%。七、可迁移的三条经验7.1 验收指标必须落在价值链的末端「非空返回率」是一个便利指标好测、好达标、看起来直接。但它测的是链条第一环而价值在最后一环兑现。判断一个验收指标够不够问一句这个指标满分时用户体验有没有可能仍然是坏的如果答案是有可能那它就不是验收指标只是一个健康检查。非空返回率 100% Hit1 45.8% 就是这句反问的具体形态。7.2 修复一个缺陷时要给修法本身设一个反向假设第 72 篇的修法是把衰减归类为仅用于排序加权然后就不再审视它了。分类动作本身建立了一个未经检验的假设排序加权是安全的。更稳的做法是在修复时顺手写下这个假设并给它一个证伪条件假设_apply_time_decay只影响优先级不影响正确性。证伪方式测一次 Hit1看排序步骤前后是否变化。这个测试当时就能做成本是几行代码。它没做是因为验收标准没要求而验收标准没要求是因为我们相信那个假设。假设不写下来就不会被测试。7.3 归因要用剥离实验不要用推理管道类缺陷最容易被看代码想一想骗过去——四个函数彼此有顺序耦合人脑推不准。逐层剥离实验的成本极低本轮 145 行产出的是归因而不是猜测。尤其记得多测一列候选数。指标告诉你变差了候选数告诉你是被删了还是被挪了——这两种结论指向完全不同的修法。本轮如果只看指标不看候选数很容易误判成又是阈值问题然后去调阈值而阈值根本无辜。八、总结3 个关键点排序信号也会越权不只是不得行使否决权当加权强度足以覆盖相关性差异时加权本身就在毁坏正确性。翻转门槛b/a 0.8在密集的向量邻域里几乎默认满足验收口径可以掩盖缺陷非空返回率 100% 与 Hit1 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 版权协议转载请附上原文出处链接及本声明。
返回列表