2026-06-19
RAG 知識庫的權限控制:別讓 AI 把不該看的文件答出來
在 RAG 系統中,越權答案不是「答錯」,而是把不該暴露的文件餵給了模型。本文從風險認知出發,深入講解 RAG 權限隔離的正確實現路徑:為什麼過濾必須發生在檢索層、召回→ACL 過濾→Rerank 的正確流水線順序、物理隔離與邏輯隔離方案的選型依據、多租戶 tenant_id 的安全傳遞規範,以及如何堵住側通道與引用洩露,最終給出可落地的審計與驗收方案。
越權答案不是答錯,是把不該看的餵進了上下文
先把問題定義清楚。一個 RAG 知識庫出錯,通常分兩類:一類是檢索到的內容本身有限,模型答得不夠準,這屬於品質問題,可以靠召回最佳化、提示工程、人工評測慢慢磨。另一類完全不同——模型答得很準,準到把一份你本來無權看的文件原文複述給你。後者不是品質問題,是安全事故。把這兩件事混在一起談,是很多團隊上線後才反應過來的第一個坑。
列幾個具體場景,工程上都不少見。一個普通員工在內部問答裡輸入「今年管理層定了哪些戰略調整」,系統從向量庫裡召回了一份高管閉門會紀要,模型照著總結出來;財務以外的部門同事問「我們組明年預算大概多少」,結果連隔壁部門的預算盤子一起答了出來;外包駐場人員手裡只有一個普通帳號,卻能問出核心研發模組的設計文件;多租戶的 SaaS 產品裡,客戶 A 的營運在後臺一問,命中了客戶 B 上傳的合同樣本。這些場景的共同點是:發起提問的人沒做任何「攻擊」,他只是正常打字,是系統主動把不該給的內容遞了過去。
這裡有一個時間順序上的關鍵判斷,值得單獨拎出來說。一旦無權內容進入了模型的上下文視窗,洩露在工程意義上就已經發生了,跟模型最後吐沒吐出來無關。很多人下意識想在輸出端補救——加一道敏感詞過濾、讓模型在回答前先「宣告」自己只能講有權限的部分、或者用另一個模型審一遍輸出。這些都晚了。因為只要那段文字被拼進了 prompt,它就參與了這一輪推理:模型可能在回答裡換個說法轉述,可能被追問幾句套出來,可能在引用列表裡露出文件標題,甚至可能因為多輪對話的上下文殘留而在後續輪次復現。你過濾掉的只是某一次輸出的某種表述,擋不住資訊本身已經在場。
所以真正的控制點只有一個位置:模型生成之前。具體說,是在檢索召回和上下文拼裝這兩步裡就把權限算清楚,讓無權文件根本進不了候選集,更進不了 prompt。這是結構性的安全,而不是靠運氣的過濾。把權限放在生成之後,等於先把保險櫃開啟讓人看了一眼,再討論要不要讓他描述裡面有什麼——順序錯了,後面做什麼都是補救。
那能不能把權限規則寫進系統提示,讓模型自己判斷「這段你能不能看」?答案是不能,原因也在上面那條時間線裡:要讓模型判斷,前提是這段內容已經在它的上下文裡了,判斷這個動作本身就發生在洩露之後。退一步講,即便不考慮順序,讓模型執行訪問控制也是把一個確定性的邏輯判斷,交給一個機率性的系統去做——它今天遵循,明天可能在一句巧妙的追問下就繞過去了。權限校驗需要的是「要麼給、要麼不給」的確定結果,這種活兒應該留給程式碼和資料層,不該指望模型的自覺。
把這層認識立住,後面的工程設計才有地基。權限控制在 RAG 裡不是某個可選的增強功能,而是檢索流水線的一等公民,它決定的是哪些文件有資格成為候選、能不能進入排序、最終能不能被模型看到。從這一節往後,我們討論的所有方案——檢索層過濾的順序、物理與邏輯隔離的取捨、多租戶的身份來源、快取策略、側通道封堵——本質上都是在回答同一個問題:怎麼保證在模型動筆之前,它面前攤開的每一份材料,提問的人都確實有權看。這個判斷標準會貫穿全文,遇到任何方案,都先拿它去量一量是否成立。
把權限釘死在檢索層:為什麼「拿到結果再過濾」不等於安全
先說一個工程上容易被混淆的等式:很多團隊預設「系統有登入、分了管理員和普通使用者,那權限就做完了」。這是把兩層不同的東西畫上了等號。登入鑑權解決的是「你是誰、能不能進這個功能」,它是一道訪問門;而知識庫的權限要回答的是「這條具體的文件片段,當前這個人能不能被它影響到答案」。前者是功能粒度,後者是資料粒度,兩者之間隔著整個檢索語義。門開了不代表門後每一份資料都對你開放。
真正的分歧出現在過濾的時機上。一種做法是先正常召回,把命中的片段拿到手,再在返回前按權限篩一遍——聽起來結果一樣,對外只吐出有權看的那部分。但這條路在工程上是有裂縫的。問題不在最終展示,而在中間那一步:被篩掉的片段已經從向量庫裡取出來了,它的存在、它的相關性得分、甚至它的標題,都已經進入了系統的處理鏈路。一旦後續有任何環節(重排序、日誌、除錯輸出、快取鍵)觸碰到這批未過濾的候選,無權內容就有了洩露的入口。「事後篩掉」保護的是出口,沒保護過程。
所以過濾必須前移到召回和上下文注入這兩個動作之前發生。換個角度看會更清楚:模型最終能說出什麼,完全取決於上下文裡被餵進去了什麼。要讓無權文件絕不可能出現在答案裡,唯一可靠的辦法是讓它壓根沒機會進入上下文——也就是在構造檢索條件時就把權限約束當成查詢的一部分,沒權限的片段連被取出的資格都沒有。這樣保護的邊界從「輸出層」挪到了「資料獲取層」,越靠前,可被繞過的環節就越少。
結構上,比較穩的做法是把權限抽成一個獨立的層,讓它橫在業務邏輯之上,而不是散落在各個呼叫點裡。無論是 RAG 的檢索請求,還是 Agent 對話發起的工具呼叫、知識查詢,入口都先過這一層校驗,拿到的是已經被權限收窄過的可見範圍,再往下走召回。把權限做成共享的前置關卡而非每個功能各寫一遍,好處有兩個:一是判定邏輯只有一份,不會出現「檢索介面校驗了、Agent 那條鏈路忘了校驗」的不一致;二是審計有統一的落點,誰在什麼權限下取了哪些片段,能集中記錄、能回放。
需要強調的是,應用層那套「管理員/普通使用者」的角色隔離不是多餘的,它是必要的第一道關,負責功能可見性和操作權限的粗粒度劃分。但它處理的是「能不能用這個功能」,管不到「這次檢索召回的幾十條片段裡哪幾條該被你看見」。後者是細到單條資料、還要結合查詢語義動態判定的事,必須由檢索層自己承擔。把這兩層分清楚,才不會出現「角色配對了,可知識庫照樣答出了別的部門的合同條款」這類問題。
落到判斷上:評估一個 RAG 系統的權限是否可信,不要只看它有沒有登入和角色,要看過濾動作發生在流水線的哪一步。如果答案是「召回之後、返回之前」,那它防的是展示,不是資料;如果答案是「召回條件裡就帶著權限約束、無權片段從未被取出」,才算把權限釘在了檢索層。這條線,是「看起來安全」和「真的安全」的分界。
檢索流水線的正確順序:召回 → ACL 過濾 → Rerank → top5
檢索鏈路裡有四個動作:向量召回、權限過濾、重排、截斷取前幾條。它們的相對順序不是風格問題,而是會直接決定有沒有越權洩露。把順序定下來,比調任何一個模型參數都重要。一個能用的預設配置是:召回階段放寬到幾十條候選(取 50 是個穩妥的起點),交給權限服務逐條精確校驗,過濾掉無權存取的,剩下的再做重排,最後擷取前五條進上下文。下面拆開講每一步為什麼是這個位置。
召回的口子要開得夠大
很多人憑直覺把初始召回的 top_k 設成 5,理由是「反正最後也只用五條」。這個直覺在加了權限過濾之後就會出問題。召回是按向量相似度排的,跟當前使用者有沒有權限毫無關係——相似度最高的那幾條,完全可能正好是這個使用者看不到的文件。如果初始只撈 5 條,過濾完可能只剩 1 條甚至 0 條有效結果,模型拿著殘缺的上下文去答,要麼答得很虛,要麼乾脆說不知道。使用者的體感是「這破系統什麼都查不到」,但根因不在檢索品質,在召回口子開太小,被權限過濾一刀切空了。
所以召回階段要按「過濾後還能剩夠用的量」來倒推。如果一個部門的可見文件佔比偏低,召回數就得相應往上抬。50 是經驗值,不是鐵律——可以觀察過濾通過率,動態調整:通過率低的租戶多召回,通過率高的可以收一點省算力。關鍵是別讓過濾把候選池抽乾。
過濾必須卡在重排前面
把權限過濾放在重排之後,是個看起來無害、實則危險的順序錯誤。重排(Rerank)的本質是讓一個模型讀進候選文件的正文,再按與問題的相關性重新打分。注意「讀進正文」這件事——只要先重排再過濾,那些無權存取的文件內容就已經被送進了重排模型。即便它們最後被過濾掉、沒進最終上下文,洩露在重排那一刻就已經發生了。
如果用的是自建、跑在內網的重排服務,這層風險還算可控,至少資料沒出牆。但很多團隊為了省事直接調外部 Rerank API,性質就完全不同了:無權文件的正文被打包發到了第三方服務商的伺服器上。這已經不只是邏輯越權,而是把租戶的敏感資料外傳出了自己的邊界。合規審計裡這是要出大事的一類,跟「答案裡多說了一句」完全不在一個量級。
反過來,先過濾再重排,邏輯就乾淨了:進入重排模型的每一條候選,都是當前使用者確認有權看的。重排只在合法候選裡排序,無權內容根本沒機會被任何模型——無論內部還是外部——接觸到。順帶還省了算力:重排是按候選條數收費、按條數耗時的,先把無權的砍掉,等於讓重排只處理真正會用到的那部分。
把這條鏈路當成一條防線看
這四步連起來,是一條單向收窄的漏斗:50 條候選 → 權限過濾後剩下合法的若干條 → 重排 → 取前五。每一步都只會讓資料範圍變小或重排,絕不會把已經過濾掉的東西再放回來。這個單調收窄的性質,是你能對外承諾「越權內容不會進上下文」的工程依據——只要過濾這一環的判定是可靠的、卡位是在重排之前的,後面無論重排怎麼排、截斷取幾條,都不可能憑空冒出無權文件。
落到實現上,權限過濾這一步建議走集中的權限服務,對召回回來的每條文件逐一校驗,而不是在檢索層裡塞一堆零散的 if 判斷。這樣過濾邏輯只有一處、可測、可審計,哪天權限規則變了也只改一個地方。校驗的入參裡,使用者身份和租戶標識必須來自服務端可信來源,這一點在多租戶場景下尤其要釘死——但那是另一節要展開的事了。
三種隔離方案怎麼選:從獨立索引到行級權限
權限隔離不是一個開關,而是一條強度連續的光譜。從工程實現上看,可落地的做法大致分三檔:每租戶獨立的索引或庫、共享儲存裡靠 tenant_id 與 acl_tags 強制過濾、再到把權限下沉進資料來源本身的行級控制。強度從高到低,成本與靈活性恰好相反。選哪一檔,取決於你的資料敏感度、租戶數量級,以及團隊願意為隔離付出多少維運代價。
最強的一檔是物理隔離:每個租戶一套獨立的向量索引、獨立的庫,甚至獨立的 namespace。它的好處是邊界清晰到幾乎不需要解釋——查詢根本碰不到別人的資料,因為那些資料壓根不在當前連接的索引裡。出了問題,排查範圍天然收斂在單租戶內。代價也直白:租戶數一上規模,索引碎片化嚴重,資源利用率掉下來,熱點租戶和長尾租戶混在一套排程裡,維運要為成千上萬個小庫做版本管理、備份、遷移。這條路適合租戶少、單租戶資料量大、合規要求硬的場景,比如幾家大客戶各自獨佔一套;不適合 SaaS 那種動輒上萬小租戶的形態。
中間一檔是邏輯隔離加強約束:所有租戶的資料放在同一套儲存裡,每條切片帶上 tenant_id 和 acl_tags 這類標籤,查詢時把這些標籤作為強制條件壓進過濾器。它在資源效率和隔離性之間取了個平衡,是大多數多租戶系統的現實選擇。但這一檔的安全完全押在「過濾永遠不被繞過」上——只要有一條查詢路徑忘了拼 tenant_id,跨租戶洩露就發生了。所以它對工程紀律的要求最高:過濾條件必須收口到一個統一的檢索入口,不允許任何業務程式碼自己手寫裸查詢,標籤的寫入和校驗都要有測試兜底。它給你省下的機器成本,會以程式碼審查和迴歸測試的形式還回來。
最貼近資料的一檔是行級權限,典型實現是 PostgreSQL 的 Row Level Security。它把過濾規則寫進資料庫策略,無論誰發起查詢、走哪條業務路徑,資料庫都會按當前會話身份自動追加行級條件。它的價值在於把「別忘了過濾」這件事從應用層移走了——應用就算寫了一條不帶租戶條件的查詢,資料庫也會替你擋住越權行。對那些怕業務程式碼越來越多、過濾點越來越散的團隊,RLS 是一道兜底的護欄。前提是你的檢索棧確實經過這層資料庫,而且會話身份的設定本身可信;如果向量檢索繞開了關係庫直接打索引,RLS 就管不到那部分了。
這三檔不是互斥的。常見的穩妥組合是:用邏輯隔離承載主體,再疊一層 RLS 做兜底,對極少數最敏感的租戶單獨切出物理隔離。關係庫這一側的權限模型也要跟上。一個樸素但有效的做法,是在使用者表裡放一個 role 欄位,在資料庫層面把 admin 和普通 user 區分開,讓「誰能看管理範圍、誰只能看自己範圍」在資料結構裡就成立,而不是散落在各處的 if 判斷裡。再往上,單獨建一張 user_file 之類的關聯表,把每一份上傳進知識庫的文件和它的歸屬、可見範圍登記清楚。這張表的意義不止於權限校驗:它讓管理員能統一維護文件清單,出問題時能溯源到「這份文件是誰傳的、授權給了誰」。權限和資產臺賬放在一起管,審計和回收才有抓手。
有一個誘惑值得專門提醒:不要試圖用知識圖譜來替代權限系統。圖譜擅長表達實體之間的關係,看起來天然能描述「誰能訪問什麼」,於是有人想把訪問控制也建模成圖裡的邊,靠圖查詢來判定權限。這條路通常會走進新的權限地獄——關係一旦複雜起來,邊的語義會膨脹,繼承、傳遞、例外規則互相纏繞,最後沒人說得清某個使用者到底為什麼能看到某份文件。權限判定要的是確定、可列舉、可審計,而圖查詢給的是靈活但難以窮盡的推理。把這兩件事混在一層,除錯時你會同時丟掉圖譜的清晰和權限系統的可控。讓知識圖譜去做它擅長的語義關聯,權限的最終裁決交給一套獨立、規則明確的機制,這個邊界劃清楚,後面省下的麻煩遠比當初多寫的幾張表要多。
選型上給個可操作的判斷:租戶少、合規硬,優先物理隔離;多租戶 SaaS,邏輯隔離打底加 RLS 兜底;無論哪種,關係庫裡的 role 與 user_file 這類臺賬都不能省,它們是權限校驗和事後審計共同依賴的地基。強度越高的方案越貴,但洩露一次的代價通常比這些成本高得多,往強的一側多留一點餘量,一般不會後悔。
多租戶強隔離:tenant_id 只能來自服務端,絕不讓前端傳
多租戶的事故往往不是因為過濾邏輯寫錯了,而是因為過濾所依賴的那個身份欄位本身就可被偽造。你可以把召回、ACL、Rerank 的順序排得一絲不苟,但只要 tenant_id 是從請求體裡讀出來的,整條流水線就建在沙地上。攻擊者不需要繞過任何過濾規則,只要把 JSON 裡的 tenant_id 改成別人的值,過濾器就會忠實地幫他召回另一個租戶的全部文件。這是把判官的筆交到了被告手裡。
所以這一節其實只講一件事:身份欄位的來源。tenant_id、user_id、department_id 這類決定「你能看什麼」的欄位,必須從服務端的認證上下文裡取,由閘道器或鑑權中介軟體在校驗完會話之後注入,下游服務不接受、也不讀取請求裡攜帶的同名欄位。換句話說,前端可以告訴你它想查什麼,但不能告訴你它是誰。「想查什麼」是業務參數,「我是誰」是身份事實,這兩者的信任級別天差地別,混在同一個請求體裡傳是工程上的大忌。
為什麼「前端能傳」約等於「一定會被偽造」
有人會說,前端傳 tenant_id 只是為了方便,正常使用者不會去改它。這個假設在內部系統裡都站不住腳,在對外服務裡更是災難。請求體對客戶端完全透明,改一個欄位的成本是開啟開發者工具按幾下回車。一旦 tenant_id 可由前端控制,攻擊面就從「突破鑑權」退化成「猜一個合法的租戶 ID」,而租戶 ID 經常是自增整數或可列舉的短字串。這等於把強隔離降級成了一道形同虛設的提示。多租戶場景下,tenant_id 應當作為強制附加的隔離欄位繫結在每一次檢索請求上,並且這個值只能源自服務端的認證結果——這一點在相關工程實踐中被反覆強調,原因正是偽造 tenant_id 或 user_id 會直接導致跨租戶的越權讀取。
落到實現上,可操作的判斷有幾條。第一,鑑權中介軟體解析完 token 後,把租戶與使用者身份寫進一個請求級的上下文對象,檢索服務只從這個上下文裡取值,程式碼層面不提供「從 body 讀 tenant_id」的入口,從源頭上消滅誤用。第二,把 tenant_id 作為查詢的硬性條件下推到向量庫或後設資料儲存,而不是查完再在應用層比對——前者是資料庫幫你保證邊界,後者是你自己記得過濾,可靠性不在一個量級。第三,對每一條入庫的文件,租戶歸屬在寫入時就固化為不可變的後設資料欄位,避免後續靠路徑、名稱空間等「軟約定」來推斷歸屬。
登入鑑權是隔離生效的前提
tenant_id 來自服務端的前提,是先有一個可信的服務端身份。如果系統允許匿名訪問,那認證上下文裡根本沒有租戶資訊,強隔離也就無從談起。所以知識庫管理頁和對話頁都應當強制登入,只有通過帳號鑑權的合法會話才能進入系統,不留匿名入口。這不只是為了擋住未授權的人,更是為了保證每一次檢索請求背後都有一個明確、可追溯的身份,過濾邏輯才有依據可循。一個沒有登入態的請求,應當在進入檢索流程之前就被拒絕,而不是帶著空身份走到過濾這一步再做特判——fail-closed 比 fail-open 在這裡是不可妥協的預設值。
權限的管理面同樣要收緊。新增使用者、刪除使用者、調整歸屬與權限這類操作,應當限定在專門的管理員角色手裡,普通帳號既看不到也調不動。把使用者與租戶的對映關係、權限變更記錄統一持久化到可靠的儲存裡,一來保證服務重啟或擴容後身份關係不丟,二來讓每一次權限調整都有據可查。這部分聽起來像基礎的後臺管理,但它恰恰是 tenant_id 可信度的根基:服務端注入的那個值之所以可信,是因為它背後有一套被嚴格管控的身份資料在支撐。
幾個容易被忽略的邊角
多租戶隔離做到位之後,仍有幾處縫隙值得盯緊。其一是後臺批處理與非同步任務,這類呼叫往往沒有使用者會話,開發者容易圖省事用一個超級權限去跑全量資料,結果繞過了租戶邊界,建議為系統任務也分配明確的租戶範圍或顯式的服務身份,而不是預設放行。其二是跨服務呼叫時身份的透傳,A 服務帶著使用者身份調 B 服務,如果中間環節把上下文丟了,B 服務就可能用預設或空租戶執行檢索,這種「中途掉身份」的問題在鏈路長的系統裡很常見,需要在服務間約定好身份的傳遞契約。其三是快取鍵,多租戶下任何快取都必須把 tenant_id 納入鍵的組成,否則一個租戶的查詢結果可能被另一個租戶命中,隔離在快取層悄悄失效。
這一節的核心立場可以收成一句:身份是事實,不是參數。讓服務端獨佔 tenant_id 的寫入權,配合強制登入與受控的權限管理,多租戶隔離才有一個不可偽造的支點。其餘的過濾、排序、審計都建立在這個支點之上——支點鬆了,上面蓋得再精巧也是空中樓閣。
效能與快取:批次校驗、TTL 與高敏不快取的取捨
把 ACL 過濾插進檢索路徑,繞不開一個現實問題:每多一道權限判斷,就多一段同步等待。檢索本身已經要扛向量召回的延遲,如果過濾環節再拖幾百毫秒,整條鏈路的體感就崩了。這一節談的是怎麼讓權限校驗既準又快,以及哪些地方為了快而省的步驟其實省不得。
最容易踩的坑是逐條校驗。召回階段一次拿回幾十個候選 chunk 很正常,假如對每個 chunk 都單獨發一次權限查詢,那就是幾十次往返。權限服務通常是獨立部署的,單次呼叫看著不貴,乘上候選數量再疊加網路抖動,尾延遲會被拉得很難看。更糟的是這種呼叫模式對權限服務自身是放大攻擊——一個檢索請求扇出成幾十個下游請求,併發一上來下游先被打垮。
正確的做法是把判斷合併成一次。拿到候選集後,先按文件維度去重——同一篇文件常常命中多個 chunk,真正需要確認權限的是文件而不是每個片段。去重後把這批文件 ID 一次性丟給權限服務,讓它批次返回每個文件對當前主體是否可見。幾十個候選去重後往往只剩十幾篇文件,一次批次查詢就能覆蓋,往返次數從幾十降到一次。前提是權限服務得提供批次介面;如果只有單查介面,這是優先要補的工程缺口,而不是用併發循環硬湊。
批次之後還想再壓延遲,就輪到快取。檢索場景裡,短時間內對相近問題的召回結果高度重疊,同一個使用者對同一批文件的可見性在幾十秒內基本不會變,這部分判斷結果是值得快取的。快取鍵要帶上主體身份和文件標識,絕不能只按文件快取——按文件快取等於把 A 的權限判斷結果發給了 B,這就不是效能最佳化是越權了。
TTL 怎麼定,本質是在新鮮度和命中率之間找平衡。把過期時間控制在 60 秒或 300 秒這個量級是比較穩妥的區間:足夠覆蓋一次連續問答裡的重複召回,又短到讓權限變更能在可接受的視窗內生效。TTL 拉得越長命中率越高,但權限收回後的「放行視窗」也越長——使用者已經被移出某個專案組,快取卻還認為他能看,這段時間裡他依然問得出內容。所以 TTL 不是越大越好,它是一個可以解釋給安全團隊聽的風險參數。
真正的分界線在高敏資料上。對涉密文件、合規受限內容這類高敏權限,我的判斷是不快取。原因很直接:快取意味著接受一個過期視窗,而高敏場景對「權限已收回但仍被放行」幾乎零容忍。這裡寧可每次都走一遍即時校驗,把那點延遲吃下來,也不要為了快留一個數分鐘的暴露口子。普通文件可以快取換效能,高敏文件用效能換確定性,這是按資料等級分檔處理,不是一刀切。
落地時把這幾條串起來:校驗路徑上先去重再批次,快取鍵繫結主體,普通權限設短 TTL,高敏權限標記為不可快取並強制即時查。另外留一個口子——當權限系統發生變更(成員調整、文件密級升級)時,最好能主動失效相關快取,而不是乾等 TTL 自然過期。能做到主動失效,普通文件的 TTL 就可以適當放寬,整體延遲和新鮮度都更好看。這套機制不復雜,但它決定了權限過濾是「能用」還是「能上生產」。
堵住側通道與引用洩露:答案沒漏,標題和命中數也可能漏
前面幾節解決的是「內容不進上下文」。但權限洩露不止內容這一條路。一個把正文過濾得很乾淨的系統,照樣可能從邊縫裡漏資訊出去——而且這些邊縫平時沒人盯,上線後才被人摸出規律。
最容易被忽視的是引用本身。RAG 答案後面掛的那串出處,工程上往往被當成「已經過濾過的安全副產品」直接渲染出去。問題在於,標題這一行就可能是敏感資訊。一篇叫《裁員名單及補償方案(2025Q1)》的文件,哪怕模型一個字正文都沒引用,光把這個標題顯示在引用列表裡,就已經把「公司在做裁員、且方案已成文」這件事告訴了不該知道的人。檔名、文件摘要、所屬空間名、最後修改人,這些後設資料都屬於要做權限判斷的對象,不能因為它不是「正文」就放行。
引用連結的處理要比展示更狠一層。展示時做了過濾,不代表點開時還安全。常見的坑是:列表渲染走了一遍 ACL,但點選跳轉直接拿文件 ID 去對象儲存取原文,這一跳沒有再校驗。結果就是越權使用者雖然看不到引用條目,卻能拿到一個洩漏出去的連結、或者猜到 ID 規律,繞過列表直接拉全文。正確做法是把點選開啟當成一次獨立的資源訪問請求,在服務端重新執行一遍文件級權限校驗,和檢索那次是兩套獨立的檢查點,誰都不替誰背書。ID 用不可列舉的隨機串,也是順手該做的。
真正陰險的是統計型側通道。系統經常會在介面上順手暴露一些「無害」的數字:召回命中數、相似度分數分佈、「為你找到 N 篇相關文件」這類提示。攻擊者不需要看到內容,他用低權限帳號反覆試探不同關鍵詞,觀察命中數從 0 跳到 1,就能推斷出某個名字、某個專案、某份合同在庫裡存在。這就是經典的存在性洩露——你確認了「有這麼個東西」,本身就是情報。錯誤提示的差異同樣致命:「無權存取」和「未找到」如果返回得不一樣,等於明明白白告訴對方「東西在,只是你看不了」。這兩種情況的對外表現必須做成一致的:要麼都返回空,要麼都返回同一個泛化提示,不讓響應的形狀隨權限狀態變化。
處理側通道的思路和處理正文不同。正文是「過濾掉就行」,側通道是「要讓有權和無權的兩次請求在可觀測層面長得一樣」。所以命中數、分數這類輔助資訊,要麼不展示,要麼基於過濾後的結果集再算一遍,絕不能拿過濾前的原始數字。這裡有個容易踩的反例:為了效能,先算好總命中數快取起來,再做權限過濾——那個快取的數字就是過濾前的,照樣漏。統計口徑必須跟在權限邊界後面走。
最後是可審計這件事,它不是合規擺設,是排查洩露的唯一抓手。側通道洩露的特點是隱蔽、滯後、難復現,等你發現異常往往已經過去很久。所以每次檢索要把判斷依據落到日誌裡:請求方的身份和權限上下文從哪來、參與過濾的策略命中了哪幾條、原始召回多少、過濾後剩多少、最終引用了哪些文件 ID。這幾個數字湊齊,事後任何一條「某某看到了不該看的」的投訴,都能拉出當時的現場重新跑一遍,確認到底是過濾邏輯出錯、快取讀髒了,還是權限資料本身配錯。沒有這條證據鏈,你連「有沒有漏」都說不清,更別提定位。把策略和過濾過程做成可回放,等於給整套權限控制上了一道事後保險。
落地與驗收:審計欄位、交付目標與可回放的過濾證據
到這一步,權限設計的成敗不再取決於你畫的架構圖,而取決於一件事:出了問題,你能不能把當時的過濾過程原樣調出來看。驗收一個 RAG 權限方案,本質是驗收它的可觀測性——每一次檢索都得留下能復盤的痕跡,而不是只看最終答案對不對。下面四個問題,是交付評審裡最常被追問的。
我已經在 Prompt 裡寫了「不要洩露機密內容」,還需要做檢索層權限嗎?
需要,而且這兩件事不在一個層面上。先撇開模型聽不聽話的爭論,只看一個工程指標:你怎麼向驗收方證明它生效了?Prompt 裡的約束沒有任何可記錄的產物——沒有欄位、沒有計數、沒有可回放的判定記錄,事後只能靠重新提問去碰運氣復現,這種東西過不了審計。
檢索層的權限則天然產出證據。一次請求走完,日誌裡應當落下三個遞減的數:召回階段拿到多少條、按訪問控制清單篩過之後剩多少條、最終拼進上下文多少條。比如原始召回 50、過濾後 12、入上下文 5,再附上這次校驗了幾篇文件、放行了幾篇。這串數字才是驗收方真正要的東西。它把「權限有沒有起作用」從一個信念問題,變成了一個可以逐條核對的事實問題。Prompt 約束可以作為最末端的兜底話術,但它替代不了能留痕的檢索層管控。
權限校驗到底放在 Rerank 之前還是之後?
之前,沒有例外。這裡換個角度從驗收倒推:交付目標是「越權證據進入上下文 0」,要證明這一點,你必須能拿出一個在重排序發生之前就已經確定的過濾後計數。如果校驗排在 Rerank 後面,那意味著越權文件已經先一步進了打分環節——哪怕最後沒被選中,它的內容也已經被外部重排序服務讀取過,這就已經構成洩露,而你的日誌根本記不下「它本不該出現在這裡」。
所以順序不只是效能考量,它直接決定了 after_acl_count 這個欄位有沒有意義。只有過濾先行,這個數才代表「經過授權的候選集大小」,重排序和截斷都只在這個集合內部進行,證據鏈才閉合。
逐條調權限服務太慢,能不能加快取?
能加,但快取命中不能讓審計鏈斷掉。常見做法是把一批文件 ID 的訪問判定合併成一次批次校驗,再給結果配一個較短的過期時間,避免每條都去敲權限服務。這部分最佳化在效能那節已經講透,這裡只補一條驗收紅線:即便命中快取,本次請求仍要照常記錄校驗文件數與通過數,過濾原因照常可回放。否則快取就成了審計盲區——你查一次越權事件,發現日誌裡寫著「命中快取」,卻調不出當時依據的是哪個版本的權限快照,這種日誌等於沒記。
另外,高敏感等級的內容判定不進快取,每次實算。快取換來的是吞吐,代價是判定的時效性會滯後於權限變更,對普通文件可以接受,對高敏文件不能拿這點延遲去賭。
答案正文沒洩露內容,引用標題和命中數也算洩露嗎?
算。這正是為什麼 final_count 要單獨記錄、日誌要按維度拆開。一份使用者無權存取的文件,哪怕正文沒被複述,只要它的標題出現在引用列表裡,或者命中數從平時的 3 條悄悄變成 4 條,對方就推斷出了「這裡存在一份我看不到的東西」。這類側通道洩露不會體現在答案文本上,只能靠把管理員操作、RAG 檢索、Agent 對話執行這幾類日誌分開審計才查得出來。
驗收方法很直接:拿一個低權限帳號,專門去問那些你明知存在但它無權看到的內容,然後核對返回的引用標題、命中計數有沒有任何抖動。三個計數欄位加上分類日誌,讓這種探測留下可對比的基線。
把上面四點收口成一張驗收清單:權限必須在檢索層強制生效,越權證據零進入上下文,每次過濾都附帶可回放的判定原因。落地節奏上,權限版本建議放在 MVP 的第二到第四周完成,先把審計欄位和這道越權防線補齊,再往上疊功能。順序反了,後面每加一個檢索入口,都是在沒有留痕能力的地基上蓋樓。