Skip to content

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.70.3语义匹配为主,关键词补漏
专有名词密集0.50.5精确匹配和语义同等重要
语义为主0.80.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.820.89+7%
包含专有名词0.680.85+17%
产品手册0.790.88+9%
法律条文0.750.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。这是下一篇的主题。

基于 MIT 协议开源