RAG 實戰筆記

檢索層權限實作與應用:讓沒權限的文件連被檢索到的機會都沒有

2026-08-09 #RAG#Qdrant#權限控制#RBAC#資安

「RAG 實戰筆記」第四篇。企業要導入文件問答,第一個問題永遠不是準不準,而是:機密文件會不會被不該看的人問出來? 這篇拆解我的平台如何回答這個問題——核心原則只有一句話:權限在檢索層強制執行,絕不交給 LLM 把關。從 RBAC 模型、切塊 payload 設計、Qdrant filter 的組法,到幾個「不做就會被繞過」的細節設計。(前情:RAG 基礎混合檢索拆解

一、為什麼不能讓 LLM 把關權限

直覺的做法:把權限規則寫進 prompt——「你不可以透露機密文件的內容」。這條路是死路,原因有三個層次:

  1. LLM 是可以被說服的。 Prompt injection、角色扮演、「我是管理員」——模型的服從性本身就是攻擊面。把權限交給一個「會被聊天內容影響的元件」把關,等於把金庫鑰匙掛在會跟陌生人聊天的警衛身上。
  2. 進了 prompt 就等於外洩。 就算模型忍住不直接複述,機密內容已經影響了它的回答——摘要、暗示、選字都可能洩漏。真正的邊界必須在內容進入 prompt 之前
  3. 連「存在」本身都是資訊。「我找到了相關文件但不能告訴你」——這句話已經洩漏了機密文件的存在與主題方向。

所以我的平台有四條鐵則,第一條就是:權限在檢索層強制執行。使用者無權看的切塊,根本不會出現在檢索結果,不會進 rerank,更不會進 prompt——對整條下游管線來說,那些文件不存在

二、RBAC 模型:角色決定看得到什麼

權限模型參考 RuoYi 的 RBAC 設計:

1
2
3
使用者 ──* 角色 ──┬─* 功能權限(能用哪些功能)
│ └─* 資料權限(看得到哪些文件)
工號/部門

資料權限拆成四個維度,前三個管「範圍」、第四個管「密級」,彼此正交:

維度 意義
public 全公司公開文件
own_dept 自己部門的文件
cross_dept 其他部門的文件
confidential 機密文件(與範圍正交)

「正交」是關鍵設計:一份文件可以同時是「人資部 + 機密」,要看到它必須同時具備部門範圍權限與機密權限。預設的角色矩陣:

角色 public own_dept cross_dept confidential
一般員工
部門主管
系統管理員
稽核

而且這張表不是寫死的——它存在資料庫裡,管理員可以在介面上編輯。這帶出一條隱形的設計紀律:程式碼裡永遠不出現 if role == "admin" 這種硬編碼判斷,一律查權限表。否則日後新增角色時,散落各處的角色判斷就是一顆顆地雷。

三、資料端:權限住在每一個切塊上

檢索層要能過濾,前提是每個切塊自帶權限標籤。文件切塊寫入 Qdrant 時,payload 除了原文和來源,還帶兩個權限欄位:

1
2
3
4
5
6
7
{
"text": "切塊原文…",
"source": "人事管理辦法.pdf",
"chunk_index": 12,
"scope": "HR", // "public" 或部門代號
"confidentiality": "unclassified" // unclassified | confidential
}

一個看似小事、實則重要的決定:scope 存部門代號(HRIT),不存中文名稱。中文名稱只是 departments 表裡的顯示標籤——部門改名時只改標籤,幾十萬個切塊的 payload 一個都不用動。

那如果真的要改代號呢?這裡藏著一個多資料庫架構的經典陷阱:權限表在 SQLite、文件記錄在 MongoDB、但 scope 複製在每一個 Qdrant 切塊的 payload 裡。只改前兩者的話,檢索層還在用舊代號比對——結果是那個部門的員工突然什麼都查不到。所幸方向是安全的(fail-closed:資料消失而不是外洩),但功能就是壞了。所以系統提供了 rename_scope:用 Qdrant 的 set_payload 批次把所有 scope == 舊代號 的切塊改成新代號,並回傳受影響的切塊數供比對。冗餘欄位帶來檢索效率,也帶來同步義務——這是把權限放進檢索層的成本,值得,但要記帳。

四、查詢端:從 JWT 到 Qdrant Filter

完整的請求路徑是三段接力,每一段只做自己的事:

1
2
3
4
5
6
7
8
前端 ──JWT──▶ C# 閘道:驗證身分 → 查角色的 data_scopes → 展開成 perm_filter

▼ {"scopes": ["public","own_dept"], "depts": ["HR"],
"max_confidentiality": "unclassified"}
Python Agent:build_permission_filter() → Qdrant Filter


Qdrant:filter 套在檢索的 prefetch 層,無權切塊不進候選

身分由閘道從 JWT 注入——這是鐵則第二條。使用者說自己是誰不算數,LLM 認為使用者是誰更不算數;權限條件是後端查表展開的,前端傳什麼都不採信。

Agent 端把權限條件翻譯成 Qdrant Filter 的核心邏輯(節錄實際程式碼):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def build_permission_filter(perm_filter: dict | None) -> models.Filter | None:
if perm_filter is None:
return None # 僅限內部評測;正式請求一律由閘道帶入

scopes = perm_filter.get("scopes") or []
depts = perm_filter.get("depts") or []

allowed_scopes = []
if "public" in scopes:
allowed_scopes.append("public")

conds = []
if "cross_dept" in scopes:
pass # 不限部門 → 不加 scope 條件
else:
if "own_dept" in scopes:
allowed_scopes.extend(depts)
if not allowed_scopes:
# 沒有任何可見範圍 → 回傳「必然無結果」的 filter
return models.Filter(must=[models.FieldCondition(
key="scope", match=models.MatchValue(value="__none__"))])
conds.append(models.FieldCondition(
key="scope", match=models.MatchAny(any=allowed_scopes)))

if perm_filter.get("max_confidentiality") != "confidential":
conds.append(models.FieldCondition(
key="confidentiality",
match=models.MatchValue(value="unclassified")))

return models.Filter(must=conds) if conds else None

最值得停下來看的是中間那段刻意的彆扭:當使用者沒有任何可見範圍時,函式不回傳 None,而是回傳一個匹配 scope == "__none__" 的 filter——一個保證撈不到任何東西的條件。因為在這個介面裡 None 的意思是「不過濾」;如果「無權限」被表示成 None,一個上游的疏忽就會讓無權限使用者看到全部文件。這就是 fail-closed 設計:預設值永遠朝安全的方向倒。

最後一哩:這個 filter 套在混合檢索的 prefetch 層(dense 與 sparse 兩路都套)。這意味著權限過濾發生在召回之前,而不是撈回來再刪——差別很實際:

  • 召回名額不被無權文件浪費(top-20 全是你看得到的)
  • rerank 不用處理註定被丟掉的候選
  • 不存在「先看到再遮住」的時間窗

五、應用層的防繞過設計

檢索層是地基,但權限系統的完整性取決於每一條繞過路徑都被堵上。幾個實例:

上傳權與定範圍權是兩個權限。 docs.upload(能上傳)和 docs.scope(能決定文件給誰看)刻意拆開。部門主管有前者沒後者——他上傳的文件一律鎖定為自己部門,用戶端指定什麼 scope 都直接覆寫(並落一筆 SCOPE_OVERRIDDEN 稽核記錄)。同時,事後修改 scope 也被封死(回 403)——只擋上傳的話,「先上傳成部門文件、再編輯成 public」就繞過去了。但改機密等級仍被允許:把自己部門的文件標成機密只會更嚴,不構成風險。每條規則都對應一條被想像過的繞過路徑。

稽核角色能查軌跡、不能藉軌跡讀內容。 稽核能看所有人的問答紀錄(誰問了什麼、引用了哪些文件),但紀錄裡的引用只存檔名與切塊編號,不存全文——稽核權限不會變成讀機密文件的後門。

機密文件連生成階段都被隔離。 檢索結果裡如果含機密切塊,Router 會把生成路由到地端 LLM(文件內容不出內網);地端模型不可用時直接拒絕服務,而非降級改用雲端——「不可用」的正確反應是失敗,不是妥協。

測試測的是繞過,不是正常流程。 權限測試專門驗證攻擊路徑:指定 public 上傳會不會被覆寫?先上傳再編輯會不會被擋?正常流程會過只證明功能存在,繞過測試才證明邊界存在。

小結:權限系統的三層縱深

1
2
3
資料層   每個切塊自帶 scope + confidentiality(同步義務:rename_scope)
檢索層 JWT → 權限表展開 → Qdrant filter 套在召回之前(fail-closed)
應用層 權限拆分、防繞過規則、稽核不越權、機密走地端

貫穿三層的是同一個思維:不信任下游。不信任 LLM 會守規矩、不信任前端傳來的參數、不信任「正常使用者不會那樣操作」。權限系統的品質不在正常路徑多順暢,而在每一條歪路是不是都走不通。

RAG 系列四篇到此完成一個段落:基礎邏輯向量庫與 MongoDB混合檢索、權限實作。後續主題(語意快取、結構化查詢、評測方法論)等專案推進再寫。

Creative Commons 姓名標示 非商業性

本文採用 CC BY-NC 4.0 授權

歡迎轉載與引用,請標明出處(檢索層權限實作與應用:讓沒權限的文件連被檢索到的機會都沒有 — EmptyWu);禁止用於商業用途。

商業使用或合作提案,歡迎來信洽談:[email protected]

留言
分享

留言