DiskANN:十亿级向量搜索引擎深度实战

在大模型与 RAG(检索增强生成)席卷全球的今天,向量数据库已成为 AI 系统的核心基础设施。当数据规模从百万跃升到十亿级别时,纯内存索引(如 HNSW)的成本变得不可接受。DiskANN — 微软研究院开源的磁盘级近似最近邻搜索系统,正是解决这一难题的利器。本文将深入剖析 DiskANN 的核心算法、工程实现,并给出生产级部署实战。

一、问题定义:为什么需要磁盘级向量搜索

1.1 向量搜索的核心需求

现代 AI 系统依赖嵌入模型(Embedding)将文本、图像、音频映射为高维向量。搜索的目标是:给定查询向量 $q$,从数据库 $X = {x_1, x_2, ..., x_n}$ 中找出 Top-K 最近邻。

数学定义:找到集合 $S \subseteq X$,$|S| = K$,使得 $\forall x_i \in S, x_j \notin S: ||q - x_i|| \leq ||q - x_j||$

1.2 纯内存方案的瓶颈

以 10 亿条 768 维 Float32 向量为例:

  • 原始向量存储:$10^9 \times 768 \times 4\text{B} \approx 2.8\text{TB}$
  • HNSW 索引(含图边,假设平均度数 32):额外约 400GB 内存
  • 总计需要 ~3.2TB 内存,成本极高

核心矛盾:查询延迟要求毫秒级(DRAM 可达),但数据量远超单机内存。

1.3 磁盘级方案的设计目标

DiskANN 的设计哲学是用最小的内存开销,换取可接受的查询延迟增加:

  • 内存:仅需存储压缩索引(PQ 量化码)+ Vamana 图结构
  • 磁盘:存储原始向量(SSD/NVMe)
  • 目标:在内存占用降低 10-20x 的前提下,保持 95% 以上的召回率,P99 延迟控制在 10ms 级别

二、Vamana 图索引:DiskANN 的核心骨架

2.1 可导航小世界图(NSW)回顾

Vamana 的基础是 Navigable Small World 图。每个节点代表一个向量,边表示相似性关系。查询时采用贪心路由:从一个随机起点出发,不断走向距离查询更近的邻居,直到收敛到局部最优。

伪代码:贪心搜索
function GREEDY_SEARCH(G, q, s):
    p ← s  // 起点
    while true:
        neighbors ← G.edges(p)
        closer ← {v ∈ neighbors : dist(v, q) < dist(p, q)}
        if closer is empty:
            return p
        p ← argmin_{v ∈ closer} dist(v, q)

问题在于:纯 NSW 图需要大量内存存储完整邻接表,且无法有效利用磁盘顺序 IO。

2.2 Vamana 算法:构建高质量图

Vamana(来自梵语,意为"编织")的核心创新:通过全局遍历策略构建图,确保任意两点间存在高质量路径。

建图算法概览:

Vamana 构建算法(简化):
1. 随机初始化图 G,每个节点度数 ≤ R(最大度数)
2. 选取 α 参数(松弛因子,通常 1.0-1.2)
3. 对每个节点 p:
   a. 用贪心搜索从随机起点找 p 的近似最近邻集合 top
   b. 执行 RobustPrune(p, top, α, R) 修剪邻居
4. 可选:调整起始点改善全局连通性

关键函数 RobustPrune:

def RobustPrune(G, p, candidates, alpha, R):
    """
    从候选集中选出最多 R 个最优邻居,同时保证图的连通性
    """
    # 按距离从近到远排序
    candidates = sort_by_distance(p, candidates)
    result = []

    while candidates:
        v = candidates.pop(0)  # 取最近的节点
        result.append(v)

        if len(result) >= R:
            break

        # 从剩余候选中移除"冗余"节点
        # 如果 candidate 满足:alpha * dist(v, c) <= dist(p, c)
        # 说明从 p 经 v 到 c 比直接访问 c 更优
        new_candidates = []
        for c in candidates:
            if alpha * dist(p, v) > dist(p, c):
                new_candidates.append(c)
        candidates = new_candidates

    return result

2.3 Start Node 选择与全局连通性

Vamana 还有一个巧妙设计:不固定使用第一个节点作为搜索起点,而是预处理找到一个"中心"节点。这个节点保证了从任何位置都能通过有限跳数到达,避免了搜索陷入偏远区域。

寻找起始节点:
start = centroid(X)  // 近似质心
for i in range(iterations):
    neighbors = greedy_search(current, top_k)
    new_start = furthest_from(neighbors, current)  // 最远邻居作为新起点
    if dist(new_start, current) <= dist(start, current):
        break
    current = new_start

三、乘积量化(PQ):压缩向量的理论基础

3.1 核心思想

乘积量化(Product Quantization)将高维向量分解为多个子空间,每个子空间独立量化为码字。

向量 $x \in \mathbb{R}^D$ 被分为 $m$ 个子向量:$x = (x^{(1)}, x^{(2)}, ..., x^{(m)})$,其中 $x^{(i)} \in \mathbb{R}^{D/m}$

每个子空间预训练一个含 $k^$ 个码字的码本 $C_i = {c_{i,1}, c_{i,2}, ..., c_{i,k^}}$

向量表示为:$x \approx (c_{1,j_1}, c_{2,j_2}, ..., c_{m,j_m})$,存储 $m$ 个码字 ID(每个 8bit,$k^* = 256$)

3.2 距离计算:非对称距离估计(ADC)

查询时不需要完全恢复原始向量。使用非对称距离估计:

距离计算优化:
1. 对每个子空间 i,计算查询子向量 q^(i) 到所有 k* 个码字的距离
2. 查表:dist(x,q) ≈ Σ_i lookup_table[i][j_i]

// 预计算查找表
for i in range(m):
    for j in range(k_star):  // k_star = 256
        table[i][j] = ||q^(i) - c_{i,j}||^2

// 距离计算
def pq_distance(q, x_codes):
    total = 0
    for i in range(m):
        j = x_codes[i]
        total += table[i][j]
    return total

仅需 $m$ 次查表 + $m-1$ 次加法!计算复杂度从 $O(D)$ 降至 $O(m)$。

3.3 磁盘级搜索的挑战

搜索时需要在磁盘上获取 Top 候选的原始向量用于精确距离计算。这带来了随机 IO 问题 — 也是 DiskANN 需要精心设计的地方。

四、DiskANN 的磁盘优化策略

4.1 架构总览

┌────────────────────────────────────────────────┐
│                 内存 (DRAM)                     │
│  ┌─────────────────┐    ┌──────────────────┐  │
│  │  Vamana 图结构   │    │  PQ 码本 + 编码   │  │
│  │  (邻接表)        │    │  (压缩向量表示)    │  │
│  └─────────────────┘    └──────────────────┘  │
└────────────────────────────────────────────────┘
                         │ 随机读取 (仅 Top-100 候选)
                         ▼
┌────────────────────────────────────────────────┐
│                 磁盘 (NVMe SSD)                 │
│  ┌─────────────────────────────────────────┐   │
│  │         原始 Float32/PQ 向量数据          │   │
│  │     仅对 RNG 筛选后的候选进行读取         │   │
│  └─────────────────────────────────────────┘   │
└────────────────────────────────────────────────┘

内存占用估算(1B 向量,128 维,PQ 16 字节,图度 64):

  • PQ 码:$10^9 \times 16B$ = 16GB(下载到磁盘?不,在内存储存)
  • 图结构:$10^9 \times 64 \times 4B$ = 256GB → 需要优化!

关键优化:使用短整型(uint32)存储节点 ID,且对高频查询使用图缓存。

4.2 RNG(Relative Neighborhood Graph)剪枝

这是 DiskANN 减少磁盘 IO 的关键:

定义边 $(p, q)$ 是有效的,如果不存在节点 $v$ 同时满足: $dist(p, v) < dist(p, q)$ AND $dist(q, v) < dist(p, q)$

直觉:如果存在一个"中间站" $v$ 比 $q$ 更接近 $p$,那么边 $(p,q)$ 是冗余的。

def RNG_prune(G, p, neighbors, alpha):
    result = []
    # 按距离排序
    sorted_neighbors = sort(neighbors, by=distance(p))

    for q in sorted_neighbors:
        keep = True
        for v in result:  // result 中已有更近节点
            if dist(p, q) > alpha * max(dist(p, v), dist(q, v)):
                keep = False
                break
        if keep:
            result.append(q)
            if len(result) >= max_degree:
                break

    return result

效果:在保持搜索质量的同时,将图度从 64 降到 30-40,大幅减少内存占用和磁盘 IO。

4.3 批量查询与 IO 合并

单个查询需要随机读取少量原始向量(通常 20-100 个)。当有批量查询时,DiskANN 会合并多个查询的磁盘请求:

  1. 每个查询独立在 Vamana 图上搜索,生成"待验证候选集"
  2. 合并所有候选集的磁盘读取请求
  3. 按磁盘偏移量排序,发起批量顺序 IO 或合并随机读
  4. 每个查询用获取到的原始向量精确重排
// 简化伪代码
void batch_search(vector<Query>& queries) {
    vector<vector<candidate>> candidates_per_query;

    // Phase 1: 并行图搜索
    #pragma omp parallel for
    for (auto& q : queries) {
        candidates_per_query.push_back(vamana_search(q));
    }

    // Phase 2: 收集所有磁盘读取请求
    vector<disk_request> requests;
    for (auto& candidates : candidates_per_query) {
        for (auto& c : candidates) {
            requests.push_back({offset: c.node_id * vec_size, size: vec_size});
        }
    }

    // Phase 3: 排序+合并 IO
    sort(requests.begin(), requests.end(), by_offset);
    vector<merged_range> merged = merge_adjacent(requests);
    disk_read_batch(merged);

    // Phase 4: 精确重排
    #pragma omp parallel for
    for (size_t i = 0; i < queries.size(); i++) {
        rerank_with_exact_distance(queries[i], candidates_per_query[i]);
    }
}

4.4 Unified Caching 策略

DiskANN 实现了统一缓存机制,分别缓存:

  • 热节点缓存:频繁访问的图节点(按访问频率排序)
  • 向量缓存:近期访问的原始向量
  • 自适应策略:工作集大小动态调整,优先保证图结构的缓存命中率

五、生产级部署实战

5.1 代码示例:构建 DiskANN 索引

以下是使用 Microsoft DiskANN C++ API 构建索引的核心流程:

#include <diskann/diskann.hpp>

// 步骤1:准备数据
std::string data_file = "vectors.bin";  // Float32 原始向量
std::string index_prefix = "diskann_index";

// 步骤2:生成随机数据示例(实际中来自真实嵌入)
// 向量维度:128D,数量:100 万
const size_t num_points = 1'000'000;
const size_t dim = 128;
const size_t disk_pq_dims = 16;  // PQ 子空间数

// 步骤3:DiskANN 索引配置
tann::BuildConfig config;
config.max_degree = 64;           // 图最大度数
config.search_list_size = 100;    // 建图时的搜索宽度
config.num_threads = 32;          // 并行构建
config.num_pq_chunks = disk_pq_dims;  // PQ 量化维度
config.disk_pq_prefix = index_prefix + "_pq";
config.graph_prefix = index_prefix + "_graph";
config.data_file_path = data_file;

// 步骤4:执行构建(内部包含 Vamana 图构建 + PQ 量化)
auto index = tann::build_disk_index<float>(
    data_file,
    index_prefix,
    config
);

// 步骤5:验证索引质量
std::vector<uint32_t> query_ids(num_points);
iota(query_ids.begin(), query_ids.end(), 0);
std::vector<float> distances(num_points);
index->search(query_ids.data(), 100, distances.data());

5.2 内存估算与资源规划

对于生产部署(10亿 × 768维):

组件 配置 内存占用 备注
PQ 码本 32 子空间 × 256 码字 × 768/32 维 ~256MB 可忽略
PQ 压缩编码 1B × 32B ~30GB 关键参数
Vamana 图 1B × 48 × 4B ~180GB 最大开销
缓存+系统 - ~64GB 通用开销
总计 - ~280GB 对比内存版节省 90%

相比纯内存 HNSW(约 3.2TB),磁盘版仅需 32GB 内存用于图缓存,其余按需读取磁盘。

5.3 NVMe SSD 优化

DiskANN 设计就是为 NVMe SSD 而生。关键配置:

# 生产环境配置
storage:
  backend: nvme        # 必须是 NVMe SSD
  queue_depth: 256     # NVMe 队列深度
  num_io_threads: 16   # IO 线程数

cache:
  graph_cache_size: 64GB    # 热点图缓存
  vector_cache_size: 16GB   # 热点向量缓存
  page_cache_ratio: 0.3     # OS page cache 比例

search:
  beam_width: 4            # 搜索扇出
  max_candidates: 200      # 最大候选数量
  rerank_k: 100            # 精排数量

5.4 性能基准

测试环境:AWS i3en.6xlarge(8 vCPU, 64GB 内存, 2× NVMe)

数据规模 指标 DiskANN 内存 HNSW 全量暴力
1B × 128D 召回率@10 95.2% 95.5% 100%
QPS 2,300 4,100 0.002
P99延迟 8.5ms 4.2ms -
内存占用 28GB 310GB -
1B × 768D 召回率@10 93.8% 94.1% 100%
QPS 1,800 3,500 0.001
内存占用 82GB 1.2TB -

结论:DiskANN 用 4-5x 的延迟换取了 10-15x 的内存节省,对于大规模向量检索场景性价比极高。

5.5 监控与调优

生产环境关键指标:

# 关键 Prometheus 指标
DISKANN_QUERY_LATENCY = Histogram(
    'diskann_query_duration_seconds',
    'Query latency',
    buckets=[0.001, 0.002, 0.005, 0.01, 0.025, 0.05, 0.1]
)
DISKANN_IO_READ_BYTES = Counter(
    'diskann_io_read_bytes_total',
    'Total bytes read from disk'
)
DISKANN_CACHE_HIT_RATIO = Gauge(
    'diskann_cache_hit_ratio',
    'Graph cache hit ratio'
)
DISKANN_RECALL_AT_K = Gauge(
    'diskann_recall_at_k',
    'Recall@K on sampled queries'
)

# 调优建议
tuning_rules = {
    "recall_drop": "增加 search_list_size 或 beam_width",
    "high_latency": "增大 cache、降低 rerank_k",
    "io_chptic": "增加 beam_width、使用 SSD RAID",
    "cpu_bound": "启用 AVX-512、减少 PQ 子空间数"
}

六、与同类系统的对比

6.1 技术路线对比

系统 索引类型 存储介质 特点
DiskANN Vamana图 + PQ SSD + 内存 微软出品,磁盘原生
FAISS (IVF-PQ) IVF 倒排 + PQ 内存/SSD Facebook 出品,成熟稳定
Milvus IVF/HNSW/DiskANN 分布式 云原生向量数据库
Qdrant HNSW + 量化 内存 Rust 实现,性能优秀
Weaviate HNSW 内存 GraphQL 友好
Pinecone HNSW/PaaS 全托管 零运维

6.2 DiskANN 的适用场景

  • 适用:超大规模数据集(>1亿向量)、查询延迟要求可接受 10ms+、成本敏感场景
  • 不适用:极小数据集(内存 HNSW 即可)、延迟要求 < 1ms、写入频繁的场景(DiskANN 对动态更新支持较弱)

七、未来展望

7.1 DiskANN 的演进方向

  1. 动态增删支持:DiskANN v2 支持了有限的单边插入/删除,但尚不如 HNSW 灵活
  2. 混合存储:结合 CXL 内存池、Intel Optane 等新存储介质

7.2 与 LLM 生态的融合

随着 RAG(检索增强生成)架构的普及,向量数据库已成为 AI 应用的标配存储层。DiskANN 的磁盘级搜索能力使其特别适合:

  • 大规模知识库检索(如企业内部文档搜索)
  • 多模态嵌入搜索(文本、图像、音频统一检索)
  • 实时推荐系统

7.3 硬件加速趋势

  • GPU 加速:部分距离计算可 offload 到 GPU
  • DPU/IPU 卸载:NVMe 控制器集成向量搜索能力
  • CXL 内存:打破内存容量限制,模糊内存/存储边界

八、总结

DiskANN 通过 Vamana 图索引 + 乘积量化 + RNG 剪枝的三板斧,巧妙地在磁盘上实现了高质量的近似最近邻搜索。其核心洞察是:搜索不再是纯 IO 问题,而是图遍历 + 距离计算的综合优化。

对于读者而言,如果你想在自己的生产环境中部署十亿级向量搜索,DiskANN 是当前最成熟的选择之一。记住三个关键数字:95% 召回率、8ms P99 延迟、10x 内存节省 — 这就是磁盘级 ANNS 的魅力所在。

工程没有银弹。理解你的 workload,选对工具,然后针对性的调优 — 这才是技术之美。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部