主题
14.12-混合检索
要点
- 纯向量检索擅长语义匹配,但对专有名词、编号、代码等精确匹配场景系统性失效
- 关键词检索(BM25)精确匹配强,但对同义词和语义理解无能为力——两者覆盖完全不同的失败模式
- 混合检索结合两者优势:关键词负责精确匹配,向量负责语义匹配
- 融合策略的选择取决于场景:RRF 免调参适合快速上线,加权融合适合需要精细控制的场景
- 稀疏向量模型(SPLADE)让关键词检索也能利用向量数据库的基础设施,但推理成本更高
- 混合检索在包含专有名词的场景中 Recall 提升最明显(+15-17%),纯语义场景提升有限
1. 订单号查不到:向量检索的精确匹配盲区
你在 11 篇里已经实现了向量相似度检索,能用余弦距离找到语义最近的文档片段。但生产环境中,用户不会总问语义清晰的问题。
看看这个场景:
知识库内容:
chunk A: "订单 ORD-2024-001 的配送状态为已发货"
chunk B: "订单 ORD-2024-002 的配送状态为待发货"
chunk C: "订单配送流程说明:一般发货后 2-3 天到达"
用户问:「ORD-2024-001 到哪了?」
向量检索返回:
1. chunk C (0.89) —— 语义最相关(都在讲配送)
2. chunk A (0.85) —— 精确匹配但语义分被稀释
3. chunk B (0.82)chunk A 才是正确答案,但向量检索把它排在了第三。 原因很清楚:「ORD-2024-001」这个编号在向量空间里没有语义信息,embedding 模型不会理解它是一个精确标识符。向量检索把编号和「配送」的语义混在一起,让语义更相关的 chunk C 抢到了前面。
反过来,关键词检索在这个场景下表现完美——精确匹配「ORD-2024-001」,直接返回 chunk A。但它对同义词无能为力:
知识库:「退款一般在 3-5 个工作日到账」
用户问:「返款多久到?」
关键词检索:找不到(「退款」≠「返款」,「到账」≠「到」)
向量检索:能找到(语义相近)两种检索方式覆盖完全不同的失败模式。 向量检索漏掉精确匹配,关键词检索漏掉语义匹配。混合检索把它们结合起来,让每种失败模式都有对应的补救手段。
2. 互补机制:两种检索覆盖什么
在动手实现之前,先搞清楚两种检索各自擅长什么。这决定了混合检索能带来多少提升。
| 维度 | 向量检索 | 关键词检索(BM25) |
|---|---|---|
| 匹配方式 | 语义相似度(embedding 空间距离) | 词面精确匹配(词频 + 逆文档频率) |
| 擅长 | 同义词、近义表达、跨语言匹配 | 专有名词、编号、代码、产品名 |
| 失效 | 编号、代码、罕见专有名词 | 同义词、改写、语义相近但措辞不同 |
| 底层假设 | 语义相近 = 相关 | 词面重叠 = 相关 |
关键判断:如果你的知识库里几乎没有专有名词、编号或代码,纯向量检索可能已经够用。 混合检索的提升主要来自精确匹配场景的补救。
BM25 是关键词检索的核心算法,它基于三个信号打分:
- 词频(TF):查询词在文档中出现越多,相关性越高——但有饱和效应,出现 100 次不比 10 次强多少
- 逆文档频率(IDF):越罕见的词区分度越高。「退款」出现在 5% 的文档中,区分度远高于出现在 80% 文档中的「订单」
- 长度归一化:长文档天然有更多词命中,BM25 通过文档长度与平均长度的比值来惩罚这种偏差
这三个信号让 BM25 在精确匹配场景下非常可靠——一个编号只要出现了,就一定会被找到,不会因为语义上的「不太相关」而被降级。
3. 稀疏向量:让关键词检索搭上向量数据库的车
传统 BM25 返回的是文档分数,不生成向量。但很多向量数据库(Qdrant、Milvus)要求用向量格式输入。把 BM25 和向量检索分开部署意味着维护两套存储和索引,工程复杂度高。
解决方案:把 BM25 分数表示成稀疏向量,和稠密向量一起存在同一个向量数据库里。
词表: [退款, 订单, 配送, 多久, ...] (10000 个词)
文档 "退款一般 3-5 天到账" 的稀疏向量:
[0.0, 0.0, 0.0, 0.82, 0.0, ..., 0.45, 0.0, ...]
↑ ↑ ↑
退款 多久 到账
BM25权重 BM25权重 BM25权重
只有少数维度非零——稀疏向量的非零维度和文档中出现的词一一对应这样做的好处是:可以用向量数据库的 ANN 索引加速稀疏向量的检索,也可以在同一查询里同时做稠密和稀疏向量的融合——不需要额外部署 Elasticsearch 或 Meilisearch。
SPLADE:比 BM25 更智能的稀疏向量
SPLADE 是用神经网络训练的稀疏向量模型。它和 BM25 的核心区别在于词扩展:即使文档里没有「返款」这个词,SPLADE 也能在「返款」对应的维度上给出非零权重,因为它理解「退款」和「返款」语义相关。
typescript
// SPLADE 会做词扩展——文档里没有「返款」,但语义上相关
// SPLADE 在「返款」维度上给出非零权重
const sparseVector = await spladeModel.encode("退款政策说明")
// { indices: [142, 892, 2051, ...], values: [0.82, 0.45, 0.67, ...] }SPLADE 的特点:词扩展能捕捉同义词,自动加权比 BM25 的 IDF 更智能。但它的代价也很明确——模型大、推理慢,通常需要离线生成稀疏向量。如果你的知识库有 10 万条文档,用 BM25 生成稀疏向量只需要几秒,用 SPLADE 可能需要几十分钟。
选择建议:BM25 稀疏向量是大多数场景的合理起点。 只在以下情况考虑 SPLADE:知识库有大量同义词改写、用户对召回率要求极高、且有足够计算预算做离线生成。
4. 融合策略:RRF 还是加权
两路检索返回的分数不在同一尺度——向量的余弦相似度通常在 0.7-0.95 之间,BM25 的分数范围则取决于词频和文档长度,可能是 0-20 或更大。不能直接相加,需要融合策略。
4.1 RRF:按排名融合,不需要关心分数
RRF(Reciprocal Rank Fusion)只关心文档在每路检索中的排名,不关心具体分数。
RRF_score(d) = Σ 1 / (k + rank_i(d))
k 通常取 60,rank 从 1 开始
只对出现在结果中的检索器求和typescript
function rrfFusion(
resultsList: Array<Array<{ id: string; score: number }>>,
k = 60
): Array<{ id: string; score: number }> {
const scores = new Map<string, number>()
for (const results of resultsList) {
for (let rank = 0; rank < results.length; rank++) {
const { id } = results[rank]
const current = scores.get(id) ?? 0
scores.set(id, current + 1 / (k + rank + 1))
}
}
return Array.from(scores.entries())
.map(([id, score]) => ({ id, score }))
.sort((a, b) => b.score - a.score)
}RRF 的核心优势:不需要分数归一化,对异常分数不敏感,实现简单。 如果两路检索的分数尺度差异很大,或者你不想花时间调参,RRF 是默认选择。
RRF 的局限:所有检索器权重相同。 你无法表达「向量检索占 70%、关键词占 30%」这种偏好。如果你的场景中语义匹配明显更重要,RRF 的等权策略会拖累效果。
4.2 加权融合:精确控制两路权重
加权融合把两路分数归一化到 [0, 1],然后按权重相加。
typescript
function weightedFusion(
vectorResults: Array<{ id: string; score: number }>,
keywordResults: Array<{ id: string; score: number }>,
vectorWeight = 0.7,
keywordWeight = 0.3
): Array<{ id: string; score: number }> {
const normalizedVector = normalizeScores(vectorResults)
const normalizedKeyword = normalizeScores(keywordResults)
const scores = new Map<string, number>()
for (const { id, score } of normalizedVector) {
scores.set(id, (scores.get(id) ?? 0) + vectorWeight * score)
}
for (const { id, score } of normalizedKeyword) {
scores.set(id, (scores.get(id) ?? 0) + keywordWeight * score)
}
return Array.from(scores.entries())
.map(([id, score]) => ({ id, score }))
.sort((a, b) => b.score - a.score)
}
function normalizeScores(
results: Array<{ id: string; score: number }>
): Array<{ id: string; score: number }> {
if (results.length === 0) return []
const scores = results.map((r) => r.score)
const max = Math.max(...scores)
const min = Math.min(...scores)
// 所有分数相同时,给相同权重
if (max === min) return results.map((r) => ({ ...r, score: 1 }))
return results.map((r) => ({ ...r, score: (r.score - min) / (max - min) }))
}加权融合需要调参,但调参有明确的经验起点:
| 场景 | 向量权重 | 关键词权重 | 理由 |
|---|---|---|---|
| 通用问答 | 0.7 | 0.3 | 语义匹配为主,关键词补漏 |
| 专有名词密集 | 0.5 | 0.5 | 精确匹配和语义同等重要 |
| 语义为主 | 0.8 | 0.2 | 关键词只是安全网 |
选择条件:先用 RRF 跑通流程,如果效果不达标且你判断两路权重需要调整,再切加权融合。 RRF 能覆盖大部分场景,加权融合是在 RRF 不够好时的精细控制手段。
5. 工程实现:三套方案
5.1 Qdrant 原生混合检索
Qdrant 原生支持稠密 + 稀疏向量的服务端融合,延迟最低。
typescript
// 写入时同时存稠密和稀疏向量
await client.upsert('documents', {
points: [
{
id: 'doc-1-chunk-0',
vector: {
dense: denseEmbedding, // 768 维稠密向量
'text-sparse': sparseVector, // 稀疏向量(BM25 或 SPLADE)
},
payload: { text: '...' },
},
],
})
// 混合查询:服务端完成两路检索 + RRF 融合
const results = await client.query('documents', {
prefetch: [
{ query: denseQueryVector, using: 'dense', limit: 20 },
{ query: sparseQueryVector, using: 'text-sparse', limit: 20 },
],
query: { fusion: 'rrf' },
limit: 5,
})5.2 pgvector + PostgreSQL 全文检索
如果你已经在用 pgvector,可以直接利用 PostgreSQL 内置的全文检索能力,不需要额外部署。
sql
SELECT
id,
content,
1 - (embedding <=> $1) AS vector_score,
ts_rank(content_tsv, plainto_tsquery('simple', $2)) AS keyword_score,
0.7 * (1 - (embedding <=> $1))
+ 0.3 * ts_rank(content_tsv, plainto_tsquery('simple', $2))
AS combined_score
FROM document_chunks
WHERE content_tsv @@ plainto_tsquery('simple', $2)
OR 1 - (embedding <=> $1) >= 0.8
ORDER BY combined_score DESC
LIMIT 5这个方案的 WHERE 条件值得注意:content_tsv @@ ... OR vector_score >= 0.8 确保即使关键词检索没命中,高相似度向量结果也不会被漏掉。
5.3 应用层融合
如果向量数据库不支持服务端融合,在应用层并行执行两路检索再合并。
typescript
async function hybridSearch(
queryText: string,
queryVector: number[],
options: SearchOptions
): Promise<SearchResult[]> {
// 两路检索并行执行,延迟取决于较慢的那一路
const [vectorResults, keywordResults] = await Promise.all([
vectorDB.search(queryVector, { topK: 20, ...options }),
bm25Search(queryText, { topK: 20, ...options }),
])
return rrfFusion([vectorResults, keywordResults]).slice(0, options.topK ?? 5)
}应用层融合的优势是灵活——可以随时切换 RRF 和加权融合,不受向量数据库能力限制。代价是两路检索的网络开销和融合计算都在应用侧。
三套方案的选型逻辑:如果你用 Qdrant,直接用原生融合(延迟最低);如果你用 pgvector,用 PostgreSQL 全文检索(无额外依赖);如果向量数据库不支持融合或是多库架构,用应用层融合。
6. 效果、成本与失败模式
6.1 典型效果数据
混合检索的提升幅度取决于知识库的内容特征。以下是典型场景的 Recall@10 对比:
| 场景 | 纯向量 Recall@10 | 混合检索 Recall@10 | 提升 |
|---|---|---|---|
| 通用问答 | 0.82 | 0.89 | +7% |
| 包含专有名词 | 0.68 | 0.85 | +17% |
| 产品手册 | 0.79 | 0.88 | +9% |
| 法律条文 | 0.75 | 0.83 | +8% |
提升最明显的是包含专有名词的场景(+17%)——向量检索对编号、人名、产品名天然弱,关键词检索可以精准补救。 通用问答场景提升约 7%,因为大部分问题靠语义匹配就能找到正确答案。
6.2 成本代价
混合检索不是免费的:
- 存储:稀疏向量索引有额外开销(虽然很稀疏,但索引结构需要内存)
- 延迟:两路检索 + 融合,比单路检索高 30-50%
- 工程复杂度:需要维护两套索引、处理分词、管理稀疏向量的生成流程
是否值得引入,取决于你的知识库是否触发了向量检索的盲区。 如果知识库里主要是自然语言描述、FAQ、教程,纯向量检索 + 后续 Rerank 可能已经够用。如果知识库里大量订单号、产品名、代码变量、法规条款编号,混合检索几乎是必须的。
6.3 混合检索也会失败
混合检索不是万能的,以下场景它同样处理不好:
两路都漏:用户问的是文档中没有明确写出的推断性知识。比如知识库写了「退款 3-5 天到账」和「信用卡处理需要 2 天」,用户问「退款什么时候能刷到信用卡上」——需要跨越两个 chunk 做推理,向量检索和关键词检索都只能找到其中一个。
关键词检索引入噪声:如果查询中包含高频通用词(「系统」「功能」「怎么」),BM25 会返回大量低质量命中。虽然 IDF 会降低高频词权重,但在短文档场景下,这些词的干扰仍然可能把正确结果挤出 top-K。
融合权重错配:加权融合中如果关键词权重设得太高,语义相近但措辞不同的正确结果会被排名靠后。反过来,向量权重过高时,编号匹配又会退化为纯向量检索的效果。权重没有银弹,需要根据实际检索日志持续调整。
不确定是否需要混合检索? 先跑纯向量检索,收集检索失败的 case,统计其中精确匹配失败(编号、代码、专有名词)的占比。如果超过 20%,混合检索大概率能带来显著提升。
7. 下一步:从召回到精排
混合检索解决了「召回不够全」的问题——让更多相关文档进入候选集。但候选集里仍然可能有语义相近但不精确的结果排在前面。
检索阶段的另一个瓶颈不是找不到,而是找到的排序不够准。 向量检索和 BM25 都是浅层匹配——一个用 embedding 空间的距离,一个用词面重叠度。它们都不理解查询和文档之间的深层语义关系。
Rerank 重排序解决这个问题:在混合检索返回 top-20 到 top-50 的候选之后,用交叉编码器逐个精排,把最相关的 top-5 送到 LLM。这是下一篇的主题。