RAG Metadata Filter 设计:从字段选型到多租户隔离
很多 RAG 系统上线之后第一个真实痛点不是”检索不准”,而是”检索到了不该检索到的东西”——A 客户看到了 B 客户的文档片段,2023 年的失效政策条款排在了 2026 年的新条款前面,测试环境的草稿内容混进了生产回答。这些问题的根源几乎都指向同一处:Metadata Filter 设计得太晚,或者太随意。
Metadata Filter 不是”加几个字段方便筛选”这么简单,它决定了检索的正确性边界、隔离边界和性能边界。这篇文章讲清楚怎么设计这层过滤。
一、Metadata Filter 解决什么问题
向量检索本质上是”语义最相似”的排序,它不知道也不关心业务规则。比如:
- 用户 A 的检索请求,绝对不能返回用户 B 的私有文档(租户隔离)
- 已下线的产品手册不该出现在当前版本的问答里(时效性过滤)
- 内部草稿不该被外部客服机器人引用(可见性过滤)
- 中文用户的问题应该优先匹配中文文档,而不是同语义的英文原文(语言过滤)
这些规则语义检索模型学不出来,必须靠结构化的 metadata 字段和过滤条件强制约束。可以把 Metadata Filter 理解成向量检索之外的”第二套查询语言”——向量负责语义相关性排序,metadata 负责业务正确性的硬边界。
两者的关系要分清楚:语义相似度决定排序,metadata 过滤决定候选集合的合法性。如果把该属于过滤层的规则塞进 embedding(比如训练一个能”感知租户”的向量模型),会让向量空间变得又大又脏,还是没能提供硬隔离保证。
二、字段选型:只加真正需要过滤的字段
常见的设计误区是把能拿到的字段都塞进 metadata——文档标题、作者、创建时间、修改时间、来源系统、页码……结果是 metadata 越来越臃肿,索引变慢,还没解决真正的问题。
字段选型只问一个问题:这个字段是否会出现在查询时的 WHERE 条件里? 如果答案是否,它应该放进 chunk 的正文或者存进关联的关系型数据库,通过 chunk_id 二次查询,而不是塞进向量库的 metadata。
一般 RAG 系统的高频过滤字段就四类:
| 类别 | 典型字段 | 用途 |
|---|---|---|
| 租户/权限 | tenant_id、org_id、visibility | 硬隔离,必须过滤,不可缺省 |
| 时效性 | valid_from、valid_to、is_latest | 排除失效版本 |
| 内容分类 | doc_type、language、source | 缩小候选范围,提升相关性 |
| 生命周期 | status(draft/published/archived) | 排除未发布或已归档内容 |
超出这四类的字段,先问自己”过去三个月有没有真实查询用到它”,没有就不要加。字段越少,索引维护成本越低,过滤命中率越可预测。
三、预过滤 vs 后过滤:性能取舍的核心决策
这是 Metadata Filter 设计里最容易被忽视、但对性能影响最大的一环。
后过滤(Post-filter):先做 Top-K 向量检索,拿到结果后再用 metadata 条件筛掉不符合的。实现简单,但有个致命问题——如果过滤条件命中率低(比如某租户的文档只占索引的 2%),Top-K 里可能一个符合条件的结果都没有,导致检索”假失败”。常见的临时补救是把 K 调大(比如从 10 调到 200)再过滤,但这只是把问题往后推,延迟和成本同步上升。
预过滤(Pre-filter):先用 metadata 条件缩小候选集合,再在缩小后的子集里做向量检索。多租户场景几乎必须用这种模式,因为它从根本上保证了”检索池”本身就是合法的,不存在过滤后结果不够的问题。代价是如果索引不支持高效的过滤下推(filter pushdown),预过滤可能退化成全表扫描再向量比对,性能反而更差。
判断标准很简单:凡是涉及硬隔离(租户、权限)的过滤,一律用预过滤,且必须确认向量库支持过滤下推;凡是软性的相关性调节(比如语言偏好、时效性加权),可以用后过滤或者在排序阶段做加权,不必强制预过滤。
四、主流向量库的过滤语法与实现方式对比
不同向量库对 metadata 过滤的支持程度差异很大,选型时这一点常被低估(选型的性能维度可参考 2026 向量数据库对比,这里只聚焦过滤能力)。
# Qdrant:原生支持带索引的 payload 过滤,filter 下推到 HNSW 图遍历阶段
client.search(
collection_name="docs",
query_vector=query_embedding,
query_filter=Filter(
must=[
FieldCondition(key="tenant_id", match=MatchValue(value="org_123")),
FieldCondition(key="status", match=MatchValue(value="published")),
]
),
limit=10,
)
# pgvector:走标准 SQL WHERE,过滤效率取决于是否给 metadata 列建了 B-tree 索引
SELECT id, content
FROM documents
WHERE tenant_id = 'org_123' AND status = 'published'
ORDER BY embedding <-> $1
LIMIT 10;
# Milvus:需要显式声明标量字段并建索引,否则过滤会退化成暴力扫描
几个实践中要注意的差异点:
- Qdrant / Weaviate 这类原生支持 payload 索引的库,过滤条件可以下推到近似检索图的遍历阶段,预过滤性能损失较小,适合租户数多、隔离要求高的场景。
- pgvector 本质是 Postgres 扩展,metadata 过滤就是普通 SQL WHERE,只要给过滤字段建好索引(尤其是 tenant_id 这种高选择性字段),性能可预测,运维团队也更熟悉。
- Milvus 的标量字段过滤需要显式建索引,容易被忽略——新加一个 metadata 字段忘了建索引,过滤就会静默退化成全量扫描,延迟会突然上升但不会报错,排查起来比较隐蔽。
五、多租户隔离:metadata 过滤之外还要加一层保险
只靠 metadata 过滤做租户隔离,有个隐患:一旦查询代码里漏写了 tenant_id 条件(比如新写的一个后台批处理任务忘了加过滤),就会直接读到跨租户数据,而且这种 bug 很难在测试环境暴露,因为测试环境往往只有一个租户的数据。
更稳健的做法是双重保险:
- 应用层:所有检索入口统一走一个封装函数,tenant_id 作为必填参数,不允许绕过这层直接调用向量库 SDK。
- 存储层:如果向量库支持(比如 Qdrant 的多集合、Milvus 的分区、Postgres 的 Row-Level Security),按租户物理或逻辑分片存储,即使应用层过滤条件写漏了,底层也不会跨租户返回数据。
对高安全要求场景(金融、医疗、法务类客户数据),建议直接按租户拆分成独立的 collection/partition,而不是共享一个大 collection 靠 metadata 区分——物理隔离出问题的概率远低于逻辑隔离。
六、时效性过滤:一个容易漏掉的细节
文档更新后,旧版本的 chunk 如果没有及时清理或标记失效,会和新版本同时存在于向量库里,两者语义高度相似,检索时经常一起被召回,导致回答里新旧信息混杂。
处理方式有两种:
- 硬删除:文档更新时,把旧版本的所有 chunk 从向量库里物理删除。简单直接,但如果需要审计追溯旧版本内容就麻烦了。
- 软失效:给每个 chunk 加
is_latest或valid_to字段,更新时把旧版本标记为失效,检索时用 metadata 过滤条件排除。保留了历史可追溯性,但要求所有检索路径都不能忘记加这个过滤条件——这也是为什么第五节强调要用统一封装函数,漏加一次过滤条件的后果是新旧信息混着回答用户。
七、常见误区
- 误区一:把租户隔离也交给”语义”去区分。指望向量模型自己学会”这是别的租户的内容,应该打低分”,这在语义上根本不成立,必须靠硬过滤。
- 误区二:metadata 字段类型不统一。同一个字段有的文档存字符串
"2026-09-11",有的存 Unix 时间戳,过滤条件写起来要适配两种格式,查询变复杂还容易出 bug。写入时就该做 schema 校验。 - 误区三:过滤条件和 chunking 策略脱节。如果 chunk 切分时把不同来源、不同时效的内容合并进同一个 chunk(常见于跨文档拼接摘要),metadata 过滤就无法精确到内容级别。Chunking 阶段的边界设计可以参考 RAG Chunking 策略的量化评测,过滤字段的粒度要和 chunk 的粒度对齐。
八、相关阅读
- Hybrid Search 权重调优实战
- RAG Chunking 策略的量化评测
- RAG Reranker 选型与评测
- 2026 向量数据库对比:Chroma/Qdrant/Milvus/pgvector
Metadata Filter 设计好了,检索的正确性边界才算真正立住,后续调 Hybrid Search 权重或加 Reranker 才有意义。