DiskANN:让向量数据库装下上亿向量
摘要:Zvec 在最新版本中引入了 DiskANN 索引,把原始向量和图结构放到 SSD、只在内存中留一份 PQ 压缩地图,让上亿级向量的内存成本降低一个数量级,同时保持高召回。在 Cohere 100 万数据集上,相同召回率下 Zvec DiskANN 的单线程查询吞吐相比微软开源 DiskANN 提升 1.5×~2.4×,索引构建时间缩短约 36%~54%。
当内存装不下上亿向量
想象一下:给一家中型公司做企业级 RAG,把过去十年积累的合同、工单、会议纪要等历史资料建立向量索引,落到 embedding 上大约是 2.3 亿条 768 维向量。如果沿用向量检索最常见的 HNSW 索引,仅 FP32 原始数据就会吃掉近 650 GB 内存,再叠上图结构和运行时开销,一台 512 GB 内存的机器都难以装下。这一切,仅仅是让检索"能够跑起来"。
对于百万级数据,HNSW 确实是最简单高效的选择,微秒级延迟,简单直接。但当规模跨过千万级的门槛,内存成本开始成为系统中最不可忽视的开销。
DiskANN 的方案比较直接:既然内存装不下,那就不全往内存里放。通过把大部分数据放置到 SSD 上,只在内存中留一份 "压缩地图"(PQ 码本)。查询时先看地图决定方向,再去磁盘上取全精度数据做最终判断。上亿向量的内存占用从近 TB 级降到 GB 级,代价是每次查询多几次 SSD 随机读。在 NVMe SSD 普遍能提供 10 万 IOPS 的今天,这是一个合理的权衡。对于不同场景下该选哪种索引,可以参考下表:
| 场景 | 索引 | 理由 |
|---|---|---|
| 千万级以下、延迟至上 | HNSW | 全内存,微秒级响应 |
| 千万~上亿级、成本敏感 | DiskANN | 同等召回,内存降一个数量级 |
| 需要 100% 精确结果 | Flat | 暴力搜索,零信息损失 |
Zvec 把 DiskANN 做成了数据库内的原生索引类型。它和 HNSW、IVF 共享同一套 Builder/Streamer/Reducer 组件模型,同一套 Segment 生命周期。对用户来说,选择 DiskANN 还是 HNSW,只需要建表时换一个参数。
如何快速上手
创建一个 DiskANN 索引集合,只需要把 HnswIndexParam 换成 DiskAnnIndexParam:
import zvec
from zvec import DataType, VectorSchema, MetricType, Query
from zvec import DiskAnnIndexParam, DiskAnnQueryParam
schema = zvec.CollectionSchema(
name="massive_vectors",
vectors=[
VectorSchema(
"embedding",
DataType.VECTOR_FP32,
dimension=768,
index_param=DiskAnnIndexParam(
metric_type=MetricType.COSINE,
max_degree=64,
list_size=100,
),
),
],
)
coll = zvec.create_and_open(path="./diskann_example", schema=schema)查询时,一个 list_size 参数就能调节 "召回率 vs 速度" 的平衡:
results = coll.query(
queries=Query(
field_name="embedding",
vector=query_vector,
param=DiskAnnQueryParam(list_size=200),
),
topk=10,
)list_size 可以理解为 "搜索时愿意多走几步":值越大,beam search 探索的候选越多,召回越高,但磁盘 I/O 的次数也会越多。实测中 100~300 就能拿到 95% 以上的 Recall@10,不必为了最后几个百分点付出过高的代价。
性能:快多少?
为了获得性能指标,我们将 Zvec 的 DiskANN 实现与微软的 DiskANN 做了对比测试。
测试环境:阿里云 g9i 4xlarge(16 vCPU、64 GiB、PL2 SSD / 100000 IOPS)。
数据集合:GIST 100 万条 960 维向量,欧式距离,1000 条查询。 Cohere 100 万条 768 维向量,Cosine 距离,1000 条查询。
说明:查询测试时,通过 BFS 缓存 10000 个节点。
构建速度
| 数据集 | Zvec | 微软 DiskANN | 提升 |
|---|---|---|---|
| GIST | 14 min | 22 min | 1.55× |
| Cohere | 8 min | 17.5 min | 2.21× |

可以看到,通过一系列的工程优化,相比微软的 DiskANN,Zvec 的构建速度提升了约 1.5 倍。
构建时的优化
- 直写磁盘格式,不做二次转换。传统 DiskANN 的构建实现为流水线:先读一遍原始数据训练 PQ,再读一遍把图建到内存,最后再把内存里的图重新读出来、逐个节点打包成磁盘上的扇区格式。Zvec 把这条链路进行了拉直:图在内存里构建时用的就是搜索端要读的布局,dump 时一次性落盘,省掉了 "重读一遍、单线程再排一遍" 的中间环节,也省掉了临时文件的反复读写。
- 连续内存存图,而不是每个节点单独申请。Zvec 用一整块连续缓冲区存放所有节点的向量和邻居,访问某个节点就是一次指针偏移,零额外分配、对 CPU 缓存友好。传统方案给每个节点单独分配一个动态数组,亿级规模下就是上亿次小块堆分配,既拖慢分配器又打散了缓存局部性。
- PQ 并行训练 + 条纹锁。PQ 训练阶段每个维度段跑各自的 k-means(各段聚类互不相干,传统方案却排队串行);多线程建图时则用 65536 把条纹锁替代 "一个节点一把锁"。锁数组只占约 1MB 内存,两个线程撞上同一把锁的概率仅 1/65536,既避免了亿级节点各配一把锁的内存负担,又几乎不让线程互相排队。
检索吞吐
GIST 数据集:
| 指标 | Zvec L100 | Zvec L300 | Zvec L500 | MS L100 | MS L300 | MS L500 |
|---|---|---|---|---|---|---|
| Recall@1 (%) | 93.90 | 98.30 | 98.80 | 92.90 | 98.00 | 98.40 |
| Recall@10 (%) | 91.18 | 97.51 | 98.82 | 89.96 | 97.19 | 98.59 |
| Recall@50 (%) | 86.47 | 95.74 | 97.83 | 84.73 | 95.33 | 97.74 |
| QPS(1线程) | 209.2 | 117.1 | 86.3 | 145.8 | 61.6 | 35.6 |
| QPS(2线程) | 405.3 | 233.5 | 166.6 | 301.8 | 123.9 | 78.8 |
| QPS(4线程) | 600.3 | 255.0 | 173.3 | 600.3 | 250.9 | 153.7 |

Cohere 数据集:
| 指标 | Zvec L100 | Zvec L300 | Zvec L500 | MS L100 | MS L300 | MS L500 |
|---|---|---|---|---|---|---|
| Recall@1 (%) | 98.20 | 99.40 | 99.60 | 97.80 | 99.50 | 99.60 |
| Recall@10 (%) | 98.30 | 99.49 | 99.67 | 98.09 | 99.54 | 99.71 |
| Recall@50 (%) | 96.45 | 99.09 | 99.53 | 95.87 | 99.06 | 99.53 |
| QPS(1线程) | 238.2 | 142.9 | 94.3 | 158.9 | 65.5 | 40.0 |
| QPS(2线程) | 493.6 | 270.8 | 184.3 | 325.5 | 129.5 | 81.6 |
| QPS(4线程) | 618.5 | 267.5 | 179.1 | 630.2 | 252.8 | 160.8 |

横向对比:在 L=300 这个实用区间,Zvec 的单线程 QPS 接近甚至超过微软 DiskANN 的 2 倍(GIST 1.9×、Cohere 2.2×),且随着 list_size 增大优势还会拉开(L=500 时两个数据集均达到 2.4× 左右)。召回率上两者基本持平。
可以看到,优化的幅度会随线程数收敛:4 线程满负载下吞吐几乎持平(0.98×~1.13×)。从扩展性曲线可以看出:从单线程到 4 线程,微软 DiskANN 能加速到约 3.9×~4.3×(接近线性),而 Zvec 只有约 1.9×~2.9×(亚线性)。这不是 Zvec"扩展不动",而是两者的优势来源不同:Zvec 的单线程领先来自更低的单次查询延迟(异步 I/O 流水线让 CPU 和磁盘重叠、宽 beam 减少往返次数),所以 Zvec 可以在两三个线程时就已经把这块 PL2 SSD 的带宽吃得差不多了;微软 DiskANN 单查询延迟高、但每个线程相对更闲。在 4 线程处,两者最终都接近于磁盘的物理带宽。
检索时的优化
吞吐的提升不是来自某个单点优化,而是多个层面的优化叠加在一起产生的结果:
- 异步批量读盘。beam search 不是 "读一个节点、处理一个节点",而是每轮攒够一批待访问节点,通过 libaio 一次性提交,然后 "完成一个就收割一个"。先返回的节点立刻拿去算距离,剩下的 I/O 仍在内核里进行。CPU 在等待磁盘返回的间隙处理上一批已读回的节点,让计算和 I/O 相互重叠。作为对照,传统 DiskANN 在 Linux 上并不支持真正的异步:它每一跳都要阻塞等待这一批 I/O 全部完成,CPU 才能继续计算,等待期间只能空转。CPU 与磁盘能否真正重叠,是单查询延迟差距的主要来源。
- O(1) 的 "已访问" 标记。图遍历中每步都要问 "这个节点是不是已经来过了"。亿级图上每次搜索会问几万次。常规做法是一个 bitmap,但 bitmap 的问题是每次新查询都需要 memset 清零。Zvec 采用了 ByteMap 方案:每个节点对应一个字节,记录 "第几轮查询访问过我"。新查询只需把轮次加一,所有旧标记随之失效,O(1) 完成清零。只有 uint8 溢出回绕时才做一次真正的 memset。相比之下,传统实现用哈希集合记录访问过的节点,每次查询几百上千次的查、插都带着哈希探测的开销,查询结束还要逐个桶遍历清空。
- 宽 beam 少绕路。同样是走到目标,每跳一次就是一次磁盘往返,跳得越少越快。Zvec 会根据搜索宽度自适应地把 beam width 放大到 8~32,L=300 时大约 10 跳就能逼近目标;而窄 beam(传统默认 2、测试里也才 8)要绕差不多 37 跳才到,I/O 往返次数是 Zvec 的三四倍。
- 距离计算充分利用 CPU 指令集。精确距离是热路径上被反复调用的函数。Zvec 在运行时按 CPU 实际能力分发到 AVX-512、AVX2、SSE、NEON,在支持 AVX-512 的机器上比固定只走 AVX2 的实现快约 1.5 倍;传统实现则写死了一条 AVX2 路径,算力就会使用不上。
- 每次查询近乎零开销。Zvec 给每个线程配了本地的上下文池,所有缓冲区在加载时就预分配好,一次新查询只需清空复用、拷入 query 向量即可,全程零锁争用;传统实现每来一个查询都要从一个并发队列里加锁借出、再加锁归还临时工作区,高频调用下这点锁开销也会积少成多。
峰值内存:省在哪里
除了吞吐提升,DiskANN 的另一大优势主要体现在与 HNSW 的内存消耗对比上(FP32,单线程峰值内存,MB):

可以看到,同样是 FP32,HNSW 稳定地比 DiskANN 多吃约 8 倍内存。主要原因是,HNSW 把完整的图结构和 FP32 原始向量全部塞进 RAM(100 万 ×960 维的 GIST 约 4 GB,100 万 ×768 维的 Cohere 约 3.4GB),而 DiskANN 在内存里仅保留 PQ 表和 1MB 的访问过滤器。
如果把这个 8 倍放到上亿级场景中,HNSW 需要几百 GB 的内存,而 DiskANN 只要几十 GB。
整体设计:让 DiskANN 成为 Zvec 的一部分
Zvec 的存储以 Segment 为基本单元。DiskANN 加入时,遵照 Zvec 的规范,以一种新的索引类型接入现有框架。这意味着 DiskANN 天然继承了 Zvec 的写入、删除、导出、恢复和合并逻辑,不需要为磁盘索引单独处理一致性问题。
磁盘上长什么样
索引文件以 4096 字节扇区为基本单位(对齐 O_DIRECT),结构如下:
| 段 | 内容 |
|---|---|
| Meta | 元数据头:文档数、维度、入口点、最大度数等(恰好塞满一个 4KB 扇区) |
| PQ Meta | PQ 码本:256 个聚类中心的坐标,维度分段方式,全局质心 |
| PQ Data | 压缩码:每个向量被压缩为 chunk_num 个字节 |
| Vector | 向量数据:原始向量紧跟邻居列表,节点按扇区对齐排布 |
| Key 和 EntryPoint | 主键映射和入口点列表 |
每个图节点在磁盘上长这样:[向量数据 | 邻居数量 | 邻居ID列表 | 填充到扇区边界]。小节点可以共享扇区(避免浪费),大节点可以跨多个扇区。这种布局的好处是:每次读一个节点,恰好是一次或几次完整的扇区读取,配合 O_DIRECT 直达 SSD,绕过 page cache,没有多余的内存拷贝。
如何建图
构建 DiskANN 索引分四步走:
- 训练 PQ 码本:随机抽样 200K 条向量,减去全局均值后按维度分段,每段独立跑 k-means 得到 256 个聚类中心。
- 构建 Vamana 图:为每个节点通过贪心搜索找到一组高质量邻居,这是计算量最大的一步。
- 裁剪:把超出 max_degree 的节点修剪回来。
- PQ 编码:用码本把每个向量压成一串字节。
关键在第二步。对每个新节点,Vamana 做两件事:
- 先找候选(贪心搜索):从入口点(medoid,离所有向量质心最近的点)出发逐步逼近目标,每次跳到视野中最近的未探索邻居,直到无法更近,得到离目标最近的一批点。
- 再做筛选(RobustPrune):这是 Vamana 区别于普通贪心图的关键。它不取最近的 K 个点,而要求邻居之间 "方向多样"—— 像站在路口选指路牌,两块牌子指向几乎相同的方向就只留一块。具体来说,若候选 B 的方向已被已选邻居 A"遮挡"(A 到 B 比我到 B 更近)就跳过 B,松紧度由参数 α=1.2 控制。这样图能用更少的边覆盖更多方向,搜索也就更少跳数到达目标。
- 反向边:选完邻居后,给每个被选中的邻居也加一条回来的边;若对方邻居因此超过 1.3×max_degree,就对它再裁剪一次。这避免了 "只能从 A 到 B、不能从 B 到 A" 的死角。
多线程构建时的锁设计同样做了优化:65536 把互斥锁组成条纹锁池,节点 ID 取低 16 位做哈希。不同节点几乎总是落在不同锁上(冲突率 1/65536),既不用给每个节点分配一把锁(亿级场景下锁本身就是内存负担),也不会因为粗粒度锁让线程排队等待。
PQ 训练为什么能并行
传统做法是把 768 维切成若干段,逐段串行跑 k-means。但各段之间的聚类完全独立,即第 1 段的聚类中心和第 2 段没有任何关系。Zvec 的 MultiChunkCluster 直接把各段分给不同线程并行训练,每段独立跑到收敛。当维度高、段数多时(768 维默认切 384 段),并行收益非常可观。
搜索:一次查询里发生了什么
一次 DiskANN 查询分三个阶段:
-
阶段一:准备 "地图" 查询到达时,先算出 query 到每个 PQ 聚类中心的距离,生成一张 [chunk_num×256] 的查找表 —— 这就是后续路由的 "地图"。之后估算任何节点的 PQ 距离,只需按它的 PQ 编码去表里查几个数相加。
-
阶段二:Beam Search 从入口点出发,维护一个按 PQ 距离排序、大小为 list_size 的候选队列。每轮迭代取出队头最有希望的候选:在缓存里就直读内存,不在就把扇区偏移凑成一批、一次性异步 I/O 读回。
拿到节点数据后做两件事:用完整向量算精确距离、放进 topk 堆(最终结果来源),同时用 PQ 地图估算它所有邻居的距离、把有潜力的塞进候选队列。如此循环,直到队列里再没有比当前 TopK 最差结果更有希望的候选。
核心取舍是:PQ 距离做方向判断,精确距离做裁判。PQ 查表加法极快但有噪声,负责不漏掉好候选;精确距离走磁盘 I/O,负责保证结果质量。
-
阶段三:缓存兜底 入口的前 2 到 3 跳几乎每次都走相同的路(入口点及其周围邻居),这些节点在索引加载时就用 BFS 缓存进了内存。命中缓存意味着 beam search 开头几乎没有磁盘 I/O,对 p99 延迟的改善很明显。
-
几个工程细节
- FP16 支持。通过 C++ 模板在 FP32 和 FP16 之间统一接口。FP16 让每个向量的磁盘体积减半、每次 I/O 读取的数据量减半。实测 Recall@10 只掉了 0.16 个百分点(从 99.49 到 99.33),对大多数场景来说等于免费省了一半存储。
- 编译期分派的访问过滤器。传统做法用虚函数切换 BloomFilter、BitMap、ByteMap,但图遍历中这个函数每次搜索被调用几万次,虚函数的间接跳转让编译器没法内联优化。Zvec 用宏展开做编译期静态分派,让编译器可以彻底内联,消除每次调用的额外开销。
- 可观测的性能指标。每次查询自动采集:总耗时拆分为 I/O 等待和 CPU 计算,磁盘页读取次数,距离计算次数,缓存命中率,图遍历跳数。当延迟偏高时,对比 io_us 和 cpu_us 的比例即可判断该加缓存还是优化距离函数。
- 动态 I/O 优化框架:Zvec 在运行时,会自动探测并选择当前环境中最合适的 I/O 后端,并通过 fallback 进行降级兼容。对于 libaio,实现了框架来进行动态监测和 dlopen 加载。
参数调优速查
| 参数 | 作用 | 默认 | 调法 |
|---|---|---|---|
| max_degree | 每个节点最多连几条边 | 64 | 调大→召回更高,但节点更大、I/O 更多 |
| list_size(建图) | 建图时的搜索宽度 | 100 | 调大→图质量更好,但建得更慢 |
| list_size(搜索) | 查询时的 beam 宽度 | 200 | 调大→召回更高,但磁盘 I/O 更多 |
| cache_node_num | 缓存多少热节点 | 0 | 建议设总量的 1–10%,对延迟立竿见影 |
实用建议是:max_degree=64、cache_node_num = 总量 ×5%、搜索 list_size 从 100 开始往上调,观察 recall-QPS 曲线找到业务能接受的最佳平衡点。
总结
Zvec DiskANN 的核心思路可以概括为:把磁盘图搜索做成向量数据库的原生能力,用一份压缩地图支撑上亿级规模。
对用户来说,它带来的改变很简单:
- 建表时写 DiskAnnIndexParam,上亿级向量的内存占用从近 TB 降到 GB;
- 查询时调 list_size,一个选项搞定 recall-latency 权衡;
- 和 HNSW、IVF 共享同一套 API,切换索引类型只需改一行配置;
- FP16 再省一半存储,召回几乎无损。
在引擎内部,它靠 Vamana 图构建、多块 PQ 并行训练、4K 扇区对齐布局、BFS 层级缓存、ByteMap O(1) 清零等多项优化的协同,来保证 "相同召回率下吞吐提升、内存降一个数量级"。
HNSW 适合内存充足、追求低延迟的场景,DiskANN 适合规模庞大、成本敏感的场景。两者互相补充,为用户的不同应用提供了更贴近实际的选择。
后续规划
当前版本已经在 Linux (x86_64) libaio 上实现了端到端流程,接下来还会继续拓展 I/O 后端与平台覆盖:
- io_uring 后端:在支持的新内核上启用 io_uring,替代 libaio 成为默认异步 I/O 路径。
- Windows:接入 IOCP 作为异步 I/O 后端,让 DiskANN 在 Windows 上也能拿到不错的吞吐。
- MacOS (Darwin):适配 kqueue 的异步读盘方式,方便开发者在 Mac 本地直接跑通调试与评测。
- ARM 架构:覆盖 ARM 服务器(如倚天、Graviton)等。
- 移动端:适配 iOS / Android 内存受限环境,支持本地向量检索。
敬请期待!
Zvec 以 Apache 2.0 协议开源,欢迎体验、反馈与贡献。