大模型 RAG 进阶:混合检索与重排序优化实战

大模型 RAG(检索增强生成) 已成为企业落地大模型的标配,但很多团队仍停留在「文本切块 + 向量检索」的朴素方案,面对专有名词、代码、长尾问题时召回准确率上不去,最终答案答非所问。本文讲透 混合检索(Hybrid Search)重排序(Rerank) 两大进阶手段,并给出可直接抄的代码与评估方法,帮你把 RAG 的命中率从「能用」拉到「好用」。

一、为什么朴素向量检索不够用

纯向量检索把 query 和文档都映射成语义向量,靠余弦相似度召回。它的短板非常具体:

  • 专有名词与 ID 不敏感:模型容易把「订单号 80231」「error_code=5099」这类强关键字语义化掉,导致完全匹配却排不到前面。
  • 长尾 query 噪声大:低频但精确的问题,向量空间里可能离一堆语义相近但事实不符的文档更近。
  • 可解释性差:召回结果没法用关键词命中来解释,排查 badcase 时两眼一抹黑。

解决思路不是抛弃向量,而是「向量负责语义泛化,关键词负责精确命中」,两者融合,再用重排序做最后一道精排。

二、混合检索:BM25 + 向量的双路召回

1. BM25 关键词召回

BM25 是基于词频与逆文档频率的经典算法,对精确关键词命中极其敏感。用 rank_bm25 几行就能跑起来:

from rank_bm25 import BM25Okapi

docs = ["如何在 Nginx 配置反向代理", "PostgreSQL 慢查询优化方法", ...]
tokenized = [d.split() for d in docs]
bm25 = BM25Okapi(tokenized)

query = "Nginx 反向代理 配置".split()
scores = bm25.get_scores(query)
top_bm25 = [docs[i] for i in sorted(range(len(scores)), key=lambda x: -scores[x])[:10]]

2. 向量语义召回

向量路负责「换个说法也能找到」的泛化能力,常用 BGE、bge-m3 等中文 embedding 模型,配合 PG 向量字段或 Chroma 做 ANN 检索:

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-base-zh")
q_emb = model.encode("Nginx 怎么把请求转发到后端服务")

# 在向量库里做最近邻检索(PG /v2 或 Chroma 的 query API)
# 返回 top_k 语义相似文档

3. 分数融合:RRF 与加权归一

两路分数量纲不同,不能直接相加。工业界最稳的是 RRF(Reciprocal Rank Fusion):只看排名不看原始分数,避免量纲冲突。

def rrf(rank_lists, k=60):
    score = {}
    for ranks in rank_lists:          # 每个元素是某路召回的 doc 排名列表
        for rank, doc_id in enumerate(ranks):
            score[doc_id] = score.get(doc_id, 0) + 1.0 / (k + rank + 1)
    return sorted(score.items(), key=lambda x: -x[1])

# 两路召回的 top 列表融合
fused = rrf([bm25_top_ids, vector_top_ids])

三种策略的取舍一目了然:

召回策略精确关键词语义泛化长尾稳健性
纯向量检索
纯 BM25弱(同义词失效)
混合检索 + RRF

三、重排序 Rerank:让 Top-K 真正相关

Cross-Encoder 原理

召回阶段为了快,用的是 Bi-Encoder(query 和 doc 各自编码)。而 Cross-Encoder 会把 query 和 doc 拼在一起过一次模型,能捕捉细粒度交互,排序质量远高于向量余弦,但慢,所以只放在召回之后对 Top-50/100 做精排。

代码实战:BGE Reranker

from FlagEmbedding import FlagReranker

reranker = FlagReranker("BAAI/bge-reranker-base", use_fp16=True)

# candidate_docs 是混合检索融合后的候选
pairs = [[query, doc] for doc in candidate_docs]
scores = reranker.compute_score(pairs)

ranked = [d for _, d in sorted(zip(scores, candidate_docs), reverse=True)]
final_top3 = ranked[:3]   # 送入大模型上下文

经验值:召回 100 条、Rerank 取前 5~8 条喂给 LLM,能在「上下文长度」与「相关度」之间取得最佳平衡。算力紧张时可只对前 30 条重排。

四、工程落地:在 Dify 中接入混合检索

如果你用 Dify + Ollama 自建本地知识库,可以在知识库的召回设置里开启「混合检索」与「重排序」开关,无需自己写融合代码。底层正是 BM25 与向量双路 + Rerank 的组合,调参重点是:向量检索 Top-K、关键词权重、Rerank 模型选型。自建场景下建议优先用本地 reranker,避免文档出公网。

五、效果评估:Recall@K 与 MRR

优化不能靠「感觉」,要量化。准备一批带标准答案的 query,统计:

  • Recall@K:正确答案是否出现在前 K 条召回里,衡量召回覆盖。
  • MRR(平均倒数排名):正确答案排第一的位置的倒数,衡量精排质量。
def recall_at_k(relevant, retrieved, k):
    return 1.0 if relevant in retrieved[:k] else 0.0

def mrr(retrieved_list, relevant_ids):
    for i, doc in enumerate(retrieved_list):
        if doc.id in relevant_ids:
            return 1.0 / (i + 1)
    return 0.0

混合检索 + Rerank 上线前后,对比这组指标通常能把 Recall@10 提升 15%~30%。

六、生产实践 Checklist

  • 切块策略按语义边界切,而非固定字数,保留标题上下文。
  • 关键词路用分词后的 BM25,中文务必先切词。
  • 融合优先 RRF,再考虑加权;加权需要在线标定。
  • Rerank 只对召回 Top-N 跑,控制时延与成本。
  • 每个版本变更都要跑 Recall@K / MRR 回归,防止「越改越差」。

混合检索与重排序是 RAG 从 demo 走向生产的分水岭。先把双路召回搭起来,再用 Rerank 收口,最后用指标驱动迭代,你的知识库问答准确率会肉眼可见地往上走。

上一篇 Docker 网络模式详解:bridge/host 生产选型
下一篇 MySQL 索引底层原理:B+树、覆盖索引与最左前缀