← 返回资讯

混合检索:关键词 + 向量双路融合

2026-07-03

纯向量检索对口语化问题效果好,但遇到精确的产品型号、代码片段、人名时常常找不到;纯 BM25 关键词匹配对拼写一致的词精准,但对同义词、近义句毫无抵抗力。混合检索(Hybrid Search)把两路结果融合,是目前 RAG 生产环境的默认最佳实践。

我踩过这个坑:某次给一个硬件厂商做知识库问答,纯向量方案上线一周后被投诉”查不到 XR-2200S 这个型号的参数”。排查发现向量模型把”XR-2200S”编码成了一个泛化的”型号类文本”向量,跟”XR-2100”、“XR-3000”挤在语义空间里分不开,召回排到了 30 名开外。换成纯 BM25 又反过来——用户问”这台设备能不能连 WiFi”,文档里写的是”支持无线网络接入”,一个”WiFi”的字面词都没有,直接召回为空。两条路都单独试过之后我才理解,混合检索不是锦上添花的优化项,而是覆盖两类完全不同查询模式的刚需方案:一类是”人怎么问”(口语化、同义转述),一类是”文档怎么写”(专有名词、代码、型号)。

两种检索方式的盲区

维度向量检索(Dense)关键词检索(BM25)
同义词/近义句弱(必须词面匹配)
精确产品型号/代码弱(语义距离大)
多语言混合文档差(需分词器支持)
计算成本高(ANN 索引)低(倒排索引)
对 typo 容忍

结论:两路各自召回,再融合排名,比任何一路单独用都好。

但这不代表任何场景都无脑上混合检索。判断标准很简单,看你的知识库里有没有下面这三类内容:型号/编号/报错码这种必须逐字符匹配的字符串、缩写和黑话(比如”内存溢出”和”OOM”用户可能混着问)、代码片段或配置项名称。三类占比越高,混合检索的收益越明显;如果知识库是清一色规范化的百科式说明文(比如统一改写过的产品文案),纯向量往往已经够用,硬上混合检索只是多背了一套倒排索引的运维成本,回报有限。

混合检索架构

用户问题

  ├──── 向量化 ────► ANN 检索 ──► Dense Top-K(如 Top-20)
  │                                         │
  └──── BM25 分词 ──► 倒排检索 ──► Sparse Top-K(如 Top-20)

                                  RRF 融合两路结果

                                  Merged Top-N(如 Top-20)

                                  Reranker 精排 → Top-3~5

                                     注入 LLM 上下文

RRF(倒数排名融合)实现

RRF 是最常用的融合算法,无需调参,对两路结果的分数尺度不敏感:

def rrf_merge(
    dense_results: list[tuple[str, float]],   # [(doc_id, score), ...]
    sparse_results: list[tuple[str, float]],
    k: int = 60,
    top_n: int = 20
) -> list[str]:
    """
    k=60 是 RRF 原论文推荐默认值,实验表明对大多数数据集稳定。
    """
    scores: dict[str, float] = {}
    for rank, (doc_id, _) in enumerate(dense_results):
        scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
    for rank, (doc_id, _) in enumerate(sparse_results):
        scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)

    sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return [doc_id for doc_id, _ in sorted_docs[:top_n]]

这段代码的关键设计是只用排名(rank),不用原始分数(score)。这是新手最容易踩的坑:向量检索的相似度分数(比如余弦相似度 0.82)和 BM25 的分数(比如 TF-IDF 加权和 15.3)根本不在一个量纲上,直接拿两边分数加权求和(比如 0.5*dense_score + 0.5*bm25_score)看起来简单,但会因为分数尺度不一致而失真——BM25 分数没有上限,长文档、词频高的文档分数可能轻松到几十甚至上百,而向量相似度大多落在 0~1 之间,权重形同虚设。RRF 绕开了这个问题:不管原始分数是多少,只看这个文档在各自列表里排第几名,用 1/(k+rank+1) 换算成一个和分数尺度无关的贡献值再累加。

举个具体例子帮你建立直觉:假设某文档 A 在向量检索里排第 1 名,在 BM25 里没有出现(因为文档里没有查询的关键词字面匹配);文档 B 在向量检索里排第 15 名,但在 BM25 里排第 1 名。按 RRF 公式(k=60):A 的分数是 1/(60+0+1) = 0.0164(只有一路贡献);B 的分数是 1/(60+14+1) + 1/(60+0+1) = 0.0133 + 0.0164 = 0.0297。B 反而排到了 A 前面——这正是我们想要的效果:两路都认可、哪怕都不是各自第一名的文档,比只有一路强烈认可的文档更可信。这也是为什么混合检索比”各取一路 Top-N 直接拼接去重”更聪明:拼接方案没有排序逻辑,谁先出现全看列表顺序;RRF 是真正做了一次融合排序。

k=60 这个常数的作用是压低排名靠后位次之间的分数差异(分母越大,排名差 1 位带来的分数变化越小),实践中不太需要调它;如果你发现融合结果严重偏向某一路(比如几乎全是向量检索的 Top 结果),大概率不是 k 值的问题,而是两路召回数量 top_k 设置不对称,或者其中一路的分词/索引本身就没配置好(下文中文分词那节会讲这个坑)。

两路检索要并发跑,别串行等

上面的伪代码省略了一个容易被忽略的工程细节:向量检索和 BM25 检索互不依赖,应该并发发起,而不是写成”先等向量检索返回,再发起 BM25 检索”的串行代码。如果两路检索各自耗时 200ms,串行写法总耗时 400ms,并发写法只要较慢的那一路耗时,通常在 200~250ms:

import asyncio

async def hybrid_retrieve(query: str, query_vector: list[float]):
    dense_task = asyncio.create_task(dense_search(query_vector))
    sparse_task = asyncio.create_task(sparse_search(query))
    dense_results, sparse_results = await asyncio.gather(dense_task, sparse_task)
    return rrf_merge(dense_results, sparse_results)

线上跑起来后建议给每一路单独打点耗时日志(而不是只记总耗时),因为混合检索最怕的故障模式是”其中一路悄悄变慢或悄悄挂了但整体还能返回结果”——比如 Elasticsearch 集群负载高导致 BM25 那一路超时降级成空列表,融合结果看起来正常返回了,实际上退化成了纯向量检索,问题不容易被发现,等到有人反馈”型号又查不到了”时才会被追溯到。

支持混合检索的向量数据库

数据库原生混合检索BM25 方式备注
Elasticsearch / OpenSearch内置成熟方案,搜索场景首选
WeaviateBM25FGraphQL 接口,配置简单
Qdrant是(v1.7+)稀疏向量需开启 sparse 索引
Milvus是(v2.4+)内置 BM25中文需配置分词器
pgvector + pg_trgm半原生pg 全文检索代码层手动融合
Chroma需外挂适合开发阶段,生产建议换库

Elasticsearch 混合检索示例

from elasticsearch import Elasticsearch

es = Elasticsearch("http://localhost:9200")

def hybrid_search(query: str, query_vector: list[float], top_k: int = 20) -> list[dict]:
    resp = es.search(
        index="knowledge_base",
        body={
            "query": {
                "bool": {
                    "should": [
                        # BM25 关键词检索
                        {"match": {"content": {"query": query, "boost": 1.0}}},
                        # 向量检索(knn)
                    ]
                }
            },
            "knn": {
                "field": "embedding",
                "query_vector": query_vector,
                "k": top_k,
                "num_candidates": 100,
                "boost": 1.5
            },
            "size": top_k
        }
    )
    return [hit["_source"] for hit in resp["hits"]["hits"]]

这个示例用的是 Elasticsearch 内置的 boost 加权拼接,跟前面讲的 RRF 是两种不同的融合思路:boost 属于加权线性融合(在 ES 内部把 knn 分数和 BM25 分数按权重合并成一个分数排序),实现简单、一次查询就能拿到结果,但正如前面说的,两路分数尺度不同,boost: 1.5 具体调多少合适没有理论依据,只能靠线下评测集反复试。如果你的业务对排序质量要求高,更稳妥的做法是分别发起两次查询拿到两份排名列表,再在应用层用 RRF 融合——牺牲一点点请求数换取排序逻辑的可解释性和可调试性。

这里有个真实踩过的坑:num_candidates 设置得比 k 小,或者跟 size 不匹配,knn 检索会提前截断候选集,导致看起来召回数量够但实际语义相关的文档没进候选池。ES 官方建议 num_candidates 至少是 k 的 5~10 倍,候选集越大召回质量越稳,代价是查询延迟略微上升,一般在几十毫秒量级,值得用延迟换召回质量。

中文场景的额外注意事项

BM25 依赖分词器,中文必须配置正确的分词插件:

  • Elasticsearch:安装 analysis-ik 插件,字段映射指定 ik_max_word 分词器。
  • Milvus:内置 jieba 分词,但对专业术语需要自定义词典。
  • 自建 BM25:用 rank_bm25 库 + jieba 分词,适合小规模快速验证。
import jieba
from rank_bm25 import BM25Okapi

corpus = ["文档一内容...", "文档二内容..."]
tokenized = [list(jieba.cut(doc)) for doc in corpus]
bm25 = BM25Okapi(tokenized)

query_tokens = list(jieba.cut("用户查询"))
scores = bm25.get_scores(query_tokens)

自建 BM25 这条路线在中文场景下最容易翻车的地方,是分词粒度不一致导致检索不到本该命中的文档。举个真实例子:知识库里有一句”支持向量数据库的混合检索”,jieba 默认分词会切成”支持 / 向量 / 数据库 / 的 / 混合 / 检索”;但用户查询”向量库检索”会被切成”向量库 / 检索”——“向量库”和”向量 数据库”完全是两个词面,BM25 靠词面匹配,这两者交集只剩”检索”一个词,分数会很低。解法有两种:一是维护业务领域的自定义词典(jieba.load_userdict()),把”向量库""向量数据库”这类同义变体都加进去并标注权重;二是干脆放弃自建方案,换成 Elasticsearch 的 ik_max_word 分词器,它会对同一段文本做多粒度切分(既切”向量数据库”整体,也切”向量""数据库”两个子词),召回口径更宽松,这也是我更推荐生产环境直接上 ES/OpenSearch 而不是自建 BM25 的原因——自建方案省了部署成本,但分词维护的隐性成本更高,队伍规模不大的话很容易顾不过来。

rank_bm25 这个库有个容易被忽略的限制:它是纯内存实现,没有索引持久化和增量更新能力,每次启动都要把整个语料库重新分词建索引一遍。语料库到几万篇文档级别时,冷启动时间会明显变长(几秒到几十秒),如果你的应用是每次请求都新建一个 BM25Okapi 实例,这个开销会被放大到不可接受。正确做法是在服务启动时构建一次全局单例,语料库更新时用后台任务异步重建,而不是每次查询都重新构建。

常见问题

混合检索一定比纯向量检索好吗? 在大多数真实业务场景下是的,尤其是文档包含专有名词、数字、型号时提升显著。但如果知识库文档风格高度统一、问法也规范,纯向量差距不大,混合检索的工程复杂度是额外成本,需要权衡。

两路权重怎么调? RRF 不需要权重参数(k 值对结果影响不大,默认 60 即可)。如果用加权线性融合,建议先 50/50,再用线下评测集调参。通常向量权重可适当高于 BM25。

混合检索后还需要 Reranker 吗? 推荐保留。混合检索+RRF 提升了召回覆盖率,但顺序还不够精准;Reranker 进一步精排,两者叠加效果最好。

混合检索会不会拖慢整体延迟? 如果两路检索是并发发起(见前面异步示例),额外增加的延迟主要来自较慢的那一路,通常不会翻倍。真正拖慢速度的常见原因反而是 RRF 融合之后又加了一层 Reranker——Reranker 对候选集做逐条打分,候选集越大越慢,建议融合阶段的 Top-N 控制在 20~30 条以内再送入 Reranker,而不是把两路 Top-K 都不做截断地塞进去。

两路召回的文档 ID 对不上怎么办? 这个问题比想象中常见,尤其是分库存储、异步更新的场景:向量库和倒排索引各自独立更新,如果某次文档更新只同步到了一边,两路返回的 doc_id 集合就会出现”文档在向量库有但倒排索引没有”的错位,RRF 融合时这类文档只会拿到单路分数,排名被低估但不会报错,问题很隐蔽。稳妥做法是让两边的写入走同一个更新任务、失败时都要重试或告警,而不是各自维护各自的更新脚本。

怎么验证混合检索真的生效了,而不是退化成了单路? 上线前后各挑 10~20 条”必须靠关键词才能查到”的型号/代码类问题和”必须靠语义才能查到”的口语化问题,分别过一遍单路检索和混合检索,对比 Top-5 命中率。如果条件允许,日志里把两路各自召回的 doc_id 列表和最终融合结果都打印出来,方便回溯某条查询到底是被哪一路救回来的——这份日志在后续调参和排查”为什么这条查不到”时非常有用,建议从上线第一天就留着,不要等出问题了再补。


← 返回 应用模式总览:从 Prompt 到 Agent | 应用模式专题

相关阅读:RAG 重排提升召回 · 向量数据库选型 · RAG 怎么做:架构与落地

多家向量库切换测试混合检索效果?力达云聚合 API 统一接入层,embedding 供应商一键切换。