12k
All articles

如何在 Postgres 中实现向量搜索

使用 pgvector 在 Postgres 中实现向量搜索:添加嵌入,按余弦距离查询,用 HNSW 或 IVFFlat 建索引,并结合全文搜索。

OpenReplay Team
OpenReplay Team
如何在 Postgres 中实现向量搜索

pgvector 为 Postgres 增加了一个 vector 列类型,而相似度查询本质上就是在距离运算符上做 ORDER BY,再加上一个 LIMIT

这类需求通常出现在这样的场景:帮助中心搜索 “cancel my subscription” 却什么都没返回,因为对应文章的标题是 “Ending your plan”,于是有人提议在现有的 Postgres 旁边再部署一套托管的向量数据库。多数情况下,这第二个服务并没有必要。本文接下来会用 SQL 完整走一遍整条路径:启用扩展、按嵌入模型确定列的维度、存储向量、用余弦距离查询、使用 HNSW 或 IVFFlat 建立索引、将向量结果与普通表做关联,以及哪些查询根本不适合用向量搜索、应该交给 Postgres 全文搜索来处理。

关键要点

  • vector(n) 中的数字必须等于你的嵌入模型输出向量的长度,且来自不同模型的向量之间无法进行有意义的比较。
  • <=> 运算符返回余弦距离,因此升序排序会把最接近的匹配排在最前;需要余弦相似度时,用 1 减去该值即可。
  • HNSW 和 IVFFlat 都是近似索引;只有当列上没有任何向量索引时,你得到的才是精确搜索。
  • 只有当查询直接在距离运算符上做升序 ORDER BY 并带有 LIMIT 时,规划器才会考虑使用向量索引。
  • 向量搜索找的是”意思相同”的行,全文搜索找的是”词相同”的行;生产环境的搜索通常两者都跑,然后把两份排序列表合并。

向量搜索用在什么地方?

向量搜索按语义匹配,因此查询 “cancel my subscription” 可以返回标题为 “Ending your plan” 的文档,即便两者没有任何共同词汇。每段文本都会被嵌入模型转换成一个定长的数字列表,语义相近的文本在该空间中彼此靠近。搜索就是找出与查询向量距离最近的已存储向量。关于嵌入的原理,可参阅 Vector Databases Explained

它主要在三类场景中见效:能容忍改写表述的站内搜索、支持工单匹配(找出与当前工单类似的历史工单),以及 RAG 中的检索环节——把最相近的文档作为上下文喂给语言模型,相关内容可参见 introduction to RAG for web apps

启用 pgvector 并添加向量列

每个数据库只需通过 CREATE EXTENSION vector 启用一次 pgvector,列类型为 vector(n),其中 n 是维度数。0.8.6 版本支持 Postgres 13 及以上版本,不过 Postgres 13 已超出社区支持周期,因此实际上的下限是 14 或更新版本。

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id        bigserial PRIMARY KEY,
  title     text NOT NULL,
  body      text NOT NULL,
  embedding vector(1536)   -- replace 1536 with your embedding model's output size
);

-- or, on a table you already have:
ALTER TABLE documents ADD COLUMN embedding vector(1536);

vector(n) 中的数字必须等于你的嵌入模型输出向量的长度。例如,OpenAI 的 text-embedding-3-small 默认返回 1536 维向量。来自不同模型的向量无法进行有意义的比较,因此更换模型意味着要对整列重新生成嵌入。

存储来自任意模型的嵌入

嵌入的写入方式与其他列值别无二致:在应用代码中生成向量,然后作为绑定参数传入并转换为 vector。pgvector 不会替你调用模型,任何带有 Postgres 驱动的语言都可以使用。

INSERT INTO documents (title, body, embedding)
VALUES ($1, $2, $3::vector);

-- re-embed an existing row without creating a duplicate
INSERT INTO documents (id, title, body, embedding)
VALUES ($1, $2, $3, $4::vector)
ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding;

对于首次批量回填,pgvector 建议使用 COPY ... FROM STDIN WITH (FORMAT BINARY) 批量加载,并在数据入库之后再建索引,而不是事先建好。

应该使用哪个 pgvector 距离运算符?

除非模型文档另有说明,文本嵌入一律使用 <=>。它返回余弦距离,因此升序排序会把最接近的匹配排在最前;需要在界面上展示余弦相似度时,用 1 减去该结果即可。

-- five closest documents; $1 is the query embedding
SELECT id, title, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 5;

pgvector 总共支持六种距离运算符,每一种都需要配套的索引运算符类:

运算符度量适用场景运算符类
<=>余弦距离文本嵌入;通常的默认选择vector_cosine_ops
<->L2(欧几里得)距离向量模长本身带有含义vector_l2_ops
<#>负内积向量已归一化为单位长度vector_ip_ops
<+>L1(曼哈顿)距离文本场景很少用;仅支持 HNSWvector_l1_ops

<#> 返回的是取反符号后的内积。Postgres 只能按升序扫描索引,因此取负形式能让最小的数值对应最接近的匹配;乘以 -1 即可还原为普通内积。如果你的向量已经归一化为单位长度,那么在精确搜索中内积是最快的选择。<~>(Hamming)和 <%>(Jaccard)用于二进制向量,不在本文讨论范围内。

该用 HNSW 还是 IVFFlat 建索引?

在没有索引的情况下,pgvector 会将查询向量与每一行逐一比较,返回精确的最近邻。添加 HNSW 或 IVFFlat 索引后就变成了近似搜索:速度快得多,能找到大部分真正的近邻,但返回的行可能与精确查询不同。这两种索引类型都是近似的。所谓精确搜索,其实就是列上根本没有向量索引时自然发生的情况。

除非构建时间或内存迫使你选择 IVFFlat,否则默认用 HNSW。运算符类必须与你查询所用的运算符匹配。

-- production: avoid blocking writes
CREATE INDEX CONCURRENTLY documents_embedding_hnsw
  ON documents USING hnsw (embedding vector_cosine_ops);

-- IVFFlat alternative; create only after the table has data
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
HNSWIVFFlat
结构多层图向量分桶进列表
速度—召回率权衡更好更差
构建时间与内存更慢、更多更快、更少
可在空表上构建可以,无需训练步骤不可以;聚类中心来自构建时已有的数据
查询调节参数hnsw.ef_search(默认 40)ivfflat.probes(默认 1;建议从 sqrt(lists) 起步)

HNSW 没有训练步骤,因此可以在表中还没有任何一行数据时就建好索引。IVFFlat 则有:它的列表来自建索引时表中已有的数据,所以要先准备一批有代表性的行。在事务中用 SET LOCAL 提高 ef_searchprobes,可以以速度为代价提升单次查询的召回率。

只有当查询直接在距离运算符上做升序 ORDER BY 并同时带有 LIMIT 时,规划器才会考虑使用向量索引;ORDER BY 1 - (embedding <=> $1) DESC 不会走索引。在小表上规划器仍可能偏向顺序扫描,所以要用 EXPLAIN 确认。想衡量索引在召回率上的代价,可以强制执行精确搜索并比较两组结果:

BEGIN;
SET LOCAL enable_indexscan = off;  -- exact search for comparison
SELECT id FROM documents ORDER BY embedding <=> $1::vector LIMIT 5;
COMMIT;

你需要一个独立的向量数据库吗?

如果你已经在运行 Postgres,那你多半不需要单独的向量数据库:pgvector 让你在同一套备份、同一套事务体系下获得向量搜索能力,还能在一条查询里把向量结果与普通表关联起来。文档及其嵌入可以在一次 INSERT 中写入,因此两套存储之间不需要同步管道,也不存在”孤儿向量”这种故障模式。向量和其他列一样通过预写日志传播,所以副本和时间点恢复都能自动带上它们,无需额外工作。

关联查询最能体现这一点。查找最相近的支持工单并限定为企业版客户,只需一条语句:

SELECT t.id, t.subject, c.plan, t.embedding <=> $1::vector AS distance
FROM tickets t
JOIN customers c ON c.id = t.customer_id
WHERE c.plan = 'enterprise'
ORDER BY t.embedding <=> $1::vector
LIMIT 10;

近似索引有一个需要注意的地方:索引会先被扫描,随后 WHERE 子句才作用于索引返回的结果,因此选择性很高的过滤条件可能导致最终结果少于 LIMIT 所要求的行数。在过滤列上建 B-tree 索引往往能快速得到精确结果;其余情况可通过迭代扫描和部分索引解决。

在向量规模达到数十亿或写入速率极高时,专用向量数据库依然有其价值。低于这个量级,多出来的服务只是运维成本,对用户而言没有可见的收益。

向量搜索的短板:与全文搜索结合的混合检索

向量搜索找的是”意思相同”的行,全文搜索找的是”词相同”的行;生产环境的搜索通常两者都跑,然后把两份排序列表合并。精确标识符、产品编码、错误字符串和人名对嵌入模型来说没有多少有用的”语义”,而查询 SKU-4471 应当返回包含该 token 的那一行,而不是关于相似产品的若干行。这类查询需要的是 Postgres 全文搜索,pgvector 的文档也将它与向量搜索配对用于混合查询

先添加一个存储型生成列 tsvector 以及其上的 GIN 索引:

ALTER TABLE documents
  ADD COLUMN body_tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;

CREATE INDEX ON documents USING gin (body_tsv);

然后用倒数排名融合(Reciprocal Rank Fusion)把两种排名结合起来,写法参照 pgvector 自己的 RRF 示例

-- $1 = query embedding, $2 = raw query text
WITH semantic AS (
  SELECT id, RANK() OVER (ORDER BY embedding <=> $1::vector) AS rnk
  FROM documents
  ORDER BY embedding <=> $1::vector
  LIMIT 20
),
lexical AS (
  SELECT id, RANK() OVER (ORDER BY ts_rank_cd(body_tsv, q) DESC) AS rnk
  FROM documents, plainto_tsquery('english', $2) AS q
  WHERE body_tsv @@ q
  ORDER BY ts_rank_cd(body_tsv, q) DESC
  LIMIT 20
)
SELECT COALESCE(s.id, l.id) AS id,
       COALESCE(1.0 / (60 + s.rnk), 0.0) + COALESCE(1.0 / (60 + l.rnk), 0.0) AS score
FROM semantic s
FULL OUTER JOIN lexical l ON l.id = s.id
ORDER BY score DESC
LIMIT 10;

每份列表贡献 1 / (k + rank),因此在任意一份列表中靠前的文档得分都不低,而同时出现在两份列表中的文档得分最高。常数 k = 60 来自 Cormack、Clarke 和 Büttcher,他们在一项试点研究中确定了这个值,并指出其精确取值并不关键。请把这段查询当作示例,并用 EXPLAIN (ANALYZE, BUFFERS) 确认在你的数据上 HNSW 和 GIN 索引都被用到了。

从哪里开始

整套实现无非是一个列、一个运算符和一个索引,全都位于你已经在备份和复制的那个数据库里。先按模型维度添加 vector(n) 列,在没有索引的情况下写出 ORDER BY ... <=> ... LIMIT 查询,然后加上 HNSW,并用 enable_indexscan = off 检查召回率的差异。语义结果看起来正常之后,再接入全文搜索这一侧——否则第一个粘贴订单号进来的用户就会暴露这个缺口。

常见问题

pgvector 能为超过 2000 维的嵌入建索引吗?

仅用标准 vector 类型不行。HNSW 和 IVFFlat 最多能为 2000 维的向量列建索引,尽管该列本身最多可存储 16000 维。对于更大的嵌入,可以在表达式索引中转换为 halfvec(可索引至 4000 维)、使用二进制量化配合重排序(最高 64000 维)、对子向量建索引,或者要求模型输出更少的维度——OpenAI 的 text-embedding-3 系列模型支持通过 dimensions 参数实现这一点。

插入新行后需要重建 pgvector 索引吗?

HNSW 不需要:新行在插入时就会被加入图中,这也是它可以在空表上建索引的原因。IVFFlat 则不同。它的列表聚类中心在构建时由 k-means 计算一次且此后不再变动,因此随着表增长或数据分布变化,召回率可能下降。在大批量加载或分布发生变化后,应使用 REINDEX INDEX CONCURRENTLY 重建 IVFFlat 索引。

为什么加上 HNSW 索引后,查询返回的行数少于 LIMIT?

因为一次 HNSW 扫描最多返回 hnsw.ef_search 个候选(默认为 40),而任何 WHERE 过滤都是随后作用于这些候选之上的。LIMIT 大于 40、选择性很强的过滤条件,或者死元组,都可能导致结果不足。可以用 SET LOCAL 提高 hnsw.ef_search;在 pgvector 0.8.0 及以后版本中,也可以把 hnsw.iterative_scan 设为 strict_order 来启用迭代索引扫描,使扫描持续进行直到匹配到足够的行。

我能在同一张表里存储来自两个不同模型的嵌入吗?

可以,但不能放在同一个共享的索引列中。应为每个模型使用单独的 vector(n) 列,各自按该模型的输出维度设置并各自建索引。pgvector 也允许使用不指定维度的 vector 列来存放混合维度的向量,但一个索引只能覆盖单一维度的行,需要借助带类型转换的表达式索引,再加上针对模型标识符的部分索引 WHERE 条件。跨模型的距离没有任何意义。

DevTools for the frontend

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.