文章介绍在现有 PostgreSQL 中用 pgvector 存储嵌入并实现语义检索,解释向量检索、索引原理及运行注意事项。它将这一方案与另建向量数据库比较,涉及额外服务和数据同步的维护负担。
假设你的产品需要能够理解语义的搜索功能。用户输入“卖出股票获利时需要缴纳的税”,希望找到介绍资本利得税的页面,即使页面标题中一个这样的词都没有。通常,下一步就是引入一套新的基础设施:Pinecone、Qdrant、Milvus 或 Weaviate。它有单独的账单、单独的 SLA,还需要一个同步任务,让它与你的实际数据保持一致。
还有另一种选择:使用 pgvector,把嵌入向量存放在你已经运行的 PostgreSQL 数据库中。本文将介绍向量数据库的作用、索引的工作原理,以及在 Postgres 中运行向量数据库时需要注意的问题,包括那些很容易被忽略、一路带进生产环境的陷阱。
关系型数据库回答的是:“哪些行等于这个值?”
SELECT * FROM orders WHERE customer_id = 42;
答案是精确的。一行数据要么匹配,要么不匹配。
向量数据库回答的是:“哪些行与这个相似?”
SELECT id, body FROM documents
ORDER BY embedding <=> $1
LIMIT 5;
答案是一个排名。每行数据与查询之间都有一定距离,你要找的是距离最近的几行。本文后续的所有内容,都源于查询约定的这一变化。
pgvector 将右侧这一类能力,放进了左侧的关系型数据库中。
嵌入模型会将一段内容转换成一个固定长度的数字列表。经过训练,含义相似的内容会生成相似的数字。OpenAI 的 text-embedding-3-small 默认返回 1,536 个数字,text-embedding-3-large 则返回 3,072 个。两者都支持通过 dimensions 参数减少返回的维度数(OpenAI 嵌入指南)。
实际使用中,有两个特性很重要:
长度固定。 一个单词和一整页内容生成的向量,维度数相同。
存储端和查询端使用同一个模型。 已存储的数据行和传入的查询,必须经过同一个模型处理,并使用相同的设置。更换模型后,所有已存储的向量都需要重新生成。
可以把这些数字想象成坐标。实际的嵌入向量有 1,536 个坐标轴,但用两个就足以理解其原理。“cat”“dog”和“puppy”会落在彼此接近的位置。“pizza”“pasta”和“bread”形成另一个簇。“capital gains tax”“CGT”和“tax on a share sale”则位于第三个簇中。
搜索就这样变成了几何问题。使用同一个模型为查询生成嵌入向量,然后寻找它的最近邻。查询“kitten”时,会找到“cat”和“puppy”,尽管“kitten”与它们都不是同一个词。整个过程不涉及关键词匹配。
pgvector 提供了多种距离运算符(pgvector README,Querying):
注意:<#> 返回内积的负值,因为 Postgres 对运算符只支持按升序执行索引扫描(pgvector README,Getting Started)。如果需要实际的内积值,请乘以 -1。
pgvector README 指出,OpenAI 的嵌入向量已归一化为长度 1,并建议对归一化向量使用内积,以获得最佳性能(pgvector README,Exact Search)。对于单位长度向量,内积与余弦相似度得到的结果排序相同。
B-tree 可以处理能够按某种顺序排序的数据上的等值查询和范围查询(PostgreSQL 文档,Index Types)。执行 WHERE id = 42 时,只需沿着树跳转几次,就能定位到对应的数据行。
“距离这个向量最近”并没有固定的顺序。每次查询都有自己的中心,因此无法提前排好序。如果没有近似索引,pgvector 就会执行精确最近邻搜索:将查询向量与每一行逐一比较(pgvector README,Indexing)。
计算成本很快就会累积起来。以一百万行、每行包含一个 1,536 维向量为例:
每次查询需要约 15 亿次乘加运算(1,000,000 × 1,536)。
每次查询需要读取约 6.2 GB 数据。 pgvector 存储每个向量需要 4 × dimensions + 8 字节,因此每行需要 6,152 字节(pgvector README,Vector Type)。
精确搜索能够达到 100% 的召回率,但其成本会随着表的规模线性增长。
向量索引牺牲少量准确性,以避免读取全部数据。衡量这种准确性的指标是召回率:真正的 k 个最近邻中,实际返回的结果所占的比例。
召回率可以调节。 每种索引都有一个查询时设置,用于在速度与召回率之间取舍。你可以自行选择这条权衡曲线上的位置。
添加索引可能改变查询结果。pgvector README 说得很直接:添加近似索引后,查询会返回不同的结果(pgvector README,Indexing)。一个在顺序扫描下通过的测试,可能在索引上线当天就失败。
IVFFlat 将向量划分为多个列表,每个列表中的向量围绕一个质心聚集,查询时只搜索距离查询向量最近的几个列表。搜索的列表数量由 probes 指定,默认值为 1(pgvector README,IVFFlat)。这些列表是在索引构建时,对表中已有的数据行执行 k-means 聚类得到的;索引构建过程会报告 performing k-means 阶段。
它的弱点在于边界。如果真正的最近邻刚好位于相邻列表的边界另一侧,probes = 1 的查询就根本不会搜索那里。
README 给出了以下初始设置建议:
CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
SET ivfflat.probes = 10;
README 的故障排查部分也指出了相应症状:如果创建 IVFFlat 索引时,数据量相对于列表数量过少,查询返回的结果就会变少(pgvector README,Troubleshooting)。
这个问题很容易遇到。 索引通常通过数据库迁移创建,而迁移通常会在数据写入之前执行。这样创建的 IVFFlat 索引,会基于一张空表构建列表。如果 probes 保持默认值,每次查询就只会搜索 100 个列表中的一个。解决方法是在数据加载完成后执行 REINDEX,并为每次查询显式执行 SET ivfflat.probes。更好的办法是使用 HNSW,它不需要训练步骤。
HNSW(Hierarchical Navigable Small World,分层可导航小世界)源自 Malkov 和 Yashunin 的一篇论文(arXiv 1603.09320)。它会构建一个分层图。最上层的节点很少,连接跨度很大;越往下,节点越多,连接跨度越小,直到包含所有向量的第 0 层。搜索从顶层开始,跳转到距离查询更近的相邻节点,然后下降一层,重复这一过程。
它的设置及默认值如下(pgvector README,HNSW):
HNSW 不需要训练步骤,因此可以在空表上创建。
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops);
SET hnsw.ef_search = 100;
当整张图能够放进 maintenance_work_mem 时,索引构建速度会快得多;当内存不再足够容纳它时,Postgres 会输出一条提示(pgvector README,Index Build Time)。
下方各行内容均来自 pgvector README。
我默认选择 HNSW,除非构建时间或内存成为限制因素。它在速度与召回率之间提供了更好的权衡,也彻底消除了空表建索引的陷阱。
pgvector 是一个开源的 Postgres 扩展;截至本文撰写时,README 中的安装说明使用的是 0.8.7 版本(pgvector README,Installation)。它增加了向量类型、距离运算符,以及前面介绍的两种索引。你原本依赖的能力都还在:ACID 事务、连接查询、时间点恢复、复制和查询规划器。在 AWS 上,Aurora PostgreSQL 也支持它(AWS 公告:pgvector 0.8.0 on Aurora PostgreSQL)。
CREATE EXTENSION vector;
一个 1,536 维嵌入向量在各种类型中占用的空间如下。存储公式和索引限制来自 pgvector README 的参考资料部分及 HNSW 章节。
向量最多可以存储 16,000 个维度;受到限制的只是索引。text-embedding-3-large 这类生成 3,072 维向量的模型,无法以 vector 类型建立索引,但可以以 halfvec 类型建立索引(pgvector README FAQ)。
注意:bit 每个维度只保留一个比特。可以用它快速完成第一轮检索,再使用完整向量重新排序(参见下文的扩展部分)。
CREATE TABLE documents (
id bigserial PRIMARY KEY,
body text NOT NULL,
embedding vector(1536) NOT NULL
);
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops);
-- the five nearest to a query vector
SET hnsw.ef_search = 100;
SELECT id, body FROM documents
ORDER BY embedding <=> $1
LIMIT 5;
有四个细节经常让人耗费时间:
维度是列类型的一部分。vector(1536) 列存放的是 1,536 维向量;如果要混合存储不同维度的向量,需要使用不指定维度的 vector 类型(pgvector README FAQ)。
一个索引只服务于一种距离函数。README 指出,你需要为每一种想使用的距离函数分别添加索引(pgvector README,HNSW)。使用 vector_cosine_ops 构建的索引,只能加速 <=> 运算。
hnsw.ef_search 是会话级设置。在事务中使用 SET LOCAL,可以只为一次查询修改它(pgvector README,查询选项)。
要使用索引,查询必须同时包含 ORDER BY 和 LIMIT,而且 ORDER BY 必须直接使用距离运算符,并按升序排列。ORDER BY 1 - (embedding <=> $1) DESC 不会使用索引(pgvector README,故障排查)。
既然前面说内积最快,为什么这里又用余弦距离?对于单位长度向量,两者的排序完全相同;而如果未来的模型不对输出进行归一化,余弦距离仍然能保证结果正确。我选择了稳妥,放弃了一点速度收益。
pgvector npm 包支持 node-postgres、Knex.js、Kysely、Prisma、Drizzle ORM 等多个库(pgvector-node README)。
npm install pgvector pg openai
import OpenAI from 'openai'
import pg from 'pg'
import pgvector from 'pgvector/pg'
const question = 'the tax due when you sell shares at a profit'
const client = new pg.Client()
await client.connect()
await pgvector.registerTypes(client)
// reads OPENAI_API_KEY from the environment
const { data } = await new OpenAI().embeddings.create({
model: 'text-embedding-3-small',
input: question,
})
const { rows } = await client.query(
`SELECT id, body FROM documents
ORDER BY embedding <=> $1 LIMIT 5`,
[pgvector.toSql(data[0].embedding)],
)
各部分的出处如下:
registerTypes 和 toSql 的用法遵循 pgvector-node README 中的 node-postgres 部分。使用连接池时,应在连接池的 onConnect 钩子中调用 registerTypes。
registerType(单数形式)自 pgvector-node 0.3.0 起已弃用(pgvector-node README,升级说明)。
new OpenAI() 会从环境变量中读取 OPENAI_API_KEY(openai-node README)。
响应中的 data 数组为每个输入保存一个条目,每个条目都有一个 embedding 字段(OpenAI API 参考,Create embeddings)。
这个问题很容易悄无声息地出现。使用近似索引时,过滤发生在索引扫描之后。
pgvector README 给出了具体数字:如果某个条件匹配 10% 的行,那么 HNSW 在使用默认值为 40 的 ef_search 时,平均只剩下约 4 行符合条件(pgvector README,过滤)。你要 10 条,得到的却大约只有 4 条。没有错误,没有警告,只是结果变少了。
-- asks for 10, typically gets about 4 back once an HNSW index exists
SELECT id, body FROM documents
WHERE course_id = 123
ORDER BY embedding <=> $1
LIMIT 10;
从 pgvector 0.8.0 开始,迭代索引扫描可以解决这个问题(pgvector CHANGELOG)。启用后,扫描会持续读取索引,直到找到足够的结果,或达到 hnsw.max_scan_tuples(默认 20,000)或 ivfflat.max_probes 的限制(pgvector README,迭代索引扫描)。
SET hnsw.iterative_scan = strict_order;
-- or, trading exact ordering for recall:
SET hnsw.iterative_scan = relaxed_order;
-- IVFFlat supports relaxed ordering only
SET ivfflat.iterative_scan = relaxed_order;
README 还列出了其他方案:对于只匹配少量行的条件,在过滤列上建立索引;如果按少数几个不同值过滤,使用部分索引;如果按大量不同值过滤,则使用分区(pgvector README,过滤)。对于租户隔离,它建议使用列表分区或独立的表(pgvector README,多租户)。
为什么这个问题能躲过代码审查。 在没有向量索引的表上,带过滤条件的相似度查询会返回精确结果,因为 Postgres 会扫描所有数据。等到某天有人为了提速添加了索引,同一条查询就开始返回更少的行,而且不会报错。在没有索引的小表上运行的测试发现不了这个问题。如果查询需要过滤,就应在添加索引之前决定如何使用迭代扫描。
向量搜索会漏掉精确标识符。搜索课程代码或法规编号时,嵌入模型会把它们当成噪声。全文搜索则有相反的短板:它识别不了换一种说法表达的相同意思。混合搜索会同时运行两种搜索,再合并结果。pgvector README 建议结合 Postgres 全文搜索,并使用倒数排名融合(Reciprocal Rank Fusion)或交叉编码器(cross-encoder)合并结果(pgvector README,混合搜索)。
合并才是难点,因为两种分数的尺度不同。余弦距离和 ts_rank 不能直接相加。倒数排名融合(RRF)通过合并排名位置而非分数,绕开了这个问题。常数 k 用于平滑分数,避免第一名占据过大的优势:
score(doc) = Σ 1 / (k + rank)
当 k = 50 时,也就是 Supabase 混合搜索指南中 rrf_k 的默认值:
文档 A 和 B 同时出现在两个列表中,因此会升到最前面。只被其中一种方法找到的文档仍然有分数,只是更低。
在 Postgres 中,两部分数据都放在同一张表里。在 vector 列旁保留一个 tsvector 列,将两个带排名的查询写成 CTE,再按文档 ID 连接。Supabase 的指南展示了一个采用这种方式构建的完整 SQL 函数。
支持采用专用向量存储的常见理由是规模。在引入另一个服务之前,先用上 Postgres 自身的扩展手段。README 列出了这些方法(pgvector README,扩展);以下顺序由我排列:
使用 halfvec 列。每个向量占用的字节数减半,从而缩小工作集。
对索引使用二值量化,再以完整精度对候选结果重新排序。
将 maintenance_work_mem 设置得足够大,让 HNSW 能在内存中完成构建。
使用更大的实例:增加内存、CPU 和存储。
使用只读副本,然后通过 Citus 或 PgDog 分片。
下面的二值量化用法改编自 README:
CREATE INDEX ON documents
USING hnsw ((binary_quantize(embedding)::bit(1536)) bit_hamming_ops);
SELECT * FROM (
SELECT * FROM documents
ORDER BY binary_quantize(embedding)::bit(1536) <~> binary_quantize($1)
LIMIT 20
) shortlist
ORDER BY embedding <=> $1
LIMIT 5;
当这些手段都已用上,召回率或延迟仍达不到目标,或者向量查询流量必须独立于事务数据库扩展时,专用向量存储才有了用武之地。
我曾寻找独立基准测试,想知道数据规模达到多大时 Postgres 就不够用了,但没有找到。做决定前,请用自己的数据测量召回率和延迟。README 给出了方法:通过 SET LOCAL enable_indexscan = off 执行查询,将近似搜索的结果与精确搜索进行比较(pgvector README,监控)。
并非每张向量表都需要近似索引。以下是两种常见场景:
为 LLM 提供少样本示例。 你挑选最相似的历史示例,放进提示词。这类表通常很小,因此不建索引的精确搜索就已经足够快,而且无需额外代价就能获得完美召回率。
用于检索的文档库。上传的文档会被分块并转换为嵌入向量,每个向量旁都有一个全文搜索列,为混合搜索做好准备。这类数据会持续增长,前面讨论的索引决策也会开始变得重要。
先不建索引,测量延迟,等数据表明确有需要时再添加。添加之前,先重新检查每一条带过滤条件的查询。
向量数据库回答的是“与这个相似的内容”。嵌入将语义转化为距离,最近邻搜索将距离转化为结果。
近似搜索有调节旋钮。ef_search 和 probes 用于在速度与召回率之间取舍。把默认值作为起点,再逐步调优。
从 Postgres 起步;有充分理由后再引入下一个数据库。pgvector 让向量与它们所描述的数据行存放在一起,而这个系统你已经会备份、会监控,也足够了解。在增加一个需要运维的服务之前,先测量召回率和延迟。
你呢?你一开始就使用了专用向量存储,还是把所有数据都保留在 Postgres 中?如果你在使用 pgvector 时碰到了召回率或延迟瓶颈,我很想知道当时的表规模,以及导致瓶颈的配置。有问题也欢迎留言。
Bruno Souza,软件工程师
pgvector README:类型、运算符、HNSW、IVFFlat、过滤、迭代扫描、混合搜索、扩展、监控
pgvector CHANGELOG:0.7.0 新增的类型、0.8.0 新增的迭代扫描
pgvector-node README:Node.js 用法、支持的库、0.3.0 升级说明
OpenAI 嵌入指南:模型维度与 dimensions 参数
OpenAI API 参考,Create embeddings:响应结构
openai-node README:客户端构造与 API 密钥
PostgreSQL 文档,索引类型:B-tree 能处理哪些查询
Malkov 和 Yashunin:《使用分层可导航小世界图实现高效、稳健的近似最近邻搜索》
Supabase 文档,混合搜索:使用 rrf_k = 50 的 RRF
AWS:Aurora PostgreSQL 对 pgvector 0.8.0 的支持
事实核查日期:2026 年 10 月 20 日。
如需采取进一步措施,可以考虑屏蔽此人和/或举报滥用行为。