前阵子做一个 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 先算出查询向量和所有中心点的
