假设你正在给一个企业知识库接入向量检索。
文档已经切片,Embedding 已经生成,HNSW 索引也已经建立。不加条件时,搜索正常;但接入租户隔离之后,问题出现了:
明明当前租户有上千条文档,查询写着 LIMIT 10,结果却只有几条,有时甚至一条都没有。
第一反应可能是模型不够好、文档切片不合理,或者相似度阈值太高。但在 pgvector 中,还存在一种更直接的原因:近似向量索引产生的候选,在经过业务条件过滤后,可能已经不够用了。这也是 pgvector 引入迭代索引扫描所要解决的问题之一。
本文从一条带租户条件的 SQL 出发,拆解候选生成、过滤、迭代扫描与召回率之间的关系,并给出可以在实验库中执行的验证脚本。
一、同一条 SQL,背后可能是不同的检索路径
先看一条查询:
SELECT
id,
content,
embedding <=> '[0.2,0.4,0.8]'::vector AS distance
FROM rag_chunk_demo
WHERE tenant_id = 42
AND enabled = true
ORDER BY embedding <=> '[0.2,0.4,0.8]'::vector
LIMIT 10;
这里使用的是余弦距离运算符 <=>,距离越小,排序越靠前。要让对应的近似索引参与距离排序,查询的距离运算符需要与索引的操作符类匹配。
从业务角度,我们希望这条 SQL 表达:
在租户 42 的全部有效文档中,找到距离查询向量最近的 10 条。
但这个目标不能只靠观察 SQL 的书写顺序来判断是否实现。
一种执行路径是先筛出符合条件的记录,再对这些记录计算距离、排序。这可以得到该候选范围内的精确最近邻。
另一种路径是先扫描近似向量索引,再对它产生的候选执行租户和状态过滤。对于后一种路径,候选数量不足会直接影响最终返回数量。pgvector 的发布说明也明确指出:某些过滤查询使用普通索引而非 ANN 索引,可能在相近性能下获得完整召回。
因此,排查时需要区分三个问题:
| 问题 | 判断标准 |
|---|---|
| 数量是否足够 | 最终返回了多少条符合条件的记录 |
| 顺序是否正确 | 返回的记录是否按距离递增排列 |
| 最近邻是否找全 | 返回集合与精确检索结果有多大重合 |
这三个问题相关,但不等价。
返回 10 条,不代表找到了真正最近的 10 条;结果已经排序,也不代表没有遗漏更近的记录。
二、为什么加一个租户条件,结果就不够了?
1. 过滤条件可能在候选生成之后生效
对于共享的 HNSW 索引,可以先用下面这个简化模型理解过滤检索:
graph LR
A["查询向量"] --> B["HNSW 产生候选"]
B --> C["租户和状态过滤"]
C --> D["保留下来的候选"]
D --> E["返回结果"]
图中的重点不是数据库完全不认识 tenant_id,而是:当查询采用这种近似索引扫描路径时,业务过滤并没有把共享索引自动变成一个租户专属索引。候选生成之后,执行器仍可能丢弃不符合过滤条件的记录。
2. 用一个简化模型理解候选损耗
假设某轮检索产生了 100 个候选,而目标租户的数据在这些候选中大约占 1%。
那么过滤之后,期望只剩:
如果希望得到 10 个有效候选,在同样的简化假设下,候选规模可能需要达到:
这里的计算只是帮助理解数量关系,并不是调参公式。
它至少依赖两个前提:候选中的租户分布接近这个比例,而且租户标签与向量距离没有很强的相关性。
真实数据未必满足这些前提。例如,某个租户的文档主题与当前问题非常接近,它在近邻区域的占比可能远高于全表占比;反过来,也可能远低于全表占比。
因此,更准确的判断应该是:
过滤后剩多少,取决于近邻候选中的有效比例,而不只是目标租户占整张表的比例。
另外,hnsw.ef_search 控制的是搜索过程中的动态候选列表规模,不能直接理解为“数据库恰好读取这么多行”。图遍历、候选维护和最终返回记录不是同一个计数口径。
三、搭建一个能观察问题的实验
下面以 PostgreSQL 17 和支持迭代扫描的 pgvector 为基础。迭代扫描从 pgvector 0.8.0 开始提供。
示例使用三维随机向量,目的是观察过滤检索机制,不是模拟真实文本 Embedding 的分布,也不用于推断生产环境的性能。
请在独立实验库中执行。扩展需要预先安装到 PostgreSQL 环境,创建扩展的操作由具备权限的账号完成。
1. 创建数据与索引
CREATE EXTENSION IF NOT EXISTS vector;
SELECT extversion
FROM pg_extension
WHERE extname = 'vector';
CREATE TABLE rag_chunk_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id integer NOT NULL,
enabled boolean NOT NULL DEFAULT true,
content text NOT NULL,
embedding vector(3) NOT NULL
);
SELECT setseed(0.42);
INSERT INTO rag_chunk_demo (
tenant_id,
enabled,
content,
embedding
)
SELECT
(g % 100) + 1,
true,
'document-' || g,
ARRAY[
random(),
random(),
random()
]::vector(3)
FROM generate_series(1, 100000) AS s(g);
CREATE INDEX rag_chunk_demo_embedding_hnsw
ON rag_chunk_demo
USING hnsw (embedding vector_cosine_ops);
ANALYZE rag_chunk_demo;
按照这段数据生成规则,10 万条记录被平均分配给 100 个租户,每个租户有 1000 条有效记录。
可以先核对:
SELECT count(*) AS eligible_count
FROM rag_chunk_demo
WHERE tenant_id = 42
AND enabled = true;
2. 关闭迭代扫描,观察执行计划
BEGIN;
SET LOCAL hnsw.iterative_scan = off;
SET LOCAL hnsw.ef_search = 40;
EXPLAIN (ANALYZE, BUFFERS)
SELECT
id,
embedding <=> '[0.2,0.4,0.8]'::vector AS distance
FROM rag_chunk_demo
WHERE tenant_id = 42
AND enabled = true
ORDER BY embedding <=> '[0.2,0.4,0.8]'::vector
LIMIT 10;
COMMIT;
不要预设这次一定返回几条。
真正需要观察的是执行计划中的这些信息:
| 观察位置 | 需要确认的内容 |
|---|---|
| 扫描节点及索引名称 | 是否真的使用了 HNSW 索引 |
Filter |
租户和有效状态条件在哪里执行 |
Rows Removed by Filter |
有多少已取得的记录被过滤掉 |
| 顶层实际行数 | 最终交给调用方多少条结果 |
EXPLAIN ANALYZE 会实际执行查询,并展示实际行数和执行时间;BUFFERS 用于补充缓冲区访问情况。不要把估算的 rows、实际返回的行数和索引内部访问的节点数混为一谈。
如果查询没有走 HNSW,而是扫描符合条件的数据后排序,那么这次观察到的就不是近似索引候选不足的问题。
先确认执行路径,再讨论为什么返回不全。
四、迭代扫描:不够时继续找,而不是第一轮结束就停止
迭代扫描的作用,可以理解为允许查询在过滤后结果不足时继续推进索引搜索,而不是仅依赖最初产生的一批候选。这个过程仍受到扫描预算等条件约束,并不是无条件搜索完整个索引。
graph LR
A["扫描候选"] --> B["应用业务过滤"]
B --> C{"结果足够"}
C -->|是| D["返回结果"]
C -->|否| E{"还有候选和扫描预算"}
E -->|是| A
E -->|否| D
可以先在单个事务中验证:
BEGIN;
SET LOCAL hnsw.iterative_scan = strict_order;
SET LOCAL hnsw.ef_search = 100;
SET LOCAL hnsw.max_scan_tuples = 20000;
SET LOCAL hnsw.scan_mem_multiplier = 2;
SELECT
id,
content,
embedding <=> '[0.2,0.4,0.8]'::vector AS distance
FROM rag_chunk_demo
WHERE tenant_id = 42
AND enabled = true
ORDER BY embedding <=> '[0.2,0.4,0.8]'::vector
LIMIT 10;
COMMIT;
这里的参数只是实验起点,不是通用生产配置。
需要特别区分几个参数的职责:
| 参数 | 主要职责 | 不应怎样理解 |
|---|---|---|
hnsw.ef_search |
控制搜索候选列表规模 | 不是最终返回条数 |
hnsw.max_scan_tuples |
控制迭代扫描的近似访问上限 | 不是整个查询的严格工作量上限,也不限制初始扫描 |
hnsw.scan_mem_multiplier |
以 work_mem 的倍数设置迭代扫描内存预算 |
不是 PostgreSQL 进程的总内存限制 |
这些控制项在 pgvector 的参数定义与扫描实现中有明确区分。单纯增大扫描数量,却没有足够的扫描内存,并不意味着搜索就能按预期继续扩展。
还有一个容易被忽略的工程细节:使用连接池时,更适合把这类请求级调参放在事务内,通过 SET LOCAL 设置。
SET LOCAL 在当前事务提交或回滚后失效;普通 SET 则可能在事务提交后继续影响当前会话。因此,设置参数与执行查询必须使用同一条连接、同一个事务。
五、严格排序,不等于完整召回
strict_order 这个名称容易造成误解。
它的“严格”,针对的是扫描结果的距离顺序,而不是承诺找到了整个符合条件的数据集合中真正最近的所有记录。
在 pgvector 的扫描实现中,严格模式会检查后续候选的距离,避免输出破坏既有距离顺序的记录。这是一种输出顺序约束,不是“没有遗漏任何更近记录”的证明。
1. 两种模式解决不同问题
strict_order 适合需要直接获得有序扫描结果的场景。
relaxed_order 允许结果出现轻微的距离乱序,可以用于更偏向召回的检索策略。随后可以对取到的候选再次排序。pgvector 官方文档给出了物化 CTE 的处理方式,并特别说明 PostgreSQL 17 及以上版本可通过外层的 distance + 0 确保重新排序。(GitHub)
例如,先在目标租户范围内取得最多 50 个候选,再返回其中距离最近的 10 个:
BEGIN;
SET LOCAL hnsw.iterative_scan = relaxed_order;
SET LOCAL hnsw.ef_search = 100;
WITH candidates AS MATERIALIZED (
SELECT
id,
content,
embedding <=> '[0.2,0.4,0.8]'::vector AS distance
FROM rag_chunk_demo
WHERE tenant_id = 42
AND enabled = true
ORDER BY embedding <=> '[0.2,0.4,0.8]'::vector
LIMIT 50
)
SELECT
id,
content,
distance
FROM candidates
ORDER BY distance + 0, id
LIMIT 10;
COMMIT;
MATERIALIZED 用来建立明确的计算边界,避免这个 CTE 被直接合并进外层查询。它不是普通的代码分组标记,而是会影响优化与执行的语义。
2. 外层重排也有边界
这条 SQL 的外层排序,能解决的是:
已经取得的这批候选,最终按什么顺序返回。
它不能解决的是:
未被内层检索取得的记录,是否比这批候选更近。
假设真正最近的记录没有进入内层 50 个候选,外层无论怎么排序,都不可能凭空把它找回来。
所以,候选重排可以改善候选内部的选择,但不能替代召回率验证。
六、怎样证明“搜得更好了”?建立精确检索对照
只看返回数量,很容易得到错误结论。
假设精确检索的前 10 条是集合 E,近似检索返回的记录是集合 A。可以用集合重合程度衡量索引召回:
当符合条件的数据不少于 10 条时,分母就是 10。
如果近似检索返回了 10 条,但只有 7 条属于精确前 10,那么召回率是 70%,而不是因为“返回够了”就变成 100%。
这里的召回率只是在验证:近似索引是否找回了同一距离标准下的最近邻。它不直接证明这些文档一定能回答用户问题,业务相关性仍然需要另外评测。
1. 对照查询应使用同一份数据视图
如果精确查询和近似查询之间发生了数据更新,就可能把数据变化误认为检索差异。
可以在一个短时间的 REPEATABLE READ 事务中完成对照。PostgreSQL 的这一隔离级别使事务内连续查询共享稳定的数据快照,避免看到其他事务在此期间提交的数据变化。
2. 一个集合对照脚本
下面的脚本通过物化 CTE,先完整取得符合条件的记录,再执行距离排序,建立精确结果集。CTE 内部没有近邻排序或候选截断,因此外层排序不会借助原表上的 HNSW 索引裁剪这个集合。这个设计利用了 PostgreSQL 的物化边界,以及 HNSW 必须依赖距离排序才能参与扫描的实现约束。
BEGIN ISOLATION LEVEL REPEATABLE READ;
SET LOCAL hnsw.iterative_scan = strict_order;
SET LOCAL hnsw.ef_search = 100;
SET LOCAL hnsw.max_scan_tuples = 20000;
SET LOCAL hnsw.scan_mem_multiplier = 2;
CREATE TEMP TABLE exact_top10
ON COMMIT DROP
AS
WITH eligible AS MATERIALIZED (
SELECT
id,
embedding
FROM rag_chunk_demo
WHERE tenant_id = 42
AND enabled = true
)
SELECT
id,
embedding <=> '[0.2,0.4,0.8]'::vector AS distance
FROM eligible
ORDER BY distance, id
LIMIT 10;
CREATE TEMP TABLE ann_top10
ON COMMIT DROP
AS
SELECT
id,
embedding <=> '[0.2,0.4,0.8]'::vector AS distance
FROM rag_chunk_demo
WHERE tenant_id = 42
AND enabled = true
ORDER BY embedding <=> '[0.2,0.4,0.8]'::vector
LIMIT 10;
WITH scores AS (
SELECT
(SELECT count(*) FROM exact_top10) AS exact_count,
(SELECT count(*) FROM ann_top10) AS returned_count,
(
SELECT count(*)
FROM ann_top10 a
JOIN exact_top10 e USING (id)
) AS hit_count
)
SELECT
exact_count,
returned_count,
hit_count,
round(
hit_count::numeric / NULLIF(exact_count, 0),
4
) AS recall_at_10
FROM scores;
COMMIT;
这个脚本有几个使用边界。
首先,ann_top10 只是表名,不能证明其中的查询真的走了 ANN。应当对它内部的 SELECT 单独执行 EXPLAIN,确认使用了目标 HNSW 索引。否则,可能只是比较了两次精确查询。
其次,精确结果为空时,脚本返回 NULL 召回率,表示这个查询没有可比较的最近邻集合,不应武断记成 0% 或 100%。
再次,如果大量记录在第 10 名附近具有相同距离,需要制定并列结果的评测规则。简单比较 ID 集合,可能把距离等价的答案算成遗漏。
最后,这个脚本用于核对结果集合,不用于直接比较两段 SQL 的耗时。前一次查询对缓存状态的影响,会干扰后一次查询。性能测试应另外设计冷热缓存、并发量和执行顺序。
七、别把所有查询都塞进同一个 HNSW 索引
当过滤条件很强时,继续扩大近似扫描范围不一定是最好的方案。
更值得比较的,是不同的数据访问路径。
1. 过滤后数据很少:先筛选,再精确排序
在前面的实验中,租户 42 只有 1000 条记录。
可以先为过滤字段建立普通索引:
CREATE INDEX rag_chunk_demo_tenant_idx
ON rag_chunk_demo (tenant_id);
ANALYZE rag_chunk_demo;
然后比较“按租户筛选后计算全部距离”和“HNSW 扫描后过滤”两条路径。
pgvector 官方发布说明明确强调:当普通索引等路径可以取得相近性能时,不使用 ANN 可以获得完整召回。因此,不应把“查询没有使用向量索引”自动判定为优化失败。(PostgreSQL)
前面精确对照中的 eligible 查询结构,也可以作为这类检索路径的实验起点。
不过,是否更快取决于筛选后的实际数据量、向量维度、缓存与并发,不能仅凭“1000 条看起来不多”下结论。
2. 少量稳定的数据子集:考虑部分索引
如果只有少量访问量很大的固定租户,可以评估为特定子集建立部分 HNSW 索引:
CREATE INDEX rag_chunk_demo_t42_hnsw
ON rag_chunk_demo
USING hnsw (embedding vector_cosine_ops)
WHERE tenant_id = 42
AND enabled = true;
这个索引只覆盖谓词所定义的数据子集。
但 PostgreSQL 必须能在规划时证明查询条件满足索引谓词,才可能使用它。尤其在参数化查询采用通用计划时,tenant_id = $1 不能被直接证明始终满足 tenant_id = 42。是否可用需要检查应用实际执行计划,不能只验证手写常量 SQL。
同样,不建议因为这个方案有效,就给每一个租户都建立独立部分索引。大量部分索引会增加规划和维护成本,PostgreSQL 文档也明确提醒不要用大量互不重叠的部分索引替代分区设计。
3. 数据规模与租户差异明显:考虑分区或独立表
当租户本身就是稳定的数据边界时,可以评估按租户分区,或者为少量大型租户使用独立表。
PostgreSQL 分区可以利用分区约束裁剪不相关的数据范围,但分区数量与查询涉及的分区数量也会影响规划和内存开销,并不是越细越好。
可以把下面这张表作为实验顺序,而不是固定选型结论:
| 数据形态 | 优先比较的方案 |
|---|---|
| 过滤后只剩较小数据集 | 普通索引筛选,再精确距离排序 |
| 过滤后仍有大量数据 | HNSW 配合迭代扫描 |
| 少量稳定且高频的子集 | 部分 HNSW 索引 |
| 少量大型租户与大量小租户并存 | 分区、独立表与共享表的组合 |
核心不是“哪种索引更先进”,而是:
当前查询真正需要搜索多大的有效数据空间?
八、权限过滤不能为了召回率让路
在多租户知识库里,过滤不仅是性能条件,也是业务边界。
不要因为结果不足,就把查询改成“先不加租户条件检索更多内容,交给模型后再要求它只使用当前租户的文档”。
也不要在向量检索路径上保留权限过滤,却在降级查询、关键词补充检索或缓存读取路径上遗漏它。
这里应当坚持一个设计不变量:
无论采用哪条检索路径,进入模型上下文的记录都必须属于当前请求允许访问的范围。
数据库行级安全策略可以作为额外防线,但需要注意:超级用户、具有 BYPASSRLS 属性的角色,以及默认情况下的表所有者,存在绕过行级安全策略的情况。不能仅凭“启用了 RLS”就认定应用的权限隔离已经完整。
候选不足时,可以扩大合法范围内的扫描预算,可以改用精确检索,也可以明确返回没有足够相关内容。
不能通过扩大权限范围来提高召回率。
九、上线验收:同时看数量、召回与资源代价
经过调参之后,至少要分别回答三个问题:
“结果够不够?”“最近邻找得对不对?”“代价是否可接受?”
建议先在离线样本中覆盖不同租户规模、过滤比例和查询主题,再按小流量验证线上表现。不要只使用一个大租户、几个热门问题,就认为所有租户都已经优化完成。
验收记录可以围绕下面几个维度组织:
| 维度 | 需要记录的内容 |
|---|---|
| 返回完整程度 | 在确实存在足够有效记录时,返回数量是否不足 |
| 索引召回率 | 与相同过滤条件下精确结果的重合程度 |
| 延迟表现 | 不同租户与查询类型的 P50、P95、P99 |
| 资源代价 | CPU、缓冲区访问、内存、临时文件与并发吞吐 |
特别要谨慎提高扫描内存。
work_mem 并不是整个数据库只能使用一次的统一内存池。多个查询和执行节点可能分别使用内存预算;在此基础上提高扫描内存倍数,必须结合并发验证,而不是只看单条查询是否变快。
对于过滤条件变化很大的系统,更适合验证几类查询策略,而不是给所有请求设置同一组较大的参数。
小范围数据采用精确搜索,大范围数据使用近似搜索;普通请求控制预算,高价值请求使用经过评测的更高召回配置。具体切换阈值应由实际数据与延迟目标决定。
十、结语:向量检索的正确性,不能只看“索引建好了”
向量检索中的三个动作,需要始终分开理解:
候选生成决定“看到了哪些记录”;业务过滤决定“哪些记录符合条件”;最终排序决定“已经看到的记录怎样排列”。
所以,面对“明明有数据却搜不全”,更有效的排查顺序是先确认有效数据数量,再查看执行路径,随后验证扫描预算,最后用精确结果衡量召回。
返回数量增加,只能说明结果更充足。
距离排序正确,只能说明输出更有序。
真正可靠的检索,需要证明:在正确的权限范围内,以可以接受的资源代价,找到了足够接近目标的结果。
这比单纯证明“用了 HNSW 索引”,重要得多。

评论区 0