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%。两种写法:
- 先按
tenant_id过滤,再在剩下的 1000 条里算相似度(下推,pre-filter); - 先全量取最相似的 10 条,再看哪几条属于本租户(后过滤,post-filter)。
直觉上第二种「更省事」,结果上它几乎必然返回空——下面用数据说明。
过滤策略的全链路
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
三组数字各自的含义:
- ① 下推:用 faiss 的
IDSelector把允许的 id 交给 IVF,距离只在通过的候选上算。nprobe=32时召回 0.3111——注意它并不高。原因是 IVF 只探测 32 个簇,而过滤后的目标向量分散在所有 256 个簇里,没探测到的簇里的邻居就丢了。要恢复召回得把nprobe加到 256(等于探满),此时 0.9999,接近精确。 - ② 后过滤:全量取 top-10,再筛掉不属于允许集合的。平均只剩 0.11 条,89.8% 的查询一条都不剩。这就是「1% 选择率 + 后过滤」的必然结果:10 条里期望命中 0.1 条。
- ③ 过度取数:先取 top-1000 再筛,平均能凑到 8.86 条,召回 0.3111——和 ① 在
nprobe=32时完全相同。这不是巧合:两者探测的簇一样多,候选池一样大,过度取数只是把「取不够条数」补上了,补不了「候选集里根本没有」。
一个工程提醒:① 里 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
这几个数字值得逐条读:
iterative=off且category=3(10%):只返回 4 条。LIMIT 10写了,结果只有 4 条。这是 pgvector 默认行为——HNSW 索引扫描给出一个候选列表,过滤器把它们筛掉后剩下的不够 10 条,SQL 不会自动补偿。iterative=off且rare=1(0.1%):返回 0 条。极端过滤下 top-10 被完全打空,和 faiss 的 89.8% 是同一个现象。iterative=strict_order:category=3修复到 10/10,但耗时 5.996ms;rare=1也只能到 3/10,耗时 14.371ms。pgvector 0.8 引入的迭代扫描会在过滤后继续往下扫,直到凑够LIMIT或到达hnsw.max_scan_tuples(默认 20000)上限。0.1% 选择率下扫 20000 个候选大约只能命中 20 个目标,加上图遍历的限制,实际只凑到 3 条。relaxed_order:同样 10/10,耗时 3.230ms,比strict_order的 5.996ms 快一倍。代价是返回结果不保证按距离严格排序。如果你的下游还会重排(比如第 07 章的 Rerank),用 relaxed 没问题;如果直接把结果按序展示给用户,就得用 strict。max_scan_tuples=2000:把扫描上限从 20000 降到 2000,rare=1的耗时从 14.371ms 降到 2.796ms,返回条数不变(还是 3/10)。上限压低能止损延迟,但你得接受「可能扫不到」。
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,未实测)。
- 手动 partition:一个 collection 最多 1024 个,得自己决定哪条数据进哪个 partition。
- Partition Key:创建 collection 时把一个标量字段标成
is_partition_key=True,Milvus 按该字段值的 hash 对partitions_num取模决定落到哪个 partition;不指定时默认 16 个。检索时必须带上基于 partition key 的过滤表达式(如partition_key == "x"),否则不会裁剪。 - Partition Key Isolation:多租户场景按 partition key 分组建独立索引,检索时过滤条件只能包含单个 partition key 值。文档说当前只支持 HNSW。
这个机制解决的是前面那个「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 条」的记录,而这些请求不会报错。
生产边界
- 教学替身:单表 + 单机索引。生产里过滤条件往往不止一个、还要联合,分区键的基数和数据分布都更复杂。
- 上线要盯的指标:
返回条数分布(有没有长期低于 LIMIT)、过滤器选择率、计划里用没用索引、iterative_scan 或下推参数的实际值。 - 失败策略:把「过滤后取不够」当一级故障监控,因为它静默。选择率极低时切换到「先标量查 id 再内存算距离」;多租户场景优先用分区/分区键把租户物理隔离。
动手
- 跑
07_filter_faiss.py,把允许比例从 1% 改成 10%,重看三种策略的召回和「打空比例」,验证选择率越高、后过滤越不致命。 - 跑
07c_pgvector_iterative.py,把hnsw.max_scan_tuples设成 50000,看rare=1能不能从 3/10 涨上去,代价是多少毫秒。 - 在 pgvector 上给
category建一个 B-tree 索引,再用WHERE category=3 ORDER BY v <-> ...看计划——对比「有 B-tree 时 planner 选哪个索引」,理解标量索引和向量索引怎么配合。
自测
- 为什么「1% 选择率 + 后过滤」的平均剩余是 0.11 条?用期望值算一遍。
- faiss 下推时
nprobe=32召回只有 0.3111,而nprobe=256有 0.9999。为什么下推之后还需要更大的nprobe? - 「过度取数(取 top-1000)」的召回和「下推(nprobe=32)」完全一样(都是 0.3111),为什么?
- pgvector 默认
iterative=off时,LIMIT 10只返回 4 条甚至 0 条,为什么这种问题在生产里特别危险? - 0.1% 选择率下连
strict_order也只能凑到 3/10。这时候你会怎么改架构,而不是继续调参数?
↓ 下一步:06 章 · 选型与运维 —— 把前面所有代价收进一张选型表。