为什么要把向量存储在PostgreSQL中
RAG(检索增强生成)是一种找出与问题含义相近的文档片段,并将其一并交给LLM的方式。为了计算"含义相近",需要把文档片段转换为数值向量(嵌入向量,embedding)进行存储。
pgvector是一个开源扩展,可以在PostgreSQL中存储向量并按相似度进行检索(2026年10月1日发布0.8.7)。不使用单独的向量数据库而放在PostgreSQL中,好处是可以与文档的权限、租户、修改时间等现有数据在同一事务中、通过同一JOIN进行处理。正因如此,表设计会左右检索质量和性能。
1. 拆分文档与分块的结构
嵌入向量通常不是针对整个文档,而是以按一定长度切分的**分块(chunk)**为单位生成。将文档与分块拆分为1:N关系。
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
title TEXT NOT NULL,
source_url TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_chunks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents (id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536) NOT NULL,
model TEXT NOT NULL,
embedded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (document_id, chunk_index)
);
设计要点如下。
vector(1536)的维度必须与嵌入模型的输出维度相同。不同维度的向量不能放入同一列model列:不同模型生成的向量无法相互比较。更换模型时,可以通过该列找到需要重新生成的行- 分块中也保留
tenant_id:检索时几乎总是按租户过滤,因此无需JOIN即可直接过滤,也可以用于下文的部分索引和分区 ON DELETE CASCADE:删除文档时,其分块和向量也会一并删除,已删除的文档不会残留在检索结果中- 比较
embedded_at与文档的updated_at,可以找出原文变更后尚未重新嵌入的分块
2. 向量类型与维度限制
pgvector提供四种类型,可存储的维度与可创建索引的维度不同。vector和halfvec最多可存储16,000维,但索引上限低于此值。
| 类型 | 可创建索引的上限 |
|---|---|
vector | 2,000维 |
halfvec(半精度) | 4,000维 |
bit(二进制) | 64,000维 |
sparsevec(稀疏) | 1,000个非零元素(存储最多16,000个) |
嵌入向量超过2,000维时,无法直接在vector列上创建索引。此时应使用转换为半精度的表达式索引。查询也必须按与索引相同的表达式排序,才能用上索引。
-- 将3072维嵌入向量以半精度建立索引
CREATE INDEX ON document_chunks_large
USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);
SELECT id, content
FROM document_chunks_large
ORDER BY embedding::halfvec(3072) <=> $1::halfvec(3072)
LIMIT 5;
3. 距离运算符与HNSW索引
pgvector的距离运算符包括<->(L2)、<#>(负内积)、<=>(余弦距离)、<+>(L1),以及用于二进制向量的<~>(汉明)、<%>(杰卡德)。应选择嵌入模型推荐的距离,并且索引的运算符类(opclass)必须与查询的运算符一致。索引在ORDER BY为距离运算符的升序时使用。
索引有两种。
- HNSW:多层图。检索性能好,但构建较慢,占用内存更多。不需要训练步骤
- IVFFlat:将向量划分为多个列表。构建较快,占用内存更少,但检索性能低于HNSW
-- 余弦距离HNSW索引(默认值:m = 16, ef_construction = 64)
CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops);
-- 检索时的候选数量(默认40)。调高则召回率↑ 速度↓
SET hnsw.ef_search = 100;
SELECT id, document_id, content
FROM document_chunks
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 5;
当图能够放入maintenance_work_mem时,HNSW索引的构建速度会快得多。在大表上创建索引时,请一并检查该值。
4. 过滤:结果不足的问题及解决方案
在近似索引中,先扫描索引,之后才应用过滤条件。以pgvector文档中的例子来说,如果符合条件的行占全部的10%,且hnsw.ef_search为默认值40,那么平均只剩下4行。也就是说,即使指定LIMIT 5,也可能得不到5条结果。
应根据情况选择解决方案。
迭代索引扫描(从0.8.0开始)。 持续扫描索引,直到收集到足够的结果。
SET hnsw.iterative_scan = strict_order; -- 严格保持距离顺序
-- 或者
SET hnsw.iterative_scan = relaxed_order; -- 顺序可能略有偏差,但召回率更好
为过滤列创建普通索引。 如果条件只选出全部行中的一小部分,先用B-tree索引过滤会更好。
CREATE INDEX ON document_chunks (tenant_id);
常用的值只有几个时用部分索引,值很多时用分区。
-- 特定租户专用的向量索引
CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops)
WHERE (tenant_id = 42);
如果值的种类很多,pgvector文档建议像PARTITION BY LIST (tenant_id)这样按过滤列进行分区。
5. 运维检查清单
- 更换嵌入模型时:通过
model列筛选目标,在其他列或表中生成新向量后再切换,检索就不会中断 - 原文变更时:删除该文档的分块后重新生成,或定期找出
embedded_at < updated_at的分块进行更新 - 确认维度限制:需要索引的列必须在对应类型的索引维度上限之内
- 衡量检索质量:
ef_search和迭代扫描的设置是在准确度与速度之间进行取舍。请用实际查询测量召回率后再决定
使用Erdmates设计
RAG模式的核心是文档–分块的1:N关系、级联删除的外键,以及用于过滤的租户键。在Erdmates中先把这些关系画成ERD,团队就可以对着同一张图,决定把向量列放在哪张表、用哪个列来过滤。
总结
在pgvector设计中,决定性能与质量的归根结底是平常的数据库设计:拆分文档与分块,记录模型与更新时间,选择符合维度上限的类型,制定与过滤条件相匹配的索引策略(迭代扫描、部分索引、分区)。具备这四点,向量检索也能以与现有数据同等的水平进行管理。