← 返回博客列表

RAG 混合检索生产实战:BM25 + 向量 + Reranker + Query Rewrite 全链路

很多团队的 RAG 上线第一周表现惊艳,第二周就开始翻车:用户搜「Qwen3-32B INT4 显存占用」,向量检索返回一堆「大模型选型指南」;搜内部项目代号 agenzo-ledger,语义相近的「账本服务」文档排在真正文档后面。根因通常不是 Embedding 模型太差,而是只靠稠密向量一条路——它擅长语义泛化,却在精确匹配、稀有实体、数字版本号上系统性吃亏。

2026 年的生产 RAG 默认答案已经变成 Hybrid Retrieval:BM25 抓关键词,向量抓语义,Reranker 做精排,Query Rewrite 把用户口语翻译成检索友好查询。这篇把四层串成一条可落地的工程链路。

四层架构:各自解决什么问题

用户 Query


┌─────────────┐
│ Query Rewrite│  ← 口语 → 检索友好(可选,复杂场景必开)
└──────┬──────┘

   ┌───┴───┐
   ▼       ▼
┌──────┐ ┌──────────┐
│ BM25 │ │  Vector  │   ← 双路召回(并行)
│稀疏  │ │  稠密    │
└──┬───┘ └────┬─────┘
   │          │
   └───┬──────┘

┌─────────────┐
│  RRF 融合   │  ← Reciprocal Rank Fusion,无需调权重
└──────┬──────┘

┌─────────────┐
│ Cross-Encoder│  ← 精排 Top-K(计算贵,只跑 20-50 条)
│   Reranker   │
└──────┬──────┘

    Top-N → LLM 生成
层级擅长翻车场景典型延迟
BM25专有名词、版本号、代码符号同义词、口语化表达< 10ms
向量语义相似、跨语言稀有实体、精确匹配20-80ms
RRF 融合互补两路弱点两路都错时救不了< 1ms
Reranker精细相关性判断候选集太大时太慢50-200ms
Query Rewrite消歧、补全上下文多一次 LLM 调用200-800ms

Step 1:双路召回实现

下面用 Python 演示最小可用的混合召回,依赖 rank_bm25 + chromadb + sentence-transformers

from rank_bm25 import BM25Okapi
import chromadb
from sentence_transformers import SentenceTransformer
import jieba

# 假设 chunks 已分好
chunks = [
    {"id": "c1", "text": "Qwen3-32B 在 INT4 量化后约需 18GB 显存"},
    {"id": "c2", "text": "大模型选型需综合考虑任务类型和 GPU 预算"},
    {"id": "c3", "text": "agenzo-ledger 是 Agenzo 项目的账本微服务"},
]

# --- BM25 索引 ---
tokenized = [list(jieba.cut(c["text"])) for c in chunks]
bm25 = BM25Okapi(tokenized)

def bm25_search(query: str, top_k: int = 10) -> list[tuple[str, float]]:
    tokens = list(jieba.cut(query))
    scores = bm25.get_scores(tokens)
    ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)
    return [(chunks[i]["id"], s) for i, s in ranked[:top_k] if s > 0]

# --- 向量索引 ---
encoder = SentenceTransformer("BAAI/bge-m3")
client = chromadb.EphemeralClient()
col = client.create_collection("docs")
col.add(
    ids=[c["id"] for c in chunks],
    documents=[c["text"] for c in chunks],
    embeddings=encoder.encode([c["text"] for c in chunks]).tolist(),
)

def vector_search(query: str, top_k: int = 10) -> list[tuple[str, float]]:
    emb = encoder.encode([query]).tolist()
    res = col.query(query_embeddings=emb, n_results=top_k)
    return list(zip(res["ids"][0], res["distances"][0]))

中文场景记得分词:BM25 对中文不用 jieba 基本废掉。英文文档可以直接按空格切。

Step 2:RRF 融合——比调 alpha 权重更稳

早期混合检索常用 score = alpha * dense + (1-alpha) * sparse,alpha 要按数据集调参,换个领域就漂。RRF(Reciprocal Rank Fusion)只看排名不看绝对分数,工程上更省心:

def reciprocal_rank_fusion(
    result_lists: list[list[tuple[str, float]]],
    k: int = 60,
) -> list[tuple[str, float]]:
    """多路召回结果按 RRF 融合。k 通常取 60,无需调参。"""
    scores: dict[str, float] = {}
    for results in result_lists:
        for rank, (doc_id, _) in enumerate(results):
            scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
    return sorted(scores.items(), key=lambda x: x[1], reverse=True)


query = "agenzo-ledger 是什么"
bm25_hits = bm25_search(query)
vec_hits = vector_search(query)
fused = reciprocal_rank_fusion([bm25_hits, vec_hits])
# fused[0] 大概率是 c3 — BM25 精确命中 + 向量语义补充

经验值:双路各召回 Top-20,RRF 融合后取 Top-30 送给 Reranker。

Step 3:Cross-Encoder Reranker

Bi-Encoder(向量检索)把 query 和 document 分别编码再算相似度,快但精度有限。Cross-Encoder 把 (query, document) 拼在一起过 Transformer,精度高但只能跑小候选集。

from sentence_transformers import CrossEncoder

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

def rerank(query: str, candidates: list[dict], top_n: int = 5) -> list[dict]:
    pairs = [(query, c["text"]) for c in candidates]
    scores = reranker.predict(pairs)
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [c for c, _ in ranked[:top_n]]

# 把 fused 的 doc_id 还原成 chunk 对象后调用
id_to_chunk = {c["id"]: c for c in chunks}
candidate_chunks = [id_to_chunk[did] for did, _ in fused[:30]]
final = rerank(query, candidate_chunks, top_n=3)

Reranker 选型参考:

模型语言延迟 (pair)适用
bge-reranker-v2-m3中英~15ms通用首选
jina-reranker-v2多语言~12ms跨语言场景
Cohere rerank-v3API网络延迟不想自托管
ms-marco-MiniLM英文~5ms纯英文、低延迟

Step 4:Query Rewrite——什么时候值得开

Query Rewrite 用 LLM 把用户口语改写成检索友好查询,典型场景:

  • 多轮对话:用户说「那个量化方案呢?」→ 改写为「Qwen3-32B INT4 量化显存占用」
  • 消歧:「ledger 服务」→ 改写为「agenzo-ledger 微服务架构」
  • HyDE(Hypothetical Document Embedding):让 LLM 先生成一段「理想答案」,用这段假想文档去向量检索
from openai import OpenAI

client = OpenAI()

REWRITE_PROMPT = """你是检索查询优化器。根据对话历史和用户最新问题,
输出一个独立的、适合搜索引擎的查询(一行,不要解释)。

对话历史:
{history}

用户问题:{query}

检索查询:"""

def rewrite_query(history: str, query: str) -> str:
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": REWRITE_PROMPT.format(history=history, query=query),
        }],
        max_tokens=64,
        temperature=0,
    )
    return resp.choices[0].message.content.strip()

什么时候不开 Rewrite:单轮问答、查询本身已经很具体、延迟预算 < 500ms。Rewrite 多一次 LLM 调用,简单场景 ROI 不高。

生产环境的三个常见坑

1. 分块策略比检索算法影响更大

表格、代码块、列表被 RecursiveCharacterTextSplitter 从中间切断后,BM25 和向量都检索不到完整信息。生产建议:

  • Markdown 按标题层级切(## 为边界)
  • 代码块保持完整,不跨 chunk
  • 每个 chunk 头部注入面包屑:[文档名 > 章节名]

2. Reranker 候选集不是越大越好

超过 50 条候选,Reranker 延迟线性增长但精度提升边际递减。双路各 Top-20 → RRF Top-30 → Rerank Top-5 是多数场景的最优平衡点。

3. 评测要分「召回」和「端到端」

评测层级指标工具
召回Recall@K、MRR标注 query-doc 对,跑检索不看生成
RerankNDCG@5Rerank 前后对比
端到端答案正确率、幻觉率RAGAS / 自建 LLM-as-Judge

只测端到端会把问题甩给 LLM——召回没召到,生成再好也没用。

选型建议

你的场景推荐配置
< 1 万文档、延迟敏感BM25 + 向量 + RRF,跳过 Reranker
中英文混合、专有名词多全四层 + jieba 分词 + bge-m3
多轮对话 Agent加 Query Rewrite + 对话历史窗口
纯英文技术文档BM25Okapi + e5-large + ms-marco reranker
不想自托管Cohere embed + Cohere rerank API

落地 Checklist

  • 文档分块按 Markdown 结构切,chunk 带面包屑前缀
  • BM25 中文分词(jieba / pkuseg)已接入
  • 向量模型与 Reranker 语言域匹配(不要英文 embed + 中文 query)
  • 双路召回 + RRF 融合,召回阶段有 Recall@10 基线
  • Reranker 候选集 ≤ 50,输出 Top-5 给 LLM
  • Query Rewrite 仅在多轮/消歧场景开启,有 fallback 直通
  • 检索链路各阶段延迟可观测(BM25 / vector / rerank 分段打点)
  • 每周抽检 20 条 query,对比「仅向量」vs「混合」的答案质量

纯向量 RAG 是 Demo 阶段的捷径,混合检索才是生产阶段的默认配置。四层架构看起来复杂,但每一层解决的是不同维度的失败模式——BM25 保底精确匹配,向量兜底语义泛化,Reranker 做最后 10% 的精度提升,Rewrite 处理多轮上下文。先把双路召回 + RRF 跑通,再按需加 Reranker 和 Rewrite,是最稳的演进路径。