Appearance
课 2 · 混合检索与 RRF 融合
本课目标
把向量召回和关键词召回的结果用 RRF 算法融合,得到比单一检索更好的召回结果。
关键理解:混合检索不是"向量检索更厉害",而是"两种方式各有盲区,融合后互补"。
混合检索架构
混合检索流程
用户问题
→
向量召回sqlite-vec / pgvector
关键词召回Elasticsearch BM25
Top-K 结果列表 A
Top-K 结果列表 B
→
RRF 融合按排名加权重新排序
→
最终 Top-K
两路结果各自取 Top-K,然后用 RRF 算法融合成一份有序列表。
RRF(Reciprocal Rank Fusion)算法
RRF 是当前混合检索最常用的融合算法,公式极简:
其中
直觉理解:排名越靠前的文档贡献越大,两路都排前列的文档得分最高。不需要关心原始分数的量纲(向量相似度 vs BM25 分数不可直接比较)。
代码实现
typescript
// packages/rag-core/src/retrieval/hybrid-search.ts
import { vectorSearch } from '../vector/search'
import { keywordSearch } from '../es/search'
interface RetrievalResult {
id: string
content: string
fileName: string
pageNumber?: number
rrfScore: number
}
const RRF_K = 60 // 平滑常数,业界通常用 60
export async function hybridSearch(query: string, topK = 5): Promise<RetrievalResult[]> {
// 并行执行两路检索
const [vectorResults, keywordResults] = await Promise.all([
vectorSearch(query, topK * 2), // 多取一些,融合后再截断
keywordSearch(query, topK * 2),
])
// 建立所有文档的 Map
const docMap = new Map<string, Omit<RetrievalResult, 'rrfScore'>>()
const rrfScores = new Map<string, number>()
// 初始化 RRF 分数
function initScore(id: string) {
if (!rrfScores.has(id)) rrfScores.set(id, 0)
}
// 向量检索贡献 RRF 分数
vectorResults.forEach((doc, index) => {
initScore(doc.id)
rrfScores.set(doc.id, rrfScores.get(doc.id)! + 1 / (RRF_K + index + 1))
docMap.set(doc.id, doc)
})
// 关键词检索贡献 RRF 分数
keywordResults.forEach((doc, index) => {
initScore(doc.id)
rrfScores.set(doc.id, rrfScores.get(doc.id)! + 1 / (RRF_K + index + 1))
if (!docMap.has(doc.id)) docMap.set(doc.id, doc)
})
// 按 RRF 分数降序排列,取 Top-K
return Array.from(rrfScores.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, topK)
.map(([id, rrfScore]) => ({
...docMap.get(id)!,
rrfScore,
}))
}并行检索的性能
两路检索用 Promise.all 并行,总耗时取决于较慢的那路,而不是两路之和:
typescript
// ✅ 并行:总耗时 ≈ max(向量检索, 关键词检索) ≈ 200ms
const [vectorResults, keywordResults] = await Promise.all([
vectorSearch(query, topK * 2),
keywordSearch(query, topK * 2),
])
// ❌ 串行:总耗时 ≈ 向量检索 + 关键词检索 ≈ 400ms
const vectorResults = await vectorSearch(query, topK * 2)
const keywordResults = await keywordSearch(query, topK * 2)接入 Agent RAG 流程
把原来的单路向量检索替换为混合检索:
typescript
// packages/rag-core/src/retrieval/index.ts
export async function retrieve(query: string, topK = 5) {
// 之前:return vectorSearch(query, topK)
// 现在:
return hybridSearch(query, topK)
}其他代码不需要改——检索层的接口不变,只是内部实现升级了。
权重调整(进阶)
如果想给两路检索设置不同权重,可以在 RRF 公式里乘以权重系数:
typescript
// 给向量检索更高权重
const VECTOR_WEIGHT = 1.2
const KEYWORD_WEIGHT = 0.8
vectorResults.forEach((doc, index) => {
rrfScores.set(
doc.id,
(rrfScores.get(doc.id) ?? 0) + VECTOR_WEIGHT / (RRF_K + index + 1)
)
})实践中先用等权重,再根据评测指标(模块 5)调整。
本节产物
packages/rag-core/src/retrieval/
hybrid-search.ts # RRF 混合检索
index.ts # 检索入口(替换为 hybridSearch)面试追问
你的系统如何保证召回率?
召回率由两个机制保障:1)混合检索:向量召回和关键词召回各自的盲区不同,融合后互补,整体召回率高于单路;2)适当扩大初始召回数量(取 Top-10 再截断到 5),保留更多候选,Rerank 阶段再精选。最终通过模块 5 的评测集量化 Recall@K 指标,用数据说话。
RRF 为什么不直接用分数加权平均?
向量检索的分数是余弦相似度(0-1),关键词检索的 BM25 分数范围不固定(可能是 0-10,也可能是 0-100)。两个量纲不同的分数直接加权没有意义。RRF 只用排名位置,不依赖原始分数的量纲,因此天然适合多路融合。