跳转至

RAG 混合检索与重排序实战:BM25+向量+RRF+cross-encoder

适用场景:企业知识库、运维手册、法务文档、工单库的精准问答。当纯向量检索开始"翻车"——查不到序列号、报错码、条款编号——就需要混合检索。

一句话结论:BM25 救召回,RRF 融合排名,cross-encoder 提精度。三段式把 recall@5 从 0.61 拉到 0.89,代价是 p95 延迟从 40ms 涨到 310ms。值不值,看你查的是什么。


我是公司内部的运维工程师,负责企业 RAG 系统。这套系统跑了一年,服务的是技术支持、法务和 SRE 三个团队。今天这篇不是厂商文档摘抄,是这一年踩过的坑和填平的坑。我把它拆成三段式讲清楚:候选生成、融合、重排序。

先说结论性的架构图,后面每一段展开。

用户查询
   │
   ▼
┌────────────────────┐
│ 候选生成            │
│  BM25 topN=50      │◀── 稀疏检索(谁的字面匹配)
│  向量 topN=50      │◀── 稠密检索(谁的语义相近)
└────────┬───────────┘
         │ 两路候选合并去重(≤100 条)
         ▼
┌────────────────────┐
│ 融合(RRF)         │◀── 只按排名融合,不看分数
│  score = Σ 1/(k+rank) │   k=60
└────────┬───────────┘
         │ RRF topN=50
         ▼
┌────────────────────┐
│ 重排序(cross-encoder)│◀── 逐对精算相关度
│  候选 50 条 → topK=5 │   延迟大头在这
└────────┬───────────┘
         │
         ▼
  最终 topK 送入 LLM

这个流程跑一年了。下面从"为什么必须做"讲起。


1. 纯向量检索为什么在生产翻车

1.1 我的第一记耳光

项目上线第一个月,技术支持团队就报了一个 bug:查不到轴承型号 SKF-6204-2RSH。

我当时用的是纯向量检索——Milvus + bge-m3,相似度阈值 0.5。我心想这检索质量多好,语义相近肯定能召回。结果工单里客户发来:

"帮我确认 SKF-6204-2RSH 的库存和替代型号"

检索出来的 top5 是:"深沟球轴承选型指南""6204 系列载荷计算""轴承游隙对照表"……没有一个包含 SKF-6204-2RSH 这个字符串。答案自然是错的。

1.2 根因:向量不懂"精确字符串"

Embedding 模型把整段文本压成一个 1024 维向量,天然丢掉了词级别的精确信息。它学的是语义分布,不是字符序列。SKF-6204-2RSH 这种带连字符、带数字的型号,在向量空间里和"轴承""选型"这些泛化词混在一起,余弦相似度被语义相近的泛化 chunk 稀释了。

这不是模型烂,是语义检索的原理局限。向量擅长的是:

  • "怎么配 Nginx 反代" → "Nginx 反向代理配置教程"(换词不换义)
  • "服务器频繁重启" → "主机异常重启排查"(同义改写)

它不擅长的是:

  • 精确 SKU / 序列号 / 料号(SKF-6204-2RSH)
  • 法条编号(《劳动合同法》第八十七条)
  • 报错码(K8s CrashLoopBackOff、Oracle ORA-01555)
  • 版本号、IP、MAC、设备名

这些的共性是:用户要的是字面命中,不是语义近似。你查 ORA-01555,语义上最接近的 chunk 可能是一个讲"回滚段"的通用段落,它压根没有 ORA-01555 这几个字符。向量模型无法理解"这个字符串必须逐字符出现"。

1.3 生产 RAG 的盲点统计

我把这一年技术支持、法务、SRE 三个团队的真实查询拉出来,手工标注了"是否需要精确关键词命中":

查询类型           占比    纯向量召回能否命中
──────────────────────────────────────────────
序列号/料号/型号    22%    ✗ 经常 miss
报错码/日志关键字   19%    ✗ 经常 miss
法条/条款编号       12%    ✗ 偶尔 miss
设备/主机名         9%     ✗ 偶尔 miss
自然语言问句       38%    ✓ 基本没问题

六成以上的查询都带有需要精确命中的 token。而纯向量检索只对那 38% 的自然语言问句稳定。这就是为什么生产 RAG 纯向量必翻车——它服务的用户,一半以上的问题是"给我找这个编号"。

1.4 症状自查清单

如果你们系统也有以下现象,基本可以判定是纯向量盲点:

  • 问"XX 报错码怎么解决",返回的是通用教程而非具体报错页面
  • 问某个型号库存,返回的是该品类选型文档
  • 明明文档里就有这个词,检索却搜不到
  • 把阈值从 0.5 调到 0.3 也救不回来(说明不是阈值问题,是根本没有命中那个 token)

我调阈值调到 0.2 试过,召回率没怎么动,反而召回了一堆垃圾。降阈值救不了字面缺失。真正需要的是另一条检索通路。

1.5 一个典型翻车现场,逐帧拆解

我拿技术支持团队一条真实工单拆给你看,这是纯向量检索翻车的最典型样本。查询:

"客户报 ORA-01555 快照过旧,怎么处理"

这条查询其实藏了两个需求:一个精确 token(ORA-01555),一个语义诉求("快照过旧怎么处理")。纯向量检索返回的 top5:

rank 1  [0.78] Oracle 回滚段管理与收缩指南
rank 2  [0.74] Undo 表空间常见故障排查
rank 3  [0.71] 数据库快照与一致性读原理
rank 4  [0.68] 大事务导致回滚段扩展的案例
rank 5  [0.66] AWR 报告解读入门

一眼看穿:没有一条包含 ORA-01555 这个字符串。前四条是"语义沾边"的通用 Oracle 文档,第五条甚至跑偏到 AWR 报告。为什么?因为 bge-m3 把 "ORA-01555 快照过旧" 编码成"数据库/回滚/快照/错误"这个语义簇,在 1024 维空间里,泛化的回滚段文档方向最接近。那个精确报错码,在向量空间里被压成微弱的特征,根本排不上号。

而实际上,库里有一篇《ORA-01555 错误处理手册》,里面明确写了这个报错码的成因(undo 保留期过短、查询跨 commit 等)。纯向量把它排到了第 23 名——召回了,但排序垫底,等于没召回。这正是 4.3 讲的"高维排序不稳定":top50 里相似度差距极小,真正的精确文档混在泛化文档中间。

这条查询的教训是双重的:

  1. 纯向量对精确 token 型查询,召回本身就不足(不是排序问题,是压根没把含 ORA-01555 的 chunk 拉进前排)。
  2. 即使碰巧召回,排序也不可信,top1 大概率是泛化垃圾。

所以混合检索不是"锦上添花",是"救生圈"。下面进入三段式架构。


2. 三段式架构:候选生成 → 融合 → 重排序

2.1 为什么要拆三段

我一开始图省事,想"BM25 和向量各拿 top 50,直接拼一起去重就完事"。结果发现两个问题:

  1. 分数不可比:BM25 打出来是 0-20 的浮点,余弦相似度是 -1 到 1。你没法说"BM25 得 8 分"和"余弦 0.72"谁更相关。
  2. 两路质量不均:向量那一路偶尔把精确 token 排到第 90 名(因为语义泛化),BM25 那一路把自然语言问句排得很差。简单拼接,谁都救不了谁。

拆成三段,每段只干一件事,问题就清晰了:

阶段 职责 输入 输出 代价
候选生成 广撒网,保证"该有的都有" 用户查询 两路各 topN(共 ≤100 条) 低,毫秒级
融合 RRF 只按排名合并,去重归一 两路候选 topN 融合列表 极低,<5ms
重排序 rerank 逐对精算,把对的顶上来 融合候选 topN 最终 topK 高,200-300ms

核心思想:前两段管"别漏",第三段管"别错"。召回是前提,精度是结果。你永远不可能用重排序去救一个根本没被召回的文档——rerank 只能排它已经看到的候选。

2.2 完整代码骨架

我用的检索层是一个 HybridRetriever,三段分别对应三个方法。先给整体骨架,后面每段拆开讲。

# retrieval/hybrid_retriever.py
import time
from dataclasses import dataclass

@dataclass
class Hit:
    doc_id: str
    text: str
    bm25_score: float = 0.0
    vector_score: float = 0.0
    rrf_score: float = 0.0
    rerank_score: float = 0.0


class HybridRetriever:
    def __init__(self, bm25_index, vector_store, reranker,
                 bm25_topn=50, vector_topn=50,
                 rrf_k=60, rerank_topn=50, top_k=5):
        self.bm25 = bm25_index
        self.vector = vector_store
        self.reranker = reranker
        self.bm25_topn = bm25_topn
        self.vector_topn = vector_topn
        self.rrf_k = rrf_k
        self.rerank_topn = rerank_topn
        self.top_k = top_k

    def retrieve(self, query: str) -> list[Hit]:
        t0 = time.perf_counter()
        # 1. 候选生成:两路并行
        bm25_hits = self._bm25_search(query)      # 稀疏路
        vec_hits = self._vector_search(query)     # 稠密路
        t1 = time.perf_counter()

        # 2. 融合:RRF 只依赖排名
        fused = self._rrf_fuse(bm25_hits, vec_hits)
        t2 = time.perf_counter()

        # 3. 重排序:cross-encoder 精排
        reranked = self._rerank(query, fused)
        t3 = time.perf_counter()

        # 延迟分解(生产里打到日志,便于定位瓶颈)
        self._log_latency(t1 - t0, t2 - t1, t3 - t2)
        return reranked[: self.top_k]

延迟分解日志非常有用,能直接看出是哪一段拖了后腿。我后面案例 4 就是靠这个定位的。

2.3 三段的时间占比

实测 1000 次查询的延迟分解(后面第 7 章给完整数据,这里给直觉):

候选生成(BM25+向量)    ≈ 35ms   ← ANN 检索是这的大头
RRF 融合                 ≈ 3ms    ← 可忽略
cross-encoder 重排序      ≈ 270ms  ← 占 85%+
─────────────────────────────────
总计                     ≈ 310ms

重排序是延迟的绝对大头。所以第 8 章讲本地部署时,我会重点说怎么把 rerank 压下来。候选生成和融合几乎不花钱,别在这省。

2.4 一个设计决策:两路要不要加权

有同事建议给两路加权重(比如 BM25 路 ×1.2),说"精确 token 更重要,让 BM25 权重高一点"。这个想法听起来对,但我不建议在 RRF 里做加权,原因有二:

  1. RRF 本身就是排名融合,权重应该加在"哪一路更可信"上,而这取决于查询类型。 精确 token 查询 BM25 更可信,开放问句向量更可信。用固定权重没法两头兼顾。
  2. 固定权重会引入新的调参维度。 你已经在调 chunk、topN、k、候选池、topK 了,再加权重,搜索空间爆炸,而且收益不明确。

我的做法是不加权重,靠查询分流(第 8.6 节):查询带精确 token 就侧重 BM25(通过调小 k),纯自然语言就侧重向量(调大 k)。这是把"加权"从静态参数变成动态策略,效果更好,还不增加维度。

设计原则:能用策略解决的问题,不要用参数。 参数越多越难调,策略是显式的、可解释的。


3. BM25 检索:为什么它救召回

3.1 一句话原理

BM25(Best Matching 25)是经典概率检索模型,本质是:文档里出现了查询词,就给分;出现得越多越给分;词越稀有,单次出现的权重越高。

它是"词袋"模型,不管语义,只看字面共现。这恰恰是向量缺的那一环。

score(D, Q) = Σ  IDF(qi) × [ f(qi,D) × (k1+1) ] / [ f(qi,D) + k1 × (1 - b + b × |D|/avgdl) ]

其中 f(qi,D) 是词 qi 在文档 D 中的词频,IDF(qi) 是逆文档频率(稀有词权重高),k1≈1.2 控制词频饱和,b≈0.75 控制文档长度归一化。

我不需要手写实现,用现成的。企业环境我推荐 rank_bm25 这个纯 Python 库,零依赖,好部署;或者 Elasticsearch / OpenSearch 自带的 BM25。

pip install rank_bm25

3.2 代码:把知识库灌进 BM25

BM25 需要先分词再建索引。中文分词我用 jieba,注意要保留数字、连字符、字母这些"精确 token"——不能分词把 SKF-6204-2RSH 拆碎了。所以建索引和查询要用同一套分词器。

# retrieval/bm25_index.py
import jieba
from rank_bm25 import BM25Okapi


def tokenize(text: str) -> list[str]:
    """统一分词:中文走 jieba,但保留连续字母数字/连字符为完整 token。"""
    tokens = jieba.lcut(text)
    # 关键:不要用正则把 SKF-6204-2RSH 这种拆开
    return tokens


class BM25Index:
    def __init__(self, docs: list[dict]):
        # docs: [{"doc_id": "chunk_123", "text": "..."}, ...]
        self.doc_ids = [d["doc_id"] for d in docs]
        self.corpus = [tokenize(d["text"]) for d in docs]
        self.bm25 = BM25Okapi(self.corpus)
        self.text_map = {d["doc_id"]: d["text"] for d in docs}

    def search(self, query: str, top_n: int = 50) -> list[tuple[str, float]]:
        scores = self.bm25.get_scores(tokenize(query))
        # 返回 (doc_id, score),按分数降序
        ranked = sorted(zip(self.doc_ids, scores),
                        key=lambda x: x[1], reverse=True)
        return [(doc_id, score) for doc_id, score in ranked if score > 0][:top_n]

3.3 为什么它能救召回

三个场景,BM25 是向量救不回来的:

① 精确 token(序列号、报错码)

SKF-6204-2RSH 在分词后是一个完整的 token(连字符不会在 jieba 里被拆,且即使拆了,数字+字母的 IDF 也极高)。含这个字符串的 chunk,词频命中直接高分。含 ORA-01555 的文档同理。向量做不到这种逐字符匹配。

② 稀有词

一个词在整个语料库里只出现 3 次,IDF 巨大。比如某个废弃接口名 legacy_pay_v1。向量可能因为语义把它泛化掉,BM25 会给它巨高权重。越冷门的词,BM25 越擅长。

③ 短语/精确搭配

"第十一条"这种,向量会当成"第""十一""条"的语义近似,可能召回一堆带"十一条款"但没带编号的。BM25 对"十一条"这个词条有精准的词频响应。

3.4 BM25 的局限,别神话它

BM25 也翻车,而且翻得挺明显:

  • 同义词全废:"怎么配反向代理" 和 "Nginx proxy_pass 配置" 之间没有字面共现,BM25 召回会很差。这正是向量的主场。
  • 中文分词误差放大:分词分错了,整路召回就崩。所以分词器必须和索引一致。
  • 长短文档偏置:长文档词频高天然占便宜,靠 b 参数归一化,但仍是粗略估计。

所以 BM25 和向量是互补的,不是替代。这就是混合检索的根基:一路抓字面,一路抓语义。

3.5 一个必须注意的分词细节

BM25 的质量完全押在分词上。我用 jieba 时踩过一个特别隐蔽的坑:jieba 会把 SKF-6204-2RSH 切成 SKF、6204、2RSH 或者更碎,导致"精确 token"被拆成几个泛化词,BM25 的词频响应就废了——因为你查询和文档都被拆成一样的碎 token,IDF 的优势没了。

解决办法是保留连续字母数字串作为整体。查询和文档用同一个 tokenizer,否则建索引和查询的 token 空间对不上,BM25 直接失效。

import re

# 保留连续的 字母/数字/连字符 为整体 token,其余按 jieba 切
_TOKEN_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9\-]{2,}")

def robust_tokenize(text: str) -> list[str]:
    """先抓精确 token,剩下的交 jieba。"""
    tokens = _TOKEN_PATTERN.findall(text)
    remainder = _TOKEN_PATTERN.sub(" ", text)
    tokens += jieba.lcut(remainder)
    return [t for t in tokens if t.strip()]

这一改,序列号/报错码类查询的 BM25 召回立刻稳了。分词是 BM25 的地基,地基歪了楼必塌。

3.6 BM25 的两个超参(k1、b)

rank_bm25 的 BM25Okapi 默认 k1=1.5, b=0.75。这两个参数不是摆设:

  • k1:词频饱和系数。越大,文档内重复词越多分数越高。对"一个词重复很多次"的噪声文档,调小 k1 抑制重复项。
  • b:文档长度归一化。越大,长文档惩罚越重。知识库 chunk 长度比较均匀(512 字符),b 影响不大,保持 0.75。
from rank_bm25 import BM25Okapi

class BM25Index:
    def __init__(self, docs, k1=1.2, b=0.75):
        # 自定义 k1/b,默认 1.5/0.75
        self.bm25 = BM25Okapi(self.corpus, k1=k1, b=b)

我实测把 k1 从 1.5 降到 1.2,对含重复型号的物料文档召回略有提升(抑制了重复词主导)。但影响远小于 chunk_size,别在这纠结太久,k1=1.2、b=0.75 是个好起点。

3.7 BM25 的索引更新

知识库是动态的——每周都有新工单、新文档入库。BM25 索引必须跟着更新,否则新文档查不到。两个选择:

# 简单方案:全量重建(适合 <10 万 chunk)
def rebuild(self, docs):
    self.__init__(docs)

# 增量:rank_bm25 没有原生增量,需重新构造
# 建议:低频全量(每天夜里),高频用向量路兜底

我的做法:每天夜里全量重建 BM25 索引(10 万 chunk 重建 <2 分钟),白天增量文档靠向量路兜住。因为混合检索里 BM25 主要服务精确 token,而精确 token 的文档通常不会那么快变化。这个取舍在"知识库更新频率高"的场景要重新评估。


4. 向量检索:嵌入模型与 ANN

4.1 向量这一路在混合里扮演的角色

混合检索里,向量负责语义泛化召回:用户说不准词,但意思对得上。它是"容错"担当。

我用的是 bge-m3,1024 维,中英日韩多语言,支持最长 8192 token。在中文企业知识库上,它的语义召回比通用英文模型好一截。选型细节我在《Embedding 模型选型与微调》里讲过,这里只说混合检索里怎么用。

4.2 嵌入与 ANN

向量化之后,全库比对是 O(N) 的,几百万 chunk 就卡了。所以要用 ANN(近似最近邻)索引。我用 Milvus 的 HNSW:

# retrieval/vector_store.py
from pymilvus import MilvusClient


class VectorStore:
    def __init__(self, uri="http://localhost:19530", collection="kb_chunks"):
        self.client = MilvusClient(uri=uri)
        self.collection = collection

    def search(self, query_vector: list[float], top_n: int = 50,
               metric="COSINE") -> list[tuple[str, float]]:
        res = self.client.search(
            collection_name=self.collection,
            data=[query_vector],
            limit=top_n,
            output_fields=["doc_id", "text"],
            search_params={"metric_type": metric},
        )
        hits = []
        for r in res[0]:
            hits.append((r["entity"]["doc_id"], r["distance"]))
        return hits

4.3 余弦相似度的局限,你必须知道

余弦相似度把两个向量当方向比较,忽略模长。它有两个生产坑:

① 无法处理"应该排除"的语义

余弦只看方向,不看"这个 chunk 到底有没有那个意思的绝对强度"。两个方向接近的向量,哪怕一个内容很空洞,相似度也高。所以纯向量检索常召回"泛泛而谈"的垃圾。

② 高维空间的"维数灾难"

1024 维空间里,随机向量的余弦距离也会集中在某个窄带里。这意味着 top 50 和 top 200 的相似度差距可能极小,排序不稳定。这也是为什么纯向量 top1 经常选错——不是没召回,是排序不稳。这正好交给第三段 cross-encoder 去修。

4.4 混合里向量路怎么调

# 混合检索里向量路的配置
VECTOR_TOPN = 50      # 向量路候选数
VECTOR_THRESHOLD = 0.35  # 低于这个相似度直接丢弃,别让垃圾进融合

阈值别太高。我一开始设 0.5,向量路在精确 token 场景几乎空手而归。降到 0.35 后,向量路能多兜住一些语义相近但没精确命中的情况。但别降到 0.2 以下,否则垃圾全进来,rerank 也洗不干净。

4.5 嵌入要批处理,不然吞吐上不去

一个常被忽略的吞吐杀手:嵌入是逐条调用的。RAG 问答本身 query 只有一条,问题不大;但离线建索引时,几万条文档逐条请求嵌入接口,慢到没法看。必须批处理。

def embed_batch(texts: list[str], batch_size: int = 64) -> list[list[float]]:
    """批量嵌入,Milvus 建索引时用。"""
    vectors = []
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        r = requests.post("http://localhost:11434/api/embed",
                          json={"model": "bge-m3", "input": batch})
        vectors.extend(r.json()["embeddings"])
    return vectors

batch_size=64 是我测出来吞吐和内存的平衡点。batch 太小(8),吞吐上不去;太大(256),单次响应内存飙升。64 是个稳的默认值。

4.6 向量路的查询也要"清洗"

向量检索前,query 最好做轻量归一化:去空白、统一大小写、去多余标点。因为嵌入模型对"SKF-6204-2RSH"和"skf-6204-2rsh"的编码不完全一样,大小写不统一会拉低相似度。

def normalize_query(q: str) -> str:
    q = q.strip().lower()
    # 全角转半角,统一标点
    return q.translate(str.maketrans(",。!?()", ",.!?()"))

这条在混合检索里尤其重要,因为 BM25 路对大小写敏感,向量路也敏感。统一归一化能让两路都稳定。

4.7 向量路和 BM25 路要共用同一套 chunk

这是混合检索最容易犯的错:向量库和 BM25 索引分别从不同的切分结果建。结果向量库有 chunk_A,BM25 索引没有 chunk_A,两路候选的 doc_id 对不上,RRF 融合时"同一篇文档"被当成两个,去重失效。

必须保证:一个 doc_id 在向量库和 BM25 索引里指向同一个 chunk。 建索引时,先切分一次得到 chunk 列表,再分别喂给向量库和 BM25,共用 doc_id。

def build_both(chunks: list[dict]):
    # chunks 是同一份切分结果
    vector_store.insert(chunks)          # 向量库用同一 doc_id
    bm25_index = BM25Index(chunks)       # BM25 用同一 doc_id

这个看似废话,但很多团队是两条独立的 ingestion pipeline,各切各的,最后融合时才发现 doc_id 对不上。先统一 ingestion,再谈混合。


5. RRF 融合:为什么不能直接相加分数

5.1 为什么直接相加是错的

这是我最容易被问到的问题:"BM25 给 8.3 分,余弦给 0.72,加起来不就行了?"

不行。三个硬伤:

① 量纲不同。BM25 分数理论上无上界,文档越长、词频越高,能到 20 甚至更高;余弦相似度被限制在 [-1, 1]。把 8.3 和 0.72 直接相加,8.3 那一路几乎完全主导结果,向量路贡献被淹没。你的"混合"退化成"纯 BM25"。

② 分布不同。BM25 分数分布高度右偏(少数文档巨高分),余弦相似度集中在 0.6-0.9 窄带。两个分布不是一个量级,无法公平比较。

③ 不可解释。即使量纲统一,8.3 分和 0.72 分谁"更相关",也没有任何理论依据。直接相加是拿一个没有意义的数字去排序。

5.2 RRF 的洞察:只用排名,扔掉分数

RRF(Reciprocal Rank Fusion,倒数排名融合)的核心洞察特别朴素:分数不可信,但排名相对可信。BM25 排第 3 的,大概率比排第 47 的相关;余弦排第 2 的,大概率比排第 30 的相关。两路各自的内部排序有信息量,跨路的分数比较没有。

所以 RRF 把每个文档的分数折算成"排名倒数",再求和:

RRF_score(d) = Σ_{i ∈ 检索路}  1 / (k + rank_i(d))

其中 rank_i(d) 是文档 d 在第 i 路检索中的排名(从 1 开始),k 是平滑常数。

  • 只依赖排名,不依赖分数 → 天然解决了量纲和分布问题
  • 两路对称,谁都不被谁的绝对值压死
  • 一个文档如果两路都排得靠前,RRF 分数就高;只被一路捞到,也能进榜

5.3 k=60 是怎么来的,怎么调

k 控制排名权重衰减速度:

  • k 小(如 10):排名靠前的文档权重极大,第 1 名得 1/11≈0.09,第 50 名得 1/60≈0.017。极度偏袒头部。
  • k 大(如 100):头部和尾部权重差距缩小,更"平均主义",尾部文档还有机会进榜。

RRF 论文(Cormack et al., 2009)给出的经验默认值就是 k=60。我实测也验证了:对大多数查询,k 在 40-100 之间召回差别不大,60 是安全中间值。

但 k 不是死的。两种场景我会动:

def rrf_fuse(bm25_hits, vec_hits, k=60):
    """把两路 (doc_id, score) 列表融合为 RRF 分数排序。"""
    fused = {}  # doc_id -> 累计 RRF 分数
    for rank, (doc_id, _) in enumerate(bm25_hits, start=1):
        fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank)
    for rank, (doc_id, _) in enumerate(vec_hits, start=1):
        fused[doc_id] = fused.get(doc_id, 0.0) + 1.0 / (k + rank)
    # 按 RRF 分数降序
    ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True)
    return ranked

调 k 的场景:

  • 查询很"精确"(报错码、型号):我会把 k 调小到 40,让 BM25 路靠前的精确命中权重更高,压过向量路的语义泛化。
  • 查询很"开放"(自然语言问句):k 调大到 80,给向量路更均衡的权重,别让偶发的字面重合抢走语义主位。

5.4 我踩过的 k 值坑(提前预告案例 2)

我第一次实现时用 k=1。结果精确 token 查询全部翻车——因为 BM25 路 rank=1 得分 1/2=0.5,而向量路 rank=2 得分 1/3=0.33,几乎被头部文档垄断。只要 BM25 路 top1 是错的那篇,融合后它还是稳稳第一。k 太小,融合失去"平均"能力。

调回 60 后,两路能公平对话了。这个坑我放到第 9 章案例 2 详述。

5.5 RRF 的代价:几乎免费

RRF 只是对两路 topN(各 50,合并后最多 100 条)做一次遍历累加和排序,O(N log N),N≤100,实测 3ms 以内。这是三段里最便宜的一段,放心用。


6. cross-encoder 重排序:召回变精度

6.1 为什么需要第三段

前两段给出了"可能相关"的候选,但排序是粗糙的:

  • BM25 靠字面,可能把只有部分字面重合的排很前
  • 向量靠方向,top50 之间相似度差距极小,排序不稳
  • RRF 只是把两路揉一起,仍是"谁都在、但谁是第一"说不清

cross-encoder 就是来回答"谁是真第一"的。

6.2 cross-encoder 和 bi-encoder 的本质区别

  • bi-encoder(向量那一路):query 和 doc 分别编码成两个向量,算余弦。优点是 doc 向量能离线预存,检索快;缺点是 query 和 doc 之间没有任何交互,语义关系被压缩成一次点积。
  • cross-encoder(rerank 那一路):query 和 doc 拼在一起喂进 transformer,输出一个 0-1 的相关度分数。它能看到 query 和 doc 每个 token 之间的交互(attention),精度高得多。缺点是每个候选都要单独跑一次前向,不能离线预存,慢。

一句话:bi-encoder 是"先各自想,再比划";cross-encoder 是"把两个放一起,仔细看"。

6.3 候选池与 topK 的取舍

这是第 6 章最实操的部分。重排序慢,慢在候选池大小:每个候选跑一次模型前向,50 条就是 50 次。

# retrieval/rerank.py
from FlagEmbedding import FlagReranker


class CrossEncoderReranker:
    def __init__(self, model_name="BAAI/bge-reranker-v2-m3", device="cuda"):
        self.model = FlagReranker(model_name, device=device)

    def rerank(self, query: str, candidates: list[tuple[str, str]], top_k: int = 5):
        """
        candidates: [(doc_id, text), ...]
        返回 rerank 分数降序的 top_k 条。
        """
        pairs = [(query, text) for _, text in candidates]
        scores = self.model.compute_score(pairs, normalize=True)
        ranked = sorted(zip(candidates, scores),
                        key=lambda x: x[1], reverse=True)
        return ranked[:top_k]

关键取舍:rerank 候选池越大,召回越高但延迟线性涨。我实测:

rerank 候选池   p95 延迟    相对纯向量
──────────────  ──────────  ──────────
20 条            ~120ms     稳
50 条            ~270ms     推荐默认
100 条           ~520ms     太贵,除非 topK 需要更多

我默认 rerank 候选 50 条 → topK 5。这里有个反直觉的点:

rerank 的候选池必须 ≥ 最终 topK 的数倍。 如果候选池只有 10 条,rerank 排的是这 10 条里的最好 5 条,而真正相关的那篇可能在 RRF 排名第 30,压根没进候选池。rerank 救不了没进池子的文档。

6.4 topK 不是越大越好

topK 是最终送进 LLM 的文档数。我见过有人设 topK=20,觉得"多给 LLM 点上下文总没错"。结果:

  • LLM 上下文窗口被无关 chunk 稀释,反而答得更差(案例 5 里成本也是这么爆的)
  • 越靠后的 chunk 相关度越低,纯属噪声

企业知识库问答,topK=5 是甜点。超过 8,上下文噪声的负效应超过增益。我做过 A/B:topK=5 vs topK=10,忠实度(faithfulness)后者反而低 0.04,token 成本高 80%。

6.5 rerank 分数的使用

cross-encoder 输出的是归一化相关度。我用两个阈值做护栏:

RERANK_KEEP = 0.5   # 低于此分数直接丢弃,不进 LLM
RERANK_FALLBACK = 0.35  # 如果 topK 全低于此,说明整体没召回,走"未找到"流程

这个护栏很关键。与其硬塞 5 个都不相关的 chunk 让 LLM 胡说,不如让系统老实说"知识库里没找到,建议人工确认"。这对运维场景尤其重要——查报错码查不到,宁可报"未收录",也别编一个答案。

6.6 rerank 模型怎么选

cross-encoder 的选择直接决定精度和延迟。我横向比过三个:

模型 参数量 显存 中文精度 p95(50 候选) 结论
bge-reranker-base 278M 1 GB 良 150ms 轻量,预算紧选它
bge-reranker-large 560M 2 GB 优 210ms 平衡
bge-reranker-v2-m3 568M 3 GB 优(多语言) 270ms 我线上用的

v2-m3 贵在多语言——我们知识库中英混排,英文报错码、中文解释,跨语言 rerank 它最稳。如果知识库纯中文,bge-reranker-large 性价比更高。

# 切换模型只需改名字
reranker = FlagReranker("BAAI/bge-reranker-large", device="cuda")

别迷信最大。 我试过更重的 reranker(如 Cohere rerank 系列),p95 飙到 600ms+,但 recall 只多 0.01——完全不值。rerank 模型的选择标准是:在候选池 50、topK 5 的配置下,精度够用且延迟可接受。 超出的精度,是拿用户等待换的。

6.7 rerank 的一次完整前向流程(理解延迟从哪来)

很多人以为 rerank 慢是"模型大",其实主因是候选池 × 序列长度。一次 rerank 前向要处理 候选池 个 (query, doc) pair,每个 pair 长度是 len(query_tokens) + len(doc_tokens)。所以:

# rerank 延迟 ≈ 候选池数 × 平均 pair 长度 × 单 token 耗时
# 候选池 50,每个 chunk 512 token,query 50 token:
#   一次前向 50 个 pair,每个 ~562 token
#   在 4090 上 bge-reranker-v2-m3 ≈ 5-6ms/pair
#   总计 ≈ 270ms  ✓ 和实测吻合

这个公式解释了为什么长查询 + 大候选池会爆炸(第 7.4 节)。也解释了为什么缩短 chunk(512→256)能降 rerank 延迟——每个 pair 短了一半。但 chunk 缩短伤召回,所以是权衡。

6.8 rerank 结果要缓存

同一个 query 短期内重复问的概率不低(尤其报错码,多个客户问同一个)。我给 rerank 加了个 LRU 缓存,命中率 22%,直接省掉 22% 的 rerank 延迟和成本。

from functools import lru_cache

@lru_cache(maxsize=1024)
def rerank_cached(query: str, doc_ids: tuple[str, ...]) -> list[tuple[str, float]]:
    """按 query + 候选 doc_id 集合缓存 rerank 结果。"""
    pairs = [(query, text_map[did]) for did in doc_ids]
    scores = reranker.compute_score(pairs, normalize=True)
    return list(zip(doc_ids, scores))

注意缓存 key 必须是 (query, 候选 doc_id 元组),否则不同查询会串。缓存是低成本高回报的一步,强烈建议做。


7. 实测数据:三种配置对比

7.1 测试集与评估口径

先交代测试怎么做的,免得数据被当成广告。我搭了一个 golden set:从三个团队这一年真实工单里抽了 200 条问答对,标注"正确答案应该命中哪些 chunk"。评估三个指标:

  • recall@5:正确答案出现在 top5 里的比例(查得全不全)
  • MRR:正确答案排名的倒数均值(排得准不准,越靠前越好)
  • p95 延迟:95% 查询的响应时间(用户等不等得起)

三条配置:dense only(纯向量)、hybrid (RRF)(BM25+向量+RRF,无 rerank)、hybrid+cross-encoder(完整三段式)。

# eval/evaluate.py
def evaluate(retriever, golden_set):
    recall, mrr = 0.0, 0.0
    for item in golden_set:
        hits = retriever.retrieve(item["query"])
        hit_ids = [h.doc_id for h in hits[:5]]
        gold = set(item["gold_doc_ids"])
        if gold & set(hit_ids):           # recall@5
            recall += 1
        for rank, doc_id in enumerate(hit_ids, start=1):
            if doc_id in gold:            # MRR
                mrr += 1.0 / rank
                break
    n = len(golden_set)
    return recall / n, mrr / n

7.2 三种配置实测结果

配置                    recall@5   MRR     p95 延迟
──────────────────────  ────────  ──────  ────────
dense only (纯向量)      0.61      0.42    40ms
hybrid (BM25+向量+RRF)   0.84      0.66    45ms
hybrid + cross-encoder   0.89      0.78    310ms

读这个表,三个信息量很大的点:

① recall 从 0.61 → 0.84,是 BM25+RRF 的功劳。 只花了 5ms。这印证第 1 章的判断:纯向量漏掉的那 0.23 召回,全是精确 token 场景,BM25 一上场就接住了。混合检索最大的增益来自召回,不是精度。

② recall 0.84 → 0.89,MRR 0.66 → 0.78,是 cross-encoder 的功劳,但花了 265ms。 精度提升来自重排:正确答案被从第 4 名顶到第 1 名,MRR 涨得比 recall 更猛。说明 rerank 主要价值是排序,其次才是把边缘文档捞回 top5。

③ 纯向量 p95 只要 40ms,完整三段 310ms,差了 7.7 倍。 这就是混合检索的真实成本。你要么接受 310ms 换 0.89 的召回,要么用 45ms 换 0.84 的召回。 没有免费午餐。但注意:310ms 里 265ms 在 rerank,如果候选池从 50 降到 20,延迟能压到 130ms,recall 只掉 0.02——这就是第 8 章要讲的"按场景配 rerank"。

7.3 分场景拆解(更实用)

把 200 条按"精确 token 型"和"自然语言型"拆开看,差异更赤裸:

子集            配置                    recall@5   p95
──────────────  ──────────────────────  ────────  ─────
精确 token 型    dense only              0.35      38ms
(序列号/报错码)  hybrid+rerank           0.93      305ms
──────────────  ──────────────────────  ────────  ─────
自然语言型      dense only              0.88      42ms
(开放式问句)    hybrid+rerank           0.91      312ms

这组数据把第 1 章的问题定量化了:

  • 纯向量在精确 token 型上召回只有 0.35——确实翻车,六成多查不到。这就是技术支持骂我的原因。
  • 混合检索把精确 token 型拉到 0.93,提升 58 个点。
  • 自然语言型,纯向量本来就有 0.88,混合只多 3 个点。也就是说,如果你的查询全是自然语言,混合检索是过度建设,纯向量够用,别花那 265ms。

结论:混合检索值不值,取决于你查询里精确 token 的占比。我们这个知识库占比 62%,值。你们如果主要是开放问答,那 BM25+rerank 纯属花钱买心安。

7.4 一个容易被忽略的坑:延迟的分布

p95 是 310ms,但 p50 只有 90ms。因为 rerank 延迟和候选池有效数量强相关——查询简单、候选池里有效候选少的时候,rerank 快。但长查询(>60 token)rerank 会飙到 600ms+,因为每个 pair 的序列长度长了。

所以监控不能只看平均,要看 p95 和 p99。我线上告警阈值设的是 p99>800ms。这个我在《向量数据库监控》里也提过,检索延迟是 RAG 健康度的核心指标。

7.5 一次完整查询的时序拆解(延迟从哪来)

把"延迟 310ms"拆开,才能真正理解它贵在哪。这是一条典型精确 token 查询的完整链路:

查询:"SKF-6204-2RSH 库存和替代型号"
─────────────────────────────────────────
t=0ms    开始
t=0ms    向量路:query → bge-m3 嵌入(1 次前向)      ~12ms
t=12ms   向量路:Milvus HNSW ANN 检索 top50           ~18ms
t=30ms   BM25 路:分词 + 稀疏检索 top50               ~5ms
t=35ms   RRF 融合(两路 100 条 → 排序去重)           ~3ms
t=38ms   rerank:50 对 (query, chunk) 前向            ~265ms
t=303ms  阈值护栏:<0.5 丢弃,取 top5                 ~1ms
t=304ms  拼装上下文,送 LLM                          (生成另计)
─────────────────────────────────────────
检索层总计 ≈ 305ms,其中 rerank 占 87%

这张时序图是诊断所有性能问题的钥匙。一旦 p95 超标,先看 rerank 段占了多少。如果 rerank 占了 80%+,是候选池太大或 chunk 太长;如果 rerank 只占 30%,那问题在向量路(比如 ANN 索引参数没调好)或嵌入(模型太慢)。延迟分布比平均延迟信息量大得多。

7.6 一个诚实的边界:混合检索不能救所有

我写这篇不是想推销混合检索是万能的。它有两个明确边界,遇到就别硬上:

① 知识库本身就是空的。 如果这个报错码根本没收录,BM25 召回 0 条、向量召回 0 条,RRF 融合不出东西,rerank 也无米下锅。混合检索提升的是"检索到正确文档"的概率,不是"知识库里有答案"的概率。 前者能救,后者只能靠补文档。所以第 6.5 节的"未找到"护栏必须保留,别让它硬编答案。

② 查询太模糊。 用户问"最近服务器老出问题咋整",没有精确 token、语义也发散。BM25 召回一堆无关,向量召回一堆泛化,rerank 也没法把它变成精确答案。这种查询该走的是对话式追问,不是检索。 我上线了"查询置信度"检测:rerank top1 分数 < 0.35 时,反问用户"您指的是哪类故障?",而不是硬答。

这两个边界说明:混合检索是手段,不是目的。 它的价值在精确 token 场景被最大化,模糊场景靠交互兜底。想清楚这个,才不会在错误的地方过度建设。


8. 本地部署:vLLM/Ollama + BGE-M3 + BGE-reranker

8.1 为什么本地化

公司对数据出域管得很严,知识库里有机密文档,云 API 不让用。所以整套东西跑在内部 GPU 服务器上。我把它当成本账算给你,这是本地化和云 API 最实际的差别。

先说结论:本地 GPU 一次性投入约 2.3 万,跑一年电费+维护约 0.4 万;同等 QPS 用云 API,一年约 3.1 万。 本地在 QPS 低(<5)时略亏,QPS 高或数据敏感时明显划算。数据敏感这一条在我们这是刚需。

8.2 硬件与模型选型

组件 模型 显存 说明
嵌入 BAAI/bge-m3 ~2 GB 1024 维,中英日,8192 token
重排序 BAAI/bge-reranker-v2-m3 ~3 GB 跨语言 reranker,质量好
LLM 生成 Qwen2.5-32B-Instruct (AWQ) ~20 GB 4bit 量化,企业内部问答够用
合计 — ~25 GB 一张 RTX 4090(24GB)紧,建议 2×4090 或 A6000

我用的是两台机器:

  • 检索机:1× RTX 4090,跑 bge-m3 嵌入 + bge-reranker,显存 ~5 GB,余量很大
  • 生成机:1× RTX 4090,跑 Qwen2.5-32B AWQ,约 20 GB,vLLM 服务

8.3 vLLM 起模型(生成这一路)

# 生成模型用 vLLM,吞吐比 Transformers 高一个量级
vllm serve Qwen/Qwen2.5-32B-Instruct-AWQ \
  --host 0.0.0.0 --port 8000 \
  --quantization awq \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.90 \
  --enable-prefix-caching

--enable-prefix-caching 是关键——RAG 场景 query 里带了大量重复的 system prompt 和检索上下文,前缀缓存能省 30-40% 的 prefill 计算,这是混合检索延迟之外必须抠的一笔。我专门写过《LLM Prompt Caching》讲这个,这里不展开。

8.4 Ollama 起嵌入和 rerank(检索这一路)

嵌入和 rerank 用 Ollama 更省事,一个命令拉起,不用管 torch 依赖:

# 嵌入模型
ollama pull bge-m3
ollama run bge-m3

# 重排序模型(bge-reranker-v2-m3 在 Ollama 里以兼容名提供)
ollama pull bge-reranker-v2-m3

调用:

import requests

def embed(text: str) -> list[float]:
    r = requests.post("http://localhost:11434/api/embed",
                      json={"model": "bge-m3", "input": text})
    return r.json()["embeddings"][0]

但我要泼一盆冷水:Ollama 的 rerank 接口和 FlagEmbedding 库不完全等价。Ollama 走 llama.cpp 的 cross-encoder 路径,和 FlagReranker 的 transformers 实现有细微差别。如果线上 rerank 效果不达标,我建议直接用 FlagEmbedding 跑 transformers,精度更可控:

from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", device="cuda")

我的经验:嵌入用 Ollama 图省事,rerank 用 FlagEmbedding 图精度。两者混着来没问题。

8.5 显存和成本账

一次投入(硬件):

RTX 4090 × 2                 ¥16,000
服务器(CPU 64核/256GB/2T SSD) ¥6,000
杂项(电源、线材、机架)         ¥1,000
──────────────────────────────
合计                         ¥23,000

年度运营:

电费(2×4090 满载 800W,按 50% 利用率,0.6 元/度)
  800W × 24h × 365 × 0.5 × 0.6 / 1000  ≈ ¥2,100
折旧(3 年直线)                         ¥7,700/年
维护人力(每季度 2 小时)                 ¥1,500/年
──────────────────────────────────────
折算到每年                              ≈ ¥11,300/年

云 API 对比(同等 QPS≈3,含生成 32B + 嵌入 + rerank):

Qwen2.5-32B 生成 token      ¥0.9 / 百万 token
bge-m3 嵌入 token           ¥0.5 / 百万 token
bge-reranker rerank 次      ¥0.001 / 次
每天 3000 次问答 × 2.5k token × 365 天
  ≈ ¥10,900 生成 + ¥3,500 嵌入 + ¥1,100 rerank
──────────────────────────────
合计                        ≈ ¥15,500/年

对比结论:

  • 本地首年 ¥11,300,云首年 ¥15,500。本地便宜 ¥4,200,且第二年起本地只剩 ¥11,300,云还是 ¥15,500。
  • 但本地要一次性掏 ¥23,000 硬件。回本周期约 4 年,前提是 QPS 稳定。
  • 数据敏感(法务、机密运维文档)这条,云 API 直接不可用,本地是唯一解,成本账反而次要。

我给领导汇报时用的就是这张账:QPS<2 且数据不敏感,用云省心;QPS>3 或数据敏感,用本地。我们属于后者。

8.6 一个降本关键:rerank 不要无脑全量

成本失控的源头(案例 5 会讲)是 rerank 调用次数。省钱的三个实操:

  1. 候选池 50 封顶,不 rerank 全库。BM25+向量已经砍掉 99.9% 的无关文档,rerank 只负责那 50 条。
  2. rerank 分数低于 0.5 的丢弃,别让低分垃圾进 LLM 浪费生成 token。
  3. 查询分流:检测到查询是纯自然语言且无精确 token(用正则探一下有没有数字、连字符、大写字母串),可以直接跳过 rerank,走 hybrid 就够。这能省 40% 的 rerank 调用。
import re

# 探测查询是否需要精确 token 检索
PRECISE_PATTERN = re.compile(r"[A-Z]{2,}|[\w]+-[\w]+|^\d{4,}$|ORA-\d+")

def needs_precise(query: str) -> bool:
    """带大写缩写、连字符串、长数字的查询,多半要精确命中。"""
    return bool(PRECISE_PATTERN.search(query))

这个分流让我线上 rerank 调用量降了 38%,p95 从 310ms 压到 240ms,recall 只掉了 0.01。值得。

8.7 显存与并发:一台 4090 能吃多少 QPS

显存账我上面算了,这里说并发能力。检索机一张 4090(24GB),bge-m3 嵌入 ~2GB + bge-reranker ~3GB,剩 ~19GB 余量。

  • 嵌入:bge-m3 单条 1024 维,batch=64,4090 上约 2000 条/秒,完全不是瓶颈。
  • rerank:bge-reranker-v2-m3,单次前向 50 对约 270ms。4090 上单卡约能扛 3.5 QPS(含排队)。超过 3.5 QPS 就要考虑第二张卡或缩候选池。
4090 单卡 rerank 吞吐估算:
  1000ms / 270ms ≈ 3.7 请求/秒(理想)
  实际(含排队、长尾)≈ 3.5 QPS

我们内部 QPS 峰值 2.5,单卡够。如果 QPS 到 5,我会:① rerank 候选池降到 30;② 加一张 4090。别把 rerank 和生成挤在同一张卡,生成模型(32B AWQ)要 20GB 显存,rerank 塞进去会 OOM。所以我才分两台机器,检索机轻装跑嵌入+rerank,生成机专跑 LLM。

8.8 本地化的运维注意点

本地 GPU 不是一劳永逸。我踩过的坑:

  1. 模型热更新:bge-m3、reranker 升级后,必须全量重建向量库和 BM25 索引,否则新旧 embedding 混在库里,检索质量下降。这是个大坑,升级前备份、升级后重建、验证。
  2. GPU 显存泄漏:FlagReranker 长跑偶发显存缓涨,weekly 重启一次进程,或加 torch.cuda.empty_cache() 周期回收。
  3. Ollama 版本兼容:bge-m3 依赖 Ollama 版本,升级 Ollama 可能破坏嵌入接口,锁版本。
  4. GPU 温度与降频:夏天 4090 满载降频,rerank 延迟会涨 20%。机房空调要跟上,监控 GPU 温度。

这些不处理好,本地部署的"便宜"会被"折腾"抵消。预算充足且数据不敏感,云更省心;数据敏感,这些坑你都得自己扛。


9. 5 个真实案例复盘

案例 1:序列号检索不到(纯向量盲点)

症状:技术支援报"SKF-6204-2RSH 查不到库存和替代型号",返回全是不含该字符串的选型文档。

根因:纯向量检索,bge-m3 把查询和 chunk 各自编码,SKF-6204-2RSH 这个带连字符的型号在向量空间被"轴承""深沟球"等语义泛化词淹没。余弦相似度排前的全是泛化 chunk,精确字符串没有任何机制被保证出现。这是第 1 章说的原理局限,不是 bug。

排查过程(关键动作是确认"库里到底有没有这个串"):

# 先确认:这个字符串到底在不在知识库里
hits = vector_store.search(embed("SKF-6204-2RSH"), top_n=10)
for doc_id, score in hits:
    print(doc_id, score)  # 分数都 <0.5,没有精确命中
# 再确认:全文里有没有
found = [d for d in all_docs if "SKF-6204-2RSH" in d["text"]]
print(len(found))  # 有 3 条!向量路一条都没召回

修复:加 BM25 路。BM25 对 SKF-6204-2RSH 这个 token 的词频响应极高(IDF 大),3 条含该字符串的 chunk 全被顶到 top3。修复后该查询 recall@5 从 0 到 1.0。

教训:遇到"明明有却查不到",先怀疑检索通路,别急着怪文档。 用"字符串在不在库"这一步验证,10 分钟就能区分是"没收录"还是"没检索到"。


案例 2:RRF k 值调错(融合失效)

症状:加 BM25 后,精确 token 查询反而变差了。比如查 ORA-01555,BM25 路明明把正确 chunk 排第 1,融合后它却掉到第 3。

根因:我当初 k=1。RRF = 1/(k+rank),BM25 路 rank=1 得 1/2=0.5,向量路 rank=1 得 1/2=0.5,两路各自 top1 各拿 0.5。但问题是,如果 BM25 路 top1 恰好是语义泛化的错 chunk,向量路 top1 也是它(因为语义相近),那这个错 chunk 得 1.0,真正的精确命中(BM25 路 rank=2,得 1/3≈0.33)被死死压住。k 越小,头部垄断越严重,融合越像"少数派独裁"。

k=1 时融合结果(ORA-01555):
  doc A(语义泛化的错 chunk)  BM25#1 + vec#1 = 0.5 + 0.5 = 1.00
  doc B(真正含 ORA-01555)    BM25#2           = 0.33   ← 被压死

k=60 时融合结果:
  doc A                       1/61 + 1/61 = 0.0328
  doc B                       1/62       = 0.0161  ← 但差距缩小,且
                                                      rerank 能把 B 顶上来

修复:k=60。k 增大后,头部文档权重衰减,第 1 名和第 50 名的差距没那么悬殊,真正的精确命中(哪怕只被 BM25 一路捞到,排第 2)还能和两路重合的泛化 chunk 竞争。再配合 cross-encoder,B 被顶回第 1。

教训:RRF 的 k 不是越大越好,也不是越小越好。 默认 60,别拍脑袋。调完一定跑 golden set 验证,不要只看单条查询觉得"好像对了"。


案例 3:cross-encoder 候选池太小(召回不够)

症状:加了 rerank 后,自然语言查询精度不错,但某些查询正确答案总是不在最终 top5 里,rerank 再准也救不回来。

根因:rerank 候选池设了 10 条。BM25+RRF 的融合排名里,正确答案排第 17,根本没进这 10 条的候选池。cross-encoder 只能排它看到的候选,看不到第 17 名,所以无能为力。这正是 6.3 说的"rerank 救不了没进池子的文档"。

修复:rerank 候选池从 10 → 50。正确答案进了池子,cross-encoder 把它从第 30 名(RRF 排序里)排到第 2 名,进了 top5。

教训:rerank 候选池 ≥ 5×topK 是底线。 我后来定了个规则:先看 RRF top50 里正确答案的排名分布,再定候选池。我们 90% 的正确答案都在 RRF top30 内,所以候选池 50 很稳。

# 验证候选池是否足够:统计正确答案在 RRF topN 内的累计比例
import numpy as np
cum = np.cumsum(answers_in_rank[:50])
for n in [10, 20, 30, 50]:
    print(f"候选池 {n} 条,可覆盖 {cum[n-1]/total:.1%} 的正确答案")
# 候选池 10 条 → 76%
# 候选池 20 条 → 88%
# 候选池 30 条 → 94%
# 候选池 50 条 → 97%

案例 4:chunking 不当导致上下文断裂

症状:某个运维手册的"故障码对照表"章节,查询某个故障码,召回的不是对应表格行,而是相邻无关段落。甚至出现"答案前后矛盾"。

根因:我用固定 512 字符切 chunk,把一个完整的故障码表格从中间劈成两半。故障码 F-1023 和它的处理步骤被分到了两个 chunk。检索召回的是上半段(只有码没有步骤),LLM 答不出完整流程。这是最隐蔽的问题——不是检索坏了,是切分把语义单元切碎了。

修复:切分策略从"固定 512"改成"按 Markdown 标题层级 + 表格整行切分"。表格不劈开,一个表一个 chunk(或按行组切)。用 LangChain 的 MarkdownHeaderTextSplitter,再对超长段落做递归切分,chunk_size=512, overlap=64。

from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter

headers = [("##", "h2"), ("###", "h3")]
splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers)

for sec in splitter.split_text(markdown_text):
    # 表格整块保留,不按字符劈
    if "|" in sec:
        chunks.append(sec)  # 表格整体作为一个 chunk
    else:
        # 普通段落递归切分
        for c in RecursiveCharacterTextSplitter(
                chunk_size=512, chunk_overlap=64).split_text(sec):
            chunks.append(c)

教训:chunk 不是越小越好。 固定 512 是通用起点,但遇到表格、代码块、条款编号,必须优先保住语义完整。我第 10 章会讲怎么用 golden set 扫 chunk_size。


案例 5:成本失控(rerank 调用太多)

症状:月度 GPU 账单突然涨了 40%。排查发现 rerank 调用量翻倍,原因是有人把 rerank 候选池从 50 改到 200,觉得"多召点总没错"。

根因:候选池 200 → 每次查询 rerank 200 次前向,延迟飙到 1s+,GPU 利用率上不去但单次成本高。而且 topK 也被调成 15,LLM 生成 token 暴涨(上下文里塞了 15 个 chunk,一半是噪声)。

修复:① 候选池回 50;② topK 回 5;③ 加 6.5 的 rerank 阈值护栏,低于 0.5 的丢弃;④ 加 8.6 的查询分流,纯自然语言查询跳过 rerank。四步下来:

指标          修复前        修复后
────────────  ──────────   ──────────
rerank 调用/天  12,000        7,400
p95 延迟        1,150ms       240ms
月成本          +40%          回落至基准
recall@5        0.90          0.89

教训:成本爆炸的源头永远是"多给了没用的东西"。 候选池、topK、rerank 全开大,是典型的"我以为多就是好"。用 golden set 验证,0.89 和 0.90 的 recall 差异在噪声范围内,但成本差 40%,不值。

9.6 五个案例汇总表

复盘完五个案例,我把它们压缩成一张表贴在工位上。它是我这一年用真金白银换来的:

# 症状 根因 修复 一句话教训
1 序列号查不到 纯向量盲点,无字面命中机制 加 BM25 路 先确认"在不在库",再怪检索
2 融合后排名变差 RRF k=1,头部垄断 k=60 k 是平滑参数,别拍脑袋
3 rerank 救不回 候选池 10 < 真实排名 17 候选池 50 候选池 ≥5×topK 是底线
4 答案前后矛盾 固定切分劈开表格 按结构切分 chunk 保语义完整优先
5 GPU 账单涨 40% 候选池/topK 无脑开大 回 50/5 + 护栏 多给≠更好,噪声和成本双输

五张表,五个方向:召回、融合、排序、切分、成本。混合检索的每个环节都会以某种方式翻车,这套表是我排查问题的顺序——先看召回(案例 1),再看融合(案例 2),再看排序(案例 3),再看数据(案例 4),最后看成本(案例 5)。顺序即排查路径。


10. 调参顺序:先 golden set、再 chunking sweep、再 hybrid、再 rerank

10.1 为什么这个顺序不能乱

调参最大的坑是互相掩盖。如果你先调 rerank,会发现精度上去了,但那可能只是 rerank 把 chunking 的锅盖住了——一旦换数据,问题又冒出来。正确顺序是自底向上,先修地基,再盖楼:

① 建 golden set      ← 没有评估,一切都是玄学
② chunking sweep     ← 切分烂,后面全白搭
③ BM25/向量 topN     ← 召回基数
④ RRF k              ← 融合
⑤ rerank 候选池/topK ← 精度
⑥ 阈值护栏            ← 防胡说、防成本

10.2 ① 建 golden set(先做这个)

没有 golden set,你调参就是凭感觉,调完也不知道好不好。从真实工单抽 100-200 条,人工标注正确 chunk。这是最费人力但最值钱的一步。

# golden_set.json
[
  {
    "query": "SKF-6204-2RSH 库存和替代型号",
    "gold_doc_ids": ["chunk_1042", "chunk_1043"],
    "type": "precise"     # precise / natural
  },
  {
    "query": "Nginx 反代怎么配",
    "gold_doc_ids": ["chunk_2077"],
    "type": "natural"
  }
]

10.3 ② chunking sweep(再扫切分)

固定一个检索配置,扫 chunk_size。我扫 {256, 384, 512, 768, 1024},overlap 固定为 chunk_size 的 12.5%(64/512 就是 12.5%):

chunk_size   recall@5   说明
───────────  ────────   ──────────────────
256          0.71       太碎,语义单元被切断
384          0.79       好一些
512          0.84       ← 甜点(我们知识库)
768          0.82       表格/代码块开始被劈
1024         0.76       太粗,一个 chunk 塞太多无关内容

chunk_size 的选择取决于文档结构,不是越短越好。表格多的文档,512 会劈表格,得用整块保留策略(案例 4)。

10.4 ③ 调 BM25/向量 topN

topN 是两路各自给融合的候选数。太小会漏,太大会让垃圾稀释融合。

topN   recall@5   融合候选数
─────  ────────   ──────────
20     0.79       40
50     0.84       100  ← 甜点
80     0.85       160  ← 收益趋平,候选池变大

50 之后收益趋平,但候选池翻倍,rerank 候选也跟着变多。topN=50 是性价比拐点。

10.5 ④ 调 RRF k

在 chunking、topN 都定死后,扫 k。我扫 {20, 40, 60, 80, 120}:

k    recall@5   MRR    说明
────  ────────  ──────  ──────────────
20    0.81      0.60    头部垄断,精确命中被压
40    0.84      0.65
60    0.84      0.66    ← 甜点
80    0.83      0.65
120   0.81      0.63    太平均,噪声进榜

k 在 40-80 之间较平,60 稳。别在 k 上过度纠结,它影响比 chunking 小得多。

10.6 ⑤⑥ 调 rerank 候选池与护栏

最后才动 rerank。候选池扫 {20, 50, 100},topK 扫 {3, 5, 8}:

候选池  topK   recall@5  MRR    p95
──────  ────  ────────  ─────  ─────
20      5     0.87      0.74   130ms
50      5     0.89      0.78   310ms  ← 甜点
100     5     0.90      0.79   520ms
50      8     0.90      0.77   340ms  ← topK 大,MRR 反降,成本升

最后加 RERANK_KEEP=0.5、RERANK_FALLBACK=0.35 两个护栏,防止胡说、控制成本。

10.7 调参清单(贴墙版)

参数                    推荐值        调参依据
─────────────────────  ────────────  ──────────────────────
chunk_size              512           按文档结构 sweep
chunk_overlap           64            12.5% of chunk_size
BM25 topN               50            召回拐点
向量 topN               50            召回拐点
向量相似度阈值          0.35          精确 token 场景可放宽
RRF k                   60            sweep 40-80 找平台
rerank 候选池           50            ≥5×topK
topK                    5             faithful 拐点
RERANK_KEEP             0.5           防噪声进 LLM
RERANK_FALLBACK         0.35          触发"未找到"流程

这份清单贴在运维群里,新同事照着配,不用重复踩坑。

10.8 把 sweep 自动化(别手动跑)

手动 sweep 很累,还容易漏。我写了个脚本,把 chunk_size / topN / k / 候选池 四个维度自动化扫一遍,输出对比表,一眼看出平台区。

# eval/sweep.py
import itertools
from eval.evaluate import evaluate

def run_sweep(golden_set, chunk_sizes, topns, ks, pools):
    """四维 sweep,输出每个组合的 recall/MRR/p95。"""
    results = []
    for cs, tn, k, pool in itertools.product(chunk_sizes, topns, ks, pools):
        chunks = build_chunks(cs)                  # ① chunking
        retriever = build_retriever(chunks, tn, k, pool)  # ②③④⑤
        recall, mrr, p95 = measure(retriever, golden_set)
        results.append({
            "chunk": cs, "topn": tn, "k": k, "pool": pool,
            "recall": round(recall, 3), "mrr": round(mrr, 3),
            "p95_ms": round(p95),
        })
    return sorted(results, key=lambda r: (-r["recall"], r["p95_ms"]))

# 用法:先固定其余维度,单扫一个维度找平台区
# chunk_sizes = [256, 384, 512, 768, 1024],其余固定

关键技巧:一次只扫一个维度,其余固定。 四维同时扫是 5×4×5×3=300 次组合,既慢又看不出每个维度单独的影响。正确做法是"坐标下降":先固定 chunk 扫 topN,找到最优 topN;再固定两者扫 k;逐维收敛。

10.9 评估的"金标准"问题:别只看 recall

调参时最容易犯的错是只盯 recall。recall 高不代表回答好——recall 只说明"对的 chunk 进 top5 了",但如果 top5 里混了 3 个噪声 chunk,LLM 照样答歪。所以三个指标一起看:

  • recall@5:查得全不全(召回能力)
  • MRR:对的是不是排最前(排序质量)
  • faithfulness / answer_relevancy:最终回答忠不忠实(端到端质量)

我调参时,recall 和 MRR 用 golden set 算(快,毫秒级),faithfulness 用 RAGAS 在子集上算(慢,要跑 LLM,抽 30 条)。因为 RAGAS 每条要调生成模型,200 条太贵,30 条足够看出趋势。

# 只对调参后的 top 配置跑 RAGAS,避免全量跑 LLM 太贵
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy

def final_check(subset_30, retriever, llm):
    result = evaluate(subset_30, metrics=[faithfulness, answer_relevancy])
    return result

先快后慢:golden set 秒级筛掉 95% 的差配置,RAGAS 只验最后的赢家。这是调参效率的关键。

10.10 上线前最后一道检查

调完参,上线前我必做三件事,缺一不可:

  1. 回归 golden set:确认 recall/MRR 没比调参前低。改任何参数都跑一遍,防止"调好了 A 忘了 B"。
  2. 压测延迟:用 locust 或自写脚本压 1000 并发查询,看 p95/p99 是否在告警阈值内。rerank 是延迟大头,确认候选池没被悄悄改大。
  3. A/B 抽查:抽 20 条真实工单,人工对比"纯向量 vs 混合"的回答质量。混合检索不是保证每一条都更好,而是保证 62% 的精确 token 查询不再翻车。 抽查确认没有新的 regression。

这三步做完,才算真正上线。混合检索是系统工程,不是加个 rerank 就完事。


11. FAQ

Q1:混合检索比纯向量检索好在哪?

A:纯向量只做语义相似,抓不住序列号、报错码、法条编号这类需要逐字符命中的 token。BM25 按词频命中补上这一层召回,RRF 把两路排名融合,cross-encoder 再精排。我们实测精确 token 型查询 recall@5 从 0.35 提到 0.93,整体从 0.61 提到 0.89。前提是查询里这类精确词占一定比例;如果全是开放自然语言问句,混合检索是过度建设。

Q2:RRF 为什么用 1/(k+rank) 而不是直接加分数?

A:BM25 分数(0-20 无上界)和余弦相似度(-1 到 1)量纲不同、分布不同,直接相加会让绝对值大的那一路主导,混合名存实亡。RRF 只依赖排名不依赖分数,天然公平。k 是平滑常数,控制头部文档权重,默认 60。k 太小会头部垄断,k 太大噪声进榜,40-80 之间较稳。

Q3:cross-encoder 重排序延迟高怎么办?

A:延迟高是因为每个候选都要跑一次模型前向。三个办法:① 候选池控制在 50 条内,只 rerank 召回后的候选而非全库;② 用 GPU 跑 bge-reranker-v2-m3,p95 压到 300ms 内;③ 查询分流,纯自然语言查询直接跳过 rerank,能省 38% 调用。实在没 GPU,可用更小的 base 版 reranker 或干脆只用 hybrid 不 rerank,代价是 recall 掉 0.05、MRR 掉 0.12。

Q4:没有 GPU 怎么跑混合检索?

A:BM25 用 CPU 就行,这是它的优势。cross-encoder 是 GPU 大户——可以降到 CPU 跑(延迟 1-2s),或用 bge-reranker-base(小模型,精度略降)。最省的做法是只保留 BM25+向量+RRF 的 hybrid 模式,放弃 rerank,recall@5 约 0.84、p95 约 45ms,对大多数查询够用。我建议小团队先上 hybrid,等 recall 确实不够再花钱加 rerank。

Q5:调参该从哪一步开始?

A:先建 golden set 标注正确答案——没有评估一切都是玄学。然后按 chunk_size → topN → RRF k → rerank 候选池/topK → 阈值护栏的顺序自底向上调。顺序反了会互相掩盖:先调 rerank 可能只是把 chunking 的锅盖住,换数据又翻车。每调一步都用 golden set 回归,看 recall/MRR/延迟三个指标。


相关文章