系列「企业级 AI Agent 实现拆解」E58 篇,Part 13 RAG 篇第七章。上一篇 拆完了缓存层。这篇解决「算好的向量存哪」。
**这一章原计划写 Qdrant,改成了 pgvector。**理由不是偷懒——DeepFlux 生产环境用的就是 pgvector,
deploy/sql/013_pgvector.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。
-- ① 启用扩展
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:
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 完全不同。后果是维度不匹配会被数据库拦下来:
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,会得到莫名其妙的报错:
ERROR: type "vector" does not exist
写成 SET search_path = myschema, public 就好。
二、三种距离算子,选错了排序全乱
pgvector 提供三个算子。我造了 4 个向量、查询向量固定为 [1,0,0],把三种距离全算出来:
查询向量 [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])
三个算子排出来是三个不同的顺序:
按 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。转相似度:
SELECT 1 - (embedding <=> '[...]') AS similarity FROM chunks;
实测验证:
name | 余弦相似度
--------+------------
同向短 | 1.0000
同向长 | 1.0000
垂直 | 0.0000
反向 | -1.0000
跟第 66 篇内存版 cosine() 函数的输出对齐了。做「相似度低于 0.7 就丢弃」这种阈值过滤,记得先转换。
③ <#> 是负内积,不是内积。pgvector 取负是为了让「越小越相似」这个约定统一(索引只支持升序)。
三、HNSW 有 2000 维硬上限
这个坑值得单独一节,因为它能直接否掉你的模型选型。
vector 类型本身能存很宽的向量,4096 维建表毫无问题:
Table "e72demo.wide"
Column | Type | Nullable | Default
-----------+--------------+----------+---------
id | integer | |
embedding | vector(4096) | |
但给它建 HNSW 索引:
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-small1536 维 → 能建 - OpenAI
text-embedding-3-large3072 维 → 建不了 HNSW
没有索引不是不能用,只是退化成全表扫描——几千行还行,上万行就废了。
三条出路:
- 降维。
text-embedding-3-large支持dimensions参数截到 1024 或 2000。第 70 篇说过降维要用评测集测掉几个点,这里多了一条硬约束:必须 ≤2000 - 用
halfvec。pgvector 0.7+ 引入的半精度类型,HNSW 上限 4000 维,存储也省一半。代价是精度降低 - 换 IVFFlat 索引。它的维度上限也是 2000,所以这条其实走不通——列出来是为了让你别白试
四、索引比表还大
5 万行 × 128 维,实测:
=== 建 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。
五、默认 ef_search 只召回 60%
HNSW 是 Approximate Nearest Neighbor——近似最近邻。「近似」意味着它会漏。
漏多少?我先关掉索引拿到精确 Top10 当基准,再用不同 ef_search 跑 HNSW,看两个结果集的交集:
=== 精确 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 控制搜索时候选队列的大小——越大搜得越广、越准、越慢。查看和调整:
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 的曲线在哪里拐平,取拐点。
顺带说:建索引时的 m 和 ef_construction 也影响召回上限,但那是建索引时定死的,改要重建。ef_search 是查询时的旋钮,先调它。
六、过滤是后过滤:LIMIT 5 只给 3 行
这是 pgvector 最容易踩、也最容易被误诊的坑。
SELECT id FROM vecs
WHERE tenant = 'tenant_a' -- tenant_a 占全表 10%
ORDER BY embedding <=> '[...]'
LIMIT 5;
执行计划:
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 这行:
- HNSW 索引按向量距离取出约 40 个候选(
ef_search=40) - 对这 40 个候选逐个应用
tenant = 'tenant_a'过滤 - 40 个里只有 3 个属于
tenant_a(其余 37 个被Rows Removed by Filter掉) - 没了,只能返回 3 条
过滤发生在向量检索之后,不是之前。
多租户场景下这个坑是致命的:租户占比越小,返回的结果越少。一个只占 1% 数据的小租户,ef_search=40 时可能一条都返回不了——而你的 RAG 会因此告诉用户「知识库里没有相关内容」。
四个解法,按推荐度排:
**① 物理隔离。**每个租户一张表,或者用 PostgreSQL 分区表按 tenant_id 分区。过滤变成分区裁剪,在索引之前发生,根本不存在这个问题。数据量大、租户少时最优。
**② 调大 ef_search。**候选池够大就能凑够数。缺点是变慢,而且是「猜」——租户占比 1% 想稳定拿 5 条,ef_search 得开到 500+。
**③ 检索后在应用层补偿。**发现返回数不足就加大 ef_search 重查一次。工程上可行,但多一次往返。
**④ 部分索引。**给高频过滤条件建带 WHERE 的索引:
CREATE INDEX ON vecs USING hnsw (embedding vector_cosine_ops)
WHERE tenant = 'tenant_a';
租户固定且不多时好用,租户动态增长就不现实。
DeepFlux 走的是 RLS + 每租户独立 namespace 的路子,本质上是方案 ①。
七、优化器会自己放弃 HNSW,这是对的
过滤条件极窄时(比如按主键范围),情况反过来:
SELECT id FROM vecs WHERE id BETWEEN 1 AND 10
ORDER BY embedding <=> '[...]' LIMIT 5;
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 万行后再测:
=== 查询向量是子查询 ===
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 万行随机向量,第一版这么写:
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,以为能强制逐行求值:
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) 数实际有几个不同的向量:
写法 | 不同向量数
----------------------+------------
① 不相关子查询 | 1
② LATERAL 不引用外层 | 1
③ 子查询引用 g | 3
(3 行数据的测试,正确结果应该是 3)
根因:子查询里没有引用外层的 g,PostgreSQL 判定它与外层无关,只求值一次然后复用结果。LATERAL 只是允许引用外层,不引用的话照样退化成不相关子查询。
正确写法是让子查询真正依赖外层:
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 的依赖。
修正后数据才对:
行数 | 不同向量数 | 表大小
-------+------------+--------
50000 | 50000 | 29 MB
=== 余弦距离分布 ===
最小 | 平均 | 最大
--------+--------+--------
0.0000 | 0.9992 | 1.3613
平均距离 0.9992 —— 高维随机向量近似正交,符合预期。(顺带一提,分量要取 random() - 0.5 而不是 random()。全正分量的向量都挤在第一象限,两两余弦距离只有 0.25 左右,区分度太差。)
造完数据先跑两句体检:
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:
// 存: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维…]以便阅读)。实验用的是临时 schemae72demo,跑完已DROP SCHEMA CASCADE,未触碰任何业务表。第九节的 Go 代码是骨架示意,完整实现在第 73 篇。