
开头先说个实话——我一开始看到“HNSW”这三个字母的时候头都是大的。作为一个平时写业务代码居多、对算法细节有点发怵的人PG Vector 里的索引参数在我眼里就是一堆莫名其妙的名词m 是什么efConstruction 是什么efSearch 又是什么更别提那个动不动就把内存吃满的 HNSW感觉一不小心就能把数据库搞挂。但后来我真的静下心来花了一个小时不看花里胡哨的博客直接自己动手建表、插数据、建索引、跑查询突然发现这玩意儿根本没有想象中那么玄乎。HNSW 本质上就是一个“多跳的朋友圈索引”它用图的结构帮你快速找到“最相似的邻居”不需要逐个全表比对。你只需要理解它有几个关键旋钮再知道每个旋钮拧大拧小分别会带来什么后果就能应付绝大多数场景。这篇文章就是我从“完全不懂”到“能上手调优”的完整过程记录适合那些想用 PG Vector 做向量检索、但不打算系统啃论文的人。我尽量用大家能听懂的大白话讲清楚 HNSW 的调优逻辑并给出可以直接抄的 SQL 和参数建议。如果你也觉得自己“笨”那正好我也是这么学的。1. 先搞明白 HNSW 到底解决什么问题1.1 向量检索的痛点在哪里我第一次接触向量数据是在做“以图搜图”的需求把每张图片通过一个模型转成一串几百维的浮点数向量然后用户传来一张图也要转成向量再去数据库里找哪些向量“离得最近”。如果用最原始的办法就是把表里所有向量都算一遍距离然后排序取前几个这在几千条数据下没问题但到了几十万条、几百万条查询会越来越慢最后慢到不能用。因为每一条查询都要做全量距离计算时间随着数据量线性增长这就是暴力检索的致命伤。而 HNSW 这类索引做的事情就是牺牲一部分构建时间和内存换取更快的查询速度让“找最近邻居”这个动作不再需要扫全表而是沿着索引结构跳跃式地接近目标。这个思路其实和人查地图很像。你不会从家门口挨条街走过去找一家商场的具体位置而是先看城市级别的地图找到商圈再细化到街道最后定位到具体门牌号。HNSW 就是给向量数据也建了一张多层级的地图。1.2 为什么选 HNSW 而不是别的算法PG Vector 这个扩展提供了很多索引类型除了 HNSW还有 IVFFlat。我刚入门的时候看的教程里很多都推荐先用 IVFFlat因为它的内存占用相对更低。但工业界用得最多的反而是 HNSW原因是它不用像 IVFFlat 那样必须先“训练”出聚类中心也不需要对数据集做预分组索引构建完直接查查询效果也更稳定。打个比方IVFFlat 就像你去一座陌生的城市先根据行政区把地点分好类查询时先判断你要找的东西可能在哪个区再去那个区里面翻。这里的关键在于“分类准不准”如果数据分布不均匀或者聚类中心训练得不好查询召回率会明显下降。HNSW 则是另外一个玩法它不看什么“区域”而是让每个向量都认识一些邻居再通过邻居的邻居一路找过去天然就适应各种分布的数据。我之前在验证集上做过对比同样是 50 万条 768 维的向量数据IVFFlat 的召回精度和参数比如 lists的选择强相关需要反复试验而 HNSW 只要按我下文说的那套参数起手召回率通常都比较稳不需要做太多额外调参。所以对于普通开发者和大多数业务场景我建议优先选 HNSW。1.3 想清楚自己调优的目标调试任何索引前先搞清楚自己的核心诉求是要查询极快还是要召回率尽可能高还是要节省内存大多数人不可能同时拿到三个“最好”因为 HNSW 本质上是用内存换时间用召回率换速度。你在调优时只能根据业务做取舍。比如做 RAG 应用也就是知识库问答用户问一个问题系统要从大量文档片段里召回最相关的几个片段。这种场景下召回率很重要如果召回的片段不对后面的生成模型再强也白搭。但如果你是在做大规模去重、相似商品推荐可能对精度要求没那么高反而要控制机器成本这时参数就可以往“省、快”的方向调。记住这个判断之后再去看参数就心里有数了。下面我把我理解的 HNSW 内部结构和每个参数的意义拆开理顺。2. HNSW 核心参数拆解搞懂原理再调优2.1 m 和 efConstruction一个管“朋友圈大小”一个管“构建时找朋友多认真”HNSW 底层是一个多层的图结构每一层都有很多节点每个节点代表一个向量节点通过边连接起来构成一张网络。你要找某个向量的最近邻居时就先从最顶层开始找几个入口点然后逐层往下每层都走一些节点直到最底层再在附近仔细搜一圈。这里出现了两个最重要的构建期参数m每个节点最多可以有多少个邻居也就是每个节点在图中最多连多少条边。efConstruction在构建索引时搜索每个节点邻居的候选队列大小越大代表构建时越“精挑细选”。我一开始也分不清这两个参数的关系后来用交朋友的逻辑理解就好。想象一下你刚搬到一个小区想快速认识周围住的人。m就是你最多能交换联系方式的邻居数量而efConstruction就是你在挑邻居时有多认真是随便抓一把就交换联系方式还是会把附近的人都比较一遍挑最聊得来、离你最近的那些人。如果m太小每个人的朋友圈都很小查询时从一个人走到另一个人要绕很多路如果m太大虽然路网密了但每个节点要保存的邻居列表也长内存占用大幅上升而且检索时也会多不少无效跳转。经验上一般从 16 起步数据维度越高越可能需要更大一些的m但很少需要超过 64。efConstruction设置得太小建索引时“偷工减料”相邻节点之间的边连得不够好后面查询时就容易漏掉真正距离近的点也就是召回率会下降。设置得太大构建索引的时间会明显变长。一般建议从 64 或 128 起步对于一两百万以内的数据量128 通常已经够了。2.2 efSearch查询时的“深挖”程度另一个绕不开的参数是查询时的ef_search它代表了检索过程中每一层能保留的候选队列大小。你可以理解成“搜索时的探路小队人数”人越多越能多走几条岔路看看不容易漏掉藏在角落里的真邻居但耗时也会增加。EF 这个词全称是 explore factor中文可以理解为“探索因子”形象点说就是你愿意派多少“探子”出去找邻居。ef_search设为 40 时查询的召回率中等响应也很快设为 200 以上时召回率能提上去不少但单次查询的耗时可能翻好几倍。最需要记住的是ef_search不是建索引时固定的而是查询时传入的实时参数可以按需调整这也是 HNSW 一个比较大的优势。我见过很多初学者把ef_search和ef_construction搞混误以为改了建索引的 SQL 就能同时影响查询结果建完索引查询速度还是不行。实际上这两个参数分工明确ef_construction管“建房质量”ef_search管“进屋找东西的仔细程度”。修改查询端的hnsw.ef_search只影响当前会话的查询行为不用重建索引。2.3 HNSW 索引内部对性能影响的其他因素除了上面三个核心参数还有两个容易忽略的细节。第一个是max_connections和m的关系在 PostgreSQL 的 PG Vector 实现中m是 BRIN 等索引术语之外的单独参数构建索引时你指定m最终每层的最大邻居数由它决定最底层连接数可能更多因为最底层节点最多、检索频次也最高。第二个是索引构建时的并行度PG Vector 的 HNSW 构建目前默认是单线程的数据量大时构建时间会很长这时可以结合maintenance_work_mem适当调大内存减少因为内存不足导致的额外 IO。还有一个我踩过的坑HNSW 索引文件size会随着数据量增长变得非常大。768 维的向量100 万条数据索引文件甚至可能逼近几个 GB。因为 HNSW 不仅要存向量本身还要存每个节点的邻居表以及多层图结构的相关信息。所以你的数据库磁盘预算里要提前把这个索引膨胀系数算进去。3. 实操一把梭建表、写数据、建 HNSW 索引3.1 环境准备和 PG Vector 安装先确认你用的 PostgreSQL 版本和 PG Vector 扩展是不是匹配。我本地用的是 PostgreSQL 15PG Vector 装的是 0.5.0 以上版本。不同版本在 SQL 写法上有细微差异建议动手前先跑一条命令确认免得后面报错时手忙脚乱SELECT extversion FROM pg_extension WHERE extname vector;如果你还没安装在 Ubuntu 上可以用 apt 装postgresql-15-pgvector也可以用源码编译。源码编译无非就是拉仓库、make、make install然后再到数据库里执行CREATE EXTENSION vector;这步成功之后你就可以创建带向量类型的表了。PG Vector 提供的是一个名为vector的数据类型用来存储固定维度的浮点数组。创建表的时候要指定维度大小如果不确定用 768 还是 1536可以先看你所用 embedding 模型的输出维度。像 OpenAI 的text-embedding-ada-002是 1536 维很多开源模型是 768 维自己训练的可能更小。3.2 建立测试表和插入向量数据我们来建一个简单的 demo 表模拟文档片段和它的向量表示CREATE TABLE doc_embedding ( id bigserial PRIMARY KEY, content text, embedding vector(768) );插入数据的方式也很简单把外部程序生成好的向量以字符串形式传入INSERT INTO doc_embedding (content, embedding) VALUES (PG Vector 是一个好用的向量检索扩展, [0.001, 0.002, ...]), (HNSW 索引能加速最近邻搜索, [0.003, 0.001, ...]);这里有个注意点向量数组的维度、类型必须和列定义完全一致。如果模型输出是 float32 但因为某些序列化过程变成了 float64或者某个维度被截断了插入时会直接报错。我建议在写入前先做一次维度校验尤其是从 Python 侧写入时最容易出这种低级但很恼火的错误。真正常见的生产导入方式是用COPY命令从文件批量导入或者通过 Python 的 psycopg2、SQLAlchemy 循环插入。但在准备小规模验证时直接写 INSERT 更直观。3.3 创建 HNSW 索引的完整 SQL 示例有了数据就可以建索引了。PG Vector 创建 HNSW 索引的 SQL 长这样CREATE INDEX ON doc_embedding USING hnsw (embedding vector_l2_ops);这里使用的是 L2 距离欧氏距离。PG Vector 支持三种距离算子vector_l2_ops欧氏距离适合需要物理意义距离的场景。vector_ip_ops内积距离适合点积相似度的场景。vector_cosine_ops余弦距离适合语义相似度场景也是 RAG 里最常用的。选距离算子不能拍脑袋要和你的向量模型匹配。比如很多 sentence embedding 模型训练时用余弦相似度你就应该用vector_cosine_ops。用了不匹配的算子在数量级上可能会差不少虽然不一定会天崩地裂但会白白损失精度。如果你想让m和ef_construction显式指定出来SQL 可以这样写CREATE INDEX doc_embedding_hnsw_idx ON doc_embedding USING hnsw (embedding vector_cosine_ops) WITH (m 16, ef_construction 64);建索引的过程可能比较长如果表里已经有几十万甚至几百万条数据建议先设置一下会话的maintenance_work_mem然后再执行建索引语句。比如SET maintenance_work_mem 1GB;这里本质上就是告诉 PostgreSQL你这次维护操作允许使用更多内存。PG Vector 构建索引时确实会用到这个配置内存大一点构建期间临时排序和邻居候选的缓存都能更宽松整体的构建时间会明显更短。3.4 查询语句里的 ef_search 怎么设置查询端参数hnsw.ef_search有点特殊它不是建索引的配置而是每次查询会话里的配置项。你可以这样设置SET hnsw.ef_search 100;也可以只对当前事务生效BEGIN; SET LOCAL hnsw.ef_search 100; SELECT id, content, embedding [向量...] AS distance FROM doc_embedding ORDER BY embedding [向量...] LIMIT 10; COMMIT;注意这里是余弦距离运算符和索引里的vector_cosine_ops对应。如果你建索引时用的是vector_l2_ops查询距离运算符就该是-如果是vector_ip_ops距离运算符就是#。约定俗成但不检查的话很容易踩到“明明有索引却不走索引”的坑。设置ef_search的经验法则很简单先用一个中间值跑一轮比如 100再根据实际查询质量和耗时去调。低于 40 时召回率下降明显高于 200 之后收益递减往往只是徒增耗时。4. 调优实战从参数到场景的一整套策略4.1 小数据集也要讲究参数不能一味抄默认很多人上来就问“HNSW 最优参数是什么”说实话这个问题没答案得结合数据量和业务诉求来定。但可以给一个靠谱的起始点数据量mef_constructionef_search说明万级以内8~1632~6440~80数据量小别把索引建太大十万级166480~120相对均衡查询快内存适中百万级16~32128120~200适当增加候选队列保召回千万级以上32~48256200需要靠内存换精度得评估机器情况不过你别把上表当成金科玉律。它只是我实测下来的经验范围更符合“大多数人的起点”。具体到你的数据分布、向量维度、查询 QPS最终参数还得靠实验来定。我还想强调一个我自己犯过的错误数据量很小的时候比如只有几千条向量没必要用m32这种大参数。因为图本身就很稀疏把每个点的邻居数上限设得太大只会让索引构建时多花时间也会把索引文件撑得更大但查询速度提升微乎其微。这时候用默认的参数或者偏小的m反而更合理。4.2 先跑通再调优别一开始就追求完美我在调优时有一个固定流程先用一组保守参数跑通全链路确认查询接口返回结果正常然后围绕“召回率”和“延迟”这两个指标做定向调优而不是一开始就去读各类论文。第一步是准备一份带标准答案的验证集。比如你有 1000 条查询向量每条都知道它最相近的真值 top-10 是什么。然后用ef_search 40、80、120、200分别跑一遍计算召回率。所谓召回率就是“你查出来的结果里有多少比例包含在真值 top-10 中”。如果真值 top-10 有 8 条出现在你的查询结果中召回率就是 80%。但真值怎么来最可靠的办法是小范围内暴力检索。在你自己准备的数据集里挑出一小部分比如 1000 条作为查询集不管你用不用索引先用从全表中逐条比对距离并排序的方式拿到每个查询的真实 top-10。之后再针对不同参数把查询结果和真值比对算召回和平均耗时。我实际做过的案例是50 万条 768 维文档向量先用暴力检索算出 1000 条查询向量的真值然后对比 HNSW 在不同ef_search下的表现。当ef_search从 20 升到 40召回率从 88% 涨到 95%从 40 涨到 100召回率只提升了 2 个百分点而耗时却涨了接近一倍。所以最终我把线上查询的ef_search稳定在 80 左右算是兼顾了延迟和精度。4.3 一个真实调优案例RAG 应用中的文档召回我之前帮朋友调过一个小型 RAG 项目数据量大概 30 万条每条向量来自开源 embedding 模型维度 768语义相似度采用余弦距离。初始参数建索引时用的m16, ef_construction64查询ef_search40。初始实测结果单条查询耗时约 8ms这个延迟已经很理想了但业务方反馈“召回的上下文不够精准”部分问题找不到对应的文档片段。我查了下日志发现这其实不全是索引的问题而是文本切片切得太碎导致向量丢失上下文语义。不过在索引层面我也做了调整先通过验证集计算发现ef_search40时召回率大概只有 90% 上下对我来说可以接受但还不够好。于是我把验证集改成ef_search100召回率到了 96%延迟涨到 18ms。最后我调了一下ef_construction重建索引从 64 提到 128查询时再把ef_search降到 80最终结果是召回率保持在 95% 以上延迟稳定在 12ms。原因也很简单构建期的邻居选择更精细了查询时需要“探索”的代价就低了能用更小的ef_search达到更好的召回率。这个案例给我最大的启发是构建期参数和查询期参数需要配合调节而不是单看某一边。4.4 如何观察索引是否生效调优过程中最基础但最关键的一步确认查询真的在走 HNSW 索引。很多人会用EXPLAIN看执行计划但如果止步于“看到 Index Scan”就以为自己调优成功反而容易忽略骨骼清奇的细节。EXPLAIN ANALYZE SELECT id, content, embedding [向量...] AS distance FROM doc_embedding ORDER BY embedding [向量...] LIMIT 10;正常走 HNSW 索引的执行计划中会出现类似Index Scan using doc_embedding_hnsw_idx的字段。如果出现的是Seq Scan说明你写的查询条件和索引定义不匹配常见的原因包括使用了不同的距离运算符、查询表中存在索引没覆盖到的其他排序条件、或者LIMIT设置太大导致优化器认为全表扫描更快。这里有个冷门知识如果索引列类型是 vector但查询参数写成[...]::textPG 没法直接推断类型也可能走不了索引。正确的做法是在参数前面加上类型转换比如ORDER BY embedding [向量...]::vector这种小细节最容易坑人。很多人在网上求助“为什么建了索引却没变快”最后定位到的原因根本不是参数问题而是 SQL 写法问题。5. 常见问题与排查技巧实录5.1 建索引时内存、时间长到怀疑人生如果你在 100 万条以上数据建 HNSW 索引发现执行了十几分钟还没结束不用太慌。HNSW 构建时的计算量确实不小因为每个节点插入时都要在多层图中搜索、选邻居。你可以调整以下几个方面调大maintenance_work_mem减少排序和外存临时文件的产生。一次性批量导入数据尽量减少与建索引并行的写操作。如果可能把ef_construction先设小一点比如 64索引建完后再评估是否需要重建。监控服务器磁盘和 CPU确认不是被别的任务占满了。我遇到过一种比较极端的情况数据量约 200 万条当时ef_construction设成了 256结果机器内存直接吃满数据库进程无响应最后只能杀掉会话、重启实例。后来我学乖了建大索引时都盯着top看一般让内存使用率控制在物理内存的 60%~70% 以下比较稳妥。5.2 查询结果看起来不太对召回特别差召回率低的原因通常是这几种一是ef_search设得太低探路小队人手不足远处一些真正近的点根本没有被发现。二是索引距离算子选错比如建索引用了vector_l2_ops查询运算符却用的是余弦距离这时 PostgreSQL 虽然可能会走索引但计算的距离含义完全错位结果当然也错。三是向量归一化问题很多 embedding 模型在输出向量前会做归一化但如果你自行对向量做了缩放或没有归一化余弦距离和 L2 距离的排序结果可能会有显著差异。你也可以先做一个实验来定位问题临时禁掉索引强制走全表扫描看看全表扫描的结果和你 HNSW 查询的结果差多少。如果全表扫描的结果更符合预期说明索引参数有问题如果全表扫描本身也不好那问题多半出在向量本身或切分逻辑上别把锅甩给索引。5.3 索引文件太大磁盘告急HNSW 索引占用空间大不是什么秘密。我之前建的 768 维索引一个 100 万行的表索引文件差不多是数据文件的好几倍。这是因为 HNSW 需要存储多层图结构每个向量在每一层都可能出现在多个节点的邻居链表里重复存储不可避免。有几种处理思路如果业务对召回要求不是极致把m调小一点比如从 32 降到 16索引文件能缩小不少。使用halfvec类型或者降维处理比如把 768 维压缩到 256 维索引大小和查询速度都会显著改善前提是召回损失在可接受范围。部分场景可以改用 IVFFlat索引文件更小构建速度也更快但它的召回率稳定性要差一些需要你多花功夫调lists参数。5.4 高并发场景下延迟越来越明显当你把ef_search调得很大时单条查询延迟低但一旦并发几十上百数据库 CPU 可能会被打满。HNSW 查询是 CPU 密集型的不像普通 B-Tree 那样轻松。应对方式主要有两个方向一是减少ef_search允许一定召回率损失换取更低的单查询耗时二是引入 Redis 或者单独部署向量检索服务把数据库承担的查询压力分流出去。从成本角度我更倾向于先做一层缓存。对于内容基本固定的知识库热点向量查询结果完全可以在 Redis 里缓存几个常见 query 的返回结果减少重复计算。如果业务对召回要求高且查询模式多变再考虑用专门的向量搜索引擎但这就超出了 PG Vector 的范畴了。5.5 建索引后数据更新频繁导致查询变慢PG Vector 的 HNSW 索引支持增量更新但如果你的业务里频繁插入、修改、删除向量索引的图结构会逐渐产生一些质量下降的问题。数据库里的 HNSW 实现会在插入时动态维护图但如果大量删除历史向量被删除节点遗留下来的边可能导致路径变长进而让查询变慢。对这种场景我的建议是不要频繁做小批量的 delete/update而是采用定期重建索引的策略。比如每天凌晨对当天的增量数据合并一次重建一次索引。虽然重建时间长了点但可以保证查询性能始终准确稳定。你要是图省事也可以直接对表做一次REINDEX但那会把整张表的索引一次性重建耗时和内存占用都会比较高建议在业务低峰期执行。结尾最后再分享一个实实在在的经验HNSW 调优不是一次性的它会随着你的数据量增长、业务需求变化而需要重新审视。我一开始也觉得调参很难直到自己花了那个下午一条条 SQL 跑下去一遍遍对比召回率和延迟才真正建立起了对参数的感觉。现在再有人问我“HNSW 怎么调”我都会建议他先别看论文先把你自己的数据放进 PG Vector 里用默认参数跑起来然后用验证集一点一点去探索。踩过坑、对比过数据你才会真正理解这些参数背后到底在调什么。如果你也觉得自己“笨”完全没关系。搞技术这回事很多时候就是靠手熟和踩坑换来的多试几次你也能很快上手。