101 lines
5.7 KiB
Markdown
101 lines
5.7 KiB
Markdown
---
|
||
id: ai.vector-db.ann-metrics
|
||
title: 向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引
|
||
type: lesson
|
||
domain: ai
|
||
stack: [vector-db]
|
||
tags: [向量检索, 余弦相似度, 内积, L2, HNSW, IVF, PQ, 召回率]
|
||
level: basic
|
||
prerequisites: [ai.rag.pipeline-chunking]
|
||
est_hours: 2.5
|
||
related: [ai.vector-db.selection-pgvector, ai.rag.hybrid-rerank, ai.vector-db.interview]
|
||
week: [5]
|
||
order: 1
|
||
---
|
||
|
||
# 向量检索原理:距离度量、暴力检索、HNSW / IVF 近似索引
|
||
|
||
## 解释说明
|
||
|
||
**嵌入(embedding)** 把一段文本变成一个固定长度的浮点数组(例如 768、1024、1536 维),语义相近的文本向量也相近。向量数据库要解决的问题就是:给定一个查询向量,从几百万个向量中**快速**找出最相近的 k 个(k 近邻,kNN)。
|
||
|
||
### 距离度量
|
||
|
||
| 度量 | 含义 | 什么时候用 |
|
||
| --- | --- | --- |
|
||
| 余弦相似度(cosine) | 两个向量夹角的余弦,只看方向不看长度 | 文本嵌入最常用 |
|
||
| 内积(inner product, IP / dot) | 对应元素相乘求和 | 向量已归一化时**和余弦等价且更快** |
|
||
| 欧氏距离(L2) | 两点之间的直线距离 | 部分模型推荐;归一化向量的 L2 排序和余弦排序一致 |
|
||
|
||
规则:**用嵌入模型文档推荐的度量**;如果向量已归一化(长度为 1),三种度量的排序结果一致。注意“距离”和“相似度”方向相反:余弦距离 = 1 − 余弦相似度,越小越相近。
|
||
|
||
### 暴力检索和近似检索
|
||
|
||
- **暴力检索(精确)**:查询向量和所有向量逐一计算,复杂度 O(N·d)。几万条以内完全够用(numpy 一次矩阵乘法),而且结果是精确的。
|
||
- **近似最近邻(ANN)**:用索引结构牺牲一点准确度,换取数量级的速度提升。衡量指标是**召回率**:近似结果和精确结果的重合比例。
|
||
|
||
### 常见索引
|
||
|
||
| 索引 | 原理 | 关键参数 | 特点 |
|
||
| --- | --- | --- | --- |
|
||
| **HNSW** | 多层“小世界”图,从稀疏的上层快速定位,逐层向下精细搜索 | `M`(每个点的连接数)、`ef_construction`(建索引时的候选数)、`ef_search`(查询时的候选数) | 查询快、召回高,**最常用**;内存占用大,建索引慢 |
|
||
| **IVF**(倒排文件) | 先用聚类把向量分成 `nlist` 个桶,查询时只搜最近的 `nprobe` 个桶 | `nlist` / `lists`、`nprobe` / `probes` | 建索引快、省内存;要先有数据才能训练聚类中心,数据分布变化后要重建 |
|
||
| **PQ**(乘积量化) | 把向量切段并压缩成编码 | 段数、码本大小 | 大幅省内存,精度下降,常和 IVF 组合(IVF_PQ) |
|
||
| DiskANN 等磁盘索引 | 索引放在 SSD 上 | —— | 数据量超出内存时使用 |
|
||
|
||
参数的规律:`ef_search` / `nprobe` 调大 → 召回率上升、延迟上升。**用自己的数据和评测集测出合适的值**,不要照抄。
|
||
|
||
### 过滤和近似索引的冲突
|
||
|
||
带条件的查询(只搜某租户 / 某部门)在近似索引上有一个经典问题:索引先返回 top-N 个近邻,再按条件过滤,如果满足条件的数据很少,过滤后可能不足 k 个甚至为空。解决办法:数据库内置的过滤遍历(例如 pgvector 0.8 的迭代索引扫描、Milvus 的过滤搜索)、增大候选数、按租户分区 / 分集合、过滤条件非常严格时直接走精确检索。
|
||
|
||
## 案例
|
||
|
||
制度问答知识库有 3 万个块:直接用 numpy 暴力检索,单次查询几毫秒,没有召回损失。业务扩展到 50 个租户、800 万个块后,暴力检索需要几百毫秒,改用 HNSW 索引;上线前用 200 个评测问题对比 HNSW 和暴力检索的结果,调整 `ef_search` 使召回率达到 0.95 以上,同时 P99 延迟满足要求。
|
||
|
||
## 代码示例
|
||
|
||
用 numpy 实现暴力检索,验证“归一化后余弦 = 内积,L2 排序一致”:
|
||
|
||
```python
|
||
import numpy as np
|
||
|
||
rng = np.random.default_rng(0)
|
||
docs = rng.normal(size=(1000, 64)).astype(np.float32)
|
||
q = rng.normal(size=64).astype(np.float32)
|
||
|
||
docs_n = docs / np.linalg.norm(docs, axis=1, keepdims=True)
|
||
q_n = q / np.linalg.norm(q)
|
||
|
||
cos = docs_n @ q_n # 余弦相似度(越大越近)
|
||
ip = docs_n @ q_n # 归一化后内积就是余弦
|
||
l2 = np.linalg.norm(docs_n - q_n, axis=1) # 欧氏距离(越小越近)
|
||
|
||
top_cos = np.argsort(-cos)[:5]
|
||
top_l2 = np.argsort(l2)[:5]
|
||
assert list(top_cos) == list(top_l2) # 归一化后排序一致
|
||
print(top_cos, np.round(cos[top_cos], 3))
|
||
|
||
# 未归一化时内积会偏向长度大的向量,排序可能不同
|
||
top_ip_raw = np.argsort(-(docs @ q))[:5]
|
||
print("未归一化内积 top5:", top_ip_raw)
|
||
```
|
||
|
||
近似索引和精确检索的召回率对比(Chroma HNSW、pgvector HNSW)见 [examples/vector-stores](examples/vector-stores/)。
|
||
|
||
## 易错点
|
||
|
||
- 嵌入模型和度量不匹配:模型输出未归一化却用内积,结果偏向长向量。
|
||
- 入库和查询使用了**不同的嵌入模型**(或同一模型的不同版本):向量空间不一致,检索结果完全不对。换模型必须全量重新嵌入。
|
||
- 把“距离”当“相似度”用:阈值方向写反。
|
||
- 只看近似检索的结果,不和暴力检索对比,不知道召回率是多少。
|
||
- 数据量很小也上复杂的向量数据库:几万条以内 numpy / pgvector 精确检索就够了。
|
||
- IVF 索引在空表上建立:聚类中心没有意义,召回率很低。应在导入有代表性的数据之后再建。
|
||
|
||
## 面试常问
|
||
|
||
- 余弦相似度、内积、L2 的区别?什么时候结果一样?
|
||
- HNSW 的原理和关键参数?`ef_search` 调大会怎样?
|
||
- HNSW 和 IVF 怎么选?
|
||
- 带过滤条件的向量检索为什么可能召回不足?怎么解决?
|