生命不息,折腾不止。前两课把知识库做到「能存能查」了,但还差两口气:检索回来的候选到底谁该排第一?调来调去,怎么证明自己越调越好?这一课补上 reranker 精排 + 评测打分,让知识库从「能跑」变成「测得准」。

上一课我们把 BM25 + 向量检索用 RRF 融合,能同时召回「精确命中」和「语义相关」的 chunk。但一个新问题冒出来了:召回是宽进宽出,一次给你 top50、top100 个候选,谁最相关、谁该先喂给大模型,纯靠原来的分数排序其实拿不准。另外,你改个切分参数、换了个 embedding,到底有没有变好,不能靠感觉,得有个数字说话。

这就是这一课要干的活:加一层 reranker 精排 + 写一个评测脚本。

一、先搞清楚:召回和精排是两个阶段

RAG 检索里,模型分两种思路,成本天差地别:

  • Bi-Encoder(双塔,召回用):query 和文档各自单独编码成一个向量,用余弦/点积算相似度。优点是快、能预计算海量文档向量;缺点是「各算各的」,query 和文档之间没发生真正的交互,精细的相关性判断不准。
  • Cross-Encoder(交叉编码,精排用):把 query 和文档拼接成一段一起丢进 transformer,直接输出一个相关性分数。优点是准得多;缺点是没法预计算、一次只能处理一对,慢。

所以业内的标准玩法是两段式:先用双塔(上一课的 FAISS + BM25)把全库成千上万篇快速召回成 top50~100 个候选,再用 Cross-Encoder 把这个小集合精排一遍,取最靠前的几个塞给大模型。一句话记住:

召回负责「别漏」,精排负责「别乱」。

你不用担心 Cross-Encoder 慢——它只对着几十个候选跑,不是对着全库跑,时间完全可控。

二、上一个 reranker:一个 CrossEncoder 就够

精排最省事的是直接用现成的 reranker 模型。中文场景我推荐 BAAI/bge-reranker-v2-m3,多语言、中文效果稳,就长这样:

1
2
3
4
5
6
7
8
9
10
from sentence_transformers import CrossEncoder

model = CrossEncoder("BAAI/bge-reranker-v2-m3")

def rerank(query, candidates, top_k=8):
# candidates: [(chunk_id, text)],来自上一课的混合检索
pairs = [(query, text) for _, text in candidates]
scores = model.predict(pairs) # 每个 (query, doc) 对一个分数,越高越相关
ranked = sorted(zip(candidates, scores), key=lambda x: -x[1])
return ranked[:top_k]

用法就是:先用混合检索召回 top100,再把 top100 丢给 reranker 精排出 top8,最后喂给大模型的就这 8 个。代码逻辑是:

1
2
top100 = hybrid_search(query, k=100)   # 上一课的 BM25 + 向量 + RRF
top8 = rerank(query, top100, top_k=8) # 精排

其他可选的 reranker 也顺手列一下,按需换:

  • BAAI/bge-reranker-baseBAAI/bge-reranker-large:同门的更小/更大号,精度阶梯。
  • cross-encoder/ms-marco-MiniLM-L6-v2:纯英文、只有 22M 参数,跑得快,适合英文语料想省资源的场景。
  • Cohere / Jina 的 Rerank API:不想本地跑模型,就调云 API,按调用计费。

记一个关键差异:向量 cosine 是在「各算各的向量」上比相似,Cross-Encoder 是把两段文字塞一起精算,所以精排后的排名通常明显更顺。光嘴上说没用,下一节教你怎么用数字证明它。

三、给 RAG 打分:先搞懂这几个指标

要评测,得先有一份测试集:一组 (问题, 标准答案chunk),比如「ERR_500 怎么处理」该命中哪几个 chunk id,手工标好。有了标准,才谈得上打分。

检索层只看「该找的找到没、排没排对」,最常用这五个:

  • Recall@k(召回率):标准相关的 chunk 里,有多少进了 top-k。这是 RAG 最关键的指标——相关 chunk 没进上下文,大模型再怎么牛也答不对。目标一般 ≥ 0.8(k=5)。
  • Precision@k(精确率):top-k 里有多少是真的相关。它管的是「别塞一堆垃圾进上下文」,k 越大越容易掉。
  • Hit@k(命中率):top-k 里只要命中至少一个相关 chunk 就算 1,否则 0,最后取平均。最直观的「这题答没答到点」。
  • MRR(平均倒数排名):看第一个相关 chunk 排在第几位,取排名倒数再平均。排第 1 得分 1,排第 5 得分 0.2。衡量「好东西排得靠不靠前」。
  • NDCG@k(可选):带分级相关度(相关/非常相关)的排序质量,需要更细的标注才值得用,初学先跳过。

一句话区分盲点,方便你选指标:

指标 回答的问题 盲点
Recall@k 该找的都找到没 不管排名、不管混进来的噪音
Precision@k 找回来的干净不干净 不管是不是漏了相关 chunk
Hit@k 至少命中一个没 只认「有没有」,不认「排第几」
MRR 第一个相关的排得靠前不 只盯第一个命中,后面的不管

四、写一个一劳永逸的评测脚本

下面这个脚本把上面五样指标一次性算出来,而且能对比精排前后,让你亲眼看到 reranker 到底有没有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def evaluate(test_set, retrieve_fn, rerank_fn=None, k=5):
recall = hit = mrr = 0.0
precisions = []
for q, gold_ids in test_set:
# 1) 召回 + 可选精排
candidates = retrieve_fn(q) # [(chunk_id, score)]
if rerank_fn:
candidates = rerank_fn(q, candidates) # 精排后的 (chunk_id, score)
top_ids = [cid for cid, _ in candidates[:k]]

hit_set = set(top_ids) & set(gold_ids)
recall += len(hit_set) / len(gold_ids) # Recall@k
hit += 1 if hit_set else 0 # Hit@k
precisions.append(len(hit_set) / k) # Precision@k
for i, cid in enumerate(top_ids, 1): # MRR
if cid in gold_ids:
mrr += 1 / i
break

n = len(test_set)
return {
f"recall@{k}": recall / n,
f"precision@{k}": sum(precisions) / n,
f"hit@{k}": hit / n,
"mrr": mrr / n,
}

test_set = load_test_set() # [(query, [gold_chunk_id, ...]), ...]

print("无 reranker:", evaluate(test_set, lambda q: hybrid_search(q, k=100)))
print("有 reranker:", evaluate(test_set, lambda q: hybrid_search(q, k=100),
rerank_fn=lambda q, c: rerank(q, c, top_k=8)))

跑一版,你会看到类似这样的结果(示意值):

1
2
无 reranker: {'recall@5': 0.71, 'precision@5': 0.46, 'hit@5': 0.78, 'mrr': 0.52}
有 reranker: {'recall@5': 0.83, 'precision@5': 0.61, 'hit@5': 0.88, 'mrr': 0.69}

数字一摆,精排值不值得上,一目了然。改切分、换 embedding、调 k,都用这同一份脚本跑,改动好坏不再靠玄学

五、这几个坑,踩过才知道

  • 精排前召回要留够。reranker 是「从候选里挑」,候选没召回全,它再神也白搭。所以精排一定放在「宽召回 top50~100」之后,别图省事把召回 k 也设成 8。
  • reranker 只对候选跑。别犯「对全库每个 chunk 都跑一遍 CrossEncoder」的错,速度会教你做人。
  • 评测要分层。retrieval 层(recall/MRR/hit)和 generation 层(忠实度、答案正确性)是两码事,得分开测。recall 低是检索的问题,recall 高但答案胡扯是生成的问题,混在一起只会误导你。
  • 指标涨了要回看样本。一个 aggregate 数字看不出门道,把「加了 rerank 反而掉」的 query 挑出来看几眼,往往能发现测试集标注或切分的问题。
  • 测试集要跟线上分布一致。你拿技术文档的测试集测出来 recall 0.9,上线用户问的是口语化问题,指标就全骗了你。

六、小结

这一课给知识库补上了「眼睛」和「尺子」:用 CrossEncoder 精排,让最相关的 chunk 稳坐头名;用 recall/MRR/hit 这些指标,把每次改动的好坏量化出来。到这一步,你的 RAG 检索端算是「测得准、调得动」了。

但还有个关没打通:把检索到的上下文变成真正给人看的答案——生成层还没接上。

生命不息,折腾不止。下一篇 RAG 实战第四课:把召回的上下文喂给大模型,生成带引用来源的答案,并补上 faithfulness(忠实度)、答案正确性这些生成层指标——让整条「召回 → 精排 → 生成」链路真正闭环,答得不光有据,还能摊开给你看出处。回见。