本文是「RAG 链路实测」第 2 篇 · 上篇:RAG 分块策略实测
后端做了多年了,推荐、搜索都碰过,对这套分层不陌生:召回用便宜的向量检索把十万候选筛到几百,精排用贵的模型把几百排到几十。RAG(检索增强生成,Retrieval-Augmented Generation)的「双编码召回 + CrossEncoder(交叉编码器)重排」是同一件事换了相似度函数——双编码把 query 和段落各压成一个向量,离线可算、在线只做点积,快但糙;交叉编码把 query 和段落拼起来过一遍模型,准但每对都要现算。这篇用公开基准把精排层单独拆出来测:它到底能救回多少分,救不回的题死在哪,每次查询要多付多少毫秒——不,是几秒。
一、实验怎么搭的
为什么换公开数据集:第 1 篇用自建语料和自建题,结论外推性存疑。这篇直接上 DuReader-retrieval——百度/BAAI 出的中文段落检索数据集,查询来自真实百度搜索,正例段落人工标注。测试集不是“我出的题”,是别人标的金标准,对读者更硬。
数据口径(从data/dureader/三个 jsonl 可完整复现):
| 项 | 值 |
|---|---|
| 查询 | dev split 2000 条中固定种子抽 500 条(真实搜索 query,平均 9 字) |
| 语料池 | 全部正例∪负例去重段落 93,885 篇(平均 328 字 / 中位 275 字 / 最长 42,467 字) |
| 标注 | 每查询平均 3.16 个正例段落;负例为 BM25 难负例 |
诚实口径:语料池是 dev 候选池,不是 866k 全库——池内全是难负例,难度高于随机采样,MRR 不能跟官方全库 leaderboard 直接对比。候选池口径的好处是 CPU 跑得动,而且难负例把重排的价值空间真实压了出来。
管线:
查询 → bge-small-zh-v1.5 双编码召回 top-N → [bge-reranker-base 交叉编码重排] → 指标召回层与第 1 篇同款(bge-small-zh-v1.5,余弦)。9.4 万段落的嵌入只算一次、向量矩阵落盘复用——重排实验换的只是排序层,不碰索引。检索用 numpy 精确余弦而非 ANN(近似最近邻)近似:这里踩了个真实的坑,Windows 上 Chroma PersistentClient 进程正常退出后 HNSW 段不落盘,重开直接 InternalError,9.4 万向量白嵌一遍;对 9.4 万×512 维的规模,精确点积本来就是秒级,基准口径还少了 ANN 的近似噪声。查询侧双编码时带不带指令前缀(bge 官方检索用法)也做了对照,MRR@10 高的那侧(0.5042 vs 0.5001)定为召回层。
主变量两个:重排开/关、候选深度 N(10/20/50)。指标 Hit@1/5/10(前 k 条里至少有一个正例即记命中)、MRR@10(首个正例的倒数排名均值),金标注到段落级。全部 CPU(6 核 12 线程)单次运行。
二、主结果:精排救回的不是召回,是排序
500 题全量跑完,主表长这样:
| 配置 | Hit@1 | Hit@5 | Hit@10 | MRR@10 |
|---|---|---|---|---|
| 仅召回(cosine 直接取 top-10) | 0.364 | 0.696 | 0.780 | 0.5042 |
| 召回 + 精排(候选池 10) | 0.486 | 0.742 | 0.780 | 0.5931 |
| 召回 + 精排(候选池 20) | 0.506 | 0.782 | 0.840 | 0.6229 |
| 召回 + 精排(候选池 50) | 0.504 | 0.802 | 0.856 | 0.6291 |
第一,候选池不扩,只换排序器,Hit@1 就涨了 12.2 个点(0.364→0.486)。候选池是同样的 top-10,召回阶段 cosine 把真答案埋在中后位、把不相干内容顶到最前,CrossEncoder 重排一遍就把它挪到第一。这一档 Hit@10 纹丝不动(0.780)——池子没变大,只是重新排队。这部分收益不花一分钱检索成本,是精排层最白赚的钱。
第二,候选池从 10 开到 20,Hit@10 才动了(0.780→0.840),Hit@1 再抬到 0.506。前 20 名之外还压着真答案的题,靠扩池捞进来再精排,才够得着 top-10。
第三,20 到 50 基本是白烧:候选翻 2.5 倍,精排算力翻 2.5 倍,Hit@10 只再涨 1.6 个点,Hit@1 反而回落 0.002,MRR 象征性涨 0.006。top-20 是这组实验的甜点。
对照召回基线,精排(候选池 20)把 Hit@1 从 0.364 抬到 0.506、MRR@10 从 0.504 抬到 0.623。重排的价值主要在 Hit@1 和 MRR,也就是“把对的排到最前面”;它改变不了命中上限,那个上限由召回层和候选池决定。
按查询长度分层看,收益不是均摊的:短查询(≤8 字,214 题)MRR@10 从 0.5114 到 0.6193,长查询(286 题)从 0.4988 到 0.6256。长查询的重排收益略大、终点更高——信息量大、噪声候选多,排序层越有用。
三、救不回的题死在哪:先归因,再上精排
重排不是万能的。把 500 题按 rerank@20(精排候选池 20)对照召回基线 top-10 做失效归因,结果分五类:
| 类别 | 题数 | 占比 | 含义 |
|---|---|---|---|
| 召回缺失 | 51 | 10.2% | 正例压根不在 top-50,精排无米下锅 |
| 精排救回 | 36 | 7.2% | 召回没进 top-10,精排拉进来了 |
| 精排误杀 | 6 | 1.2% | 召回在 top-10,精排后掉出去了 |
| 排不进去 | 23 | 4.6% | 正例在 top-50 池里,但两轮都没进 top-10 |
| 两轮都在 top-10 | 384 | 76.8% | 无争议命中 |
五类分配合计严丝合缝:召回基线 top-10 命中 390 题 = 384 + 6;rerank@20 命中 420 题 = 384 + 36。
51 题召回缺失是最值得先看的一类。比如“为什么京东商城打不开”这种故障类查询,正例在当年的语料里可能压根没有——召回层把 top-50 都翻遍了也找不到,CrossEncoder 再强也没得排。工程含义很直接:精排只能救“正例在池里但排不进来”的题,救不了“正例根本不在池里”的题。上线重排之前先量这个占比:占比高,说明该修召回(扩 N、换更强的嵌入模型、上混合检索),而不是急着加精排。在这组数据里,10.2% 的题精排连参与资格都没有。
36 题救回是真金白银。举一个:查询“北京机动车过户地点”,召回没把任何正例送进 top-10(6 个正例全部压在池子深处),rerank@20 把 3 个正例分别提到第 1、4、10 位——材料准备、过户流程那种长段落,词面 overlap 少,cosine 抓不到,交叉编码能读懂“查的是过户要去哪办、要带什么”。这种“词面不沾边、语义相关”的题,就是重排存在的理由。
6 题误杀提醒别把重排当免费午餐。例:“would like 的回答”,召回阶段正确答案在 top-10,rerank@20 排序后掉到第 17 位、跌出 top-10(rerank@10 时它还在第 9 位——扩池捞进更多候选,反而把它挤了出去)。误杀率低(1.2%),但不是零——精排的输出是模型主观排序,会犯错。评估时别只看救回了多少,净收益(36 − 6 = 30 题)才是精排的真实贡献。
四、两个预埋的坑,都踩实了
坑 1:双窗截断,这次两边占比都不高,但长尾是真凶。bge-small 嵌入窗 512 token、CrossEncoder 的 pair 也是 512 token,超窗静默截断。实测:嵌入侧全语料 93,885 段里 7,173 段超窗,占 7.64%;重排侧 25,000 对 pair 里 1,173 对超窗,占 4.69%。比第 1 篇 fixed-1024 那个 90% 的截断率温和得多——DuReader 段落中位才 275 字。但最长的那段 42,467 字是必然超窗的,语料里这种百倍于窗口的长尾段落一旦被查询命中,嵌入侧和重排侧都在截断后的内容上打分。段落级语料同样要查max_seq_length,别默认“公开数据集都很短”。
坑 2:CrossEncoder 的分数不是置信度。这是我最想拿数据打脸的一个误用。bge-reranker-base 输出的分数落在 0~1,正例对平均 0.967、负例对平均 0.567——看着分得开?陷阱在分布里:全部 25,000 对候选的分数 P50 是 0.73,负例里有 56.7% 分数 ≥ 0.5,35.4% 分数 ≥ 0.9。为什么?因为这是 BM25 难负例 + 召回高分段落组成的池子,负例本来就“长得像答案”,分数普遍虚高。真拿固定阈值当过滤闸门试试:设 0.5,会误杀 1.7% 的正例、同时放进 56.7% 的负例;把闸门抬到 0.9,误杀涨到 7.9%,放进来的负例还有 35.4%。两头吃亏。这套分数只配组内排序用——“这段比那段更像答案”成立,“这段分数超过 0.8 所以可信”不成立。
五、成本口径:精排的贵,是按数量级算的
| 项 | 数值 |
|---|---|
| 查询嵌入(bge-small,CPU) | 7.4 ms/查询 |
| 余弦检索 top-50(93,885 段) | 2.7 ms/查询 |
| 精排候选池 20(20 对 pair) | 约 5.9 s/查询 |
| 精排候选池 50(50 对 pair) | 14.8 s/查询 |
| 精排吞吐 | 3.4 pair/s(CPU) |
| 模型体积 | bge-small 约 92 MB / bge-reranker-base 约 1.06 GB |
| 索引构建(一次性,93,885 段) | 约 74 分钟,21.1 段/s |
这组数把“贵”讲清楚了:召回全流程加起来 10 毫秒,精排一个 query 的 20 对候选要近 6 秒——约 600 倍,快三个数量级。模型体积也从 92 MB 跳到 1.06 GB,参数规模差约一个量级(24M vs 278M)。索引那 74 分钟是一次性成本,嵌入只算一次,之后换排序层、调 N 都不碰它。
值不值,取决于场景:离线精排(先粗排 200 条存下来,夜里把 top-20 精排好)几乎白赚;在线实时 RAG 要掂量,CPU 上 6 秒对交互不可接受,得上 GPU(批量时吞吐能拉上去)或者把候选池压到 10。换来的收益锚点就一句:Hit@1 从 0.364 到 0.506,MRR 从 0.504 到 0.623——值不值,拿你自己的查询分布和延迟预算套一下就有数。
六、工程结论
- 精排干的是“把对的往前挪”的活:候选池不扩、只换排序器,Hit@1 就涨 12.2 个点;它对已召回的题生效,对召回上限无能为力。
- 候选池 20 是甜点:10→20 有肉(Hit@1 再 +2、Hit@10 +6),20→50 基本白烧——先按你自己的数据扫一遍 N,别默认越大越好。
- 上精排前先做失效归因:这组数据 10.2% 的题正例不在 top-50,精排无米下锅;召回缺失占比高时,先修召回再上精排。
- 精排会误杀,评估看净收益:救回 36、误杀 6,净 +30 才是它的真实贡献;只报救回数是自欺。
- 分数别当置信度:难负例池里负例 35% 以上分数过 0.9,固定阈值过滤两头吃亏;rerank 分数只做组内排序。
- 成本按数量级算:召回 10 ms、精排 6 s(CPU);离线或 GPU 场景随便上,在线 CPU 场景先压缩候选池。
数据核验说明
- 语料与查询来自 DuReader-retrieval dev split(HuggingFace
zyznull/dureader-retrieval-ranking),数据本体不入 git,src/download_data.py一条命令从 hf-mirror 拉取;段落池 93,885 篇 = 全部正例 ∪ 负例去重,正例在池内 100% 核验; - 查询抽样固定种子 42,500/2000,可复现;每查询正例平均 3.16 个;
- 全部实验单次运行(无重复),CPU(6 核 12 线程)口径;嵌入 bge-small-zh-v1.5(512 维,余弦),重排 BAAI/bge-reranker-base(max_length=512);检索为 numpy 精确余弦(非 ANN 近似),无近似噪声;
- 指标口径:top-k 含 ≥1 正例记 hit,MRR@10 取首个正例排名倒数;召回层采用带指令前缀一侧(MRR@10 0.5042 vs 无前缀 0.5001),两侧数字均如实入库;
- 候选池非 866k 全库,MRR 不与官方 leaderboard 直接对比;池内为 BM25 难负例,难度高于随机采样;
- 截断口径:嵌入侧统计全语料段落、重排侧统计 500×50 = 25,000 对实际 pair,均按 max_length=512 token 判定;42,467 字长尾段落计入超窗统计,未单独归档;
- 归因口径:以 rerank@20 对照召回基线 top-10,五类互斥计数,500 题分配合计验证通过;
- 单次运行、n=500,报分层观察,不做显著性检验。
一条命令复现:
gitclone https://github.com/ethanliang2016/rag-lab&&cdrag-lab pipinstall-rrequirements.txt python src/download_data.py&&python src/prepare_dataset.py python src/build_index.py&&python src/run_rerank.py模型自动从 hf-mirror 下载,全部结果(含逐查询命中记录、失效归因明细、截断与 logit 统计)在results/rerank_results.json。跑批支持断点续跑(--resume),算到一半断电不丢进度。
留个问题:你们的 RAG 现在上精排了吗?候选池开多大、延迟预算给多少?评论区聊聊,下一篇我拿生成层来补完这条链路。
相关实测:
- 《RAG 分块策略实测:固定窗口 vs 递归切分 vs 结构感知,用 100 道题说话》—— 分块榨不出分,才要往下游要,这是本系列第 1 篇
- 《让大模型给 6,208 条答案打分:判对率 94%,逐字正确率 6%》—— 效果好不好,先得有个可信的尺子
完整导航:博客导航|agent 安全 / RAG 实测 / AI 代码治理,都在这