数据库基础体系 · 第 60/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

向量索引原理:Flat、HNSW、IVF、PQ 的精度、内存和延迟

向量索引解决的是一个近邻搜索问题:

给定查询向量 qq,在数据集

X={x1,x2,,xN}X=\{x_1,x_2,\ldots,x_N\}

中找出距离 d(q,xi)d(q,x_i) 最小的 kk 个向量。

这里至少有三个容易混淆的概念:

  • 距离度量:如何计算两个向量的远近,例如 L2、内积、余弦距离。
  • 精确搜索:对所有 NN 个向量计算距离,再取真正的 Top-K。
  • 近似最近邻搜索(ANN):只访问部分数据或使用压缩表示,以更低延迟换取可能下降的召回率。

Flat、HNSW、IVF 和 PQ 并不是完全同一层次的技术:

  • Flat 是不建立近似索引的穷举搜索基线。
  • HNSW 是基于图的近似搜索索引。
  • IVF 是先分桶、再在少数桶中搜索的倒排索引。
  • PQ 是向量压缩和距离近似技术,通常与 IVF 组合成 IVF-PQ。

因此,常见组合包括:

  • Flat
  • IVF-Flat
  • HNSW
  • IVF-PQ

其中 IVF-Flat 和 IVF-PQ 的“IVF”负责缩小候选范围,Flat 或 PQ 负责计算候选向量之间的距离。


一、先明确“精度”:距离精确不等于召回精确

1.1 精确 Top-K 的定义

假设使用 L2 距离:

dL2(q,x)=j=1D(qjxj)2d_{L2}(q,x)=\sum_{j=1}^{D}(q_j-x_j)^2

其中:

  • DD 是向量维度;
  • qjq_jxjx_j 是第 jj 维分量;
  • 距离越小,向量越相似。

精确搜索必须计算:

d(q,x1),d(q,x2),,d(q,xN)d(q,x_1),d(q,x_2),\ldots,d(q,x_N)

然后选出距离最小的 kk 个结果。

如果使用内积:

s(q,x)=qxs(q,x)=q\cdot x

则通常选择内积最大的结果。余弦相似度为:

cos(q,x)=qxqx\cos(q,x)=\frac{q\cdot x}{\|q\|\|x\|}

如果所有向量都已经归一化,则:

q=x=1\|q\|=\|x\|=1

并且:

qx22=22(qx)\|q-x\|_2^2=2-2(q\cdot x)

所以在归一化向量上,最小 L2 距离、最大内积和最大余弦相似度会产生相同的排序。

但如果向量没有归一化,这三种度量不一定等价。将余弦距离改成内积搜索而不归一化,是常见的精度错误。

1.2 召回率的定义

近似索引通常不会直接告诉你“结果错了多少”,而是用精确 Flat 搜索作为基准计算召回率。

对某个查询,设:

  • Gk(q)G_k(q):精确搜索得到的 Top-K 集合;
  • Ak(q)A_k(q):近似索引得到的 Top-K 集合。

则 Recall@K 可以写成:

Recall@K=Gk(q)Ak(q)KRecall@K=\frac{|G_k(q)\cap A_k(q)|}{K}

例如精确结果为:

G3={a,b,c}G_3=\{a,b,c\}

近似结果为:

A3={a,c,d}A_3=\{a,c,d\}

那么:

Recall@3=23Recall@3=\frac{2}{3}

这里的“精度”通常指召回率,而不是浮点距离计算本身是否精确。

  • HNSW、IVF-Flat 的向量距离通常仍使用原始向量计算,但候选集合可能不完整。
  • IVF-PQ 不仅可能漏掉候选,还可能使用压缩码近似计算距离,因此存在“候选召回误差”和“距离排序误差”两层误差。

二、Flat:精确搜索的基线

2.1 工作方式

Flat 不建立复杂的近似结构。查询到来时,系统依次扫描每个向量,计算距离,再维护 Top-K。

伪代码如下:

result = empty top-k heap

for x in all_vectors:
    distance = d(q, x)
    result.insert(x, distance)

return result

如果数据有 NN 个向量、每个向量 DD 维,则单次查询的主要计算量约为:

O(ND)O(ND)

这不是“没有索引”就一定慢,而是它的延迟随数据量近似线性增长。数据量较小时,Flat 反而可能由于没有索引遍历、候选维护和随机访问开销而很快。

2.2 完整数值例子

查询向量为:

q=(0,0)q=(0,0)

候选向量为:

a=(1,0),b=(0,2),c=(0.7,0.7),d=(3,0)a=(1,0),\quad b=(0,2),\quad c=(0.7,0.7),\quad d=(3,0)

使用平方 L2 距离:

d2(q,a)=1d^2(q,a)=1

d2(q,b)=4d^2(q,b)=4

d2(q,c)=0.72+0.72=0.98d^2(q,c)=0.7^2+0.7^2=0.98

d2(q,d)=9d^2(q,d)=9

因此精确排序为:

c,a,b,dc,a,b,d

Top-2 是 c,ac,a。Flat 会检查四个向量,因此不会因为索引结构而漏掉 cc

2.3 Flat 的内存和延迟

如果使用 32 位浮点数存储向量,原始向量内存大约为:

N×D×4 bytesN\times D\times 4\text{ bytes}

例如:

  • N=10,000,000N=10,000,000
  • D=768D=768
  • 每个分量 4 字节

则仅原始向量就约为:

107×768×430.7 GB10^7\times768\times4\approx30.7\text{ GB}

这还不包括主键、删除标记、分区结构、页或 segment 元数据,以及数据库本身的缓存和管理开销。

Flat 的特点是:

维度 特征
召回率 理想情况下为 100%,作为基准
距离计算 对原始向量精确计算
内存 主要是原始向量
延迟 NN 线性增长
构建 几乎没有索引构建成本
适用 小数据集、离线评测、精度基线

在 pgvector 中,普通的向量排序查询可以作为精确基线:

SELECT id, content, embedding <-> '[0.1,0.2,0.3]'::vector AS distance
FROM documents
ORDER BY embedding <-> '[0.1,0.2,0.3]'::vector
LIMIT 10;

这里 <-> 是 L2 距离操作符。查询是否走索引由 PostgreSQL 优化器决定;没有匹配的近似索引时通常会进行顺序扫描。要做召回率评测,应固定查询、过滤条件和距离度量,把这类结果保存为 ground truth。


三、HNSW:用多层图减少搜索访问量

HNSW 的全称是 Hierarchical Navigable Small World,即分层可导航小世界图。

它的核心思想是:不再扫描所有向量,而是把向量组织成多个图层,通过图上的邻居跳转,逐步接近查询向量。

3.1 图结构

HNSW 中每个向量是一个节点,节点之间有若干邻居边。

通常:

  • 最底层包含所有节点;
  • 更高层包含较少节点;
  • 高层边较长,用于快速跨越数据空间;
  • 低层边较密,用于精细搜索。

一次查询大致分为两步:

  1. 从最高层入口点开始,在稀疏图中贪心向更近的节点移动;
  2. 到达下一层后,以当前结果为入口继续搜索;
  3. 在底层使用更大的候选队列搜索,返回 Top-K。

可以把它类比为地图导航:

  • 高层是高速公路,快速跨区域;
  • 低层是普通道路,精确到达目的地附近。

3.2 HNSW 查询过程

设当前节点为 vv,查询向量为 qq

如果某个邻居 uu 满足:

d(q,u)<d(q,v)d(q,u)<d(q,v)

则搜索可能移动到 uu。当当前节点的所有相关邻居都不能继续改善距离时,搜索在这一层停止并下降到下一层。

底层不会只保留一个当前节点,而是维护候选集合。候选集合越大,越有机会探索到真正近邻,但距离计算和队列操作也越多。

因此 HNSW 的典型查询参数包括:

  • efSearch:查询时维护的候选规模;
  • M:每个节点在图中保留的邻居数量;
  • efConstruction:构建图时搜索候选的规模。

通常:

  • 增大 efSearch,召回率提高,延迟增加;
  • 增大 M,图的连通性增强,内存和构建成本增加;
  • 增大 efConstruction,图质量通常提高,构建时间增加。

这些是常见实现语义,不是所有引擎都使用完全相同的默认值或参数名称。

3.3 HNSW 为什么可能漏结果

HNSW 的图搜索不是对所有节点穷举。一个局部贪心过程可能进入“看起来较近但通往真实近邻的路径不明显”的区域。

构造一个抽象反例:

查询 q
 |
入口 s —— 局部节点 a —— 局部节点 b
                         \
                          真实近邻 t

如果从 sa 的距离改善明显,但从 a 继续到 b 后,通往 t 的边没有被搜索到,或者候选队列过小,搜索可能在 b 附近结束。即使 t 是全局真正近邻,也可能没有被访问。

增大 efSearch 的作用不是改变向量本身,而是扩大待探索区域,降低这种漏检概率。它不能在有限参数下提供数学意义上的 100% 召回保证。

3.4 HNSW 内存

HNSW 通常保留原始向量,并额外存储图边。

原始向量内存约为:

NDbNDb

其中 bb 是每个分量的字节数,float32b=4b=4

图边内存可粗略估算为:

O(NM)O(NM)

如果一条边使用 32 位节点编号,且无向关系在实现中按双向边存储,粗略边存储量可能接近:

N×M×2×4N\times M\times 2\times4

实际内存会受到以下因素影响:

  • 邻居列表的容器开销;
  • 多层节点;
  • 对齐;
  • 删除标记;
  • 主键和元数据;
  • 引擎是否复制向量或建立额外缓存。

因此不能把这个公式当作精确容量结果,但它能说明一个重要事实:HNSW 的内存通常显著高于只存原始向量的 Flat。

3.5 pgvector 中的 HNSW

pgvector 支持 HNSW 索引。一个示例:

CREATE INDEX documents_embedding_hnsw
ON documents
USING hnsw (embedding vector_l2_ops)
WITH (m = 16, ef_construction = 64);

查询时可以调整搜索候选规模:

BEGIN;

SET LOCAL hnsw.ef_search = 100;

SELECT id, embedding <-> '[0.1,0.2,0.3]'::vector AS distance
FROM documents
ORDER BY embedding <-> '[0.1,0.2,0.3]'::vector
LIMIT 10;

COMMIT;

关键点有两个:

  1. vector_l2_ops 必须与查询操作符匹配。使用内积或余弦距离时,应建立对应的操作类。
  2. ef_search 是查询级别的近似搜索参数。用 SET LOCAL 可以把影响限制在当前事务,而不是永久改变整个会话或实例配置。

在 PostgreSQL 中,索引仍然参与普通事务语义。插入、更新、删除与事务提交、回滚相关;但索引构建本身仍然需要考虑锁、资源和构建失败后的恢复。HNSW 不是一个脱离数据库事务系统独立运行的缓存。


四、IVF:先分桶,再搜索候选桶

IVF 的全称是 Inverted File Index,即倒排文件索引。

它先用聚类中心把向量空间划分成若干个桶,再根据查询向量找到最相关的少数桶,只在这些桶里搜索。

4.1 建立 IVF 的过程

设聚类中心为:

C={c1,c2,,cL}C=\{c_1,c_2,\ldots,c_L\}

其中 LL 是桶数量,通常称为 nlist 或类似名称。

对每个向量 xx,分配到距离最近的中心:

assign(x)=argminid(x,ci)assign(x)=\arg\min_i d(x,c_i)

于是每个桶保存一组向量:

listi={xassign(x)=i}list_i=\{x\mid assign(x)=i\}

查询时,先计算查询向量与所有中心的距离,找到最近的 PP 个桶:

TopP{d(q,ci)}\operatorname{TopP}\{d(q,c_i)\}

然后只扫描这些桶中的向量。这里的 PP 通常称为 nprobeprobes

  • nlist 决定数据被分成多少桶;
  • nprobe 决定一次查询访问多少桶。

4.2 IVF 的召回反例

假设真实最近邻 x\*x^\* 被分配到了桶 2,但查询 qq 的最近中心是桶 1:

查询 q —— 中心 c1
          \
           真实近邻 x* 位于 list_2

如果 nprobe=1,只搜索桶 1,那么 x\*x^\* 根本不会参与最终排序。即使桶 1 内的距离计算完全精确,结果仍然可能错误。

这说明 IVF-Flat 的误差主要来自:

候选桶没有覆盖真实近邻。

而不是来自桶内的距离计算。

增大 nprobe 会让更多桶参与搜索:

nprobe候选数量通常增加nprobe\uparrow \Rightarrow \text{候选数量通常增加}

因此召回率通常提高,但延迟和 CPU 消耗也会增加。若 nprobe=nlist,理论上会搜索所有桶,此时 IVF-Flat 的候选范围接近 Flat;但它仍可能承担中心查找和倒排结构的额外开销,因此不一定比直接 Flat 更快。

4.3 IVF 的内存和构建边界

IVF 需要保存:

  • 聚类中心;
  • 每个向量所属的桶;
  • 倒排列表;
  • 向量本身,若使用 IVF-Flat;
  • 或压缩码,若使用 IVF-PQ。

中心内存约为:

LDbLDb

相对于 NDbNDb,当 LNL\ll N 时通常很小。真正影响运行时的是:

  • 桶是否均衡;
  • 查询要探测多少桶;
  • 桶内向量是否连续;
  • 候选是否需要从磁盘或远程存储加载。

聚类中心不是凭空产生的。IVF 建立前通常需要训练或聚类。训练样本不能代表真实数据分布时,桶边界质量会下降。

例如,历史数据主要来自英语文本,但新数据主要来自代码和多语言文本,继续沿用旧中心可能造成:

  • 新数据集中落入少数桶;
  • 某些桶过大;
  • 查询对应的中心不稳定;
  • nprobe 相同但实际扫描量显著变化。

因此 IVF 的“索引创建成功”不等于聚类质量足够好。

4.4 pgvector 中的 IVFFlat

pgvector 支持 IVFFlat。示例:

CREATE INDEX documents_embedding_ivfflat
ON documents
USING ivfflat (embedding vector_l2_ops)
WITH (lists = 100);

查询时可以设置探测桶数量:

BEGIN;

SET LOCAL ivfflat.probes = 10;

SELECT id, embedding <-> '[0.1,0.2,0.3]'::vector AS distance
FROM documents
ORDER BY embedding <-> '[0.1,0.2,0.3]'::vector
LIMIT 10;

COMMIT;

lists 与查询时的 probes 共同决定搜索行为。建立 IVFFlat 时,训练数据应尽量包含具有代表性的数据。对一个刚创建但数据量很少的表建立 IVF,然后大量写入分布不同的数据,索引质量可能不理想。


五、PQ:把向量变成短码

PQ 的全称是 Product Quantization,即乘积量化。

它的目标不是减少候选桶数量,而是用更短的编码近似表示向量,从而减少内存和距离计算成本。

5.1 分段量化

设原始向量维度为 DD,将其分成 mm 个子向量:

x=[x(1),x(2),,x(m)]x=[x^{(1)},x^{(2)},\ldots,x^{(m)}]

每个子向量维度约为:

D/mD/m

对第 jj 个子空间训练一个码本:

Cj={cj,1,cj,2,,cj,K}C_j=\{c_{j,1},c_{j,2},\ldots,c_{j,K}\}

其中 K=2bK=2^bbb 是每个子空间使用的比特数。

编码时,对每个子向量选择最近的码字:

codej(x)=argmintd(x(j),cj,t)code_j(x)=\arg\min_t d(x^{(j)},c_{j,t})

整个向量只保存 mm 个码字编号。

例如:

  • D=128D=128
  • m=16m=16
  • 每个子空间 b=8b=8 bit

则一个向量的 PQ 编码大小为:

16×8=128 bit=16 byte16\times8=128\text{ bit}=16\text{ byte}

原始 float32 向量大小为:

128×4=512 byte128\times4=512\text{ byte}

编码部分理论上缩小到四分之一。实际系统还需要保存码本、主键、倒排结构和管理元数据。

5.2 PQ 的距离近似

以 L2 距离为例:

d(q,x)2=j=1mq(j)x(j)2d(q,x)^2 = \sum_{j=1}^{m} \|q^{(j)}-x^{(j)}\|^2

PQ 将 x(j)x^{(j)} 替换为对应码字 cj,codej(x)c_{j,code_j(x)}

d^(q,x)2=j=1mq(j)cj,codej(x)2\hat d(q,x)^2 = \sum_{j=1}^{m} \|q^{(j)}-c_{j,code_j(x)}\|^2

查询时,可以为每个子空间预先计算查询子向量到所有码字的距离表,然后通过查表快速累加。

误差来自量化残差:

e(j)=x(j)cj,codej(x)e^{(j)}=x^{(j)}-c_{j,code_j(x)}

如果残差较大,近似距离就可能明显偏离真实距离。

5.3 一个排序反例

假设两个候选的真实距离为:

d(q,a)=1.00,d(q,b)=1.05d(q,a)=1.00,\quad d(q,b)=1.05

如果 PQ 量化后:

d^(q,a)=1.10,d^(q,b)=1.02\hat d(q,a)=1.10,\quad \hat d(q,b)=1.02

则压缩距离会把 bb 排在 aa 前面。此时即使两个候选都已经进入搜索范围,PQ 仍然可能改变最终 Top-K 排序。

因此 PQ 的误差有两层:

  1. IVF 可能没有把真实近邻所在的桶加入候选;
  2. PQ 可能把候选向量的距离估计错,导致排序改变。

在某些实现中,会对 PQ 找到的候选再使用原始向量重排,以减少第二种误差。这会增加原始向量访问和距离计算成本,具体是否支持、如何配置,应以对应引擎版本的文档为准。


六、IVF-PQ:用分桶和压缩同时降低成本

IVF-PQ 将两种机制串联起来:

  1. 使用 IVF 找到 nprobe 个候选桶;
  2. 使用 PQ 码快速计算桶内向量的近似距离;
  3. 返回近似 Top-K,或者对部分候选进行原始向量重排。

其查询成本可以粗略理解为:

O(LD)+O(Sm)O(LD)+O(S\cdot m)

其中:

  • LL 是桶数量,查询中心阶段需要比较的中心数;
  • SS 是被探测桶中的候选向量数量;
  • mm 是 PQ 子空间数量。

这不是严格的端到端复杂度公式,因为缓存、SIMD、磁盘访问、并行度和数据布局都会影响实际性能,但它体现了 PQ 的主要收益:候选距离计算不再完整读取 DD 维浮点向量。

PQ 的主要代价是:

  • 训练码本需要 CPU 和样本;
  • 码本不适合当前数据分布时,量化误差升高;
  • m、每段 bit 数、是否残差量化会影响质量;
  • 重排会重新引入原始向量读取;
  • 更新数据后,原有码本是否仍然适合,需要重新评估。

在 Milvus 的索引体系中,FLAT、IVF_FLAT、IVF_PQ、HNSW 等是不同索引类型;具体参数名称通常包括 nlistnprobeMef、PQ 的子空间数量和每段位数等。不同 Milvus 稳定版本、索引类型和部署模式可能有参数约束,不能把某个版本示例中的参数直接套用于所有索引。


七、四种方式的内存与延迟对比

设:

  • NN:向量数量;
  • DD:维度;
  • bb:每个浮点分量字节数;
  • LL:IVF 桶数;
  • PP:查询探测桶数;
  • MM:HNSW 邻居规模;
  • mm:PQ 子空间数。

7.1 内存模型

索引 主要内存组成 粗略特征
Flat 原始向量 NDbNDb
HNSW 原始向量 + 图边 NDb+O(NM)NDb+O(NM)
IVF-Flat 原始向量 + 中心 + 倒排列表 NDb+LDb+NDb+LDb+列表开销
IVF-PQ PQ 编码 + 码本 + 倒排列表 Nmb/8+Nmb/8+码本和列表开销

PQ 表达式中的 bb 应改为每个编码位数对应的字节换算;例如每个子空间 8 bit 时,每个向量编码占 mm 字节。

7.2 延迟模型

索引 主要延迟来源
Flat 扫描全部向量并计算完整距离
HNSW 图遍历、候选队列、随机访问
IVF-Flat 计算中心距离、读取探测桶、对候选计算完整距离
IVF-PQ 计算中心距离、读取压缩码、查表和累加,可能还要重排

延迟不是只由算法名字决定的。例如:

  • HNSW 访问节点通常不连续,CPU 缓存不友好;
  • IVF-Flat 候选可能连续存储,适合批量计算;
  • IVF-PQ 计算少,但如果重排阶段需要随机读取大量原始向量,收益会下降;
  • Flat 可以很好地利用 SIMD 和顺序内存带宽,在中小数据集上未必输给复杂索引。

因此“复杂度更低”不能直接等价为“线上 P99 更低”。


八、如何选择参数,而不是凭经验猜数值

8.1 HNSW 参数变化

以 pgvector 的 HNSW 参数为例:

CREATE INDEX items_embedding_hnsw
ON items
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

查询阶段:

BEGIN;
SET LOCAL hnsw.ef_search = 80;

SELECT id, 1 - (embedding <=> '[0.1,0.2,0.3]'::vector) AS cosine_similarity
FROM items
ORDER BY embedding <=> '[0.1,0.2,0.3]'::vector
LIMIT 20;
COMMIT;

这里 <=> 是余弦距离,1 - distance 在该度量下可作为余弦相似度表达。前提是查询和数据使用同一度量语义,且不能把距离和相似度的排序方向弄反。

调参时应观察:

  • Recall@K;
  • 平均延迟和 P95/P99;
  • CPU;
  • 内存;
  • 构建时间;
  • 写入吞吐。

只提高 ef_search 而不测量,可能把召回率提升换成不可接受的尾延迟。

8.2 IVF 参数变化

IVF 同时存在“训练参数”和“查询参数”:

  • nlist:分桶数量,影响桶平均大小;
  • nprobe:查询访问的桶数量,直接影响候选量。

如果每个桶平均有 N/LN/L 个向量,那么粗略候选数约为:

SP×NLS\approx P\times\frac{N}{L}

但真实数据通常不均匀,因此实际候选数还取决于桶大小分布。

LL 太小:

  • 每个桶很大;
  • nprobe 即使较小也会扫描大量候选;
  • IVF 的缩小效果有限。

LL 太大:

  • 中心训练和查询开销增加;
  • 桶可能过小;
  • 数据分布变化时,中心质量更敏感;
  • 查询探测比例不变时,候选数量可能下降,但召回风险上升。

应该用代表性数据集做网格测试,而不是只根据向量总数套用固定比例。


九、过滤条件会改变索引的有效召回

向量搜索经常不是全表 Top-K,而是:

SELECT id
FROM documents
WHERE tenant_id = 42
ORDER BY embedding <-> $1
LIMIT 10;

这引入了一个额外问题:近似索引找到的候选,可能大部分不满足过滤条件。

假设系统先取得 10 个近似候选,再应用租户过滤:

近似候选:a,b,c,d,e,f,g,h,i,j
满足 tenant_id=42:只有 c

最终只能返回一个结果,即使过滤后的数据集中实际存在很多近邻。

增加预取候选数有时可以缓解:

先取 100 个候选
过滤后剩余 12 个
再返回前 10 个

但这会增加延迟,而且不能保证过滤选择性极低时仍然足够。

因此评测向量索引时,至少要分别测试:

  1. 不带过滤的 Recall@K;
  2. 高选择性过滤;
  3. 低选择性过滤;
  4. 多租户或分区场景;
  5. 过滤字段发生更新或删除后的行为。

PostgreSQL 和 Milvus 对过滤、分区、segment、索引扫描的执行路径不同。不能把一个引擎的“先向量搜索后过滤”假设直接套到另一个引擎上。应通过 EXPLAIN、查询执行统计或引擎提供的搜索统计确认实际路径。


十、数据写入、索引构建和故障边界

10.1 PostgreSQL/pgvector

pgvector 作为 PostgreSQL 扩展,向量列、表、索引和事务都处在 PostgreSQL 的事务模型中。

典型流程:

CREATE TABLE documents (
    id bigint PRIMARY KEY,
    tenant_id bigint NOT NULL,
    content text NOT NULL,
    embedding vector(3) NOT NULL
);

INSERT INTO documents(id, tenant_id, content, embedding)
VALUES
(1, 42, 'first', '[0.1,0.2,0.3]'),
(2, 42, 'second', '[0.2,0.1,0.4]');

随后建立索引:

CREATE INDEX documents_embedding_hnsw
ON documents
USING hnsw (embedding vector_l2_ops);

需要注意:

  • 建索引前已有数据需要被纳入构建;
  • 建索引期间会消耗 CPU、内存和 I/O;
  • 写入和更新会带来索引维护成本;
  • 大规模构建应结合 PostgreSQL 的锁、并发建索引、备份和恢复策略评估;
  • 备份不仅要考虑表数据,也要验证恢复后索引是否存在、查询计划是否符合预期。

10.2 Milvus

Milvus 通常以 collection、segment 和索引构建任务组织数据。数据写入、flush、索引构建、加载和查询是相互关联但不完全相同的阶段。

一个典型生命周期是:

创建 collection
    ↓
插入向量和标量字段
    ↓
flush,使数据形成可持久化 segment
    ↓
为向量字段创建索引
    ↓
load collection 或相关 partition
    ↓
执行 search

索引创建完成不代表数据自动处于可查询的内存状态;部署模式、segment 状态和 load 状态会影响查询是否能执行以及资源使用。

故障诊断时应区分:

  • 数据是否已经 flush;
  • 索引任务是否成功;
  • collection 或 partition 是否已 load;
  • 查询字段维度和度量是否匹配;
  • 标量过滤字段是否存在并使用正确类型;
  • 新写入数据是否仍处于待整理或待索引状态;
  • 节点重启后索引文件和数据是否能够重新加载。

Milvus 的具体 API 和索引参数会随稳定版本变化,生产代码应锁定客户端与服务端兼容版本,并对创建索引、加载、搜索失败实现显式错误处理,而不是只检查请求是否发出。


十一、如何建立可复现的评测

一个可靠的评测必须先固定实验边界。

11.1 固定数据和度量

至少固定:

  • 向量维度;
  • 数据集版本;
  • 查询集;
  • Top-K;
  • L2、内积或余弦距离;
  • 是否归一化;
  • 是否带标量过滤;
  • 单机还是分布式部署;
  • 冷缓存还是热缓存;
  • 并发数。

11.2 用 Flat 生成 ground truth

以 pgvector 为例,可以先执行不依赖 ANN 索引的查询,保存每个查询的精确 Top-K。之后再分别运行 HNSW、IVFFlat,并计算:

Recall@K=近似结果与精确结果的交集大小KRecall@K=\frac{\text{近似结果与精确结果的交集大小}}{K}

不要用 HNSW 的结果去验证 HNSW,也不要用某一组 IVF 参数生成另一组参数的“真值”。

11.3 记录三个维度

每组参数至少记录:

索引构建时间
索引和数据占用内存/磁盘
Recall@K
P50/P95/P99 延迟
查询吞吐
写入或更新吞吐

尤其要记录 P99。某组参数可能平均延迟很好,但在大桶、冷缓存或高并发下出现严重尾延迟。


十二、常见误解和失败表现

误解一:HNSW 一定比 Flat 快

错误。数据量小、查询并发低、数据全部在缓存中时,Flat 可能更快。HNSW 只有在减少的距离计算量足以抵消图遍历开销时才有优势。

误解二:IVF-Flat 是精确搜索

错误。Flat 只表示桶内使用原始向量计算距离。IVF 仍然可能因为只探测部分桶而漏掉真实近邻。

误解三:PQ 只是把 float32 改成 int8

错误。PQ 是按子空间训练码本,并用码字编号表示向量。它不同于简单的标量量化。标量量化把每个分量独立映射,PQ 则利用子空间中的联合结构。

误解四:增加索引内存一定提高召回率

不一定。

  • HNSW 增加 M 通常有利于图连通性;
  • IVF 增加 nlist 可能减少单桶大小,但如果 nprobe 不同步调整,召回率可能下降;
  • PQ 使用更高码率通常降低量化误差,但码本、训练数据和重排策略同样重要。

误解五:返回了 K 条结果就说明召回率足够

错误。系统可能用次优结果填满 K 条。必须与精确 ground truth 比较,才能知道真正漏掉了多少近邻。

误解六:建立索引后所有查询都会使用它

错误。数据库优化器可能认为顺序扫描更便宜,或者查询表达式、操作符、操作类不匹配。应检查执行计划或引擎查询统计,而不是仅凭“索引已创建”判断。


十三、最终取舍

可以用下面的原则理解四种方式:

  • Flat:用计算时间换取确定的精确结果,适合基准、小规模数据和高精度校验。
  • HNSW:用额外内存和构建时间换取较低查询延迟,适合需要在线低延迟且内存充足的场景。
  • IVF-Flat:用聚类和候选桶缩小搜索范围,保留原始向量距离计算,参数解释相对直接。
  • PQ:用量化误差换取更低内存和更少数据读取,适合规模较大、能够接受近似结果或采用重排的场景。
  • IVF-PQ:同时利用分桶和压缩,通常在规模、成本和召回之间取得平衡,但参数和训练数据更加敏感。

工程上不应先问“哪个索引最好”,而应先确定:

允许的召回损失+目标 P99 延迟+可用内存+数据更新和过滤方式\text{允许的召回损失} \quad+\quad \text{目标 P99 延迟} \quad+\quad \text{可用内存} \quad+\quad \text{数据更新和过滤方式}

然后以 Flat 结果作为精度基线,逐步增加 HNSW 的 efSearch、IVF 的 nprobe,或调整 PQ 的编码参数,测量真实的 Recall@K、内存和尾延迟。索引选择的本质,是在“访问多少数据”“以什么表示计算距离”和“是否允许漏掉或误排结果”之间做出可验证的取舍。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。