本文是「Agent 开发实践与思考」系列第 5 篇。系列目录:
- 2024
- 07-02 我手写了一个 Agent:从一问一答到”思考-行动-观察”循环
- 08-06 工具调用踩坑记:schema、描述与错误返回怎么写
- 09-10 系统提示词写到几千字之后:让 Agent 迁移 Vue 业务与资产到 React
- 10-15 给 Agent 做记忆:分层、状态与遗忘
- 11-01 迁移知识库的 embedding 召回评测方法(本篇)
在接入 RAG 前评测召回
上一篇将迁移 Agent 的长期记忆拆成可检索的经验条目。接入 RAG 时,需要为每个条目生成 embedding,并在新任务到来时按相似度检索候选经验。
一开始我也把问题想得很简单:找一个公开榜单靠前的模型,写入向量库,看看检索结果。真正拿迁移记录试过后才发现,这样很难知道模型到底好不好。搜 “表格 reload 怎么处理” 时,返回一堆都在讲刷新列表的条目,看上去没有离题;但有的条目属于另一个表格组件,有的缺少 ref 调用的前提。模型把它们排在前面,反而会让 Agent 套错经验。
embedding 的职责不是生成答案,而是在候选集合中把后续可能有用的条目排到前面。它是否合格,要用当前知识库、当前查询类型和实际的下游预算来判断。公开检索榜单可以用来缩小候选模型范围,不能替代本地评测。
本文说明迁移知识库比较多个 embedding 模型的流程,目标是将 “模型 A 比模型 B 好” 转化为可复查的结论。
先明确模型需要找什么
知识库里的每一条记录都带有自然语言和结构化元数据。自然语言部分描述问题、处理动作、适用条件、验证结果与反例。元数据包括组件类型、项目、框架版本、接口名和经验状态。
查询也不只一种。至少要分开看下面几类:
- 问具体标识符,例如
LegacyTable、reload、selection-change。这类查询需要精确命中,不能把名字相似的组件当成同一个对象。 - 问处理模式,例如 “Vue 动态插槽迁到 React 后如何保留行渲染”。查询和经验条目未必复用同一组词,语义匹配更重要。
- 问限制或失败条件,例如 “列表能展示但提交后没有刷新时先查什么”。相关条目可能没有出现 “刷新” 这个词,却记录了 ref 方法、请求参数和调用链。
- 问项目内术语,例如团队习惯称为 “审批流弹窗” 的组件。通用模型未必理解这个名字,必须单独测它是否被正确召回。
如果把这些查询混成一个总分,结果往往会掩盖问题。某个模型可能很擅长把相似描述聚在一起,却持续漏掉组件名;另一个模型则能命中标识符,但对不同说法的同类问题不敏感。下游是否会同时搭配关键词检索和重排,也会改变 embedding 的定位。模型评测前,先把它要承担的职责说清楚。
从真实任务整理评测集
评测集来源决定了结果是否能反映真实任务。我没有让模型编题,而是从已完成的迁移任务中整理问题。每个样本包含一个查询、若干相关条目和相关等级。
例如,查询 ”LegacyTable 的 reload 要不要保留” 对应的直接相关条目,应同时说明调用方是否通过 ref 调用它、触发刷新时是否要保留分页和请求参数。只说 “表格需要刷新” 的条目只能算部分相关。另一个组件的 reload 经验如果接口和调用链不相同,则属于不相关,即使它的文字主题很接近。
我把人工标注分成三档:
| 等级 | 含义 | 在指标中的用法 |
|---|---|---|
| 可直接复用 | 条件和契约与当前任务一致,Agent 可以将它作为候选方案 | 主要正例 |
| 只能参考 | 处理模式相近,但需要回到当前代码确认前提 | 次要正例,单独统计 |
| 不适用 | 名称、组件或业务主题相近,但契约不一致 | 负例 |
标注时不应将 “看起来有帮助” 直接视为相关。检索系统服务于 Agent 的下一步动作,因此判据应是:将记录提供给 Agent 后,它能否减少一次无效搜索,或提出一项正确的检查。若记录只能提供宽泛背景,甚至可能诱导 Agent 修改错误接口,就不应算作正例。
评测集还应避免将同一条经验的改写版本同时放入库和查询。否则模型可能只是在进行近似复述匹配,离线分数不能代表真实提问的结果。对于每条查询,我会检查措辞是否来自任务日志,是否保留了调用方实际会用到的字段和约束,并让标注者在不看候选排序的情况下先确定相关条目。
评测集不必在初始阶段很大,但应覆盖实际问题。十几条只包含常规语义查询的样本,可能无法区分多个模型;加入缩写、接口名、反例和不同表述后,差异才会显现。新任务中的漏召回和误召回也应补充到评测集,而非只记录在复盘文档中。
比较多个模型时,固定其他变量
我先根据语言、部署条件、向量维度和成本筛出可试的候选,再在同一个评测集上比较。例如中文知识库可以把 m3e-base、bge-large-zh-v1.5、bge-m3 与 text-embedding-3-large 放进候选列表。它们是否适合,仍要由自己的数据决定。
对比时要固定以下条件:
- 所有模型使用同一份文档切分结果和同一份元数据。模型 A 按段落写入、模型 B 按句子写入,比较到的是切分策略,不是 embedding。
- 查询和文档分别使用模型要求的输入格式。部分模型为查询和文档定义了不同前缀,漏掉前缀可能让结果失真。
- 使用同一种向量归一化和距离计算方式。若采用余弦相似度,应确认向量是否已经归一化,避免把实现差异当成模型能力。
- 固定每次返回的候选数 K。下游只会拿 top 5 做重排,就不能只报告 top 100 的召回。
- 记录索引版本、模型版本、维度、请求批次和评测时间。托管模型或模型服务更新后,旧分数不能自动沿用。
下面是一个最小的离线计算框架。它不关心向量来自哪个服务,只接收每个查询的排序结果和人工标注:
type Judgement = "direct" | "reference" | "irrelevant";
type QueryCase = {
id: string;
relevant: Map<string, Judgement>;
};
function recallAtK(results: string[], testCase: QueryCase, k: number): number {
const directIds = [...testCase.relevant]
.filter(([, judgement]) => judgement === "direct")
.map(([id]) => id);
if (directIds.length === 0) return 1;
const retrieved = new Set(results.slice(0, k));
return directIds.some((id) => retrieved.has(id)) ? 1 : 0;
}
function mrr(results: string[], testCase: QueryCase): number {
const firstRank = results.findIndex(
(id) => testCase.relevant.get(id) === "direct",
);
return firstRank === -1 ? 0 : 1 / (firstRank + 1);
}结合指标与误召回检查
我至少记录三个方面。
第一个是 Recall@K:直接可复用的条目是否出现在前 K 名。对后面还要重排的流程,Recall@50 更有意义。它回答的是 “真正需要的条目有没有进入候选池”。如果相关条目没有被召回,reranker 再准确也无法找回它。
第二个是 MRR,也就是第一个直接相关条目的倒数排名。它会区分 “相关条目在第 1 名” 和 “相关条目在第 50 名”。对于不走重排、只注入少量片段的简单流程,MRR 和 Recall@5 更接近实际体验。
第三个不是传统检索指标,而是误召回检查。把每个查询 top K 的不适用条目拉出来,特别看它们为什么排得高。常见情况有三种:组件名相似、共享了 “刷新” 这类宽泛词,或者同一个失败现象背后的契约不同。平均分相近时,这份 bad case 列表通常比小数点后的差距更能说明应选哪个模型。
对于 “只能参考” 的条目,我不会和 “可直接复用” 混在一个正例指标里。前者被排到前面不一定是坏事,但它不能替代直接经验。单独统计它,可以知道某个模型是只会找主题相近的资料,还是能找到条件也相符的经验。
还需要按查询类型拆分结果。一个模型整体 MRR 最高,却在标识符查询上持续失败,就不应单独承担检索任务。此时更合理的结论可能是:它负责语义召回,再由关键词检索补上接口名和组件名。这也是后续混合检索的原因。
不应直接用相似度分数设全局阈值
一次评测中,某条查询的最高余弦相似度可能为 0.82,但不能据此规定高于 0.8 的条目即可采用。该做法不可靠。
相似度分数的含义由模型、语料分布、查询长度和知识库共同决定。同一个数值出现在不同模型上不代表同样相关;同一个模型面对 ”reload” 这样的短查询和一段完整问题时,分数分布也可能不同。embedding 更适合排序,不适合单独承担 “这条经验能否直接采用” 的判断。
我的做法是先把分数当作诊断信息,而不是全局阈值。对于每个模型,分别查看直接相关、只能参考和不适用条目的得分区间是否重叠。如果三类样本大面积重叠,就不要试图用一个 embedding 阈值解决问题。可以先做项目、版本、经验状态等硬过滤,再用关键词和向量共同召回,最后交给重排模型或当前代码验证。
评测也要把成本放进记录。需要统计建库耗时、单次查询延迟、索引体积和调用成本,但不能因为一个模型更快就忽略它漏掉关键经验的情况。若它只是用于第一阶段召回,性能与 Recall@50 的关系更值得看;若检索结果直接进入上下文,则更应关注 top 5 的准确性。
评测结果如何进入工程决策
模型评测完成后,还应挑出每个模型表现最差的查询,并回到原始经验条目检查原因。
有时问题在 embedding。比如同一概念用了不同表述,相关条目没有共同词,某个模型没有把它们聚起来。有时问题在切分,组件契约和反例被拆到两个片段里,任一片段都不足以判断是否适用。还有时问题根本不该由向量解决,查询里包含明确的组件名和 ref 方法,应该加精确字段过滤或 BM25,而不是继续换模型。
评测结论应写成具体配置,而不是 “某模型最好”。例如:某个模型作为语义召回器,返回 50 个候选;查询包含组件名、prop 名或错误码时,同时执行关键词召回;经验状态不是 verified 的条目不进入默认方案;检索结果注入 Agent 前仍需核对当前代码。这些条件比模型名称更容易复现,也更直接影响检索结果。
embedding 只解决 “哪些条目值得进入候选集”。它不验证接口是否仍存在,不判断当前分支是否已变更,也不保证过去的经验可以直接套用。下一篇将讨论如何处理候选集中的精确标识符、语义匹配和不适用经验,以及为何需要将关键词检索、向量检索、重排和拒绝直接采用组织在同一条链路中。

