Appearance
2.4.4 多租户 RAG 一般怎么做隔离?
先给结论:多租户 RAG 的隔离目标,不只是“不同租户看不到彼此页面”,而是“不同租户的知识不应该在检索和生成链路里互相污染”。
这是比普通前端分账号更严格的一层要求。
什么是多租户 RAG
多租户场景下,同一套系统会服务多个客户、团队或业务单元。
这时每个租户通常都有自己的:
- 文档
- FAQ
- 业务规则
- 操作流程
- 权限体系
系统必须保证这些知识在被接入、检索和回答时都有明确边界。
为什么多租户隔离特别重要
因为 RAG 不是简单返回数据库记录,而是会:
- 检索候选内容
- 排序和筛选
- 拼接上下文
- 生成自然语言答案
一旦不同租户内容混入同一次回答,风险就不只是“看错数据”,而是可能直接把别人的知识说给当前用户。
常见的隔离层次
1. 数据层隔离
内容进入系统时,就要明确属于哪个租户,并尽量携带稳定租户标识。
2. 索引层隔离
有些系统会按租户拆分索引,有些会共用索引但用强制过滤字段。具体方式可以不同,但目标一致:
当前租户不该在候选召回里看到其他租户内容。
3. 检索层隔离
每次查询都要带当前租户上下文,在检索阶段先做边界过滤。
4. 缓存和会话层隔离
如果回答缓存、查询缓存或对话状态没有隔离,也可能把跨租户内容带进后续回答。
5. 观测与审计层隔离
日志、追踪和监控中也要能区分租户边界,避免排障过程里再次造成泄露。
真正落地时,多租户隔离一般有哪些做法
多租户隔离不是只有一种答案。更常见的其实是根据敏感度和规模,在不同层次采用不同强度的隔离。
方案一:共享索引 + 强制 metadata 过滤
这是最常见、成本也相对低的一种做法。
特点是:
- 所有租户共用一套检索基础设施
- 每个 chunk 都带
tenant_id - 每次查询都必须附带
tenant_id过滤
优点是实现和扩容相对简单。
缺点是如果过滤链路出错,串租户风险会更高,所以对工程纪律要求很高。
这种方案真正落地时,关键不是“记得过滤一下”,而是要把过滤做成默认路径,而不是可选路径。更稳的做法通常包括:
- 接入阶段就给每个文档和 chunk 补齐
tenant_id - 检索接口不允许调用方自己拼过滤条件,而是由服务端根据当前登录态生成过滤表达式
- 检索、重排、引用、缓存都复用同一份租户上下文
- 任何缺失
tenant_id的文档都不进入可检索集合,而不是先放进去再慢慢补
如果你走共享索引路线,最怕的不是过滤语法不会写,而是系统里总有一两个“例外入口”。比如批量导入脚本没写 tenant_id,某个调试接口绕过了服务端过滤,某个缓存层只按 query 做 key。这些地方只要漏一个,就可能把前面所有隔离设计打穿。
方案二:按租户拆索引
这种做法会为不同租户建立独立索引或独立 namespace。
优点是边界更清晰,很多串租户风险天然更低。
缺点是:
- 租户很多时运维复杂度更高
- 小租户可能造成资源碎片
- 公共知识复用更麻烦
这种方案更适合这些情况:
- 租户之间的数据敏感度非常高
- 单个大租户的数据规模已经足够大,值得单独维护索引
- 合同、合规或审计要求明确要求更强边界
- 你已经遇到过共享索引下的串租户风险,且业务不能接受继续靠工程纪律兜底
它也不是“拆了就安全”。如果你的应用层仍然把会话、缓存、引用链接、后台调试视图混在一起,物理隔离也只能解决一部分问题。所以按租户拆索引通常是加强边界,不是替代全链路设计。
方案三:共享公共知识 + 私有租户知识双路检索
很多真实系统不是只有“租户私有知识”,还会有一层公共知识,比如:
- 平台通用 FAQ
- 通用产品文档
- 公共操作指南
这时常见做法是:
- 一路查公共知识库
- 一路查租户私有知识库
- 然后在合并阶段再区分优先级
这种做法更贴近真实业务,但也要求你明确:
- 哪些内容是公共的
- 哪些内容只能租户内使用
- 合并时谁优先
这一类系统最常见的坑是“公共知识把私有知识冲掉了”。比如平台 FAQ 文档质量更高、字数更多、被召回次数更多,结果一合并就把真正应该优先回答的租户规则压下去了。更稳的做法通常是:
- 先分别召回公共候选和私有候选,而不是混在一起做一次大检索
- 在合并阶段给私有知识更高优先级,至少不要让公共规则覆盖明确的租户规则
- 在生成答案时保留引用来源,明确哪些结论来自租户私有资料,哪些来自公共资料
- 如果公共规则和私有规则冲突,优先返回私有规则,并把冲突记录进审计日志
这不是“排序技巧”,而是系统语义的一部分。因为用户问的是“我所在租户下应该怎么做”,不是“平台总体上通常怎么说”。
多租户 RAG 至少要设计哪些 metadata 字段
如果系统要长期维护,多租户隔离不能只靠一个 tenant_id 字段硬撑。更常见的基础字段组合是:
tenant_id:文档所属租户scope:private、public,或者更细的共享范围doc_id:稳定文档 ID,方便更新、删除、回滚source_type:来自 FAQ、制度文档、工单知识还是产品手册visibility:是否可被普通用户检索,还是仅特定角色可见status:active、draft、archivedupdated_at:便于 freshness 排序和失效管理
如果业务更复杂,还可能继续补:
department_idrole_idspolicy_versionregion
字段越多,不代表系统越高级。关键是这些字段是不是和你的隔离策略真的对应得上。很多系统的问题不在于字段太少,而在于字段写了却没有进入检索、缓存和审计链路。
一个多租户过滤的示意代码
python
from dataclasses import dataclass
@dataclass
class UserContext:
tenant_id: str
role_ids: list[str]
allow_public: bool = True
chunks = [
{
"text": "租户 A 的退款规则:7 天内可退。",
"metadata": {
"tenant_id": "tenant-a",
"scope": "private",
"visibility": ["employee"],
},
},
{
"text": "平台通用退款说明:特殊品类不支持无理由退货。",
"metadata": {
"tenant_id": None,
"scope": "public",
"visibility": ["employee", "admin"],
},
},
]
def build_filter(ctx: UserContext) -> dict:
return {
"tenant_ids": [ctx.tenant_id, None] if ctx.allow_public else [ctx.tenant_id],
"allowed_roles": ctx.role_ids,
}
def retrieve(query: str, ctx: UserContext) -> list[dict]:
filter_expr = build_filter(ctx)
matched = [chunk for chunk in chunks if "退款" in chunk["text"]]
return [
chunk
for chunk in matched
if chunk["metadata"]["tenant_id"] in filter_expr["tenant_ids"]
and any(role in chunk["metadata"]["visibility"] for role in filter_expr["allowed_roles"])
]
ctx = UserContext(tenant_id="tenant-a", role_ids=["employee"])
results = retrieve("退款规则是什么?", ctx)这段代码看起来很简单,但它体现了一个很重要的原则:
当前租户的边界,必须在检索阶段就进入过滤条件,而且过滤条件应该由服务端根据当前用户上下文生成,而不是让前端自己决定传什么。
一个更接近真实系统的多租户查询流程
python
from dataclasses import dataclass
@dataclass
class TenantContext:
tenant_id: str
allow_public: bool = True
def retrieve_private_chunks(query: str, tenant_id: str) -> list[dict]:
return [
{
"text": "租户 A 私有退款规则:高风险订单需人工审核。",
"metadata": {"tenant_id": "tenant-a", "scope": "private"}
}
] if tenant_id == "tenant-a" else []
def retrieve_public_chunks(query: str) -> list[dict]:
return [
{
"text": "平台通用规则:普通退款申请 7 天内处理。",
"metadata": {"tenant_id": None, "scope": "public"}
}
]
def retrieve_for_context(query: str, ctx: TenantContext) -> list[dict]:
private_chunks = retrieve_private_chunks(query, tenant_id=ctx.tenant_id)
public_chunks = retrieve_public_chunks(query) if ctx.allow_public else []
merged = private_chunks + public_chunks
# 真实系统里这里还会继续做去重、排序、冲突处理和权限校验
merged.sort(key=lambda item: item["metadata"]["scope"] != "private")
return merged这个流程想表达的是:很多多租户系统不是简单“查一个库”,而是:
- 先带租户边界查私有知识
- 再按规则决定是否补公共知识
- 最后再合并、去重、排序
这样更容易把“租户私有知识优先”和“公共知识可复用”同时做进去。
如果要把这条链路做得更稳,通常还会补三层约束:
检索前先把用户身份翻译成统一的租户上下文。 不要在不同接口里重复写一套“这个用户能看什么”的判断。更稳的做法是先把登录态、组织关系、角色信息统一转换成
TenantContext,后面检索、缓存、审计都只认这一份上下文。合并后再做一次兜底校验。 即使前面已经按租户过滤,进入 prompt 之前最好再检查一次:当前上下文是否允许引用这些 chunk。这样能拦住“某个中间层意外放进了不该出现的候选”。
答案返回时保留引用来源。 引用里至少要能看出文档 ID、租户范围、更新时间。这样一旦用户反馈“这不是我们租户的规则”,你才能快速回溯问题出在接入、检索、合并还是缓存。
“逻辑隔离”和“物理隔离”怎么理解
多租户隔离常见有两种思路:
逻辑隔离:同一套基础设施上共享存储,但用租户标识和强制过滤做隔离物理隔离:不同租户使用独立索引、独立库,甚至独立环境
哪种更合适,要看业务敏感度、规模和成本要求。
但无论采用哪种方案,有一点都一样:
隔离必须真实发生在数据和检索链路里,而不是只发生在界面层。
除了检索本身,还要防哪些地方串租户
很多系统把检索层做好了,但仍然会在这些地方出问题:
1. 回答缓存
如果 cache key 只按问题文本存,不带 tenant_id,那不同租户可能复用同一答案。
更稳一点的 key 通常至少会带上:
tenant_id- 用户角色或权限版本
- 检索策略版本
- prompt 模板版本
否则你即使没有串租户,也可能把“管理员可见答案”缓存给普通用户。
2. 会话记忆
如果同一个会话状态被多个租户复用,之前的私有上下文就可能带到后面的回答里。
比较实用的做法是:
- 会话 ID 绑定租户上下文
- 租户切换时强制清空或重建会话状态
- 历史消息进入检索增强前,再做一次租户一致性检查
3. 管理后台和调试平台
工程师为了排查问题,往往会直接看候选 chunk、trace 和 prompt。如果后台没有做隔离,问题会从用户侧转移到运维侧。
这也是很多团队容易忽略的一点。线上用户没有串租户,不代表系统就真的安全。只要后台调试台能让客服、运营或工程师直接看到其他租户的候选结果,隔离仍然是不完整的。
4. 离线评测集
如果评测样本和真实文档没有按租户隔离,评测时也可能出现错误混用。
更实际一点说,评测集最好和线上索引一样携带租户字段。否则你离线看起来“召回率很好”,上线后却发现模型总在用错租户资料,原因往往不是模型变差了,而是评测环境根本没有模拟真实边界。
所以多租户隔离不是只做“线上回答边界”,而是整个系统生命周期都要带着租户边界意识。
一种实用的实施顺序
如果你要从 0 开始做多租户 RAG,比较现实的推进顺序通常是:
- 先给所有文档和 chunk 强制补
tenant_id。 - 在服务端把登录态转换成统一的
TenantContext,不要让前端自己拼过滤条件。 - 检索 API、重排层、引用层都只接受
TenantContext驱动的过滤。 - cache key、session key、trace key 都带上
tenant_id和权限相关信息。 - 把公共知识和私有知识显式拆开,避免混在一个候选池里盲目排序。
- 给后台调试页、日志、评测集补上同样的租户边界。
- 敏感租户再评估是否升级为物理隔离。
这样通常比一开始就追求“所有租户一键独立部署”更现实,也更容易逐步演进。
怎么判断该选共享索引还是按租户拆索引
如果你拿不准,可以先用这几个问题做判断:
串租户的代价有多高? 如果一旦串租户就是合规事故、合同事故或重大客户事故,那就不要只看共享索引的成本优势。
租户数量和单租户体量分别多大? 如果租户很多但每个都很小,共享索引通常更现实;如果少数大租户占了绝大部分流量和数据量,拆索引会更稳。
公共知识占比高不高? 如果大量内容是跨租户共享的,纯粹按租户完全拆开会让公共知识复用和版本管理更麻烦。
你的工程治理能力够不够强? 共享索引不是“更简单”,而是“把复杂度转移到工程纪律里”。如果团队连统一 metadata、统一过滤入口、统一缓存策略都还没有做好,过早选择共享索引,后面很容易到处补洞。
很多团队的现实做法是:默认共享索引 + 强制过滤,少数高敏感大客户单独拆索引。这样既控制成本,也能对关键租户给更强边界。
上线前,最好先做一次多租户自查
如果你准备把多租户 RAG 真正上线,至少可以先问自己下面这些问题:
- 所有文档和 chunk 是否都带了稳定的
tenant_id? - 检索过滤是不是由服务端统一生成,而不是前端自由传入?
- 公共知识和私有知识是不是已经显式分开?
- cache、session、trace、调试后台是不是也带着租户边界?
- 低权限租户能不能通过问法变化、缓存命中或调试页面间接拿到别的租户信息?
如果这些问题里还有几个答不上来,就说明多租户隔离还没有真正形成全链路约束。
多租户 RAG 里常见的错误
比较典型的包括:
- 文档接入时没写租户标识
- 检索时没加租户过滤
- 公共内容和私有内容优先级混乱
- 回答缓存跨租户复用
- 管理员视角和普通用户视角混在一起
这些问题表面不同,本质上都说明系统没有把“租户边界”做成全链路约束。
也可以换一种更直接的理解方式:
只要系统里还有某个环节在想“先放过去,后面再补一次租户判断”,那个环节通常就是后续串租户风险最容易冒出来的地方。
一句话总结
多租户 RAG 的隔离,本质上是在保证“当前用户只能基于当前租户允许使用的知识作答”。所以隔离必须贯穿数据接入、索引、检索、缓存、生成和审计,而不是只做一个前端开关。