摘要
做多租户 RAG 系统时,最大的红线是数据不能串。如果把 A 客户的业务文档检索出来喂给了 B 客户的大模型,就是严重的生产事故。本文从应届生做 SaaS 项目或企业级知识库的视角出发,拆解多租户 RAG 的三种隔离方案(物理隔离、逻辑隔离、混合隔离),重点给出基于元数据过滤(Metadata Filtering)的代码示例与防越权设计。读完你能理清不同业务规模下的架构选型思路,并掌握面试中关于 SaaS 架构和 RAG 权限隔离的高频考点。
关键词
RAG、多租户、数据隔离、向量数据库、权限控制、SaaS架构
正文
- 背景与问题
如果你正在开发一套 SaaS 知识库,或者公司内部不同部门的 RAG 问答系统,这往往意味着你的向量库要同时服务于多个"租户"(Tenant)。 读完本文,你能搞清楚怎样在检索链路中做好数据隔离,既防数据泄露,又兼顾查询性能与开发成本。
我在做示例项目时,最开始的 RAG 链路非常简单:把文档切片、Embedding、存入向量库,查询时计算相似度返回 Top-K。但当系统变成多租户架构后,问题随之而来:
数据混淆与越权检索:如果向量检索不加限制,用户查询时,系统可能会从别的业务线(其他租户)的文档库里召回相似度高的结果,导致敏感数据泄露。
资源抢占与性能下降:如果所有租户的向量都放在一个大索引里做全局检索,随着数据量膨胀,每次检索的性能会严重退化,且某个租户的高频查询会拖垮整个服务。
向量模型调用的成本分摊:多租户下如何计费、模型调用额度如何限制,都需要从架构层去考虑。
对于准备求职的同学来说,"多租户 RAG 设计"能证明你不仅懂大模型应用,还具备后端架构思维和数据安全意识。这比只会跑通一个单机版的 Demo 要有含金量得多。
- 核心概念/环境说明
在展开设计前,先对齐几个 RAG 与多租户的核心概念。
租户(Tenant):使用系统的一个独立客户或业务部门。在多租户架构中,每个租户的数据逻辑上或物理上是隔离的。
元数据过滤(Metadata Filtering):向量数据库在检索时,除了计算向量相似度(ANN),还可以根据 payload/metadata(例如 tenant_id)进行标量过滤,只从满足条件的候选集中返回结果。
隔离级别:业界常见的多租户隔离有三种级别:
物理隔离(独立数据库/集合)
逻辑隔离(共享集合,依靠字段区分)
混合隔离(按租户分级,大客户物理隔离,小客户逻辑隔离)
注:本文代码为简化版伪代码,采用本地 Python 环境 + 字典模拟向量库,聚焦"如何防越权检索"的逻辑演示。实际生产中,你可以替换为 Chroma、Milvus 或 Qdrant 等数据库。
- 核心内容:隔离方案与代码演示
3.1 多租户隔离的三种方案对比
在设计时,我们需要先根据业务规模做出选型。以下是三种常见方案的对比(适合直接作为面试或技术评审时的素材):
隔离方案 实现方式 优点 缺点 适用场景
物理隔离 每个租户独享一个数据库/Collection 绝对安全、性能隔离好 成本极高、后期运维困难 强监管行业(金融、医疗)或超大 B 端客户
逻辑隔离 共享 Collection,每条向量带 tenant_id 成本低、资源利用率高 存在越权风险,需强依赖代码规范 中小客户、SaaS 初创期
混合隔离 核心大客户物理隔离,长尾小客户逻辑隔离 兼顾成本与安全 架构复杂度上升 成熟的 SaaS 产品
对于大多数初中级开发者接触到的项目,逻辑隔离是最普遍的起点。接下来的重点,就是如何确保逻辑隔离的绝对安全。
3.2 防越权检索的实现思路
逻辑隔离最容易犯的错误是:仅仅在用户查询时,把 tenant_id 作为 Prompt 的一部分发给 LLM,而没有在向量检索层拦截。 这非常危险,因为大模型可能受到 Prompt 注入攻击,把越权数据读出来。
正确的做法是:在进入向量数据库检索的瞬间,必须强制携带当前登录用户的 tenant_id,通过元数据过滤,强行截断非本租户的数据。
3.3 可运行的代码示例
下面是一个用 Python 模拟的检索拦截流程。我们将展示一个没有做好隔离导致"串号"的反例,以及修复后的正例。
python
模拟的向量库(共享同一个集合)
每条文档包含 text 和 metadata (记录所属租户)
VECTOR_STORE = [
{"id": 1, "text": "A公司的年度财务审计报告是绝密。", "metadata": {"tenant_id": "company_A"}},
{"id": 2, "text": "B公司的项目排期计划。", "metadata": {"tenant_id": "company_B"}},
{"id": 3, "text": "A公司的员工薪资结构说明。", "metadata": {"tenant_id": "company_A"}},
]
def fake_embedding(text, query):
"""模拟向量相似度计算:只要有关键词命中,就算高分"""
简单用字符重叠模拟相似度
score = sum(1 for char in query if char in text) / max(len(query), 1)
return score
def unsafe_retrieve(query):
"""❌ 错误做法:没有做租户过滤"""
results = \[\]
for doc in VECTOR_STORE:
score = fake_embedding(doc"text", query)
if score > 0.2:
results.append(doc)
按相似度排序取 Top-K
results.sort(key=lambda x: fake_embedding(x"text", query), reverse=True)
return results:2
def safe_retrieve(query, tenant_id):
"""✅ 正确做法:检索时强制带上租户 ID 进行元数据过滤"""
if not tenant_id:
raise ValueError("非法的租户上下文,拒绝检索")
results = []
for doc in VECTOR_STORE:
# 关键步骤:先做权限隔离,再做相似度计算
if doc["metadata"]["tenant_id"] != tenant_id:
continue
score = fake_embedding(doc["text"], query)
if score > 0.2:
results.append(doc)
results.sort(key=lambda x: fake_embedding(x["text"], query), reverse=True)
return results[:2]
---------------- 测试执行 ----------------
current_tenant = "company_A"
query_text = "公司 年度 报告"
print("=== 反例:未做隔离 ===")
bad_results = unsafe_retrieve(query_text)
for r in bad_results:
print(f"命中文本: {r'text'}, 所属租户: {r'metadata''tenant_id'}")
print("\n=== 正例:做了租户上下文隔离 ===")
good_results = safe_retrieve(query_text, current_tenant)
for r in good_results:
print(f"命中文本: {r'text'}, 所属租户: {r'metadata''tenant_id'}")
3.4 预期运行结果
text
=== 反例:未做隔离 ===
命中文本: A公司的年度财务审计报告是绝密。, 所属租户: company_A
命中文本: A公司的员工薪资结构说明。, 所属租户: company_A
=== 正例:做了租户上下文隔离 ===
命中文本: A公司的年度财务审计报告是绝密。, 所属租户: company_A
在反例中,如果当前租户是 B 公司,用户通过特定的查询词,可能直接拉取到 A 公司的绝密财务信息。在正例中,我们在向量比对前强制通过 tenant_id 过滤,保证了底层的物理安全。
- 常见问题与排错
现象一:查询结果明明是 B 租户的,却混入了 A 租户的数据。
原因:检索链路中某处遗漏了租户标识传递,或者选用了不支持元数据过滤的轻量级向量检索库,导致只能用 Python 内存过滤。解决:必须在全链路(API网关、Controller、Service、向量库)透传当前上下文,并在存入和检索时严格核对 tenant_id。
现象二:带过滤条件后,向量检索变慢。
原因:数据库没有建立针对 tenant_id 的标量索引。解决:在创建 Collection 时,将 tenant_id 设置为分区键(Partition Key)或建立标量索引,利用底层引擎加速过滤(如 Milvus 的 partition、Qdrant 的 filterable payload index)。具体配置以所用向量库的官方文档为准。
现象三:黑客通过 Prompt 注入,让大模型"忽略限制"读取其他租户数据。
原因:仅在 Prompt 层做防护,没有在系统层拦截。解决:不要指望大模型守规矩,在输入 LLM 之前,从向量库召回的数据已经是被限定的(通过上面的 safe_retrieve),LLM 无论如何也无法编造出未召回的数据。
- 优化建议与适用边界
动态的 Collection 命名设计。对于物理隔离,可以设计统一的命名规范,如 tenant_{id}_documents,在应用层动态拼接 Collection 名称进行连接。
分级处理方案。初期可以采用纯逻辑隔离。当你发现某个大客户的 tenant_id 占用了全库 50% 以上的数据量时,可以开发一个平滑迁移脚本,将该客户的向量数据拆分到单独的 Collection 中,实现降本增效。
适用边界。逻辑隔离适用于对成本极度敏感、租户数据安全风险相对可控的 SaaS 场景。如果你的产品是面向医院、银行等机构,或者面临等保合规要求,必须考虑物理隔离。
- 求职与面试关联
多租户架构结合 RAG 是当前非常新颖且具有区分度的考点。
Q1:让你设计一个 SaaS 版的知识库,不同客户的数据怎么隔离?
思路:从业务规模出发。首先说明三种隔离策略(物理、逻辑、混合)各自的优缺点。接着说明在检索链路中必须透传 tenant_id 进行元数据过滤,严禁在 LLM 层或 Prompt 层做权限控制。
Q2:逻辑隔离下,如何防止向量检索的越权?
思路:强调"先过滤,后计算"原则。在请求到达向量数据库层时,必须带有当前登录态解析出的 tenant_id,作为强限制条件(Metadata Filter)加入查询。遇到不支持过滤的库(比如早期的 FAISS),则需在应用层将结果按 tenant_id 二次清洗,但这会牺牲性能。
Q3:多租户场景下,向量库的 Collection 应该怎么设计?
思路:不要全放在一个大索引里。如果采用逻辑隔离,建议将 tenant_id 设为标量索引字段或 Partition Key。如果数据量差异极大,采用混合隔离,针对大 B 客户单独建 Collection。
Q4:为什么只做数据隔离不够,还要做 Prompt 隔离?
思路:即使数据只召回该租户的,如果 Prompt 上下文中泄露了其他租户的 ID 或敏感信息,也会被 LLM 利用。更关键的是,大模型容易被 Prompt 注入攻破,系统必须在向量检索层硬拦截,而不是在 Prompt 中提醒"请不要泄露 A 公司数据"。
Q5:在你的简历项目中,你是怎么处理多租户的?
思路:建议在作品里如实写明实现程度。例如:"在示例项目中,采用了逻辑隔离方案,通过中间件解析用户 Token 注入 tenant_id,并在向量库层使用元数据过滤,避免越权召回。"
总结
多租户 RAG 的核心难点在于数据隔离与防越权。
选型时根据 B 端客户规模选择物理隔离或逻辑隔离,尽早规划混合架构。
逻辑隔离必须依赖系统级元数据过滤,绝不能在 LLM 侧做权限控制。
遇到带过滤条件的性能下降,优先考虑底层向量库的 Partition 或标量索引优化。
面试中能说清楚"先过滤后计算"以及数据隔离级别,能展现你扎实的后端工程素养。