在上一篇文章中,我们从宏观架构层面探讨了生产级 RAG 的各个组件。今天,我们将潜入深水区,聚焦 RAG 系统中最核心、也最凶险的部分——检索 (Retrieval)。
许多开发者在实际落地 RAG 时会遇到这种困惑: “为什么我文档切分得很完美,存进了 Chromadb,但用户问‘X-200 产品型号’时,通过 Embedding 找出来的却是一段不相干的‘已通过 ISO9001 认证’的废话?”
其背后的根本原因是:Embedding 模型(双塔/Bi-Encoder 模型)擅长捕捉“语义相似性”,但在面对“精确匹配”(如型号、人名、代码 ID)时,往往表现拉胯。
要解决这个问题,我们需要引入两把重型武器:混合检索 (Hybrid Search) 和 重排序 (Re-ranking)。本文不讲虚的,直接上代码和算法原理,教你手搓一个高精度检索管道。
1. 混合检索 (Hybrid Search):组合的力量
混合检索的核心思想是“不把鸡蛋放在同一个篮子里”。它结合了两种截然不同的检索技术:
- 稠密检索 (Dense Retrieval):计算 Embedding 向量的余弦相似度。擅长理解“意图”。
- 稀疏检索 (Sparse Retrieval):基于 BM25 算法的倒排索引。擅长“精确匹配”。

为什么要结合?
试想用户 Query: “Error 502 Bad Gateway 怎么修?”
向量检索视角: 它看到了 “fix”, “error”, “gateway”。它可能会给你返回一篇关于“服务器通用维护指南”的文章,语义确实相关,但可能压根没提 502 代码。这就是“相关但无用”。
BM25 视角: 它是一个死板的匹配机器。它会死死盯着 “502” 这个 Token。它极大概率能直接定位到包含 “502” 字符串的故障手册。
混合检索视角: 既理解你想修故障(向量功劳),又锁定了 502 这个关键特征(BM25 功劳)。只有双剑合璧,才能保证 Recall(召回率)。
代码实战:使用 LangChain 实现 RRF 融合
如何把两个不同量纲的检索结果(向量通常是 0.7-0.9 的余弦值,BM25 可能是 10-20 的绝对分)合并? 最稳健的算法是 RRF (Reciprocal Rank Fusion)。它完全忽略分数,只看排名。
公式: $$ RRFscore(d) = \sum_{r \in R} \frac{1}{k + r(d)} $$ 其中 $k$ 是常数(通常取 60),$r(d)$ 是文档在该路检索中的排名。
这里是 Python 伪代码实现逻辑:
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
# 1. 初始化两个 Retriever
# 假设 docs 是已经切分好的 Document 列表
bm25_retriever = BM25Retriever.from_documents(docs)
bm25_retriever.k = 50 # 稀疏检索召回前 50
embedding = OpenAIEmbeddings()
vectorstore = FAISS.from_documents(docs, embedding)
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 50}) # 向量检索召回前 50
# 2. 初始化混合 Retriever (EnsembleRetriever 默认使用 RRF 算法)
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.5, 0.5] # 权重可根据业务调整,通常 50/50
)
# 3. 执行检索
docs = ensemble_retriever.invoke("Error 502 怎么修?")
# 此时返回的是去重并重新排序后的结果列表
实际生产中,如果你使用 ElasticSearch 或 Milvus,它们内部已经内置了 Hybrid Search 能力,原理大同小异。
2. 重排序 (Re-ranking):精确打击
混合检索之后,我们通常会得到 Top 50 甚至 Top 100 的候选文档。 注意:千万别把这 50 个文档直接丢给 LLM!
原因有三:
- 噪声干扰:这 50 个里可能只有 3 个是真的对的,其他 47 个是干扰项。过多的信息会显著降低 LLM 的推理能力 (Distraction Issue)。
- 成本高昂:Token 就是钱。每次多喂 10k token,你的 API 账单会炸。
- 延迟增加:Context 越长,首字生成越慢。
这时候我们需要引入 Re-ranker (重排序模型)。

Bi-Encoder vs Cross-Encoder
-
前面的向量检索使用的是 Bi-Encoder (双塔模型): Query 和 Document 分别独立编码成向量。计算速度极快(毫秒级),适合在海量数据中进行海选(粗排)。
-
重排序使用的是 Cross-Encoder (交互模型): 它将 Query 和 Document 拼接在一起(例如
[CLS] Query [SEP] Document [SEP]),送入 BERT 模型进行深度的 Full-Attention 交互。它能精准地判断“这句话到底有没有回答这个问题”。 计算速度慢(几百毫秒),但在精度上极高,适合对粗排结果进行优选(精排)。
实战选型与代码
目前业界表现最好的 Re-ranker 模型包括:
- BGE-Reranker-v2-m3 (智源):开源界的扛把子,多语言支持极好。
- Cohere Rerank API:商业闭源方案,效果 SOTA,且不用自己维护 GPU 资源,非常适合初创公司。
- Jina Reranker:也是非常强的选手,支持超长 Context。
以 HuggingFace + BGE-Reranker 为例:
from sentence_transformers import CrossEncoder
# 加载模型 (建议部署在 GPU 上)
model = CrossEncoder('BAAI/bge-reranker-v2-m3', max_length=512)
def rerank_documents(query, retrieved_docs, top_k=5):
# 构造模型输入对: [[query, doc1], [query, doc2], ...]
pairs = [[query, doc.page_content] for doc in retrieved_docs]
# 预测分数
scores = model.predict(pairs)
# 打包文档和分数
doc_score_pairs = list(zip(retrieved_docs, scores))
# 按分数降序排列
doc_score_pairs.sort(key=lambda x: x[1], reverse=True)
# 过滤低分文档 (可选,设定阈值)
filtered_results = [
doc for doc, score in doc_score_pairs
if score > 0.5 # 这是一个经验阈值,通常负分或极低分意味着不相关甚至矛盾
]
return filtered_results[:top_k]
# 接在刚才的 ensemble_retriever 后面
final_docs = rerank_documents("Error 502 怎么修?", docs)
3. 完整 Pipeline 架构
一套成熟的、能应对复杂业务场景的 RAG 检索管道应该是这样的:
- Query Rewrite(查询改写):如果用户问“它多少钱?”,先用历史对话把 Query 改写为“iPhone 15 Pro Max 多少钱?”。
- Hybrid Retrieval(混合检索/粗排):
- 并发执行 Vector Search (Top 50)。
- 并发执行 BM25 Search (Top 50)。
- RRF 融合得到 Unique Top 60。
- Re-ranking(重排序/精排):
- 使用 Cross-Encoder 对 Top 60 打分。
- 如果最高分都很低(比如 < -5),触发“拒答”机制,告诉用户“知识库里没找到相关信息”,而不是强行回答。
- Context Construction(上下文构建):截取 Top 5,按顺序拼接进 Prompt。
4. 常见避坑与优化 Tips
-
重排序太慢怎么办? Cross-Encoder 确实重。如果你对延迟敏感,可以考虑使用 ColBERT 架构(如 Jina-ColBERT),它是一种 Late Interaction 架构,保留了 Cross-Encoder 的精度,速度却接近 Bi-Encoder。
-
多语言问题? 如果你的文档是英文,用户问中文,BM25 会完全失效。需要在检索前加一步“Query Translation”,把中文问题翻译成英文再搜。
-
阈值怎么定? 不要 Hardcode。Re-ranker 的分数(logits)分布不一定是归一化的。建议上线前跑一批测试数据,观察正样本和负样本的分数分布区间,定一个有统计学依据的阈值。
总结
如果你的 RAG 系统回答经常牛头不对马嘴,千万别急着换更大的 LLM(比如从 GPT-3.5 换到 GPT-4)。根据边际效用递减法则,你应该先检查你的检索链路。
检索是给 LLM 厨师备菜的。如果你能通过混合检索和重排序,把“烂叶子”(不相关文档)挑出去,只留“顶级和牛”(精准文档),那么哪怕是个普通的厨师(小参数 LLM),也能做出一桌大餐。