AI System on Native AWS - Part 6 - 企業級多租戶 RAG 平台

大部分 RAG 的 demo 只有一個租戶、一個使用者、一批文件,跑起來很漂亮。一放到企業就爆:A 公司的合約絕不能被 B 公司檢索到、行銷部的人不該問得出財務部的薪資表、而且老闆要知道這個月每個客戶到底燒了你多少 token。 單租戶 RAG 是玩具,多租戶 RAG 才是產品。難點不在「檢索」,而在「檢索之前,先確定這個人有權看到哪些東西」——而且要在毫秒級、要能撐幾百個租戶、還要能把成本拆得清清楚楚。 AWS 原生的解法:用 Verified Permissions(Cedar 政策引擎)在檢索前算出可見範圍、用 metadata filtering 把範圍壓進向量檢索、用語意快取把重複問題的成本歸零、用 tenant tag 把每一塊錢歸因到租戶。 這是進階篇的起點,也是 Part 1 的企業級進化:當 RAG 要賣給一百家公司時,系統長什麼樣。


關於進階篇(Part 6–10)

前五篇(Part 1–5)我們各自蓋了一個能跑的 AI 系統。但「能跑」跟「賣得出去、扛得住稽核、管得動成本」之間,還隔著一整套企業級的考量:多租戶隔離、模型客製與治理、即時風控、資安合規、平台化。進階篇這五篇,把前面的系統往「企業平台」推進:

  • Part 6(本篇):企業級多租戶 RAG 平台 —— 租戶隔離 + 文件級授權 + 語意快取
  • Part 7:基礎模型客製化與模型治理 —— Fine-tuning、蒸餾、Model Registry、評估關卡
  • Part 8:即時串流 ML 與詐欺偵測 —— Kinesis + Managed Flink + Neptune 圖偵測
  • Part 9:企業 AI 安全、合規與資料治理 —— PrivateLink、KMS、Macie、Lake Formation、SCP
  • Part 10:企業 AI 平台工程 —— 多帳號落地區、LLM Gateway、Service Catalog、FinOps

一樣全程用 AWS CDK(TypeScript / CloudFormation) 描述。


一、情境與痛點:單租戶到多租戶的斷崖

你把 Part 1 的 RAG 客服做成功了,現在要把它變成一個 SaaS 產品賣給一百家企業客戶。同一天你會撞上三面牆:

  • 隔離牆:A 公司上傳的合約,B 公司絕對不能在檢索結果裡看到哪怕一個片段。這不是準確度問題,是資安事故——一次跨租戶洩漏就足以讓整個產品出局。
  • 授權牆:就算在同一個租戶內,行銷部的員工不該檢索到 HR 的薪資檔、外包廠商只能看到公開文件。權限是文件級、甚至片段級的。
  • 成本牆:向量庫與 LLM 的帳單是一整包,但你要按租戶收費、要知道哪個大客戶在虧錢。成本必須歸因到租戶

這三面牆,單租戶 RAG 一個都沒解。而且它們必須在檢索發生之前就處理好——你不能先檢索出所有片段、再事後過濾(那等於資料已經離開了它該待的邊界,而且浪費檢索成本)。

核心心法:多租戶 RAG 的難點是「授權」而不是「檢索」。檢索是 Part 1 就解掉的;企業級要解的是「這個 principal,在這個 tenant 裡,對哪些資源有 read 權限」——並把答案變成向量檢索的過濾條件。


二、系統目的:功能與非功能需求

功能需求:

  • 多租戶:一套平台服務 N 個租戶,租戶間資料完全隔離。
  • 文件級 / 片段級授權:依 principal 的角色、部門、屬性決定可見文件。
  • 檢索前過濾:授權範圍在向量檢索階段就生效,不做事後過濾。
  • 語意快取:語意相近的問題命中快取,不重複打 LLM。
  • 每租戶成本歸因與用量配額(quota / rate limit)。

非功能需求:

面向目標為什麼
隔離跨租戶洩漏機率為零一次洩漏 = 產品出局
授權延遲授權判斷 < 10ms卡在檢索前的關鍵路徑上
規模支援 500+ 租戶SaaS 要長大
成本歸因每租戶 token/檢索成本可拆定價與 FinOps 的前提
噪音鄰居大租戶不拖垮小租戶公平性與 SLA

「授權判斷 < 10ms」+「檢索前過濾」這兩條,直接排除了「查一個關聯式權限表再 join」的作法,把我們推向 Verified Permissions(Cedar)這種專用政策引擎 + 向量庫 metadata filtering 的組合。


三、系統設計與架構

3.1 先選租戶隔離模型:Silo / Pool / Bridge

這是多租戶系統的第一個、也是最重要的決策。SaaS 領域的三種經典模型套在向量庫上:

模型      向量庫配置                       隔離強度    成本效率    適用
──────────────────────────────────────────────────────────────────────
Silo     每租戶一個獨立 collection/index    最強        最差       高合規/大客戶
Pool     所有租戶共用 index,靠 tenantId    最弱        最好       海量小租戶
         欄位過濾
Bridge   分層:大客戶 silo、長尾 pool        折衷        折衷       混合客群(推薦)
  • Silo(倉儲式):每個租戶一套自己的向量索引。物理隔離最強、噪音鄰居問題天然消失,但幾百個租戶就是幾百套索引,OpenSearch 的固定成本乘以租戶數,長尾小租戶會把你拖垮。
  • Pool(共池式):所有租戶的向量塞進同一個索引,每筆向量帶 tenantId metadata,檢索時強制加 tenantId = X 的過濾。成本最優、擴縮最容易,但隔離全靠「過濾條件不能寫錯」——一個 bug 就是跨租戶洩漏。
  • Bridge(橋接式):大客戶 / 高合規客戶走 silo,長尾小客戶走 pool。這是多數 B2B SaaS 的務實選擇,也是本篇的主線。

3.2 整體架構

┌──────────┐  JWT(含 tenantId + 角色/部門屬性)   ┌──────────────┐
│  使用者    │ ──────────────────────────────────▶ │ API Gateway   │
└──────────┘                                      └──────┬───────┘
      ▲                                                  ▼
      │ 答案 + 引用(僅限可見範圍)             ┌────────────────────────┐
      │                                      │   Lambda(Orchestrator)  │
      │                                      └──┬────────┬────────┬────┘
      │                     ①授權(<10ms)         │        │檢索前  │
      │                     ▼                    │        ▼        │
      │            ┌──────────────────┐          │  ┌──────────────┐│
      │            │ Verified          │          │  │ 語意快取       ││ ②快取命中?
      │            │ Permissions(Cedar)│──────────┘  │(ElastiCache)  ││    命中→直接回
      │            │ 回傳可見 doc 標籤   │             └──────┬───────┘│
      │            └──────────────────┘                    未命中      │
      │                                                     ▼          │
      │                                          ┌────────────────────┐│
      │                                          │ Bedrock KB / OpenSearch│
      │                                          │ 帶 tenantId + 可見標籤  ││③帶過濾檢索
      │                                          │ 的 metadata filter    ││
      │                                          └──────────┬───────────┘│
      │                                                     ▼            │
      │                                          ┌────────────────────┐  │
      │                                          │ Rerank + Claude 生成 │  │
      └──────────────────────────────────────────│ + 寫回語意快取         │◀─┘
                                                 └────────────────────┘

一次請求的四步:授權 → 查快取 → 帶過濾檢索 → 生成並回填快取。授權與快取都在「昂貴的檢索/生成」之前,這個順序是省錢與安全的關鍵。

3.3 為什麼授權要獨立成一個政策引擎

你當然可以把權限塞進一張 DynamoDB 表,每次查一次。但企業授權規則會長成這樣:「使用者可讀文件,若 (文件.tenant == 使用者.tenant) 且 (文件.department in 使用者.departments 或 文件.visibility == ‘public’) 且 (使用者.clearance >= 文件.classification)」。用程式碼 if-else 拼這個,很快變成沒人敢改的義大利麵。

Amazon Verified PermissionsCedar 政策語言把「誰能對什麼做什麼」抽成宣告式政策,與業務程式碼分離、可獨立稽核、毫秒級評估。這正是文件級授權該用的工具。


四、CDK(CloudFormation)實作

4.1 授權:Verified Permissions 政策存放區與政策

 1// lib/authz.ts 片段
 2import * as vp from 'aws-cdk-lib/aws-verifiedpermissions';
 3
 4const policyStore = new vp.CfnPolicyStore(this, 'RagPolicyStore', {
 5  validationSettings: { mode: 'STRICT' },
 6  schema: {
 7    cedarJson: JSON.stringify({
 8      RagApp: {
 9        entityTypes: {
10          User: { shape: { type: 'Record', attributes: {
11            tenant: { type: 'String' },
12            departments: { type: 'Set', element: { type: 'String' } },
13            clearance: { type: 'Long' },
14          } } },
15          Document: { shape: { type: 'Record', attributes: {
16            tenant: { type: 'String' },
17            department: { type: 'String' },
18            visibility: { type: 'String' },
19            classification: { type: 'Long' },
20          } } },
21        },
22        actions: { ReadDocument: { appliesTo: { principalTypes: ['User'], resourceTypes: ['Document'] } } },
23      },
24    }),
25  },
26});
27
28// 靜態政策:同租戶 + (同部門 或 public) + 密級足夠 才可讀
29new vp.CfnPolicy(this, 'ReadPolicy', {
30  policyStoreId: policyStore.attrPolicyStoreId,
31  definition: { static: {
32    description: 'Tenant-isolated, department + clearance based read',
33    statement: `
34      permit(principal, action == RagApp::Action::"ReadDocument", resource)
35      when {
36        principal.tenant == resource.tenant &&
37        (resource.visibility == "public" || resource.department in principal.departments) &&
38        principal.clearance >= resource.classification
39      };`,
40  } },
41});

4.2 檢索前:把授權結果變成 metadata filter

關鍵技巧:我們對每份文件逐一問 Verified Permissions(那要問幾千次)。而是先算出 principal 的可見維度(tenant、部門集合、密級),把它們直接編譯成向量檢索的 metadata filter。Verified Permissions 用來驗證政策邏輯與稽核,線上熱路徑用它推導出的過濾條件。

 1// lib/lambda/query/index.ts 片段
 2import { BedrockAgentRuntimeClient, RetrieveAndGenerateCommand }
 3  from '@aws-sdk/client-bedrock-agent-runtime';
 4
 5const brt = new BedrockAgentRuntimeClient({});
 6
 7export const handler = async (event: any) => {
 8  const claims = event.requestContext.authorizer.jwt.claims;
 9  const tenant = claims['custom:tenant'];
10  const departments = String(claims['custom:departments'] ?? '').split(',');
11  const clearance = Number(claims['custom:clearance'] ?? 0);
12
13  // 由授權維度組出 metadata filter:租戶隔離 + 部門/公開 + 密級
14  const retrievalFilter = {
15    andAll: [
16      { equals: { key: 'tenant', value: tenant } },              // 硬隔離,永遠存在
17      { orAll: [
18        { equals: { key: 'visibility', value: 'public' } },
19        { in: { key: 'department', value: departments } },
20      ] },
21      { lessThanOrEquals: { key: 'classification', value: clearance } },
22    ],
23  };
24
25  const res = await brt.send(new RetrieveAndGenerateCommand({
26    input: { text: event.question },
27    retrieveAndGenerateConfiguration: {
28      type: 'KNOWLEDGE_BASE',
29      knowledgeBaseConfiguration: {
30        knowledgeBaseId: resolveKbForTenant(tenant),   // Bridge:大租戶回自己的 silo KB
31        modelArn: process.env.MODEL_ARN!,
32        retrievalConfiguration: {
33          vectorSearchConfiguration: {
34            numberOfResults: 8,
35            filter: retrievalFilter,                    // ★ 檢索前過濾,不做事後過濾
36          },
37        },
38      },
39    },
40  }));
41  // ...整理引用回傳
42};

隔離的最後一道保險:tenant 過濾條件用 pool 模型時是唯一的隔離屏障。務必在平台層強制注入(從驗證過的 JWT claim 取,絕不接受前端傳入),並寫整合測試「A 租戶的 token 永遠檢索不到 B 租戶的資料」當作 CI 的紅線。

4.3 Ingestion 時給每個 chunk 打上授權 metadata

授權過濾能生效的前提:寫入時就把 tenant / department / visibility / classification 打進每個 chunk 的 metadata。Bedrock KB 支援對 S3 文件附一份 .metadata.json

 1// 上傳文件時,同時寫一份 side-car metadata(供 KB 索引為可過濾欄位)
 2// s3://bucket/tenantA/hr/salary.pdf
 3// s3://bucket/tenantA/hr/salary.pdf.metadata.json
 4const metadata = {
 5  metadataAttributes: {
 6    tenant:         { value: { type: 'STRING', stringValue: 'tenantA' }, includeForEmbedding: false },
 7    department:     { value: { type: 'STRING', stringValue: 'hr' },      includeForEmbedding: false },
 8    visibility:     { value: { type: 'STRING', stringValue: 'private' }, includeForEmbedding: false },
 9    classification: { value: { type: 'NUMBER', numberValue: 3 },         includeForEmbedding: false },
10  },
11};

4.4 語意快取:重複問題不再打 LLM

企業客服有大量重複問題(「怎麼重設密碼」被問一千次)。語意快取把問題向量化,若跟快取裡某個問題夠相近(cosine > 0.95)且在同一租戶/授權範圍內,直接回快取答案。

 1// lib/lambda/query/semantic-cache.ts 片段
 2import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
 3// 用 ElastiCache for Redis(啟用向量搜尋)或 MemoryDB 存快取向量
 4
 5async function tryCache(tenant: string, authzKey: string, question: string, redis: any) {
 6  const emb = await embed(question);                 // Titan Embeddings
 7  // 快取 key 一定要含 tenant + 授權維度,否則會跨權限洩漏答案!
 8  const hits = await redis.call('FT.SEARCH', `cache:${tenant}`,
 9    `(@authz:{${authzKey}})=>[KNN 1 @vec $q]`, 'PARAMS', 2, 'q', toBytes(emb), 'DIALECT', 2);
10  if (hits && similarity(hits) > 0.95) return hits.answer;   // 命中
11  return null;
12}

快取的隔離陷阱:快取 key 必須包含租戶與授權維度。若只用「問題文字」當 key,高權限使用者問過的答案會被低權限使用者的相同問題命中——快取變成越權管道。這是多租戶語意快取最陰險的坑。

4.5 每租戶成本歸因與配額

用 API Gateway 的 usage plan + API key(每租戶一把) 做限流與配額,並在每次 LLM 呼叫後把 token 用量寫進帶 tenant 維度的 CloudWatch 自訂指標,供 FinOps 拆帳。

1import * as apigw from 'aws-cdk-lib/aws-apigateway';
2
3const plan = api.addUsagePlan('TenantPlan', {
4  throttle: { rateLimit: 50, burstLimit: 100 },     // 防噪音鄰居
5  quota: { limit: 100000, period: apigw.Period.MONTH },
6});
7// 每個租戶一把 key,綁到 plan;帳單與限流天然按租戶切開
8const key = api.addApiKey('TenantA-Key');
9plan.addApiKey(key);

五、技術選型考量:為什麼選 X 不選 Y

5.1 Verified Permissions(Cedar) vs 自建權限表 vs OPA

選擇                優勢                              代價 / 翻盤條件
──────────────────────────────────────────────────────────────────────
Verified Perms      宣告式、可稽核、AWS 託管、毫秒級      規則極度動態時政策管理成本上升
自建 DynamoDB 表    簡單直觀                          複雜規則變義大利麵,難稽核
OPA/Rego(自架)    生態強、跨雲                        要自己 host 與維運

翻盤條件:授權規則極簡(只有 tenant 隔離、沒有部門/密級的複雜組合)時,一個 tenant 過濾條件就夠,不需要政策引擎。規則一旦涉及多屬性組合、且需要合規稽核「為什麼這個人能看這份檔」,Verified Permissions 的價值才浮現。

5.2 Bridge vs 純 Silo vs 純 Pool(再論)

翻盤條件:

  • 客戶數少(< 20)、每個都是大合約、合規要求物理隔離 → 純 Silo 划算,別為了省成本冒隔離風險。
  • 海量長尾小租戶(數千個免費/低價戶)、隔離靠應用層即可 → 純 Pool,把固定成本攤到極致。
  • 兩種客群都有 → Bridge,用租戶等級決定走哪條路,並在 CDK 用同一組 construct 參數化產生。

5.3 事後過濾 vs 檢索前過濾

有人會問:先檢索 Top-50、再用授權過濾出 Top-8 不行嗎?不行,三個理由:

  • 洩漏風險:被過濾掉的片段已經進了你的應用記憶體與 LLM context,一個記錄 bug 就外洩。
  • 檢索品質:如果 Top-8 全被過濾掉,你就沒有答案了——授權必須是檢索的一部分,不是之後。
  • 成本:檢索更多、把不該看的也算進去,純浪費。

檢索前過濾(metadata filter)是唯一正確解。

5.4 語意快取 vs 精確快取 vs 不快取

  • 精確快取(問題字串完全相同才命中):命中率低,企業問題措辭千變萬化。
  • 語意快取(語意相近即命中):命中率高、省 LLM 成本最多,但要小心「相近但不相同」導致答非所問,設高門檻(0.95+)並保留原引用。
  • 不快取:答案時效性極高(如即時庫存)時反而正確。

六、成本估算

以「200 租戶、每月 50 萬次問答、其中 30% 命中語意快取」估算(概略):

項目用量概略月費
OpenSearch Serverless(Pool 共池 + 少數 Silo)共池 4 OCU + 5 個大租戶各自 index~$1,200(固定大戶)
Bedrock 生成(Claude,扣除快取 30%)35 萬次有效呼叫~$700–1,400
語意快取嵌入 + ElastiCache50 萬次嵌入 + Redis 節點~$300
Verified Permissions授權評估(推導維度,非逐檔)~$50–150
Lambda / API Gateway高用量~$300
合計~$2,600–3,400 / 月

成本洞察:多租戶把 Part 1 的「固定成本大戶」問題放大了——向量庫的固定成本現在要被 200 個租戶分攤,這正是 Pool 模型省錢的來源(共享固定成本),也是 Silo 模型昂貴的原因(固定成本 × 租戶數)。三個降本槓桿:

  • 語意快取直接砍生成成本:30% 命中 = 30% 的 LLM 費用消失,而且延遲更低。命中率是這套系統最值得優化的單一指標。
  • Bridge 混合隔離:讓長尾租戶共享固定成本,只有付得起的大客戶才獨享 silo。
  • 成本歸因驅動定價:有了每租戶 token 數據,才能設計「用越多付越多」的定價,把虧錢的大戶轉成賺錢的。

七、延伸與常見的坑

延伸方向:

  • Hybrid search + reranking:企業文件常有專有名詞、料號,純向量檢索抓不到。加關鍵字(BM25)混合檢索,再用 Cohere Rerank(Bedrock 上)重排前 K,大幅提升企業場景準確度。
  • 租戶自助上傳與 ingestion 隔離:每個租戶一個 S3 prefix + 獨立 ingestion job,避免一個大租戶的重建拖垮全體。
  • Per-tenant 模型選擇:高階租戶用 Claude Opus、免費租戶用便宜模型,用 SSM 參數依租戶等級路由(呼應 Part 5 的模型解耦)。
  • 稽核報表:用 Verified Permissions 的評估日誌產出「誰在何時存取了哪份文件」,滿足企業合規需求。

最容易踩的坑:

  1. tenant 過濾條件漏注入:任何一條檢索路徑忘了加 tenant filter,就是跨租戶洩漏。要在平台層集中強制,並用 CI 紅線測試守住。
  2. 快取 key 不含授權維度:語意快取變成越權管道,高權限的答案洩漏給低權限使用者。
  3. 從前端接收 tenantId:永遠只從驗證過的 JWT claim 取租戶身分,絕不信任前端傳入的租戶參數。
  4. Silo 無限膨脹:每個小租戶都給獨立 index,OpenSearch 固定成本爆炸。長尾一定要進 pool。
  5. metadata 沒設 includeForEmbedding: false:授權屬性若被納入 embedding,會污染語意向量、影響檢索品質——它們只該是可過濾欄位,不是語意的一部分。

小結

多租戶 RAG 把 Part 1 的「檢索問題」升級成「授權 + 隔離 + 歸因問題」。企業級的關鍵洞察是:檢索之前先確定可見範圍,而且這個範圍要用政策引擎推導、用 metadata filter 落地、用 tenant tag 歸因。Silo/Pool/Bridge 的隔離選擇、Verified Permissions 的宣告式授權、語意快取的降本、每租戶的配額與成本拆分——這些才是把一個 RAG demo 變成能賣給一百家公司的產品的距離。

下一篇,我們處理另一個企業級硬骨頭:當通用模型不夠好、或你有大量專有資料想讓模型「內化」時,該怎麼客製基礎模型——以及更重要的,怎麼治理這些客製模型,讓它們的訓練、評估、上線都可稽核、可回溯、有品質關卡。這是 Part 7:基礎模型客製化與模型治理。


系列導覽(進階篇)

  • Part 6(本篇):企業級多租戶 RAG 平台
  • Part 7:基礎模型客製化與模型治理
  • Part 8:即時串流 ML 與詐欺偵測
  • Part 9:企業 AI 安全、合規與資料治理
  • Part 10:企業 AI 平台工程 —— 落地區、LLM Gateway 與 FinOps

基礎篇回顧:Part 1 RAG 問答 · Part 2 IDP 管線 · Part 3 即時推薦 · Part 4 自主 Agent · Part 5 MLOps 與可觀測性

Yen

Yen

Yen