用数据库权限守住多租户RAG边界
文章以假设的跨租户检索场景说明:新增Agent工具若遗漏租户过滤,其他客户的文档就可能进入模型上下文。方案围绕Next.js、PostgreSQL、pgvector、行级安全及集成测试组织租户隔离。
AI 深度解读
多租户 RAG 最危险的错误,可能是一段「找得很准」的资料:它与问题高度相关,却属于另一家公司。等这段文字进入模型上下文,数据就已经越过权限边界,再严密的提示词也补不回来。
原文给出了一套 Next.js、PostgreSQL、pgvector 和 OpenAI API 的参考方案:服务端确认租户,SQL 限定范围,数据库行级安全兜底,再用集成测试验证隔离。它值得读的地方,是把权限检查从单个接口里的约定,变成贯穿检索链路的约束。
租户身份不能由浏览器说了算
作者设想的故障很常见:第一个搜索接口检查了组织成员身份,也加了 tenant_id 条件;后来新增 agent 工具,开发者复制相似度查询,却漏掉租户过滤。搜索依然正常,回答依然流畅,只是上下文混进了其他公司的资料。
这是一个假设场景,原文没有报告真实生产事故。
修复要从身份来源开始。/org/acme/knowledge 只能表达用户想访问哪个组织,不能证明他有权访问。服务端必须根据登录身份查询成员关系,再把验证后的租户 ID 交给检索函数。请求 JSON、文件名、上传文档的元数据,都不能决定数据归属。
归属还要写进每个 chunk。原文在 kb_documents 和 kb_chunks 中都保存 tenant_id,并用 (tenant_id, document_id) 复合外键关联文档。这样,数据库会拒绝「声明属于 A 租户,却指向 B 租户文档」的分块,避免错误数据藏在查询条件后面。
入库应在事务中写入文档和分块。异步生成 embedding 时,任务携带服务端确认的租户与文档 ID,worker 写入前再次核对归属。
这里有个明确前提:示例允许组织成员搜索本组织全部文档。私有文件夹、角色限制、文档级访问控制列表(ACL),仍需进入检索条件。
租户隔离只回答属于哪家公司,不能替代公司内部的文档权限。
SQL 过滤和 RLS 为什么要同时保留
原文要求检索 SQL 明确包含 WHERE c.tenant_id = $1,同时启用行级安全(RLS)。前者让权限边界在代码审查时可见,后者防止新增接口漏写过滤条件。
下面这段配置让数据库按当前事务的租户上下文限制读写:
ALTER TABLE public.kb_documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.kb_documents FORCE ROW LEVEL SECURITY;
ALTER TABLE public.kb_chunks ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.kb_chunks FORCE ROW LEVEL SECURITY;
CREATE POLICY kb_documents_tenant_scope
ON public.kb_documents
FOR ALL TO app_runtime
USING (
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
);
CREATE POLICY kb_chunks_tenant_scope
ON public.kb_chunks
FOR ALL TO app_runtime
USING (
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
)
WITH CHECK (
tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::uuid
);
GRANT USAGE ON SCHEMA public TO app_runtime;
GRANT SELECT, INSERT, UPDATE, DELETE
ON public.kb_documents, public.kb_chunks TO app_runtime;
要盯住 USING 和 WITH CHECK:前者约束可见、可修改的已有行,后者约束插入或更新后的归属。缺少租户设置时,比较不会匹配任何租户行。
连接池必须使用受限的 app_runtime,它不能是超级用户、表所有者,也不能具有 BYPASSRLS。迁移使用独立的高权限角色。
检索在 BEGIN 后执行 SELECT set_config('app.tenant_id', $1, true),最后 COMMIT,失败时 ROLLBACK。第三个参数 true 把设置限定在事务内,事务结束后恢复,避免连接复用时残留上一次请求的租户。其他代码也不能偷偷设置会话级租户值。
但 app.tenant_id 只是 PostgreSQL 自定义设置,没有密码学身份保证。能任意执行 SQL 的攻击者通常也能修改它。RLS 在这里防的是遗漏和误用,参数化查询、最小权限仍然必需。
检索质量可以调,授权边界不能跟着调
示例要求 PostgreSQL 13 或更新版本、pgvector 0.8.0 或更新版本,使用 text-embedding-3-large 的 3,072 维向量。
这里有个实现细节:pgvector 的 HNSW 索引对 vector 最多支持 2,000 维,对 halfvec 最多支持 4,000 维。因此原文保存 vector(3072),索引则转换为 halfvec(3072),配合 halfvec_cosine_ops。
查询先用半精度索引取 30 个候选,再按原始向量的 <=> 距离重排,返回 6 个。30 和 6 都是起始值,没有通用最优保证;重排只能调整已有候选,无法找回没进入候选集的真正近邻。
另一个容易混淆的地方是过滤时机。权限必须成为检索 SQL 的条件,不能取回文本后交给 UI 筛选;但在执行层,pgvector 会先扫描近似索引,再应用过滤。其他租户的向量可能挤占扫描候选,让当前租户拿不到足够结果,却不会因此获得返回资格。
原文设置 SET LOCAL hnsw.iterative_scan = 'strict_order'。pgvector 0.8.0 起提供的迭代扫描,可以在配置限制内继续寻找过滤后的匹配。规模更大时可考虑部分索引或分区,小知识库则可能用精确扫描更省事。具体选择仍需测召回率和延迟。
生成端使用 OpenAI Responses API,即 client.responses.create;通过 OPENAI_GENERATION_MODEL 配置模型,示例默认 gpt-6.1-sol。这是原文示例,原文也要求部署前核对模型目录和价格。client.embeddings.create 设置 dimensions: 3072,检索函数检查向量长度和数值有限性。
Next.js 路由通过应用自定义的 requireActiveTenant() 验证成员关系,只接收问题文本,限制为非空且不超过 2_000 个字符。模型只收到已经授权的资料,不决定租户。关于忽略文档内指令的提示词用于降低提示注入风险,不能充当权限检查。
我的判断
这套方案最有价值的是让应用查询、数据约束、RLS 和测试互相补位。只依靠开发者记住加过滤条件,新增 agent 工具时很容易失守;只依靠 RLS,又可能因运行角色或上下文设置错误而落空。
不过,它是一套参考模式,没有可直接接入的完整认证实现,也没有召回率、延迟或成本的对照数据。半精度索引、30 个候选和 6 个结果是否合适,需要团队自己验证,不能当成作者已经实测证明的最佳配置。
作者提出的隔离测试很实用:给两个租户写入相同、有效、非零的 3,072 维向量,让跨租户内容在相似度上同样有竞争力,再验证它进不来。下面保留测试的核心断言:
const results = await retrieveChunks(tenantA.id, matchingEmbedding);
const retrievedText = results.map((row) => row.content).join('\n');
expect(retrievedText).toContain('TENANT-A-OWN-CONTENT');
expect(retrievedText).not.toContain('ORCHID-ACCOUNT-SECRET');
要看的是第二条断言:B 租户的标记文本必须不存在。这个 Vitest 测试需要真实测试数据库,并通过 app_runtime 检索;mock PostgreSQL 无法证明 RLS 生效。原文提供了测试代码,但没有提供执行结果。
我还会把测试推进到模型调用前,检查实际送入的上下文。数据库结果正确,不代表后续拼接没有混入另一条来源。返回的 sources 也只表示提供过哪些资料,不能证明模型正确引用;界面要展示已验证引用,就应校验引用编号是否属于检索结果。
能拿来做什么
- 给 Next.js 知识库接口统一租户解析入口,再检查搜索、导出和 agent 工具是否都使用已验证身份。未授权组织选择、缺失租户上下文、跨租户外键都应有失败测试。
- 给 Node 检索层固定事务模板和受限数据库连接,入库、删除及后台 worker 同样设置事务级租户上下文。不要让后台任务通过管理员连接绕过策略。
- 用实际租户规模分布比较精确扫描与 HNSW 的召回率、延迟,单独验证半精度索引和候选数量,避免把返回不足误判成知识库没有答案。
- 检查缓存、引用和日志。
Cache-Control: no-store不能替代应用缓存的租户与权限键;撤权、删除文档时还需清理衍生答案。补齐限流和运行日志,但默认不要记录用户问题及检索原文。