2024 年我做一个 RAG 知识库问答系统,检索这一步,第一版我做得很省事:用户提问,我把问题向量化,去向量库里检索出最相似的 5 段,直接塞给大模型作答。本地我拿几个问题测了测——真不错:问什么,答案就在召回的那几段里。我心里很踏实:"RAG 的检索嘛,向量库召回 top-k,这 k 段就是最相关的,直接喂给模型不就行了。"可等这个系统真正上线、面对用户五花八门的真实提问,一串问题冒了出来。第一种最先把我打懵:某个问题,知识库里明明有一段写得清清楚楚的标准答案,可它就是没被召回进 top-5,模型自然答不出来。第二种:召回的 5 段里,真正能回答问题的那段,排在第 4 位、第 5 位,前面几段是字面相似、其实跑偏的内容——模型的注意力被前面几段带偏了,答得含含糊糊。第三种:我想"那就多召回点",把 top-k 从 5 调到 20,结果上下文一下子塞太满,既贵又慢,模型还在这 20 段里"中间遗忘",照样抓不准。第四种最隐蔽:用户用口语、用同义词提问,向量检索召回的,全是和问题"表层语义相似、意思却对不上"的段落。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"向量库召回的 top-k,就是最相关的几段"。这句话把"召回"和"精确排序"当成了一回事。可它不是。向量检索是为"快"和"召回率"设计的——它要在海量向量里飞快地、近似地捞出一批"大概相关"的候选;它给的排序是粗的:向量相似度只是语义相关的一个近似,相似不等于真能回答问题。真正决定该把哪几段、按什么顺序喂给大模型,需要第二个阶段——重排序。真正做好 RAG 的检索,核心不是"向量召回完就直接用",而是理解召回与精排是两件事、用 retrieve-then-rerank 两阶段架构、召回阶段放宽、精排阶段收紧、再用混合检索补上纯语义的短板。这篇文章就把 RAG 的检索重排序梳理一遍:为什么"召回即排序"是错的、双塔与交叉编码两类模型的分工、retrieve-then-rerank 两阶段怎么搭、重排序模型怎么用、混合检索怎么补短,以及重排延迟、分数阈值、检索评测这些把检索真正做对要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:把向量召回的 top-5 直接喂给模型之后,上线冒出一串问题:知识库里明明有标准答案,却没被召回进 top-5;召回的几段里,真正相关的排在末位,模型注意力被前面的无关段带偏;盲目调大 top-k,上下文塞太满又贵又慢、还中间遗忘;用户口语化、同义词提问时,召回的全是表层相似、意思跑偏的段落。
我当时的错误认知:"向量库召回的 top-k,就是最相关的几段,直接喂给模型就行了。"
真相:一个可靠的 RAG 检索,不是一步,而是两步:召回(retrieve)和精排(rerank)。这两步的目标根本不同。召回追求的是"快"和"不漏":它要在成千上万、甚至上亿的文档向量里,用近似最近邻(ANN)飞快地捞出一批候选——为了快,它用的是双塔模型,把 query 和 doc 各自独立编码成向量再算距离,这种算法能预计算、能建索引,所以快,但它对相关性的判断是粗糙的。精排追求的是"准":它只面对召回出来的那几十个候选,用一个更重、更慢、但更精确的模型(交叉编码模型),把 query 和每个 doc 拼在一起送进去,直接算出一个精确的相关性分数,据此重新排序。召回负责"把答案网进来",精排负责"把答案挑出来、排到最前"——少了精排这一步,你交给大模型的,就只是一堆"大概相关"、顺序还很粗的段落。
要把 RAG 的检索重排序做对,需要几块认知:
- 为什么"召回即排序"是错的——召回求快求全,精排才求准;
- 双塔与交叉编码——两类模型,一个快而粗、一个慢而精;
- retrieve-then-rerank——召回阶段放宽,精排阶段收紧;
- 混合检索——向量检索叠加 BM25,补上纯语义的短板;
- 重排延迟、分数阈值、检索评测这些工程坑怎么处理。
一、为什么"向量召回的 top-k 直接用"是错的
先把这件最根本的事钉死:向量检索给你的那个排序,从来不是"按相关性从高到低的精确排名"。它是"按向量距离从近到远"的排名——而向量距离,只是相关性的一个廉价的、近似的替身。两段文字向量距离近,通常意味着它们语义相关,但这只是"通常"。它可能因为话题相近而距离近,却答非所问;它可能因为用词风格像而距离近,却内容无关。更别说,为了在海量数据里追求速度,向量库用的还是"近似"最近邻——它连"距离最近"这件事本身,都只是个近似。你把这样一个排序的前 5 名直接当成"最该喂给模型的 5 段",本身就是一场误会。
下面这段代码,就是我那个"召回完就直接喂模型"的第一版:
# 反面教材:向量检索召回 top-5,原样直接喂给大模型
def search_and_answer(query, collection):
q_vec = embed(query) # 把问题编码成向量
hits = collection.query(
query_embeddings=[q_vec], n_results=5) # 召回最相似的 5 段
context = "\n".join(hits["documents"][0])
return llm_answer(query, context) # 5 段原样塞进 prompt
# 破绽一:向量相似度只是相关性的粗糙近似,top-5 未必真最相关。
# 破绽二:真正能回答问题的那段,可能排在第 8、第 15,根本没进来。
# 破绽三:就算进了 top-5,排在末位也会被前面的无关段带偏模型。
这段代码在本地拿几个问题测试时表现不错,因为那几个测试问题,我是看着知识库提的——用词和文档高度重合,向量检索这种粗排恰好够用。它的问题不在代码本身,而在一个被忽略的前提:它默认"向量距离的排名,就等于相关性的排名"。可真实用户的提问,用词、口吻、角度和文档千差万别。于是那串问题就有了解释:答案没进 top-5,是因为它和问题用词不重合,向量距离被排到了后面;相关段排在末位,是因为向量这把粗尺子分不清"第 1 相关"和"第 5 相关";调大 top-k 也没用,是因为粗排再长,也还是粗排,只是把噪声一起灌给了模型。问题的根子清楚了:做好 RAG 检索的工程量,全在"承认向量召回只是粗排、必须再加一道精排"之后——你不补上精排,就只能把一堆顺序很粗的候选直接甩给模型。先从看懂这两道工序背后的两类模型说起。
二、看懂两类模型:双塔召回与交叉编码精排
召回快、精排准,背后是两类工作方式完全不同的模型。召回用的是"双塔模型"(Bi-Encoder):它把 query 和 doc 分别、独立地编码成两个向量,再算这两个向量的距离。"分别独立"是它快的根源——所有 doc 的向量可以提前算好、建好索引,查询时只需现算一个 query 向量:
from sentence_transformers import SentenceTransformer
# 双塔模型:把 query 和 doc 各自独立编码成向量
_encoder = SentenceTransformer("BAAI/bge-base-zh")
def recall(query, collection, top_k=50):
"""召回阶段:用双塔模型编码 query,去向量库宽松地捞候选。"""
q_vec = _encoder.encode(query).tolist()
hits = collection.query(query_embeddings=[q_vec], n_results=top_k)
# 召回故意放宽 top_k —— 宁可多捞,先把答案"网"进候选池
return hits["documents"][0]
精排用的是"交叉编码模型"(Cross-Encoder):它把 query 和 doc 拼在一起,作为一个整体送进模型,让模型从头到尾通盘比对这两段文字,直接吐出一个相关性分数。正因为是"拼在一起算",它没法预计算(doc 的得分依赖于具体的 query),只能在查询时对每个候选现场跑一遍——慢,但准得多:
from sentence_transformers import CrossEncoder
# 交叉编码模型:把 query 和 doc 拼在一起,直接算出一个相关性分数
_reranker = CrossEncoder("BAAI/bge-reranker-base")
def rerank(query, docs, top_n=5):
"""精排阶段:逐个精算 query 与 doc 的相关性,重新排序。"""
pairs = [(query, doc) for doc in docs]
scores = _reranker.predict(pairs) # 逐对精算,慢但准
ranked = sorted(zip(docs, scores),
key=lambda x: x[1], reverse=True)
return ranked[:top_n] # 收紧:只留最相关的 top_n
这里的认知要点是:双塔模型和交叉编码模型,不是"谁取代谁",而是"各占一段"。双塔模型快,所以让它去面对海量文档、做第一道粗筛;交叉编码模型准,所以让它只面对粗筛出来的几十个候选、做第二道精挑。用快的扛规模,用准的保质量——这就是两阶段检索的全部分工逻辑。明白了分工,就可以把这两道工序串成一条完整的流水线。
三、retrieve-then-rerank:召回放宽,精排收紧
把召回和精排串起来,就是 RAG 检索的标准架构:retrieve-then-rerank(先召回,再重排)。这条流水线有一个关键的设计直觉:召回阶段要"放宽",精排阶段要"收紧"。召回时,多捞一些(比如 50 个),目的是尽量别把正确答案漏在门外——这一步宁可多、不可漏;精排时,只留几个(比如 5 个),目的是把噪声挡在模型门外——这一步宁缺、不可滥:
def retrieve(query, collection):
"""完整的两阶段检索:召回放宽 + 精排收紧。"""
# 阶段一:召回 —— 用快的双塔模型,宽松地捞 50 个候选
candidates = recall(query, collection, top_k=50)
# 阶段二:精排 —— 用准的交叉编码模型,精排出最相关的 5 段
ranked = rerank(query, candidates, top_n=5)
return [doc for doc, score in ranked]
如果不想自己部署交叉编码模型,也可以用云端的重排序 API(很多厂商提供 Rerank 服务),用法是一样的——传 query 和一批候选,拿回排好序的结果:
import requests
def rerank_via_api(query, docs, top_n=5):
"""用云端重排序 API 替代本地交叉编码模型,省去部署成本。"""
resp = requests.post(
"https://api.example-rerank.com/v1/rerank",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"query": query, "documents": docs, "top_n": top_n},
timeout=5,
)
results = resp.json()["results"]
# API 返回的是按相关性排好序的下标 + 分数
return [(docs[r["index"]], r["relevance_score"]) for r in results]
下面这张图,把一次两阶段检索的完整流程串起来:
这里的认知要点是:retrieve-then-rerank 的精髓,在于"用两种不同的粒度,做两件不同的事"。召回是粗粒度的、放宽的,它的职责只有一个——别让答案漏网;精排是细粒度的、收紧的,它的职责也只有一个——把答案挑出来排到最前。盲目调大 top-k 之所以没用,正是因为它只在"召回放宽"上使劲,却始终缺了"精排收紧"那一步。不过,上面召回这一步,还只用了向量检索一条腿——它有一个天生的短板,要靠混合检索来补。
四、混合检索:向量检索补不上的,交给关键词
纯向量检索有一个天生的短板:它擅长语义,却不擅长精确的字面匹配。当用户搜一个具体的产品型号、错误码、专有名词、缩写时,向量检索常常抓不住这个关键 token——它会找来一堆"语义上沾边"的,却漏掉那段恰好包含这个确切词的文档。补这个短板的,是关键词检索,经典算法是 BM25——它按关键词的重合程度打分:
from rank_bm25 import BM25Okapi
import jieba
def build_bm25(docs):
"""构建 BM25 关键词索引 —— 它擅长术语、型号、专名的精确匹配。"""
tokenized = [list(jieba.cut(d)) for d in docs]
return BM25Okapi(tokenized)
def bm25_recall(bm25, docs, query, top_k=50):
"""BM25 召回:按关键词重合度打分,补向量检索的字面短板。"""
scores = bm25.get_scores(list(jieba.cut(query)))
ranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
return [doc for doc, s in ranked[:top_k]]
有了向量召回和 BM25 召回两路结果,要把它们融合成一个。最常用的融合算法是 RRF(Reciprocal Rank Fusion,倒数排名融合):它不看两路各自的原始分数(那两个分数尺度根本不可比),只看每个文档在各路里排第几名,按名次的倒数累加:
def reciprocal_rank_fusion(rank_lists, k=60):
"""RRF:把向量召回和 BM25 召回两个排名列表,融合成一个。"""
scores = {}
for ranked in rank_lists:
for rank, doc in enumerate(ranked):
# 每个文档的得分 = 它在各列表里 1/(k+名次) 的累加
scores[doc] = scores.get(doc, 0) + 1 / (k + rank + 1)
fused = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [doc for doc, score in fused]
这里的认知要点是:向量检索和关键词检索,是两种互补的"理解"——前者懂"意思",后者认"字眼"。一个用户既可能换一种说法来问同一件事(这要靠语义),也可能精确地搜一个错误码(这要靠字面)。混合检索就是同时派出这两条腿去召回,再用 RRF 把它们的结果公平地融合——融合后的候选池,才真正既不漏语义、也不漏字面。召回、融合、精排的主链路走通了,最后是几个真正上规模后才会撞见的工程坑。
五、工程坑:重排延迟、分数阈值与检索评测
两阶段检索的主链路之外,还有几个工程坑,不处理就会让检索要么慢、要么不准、要么你根本不知道它准不准。坑 1:重排序是慢操作,延迟必须管住。交叉编码模型要对每个候选现场跑一遍,召回 50 个候选就要算 50 次,这是实打实的延迟。对策有两个:一是别把召回的 top_k 放得过大(50~100 通常够了,放到 500 只是徒增延迟);二是对"相同 query + 相同候选集"的重排结果做缓存:
import hashlib
_rerank_cache = {}
def cached_rerank(query, docs, top_n=5):
"""重排序较慢,对"相同 query + 相同候选集"的结果做缓存。"""
key = hashlib.md5(
(query + "||" + "|".join(sorted(docs))).encode()).hexdigest()
if key in _rerank_cache:
return _rerank_cache[key] # 命中缓存,省下一次重排
result = rerank(query, docs, top_n=top_n)
_rerank_cache[key] = result
return result
坑 2:精排分数太低的候选,宁可丢掉。精排只负责把候选排序,不负责判断"到底相不相关"。如果知识库里压根没有能回答这个问题的内容,精排照样会给你排出一个 top-5——只不过这 5 段分数都很低。把这些低分段喂给模型,只会诱导它编造。所以精排之后要再加一道分数阈值闸:
def rerank_with_threshold(query, docs, top_n=5, min_score=0.3):
"""精排后再加一道阈值闸:分数过低的候选,宁可不要也不喂给模型。"""
ranked = rerank(query, docs, top_n=top_n)
kept = [(doc, s) for doc, s in ranked if s >= min_score]
if not kept:
# 一段都没过线 —— 知识库里大概率没有答案,如实告知用户
return []
return kept
坑 3:检索质量必须用指标量,不能凭感觉。"我觉得检索变准了"是没有意义的。要准备一个测试集(一批"问题—标准答案文档"对),用两个指标持续衡量:命中率(标准答案有没有进 top_n)和 MRR(标准答案排在第几位,越靠前得分越高):
def evaluate(test_set, collection):
"""评测检索质量:命中率(答案有没有进结果)和 MRR(排得够不够前)。"""
hit, mrr = 0, 0.0
for question, answer_doc in test_set:
docs = retrieve(question, collection) # 走完整的两阶段检索
if answer_doc in docs:
hit += 1
rank = docs.index(answer_doc) + 1 # 答案排在第几位
mrr += 1.0 / rank # 排得越靠前,得分越高
n = len(test_set)
return {"hit_rate": hit / n, "mrr": mrr / n}
坑 4:加了重排,分块质量依然是地基。重排序能把好的候选排到前面,却变不出原本就不存在的好候选。如果文档分块本身就是碎的(半句话、半张表),那召回进来的就是残片,重排只是在一堆残片里挑了个最不残的。重排是检索的"提纯",不是"点石成金"——它的上限,被分块质量牢牢锁死。坑 5:不同重排模型要实测选型。重排模型有大有小、有中英文偏向,别人榜单上最好的,未必最适配你的领域和语言。拿你自己的测试集,用坑 3 的评测脚本,把几个候选模型实测一遍,用数据选型,而不是迷信榜单。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 召回 Recall | 从海量文档里快速捞出一批"大概相关"的候选 |
| 精排 Rerank | 对召回的候选逐个精算相关性,重新排序 |
| 双塔模型 | query 与 doc 各自独立编码,快、可预计算,但排序粗 |
| 交叉编码模型 | query 与 doc 拼在一起算分,慢但相关性判断精准 |
| retrieve-then-rerank | 召回放宽 + 精排收紧的两阶段检索架构 |
| BM25 | 基于关键词重合度的检索,擅长术语、型号的精确匹配 |
| 混合检索 | 向量检索与关键词检索并行,互补语义与字面的短板 |
| RRF 融合 | 按名次倒数把多路检索结果融合成一个统一排名 |
| 分数阈值 | 精排分数过低的候选宁可丢弃,避免诱导模型编造 |
| 命中率 / MRR | 检索评测指标,答案进结果且排得越靠前得分越高 |
避坑清单
- 向量召回的 top-k 是"大概相关",不等于精确排序,别直接用。
- 召回与精排是两件事:召回求快求全,精排求准。
- 召回阶段放宽 top-k,先尽量把正确答案网进候选池。
- 精排用交叉编码模型,逐对精算 query 与 doc 的相关性。
- 别靠盲目调大 top-k 提效果,上下文塞太满又贵又中间遗忘。
- 纯向量检索对术语、型号、缩写不敏感,要叠加 BM25 关键词检索。
- 多路检索结果用 RRF 按名次融合,不要简单拼接或比原始分。
- 精排后加分数阈值,候选都过低就如实说没有答案。
- 重排序较慢,要控制召回 top_k 并对重排结果做缓存。
- 用命中率和 MRR 持续评测,重排模型要拿自己的数据选型。
总结
回头看那串"答案没召回、相关段排末位、调大 top-k 也没用"的问题,以及我后来在检索上接连踩的坑,最该记住的不是某一个模型名字,而是我动手前那个想当然的判断——"向量库召回的 top-k,就是最相关的几段"。这句话错在它把"召回"这个动作,误当成了"精确排序"这个结果。我以为向量库吐出来的顺序,就是相关性的真实顺序。可我忽略了一件事:向量检索为了能在海量数据里跑得飞快,用的是一把又快又粗的尺子——它量出来的"近",只是"相关"的一个近似。把这把粗尺子的前 5 名直接交给大模型,不是做了检索,而是把"挑出最该看的内容"这件最关键的事,糊弄了过去。
所以做好 RAG 的检索,真正的工程量不在"调用一次向量库的 query"那一行代码上。那一行,谁都会写。真正的工程量,在于你要承认"召回只是粗排,精排才定胜负",并据此把检索拆成放宽与收紧的两个阶段:召回时,你就用快的双塔模型,宽松地多捞一些,别让答案漏在门外;召回之后,你就用准的交叉编码模型,逐个精算、重新排序;向量检索认不出术语,你就叠一路 BM25 关键词检索,再用 RRF 融合;精排分数太低,你就设个阈值把它挡下、宁可说"没有";你还得用命中率和 MRR,把检索准不准这件事真正量出来。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"召回即排序"为什么错,再讲两类模型的分工、两阶段架构怎么搭、混合检索怎么补短,最后是重排延迟、分数阈值、检索评测这几个把检索做扎实的工程细节。
你会发现,RAG 的两阶段检索,和现实里"一家公司怎么招人"完全相通。一个职位来了一万份简历,公司不可能逐份细读。一个偷懒的 HR 会怎么做?他用几个关键词把简历快速过一遍,排在最前的 5 份,直接就发了 offer(这就是拿向量召回的 top-5 直接用)。结果呢?真正的好苗子,可能因为简历用词朴实,被排到了第 80 名,连面试机会都没有(这就是答案没进 top-k);而排在最前的几个,只是关键词堆得好看,一聊才发现并不胜任(这就是相关段排在前面却跑偏)。而一个专业的招聘流程怎么做?它分两轮:第一轮粗筛,用关键词从一万份里宽松地筛出 50 份——这一轮宁可多放几个进来,也别错杀好苗子(这就是召回放宽);第二轮面试,让面试官对这 50 个人逐一深谈、仔细考察,最后只挑出真正最合适的 5 个(这就是精排收紧)。两家公司都招到了人,可前者招进来的常常名不副实,后者招进来的才是真正能干活的。
最后想说,RAG 的检索做没做对,差距永远不会在"本地拿几个问题一测就准"时暴露——本地你提的那几个测试问题,用词是照着文档来的,和文档高度重合,粗排恰好够用,你会觉得"向量库召回 top-k"已经是全部。它只在真实的、用户用千奇百怪的说法提问、知识库里还有大量近似内容互相干扰的环境里才显形。那时候它会用最伤体验的方式给你结账:做不好,你的问答系统会把明明有答案的问题答成"我不知道",会被排在前面的无关段带着说错话,你越是调大 top-k 想补救,它越是又贵又乱;而做对了,你的检索会稳稳地把最该看的那几段,排在最前交出去:答案很少再漏网,喂给模型的每一段都真正相关,知识库里没有答案时它会老实说没有。所以别等"用户抱怨它答非所问"找上门,在你写下那行向量库 query 的时候就该想清楚:我拿到的不是"最相关的几段",而是"一批大概相关、顺序还很粗的候选"——这批候选,我精排了吗、融合了关键词检索吗、低分的挡掉了吗,这一道道工序,我是不是都替它走完了?这些问题有了答案,你交给大模型的,才不只是一堆"看着相似"的段落,而是一份真正经过精挑、撑得起高质量回答的检索结果。
—— 别看了 · 2026