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.82 | 0.89 | +7% |
| 包含专有名词 | 0.68 | 0.85 | +17% |
| 产品手册 | 0.79 | 0.88 | +9% |
| 法律条文 | 0.75 | 0.83 | +8% |
提升最明显的场景是包含专有名词的——向量检索对专有名词(订单号、产品名、人名)天然弱,关键词检索可以弥补。
7. 混合检索的成本
混合检索的成本比纯向量检索高:
- 存储:稀疏向量额外占用空间(虽然很稀疏,但索引有开销)
- 计算:两路检索 + 融合——延迟比单路高 30-50%
- 复杂度:需要维护两套索引
是否值得?取决于场景:
- 知识库里有大量专有名词、编号、代码 → 值得
- 纯语义问答、闲聊 → 纯向量检索够用
- 不确定 → 先跑纯向量,看检索效果再决定
总结
回顾这一节的要点:
- 向量检索擅长语义,关键词检索擅长精确匹配——混合检索结合两者
- BM25 是经典关键词检索算法,基于词频和逆文档频率
- 稀疏向量让 BM25 可以存在向量数据库里
- SPLADE 是神经网络稀疏向量模型,比 BM25 更智能但更重
- RRF 按排名融合,不需要分数归一化
- 加权融合需要调参,经验值 0.7 向量 + 0.3 关键词
- 混合检索 Recall 通常比纯向量高 5-15%
- 混合检索有额外成本——按场景决定是否值得
下一篇讲 Rerank 重排序——在检索之后用更精细的模型重新排序。