Zvec Logo

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提升
GIST14 min22 min1.55×
Cohere8 min17.5 min2.21×

Zvec vs MS Index Build Time

可以看到,通过一系列的工程优化,相比微软的 DiskANN,Zvec 的构建速度提升了约 1.5 倍。

构建时的优化

  • 直写磁盘格式,不做二次转换。传统 DiskANN 的构建实现为流水线:先读一遍原始数据训练 PQ,再读一遍把图建到内存,最后再把内存里的图重新读出来、逐个节点打包成磁盘上的扇区格式。Zvec 把这条链路进行了拉直:图在内存里构建时用的就是搜索端要读的布局,dump 时一次性落盘,省掉了 "重读一遍、单线程再排一遍" 的中间环节,也省掉了临时文件的反复读写。
  • 连续内存存图,而不是每个节点单独申请。Zvec 用一整块连续缓冲区存放所有节点的向量和邻居,访问某个节点就是一次指针偏移,零额外分配、对 CPU 缓存友好。传统方案给每个节点单独分配一个动态数组,亿级规模下就是上亿次小块堆分配,既拖慢分配器又打散了缓存局部性。
  • PQ 并行训练 + 条纹锁。PQ 训练阶段每个维度段跑各自的 k-means(各段聚类互不相干,传统方案却排队串行);多线程建图时则用 65536 把条纹锁替代 "一个节点一把锁"。锁数组只占约 1MB 内存,两个线程撞上同一把锁的概率仅 1/65536,既避免了亿级节点各配一把锁的内存负担,又几乎不让线程互相排队。

检索吞吐

GIST 数据集:

指标Zvec L100Zvec L300Zvec L500MS L100MS L300MS L500
Recall@1 (%)93.9098.3098.8092.9098.0098.40
Recall@10 (%)91.1897.5198.8289.9697.1998.59
Recall@50 (%)86.4795.7497.8384.7395.3397.74
QPS(1线程)209.2117.186.3145.861.635.6
QPS(2线程)405.3233.5166.6301.8123.978.8
QPS(4线程)600.3255.0173.3600.3250.9153.7

Zvec vs MS Gist Test

Cohere 数据集:

指标Zvec L100Zvec L300Zvec L500MS L100MS L300MS L500
Recall@1 (%)98.2099.4099.6097.8099.5099.60
Recall@10 (%)98.3099.4999.6798.0999.5499.71
Recall@50 (%)96.4599.0999.5395.8799.0699.53
QPS(1线程)238.2142.994.3158.965.540.0
QPS(2线程)493.6270.8184.3325.5129.581.6
QPS(4线程)618.5267.5179.1630.2252.8160.8

Zvec vs MS Cohere Test

横向对比:在 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):

Zvec Memory Test

可以看到,同样是 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 MetaPQ 码本:256 个聚类中心的坐标,维度分段方式,全局质心
PQ Data压缩码:每个向量被压缩为 chunk_num 个字节
Vector向量数据:原始向量紧跟邻居列表,节点按扇区对齐排布
Key 和 EntryPoint主键映射和入口点列表

每个图节点在磁盘上长这样:[向量数据 | 邻居数量 | 邻居ID列表 | 填充到扇区边界]。小节点可以共享扇区(避免浪费),大节点可以跨多个扇区。这种布局的好处是:每次读一个节点,恰好是一次或几次完整的扇区读取,配合 O_DIRECT 直达 SSD,绕过 page cache,没有多余的内存拷贝。

如何建图

构建 DiskANN 索引分四步走:

  1. 训练 PQ 码本:随机抽样 200K 条向量,减去全局均值后按维度分段,每段独立跑 k-means 得到 256 个聚类中心。
  2. 构建 Vamana 图:为每个节点通过贪心搜索找到一组高质量邻居,这是计算量最大的一步。
  3. 裁剪:把超出 max_degree 的节点修剪回来。
  4. 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 协议开源,欢迎体验、反馈与贡献。