向量索引选型:HNSW、IVF-PQ、DiskANN 的三角权衡
前言
向量检索的选型讨论,经常简化成一句话:"数据量小用 HNSW,数据量大用 IVF-PQ。"
这句话不算错,但它跳过了最关键的一步:"大"和"小"的界线在哪,取决于你有多少内存。
同样是 1000 万条 768 维向量,如果机器有 128GB 内存,HNSW 完全跑得动;如果只有 16GB,就必须上量化。所以数据量本身不是判据,数据量 × 单条内存占用 与 内存预算的比例才是。
这篇文章从内存账算起。
一、三种索引的本质差异
先给结论,再展开:
| HNSW | IVF-PQ | DiskANN | |
|---|---|---|---|
| 结构 | 多层可导航小世界图 | 倒排聚类 + 乘积量化 | 内存图 + 磁盘向量 |
| 内存占用 | 高(原始向量 + 图) | 低(压缩码) | 中(只有图在内存) |
| 召回 | 高 | 中高(依 nprobe) | 高 |
| 构建成本 | 高(要建图) | 中(要训练码本) | 高(要建图 + 落盘) |
| 增量更新 | 友好(插入即可) | 差(分布漂移需重训) | 中 |
| 适合规模 | 十万 ~ 千万 | 百万 ~ 十亿 | 千万 ~ 十亿 |
这三个不是"谁更好",是在内存、召回、成本三者之间选了不同的角。
二、先算一笔内存账
以 1000 万条、768 维、float32 为例。这个规模很典型——大概是几十万篇文档经过切分后的量级。
2.1 原始向量
1000 万 × 768 维 × 4 字节 = 30.72 GB
光存向量就要 30GB。这一步跟索引无关,是硬成本。
2.2 HNSW 的额外开销
HNSW 的图结构,每个节点在第 0 层有 2M 条边(M 是每个节点的连接数,常用 16 或 32)。每条边存一个 4 字节的 id。
M = 16 时: 第 0 层:1000 万 × 32 条边 × 4 字节 = 1.28 GB 上层:约为第 0 层的 1/M,可忽略
所以 HNSW 总量:
30.72 GB(向量) + 1.28 GB(图) ≈ 32 GB
注意构建期间内存还要更高——建图时需要额外的候选队列和临时结构,实际峰值常见是稳态的 1.5 倍左右。32GB 稳态意味着构建时可能要 48GB,这个在容量规划里经常被漏掉。
2.3 IVF-PQ 的压缩效果
PQ(乘积量化)把 768 维切成 m 段,每段用一个 256 项的码本编码成 8 bit。
m = 96 时(768 / 96 = 每段 8 维): 每条向量占用 = 96 段 × 1 字节 = 96 字节 1000 万 × 96 字节 = 0.96 GB
加上粗聚类中心(nlist × 768 × 4,nlist=4096 时约 12 MB)和 PQ 码本(不到 1 MB),总量约 1 GB。
对比 HNSW 的 32 GB,压缩比约 30 倍。
代价是召回下降。PQ 是有损压缩,每段只保留 8 bit 的信息,向量间距离只能近似计算。用 nprobe(探测多少个聚类)和 refine(对候选重算精确距离)能补回来一部分,但补不回全部。
2.4 DiskANN
DiskANN 的思路是把原始向量放 SSD,内存里只保留图结构和一个压缩后的向量副本。
内存:图结构(约 1-2 GB)+ 压缩向量(约 1 GB) 磁盘:30.72 GB 原始向量
内存占用降到个位数 GB,但查询时会有磁盘随机读。SSD 上延迟可以接受,机械盘上基本不可用。
三、HNSW:参数怎么调
HNSW 有三个关键参数,作用各不相同:
| 参数 | 作用 | 调大的影响 |
|---|---|---|
M | 每个节点的连接数 | 召回↑、内存↑、构建慢 |
efConstruction | 建图时的候选队列长度 | 图质量↑、构建慢、不影响查询 |
efSearch | 查询时的候选队列长度 | 召回↑、延迟↑ |
调优顺序很重要:
- 先定
M。它决定了图的质量上限,且影响内存。M=16 是常见起点,追求高召回可以上 32 或 48。 - 再把
efConstruction调够,通常 200-500。这个参数只影响构建,不影响线上性能,值得给足。 - 最后调
efSearch,这是线上唯一可调的旋钮。从 64 开始往上试,找到召回和延迟的拐点。
一个常见误区:把 efSearch 设得比 efConstruction 还大。这没有意义——图里本来就没有那么多候选边可探索。
3.1 HNSW 的删除问题
这是个实际使用中会踩的坑。HNSW 的图结构决定了删除一个节点会破坏连通性,所以大多数实现不真删,而是标记删除(tombstone)。
后果是:删除的向量仍然占内存、仍然参与遍历,只是不出现在结果里。频繁增删的场景下,索引会持续膨胀,需要定期重建。
如果你的场景是"每天全量重建",这个问题不大;如果是"持续增删",要把重建周期考虑进去。
四、IVF-PQ:训练是隐藏成本
IVF-PQ 的"训练"指的是:用一批样本数据跑 k-means 得到粗聚类中心,再为每个子空间训练码本。
这带来两个约束:
第一,训练样本要有代表性。 如果码本是在通用语料上训的,而实际数据是垂直领域文本,量化误差会明显变大。建议用实际数据的一个随机子集训练。
第二,分布漂移后需要重训。 这是 IVF-PQ 最麻烦的地方。你的文档库在持续更新,新内容的语义分布逐渐偏离训练时的分布,召回会缓慢劣化——而这种劣化是渐进的,很难被察觉。
实践上的处理:定期(比如每月)用最新数据重训码本,并做一次召回评估对比。或者干脆在数据更新频繁的阶段避开 PQ。
4.1 nprobe 的取舍
nprobe 决定查询时扫描多少个聚类。它和召回、延迟的关系近似线性:
nprobe = 1:只看最近的聚类,快但漏nprobe = nlist / 10:常见平衡点nprobe = nlist:退化成暴力搜索
调 nprobe 时要看的不是绝对延迟,而是召回-延迟曲线的拐点。在拐点之前,增加 nprobe 带来的召回提升很划算;过了拐点,延迟涨得比召回快。
五、选型矩阵
把三个维度放在一起:
| 场景 | 数据量 | 内存约束 | 推荐 | 理由 |
|---|---|---|---|---|
| 原型 / 小库 | < 50 万 | 宽松 | HNSW | 简单、无需训练、召回最好 |
| 生产 / 中等库 | 50 万 - 1000 万 | 有预算 | HNSW | 召回优先,内存可接受 |
| 大规模 / 内存紧 | > 1000 万 | 严格 | IVF-PQ | 压缩 30 倍,召回用 nprobe 补 |
| 大规模 / 内存中等 | > 1000 万 | 中等 | DiskANN | SSD 换内存,召回接近 HNSW |
| 频繁增删 | 任意 | 任意 | 重新评估 | HNSW 删除不友好,PQ 需重训 |
| 极高召回要求 | 任意 | 任意 | HNSW + 精确重算 | 用索引粗排,候选集精确算 |
最后一行值得展开:索引负责"缩小候选集",不负责"给出最终答案"。即使是 HNSW,通常也会把 top-100 拿回来做一次精确距离重算,再取 top-10。这一步能消掉索引近似带来的大部分误差。
六、几个常见误判
6.1 "先上 HNSW,不够再换"
听起来很合理,但换索引不是配置项——换索引意味着重建全量索引。千万级数据重建要几小时到十几小时,期间服务怎么办?
更稳的做法是在数据量还小的时候就把索引层抽象好,让"重建 + 切换"变成可演练的流程,而不是紧急救火。
6.2 "压缩比越高越好"
PQ 的压缩比可以调到很高(m 更小,每段维度更多,但码本表达能力下降)。过度压缩的后果是语义相近的向量被量化到同一个码字,它们之间的距离变成 0。表现出来就是"检索结果里出现一堆差不多的条目,但真正对的那条没了"。
判断方法:从库里采样一批向量,量化后再算距离,看距离分布的失真程度。失真超过一定范围就该降低压缩比。
6.3 "召回率只看平均值"
平均召回 0.9 听起来不错,但如果分布是"大部分 query 召回 1.0,少部分 query 召回 0.3",实际体验很差——用户正好是那少部分时,得到的是完全错误的结果。
评估时要看分位数,不只看均值。关注 P10 召回比关注平均召回更有意义。
6.4 忽略了过滤条件的交互
带标量过滤的向量检索(比如"只在某个租户的文档里检索")和纯向量检索的性能特征完全不同。
如果过滤后剩下的候选很少,图索引可能因为连通性问题找不到足够的邻居,召回骤降。这类场景需要专门的方案:分区索引、或者先过滤再在子集上暴力搜索。
做了什么
- 指出索引选型的真实判据是"数据量 × 单条占用 vs 内存预算",而不是数据量本身
- 以 1000 万 × 768 维为例算出三者的内存账:HNSW 约 32GB、IVF-PQ 约 1GB、DiskANN 内存个位数 GB + 32GB 磁盘
- 讲清 HNSW 三个参数的调优顺序,以及构建期内存峰值约为稳态 1.5 倍
- 指出 HNSW 的删除是标记删除,频繁增删会导致索引膨胀
- 说明 IVF-PQ 的训练成本与分布漂移后需要重训的问题
- 给出六种场景的选型矩阵
- 列出四个常见误判:换索引要重建、过度压缩、只看平均召回、忽略过滤条件交互
一些体会
容量规划要把构建期算进去。 稳态 32GB 不代表 32GB 内存够用。建图时需要额外的临时结构,峰值可能是 1.5 倍。这个数字在选机器配置的时候必须算,否则会在第一次重建时 OOM。
索引是粗排,不是终点。 几乎所有生产方案都是"索引取 top-N,精确重算取 top-K"。理解这一点,就不会执着于把索引召回从 0.95 调到 0.99——那部分误差用重算消除更便宜。
IVF-PQ 的真实成本在运维,不在查询。 查询又快又省内存,但你要维护重训流程、要监控分布漂移、要在数据更新时做召回评估。这些是持续的人力成本,选型时容易只看技术指标。
过滤 + 向量是另一个问题。 带强过滤条件的检索,性能特征和纯向量检索差别很大。如果你的场景里过滤条件很严格(比如多租户隔离),不要假设通用索引能直接套用。
代码
用 FAISS 对比三种索引的构建方式,以及内存估算函数:
import faiss
import numpy as np
d, n = 768, 10_000_000
# ---- 内存估算(字节)----
def bytes_flat(n, d):
return n * d * 4
def bytes_hnsw(n, d, M=16):
# 向量本体 + 第 0 层 2M 条边(4 字节 id)
return n * d * 4 + n * 2 * M * 4
def bytes_ivfpq(n, m=96):
# m 段 × 1 字节
return n * m
print(f"Flat : {bytes_flat(n, d) / 1e9:.1f} GB")
print(f"HNSW : {bytes_hnsw(n, d) / 1e9:.1f} GB")
print(f"IVF-PQ : {bytes_ivfpq(n) / 1e9:.2f} GB")
# ---- 构建 ----
xb = np.random.random((n, d)).astype('float32')
# HNSW
index = faiss.IndexHNSWFlat(d, 16) # M=16
index.hnsw.efConstruction = 200
index.add(xb)
# IVF-PQ(需要先训练)
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFPQ(quantizer, d, 4096, 96, 8) # nlist=4096, m=96, nbits=8
index.train(xb[:500_000]) # 用样本训练码本
index.add(xb)
# 查询:先取 top-100,再精确重算
index.nprobe = 32
D, I = index.search(xq, 100)
# 对 I 里的候选做精确距离重算,再取 top-10
index.train() 那一步是最容易出问题的地方——训练样本必须来自实际数据分布。用随机数据训出来的码本,在真实文本向量上表现会明显变差。
- 点赞
- 收藏
- 关注作者
评论(0)