给自研 RAG 做评测:三个优化是怎么被数据选出来的
本文目录(11 节)
pycamp 的 AI 陪练会先在一份课程知识库里检索相关段落,再交给模型作答。检索准不准,直接决定回答会不会"一本正经地胡说"。但"优化检索"这件事最容易凭感觉——换个分词、加个向量、调个权重,看起来都像在进步。
所以这轮我先不优化,而是先建一把尺子:一套 237 题的评测集 + 一个能横向对比多种检索配置的 runner。后面的每一个改动,都必须拿数据说话。
一、评测底座长什么样#
知识库是 29 个关卡切出来的 239 个块(按小节/常见坑/动手练习分类,中文双字 bigram 建 TF-IDF 索引,全程零第三方依赖)。评测集有两份:
| 数据集 | 题量 | 构造方式 | 用途 |
|---|---|---|---|
| 规则集 | 219 | 从课程结构反向生成(知识点问答 / 关卡任务 / 常见坑 / 定位关卡 / 报错咨询) | 长期回归基线,gold 零标注成本 |
| 口语对抗集 | 18 | 手写"学员口吻"提问:循环一直停不下来,电脑风扇狂转 | 模拟真实提问分布 |
指标上除了常规的 hit@1/3/5(gold 块是否出现在前 k),我额外记了两个:
- loose@k:前 k 里只要有一块来自正确关卡就算命中——因为课程块很细,答案常落在邻近块;
- MRR:第一个正确块排名的倒数,反映"排序质量"而不只是"有没有找到"。
检索侧抽成了统一接口,配置可切换,跑一条命令就能横向对比:
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@5 | 10.3% | 48.3% |
| 全局 hit@5 | 74.4% | 79.5% |
2. 关卡号应该当"元数据条件",不是普通词#
修了数字还不够:「第 7 关的编程题要求是什么」里"编程""要求"这些词在别的关也出现,词面得分反而更高。于是加了一层关卡软加权——查询点名「第 N 关」时,该关的块得分 ×2。
为什么是软加权而不是硬过滤?因为「第 7 关的 while 循环」这种跨关提问是存在的,硬过滤会直接把正确答案挡在外面。
| 指标 | 加权前 | ×2(现网) | ×3 |
|---|---|---|---|
| hit@1 | 50.2% | 58.9% | 59.8% |
| hit@5 | 79.5% | 84.0% | 85.4% |
取 ×2 不取 ×3:后者只多 0.9 个百分点,但误伤跨关提问的风险更大——参数不是越激进越好,要看边际收益。
3. 条件式 query 改写:口语提问 0% → 38.9%#
口语对抗集暴露了最严重的问题:所有配置的 hit@1 都是 0%。「循环一直停不下来,电脑风扇狂转」和知识块里的「这一关常见的坑」词面零重合,再好的排序也救不回来。
解法不是换更重的检索模型,而是在 query 侧动手:先按原问题检索,如果 top-1 得分低于阈值(0.15),就用免费模型把它改写成关键词式查询,再检一次。
输入:循环一直停不下来,电脑风扇狂转,怎么让它能正常结束?
改写:死循环 无限循环 电脑风扇狂转 结束 循环控制 break
结果:top-1 从 0.091(第 5 关的练习块)
→ 0.215(第 5 关"break:一脚刹车"),引用全部落在第 5 关| 数据集 | 改写前 | 全量改写 | 条件改写(现网) |
|---|---|---|---|
| 口语集 hit@1 / hit@3 | 0.0% / 16.7% | 38.9% / 77.8% | 38.9% / 77.8% |
| 规则集 hit@1 / hit@3 | 58.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@1 | hit@3 | hit@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 版)再做对比——重要的是这个决定是数据给的,不是感觉给的。
❌ 按关卡取块:78.1% → 77.2%#
指标里有个反复出现的现象:loose(关卡级)远高于 strict(块级),说明"关卡找得对、块找不准"。于是我想:那干脆多给同关的几块?实测直接打脸——
| 配置 | hit@1 | hit@3 | hit@5 |
|---|---|---|---|
| 常规检索 | 58.9% | 78.1% | 84.0% |
| 同关多取几块 | 58.9% | 77.2% | 82.2% |
原因:"关卡识别率高"并不能推出"同关的块都有用"——正确答案是特定块,同关的其他块占了位置,反而把别的关里真正正确的块挤了出去。这个优化没上线(它在"生成质量"上是否有效需要另一套评测,见文末)。
五、三条方法论#
- 评测口径必须和线上实现完全一致。 我第一版把"改写后分数更高才采用"写进了线上代码,而评测脚本里没有这层判断——结果线上功能永远不生效。余弦分数不可跨查询比较(改写后词更多、分母更大,分数天然偏低),任何"谁分高用谁"的直觉都会毁掉改写。
- 负结果同样是产出。 这轮最有价值的两个结论都是"不要做什么":不要上小模型向量、不要按关卡取块。没有评测,这两个改动很可能会以"看起来更高级"的理由上线,然后悄悄拉低效果。
- 验证线上行为要带服务的环境变量。 密钥是 systemd 注入服务进程的,我在 shell 里手跑脚本拿到的是空 key(401),一度以为功能没生效——纯属假阴性。现在验证一律走真实服务进程或带上环境文件。
现状与下一步#
现网三层优化叠加:数字分词修复、关卡加权 ×2、条件式 query 改写。全部改动都有备份点和回滚路径。
- 下一步候选:换 API 版 embedding + rerank 重测(补上向量没跑完的那半场);
- 把口语集从 18 题扩到 50+(现在样本小、方差大);
- 做生成层评测:现在只评了"检索有没有找到",还缺"答得对不对"——像"按关卡取块"这类改动,最终要靠生成质量来裁决。