向量数据库实战(Milvus/Chroma)
从 Embedding 的第一个维度到 RAG 系统的最后一公里——手把手带你玩转向量数据库。
4. 索引算法揭秘——HNSW、IVF、DiskANN
上一章我们知道了:百万级向量上做暴力搜索太慢,需要 ANN 索引来加速。但"索引"到底是怎么工作的?
这一章,我们用直觉而非数学来拆解三大主流索引算法。你不需要记住公式,只需要理解每种算法的核心思想和适用场景,就能在实际项目中做出正确的选型。
4.1 HNSW:高速公路式的图索引
HNSW(Hierarchical Navigable Small World) 是目前最流行的向量索引算法,Milvus 和 Chroma 的默认索引都是它。
核心思想:跳表 + 图搜索
HNSW 的灵感来自一个现实类比——城市交通网络:
HNSW 的直觉理解——高速公路网络:
从北京找到一家上海的小餐馆
方案 A:只走小路(暴力搜索)
─────────────────────────────────────
北京 → 每条街都走一遍 → 效率极低
→ 每个路口都要停下来问路
→ 走了 1000 条路才找到
方案 B:高速公路 + 省道 + 市道(HNSW)
─────────────────────────────────────
第 3 层(高速公路):北京 ──→ 上海 粗略定位
第 2 层(省道): 上海 ──→ 浦东区 缩小范围
第 1 层(市道): 浦东 ──→ 某条街 精确找到
第 0 层(步行): 某街 ──→ 那家餐馆 到达目标
→ 只走了 4 步就到了
→ 从宏观到微观,逐层细化HNSW 的多层结构
HNSW 索引的层次结构(简化示意):
第 3 层(最稀疏): A ━━━━━━━━━━━ B
只有极少数"枢纽"节点 │ │
负责远距离跳转 │ │
▼ ▼
第 2 层: A ━━━ C ━━━ B ━━━ D
更多节点,更细粒度 │ │ │
▼ ▼ ▼
第 1 层: A ─ E ─ C ─ F ─ B ─ G ─ D
大部分节点在这层 │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
第 0 层(最稠密): A E H C I F J B K G L D
所有节点都在这层
每个节点连接最近的邻居
搜索过程(找和 Q 最近的节点):
─────────────────────────────────
1. 从第 3 层的入口节点 A 开始
2. 在第 3 层:A 和 B 谁离 Q 更近?→ B
3. 从 B 下降到第 2 层
4. 在第 2 层:B 的邻居 C、D 谁离 Q 更近?→ C
5. 从 C 下降到第 1 层
6. 在第 1 层:C 的邻居 E、F 谁离 Q 更近?→ F
7. 从 F 下降到第 0 层
8. 在第 0 层:F 的邻居 I、J 谁离 Q 更近?→ I
9. I 的所有邻居都比 I 更远 → I 就是结果
总共只比较了 ~8 个节点(而非全部 12 个)
数据量越大,优势越明显HNSW 的关键参数
两个最重要的参数:
M(每层的最大邻居数)
─────────────────────────────────────
M = 16(默认)
→ 每个节点最多连接 16 个邻居
→ M 越大 → 连接越多 → 召回率越高 → 但内存占用更多
→ M 越小 → 连接越少 → 速度更快 → 但可能漏掉结果
推荐值:
小数据集(< 10 万):M = 8-16
中数据集(10-100 万):M = 16-32
大数据集(> 100 万):M = 32-64
ef_construction(建索引时的搜索宽度)
─────────────────────────────────────
ef_construction = 200(默认)
→ 建索引时,每插入一个节点,搜索 200 个候选邻居
→ 值越大 → 索引质量越高 → 但建索引越慢
→ 通常设为 M 的 8-10 倍
ef_search / ef(搜索时的搜索宽度)
─────────────────────────────────────
ef = 64(默认)
→ 搜索时,维护一个大小为 ef 的候选列表
→ ef 越大 → 召回率越高 → 但搜索越慢
→ ef 必须 ≥ Top-K
推荐值:
追求速度:ef = 64
平衡模式:ef = 128
追求精度:ef = 256-512HNSW 的优缺点
| 维度 | 表现 |
|---|---|
| 搜索速度 | ⭐⭐⭐⭐⭐ 极快,毫秒级 |
| 召回率 | ⭐⭐⭐⭐⭐ 95-99%,接近精确搜索 |
| 内存占用 | ⭐⭐ 高,所有数据必须在内存中 |
| 建索引速度 | ⭐⭐⭐ 中等,支持增量插入 |
| 适合场景 | 数据量 < 5000 万,对延迟要求高 |
一句话总结:HNSW 是"用内存换速度"的典型策略。如果你的数据能装进内存,HNSW 几乎是最优选择。
4.2 IVF:先分区再搜索
IVF(Inverted File Index,倒排文件索引) 的思路更直接——先把数据分成若干区,搜索时只看几个最相关的区。
核心思想:聚类 + 就近搜索
IVF 的直觉理解——图书馆分区:
传统方式(暴力搜索):
→ 在整个图书馆找一本关于 Python 的书
→ 每一排书架都要扫一遍
IVF 方式:
→ 图书馆分成 100 个区域(聚类中心)
→ 每个区域放语义相近的书
→ 要找 Python 书?先找到"编程语言区"(最近的聚类中心)
→ 只在"编程语言区"的 2-3 个书架上找
→ 跳过了其他 97 个区域IVF 的工作流程
IVF 索引的构建与搜索:
═══ 建索引阶段 ═══
Step 1:用 K-Means 聚类把所有向量分成 nlist 个簇
─────────────────────────────────────────────
1000 万个向量,nlist = 1024
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 簇 1 │ │ 簇 2 │ ... │ 簇 1024 │
│ ~9766 个 │ │ ~9766 个 │ │ ~9766 个 │
│ 向量 │ │ 向量 │ │ 向量 │
│ │ │ │ │ │
│ 中心 C1 │ │ 中心 C2 │ │ 中心 C1024│
└─────────┘ └─────────┘ └─────────┘
Step 2:记录每个向量属于哪个簇(倒排表)
═══ 搜索阶段 ═══
Step 3:查询向量 Q 来了
─────────────────────────────────────────────
1. Q 和 1024 个簇中心比较距离
→ 找到最近的 nprobe 个簇(如 nprobe = 8)
2. 只在这 8 个簇内做暴力搜索
→ 搜索范围:8 × 9766 ≈ 78000 个向量
→ 而不是全部 1000 万个
→ 搜索量减少到 0.78%
速度提升:
暴力搜索:1000 万个向量
IVF 搜索:~78000 个向量(nprobe=8)
→ 速度提升约 128 倍IVF 的关键参数
两个核心参数:
nlist(簇的数量)
─────────────────────────────────────
→ 把数据分成多少个区域
→ nlist 越大 → 每个簇越小 → 搜索更快 → 但可能跨簇丢失
→ 经验公式:nlist = √N(N 为向量总数)
推荐值:
10 万条 → nlist = 256-512
100 万条 → nlist = 1024-2048
1000 万条 → nlist = 4096-8192
nprobe(搜索时检查的簇数量)
─────────────────────────────────────
→ 搜索时扫描多少个簇
→ nprobe 越大 → 召回率越高 → 但速度越慢
→ nprobe = nlist 时退化为暴力搜索
推荐值:
追求速度:nprobe = 8-16
平衡模式:nprobe = 32-64
追求精度:nprobe = 128-256
两者的配合关系:
nprobe / nlist = 扫描比例
→ 8 / 1024 = 0.78%(只看不到 1% 的数据)
→ 64 / 1024 = 6.25%(看 6% 的数据,召回率 > 95%)IVF 的优缺点
| 维度 | 表现 |
|---|---|
| 搜索速度 | ⭐⭐⭐⭐ 快,但不如 HNSW |
| 召回率 | ⭐⭐⭐⭐ 依赖 nprobe 参数,通常 90-98% |
| 内存占用 | ⭐⭐⭐⭐ 较低,支持量化压缩(IVF_PQ) |
| 建索引速度 | ⭐⭐ 需要全量 K-Means 聚类,较慢 |
| 适合场景 | 数据量大、内存有限、可接受稍低召回率 |
IVF 的变体:
IVF_FLAT(簇内暴力搜索)适合精度要求高的场景;IVF_PQ(簇内量化压缩)适合内存紧张的场景,能把内存占用降低 4-8 倍,代价是召回率下降 2-5%。
4.3 DiskANN:当数据大到内存放不下
当数据量达到亿级,连 IVF 都撑不住了——因为即使是压缩后的向量,内存也装不下。
DiskANN 的解法是:把索引放在 SSD 磁盘上。
DiskANN 解决的问题:
1 亿条 1024 维向量的存储需求:
─────────────────────────────────────
原始大小:100M × 1024 × 4 bytes = 400 GB
HNSW 索引:~500 GB(含图结构) ← 需要 500GB 内存!
IVF_PQ: ~50 GB(压缩后) ← 还是太大
DiskANN: ~5 GB 内存 + 400 GB SSD ← ✅ 可行!
DiskANN 的核心能力:
→ 只在内存中放一个轻量级索引(导航图)
→ 实际向量数据存在 SSD 上
→ 搜索时通过内存索引定位候选区域
→ 再从 SSD 读取少量向量做精确计算DiskANN 的工作原理
DiskANN 的搜索流程:
内存(~5 GB) SSD 磁盘(~400 GB)
┌──────────────────┐ ┌──────────────────────┐
│ 压缩的导航图 │ │ 完整的向量数据 │
│ (PQ 量化后的向量) │ │ (原始精度) │
│ │ │ │
│ 搜索入口 │ │ 向量 1: [0.82, ...] │
│ ↓ │ │ 向量 2: [0.15, ...] │
│ 粗略定位候选集 │ ──→ │ 向量 3: [0.93, ...] │
│ (~100 个候选) │ 读取 │ ...... │
└──────────────────┘ └──────────────────────┘
│
▼
精确重排序 Top-K
性能表现(1 亿条 1024 维):
延迟:~5ms(SSD NVMe)
召回率:95%+
内存:5-10 GB
→ 对比 HNSW 需要 500 GB 内存,DiskANN 只要 5 GBDiskANN 的优缺点
| 维度 | 表现 |
|---|---|
| 搜索速度 | ⭐⭐⭐ 毫秒级,但比纯内存方案慢 2-5 倍 |
| 召回率 | ⭐⭐⭐⭐ 95%+,通过参数可调 |
| 内存占用 | ⭐⭐⭐⭐⭐ 极低,大部分数据在磁盘 |
| 建索引速度 | ⭐⭐ 需要全量构建,耗时较长 |
| 适合场景 | 亿级数据、内存有限、可接受 5ms 级延迟 |
使用门槛:DiskANN 需要高性能 NVMe SSD,HDD 上性能会急剧下降。Milvus 从 2.3 版本开始支持 DiskANN 索引。
4.4 索引选型决策树
三种索引各有所长,实际项目中怎么选?用这棵决策树:
索引选型决策树:
你的数据量是多少?
│
├── < 1 万条
│ → 不需要索引,直接暴力搜索(FLAT)
│ → Chroma 默认就是这样
│
├── 1 万 ~ 500 万条
│ │
│ ├── 对延迟要求极高(< 5ms)?
│ │ └── ✅ HNSW(内存够的前提下)
│ │
│ ├── 内存紧张?
│ │ └── ✅ IVF_PQ(牺牲一些精度换内存)
│ │
│ └── 不确定?
│ └── ✅ HNSW(默认最优选)
│
├── 500 万 ~ 5000 万条
│ │
│ ├── 内存 > 64 GB?
│ │ └── ✅ HNSW(M=32, ef=128)
│ │
│ ├── 内存 16-64 GB?
│ │ └── ✅ IVF_PQ(nlist=4096, nprobe=64)
│ │
│ └── 内存 < 16 GB?
│ └── ✅ DiskANN(需要 NVMe SSD)
│
└── > 5000 万条
│
├── 有大内存服务器(> 256 GB)?
│ └── ✅ HNSW(分布式部署 + 分片)
│
└── 普通服务器?
└── ✅ DiskANN(唯一可行方案)三大索引横向对比
| 维度 | HNSW | IVF_FLAT | DiskANN |
|---|---|---|---|
| 搜索延迟 | ~1ms | ~5ms | ~5-10ms |
| 召回率 | 95-99% | 90-98% | 95%+ |
| 内存占用 | 高 | 中 | 极低 |
| 建索引速度 | 中(增量) | 慢(全量) | 慢(全量) |
| 支持增量插入 | ✅ | ❌ 需重建 | ❌ 需重建 |
| 最大数据规模 | ~5000 万 | ~1 亿 | 10 亿+ |
| 硬件要求 | 大内存 | 中等内存 | NVMe SSD |
| Milvus 支持 | ✅ | ✅ | ✅(v2.3+) |
| Chroma 支持 | ✅(默认) | ❌ | ❌ |
在 Milvus 和 Chroma 中配置索引
# --- Milvus:创建 HNSW 索引 ---
from pymilvus import Collection
collection = Collection("my_docs")
index_params = {
"metric_type": "COSINE", # 距离度量
"index_type": "HNSW", # 索引类型
"params": {
"M": 16, # 每层最大邻居数
"efConstruction": 200 # 建索引搜索宽度
}
}
collection.create_index("embedding", index_params)
# 搜索时设置 ef
search_params = {"metric_type": "COSINE", "params": {"ef": 128}}
# --- Milvus:创建 IVF_FLAT 索引 ---
index_params = {
"metric_type": "COSINE",
"index_type": "IVF_FLAT",
"params": {"nlist": 1024} # 簇的数量
}
collection.create_index("embedding", index_params)
# 搜索时设置 nprobe
search_params = {"metric_type": "COSINE", "params": {"nprobe": 64}}
# --- Chroma:默认使用 HNSW,可调参数 ---
import chromadb
client = chromadb.Client()
collection = client.create_collection(
name="my_docs",
metadata={
"hnsw:space": "cosine", # 距离度量
"hnsw:M": 16, # 邻居数
"hnsw:construction_ef": 200, # 建索引宽度
"hnsw:search_ef": 128 # 搜索宽度
}
)实用建议:如果你刚开始,不需要纠结索引选型——用 HNSW 默认参数就好。等数据量真正到了百万级,再根据实际的延迟和内存表现来调优。过早优化是万恶之源。
本章小结
| 知识点 | 要点 |
|---|---|
| HNSW | 多层图索引,搜索最快(~1ms),但吃内存 |
| HNSW 参数 | M=16(邻居数)、ef=128(搜索宽度)是好的起点 |
| IVF | 先聚类分区再搜索,内存友好,召回率依赖 nprobe |
| IVF 参数 | nlist=√N,nprobe=nlist 的 1-10% |
| DiskANN | 索引放 SSD,亿级数据唯一方案,需要 NVMe |
| 选型原则 | 数据 < 500 万用 HNSW,内存紧张用 IVF_PQ,亿级用 DiskANN |
| 默认建议 | 不确定就用 HNSW 默认参数,够用再调 |
下一章预告:Chroma 实战——5 分钟上手向量数据库。终于要动手操作了!我们会从安装到 CRUD,完整走通 Chroma 的所有核心操作,包括嵌入式模式、元数据过滤、持久化存储,让你在 5 分钟内拥有一个可用的向量数据库。