RAG 混合检索实战:向量 + BM25 怎么用 RRF 融合

91 阅读9分钟

检索优化这条链写到第几篇了:第 2 篇讲分块、第 19 篇讲重排序、第 25 篇讲 pgvector 多租户性能。这条链上一直缺一环——召回端。

缺口的由来是一个真实故障:客户的知识库里明明有「XLC-300 数控机床质保期 24 个月」这条文档,用户问「XLC-300 的质保多久」,检索就是捞不出来,top10 里全是 XLC-500 和通用质保政策。

这不是 embedding 模型不行,是纯向量检索的天然盲区。这篇把混合检索(Hybrid Search)补上:先复现问题,再看 BM25 为什么能补位,重点讲两路结果怎么用 RRF 融合——这是最容易做错的一步。测试数据来自我自己的交付项目环境,量级和硬件会标注,结论可以复现。


一、先复现问题:精确术语是向量检索的天敌

测试环境:

PostgreSQL 16 + pgvector 0.8
chunks 表 120 万行,embedding bge-m3 1024 维,cosine 距离
中文设备文档知识库:型号、错误码、参数表密集
HNSW 索引:m = 16,ef_construction = 64,ef_search = 100

用户查询:XLC-300 质保期多久

SELECT id, content, 1 - (embedding <=> $1::vector) AS score
FROM chunks
ORDER BY embedding <=> $1::vector
LIMIT 10;

结果:top10 里没有那条文档。排前面的全是「XLC-500 质保政策」「数控设备通用质保条款」。

原因不神秘:embedding 是语义压缩,「XLC-300」和「XLC-500」在语义空间里几乎重合——模型知道它们都是机床型号,但区分不了这两个 token。对 embedding 来说,300 和 500 是噪音;对你的客户来说,这是两个产品线,答错了就是事故。

同理中招的查询类型:

查询类型例子纯向量表现
产品型号XLC-300 / A7-Pro召回崩,近义型号挤占 top
错误码E-4021 / 0x8F基本捞不到
法条 / 条款号第 12.3 条 / GB-4943捞到主题相近的其他条款
人名 / 术语缩写FDE / RRF同上

规律:query 里包含必须精确匹配的 token 时,向量检索不可靠。 而企业知识库(设备、法务、财务)恰恰全是这类 token。


二、BM25 补位:词法匹配正好是向量的反面

BM25(词法检索)不压缩语义,就是精确的词项匹配加权重打分:query 里的「XLC-300」作为一个完整 token,文档里有就加分,没有就是零。它不懂「机床」和「CNC」是一回事——这正好和向量互补:

向量检索BM25
强项口语改写、同义表达、跨语言精确术语、型号、错误码、ID
弱项精确 token 被平滑掉说不出「电脑」和「计算机」是一回事

PostgreSQL 里做 BM25 不需要额外装向量库,内置的全文检索就够用。中文需要装 zhparser 分词扩展:

-- 给 chunks 加一个生成列:中文分词后的 tsvector
ALTER TABLE chunks ADD COLUMN tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('chinese_zh', content)) STORED;

-- GIN 索引:词法检索的性能保证
CREATE INDEX chunks_tsv_idx ON chunks USING GIN (tsv);

用生成列(GENERATED ALWAYS ... STORED)而不是触发器维护,插入和更新自动跟上,不用写同步逻辑。BM25 路的查询:

SELECT id, content, ts_rank(tsv, query) AS lex_score
FROM chunks, plainto_tsquery('chinese_zh', 'XLC-300 质保期') query
WHERE tsv @@ query
ORDER BY ts_rank(tsv, query) DESC
LIMIT 50;

这条查询把目标文档排到了第 1 位。但直接切到 BM25 不行——口语化查询「机器老是自动重启」BM25 就抓瞎了(文档里写的是「异常重启」「宕机」)。


三、两路结果怎么融合:别用线性加权,用 RRF

现在手里有两份 top50:向量路按 cosine 相似度排序,BM25 路按 ts_rank 排序。融合的第一反应是线性加权:

final_score = α × vec_score + (1-α) × lex_score

别这么干。 两个分数不可比:cosine 相似度在 [0,1] 有界,ts_rank 无界且分布跟语料强相关。想加权就得先归一化,归一化就要定阈值,阈值在不同 query 上根本不稳,α 调到怀疑人生。这是我踩过的坑,别再踩。

正解是 RRF(Reciprocal Rank Fusion,倒数排名融合):不看分数,只看排名。

RRF_score(doc) = Σ  1 / (k + rank_i)

每个文档在每路结果里的排名取倒数求和,k 通常取 60。直觉:一个文档在两路结果里都排前面,得分就高;只在一路排前面,也能保住一个基本盘。

RRF 的好处恰好对着线性加权的痛点:

  1. 不需要归一化——只消费排名,分数尺度无关
  2. 没有超参敏感问题——k=60 是论文验证过的鲁棒默认值,对排序不敏感
  3. 天然容忍某一路缺席——文档只被一路召回时也有得分,不会被加权公式里的零分拖死

代价是丢了分数信息:RRF 不知道「第 1 名比第 2 名好多少」。对这个场景无所谓,后面还有重排序(第 19 篇)兜底精排。

掘金-26-02.png


四、落地代码:双路查询 + 30 行融合逻辑

架构很简单:两路各取 top50,Python 侧 RRF 融合,取 top10 送重排序。

import psycopg

K = 60          # RRF 常数
CAND = 50       # 每路候选数
TOP_N = 10

def hybrid_search(conn, query_text: str, query_vec: list[float],
                  tenant_id: str) -> list[dict]:
    with conn.cursor() as cur:
        # 路 1:向量召回
        cur.execute("""
            SELECT id, content,
                   1 - (embedding <=> %s::vector) AS score
            FROM chunks
            WHERE tenant_id = %s
            ORDER BY embedding <=> %s::vector
            LIMIT %s
        """, (query_vec, tenant_id, query_vec, CAND))
        vec_hits = cur.fetchall()

        # 路 2:BM25 词法召回
        cur.execute("""
            SELECT id, content, ts_rank(tsv, query) AS score
            FROM chunks,
                 plainto_tsquery('chinese_zh', %s) query
            WHERE tsv @@ query
              AND tenant_id = %s
            ORDER BY score DESC
            LIMIT %s
        """, (query_text, tenant_id, CAND))
        lex_hits = cur.fetchall()

    # RRF 融合:只看排名,不看分数
    scores: dict[str, float] = {}
    for rank, (doc_id, _, _) in enumerate(vec_hits, start=1):
        scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (K + rank)
    for rank, (doc_id, _, _) in enumerate(lex_hits, start=1):
        scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (K + rank)

    top_ids = [doc_id for doc_id, _ in
               sorted(scores.items(), key=lambda x: -x[1])[:TOP_N]]

    # 取回正文(省略 content 缓存)
    contents = {doc_id: content
                for doc_id, content, _ in vec_hits + lex_hits}
    return [{"id": i, "content": contents[i]} for i in top_ids]

三个工程细节:

  1. 两路查询可以并行。asyncpg 两个连接各跑一路,P95 延迟基本等于慢的那一路,实测整体只比纯向量多 6~10ms
  2. 候选数别抠门。每路 50 起,融合后剩 top10 给 reranker。候选给太少,融合就变成了在残缺集合里排序
  3. tenant_id 两路都要带(第 25 篇的结论直接复用):BM25 路是精确的 b-tree 过滤,几乎零开销;向量路记得开 iterative_scan,别让长尾租户的 post-filter 把召回拖崩

五、效果:三条曲线各管一段

同一批 300 条真实 query(来自客户工单脱敏),分三类测 recall@10,ground truth 用暴力扫描 + 人工标注:

查询类型纯向量纯 BM25RRF 融合
精确术语(型号/错误码)0.420.880.91
口语改写("老重启""印不出来")0.860.310.89
混合(型号 + 现象描述)0.550.620.90

三条结论:

  • 精确术语类,纯向量的 0.42 是事故水平,RRF 拉回 0.91——这是上混合检索的核心收益
  • 口语改写类,RRF(0.89)和纯向量(0.86)基本持平——混合检索没有伤害向量的强项,这正是 RRF 比「切到 BM25」高明的地方
  • 混合类 query(真实工单里最多)收益最大:两路各自捞回一半,融合后凑齐

延迟代价:双路并行后 P95 从 22ms 到 29ms(120 万行、16C32G 云主机),对 RAG 链路来说可以忽略——后面 reranker 一次推理的时间都比这个多。


六、什么时候别上混合检索

说反方向,三种情况可以不折腾:

  1. 语料里没有精确术语。全是口语化的客服记录、经验贴,BM25 无词可精匹配,白维护一套索引
  2. 数据量小(十万行以内)。暴力扫描都够快的话,先把 reranker 调好,收益更直接
  3. 没有中文分词条件。zhparser 装不上(比如托管数据库不给装扩展)的话,BM25 路效果会打折,二元分词凑合能用但别期待上面的数字

混合检索不是免费午餐:多一路索引就多一份写入放大和维护成本。它解决的是「精确术语召回」这个具体问题,问题不存在就别引入解法。


七、一张决策表 + 三条踩坑预警

你的 query 特征建议
型号、错误码、条款号占比高必上,RRF 混合检索
口语化描述为主,无精确 token纯向量 + reranker 就够
两类都有(多数企业知识库)上,且重点测混合类 query
代码搜索另说,走符号索引 + 向量,本篇不展开

踩坑预警:

  1. k=60 不用调,候选数才要调。RRF 对 k 极不敏感(30~100 结果差异可忽略),但每路候选从 10 提到 50,融合质量立竿见影
  2. 中文分词器决定 BM25 上限。zhparser 对新品名、自造词不认识,误拆之后 token 就错了。领域词汇用自定义词典补,上线的分词结果抽样人工看过再信
  3. 生成列 + GIN 有写入成本。高频更新的表实测写入延迟涨 15% 左右,知识库场景(批量导入、低频更新)无感,交易类表别套用

总结

纯向量检索的盲区一句话:embedding 压缩语义的同时,也抹掉了必须精确匹配的 token——型号、错误码、条款号在企业知识库里全是高危 query。

补法三步:

  1. BM25 补位:PostgreSQL 内置全文检索 + zhparser,一个生成列一个 GIN 索引,不用引入新组件
  2. RRF 融合:只看排名不看分数,k=60,两路各 50 个候选,30 行代码
  3. reranker 兜底:融合后的 top10 送精排(第 19 篇),整条检索链闭环:分块 → 混合召回 → RRF → 重排序

掘金-26-03.png

以及一条贯穿这条链的原则:召回率是可以测的,用 ground truth 分 query 类型测,别只看整体均值——均值会藏住那 0.42。


你们的知识库遇到过渡型号、错误码捞不出来的问题吗?上的混合检索还是换了专门的向量库?评论区交流。


原文首发于公众号「程语AI」