KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

05 · 过滤与混合检索 — keel 龙骨

真实检索几乎从不裸搜。你总要带条件:tenantid = 7、status = 'active'、createdat > ...。过滤放在检索的哪一步,决定了 top-k 里还能剩几条。这一章在两个引擎上跑出结论:faiss 里「先检索再过滤」会把 89.8% 的查询打空,pgvector 里同样的问题默认也会发生,得靠 iterative_scan 才修一半。

真实检索几乎从不裸搜。你总要带条件:tenant_id = 7、status = 'active'、created_at > ...。过滤放在检索的哪一步,决定了 top-k 里还能剩几条。这一章在两个引擎上跑出结论:faiss 里「先检索再过滤」会把 89.8% 的查询打空,pgvector 里同样的问题默认也会发生,得靠 iterative_scan 才修一半。

现场

一个多租户检索服务,查询是「在我这个租户的文档里找回最相似的 10 条」。数据总共有 10 万条,当前租户只占 1%。两种写法:

直觉上第二种「更省事」,结果上它几乎必然返回空——下面用数据说明。

过滤策略的全链路

flowchart TD
    Q["查询: 向量 + 过滤条件<br/>例如 tenant_id = 7"] --> D{"过滤放在哪一步?"}
    D -->|"下推 (pre-filter)"| PF["把条件交给索引<br/>只让通过的候选参与距离计算"]
    D -->|"后过滤 (post-filter)"| PO["先取全量 top-k<br/>返回后再按条件筛"]
    D -->|"过度取数 (over-fetch)"| OF["先取 top-N, N 远大于 k<br/>筛完再取前 k"]
    PF --> R1["召回高<br/>但 IVF 需要更大 nprobe<br/>实测 1% 选择率下 nprobe=32 → 0.3111"]
    PO --> R2["top-k 被筛空<br/>实测 89.8% 的查询剩 0 条"]
    OF --> R3["能凑够条数<br/>召回仍受被探测候选池限制<br/>实测 0.3111"]

    style Q fill:#e3f2fd,color:#0d3b66
    style D fill:#fff3e0,color:#8a4b00
    style PF fill:#e8f5e9,color:#1b5e20
    style PO fill:#ffebee,color:#b71c1c
    style OF fill:#f1f8e9,color:#33691e
    style R1 fill:#e8f5e9,color:#1b5e20
    style R2 fill:#ffebee,color:#b71c1c
    style R3 fill:#f1f8e9,color:#33691e

顺着图往下:只有最左边那条「下推」把过滤条件交给了索引。中间那条「后过滤」是本课要重点破除的写法。

faiss 侧:1% 选择率下的三种策略

数据 10 万条,过滤条件保留 id % 100 == 0,即 1%。精确答案在允许集合里单独算(相当于「下推且探满」的理想结果)。原始输出(07-filter-faiss.txt):

N=100000 允许通过=1000 (1.0%)

① 先过滤再检索:IDSelector 下推到 IVF(只探测 nprobe 个簇)
nprobe | recall@10
    32 |    0.3111
   128 |    0.7532
   256 |    0.9999

② 先检索再过滤:全量取 top-10,之后再筛掉不允许的(nprobe=32)
   平均剩余 = 0.11/10 条
   10 条被杀空的查询占比 = 89.8%
   recall@10 = 0.0107

③ 过度取数补偿:取 top-1000 再筛(nprobe=32)
   平均剩余 = 8.86/10 条
   recall@10 = 0.3111

三组数字各自的含义:

一个工程提醒:① 里 IDSelector 必须配合把 nprobe 一起设大,否则下推也白搭。上面的代码里如果只设 params.sel 不设 params.nprobe,faiss 会忽略 index.nprobe,实际按默认值探测——这个坑我一开始就踩了,第一次跑出来三档 nprobe 召回都是 0.0187,排查后才发现是 params.nprobe 没设。写过滤下推时,参数要设在 params 对象上,不是索引上。

pgvector 侧:过滤会退化成「取不够」

换成 pgvector 0.8.6。表 items(id, category, rare, v vector(128)),10 万行;category 有 10 个值(每个 10%),rare=1 只有 0.1%。建 HNSW 索引 m=16, ef_construction=64,索引 79 MB,表 56 MB——索引比数据还大 1.4 倍,这是 HNSW 的常态。

无过滤时 ef_search 曲线(07-filter-pgvector.txt):

--- ef_search=10 ---
  Index Scan using items_v_hnsw ... (actual time=0.443..0.505 rows=10)
  Buffers: shared hit=637
  Execution Time: 1.033 ms
--- ef_search=40 ---
  Buffers: shared hit=1534
  Execution Time: 0.855 ms
--- ef_search=200 ---
  Buffers: shared hit=6102
  Execution Time: 3.556 ms

ef_search 从 10 加到 200,shared hit 从 637 涨到 6102(访问的索引页数),执行时间涨到 3.556ms。计划里 Index Scan using items_v_hnsw 表示用上了 HNSW;Order By: (v <-> '...') 是 pgvector 的 L2 距离算子。同一批无过滤查询和精确结果比,重合只有 4/10(随机数据 + ef_construction=64 偏低)。

带过滤时的行为用同一条查询、同一个索引测(07c 段):

================ category=3 (10%) ================
精确答案(顺序扫描) top10 = [74533, 50133, 65693, ...]
--- iterative=off ---
返回条数 = 4/10   recall@10 = 0.20
  Index Scan using items_v_hnsw on items ... rows=4 loops=1
  Execution Time: 3.919 ms
--- iterative=strict_order ---
返回条数 = 10/10   recall@10 = 0.20
  Execution Time: 5.996 ms
--- iterative=relaxed_order ---
返回条数 = 10/10   recall@10 = 0.30
  Execution Time: 3.230 ms

================ rare=1 (0.1%) ================
精确答案(顺序扫描) top10 = [18000, 67000, 34000, ...]
--- iterative=off ---
返回条数 = 0/10   recall@10 = 0.00
  Execution Time: 1.060 ms
--- iterative=strict_order ---
返回条数 = 3/10   recall@10 = 0.10
  Execution Time: 14.371 ms
--- iterative=strict_order max_scan_tuples=2000 ---
返回条数 = 3/10   recall@10 = 0.10
  Execution Time: 2.796 ms

这几个数字值得逐条读:

pgvector 内部这条链是:

flowchart TD
    S["ORDER BY v <-> q LIMIT 10<br/>+ WHERE 过滤"] --> IDX["HNSW 索引扫描<br/>按图遍历产出候选"]
    IDX --> F["应用 Filter<br/>Rows Removed by Filter"]
    F --> C{"凑够 LIMIT 了吗?"}
    C -->|"够"| RET["返回"]
    C -->|"不够"| IT{"iterative_scan 开了吗?"}
    IT -->|"off (默认)"| SHORT["返回不足条数<br/>甚至 0 条"]
    IT -->|"strict/relaxed"| MORE["继续扫, 直到够数<br/>或到 max_scan_tuples"]
    MORE --> LIM{"到扫描上限?"}
    LIM -->|"是"| PART["仍返回不足条数<br/>0.1% 场景实测只有 3/10"]
    LIM -->|"否"| RET

    style S fill:#e3f2fd,color:#0d3b66
    style IDX fill:#f1f8e9,color:#33691e
    style F fill:#fff3e0,color:#8a4b00
    style C fill:#fff3e0,color:#8a4b00
    style RET fill:#e8f5e9,color:#1b5e20
    style IT fill:#fff3e0,color:#8a4b00
    style SHORT fill:#ffebee,color:#b71c1c
    style MORE fill:#f1f8e9,color:#33691e
    style LIM fill:#fff3e0,color:#8a4b00
    style PART fill:#ffebee,color:#b71c1c

Partition 与 Partition Key

pgvector 和 faiss 都靠「过滤下推」解决过滤,Milvus 多给了一个物理手段:把数据按分区键预先物理分开(以下为 Milvus 官方文档陈述,检索日期 2026-10-05,本机无 Milvus,未实测)。

这个机制解决的是前面那个「1% 选择率」问题:如果事先把每个租户物理隔离到独立分区,检索时直接只扫目标分区,不需要在全局索引里下推再筛。代价是分区数量和分区键的基数绑定——租户特别多、特别不均衡时,分区会倾斜。

混合检索的顺序

把这一章和第 07 章(记忆系统)串起来:作用域过滤必须发生在向量排序之前,而且最好是物理层面的下推或分区,而不只是返回后检查。第 07 章的原话是「先全库搜索再过滤用户,会扩大跨租户泄露面」。这一章给的是它在引擎层的样子:不只要考虑泄露,还要考虑「后过滤会把 top-10 打空」。标量 + 向量的混合检索,正确顺序是「作用域过滤 → 向量检索 → 重排 → top-k」,重排那一步可以再叠关键词、时间、来源等信号。

混合检索的两种做法

「过滤 + 向量」不是一个统一的模式,实际有两种差别很大的做法:

第一种,硬过滤 + 向量排序。 过滤是布尔门槛,不参与打分:不满足 tenant_id = 7 的文档根本不进候选。本章前面测的都是这种。它适合权限、租户、状态这类「不满足就不该返回」的条件——它们没有「权重」,是安全边界,不是排序信号。

第二种,多路召回 + 融合打分。 向量 top-k 和关键词(BM25)各自召回一批,再把两批结果用 RRF(倒数排名融合)或加权分数合并。它适合「关键词和语义都可能有信号」的查询,比如用户输入里同时有服务名和自然语言描述。这条路要额外处理分数不可比的问题:余弦在 [-1, 1],BM25 没有上界,直接加权会把其中一路淹没,所以通常按排名而不是按原始分数融合。

两种做法都要守住同一条底线:作用域过滤必须在最前。第 07 章(记忆系统)把检索拆成「元数据过滤 → 关键词 → 语义 → 重排 → Top-k」,并强调先过滤后排序是为了不大范围算出又要丢弃跨租户数据。本章给的是它在引擎层的落地:硬过滤要下推给索引(或用 partition 物理隔离),多路召回也要先在各路内部完成作用域裁剪,再融合。

主动破坏

把选择率继续往下压:本机已经测到 0.1% 时 iterative=off 返回 0 条、strict_order 也只能凑到 3/10。再压到 0.01%(表里只有 10 条 rare=1 的话是 0 条)时,任何索引手段都很难保证 top-10——这时正确的做法不是调索引,而是换策略:先按 rare 做一次标量索引扫描拿到那几十条 id,再在内存里暴力算距离。这跟第 00 章「候选集能缩到几千条就暴力算」是同一个判断。

第二个破坏:把 pgvector 的 hnsw.iterative_scan 设回 off,然后跑一个带 WHERE 的接口压测,监控返回条数分布。你会在日志里看到大量「请求 LIMIT 10、实际返回 0~4 条」的记录,而这些请求不会报错。

生产边界

动手

  1. 跑 07_filter_faiss.py,把允许比例从 1% 改成 10%,重看三种策略的召回和「打空比例」,验证选择率越高、后过滤越不致命。
  2. 跑 07c_pgvector_iterative.py,把 hnsw.max_scan_tuples 设成 50000,看 rare=1 能不能从 3/10 涨上去,代价是多少毫秒。
  3. 在 pgvector 上给 category 建一个 B-tree 索引,再用 WHERE category=3 ORDER BY v <-> ... 看计划——对比「有 B-tree 时 planner 选哪个索引」,理解标量索引和向量索引怎么配合。

自测

  1. 为什么「1% 选择率 + 后过滤」的平均剩余是 0.11 条?用期望值算一遍。
  2. faiss 下推时 nprobe=32 召回只有 0.3111,而 nprobe=256 有 0.9999。为什么下推之后还需要更大的 nprobe?
  3. 「过度取数(取 top-1000)」的召回和「下推(nprobe=32)」完全一样(都是 0.3111),为什么?
  4. pgvector 默认 iterative=off 时,LIMIT 10 只返回 4 条甚至 0 条,为什么这种问题在生产里特别危险?
  5. 0.1% 选择率下连 strict_order 也只能凑到 3/10。这时候你会怎么改架构,而不是继续调参数?

↓ 下一步:06 章 · 选型与运维 —— 把前面所有代价收进一张选型表。

进入 keel 阅读