Skip to content

14.11-相似度检索

要点

  • 相似度检索的本质:在向量空间里找离查询向量最近的 k 个向量
  • 三种距离度量:余弦相似度、欧氏距离、点积——语义检索几乎都用余弦相似度
  • 精确搜索(暴力遍历)在大规模数据上不可行,需要近似最近邻(ANN)索引
  • 四种主流索引:IVF(倒排文件)、HNSW(图索引)、PQ(乘积量化)、LSH(局部敏感哈希)
  • 索引选型没有银弹——按数据规模、内存、延迟要求选择
  • 检索质量用 Recall@K 衡量——生产环境通常要求 ≥ 0.9,低于 0.85 需要调参或换索引

1. 检索质量为什么是 RAG 的上限

RAG 系统的常见瓶颈不是模型能力,而是检索质量。即使 LLM 再强,如果检索阶段没有召回正确的文档片段,模型也无法凭空给出正确答案。

检索决定了 RAG 的上限,上下文拼接决定了模型能用到多少。 这个上限由三个因素共同决定:距离度量的选择(怎么衡量「相似」)、索引结构(怎么在百万级数据中快速找到近邻)、检索质量的评估与调优(怎么知道检索结果够不够好)。

你已经在前几篇中实践了 pgvector、Qdrant 和 Milvus 的向量写入与查询。这一篇深入这三个因素背后的机制——理解它们,才能在检索效果不达标时知道该调什么、怎么调。

2. 距离度量:余弦、欧氏、点积

同样的两个向量,用不同的距离度量衡量,可能得到完全不同的「相似度」结论。距离度量的选择取决于数据的特性,而不是哪个「更好」。

2.1 余弦相似度(Cosine Similarity)

余弦相似度衡量两个向量的方向相似程度,不考虑长度。值域 [-1, 1]:1 表示方向完全相同,0 表示正交无关,-1 表示方向完全相反。

cos(θ) = (A · B) / (|A| × |B|)
typescript
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),此时余弦相似度等价于点积——后面第 5 节会详细解释这个等价关系。

2.2 欧氏距离(Euclidean Distance)

欧氏距离衡量两个点在空间中的直线距离。值越小越相似。

d(A, B) = √(Σ(Ai - Bi)²)
typescript
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)
}

欧氏距离适合向量没有归一化、绝对位置有意义的场景。 比如图像特征向量中数值本身代表强度,或地理位置坐标中距离就是物理距离。文本检索场景用得少。

2.3 点积(Dot Product)

点积直接计算两个向量的加权求和。值越大越相似。

A · B = Σ(Ai × Bi)
typescript
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.4 距离度量的选择

场景推荐原因
文本语义检索余弦相似度(或归一化后点积)关注方向而非长度,对文本长度差异不敏感
图像检索余弦相似度同上
推荐系统点积用户/物品向量的长度携带信息(活跃度、热门度),不应忽略
地理位置欧氏距离绝对位置有意义,距离就是物理距离

3. 精确搜索与近似搜索

有了距离度量,下一个问题是:怎么在百万、千万级向量中快速找到最近的 k 个?

3.1 暴力搜索(Flat / Brute Force)

遍历所有向量,逐一计算距离,取 topK。逻辑简单,结果精确。

typescript
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)
  return distances.slice(0, topK)
}

时间复杂度 O(N × D),N 是向量数,D 是维度。这个复杂度意味着搜索成本与数据量成线性关系:

  • 10 万条 768 维向量:约 7600 万次浮点运算,几十毫秒——可接受
  • 100 万条:几百毫秒到几秒——开始影响用户体验
  • 1 亿条:数分钟——不可行

3.2 近似最近邻(ANN)

向量数据库面对的是百万到亿级数据。暴力搜索太慢,需要一种方法牺牲一点精度,换取几个数量级的速度提升——这就是近似最近邻(Approximate Nearest Neighbor)。

ANN 索引不保证找到「真正最近的」k 个向量,而是找到「大概率很近的」k 个。通过索引结构跳过大部分不需要计算的距离,只保留最可能成为近邻的候选。

衡量 ANN 质量的核心指标是 Recall@K:

Recall@K = 真正 topK 中被找到的数量 / K

Recall@10 = 0.9 意味着平均 10 个结果里有 9 个是真正最近的。大多数向量数据库允许你调节精度-速度的权衡参数——下一章会针对每种索引具体说明。

4. IVF 索引:聚类后搜索局部

IVF(Inverted File Index)的核心思路:先把全部向量聚类成多个簇,查询时只搜索最近的几个簇,跳过其余大部分向量。

4.1 工作机制

训练阶段用 K-Means 把所有向量聚成 nlist 个簇,每个簇有一个聚类中心。查询时计算向量与所有聚类中心的距离,选最近的 nprobe 个簇,只在这几个簇里做暴力搜索。

训练阶段:
1. 随机选 nlist 个向量作为初始聚类中心
2. 每个向量分配到最近的聚类中心
3. 重复直到收敛

查询阶段:
1. 计算查询向量与所有聚类中心的距离
2. 选最近的 nprobe 个簇
3. 只在这 nprobe 个簇里暴力搜索
→ 跳过其余簇的全部向量

4.2 关键参数

  • nlist:聚类数。经验值 sqrt(N)4 × sqrt(N),N 是向量总数。100 万向量对应 nlist ≈ 1000-4000
  • nprobe:查询时搜索的簇数。nprobe 越大精度越高但速度越慢

4.3 优缺点

IVF 的内存占用与原始向量差不多(存原始向量 + 聚类中心),不需要额外的数据结构开销。但有两个明显限制:需要训练(先有数据才能建索引);处于簇边界的向量容易被漏掉——它可能离某个聚类中心很近,但因为被分到了另一个簇,而那个簇没被选入 nprobe。

4.4 PQ 结合(IVF_PQ)

IVF 可以和乘积量化(Product Quantization)结合,进一步压缩内存。PQ 的核心思想是把向量切成若干段,每段独立量化为一个 code,用 code 代替原始向量存储和计算。

比如 768 维向量切成 96 段,每段 8 维,用 8 bit 表示:

原始:  [0.12, -0.34, 0.88, ..., 0.05]  (768 维, float32 → 3072 bytes)
PQ 后: [42, 15, ..., 88]                (96 bytes → 压缩 32 倍)

IVF_PQ 的精度损失比 IVF_FLAT 大,但内存占用极低。在十亿级向量的场景下,IVF_PQ 几乎是唯一可行的选择——其他索引的内存开销在这个规模下很难承受。

5. HNSW 索引:多层图结构

HNSW(Hierarchical Navigable Small World)解决的是 IVF 的两个问题:需要训练、边界向量容易被跳过。HNSW 通过多层图结构实现快速定位,不需要训练,查询速度和精度通常最优。

5.1 工作机制

构建多层图结构。上层稀疏,节点间距离远,用于快速跨越大范围;下层密集,节点间距离近,用于精确定位。

Layer 2 (最稀疏):  A -------- D
                   |          |
Layer 1 (中等):    A --- B --- D --- F
                   |  |  |  |  |  |
Layer 0 (最密集):  A-B-C-D-E-F-G-H-I-J

查询从最上层的入口点开始,在当前层贪心搜索(每一步走向最近的邻居),到达本层最近点后下降到下一层,继续贪心搜索,直到最底层做精细搜索。

typescript
// 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])
  }

  // 在最底层精细搜索,返回 topK
  return beamSearch(query, current, layers[0], topK)
}

5.2 关键参数

  • M:每个节点的最大连接数。通常 12-64,M 越大精度越高但内存越大
  • efConstruction:构建索引时的搜索宽度。越大索引质量越好,但构建越慢
  • ef:查询时的搜索宽度。ef 越大精度越高但越慢——这和 IVF 的 nprobe 起类似作用

5.3 优缺点

HNSW 查询速度极快(通常 O(log N)),不需要训练,支持动态插入。代价是内存占用高——每个节点需要存储 M 个邻居指针。 在百万级向量场景下的实际表现:

索引类型查询延迟内存占用Recall@10
Flat~500ms3GB(768 维)1.0(精确)
IVF_FLAT (nlist=1024, nprobe=16)~5ms3.1GB0.92
HNSW (M=16, ef=128)~2ms4.5GB0.95
IVF_PQ (nlist=1024, m=96)~3ms0.5GB0.85

HNSW 在查询速度和精度上通常最优,但内存占用最高。 如果内存紧张,IVF_PQ 用 1/6 的内存换回了可接受的精度。

6. 索引选型:没有银弹

索引选型取决于四个变量:数据规模、内存限制、延迟要求和召回率目标。不同场景下的最优选择完全不同。

基于上表的性能数据,可以给出条件化的选型建议:

数据规模推荐索引预期 Recall@10关键权衡
< 10 万Flat1.0数据量小,暴力搜索足够快,不需要索引
10 万 - 500 万HNSW0.95+内存充足时首选,查询最快精度最高
500 万 - 5000 万IVF_FLAT 或 HNSW0.90-0.95内存够用选 HNSW;内存紧张选 IVF
5000 万+IVF_PQ0.85-0.90内存是第一约束,PQ 压缩不可替代
10 亿+IVF_PQ0.80-0.90几乎只能选 PQ 系列,接受精度损失

选型的核心逻辑:先算内存是否放得下,再在内存允许的范围内选精度最高的索引。 如果内存充足,HNSW 在大多数场景下是最佳选择。如果内存是第一约束,IVF_PQ 是唯一能支撑超大规模的方案。

7. 检索质量评估:Recall@K

索引建好了,查询也够快——但怎么知道它「找对了」?

7.1 Recall@K 的计算

Recall@K = |{真正 topK} ∩ {检索到的 topK}| / K

真正最近的 10 个向量是 A-J,索引返回了 A-H、K、L,则 Recall@10 = 8/10 = 0.8。

生产环境的 RAG 系统,Recall@10 通常要求 ≥ 0.9。 低于 0.85 意味着用户的问题有较大概率漏掉最相关的文档片段,模型会基于不完整的上下文给出不准确的回答。

7.2 影响 Recall 的因素

  1. 索引参数:nprobe / ef 越大,搜索范围越广,Recall 越高——但速度越慢。你需要找到满足召回率要求的最小参数,平衡精度与延迟
  2. 数据分布:向量分布均匀时 ANN 效果好;分布严重偏斜(大量聚簇或长尾)时,某些索引可能退化
  3. 维度:维度越高,「近邻」的概念越模糊——高维空间中所有向量间的距离趋于接近(维度灾难)。文本检索中通常受限于 embedding 模型的输出维度(768 / 1536),无法降低

7.3 评估方法

typescript
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
}

groundTruth 通常由暴力搜索在测试集上预先计算。评估时要覆盖多种类型的查询——不要只用几段相似的问题测试,要包含短查询、长查询、专业术语查询和口语化查询。

8. 检索优化策略

理解了距离度量和索引机制,还需要处理几个工程问题:带条件的过滤查询、批量检索和缓存。

8.1 预过滤与后过滤

向量数据库处理带标量条件的向量查询有两种策略:

预过滤(Pre-filtering):先用标量条件过滤出候选集,再在候选集上做向量检索。结果精确,但候选集太小时向量检索效果差——100 条候选里找 top-10,和从 100 万条里找 top-10,召回率完全不同。

后过滤(Post-filtering):先做向量检索,返回 topK × 2 的结果,再用标量条件过滤。速度快,但可能过滤后结果不足 topK。

Qdrant 和 Milvus 使用预过滤或混合策略。实际选择取决于过滤条件的宽松程度: 条件宽松(过滤后仍有大量候选)时预过滤没问题;条件严格(过滤后只剩少量数据)时,需要后过滤或混合策略保证结果数量。

8.2 批量化

查询多个向量时,批量化能减少网络往返开销:

typescript
// ❌ 逐条查询:N 次网络往返
for (const query of queries) {
  const results = await vectorDB.search(query)
}

// ✅ 批量查询:1 次网络往返
const results = await vectorDB.searchBatch(queries)

8.3 缓存

热门查询的检索结果可以缓存,减少重复计算:

typescript
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
}

缓存命中率取决于向量哈希的稳定性——同一个 embedding 模型的相同输入会产出相同的向量,哈希值稳定。但数据更新后需要清理相关缓存,否则会返回过期结果。

相似度检索的核心机制到此讲完。距离度量决定了「什么算相似」,索引结构决定了「怎么快速找到」,Recall@K 决定了「找到的够不够好」。你已经有了选型和调优的判断框架。

但向量检索有一个固有局限:擅长语义相似,但对专有名词、编号、代码片段等精确匹配弱。 关键词检索(BM25)正好相反——精确匹配强,语义理解弱。下一篇讲混合检索:怎么把向量检索和关键词检索结合起来,用 RRF 或加权融合合并结果,让系统同时具备语义理解和精确匹配能力。

基于 MIT 协议开源