故事:Q3 合約查到了,內容是別家公司的

某天下午,業務部門在 demo 環境測自家 RAG。業務輸入「我們跟供應商的 Q3 合約,終止條款是什麼」——RAG 回了一段條文,引用編號 [1] 對應到一份文件,業務打開看:合約編號是 NatyNites-Q3-2024,不是自家的。

demo 環境。沒有 user 區隔。整套 RAG 跑得起來、答案看起來合理、citation 也有、但撈到的是別人的資料。這個 bug 在 demo 階段根本不會被發現——demo 環境只有自己一家的文件。

但 production 環境就藏不住。user A、user B、user C 同時在用,Qdrant 裡的 chunk 不分誰家的;同一天某 user 投訴「答案看起來是別家公司的」、查 log 才看到三家公司(nattynites、company-a、company-b)的 user 都打中同一批 chunk。重標所有 chunk 的 tenant_id、跑一次 ingestion、整個流程 debug 了三天才把污染的 chunks 隔乾淨。這個 bug 不是 RAG 寫錯、是文件端的 metadata 設計從來沒被當成 production 級問題看待——demo 環境永遠只有自家資料,沒人會主動設計 cross-tenant 測試案例。

Part 08 把 faithfulness / citation / tracing / cost 4 個 capability 跟 5 種 query mode 設計得再好,query 端 routing 解不掉文件端沒設計的問題。這一篇 Part 09 拆文件進系統後實際走過的 7 個環節、為什麼「Vector DB 不能當 source of truth」、ACL 在 production 為什麼不是一層 filter 就夠。


文件端的 7 站旅程(高層)

Mermaid 圖(文件生命週期):

flowchart LR
    A[User upload<br/>PDF/docx/md] --> B[1. parsing<br/>→ parsed markdown]
    B --> C[2. raw storage<br/>Supabase Storage / S3]
    B --> D[3. Postgres metadata<br/>owner/tenant/source_path/version]
    D --> E[4. ingestion queue<br/>async batch]
    C --> E
    E --> F[5. auth/permission<br/>JWT + RLS + tenant filter]
    F --> G[6. document APIs<br/>list/upload/delete/update]
    F --> H[7. citation viewer<br/>chunk ID + retrieval score]
    H --> I[User asks<br/>Part 08 routing]
    G --> I

7 站分成 3 條主線:

  • 進得來(站 1-2):檔案怎麼從硬碟變成可索引的東西
  • 管得住(站 3-5):metadata 怎麼存、誰能看、怎麼不漏
  • 查得到(站 6-7):管理後台、答案可解釋性

先講結論:7 站可以拆開各自實作,但 metadata 設計(站 3)跟 ACL(站 5)是兩個常被低估、爆掉就要重做的環節。剩下 5 站相對線性、實作後不太會再回頭大改。


第一條主線:進得來(parsing + raw storage)

第 1 站:parsing——把 PDF / docx 變成 parsed markdown

檔案丟進系統那一刻,第一件事是 parsing。PDF 跟 docx 是給人看的格式,不是給 embedding model 看的——PDF 抽出來的是座標流、docx 是 XML 結構,chunking 跟 embedding 直接吃這些原始格式效果都很差。

中間要有一層 parsed markdown:把 PDF / docx 轉成結構化的 markdown(標題、列表、表格、code block 都保留)。

Takeaway:parsed markdown 是好的中間格式——保結構(標題層級、表格、code block)、好切塊(按 heading / paragraph 切都有意義)、可重處理(改 chunking 策略不用重 parse)。

反例(必看):存 PDF 二進位進 object storage,parsing 在 query 時才做。

❌ PDF 進 object storage → query 時才 parse → chunking
✅ PDF 進 object storage → parse 一次成 markdown → 存 markdown

query 時才 parse 聽起來省空間,實際上每次 query 都要重做一次 parsing、latency 飆高;改 chunking 策略要重 parse 所有文件(幾萬份 PDF 等到天荒地老)。parsing 是一次性工作、結果要 reuse

反例情境:早期試過 query 時 lazy parse,發現每次 query 慢 800ms、改 chunking 一次跑了 6 小時,後來決定 ingestion 階段一次性 parse 完。

第 2 站:raw storage——物件儲存 + Postgres 分工

Parsed markdown 跟原始 PDF 都要存。但不要全部塞 Postgres

物件儲存(Supabase Storage / S3 / GCS)跟 RDB 分工:

資料放哪為什麼
原始 PDF / docxObject storage不常讀、檔案大、cost 敏感
Parsed markdownObject storagechunking 改要重讀、不進 DB
結構化 metadataPostgres要查詢、要 join、要 ACL
EmbeddingVector DB專門給 similarity search

Takeaway:Postgres 存 metadata、不存 content。Object storage 存 content、不存 metadata。Vector DB 是 metadata 的衍生,不是 source of truth(這條進到第 3 站會展開)。

反例(必看):把 markdown 直接塞 Postgres BYTEA 欄位、metadata 用 JSONB 一起塞。

-- ❌ 反例
CREATE TABLE documents (
  id UUID PRIMARY KEY,
  raw_content BYTEA,        -- markdown 塞這
  metadata JSONB,           -- tenant_id, owner, version 一起塞
  embedding vector(1536)
);

為什麼錯:metadata 要 query / index / filter(撈某 user 的所有文件、清 ACL),JSONB 查起來慢、index 麻煩;markdown 要重讀(改 chunking 策略要撈全部),用 BYTEA 傳輸整份文字比從 object storage 撈貴。結構化跟非結構化要分開


第二條主線:管得住(metadata + ingestion + auth)

第 3 站:Postgres metadata = source of truth(核心論點)

這是 Part 09 最容易被忽略、爆掉成本最高的論點。

Vector DB(Qdrant / Pinecone / Weaviate)設計的目的是 similarity search,不是 source of truth。所以 metadata 一定要先在 Postgres 落地、Vector DB 是 Postgres 派生出來的檢索引擎

Mermaid 圖(metadata 流向):

flowchart TB
    subgraph Postgres [Postgres = source of truth]
        M[documents table<br/>id, tenant_id, owner_user_id,<br/>permission_groups, source_path,<br/>version, created_at, deleted_at]
    end
    subgraph VectorDB [Vector DB = 衍生]
        V[chunks<br/>embedding + payload]
    end
    subgraph Storage [Object storage]
        S[raw PDF / parsed markdown]
    end

    M -->|upsert with filter| V
    V -->|metadata filter only| Q[query result]
    S -->|reference path only| V

    M -.->|rebuild from here| V
    M -.->|rebuild from here| S

    style Postgres fill:#e8f5e9
    style VectorDB fill:#fff3e0
    style Storage fill:#f3e5f5

metadata 拆成幾類:

CREATE TABLE documents (
  id UUID PRIMARY KEY,
  -- 身份類
  tenant_id TEXT NOT NULL,           -- 哪個公司 / workspace
  owner_user_id TEXT NOT NULL,       -- 誰上傳
  permission_groups TEXT[],          -- 在這個 tenant 裡有什麼角色
  -- 來源類
  source_path TEXT NOT NULL,         -- "contract" / "faq" / "runbook" / "open"
  -- 版本類
  version INT DEFAULT 1,
  parent_document_id UUID,           -- 同一份文件多次上傳的 lineage
  created_at TIMESTAMPTZ DEFAULT now(),
  deleted_at TIMESTAMPTZ,            -- soft delete 不直接砍
  -- ingestion 狀態
  ingestion_status TEXT,             -- pending / processing / done / failed
  ingestion_job_id TEXT              -- 對到 ingestion queue
);

Takeaway:Postgres 是 source of truth、Vector DB 是 metadata 衍生、Object storage 是 content 載體。刪文件時,先 Postgres 標 deleted_at、再刪 Vector DB chunks、再刪 Object storage 內容——順序反了就會出孤兒。

反例(必看):把 tenant_id / owner_user_id 塞 Vector DB 的 payload、Postgres 只存文件名。

# ❌ 反例
qdrant.upsert(
    points=[
        PointStruct(
            id=chunk_id,
            vector=embedding,
            payload={
                "file_name": "Q3-contract.pdf",
                "tenant_id": "company-a",   # metadata 塞 vector DB
            }
        )
    ]
)

為什麼錯:刪文件的時候,Postgres 沒有 tenant_id → 不知道 Vector DB 裡哪些 chunk 要刪;ACL 查詢(user A 能不能看這份文件)要去 Vector DB payload filter 撈,比 Postgres indexed column 慢 10 倍;Qdrant 倒資料重建時,metadata 全部要重塞。把 metadata 放錯地方就是要重做的徵兆

反例情境:早期試過 metadata 全塞 Qdrant payload、Postgres 只存檔名,結果刪一份文件要先去 Postgres 查 file_name、再去 Qdrant 找所有 payload 包含這檔名的 chunk、再一個一個刪。Qdrant 沒辦法用 file_name filter(payload 沒 index)、只能 full scan。一萬份文件刪一次跑了 40 分鐘。後來決定 Postgres 是 source of truth、Vector DB 是衍生、刪文件用 Postgres id 直接 query Qdrant 拿 chunk_ids 批次刪。

第 4 站:ingestion queue——同步 ingestion 撐不住 production

Incoming file 進到系統後,要走 parsing → store → metadata → index 這條 pipeline。這條 pipeline 不該在 API request 同步觸發

Mermaid 圖(ingestion flow):

sequenceDiagram
    participant U as User
    participant API as /upload
    participant Q as Job Queue
    participant W as Worker
    participant DB as Postgres
    participant V as Vector DB
    participant S as Object storage

    U->>API: POST /upload (file)
    API->>S: store raw file
    API->>DB: insert row (status=pending)
    API->>Q: enqueue ingestion job
    API-->>U: 202 Accepted (job_id)

    Note over Q,W: async
    W->>Q: dequeue job
    W->>S: fetch file
    W->>W: parse → markdown
    W->>DB: update status=processing
    W->>V: upsert chunks + payload
    W->>DB: update status=done

Takeaway:upload API 只負責收檔 + enqueue job、不負責跑 ingestion。Worker 異步跑、status 寫回 Postgres、user 透過 job_id 查進度。

反例(必看):API request 同步觸發整條 ingestion(parse + chunk + embed + upsert)。

# ❌ 反例
@app.post("/upload")
async def upload(file: UploadFile):
    markdown = await parse(file)           # 5 秒
    chunks = await chunk(markdown)          # 2 秒
    embeddings = await embed(chunks)         # 10 秒(外部 API)
    await upsert_to_vector_db(embeddings)    # 3 秒
    return {"status": "done"}                # 總共 20 秒

為什麼錯:20 秒同步 → user 端 timeout、上傳失敗但檔案其實已寫入 storage 變孤兒、worker crash 後沒重試。Production ingestion 一定要 async、要有 retry、要有 status trace

反例情境:上線初期用同步 ingestion,某天 500 個 user 同時上傳 → API 全部 timeout、檔案卡在 storage 沒進 DB、user 抱怨「檔案傳了但查不到」。改 job queue 之後,202 Accepted + job_id poll 進度、retry 機制、status trace 三件補齊;後續半年處理了 12 萬份文件、p99 ingestion latency 控制在 8 分鐘(500 份並行 worker)。同步 ingestion 撐不住 production 不是「會慢」、是「會壞」——timeout、孤兒檔案、user 抱怨三個一起來。

第 5 站:auth + permission——ACL 多層防護(4 個失敗情境)

Part 08 的 query 端 routing 決定這題走哪個 mode,document 端的 ACL 決定這 user 能撈到哪些文件。兩端各自獨立、合在一起才是完整的存取控制。

ACL 設計在 production 容易死的地方有 4 個,按「破的時間點」由早到晚排:header spoofing → 無 tenant filter → app-layer filter 沒 RLS → JWT 沒 membership check。每一個都會讓 cross-tenant 資料 leak、只是觸發點不同。下面 4 段各自拆。

三個術語先講清楚:user_id / tenant_id / permission_groups

進 4 個失敗情境前,先講三個常被混用的欄位:

user_id            = 這個人是誰(身份,來自 JWT sub claim)
tenant_id          = 這個人正在操作哪個組織 / workspace(公司、團隊、project)
permission_groups  = 這個人在這個 tenant 裡有什麼角色(admin / staff / viewer / finance 等)

一個 user 可以屬於多個 tenant;一個 tenant 裡可以有不同 permission_groups。例如 Daniel 在 nattynites tenant 裡是 hotel_admin

{
  "user_id": "daniel",
  "tenant_id": "nattynites",
  "permission_groups": ["default", "hotel_admin"]
}

ACL 4 層防護的核心問題都是圍繞這三個欄位:誰能拿 user_id(身份驗證)、能不能進 tenant(授權)、在 tenant 裡能做什麼(角色)。下面 4 個失敗情境就是這三個問題的不同死法。

失敗 1:Header spoofing(開發版 ACL 死法)

# ❌ 反例:開發階段用 header 模擬身份
curl -X POST https://api.example.com/ask \
  -H "X-User-Id: alice" \
  -H "X-Tenant-Id: company-a" \
  -d '{"query": "Q3 合約"}'
# ❌ 反例:FastAPI 直接 trust header
@app.post("/ask")
async def ask(req: Request, body: AskRequest):
    user_id = req.headers.get("X-User-Id")        # 任何人都能改
    tenant_id = req.headers.get("X-Tenant-Id")    # 任何人都能改
    return await rag.ask(body.query, user_id, tenant_id)

為什麼錯:header 是 client 控制的、攻擊者改 X-User-Id: ceo 就假冒 CEO。開發階段用 header 模擬身份是合理的,正式 production 一定要用真實的 token 驗證(Supabase Auth JWT、Auth0、Clerk 等),user_id 從 token 解析、不從 header 讀。

修法:把 header mode 換成 JWT mode。

# ✅ Production:Authorization Bearer token
curl -X POST https://api.example.com/ask \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..." \
  -d '{"query": "Q3 合約"}'
# ✅ user_id 從 JWT sub 解析、不從 header
@app.post("/ask")
async def ask(req: Request, body: AskBody, auth: AuthContext = Depends(verify_jwt)):
    user_id = auth.user_id        # 來自 token.sub、無法偽造
    tenant_id = auth.tenant_id    # 來自 membership table
    return await rag.ask(body.query, user_id, tenant_id)

失敗 2:無 tenant filter(retriever 撈全庫)

JWT 驗完身份後,retriever 撈 chunks 時必須加 tenant filter,不然還是會 leak。

# ❌ 反例:Qdrant query 沒加 tenant filter
def retrieve(query: str, user_id: str, tenant_id: str):
    embedding = embed(query)
    results = qdrant.search(embedding, top_k=10)  # 沒 filter、撈全庫
    return results
User A 在 company-a tenant 問「Q3 合約終止條款」
→ Qdrant 搜全庫、撈到 10 個最相近的 chunk
→ 其中 2 個是 company-b 的合約
→ LLM 把 company-b 的條文回給 User A

修法:每次 Qdrant query 都帶 tenant filter。Qdrant Python client 用 models.Filter + models.FieldCondition + models.MatchValue 組 query_filter:

# ✅ 強制 tenant filter(Qdrant 1.x python client)
from qdrant_client.http import models

def retrieve(query: str, user_id: str, tenant_id: str):
    embedding = embed(query)
    results = qdrant.search(
        embedding,
        top_k=10,
        query_filter=models.Filter(
            must=[
                models.FieldCondition(
                    key="tenant_id",
                    match=models.MatchValue(value=tenant_id),  # 強制 filter
                ),
            ]
        ),
    )
    return results

但光是 app-layer filter 不夠(見失敗 3)。

失敗 3:App-layer filter 沒有 Postgres RLS(程式碼一 bug 就破)

# ❌ 反例:只有 app 層強制 filter
@app.get("/documents")
async def list_documents(req: Request):
    user_id = req.headers.get("X-User-Id")
    tenant_id = req.headers.get("X-Tenant-Id")
    # 漏寫 tenant_id filter ← 這個 bug 一次、就全庫 leak
    return await db.query("SELECT * FROM documents")

為什麼錯:app 層 filter 依賴工程師每次都記得寫、少寫一個就全庫 leak;多一層 Postgres RLS(Row Level Security)作 defense in depth、就算 app 程式碼忘了 filter、DB 層會擋

-- ✅ Postgres RLS:DB 層強制 filter
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON documents
  USING (tenant_id = current_setting('app.tenant_id')::text);
# ✅ app 設定 tenant_id context、Postgres 自動 filter
@app.get("/documents")
async def list_documents(req: Request):
    tenant_id = req.headers.get("X-Tenant-Id")
    await db.execute(f"SET app.tenant_id = '{tenant_id}'")
    # 就算這裡忘了加 WHERE tenant_id = ...,RLS 還是會擋
    return await db.query("SELECT * FROM documents")

Mermaid 圖(ACL 多層防護):

flowchart TB
    L1[1. JWT 驗證<br/>user_id 從 token.sub]
    L2[2. Tenant membership check<br/>user 有沒有進這個 tenant]
    L3[3. Postgres RLS<br/>documents table row filter]
    L4[4. Vector DB payload filter<br/>chunks tenant_id filter]
    L5[5. Storage signed URL<br/>raw file 限時 + 限 IP]

    L1 --> L2 --> L3 --> L4 --> L5

    style L1 fill:#ffcdd2
    style L2 fill:#ffe0b2
    style L3 fill:#fff9c4
    style L4 fill:#c8e6c9
    style L5 fill:#b3e5fc

Takeaway:ACL 不是一層就夠。Postgres RLS + Vector DB payload filter + Storage signed URL + App-layer filter四層各自擋不同情境。任何一層單獨 fail、其他三層還能擋住——這是 defense in depth 的真實意義。

失敗 4:JWT 沒 membership check(拿到 token 不等於能進 tenant)

# ❌ 反例:JWT 驗完就信
@app.post("/ask")
async def ask(req: Request, body: AskBody, auth: AuthContext = Depends(verify_jwt)):
    tenant_id = req.headers.get("X-Tenant-Id")  # 從 header 拿 tenant
    return await rag.ask(body.query, auth.user_id, tenant_id)

為什麼錯:JWT 只能證明「這個人是誰」(sub: alice),不能證明「alice 能不能進 company-b tenant」。攻擊者拿到合法 token 後改 header 把 tenant 換成別家,照樣撈到別家資料。

修法:JWT 證身份、membership table 證授權、tenant 從 membership 撈不從 header 拿。

-- ✅ Membership table
CREATE TABLE tenant_memberships (
  user_id TEXT NOT NULL,
  tenant_id TEXT NOT NULL,
  permission_groups TEXT[],
  PRIMARY KEY (user_id, tenant_id)
);
# ✅ tenant 從 membership 撈
@app.post("/ask")
async def ask(req: Request, body: AskBody, auth: AuthContext = Depends(verify_jwt)):
    membership = await db.query(
        "SELECT tenant_id, permission_groups FROM tenant_memberships WHERE user_id = $1",
        auth.user_id,
    )
    if not membership:
        raise HTTPException(403, "No tenant access")
    tenant_id = membership["tenant_id"]  # 從 DB 來、不從 header
    return await rag.ask(body.query, auth.user_id, tenant_id)

Takeaway:JWT + membership 是兩件事。JWT 證身份(你是誰)、membership 證授權(你能進哪)。Production ACL 一定要兩層都有


第三條主線:查得到(document APIs + citation viewer)

第 6 站:document APIs——管理後台的接口

User 上傳文件、列出文件、刪除文件、更新 metadata,這些操作不該直接戳 DB。

# ✅ Document APIs
@app.get("/documents")
async def list_documents(auth: AuthContext = Depends(verify_jwt)):
    """列出 user 看得到的文件(ACL 自動套用)"""

@app.post("/documents/upload-url")
async def get_upload_url(req: UploadRequest, auth: AuthContext = Depends(verify_jwt)):
    """拿 signed upload URL(限時 + 限 path)"""

@app.delete("/documents/{doc_id}")
async def delete_document(doc_id: str, auth: AuthContext = Depends(verify_jwt)):
    """刪文件(Postgres soft delete → Vector DB 刪 chunks → Storage 刪檔)"""

@app.patch("/documents/{doc_id}/metadata")
async def update_metadata(doc_id: str, req: MetadataUpdate, auth: AuthContext = Depends(verify_jwt)):
    """改 metadata(permission_groups / source_path 等)"""

Takeaway:document APIs 是 user 跟系統之間唯一的接口——所有 ACL 都在 API 層強制、所有 audit log 都在 API 層記錄。管理後台直接戳 DB = ACL 繞過、audit log 沒留

反例(必看):為了「快速管理」寫 admin script 直接戳 DB。

# ❌ 反例:admin 為了快速清掉測試資料
async def cleanup_test_documents():
    await db.execute("DELETE FROM documents WHERE source_path = 'test'")
    # 沒過 ACL、沒記 audit log、後面查不到誰刪的

反例情境:早期有 admin 用 psql 直接砍測試文件,後來某 user 投訴「我的文件不見了」、查 audit log 完全沒紀錄、花了兩天才從 backup 撈回來。所有寫操作都走 API = 不只是 ACL、是 auditability

Part 01 的 demo 後台就是用 document APIs 串的——使用者看到的「上傳 / 列出 / 刪除」按鈕,後面是同一套 API。

第 7 站:citation viewer——可解釋性的最後一哩

每個答案附 [1] [2] 引用、點下去看到原文 + chunk ID + retrieval score。Part 06 講 retrieval 強化(hybrid + rerank + parent expansion)、Part 07 講 citation check(faithfulness 跟 source 對得起來),這裡講 citation 怎麼送到 viewer

Payload 怎麼從 retrieve 流到 viewer——每個 stage 對 citation payload 貢獻一個欄位:

flowchart LR
    A[Query] --> B[Hybrid retrieve<br/>top 50]
    B --> C[Rerank<br/>top 10]
    C --> D[Parent expansion<br/>補 section]
    D --> E[LLM synthesize]
    E --> F[Answer]

    B -.->|file_name + chunk_id| P[citation payload]
    C -.->|retrieval_score| P
    D -.->|parent_content_preview + section| P
    P --> V[viewer<br/>[1] [2] 引用 + 原文展開]

    style P fill:#fff9c4
    style V fill:#c8e6c9

Payload 範例(不是 pseudo code、是實際送給 viewer 的 JSON shape):

{
  "answer": "Q3 終止條款規定提前 60 天書面通知。",
  "citations": [
    {
      "id": 1,
      "file_name": "NatyNites-Q3-2024.pdf",
      "chunk_id": "doc_abc_chunk_42",
      "parent_doc_id": "doc_abc",
      "section": "Article 5. Termination",
      "retrieval_score": 0.92,
      "parent_content_preview": "Article 5. Termination\nEither party may terminate this Agreement upon sixty (60) days prior written notice...",
      "highlights": [
        {"start": 0, "end": 87, "match": "Either party may terminate..."}
      ]
    }
  ]
}

Takeaway:citation 不只是「檔名 + 行號」——要有 chunk_id(debug 用)、retrieval_score(知道是 vector 撈到還是 keyword 撈到)、parent_content_preview(user 不用下載檔案就能 verify)、highlights(user 一眼看到哪段被引用)。

反例(必看):citation 只給「檔名 + 行號」。

// ❌ 反例
{
  "answer": "Q3 終止條款規定提前 60 天書面通知。",
  "citations": [
    {"file_name": "NatyNites-Q3-2024.pdf", "line": 42}
  ]
}

為什麼錯:user 看到「line 42」要自己去 PDF 翻第 42 行;debug 時不知道這個 chunk 是 vector recall 撈到(score 0.92)還是 keyword 撈到(score 0.4)、判斷不了 retrieval 哪個環節出問題;auditor 要 verify 答案真實性時沒原文、只能信 LLM。citation 設計不良 = 把可解釋性外包給 LLM 跟 user

反例情境:早期 citation 只有檔名 + 行號,user 投訴「AI 亂講」時我們要回 PDF 找原文對、debug 一次 20 分鐘。後來 payload 加上 chunk_id 跟 retrieval_score,debug 變 30 秒——直接查 Qdrant payload 就知道哪個環節出問題。


Part 09 + Part 08 = production RAG 完整迴路

文件端(Part 09 7 站)跟 query 端(Part 08 5 mode)合起來才是 production RAG 的完整 closure:

文件進系統 → Part 09 站 1-5(parsing → storage → metadata → queue → ACL)
User 問問題 → Part 08 5 種 mode routing
            → Part 09 站 5 ACL 過濾 user 能看到的文件
            → Part 08 各 mode pipeline
            → Part 09 站 7 citation viewer
            → 答案 + [1] [2] 引用回到 user

兩端各自獨立、缺一就破:文件端沒設計好(Part 09)→ cross-tenant leak、刪文件連 chunk 都找不回;query 端沒設計好(Part 08)→ 不管什麼 query 都跑同一套。兩端都做好,Part 01 互動 demo 跑的就是這條 closure。

Part 10 接著拆「把這套推上 production 的那 3 個月、11 個坑」——部署時這 7 站各自會出什麼問題、ACL 哪一層先破、citation viewer 怎麼監控、ingestion queue 怎麼 scale。