主题
11.5 搜不准、搜不全怎么办?—— 混合检索与重排
痛点场景:你辛辛苦苦建好了向量库,一测却发现两个致命问题—— ① 搜不全:用户搜设备报错码
ERR-404-X9,向量检索因为“语义相近”把ERR-NETWORK-9022也捞出来了,唯独漏了精确命中那个; ② 搜不准:检索回来 Top-3 里有两篇是“看起来相关其实跑题”的噪声。 本节就用「混合多路召回 + RRF 融合 + Cross-Encoder 重排」这套实际项目中的组合拳解决它。
痛点:向量检索的两大阿喀琉斯之踵
| 检索方式 | 强项 | 软肋 |
|---|---|---|
| 向量检索(Dense) | 语义泛化强:“难过”能召回“悲伤” | 精确型号/编号/专有名词容易“记错字面” |
| 关键词检索(BM25) | 字面精确命中强:抠死货号、编号 | 同义词/口语表达完全失效 |
直观理解:向量检索擅长发现语义相近的表达,BM25 擅长匹配准确的关键词。两者分别会漏掉一部分结果,混合检索可以让它们互相补充。
思路:三段式流水线
为什么要分“粗排 + 精排”两段? 重排模型精度高但很贵(要对每篇文档实时交叉计算),所以先让便宜的检索器快速捞出 Top-20,再让贵的重排器只对这 20 篇精排取 Top-3。
代码实现一:自己手写 RRF 融合(看透算法本质)
很多人只会调
EnsembleRetriever,却不知道它内部在算什么。这里我们自己实现一遍 RRF(Reciprocal Rank Fusion,倒数排名融合),把算法的“魂”看清楚。
python
from typing import List, Dict
def rrf_fuse(rankings: List[List[str]], k: int = 60) -> Dict[str, float]:
"""把多路检索结果的排名(每路是 doc_id 列表)融合成综合得分。
公式:score(d) = Σ 1 / (k + rank_m(d))
"""
scores: Dict[str, float] = {}
for ranking in rankings: # 遍历每一路检索器
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return dict(sorted(scores.items(), key=lambda x: x[1], reverse=True))
# 演示:向量路与 BM25 路各自返回的排名
dense_rank = ["doc_A", "doc_B", "doc_C", "doc_D"]
bm25_rank = ["doc_E", "doc_A", "doc_D", "doc_B"]
fused = rrf_fuse([dense_rank, bm25_rank])
print("=== RRF 融合结果(按得分排序)===")
for doc_id, score in fused.items():
print(f"{doc_id}: {score:.4f}")
# 观察:doc_A 两路都排前列 → 得分最高;doc_E 只在 BM25 排第一 → 也不低
# 这正是 RRF 的价值:不比较两路的“绝对分数”(量纲不同),只比较“名次”💡 为什么不能直接加分? 向量的余弦分数在 [0,1] 之间,BM25 分数可能是 18.5、3.2 这种任意正数——两者量纲完全不同,直接相加等于“把米和公斤加在一起”。RRF 抛弃绝对分数、只看名次,科学多了。
代码实现二:完整「BM25 + 向量 + RRF + 重排」流水线
python
from langchain_community.retrievers import BM25Retriever
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_classic.retrievers import EnsembleRetriever
from langchain_core.documents import Document
from sentence_transformers import CrossEncoder
# 1. 模拟一份“既有精确编号、又有语义描述”的知识库
docs = [
Document(page_content="设备 RX-9000 在高温 80 度运行时会出现告警代码 ERR-404-X9,请立即切断电源冷却。", metadata={"id": "doc1"}),
Document(page_content="RX 系列通用维修指南:遇到常规报警请检查保险丝与散热风扇。", metadata={"id": "doc2"}),
Document(page_content="售后退换货政策:数码产品在购买 7 天内支持无理由退换。", metadata={"id": "doc3"}),
Document(page_content="网络模块 NW-2000 出现 ERR-404-X9 时需重启并升级固件。", metadata={"id": "doc4"}),
]
# 2. 双路检索器
vectorstore = Chroma.from_documents(docs, OpenAIEmbeddings(model="text-embedding-3-small"))
dense = vectorstore.as_retriever(search_kwargs={"k": 3})
sparse = BM25Retriever.from_documents(docs)
sparse.k = 3
# 3. 官方封装:带权重的 RRF,和上面手写的纯名次公式不是同一把尺
ensemble = EnsembleRetriever(retrievers=[dense, sparse], weights=[0.5, 0.5])
query = "RX-9000 报 ERR-404-X9 故障怎么解决?"
candidates = ensemble.invoke(query)
print(f"粗排召回 {len(candidates)} 篇")
# 4. Cross-Encoder 重排:把 Query 和每篇文档拼在一起做深度交叉
reranker = CrossEncoder("BAAI/bge-reranker-base") # 本地模型约 1GB;不想下载可换 API 重排(见下)
pairs = [[query, d.page_content] for d in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
print("=== 重排后 Top-2 ===")
for doc, score in ranked[:2]:
print(f"[{score:.4f}] {doc.page_content}")🔁 重排的两种实现路线:上面的 Cross-Encoder 跑在本地(首次下载约 1GB,之后离线可用,数据不出门);如果不想下载模型,可以改用 API 重排——Jina Reranker 和 Cohere Rerank 都提供 HTTP 接口,把候选文档列表 POST 过去拿回分数即可;预算紧张时甚至可以让现有 Chat 模型做「逐篇打分」当重排器(慢但零新增依赖)。生产选型看三件事:单次候选量、延迟预算、数据合规要求。
官方 EnsembleRetriever 是加权倒数排名:weights=[0.5, 0.5] 时两路平权,把权重改成 [0.8, 0.2] 就等于更信向量那一路。上面手写的 1/(k+rank) 不乘权重。两者都能用,别拿手写公式的分数去核对官方封装的输出——就像两家快递都按“名次”派单,一家还按客户等级加分,总分对不上很正常。LangChain 1.x 里这个类在 langchain_classic.retrievers,从 langchain.retrievers 直接 import 会报模块不存在。
保留每一路召回的诊断信息
混合检索返回结果时,可以记录文档来自关键词还是向量通道、原始排名、融合得分和重排得分。一条坏结果最终排到前面时,就能判断是某路召回噪声太大、融合偏置,还是重排模型误判。
RRF 合并的是排名,不是相关概率;不同查询的分数也不能直接共用一个业务阈值。阈值和 Top-K 应在固定评测集上调整,并观察答案质量与上下文长度的共同变化。
可解释检索:告诉用户“为什么是它”
痛点场景:答案对了,用户还是不敢用——“这段话从哪来的?是不是你编的?”;答案错了,工程师也难定位——“到底是哪一路召回的锅?”
可解释性要同时服务两个受众,而且两者的信息量完全不同:面向用户的是**“出处提示”(命中的原文片段高亮出来、来自哪个文件第几页),面向调试的是“召回面板”**(这一路是 BM25 还是向量还是图谱召回的、原始分与融合后名次各是多少、为什么被截断)。
| 展示字段 | 给用户看 | 给工程师看 |
|---|---|---|
| 命中原文片段 | ✅ 高亮摘录,可一键跳转核对 | ✅ 完整块 + 字符偏移 |
| 出处位置 | ✅ 文件名 / 章节 / 第几页 | ✅ 文档 ID、Chunk ID、版本号 |
| 召回来源 | ❌ 不展示 | ✅ 哪几路命中、各路权重 |
| 原始分与融合后名次 | ❌ 不展示 | ✅ 各路原始分、RRF 名次、重排分 |
| 被截断或去重的原因 | ❌ 不展示 | ✅ 超 Token 预算 / 分数断崖 / 与已选块重复 |
| 排序依据 | ✅ 只用“相关度:高/中/低”这类粗档 | ✅ 完整分数与阈值 |
⚠️ 一条边界:可解释 ≠ 把内部分数直接甩给用户。检索分数不是概率(MaxSim 类分数尤其如此,见 11.11 的提醒),把
0.73暴露出去只会换来“为什么不是 0.9”的无效追问。给用户“出处 + 粗档”,给工程师“明细 + 链路”。
配套脚本:
code/s05_hybrid_retrieval.py的explain_retrieval()。
为什么 Cross-Encoder 能“优于”双塔?
| 架构 | 流程 | 精度 | 速度 |
|---|---|---|---|
| Bi-Encoder(双塔) | Query 和 Doc 分别过 Embedding,最后算一次余弦 | 中等(没有词级交互) | 极快(向量可离线算好) |
| Cross-Encoder(重排) | [CLS] Query [SEP] Doc 拼一起过 Transformer,逐词交互 | 极高(能捕获细微逻辑关联) | 慢(只能对少量候选打分) |
工程定位:双塔负责“海选”(对全库),Cross-Encoder 负责“决赛”(对 Top-20),各司其职。代表模型:BGE-Reranker、Cohere Rerank。
进阶:Cross-Encoder 怎么选、怎么调、怎么微调
痛点场景:知道“该上重排”之后,工程师立刻会撞上三个现实问题——选哪个模型?候选数开多大?通用模型在我的领域里判不准,能不能自己训一个?
1)选型:本地自部署 vs 托管 API
| 方案 | 部署方式 | 语言适配 | 延迟量级 | 适用场景 |
|---|---|---|---|---|
| BGE-reranker 系列(base / large / v2-m3) | 本地自部署(下载权重即可离线跑) | 中英双语;v2-m3 面向多语言 | 一条 Query × 几十条候选,GPU 上通常几十到上百毫秒;纯 CPU 会明显更慢 | 数据不出门、想控成本、有一定算力储备;v2-m3 是当前性价比首选 |
| Cohere Rerank | 托管 API(官方页) | 多语言 | 一次网络往返 + 服务端计算,通常百毫秒量级 | 想快速上线、不想运维模型;需评估数据出境与合规 |
| Jina Reranker | 托管 API(官方页,同时有开源权重) | 多语言 | 百毫秒量级 | 已用 Jina 生态做嵌入、多语言语料 |
| Voyage rerank | 托管 API(文档) | 英文场景成熟,多语言持续补强 | 百毫秒量级 | 英文语料为主、追求省心 |
💰 费用说明:以上托管方案都是按量计费(按调用次数/Token 结算),具体单价各家会调整,以官网为准,并用自己的真实 QPS 实测一次单 Query 成本——重排的账单是“候选数 × 请求数”,会被悄悄放大。
2)成本算术:候选数是调优旋钮,不是越大越好
重排的代价几乎线性于候选数:粗排给你 200 条,重排就要算 200 次交叉编码;只给 20 条,就只算 20 次。所以:
- 重排只对粗排 Top-20~50 做,别把整个库丢给它;
- 候选数往上加,收益通常是“先陡后平”:从 10→30 常常有肉眼可见的涨点,从 100→200 往往只多捞回极少数正确文档,却让延迟和费用翻倍;
- 候选数要和 Recall@K 曲线一起看:如果正确文档在 Top-30 里的召回率已经是 95%,把候选加到 200 就是在给已经能捞到的东西多付 6 倍钱。
3)领域微调:难负例才是涨点关键
通用重排模型在“公司内部术语、产品型号、行业黑话”上常常判不准,这时自己微调一轮收益最大。数据构造比训练技巧更重要,核心就两类样本:
- 正例:真正含答案的块(人工标注或从高质量问答日志里挖);
- 难负例:被粗排捞上来、看起来很像但不含答案的块——这才是涨点的关键。如果用随机文档当负例,模型学到的只是“区分相关和完全不相关”,而你线上真正的麻烦恰恰是“区分相关和很像相关”。
配套脚本:
code/s05_hybrid_retrieval.py的build_hard_negatives(),只做难负例数据准备,训练不在脚本里跑。
训练入口两条路,都只几行代码:
| 路线 | 入口 | 特点 |
|---|---|---|
| sentence-transformers | CrossEncoder | 生态成熟、API 简洁,适合快速验证 |
| FlagEmbedding | reranker 训练脚本 | BGE 原厂脚本,超参与数据格式与 BGE 系列对齐 |
微调之后别急着上线:一定要跑一遍回归集(确认没把原来判对的搞坏),再做线上 A/B——这正是 11.9 讲的评测流程,微调不是“训完就赢”。
4)蒸馏:把延迟砍回去
微调提升精度的同时往往也抬高了延迟(尤其换到 large)。如果延迟超预算,可以用蒸馏:
- 让大重排模型或 LLM 给大量“Query-文档”对打软标签(相关性分数);
- 用这些标签训一个小模型(更小的 base,甚至换成更轻的结构);
- 小模型学的是“老师的判断”,推理成本却降回原来的量级。
这条路的本质是“用离线算力换线上延迟”,适合查询分布稳定、样本量大的场景。
5)代码骨架:构造难负例 + 微调 + 推理
python
from sentence_transformers import CrossEncoder, InputExample
from torch.utils.data import DataLoader
# 1. 构造训练数据:正例 + 难负例(难负例 = 被粗排捞上来但不含答案的块)
samples = []
for item in labeled_queries: # 每条带人工标注的 Query
for pos in item["gold_chunks"]: # 正例:真正含答案的块,标签 1
samples.append(InputExample(texts=[item["query"], pos], label=1.0))
for neg in item["hard_negatives"]: # 难负例:粗排 Top-K 里不含答案的块,标签 0
samples.append(InputExample(texts=[item["query"], neg], label=0.0))
# 2. 在预训练重排模型上小步微调:学习率要小、epoch 要少,防止过拟合小数据集
model = CrossEncoder("BAAI/bge-reranker-base", num_labels=1)
loader = DataLoader(samples, shuffle=True, batch_size=16)
model.fit(train_dataloader=loader, epochs=2, warmup_steps=100, lr=2e-5)
# 3. 推理接口与微调前完全一致:可以直接原地替换,无需改动上层流水线
scores = model.predict([[query, d.page_content] for d in candidates])拓展:上下文压缩(Context Compression)—— 防止“上下文污染”
检索回的 Top-K 里常有“一半有用一半废话”,直接全塞给 LLM 会稀释注意力。可以在生成前再加一道压缩/筛选:
python
from langchain_classic.retrievers.contextual_compression import ContextualCompressionRetriever
from langchain_classic.retrievers.document_compressors import LLMChainExtractor
from langchain_openai import ChatOpenAI
# 用 LLM 把每篇文档里“与问题相关”的片段单独抽出来,剔除无关废话
compressor = LLMChainExtractor.from_llm(ChatOpenAI(model="gpt-4o-mini", temperature=0))
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=ensemble, # 复用上面的混合检索器
)
# 生产建议:先用便宜的启发式(截断/去重/按元数据筛)再上 LLM 压缩,控制成本一句话总结本节:检索不准不全,就用「混合多路召回 → RRF 融合 → 重排 → 上下文压缩」这条实际项目中的流水线,把“粗(廉价海量)”和“精(昂贵精准)”组合成最优性价比。
进阶:上下文编排 —— 顺序、位置偏置与 Token 预算
生活化比喻:同样的化验单,夹在病历首页和夹在中间,值班医生看到的重点不一样。检索回来的文档就是这些化验单,塞进 Prompt 的顺序和位置,决定了模型这个“值班医生”能不能先看到最关键的那一页。
位置偏置:模型不是均匀地读上下文
研究(Lost in the Middle, arXiv:2307.03172)发现一个稳定的现象:模型对上下文的开头和结尾更敏感,中间部分容易被“看漏”。这意味着——把最相关的证据埋在第 7、8 块的位置,等于让它自生自灭。
| 摆放位置 | 模型注意力 | 该放什么 |
|---|---|---|
| 开头 | 高 | 最相关、能直接回答问题的 1~2 块 |
| 中间 | 低(最易被忽略) | 次要佐证、背景资料 |
| 结尾 | 高 | 必须遵守的条款/结论(紧贴“现在开始回答”) |
推荐布局四条
- 最相关的放开头或结尾,次要的放中间——别把 Top-1 埋在中间当“压轴菜”;
- 同一文档的相邻块尽量连续放:一份文档被切碎后打散,模型得自己“跨页拼图”,很容易拼错;
- 每块用结构化标签包裹,例如
<doc id="3" source="设备手册.pdf#p12">...</doc>,方便模型引用,也方便事后校验出处; - 去重后按来源分组,同一来源的多块可以合并成一段,省下标签开销留给正文。
Token 预算是“背包问题”
上下文窗口固定,就是背包容量固定:每块文档都有“重量(Token 数)”和“价值(相关性 × 信息密度)”,要在不超重的前提下装进最高价值的东西。两条工程铁律:
- 宁可给 3 块完整的,也别塞 10 块被截断的——被截断的块常常丢掉结论句,反而误导模型;
- 预算分配优先级:直接答案 > 计算依据 > 例外条款 > 背景说明。
错误示范 vs 推荐示范
text
# ❌ 错误示范:Top-1 埋在中间、无来源标签、末尾还塞了被截断的块
[块1] 售后退换货政策……(无关,却抢了开头的好位置)
[块2] 设备型号参数对比……(无关)
[块3] ✅ RX-9000 报 ERR-404-X9 的正确处理步骤(Top-1 被埋在第 3 位)
[块4] 散热结构设计说明……
[块5] 历史维修记录……(被截断:“切断电源后应当……” → 后半句消失)text
# ✅ 推荐示范:Top-1 置顶 + 相邻块连续 + 标签包裹 + 结论条款放末尾
<doc id="1" source="RX-9000设备手册.pdf#p12">
RX-9000 在高温 80 度运行时报 ERR-404-X9,请立即切断电源冷却 30 分钟。
</doc>
<doc id="2" source="RX-9000设备手册.pdf#p13">
冷却后检查散热风扇转速,低于 1200 RPM 需更换风扇模块。(同文档相邻块,紧跟着放)
</doc>
<doc id="3" source="安全规范.pdf#p3">
高温告警未处理即重启,可能造成主板永久损坏。(必须遵守的条款,放结尾)
</doc>配套脚本:
code/s05_hybrid_retrieval.py的order_contexts()。
一条 Badcase 怎样穿过整条检索线?
问题:“RX-9000 报 ERR-404-X9,高温环境怎么处理?”可以把每一层的职责拆开看:
| 阶段 | 应保留什么 | 常见失败 | 该看哪个指标 |
|---|---|---|---|
| Dense 召回 | “高温告警、断电冷却”等同义描述 | 型号与错误码被语义平滑掉 | Recall@K |
| BM25/Sparse 召回 | RX-9000、ERR-404-X9 精确命中 | 不懂“过热”和“高温”同义 | Recall@K |
| RRF 融合 | 两路都靠前的证据优先 | 某一路候选过多霸占名次 | 各路贡献、Hit Rate |
| Cross-Encoder 重排 | 同时满足型号、错误码和高温条件 | 候选阶段已漏掉正确文档,重排无米下锅 | MRR、nDCG |
| 去重与上下文装箱 | 保留处理步骤与安全警告 | 三个近重复 Chunk 挤掉不同证据 | 重复率、答案完整性 |
重排只能重新排列已经召回的候选,不能把漏掉的文档变出来。因此优化顺序应是先保证候选集 Recall,再优化名次,最后控制上下文噪声。
RRF 也不是万能公式
k越大,前几名之间差距越小;越小则更偏爱榜首。它必须用评测集调,不能把 60 当宇宙常数;- 不同召回器返回数量悬殊时,要限制每路候选数并记录来源,避免一条路“人海战术”;
- RRF 不利用原始置信度。如果各路分数经过可靠校准,学习排序或加权融合可能更好;
- 同一文档被切成多个 Chunk 时,应先按父文档或内容哈希去重,再给生成层。
Top-K 应服从证据预算
固定 Top-K 像规定“无论问题难易都带五本书进考场”。事实型问题可能一条权威证据就够;对比题需要覆盖两方;多跳题需要多类证据。可用以下信号动态调整:
- 重排分数出现明显断崖时在断点截断;
- 证据已覆盖问题的全部子问题时停止;
- 达到上下文 Token、延迟或费用预算时停止;
- 候选相关性全低时拒答,不用垃圾填满窗口。
MMR 处理“都很相关但都一样”
最大边际相关性(MMR)同时奖励与问题相关、惩罚候选之间过于相似。它适合总结、比较、多个原因等需要多样证据的任务;精确条款查询则不一定需要。s05_hybrid_retrieval.py 的双路召回、重排与 MMR 全部跑在 testdata/ 真实制度语料上:BM25 + Chroma 召回「RX-9000 报 ERR-404-X9」会命中真实设备手册的四页,MMR 面对差旅问题会同时挑出住宿标准页和报销时限页,而不是同一件事说三遍。
权威官方参考
- LangChain EnsembleRetriever / RRF 官方文档
- BAAI FlagEmbedding(BGE & BGE-Reranker)
- BGE-Reranker-v2-m3 模型卡(Hugging Face)
- Sentence-Transformers(CrossEncoder 训练与推理)
- Cohere Rerank 官方页
- Jina Reranker 官方页
- Voyage AI Rerank API 文档
- Rank-BM25 算法实现
- Sentence-Transformers Cross-Encoder 指南
- Lost in the Middle: How Language Models Use Long Contexts(arXiv:2307.03172)