尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

pgvector HNSW索引调优实战:从默认参数到高召回率

pgvector HNSW索引调优实战:从默认参数到高召回率 看到标题点进来的朋友我猜你八成也在折腾pgvector或者正准备往PostgreSQL里塞向量数据。先说下背景这个系列前面几篇我写了怎么装扩展、怎么建表、怎么做最基础的向量查询这篇是第4篇主题就是HNSW索引调优也是我花1个小时从会用到知道怎么调的关键一篇。标题里说我很笨不是谦虚是我真踩了不少坑。最开始看文档时我总觉得HNSW不就是建个索引吗USING hnsw一行搞定有什么好调的后来发现天真了。只顾着能跑没去管跑得对不对跑得快不快。直到有一天我拿HNSW索引去查一个50万条数据的表结果召回率只有0.6我才意识到索引是建了但根本没调好。这篇文章适合谁跟我一样刚上手pgvector、对向量索引只懂个大概、想知道m和ef开头的参数到底该设多少的人。我会把原理和实操糅在一起讲尽量不搞玄学所有步骤你都可以照着敲一遍。1. 先说清楚HNSW在pgvector里到底解决了什么问题1.1 暴力扫描的痛你大概率也经历过第一次在pgvector里做相似度查询我是这样写的SELECT id, content, embedding [0.012, 0.034, ...]::vector AS distance FROM documents ORDER BY embedding [0.012, 0.034, ...]::vector LIMIT 10;小数据量的时候一点问题没有。到了几十万条数据查询时间就开始肉眼可见地涨。尤其我这边存的还是768维的向量每一次距离计算都得遍历几十万个浮点数再全局排序取TopK。这种操作在PostgreSQL里叫Seq Scan Sort全表扫一遍数据量越大越扛不住。我调优前的暴力扫描50万条数据单次查询大概在350到400毫秒听起来好像还能忍但如果你的场景是每秒钟几十次甚至上百次这样的查询那就彻底完蛋了。这也正是向量索引存在的意义用空间换时间把每次查询的复杂度从O(n)降下来。1.2 HNSW这个名词拆开看其实不难HNSW全称是Hierarchical Navigable Small World层级化可导航小世界图。名字听着挺唬人但你可以把它想象成一张社交网络每个节点认识一批好友邻居节点。好友之间又各自有不同的人脉。查询的时候你从一个入口节点出发不停问谁离目标更近顺着关系链一级一级跳过去很快就能定位到目标区域附近。HNSW在这个基础上加了一个层级的思路。最上层连接很稀疏就像高速公路网让你快速跨越一大段距离越往下层连接越密像城市里的街道小巷用来精确找到最近的几个点。查询时先从上层粗定位再逐层向下细化效率就上去了。pgvector从0.5.0开始支持HNSW索引它对比之前常用的IVFFlat最大的优势是不需要预先训练。你插入数据的同时索引就在构建不存在数据更新了但训练集过时的问题。而且HNSW在高召回率场景下表现更稳定不需要反复猜lists参数。提示如果你的pgvector版本还停留在0.4.x需要先升级扩展才能用HNSW。PostgreSQL 13以上基本都支持建议直接用最新稳定版。2. 三个参数一次讲透m、ef_construction、ef_search当时让我最头疼的就是文档里这三个参数。现在我用一句话总结m决定图的稠密程度ef_construction决定建图时有多较真ef_search决定查询时愿意花多大力气去找。2.1 m每个节点的好友数上限m代表HNSW图中每个节点最多可以连接多少个邻居。这个数字越大图就越稠密从一个节点出发能选择的路就越多查询时自然越不容易漏掉真正的最近邻召回率会上升。但代价也直接建索引时计算量变大因为每个节点都要从更多候选里挑最优邻居。索引体积变大占用的磁盘和内存都会涨。插入新向量的速度变慢因为要维护的连接关系变多了。我实际测试下来m16能覆盖大多数场景这是pgvector的默认值。如果追求更高的召回率可以试32甚至48。但再往上收益就开始明显递减构建时间倒是肉眼可见地涨。m64在我的数据上只比m32提升了约2到3个百分点的召回率构建时间却多了近一倍完全不划算。2.2 ef_construction建索引时的认真程度ef_construction控制的是构建索引期间每插入一个新向量时动态候选列表的大小。这个值越大新向量在图中寻找自己位置时考虑得越充分最终得到的图质量越高查询时更容易找到准确的最近邻。它的默认值是64。我试过把它降到16索引构建确实快了不少但召回率会明显下降把它提到256图质量变好但建索引时间翻了将近一倍。比较合理的做法是先按默认64跑如果召回率不达标再慢慢往上加。2.3 ef_search查询时的搜索宽度这个参数和前面两个完全不是同一个阶段的东西所以经常被忽略。它表示查询时每一层搜索维护的候选列表大小值越大查询时搜索的范围越宽越能找到真正的最近邻但延迟也会上升。关键点在于ef_search是在查询时设置的会话参数不需要重建索引。SET hnsw.ef_search 100; SELECT ...;如果你不设置默认值是40。40对很多场景够用但遇到数据分布比较差或者维度高的向量召回率可能不够理想。2.4 三个参数怎么配合我习惯把调优分成两个阶段来看建索引阶段m和ef_construction共同决定图的质量。这一步慢一点没关系多花几十秒构建时间换来一个更准的索引通常值得。查询阶段ef_search是运行时参数调它不需要重建索引所以它适合在系统上线后根据真实查询效果反复调整。参数关系大概如下表参数控制阶段默认值常见范围调大后的影响调小后的影响m建索引162-100召回率上升构建变慢索引变大构建快索引小召回率下降ef_construction建索引644-1000图质量更高构建更慢构建快图质量可能下降ef_search查询401-1000召回率上升查询变慢查询快召回率可能下降调优的核心就是在这三者之间找平衡点而不是把每个参数都拉到最大。我见过有人把ef_search调到1000查询延迟翻了5倍召回率只从0.95涨到0.96这完全没必要。3. 动手调优从默认参数到能掏出数据的完整过程3.1 建表和造数据我测试用的环境是PostgreSQL 15 pgvector 0.7.x表结构大概长这样CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE documents ( id bigserial PRIMARY KEY, content text, embedding vector(768) );我往里面插了50万条随机构造的向量另外单独准备100条查询向量用来测召回率。这里我提个建议测召回率的查询向量最好从真实数据里抽不要用纯随机数。真实数据通常有聚类特性纯随机向量测出来的结果没有代表性。3.2 第一次建索引默认参数先跑通用默认参数建索引很简单CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m 16, ef_construction 64);如果你用的是L2距离最后那部分要换成vector_l2_ops用内积的话就是vector_ip_ops。这个细节很关键后面我会单独讲它有多坑。建完索引跑一次同样的查询SET hnsw.ef_search 40; EXPLAIN ANALYZE SELECT id, content, embedding [0.012, 0.034, ...]::vector AS distance FROM documents ORDER BY embedding [0.012, 0.034, ...]::vector LIMIT 10;在我机器上查询时间从暴力扫描的约380ms降到了约8ms。但你如果只看到这个数字就觉得自己调优完成了那就掉进我踩过的坑里了快不一定准。3.3 召回率到底怎么测这一步我强烈建议你跑一遍因为后面所有调优决策都要靠数据说话。方法很简单先关掉索引或者直接写一个暴力查询拿到每条查询向量的精确前20个结果。再走HNSW索引拿到近似前20个结果。两个集合的交集数量除以20就是召回率。暴力查询的SQL可以直接把索引禁用掉SET enable_indexscan off; SET enable_bitmapscan off; EXPLAIN ANALYZE SELECT id, embedding [0.012, 0.034, ...]::vector AS distance FROM documents ORDER BY embedding [0.012, 0.034, ...]::vector LIMIT 20;然后在代码里或临时表里把两条结果集的id拿出来比对。我在单测里是写了个Python脚本批量跑的import psycopg2 import numpy as np conn psycopg2.connect(...) cur conn.cursor() def exact_topk(query_vec, k20): cur.execute( SELECT id FROM documents ORDER BY embedding %s::vector LIMIT %s , (query_vec.tolist(), k)) return set(row[0] for row in cur.fetchall()) def hnsw_topk(query_vec, k20): cur.execute(SET hnsw.ef_search 100) cur.execute( SELECT id FROM documents ORDER BY embedding %s::vector LIMIT %s , (query_vec.tolist(), k)) return set(row[0] for row in cur.fetchall()) # 假设 query_vecs 是准备好的100条查询向量 recalls [] for qv in query_vecs: exact exact_topk(qv) approx hnsw_topk(qv) recalls.append(len(exact approx) / 20) print(avg recall:, np.mean(recalls))这里要注意精确结果集的id列表和近似结果集的id列表是同一个表里同一批数据只是走了不同执行路径比对才有意义。3.4 我的一组实测数据供你参考我在自己机器上把参数组合过了一遍得到下面这组结果。不同硬件和数据分布会有差异但趋势是一致的参数组合ef_search平均查询耗时召回率20暴力扫描-约380ms100%m16, ef_construction6440约8ms约85%m16, ef_construction64200约20ms约96%m32, ef_construction128100约15ms约94%m32, ef_construction200200约28ms约98%这组数据很好地说明了两个问题只调ef_search不打磨索引质量也能快速提升召回率。m和ef_construction同时加大的情况下同样的ef_search能拿到更高召回率。所以我建议的调优顺序是先用默认参数建索引。固定m和ef_construction单独把ef_search从40拉到100、200看召回率和延迟变化。如果ef_search拉到200召回率还不到0.9再考虑重建索引把m提到24或32ef_construction提到128左右。重复测召回率直到达到你的要求。3.5 用EXPLAIN ANALYZE确认真的走了索引建了索引不等于查询就会用。你可以通过执行计划确认EXPLAIN ANALYZE SELECT id, embedding [0.012, 0.034, ...]::vector FROM documents ORDER BY embedding [0.012, 0.034, ...]::vector LIMIT 10;如果你看到类似这样的输出说明索引生效了Limit (cost... rows10 width...) - Index Scan using documents_embedding_idx on documents (cost...)如果看到的是Seq Scan加Sort那说明HNSW索引根本没派上用场。常见原因我放到下一节说这里先留个印象执行计划比任何参数都诚实。4. 我踩过的坑提前帮你踩平4.1 第一个坑索引建了但查询时忘了调ef_search我当时测召回率只有0.6第一反应是m和ef_construction不够准备重建索引。后来才反应过来查询的时候根本没设hnsw.ef_search用的还是默认40。这个坑最隐蔽的地方在于整个流程都能跑通执行计划也显示走了索引查询时间也快就是结果不对。你在排查的时候一定记得先查一下当前会话的ef_search值SHOW hnsw.ef_search;0.6的召回率就是这么来的不是因为索引质量差而是我查询阶段没有配合好。4.2 第二个坑m和ef_construction不是越大越好有一阵我陷入了参数越大越厉害的误区把m设成64ef_construction设成512结果50万条数据建了将近15分钟索引体积也比默认配置大了快一倍最终召回率只从0.93涨到了0.95。这个边际收益和成本完全不成比例。血的教训是调参要看收益曲线不要看参数绝对值。如果ef_search200、m32、ef_construction128已经能拿到0.95以上的召回率那就够了。你去追求0.98只能说明你业务确实需要但绝大多数业务0.9以上就已经能正常工作了。4.3 第三个坑opclass不匹配导致索引失效这是新手最容易忽略的一个细节。pgvector里有三种距离类型对应三种索引操作符类距离类型操作符opclassL2欧氏距离-vector_l2_ops内积#vector_ip_ops余弦距离vector_cosine_ops我用vector_l2_ops建了索引查询时却用余弦距离去排序pgvector根本不会匹配到这个索引。它会默默走全表扫描然后你觉得索引好慢。不是索引慢是你用错了。索引的opclass必须和你查询时的距离操作符匹配否则就是白建。4.4 第四个坑什么时候该用IVFFlat而不是HNSWHNSW不是万能的我发现很多文章把它吹得天花乱坠导致我一度以为其他索引用不着了。后来跟一个做推荐系统的朋友聊才清醒过来。IVFFlat在数据量特别大千万级以上、内存受限的场景下仍有价值。它通过把向量聚类分桶查询时只扫描少数几个桶用更小的内存占用换来可接受的召回率。缺点是它需要训练而且lists参数如果不合适召回率波动很大。我的选择标准很简单百万级以内的数据、RAG类应用、追求稳定高召回无脑选HNSW。千万级以上、对召回率要求不是极致、内存有限考虑IVFFlatlists按sqrt(数据量)的经验值起步再根据效果调整。4.5 第五个坑小数据量别急着建索引如果你的表只有1万条数据HNSW索引带来的查询加速可能并不明显反而浪费构建时间和存储空间。优化器在数据量很小时也会倾向于直接全表扫描因为它计算出来全扫更快。所以我的建议是数据量低于几万条可以先不建索引暴力扫描足够快。等数据量上来了再建效果更明显。5. 一小时学习路线图照着做就行5.1 我的时间分配你可以直接抄这1个小时我是这样花的前10分钟把官方文档里HNSW部分重新读了一遍重点看参数说明和示例SQL。中间25分钟用默认参数建索引跑通查询然后写出召回率测试脚本得到第一组基线数据。后25分钟先后调整ef_search再重建索引调整m和ef_construction每调一次记录一次查询耗时和召回率。建议你准备一个表格记录每次实验的参数组合和结果。没有数据的调优全是心理安慰记录下来你才知道哪些调整真正有效。5.2 一份可以直接参考的调优清单我把这次调优里最关键的步骤整理成清单确认pgvector版本支持HNSW。建表时选对向量维度别在数据类型上省事直接vector(768)或你实际用的维度。建索引时确认opclass和业务距离类型一致余弦距离用vector_cosine_opsL2用vector_l2_ops。先用默认参数建索引m16, ef_construction64。查询前显式设置SET hnsw.ef_search 100或更高进行测试。用暴力扫描结果作为Ground Truth测召回率。召回率不达标先调ef_search到200再考虑重建索引调m和ef_construction。每次调整后记录查询延迟确保召回率提升不是靠牺牲性能换来的。最后用EXPLAIN ANALYZE确认执行计划里是Index Scan不是Seq Scan。上线后继续观察真实查询效果必要时微调ef_search即可不需要频繁重建索引。5.3 最后一点个人体会学HNSW调优这1个小时让我最受用的不是记住了哪几个参数值而是建立了一个观念索引调优的核心是把快和准变成可量化的指标然后用实验去逼近目标。召回率必须能测出来耗时必须能测出来参数调整前后必须能对比这一套动作下来任何和索引有关的怪问题都藏不住。如果你也刚接触pgvector建议别跳过召回率测试这一步。它排错时提供的价值比任何博客文章都大。希望这篇能帮你少走点我走过的弯路。
返回列表