查询延迟从 20ms 涨到 180ms,先别急着扩容。向量数据库的慢往往发生在索引构建时的参数选择、查询路径中的距离计算次数,以及向量存储格式对内存带宽的消耗上。高维向量的近邻搜索本质上是在精度和速度之间做权衡,只要把索引结构和量化方式调对,单机支撑千万甚至亿级向量并不需要堆满 GPU。

下面从索引结构、HNSW 参数、IVF 与量化压缩的组合,以及调优验证四个角度展开,给出一套可以直接落到工程里的优化思路。
一、向量检索慢的直观来源:索引结构与查询路径
向量数据库查询慢,通常不是距离计算本身有多贵,而是查询路径上需要计算的候选向量太多。以 HNSW 为例,它构建的是一个多层近邻图,查询时从顶层少量节点开始,沿着边不断跳到更接近目标的节点,最后在底层完成精细搜索。这个过程的耗时和跳数、每层考察的邻居数量直接相关。如果图的连接质量差,查询可能在中途绕路,甚至落入局部区域出不来。
IVF 类索引则走了另一条路:先用聚类算法把全库向量分成若干个倒排桶,查询时只搜索最近的一部分桶。这种方式能大幅减少距离计算次数,但如果桶数量过少,单个桶内的向量过多,查询退化成暴力扫描;如果桶数量过多,查询需要探测的桶数又降不下来。无论 HNSW 还是 IVF,一旦参数和实际数据分布不匹配,延迟都会成倍增加。
另一个被忽视的慢来源是内存和缓存。全精度向量通常用 float32 存储,一个 768 维向量就要 3KB。一千万条向量大约是 30GB,已经远远超出 CPU 三级缓存。每次距离计算都要从内存甚至磁盘取数据,缓存命中率极低。量化压缩的第一目标不是单纯省硬盘,而是让更多向量进缓存,减少主存访问。
二、HNSW 索引优化:先稳住图结构再谈搜索
HNSW 的核心参数有三个:M 控制每个节点的最大邻居数,efConstruction 控制构建时候选队列长度,efSearch 控制查询时动态候选队列长度。M 越大,图越密,查询路径越短,但内存占用也越高。一般建议从 16 到 32 之间取值,高召回场景可以到 48,低内存场景用 12 到 16。
efConstruction 决定索引构建质量,这个值太小会让图出现大量长边和断点,查询时被迫绕远路。实践中把它设为 200 到 400 比较稳妥,构建时间会增加,但换来的是更稳的查询路径。查询阶段的 efSearch 必须大于等于 K,一般建议从 64 起步,逐步加大到 128 或 256。增大 efSearch 会线性增加查询耗时,但能明显提升召回率。
下面是一段使用 hnswlib 构建和查询的示例,重点在参数搭配:
import hnswlib
import numpy as np
dim = 768
num_elements = 2000000
index = hnswlib.Index(space='cosine', dim=dim)
index.init_index(max_elements=num_elements, ef_construction=300, M=24)
# 插入向量时建议分批,避免一次性占满内存
batch_size = 50000
for start in range(0, num_elements, batch_size):
end = min(start + batch_size, num_elements)
vectors = np.random.randn(end - start, dim).astype('float32')
index.add_items(vectors, np.arange(start, end))
# 查询时 efSearch 必须高于 top_k
index.set_ef(128)
query = np.random.randn(1, dim).astype('float32')
labels, distances = index.knn_query(query, k=10)
如果 HNSW 查询延迟仍然偏高,优先检查索引是否被增量写入打碎。频繁插入会破坏图结构,导致局部度数失衡。大规模写入后最好触发一次索引重建,或者采用定期全量重建加增量缓冲的方案。对于数据量超过一亿的场景,单机内存放不下完整 HNSW 图时,更适合转向 IVF 加量化压缩的组合。
三、IVF 与量化压缩:用更少内存换可用召回率
IVF 的 nlist 是聚类桶数量,nprobe 是查询时探测的桶数。经验上,nlist 可以设为数据量的平方根到平方根的几倍,例如 100 万向量可以用 1024 到 4096 个桶。调大 nprobe 能提高召回率,但代价是距离计算次数上升。优化时不要只调 nprobe,还要观察桶内分布是否均匀。如果某些桶特别大,说明聚类没有适应数据分布,可以尝试换聚类算法或增加 nlist。
量化压缩解决的是桶内向量仍以全精度存储的问题。PQ 乘积量化把每个高维向量切成若干子段,对每个子段分别做聚类,用聚类中心编号替代原始浮点值。这样 768 维 float32 向量可以被压缩到几十字节。OPQ 则在 PQ 之前加入一个旋转矩阵,让子段之间能量更均匀,减小量化误差。标量量化 SQ 则更直接,把每个 float32 映射到 int8,压缩率固定在 4 倍,适合召回率要求高的场景。
下面的代码用 Faiss 训练一个 IVF 加 PQ 索引,展示压缩率和查询流程:
import faiss
import numpy as np
d = 768
nb = 5000000
nlist = 4096
m = 64 # PQ 子段数,必须能整除 d
nbits = 8
xb = np.random.randn(nb, d).astype('float32')
xq = np.random.randn(1000, d).astype('float32')
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFPQ(quantizer, d, nlist, m, nbits)
index.train(xb[:200000])
index.add(xb)
index.nprobe = 32
D, I = index.search(xq, 10)
PQ 的压缩率由 m 和 nbits 决定。以 m=64、nbits=8 为例,每个向量存储为 64 字节,比 float32 的 3072 字节缩小了 48 倍。压缩率越高,召回率损失越大。OPQ 训练更慢,但能明显减少高维数据的量化失真。标量量化可以作为对照组,如果 SQ 的召回率已经满足需求,就不必上 PQ,因为 SQ 的解码和距离计算更快。
下面用表格对比三种量化方式的基本特点:
| 量化方式 | 典型压缩率 | 召回率损失 | 适用场景 |
|---|---|---|---|
| 标量量化 SQ | 4 倍 | 极小 | 召回要求高、内存稍宽裕 |
| 乘积量化 PQ | 20 到 50 倍 | 中等 | 亿级数据、内存受限 |
| 旋转乘积量化 OPQ | 20 到 50 倍 | 低于 PQ | 高维、分布不均匀的数据 |
组合使用 IVFPQ 时,先固定 nlist,再逐步增大 m 或降低 nbits,观察召回率变化。训练数据要覆盖真实查询分布,不能只用随机向量,否则聚类中心和旋转矩阵会失真。
四、调优顺序与压测验证:别只盯平均延迟
很多团队只看平均延迟,结果上线后 P99 飙高。向量查询的延迟分布往往有长尾,尤其是 HNSW 遇到局部稠密区域,或者 IVF 查询命中了异常大的桶。压测时必须记录 P95、P99 和最大延迟,并统计每个查询计算距离的次数。平均延迟下降不代表长尾问题解决。
一个合理的调优顺序是:先做数据分布分析,看向量模长、聚类分布和维度相关性;再选择索引类型,百万级优先 HNSW,亿级优先 IVFPQ;然后固定召回率目标,例如 95% 或 99%,在相同召回率下比较不同参数组合的 QPS 和内存占用。召回率评估需要构造 ground truth,可以离线用暴力搜索生成一部分查询的真实近邻。
还可以用批量查询和预热来降低冷启动影响。查询接口应当支持批量输入,减少单条请求带来的调度和锁开销。索引加载完成后先跑一轮伪查询,把热数据拉进内存,避免前几分钟延迟异常。监控上重点看内存带宽使用率、索引文件大小、查询队列长度和召回率指标,而不是只盯 CPU 使用率。
最终目标不是把向量数据库调到最快,而是在满足召回率要求的前提下,把单机吞吐和 P99 延迟压到业务可接受的区间。索引优化和量化压缩是两条可以叠加的路径:前者缩短查询路径,后者降低内存访问代价。两者配合好,比盲目增加副本或换硬件要划算得多。