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

资讯详情

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

PostgreSQL向量检索优化:HNSW索引原理与调优实战

PostgreSQL向量检索优化:HNSW索引原理与调优实战 说实话我给自己定过规矩一个新东西只给自己一小时到点必须能讲明白、能上手用。这次轮到 PG Vector 的 HNSW 索引调优和使用标题说“我很笨”真不是客气——刚看到 HNSW 这三个字母的时候我连“Hierarchical Navigable Small World”这串英文都念不顺。但一个小时折腾下来我发现它的核心思路其实特别朴素把图、跳表、二分查找这几个老朋友拼在一起再加点工程细节。这篇就把我这一个小时内搞明白的东西原原本本写出来包含能直接抄走的建索引语句、调参表格和排坑记录。你要是也刚接触 PG Vector跟着走一遍大概率不用一小时。1. 先搞懂原理HNSW 索引到底解决了什么问题1.1 没有好索引时向量检索有多痛先还原一下最原始的用法。你把文本、图片、商品标题都转成了向量存进 PG 的一张表里字段类型是vector。要查“和某个向量最像的前 10 条”SQL 写起来很简单SELECT id, embedding [0.1, 0.2, ...] AS distance FROM items ORDER BY embedding [0.1, 0.2, ...] LIMIT 10;这条 SQL 在数据量小的时候没问题几千条数据秒回。但数据到了几十万上百万问题就来了数据库需要对每一行都算一遍距离再排序取前 10。128 维向量算余弦距离一百万行就是一百万次浮点乘法加法响应时间会从几十毫秒变成几百毫秒甚至秒级。这个操作在行业里有名字叫暴力扫描Brute Force也叫全表扫描。那 PG Vector 早期有没有索引有叫 IVFFlat。但 IVFFlat 有两个让我不太舒服的点。第一它需要先做一次聚类训练要指定中心点数量训练不好召回率会很难看第二它是“先粗筛再精排”的思路某些分布不均匀的数据集上它会把真正相近的向量分到不同的桶里导致结果里混进不少不相关项。也就是说IVFFlat 在召回率和易用性上都有妥协。直到 PG Vector 0.5.0 之后引入了 HNSW这个问题才算有了一个更优雅的答案。1.2 HNSW 的核心思想分层导航 贪心搜索HNSW 全称 Hierarchical Navigable Small World翻译过来是“分层可导航小世界网络”。听名字很吓人但拆开看就两件事分层、可导航。分层这个思想你可以直接类比成地图。出门导航的时候你先看省际高速路网确定从一个城市到另一个城市走哪条高速然后切换到城市道路再切到街道。每一层的“路”越来越密但你永远先在稀疏的高层做粗定位再到密集的低层精确定位。HNSW 的图就是这样的多层结构顶层节点很少、边很稀疏底层节点最多、边最密集。查询时从最顶层一个入口点开始每层用贪心算法往目标方向走走到当前层找不到更近的邻居了就带着结果掉到下一层继续走一直到最底层最后在底层候选集里挑出 top K。第二个关键词“可导航”意思是这个图的连边方式刻意构造得让搜索能快速收敛。它借鉴了跳表的思想高层节点像“快速通道”一次跳跃可以跨过大量底层节点。你不需要维护复杂的平衡树结构只需要在建图时按一定规则随机分层、连接邻居查询时靠贪心就能拿到很高的检索质量。这也是它和传统树形索引最大的区别它不是一个严格的树而是一个带层次结构的图。所以回到开头那句话HNSW 就是把“跳表分层”和“图的邻居导航”结合在一起牺牲一部分构建时间和内存换取了查询阶段极低的延迟和极高的召回率。这也是为什么现在很多向量数据库默认索引都选 HNSW因为它确实在“快”和“准”之间找到了一个非常实用的平衡点。2. 动手实践在 PG Vector 里创建 HNSW 索引2.1 环境准备和测试数据我本地的环境是 PostgreSQL 14 PG Vector 0.6.x。先确认扩展版本因为 HNSW 是在 0.5.0 才正式支持的太老版本没有。SELECT extversion FROM pg_extension WHERE extname vector;如果还没装扩展先执行CREATE EXTENSION vector;然后建一张最典型的向量表。我这里模拟一个文档库每行一条文档embedding字段存 128 维的浮点向量CREATE TABLE items ( id bigserial PRIMARY KEY, content text, embedding vector(128) );测试数据我直接写了个 Python 脚本随机生成 100 万条 128 维向量灌进去。实际生产中这些 embedding 来自 OpenAI、BGE、通义等模型但测试阶段用随机向量完全够评估索引性能import psycopg2 import numpy as np from tqdm import tqdm conn psycopg2.connect(hostlocalhost dbnametest userpostgres) cur conn.cursor() batch [] for i in tqdm(range(1_000_000)): v np.random.rand(128).astype(np.float32) batch.append((i, fdoc-{i}, v.tolist())) if len(batch) 1000: cur.executemany( INSERT INTO items (id, content, embedding) VALUES (%s, %s, %s), batch ) batch [] conn.commit()这一百万条随机向量灌进去之后全表没索引时的暴力查询后面会作为基线数据。我的建议是造数阶段就把maintenance_work_mem调大一点免得后面建索引时 PG 频繁刷磁盘明显变慢。我一般建索引前会执行SET maintenance_work_mem 2GB;这个参数只影响当前会话不会污染全局配置实测建索引速度能提升一大截。2.2 创建 HNSW 索引的一行命令数据就绪后创建 HNSW 索引的语法非常简洁CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops) WITH (m 16, ef_construction 64);这里有三个关键点要解释清楚。第一vector_cosine_ops是操作符类它决定了索引用哪种距离度量。pgvector 提供了三种vector_l2_ops对应 L2 欧氏距离vector_ip_ops对应内积距离vector_cosine_ops对应余弦距离。选哪个取决于你 embedding 的相似度计算方式。我之前用的很多文本向量都是归一化后的向量用余弦距离比较多如果你做的是图像检索可能 L2 更合适。如果选错了操作符类查询时虽然也能走索引但相似度语义会对不上结果会南辕北辙。第二USING hnsw表示索引算法是 HNSW。pgvector 里目前还能用ivfflat但如果没什么特殊理由新项目我建议直接上 HNSW省去训练 IVF 中心点的步骤召回率和速度体验都更好。第三WITH (m 16, ef_construction 64)这两个是构建参数。我刚接触的时候把ef_construction和查询参数ef_search搞得混了好一阵这里先记一个结论m控制图中每个节点的最大连接数ef_construction控制建图时候选列表的宽度ef_search是查询时用的参数不是建索引时写在WITH里的。2.3 三个核心参数的关系和取值边界很多教程一上来就丢参数但不说它们到底影响什么。我用一句话分别概括m每个节点最多连几条边。m 越大图越“密”检索时路径更多、更准但索引体积变大构建变慢内存占用也上升。PG Vector 里 m 的默认值是 16最大值是 100。ef_construction构建索引时每个新节点找邻居的候选列表长度。它越大建图时越“认真”索引质量越高但构建耗时明显加长。默认是 64官方上限 1000实际用到 128 或 256 通常就很稳。ef_search查询时候选列表的长度。它不是建索引时的参数而是查询时的动态参数默认 40官方上限 1000。ef_search 越大查询越慢但召回率越高。我把三者关系用一个生活化的类比记住m 像是修路时每个路口连了几条路ef_construction 像是修路时考察了多少个候选路口来决定怎么连ef_search 像是导航时你愿意多看几个备选路线再下结论。路多、考察细、查询时愿意多看结果自然更准但成本也更高。我还特意去翻了 PG Vector 官方文档确认边界m 最大 100ef_construction 最大 1000ef_search 最大 1000。但“最大”不等于“最优”实际调参的时候不要一上来就拉满否则要么构建慢到让你怀疑人生要么查询延迟高得没法用。3. 参数调优实战一小时里我做了什么3.1 先建立基线别凭感觉调参我调任何参数之前都习惯先记录两个数索引构建耗时、查询延迟再配一个召回率指标。没有基线后面所有数值都失去意义。我的召回率测试方法比较粗暴先跑一遍暴力查询拿到前 10 的 id 当作“标准答案”再跑 HNSW 查询对比交集数量除以标准答案数量得到召回率。先看基线。数据100 万条随机 128 维向量。暴力查询耗时大约 780 毫秒。建 HNSW 索引默认参数 m16、ef_construction64构建耗时约 85 秒索引体积约 380 MB。查询时先用默认 ef_search40单条查询耗时约 8 毫秒召回率约 0.94。这个结果已经很能说明问题了从 780 毫秒降到 8 毫秒快了接近 100 倍同时召回率保持在 0.94。但既然是调优当然还可以继续压榨。3.2 构建参数 m 和 ef_construction 怎么调我先把 ef_search 固定在 40然后逐个测试 m 和 ef_construction 的取值组合。m从 8 往上加到 32 和 64固定 ef_construction64m构建耗时(秒)索引体积(MB)查询延迟(ms)召回率85222060.87168538080.9432155690130.97642901280220.98这个结果印证了理论m 翻倍索引体积接近翻倍构建时间接近翻倍查询延迟也在涨但召回率并不是线性的从 16 到 64 只提升了 4 个百分点。所以除非你追求极高的召回率否则 m16 到 32 是性价比最高的区间。接着固定 m16看 ef_construction 从 64 到 256 的变化ef_construction构建耗时(秒)查询延迟(ms)召回率648580.9412813080.9525620580.95有意思的现象是ef_construction 翻到 256构建时间多了 2.4 倍但召回率只从 0.94 涨到 0.95查询延迟基本没变。这说明 ef_construction 到 128 之后边际收益就很低了。它本质上是构建期的“认真程度”数据量不大的时候 64 完全够用数据量大或者对召回率敏感加到 128 是甜点值。3.3 查询参数 ef_search 是日常大头如果说 m 和 ef_construction 是修路阶段的事那 ef_search 就是你每天查询时真正要调的东西。它的特点是不用重建索引随时可以改效果立竿见影。设置方式实测有两种。第一种是会话级设置SET hnsw.ef_search 100;这个设置只对当前会话生效连接断开就恢复默认非常灵活。第二种是在索引层面设置比如只对某个特定的 HNSW 索引生效。我平时基本只用会话级因为 ef_search 本来就应该按查询场景动态切换。我把 ef_search 从 10 一路调到 200固定 m16、ef_construction64ef_search查询延迟(ms)召回率1030.802050.884080.9480160.97120240.98200410.99这张表是这一小时里最值钱的东西。在 ef_search40 的时候召回率 0.94延迟 8 毫秒拉到 80召回率涨到 0.97延迟只翻了一倍到 16 毫秒拉到 200召回率 0.99但延迟已经 41 毫秒了。这就是调优的本质你永远在延迟和召回率之间做权衡。我的经验是如果你的业务对召回率不是极度敏感ef_search 设置在 40 到 80 之间是甜点区如果做语义搜索、RAG要尽量保证结果不丢可以常驻 100 到 120。但接近 200 这种配置除非你的向量维度很低、数据量不大否则不建议日常使用。4. 避坑指南这一小时里我踩过的坑4.1 索引建了查询却不走索引这是我一上来就踩的第一个坑。建好 HNSW 索引之后我兴冲冲跑了一条相似度查询结果发现延迟毫无变化暴力扫描该多慢还多慢。用EXPLAIN ANALYZE一看执行计划才知道根本没走索引。原因有几种。第一HNSW 索引只在ORDER BY embedding ... LIMIT n这种写法下才会被用到。如果你写了ORDER BY但没写LIMIT优化器可能会认为全表排序更划算不走索引。第二查询向量维度必须和建表时声明的vector(128)一致维度对不上会有报错或者语义错误。第三操作符类要匹配建索引用的vector_cosine_ops查询时最好用余弦距离的操作符用-去查一个 cosine 索引不是不行但语义会不对。排查手段非常简单养成习惯就行EXPLAIN ANALYZE SELECT id, embedding [0.1, 0.2, ...] AS distance FROM items ORDER BY embedding [0.1, 0.2, ...] LIMIT 10;执行计划里看到Index Scan using items_embedding_idx就说明走对了。看到Seq Scan就赶紧回头查上面三个原因。我后来还会顺手看一眼SET enable_seqscan off;来强制走索引测试不过这只是排查手段千万别在生产库这么干。4.2 参数设置不生效和越界问题第二个坑是关于参数名的混淆。刚上手的时候我一度以为ef_search也是写进CREATE INDEX的WITH子句里的结果执行CREATE INDEX ... WITH (ef_search 100)直接报错。后来才搞清楚m和ef_construction是索引定义时的静态参数ef_search是查询时的会话参数两个层面的东西别放在一起。另外有个细节SET hnsw.ef_search 1000;是允许的因为官方上限是 1000。但如果你把它设到 1000查询延迟会非常难看尤其向量维度高的时候。有一次我拿 1536 维的向量测ef_search1000 时延迟直接飙到 200 多毫秒这已经失去了向量索引的意义。合理的做法是把 ef_search 控制在 40 到 150 之间。m的上限是 100ef_construction的上限是 1000。我建议就算你知道上限也不要真去顶满因为顶满的代价是构建时间几十分钟起步。我第一次试 m100、ef_construction1000 的时候构建一个 100 万条数据集的索引等了将近 20 分钟中途一度以为卡死了。4.3 什么时候别用 HNSW调优过后也得清醒HNSW 不是银弹它有自己明确不适用的场景。第一条数据量小的时候没必要。一两万条以下的数据暴力扫描本身只要几十毫秒建索引的时间和内存反而成了多余成本。几十毫秒对大多数业务完全可接受何必引入一套图结构。我见过不少人几千条数据也建 HNSW属于自我感动。第二条内存吃紧的时候要慎重。HNSW 是纯内存索引pgvector 里它会把整个图加载到内存。以我实测为例100 万条 128 维向量m16 时索引体积约 380 MB而原始数据本身只有约 100 MB算下来索引膨胀了近 4 倍。数据量上升到一个亿级别HNSW 对内存的需求会变得非常夸张这时候你可能需要重新评估方案。第三条如果场景需要频繁大批量更新数据HNSW 也有代价。虽然它能支持增删改但频繁更新会让索引产生碎片查询质量会下降需要定期重建。我们的业务里如果每天只做增量插入还好如果动不动要批量刷数据我会考虑用专门的向量数据库而不是硬扛在 PG 里。5. 一点真实体会这一小时折腾完我最大的感受是HNSW 不是一个“配好就万事大吉”的黑盒它的三个参数本质上都是成本开关m 用内存换精度ef_construction 用构建时间换精度ef_search 用查询延迟换精度。理解了这一点调参就不再是靠玄学而是清楚知道自己在花什么成本、买什么收益。最后再分享一个我的习惯每次调参之前先用暴力查询跑一遍标准答案存成临时表再调参对比召回率。没有量化指标的调参都是拍脑袋。另外如果你在生产环境已经建好了 HNSW 索引改 m 和 ef_construction 需要重建索引这个操作我会安排在低峰期执行并且先把maintenance_work_mem调大不然建索引期间 I/O 压力会很难看。到这里我这个“笨人”的一小时心得就写完了。你也试试真要动手的话半小时足够你把表建好、索引搭好、基线测出来。剩下的半小时慢慢体会那张参数对比表里“快”和“准”的权衡就很有意思了。
返回列表