本文是「Agent 开发实践与思考」系列第 6 篇。系列目录:
纯向量检索的两个问题
第一类是组件名和接口字段。Agent 正在迁移 LegacyTable,问经验库”表格的 reload 方法怎么处理”,向量检索召回了一堆讲表格刷新策略的条目,语义都近,但好几个是别的表格资产的,唯独没有 LegacyTable 那条。embedding 模型通常把文本编码成一个连续向量,目标是让语义相近的文本靠近,而不是保证每个组件名都能像数据库主键一样被区分。LegacyTable 和 LegacyList 在向量空间里可能离得很近,业务上它们的契约完全不同。prop 名、事件名、ref 方法名都有这个问题:语义相近,但要的恰恰是精确匹配。
第二类是项目内部的命名和缩写。Vue 转 React 过程中,团队有一套内部叫法,比如把某类弹窗叫”审批流弹窗”,某个状态模块叫”北极星”。通用 embedding 模型训练时没见过这些词,“北极星”在它眼里就是个星座。这类词要么被映射到错误的语义上,要么在向量里被稀释掉,检索时等于不存在。
这两类问题,第一反应都是换更强的 embedding 模型。我们试过,组件名类 case 改善有限。原因不在模型好坏,在于语义空间本身不区分”相近但不同”的字符串:LegacyTable 和 LegacyList 就该离得远,但这和”语义相近”的定义冲突,靠换模型解不掉。
embedding 测评那篇先处理过模型怎么选的问题:评测要同时覆盖标识符、处理模式和不适用条目,不能只看一个平均分。即使这样,纯向量检索仍然无法保证精确标识符命中,也不能判断一条主题相近的经验是否适用。这里继续把工程解法补上。
混合检索:BM25 管精确,向量管语义
对策不新鲜,但顺序不能只看文本相关度。先按项目范围、分支、框架和组件库版本、经验状态过滤候选:已经标为 review_required 的条目不作为默认方案,candidate 只能提供检查项,当前任务不适用的条目直接排除。通过这层资格检查后,再做两路召回。BM25 是词频统计的老办法:词频、逆文档频率和文档长度都会影响得分。它不懂语义,但查 LegacyTable 就会把含 LegacyTable 的条目排在前面,精确匹配正好是它的强项。向量检索继续负责语义等价的部分,“表格插槽怎么迁移”能匹配到”列渲染模式转换方案”。两路各召回 top 50,再融合成一个列表。
工程接入比想象中顺。BM25 不用自己写,现成的搜索服务都内置;向量库是原来就有的。两路可以并行执行,因此检索阶段的主要延迟通常接近较慢的一路,但仍要计入网络、调度、融合、超时和重试开销。真正花时间的反而是索引侧的细节:组件名、prop 名和事件名这类标识符不应只依赖中文分词。可以为它们建立 keyword 字段、标识符专用 analyzer 或自定义词表,使 LegacyTable 作为完整词项入库;查询侧要使用与索引侧一致的分析方式。
RRF:不看分数,看名次
融合的第一直觉是加权求和,但两路分数的尺度根本不可比。BM25 的分数没有上界,12.7 和 3.2 都常见;向量相似度在 0 到 1 之间。直接加权得先做归一化,归一化又引入新的超参,数据分布一变就要重调。
RRF(Reciprocal Rank Fusion)绕开了不同检索器分数不可直接相加的问题:不使用原始分数,只看每一路的名次。一个条目在某一路召回里排第 r 名,就得 1/(k+r) 分,几路得分加起来排序。它解决的是融合阶段的尺度问题,不代表不同查询的 RRF 总分可以直接拿来比较。伪代码只有几行:
function rrf(rankings: string[][], k = 60): string[] {
const scores = new Map<string, number>();
for (const ranking of rankings) {
// 每路召回的有序文档列表
ranking.forEach((docId, index) => {
const rank = index + 1;
scores.set(docId, (scores.get(docId) ?? 0) + 1 / (k + rank));
});
}
return [...scores.entries()]
.sort((a, b) => b[1] - a[1])
.map(([docId]) => docId);
}经典实现常使用 k=60 作为默认值。这个值决定名次之间的分差被压多平:第 1 名得 1/61,第 10 名得 1/70,差距不大;第 50 名得 1/110,贡献明显变小。算一笔具体的账:条目 X 在 BM25 排第 2、向量排第 30,得分约 1/62 + 1/90 ≈ 0.0272;条目 Y 两路都排第 8,得分 2/68 ≈ 0.0294。Y 胜出。这正是想要的行为:单路的偏科被稀释,两路都认可的条目排到前面。
k 仍应结合召回深度、检索器数量和离线评测验证。如果某一路明显更可靠,RRF 也可以给每路乘权重。我们试过给向量那路加权,提升不明显,就保持了等权;这只是当时的资产和查询分布下的选择。
在当时收集的组件名类迁移查询中,混合检索减少了精确标识符导致的漏召回。这个结论只说明该批索引和命名规范下的变化,不代表所有 embedding 模型或数据集都会得到相同结果。
重排:召回求全,精排求准
融合后的 top 50 里仍混着不少”沾边但不对”的条目。原因在向量检索的架构:query 和文档各自独立编码成向量,再算距离,编码时两者没有任何交互,细粒度的相关性判断做不准。BM25 也一样,它只看词的重合,不看上下文。
放到迁移场景里,这意味着检索”表格 reload 怎么处理”时,混合检索能保证含 reload 的条目被召回,但向量那路会把所有涉及表格刷新的条目都拉回来,包括一些只提了”刷新”但具体机制完全不同的资产经验。BM25 那路则会把所有含 reload 的条目同等对待,不管它是 LegacyTable 的还是其他表格的。
这里使用的是 cross-encoder 类 reranker:它将 query 与候选条目联合编码,逐对输出相关性分数。精度通常高于双塔召回,但每对都要完整计算,只适合放在几十个候选之后。所以流程拆成两段:混合检索召回 top 50,重排精排后取 top 5 注入 Agent 上下文。在当时的人工复核样本中,不适用但主题相近的经验被注入的情况有所减少;这不是对所有资产类型的通用比例。
召回数 K 和精排数 N 要权衡。K 太小,相关经验在召回阶段就丢了,后面再准也救不回来;K 太大,重排逐对打分是线性开销,延迟跟着涨。K=50、N=5 是这批迁移任务在当时延迟约束下的选择,不是通用参数。重排分数比向量相似度更适合在同一模型、同一版本和相近输入格式下做校准,但不能默认跨查询具有稳定的绝对可比性。不同迁移单元的候选难度、长度和分布都可能改变分数,阈值仍然要用业务评测集验证。
为什么不直接让大模型排?试过,把 50 段经验条目塞进 prompt 让 LLM 逐个打相关分,一次调用上万 token,延迟好几秒,成本是重排模型的几十倍,效果没有拉开差距。迁移任务的检索频率高,每次都走大模型排不划算。
拒答阈值:不该套用的经验就不注入
调下来最影响迁移质量的不是召回率,是”没找着还硬塞”。经验库里没有完全匹配当前资产的经验条目时,模型拿着沾边的片段也能编出一套迁移方案,语气很肯定。迁移场景里,一个基于错误经验编出来的方案比没有经验更糟,Agent 会照着做,改完才发现行为不对。
做法是在重排分数上加一道卡控:分数不足以支持直接采用时,不把条目作为默认方案注入,而是让 Agent 回到原始代码和项目状态分析;必要时仍可保留条目标题、出处和适用条件,作为待验证的参考。提示词侧也要配合,在系统提示里明确”如果没有经过资格检查的匹配经验,从当前代码和项目记忆出发分析”。
阈值不能拍脑袋。我的定法是这样:从过去迁移任务的检索日志里攒一个评测集,按“可直接复用、只能参考、契约不适用、知识缺失”标注相关性。对每条查询跑完整的过滤、检索和重排,记录最高重排分,再从低到高扫描候选阈值,分别观察可用经验的保留率、错误经验的注入率和拒绝率。这里的阈值只是特定 reranker、版本、输入模板、候选生成策略和评测集下的操作点;不同资产类型的分布差异较大时,应分别校准或把版本、经验状态、精确标识符命中等条件纳入决策,而不是只依赖一个全局分数。
两个注意点。一,若采用重排分数做拒绝决策,阈值必须针对该 reranker 的输出校准;向量相似度和重排分的分布不同,不能混用。二,评测集要持续补充,新资产类型上线会带进新的查询模式,半年前的曲线不代表现在。
被拒绝直接采用的查询不要浪费。我让它们进一个待补队列:人工确认确实缺这类经验的,等积累够验证证据后再补;确认有经验但没检索到的,转回检索侧继续查 bad case;确认候选主题相关但契约不适用的,补充反例或失效条件。经验库的内容是这样一点点长起来的,这些查询也会成为评测集的新来源。
检索之外还有两层
检索解决的是”找到匹配的经验条目”。找到之后还有两层问题。
一是找到的条目怎么组织给模型:顺序、去重、上下文预算,目前我们只做了最朴素的按分数排,重复条目挤占上下文的情况经常发生。而且经验条目占用的上下文预算需要和项目记忆、系统提示词、工具结果一起分配,不能检索到多少塞多少。这一点等后面实战完会再单总结一篇。
二是检索现在是固定流程,每次迁移开始都查一遍,包括那些项目记忆已经覆盖的常规情况。更合理的方式是让 Agent 自己决定要不要查、查几遍,这层我们还没动,感觉方向是对的。另外多轮迁移里”上面那个表格的 reload 怎么处理的”这种指代也开始冒头,直接拿去检索必然落空,得先补全成独立查询,这块我们刚开始处理,还没有拿得出手的结论。
经验条目的结构化问题也还在。目前条目是自然语言加元数据,检索时按文本做。最近看到有人把经验先抽成条件-动作-约束的结构再检索,思路对路,工程上离能用还有距离,继续观察。

