给自研 RAG 做评测:三个优化是怎么被数据选出来的

RAG 与检索RAG检索评测词面检索Query 改写
2026-10-11 · 2459 字 · 约 7 分钟
本文目录(11 节)

pycamp 的 AI 陪练会先在一份课程知识库里检索相关段落,再交给模型作答。检索准不准,直接决定回答会不会"一本正经地胡说"。但"优化检索"这件事最容易凭感觉——换个分词、加个向量、调个权重,看起来都像在进步。

所以这轮我先不优化,而是先建一把尺子:一套 237 题的评测集 + 一个能横向对比多种检索配置的 runner。后面的每一个改动,都必须拿数据说话。

💡 一句话结论: 三个优化上线(吞吐量最大的那个让口语提问的命中率从 0% 到 38.9%),两个"看起来很美"的想法被数据否决——包括我一开始最看好的向量检索。
图 1 · 检索层的技术关系(线上部分已生效,灰色分支是试过但没上线的)
图 1 · 检索层的技术关系(线上部分已生效,灰色分支是试过但没上线的)检索层词面检索 · 线上TF-IDF 余弦bigram 分词BM25查询侧优化 · 线上条件式 query 改写关卡软加权 ×2语义检索 · 已试未上向量 ONNX 量化异构融合 RRF评测底座规则集 219 题口语对抗集 18 题run_eval runner

一、评测底座长什么样#

知识库是 29 个关卡切出来的 239 个块(按小节/常见坑/动手练习分类,中文双字 bigram 建 TF-IDF 索引,全程零第三方依赖)。评测集有两份:

数据集题量构造方式用途
规则集219从课程结构反向生成(知识点问答 / 关卡任务 / 常见坑 / 定位关卡 / 报错咨询)长期回归基线,gold 零标注成本
口语对抗集18手写"学员口吻"提问:循环一直停不下来,电脑风扇狂转模拟真实提问分布

指标上除了常规的 hit@1/3/5(gold 块是否出现在前 k),我额外记了两个:

  • loose@k:前 k 里只要有一块来自正确关卡就算命中——因为课程块很细,答案常落在邻近块;
  • MRR:第一个正确块排名的倒数,反映"排序质量"而不只是"有没有找到"。

检索侧抽成了统一接口,配置可切换,跑一条命令就能横向对比:

bash
python tools/run_eval.py --config tfidf,bm25,hybrid,hybrid_w --boost 1,2,3

二、基线:先知道自己在哪#

第一轮跑完,主配置(TF-IDF + 关卡加权)在 219 题上是 hit@1 58.9% / hit@3 78.1% / hit@5 84.0%。分题型一看就看出问题:知识点问答 84% 很稳,但"第 N 关的动手练习要写什么"这类只有 0~10%。于是有了下面三个优化。

三、被数据选出来的三个优化#

1. 数字被分词器吃掉了(一个真 bug)#

分词正则原本是 [A-Za-z_][A-Za-z0-9_]*——只匹配字母开头的词,纯数字落进中文段后被丢弃。后果是「第 2 关」和「第 9 关」token 化后几乎一样,检索器分不清关卡,实测"第 2 关的编程题"召回了第 9 关的内容。

把数字变成独立 token(三处分词实现同步改 + 重建索引)后:

指标修复前修复后
"关卡任务"类 hit@510.3%48.3%
全局 hit@574.4%79.5%

2. 关卡号应该当"元数据条件",不是普通词#

修了数字还不够:「第 7 关的编程题要求是什么」里"编程""要求"这些词在别的关也出现,词面得分反而更高。于是加了一层关卡软加权——查询点名「第 N 关」时,该关的块得分 ×2。

为什么是软加权而不是硬过滤?因为「第 7 关的 while 循环」这种跨关提问是存在的,硬过滤会直接把正确答案挡在外面。

指标加权前×2(现网)×3
hit@150.2%58.9%59.8%
hit@579.5%84.0%85.4%

取 ×2 不取 ×3:后者只多 0.9 个百分点,但误伤跨关提问的风险更大——参数不是越激进越好,要看边际收益。

3. 条件式 query 改写:口语提问 0% → 38.9%#

口语对抗集暴露了最严重的问题:所有配置的 hit@1 都是 0%。「循环一直停不下来,电脑风扇狂转」和知识块里的「这一关常见的坑」词面零重合,再好的排序也救不回来。

解法不是换更重的检索模型,而是在 query 侧动手:先按原问题检索,如果 top-1 得分低于阈值(0.15),就用免费模型把它改写成关键词式查询,再检一次。

text
输入:循环一直停不下来,电脑风扇狂转,怎么让它能正常结束?
改写:死循环 无限循环 电脑风扇狂转 结束 循环控制 break
结果:top-1 从 0.091(第 5 关的练习块)
      → 0.215(第 5 关"break:一脚刹车"),引用全部落在第 5 关
数据集改写前全量改写条件改写(现网)
口语集 hit@1 / hit@30.0% / 16.7%38.9% / 77.8%38.9% / 77.8%
规则集 hit@1 / hit@358.9% / 78.1%52.1% / 73.1%58.0% / 75.8%

注意"全量改写"那列:口语题大涨,但本来就检索友好的查询反而变差(改写会引入噪声技术点)。所以改成有条件触发——只有检索没把握时才改写,用检索分数当判据,零额外成本。阈值扫描发现两个数据集的 top-1 分数分布天然可分(规则集中位 0.153 / 口语集中位 0.096),选 0.15 让口语拉满、规则集只掉 0.9 个百分点。

四、被数据否决的两个想法#

❌ 向量检索:11.4% vs 58.9%#

我本以为向量检索是最大的收益点,还专门绕开了 torch——用 ONNX 量化版模型(24MB)在构建期算好向量写进索引,线上只编码 query 做点积,一台 2 核小机器也跑得动。结果:

配置(219 题)hit@1hit@3hit@5
TF-IDF(词面)58.9%78.1%84.0%
纯向量11.4%31.1%43.4%
词面+向量融合(RRF)28.8%53.0%73.5%

原因不神秘:小模型(512 维量化版)配合短中文问句,语义分辨力不足以超越"问句和文档共享关键词"这个词面强信号;而评测集的问法又恰好与文档词面重合度高。它在"表述差异大"的查询上确实更好(踩坑类 hit@3 48.3% vs 41.4%),但占比太小,整体是负收益。

结论:先不上线。 向量管线和接口保留在仓库里,等换更强的模型(API 版)再做对比——重要的是这个决定是数据给的,不是感觉给的。

⚠️ 顺带一个反直觉发现:融合两个词面检索器(TF-IDF + BM25)零增益(50.2% vs 50.2%)。因为它们看的是同一份词面信号。要融合就得融异构的(比如向量),同源融合只是自我重复。

❌ 按关卡取块:78.1% → 77.2%#

指标里有个反复出现的现象:loose(关卡级)远高于 strict(块级),说明"关卡找得对、块找不准"。于是我想:那干脆多给同关的几块?实测直接打脸——

配置hit@1hit@3hit@5
常规检索58.9%78.1%84.0%
同关多取几块58.9%77.2%82.2%

原因:"关卡识别率高"并不能推出"同关的块都有用"——正确答案是特定块,同关的其他块占了位置,反而把别的关里真正正确的块挤了出去。这个优化没上线(它在"生成质量"上是否有效需要另一套评测,见文末)。

五、三条方法论#

  1. 评测口径必须和线上实现完全一致。 我第一版把"改写后分数更高才采用"写进了线上代码,而评测脚本里没有这层判断——结果线上功能永远不生效。余弦分数不可跨查询比较(改写后词更多、分母更大,分数天然偏低),任何"谁分高用谁"的直觉都会毁掉改写。
  2. 负结果同样是产出。 这轮最有价值的两个结论都是"不要做什么":不要上小模型向量、不要按关卡取块。没有评测,这两个改动很可能会以"看起来更高级"的理由上线,然后悄悄拉低效果。
  3. 验证线上行为要带服务的环境变量。 密钥是 systemd 注入服务进程的,我在 shell 里手跑脚本拿到的是空 key(401),一度以为功能没生效——纯属假阴性。现在验证一律走真实服务进程或带上环境文件。

现状与下一步#

现网三层优化叠加:数字分词修复、关卡加权 ×2、条件式 query 改写。全部改动都有备份点和回滚路径。

图 2 · 这轮改动的取舍结果(上线 3 项 / 否决 3 项)
图 2 · 这轮改动的取舍结果(上线 3 项 / 否决 3 项)本轮检索优化已上线数字分词修复关卡加权 ×2条件式 query 改写已否决纯向量检索按关卡取块同源 RRF 融合
  • 下一步候选:换 API 版 embedding + rerank 重测(补上向量没跑完的那半场);
  • 把口语集从 18 题扩到 50+(现在样本小、方差大);
  • 做生成层评测:现在只评了"检索有没有找到",还缺"答得对不对"——像"按关卡取块"这类改动,最终要靠生成质量来裁决。
检索层是 AI 陪练的地基,而地基的质量只能靠数据说话。这套评测现在也是回归测试:以后每次动检索,先跑一遍 237 题,指标掉了就别合并。
🏷 RAG🏷 检索评测🏷 词面检索🏷 Query 改写
💬 问我 只依据站内资料作答
可以问这个站点的技术内容,例如「pycamp 的代码沙箱怎么做的」「为什么不直接用向量检索」。