Rerank 与混合检索:怎么让 RAG 从「能用」变成「好用」
上两篇我把 RAG 的概念和最小闭环讲完了------第 21 篇说清「先查后答」,第 22 篇手搓了一个纯 Python 的 MiniRAG。那个 MiniRAG 确实能跑,但**「能跑」和「好用」之间还差着一截**。
差在哪?就一句话:上篇的检索只用了「向量相似度」这一招,而这一招有两个天生的短板。 这篇就专治这两个短板------Rerank(重排序) 和 混合检索(Hybrid Search)。这是让 RAG 检索质量上一个台阶的最划算的两步,很多「知识库项目」从 demo 到能上线,就差这一步。
还是那句话:不引入新依赖 ,继续用 numpy + requests(外加 Python 标准库自带的 math、collections),代码照旧能直接跑。
一、先看清向量检索的两个短板
上篇的检索是这么干的:问题转向量 → 和所有文档块算余弦相似度 → 取 Top-K。这套「向量检索」很好用,但它有俩毛病:
短板一:会漏「精确关键词」。 向量管的是「语义像不像」,不是「词在不在」。你问「XT-9000 路由器怎么重置密码」,向量可能把「路由器怎么改密码」这种语义接近但压根没提 XT-9000 的块也捞出来,反而漏掉真正写着 XT-9000 的那块。型号、编号、人名、专有名词这类「精确词」,恰恰是向量检索的软肋。
短板二:排序不够准。 Embedding 模型本质是「两个东西分别编码再比距离」,它算的是「长得像不像」,不是「答不答得上」。所以 Top-K 里经常混进「相关但没用」的块,而且排序经常错------真正有用的那块可能排在第 3、第 4,甚至掉出 Top-K。

这两个短板,分别对应两个解法:短板二(排序不准)用 Rerank 救,短板一(漏关键词)用 混合检索 救。下面一个个来。
二、Rerank:粗召回 + 精排,把排序救回来
Rerank 的思路特别朴素:先用便宜快速的方法多捞一些候选(粗召回),再用一个更准但更慢的模型把这些候选重新排一遍(精排),最后只取前几个。
为什么要分两步?因为那个「更准的模型」------叫 cross-encoder(交叉编码器) ------它把「问题 + 文档」拼在一起 输入模型,让模型直接判断「这条能不能回答这个问题」,所以准确率远高于 embedding 那种「分别编码再比距离」的 bi-encoder。但它有个致命缺点:太慢。因为它要逐条算「问题+文档」的相关性,没法像向量那样一次性算完整个库。
所以正确姿势是:embedding 粗召回 Top-50,rerank 精排取 Top-5。 快慢搭配,准确率还上来了。

硅基流动(就是上篇做 embedding 那家)有现成的 rerank 模型 BAAI/bge-reranker-v2-m3,接口也是 OpenAI 兼容风格,一行就能调:
python
import requests
RERANK_MODEL = "BAAI/bge-reranker-v2-m3" # cross-encoder 重排模型
def rerank(query, documents, top_n=4):
"""对候选 documents 精排,返回 [(index, score)],按相关度降序"""
resp = requests.post(
f"{EMBED_BASE}/rerank",
headers={"Authorization": f"Bearer {EMBED_KEY}"},
json={"model": RERANK_MODEL, "query": query, "documents": documents, "top_n": top_n},
timeout=60,
)
resp.raise_for_status()
return [(r["index"], r["relevance_score"]) for r in resp.json()["results"]]
把它接在上篇的 search() 之后,就是一个「两阶段检索」:
python
def search_with_rerank(self, query, top_k=4, recall=20):
# 第一阶段:向量粗召回 Top-recall
q = np.array(embed([query])[0])
scores = cosine_similarity(q, self.vectors)
candidate_idx = np.argsort(scores)[::-1][:recall]
candidates = [self.chunks[i] for i in candidate_idx]
# 第二阶段:rerank 精排取 Top-k
ranked = rerank(query, candidates, top_n=top_k)
return [(candidates[idx], score) for idx, score in ranked]
就这几行,检索质量肉眼可见地提一档------召回还是向量干的,但最终给你的,是 cross-encoder 精挑过的。
三、混合检索:BM25 + 向量,一个管「精确」、一个管「语义」
Rerank 救了排序,但短板一「漏精确关键词」还没解决------因为粗召回阶段就把 XT-9000 那块给漏了,后面精排再准也捞不回来。所以要换思路:粗召回阶段就别只靠向量,再拉一个「关键词检索」进来打配合。
关键词检索的经典算法是 BM25,它干的是传统搜索的活------查「这串词在不在文档里、出现了几次、是不是稀有词」。它的强项正好是向量的弱项:
| 向量检索(语义) | BM25(关键词) | |
|---|---|---|
| 擅长 | 同义改写、意思相近 | 精确词、型号、编号 |
| 「XT-9000 重置」 | 可能捞到「路由器改密码」 | 精准命中含 XT-9000 的块 |
两个一互补,就把「漏词」和「漏意」都堵上了。这就是混合检索。
先写个精简版的 BM25(不装第三方库,标准库就够):
python
import math
from collections import Counter
class SimpleBM25:
def __init__(self, corpus, k1=1.5, b=0.75):
self.k1, self.b = k1, b
self.corpus = corpus
self.doc_tokens = [self._tokenize(d) for d in corpus]
self.doc_len = [len(t) for t in self.doc_tokens]
self.avgdl = sum(self.doc_len) / len(corpus)
df = Counter()
for t in self.doc_tokens:
df.update(set(t))
self.idf = {w: math.log((len(corpus) - n + 0.5) / (n + 0.5) + 1)
for w, n in df.items()}
def _tokenize(self, text):
"""简化中文分词:空格分的英文词保留,中文按单字(示意用,生产上 jieba)"""
toks = []
for seg in text.replace("\n", " ").split():
if any("一" <= ch <= "鿿" for ch in seg):
toks.extend(list(seg)) # 中文按字拆
else:
toks.append(seg.lower()) # 英文词小写
return toks
def scores(self, query):
qt = self._tokenize(query)
out = []
for dt, dl in zip(self.doc_tokens, self.doc_len):
tf = Counter(dt)
s = 0.0
for term in qt:
if term in tf:
idf = self.idf.get(term, 0)
denom = tf[term] * (self.k1 + 1) / (tf[term] + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
s += idf * denom
out.append(s)
return out
两条检索结果怎么合并?最常用的是 RRF(Reciprocal Rank Fusion,倒数排名融合)------它不看分数、只看排名,把「在向量榜排第几」和「在关键词榜排第几」按名次加权求和。优点是简单、不用调分数尺度:
python
from collections import defaultdict
def rrf_fuse(rank_lists, k=60):
"""rank_lists: 若干「按相关度降序的索引列表」,返回融合后的索引排序"""
score = defaultdict(float)
for ranks in rank_lists:
for rank, idx in enumerate(ranks):
score[idx] += 1.0 / (k + rank + 1) # 名次越靠前,贡献越大
return sorted(score, key=lambda i: -score[i])
把它们串起来,就是一个完整的混合检索:
python
class HybridRAG(MiniRAG):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.bm25 = None
def build_index(self, documents, **kw):
super().build_index(documents, **kw)
self.bm25 = SimpleBM25(self.chunks) # 顺便建 BM25 索引
def hybrid_search(self, query, top_k=4, recall=20):
# 向量召回 Top-recall
q = np.array(embed([query])[0])
vec_scores = cosine_similarity(q, self.vectors)
vec_rank = list(np.argsort(vec_scores)[::-1][:recall])
# BM25 召回 Top-recall
bm_scores = self.bm25.scores(query)
bm_rank = list(np.argsort(bm_scores)[::-1][:recall])
# RRF 融合 → 取 Top-k
fused = rrf_fuse([vec_rank, bm_rank])[:top_k]
return [(self.chunks[i], float(vec_scores[i])) for i in fused]
def ask(self, query, top_k=4):
hits = self.hybrid_search(query, top_k)
context = "\n\n".join(f"[资料{i+1}]\n{h[0]}" for i, h in enumerate(hits))
prompt = ("请只根据下面提供的资料回答问题;资料里没有的信息就明确说「资料里没有」,"
"不要编造。\n\n" + context + f"\n\n问题:{query}\n\n回答:")
return chat([{"role": "user", "content": prompt}]), hits
进阶版还可以在 RRF 之后再加一层 rerank(即「混合召回 → rerank 精排」),效果最好,代价是两次推理。生产里这个组合很常见,但 demo 阶段先跑通上面的就够。
四、避坑清单:这轮我又补了 5 个坑
-
Rerank 只对候选集做,别全量做:cross-encoder 慢,全库 rerank 直接把你拖垮。永远「向量粗召回 Top-20~50,再 rerank 取 Top-5」。
-
BM25 的中文分词别裸用空格 :上篇说过中文没空格,这里同理。英文库里
rank-bm25开箱即用,中文要么按字拆(我上面的示意),要么上jieba,否则 BM25 全退化成一整块。 -
RRF 的参数 k 别瞎调 :
k=60是社区常用的稳妥值,它对结果不敏感,别花太多时间在它上面。 -
rerank 和 embedding 别用混模型 :rerank 用的是 cross-encoder(
bge-reranker-v2-m3),embedding 用的是 bi-encoder(bge-m3),这俩是两种模型,别拿 embedding 模型去调/rerank接口。 -
先测「召回率」再测「答案质量」:很多项目一上来只看最终答案,结果错了都不知道是检索错还是生成错。正确姿势是先把「命中的资料对不对」这个指标单独跑通,再往下看。
五、什么时候值得上这套优化
不是所有场景都要上 rerank + hybrid,给个判断:
- 文档里大量专有名词 / 型号 / 编号:必上混合检索,纯向量一定漏;
- Top-K 里经常混进「相关但没用」的块:上 rerank,立竿见影;
- 数据量很小(几十块以内):别折腾,直接全量塞给模型都行,优化收益不大;
- 数据量大、要上线:混合召回 + rerank 精排,是当前性价比最高的标准配置。
小结
这篇把 RAG 从「能用」往「好用」推了一步,三句话收尾:
- 短板有两个:向量检索会漏「精确关键词」,而且排序不够准;
- 两个解法:Rerank 用 cross-encoder 精排救排序(粗召回 + 精排两阶段),混合检索用 BM25 + 向量互补救漏词,RRF 做融合;
- 还是一样的配方 :不引新依赖,
numpy + requests继续干,代码能直接跑。
系列预告:向量检索、BM25、rerank 都还是「在文本里捞」。但有些问题跨文档、要多跳推理------「A 和 B 什么关系」「这两份报告的共同结论是什么」------这时候得换一种组织方式。下一篇聊 GraphRAG:把文档抽成知识图谱,回答多跳问题。关注不迷路。
本文代码均为可直接运行的最小实现,接口调用遵循 OpenAI 兼容协议;rerank/embedding/BM25 算法细节与模型选型以各厂商官方文档为准。