🐸 青蛙编程网
首页 / AI / 检索增强 RAG / 6. 检索优化

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 动手改

  1. 同一组问题,分别用「纯向量」「纯 BM25」「RRF 混合」跑一遍,看 Top-5 有什么不同
  2. 把 rrf 的 k 从 60 改成 10,观察两路权重如何变化
  3. 给你的检索加一道路径:对元数据做过滤(如只查某个来源),再和向量结果融合

6.10 小结

  • 向量检索懂「意思」不懂「字面」,关键词检索正好互补
  • RRF 只看名次不看分数,是融合多路检索最省事的办法
  • 检索优化的顺序:混合检索 → 查询改写 → 重排,逐级加码
  • 召回宁多勿漏(20 条),送进模型宁少勿多(3–5 条)
  • 框架是最后的加速器,不是起点

下一章:评估与常见问题,怎么知道它答得对不对 🐸

📥 下载本章代码

ZIP 内含本章全部代码文件与说明,解压即用,与教程内容同步生成。