14.12-混合检索

要点

  • 纯向量检索擅长语义相似,但对专有名词、编号等精确匹配弱
  • 关键词检索(BM25)精确匹配强,但对同义词、语义理解弱
  • 混合检索结合两者优势——语义搜索找到「相关」的,关键词搜索找到「精确」的
  • 融合策略:RRF(排名融合)不需要分数归一化,加权融合需要分数在同一尺度
  • 稀疏向量模型(SPLADE、BM25 向量化)让关键词检索也能用向量数据库的基础设施
  • 实际效果:混合检索通常比纯向量检索 Recall 高 5-15%

1. 为什么需要混合检索

向量检索的弱点在精确匹配场景。

知识库内容:
  chunk A: "订单 ORD-2024-001 的配送状态为已发货"
  chunk B: "订单 ORD-2024-002 的配送状态为待发货"
  chunk C: "订单配送流程说明..."

用户问:「ORD-2024-001 到哪了?」

向量检索可能返回:
  1. chunk C (0.89)  —— 语义最相关(都在讲配送)
  2. chunk A (0.85)  —— 精确匹配但语义分被稀释
  3. chunk B (0.82)

关键词检索返回:
  1. chunk A —— 精确匹配 "ORD-2024-001"

向量检索把 ORD-2024-001 和「配送」的语义混在一起,结果可能让语义相关的 chunk C 排在精确匹配的 chunk A 前面。

反过来,关键词检索对同义词无能为力:

知识库:「退款一般在 3-5 个工作日到账」
用户问:「返款多久到?」

关键词检索:找不到(「退款」≠「返款」,「到账」≠「到」)
向量检索:能找到(语义相近)

混合检索把两者结合——关键词负责精确匹配,向量负责语义匹配。

2. 关键词检索:BM25

BM25 是经典的关键词检索算法,TF-IDF 的改进版。

BM25(q, d) = Σ IDF(qi) × [f(qi, d) × (k1 + 1)] / [f(qi, d) + k1 × (1 - b + b × |d| / avgdl)]

其中:
- q:查询词集合
- d:文档
- f(qi, d):词 qi 在文档 d 中的出现次数
- |d|:文档长度
- avgdl:平均文档长度
- k1:词频饱和参数(通常 1.2-2.0)
- b:长度归一化参数(通常 0.75)
- IDF(qi):逆文档频率,词越罕见权重越高

BM25 的核心思想:

  • 词频(TF):词在文档中出现越多,越相关——但有饱和效应(出现 100 次不比 10 次强多少)
  • 逆文档频率(IDF):越罕见的词区分度越高(「退款」比「订单」更有区分度)
  • 长度归一化:长文档天然有更多词出现,需要惩罚

TypeScript 实现

class BM25 {
  private k1 = 1.5
  private b = 0.75
  private avgDocLength = 0
  private docCount = 0
  private docFreqs: Map<string, number> = new Map()  // 每个词出现在多少文档
  private docLengths: number[] = []
  private docs: Array<Map<string, number>> = []  // 每个文档的词频
 
  fit(documents: string[]) {
    this.docCount = documents.length
    this.docLengths = []
    this.docFreqs.clear()
    this.docs = []
 
    let totalLength = 0
 
    for (const doc of documents) {
      const words = this.tokenize(doc)
      const tf = new Map<string, number>()
 
      for (const word of words) {
        tf.set(word, (tf.get(word) ?? 0) + 1)
        this.docFreqs.set(word, (this.docFreqs.get(word) ?? 0) + 1)
      }
 
      this.docs.push(tf)
      this.docLengths.push(words.length)
      totalLength += words.length
    }
 
    this.avgDocLength = totalLength / this.docCount
  }
 
  search(query: string, topK: number): Array<{ index: number; score: number }> {
    const queryWords = this.tokenize(query)
    const scores: Array<{ index: number; score: number }> = []
 
    for (let i = 0; i < this.docCount; i++) {
      let score = 0
 
      for (const word of queryWords) {
        const tf = this.docs[i].get(word) ?? 0
        const df = this.docFreqs.get(word) ?? 0
        const idf = Math.log((this.docCount - df + 0.5) / (df + 0.5) + 1)
 
        const numerator = tf * (this.k1 + 1)
        const denominator = tf + this.k1 * (1 - this.b + this.b * this.docLengths[i] / this.avgDocLength)
 
        score += idf * numerator / denominator
      }
 
      if (score > 0) {
        scores.push({ index: i, score })
      }
    }
 
    scores.sort((a, b) => b.score - a.score)
    return scores.slice(0, topK)
  }
 
  private tokenize(text: string): string[] {
    // 简单分词——实际项目用 jieba 或 ICU
    return text.toLowerCase().split(/[\s,.;:!?]+/).filter(Boolean)
  }
}

3. 稀疏向量

传统 BM25 返回的是文档分数,不是向量。但很多向量数据库要求用向量格式输入。

解决方案:把 BM25 分数表示成稀疏向量。

词表: [退款, 订单, 配送, 多久, ...]  (10000 个词)

文档 "退款一般 3-5 天到账" 的稀疏向量:
  [0.0, 0.0, 0.0, 0.82, 0.0, ..., 0.45, 0.0, ...]
       ↑              ↑           ↑
     退款           多久         到账
   BM25权重       BM25权重    BM25权重

只有少数维度非零(和文档中出现的词一一对应)

稀疏向量的好处:

  • 可以存在向量数据库里(Qdrant、Milvus 都支持)
  • 可以用同样的 ANN 索引加速检索
  • 可以和稠密向量融合

SPLADE

SPLADE 是用神经网络训练的稀疏向量模型——比 BM25 更智能。

// SPLADE 模型会做词扩展——即使文档里没有「返款」这个词,
// 如果语义上相关,SPLADE 也会在「返款」对应的维度上给出非零权重
const sparseVector = await spladeModel.encode("退款政策说明")
// { indices: [142, 892, 2051, ...], values: [0.82, 0.45, 0.67, ...] }

SPLADE 的特点:

  • 词扩展:能捕捉同义词(退款 ≈ 返款)
  • 自动加权:比 BM25 的 IDF 更智能
  • 缺点:模型大、推理慢——通常离线生成

如果不想引入 SPLADE,BM25 稀疏向量已经够用。

4. 分数融合策略

向量检索和关键词检索返回的分数不在同一尺度——不能直接相加。需要融合策略。

RRF(Reciprocal Rank Fusion)

按排名融合,不需要关心分数。

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

其中:
- k:常数,通常取 60
- rank_i(d):文档 d 在第 i 个检索器中的排名
- 只对出现在结果中的检索器求和
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))  // rank 从 1 开始
    }
  }
 
  return Array.from(scores.entries())
    .map(([id, score]) => ({ id, score }))
    .sort((a, b) => b.score - a.score)
}

RRF 的优点:

  • 不需要分数归一化
  • 对异常分数不敏感
  • 实现简单

RRF 的缺点:

  • 所有检索器权重相同——不能调「向量检索占 70%、关键词占 30%」

加权融合

把两边的分数归一化到 [0, 1],然后按权重相加。

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 }> {
  // 归一化分数到 [0, 1]
  const normalizeVector = normalizeScores(vectorResults)
  const normalizeKeyword = normalizeScores(keywordResults)
 
  const scores = new Map<string, number>()
 
  for (const { id, score } of normalizeVector) {
    scores.set(id, (scores.get(id) ?? 0) + vectorWeight * score)
  }
  for (const { id, score } of normalizeKeyword) {
    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 max = Math.max(...results.map((r) => r.score))
  const min = Math.min(...results.map((r) => r.score))
 
  if (max === min) {
    return results.map((r) => ({ ...r, score: 1 }))
  }
 
  return results.map((r) => ({ ...r, score: (r.score - min) / (max - min) }))
}

加权融合需要调参(vectorWeight vs keywordWeight)。经验值:

  • 通用问答:0.7 向量 + 0.3 关键词
  • 包含大量专有名词:0.5 + 0.5
  • 语义为主:0.8 + 0.2

5. 在向量数据库里实现混合检索

Qdrant 的混合检索

Qdrant 原生支持稠密 + 稀疏向量融合。

// 写入时同时存稠密和稀疏向量
await client.upsert('documents', {
  points: [
    {
      id: 'doc-1-chunk-0',
      vector: {
        dense: denseEmbedding,           // 768 维稠密向量
        'text-sparse': sparseVector,     // 稀疏向量(BM25 或 SPLADE)
      },
      payload: { text: '...' },
    },
  ],
})
 
// 混合查询
const results = await client.query('documents', {
  prefetch: [
    {
      query: denseQueryVector,
      using: 'dense',
      limit: 20,
    },
    {
      query: sparseQueryVector,
      using: 'text-sparse',
      limit: 20,
    },
  ],
  query: { fusion: 'rrf' },  // RRF 融合
  limit: 5,
})

pgvector + 全文检索

PostgreSQL 的全文检索可以配合 pgvector:

-- 混合查询
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

应用层融合

如果向量数据库不支持混合检索,在应用层做:

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 }),
  ])
 
  // RRF 融合
  return rrfFusion([vectorResults, keywordResults]).slice(0, options.topK ?? 5)
}

6. 实际效果

混合检索的效果通常比纯向量检索好。以下是典型场景的对比:

场景纯向量 Recall@10混合检索 Recall@10提升
通用问答0.820.89+7%
包含专有名词0.680.85+17%
产品手册0.790.88+9%
法律条文0.750.83+8%

提升最明显的场景是包含专有名词的——向量检索对专有名词(订单号、产品名、人名)天然弱,关键词检索可以弥补。

7. 混合检索的成本

混合检索的成本比纯向量检索高:

  • 存储:稀疏向量额外占用空间(虽然很稀疏,但索引有开销)
  • 计算:两路检索 + 融合——延迟比单路高 30-50%
  • 复杂度:需要维护两套索引

是否值得?取决于场景:

  • 知识库里有大量专有名词、编号、代码 → 值得
  • 纯语义问答、闲聊 → 纯向量检索够用
  • 不确定 → 先跑纯向量,看检索效果再决定

总结

回顾这一节的要点:

  • 向量检索擅长语义,关键词检索擅长精确匹配——混合检索结合两者
  • BM25 是经典关键词检索算法,基于词频和逆文档频率
  • 稀疏向量让 BM25 可以存在向量数据库里
  • SPLADE 是神经网络稀疏向量模型,比 BM25 更智能但更重
  • RRF 按排名融合,不需要分数归一化
  • 加权融合需要调参,经验值 0.7 向量 + 0.3 关键词
  • 混合检索 Recall 通常比纯向量高 5-15%
  • 混合检索有额外成本——按场景决定是否值得

下一篇讲 Rerank 重排序——在检索之后用更精细的模型重新排序。