向量索引真正要定下来的是三件事:图建成什么形状、建图时愿意花多少钱、查询时愿意为召回多走多少步。前两件在 CREATE INDEX 跑完的那一刻就固化进磁盘了,想改等于重建;只有第三件能按查询调。但线上大多数「召回不达标」的根因并不在这三个数上——而在过滤发生在图遍历之后,以及分片会让候选集在合并前就被各自截断(动参数之前先把召回基线量出来,指标口径见RAG 检索评估实战)。还有一个前提得先摆正:2026 年主流引擎的默认值已经整体从「float HNSW」迁到了「1-bit 量化 + 重排」。Elasticsearch 自 9.1 起,对维度 ≥ 384 的 float 向量默认索引类型就是 bbq_hnsw,9.4+ 在许可证允许时默认 bbq_disk(见 dense_vector 字段参考)。如果你还按「HNSW 里存原始 float32」估内存,估算已经错了一个数量级。

三个参数各自决定什么

M:图的形状,也是内存的下限

M 是每个节点的双向连接数(第 0 层通常是 2M)。它同时决定两件事:图的可导航性,以及与向量数据无关的那部分内存开销。hnswlib 的 ALGO_PARAMS 给了迄今为止最直白的经验:合理区间是 2–100,M=12-48 覆盖绝大多数场景,内存约为 M * 8-10 字节每条向量。

这条公式比它看起来重要。1M 条 768 维向量,M=16 的图结构本身要 128–160 MB。存 float32 时向量占 3.07 GB,图只是 5% 的零头;但如果你按现在的默认做了 1-bit 量化,向量只剩 96 MB——图结构反而成了内存里最大的一块。很多人量化之后发现内存没降到预期,原因就在这里。

M 不是越大越好。高内在维度(intrinsic dimensionality)和高召回目标需要更大的 M,但每次遍历要扩展的邻居也线性变多,延迟跟着涨,而召回在某个点之后基本不动。Lucene 把 MAXIMUM_MAX_CONN 卡在 512,javadoc 直接警告过大的值会吃掉大量堆内存。

efConstruction:一次性付费,且不可退款

efConstruction 只影响建索引时的候选队列大小,对查询延迟零影响。代价全在构建时间和图质量上,而图质量一旦定下来就是天花板——再高的 efSearch 也捞不回一张建坏的图。

hnswlib 给了一个不靠猜的验收法:把查询期的 ef 设成等于 ef_construction 去测 M-NN 召回,如果低于 0.9,说明 ef_construction 该提高了。这是我见过唯一一个可执行的定标方法,比「社区都用 200」这种说法靠谱得多。

顺带说,pgvector 的默认值 64 明显偏低,是三家里最保守的。

构建期还有一个容易被忽略的悬崖:pgvector 明确说明索引在图能放进 maintenance_work_mem 时构建显著更快,一旦图放不下,日志里会出现 hnsw graph no longer fits into maintenance_work_mem after N tuples 的提示,后续构建速度断崖式下跌。所以调 efConstruction 之前先把 maintenance_work_mem 调够(官方示例给的是 SET maintenance_work_mem = '8GB'),否则你测到的「efConstruction 太贵」其实是内存不够的假象。

efSearch:唯一的在线旋钮

efSearch(pgvector 叫 hnsw.ef_search,Qdrant 叫 hnsw_ef,Lucene 上就是 kNN 查询的 k,Elasticsearch 把它单独暴露为 num_candidates)是唯一能按请求调的参数,也应该是你做召回/延迟权衡时唯一动的东西。它必须 ≥ k;Qdrant 会内部执行 ef = max(ef, limit) 兜底,见其 FAQ

它的曲线是强烈非线性的:从 40 提到 100 通常能换来可观的召回,从 200 提到 400 往往只剩零点几个百分点,延迟却接近翻倍。所以正确的做法不是找一个「最优 efSearch」,而是在你的数据上画出召回-延迟曲线,找到拐点,然后把拐点两侧各留一档作为可降级的旋钮——高峰期调低换吞吐,离线任务调高换质量。把它硬编码在配置文件里是浪费。

默认值对照

引擎 M / maxConn efConstruction 查询期 ef
pgvector 0.8.6(2026-07-29) m = 16 ef_construction = 64 hnsw.ef_search = 40
Qdrant 1.19(2026-08-05) m = 16 ef_construct = 100 hnsw_ef 缺省取 ef_construct
Lucene 10.x DEFAULT_MAX_CONN = 16(上限 512) DEFAULT_BEAM_WIDTH = 100(上限 3200) kNN 查询的 k;Elasticsearch 暴露为 num_candidates按分片
hnswlib 建议 12–48(区间 2–100) 用上面的验收法定标 ef ≥ k

M 上大家都收敛到 16,efConstruction 没有共识。这本身就说明 efConstruction 是需要按数据定标的那一个。

层级结构可能不值这个钱

Munyampirwa、Lakshman 和 Coleman 的 Down with the Hierarchy: The "H" in HNSW Stands for "Hubs"(arXiv:2412.01940,v3 更新于 2025-07-03)给出了一个反直觉但被多组大规模数据集验证的结论:在高维数据上,扁平的 NSW 图与完整 HNSW 的延迟和召回基本一致,而内存开销更低。他们提出的解释是「hub highway 假说」——图里天然会涌现一批高连通的枢纽节点,承担了分层结构本该承担的远距离跳转职能。

原始算法(Malkov 与 Yashunin,arXiv:1603.09320,v4 提交于 2018-08-14)当年是拿跳表类比来论证分层的。实践含义很直接:不是说分层有害(没有哪个引擎把它拿掉了),而是分层不是你的调优面。别花时间琢磨层数或层级概率,你真正能控制的只有 MefConstructionefSearch,加上量化档位。

量化的代价在重排,不在压缩

PQ 已经不是首选了

Qdrant 的量化文档把话说得很直白:PQ 最高能压 64x,但它的距离计算「不是 SIMD 友好的,所以比 scalar quantization 慢」。也就是说 PQ 用精度和速度同时换内存。除非内存是硬约束且你明确接受变慢,否则 2026 年不该再默认选 PQ。

对照之下,int8 scalar quantization 压 4x,Qdrant 实测精度损失「通常低于 1%」。这是应该默认打开的一档。

风向变化在 FAISS 这边也很清楚:它的索引选型指南在「内存非常重要」一档里现在并列推荐 PQ 与 RaBitQ,而 1.15.0(2026-07-31,见 CHANGELOG)继续在往 RaBitQ 和新量化器上加 SIMD 内核。当一个以 PQ 起家的库开始把 PQ 和别的方法并列写进选型建议,这就是信号。

1-bit 路线为什么赢了

分水岭是 Gao 和 Long 的 RaBitQ(SIGMOD 2024):把 D 维向量压成 D bit,同时给出可证明的距离估计误差界。PQ 之所以会在某些真实数据集上灾难性失效,正是因为它没有这个界。Elastic 的 BBQ 从 RaBitQ 的思路衍生而来,默认做非对称量化——索引侧 1-bit,查询侧 4-bit。同一份文档也提醒:低于 384 维的数据集精度会变差,且校正因子的相对开销更高,这正是 Elasticsearch 的默认值以 384 维为界、低于该维度仍用 int8_hnsw 的原因。

另一条线是 TurboQuant(Zandieh et al.,arXiv:2504.19874),先对向量做随机正交旋转再逐坐标量化。Qdrant 1.18(2026-05-11)落地,提供 4-bit(8x)、2-bit(16x)、1.5-bit(约 21x)、1-bit(32x)四档,官方文章称在同等 32x 压缩下比二值量化高出 9–21 个百分点的召回。

重排是隐藏账单

压缩比是给人看的,重排开销才是要付的。Elastic 的 kNN 文档给了很具体的经验值:int8 基本不需要重排;int4 用 1.5x–2x 过采样通常能补回大部分损失;bbq 通常需要 3x–5x。

3x 过采样意味着你要多取三倍候选,再拿原始向量逐条算精确距离。原始向量在哪?如果它没在内存里,这就是一轮随机读。你省下的内存有相当一部分是以随机 IO 的形式还回去的,而随机 IO 在 P99 上比在均值上难看得多。

Qdrant 1.19 的 Turbo4 数据类型把这个权衡挑明了:4-bit 是唯一表示,不再保留全精度副本,存储从 36 bit/坐标降到 4 bit(约 9 倍),代价是官方明说「无法拿原始向量重排 top 候选」。这是个诚实的设计,但你得知道自己签的是哪份合同。

过滤和分片才是真正吃召回的地方

过滤发生在图遍历之后

pgvector 的 README 原话是:使用近似索引时,带过滤的查询可能返回更少结果,因为过滤是在索引扫描之后才应用的。0.8.0 为此加了迭代扫描:hnsw.iterative_scan 可设为 strict_orderrelaxed_orderhnsw.max_scan_tuples 默认 20000,hnsw.scan_mem_multiplier 默认为 work_mem 的 1 倍。

但迭代扫描是止血,不是治本。当过滤列只有少量取值时,pgvector 文档自己推荐的是分区索引:

CREATE INDEX ON items USING hnsw (embedding vector_l2_ops) WHERE (category_id = 123);

Qdrant 走的是另一条路:full_scan_threshold 默认 10000,单位是 KB(文档注明 1KB ≈ 一条 256 维向量),当条件命中的数据量低于阈值时,查询规划器直接放弃 HNSW 走全扫,见其索引文档。这等于官方用代码承认:过滤够窄时,暴力检索就是更快的那个。

num_candidates 是每分片的

Elastic 的语义是:每个分片先取 num_candidates 个近似邻居,各自选出 top k,再合并成全局 top k。所以分片数一变,同样的 num_candidates 对应的实际召回就变了。扩容时最容易踩这条:你以为只是加了机器,实际上把召回的定标基准也一起改了。任何调参结论都必须和当时的分片数一起记录,重分片后重测。

顺带一提:如果 efSearch 和过滤这两条都排查完召回仍不达标,问题多半在索引之外——切分粒度和只走单路向量召回(MongoDB 混合检索实战里的 $rankFusion 是补第二路的常见做法)都比图参数更能解释缺掉的那部分召回。

什么时候暴力检索反而更划算

三种情况,我给明确判断。

一、查询次数少。 FAISS 的索引选型指南写得很清楚:如果你只打算做很少的查询(比如 1000–10000 次),建索引的时间摊销不回来,用 Flat 就行——而且它是唯一能保证精确结果的索引。离线批处理、一次性去重、评测集回归,都属于这一类。

二、过滤后的子集小。 这就是 Qdrant 把这套逻辑做进查询规划器的原因。多租户系统里每个租户几万条向量,「按租户分区 + 分区内全扫」的确定性远好过「全局 HNSW + 后置过滤」,还能直接消掉一整类过滤召回 bug。

三、量化之后,暴力扫描退化成内存带宽问题。 做个算术(这是算术,不是 benchmark):1M 条 768 维,float32 是 3.07 GB,int8 是 768 MB,1-bit 是 96 MB。96 MB 的顺序扫描在现代单机上是几十毫秒量级,而且召回是 1.0(在量化误差内)——没有图,没有构建期,没有删除留下的墓碑,容量规划是一条直线。这正是 Elasticsearch 提供 bbq_flat / int8_flat、pgvector 支持对 binary_quantize(embedding)::bit(n) 建索引并用 <~> 汉明距离先粗排再重排的原因:

SELECT * FROM (
    SELECT * FROM items
    ORDER BY binary_quantize(embedding)::bit(3) <~> binary_quantize('[1,-2,3]')
    LIMIT 20
) t ORDER BY embedding <=> '[1,-2,3]' LIMIT 5;

我的判断:单分区百万级以下、且有清晰租户或分区边界的场景,先试「量化 + 分区内暴力扫描」,别急着上 HNSW。 回到前面那个数字——1-bit 量化后向量 96 MB,M=16 的图 128–160 MB。你为了避开一次 96 MB 的顺序扫描,付出了比数据本身更大的内存、一段构建期、和一组每次改分片数都要重新定标的参数。HNSW 的价值要等到数据量跨过内存带宽能从容承受的规模,才真正开始兑现。

一个不靠猜的调参顺序

  1. 先定量化档位,再谈图参数。 量化决定内存基线和是否需要重排,图参数只在这个基线上做微调。默认从 int8 起步;维度 ≥ 768 且内存确实吃紧再考虑 1-bit + 3x 过采样。
  2. M 从 16 起步,除非有证据。 三家默认都是 16 不是巧合。只有在 efSearch 已经推到延迟预算上限、召回仍不达标时才动它,动了就要重建。
  3. 用 hnswlib 的验收法定标 efConstructionef = efConstruction 测召回,低于 0.9 就往上加。别抄别人的 200。pgvector 上先把 maintenance_work_mem 调够。
  4. 在线只调 efSearch 这是唯一不需要重建的旋钮,把它做成可按请求覆盖的参数,并且知道拐点在哪。
  5. 过滤单独走一条路。 低基数过滤列用分区索引,其余情况再靠迭代扫描兜底。
  6. 重分片后全部重测。 num_candidates 是每分片的,旧的定标作废。

最后三条别做的:别指望调高 efConstruction 来救低 efSearch(它不影响查询延迟,也救不了);别在没量过重排随机 IO 的情况下上 1-bit;别再默认选 PQ。另外记一个 pgvector 的硬限制——vector 类型可索引维度上限 2000,halfvec 是 4000,超了得先降维或换类型,上面这些调参才谈得上。