14.11-相似度检索
要点
- 相似度检索的本质:在向量空间里找离查询向量最近的 k 个向量
- 三种距离度量:余弦相似度、欧氏距离、点积——语义检索几乎都用余弦相似度
- 精确搜索(暴力遍历)在大规模数据上不可行,需要近似最近邻(ANN)索引
- 四种主流索引:IVF(倒排文件)、HNSW(图索引)、PQ(乘积量化)、LSH(局部敏感哈希)
- 索引选型没有银弹——按数据规模、内存、延迟要求选择
- 检索质量用 Recall@K 衡量——不是 top1 越准越好,要关心 top10 里有多少正确答案
1. 距离度量
余弦相似度(Cosine Similarity)
衡量两个向量的方向相似程度,不考虑长度。
cos(θ) = (A · B) / (|A| × |B|)
范围:[-1, 1]
- 1:方向完全相同
- 0:正交,无关
- -1:方向完全相反
function cosineSimilarity(a: number[], b: number[]): number {
let dot = 0
let normA = 0
let normB = 0
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB))
}文本检索场景几乎都用余弦相似度。因为文本经过 embedding 后通常会被归一化(长度为 1),此时余弦相似度等价于点积。
欧氏距离(Euclidean Distance)
衡量两个点在空间中的直线距离。
d(A, B) = √(Σ(Ai - Bi)²)
function euclideanDistance(a: number[], b: number[]): number {
let sum = 0
for (let i = 0; i < a.length; i++) {
sum += (a[i] - b[i]) ** 2
}
return Math.sqrt(sum)
}适合向量没有归一化、绝对位置有意义的场景。文本检索场景用得少。
点积(Inner Product / Dot Product)
A · B = Σ(Ai × Bi)
function dotProduct(a: number[], b: number[]): number {
let sum = 0
for (let i = 0; i < a.length; i++) {
sum += a[i] * b[i]
}
return sum
}如果向量已归一化(长度为 1),点积等于余弦相似度。很多向量数据库在归一化向量场景下用点积代替余弦——计算更快。
距离度量的选择
| 场景 | 推荐 |
|---|---|
| 文本语义检索 | 余弦相似度(或归一化后点积) |
| 图像检索 | 余弦相似度 |
| 推荐系统 | 点积(用户/物品向量不一定归一化) |
| 地理位置 | 欧氏距离 |
2. 精确搜索 vs 近似搜索
暴力搜索(Flat / Brute Force)
遍历所有向量,计算距离,取 topK。
function bruteForceSearch(
queryVector: number[],
vectors: number[][],
topK: number,
metric: 'cosine' | 'euclidean' | 'dot' = 'cosine'
): Array<{ index: number; score: number }> {
const distances = vectors.map((v, i) => ({
index: i,
score: metric === 'cosine' ? cosineSimilarity(queryVector, v) : euclideanDistance(queryVector, v),
}))
distances.sort((a, b) => b.score - a.score) // cosine 越大越近
return distances.slice(0, topK)
}时间复杂度:O(N × D),N 是向量数,D 是维度。
- 10 万条 768 维向量:~7600 万次浮点运算,几十毫秒
- 100 万条:几百毫秒到几秒
- 1 亿条:不可行
近似最近邻(ANN)
核心思想:牺牲一点精度,换取几个数量级的速度提升。
ANN 索引不保证找到「真正最近的」k 个向量,而是找到「大概率很近的」k 个。通常用 Recall@K 衡量质量:
Recall@K = 真正 topK 中被找到的数量 / K
比如 Recall@10 = 0.9 意味着平均 10 个结果里有 9 个是真正最近的。
3. IVF 索引(倒排文件)
核心思路
把所有向量聚类成 nlist 个簇。查询时只搜索最近的几个簇,跳过远的簇。
训练阶段:
1. 随机选 nlist 个向量作为聚类中心
2. 每个向量分配到最近的聚类中心
3. 重复直到收敛(类似 K-Means)
查询阶段:
1. 计算查询向量和所有聚类中心的距离
2. 选最近的 nprobe 个簇
3. 只在这 nprobe 个簇里暴力搜索
聚类中心: [C1, C2, C3, C4, ...]
查询向量 Q → 最近的聚类中心: C2, C5
→ 只搜索 C2 和 C5 的向量
→ 跳过 C1, C3, C4, ... 的向量
参数
- nlist:聚类数。经验值:
sqrt(N)到4 * sqrt(N),N 是向量总数 - nprobe:查询时搜索的簇数。越大越精确但越慢
优缺点
- 优点:内存占用和原始向量差不多(存原始向量 + 聚类中心)
- 缺点:需要训练(先有数据才能建索引);边界区域的向量容易被跳过
PQ 结合(IVF_PQ)
IVF 可以和乘积量化(Product Quantization)结合,进一步压缩内存。
PQ 把向量切成若干段,每段独立量化。比如 768 维切成 96 段,每段 8 维,用 8 bit 表示——存储从 32×768 = 24576 bit 降到 8×96 = 768 bit,压缩 32 倍。
原始向量: [0.12, -0.34, 0.88, ..., 0.05] (768 维, 32 bit float)
PQ 量化:
段 1: [0.12, -0.34, 0.88, ...] (8 维) → code 42
段 2: [0.56, -0.11, 0.23, ...] (8 维) → code 15
...
段 96: [...] → code 88
量化后: [42, 15, ..., 88] (96 bytes vs 3072 bytes)
精度损失比 IVF_FLAT 大,但内存占用极低——适合超大规模场景。
4. HNSW 索引(分层导航小世界)
核心思路
构建多层图结构。上层稀疏,用于快速跳跃;下层密集,用于精确定位。
Layer 2 (最稀疏): A -------- D
| |
Layer 1 (中等): A --- B --- D --- F
| | | | | |
Layer 0 (最密集): A-B-C-D-E-F-G-H-I-J
查询时从最上层开始,贪心搜索——找到当前层最近的节点,往下走到下一层,继续贪心搜索,直到最底层。
// HNSW 查询伪代码
function hnswSearch(query: number[], entryPoint: Node, layers: Layer[]): Node[] {
let current = entryPoint
// 从最上层往下,快速定位大致区域
for (let layer = layers.length - 1; layer > 0; layer--) {
current = greedySearch(query, current, layers[layer])
}
// 在最底层精细搜索
return beamSearch(query, current, layers[0], topK)
}参数
- M:每个节点的最大连接数。通常 12-64
- efConstruction:构建索引时的搜索宽度。越大索引质量越好,但构建越慢
- ef:查询时的搜索宽度。越大越精确但越慢
优缺点
- 优点:查询速度极快(通常 O(log N)),不需要训练
- 缺点:内存占用高(每个节点存 M 个邻居指针),构建慢
实际表现
对于百万级向量:
| 索引类型 | 查询延迟 | 内存占用 | Recall@10 |
|---|---|---|---|
| Flat | ~500ms | 3GB(768维) | 1.0(精确) |
| IVF_FLAT (nlist=1024, nprobe=16) | ~5ms | 3.1GB | 0.92 |
| HNSW (M=16, ef=128) | ~2ms | 4.5GB | 0.95 |
| IVF_PQ (nlist=1024, m=96) | ~3ms | 0.5GB | 0.85 |
HNSW 在查询速度和精度上通常最优,但内存占用最高。
5. 向量归一化
归一化把每个向量缩放到单位长度。
function normalize(vector: number[]): number[] {
let norm = 0
for (const v of vector) {
norm += v * v
}
norm = Math.sqrt(norm)
return vector.map((v) => v / norm)
}归一化后:
- 余弦相似度 = 点积
- 欧氏距离² = 2 × (1 - 余弦相似度)
很多向量数据库(包括 Qdrant、Milvus)在内部会自动归一化。如果 embedding 模型输出的向量已经归一化,不需要重复处理。
6. 检索质量的衡量
Recall@K
Recall@K = |{真正 topK} ∩ {检索到的 topK}| / K
比如真正最近的 10 个向量是 A, B, C, D, E, F, G, H, I, J,索引返回了 A, B, C, D, E, F, G, H, K, L,则 Recall@10 = 8/10 = 0.8。
影响 Recall 的因素
- 索引类型和参数:nprobe / ef 越大,Recall 越高
- 数据分布:向量分布均匀时 ANN 效果好,分布偏斜时可能退化
- 维度:维度越高,「近邻」的概念越模糊(维度灾难)
实际测试
async function evaluateRecall(
testQueries: Array<{ query: number[]; groundTruth: string[] }>,
searchFn: (q: number[]) => Promise<string[]>,
k: number
): Promise<number> {
let totalRecall = 0
for (const { query, groundTruth } of testQueries) {
const results = await searchFn(query)
const correctCount = results.filter((r) => groundTruth.includes(r)).length
totalRecall += correctCount / k
}
return totalRecall / testQueries.length
}生产环境的 Recall@10 通常要求 >= 0.9。如果低于 0.85,需要调参或换索引。
7. 检索优化
预过滤 + 后过滤
向量数据库的过滤查询有两种执行策略:
预过滤(Pre-filtering):
1. 先用标量条件过滤出候选集
2. 在候选集上做向量检索
→ 精确,但候选集太小时向量检索效果差
后过滤(Post-filtering):
1. 先做向量检索,返回 topK × 2 的结果
2. 用标量条件过滤掉不符合的
→ 快,但可能过滤后结果不足 topK
大多数向量数据库(Qdrant、Milvus)用的是预过滤或混合策略。
批量化
查询多个向量时,批量化可以减少网络往返:
// ❌ 逐条查询
for (const query of queries) {
const results = await vectorDB.search(query)
}
// ✅ 批量查询
const results = await vectorDB.searchBatch(queries)缓存
热门查询的检索结果可以缓存:
async function searchWithCache(queryVector: number[]): Promise<SearchResult[]> {
const cacheKey = `search:${hashVector(queryVector)}`
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
const results = await vectorDB.search(queryVector)
await redis.set(cacheKey, JSON.stringify(results), 'EX', 300) // 5 分钟
return results
}总结
回顾这一节的要点:
- 三种距离度量:余弦相似度(文本首选)、欧氏距离、点积
- 暴力搜索在大规模数据上不可行,需要 ANN 索引
- IVF(倒排文件):聚类后搜索局部,内存占用适中
- HNSW(图索引):查询快、精度高,但内存占用高
- IVF_PQ:结合量化压缩,适合超大规模
- 向量归一化后余弦相似度等于点积
- Recall@K 衡量检索质量,生产环境通常要求 >= 0.9
- 过滤策略分预过滤和后过滤,各有取舍
下一篇讲混合检索——怎么把向量检索和关键词检索结合起来。