2026.08.09

jsonb 换成 pgvector:一个 LIMIT 1000 引发的静默故障

向量检索一直「能跑」,直到文档数量越过某个阈值。这篇记录怎么定位到这个静默故障,以及从 jsonb 迁移到 pgvector 的完整过程。

约 3 分钟

工程里最难受的一类 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 / v31024是
v2 / v11536否(传了也忽略)

危险的地方在于:同维度但不同模型的两个向量空间几乎是正交的。实测 v4 和 v3 之间余弦相似度只有 0.016,而两段毫不相关的文本之间的余弦相似度大约在 0.46。

也就是说,"混源"比"维度不符"隐蔽得多——维度不符数据库会直接报错,而混源只会让检索质量莫名其妙地崩掉,没有任何报错。

所以我把 vector(1024) 的维度写进 schema 当作契约:模型换版本,要么重跑全量向量化,要么就别换。生产库做过一次审计,90/90 全部同源,干净。

结果#

  • 检索覆盖率:修复后 1033 chunk 全部可检索(修复前该区间覆盖率为 0)
  • 出库数据量:20MB → 0(相似度计算下推到库内)
  • 排序:库内余弦召回 Top-20,再交给精排模型

事后总结,这个故障真正教会我的是:"能跑"和"正确"之间可能隔着 1033 条数据。任何带 LIMIT 的查询,都值得问一句——这个 LIMIT 是在正确的语义下生效的吗?