向量索引选型:HNSW、IVF-PQ、DiskANN 的三角权衡

举报
yd_232401777 发表于 2026/10/26 08:33:54 2026/10/26
【摘要】 向量库选型时最容易犯的错,是拿数据量当唯一指标。"百万级用 HNSW、上亿用 PQ"这种说法忽略了另外两个维度:内存预算和召回要求。这篇把三种主流索引的内存账算清楚,给出可落地的选型矩阵,以及几个常见的误判。

前言

向量检索的选型讨论,经常简化成一句话:"数据量小用 HNSW,数据量大用 IVF-PQ。"

这句话不算错,但它跳过了最关键的一步:"大"和"小"的界线在哪,取决于你有多少内存。

同样是 1000 万条 768 维向量,如果机器有 128GB 内存,HNSW 完全跑得动;如果只有 16GB,就必须上量化。所以数据量本身不是判据,数据量 × 单条内存占用 与 内存预算的比例才是。

这篇文章从内存账算起。

一、三种索引的本质差异

先给结论,再展开:

HNSWIVF-PQDiskANN
结构多层可导航小世界图倒排聚类 + 乘积量化内存图 + 磁盘向量
内存占用高(原始向量 + 图)低(压缩码)中(只有图在内存)
召回高中高(依 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查询时的候选队列长度召回↑、延迟↑

调优顺序很重要:

  1. 先定 M。它决定了图的质量上限,且影响内存。M=16 是常见起点,追求高召回可以上 32 或 48。
  2. 再把 efConstruction 调够,通常 200-500。这个参数只影响构建,不影响线上性能,值得给足。
  3. 最后调 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 万中等DiskANNSSD 换内存,召回接近 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 忽略了过滤条件的交互

带标量过滤的向量检索(比如"只在某个租户的文档里检索")和纯向量检索的性能特征完全不同。

如果过滤后剩下的候选很少,图索引可能因为连通性问题找不到足够的邻居,召回骤降。这类场景需要专门的方案:分区索引、或者先过滤再在子集上暴力搜索。

做了什么

  1. 指出索引选型的真实判据是"数据量 × 单条占用 vs 内存预算",而不是数据量本身
  2. 以 1000 万 × 768 维为例算出三者的内存账:HNSW 约 32GB、IVF-PQ 约 1GB、DiskANN 内存个位数 GB + 32GB 磁盘
  3. 讲清 HNSW 三个参数的调优顺序,以及构建期内存峰值约为稳态 1.5 倍
  4. 指出 HNSW 的删除是标记删除,频繁增删会导致索引膨胀
  5. 说明 IVF-PQ 的训练成本与分布漂移后需要重训的问题
  6. 给出六种场景的选型矩阵
  7. 列出四个常见误判:换索引要重建、过度压缩、只看平均召回、忽略过滤条件交互

一些体会

容量规划要把构建期算进去。 稳态 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() 那一步是最容易出问题的地方——训练样本必须来自实际数据分布。用随机数据训出来的码本,在真实文本向量上表现会明显变差。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。