【后端/agent开发】pgvector 语义检索踩坑:为什么加了 ORDER BY,结果反而查不到数据?
文章目录
🔥 个人主页:铁皮哥(欢迎关注)
📌 作者简介:28届校招生,后端开发/Agent 方向在学
📚 学习内容:Java、Python、计算机视觉、大语言模型、Agent开发
📝 专栏内容:从零开始的Claude Code零代码生活(持续更新中)
✨不只背八股,更想搞懂为什么这样设计
前言
最近在做一个 agent 项目,里面有一块是知识库语义检索,底层用的是pgvector。结果我碰到了一个非常反直觉的问题:
数据库里明明有数据,SQL 里也没加额外过滤条件,只是多了一个 ORDER BY distance LIMIT k,查询结果居然直接变成空数组了。
更离谱的是,这个问题还不是稳定复现的。
同样一套代码:
query = “重启” ,能查出来
query = “你好,怎么用知识库检索?” ,查不出来,直接返回 []
当时我也从很多方面进行了调试,最终发现,真正的问题,根本不在 SQL 本身,而在 pgvector 的近似检索机制。
如果你也在做 RAG、Agent、知识库问答,或者最近正好在用 pgvector,这个坑非常值得提前知道。
1 现象描述
我在最开始执行的 SQL 语句大概是这样:
SELECT
c.document_id AS documentId,
c.id AS chunkId,
c.content AS content,
d.title AS title,
d.url AS url,
(c.embedding <=> #{embedding}) AS distance
FROM kb_chunk c
LEFT JOIN kb_document d ON c.document_id = d.id
WHERE c.embedding IS NOT NULL
ORDER BY distance
LIMIT #{topK}
这段 SQL 就是标准的向量检索写法之一,根据传入的 query 向量,去知识库里找最相似的 TopK 条记录返回。可问题是,它查不出东西。
但如果我把 ORDER BY distance 去掉,改成下面这样:
SELECT
c.document_id AS documentId,
c.id AS chunkId,
c.content AS content,
d.title AS title,
d.url AS url,
(c.embedding <=> #{embedding}) AS distance
FROM kb_chunk c
LEFT JOIN kb_document d ON c.document_id = d.id
WHERE c.embedding IS NOT NULL
LIMIT #{topK}
居然就能查到数据了!
注意,这里的“能查到”不代表它真的符合需求。因为一旦没有 ORDER BY,其实就已经不再是按相似度取 TopK 了,而只是从表里拿几条数据出来,但他至少说明一个事实:
表里不是没数据,embedding 也不是完全不可用。问题只在“按距离排序”这一步
2 问题排查
遇到这种问题,第一反应通常不会想到索引机制,而是先怀疑数据本身。我最先做了 3 组确认。
(1) 确认表里真的有 embedding
先看总数据量,再看 embedding 非空数量:
select count(*) from kb_chunk; -- 2
select count(*) from kb_chunk where embedding is not null; -- 2
结果说明很清楚,表里的确有数据,embedding 也确实写进去了,不是因为存储为空导致的查不出来。
(2) 确认向量维度一致
向量检索里,维度不一致是很典型的问题。
比如库里是 1024 维,query 却是 768 维,那距离计算一定会出问题。
我查了表里的 embedding 维度
SELECT vector_dims(embedding) AS dims, COUNT(*)
FROM kb_chunk
WHERE embedding IS NOT NULL
GROUP BY dims;
结果是 dims = 1024。同时我在 服务端 里打印 query embedding 的长度,发现也是 1024。
所以,“维度不一致”这个方向也排除了。
(3) 确认 query 向量里没有 NaN / Infinity
有些 embedding 服务如果输出异常,可能会产生 NaN 或 Infinity。这种情况下,排序和距离计算会变得非常诡异。
我又在服务端加了一段调试代码:
var list = queryEmbedding.vectorAsList();
long badCount = list.stream().filter(v -> {
double d = ((Number)v).doubleValue();
return Double.isNaN(d) || Double.isInfinite(d);
}).count();
log.debug("query='{}' dim={}, badCount={}", query, list.size(), badCount);
结果也没发现异常值,也就是说,比较直觉的三个问题都被排除过了,那就很奇怪了。
3 找到真正问题:索引行为变了
排除完数据问题后,我开始往另一个方向想:
会不会不是 SQL 算错了,而是数据库为了加速 TopK 检索,走了某条“近似检索”的路径,而这条路径刚好漏掉了我的数据?
于是我去查了表上的索引定义:
SELECT indexname, indexdef
FROM pg_indexes
WHERE tablename = 'kb_chunk';
结果查到了这条索引:
CREATE INDEX idx_kb_chunk_embedding_ivfflat
ON public.kb_chunk USING ivfflat (embedding vector_cosine_ops)
WITH (lists='100')
看到这里,问题基本就清楚了。根因不是 SQL 写错,也不是 pgvector 坏了,而是因为用了 ivfflat 向量索引,lists 配得太大,而数据量又太少,导致查询时出现了“空桶”问题。
4 ivfflat 的行为讲解:它到底是什么,又是怎么工作的?
很多人第一次接触 pgvector 时,都会下意识地以为向量检索就是一件很直接的事:
把 query 向量和表里的每条向量都算一下距离, 然后按距离从小到大排序,取前 TopK 条返回。
从逻辑结果上看,这样理解没问题。但如果底层用了 ivfflat 索引,数据库实际做的事情就不是“老老实实把所有数据都算一遍”,而是走了一套近似检索流程。
4.1 ivfflat 是什么?
ivfflat 是一种基于分桶的近似最近邻检索方法,它在极大数据场景下很有用:
如果表里有几百万、几千万条向量,那么每次查询都和所有向量逐个计算距离,再排序取
TopK,代价会非常高。所以数据库需要一种更快的方法,去避免每次都全表扫描。
ivfflat 的做法是先把向量分组,查询时只去最可能相关的那几组里找。这也是为什么它叫“近似检索”:
- 它更快
- 但它不保证每次都找到全局最优结果
- 有时候会漏掉本该命中的数据
所以从本质上说,ivfflat 是一种用召回率换性能的方案。
4.2 lists 和 probes
再来看以下 SQL 语句:
-- lists:创建索引时指定,控制建索引时会把向量分成多少个桶
CREATE INDEX idx_kb_chunk_embedding_ivfflat
ON kb_chunk
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
BEGIN;
-- probes:查询时指定,控制本次查询会扫描多少个桶
SET LOCAL ivfflat.probes = 10;
SELECT id, content, embedding <=> '[0.1,0.2,0.3]' AS distance
FROM kb_chunk
ORDER BY embedding <=> '[0.1,0.2,0.3]'
LIMIT 5;
COMMIT;
创建索引之初,会指定 lists 的大小,上面 SQL 中就指定了 lists = 100,它意味着会把所有向量分到 100 个桶里,每条向量会被归入某个最接近的桶。
lists 越大,说明分桶越细,则每个桶里的数据更少,查询时局部范围更小,理论上就更容易加速。
但副作用也很明显,桶会变得更稀疏,数据量小时容易出现大量空桶,如果 query 没落到正确桶附近,就更容易漏召回。
查询时,会指定 probes 的大小,上述 SQL 指定了 probes 的大小为 10。当一个 query 向量进来之后,只会去桶中心距离 query 最近的前 10 个桶中找目标。
probes 越大,说明查询时愿意看的桶越多,则候选集更全面,召回率更高,但是查询也更慢。
4.3 为什么小数据场景下会出现问题
回过头来说为什么小数据场景下会出错,就比如我那个demo场景,我只放了两条测试数据,但是 lists 却被指定为了 100, 那就意味着 100 个桶里最多只有 2 个桶有数据,剩下 98 个桶都是空的。
这时候如果 query 来了,数据库先去找“最接近的几个桶”,而这些桶刚好是空桶,那么即使表里有数据,最终候选集也可能是空。
那么为什么有时候能查到,有时候又查不到?
因为 query 向量每次的落点不同。某些 query 的向量,刚好靠近那些有数据的桶,那么即使只探测很少的桶,也能把候选集找到。
而另一些 query 的向量,可能先落在空桶附近,如果 probes 又比较小,那最后就可能什么都查不到。
query = “重启” ,能查出来
query = “你好,怎么用知识库检索?” ,查不出来,直接返回 []
上述例子中,query = “重启”就恰好落在了有数据的桶附近,而 query = “你好,怎么用知识库检索?” 则不幸运地落在了空桶附近。
5 误解:为什么去掉 ORDER BY,结果就“正常”了?
其实问题并没有被修好,只是问题被绕过去了。
因为一旦 SQL 中没有:
ORDER BY (embedding <=> query_vector) LIMIT k
数据库就不再需要做“全局最相似的前 k 条”这件事。
它也就没有必要优先走 ivfflat 的近似 TopK 检索路径。此时查询可能退化成普通取数,所以只要表里有数据,LIMIT 基本就能拿到结果。
6 实战避坑建议
- 数据量很小的时候,不要把 lists 设很大
- 如果必须用 ivfflat的话,让 probes 足够大(至少不远小于 lists)
- 或者直接把 lists 设小(例如 1~10)
写在文后
期待您的一键三连!如果有什么问题或建议欢迎在评论区交流!
更多推荐

所有评论(0)