7.4 混合检索与重排
一句话结论:只用向量检索,等于放弃了"精确匹配"这个基本能力——搜错误码、搜人名、搜配置项都会失效。而重排是整条 RAG 链路里性价比最高的一步:加一个模型调用,就能把"能用"变成"好用"。
读这篇之前
需要先读 7.2(四个阶段)、7.3(切片)。这一篇解决"召回不准"的问题。
一、向量检索的三个结构性弱点
向量检索很强大,但它有三件事天生做不好:
| 弱点 | 例子 | 原因 |
|---|---|---|
| 精确匹配差 | 搜 ERR_4032 |
这类字符串在训练数据里罕见,向量表达不精确 |
| 不理解否定/限定 | "不是 Java 的方案" | 向量把语义压成一个点,"不是"这个限定容易丢失 |
| 泛泛相关 | 问"怎么配缓存"召回"缓存是什么" | 语义相近,但回答不了问题 |
第一条最致命,因为技术文档里充满了错误码、函数名、配置项——全是"精确匹配"的场景。
二、混合检索:向量 + 关键词
两条路各有所长
| 向量检索(Dense) | 关键词检索(BM25 / Sparse) | |
|---|---|---|
| 强项 | 同义改写、跨语言、语义相似 | 精确术语、错误码、人名、编号 |
| 弱项 | 精确匹配差 | 同义改写的找不到 |
| 机制 | 向量距离 | 词频统计 |
做法:两路各召回一批(比如各 20 条),合并去重后统一重排。
问题 ──┬─→ 向量检索 → 20 条 ─┐
└─→ 关键词检索 → 20 条 ─┴─→ 合并去重 → 重排 → Top 5
这就是生产级 RAG 的标配。 只用向量,会遇到"明明库里就有这个错误码,却搜不出来"的困境。
合并后的一个细节:分数不可直接比
向量相似度(0~1)和 BM25 分数(无上界)量纲不同,不能直接加权相加。两种处理:
| 做法 | 说明 |
|---|---|
| 归一化后加权 | 各转成 0~1 再按权重合并 |
| 只做去重,交给重排排序 | 更简单可靠,推荐 |
第二种更稳——因为重排器会重新打分,不需要你在合并阶段调权重。
三、查询改写:提升召回的上游手段
用户提问往往模糊或口语化,直接检索效果差。先用 LLM 改写查询,能显著提升召回。[原文 723]
改写的三种策略
| 策略 | 做法 | 收益 |
|---|---|---|
| 提取关键词 | 把口语问题转成检索式 | 去噪 |
| 补充同义词 | 加入可能的替代表述 | 提高召回率 |
| 生成多角度查询 | 一次生成 3 个不同角度的查询,分别检索后合并 | 召回率大幅提升 |
一个具体例子:[原文 723]
原始问题:"Agent 怎么记住东西的?"
改写后:["Agent 记忆系统架构",
"Agent 短期记忆 长期记忆",
"LLM memory management"]
多查询并行检索,结果合并去重 → 召回率大幅提升。 这是最实用的技巧之一。
HyDE:用一个"假答案"去检索
有时候问题太短,向量化后语义信息不足。HyDE 的思路很巧妙:[原文 723]
① 用户问题:"RAG 怎么做?"
② LLM 先生成一个"假回答":
"RAG 是检索增强生成技术,通过先检索相关文档再生成回答……"
③ 用这个"假回答"去向量检索
(假回答比短问题语义丰富得多)
④ 用检索到的真材料生成真答案
为什么有效? 因为"假回答"的措辞更接近真实文档的措辞,向量也就更接近。
代价与风险:
| 风险 | 说明 |
|---|---|
| 多一次模型调用 | 延迟与成本增加 |
| 假回答可能跑偏 | 如果模型对问题理解错,检索就会偏 |
| 不适合精确查询 | 搜错误码时用 HyDE 反而更差 |
适用场景:概念性、开放式问题。不适用:精确匹配类查询。
四、重排:性价比最高的一步
为什么召回后还要重排
| 召回 | 重排 | |
|---|---|---|
| 目标 | 快,从百万里捞 20 条 | 准,在 20 条里排好序 |
| 机制 | 向量相似度(廉价近似) | 交叉编码器(昂贵精确) |
| 范围 | 全库 | 只有候选集 |
核心逻辑:先用便宜的方法缩小范围,再用贵的方法精确排序。
为什么重排更准
向量检索是分别把问题和文档变成向量,再算距离——两者从未"见面"。
重排器把问题和文档一起送进模型,判断"这段能不能回答这个问题"。
打个比方:
- 向量检索像凭标签匹配(两人都标了"爱读书",就觉得合得来)
- 重排像真聊一次(才知道能不能处)
重排的实测收益
在生产环境里,重排往往能把准确率提升一个明显档次。原因很直接:"语义相似"不等于"能回答问题"。
例:问"如何解决连接超时"
| 文档 | 向量相似度 | 能回答吗 |
|---|---|---|
| 《连接超时的原因分析》 | 高 | ✅ |
| 《超时参数配置指南》 | 高 | ✅ |
| 《为什么网络会慢》 | 中高 | ❌(泛泛而谈) |
| 《连接池原理》 | 中 | 部分 |
重排器能更好地区分前三者。
五、完整链路与配置建议
查询
├─ 改写(提取关键词 / 多角度)
├─ 向量检索 → 20 条
├─ 关键词检索 → 20 条
├─ 合并去重 → 约 30 条
├─ 重排 → Top 5
├─ 拼装(加来源标记,防注入)
└─ 生成(要求引用来源)
参数建议(起点值):
| 参数 | 建议 | 说明 |
|---|---|---|
| 向量召回数 | 20 | 太少会漏 |
| 关键词召回数 | 20 | 与向量对齐 |
| 重排后保留 | 5~8 | 太多会稀释注意力(2.2) |
| 是否改写 | 概念性问题开启 | 精确查询关闭 |
六、常见误区
误区一:向量检索足够先进,不需要关键词。
精确匹配场景(错误码、函数名)会失效。混合检索是标配。
误区二:两种分数可以直接相加。
量纲不同。归一化或交给重排。
误区三:查询改写总是有益。
它会引入偏差,且增加延迟。精确查询不要改写。
误区四:HyDE 适合所有场景。
搜错误码时会变差。只用于概念性、开放式问题。
误区五:重排太贵,能省就省。
它是性价比最高的一步——只对 30 条候选做,不是全库。
误区六:召回数越多越好。
太多会让重排压力变大、上下文变长。20~30 条足够。
误区七:检索准了,答案就一定对。
还要看模型是否读懂、是否会混用材料(7.5)。
七、动手:token 泄漏诊断表
7.4 讲了三个弱点。这里给出一张"症状 → 原因"的诊断表。
| 症状 | 可能原因 | 验证方法 |
|---|---|---|
| 搜错误码/函数名搜不到 | 纯向量检索 | 换成关键词搜同一个词,能搜到就确认 |
| 召回的都"大概相关"但没用 | 缺重排 | 看 top-5 与问题的实际相关性 |
| 否定查询失效("不是 X") | 向量不理解否定 | 改写查询为肯定表述 |
| 同义改写搜不到 | 纯关键词检索 | 换同义词再搜 |
| 中文搜不到英文内容 | 单语言 embedding | 检查模型是否多语言 |
| 长尾问题完全召回空 | 切片太大或太小 | 看那块的切片结果 |
一个完整的验证脚本
TEST_CASES = [
("getUserById 定义在哪", "精确匹配"),
("怎么配置缓存", "语义检索"),
("不是用 Redis 的方案", "否定"),
("缓存配置", "同义"),
]
def diagnose():
for q, kind in TEST_CASES:
vec = retrieve_vector(q, k=5)
kw = retrieve_keyword(q, k=5)
merged = rerank(q, dedupe(vec + kw), top_n=5)
print(f"{kind:6} | 向量 {len(vec)} | 关键词 {len(kw)} | 合并后 {len(merged)}")
print(f" 首条: {merged[0]['title'] if merged else '空'}")
四类查询各测一遍,就能看出你的 RAG 缺哪一环。
一个常见的误判
现象:搜不到,以为是 embedding 模型不行
真相:切片时把那个函数名切成了两半
验证:搜切片后的原文,看它是否完整
先查切片,再查模型 —— 顺序反了会白换模型。
八、动手:重排模型的三种选择
7.4 讲了重排的重要性。这里给出怎么选。
三种实现
| 方案 | 说明 | 延迟 | 成本 |
|---|---|---|---|
| 交叉编码器(本地) | 小模型(如 bge-reranker) | 50-200ms(GPU) | 一次性部署 |
| LLM 打分 | 让大模型对每段打分 | 1-3 秒 | 每次调用计费 |
| 规则重排 | 关键词命中数、位置权重 | <1ms | 免费 |
各自的适用面
| 场景 | 建议 |
|---|---|
| 追求质量、有 GPU | 交叉编码器 |
| 候选少(<10)、质量要求极高 | LLM 打分 |
| 延迟敏感、成本敏感 | 规则重排 |
| 起步阶段 | 规则重排(先跑通) |
一个规则重排的例子
def rule_rerank(q, candidates):
kw = set(extract_keywords(q))
for c in candidates:
score = 0
# ① 关键词命中
score += 2.0 * len(kw & set(extract_keywords(c["text"])))
# ② 标题命中(更强信号)
if any(k in c["title"] for k in kw):
score += 3.0
# ③ 位置靠前(文档开头更可能是概述)
score += 0.5 * (1.0 - c["position"] / c["total"])
c["score"] = score
return sorted(candidates, key=lambda x: -x["score"])
规则重排在很多场景能拿到交叉编码器 70-80% 的效果,而成本几乎为零。
一个建议的演进路径
起步:规则重排(0 成本,先跑通)
↓ 效果不够
第二:交叉编码器(加 GPU 或云服务)
↓ 还想要更好
第三:LLM 打分(只在候选 <10 时用)
九、动手:重排的收益量化
7.4 讲重排是"性价比最高的一步"。能定量说明吗?
一个可做的对比实验
def compare(k=20, top_n=5):
stats = {"with_rerank": 0, "without_rerank": 0}
for q, expect in TEST_CASES:
cands = retrieve_hybrid(q, k=k)
# 不重排:取前 5
if hits_expect(cands[:top_n], expect):
stats["without_rerank"] += 1
# 重排后取前 5
if hits_expect(rerank(q, cands, top_n=top_n), expect):
stats["with_rerank"] += 1
return stats
一个典型结果(50 个问题)
| 方案 | 命中数 | 命中率 |
|---|---|---|
| 不重排(向量 top-5) | 31 | 62% |
| 不重排(混合 top-5) | 36 | 72% |
| 重排后 top-5 | 44 | 88% |
两个收益叠加:混合 +12%,重排再 +16%。
成本对照
| 项 | 每次额外成本 |
|---|---|
| 混合检索 | +1 次 BM25(<5ms,免费) |
| 重排 20 条 | 50-200ms(GPU)/ 或 API 计费 |
用 200ms 换 16 个百分点的命中率 —— 这是 RAG 里最划算的一笔交易。
一个反例
错:只做向量检索,直接取 top-5
后果:62% 命中率,用户觉得"经常答不到点子上"
心态:以为模型不行,去换模型
换模型救不了检索问题 —— 这是 13.1 讲的"评的是什么"在 RAG 层的重复。
一个补充:重排失败时的兜底
重排模型可能不可用(服务挂了、超时)。要有兜底。
def safe_rerank(q, cands, top_n=5):
try:
return rerank(q, cands, top_n=top_n)
except Exception as e:
log.warn(f"重排失败,降级为原文顺序: {e}")
return cands[:top_n] # 降级:用召回顺序
三种降级策略
| 降级 | 效果 |
|---|---|
| 用召回顺序(推荐) | 还能用,质量降一档 |
| 用规则重排 | 更好,但需实现 |
| 直接报错 | 不可取(用户看到失败) |
"降级但不失败"是生产系统的必备能力。
一个真实场景
事故:重排服务 OOM,整个 RAG 全部报错
用户体验:完全不可用(比"稍微不准"差得多)
修复:加重排的 try/except + 降级
这件事的教训:任何"增强"环节,都要能优雅退化。
十、动手:HyDE 与多查询改写的实际效果
7.4 提到 HyDE(先造假答案再检索)和查询改写。这里给出边界。
HyDE 的三个前提
| 前提 | 说明 |
|---|---|
| 问题模糊或口语化 | "这东西老卡怎么办" |
| 文档是正式书面语 | 手册、规范 |
| 允许一次额外生成 | 多一轮模型调用 |
三条都满足才有收益。
HyDE 的实现与陷阱
def hyde_retrieve(question):
# ① 让模型先"编"一个假答案
fake = llm.chat(f"写一段能回答这个问题的文档片段:{question}",
temperature=0.3, max_tokens=300)
# ② 用假答案去检索
return store.search(embed(fake), top_k=20)
陷阱:
| 陷阱 | 后果 |
|---|---|
| 假答案里混入错误事实 | 检索到错误方向的文档 |
| 精确查询用 HyDE | 错误码被"改写"成别的词,搜不到 |
| 多一轮模型调用 | 延迟 +300ms~1s |
最后一条是关键边界:HyDE 不适合精确查询(错误码、函数名、版本号)。
一个判断表
| 查询类型 | HyDE |
|---|---|
| 口语化问题 | ✅ 有效 |
| 概念解释 | ✅ 有效 |
| 精确匹配(ID/错误码) | ❌ 禁用 |
| 短关键词 | ❌ 无意义 |
多查询改写(比 HyDE 更稳)
def multi_query_retrieve(question, n=3):
variants = llm.chat(f"把这个问题的{n}种不同问法列出来:{question}",
temperature=0.5).split("\n")
hits = []
for v in [question] + variants:
hits.extend(store.search(embed(v), top_k=10))
return dedupe(hits)
它的好处是"不做假答案" —— 只改问法,不引入编造内容。
所以在"怕污染"的场景,多查询改写比 HyDE 更安全。
十一、下一篇
到这里,RAG 的工程主体讲完了:切片、嵌入、召回、重排、拼装。
但真实系统里,RAG 出错的方式远比"检索不准"多。下一篇专门讲失败模式与诊断——包括那个最危险的:它不会说不知道,而是会把错误的材料编成正确答案。
一个补充:top_k 怎么定
| top_k | 效果 | 风险 |
|---|---|---|
| 3 | 精准,上下文短 | 可能漏掉答案 |
| 5-8 | 平衡 | —— |
| 20+ | 召回全 | 上下文长,注意力稀释 |
推荐做法:召回 20-30 条,重排后取 5-8 条。
召回要多(20-30)→ 保证不漏
重排要准 → 把对的排前面
送入要少(5-8)→ 保证上下文干净
这个"漏斗"结构是 RAG 的核心范式 —— 每一层解决不同问题。