故事: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 / docx | Object storage | 不常讀、檔案大、cost 敏感 |
| Parsed markdown | Object storage | chunking 改要重讀、不進 DB |
| 結構化 metadata | Postgres | 要查詢、要 join、要 ACL |
| Embedding | Vector 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_idpoll 進度、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。