2026.08.09
jsonb 换成 pgvector:一个 LIMIT 1000 引发的静默故障
向量检索一直「能跑」,直到文档数量越过某个阈值。这篇记录怎么定位到这个静默故障,以及从 jsonb 迁移到 pgvector 的完整过程。
工程里最难受的一类 bug 是静默故障:不报错、不告警、不崩,只是结果悄悄变错。
我们知识库的向量检索就是这种情况。
症状:后传的文档检索不出来#
用户反馈很模糊——"有些资料搜不到"。复现时又时好时坏,因为它是按上传顺序表现的:先传的文档能检索到,后传的检索不到。
查下去发现是一条 SQL:
SELECT id, content, 1 - (embedding <=> $1) AS score
FROM chunks
ORDER BY embedding <=> $1
LIMIT 1000;看着没问题。问题在 LIMIT 1000 的语义:它先取满足条件的前 1000 行,再排序。
而在当时的实现里,embedding 存在 jsonb 字段里,相似度计算是在应用层做的——数据库这一层根本没有真正的"向量距离"可以排序。于是 SQL 里那个 ORDER BY embedding <=> $1 实际上退化成了一个全表扫描加内存排序,LIMIT 1000 于是变成了按物理顺序截取前 1000 行。
实测确认:第 1033 个 chunk 之后,检索覆盖率是 0。
不是"效果变差",是完全检索不到。而且因为不报错,监控上也看不出来。
为什么 jsonb 撑不住#
jsonb 存数组当然可以:
-- 迁移前
embedding jsonb NOT NULL -- 形如 [0.021, -0.14, ...],1024 个浮点数它的致命问题不是"慢",而是数据库无法在它上面做排序。没有距离运算符,就没法把"取 Top-K 最相似"下推到存储层,只能在应用层把整张表捞出来算。
检索出库 20MB → 0 这个数字说的就是这件事:迁移前每次检索要把全部向量拉进 Node 进程;迁移后数据库直接返回 Top-K。
pgvector 迁移#
pgvector 提供 vector 类型和距离运算符,排序可以下推:
-- 迁移后
embedding vector(1024) NOT NULL迁移分四步,每一步都可以独立验证:
1. 加新列,不动老列
ALTER TABLE chunks ADD COLUMN embedding_v vector(1024);先并存,保证出问题能回退。
2. 回填 + 校验条数
UPDATE chunks
SET embedding_v = (embedding::text)::vector
WHERE embedding IS NOT NULL;
SELECT
count(*) FILTER (WHERE embedding IS NOT NULL) AS src,
count(*) FILTER (WHERE embedding_v IS NOT NULL) AS dst
FROM chunks;3. 建索引
-- HNSW:查询快,构建慢,适合读多写少的场景
CREATE INDEX CONCURRENTLY chunks_embedding_hnsw
ON chunks USING hnsw (embedding_v vector_cosine_ops);CONCURRENTLY 是为了不锁表。生产库上跑这条要单独一个事务,不能和其他 DDL 混在一起。
4. 切读路径,保留老列一个版本周期再删
维度这件事比想象中重要#
迁移里踩到的一个坑值得单独说:向量维度必须和模型严格对应。
实测结果:
| 模型 | 维度 | 是否响应 dimensions 参数 |
|---|---|---|
| v4 / v3 | 1024 | 是 |
| v2 / v1 | 1536 | 否(传了也忽略) |
危险的地方在于:同维度但不同模型的两个向量空间几乎是正交的。实测 v4 和 v3 之间余弦相似度只有 0.016,而两段毫不相关的文本之间的余弦相似度大约在 0.46。
也就是说,"混源"比"维度不符"隐蔽得多——维度不符数据库会直接报错,而混源只会让检索质量莫名其妙地崩掉,没有任何报错。
所以我把 vector(1024) 的维度写进 schema 当作契约:模型换版本,要么重跑全量向量化,要么就别换。生产库做过一次审计,90/90 全部同源,干净。
结果#
- 检索覆盖率:修复后 1033 chunk 全部可检索(修复前该区间覆盖率为 0)
- 出库数据量:20MB → 0(相似度计算下推到库内)
- 排序:库内余弦召回 Top-20,再交给精排模型
事后总结,这个故障真正教会我的是:"能跑"和"正确"之间可能隔着 1033 条数据。任何带 LIMIT 的查询,都值得问一句——这个 LIMIT 是在正确的语义下生效的吗?