6. 检索优化:混合检索与重排
这是什么:混合检索与重排 = 一边用向量找语义相近的,一边用关键词找字面精确的,两路结果合并后再精排。
为什么用:上一章的纯向量检索天生抓不住两样东西:精确的关键词和专有的编号。搜「订单号 20240115」时它经常一头雾水,只能靠关键词检索救场。
如何用:本章先加一条 BM25 关键词路径,再用 RRF 融合两路结果,最后加上查询改写与重排。
6.1 纯向量检索的短板
| 问题 | 例子 | 向量检索的表现 |
|---|---|---|
| 关键词 | 「差旅」 | 语义相近的块可能被「出行」盖过去 |
| 编号 / 型号 | 「EMP-2024-017」 | 纯数字串在向量里几乎没有区分度 |
| 人名 | 「张三」 | 三个字,信息量太低 |
| 否定与限定 | 「不含税价」 | 语义模型可能只匹配到「含税价」 |
结论:向量检索擅长「意思」,不擅长「字面」。补一道路径就够。
6.2 加一道路径:BM25 关键词检索
BM25 是经典关键词检索算法:按词频和稀有度打分,罕见词权重更高。中文要先分词:
pip install rank-bm25 jieba
import jieba
from rank_bm25 import BM25Okapi
def tokenize(text):
"""中文按 jieba 切词,去掉空白和标点"""
return [w for w in jieba.lcut(text) if w.strip() and not w.isspace()]
# 建关键词索引
corpus_tokens = [tokenize(t) for t in documents]
bm25 = BM25Okapi(corpus_tokens)
# 查询
def keyword_search(query, top_k=3):
scores = bm25.get_scores(tokenize(query))
ranked = sorted(range(len(documents)), key=lambda i: scores[i], reverse=True)
return [(documents[i], scores[i]) for i in ranked[:top_k]]
小语料的坑:BM25Okapi 在文档很少时,常见词的 IDF 可能算成负值。文档少于几百篇时,用
BM25Plus更稳(from rank_bm25 import BM25Plus)。
6.3 融合两路结果:RRF
两路检索各给一个排序,怎么合并?RRF(Reciprocal Rank Fusion,倒数排名融合)不看绝对分数,只看名次——这正好绕开了两路分数不可比的问题:
RRF 分数(d) = Σ 1 / (k + rank(d)) k 通常取 60
def rrf(rankings, k=60):
"""rankings: 多路检索的排序列表,元素是文档 id"""
scores = {}
for rank_list in rankings:
for rank, doc_id in enumerate(rank_list, start=1):
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
用起来:
vector_ids = [i for i, _ in vector_search(query, top_k=10)] # 向量路
keyword_ids = [i for i, _ in keyword_search(query, top_k=10)] # 关键词路
fused = rrf([vector_ids, keyword_ids])[:5] # 融合后取前 5
| 场景 | 出现在哪一路 | 融合后 |
|---|---|---|
| 「差旅住宿上限」 | 两路都有 | 两路都靠前 → 融合后更靠前 |
| 「EMP-2024-017」 | 只有关键词路 | 关键词路给它好名次 → 融合后救回来 |
| 「年会在哪办」 | 只有向量路 | 同样能被救回来 |
混合检索通常能带来个位数到十几个百分点的准确率提升,是性价比最高的一招。
6.4 查询改写:把「人话」变成「查询词」
用户问「那出差住酒店最多能报多少啊」,但资料里写的是「住宿费报销上限」。改写就是让大模型把问题翻译成更像文档里的说法:
def rewrite_query(question, client, chat_model):
resp = client.chat.completions.create(
model=chat_model,
messages=[{"role": "user", "content":
"把下面的问题改写成 1~3 个适合检索的关键词,只输出改写结果,不要解释:\n"
+ question}],
)
return resp.choices[0].message.content.strip()
改写后的查询和原问题一起检索(两路都跑),再融合——这样即使改写跑偏,原问题那条路还在。
6.5 重排:先粗筛,再精排
向量数据库做的是粗筛(ANN 近似最近邻),快但不够准。重排(rerank)用更重的交叉编码器(cross-encoder)给候选文档逐对打分,准但慢——所以只对 Top-20 这种小候选集做:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") # 中文效果好
pairs = [(question, doc) for doc in candidate_docs]
scores = reranker.predict(pairs)
ranked = [doc for _, doc in sorted(zip(scores, candidate_docs), key=lambda x: x[0], reverse=True)]
| 阶段 | 用什么 | 数量级 | 作用 |
|---|---|---|---|
| 召回 | 向量 + BM25 | 10–50 条 | 宁多勿漏 |
| 重排 | cross-encoder | 10–30 条 | 精排,挤掉真正相关的 |
| 生成 | 取前 3–5 条 | 3–5 条 | 塞进提示词 |
重排模型要额外下载(几百 MB),本地没有 GPU 时会明显变慢。先做混合检索,效果不够再加重排,别一上来就全套。
6.6 top_k 怎么定
| top_k | 影响 |
|---|---|
| 太小(1–2) | 可能漏掉真正的答案段 |
| 太大(10+) | 塞进模型的噪声变多,答案反而被带偏,还烧钱 |
| 经验起点 | 召回 20 条 → 重排后取 3–5 条 → 送进模型 |
6.7 什么时候该上框架
手写到这一步,RAG 的每个零件你都亲手摸过了。真到了生产环境,可以换成框架省事:
| 框架 | 特点 |
|---|---|
| LangChain | 生态最全,组件多,学习曲线略陡 |
| LlamaIndex | 专注文档与检索,RAG 场景上手快 |
| 两者共通 | 都帮你做了切分器、向量库封装、检索器编排、评估工具链 |
但要记住:框架只是把这些零件打包,不会替你解决「切分不对」和「检索不准」这两个真问题。先手搓一遍再上框架,才知道框架帮你省了哪一步。
6.8 常见翻车现场
| 报错 / 现象 | 原因 | 修法 |
|---|---|---|
ZeroDivisionError |
BM25 语料里有空文档 | 过滤掉空文本再建索引 |
| jieba 切出大量单字 | 语料太短或含大量符号 | 过滤标点,文档太短就合并 |
| 融合后结果反而变差 | 两路检索质量差太多 | 先修好弱的那一路,或调 RRF 的 k |
| 重排特别慢 | 模型太大 + 无 GPU | 换成小模型,或减少候选数量 |
| 改写后的查询完全跑偏 | 模型太小或提示词太随意 | 同时保留原问题,两路都检索 |
6.9 动手改
- 同一组问题,分别用「纯向量」「纯 BM25」「RRF 混合」跑一遍,看 Top-5 有什么不同
- 把
rrf的k从 60 改成 10,观察两路权重如何变化 - 给你的检索加一道路径:对元数据做过滤(如只查某个来源),再和向量结果融合
6.10 小结
- 向量检索懂「意思」不懂「字面」,关键词检索正好互补
- RRF 只看名次不看分数,是融合多路检索最省事的办法
- 检索优化的顺序:混合检索 → 查询改写 → 重排,逐级加码
- 召回宁多勿漏(20 条),送进模型宁少勿多(3–5 条)
- 框架是最后的加速器,不是起点
下一章:评估与常见问题,怎么知道它答得对不对 🐸