Chapter 04 · ★ 核心
带过滤的检索:RAG 真正的难处
前三章讲了向量怎么存(01)、索引怎么建(02)、建完怎么在 MVCC 里腐烂(03)。这一章把它们拧成一股:当查询同时要 WHERE tenant_id = 42 和 ORDER BY embedding <=> $1 时会发生什么。这正是把 RAG demo 变成多租户生产系统时第一个撞上的墙。
本章你将建立的 schema
- 带
WHERE的向量查询是一道 planner 选择题,有三条计划,各有代价。 - 默认那条(post-filter)会欠返——返回不足
LIMIT k条,根因是 §1.4 的排序契约。 - 三条出路:iterative scan(0.8.0)、partial index、先 B-tree 精确排序——按过滤选择性选。
- planner "有索引却不走"是成本估算或 §1.4 契约被破坏的结果,用
EXPLAIN诊断。 - 稠密向量叠加词法检索(BM25 / tsvector)+ RRF 融合,是 RAG 召回的生产标配。
4.1planner 的三选一
回到那条贯穿全教程的查询。它把关系过滤和向量排序焊在一起:
SELECT id, content
FROM doc_chunks
WHERE tenant_id = 42 AND lang = 'zh' -- 关系过滤:B-tree 的活
ORDER BY embedding <=> $1 -- 向量排序:HNSW 的活
LIMIT 10;
向量索引只管 ORDER BY 距离 + LIMIT(§1.4),WHERE 得在别处解决——于是 planner 必须在三条计划里选一条。
§1.4 那条契约(向量索引是 amcanorderbyop 排序索引,不碰 WHERE)在这里第一次产生真实后果。既然一个索引管排序、另一种手段管过滤,planner 就得决定谁先谁后:
WHERE 筛,而向量索引一次只吐固定条数(§1.4)。② 和 ③ 是后面要讲的两条解法。4.2post-filter 为何欠返:预算用完,不是数据不够
HNSW 先按距离取 ef_search 个候选(固定预算),WHERE 在之后才筛——筛剩的未必够 k 条。
把 §1.4 的排序契约和 §2.4 的 ef_search 预算拼起来,结论是强制的:索引的工作是"按距离吐最近的 ef_search(默认 40)个,吐完就停"。WHERE tenant_id = 42 只能在这 40 个里筛。如果 tenant=42 在全表占 10%,这 40 个候选里平均只有 4 个属于租户 42——于是 LIMIT 10 的查询返回 4 条。缺口不是"数据库里没有第 5 到第 10 段",而是"索引的预算在过滤之前就花完了"。
WHERE 筛剩 4 个(下),够不到 LIMIT 10 的虚线。注意:这和 §3.1 可见性重检导致的欠返是同一个形状——固定预算先取、条件后筛、筛完不够。记住这个形状,两类事故就只是一个根因。欠返不报错。用户搜出来"只有几条结果",工程师以为是文档不够,实则是 post-filter 把召回偷偷砍了。选择性越低(命中率越小,比如冷门租户、小语种),欠返越严重。0.8.0 之前这无解,只能加大 ef_search 硬扛——但那是在为所有查询付代价。下一节给真正的解法。
4.3三条出路:按选择性选
欠返的解法不止一个,选哪个取决于 WHERE 的选择性(命中行占比)和过滤值的多样性(有多少种不同的 tenant)。
出路一:iterative scan(0.8.0,推荐默认)
让索引"边扫边补":取满后若不足 k 条,继续往外扩,直到够 k 或撞上上限。
底层机制(比文档深一层):iterative scan 把"取一次 ef_search"变成"不够就再取一轮",直到过滤后的幸存数达到 LIMIT,或扫描量撞到 hnsw.max_scan_tuples(默认 20000)、内存撞到 hnsw.scan_mem_multiplier × work_mem(默认 1×)。两种顺序模式:strict_order 重新按精确距离排序后返回;relaxed_order 按找到的顺序返回(略微乱序,但更快,质量约 95–99%)。默认是 off——必须显式打开,这是 0.8.0 之后欠返仍然普遍存在的唯一原因:很多人升级了版本却没开这个开关。
-- 会话级打开(relaxed 更快,多数 RAG 够用)
SET hnsw.iterative_scan = relaxed_order;
SET hnsw.max_scan_tuples = 20000; -- 扫描上限,护栏
SELECT id, content
FROM doc_chunks
WHERE tenant_id = 42 AND lang = 'zh'
ORDER BY embedding <=> $1
LIMIT 10; -- 现在能补够 10 条(除非命中实在太稀疏)
出路二:partial index(过滤值少且固定时)
为某个固定过滤值单独建一张只含匹配行的索引,让 ANN 直接在"已过滤"的集合上跑。
把 WHERE 写进索引定义,索引就只对匹配行建图——过滤变成了预过滤(pre-filter),召回完全恢复,因为 ANN 搜的本来就只有 tenant=42 的行。代价:一个过滤值一张索引。10 个大租户可以各建一张;10 万个租户行不通——那是 §2.3 构建成本和 §3.2 churn 成本的 10 万倍。
-- 只为大租户 42 建:图里只有它的行,搜出来必然都属于它
CREATE INDEX ON doc_chunks
USING hnsw (embedding vector_cosine_ops)
WHERE tenant_id = 42;
出路三:先 B-tree 精确排序(选择性高时其实最优)
命中行很少时,根本不需要 ANN:B-tree 过滤出几十行,逐一算精确距离再排序,又快又对。
如果 tenant=42 AND lang='zh' 全表只命中 200 行,对这 200 行做精确距离排序是微秒级的事,还没有任何近似误差。这时 planner 主动放弃向量索引、走 B-tree + 精确排序,是正确的选择——别去强迫它走 HNSW。"索引没被用"不总是 bug(§4.4 详谈)。
| 策略 | 适用选择性 | 召回 | 写入 / 运维成本 | 限制 |
|---|---|---|---|---|
| iterative scan | 中 · 低 | 可调到目标 | 仅查询期内存 | 需显式开;极稀疏时仍会撞上限 |
| partial index | 任意 | 完全恢复 | 每值一张索引 | 过滤值必须少且固定 |
| B-tree + 精确排序 | 高(命中很少) | 精确 100% | 低 | 命中多时退化为慢扫描 |
4.4planner 为何"有索引却不走"
向量索引被忽略,要么是 §1.4 的契约被破坏,要么是 planner 算下来扫表更便宜——两者都用 EXPLAIN 看得见。
"建了 HNSW 索引,查询还是很慢"是高频求助。根因分两类,都不神秘:
第一类——契约被破坏(回到 §1.4):
ORDER BY ... DESC:索引只升序吐,DESC直接出局(§1.4 的想一想)。- 没有
LIMIT:要求全量有序,planner 算下来不如全表排序。 ORDER BY里是表达式而非纯运算符、或表太小(几百行扫表更快)。
第二类——成本估算(§2 的延伸):0.8.0 之前 pgvector 把 ANN 扫描的启动成本估得过低(估 ~116,实际可达 ~7200),planner 经常误判,在带 CTE / 复杂谓词的查询里退回 Parallel Seq Scan + Sort。0.8.0 修了成本模型,这类误判大幅减少。诊断永远是同一招:EXPLAIN 看走的是 Index Scan using ..._hnsw 还是 Seq Scan + Sort。
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM doc_chunks
ORDER BY embedding <=> $1
LIMIT 10;
-- 走索引(想要的):
-- Index Scan using doc_chunks_embedding_idx on doc_chunks
-- 没走索引(要排查):
-- Sort -> Seq Scan on doc_chunks ← 全表 + 排序,慢
一张只有 500 行的 doc_chunks 测试表,建了 HNSW 索引,EXPLAIN 却显示 Seq Scan。这是 bug 吗?该怎么办?
展开答案(先停 10 秒)
不是 bug。500 行全表暴力算 500 次距离是亚毫秒的事,比走 HNSW 图(随机 I/O、§2.2)更快,planner 选 Seq Scan 完全正确。教训:在小表上验证不了索引是否生效。要测索引行为,灌到至少几万行;要强制对照精确结果,用 §2.4 的 SET LOCAL enable_indexscan = off,但那是为了量召回,不是生产配置。
4.5叠加词法:hybrid 检索与 RRF
稠密向量擅长语义、漏掉精确关键词;叠加一路词法检索(BM25 / tsvector),用 RRF 融合两个排名,是 RAG 召回的生产标配。
纯稠密检索会漏掉"必须命中的词"——产品型号 SKU-7731、人名、罕见术语,这些在向量空间里和邻居挤成一团,排不上来。词法检索(精确匹配 token)正好补这个洞。两条召回腿各取所长,但它们的打分不可比(cosine 距离 vs BM25 分),不能直接相加——RRF 用排名而非分值来融合,绕开了量纲问题。
底层机制(比文档深一层):Reciprocal Rank Fusion 给每个文档的得分是 Σ 1/(k + rank_i),rank_i 是它在第 i 个榜单里的名次,k 取经验值 60。k 的作用是压平头部差距——名次第 1 和第 2 的得分差,远小于"上榜与没上榜"的差,于是同时出现在两个榜单的文档被显著抬升。这就是 hybrid 想要的效果:双榜共识的结果排最前。
-- 两条召回腿各取 top-40,再用 RRF 融合(k=60)
WITH dense AS (
SELECT id, RANK() OVER (ORDER BY embedding <=> $1) AS rank
FROM doc_chunks
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 40
),
lexical AS (
SELECT id, RANK() OVER (ORDER BY ts_rank_cd(content_tsv, q) DESC) AS rank
FROM doc_chunks, plainto_tsquery('simple', $2) AS q
WHERE tenant_id = 42 AND content_tsv @@ q
LIMIT 40
)
SELECT COALESCE(d.id, l.id) AS id,
COALESCE(1.0/(60 + d.rank), 0)
+ COALESCE(1.0/(60 + l.rank), 0) AS score
FROM dense d
FULL OUTER JOIN lexical l USING (id)
ORDER BY score DESC
LIMIT 10;
上面用的 ts_rank_cd 是 Postgres 自带全文检索,够用但不是真正的 BM25。截至 2026,ParadeDB pg_search 等扩展把工业级 BM25(USING bm25、@@@ 运算符)带进了 Postgres,正在取代 ts_rank 作为 hybrid 的词法腿。第 5 章细讲这条前沿。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里,再点开对照。
- 用一句话说清:
WHERE tenant_id = 42 ORDER BY embedding <=> $1 LIMIT 10为什么会只返回 4 条?根因在哪一章的哪条规则? tenant=42命中全表 0.5%(极稀疏)。iterative scan、partial index、B-tree 精确排序,你优先选哪个?为什么另外两个不合适?- 升级到 0.8.0 后欠返还在。最常漏了哪一步?
- hybrid 检索里,为什么用 RRF 按"排名"融合,而不是把 cosine 距离和 BM25 分直接相加?
答案(先做完再展开)
- 向量索引一次只按距离吐
ef_search(默认 40)个候选,WHERE在之后才筛;命中率 10% 时筛剩 ~4 条。根因是 §1.4:向量索引是排序索引、只管ORDER BY+LIMIT,过滤被迫后置(与 §3.1 可见性重检同构)。 - 命中极稀疏(0.5%)时优先 B-tree + 精确排序:命中行少,精确算距离又快又准,无近似误差。iterative scan 会一路扫到
max_scan_tuples上限仍可能补不够;partial index 对单值有效但 0.5% 散落在大量行间、且若过滤值多则不可维护。 - 没执行
SET hnsw.iterative_scan = relaxed_order(或strict_order)。这个开关默认off,升级版本不会自动启用。 - 因为两者量纲不可比:cosine 距离和 BM25 分不在一个尺度,直接相加由尺度大的一方主导。RRF 只用名次(
1/(k+rank)),天然无量纲,还能让"双榜共识"的文档浮到最前。
partial index 对 10 个大租户可行,对 10 万个小租户不可行。中间地带怎么办?
你的系统有 10 个大租户(各占全表 8%)和 10 万个长尾小租户(各占 < 0.001%)。给大租户建 partial index、给查询开 iterative scan,分别解决了哪一类?长尾小租户的查询走哪条计划最稳?把"按选择性路由到不同计划"这件事,设计成一个可落地的规则。
提示(卡住再展开)
大租户命中率高(8%),post-filter 欠返不严重,但量大——partial index 让它们各自走干净的小图,召回满、延迟低。长尾小租户命中率极低,partial index 维护不起,但正因为命中行少(§4.3 出路三),B-tree + 精确排序反而最稳最准。中间的中租户用 iterative scan 兜底。所以路由规则可以按"该过滤值的行数"分三档:很多 → partial index;中等 → iterative scan;很少 → 让 planner 走 B-tree 精确。关键是先量出每个租户的行数分布,再决定阈值。