📖目录
- [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搜索流程(结合示意图)
- 高层初始化:从Layer 2的"入口节点"(如上海枢纽)出发,找到距离查询向量最近的节点(如广州枢纽);
- 下钻层级:从广州枢纽下钻到Layer 1,找到广州天河节点;
- 底层精搜:从广州天河节点在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聚类)
- 采样训练:从原始向量集中随机采样10万条向量作为训练集;
- 子空间拆分:将训练集向量拆分为8个子空间(各128维);
- K-means聚类:每个子空间执行K-means聚类(K=256),得到256个聚类中心;
- 保存码本:将8个子空间的聚类中心保存为码本文件(.codebook),供后续编码/解码使用。
源码验证 :https://github.com/facebookresearch/faiss/blob/main/faiss/ProductQuantizer.cpp#L189
码本只需构建一次,可复用在同维度向量的压缩中。
4.3 PQ距离计算:子空间距离累加(精度+速度平衡)
PQ不存储原始向量,因此无法计算真实欧氏距离,而是通过"码本距离表"估算,流程:
- 预计算:查询向量的每个子空间与对应码本中256个聚类中心的距离,生成"距离表"(8×256=2048个值);
- 遍历PQ Code:每个待查向量的PQ Code(8字节)对应8个子空间的聚类中心ID;
- 距离累加:从距离表中取出8个ID对应的距离,累加得到"近似距离";
- 重排:取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 为例)------ 从请求到结果
- Proxy 收到 search 请求
- QueryNode 加载索引 (从 MinIO 下载
.index文件) - 对 query vector :
- 计算最近
nprobe个 centroid(如:查10个区域) - 在这些区域的
invlists中遍历 PQ codes - 用 lookup table 快速估算距离 (非真实距离!)→ 提速10倍
- 计算最近
- 返回 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>cppstd::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. 经典书目推荐
-
《Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs》
- 作者:Malkov, Y., Yashunin, D. (2016)
- 为什么推荐 :HNSW 算法的开山论文,直接驱动了 Milvus/HNSWlib 的实现。
- 实用价值:论文中算法伪代码可直接用于自研索引,包含层级生成、邻居选择的核心逻辑。
-
《Faiss: A Library for Efficient Similarity Search》
- 作者:Johnson, J., Douze, M., Jégou, H. (2017)
- 为什么推荐 :Facebook 开源的向量搜索库,Milvus 的底层依赖。
- 实用价值:源码中PQ/IVF的实现细节(如码本构建、距离估算)是工业级量化的标杆。
11. 参考资料
- Milvus 官方索引文档:https://milvus.io/docs/index.md
- Faiss 官方 Wiki:https://github.com/facebookresearch/faiss/wiki
- HNSW 原论文:https://arxiv.org/abs/1603.09320
- Milvus 源码(Knowhere):https://github.com/milvus-io/knowhere
- Java 原子操作官方文档:https://docs.oracle.com/javase/8/docs/api/java/util/concurrent/atomic/package-summary.html
- HNSWlib 源码:https://github.com/nmslib/hnswlib
好的,以下是简洁但保留完整文章标题的推荐格式,清晰又不冗余: