Day 14 結尾承認了一件事:這條 RAG 管線對所有人一視同仁——誰來問,檢索都在整個 index 上跑。公司內部知識庫的第一個現實剛好相反:財務的薪資文件、法務的合約條款、跟全公司都能看的請假規則,住在同一個知識庫裡,但不同的人本來就不該看到同樣的文件。
這天把授權接上去,而整篇的主張只有一句:在共用 index 的 RAG 上,授權不能做成「拿到結果之後檢查一下」,它必須是查詢本身唯一能有的形狀——「唯一」指的是本專案 production 檢索邊界上可表達的形狀,這句話的邊界後面會誠實交代。讀完你會知道為什麼 filter 必須在伺服器端組、vectorFilterMode 選錯會以什麼樣子咬你、以及為什麼這個系統刻意沒有 403。
授權要先有「你是誰」。這天的答案是兩個 HTTP header:X-Tenant-Id(必須恰好一個)與 X-Group-Ids(零或一個,逗號分隔清單)。backend 把它們解析成一個 frozen 的 Principal,之後整條管線都只認這個物件。
先把醜話說完:這兩個 header 是信任 gateway 的輸入,不是身分驗證。任何能直接打到 backend 的人,都可以在 header 裡自稱任何租戶、任何群組——它們是 impersonation 旋鈕。這個設計成立的前提是部署拓撲:前面必須有一個 gateway,把 client 自帶的身分 header 全部剝掉或覆寫,而且流量只能從那個 gateway 進 backend。
真的驗 token(Microsoft Entra ID)是 Day 19 的正題,到時候換掉的是 header 的來源,不是後面整條授權管線——這也是為什麼值得先把管線做對。
解析本身倒是一點都不將就。identifier 限 [A-Za-z0-9_-]{1,64}、群組最多 100 個、原始 header 上限 4096 bytes;群組去重排序,讓後面組出來的 filter 是確定性的。任何違規——重複的 X-Tenant-Id、CSV 裡的空 token(a,,b)、超長超量——一律回 401 unauthorized(帶 WWW-Authenticate: Bearer),不是 422:header 語法是「你是誰」的問題,不是「你問了什麼」的問題,用 422 回會把這兩件事的界線弄糊。
另外兩條紅線:tenant_id 進每一行 log(跟 Day 5 的 correlation id 同一套 ContextVar 機制),group ids 永遠不進 log——群組成員資格本身就是敏感資料。
直覺的做法是加一層檢查:檢索回來,逐筆看 caller 有沒有權限,沒有就丟掉。這個形狀有一個結構性的弱點:它是可以忘記的。檢查是一個步驟,步驟就有可能被某個新的 callsite 跳過;review 抓得到今天的程式碼,抓不到半年後的。
這天的做法是讓「不檢查」在型別上不存在:
async def search(
self,
query_text: str,
query_vector: Sequence[float] | None = None,
*,
mode: SearchMode = SearchMode.HYBRID,
top: int,
principal: Principal, # 沒有預設值
vector_k: int = DEFAULT_VECTOR_K,
) -> SearchResult: ...
SearchClient.search() 的 principal 參數沒有 default、沒有 overload、沒有任何一條 code path 能發出不帶 filter 的查詢。「沒有授權脈絡」不是這個邊界能表達的值,所以它不可能因為某處忘了傳而發生——忘了傳是靜態檢查(mypy)與執行期都過不去的錯,不是一次靜默的全庫檢索。
filter 由 services/acl.py::build_acl_filter(principal) 這一個函式組出來,長這樣:
tenant_id eq 'tenant-a' and (
not allowed_groups/any()
or allowed_groups/any(g: search.in(g, 'finance,support'))
)
三個語意都值得說:
not allowed_groups/any() 這個「公開」分支對每個 principal 都成立,因為 allowed_groups: [] 的文件定義上就是全租戶可讀。一個自己不帶任何群組的 principal,filter 化簡成只剩這個分支:它只看得到 tenant-wide 的文件,一筆群組文件都看不到。escape_odata_literal()(值裡的 ' 寫成 '',跟 SQL 字串跳脫同一招)守的是 OData 字串字面值這層,每個進 filter 的字面值都過它。search.in 的 value list。**群組清單是逗號 join 後塞進 search.in,那層分隔字元文法(查核 2026-08,文件)跳脫函式管不到——靠 identifier charset 禁逗號與空白,就是依賴上游驗證。hostile bypass test 釘住它:放寬 charset 就先紅。為什麼強調「伺服器端組」?因為另一個常見形狀是讓 client 傳 filter(或 filter 的片段)進來。那等於把授權政策的組裝權交給被授權的對象——client 能組「我的租戶」,就能組「別人的租戶」。filter 必須是 caller 完全碰不到的東西:caller 給的只有身分(而且 Day 19 之後連身分都是驗過的),政策長什麼樣子由伺服器單方面決定。
ACL filter 套在向量查詢上有一個容易被跳過的參數:vectorFilterMode。這天每個向量查詢都明寫 preFilter,不依賴 index 建立時的預設值。
差別在 filter 跟 ANN 搜尋的先後。Azure AI Search 的 HNSW 圖是分 shard 存的,GA 的 postFilter 在每個 shard 上不看 filter 先跑完 ANN、選出 unfiltered local top-k,才在 shard 內套 ACL、把倖存者彙整成全域 top-k(查核 2026-08,官方 vector filter 文件)。
另有 preview 的 strictPostFilter 更極端:先組全域 unfiltered top-k 才過濾。本篇不用它。
也就是說:你的文件得先在自己那個 shard 的鄰居賽跑裡贏過所有租戶的文件,才進得了過濾前的名單。在共用 index 上,這個賽跑對每個租戶都不公平:shard 裡別人的文件越多,你的文件越擠不進 local top-k 的名額,過濾完就是回傳變少、甚至變零——官方文件把這個叫 false negatives。

這個症狀的險惡之處在於它不像授權 bug。沒有錯誤、沒有 log、沒有任何紅字——只是「檢索好像變爛了」,而且是隨著別的租戶灌資料逐漸變爛。等有人真的追到 vectorFilterMode 這個參數,中間通常已經繞了一圈 embedding 品質、切分策略的冤枉路。preFilter 把順序倒過來:每個 shard 在 ANN 走訪過程中就套 filter,圖會一直展開到湊滿 k 個過得了 filter 的候選——只要 index 裡存在夠多你看得到的文件,回來的就是你的世界裡的 top-k。
忍喵:這種 ticket 會開成「檢索最近怪怪的」,然後指到 embedding 團隊頭上——權限 bug 偽裝成品質 bug,是最會躲的那種。
這裡有一個誠實註記:preFilter 不是免費的。官方 benchmark(查核 2026-08,同一頁文件)顯示它幾乎總是比 postFilter 慢,index 越大、filter 越挑(selectivity 越高)差距越大——百萬向量、filter 只留 2% 以下時可以慢到七倍。本系列這天只驗了 correctness,沒有量測任何 latency/throughput,所以我不能說自己的量級「碰不到」這個問題,只能說 25 chunks 的 smoke 構不成效能證據。選 preFilter 是把 recall/正確性排在速度前面的專案決策,不是普遍最優解。
被 filter 擋掉的文件,長什麼樣子?跟從來沒被索引過的文件一模一樣。它在被評分之前就被排除了,管線下游沒有任何訊號能區分「不存在」跟「存在但你不能看」。跨租戶的提問於是走到 Day 14 蓋好的結構:零命中就是 status: "no_answer"——跟語料裡真的沒答案完全同形。
這不是偷懶,是刻意的。403 是一個訊號:它告訴 caller「這裡有你不能看的東西」。對知識庫來說,文件的存在性本身經常就是機密——「你無權查看《裁員名單.md》」洩漏的資訊可能比文件內容還多。同形(indistinguishability)才是這裡正確的合約:問了不該問的,得到的體驗跟問了不存在的一樣。
忍喵:錯誤訊息也是回應的一部分——「你無權查看」這句話本身,就已經把檔案的存在洩漏出去了。
同一條原則蓋到對話狀態。Day 7 的 conversation store 這天改成以 (tenant_id, conversation_id) 為 key——沒有跳過 tenant 的查法。租戶 B 拿著租戶 A 的 conversation_id 來,得到的是 404 conversation_not_found,跟一個從來沒發出去過的 id 同一個回應,不是 403。而且這次失敗的嘗試不做任何 mutation——A 之後繼續用同一個對話,什麼都沒發生過。
查詢側的 filter 再對,寫入側漏了就全白搭。兩個設計都是同一個原則:fail-closed——出錯的方向必須是「看不到」,不能是「多看到」。
第一,allowed_groups 是必填欄位,缺了是 contract error,不是預設公開。 空清單 [] 是一個明確的宣告(tenant-wide 可讀);欄位不在、型別不對,loader 直接拒絕整份文件。如果缺值被解讀成「沒設就是公開」,那任何一個忘了寫 front-matter 的文件都會默默對全租戶敞開——錯誤的方向就反了。
這也是為什麼上線 ACL 前 index 要用 --recreate-index 重建。官方合約說得很清楚的部分是上傳側:collection 欄位不能存 null,上傳時省略會自動變成空 collection [](查核 2026-08,supported data types),而 not allowed_groups/any() 命中的就是空 collection——公開分支。
說得不清楚的部分是遷移側:對著既有 index 加欄位、舊文件未重索引時到底是什麼狀態,reindex 文件用泛稱帶過、沒有解開 collection 專屬的行為,我這天也沒有做這個狀態的實測——所以「舊文件會默默變 tenant-wide 可讀」是一個未驗證的風險,不是我證明過的機制。重建的定位因此是 fail-closed 的預防:與其推理一個文件寫不清楚的遷移狀態,不如不讓 ACL 查詢跑在任何早於 ACL 欄位的文件上。
第二,文件 key 用 length-prefix 把租戶編進去。Day 12 的 chunk_id 是從裸的 doc_id 導出來的,跨租戶會撞:兩個租戶各自有一份 returns-policy.md,parent_id 相同,租戶 B 重建索引時的 stale 清理會把租戶 A 的 chunks 當過期資料刪掉。修法是把識別範圍改成 (tenant_id, doc_id),而且編碼用 length-prefix 而不是分隔字元:
parent_id = f"t{len(tenant_id)}={tenant_id}d{len(doc_id)}={doc_id}"
chunk_id = f"{parent_id}-{ordinal:04d}"
為什麼不直接 f"{tenant_id}--{doc_id}"?因為分隔字元不保證唯一解析:("a", "b--c") 跟 ("a--b", "c") 拼出來是同一個字串 a--b--c——兩對不同的 (tenant, doc) 撞在同一個 key 上,而兩邊的值都沒有任何「不合法」的地方。
length-prefix 把邊界變成一個讀者可以重算的數字(= 後面讀 N 個字元),不是一個值裡可能剛好出現的字元:("a", "b--c") 是 t1=ad4=b--c、("a--b", "c") 是 t4=a--bd1=c,長度不同,key 就不同。
= 跟 - 都在 Azure AI Search 的 document key charset 裡(字母、數字、_、-、=;查核 2026-08,naming rules),這個方案沒有引入任何新字元,只是重排既有的。
Day 14 記了兩筆債,這天一起清償——它們跟授權是同一個主題的不同層:ACL 管「哪些文件進得了 context」,這兩筆管「進了 context 的文件能對模型做什麼」。
Source fencing:每筆檢索回來的 chunk 用 BEGIN UNTRUSTED SOURCE n / END UNTRUSTED SOURCE n 圍起來,疊在 Day 14「來源是資料不是指令」的模板規則上。要說清楚的是:這是標記,不是沙箱——一筆刻意寫成指令樣子的投毒語料,不會因為被圍起來就失效,它仍在威脅模型上。fence 買到的是讓模型分得清楚哪裡是來源、哪裡是指令,降低誤讀,不是消滅惡意。
Citation validation:模型答案裡的 [n] 標記,現在會對實際入選的來源編號做語法驗證——超界或憑空捏造的 [n] 被剝除、只記編號進 log、不讓請求失敗也不改 status。同樣要說清楚邊界:這驗的是「引用指向一個真的在 context 裡的來源」,不是「被引用的文字真的支持這句話」。後者是 groundedness,屬於評測(Day 28),不是這天的語法檢查能承諾的。
合約層的測試(704 unit、BDD tenant_isolation.feature 4 scenarios)跑在 fake 上——fake 不解析 OData,它用 is_document_visible(document, principal) 這個純 Python 判斷式在評分之前先過濾(跟真路徑的 pre-filter 同形,不是選完 top-k 再丟)。
要誠實的是:它跟 build_acl_filter() 是同一份 policy 的兩套編碼——同住 services/acl.py,review 的時候一眼看得到彼此,但沒有任何機制強制兩邊同步;哪天有人改了一邊忘了另一邊,type checker 不會攔。兩邊的一致性靠的是同一組測試案例打在兩套編碼上,加上 live 探測——是有範圍的證據,不是結構保證。
但 Azure AI Search 是不是照設計解讀這串 filter,只有打真服務才知道。2026-07-31 在 japaneast 的 Free tier 服務上重建 index(25 chunks、兩個租戶的目錄語料),跑三個 target-visibility 探測:
三探全 PASS。留檔的 evidence 裡,請求本體不含原始 ACL filter 與 tenant/group 字面值,只記 filter_present=true 加 filter 的 SHA-256——證據檔案本身也不能變成群組成員資格的洩漏管道。
services/acl.py 的正確性,不是任何硬體或網路邊界。這是共用 index 的固有代價,不是可以之後修掉的缺口;要物理隔離就是 index-per-tenant,那是另一組成本與運維的取捨。is_document_visible 與 build_acl_filter 是同一 policy 模組裡的兩套獨立編碼,一致性由共用測試案例與 live 探測支撐;「OData 引擎對 filter 的解讀」只有 live 探測蓋到,而那是單次量測(2026-07-31、japaneast、Free tier、api-version 同 Day 13)。到這裡,Chat 與 RAG 兩條路都通了、有帳、有權限。Part 3 收工。下一個問題不是「還能加什麼」,而是「什麼時候該停」:很多場景 Chat API 加上好的 prompt 就夠了,硬上 Agent 只是把簡單問題變貴、變慢、變難除錯。Day 16 進 Part 4,先講清楚 Chat API、RAG、Agent 的分界線——不要為了 Agent 而 Agent——再決定哪些問題值得讓模型自己拿工具。
完整程式碼在 day-15 tag,CI 綠。
用到的 Azure 服務:Azure AI Search(Free tier,--recreate-index 重建與三個 ACL 探測,2026-07-31)、Azure OpenAI(embed-small 查詢向量、chat-mini 生成,僅 smoke 期間)。
Free tier Search 收費為 $0,但容量受限(50 MB、最多 3 個 index,查核 2026-08,pricing);用完記得 infra/scripts/delete-search.sh。
本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。