你的应用有基于角色的权限,你的文档管理系统有文件夹级别的 ACL。但你的 RAG 聊天机器人两者都没有——它只有一个共享的向量索引,只要问对问题,它就会乐意把工资表交给任何人。
这不是假设情况。这是企业级 RAG 部署中最常见的架构错误,而且几乎从来不会在演示阶段被发现。演示用的是一套文档、一个用户。生产环境里有人事记录、法律合同、董事会文件,还有 200 名员工——一旦其中两名员工对同一个文件夹拥有不同的权限,大多数 RAG 管道就会悄无声息地停止执行任何权限控制。
差距所在:UI 懂权限,检索不懂权限
反复出现的模式是这样的:
- 公司在内部文档存储(Google Drive、SharePoint、Confluence 或某个 DMS)上构建了一个 RAG 聊天机器人。
- 聊天界面正确校验了用户身份,并漂亮地展示了一份按权限过滤的文档列表。
- 但聊天机器人背后的向量索引是一次性从所有内容——人事、财务、法务、工程——构建的,没有任何逐块(chunk)记录谁有权查看什么。
- 用户提出问题。检索步骤在整个索引中搜索语义上最接近的匹配,找到一个用户从未获得访问权限的文档中的片段,然后直接把它作为上下文传给 LLM。
- LLM 恰如其分地完成了自己的工作——用那个片段回答了问题,包括用户本不该看到的具体内容。
没有报错,没有审计标记。界面甚至从不显示来源文档,因此没人会注意到检索已经越过了权限边界——直到它作为安全审查中的一项发现浮出水面,或者更糟,成为监管机构问询的对象。
这就是为什么"我们已经有 SSO 和应用层的基于角色的访问控制了"和"我们的 RAG 管道真正强制执行了访问控制"并不是同一回事。SSO 控制谁能登录。应用层的 RBAC 控制能看到哪些界面。但两者都没有触及检索这一步——语义相似度搜索根本不知道、也不关心自己正在读取的是谁的文档。
从等保2.0(尤其是涉及 OT/ICS 场景的要求)、《个人信息保护法》(PIPL)以及《数据安全法》的角度看,这类泄露绝不只是内部尴尬事件。如果泄露的内容涉及员工或客户的个人信息,或属于需要数据不出境管控范围的重要数据,这就构成了一起可能需要向监管机构报告的安全事件。
为什么这个问题在测试阶段发现不了
RAG 中的访问控制缺陷能一路存活到生产环境,原因有三:
- 单租户测试数据——大多数试点项目在没有敏感度分级的演示语料库上运行,根本没有可泄露的内容。
- 单用户测试——一个开发者,一个管理员账号,只测试正常路径。问题只有在权限更窄的第二个用户开始提问时才会暴露。
- 无论对错,LLM 都回答得很自信——权限泄露看起来不像报错,反而像系统运行良好。聊天机器人准确地回答了问题。这正是问题所在。
三种能在检索层真正强制执行权限的模式
1. 查询时的元数据过滤
最基本的修复方案:给每个片段打上从源文档继承来的访问控制元数据,并将其作为强制过滤条件应用在相似度搜索内部——而不是搜索之后。
在我们的 pgvector 教程 中的 document_chunks 表结构基础上,新增一张权限表:
-- 记录每份文档允许哪些组/角色查看
CREATE TABLE document_acl (
document_id BIGINT REFERENCES documents(id) ON DELETE CASCADE,
principal TEXT NOT NULL, -- 例如 'role:finance', 'user:alice@company.com', 'group:leadership'
PRIMARY KEY (document_id, principal)
);
CREATE INDEX ON document_acl (principal);
def semantic_search_with_acl(
query: str,
user_principals: list[str], # 例如 ["user:alice@company.com", "role:finance", "group:apac"]
top_k: int = 5,
) -> list[dict]:
query_embedding = get_embedding(query)
conn = psycopg2.connect(DB_URL)
cur = conn.cursor()
try:
cur.execute(
"""
SELECT dc.id, dc.content, d.title, d.source_url,
1 - (dc.embedding <=> %s::vector) AS similarity
FROM document_chunks dc
JOIN documents d ON d.id = dc.document_id
WHERE EXISTS (
SELECT 1 FROM document_acl a
WHERE a.document_id = d.id
AND a.principal = ANY(%s)
)
ORDER BY dc.embedding <=> %s::vector
LIMIT %s
""",
[query_embedding, user_principals, query_embedding, top_k],
)
return cur.fetchall()
finally:
cur.close()
conn.close()
这个过滤条件在查询内部执行,而不是对已取回结果的事后检查。这个区别很关键:检索之后再过滤,意味着被禁止的内容早已被取入应用程序的内存中,然后才决定丢弃——泄露在网络层面已经发生了;如果 post-filter 步骤存在漏洞,内容仍然会传到 LLM 那里。
2. 行级安全(RLS)作为兜底防线
应用层的过滤只有在查询该表的每一条代码路径都正确实现时才可靠。任何忘记写 WHERE EXISTS 条件的脚本、管理工具或未来的集成,都会重新打开这个漏洞。Postgres 的行级安全(Row-Level Security,RLS)在数据库层强制执行同一条规则,因此即便是应用开发者未曾预料到的查询也会被覆盖:
ALTER TABLE document_chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY chunk_access_policy ON document_chunks
USING (
EXISTS (
SELECT 1 FROM document_acl a
WHERE a.document_id = document_chunks.document_id
AND a.principal = ANY(current_setting('app.user_principals')::text[])
)
);
# 在此连接上运行任何查询之前,先设置该会话的 principals
cur.execute("SET app.user_principals = %s", [user_principals])
这是纵深防御,而不是应用层过滤的替代品——它能阻止某个接口中的一个权限漏洞演变成整个系统的数据泄露事件。
3. 处理权限变更与过时的 embedding
片段和 embedding 一旦写入就是静态的。但权限不是——员工调离财务团队、合同被重新分类、离职承包商的访问权限应立即消失。需要做两件事:
- 同步 ACL,而不是重新 embedding 内容——权限变更时,直接更新
document_acl。不需要重新 embedding 文档,因为访问控制表与向量数据是分开管理的;撤销权限只是一次元数据写入,而不是重跑整条管道。 - 来自权威数据源的同步任务——如果文档存放在 Google Drive 或 SharePoint 中,运行一个定期任务拉取当前的文件夹/文件权限,并与
document_acl进行核对——ACL 偏离源系统的速度往往比大多数团队预期的要快,尤其是在存在嵌套文件夹继承的情况下。
架构概览
flowchart TD
A["用户查询 + 身份令牌"] --> B["解析用户principals\n角色 组 直接授权"]
B --> C["将查询embed"]
C --> D["带过滤的相似度搜索\n查询内部完成ACL检查"]
E["document_acl表"] --> D
F["源系统权限同步\nDrive SharePoint DMS"] --> E
D --> G["仅返回被允许的片段"]
G --> H["LLM生成答案"]
H --> I["检索审计日志\n谁 什么 何时 匹配的principal"]
可审计性:合规团队真正会问的问题
一个懂权限的检索层能提供一个不懂权限的检索层无法提供的东西——一份经得起审查的日志。每一次检索事件都应记录发起查询的身份、为该身份解析出的 principal,以及哪些文档原本有资格被返回——无论它们是否是排名最高的匹配结果。当安全审查或监管机构问"你们如何证明 AI 系统没有暴露受限数据"时,能回答这个问题的是这份日志,而不是一句承诺。
常见问题
这不是前端权限检查已经在处理的事情吗?
不是。前端检查控制的是用户能点击什么,它无法看到检索步骤内部把什么内容拉进了 LLM 的上下文窗口。这是两个独立的执行点,而通常只有一个被真正建立起来。
这会拖慢检索速度吗?
当 document_acl.principal 建有索引时,一个带索引的 EXISTS 过滤条件带来的开销只有个位数毫秒级别,相比 embedding 生成和 LLM 推理的耗时可以忽略不计。
如果权限比扁平角色复杂得多——嵌套文件夹、继承的组、临时访问授权——该怎么办?
在同步阶段就把每条继承路径建模为 document_acl 中的一行,而不是在查询时计算继承关系。在同步任务阶段把层级结构拉平,让查询本身保持为一个简单的成员关系检查。
如果我们的文档没有那么敏感,还需要这样做吗?
如果语料库中混合了任何级别的敏感内容——人事、法务、财务、客户个人信息——和普通内容,答案是需要。这种失败模式不是"偶尔有点尴尬",而是一次事后很难解释的信息披露,因为系统里没有任何东西把它标记为异常。
正在构建或审查一套需要真正强制执行访问控制的 RAG 管道——而不只是在一个毫无权限管控的索引前面套一层带权限的界面?请联系 hello@simplico.net——我们为泰国、日本及东南亚地区的企业客户设计并部署生产级 RAG 架构。
最新文章
- 工厂为何害怕ERP项目失败——一套同步层如何化解这个风险 July 15, 2026
- 为什么东南亚的半导体和电子制造商正在超越传统 MES 的能力边界 July 15, 2026
- 保护 Agentic SOC:提示词注入、日志投毒与新型内部威胁 July 15, 2026
- 从榴莲集散地到回收集散地:simpliDepot 如何管理物料回收业务 July 7, 2026
- 凌晨3点47分:一次开源SOC技术栈真实拦截入侵的全过程 July 2, 2026
- 不用自己开发的EV充电App:基于OCPP ID标签的二维码充电方案 July 2, 2026
