RAG 召回率上不去,很多人的第一反应是换 embedding 模型。但在带元数据过滤的检索里,更常见的原因是:向量索引先按距离取了 40 条候选,过滤条件一过只剩 3 条,LIMIT 10 永远凑不满。问题不在模型,在索引的取数顺序。
pgvector 0.8.0 引入的迭代索引扫描(iterative index scan),正是冲着这个缺口来的。这篇从机制到配置把它拆开,并给出什么时候该用、什么时候该换库的判断标准。
问题背景:过滤条件让向量索引"提前交卷"
典型场景是 SaaS 知识库:一张 doc_chunks 表存所有租户的切片,检索时带 tenant_id 和 source 过滤。HNSW(Hierarchical Navigable Small World,分层可导航小世界图)索引的查询过程是:从入口点出发,逐层贪心靠近查询向量,最终在底层取出 hnsw.ef_search 个候选(默认 40),再交给执行器做过滤。
关键机制在于顺序:候选集在过滤之前就已经固定。 如果 tenant_id = 'a' 只占全表 1%,40 个候选里期望命中 0.4 条。返回 0 条不是 bug,是设计使然。
传统解法各有代价:调大 ef_search 会让延迟线性上升且上限只有 1000;给每个租户建 partial index 会在租户数上百后索引爆炸、写入放大;把过滤条件下推成 SQL 子查询再排序,则退化为全表扫描。pgvector 0.8.0 给的是第四条路。
核心概念:迭代扫描把"取一次"改成"取到够为止"
迭代索引扫描的定义很直白:当过滤后结果不足 LIMIT 时,索引继续向后扫描,直到凑够行数或触达安全上限。它由三个 GUC 参数控制:
hnsw.iterative_scan:off(默认)/relaxed_order/strict_orderhnsw.max_scan_tuples:单次查询最多扫描的索引元组数,默认 20000hnsw.scan_mem_multiplier:扫描内存相对work_mem的倍数,默认 1
为什么这样设计: 无限扫描会让一个高选择性过滤的查询拖垮实例,所以必须有一个可预算的上限;而"是否保证严格按距离排序"是正确性与延迟的取舍,交给使用方显式声明,而不是隐式决定。
适用边界也很清楚:它修复的是"过滤后不足"的问题,不解决"过滤条件本身无法走索引"的问题。若过滤列没有 B-tree 索引,扫描仍会退化成逐行回表。
关键机制:relaxed_order 与 strict_order 的取舍
relaxed_order 允许返回结果不完全按距离升序------因为迭代过程中新扫到的行可能比已返回的更近。它更快,但任何基于分数阈值的截断都会失真 :你可能用第 10 条的分数做 score < 0.3 过滤,而真正最近的第 3 条还在后面没扫到。
strict_order 保证顺序正确,代价是可能需要扫描更多元组、占用更多内存。工程上的判断很直接:
- 只取 Top-K 做 RAG 上下文拼接 →
relaxed_order足够 - 要做分数阈值截断、去重合并、或多路召回后重排 → 必须
strict_order
配置示例(会话级生效,便于按接口区分):
sql
-- 建表与索引:1536 维对应 text-embedding-3-small
CREATE TABLE doc_chunks (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
source text,
content text,
embedding vector(1536)
);
-- 过滤列必须有 B-tree 索引,否则迭代扫描仍要回表逐行判断
CREATE INDEX ON doc_chunks (tenant_id, source);
CREATE INDEX ON doc_chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 关键:开启迭代扫描,并给扫描量一个预算
SET hnsw.iterative_scan = strict_order;
SET hnsw.max_scan_tuples = 40000; -- 约为默认值的 2 倍,按 P95 延迟调
SET hnsw.ef_search = 100;
SELECT id, content, embedding <=> $1 AS distance
FROM doc_chunks
WHERE tenant_id = 'tenant_a' AND source = 'faq'
ORDER BY embedding <=> $1
LIMIT 10;
LangChain 侧接入时有一个容易忽略的边界:langchain-postgres 的 PGVector 把过滤条件放在 JSONB metadata 上,而不是业务表的独立列。若 tenant_id 是独立列,要么把它冗余进 metadata,要么绕开封装直接写 SQL。
python
# 示例:langchain-postgres + OpenAI 兼容接口,版本以实际安装为准
from langchain_postgres import PGVector
from langchain_openai import OpenAIEmbeddings
emb = OpenAIEmbeddings(
model="text-embedding-3-small",
base_url="https://api.example.com/v1", # 兼容接口
api_key