pgvector 入门:向量存进你已经在用的 PostgreSQL(第72篇-E58)

系列「企业级 AI Agent 实现拆解」E58 篇,Part 13 RAG 篇第七章。上一篇 拆完了缓存层。这篇解决「算好的向量存哪」。

**这一章原计划写 Qdrant,改成了 pgvector。**理由不是偷懒------DeepFlux 生产环境用的就是 pgvector,deploy/sql/013_pgvector.sql 第二行注释写得很直白:

sql 复制代码
-- 架构决策:全平台向量存储统一使用 PostgreSQL pgvector,不再依赖 Qdrant

既然系列定位是「从真实生产代码出发」,那就写真在用的东西。

读完这篇你会知道

  • 三行 SQL 让 PostgreSQL 支持向量检索
  • 三种距离算子 <-> / <=> / <#> 的排序结果完全不同(实测表)
  • HNSW 索引有 2000 维硬上限 ------OpenAI text-embedding-3-large 的 3072 维建不了索引
  • 索引比表还大:29MB 的表配 40MB 的索引
  • 默认 ef_search=40 只召回 60%(实测 ef 从 10 调到 400 的召回曲线)
  • 过滤是后过滤LIMIT 5 实际只返回 3 行的现场
  • 造向量测试数据的坑:三种写法两种错,5 万行共用一个向量
  • 为什么这些坑在内存版(第 66 篇《最简 RAG》)里都不存在

零、为什么不另起一个向量库

专用向量库(Qdrant / Milvus / Weaviate)性能上限更高,这没争议。但对多数团队,pgvector 的账更好算:

专用向量库 pgvector
要维护的服务 +1 0
备份 / 恢复 另一套 跟着 PG 走
权限 / RLS 另一套 跟着 PG 走
事务 跨库不可能 和业务数据同一个事务
元数据过滤 各家 DSL SQL
运维熟悉度 要学 已会

**「和业务数据同一个事务」这条是最值钱的。**文档入库、切片落库、向量写入、状态更新,一个 BEGIN...COMMIT 全搞定。用外部向量库就得处理「PG 写成功但向量库写失败」这类分布式一致性问题,通常要引入 outbox 或补偿任务。

代价是规模上限。到千万级向量、要求 P99 个位数毫秒的场景,再考虑专用库。


一、三行 SQL 起步

环境是 PostgreSQL 18.4 + pgvector 0.8.6。

sql 复制代码
-- ① 启用扩展
CREATE EXTENSION IF NOT EXISTS vector;

-- ② 建表,向量就是一个列类型
CREATE TABLE chunks (
    id        text PRIMARY KEY,
    content   text NOT NULL,
    metadata  jsonb NOT NULL DEFAULT '{}'::jsonb,
    embedding vector(128)
);

-- ③ 建索引
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);

建完 \d chunks

sql 复制代码
                    Table "e72demo.chunks"
  Column   |    Type     | Nullable |   Default
-----------+-------------+----------+-------------
 id        | text        | not null |
 content   | text        | not null |
 metadata  | jsonb       | not null | '{}'::jsonb
 embedding | vector(128) |          |

注意 vector(128) 里那个 128------维度写死在列类型里。

这跟第 66 篇内存版的 []float64 完全不同。后果是维度不匹配会被数据库拦下来:

sql 复制代码
INSERT INTO chunks (id, content, embedding) VALUES ('bad', '维度不对', '[1,2,3]');
-- ERROR:  expected 128 dimensions, not 3

**这其实是好事。**第 71 篇《Embedder 缓存层》讲过缓存层「只有 Model 进 key」会导致维度串味,静默算出错误的相似度。pgvector 这里直接报错------错得响亮总比错得安静好。

代价是换 embedding 模型要 ALTER TABLE。但换模型本来就得全量重建向量(第 70 篇《Embedding 选型》说过),加一条 DDL 不算负担。

有个小坑:扩展装在 public schema 里。如果你 SET search_path 排除了 public,会得到莫名其妙的报错:

bash 复制代码
ERROR:  type "vector" does not exist

写成 SET search_path = myschema, public 就好。


二、三种距离算子,选错了排序全乱

pgvector 提供三个算子。我造了 4 个向量、查询向量固定为 [1,0,0],把三种距离全算出来:

ini 复制代码
查询向量 [1,0,0]:
  name  | L2距离 <-> | 余弦距离 <=> | 负内积 <#>
--------+------------+--------------+------------
 同向短 |     0.0000 |       0.0000 |    -1.0000
 同向长 |     8.0000 |       0.0000 |    -9.0000
 垂直   |     1.4142 |       1.0000 |     0.0000
 反向   |     2.0000 |       2.0000 |     1.0000

同向短 = [1,0,0]同向长 = [9,0,0]垂直 = [0,1,0]反向 = [-1,0,0]

三个算子排出来是三个不同的顺序:

bash 复制代码
按 L2 排序(<->):      同向短 → 垂直 → 反向 → 同向长
按余弦排序(<=>):      同向短 → 同向长 → 垂直 → 反向
按负内积排序(<#>):    同向长 → 同向短 → 垂直 → 反向

同向长[9,0,0])这一行,它跟查询向量方向完全一致、只是长了 9 倍:

  • 余弦:排第 1(并列),距离 0------只看方向
  • L2:排最后,距离 8------长度差被当成距离
  • 负内积:排第 1 且反超,因为内积奖励长度

**RAG 要用 <=>(余弦)。**理由第 66 篇说过:一段话写得长还是短,不该影响「它讲的是什么」。用 L2 的话,长文档会被系统性地判为「不相关」。

三件配套的事:

**① 算子和索引的 ops 必须配对。**建索引写 vector_cosine_ops,查询就得用 <=>。用 <-> 查会走不到这个索引:

算子 含义 索引 ops
<-> L2 距离 vector_l2_ops
<=> 余弦距离 vector_cosine_ops
<#> 负内积 vector_ip_ops

**② 余弦距离不是相似度。**距离越小越像,范围 0~2。转相似度:

sql 复制代码
SELECT 1 - (embedding <=> '[...]') AS similarity FROM chunks;

实测验证:

diff 复制代码
  name  | 余弦相似度
--------+------------
 同向短 |     1.0000
 同向长 |     1.0000
 垂直   |     0.0000
 反向   |    -1.0000

跟第 66 篇内存版 cosine() 函数的输出对齐了。做「相似度低于 0.7 就丢弃」这种阈值过滤,记得先转换。

<#> 是负内积,不是内积。pgvector 取负是为了让「越小越相似」这个约定统一(索引只支持升序)。


三、HNSW 有 2000 维硬上限

这个坑值得单独一节,因为它能直接否掉你的模型选型。

vector 类型本身能存很宽的向量,4096 维建表毫无问题:

sql 复制代码
                   Table "e72demo.wide"
  Column   |     Type     | Nullable | Default
-----------+--------------+----------+---------
 id        | integer      |          |
 embedding | vector(4096) |          |

但给它建 HNSW 索引:

sql 复制代码
CREATE INDEX ON wide USING hnsw (embedding vector_cosine_ops);
-- ERROR:  column cannot have more than 2000 dimensions for hnsw index

我逐一试了边界:

维度 建表 HNSW 索引
128
1536
2000
2001 cannot have more than 2000 dimensions
4096

2000 是硬线。

对着第 70 篇那张选型表看,这条线的杀伤力就出来了:

  • DashScope text-embedding-v3:1024 / 768 / 512 → 都能建索引
  • DeepFlux 生产用的 1536 维 → 能建
  • OpenAI text-embedding-3-small 1536 维 → 能建
  • OpenAI text-embedding-3-large 3072 维建不了 HNSW

没有索引不是不能用,只是退化成全表扫描------几千行还行,上万行就废了。

三条出路:

  1. 降维text-embedding-3-large 支持 dimensions 参数截到 1024 或 2000。第 70 篇说过降维要用评测集测掉几个点,这里多了一条硬约束:必须 ≤2000
  2. halfvec。pgvector 0.7+ 引入的半精度类型,HNSW 上限 4000 维,存储也省一半。代价是精度降低
  3. 换 IVFFlat 索引。它的维度上限也是 2000,所以这条其实走不通------列出来是为了让你别白试

四、索引比表还大

5 万行 × 128 维,实测:

diff 复制代码
=== 建 HNSW 索引 ===
Time: 9095.205 ms (00:09.095)
 索引大小
 40 MB

 行数  | 不同向量数 | 表大小
-------+------------+--------
 50000 |      50000 | 29 MB

表 29MB,索引 40MB。索引比数据本身大 38%。

建索引花了 9 秒。这个数字要记住------它随行数和维度增长,不是线性的。百万行 1536 维的表,建索引是分钟到小时级的操作,而且期间会锁表(除非用 CREATE INDEX CONCURRENTLY)。

两个实操结论:

**① 容量规划要按「表 + 索引」算,而且系数大于 2。**1536 维的话,单行向量就是 1536 × 4 = 6KB,100 万行 = 6GB 表 + 8GB 索引。HNSW 是图结构,理想情况下整个索引要能装进内存,否则查询要走磁盘。

**② 建索引安排在数据灌完之后。**先建索引再逐条插入,每次插入都要更新图结构,比「先灌数据后建索引」慢得多。DeepFlux 的迁移脚本就是这个顺序------ALTER TABLE ADD COLUMN 之后才 CREATE INDEX


HNSW 是 A pproximate N earest Neighbor------近似最近邻。「近似」意味着它会漏。

漏多少?我先关掉索引拿到精确 Top10 当基准,再用不同 ef_search 跑 HNSW,看两个结果集的交集:

ini 复制代码
=== 精确 Top10 基准 ===
  id   |   dist
-------+----------
     7 | 0.000000     ← 查询向量就取自 id=7,距离 0
 49479 | 0.606353
 20798 | 0.642460
 42288 | 0.643692
  8733 | 0.654911
  3167 | 0.674993
 11379 | 0.685689
 21438 | 0.687307
  4451 | 0.689288
  9323 | 0.691967

=== ANN 召回率 vs ef_search ===
 ef_search | Top10 命中
-----------+------------
        10 |          6
        40 |          6     ← 默认值
       100 |          9
       400 |          9

默认 ef_search = 40 时,精确 Top10 里只召回了 6 个。

调到 100 提升到 9 个,再调到 400 没有进一步提升(这份随机数据的极限)。

ef_search 控制搜索时候选队列的大小------越大搜得越广、越准、越慢。查看和调整:

sql 复制代码
SHOW hnsw.ef_search;              -- 默认 40
SET hnsw.ef_search = 100;         -- 会话级
SET LOCAL hnsw.ef_search = 100;   -- 事务级,推荐

**用 SET LOCAL 而不是 SET。**连接池场景下 SET 会污染后续复用这条连接的请求。

怎么定这个值?**别用默认值,也别抄我的 100。**这个数跟你的数据分布、维度、行数都有关。用第 70 篇那套评测集:把 ef_search 当参数扫一遍,看 Recall@K 的曲线在哪里拐平,取拐点。

顺带说:建索引时的 mef_construction 也影响召回上限,但那是建索引时定死的,改要重建。ef_search 是查询时的旋钮,先调它。


六、过滤是后过滤:LIMIT 5 只给 3 行

这是 pgvector 最容易踩、也最容易被误诊的坑。

sql 复制代码
SELECT id FROM vecs
WHERE tenant = 'tenant_a'          -- tenant_a 占全表 10%
ORDER BY embedding <=> '[...]'
LIMIT 5;

执行计划:

sql 复制代码
 Limit (actual rows=3 loops=1)
   ->  Index Scan using idx_vecs_hnsw on vecs (actual rows=3 loops=1)
         Order By: (embedding <=> '[...128维...]'::vector)
         Filter: (tenant = 'tenant_a'::text)
         Rows Removed by Filter: 37

LIMIT 5 写着,actual rows=3 只返回了 3 行。

没报错、没警告,就是少给你两条。

机制在 Rows Removed by Filter: 37 这行:

  1. HNSW 索引按向量距离取出约 40 个候选(ef_search=40
  2. 对这 40 个候选逐个应用 tenant = 'tenant_a' 过滤
  3. 40 个里只有 3 个属于 tenant_a(其余 37 个被 Rows Removed by Filter 掉)
  4. 没了,只能返回 3 条

过滤发生在向量检索之后,不是之前。

多租户场景下这个坑是致命的:租户占比越小,返回的结果越少。一个只占 1% 数据的小租户,ef_search=40 时可能一条都返回不了------而你的 RAG 会因此告诉用户「知识库里没有相关内容」。

四个解法,按推荐度排:

**① 物理隔离。**每个租户一张表,或者用 PostgreSQL 分区表按 tenant_id 分区。过滤变成分区裁剪,在索引之前发生,根本不存在这个问题。数据量大、租户少时最优。

**② 调大 ef_search。**候选池够大就能凑够数。缺点是变慢,而且是「猜」------租户占比 1% 想稳定拿 5 条,ef_search 得开到 500+。

**③ 检索后在应用层补偿。**发现返回数不足就加大 ef_search 重查一次。工程上可行,但多一次往返。

**④ 部分索引。**给高频过滤条件建带 WHERE 的索引:

sql 复制代码
CREATE INDEX ON vecs USING hnsw (embedding vector_cosine_ops)
WHERE tenant = 'tenant_a';

租户固定且不多时好用,租户动态增长就不现实。

DeepFlux 走的是 RLS + 每租户独立 namespace 的路子,本质上是方案 ①。


七、优化器会自己放弃 HNSW,这是对的

过滤条件极窄时(比如按主键范围),情况反过来:

sql 复制代码
SELECT id FROM vecs WHERE id BETWEEN 1 AND 10
ORDER BY embedding <=> '[...]' LIMIT 5;
ini 复制代码
 Limit (actual rows=5 loops=1)
   ->  Sort (actual rows=5 loops=1)
         Sort Method: quicksort  Memory: 25kB
         ->  Index Scan using vecs_pkey on vecs (actual rows=10 loops=1)
               Index Cond: ((id >= 1) AND (id <= 10))
 Execution Time: 0.033 ms

**优化器改用主键索引,捞出 10 行后精确排序。**没用 HNSW,而且是全场最快的 0.033 ms。

这是正确决策:候选集只有 10 行,精确算 10 次距离比走近似图便宜得多,而且结果精确 ------actual rows=5,一条不少。

顺带修正我自己一个误判。5000 行的时候我看到查询没走 HNSW,一度以为是「查询向量写成子查询就用不了索引」。扩到 5 万行后再测:

sql 复制代码
=== 查询向量是子查询 ===
   InitPlan 1 (returns $0)
     ->  Index Scan using vecs_pkey ...
   ->  Index Scan using idx_vecs_hnsw on vecs (actual rows=5 loops=1)
         Order By: (embedding <=> $0)

**子查询照样能用 HNSW 索引。**之前不走索引单纯是因为 5000 行太少,Seq Scan 更便宜。

这也是一条通用经验:**在小数据量上验证「索引有没有用上」会得到错误结论。**造够量再看执行计划。


八、造向量测试数据:三种写法两种错

上面那句「造够量」,我自己就在这儿栽了两次。

想造 5 万行随机向量,第一版这么写:

sql 复制代码
INSERT INTO vecs (id, embedding)
SELECT g, (SELECT array_agg(random())::vector FROM generate_series(1, 128))
FROM generate_series(1, 50000) g;

跑完所有距离都是 0,召回率 100%------数据是假的。

第二版改用 LATERAL,以为能强制逐行求值:

sql 复制代码
INSERT INTO vecs (id, embedding)
SELECT g, v
FROM generate_series(1, 50000) g,
     LATERAL (SELECT array_agg(random())::vector AS v FROM generate_series(1, 128)) x;

还是错的。

三种写法一起测,用 count(DISTINCT embedding::text) 数实际有几个不同的向量:

sql 复制代码
         写法         | 不同向量数
----------------------+------------
 ① 不相关子查询       |          1
 ② LATERAL 不引用外层 |          1
 ③ 子查询引用 g       |          3

(3 行数据的测试,正确结果应该是 3)

根因 :子查询里没有引用外层的 g,PostgreSQL 判定它与外层无关,只求值一次 然后复用结果。LATERAL 只是允许引用外层,不引用的话照样退化成不相关子查询。

正确写法是让子查询真正依赖外层:

sql 复制代码
INSERT INTO vecs (id, embedding)
SELECT g, (
    SELECT array_agg(random() - 0.5)::vector
    FROM generate_series(1, 128) s
    WHERE s + g > 0                    -- ← 引用 g,强制逐行求值
)
FROM generate_series(1, 50000) g;

WHERE s + g > 0 恒真,唯一作用就是建立对 g 的依赖。

修正后数据才对:

diff 复制代码
 行数  | 不同向量数 | 表大小
-------+------------+--------
 50000 |      50000 | 29 MB

=== 余弦距离分布 ===
  最小  |  平均  |  最大
--------+--------+--------
 0.0000 | 0.9992 | 1.3613

平均距离 0.9992 ------ 高维随机向量近似正交,符合预期。(顺带一提,分量要取 random() - 0.5 而不是 random()。全正分量的向量都挤在第一象限,两两余弦距离只有 0.25 左右,区分度太差。)

造完数据先跑两句体检:

sql 复制代码
SELECT count(*), count(DISTINCT embedding::text) FROM vecs;
SELECT min(embedding <=> :qv), avg(embedding <=> :qv), max(embedding <=> :qv) FROM vecs;

不同向量数 = 行数,距离分布有合理的方差------两条都过了再开始做实验。否则后面所有数据都是幻觉。


九、跟 Eino 怎么接

第 66 篇用 40 行内存版实现了 indexer.Indexer + retriever.Retriever。换成 pgvector,接口一个字不改,实现里把切片操作换成 SQL:

go 复制代码
// 存:INSERT ... ON CONFLICT DO UPDATE
func (s *pgStore) Store(ctx context.Context, docs []*schema.Document,
    opts ...indexer.Option) ([]string, error) {
    vectors, err := s.embedder.EmbedStrings(ctx, texts)
    // ... INSERT INTO chunks (id, content, metadata, embedding) VALUES ...
}

// 查:ORDER BY embedding <=> $1 LIMIT $2
func (s *pgStore) Retrieve(ctx context.Context, query string,
    opts ...retriever.Option) ([]*schema.Document, error) {
    qv, err := s.embedder.EmbedStrings(ctx, []string{query})
    // ... SELECT id, content, metadata, 1 - (embedding <=> $1) AS score ...
}

eino-ext 没有官方 pgvector 组件(现成的是 es / milvus / redis / qdrant 等),所以这部分要自己写。好消息是两个接口各一个方法,而且第 66 篇已经把「接口不变、实现可换」这件事演示过了。

完整实现、SQL 拼接的坑(向量参数怎么传、pgvector-go 要不要引)、批量 upsert、以及跟 eino-ext 现有实现的对比,放在下一篇(第 73 篇《Indexer + Retriever 源码》)。


小结

  • pgvector 值得优先考虑:少一个服务,向量和业务数据同事务。规模上限到了再换专用库
  • 维度写死在列类型里,插错维度直接报错------比第 71 篇那种静默串味好得多
  • RAG 用 <=>(余弦)<-> 会因为文本长度误判相关性;算子和索引 ops 必须配对;相似度 = 1 - 距离
  • HNSW 硬上限 2000 维 :OpenAI text-embedding-3-large 的 3072 维建不了索引,只能降维或用 halfvec
  • 索引比表还大(实测 40MB vs 29MB),容量规划系数按 2+ 算,先灌数据后建索引
  • 默认 ef_search=40 只召回 60% ,调到 100 才到 90%。用 SET LOCAL,值靠评测集扫出来
  • 过滤是后过滤LIMIT 5 实测只返回 3 行。多租户优先物理隔离,别指望调 ef_search 兜住
  • 小数据量上看执行计划会得到错误结论(5000 行不走索引不代表用法有问题)
  • 造向量测试数据 :不相关子查询和不引用外层的 LATERAL 都只求值一次,先用 count(DISTINCT) 体检

下一篇(第 73 篇)拆源码:Indexer / Retriever 两个接口怎么落到 pgvector 的 SQL 上,向量参数怎么安全地传进去,以及 eino-ext 现有的几个后端实现有什么共同套路。


代码状态说明 :本文全部 SQL 与实测数据在 PostgreSQL 18.4 + pgvector 0.8.6 上真机运行,输出原样粘贴(仅把执行计划里 128 维的向量字面量折叠成 [...128维...] 以便阅读)。实验用的是临时 schema e72demo,跑完已 DROP SCHEMA CASCADE,未触碰任何业务表。第九节的 Go 代码是骨架示意,完整实现在第 73 篇。

相关推荐
武子康2 小时前
2026 年 7 月 AI 模型发布复盘:真正被比较的是整套工作系统
人工智能·llm·agent
gyx_这个杀手不太冷静2 小时前
Agent开发进阶指南(第 2 章):Agent 运行全流程拆解、上下文窗口、流式输出、记忆系统与 Function Call 实战
前端·架构·agent
AI创界者2 小时前
开源硬核LTX-Video 本地部署整合包教程:超低显存生成高清AI视频,告别云端排队!
人工智能·aigc·音视频
小虎AI生活2 小时前
一句话出片,WorkBuddy 加 LibTV,视频制作从"做"变成了"说"
ai编程
仙逆GPT3 小时前
ChatGPT、Codex实战:离开电脑后,怎么用手机继续控制任务?
chatgpt·ai编程·codex·手机控制·remote
不一样的少年_3 小时前
Claude Code 是怎么自己改代码的?答案藏在这 4 个工具里
前端·agent·ai编程
蛋先生DX3 小时前
外挂变内置: 大模型工具调用与思维链的能力进化史
llm·agent
NutShell Wang4 小时前
DeepSeek-V4-Flash正式版深度拆解:不换架构,后训练如何让Agent能力暴涨7倍
人工智能·ai·架构·agent·后训练·deepseek
llwszx4 小时前
从跳表到HNSW:深度拆解向量ANN检索的分层设计与近似本质
agent·ann·hnsw·向量检索·rag