创建日期:2026-09-24 | 最近更新:2026-09-24 证据分级(本文严格遵守):
- 【官方原文】=pgvector 官方 README / LangChain.js 包内源码的逐字片段(版本与抓取方式见
source/pgvector-rag-lab/official-sources.md);- 【项目源码】=
agent/rag_a项目里的逐字片段(私有教学仓库,文件路径已注明;该仓库版权头声明不可开源,故只引论证所必需的最小片段);- **【本人实测】**=本机容器的真实输出(脚本
source/pgvector-rag-lab/run.sh,输出run.log);- 其余为本文推断,逐处标注。 ⚠️ 实测用的是确定性 stub 向量(本地生成、不调外部 API),只保留"同主题更近"这个结构。 凡涉及召回质量、距离分布、阈值取值的数字都不可外推;可外推的是 SQL、规划器行为、报错与机制。 前置:本文默认你已读过 pgvector 入门(三种距离算子、opclass 匹配、IVFFlat/HNSW 参数、量化)。
pgvector 实战:给一条真实的 RAG 链路补上向量索引
一句话:"裸
vector列"看起来最灵活,代价是你在这列上根本建不出向量索引;就算绕过去建了表达式索引,库自己生成的查询也用不上它。 这篇解决的是**"索引该建在哪、怎么建、建完凭什么被规划器用上"**,不是"pgvector 是什么"。
0. 先给结论
- 列写成裸
vector(不带维度)→ 普通 HNSW/IVFFlat 索引直接建不出来:ERROR: column does not have dimensions。这不是配置问题,是扩展建索引时要读列的 typmod。 - 官方对裸列的出路是「表达式索引 + 查询里重复同一个 cast」。而 LangChain.js 的
createHnswIndex()只做了前半句(建表达式索引),它自己生成的查询里没有那个 cast —— 所以这条索引它自己用不上。【本人实测】 - 真正可用的路只有一条:
ALTER TABLE ... ALTER COLUMN vector TYPE vector(N)(一次全表重写)→ 再建普通索引。实测 20000 行 × 1024 维重写耗时 14.8 s。 - 索引建好了规划器也未必选它:HNSW 的估算代价随
m与hnsw.ef_search增长。同一条查询,ef_search=40(默认)时 HNSW 起价 2341 大于精确路径的 1050(不选);ef_search=100时降到 840(选中)。这跟 入门篇 讲的 opclass 不匹配是两回事。 - 窄过滤会静默少结果:
WHERE metadata->>'kbId' = ...这类过滤是在索引扫描之后才做的。受控实测:过滤只命中约 1% 时,LIMIT 10只拿回 2 条(不报错);SET hnsw.iterative_scan = relaxed_order后恢复 10 条。
1. 实战对象:rag_a 的这条链路
参照物是 agent/rag_a(NestJS + LangChain.js 的 RAG 教学 MVP,复刻类火山方舟知识库的核心链路)。仓库在私有 GitLab:agent/rag_a,站外读者拿不到,所以关键片段本文逐字贴出。
1.1 表:rag_chunks
【项目源码】apps/api/src/storage/postgres-schema.ts(只摘该表):
CREATE TABLE IF NOT EXISTS rag_chunks (
id text PRIMARY KEY,
content text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
vector vector,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS rag_chunks_kb_idx
ON rag_chunks ((metadata ->> 'kbId'));
CREATE INDEX IF NOT EXISTS rag_chunks_document_idx
ON rag_chunks ((metadata ->> 'documentId'));
CREATE INDEX IF NOT EXISTS rag_chunks_metadata_idx
ON rag_chunks USING gin (metadata);
三件事一眼看完:向量列是裸 vector(没有维度);业务字段全塞在 metadata 里;三条索引全是 metadata 的,没有一条是向量的。
1.2 写入路径:表其实是 LangChain 建的
【项目源码】apps/api/src/ai-engine/rag-engine.service.ts 里 PGVectorStore.initialize() 的调用没有传 dimensions,distanceStrategy 是 cosine,表名 rag_chunks,列名一一对应。
这决定了列长什么样。【官方原文】@langchain/community@1.1.29 的 ensureTableInDatabase():
const vectorColumnType = dimensions ? `${extensionName}(${dimensions})` : extensionName;
const tableQuery = `
CREATE TABLE IF NOT EXISTS ${this.computedTableName} (
"${this.idColumnName}" uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
"${this.contentColumnName}" text,
"${this.metadataColumnName}" jsonb,
"${this.vectorColumnName}" ${vectorColumnType}
);
`;
【本人实测】本机跑出来就这两条 SQL:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS rag_chunks ( "id" uuid NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, "content" text, "metadata" jsonb, "vector" vector );
两个细节值得记住:① 不传
dimensions,列就是裸vector(包自己的注释也提醒了:"If not set, indexes like HNSW might not be used during query time"); ② 它是CREATE TABLE IF NOT EXISTS—— 所以表已经存在时,事后补传dimensions什么也改不了,这一点后面 §10 要用到。 另外项目自己先建了id text PRIMARY KEY,LangChain 那条CREATE TABLE就变成空操作;否则它会按uuid建,而项目写入的 id 是${documentId}-0这种字符串,是插不进去的。
1.3 检索路径:过滤条件写在 WHERE 里
【项目源码】检索走 store.similaritySearchVectorWithScore(vector, topK, { kbId })。【官方原文】searchPostgres() 拼出来的 SQL:
const queryString = `
SELECT *, "${this.vectorColumnName}" ${this.computedOperatorString} $1 as "_distance"
FROM ${this.computedTableName}
${whereClause}
ORDER BY "_distance" ASC
LIMIT $2;
`;
【本人实测】{ kbId } 这个过滤对象被拼成 metadata->>'kbId' = $3(key 是字面量、value 走参数),于是实际发出去的是:
SELECT *, "vector" <=> $1 as "_distance"
FROM rag_chunks
WHERE metadata->>'kbId' = $3
ORDER BY "_distance" ASC
LIMIT $2;
这就是全部要点:ORDER BY 用距离算子(对)、升序(对)、LIMIT 走参数(对),但过滤条件也在 WHERE 里 —— pgvector 的近似索引是先按距离吐候选、再拿 WHERE 去筛(后过滤)。这条链路的性格就是这么定下来的。
1.4 顺带一提:这里是两套连接池
【项目源码】storage.module.ts 建了一个 new Pool(...) 给仓储用;PGVectorStore 内部又是 this.pool = config.pool ?? new pg.default.Pool(config.postgresConnectionOptions) —— 项目没传 pool,所以向量库那边自己又建了一个池。
【本人实测】这意味着两件事:连接数是两倍;以及在 psql 里 SET 的会话级参数对应用无效(§9 展开)。
2. 第 0 步:先证明"现在没有向量索引"
不要凭印象,先看表。
【本人实测】pg_indexes:
rag_chunks_document_idx | CREATE INDEX rag_chunks_document_idx ON public.rag_chunks USING btree (((metadata ->> 'documentId'::text)))
rag_chunks_kb_idx | CREATE INDEX rag_chunks_kb_idx ON public.rag_chunks USING btree (((metadata ->> 'kbId'::text)))
rag_chunks_metadata_idx | CREATE INDEX rag_chunks_metadata_idx ON public.rag_chunks USING gin (metadata)
rag_chunks_pkey | CREATE UNIQUE INDEX rag_chunks_pkey ON public.rag_chunks USING btree (id)
向量列的 typmod 是 -1(没有维度):
column | 类型 | typmod
------------+----------+--------
id | text | -1
content | text | -1
metadata | jsonb | -1
vector | vector | -1
再跑一次真实检索,看规划器怎么答(数据:20000 行 × 1024 维,kb 分布 60% / 30% / 9% / 1%):
Limit (cost=1033.60..1033.63 rows=10 width=299)
-> Sort (cost=1033.60..1063.60 rows=12000 width=299)
Sort Method: top-N heapsort Memory: 33kB
-> Index Scan using rag_chunks_kb_idx on rag_chunks (cost=0.29..774.29 rows=12000 width=299)
Index Cond: ((metadata ->> 'kbId'::text) = 'kb_big'::text)
Execution Time: 50.593 ms
注意它选的不是顺序扫描,而是 rag_chunks_kb_idx:因为项目恰好给 kbId 建了 btree 表达式索引,规划器就用它先把 12000 行捞出来、再逐行算余弦距离、再排序。结果完全精确(要 10 条就给你 10 条,每个 kb 都是)。
这条很关键,它决定了后面的剧情:"没有向量索引"不等于"慢得没法用",而是"慢,但准"。 没有过滤时(
WHERE true)才会退化成Seq Scan + top-N heapsort(估算代价 1514,是最贵的一条路)。
接着试建索引,就撞上本文最硬的一条结论。
3. ★ 第 1 步:裸列上根本建不出普通向量索引
【本人实测】就按官方 README 的写法建:
CREATE INDEX rag_chunks_hnsw_bare ON rag_chunks USING hnsw (vector vector_cosine_ops);
ERROR: column does not have dimensions
【官方原文】pgvector README 在「同一个列里能不能放不同维度的向量」里给的是另一种写法:
CREATE INDEX ON embeddings USING hnsw ((embedding::vector(3)) vector_l2_ops) WHERE (model_id = 123);
SELECT * FROM embeddings WHERE model_id = 123 ORDER BY embedding::vector(3) <-> '[3,1,2]' LIMIT 5;
官方的措辞是 "you can only create indexes on rows with the same number of dimensions (using expression and partial indexing)", 并且紧接着就写了查询必须重复同一个 cast。这两句是配套的:索引建在
embedding::vector(3)这个表达式上,查询里也得出现同一个表达式,规划器才认得。
所以裸列不是"少写一个维度"这么简单,它是"你放弃了普通索引这条路"。
4. 第 2 步:表达式索引能建,但库自己用不上
【官方原文】LangChain.js 的 createHnswIndex() 正是照官方那个表达式写法来的:
const createIndexQuery = `CREATE INDEX IF NOT EXISTS ${prefix}${this.vectorColumnName}_embedding_hnsw_idx
ON ${this.computedTableName} USING hnsw ((${this.vectorColumnName}::vector(${config.dimensions})) ${idxDistanceFunction})
WITH (m=${config?.m || 16}, ef_construction=${config?.efConstruction || 64});`;
try {
await this.pool.query(createIndexQuery);
} catch (e) {
console.error(`Failed to create HNSW index on table ${this.computedTableName}, error: ${e}`);
}
【本人实测】照抄执行,建成功了:
vector_embedding_hnsw_idx | CREATE INDEX vector_embedding_hnsw_idx ON public.rag_chunks USING hnsw (((vector)::vector(1024)) vector_cosine_ops) WITH (m='16', ef_construction='64')
然后拿库自己生成的那条查询再跑一遍 —— 这条索引压根没被当作候选(走的是 kbId 的精确路径):
Limit (cost=1050.60..1050.63 rows=10)
-> Sort (cost=1050.60..1080.60 rows=12000)
-> Index Scan using rag_chunks_kb_idx on rag_chunks (cost=0.29..791.29 rows=12000)
这里要小心别把两件事混成一件:**"索引表达式不匹配"和"另一条路更便宜"**都会表现为"没走索引"。为了把前者单独拎出来,做一组受控对照 —— 把精确路径全部关掉(enable_sort / enable_seqscan / enable_bitmapscan = off,官方 Troubleshooting 推荐的调试手法),只留"有序索引路径"可走,这时差异只可能来自索引表达式本身:
【本人实测,run.log 第 ⑤ 步】
负对照:裸列 vector <=> $1(库自己生成的就是这条)
→ 选中 Index Scan using rag_chunks_kb_idx(成本 0.29..774.29,返回 12000 行) ← 用不上那棵 HNSW
正对照:显式 vector::vector(1024) <=> $1(官方 README 的写法)
→ 选中 Index Scan using vector_embedding_hnsw_idx(成本 2341.69..83730,返回 10 行) ← 用上了
两条查询唯一的差别就是排序表达式里有没有那个 cast。结论:表达式索引的匹配是表达式树级别的,(vector)::vector(1024) 与裸 vector 不是同一个表达式,所以哪怕列上真有条 HNSW 索引,库自己生成的那条 SQL 也进不了候选。
createHnswIndex() 只在"你也自己写查询"时才有意义;对 PGVectorStore 自己的检索方法,它等于白建。
⚠️ 别把这条结论读成"按官方写法加个 cast 就能起飞":上面正对照里那棵索引的估算代价是 2341.69,比精确路径的 1050.60 还贵 —— 所以在这个规模下,即便你按官方写法加上了 cast,规划器照样会选精确路径。"能不能匹配"和"值不值得选"是两件事,后者见 §6。
⚠️ 顺带一个副作用:这条表达式索引一旦存在,写入侧就开始被隐式校验维度了 —— 因为插入时要先把索引值算出来。实测往列里写一个 768 维向量:
ERROR: expected 1024 dimensions, not 768。索引不存在时同样的写入是成功的(裸列没人校验)。
5. 第 3 步:ALTER 补维度 + 建普通索引
回到唯一可行的路:【本人实测】
SET maintenance_work_mem = '512MB';
ALTER TABLE rag_chunks ALTER COLUMN vector TYPE vector(1024);
代价一点都不小:
| 观测项 | 实测值 |
|---|---|
| ALTER 耗时(20000 行 × 1024 维) | 14.8 s |
relfilenode | 16713 → 36738(变了 = 表被整表重写) |
| 表 + 索引体积 | 275 MB → 271 MB |
| 随后建 HNSW 索引耗时 | 13.6 s(m=16, ef_construction=64) |
| HNSW 索引体积 | 156 MB |
关于耗时:它会随机器负载漂移。同一个 ALTER,我在负载更重的时候测到过 33.3 s(两次相差 2 倍多)。 所以请只把这些数字当量级看 —— 稳定的是"表确实被重写"这件事,而
relfilenode变了就是它的判据。
为什么 ALTER 会重写全表:这里的
vector → vector(1024)是一次 typmod 强制转换,走的是扩展注册的转换函数,不是二进制兼容的重标记。生产上这意味着 ACCESS EXCLUSIVE 锁 + 一次停机窗口,别在业务高峰做;大表更稳的做法是"加新列 → 分批回填 → 建索引 → 小窗口改名"。 幂等化:先在pg_attribute.atttypmod上判断,已经是 1024 就跳过,避免每次启动都去拿锁。maintenance_work_mem一定要调大:默认 64MB 时构建过程中会打NOTICE: hnsw graph no longer fits into maintenance_work_mem after N tuples(入门篇 §4 也踩过同一个)。建索引进度可以查pg_stat_progress_create_index。
6. ★ 第 4 步:索引建好了,规划器凭什么选它
这是本篇最容易被误判的一步:表上已经有普通 HNSW 索引了,同一条查询还是不走它。
【本人实测】hnsw.ef_search 用默认值 40,那条 rag_a 原样的 SQL:
Limit (cost=1050.60..1050.63 rows=10 width=299)
-> Sort (cost=1050.60..1080.60 rows=12000 width=299)
-> Index Scan using rag_chunks_kb_idx on rag_chunks (cost=0.29..791.29 rows=12000 width=313)
把 hnsw.ef_search 调到 100,同一条 SQL:
Limit (cost=840.77..909.91 rows=10 width=299)
-> Index Scan using rag_chunks_hnsw on rag_chunks (cost=840.77..83812.00 rows=12000 width=299)
Order By: (vector <=> '[…]'::vector)
Filter: ((metadata ->> 'kbId'::text) = 'kb_big'::text)
Rows Removed by Filter: 1
Execution Time: 1.429 ms
规划器换路了:HNSW 的起价从 2341 降到 840,低于精确路径的 1050。
为什么 ef_search 会影响估算代价?【官方原文】pgvector 0.8.6 的 src/hnsw.c:
/*
* 用到的公式:
* numIndexTuples = entryLevel * m + layer0TuplesMax * layer0Selectivity
*/
if (path->indexinfo->tuples > 0)
{
double scalingFactor = 0.55;
int entryLevel = (int) (log(path->indexinfo->tuples) * HnswGetMl(m));
int layer0TuplesMax = HnswGetLayerM(m, 0) * hnsw_ef_search;
double layer0Selectivity = scalingFactor * log(path->indexinfo->tuples) / (log(m) * (1 + log(hnsw_ef_search)));
ratio = (entryLevel * m + layer0TuplesMax * layer0Selectivity) / path->indexinfo->tuples;
if (ratio > 1) ratio = 1;
}
...
costs.indexStartupCost = costs.indexTotalCost * ratio;
【本人实测,同一张表、同一条查询】HNSW 估算起价随 ef_search 变化:
hnsw.ef_search | HNSW 估算起价 | 对比:btree + Sort | 这个设置下的表现 |
|---|---|---|---|
| 1 | 444 | 1050 | 会走 HNSW,但 LIMIT 10 只返回 1 条 |
| 10 | 955 | 1050 | 走 HNSW |
| 40(默认) | 2341 | 1050 | btree + Sort(精确,慢) |
| 100 | 841 | 1050 | 走 HNSW(50.9 ms → 1.4 ms) |
| 1000 | 892 | 1050 | 走 HNSW |
表里的起价来自
run.log里的扫描:关掉精确路径,逼规划器把 HNSW 这条路的估算代价显示出来。 同一个扫描如果在"只有表达式索引"的阶段跑,会得到清一色的Seq Scan—— 因为查询根本匹配不到那条索引,这时候调ef_search是白调(这一步也留在了run.log里)。 表里那个非单调(40 比 100 还贵)来自公式里ratio的分母(1 + log(hnsw_ef_search)):它把"候选越大越慢"和"候选越大越省步数"两件事混在一起估。别把它当成一条平滑曲线;要紧的是:ef_search既决定召回,又通过代价估算决定规划器走不走索引。 另外注意"代价"本身是估算:同一条 btree + Sort 路径,我在两次实测里读到过 1033.60 与 1050.60 —— 因为ANALYZE是随机采样,估算值会小幅抖动。看趋势,别看小数点。
再看过滤条件的影响(同一批数据、ef_search=40):
| 过滤命中率 | 规划器选的路径 | Limit 估算代价 | 要 10 条实得 |
|---|---|---|---|
| 无过滤 | Seq Scan + Sort | 1514 | 10(精确) |
kb_big 60% | btree + Sort(精确) | 1050 | 10(精确) |
kb_small 9% | btree + Sort(精确) | 161 | 10(精确) |
kb_tiny 1% | btree + Sort(精确) | 24.6 | 10(精确) |
过滤越窄,精确路径越便宜。 1% 命中时 btree 只捞 200 行、排个序就完事,HNSW 无论怎么调都赢不了。所以:"后过滤饿肚子"这件事,在窄过滤下根本轮不到发生 —— 规划器替你选了又准又便宜的那条路。
那它到底快多少?【本人实测】同样一条查询,规划器改选 HNSW 之后:
-- ef_search = 40:btree + Sort(精确)
Execution Time: 50.888 ms
-- ef_search = 100:Index Scan using rag_chunks_hnsw
Execution Time: 1.429 ms
Filter: ((metadata ->> 'kbId'::text) = 'kb_big'::text)
Rows Removed by Filter: 1 ← 后过滤的直接证据:扫到但被过滤丢掉的行
50.9 ms → 1.4 ms,代价是结果从"精确"变成"近似"(Rows Removed by Filter 那一行就是过滤在索引之后才生效的痕迹)。这正是 入门篇 那句"加索引不是加速,而是换成另一种回答"的实例。
⚠️ 一个更基础的坑:统计信息没跟上,计划完全是另一回事
上面所有选路,都建立在优化器知道真实数据分布之上。而刚灌完数据的表,统计信息还是空的 —— 规划器只能拿默认估算去猜。
【本人实测】同一张表、同一条查询,只差一次 ANALYZE:
灌完数据立刻查(没有统计信息):
选中:Bitmap Heap Scan 该节点代价 5.75..454.55 实际返回 12000 行
ANALYZE 之后:
选中:Index Scan using rag_chunks_kb_idx 该节点代价 0.29..774.29 实际返回 12000 行
同一个查询、同一份数据,计划换了。 真实系统里 autovacuum 会把这件事做掉,但批量导入后立刻压测、或者刚建完索引就验收的场景,很容易撞上"优化器还在猜"的那段时间。排障时先 EXPLAIN 看估算行数和实际差多少,再看要不要手动 ANALYZE。
这条也是我自己写这份实测时踩的:第一版脚本没
ANALYZE,结果整轮实验里 HNSW 一次都没被选中,和结论对不上。 数据是真的、计划也是真的,但缺了统计信息这个前提——所以脚本里现在灌完数据、建完索引都会补一次ANALYZE。
那什么时候会真的饿?看下一节。
7. ★ 第 5 步:后过滤会静默少结果(受控实测)
【官方原文】README 的 Troubleshooting 里专门有一条:
#### Why are there less results for a query after adding an HNSW index?
Results are limited by the size of the dynamic candidate list (`hnsw.ef_search`), which is 40 by default. There may be even less results due to dead tuples or filtering conditions in the query. Enabling iterative index scans can help address this.
上面的实测说明了它什么时候才发生:过滤条件没有可用的精确索引(比如业务新加了一个维度),而表又大到规划器愿意选 HNSW 的时候。
【本人实测】给 1% 的行加一个新 key(模拟业务新增过滤维度,且没为它建索引),然后把顺序扫描关掉、逼它走 HNSW(这也是官方 Troubleshooting 自己推荐的调试手法 BEGIN; SET LOCAL enable_seqscan = off;):
| 场景 | 要 10 条,实得 |
|---|---|
hnsw.ef_search = 100,iterative_scan = off | 2 条(随仓库留档的那一轮)← 静默少给,且没有任何报错 |
同上,iterative_scan = relaxed_order | 10 条(三轮实测每轮都满) |
hnsw.ef_search = 1000,iterative_scan = off | 这一轮是 10 条 —— 但它是拿"每次查询都扫更多"换来的,不保证 |
表里那个"2 条"不是偶然:我前后跑了三轮,
ef_search=100+iterative_scan=off分别拿到 0、1、2 条(每轮TABLESAMPLE抽中的匹配行数不同),而relaxed_order每轮都是 10 条。 也就是说:ef_search是"用一个固定的候选预算去赌过滤条件",过滤越窄越赌不中;iterative_scan换了策略 —— 不够就继续往下扫,直到凑够或扫完。
三条处置(照 入门篇 §5.2 的顺序,这里补上"什么时候用哪条"):
-- ① 首选:开迭代索引扫描(pgvector 0.8+),拿满即止
SET hnsw.iterative_scan = relaxed_order; -- 或 strict_order(严格按距离序)
-- ② 给这个过滤维度建索引,让它走精确路径(本例最实在的解法)
CREATE INDEX ON rag_chunks ((metadata ->> 'lang'));
-- ③ 过滤种类少时,官方建议部分索引
CREATE INDEX ON rag_chunks USING hnsw (vector vector_cosine_ops) WHERE metadata->>'lang' = 'zh';
⚠️ 顺序很重要:
iterative_scan只在"真的走上 HNSW 路径"时才起作用。而 §6 测到,窄过滤时规划器往往直接选精确路径 —— 这时你不需要它,结果本来就是满的。 所以别把iterative_scan当成万能补丁:先EXPLAIN确认走的是哪条路,再决定调什么。
8. 第 6 步:查询期参数怎么落到应用里
这一步最容易"设了个寂寞",因为 rag_a 有两套连接池(§1.4),而且 hnsw.* 是懒注册的 GUC。
【本人实测】四种落地路径,一张表看完:
| 做法 | 结果 | 说明 |
|---|---|---|
在 psql 里 SET hnsw.ef_search = 200 | ✅ 对那个会话生效,对应用无效 | pool.query 随机取连接,你 SET 的那条未必是跑查询的那条 |
连接参数 options=-c hnsw.ef_search=150(或 PGOPTIONS) | ✅ 生效 | 会话建立时就注入;pg / libpq 都支持。非超级用户环境下唯一方便可行的路 |
ALTER DATABASE ... SET hnsw.ef_search = 200 | ✅ 超级用户可行;非超级用户会被直接拒绝 | 实测报错:ERROR: permission denied to set parameter "hnsw.ef_search"(该角色已是库 owner 也一样);同一角色设置有内置参数(如 statement_timeout)则允许 |
每次查询前 SET LOCAL | ✅ 但要在同一条连接上 | 需要自己持有 client,或用连接池的 connect 事件 |
【本人实测】懒注册 GUC 的真实行为(这一点和直觉不同,值得单独记):
| 在新会话里先做的事 | 之后让库加载(跑一次 hnsw 查询) | 结果 |
|---|---|---|
SET hnsw.ef_search = 100(合法值) | 加载 | 值被继承,SHOW 仍是 100 |
SET hnsw.ef_search = 5000(越界) | 加载 | WARNING: 5000 is outside the valid range for parameter "hnsw.ef_search" (1 .. 1000),回落到 40 |
SET hnsw.ef_searchs = 100(名字拼错) | 加载 | WARNING: invalid configuration parameter name "hnsw.ef_searchs", removing it,随后 SHOW 报 unrecognized configuration parameter |
⚠️ 更正入门篇的一处措辞:入门篇把这件事写成"
SET会落在占位符上被丢弃"。实测的准确说法是: 合法值会被继承生效;越界值和拼错的名字才会在库加载时被丢弃(并打 WARNING)。 这个差别有实际意义 —— 连接参数里写options=-c hnsw.ef_search=150是可用的;但如果你把参数名写错,它不会立刻报错,要等到某个会话真的用到 hnsw 索引才会被悄悄移除。
9. 第 7 步:把索引接回项目(三选一)
到这里就能给 rag_a 开方子了。三条路,按推荐顺序:
| 方案 | 做法 | 代价 / 风险 |
|---|---|---|
| ① 改列 + 普通索引(推荐) | ALTER COLUMN vector TYPE vector(1024),再建 USING hnsw (vector vector_cosine_ops) | 一次全表重写(实测 14.8 s / 20000 行);之后库自己生成的 SQL 直接就能走索引,代码一行不用改 |
| ② 保持裸列 + 表达式索引 | 建 ((vector::vector(1024)) vector_cosine_ops) | 对 PGVectorStore 的检索方法无效(§4 实测);只有你放弃它、自己写 SQL 才有意义 |
| ③ 自写检索 SQL | 不用 similaritySearchVectorWithScore,只借用它的写入 | 要自己维护 SQL、过滤、分数语义,但控制力最强 |
选 ① 的时候,把索引写进启动 schema 要小心两个坑(都是【本人实测】):
① 整份 schema 当一条 SQL 发出去 = 一个隐式事务
中间任何一条失败 → 前面建好的表一起回滚
(rag_a 现在是 `await this.pool.query(postgresSchemaSql)`,schema 里还有建表,
所以"镜像里没有 pgvector 扩展"这种问题会让业务表一张都建不出来)
② 同一个批次里不能放 CREATE INDEX CONCURRENTLY
ERROR: CREATE INDEX CONCURRENTLY cannot run inside a transaction block
官方 README 明确建议生产环境用 CIC("create indexes concurrently to avoid blocking writes"),
但 CIC 必须【单独一条语句、不在事务里】—— 它不能塞进那个 schema 批次。
所以生产上的写法应该是:schema 批次照旧(幂等建表)+ 一条独立的、可重试的索引迁移, 并且按 §5 的做法先在
atttypmod上判一下,避免每次启动都去拿 ACCESS EXCLUSIVE 锁。
10. ⚠️ 两个"看名字会误会"的地方
10.1 score 其实是距离,不是相似度
【项目源码】检索结果之后有一句 .filter((result) => result.score >= params.minScore),而 minScore 在 DTO 里是 min(0).max(1),前端把它当"相似度"渲染。
【官方原文】PGVectorStore 的默认值:
scoreNormalization = "distance";
...
convertDistanceToScore(distance) {
return this.scoreNormalization === "distance" ? distance : this.convertDistanceToSimilarity(distance);
}
【本人实测】所以 score 就是余弦距离(越小越像):
前 3 条 score(= 距离) = 0.429218, 0.432552, 0.442441
对应的相似度(1 - d/2) = 0.785391, 0.783724, 0.778779
minScore=0.00 → 留下 10 条
minScore=0.40 → 留下 10 条
minScore=0.50 → 留下 0 条(连最像的那条也被丢掉了)
把 minScore 调大,丢掉的是最相似的结果。 这个式子写的是 score >= minScore,而 score 越大越不相似。
【本文推断】修法二选一:要么在构造 PGVectorStore 时把 scoreNormalization 设成别的值让 score 变成相似度,要么把阈值语义改成 distance <= maxDistance(上限而不是下限)。
10.2 重复导入同一份文档
【官方原文】写入用的 INSERT 里没有 ON CONFLICT(buildInsertQuery() 只拼 INSERT INTO ... VALUES ...),而 id 由上层生成。
【项目源码】rag-engine.service.ts 里 chunk id 是 ${documentId}-${index}。
【本人实测】同一个 id 再插一次:
ERROR: Error inserting: duplicate key value violates unique constraint "rag_chunks_pkey"
值得留意的是它在哪一步炸:
addVectors按chunkSize=500分批发多条 INSERT,每批一条 autocommit 语句 —— 所以重导时前面几批已经落库、后面那批才报错,库里会留下半份数据。要做幂等,要么先按documentId删后插,要么让 id 变成内容的确定性哈希。
11. 生产清单
□ 别用裸 vector 列:除非你确实要混存不同维度(那就要接受"没有普通索引")
□ 建索引前:确认列的 typmod 有维度(SELECT atttypmod FROM pg_attribute),否则报 column does not have dimensions
□ opclass 要对齐查询算子(cosine↔vector_cosine_ops)——见入门篇 §5.1
□ 建索引时:调大 maintenance_work_mem;生产用 CREATE INDEX CONCURRENTLY(单独一条语句、不能进事务)
□ ALTER 补维度:会重写全表 + 重建索引,选低峰;先判 atttypmod 保证幂等
□ 建完索引先 EXPLAIN:确认真的走 Index Scan using <向量索引>,而不是 btree/Seq Scan + Sort
□ 批量导入后 / 建完索引后 ANALYZE:统计信息没跟上时,规划器是在"猜",计划可能完全不同
□ 规划器不选索引时,依次查:opclass 是否匹配 → 索引表达式是否匹配(有无 cast)→ hnsw.ef_search 对估算代价的影响 → 过滤命中率是否太窄
□ 窄过滤:先 EXPLAIN 看走哪条路;真走 HNSW 才需要 hnsw.iterative_scan = relaxed_order
□ 查询期参数:别在 psql 里 SET 就以为生效了;用连接参数 options=-c,或 ALTER DATABASE(需超级用户)
□ 参数名别写错:拼错的 hnsw.* 要到库加载才被移除,全程不报错
□ score 的语义:默认是距离不是相似度,别把它当相似度设阈值
□ 导入幂等:addVectors 没有 ON CONFLICT,重复导入会留半份数据
□ 别把整份 schema(含建索引)当一条 SQL 发:批次是隐式事务,一条失败全回滚,且用不了 CIC
□ 体积:1024 维 × 20000 行 → 表主文件 6432 kB、TOAST 106 MB、HNSW 索引 156 MB
12. 本文的局限
- 全部是本机容器内实测(单机、无并发、无真实负载);耗时只说明机制与量级,不代表线上 —— 同一个
ALTER我在不同负载下测到 14.8 s 与 33.3 s(差 2 倍多),所以别引用绝对耗时,只引用"走了哪条路"和量级关系; - 向量是确定性 stub 生成的(只有"同词更近"的结构),所以:召回质量、距离分布、阈值取值这三类结论一概不可外推;可外推的是 SQL 文本、规划器选择、报错与机制;
- 表规模只有 20k 行。规划器选路是规模敏感的:20k 行时精确路径很便宜(§6 的
kb_tiny只要 24.6),所以"后过滤饿肚子"是被强制走索引才演示出来的;表越大、精确路径越贵,HNSW 越会被自然选中,这正是它变成线上问题的条件 —— 但这个拐点本文没有测; - §4 的负/正对照是关掉精确路径(
enable_sort/enable_seqscan/enable_bitmapscan = off)才比出来的,目的是把"索引表达式是否匹配"单独隔离出来;它不代表生产上会走索引(恰恰相反,§6 实测这个规模下不走); - 未测:真实 embedding 模型、并发 QPS 与 p99、
halfvec/二值量化索引、pgvectorscale、流复制、pgbouncer、CREATE INDEX CONCURRENTLY在大表上的实际锁表现; - 线上项目用的是 pg16 镜像,本文实验在 pg17 上做;规划器与 GUC 机制一致,但镜像 tag 滚动,
extversion请自行核对; rag_a是私有教学仓库且声明不可开源,本文只引用了论证所必需的最小片段;对它的问题描述是就代码与实测而言,不代表作者立场。
关联
- 前置(概念与通用调优):pgvector:为什么选它,怎么用
- 选型大图:向量数据库全景
- 另一条路(专用向量库):Milvus 入门、Milvus 企业级开发运维方案
- 检索融合的上层:多路检索与 RRF
- 同样的"索引取舍"思路:高性能 SQLite
- 配套实测与逐字来源:仓库内
source/pgvector-rag-lab/
自测
- 为什么在裸
vector列上建不出普通 HNSW 索引?报错原文是什么? - 官方对"同列混存不同维度"给的三步是什么?LangChain 的
createHnswIndex()做了哪半步? ALTER COLUMN vector TYPE vector(1024)的代价是什么?怎么判断它真的重写了表?- 表上已有普通 HNSW 索引,规划器却不选它 —— 至少说出两种原因,并说明怎么区分。
- HNSW 的估算代价为什么会随
hnsw.ef_search变化? - 什么条件下"后过滤会静默少结果"才会真的发生?
iterative_scan什么时候不需要? - 在应用里设
hnsw.ef_search,哪种做法真的生效?为什么在 psql 里 SET 没用? - 把
CREATE INDEX CONCURRENTLY写进"启动时执行整份 schema"的批次里,会发生什么?
参考
- 实测环境:PostgreSQL 17.11 + pgvector 0.8.6(容器
pgvector/pgvector:pg17);Node v24.19.0;@langchain/community1.1.29 /@langchain/core1.2.1 /pg8.16.3(版本对齐 rag_a 的package.json);数据 20000 行 × 1024 维 stub 向量 - 脚本与真实输出(随仓库留档):
source/pgvector-rag-lab/——seed.mjs(建表 + 灌数据 + 抓库生成的 DDL/INSERT)、search.mjs(检索路径与规划器矩阵)、batch-semantics.mjs(批次语义与 CIC)、run.sh(入口);跑法与局限见其中README.md - 逐字引文留档:
source/pgvector-rag-lab/official-sources.md(pgvector README v0.8.6 +hnsw.c代价公式)、official-langchain-js-pgvector.md(LangChain 包内源码)、official-langchain-pgvector-standalone-diff.md(独立包@langchain/pgvector@0.1.0的差异) - pgvector 官方仓库:github.com/pgvector/pgvector
- LangChain.js 的
PGVectorStore:github.com/langchain-ai/langchainjs - 引用项目:
agent/rag_a(gitlab.sumshare.cn/10022/agent/rag_a,私有仓库,妙码学院教学 MVP;本文只引最小片段) - 许可:pgvector PostgreSQL License;LangChain.js MIT;
@langchain/community已被官方 sunset(见留档 issue #61)