Skip to content

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、archived
  • updated_at:便于 freshness 排序和失效管理

如果业务更复杂,还可能继续补:

  • department_id
  • role_ids
  • policy_version
  • region

字段越多,不代表系统越高级。关键是这些字段是不是和你的隔离策略真的对应得上。很多系统的问题不在于字段太少,而在于字段写了却没有进入检索、缓存和审计链路。

一个多租户过滤的示意代码 ​

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

这个流程想表达的是:很多多租户系统不是简单“查一个库”,而是:

  1. 先带租户边界查私有知识
  2. 再按规则决定是否补公共知识
  3. 最后再合并、去重、排序

这样更容易把“租户私有知识优先”和“公共知识可复用”同时做进去。

如果要把这条链路做得更稳,通常还会补三层约束:

  1. 检索前先把用户身份翻译成统一的租户上下文。 不要在不同接口里重复写一套“这个用户能看什么”的判断。更稳的做法是先把登录态、组织关系、角色信息统一转换成 TenantContext,后面检索、缓存、审计都只认这一份上下文。

  2. 合并后再做一次兜底校验。 即使前面已经按租户过滤,进入 prompt 之前最好再检查一次:当前上下文是否允许引用这些 chunk。这样能拦住“某个中间层意外放进了不该出现的候选”。

  3. 答案返回时保留引用来源。 引用里至少要能看出文档 ID、租户范围、更新时间。这样一旦用户反馈“这不是我们租户的规则”,你才能快速回溯问题出在接入、检索、合并还是缓存。

“逻辑隔离”和“物理隔离”怎么理解 ​

多租户隔离常见有两种思路:

  • 逻辑隔离:同一套基础设施上共享存储,但用租户标识和强制过滤做隔离
  • 物理隔离:不同租户使用独立索引、独立库,甚至独立环境

哪种更合适,要看业务敏感度、规模和成本要求。

但无论采用哪种方案,有一点都一样:

隔离必须真实发生在数据和检索链路里,而不是只发生在界面层。

除了检索本身,还要防哪些地方串租户 ​

很多系统把检索层做好了,但仍然会在这些地方出问题:

1. 回答缓存 ​

如果 cache key 只按问题文本存,不带 tenant_id,那不同租户可能复用同一答案。

更稳一点的 key 通常至少会带上:

  • tenant_id
  • 用户角色或权限版本
  • 检索策略版本
  • prompt 模板版本

否则你即使没有串租户,也可能把“管理员可见答案”缓存给普通用户。

2. 会话记忆 ​

如果同一个会话状态被多个租户复用,之前的私有上下文就可能带到后面的回答里。

比较实用的做法是:

  • 会话 ID 绑定租户上下文
  • 租户切换时强制清空或重建会话状态
  • 历史消息进入检索增强前,再做一次租户一致性检查

3. 管理后台和调试平台 ​

工程师为了排查问题,往往会直接看候选 chunk、trace 和 prompt。如果后台没有做隔离,问题会从用户侧转移到运维侧。

这也是很多团队容易忽略的一点。线上用户没有串租户,不代表系统就真的安全。只要后台调试台能让客服、运营或工程师直接看到其他租户的候选结果,隔离仍然是不完整的。

4. 离线评测集 ​

如果评测样本和真实文档没有按租户隔离,评测时也可能出现错误混用。

更实际一点说,评测集最好和线上索引一样携带租户字段。否则你离线看起来“召回率很好”,上线后却发现模型总在用错租户资料,原因往往不是模型变差了,而是评测环境根本没有模拟真实边界。

所以多租户隔离不是只做“线上回答边界”,而是整个系统生命周期都要带着租户边界意识。

一种实用的实施顺序 ​

如果你要从 0 开始做多租户 RAG,比较现实的推进顺序通常是:

  1. 先给所有文档和 chunk 强制补 tenant_id。
  2. 在服务端把登录态转换成统一的 TenantContext,不要让前端自己拼过滤条件。
  3. 检索 API、重排层、引用层都只接受 TenantContext 驱动的过滤。
  4. cache key、session key、trace key 都带上 tenant_id 和权限相关信息。
  5. 把公共知识和私有知识显式拆开,避免混在一个候选池里盲目排序。
  6. 给后台调试页、日志、评测集补上同样的租户边界。
  7. 敏感租户再评估是否升级为物理隔离。

这样通常比一开始就追求“所有租户一键独立部署”更现实,也更容易逐步演进。

怎么判断该选共享索引还是按租户拆索引 ​

如果你拿不准,可以先用这几个问题做判断:

  1. 串租户的代价有多高? 如果一旦串租户就是合规事故、合同事故或重大客户事故,那就不要只看共享索引的成本优势。

  2. 租户数量和单租户体量分别多大? 如果租户很多但每个都很小,共享索引通常更现实;如果少数大租户占了绝大部分流量和数据量,拆索引会更稳。

  3. 公共知识占比高不高? 如果大量内容是跨租户共享的,纯粹按租户完全拆开会让公共知识复用和版本管理更麻烦。

  4. 你的工程治理能力够不够强? 共享索引不是“更简单”,而是“把复杂度转移到工程纪律里”。如果团队连统一 metadata、统一过滤入口、统一缓存策略都还没有做好,过早选择共享索引,后面很容易到处补洞。

很多团队的现实做法是:默认共享索引 + 强制过滤,少数高敏感大客户单独拆索引。这样既控制成本,也能对关键租户给更强边界。

上线前,最好先做一次多租户自查 ​

如果你准备把多租户 RAG 真正上线,至少可以先问自己下面这些问题:

  1. 所有文档和 chunk 是否都带了稳定的 tenant_id?
  2. 检索过滤是不是由服务端统一生成,而不是前端自由传入?
  3. 公共知识和私有知识是不是已经显式分开?
  4. cache、session、trace、调试后台是不是也带着租户边界?
  5. 低权限租户能不能通过问法变化、缓存命中或调试页面间接拿到别的租户信息?

如果这些问题里还有几个答不上来,就说明多租户隔离还没有真正形成全链路约束。

多租户 RAG 里常见的错误 ​

比较典型的包括:

  • 文档接入时没写租户标识
  • 检索时没加租户过滤
  • 公共内容和私有内容优先级混乱
  • 回答缓存跨租户复用
  • 管理员视角和普通用户视角混在一起

这些问题表面不同,本质上都说明系统没有把“租户边界”做成全链路约束。

也可以换一种更直接的理解方式:

只要系统里还有某个环节在想“先放过去,后面再补一次租户判断”,那个环节通常就是后续串租户风险最容易冒出来的地方。

一句话总结 ​

多租户 RAG 的隔离,本质上是在保证“当前用户只能基于当前租户允许使用的知识作答”。所以隔离必须贯穿数据接入、索引、检索、缓存、生成和审计,而不是只做一个前端开关。