🤖
AI审核中

pgvector过滤检索与召回率实战

数据库 21分钟 114浏览 0评论

假设你正在给一个企业知识库接入向量检索。

文档已经切片,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%。

那么过滤之后,期望只剩:

100×1%=1100 \times 1\% = 1

如果希望得到 10 个有效候选,在同样的简化假设下,候选规模可能需要达到:

C≈101%=1000C \approx \frac{10}{1\%} = 1000

这里的计算只是帮助理解数量关系,并不是调参公式。

它至少依赖两个前提:候选中的租户分布接近这个比例,而且租户标签与向量距离没有很强的相关性。

真实数据未必满足这些前提。例如,某个租户的文档主题与当前问题非常接近,它在近邻区域的占比可能远高于全表占比;反过来,也可能远低于全表占比。

因此,更准确的判断应该是:

过滤后剩多少,取决于近邻候选中的有效比例,而不只是目标租户占整张表的比例。

另外,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 条是集合 EE,近似检索返回的记录是集合 AA。可以用集合重合程度衡量索引召回:

Recall@10=∣A∩E∣∣E∣\mathrm{Recall@10} = \frac{|A \cap E|}{|E|}

当符合条件的数据不少于 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 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论