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 的三选一

回到那条贯穿全教程的查询。它把关系过滤和向量排序焊在一起:

filtered-query.sql SQL
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 的向量查询 WHERE tenant=42 · ORDER BY emb <=> q · LIMIT 10 选择 1 选择 2 选择 3 ① 向量索引 → 后过滤 post-filter · 默认 · 易欠返 ② B-tree → 精确排序 正确 · 不走 ANN ③ iterative scan 0.8.0 · 边扫边补够 k RAG 最常见事故 → §4.2 选择性高时最优 推荐默认 → §4.3
图 4.1同一条查询,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 段",而是"索引的预算在过滤之前就花完了"。

① 索引先取 ef_search = 40 个候选(固定预算) 再过滤 WHERE ② 过滤后 tenant=42 命中 ~10% → 幸存 4 条 LIMIT 10 这条线 缺口
图 4.2条形长度即数量:索引取的 40 个候选(上)经 WHERE 筛剩 4 个(下),够不到 LIMIT 10 的虚线。注意:这和 §3.1 可见性重检导致的欠返是同一个形状——固定预算先取、条件后筛、筛完不够。记住这个形状,两类事故就只是一个根因。
陷阱 · RAG 里最隐蔽的一个

欠返不报错。用户搜出来"只有几条结果",工程师以为是文档不够,实则是 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 之后欠返仍然普遍存在的唯一原因:很多人升级了版本却没开这个开关。

iterative-scan.sql SQL
-- 会话级打开(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 万倍。

partial-index.sql SQL
-- 只为大租户 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 详谈)。

表 4.1 · 三条出路怎么选
策略适用选择性召回写入 / 运维成本限制
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。

diagnose.sql SQL
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 想要的效果:双榜共识的结果排最前。

稠密:emb <=> q 1. 文档 A 2. 文档 C 3. 文档 B 词法:BM25 / tsvector 1. 文档 B 2. 文档 A 3. 文档 D RRF 融合 Σ 1/(k+rank) k ≈ 60 融合排名 A · B · C · D
图 4.3两条召回腿各出一个排名,RRF 按名次融合。注意:文档 A 在两个榜单都靠前,融合后稳居第一;只在词法榜出现的 D 仍被纳入——这正是 hybrid 兜住"稠密漏掉的关键词命中"的方式。
hybrid-rrf.sql SQL
-- 两条召回腿各取 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

先合上教程,把答案写在纸上或编辑器里,再点开对照。

  1. 用一句话说清:WHERE tenant_id = 42 ORDER BY embedding <=> $1 LIMIT 10 为什么会只返回 4 条?根因在哪一章的哪条规则?
  2. tenant=42 命中全表 0.5%(极稀疏)。iterative scan、partial index、B-tree 精确排序,你优先选哪个?为什么另外两个不合适?
  3. 升级到 0.8.0 后欠返还在。最常漏了哪一步?
  4. hybrid 检索里,为什么用 RRF 按"排名"融合,而不是把 cosine 距离和 BM25 分直接相加?
答案(先做完再展开)
  1. 向量索引一次只按距离吐 ef_search(默认 40)个候选,WHERE 在之后才筛;命中率 10% 时筛剩 ~4 条。根因是 §1.4:向量索引是排序索引、只管 ORDER BY+LIMIT,过滤被迫后置(与 §3.1 可见性重检同构)。
  2. 命中极稀疏(0.5%)时优先 B-tree + 精确排序:命中行少,精确算距离又快又准,无近似误差。iterative scan 会一路扫到 max_scan_tuples 上限仍可能补不够;partial index 对单值有效但 0.5% 散落在大量行间、且若过滤值多则不可维护。
  3. 没执行 SET hnsw.iterative_scan = relaxed_order(或 strict_order)。这个开关默认 off,升级版本不会自动启用。
  4. 因为两者量纲不可比: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 精确。关键是先量出每个租户的行数分布,再决定阈值。