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。我心想这检索质量多好,语义相近肯定能召回。结果工单里客户发来:
检索出来的 top5 是:"深沟球轴承选型指南""6204 系列载荷计算""轴承游隙对照表"……没有一个包含 SKF-6204-2RSH 这个字符串。答案自然是错的。
1.2 根因:向量不懂"精确字符串"
Embedding 模型把整段文本压成一个 1024 维向量,天然丢掉了词级别的精确信息。它学的是语义分布,不是字符序列。SKF-6204-2RSH 这种带连字符、带数字的型号,在向量空间里和"轴承""选型"这些泛化词混在一起,余弦相似度被语义相近的泛化 chunk 稀释了。
这不是模型烂,是语义检索的原理局限。向量擅长的是:
- "怎么配 Nginx 反代" → "Nginx 反向代理配置教程"(换词不换义)
- "服务器频繁重启" → "主机异常重启排查"(同义改写)
它不擅长的是:
- 精确 SKU / 序列号 / 料号(SKF-6204-2RSH)
- 法条编号(《劳动合同法》第八十七条)
- 报错码(K8s
CrashLoopBackOff、OracleORA-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 一个典型翻车现场,逐帧拆解
我拿技术支持团队一条真实工单拆给你看,这是纯向量检索翻车的最典型样本。查询:
这条查询其实藏了两个需求:一个精确 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 里相似度差距极小,真正的精确文档混在泛化文档中间。
这条查询的教训是双重的:
- 纯向量对精确 token 型查询,召回本身就不足(不是排序问题,是压根没把含 ORA-01555 的 chunk 拉进前排)。
- 即使碰巧召回,排序也不可信,top1 大概率是泛化垃圾。
所以混合检索不是"锦上添花",是"救生圈"。下面进入三段式架构。
2. 三段式架构:候选生成 → 融合 → 重排序
2.1 为什么要拆三段
我一开始图省事,想"BM25 和向量各拿 top 50,直接拼一起去重就完事"。结果发现两个问题:
- 分数不可比:BM25 打出来是 0-20 的浮点,余弦相似度是 -1 到 1。你没法说"BM25 得 8 分"和"余弦 0.72"谁更相关。
- 两路质量不均:向量那一路偶尔把精确 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 里做加权,原因有二:
- RRF 本身就是排名融合,权重应该加在"哪一路更可信"上,而这取决于查询类型。 精确 token 查询 BM25 更可信,开放问句向量更可信。用固定权重没法两头兼顾。
- 固定权重会引入新的调参维度。 你已经在调 chunk、topN、k、候选池、topK 了,再加权重,搜索空间爆炸,而且收益不明确。
我的做法是不加权重,靠查询分流(第 8.6 节):查询带精确 token 就侧重 BM25(通过调小 k),纯自然语言就侧重向量(调大 k)。这是把"加权"从静态参数变成动态策略,效果更好,还不增加维度。
设计原则:能用策略解决的问题,不要用参数。 参数越多越难调,策略是显式的、可解释的。
3. BM25 检索:为什么它救召回
3.1 一句话原理
BM25(Best Matching 25)是经典概率检索模型,本质是:文档里出现了查询词,就给分;出现得越多越给分;词越稀有,单次出现的权重越高。
它是"词袋"模型,不管语义,只看字面共现。这恰恰是向量缺的那一环。
其中 f(qi,D) 是词 qi 在文档 D 中的词频,IDF(qi) 是逆文档频率(稀有词权重高),k1≈1.2 控制词频饱和,b≈0.75 控制文档长度归一化。
我不需要手写实现,用现成的。企业环境我推荐 rank_bm25 这个纯 Python 库,零依赖,好部署;或者 Elasticsearch / OpenSearch 自带的 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 混合里向量路怎么调
阈值别太高。我一开始设 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 把每个文档的分数折算成"排名倒数",再求和:
其中 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 输出的是归一化相关度。我用两个阈值做护栏:
这个护栏很关键。与其硬塞 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(如 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 调用次数。省钱的三个实操:
- 候选池 50 封顶,不 rerank 全库。BM25+向量已经砍掉 99.9% 的无关文档,rerank 只负责那 50 条。
- rerank 分数低于 0.5 的丢弃,别让低分垃圾进 LLM 浪费生成 token。
- 查询分流:检测到查询是纯自然语言且无精确 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 就要考虑第二张卡或缩候选池。
我们内部 QPS 峰值 2.5,单卡够。如果 QPS 到 5,我会:① rerank 候选池降到 30;② 加一张 4090。别把 rerank 和生成挤在同一张卡,生成模型(32B AWQ)要 20GB 显存,rerank 塞进去会 OOM。所以我才分两台机器,检索机轻装跑嵌入+rerank,生成机专跑 LLM。
8.8 本地化的运维注意点
本地 GPU 不是一劳永逸。我踩过的坑:
- 模型热更新:bge-m3、reranker 升级后,必须全量重建向量库和 BM25 索引,否则新旧 embedding 混在库里,检索质量下降。这是个大坑,升级前备份、升级后重建、验证。
- GPU 显存泄漏:FlagReranker 长跑偶发显存缓涨,weekly 重启一次进程,或加
torch.cuda.empty_cache()周期回收。 - Ollama 版本兼容:bge-m3 依赖 Ollama 版本,升级 Ollama 可能破坏嵌入接口,锁版本。
- 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 是两路各自给融合的候选数。太小会漏,太大会让垃圾稀释融合。
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 上线前最后一道检查
调完参,上线前我必做三件事,缺一不可:
- 回归 golden set:确认 recall/MRR 没比调参前低。改任何参数都跑一遍,防止"调好了 A 忘了 B"。
- 压测延迟:用
locust或自写脚本压 1000 并发查询,看 p95/p99 是否在告警阈值内。rerank 是延迟大头,确认候选池没被悄悄改大。 - 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/延迟三个指标。
相关文章
- RAG 知识库构建与运维 · 操作手册 — 文档切分、Embedding 选型、知识库运维,混合检索的前置章节
- Embedding 模型选型与微调 · 运维手册 — bge-m3 等嵌入模型对比与本地部署,理解向量路的基础
- 向量数据库监控 · 运维手册 — 检索延迟、索引健康、QPS 监控,混合检索上线后的观测手段