前阵子做一个 RAG 知识库项目,数据量到几十万条之后,PostgreSQL 查询变得肉眼可见地“肉”。每次命中都要等两三秒,用户问一句,后台咔咔跑了几个查询,体验实在说不过去。排查完发现根本不是 SQL 写得烂,而是对这种高维向量列做相似度检索时,数据库只能老老实实地全表扫描、挨个算距离,毫无索引可言。
给 PG 装上 pgvector 扩展之后,才算真正把向量检索这条路打通。而 pgvector 里最常用、也是社区讨论最多的两种 ANN(近似最近邻)索引,就是 IVFFlat 和 HNSW。这篇文章没有绕弯,直接围绕这两种索引的核心机制、参数逻辑、实际性能差异,以及 Docker / Windows 环境下怎么装、怎么接进知识库,讲一轮我自己的实践总结。
1. 为什么一个向量索引差点让 PostgreSQL 劝退我
先交代一下背景,不然你可能不理解为什么要纠结索引选型。
pgvector 本身是 PostgreSQL 的一个扩展,给数据库增加了 vector 数据类型,支持存储 embedding 向量,并且提供了相似度检索算子,比如 <=> 表示欧氏距离、<-> 表示余弦距离、<+> 表示负内积。它最大的好处是让向量数据和业务数据待在同一个数据库里,不需要额外引入一个独立的向量数据库服务,事务、备份、权限体系都可以复用 PostgreSQL 现有生态。这也是工具链里 LangChain、LlamaIndex 这类框架默认支持 pgvector 的原因。
但问题在于,pgvector 刚推出时,如果不对向量列建立任何索引,查询都会退化成顺序扫描。所谓顺序扫描,就是数据库把每一行的向量都取出来,跟查询向量做一次距离计算,然后排序取 TopK。这个动作在小数据集上没什么感觉,几千行也就几十毫秒;但到了十万、百万这个量级,那就不一样了——每行 768 维的向量光读取都是一笔不小的 I/O 开销,再加上每个向量要做几千次浮点乘加,速度会迅速恶化到让人怀疑数据库是不是卡死了。
我当时的第一反应是:直接建索引不就行了。但建了 PG 原生 B-Tree?用不了。因为你检索的不是精确值,而是“跟某个向量最相似的一组向量”——这种最近邻查询天然无法用精确索引直接覆盖。ANN 的思路就是换个策略:不去遍历全部数据,而是先把向量空间划分成若干区域,或者构建一张邻居图,检索时只依据“这种大概率近邻的关系”去寻找结果,也就是牺牲少量精度来换取数量级上的速度提升。
pgvector 从 0.4.x 版本开始支持 IVFFlat,后续版本逐步加入了 HNSW。这两种索引的名字里有相似之处,但背后的数据结构和适用场景差得很大。理解清楚了,生产环境才能真正放心用;理解不透,线上召回率崩了都不知道是哪里出的问题。
顺便说一下,在没有索引的情况下,如果你只是临时验证效果,用顺序扫描是可以的,但一旦把它接到线上服务,一定要记得加索引。这类问题隐蔽在默认配置里,开发阶段数据量小时完全无感,等压测或上线流量上来才暴露。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. IVFFlat:先给数据“分桶”,召回率取决于你搜几个桶
IVFFlat 的全称是 Inverted File with Flat Storage,直译过来就是“倒排文件 + 平坦存储”。这个思路其实早在几年前的向量检索论文里就有原型(IVF 系列索引是经典方案),pgvector 通过一种相对轻量的方式把它落地在了 PG 内。
2.1 IVF 的运作机制:把向量先“聚类”
IVFFlat 的核心思想可以理解为:在建立索引的时候,先把所有向量做一次聚类(K-Means),得到一组中心点,然后每个数据向量被分配到离它最近的某个中心点所对应的“桶”里。
可以把这个过程想象成整理图书。一个巨大的图书馆,如果所有书都堆在一起,找一本特定主题的书就得翻遍整馆,这就是全表扫描。IVFFlat 是先把所有书按类别分成若干书架,每个书架贴一个代表性的类别标签。查询的时候,只需要计算目标书和每个书架标签的相似度,选出最相关的 N 个书架,然后只在这 N 个书架里仔细翻找即可。
这里有两个关键参数:
lists:表示分了多少个桶。probes:表示查询时取多少个候选桶。
pgvector 的默认 lists 是 100,官方建议是 rows / 1000,比如 50 万行数据,设 500 个桶左右,最多不超过 1000 个桶。probes 默认是 1,即查询时只搜最近的一个桶。这个默认值意味着召回率往往不够理想——不过 PG 允许通过 SET ivfflat.probes = N 在会话级别调整,也可以在查询计划阶段使用 ivfflat.probes 参数做动态配置。
索引构建时,数据会被 K-Means 大致划分到各聚类桶中。Flat 的意思是每个桶内部不做进一步压缩或量化,直接存原始向量。相比带有 PQ(乘积量化)的方案,IVFFlat 召回率的底线更高,但占用空间也相对大。
2.2 不可避免的训练步骤和“一次性分配”问题
IVFFlat 建索引时需要先对现有数据做聚类训练,因此它是一个“一次性分配”的索引结构。在 CREATE INDEX ... USING ivfflat (embedding vector_cosine_ops) WITH (lists = 500) 这种语句的背后,pgvector 会在构建前准备阶段读取数据样本、训练聚类中心,然后把所有数据按照最近中心点写入桶,再构建一份“倒排文件”结构。
这个机制带来的直接影响是:
- 如果库里的数据分布和建索引时差异较大,比如索引构建初期只有少量测试数据,后续大规模导入真实数据,那么聚类中心可能已经不具有代表性。新鲜的数据会被硬塞进最近的现存桶里,如果中心点本身严重偏移,分配结果就会不理想,最终导致召回率下降。
- 如果训练数据太少而 lists 设置太大,大量桶可能是空的,或者只有一个点,这些桶对检索几乎没有实际帮助,反而增加无效 I/O。
- 如果你删除了旧数据并写入了大量分布完全不同的新数据,建议重建索引,而不是指望它动态调整。
IVFFlat 的另一个特点是它本身对增量写入不算友好。每次插入新数据时,pgvector 会找到当前最近的中心点,然后把新向量塞进对应桶中。这个操作很快,但写入过程中桶会产生倾斜——某些“热门区域”桶越来越大,查询时若 probes 只覆盖到部分桶,落在这些热门桶里的近邻可能被漏掉。
2.3 查询路径中 probes 到底做了什么
查询发生时,pgvector 先算出查询向量和所有中心点的距离,选出前 probes 个桶,然后在这些桶内遍历所有向量,逐一计算精确距离并排序,得到 TopK 结果。
由于桶内部是平坦存储,不做近似,所以只要候选桶选得足够准、足够全,IVFFlat 的召回上限可以非常高。但 probes 每调大一些,需要扫描的向量数也会线性增多,查询延迟就会上升。实际使用中,probes 的取值需要根据数据集特征在召回率和延迟之间做出折中。
举个例子,lists = 500,如果每桶大概承载 1000 条数据,那 probes = 1 时最多扫描 1000 条;但如果查询点落在边界上,真正近邻分布在四五个桶里,而只搜一个桶,漏掉许多真相邻就是必然的了。实践里,我通常会把 probes 设置为 sqrt(lists) 作为起点,也就是约 22 左右,然后逐步按效果调整。在一些国外技术博客的基准测试里,probes 从 1 调到 5,召回率可以提升近 20 个百分点;但每次提升的边际收益会递减,过了某个值之后再调可能只增加延迟,很难明显提升召回。
使用 IVFFlat 时,建议索引构建完成后先跑一组真实查询看召回表现,不要直接沿用默认 probes 值。
2.4 适合的人群和代价
IVFFlat 的优点是索引占用的内存比 HNSW 小不少,构建速度也更快,尤其适合那种数据量较大、内存有限、对召回要求没那么极致的场景。如果你的业务对“差不多就行”的相似结果能接受,并且需要频繁大批量导入数据,IVFFlat 是一个相对稳妥的选择。
但它也会带来几个肉眼可见的坑:
- 数据量太少时效果反而更糟。几千行就上
lists = 100,每个桶才几十条数据,检索时还要负担中心点距离计算和倒排结构裁剪,收益远小于顺序扫描。这个阶段别浪费时间去调索引,直接用全表扫描一点问题都没有。 - 表里如果经常有大量写入,累积到一定比例后,索引聚类退化严重,需要用
REINDEX或重建索引。实测中,当新增数据量占原始数据 20% 以上时,召回率下降可能非常明显。 - 注意
null或空向量混入后,即使建立了索引,查询也可能落到空桶周围,影响扫描行为。
3. HNSW:用“多层地图”换检索效率,参数少但性格不同
HNSW(Hierarchical Navigable Small World)是这几年向量检索领域公认的尖子生。它在内存型向量数据库和 ANN 工具库(如 FAISS、Milvus)里屡屡被选为默认索引,pgvector 也把它引入进来,成为另一种开箱即用的索引方式。
从名字看得出来,它属于“可导航小世界网络”的层次化变体。不纠结理论细节的话,可以把它理解成一种“多层地图”的结构。
3.1 从跳表到多层图:路由和精确搜索被拆开了
想象你要在一个没有门牌号的城市里找一个人。从地面层一栋楼一栋楼地问,效率很低;但如果你先坐着直升机在空中俯瞰整个城市,锁定大致街区,再降落到具体街区仔细找,速度就会快很多。
HNSW 就是这种分层思路:最上层是非常稀疏的图,只保留少数几个“地标”节点,节点之间的连接很粗粒度;越往下层,节点数量越多,连接越细密;最底层包含所有数据向量。检索时,算法从最上层开始,沿着当前层的边向靠近查询点的方向移动,进入下一层后继续类似导航,直到抵达最底层,然后在最底层找最近的邻居集合。
这种多层图结构设计有赖于“小世界”特性:真实世界的网络往往不需要很多跳就能从一个节点到达另一个更远的节点。通过让上层节点承担“跳跃式路由”的任务,HNSW 让检索路径变得高效,同时底层细密的邻居连接保证了最终结果集的质量。
3.2 三个核心参数:m、ef_construction、ef_search
pgvector 的 HNSW 索引提供三个主要参数,全部定义在 CREATE INDEX 的 WITH 子句中,也可以在查询时调整部分参数:
m:每个节点的最大连接数,默认 16。ef_construction:构建索引时使用的动态候选列表长度,默认 64。ef_search:查询时使用的动态候选列表长度(这个参数可以在查询会话级别动态指定,例如SET hnsw.ef_search = 100;)。
m 影响每个节点会建立多少条边。边越多,搜索时可选路径越多,图连通性越强,理论上召回会提升,但索引构建时间、占用内存和存储空间都会明显上升。根据社区一般实践经验,m 从 16 调到 32,召回率提升有限,内存占用可能上升 30%-50%;从 32 调到 64 甚至更夸张,但收益微乎其微,基本不推荐。
ef_construction 影响的是建图质量,可以理解成在插入每个节点时,从候选邻居列表里挑多少个节点来“商量”哪些可以成为邻居。这个值越大,构建过程中的探索范围越广,图的质量越高,但建索引会变慢。默认 64 是一个兼顾速度和召回的选择,如果数据对召回要求极高,可以考虑设置 100 或 128,需要接受构建耗时增加 1.5 到 2 倍。
ef_search 则是查询时控制搜索宽度的旋钮。它的机制类似于在候选优先队列中维护一个动态列表:每次迭代都会把当前节点更近的邻居加入队列,将最远的溢出列表。列表长度越大,搜索覆盖范围越广,召回越高。默认 40,实测调到 100 以后召回率能达到 99% 以上,但这会带来查询延迟的上升。
这三个参数的关系,可以类比成一个人找人:
m决定了他手机通讯录里最多存多少联系人的上限;ef_construction决定了他每到一个新城市,愿意跟多少人打听道路来绘制自己的关系网;ef_search决定了他找人时愿意同时盯着的候选列表有多长。
3.3 增量更新的友好度和内存开销
HNSW 相比 IVFFlat 最大的优势之一,就是它支持逐条增量写入,而且不需要重建整个索引。因为 HNSW 本质上是按图结构组织的,插入一条新数据时,并不是去判断投到哪个固定桶里,而是搜索图找到离它最近的现有节点,然后与这些节点建立联系。整个过程是局部迭代的,数据库里新出现的分布变化可以被索引“消化”掉,不需要定期重建。
这种增量友好的特性,让 HNSW 非常适合不断追加 embedding 数据的知识库场景。毕竟 RAG 应用里,文档是持续入库的,今天加一份 PDF、明天加一批网页,如果索引结构不适合增量更新,运维负担会非常大。
不过代价也很现实:HNSW 占用内存更大。这个索引需要把图的连接结构和参与构建的动态列表保留在内存中才能高效查询,如果系统内存不足,产生磁盘换页后性能会严重下滑,甚至可能比 IVFFlat 还慢。这也是为什么我见到很多内存只有 1-2GB 的开发环境跑 HNSW,效果反而不如 IVFFlat。
如果你自己用的是 Windows 笔记本做开发,跑 PostgreSQL 16 装完 pgvector 之后建 HNSW 索引,如果感觉数据库暴内存,多半是 shared_buffers 和索引本身叠加导致的问题。后面我会专门补充安装和内存相关细节。
3.4 HNSW 的查询路径推演
一次典型的 HNSW 检索是这样开展的:
- 从最上层的入口节点开始。
- 在当前层执行贪婪搜索:找到当前节点邻居中离查询点最近的一个,移动到该节点并继续比较,直到该层无法找到更近的邻居。
- 将当前节点作为下层的入口,进入下一层重复搜索。
- 到达底层之后,维护一个长度最多为
ef_search的候选列表,不断探索更近的节点,直到候选列表收敛。 - 返回候选列表中距离最小的 K 个向量作为最终结果。
整个过程不需要像 IVFFlat 那样计算所有中心点距离,也不需要显式地选择候选桶。HNSW 的路径本身就是一种动态路由,理论上相比 IVFFlat 需要“猜桶”,HNSW 的收敛更精准。在相同的数据集上,HNSW 通常在更低的查询延迟下获得比 IVFFlat 更高的召回率,代价则是构建慢、内存占用高。
4. 同样一坨向量,两种索引的实测对比
理论讲再多,不如动手跑一组数据。为了让你能看到直观差异,我把自己的实验过程放出来。
4.1 测试环境和方法
我在一台配置比较普通的机器上做的验证:8 核 CPU,32GB 内存,PostgreSQL 15 + pgvector 0.7.x。模拟数据生成了 50 万条 768 维的浮点向量(模仿 OpenAI text-embedding-3-small 的维度规模),用 Python 脚本通过 psycopg2 分批插入表中。
插入完成后,分别建立了 IVFFlat 和 HNSW 索引,索引定义如下:
sql复制-- 如果使用余弦距离检索,运算符类选 vector_cosine_ops
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 500);
查询基准用了 100 条随机查询向量,分别记录 P95 延迟和召回率。召回率计算方式是把 ANN 索引结果和顺序扫描得到的 TopK 精确结果做对照,这里 K = 10。
4.2 索引大小与构建时间
下表是实验中的实际观测数据:
| 索引类型 | 参数配置 | 索引大小 | 构建耗时 |
|---|---|---|---|
| 无索引 | 无 | 0 | 0 |
| IVFFlat | lists = 500 | 约 1.7GB | 约 1 分 10 秒 |
| HNSW | m = 16, ef_construction = 64 | 约 2.6GB | 约 4 分 20 秒 |
可以看到,HNSW 索引确实更大,构建也更慢,几乎是指数式的差距。如果向量维度更高,比如 1536 维,差距会进一步拉大。在小数据集或无长期演进需求的临时项目里,HNSW 在构建成本上并不友好。
4.3 查询延迟与召回率对比
在默认参数和调整后参数下,两者的表现差异如下:
| 场景 | 索引与参数 | P95 延迟 | Top10 召回率 |
|---|---|---|---|
| IVFFlat 默认 | lists=500,probes=1 | 约 8ms | 约 74% |
| IVFFlat 调参 | lists=500,probes=20 | 约 35ms | 约 92% |
| IVFFlat 更高参数 | lists=500,probes=50 | 约 80ms | 约 98% |
| HNSW 默认 | m=16,ef_search=40 | 约 12ms | 约 94% |
| HNSW 调参 | m=16,ef_search=100 | 约 25ms | 约 99%+ |
从结果能得出几个结论:
- IVFFlat 默认参数下延迟不算高,但召回率非常拉胯。如果业务对相似结果精确度要求高,请务必显式调整
probes,同时和/knowledge_base这类业务查询链路里加一个“召回率监控”,不然问题不好追查。 - HNSW 的默认参数表现就好得多,延迟 12ms 情况下召回 94%,调到
ef_search=100后可以达到 99% 以上,而延迟只到 25ms。由于 HNSW 的图导航机制,它增加的延迟主要来自底层候选队列的探索广度,而这部分又是可控的。 - 当数据量增加到 500 万条时,IVFFlat 需要相应增大
lists到 5000-10000 才能避免单桶数据过大,这时 IVFFlat 的查询延迟开始明显上升;而 HNSW 保持相同参数时的延迟增长相对平缓。这说明 HNSW 在数据量持续扩大的场景下更有优势。
4.4 写入性能的差异
索引不仅影响查询,也会拖慢写入。实际压测中,构建了 IVFFlat 的表写入速度大约是无索引表的 60%-70%,而构建了 HNSW 的表可能只有无索引表的 30%-40%。这是因为每次 INSERT 时,HNSW 需要搜索旧图、寻找新邻居、加入多层图结构,涉及的随机内存访问和距离计算明显多过 IVFFlat 的一次中心点分配。
所以在大量初始数据灌入时,一般建议先不建索引,等数据导入完成后再执行 CREATE INDEX 一次性构建。后续每天增量写入的量如果不大(比如几千到几万条),HNSW 完全可以承受;如果每天几十万条高频写入,那就要重新评估是否改用 IVFFlat,或分批延后建索引了。
5. 实用环境搭建:从 Docker 与 Windows DLL 到接进知识库
这一节来自很多被环境问题拦住的朋友的真实痛点。pgvector 装不上,后面的索引选型都是空中楼阁。
5.1 Docker 安装 pgvector:注意镜像标签
现在绝大多数项目用 Docker 跑 PostgreSQL。如果直接用 postgres:16 官方镜像,里面并没有自带 pgvector。你需要选择包含 pgvector 支持的镜像,或者自己基于 PostgreSQL 镜像安装编译。
更推荐的方式是使用 pgvector/pgvector 官方镜像,例如:
bash复制docker run --name pgvector-db \
-e POSTGRES_USER=user \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=knowledge_base \
-p 5432:5432 \
-d pgvector/pgvector:pg16
进入容器后,在目标库中执行:
sql复制CREATE EXTENSION IF NOT EXISTS vector;
如果你的数据想存到宿主机,记得挂载卷:
bash复制-v /your/local/path:/var/lib/postgresql/data
在 Docker 中安装 pgvector 时注意版本与 PostgreSQL 版本的匹配。如果你使用 pgvector/pgvector:pg16 标签对应的镜像是基于 PostgreSQL 16 构建的,可以放心使用;但如果你使用的是第三方镜像,务必确认扩展版本与服务器二进制版本一致,否则会出现 extension "vector" has no installation script nor update path for version 的问题。
5.2 Windows 环境下的 DLL 下载与部署路径
有不少读者是在 Windows 上开发测试,发现 CREATE EXTENSION vector 直接报错,这是因为 pgvector 不是 PostgreSQL 自带的,需要手动把 DLL 和 SQL 控制文件放到 PostgreSQL 安装目录下。
如果你用的是 PostgreSQL 16 的 Windows 64 位安装版,可以从云盘或 GitHub Releases 找到对应版本的预编译包(有的维护者会在 release 页面发布 .zip,解压后内容如下):
vector.dll,放到C:\Program Files\PostgreSQL\16\lib目录;vector.control、vector--*.sql这些扩展定义文件,放到C:\Program Files\PostgreSQL\16\share\extension目录。
放好后,重启 PostgreSQL 服务(Windows 服务管理器里重启 postgresql-x64-16),再执行 CREATE EXTENSION vector 才会成功。
需要注意几个版本路径的坑:
- DLL 的位数必须和 PostgreSQL 位数一致,几乎都是 64 位,千万不要下载 32 位包。
- PG 版本对应关系不能错,PostgreSQL 15 的扩展文件不能用在 16 上。下载时要确认清楚对应 versions。
- 放置文件后如果仍然报错,检查数据目录下的
postgresql.conf中dynamic_library_path是否包含$libdir,通常路径没问题,但如果自定义了该变量就会加载失败。 - Windows 环境编译扩展太痛苦,等官方二进制经常过期,所以别再尝试自己编译。找对应版本 zip 包是最省力的方式。
5.3 知识库场景里如何结合 pgvector
pgvector 现在这么火,主要靠 RAG 应用带动的。知识库的流程通常是:文档 → 切分 → embedding 向量化 → 存入 pgvector → 用户提问时把 query 向量化 → 检索相似 chunk → 拼送给 LLM 生成答案。
这里有一个核心 SQL 写法示例:
sql复制-- 返回与给定向量最相似的 5 个 chunk
SELECT id, content, 1 - (embedding <=> $1) AS similarity
FROM documents
ORDER BY embedding <=> $1
LIMIT 5;
这里用的是余弦距离,1 - 余弦距离得到相似度分数,便于上层判断阈值。使用 <=> 运算符时,pgvector 会尝试使用你创建的向量索引(前提是操作符类和索引匹配),如果没有索引,则走顺序扫描。
在实际做 RAG 的 API 服务时,我一般会用 Python 这样组织:
python复制import psycopg2
import psycopg2.extras
from openai import OpenAI
client = OpenAI()
conn = psycopg2.connect("dbname=knowledge_base user=user password=password host=localhost")
cursor = conn.cursor(cursor_factory=psycopg2.extras.DictCursor)
def query_knowledge_base(question: str, top_k: int = 5):
# 1. 向量化用户问题
resp = client.embeddings.create(input=question, model="text-embedding-3-small")
query_vec = resp.data[0].embedding
# 2. 查库,检索相关片段
cursor.execute(
"""
SELECT id, content, 1 - (embedding <=> %s::vector) AS score
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s
""",
(query_vec, query_vec, top_k),
)
rows = cursor.fetchall()
# 3. 低于阈值的过滤,避免噪声进入后续 LLM
return [r for r in rows if r["score"] > 0.8]
如果你的 RAG 应用走 LangChain / LlamaIndex,它们的 PGVector 类底层就在执行类似逻辑,只是帮你封装了连接配置。比如 LangChain 里设置 collection_name 对应不同向量表,PgVector 会将文档内容和 embedding 自动写入同一个 PG 库。
这里提醒一个容易踩的坑:为了在知识库检索时获得更高召回率,不要仅仅调大 top_k。如果索引结构选错(比如 IVFFlat 下 probes 设为 1),你取回的前 5 条结果里面可能根本没有真正的相关文档,后置的 LLM 生成质量自然低。所以知识库建设中,应优先确认向量索引的召回率达标,再考虑 top_k 和后续的重排策略。
5.4 结合知识库场景的一种相对稳的方案
对这种“文档持续增加、检索质量要求高、数据量一天天变大”的典型 RAG 场景,我会建议:
- 优先 HNSW 索引,
m=16, ef_construction=64,查询时SET hnsw.ef_search = 100;左右。 - 如果数据量较大但服务器内存吃紧,可以退回 IVFFlat,但必须通过定时任务或触发方式监控数据变化量和索引构建时间,做到每累计 20%-30% 增量后触发一次
REINDEX。 - 对内容非常相似、语义粒度很细的领域(比如多份合同条款),越要注意召回率,建议在检索链路后面加一个 rerank 模型,而不是盲目依赖向量索引本身。向量索引负责初筛,重排模型负责精排。
6. 选型路线图和经验总结
到了该做决定的时候,怎么选?我建议直接按下面这个思路走:
6.1 决策条件速查表
可以直接复制下面的条件速查表来对照自己的情况:
| 场景特征 | 推荐索引 | 原因 |
|---|---|---|
| 数据量小于 10 万条 | 不用索引或 HNSW | 这个量级顺序扫描也可接受;建 HNSW 可应对后续增长 |
| 数据量 10 万-100 万,内存充足 | HNSW | 召回率与延迟表现都优于 IVFFlat |
| 数据量 100 万以上,内存有限 | IVFFlat | 索引占用相对较小,构建时间短 |
| 准实时检索,延迟要求苛刻 | HNSW | 同延迟下召回率更稳;IVFFlat 要保证召回,probes 一高延迟就不稳 |
| 知识库长期滚动添文档 | HNSW | 支持增量写入而不退化为低召回;IVFFlat 需要重建周期 |
| 场景允许离线做全量导入,查询量较稳定 | IVFFlat | 构建成本低,空间占用少,在可控 probes 内足够使用 |
这个表不是绝对标准,但覆盖了 90% 的日常选择。如果想偷懒,数据量 100 万以下、内存不低于 16GB、查询流量并不是极其夸张,我会直接建议无脑 HNSW,省心很多。
6.2 调参建议:先不要追求极端
在使用中,我发现很多人喜欢在一开始就把 HNSW 的 m 拉到 64 甚至 128,期待“更好的连接带来更高召回”,实际效果常常适得其反:构建时间暴涨两三倍,内存占用明显上升,索引文件膨胀严重,但召回率可能只提升 0.1-0.2 个百分点。因为你真正该调的,是构建质量参数 ef_construction 和查询宽度 ef_search。
反过来,IVFFlat 也是同一个道理。lists 不是越大越好。如果你有 50 万条数据却设了 5000 个桶,平均每桶只有 100 条,而检索还要计算几十个中心点的距离,最终效果甚至不如临时把 lists 降下来后调大 probes。
更合理的做法是先花一小时做一次小规模基准确认:
- 拿 200 条真实查询在索引开启和关闭时分别跑一次,得到精确 TopK 结果作为 baseline。
- 换不同索引参数重跑,画一条“召回率-延迟曲线”。
- 取曲线中业务召回率可接受的最低延迟,并在这个点前后预留弹性空间。
6.3 真正长期在用的经验
如果让我说自己长期以来最建议的固定打法,大概是这样的:
- 数据入库阶段先用 COPY 或批量 INSERT 到普通表中,不建任何向量索引。
- 全量导入完毕后再一次性
CREATE INDEX。HNSW 构建期间尽量安排低峰执行,IVFFlat 如果lists较大也同理。 - 应用查询侧不要直接依赖索引默认值,而是在每个会话设置
hnsw.ef_search或ivfflat.probes为日常参数,推荐从 100 和 20 起步做回归测试。 - 每次大量灌数据后观察召回率是否下降;如果出现明显下降,先
ANALYZE,然后考虑重建索引。 - 高可用线上环境使用
CREATE INDEX CONCURRENTLY避免长时间锁表。
关于第 5 点多说一句。PostgreSQL 建索引默认会阻塞写入,对一个正在服务的知识库表来说非常致命。所以建索引时一定要写成:
sql复制CREATE INDEX CONCURRENTLY ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
注意 CONCURRENTLY 不能在事务块中执行,且如果索引构建失败会留下一个“invalid”索引,需要 DROP INDEX CONCURRENTLY 清掉后再试。在较新版本的 pgvector 中,HNSW 构建过程支持的参数也在变化,有条件的可以顺手看下官方 CHANGELOG。
6.4 未来往里走的方向
如果你手头项目对延迟和召回要求已经到 “百亿级向量、毫秒延迟、内存动不动几百 GB” 的程度,那 PG pgvector 可能就已经不是最合适的方案了。届时更适合的可能是专业的向量数据库或支持 GPU 加速的索引体系。但作为从 0 到 1、从 MVP 到 1000 万级数据的项目,pgvector 的优雅之处在于不需要引入第二个存储系统,让团队从第一天起就能以非常顺畅的方式把向量能力接进已有业务,这对中小团队和早期项目来说价值巨大。
索引的选择本质上是工程上的取舍问题。IVFFlat 告诉我“分而治之可以很廉价”,HNSW 告诉我“多一点结构和成本,能换来更稳的质量”。真正的高手不是只会背哪个索引好,而是能根据数据规模、写入频率、内存上限、召回目标,做出让系统在成本和体验之间保持平衡的判断。希望这篇经验能帮你少走一些弯路。
