【向量数据库】Milvus 向量数据库 ④ 向量索引的存储结构与查询执行模型:从 Faiss 到 Knowhere 的源码解剖

📖目录

  • [1. 引言:索引不仅是"算法",更是"数据结构 + 存储格式"](#1. 引言:索引不仅是“算法”,更是“数据结构 + 存储格式”)
  • [2. IVF 索引的物理存储结构](#2. IVF 索引的物理存储结构)
    • [2.1 核心组件:三件套"快递分拣装备"](#2.1 核心组件:三件套“快递分拣装备”)
    • [2.1 存储格式:内存 vs 磁盘](#2.1 存储格式:内存 vs 磁盘)
  • [3 HNSW 索引的图结构存储(多层"社交网络")](#3 HNSW 索引的图结构存储(多层“社交网络”))
    • [3.1 多层图核心原理:从"全国枢纽"到"社区网点"](#3.1 多层图核心原理:从“全国枢纽”到“社区网点”)
      • [3.1.1 图1:HNSW多层图结构示意图](#3.1.1 图1:HNSW多层图结构示意图)
    • [3.2 层级生成规则:随机指数分布](#3.2 层级生成规则:随机指数分布)
    • [3.3 多层图的内存布局](#3.3 多层图的内存布局)
    • [3.4 HNSW搜索流程(结合示意图)](#3.4 HNSW搜索流程(结合示意图))
  • [4. 量化技术如何改变存储(PQ / SQ8)------ "压缩快递盒"](#4. 量化技术如何改变存储(PQ / SQ8)—— “压缩快递盒”)
    • [4.1 Product Quantization (PQ) 压缩原理](#4.1 Product Quantization (PQ) 压缩原理)
      • [4.2 图2:PQ向量压缩与解压全流程示意图](#4.2 图2:PQ向量压缩与解压全流程示意图)
    • [4.2 PQ码本构建流程(K-means聚类)](#4.2 PQ码本构建流程(K-means聚类))
    • [4.3 PQ距离计算:子空间距离累加(精度+速度平衡)](#4.3 PQ距离计算:子空间距离累加(精度+速度平衡))
    • [4.4 SQ8与PQ的混合量化(Milvus优化方案)](#4.4 SQ8与PQ的混合量化(Milvus优化方案))
  • [5. Milvus 索引生命周期管理(关键设计:索引与数据分离)](#5. Milvus 索引生命周期管理(关键设计:索引与数据分离))
    • [5.1 索引文件存储路径(基于 Milvus 文档)](#5.1 索引文件存储路径(基于 Milvus 文档))
    • [5.2 索引构建流程(类比快递分拣)](#5.2 索引构建流程(类比快递分拣))
  • [6. 查询执行流程拆解(IVF_PQ 为例)------ 从请求到结果](#6. 查询执行流程拆解(IVF_PQ 为例)—— 从请求到结果)
  • [7. 与传统数据库索引的深层类比(PostgreSQL vs Milvus)](#7. 与传统数据库索引的深层类比(PostgreSQL vs Milvus))
  • [8. CPU原子操作:索引构建中的"防冲突卫士"](#8. CPU原子操作:索引构建中的“防冲突卫士”)
    • [8.1 原子操作设计原理(CPU层面)](#8.1 原子操作设计原理(CPU层面))
    • [8.2 Java 如何调用?(Milvus Java Client 示例)](#8.2 Java 如何调用?(Milvus Java Client 示例))
    • [8.3 与向量数据库的关联](#8.3 与向量数据库的关联)
  • [9. 总结:理解索引 = 理解性能瓶颈](#9. 总结:理解索引 = 理解性能瓶颈)
  • [10. 经典书目推荐](#10. 经典书目推荐)
  • [11. 参考资料](#11. 参考资料)
  • [12. 系列文章回顾](#12. 系列文章回顾)

1. 引言:索引不仅是"算法",更是"数据结构 + 存储格式"

为什么需要读这篇?

前两篇讲"是什么"(概念)和"怎么做"(工程),这篇讲"为什么这样存"------像拆开快递盒看里面装的什么。
核心亮点 :用"快递分拣中心"类比索引存储,用CPU原子操作解释并发索引构建的"防冲突机制",新增HNSW多层图、PQ压缩的可视化解析+深度细节,附可执行Java代码(带main方法)。
大白话解释

你买过快递吗?快递分拣中心(向量数据库)的效率,不在于"快递员跑得快"(算法),而在于"分拣台怎么摆"(存储结构)。

  • PostgreSQL 的 B-Tree:像超市货架,书按书名顺序摆好(精确匹配)
  • Milvus 的 IVF 索引 :像快递分拣的"区域标签"(每个区域贴个标签,只查最近的几个区域)
    👉 重点:索引到底存了什么?不是"算法",是"数据怎么放"。

2. IVF 索引的物理存储结构

2.1 核心组件:三件套"快递分拣装备"

组件 作用 类比快递场景 源码证据
centroids 聚类中心(nlist × dim 浮点数组) 分拣中心的"区域坐标"(如:北京1号仓、上海2号仓) faiss::IndexIVFFlat::centroids
invlists 每个桶的向量ID列表(vector<vector<int>> 每个区域的"包裹ID清单"(北京1号仓:包裹ID 1001, 1003, 1005) faiss::IndexIVFFlat::invlists
metadata 索引参数(nlist, nprobe等) 分拣中心的"操作手册"(区域数量=100,每次查10个区域) faiss::IndexIVFFlat::nlist

2.1 存储格式:内存 vs 磁盘

cpp 复制代码
// Faiss 源码关键片段 (faiss/IndexIVF.h)
struct IndexIVFFlat {
  float* centroids; // 聚类中心 (nlist * dim)
  std::vector<idx_t>* invlists; // 每个桶的ID列表 (nlist 个 vector)
  int nlist;      // 区域数量 (nlist)
  int nprobe;     // 每次查几个区域 (nprobe)
};

为什么这样存?

如果把所有向量存成vector<vector<float>>(100万向量×1024维),内存占用≈4GB。
IVF 优化后 :只存centroids(100×1024×4=40KB) + invlists(100万ID×4字节=4MB) → 总内存从4GB→4.04MB(节省99.9%)。


3 HNSW 索引的图结构存储(多层"社交网络")

3.1 多层图核心原理:从"全国枢纽"到"社区网点"

HNSW(Hierarchical Navigable Small Worlds)的核心是多层跳表+图结构,可以理解为"分层级的社交网络":高层是全国性的大V(稀疏连接),中层是城市KOL(中等连接),底层是社区邻居(密集连接)。


3.1.1 图1:HNSW多层图结构示意图

复制代码
┌───────────────────────────────────────── Layer 2 (高层:稀疏枢纽) ──────────────────────────────────┐
│  Node A (北京枢纽) ◄───► Node B (上海枢纽) ◄───► Node C (广州枢纽)                                  │
└───────────────┬───────────────────────────────┬───────────────────────────────┬─────────────────────┘
                │                               │                               │
┌───────────────▼───────────────────────────────▼───────────────────────────────▼─────────────────────┐
│  Layer 1 (中层:城市节点)                                                                           │
│  Node A1 (北京朝阳) ◄─┬─► Node A2 (北京海淀)    Node B1 (上海浦东) ◄─┬─► Node B2 (上海徐汇)        │
│                       │                                             │                               │
└───────────────────────┼─────────────────────────────────────────────┼─────────────────────────────┘
                        │                                             │
┌───────────────────────▼─────────────────────────────────────────────▼─────────────────────────────┐
│  Layer 0 (底层:社区节点)                                                                           │
│  A1-1 ◄─► A1-2 ◄─► A1-3 ◄─► A1-4    A2-1 ◄─► A2-2    B1-1 ◄─► B1-2 ◄─► B1-3    B2-1 ◄─► B2-2    │
│  (朝阳101) (朝阳102) (朝阳103) (朝阳104) (海淀201) (海淀202) (浦东301) (浦东302) (浦东303) (徐汇401) (徐汇402) │
└───────────────────────────────────────────────────────────────────────────────────────────────────┘

图表说明

  • 纵轴:层级(Layer),数字越大层级越高,节点数量越少、连接越稀疏;
  • 横轴:同层级节点的邻居关系,底层(Layer 0)每个节点连接数最多(如M=16),高层(Layer 2)连接数最少(如M=2);
  • 节点关联:每个节点同时存在于多个层级(如"北京枢纽"仅在Layer 2,"北京朝阳"同时在Layer 1和Layer 0)。

3.2 层级生成规则:随机指数分布

HNSW的层级分配并非均匀,而是基于指数分布随机生成,核心公式:

复制代码
l = -ln(random(0,1)) * ml (ml为层级系数,默认1/log(M))
  • 90%的节点仅存在于Layer 0(底层);
  • 9%的节点存在于Layer 0+Layer 1;
  • 1%的节点存在于Layer 0+Layer 1+Layer 2(高层枢纽)。

源码验证https://github.com/nmslib/hnswlib/blob/master/hnswlib/hnswlib.h#L224

该逻辑保证高层节点足够稀疏,成为"搜索捷径",底层节点密集保证搜索精度。


3.3 多层图的内存布局

cpp 复制代码
// HNSW 的核心数据结构 (hnswlib::HierarchicalNSW)
struct HnswNode {
  int id; // 节点ID
  std::vector<std::vector<int>> neighbors; // [L层][邻居ID列表]
  float* vector; // 原始向量(可选,节省内存时可不存)
  int max_level; // 节点所属最高层级(如Layer 2则max_level=2)
};

// 内存占用拆解(100万节点,M=16,M0=32,ml=1/log(16))
// Layer 2:1万节点 × 2邻居 × 4字节 = 80KB
// Layer 1:10万节点 × 8邻居 × 4字节 = 3.2MB
// Layer 0:100万节点 × 32邻居 × 4字节 = 128MB
// 总邻居指针内存:≈131.28MB(仅指针,不含向量)

3.4 HNSW搜索流程(结合示意图)

  1. 高层初始化:从Layer 2的"入口节点"(如上海枢纽)出发,找到距离查询向量最近的节点(如广州枢纽);
  2. 下钻层级:从广州枢纽下钻到Layer 1,找到广州天河节点;
  3. 底层精搜:从广州天河节点在Layer 0遍历邻居,找到Top-K最相似节点。

为什么内存高但速度快?

100万节点的HNSW,仅邻居指针就占≈130MB(若存原始向量则额外+4GB),但搜索时只需遍历"枢纽→城市→社区"的少量节点(约100次计算),比暴力搜索(10^9次)提速1000万倍。


4. 量化技术如何改变存储(PQ / SQ8)------ "压缩快递盒"

4.1 Product Quantization (PQ) 压缩原理

PQ(乘积量化)是将高维向量拆分为多个低维子空间,每个子空间独立聚类编码的压缩方式,核心是"分而治之",类比"把大快递盒拆成小盒子分别贴标签"。


4.2 图2:PQ向量压缩与解压全流程示意图

复制代码
┌─────────────────────────────────┐    ┌─────────────────────────────────┐
│  原始向量(1024维,4096字节)   │    │  码本(Codebook)               │
│  [v1, v2, ..., v1024]           │    │  子空间1:256个聚类中心(128维)│
└───────────┬─────────────────────┘    │  子空间2:256个聚类中心(128维)│
            │                          │  ...                            │
            ▼                          │  子空间8:256个聚类中心(128维)│
┌─────────────────────────────────┐    └─────────────────────────────────┘
│  向量切分(8个子空间)          │               ▲
│  子空间1:v1-v128               │               │
│  子空间2:v129-v256             │               │
│  ...                            │               │
│  子空间8:v897-v1024            │               │
└───────────┬─────────────────────┘               │
            │                                     │
            ▼                                     │
┌─────────────────────────────────┐               │
│  子空间聚类编码                 │───────────────┘
│  子空间1:匹配聚类中心ID=5 → 1字节 │ (K-means聚类生成码本)
│  子空间2:匹配聚类中心ID=12 → 1字节│
│  ...                            │
│  子空间8:匹配聚类中心ID=201 → 1字节│
└───────────┬─────────────────────┘
            │
            ▼
┌─────────────────────────────────┐
│  压缩结果(8字节PQ Code)       │
│  [5,12,...,201]                 │
└─────────────────────────────────┘
            │
            ▼
┌─────────────────────────────────┐
│  解压(可选):通过码本还原向量  │
│  子空间1:聚类中心5 → 128维向量 │
│  ...                            │
│  拼接8个子空间 → 1024维近似向量 │
└─────────────────────────────────┘

图表说明

  • 左半侧:压缩流程(切分→编码→生成PQ Code),核心是"用1字节ID替代128维向量";
  • 右半侧:码本(Codebook)是离线预生成的聚类中心表,每个子空间1个码本(256个聚类中心);
  • 解压环节:PQ压缩是"有损压缩",还原的向量与原始向量存在微小误差,但足以用于距离估算。

4.2 PQ码本构建流程(K-means聚类)

  1. 采样训练:从原始向量集中随机采样10万条向量作为训练集;
  2. 子空间拆分:将训练集向量拆分为8个子空间(各128维);
  3. K-means聚类:每个子空间执行K-means聚类(K=256),得到256个聚类中心;
  4. 保存码本:将8个子空间的聚类中心保存为码本文件(.codebook),供后续编码/解码使用。

源码验证https://github.com/facebookresearch/faiss/blob/main/faiss/ProductQuantizer.cpp#L189

码本只需构建一次,可复用在同维度向量的压缩中。


4.3 PQ距离计算:子空间距离累加(精度+速度平衡)

PQ不存储原始向量,因此无法计算真实欧氏距离,而是通过"码本距离表"估算,流程:

  1. 预计算:查询向量的每个子空间与对应码本中256个聚类中心的距离,生成"距离表"(8×256=2048个值);
  2. 遍历PQ Code:每个待查向量的PQ Code(8字节)对应8个子空间的聚类中心ID;
  3. 距离累加:从距离表中取出8个ID对应的距离,累加得到"近似距离";
  4. 重排:取Top-K近似距离的向量,用原始向量计算真实距离(可选,精度无损)。
cpp 复制代码
// PQ距离计算伪代码
float pq_distance(float* query, uint8_t* pq_code, float** codebook_dist) {
  float dist = 0.0;
  for (int i = 0; i < 8; i++) { // 8个子空间
    int cluster_id = pq_code[i]; // 每个子空间的聚类ID(1字节)
    dist += codebook_dist[i][cluster_id]; // 累加子空间距离
  }
  return dist;
}

4.4 SQ8与PQ的混合量化(Milvus优化方案)

Milvus在PQ基础上新增SQ8(标量量化) 混合方案,进一步压缩:

  • SQ8:将每个浮点值(4字节)量化为8位整数(1字节),压缩率4倍;
  • PQ+SQ8:1024维向量 → 8字节(PQ)+ 1024字节(SQ8)= 1032字节(对比原始4096字节,压缩率4倍);
  • 适用场景:需要兼顾压缩率和精度的场景(如十亿级向量存储)。

Milvus文档验证https://milvus.io/docs/index.md#PQ

PQ适合"极致压缩"(512倍),PQ+SQ8适合"精度优先的压缩"(4倍)。


5. Milvus 索引生命周期管理(关键设计:索引与数据分离)

5.1 索引文件存储路径(基于 Milvus 文档)

复制代码
/milvus/data/tables/
└── your_table/
    ├── index/        # 索引文件(.index)
    │   ├── ivf_flat_1000.index  # IVF 索引文件
    │   └── hnsw_1000.index      # HNSW 索引文件
    └── raw_data/     # 原始向量(.bin)
        ├── 0.bin
        └── 1.bin

5.2 索引构建流程(类比快递分拣)

QueryNode Object Storage Knowhere IndexNode DataNode QueryNode Object Storage Knowhere IndexNode DataNode 上传原始向量(.bin文件) 调用 Faiss/HNSW 构建索引 返回索引文件(.index) 保存索引文件(MinIO/S3) 请求加载索引 下载索引文件 返回内存中的索引

关键设计 :索引文件独立于原始数据(DataNode存.bin,IndexNode存.index)→ 支持多节点并行查询


6. 查询执行流程拆解(IVF_PQ 为例)------ 从请求到结果

  1. Proxy 收到 search 请求
  2. QueryNode 加载索引 (从 MinIO 下载 .index 文件)
  3. 对 query vector
    • 计算最近 nprobe 个 centroid(如:查10个区域)
    • 在这些区域的 invlists 中遍历 PQ codes
    • 用 lookup table 快速估算距离 (非真实距离!)→ 提速10倍
  4. 返回 top-K 候选 ,再用原始向量重排(re-ranking)→ 精度无损

为什么能提速?

暴力搜索:100万向量 × 1024维 = 10^9次计算

IVF_PQ:10个区域 × 1000个向量 = 10^4次计算 → 提速10万倍!


7. 与传统数据库索引的深层类比(PostgreSQL vs Milvus)

维度 PostgreSQL B-Tree Milvus IVF
目标 精确匹配/范围查询 近似最近邻(ANN)
存储单元 Page (8KB) Inverted List (区域ID列表)
构建方式 插入时分裂 批量离线构建(异步)
更新代价 O(log n) 重建索引(或 Delta 索引)
缓存机制 Shared Buffer OS Page Cache + 自定义缓存

关键差异

PostgreSQL 用 B-Tree 精确匹配 → Milvus 用 IVF 做近似匹配(牺牲1%精度,换100倍速度)。


8. CPU原子操作:索引构建中的"防冲突卫士"

为什么需要原子操作?

想象快递分拣中心:多个工人同时往同一个"区域计数器"写数字(如:北京1号仓已处理100件)。

  • 无原子操作:工人A写"100",工人B写"100" → 最终计数=100(丢失1次更新)
  • 有原子操作 :工人A写"100" → 工人B必须等A写完才写"101" → 正确计数=101
    👉 向量数据库索引构建同样需要"防冲突"!

8.1 原子操作设计原理(CPU层面)

  • CPU指令CMPXCHG(Compare and Exchange,比较并交换)

  • C++实现std::atomic<int>

    cpp 复制代码
    std::atomic<int> counter(0);
    counter.fetch_add(1); // 原子递增:先读值,再+1,再写回(不可分割)
  • 为什么有效?
    fetch_add 在CPU级别保证"读-改-写"三步不被打断 → 避免多线程竞态条件


8.2 Java 如何调用?(Milvus Java Client 示例)

注意 :Milvus C++层用原子操作,Java客户端通过JNI调用。Java层用AtomicInteger管理状态(如进度条)。

java 复制代码
import java.util.concurrent.atomic.AtomicInteger;

public class AtomicDemo {
    public static void main(String[] args) {
        AtomicInteger counter = new AtomicInteger(0);
        // 模拟2个线程同时更新计数器
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.incrementAndGet(); // 原子递增
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.incrementAndGet();
            }
        });
        t1.start();
        t2.start();
        try { t1.join(); t2.join(); } catch (InterruptedException e) {}
        System.out.println("Final count: " + counter.get()); // 输出2000(正确!)
    }
}

执行结果

bash 复制代码
Final count: 2000

为什么Java能用?

Java的java.util.concurrent.atomic封装了CPU原子指令 (如AtomicInteger底层调用Unsafe.getAndAddInt)→ 无需手动写C++。


8.3 与向量数据库的关联

在Milvus的IndexNode中,索引构建进度通过std::atomic<int>管理:

cpp 复制代码
// Milvus 源码片段 (milvus/internal/indexnode/BuildIndexTask.h)
class BuildIndexTask {
    std::atomic<int> progress_; // 100% = 完成
    void UpdateProgress(int delta) {
        progress_.fetch_add(delta); // 原子更新
    }
};

👉 确保多线程构建索引时,进度统计不丢失!


9. 总结:理解索引 = 理解性能瓶颈

问题 答案
为什么 HNSW 快但吃内存? 每个节点存多层级邻居指针(100万节点≈130MB指针+4GB向量),高层枢纽减少搜索路径
为什么 PQ 能处理十亿级数据? 1024维向量从4KB压缩到8字节(512倍),码本+近似距离计算兼顾压缩与速度
原子操作在向量数据库中起什么作用? 保证索引构建进度统计的正确性(多线程场景)
PQ与SQ8的核心区别? PQ是"子空间聚类压缩"(极致压缩),SQ8是"标量量化"(精度优先)

下一站:分布式索引分片(如:跨节点索引)、GPU加速(Faiss-CUDA)。


10. 经典书目推荐

  1. 《Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs》

    • 作者:Malkov, Y., Yashunin, D. (2016)
    • 为什么推荐 :HNSW 算法的开山论文,直接驱动了 Milvus/HNSWlib 的实现。
    • 实用价值:论文中算法伪代码可直接用于自研索引,包含层级生成、邻居选择的核心逻辑。
  2. 《Faiss: A Library for Efficient Similarity Search》

    • 作者:Johnson, J., Douze, M., Jégou, H. (2017)
    • 为什么推荐 :Facebook 开源的向量搜索库,Milvus 的底层依赖
    • 实用价值:源码中PQ/IVF的实现细节(如码本构建、距离估算)是工业级量化的标杆。

11. 参考资料

  1. Milvus 官方索引文档:https://milvus.io/docs/index.md
  2. Faiss 官方 Wiki:https://github.com/facebookresearch/faiss/wiki
  3. HNSW 原论文:https://arxiv.org/abs/1603.09320
  4. Milvus 源码(Knowhere):https://github.com/milvus-io/knowhere
  5. Java 原子操作官方文档:https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/atomic/package-summary.html
  6. HNSWlib 源码:https://github.com/nmslib/hnswlib

好的,以下是简洁但保留完整文章标题的推荐格式,清晰又不冗余:


12. 系列文章回顾

相关推荐
码农胖虎-java12 小时前
【AI】向量数据库选型实战:pgvector vs Milvus vs Qdrant
数据库·milvus·pg
树叶会结冰1 天前
Milvus:可检索记忆的漂流瓶
langchain·milvus·llamaindex
Learn Forever2 天前
【向量库-Milvus】Milvus部署及使用
milvus
秋氘渔2 天前
LlamaIndex 实战 Milvus 向量数据库:从 CRUD 到 智能检索
milvus·llamaindex
程序员黄老师2 天前
主流向量数据库全面解析
数据库·大模型·向量·rag
玖日大大3 天前
Milvus 深度解析:开源向量数据库的技术架构、实践指南与生态生态
数据库·开源·milvus
长路 ㅤ   5 天前
Milvus向量库Java对接使用指南
milvus·向量数据库·索引优化·混合搜索·ann搜索
福大大架构师每日一题6 天前
milvus v2.6.8 发布:搜索高亮上线,性能与稳定性全面跃升,生产环境强烈推荐升级
android·java·milvus
菜鸟冲锋号6 天前
从零搭建高可用GraphRAG系统:LangChain+Neo4j+FAISS+Qwen-7B实战指南
langchain·neo4j·faiss