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

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

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

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


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

测试环境:

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

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

sql 复制代码
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 分词扩展:

sql 复制代码
-- 给 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 路的查询:

sql 复制代码
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 排序。融合的第一反应是线性加权:

text 复制代码
final_score = α × vec_score + (1-α) × lex_score

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

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

text 复制代码
RRF_score(doc) = Σ  1 / (k + rank_i)

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

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

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

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


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

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

python 复制代码
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 用暴力扫描 + 人工标注:

查询类型 纯向量 纯 BM25 RRF 融合
精确术语(型号/错误码) 0.42 0.88 0.91
口语改写("老重启""印不出来") 0.86 0.31 0.89
混合(型号 + 现象描述) 0.55 0.62 0.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 → 重排序

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


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


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

相关推荐
程序员大狐狸5 小时前
Ultra X7 358H 笔记本电脑,本地三个大模型编程能力、速度、上下文全面对比与选型报告
llm·ai编程
IllegalCreed5 小时前
让文档不烂——Agent 时代的文档生命周期(四)
ai编程
qinglongcn5 小时前
Agent 可靠性的数学——为什么编程是第一个跑通的赛道
agent·ai编程
IllegalCreed5 小时前
Agent 不是失忆,是找不到入口——大型项目文档分层设计(一)
ai编程
IllegalCreed5 小时前
让每份文档都有唯一的位置——文档寻址设计(三)
ai编程
blackappleD5 小时前
Claude Max 每月送 100 美元 API 额度:哪些能用、哪些不能用
ai编程
柯南46686 小时前
【AI工程师精讲】10:微调方案选型:全参微调、LoRA、QLoRA 到底怎么选
人工智能·ai编程
changshuaihua0016 小时前
LangChain · LangGraph · LangSmith 全景解析:LLM应用开发完整生态
人工智能·大模型·ai编程
六维空间6 小时前
让 AI 必须覆盖国内厂商:我把「选题偏好」写成了硬规则
ai编程