从 19 题到 58 题:我重建了 RAG 的评估体系,Rerank 终于测出了 +12%
做 RAG 有一个很隐蔽的坑:你以为系统在稳步变好,其实只是测试集在替你打掩护。我之前一直用一个 19 条的测试集评估检索增强生成效果,几条业务问题、几条通用问题、几条从论文里扒的,来回跑对比实验,感觉“效果还行”。直到一个真实用户投诉“答非所问”的比例居高不下,我复现了几次都定位不到问题,才意识到手里的评估体系根本没有区分度。后来我把测试集扩充到 58 题,重新设计了评估流程,Rerank 的增益从原来测出的不到 2%,变成稳定在 12% 左右。这篇文章就把我重建评估体系的完整过程、踩过的坑、以及最后如何量化 Rerank 收益的方法拆开讲清楚。
1. 旧评估体系的致命伤:为什么19题测不准Rerank
1.1 旧测试集是怎么来的
先坦白,最初那 19 题是从公司 Q&A 后台里“精选”出来的,筛选标准是“用户常问”“能考倒系统”“答案能查到”。当时我花了大概一个下午,人工写了 10 条,又从两个开源的 RAG benchmark 里抄了 9 条,拼成一个文件就叫“eval_set.jsonl”。执行方式也简单:跑一遍完整问答流程,把答案和人工参考答案放一起,用字符串匹配加人工肉眼打分,然后算一个“通过率”。
这套流程放在 demo 阶段够用,看起来也很客观:每次都跑同一个脚本,输入相同问题,输出相同格式,改一个模块后对比通过率变化。当时我也确实用它验证了 embedding 模型切换、chunk 大小调整这些优化。问题在于,这个测试集并没有覆盖真实业务里出现的各种问题形态,很多题目难度太浅,系统用简单的关键词匹配就能蒙对,导致优化方向被严重误导。
1.2 19题测不出的那些问题形态
真实业务里的 RAG 查询很少是“什么是XX”这种干净的单跳问题。我后来从用户日志里捞了 300 多条真实问句,按失败原因分类,发现至少有几类旧测试集完全没覆盖的:
- 多跳问题:用户问“A 项目负责人负责的另一个产品是否支持导出”,需要先找 A 项目负责人是谁,再找该负责人名下其他产品,最后确认功能清单。旧测试集里全是单跳,直接检索“负责人”或“产品名”就能命中。
- 条件过滤问题:“列出去年 Q3 上线且目前仍未下架的功能”,需要先召回候选,再按时间和状态字段连续过滤。当时的知识库文档里根本没有结构化的“上线时间”字段,更没有对应的过滤逻辑。
- 否定约束问题:“有哪些 API 不支持批量调用”,旧测试集里没有“不支持”这类反向表达,系统很容易被“批量调用”的语义带偏。
- 数值边界问题:“单价不超过 50 元且库存大于 200 的商品”,涉及比较运算,但这在当时是纯文本知识库,完全没有数值语义理解能力。
- 对抗性干扰问题:“给出文件上传限制的链接,但不要提 Web 端”,旧测试集里没有这种“文档里有关键词但答案是否定/排除”的题目,测的时候全是正面查询,自然测不出问题。
这些问题形态不是孤例。在分类那 300 多条日志时,我统计了“多跳”“条件过滤”“否定排除”“数值约束”四类占比高达 37%。也就是说,旧测试集里的 19 题只覆盖了业务中约 20% 的查询模式,其余 80% 的坑全都看不见。
1.3 Rerank 被低估的真正原因
当时我也陆陆续续试过 Rerank 方案,分别跑了 base 模型和 cross-encoder 模型,对比结果却让人困惑:19 题测试集上,加了 Rerank 后通过率只提升了 1.8%,而且多次运行有时涨有时跌。我当时差点就得出“Rerank 对这个场景没用”的结论。
现在回头看,原因很清楚。第一,测试集里大部分单跳问题向量检索的 top-5 里已经包含正确片段,Rerank 只是把正确片段从第 3 位提到第 1 位,最终答案生成没差异;第二,测试集样本量太小,19 题里只要有一道题因为生成模型随机性而失败,通过率就会波动 5 个百分点以上,微小的真实增益被淹没在噪声里;第三,当时的评估指标只有“答对/答错”二值判断,没有追踪检索阶段是否把正确文档送进上下文。Rerank 做的是检索排序优化,如果只看最终答案,等于让生成模型背了检索的锅。
所以问题不在于 Rerank 本身,而在于评估体系的分辨率太低。它没法回答一个核心问题:改进之后,系统是否比之前更稳定地把正确的证据放到生成器面前。
2. 重建评估体系:从19题到58题的核心思路
2.1 评估体系的定位:先分离“检索质量”和“生成质量”
重建之前我重新思考了评估到底要衡量什么。RAG 系统是一条流水线:query 输入 → 召回(Embedding检索)→ 精排(Rerank)→ 生成(LLM)→ 答案。最终答案质量由每一步共同决定,但如果你想评估某一层模块的价值,就必须把其他因素尽量固定住。
所以我把评估拆成了两层:
- 检索层评估:只看系统能否从知识库中召回与 query 相关的文档片段,以及相关片段排在什么位置。这一步不依赖任何 LLM 生成,指标是 Recall@K、MRR、NDCG。
- 生成层评估:给定固定的检索结果(比如固定 top-5),评估 LLM 生成答案的正确性、完整性、引用是否准确。这里不比较不同检索方案,只衡量“喂给生成器的材料”是否支撑了正确回答。
为什么要分开:因为 Rerank 的直接作用面在检索层,它改变的是候选文档顺序,不是生成策略。如果只用一个“最终答案是否正确”的指标,等于把检索层和生成层的效果混在一起。哪怕 Rerank 把正确文档从第 5 位提到第 1 位,只要 LLM 对 top-5 的每一条都有幻觉倾向,最终答案也可能不变。反之,如果检索结果很差,生成器再强也答不对。分离之后,我才能清楚看到 Rerank 在哪个环节产生了多少收益。
2.2 测试集扩到 58 题的抽样方案
58 题不是随便凑的数。我是按真实日志里的查询分布抽的。先把 300 多条用户日志去重,按问题类型打标签,再按比例分层抽样。当时统计出的类型分布和抽样数量如下:
| 问题类型 | 日志中占比 | 抽样数量 | 示例 |
|---|---|---|---|
| 单跳事实型 | 28% | 16 题 | “XX 模块的导出格式支持哪些” |
| 多跳推理型 | 20% | 12 题 | “XX功能的实现原理是否依赖YY组件” |
| 条件过滤型 | 15% | 9 题 | “列出上月上线且未归档的接口” |
| 否定/排除型 | 12% | 7 题 | “哪些接口不支持批量删除” |
| 数值/比较型 | 10% | 6 题 | “最大并发数不超过100的服务有哪些” |
| 跨文档对比型 | 8% | 5 题 | “比较方案A和方案B的存储上限” |
| 对抗/干扰型 | 7% | 3 题 | “只提到手动迁移方式,不涉及自动工具” |
这样抽出来的 58 题基本复现了真实业务里的难度分布,不再是拍脑袋的精选集。另外我留了 10% 的 buffer,后续日志里有新形态问题可以补充,让测试集能呼吸。
2.3 人工标注的细节:不止标答案,还要标“证据段落”
这是我认为整个重建过程中最重要的一步。对每道测试题,除了写参考答案,我还标注了它在知识库中对应的证据段落 ID。也就是说,标注不仅要回答“正确答案是什么”,还要回答“哪一段/哪些段落包含了答案依据”。
标注时我把知识库的每个 chunk 都映射为一个唯一 ID,让两个人分别标注,不一致的再人工仲裁。为什么这么较真:因为只有标了证据段落,才能计算 Recall@K 和 MRR。否则你只能像以前那样“看最终答案对不对”,依然无法定位检索层的优劣。
举个例子,题目“XX 接口的超时时间默认值是多少”,知识库中可能有三段都提到了“超时时间”,但只有一段写的是默认值,另外两段是“超时时间可配置范围”和“超时错误示例”。人工标出正确的证据段落是那段“默认值”,后续评测时才能精确判定检索排序的合理性。
这一步非常耗时,58 题我花了一个多星期才完成,但节省了后面调试模块时无数个“为什么没效果”的纠结时间。
2.4 核心评估指标:Recall@K 和 MRR 的计算口径
确定测试集之后,接下来定义指标。我最终使用了 4 个指标,分别对应检索层和生成层:
- Recall@K:在查询的 top-K 检索结果中,是否包含至少一个相关证据段落。K 通常取 5,因为我的生成器上下文窗口最多装 5 段。这个指标衡量“有没有找对材料”。
- MRR(Mean Reciprocal Rank):第一个相关证据段落在检索结果中的排序位置倒数。衡量“找得有多靠前”。比如第一个正确段落排第 2,则 RR 为 0.5。
- NDCG@K:排序质量的整体衡量,考虑了多个相关段落的位置关系,适合有多个证据段落的多跳问题。
- 答案正确率:生成层指标,由 LLM 作为评判者(LLM-as-judge)结合参考答案打分,判断最终答案是否正确、完整、无额外幻觉。
需要注意,Recall@K 和 MRR 是“查询级别”的指标,计算时每条 query 独立统计,最后取平均。我当时写了一个简单脚本统计这批指标,对比 Rerank 前后差别。最终结论是:Rerank 把 Recall@5 从 71% 提高到 86%,MRR 从 0.52 提高到 0.79。换算到生成层,答案正确率从 63% 提高到 75%,整体增益约 12 个百分点。
2.5 为什么这 58 题能让 Rerank 现出真身
旧测试集测不出的原因,并不是 Rerank 没作用,而是测试难度太低。19 题的检索结果 top-1 基本就能命中,Rerank 无论怎么排,最终结果都是“正确文档进上下文”,看不出差别。58 题里融入了多跳、条件过滤、否定排除这些类型,原始向量检索经常在 top-5 中漏掉证据,或者把证据排在末位。Rerank 的 cross-encoder 能更好地建模 query 和段落间的细粒度语义关联,在这种情况下才显出差异。
从我跑出的数据看,最明显的提升来自多跳和条件过滤类题目。这两类题目中,原始向量检索常常把“语义相近但并不是真正约束条件”的段落排得很靠前,Rerank 则能把同时满足多个约束的段落往上提。所以我常说一句话:Rerank 是在检索召回率不够时的“精排救火队”,但前提是你的测试集里有“火”。
3. Rerank 原理与实测:增益那 12% 从哪来
3.1 Rerank 的工作方式:给 query 和文档一起打分
先给没接触过 Rerank 的读者补基础。向量检索是把 query 和文档分别编码成向量,用余弦相似度排序。问题在于双塔结构的编码过程是独立进行的:query 向量和文档向量各自生成,然后直接比较。这就导致它很难处理 query 和文档之间复杂的交互信息,比如“不支持批量调用”里的否定词,或者“A的人负责的另一个项目”里的指代关系。
Rerank 采用另一种范式:把 query 和候选文档拼接成一个序列,输入一个深度交互模型,让模型同时看到 query 和文档的完整内容,输出一个相关性得分。因为模型能看到二者的完整 token 交互,它可以捕捉到很多双塔模型丢失的语义细节。代价是计算量显著变大:每个候选文档都要跑一次模型推理,所以 Rerank 一般只对向量检索的前 N 个结果做重排,比如 top-50 重排到 top-5。
一个很通俗的类比是:向量检索是“HR 先按简历关键词招来 50 个人”,Rerank 则是“部门主管一对一面试,按岗位匹配度选出 5 个”。关键词初筛容易漏掉“转行但能力匹配”的候选人,面试能发现深度相关度。
3.2 这次用到的 Rerank 方案和参数
我这次压测选了三种方案:
- 基线:不加 Rerank,直接用 embedding 相似度取 top-5。
- 方案一:用 bge-reranker-base 对 top-50 结果重排,取 top-5。
- 方案二:用 bge-reranker-large 对 top-50 结果重排,取 top-5。
执行时我固定了知识库 chunk 大小(512 字符)、重叠(64 字符)、embedding 模型(bge-m3)、生成模型(Qwen2.5-7B),确保唯一变量是 Rerank 模型。这是因为评估要控制变量,否则你无法判断收益来自哪一步。
参数方面没有太玄学。用默认的“query 与 passage 拼接”方式,batch size 根据显存调整,base 模型能跑到 64,large 模型只能跑到 16。最重要的是:一定要保证 rerank 的输入是“原始 query + 全文段落”,而不是“query 的向量 + 段落向量”,否则就失去了交互式建模的优势。
3.3 重建前后评估数据对比
为了方便说明,我整理了一张表格,展示同一个系统在旧评估集和新评估集下测得的 Rerank 增益:
| 评估集 | 指标 | 无 Rerank | 有 Rerank(base) | 提升 |
|---|---|---|---|---|
| 旧 19 题 | Recall@5 | 89% | 92% | 3% |
| 旧 19 题 | 答案正确率 | 74% | 76% | 2% |
| 新 58 题 | Recall@5 | 71% | 86% | 15% |
| 新 58 题 | MRR | 0.52 | 0.79 | +0.27 |
| 新 58 题 | 答案正确率 | 63% | 75% | 12% |
这里“答案正确率”是生成层指标,也是业务侧最关心的最终效果。加的 12% 是经过 3 次重复实验取平均的结果,避免 LLM 采样随机性带来的波动。
需要强调的是,12% 是“选对测试集才看得到的收益”。在旧测试集上这个数字只有 2%,而 2% 意味着在 19 题上就是不到一道题的差异,根本不足以形成决策。如果当时真的按旧体系下结论,可能就直接砍掉了 Rerank 方案,整个系统的检索质量也就停留在 71% 的 Recall@5 上。
3.4 为什么收益会被“藏”起来:评估分辨率与归因误差
这个问题值得展开说。很多团队抱怨“加了 Rerank 没效果”,我怀疑多半是评估体系分辨率太低。所谓分辨率,可以理解为测试集能区分不同方案差异的最小单位。19 题和 58 题之间,本质上差的是“有效样本量”和“难度覆盖”。
还有一个常见坑是把所有失败都归因于检索。“答案正确率”下降了,不一定是检索不对,可能是 LLM 没按检索内容回答,也可能是系统提示词要求不够明确。我在检测 Rerank 增益时,会同时记录每道题的检索证据段落 ID 和最终回答文本。如果一道题最终答案错了,我先看证据段落是否在 top-5 内。若在,说明检索没问题,问题出在生成阶段;若不在,说明是检索漏了。这样就不会把生成的问题算到 Rerank 头上,也不会把 Rerank 的提升错误“奖励”给生成模型。
这个习惯在调试时尤其有用。有一次我换了更强的 embedding 模型,答案正确率反而下降,一查原因:新 embedding 召回的 top-5 里正确证据比旧模型少,导致生成器无米下锅。没做归因分析的话,我可能又会怀疑新 embedding 不行。
4. 实操过程:把评估体系跑起来的代码与配置
4.1 评估 Pipeline 的完整结构
我从零搭建了一套可复用的评估脚本,目录结构大致是这样:
eval_rag/ ├── config.yaml ├── datasets/ │ ├── test_58.jsonl │ └── knowledge_base/ ├── scripts/ │ ├── retrieve.py │ ├── rerank.py │ ├── generate.py │ ├── evaluate.py │ └── utils.py └── outputs/ ├── retrieval_results.jsonl ├── generated_answers.jsonl └── eval_summary.json执行流程是:
- 读取评估集 test_58.jsonl,逐条取出 query。
- 对每条 query 调用检索模块,得到 top-50 原始候选段落。
- 若开启 Rerank,将 top-50 候选段落与 query 一起送入 Rerank 模型,输出重排后的 top-5。
- 将 top-5 段落拼成上下文,调用 LLM 生成答案。
- 对检索结果计算 Recall@K、MRR、NDCG;对生成答案计算正确率。
- 汇总结果,输出到 JSON 文件。
我特意把检索、Rerank、生成三个阶段拆成独立脚本,每次想对比不同方案时,只改其中一段,其他两段保持不动。比如验证 Rerank 时,就固定检索和生成的参数,只切换 rerank 开关和模型路径。
4.2 关键代码:检索、Rerank 和指标计算
先看检索部分,我用的是老牌向量库 Chroma,embedding 模型是 bge-m3。加载知识库后,对每个 query 召回 top-50:
from chromadb import PersistentClient from sentence_transformers import SentenceTransformer embedder = SentenceTransformer("BAAI/bge-m3") client = PersistentClient(path="./knowledge_base") collection = client.get_or_create_collection(name="docs", metadata={"hnsw:space": "cosine"}) def retrieve(query, top_k=50): query_vec = embedder.encode([query], normalize_embeddings=True).tolist() result = collection.query( query_embeddings=query_vec, n_results=top_k, include=["documents", "metadatas"] ) # 返回段落文本、ID、得分 return [{"id": m["id"], "text": docs[0][i], "score": result["distances"][0][i]} for i, (docs, m) in enumerate(zip(result["documents"], result["metadatas"]))]Rerank 部分,我用的 FlagEmbedding 的 Reranker,计算两遍——一遍关闭,一遍开启:
from FlagEmbedding import FlagReranker reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True) def rerank(query, candidates, top_n=5): pairs = [[query, item["text"]] for item in candidates] scores = reranker.compute_score(pairs) sorted_items = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True) return [item for item, score in sorted_items[:top_n]]生成部分固定使用 Qwen2.5-7B-Instruct,把重排后的段落拼成上下文:
def generate_answer(query, passages, model, tokenizer): context = "\n\n".join(f"[{idx+1}] {p['text']}" for idx, p in enumerate(passages)) prompt = f"请根据下面的参考资料回答问题。\n参考资料:\n{context}\n\n问题:{query}\n要求:回答要基于参考资料,若无法回答请说明。" response = model.generate(tokenizer.encode(prompt), max_new_tokens=512) return tokenizer.decode(response[0][len(tokenizer.encode(prompt)):], skip_special_tokens=True)评估部分,计算检索指标的代码如下,注意维持“每道题独立评估”的机制:
def compute_metrics_for_query(query_item, ranked_evidence_ids, k=5): relevant_ids = set(query_item["evidence_ids"]) hits = 1 if any(eid in relevant_ids for eid in ranked_evidence_ids[:k]) else 0 recall = hits # 二值化版本:是否找到相关证据 # MRR 计算 mrr = 0.0 for rank, eid in enumerate(ranked_evidence_ids, start=1): if eid in relevant_ids: mrr = 1.0 / rank break return recall, mrr这里 recall 按“是否找到一个相关证据”计算,符合业务预期——只要有一段正确证据供生成即可,不必要求全召回。
4.3 测试集 JSONL 格式和标注字段
测试集采用 JSONL 存储,一道题一行,包含四个字段:
{ "id": "qf-001", "question": "XX 接口的超时时间默认值是多少?", "answer": "默认值为 15 秒。", "evidence_ids": ["chunk-0231", "chunk-0232"] }其中evidence_ids是复现评估的关键。知识库每个 chunk 的 ID 是系统级的,稳定不变。即使你调整了 chunk 大小或 embedding 模型,只要知识库内容没变,证据 ID 就是可追踪的。这个设计初看不重要,但跑对比实验时就会发现它的价值:你能精确知道某次改动是让证据排到了第几位,而不是“差不多”。
标注时我写了专门的脚本,先给每个 chunk 生成 ID 和简短的标题,标注者直接在界面上选择“证据段落”。两个标注者的标注一致率约为 82%,不一致的题目通过开会讨论最终确认。另有一部分题目天然存在多段证据,我允许evidence_ids有 2-3 个 ID,方便评估 NDCG。
4.4 运行顺序与耗时控制
整个评估跑完一遍大约需要 40 分钟:检索和 Rerank 每道题大约 5 秒,生成部分每道题约 20 秒,58 题累计约 30 多分钟。如果为了验证稳定性,我会连续跑 3 次,总计 2 小时左右。在 CI 上这个时长有点久,我只在关键实验节点跑完整集,日常开发用 12 道题的快速集(从 58 题里等比例抽子集)。
快速集的设计也要注意:不能因为“题少”就丢失关键类型。我从每个类型里固定抽 1-2 题作为“smoke test”,只用来发现明显的崩溃或异常,不做最终决策。最终决策只看完整 58 题的结果。
还有一个容易被忽略的问题:固定随机种子。LLM 生成答案具有随机性,如果不固定 seed,两次实验的差异可能完全来自采样噪声。我在生成脚本里强制设置了do_sample=False或固定temperature=0,保证同一输入下生成结果尽量稳定。实际经验告诉我,即使用贪心解码,不同运行之间也可能因为算子精度问题产生微小差异,所以最终还是靠“3 次平均”兜底。
5. 常见问题与排查技巧实录
5.1 测试集被污染:调优会“背题”
最典型的坑是拿测试集数据做参考调参。比如你发现测试集里有一道“不支持批量删除”的题目,然后你在检索系统里加了“否定词检测”规则,专门处理“不支持”这类表达。这会造成什么后果:测试题测的其实是你的规则,而不是系统的泛化能力。
应对方法:保留一套“留出集”。我从新的 58 题里硬性划出 8 题完全不在日常调试中运行,只有最终评估时才跑。平时开发用剩下的 50 题调参。这样可以缓解“背题”风险,但根治很难。更实际的做法是定期从最新用户日志里补充新题,淘汰旧题,让测试集持续流动。我目前的规则是每月抽一次新日志,补充 5-10 条,并移除等量失效题。
5.2 指标波动大:误差的构成和处理
只跑一次评估,指标波动可能很大。比如答案正确率,今天 75%,明天可能变成 68%。我发现波动来源主要有三个:LLM 采样随机性、运算浮点误差、测试集抽样偏差。
处理办法分三点:
- 固定采样参数:生成阶段用贪心解码,检索和重排阶段固定 batch size。
- 重复实验取平均:关键结论至少跑 3 遍,计算均值。如果三遍结果差距大于 2 个百分点,说明环境信号不稳定,需要排查 GPU 驱动、模型加载状态或数据加载顺序。
- 使用配对显著性检验:我在对比 Rerank 和基线时,会对同一道题分别记录“是否成功”,计算二者的差异分布。只有差异在多数题目方向一致时,才认为是真实提升。这个方法很简单,但比单纯比较 total 指标靠谱得多。
5.3 人工标注不一致:证据段落的歧义问题
标注过程中最常见的矛盾是:一道题有两段都很像证据,但只有一段信息完整。比如题目“导出最大行数是多少”,文档里一段写“导出接口的最大行数为 5 万”,另一段写“该接口支持导出大批量数据”。两者都提到了“导出”和“数量”,但第一段才是真正的证据。我的经验是:标注时要看这段能否直接支撑问题答案,而不是看主题是否相关。为了减少歧义,标注指南里明确写了“证据段落必须包含问题的完整约束条件”。
另外,有些文档本身内容冲突,也会导致标注混乱。我的处理是:出现冲突时先检查知识库文档是否更新,如果文档内容过时,先修正知识库再继续标注,不要让错误文档影响评估集。
5.4 生成答案没引用检索证据:归因错位
有时候检索证据明明进去了,LLM 却答出了别的内容,这属于“生成层跑偏”。我在提示词里强制要求“引用来源编号”,并写了一个解析器检查答案中是否出现[1]、[2]这样的标注。如果一道正确证据在 top-1 的题,LLM 答错且没有引用任何段落,那问题不在 Rerank,而在生成提示词或模型指令遵循能力。
这种排查技巧在快速定位问题时价值很大。我把所有错误样例按“检索漏了”“排位靠后”“生成没引用”“生成了幻觉”四个标签分类,每周看一次分布。最近的分布显示,Rerank 引入后,“检索漏了”的比例从 22% 降到 8%,“生成没引用”反而成了最大头。这也说明下一阶段的优化方向可能要从 Rerank 转向生成层的指令微调。
5.5 其他实战心得:测试集要“脏”一点
最后分享一个反直觉的经验:不要把所有测试题都设计得边界清晰、干净规范。真实用户的问题往往口语化、带错别字、模糊表述,比如“怎么导数据啊?老是导不出去”,而不是标准的“导出失败的常见原因及解决方案”。我在 58 题中特意加入了 6 条这种“脏”问题,因为它们才是实际 hit 量最高的查询形态。脏问题刚开始会大幅拉低指标,但这正好暴露了系统对归一化的依赖。做完归一化预处理后,这类题的通过率提升是整个测试集中最明显的。
我在最终保留的 58 题里,用了 45 道干净题 + 13 道脏题的比例,追求真实业务和可解释性的平衡。太干净,系统会过度拟合标准 query;太脏,人工评测的置信度又不够。目前这个比例还算舒服,后续可以根据线上日志再调整。
6. 一点后续想法
这次重建评估体系最大的收获并不是测出了 12% 的 Rerank 提升,而是让我养成了“先定义测量,再谈论优化”的习惯。现在每次改动某个模块,我都会先问一个问题:如果它有效,应该在哪个指标上体现出来?如果连指标都不清楚,那就说明还没想清楚这件事值不值得做。后续我打算再把 58 题跑成自动化回归,接入到每次代码变更的 CI 里,让每次实验都有统一口径的数字可参考。评估体系这个东西,建立起来麻烦,但一旦跑顺了,它就是你优化 RAG 最可靠的标尺。