Post-filter 方式存在安全漏洞和排序正确性缺陷,应将访问控制推入 SQL 查询本身,防止越权结果进入候选集。
在 RAG 系统中,大多数权限 bug 在代码审查时看起来人畜无害。你取出 top-k 个最近邻文本块,然后把用户无权查看的丢掉。读起来完全正确。其实不然,而且这种失败是静默的。
我想告诉你为什么后置过滤是错误的位置,以及如何将访问控制决策推送到检索查询内部,使得未授权的文本块根本不会被选中、不会被排名、不会被计数。我会基于我的一个小项目 vaultrag 来具体说明——这是一个支持权限感知的 RAG 项目,但这个技术可以迁移到任何 Postgres + pgvector 技术栈。
这里有一个我希望你停止编写的模式。
# fetch top 10 by vector distance
rows = fetch_nearest(query_embedding, limit=10)
# then remove what the user cannot see
visible = [r for r in rows if user_can_see(r)]
这里有两个问题。
第一个是正确性 bug。你的 LIMIT 10 是在表中每个文本块上运行的。如果十个最近邻中有八个属于该用户无权阅读的文档,你会把它们过滤掉,只返回两个结果。用户感受到的是搜索坏了,而不是安全边界被触碰到了。他们有权看到的相关材料排在第 11 位,根本没进入候选集。
第二个更糟糕,这也是值得关注的理由。每一条触碰原始结果集的代码路径,现在都可能成为泄露的入口。记录过滤前的行、指标计数器、调试端点、按查询文本做 memoize 的缓存层——每一个都能看到用户无权看到的文本块。过滤器只保护了一个出口。它上游的一切都在拿着禁止访问的数据。
修复方案是让持有这些数据在一开始就结构上不可能。
从每个文档的显式访问控制列表开始。在 vaultrag 中,一个文档有一个由 principal 组成的 ACL,其中 principal 可以是用户 ID 或组名。
CREATE TABLE documents (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
deleted_at TIMESTAMPTZ
);
CREATE TABLE doc_acl (
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
principal TEXT NOT NULL
);
CREATE TABLE chunks (
id BIGSERIAL PRIMARY KEY,
doc_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
text TEXT NOT NULL,
embedding VECTOR(1536)
);
关键的设计选择是在服务器端解析调用者的 principal,而不是从请求中获取。如果客户端能自行声明自己的组成员身份,那 ACL 就是装饰品。
async def resolve_principal(conn, user_id: str):
async with conn.cursor(row_factory=dict_row) as cur:
await cur.execute("SELECT id, groups FROM users WHERE id = %s", (user_id,))
row = await cur.fetchone()
if row is None:
return None
# principals we match against: the user id plus every group they belong to
return [row["id"], *(row["groups"] or ())]
这个技术的核心是一个公共表表达式 visible,它定义了当前调用者可以看到的文本块的集合。查询中所有其他部分都从 visible 读取,而不是从 chunks。没有任何路径可以从别处开始。
WITH visible AS (
SELECT c.id, c.doc_id, c.text, c.embedding
FROM chunks c
JOIN documents d ON d.id = c.doc_id
WHERE d.deleted_at IS NULL
AND EXISTS (
SELECT 1 FROM doc_acl a
WHERE a.doc_id = d.id
AND a.principal = ANY(%(principals)s)
)
),
ranked AS (
SELECT id, doc_id, text
FROM visible
WHERE embedding IS NOT NULL
ORDER BY embedding <=> %(embedding)s::vector
LIMIT %(limit)s
)
SELECT * FROM ranked;
读一下操作顺序,因为这就是全部要点。EXISTS 对 doc_acl 的检查在 ORDER BY ... <=> 之前运行。近邻排序和 LIMIT 操作在 visible 上,而 visible 已经排除了禁止访问的文本块。你的 top-k 现在是用户有权查看的 top-k。前述的正确性 bug 已经消失,泄露面也随之消失,因为查询规划器永远不会将禁止访问的行物化到候选集中。
有一个值得借鉴的细节:使用 EXISTS 而不是普通的 JOIN doc_acl。如果一个文档有三条匹配的 ACL 记录,用 JOIN 会让每个文本块乘以三份复制。EXISTS 在第一次匹配时就会短路,因此每个可见文本块只出现一次。
同样的 visible CTE 与混合搜索也能干净地组合。在 vaultrag 中,向量检索分支和全文检索分支都在融合之前从 visible 中选择,然后通过 reciprocal rank fusion 合并,因此两个分支都不会暴露另一个分支无权访问的文本块。
WITH visible AS ( ... ), -- the boundary, defined once
vec AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> %(embedding)s::vector) AS rank
FROM visible WHERE embedding IS NOT NULL
ORDER BY embedding <=> %(embedding)s::vector LIMIT %(candidates)s
),
kw AS (
SELECT id, ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(tsv, websearch_to_tsquery('english', %(q)s)) DESC) AS rank
FROM visible WHERE tsv @@ websearch_to_tsquery('english', %(q)s)
LIMIT %(candidates)s
)
-- fuse vec and kw, both already scoped to visible
因为两个分支都从 visible 读取,函数中没有任何代码路径可以对未授权文本块进行排名。这是一个你可以对查询陈述的属性,而不是你寄希望于过滤器来强制执行的行为。
将 ACL 推入查询只是移动了边界,并没有让边界消失。EXISTS 子查询对每个候选文档运行,在大表上规划器的选择很关键。你需要一个让 ACL 检查足够廉价的索引:
CREATE INDEX ON doc_acl (doc_id, principal);
这里与 pgvector 索引存在真实的张力。HNSW 或 IVFFlat 索引能让你快速得到近似近邻,但近似发生在扫描内部你的 WHERE 过滤器应用之前。当过滤器具有高度选择性——意味着用户只能看到极小一部分文档时——向量索引可能返回一个页面全是候选、然后几乎全部被过滤掉,你就会得到比 LIMIT 要求少得多的结果。这就是知名的 filtered-search 问题。存在缓解手段:调高 hnsw.ef_search、使用分区,或者在较新版本的 pgvector 上使用迭代索引扫描。在你的数据上测量它。不要因为 CTE 是正确的就假设它是免费的。
当权限检查是排名的前置条件而不是事后的清理步骤时,正确性和安全性都会提升。在一个 CTE 中一次性定义可见集合,让搜索的每个分支都从它读取。你获得的保证是结构性的:未授权的文本块不是被后期过滤掉的,它根本不会被选中。
如果你想要一个完整可用的参考实现,检索查询、ACL schema 和服务器端 principal 解析都在我的项目中:github.com/AgentPostmortem/vaultrag。借鉴这个 CTE 并将 ACL 模型适配到你自己的租户规则中。