權限感知檢索(Permission-Aware Retrieval)一句話講完:在 AI 去資料庫「找資料」的那一步,就先確認「問問題的這個人有沒有資格看」,讓他沒權限的資料根本不會被交到 AI 手上。
今天這篇是我自己手打的筆記加AI的整理,希望對正在打造 RAG 知識庫的夥伴有幫助。先把幾個名詞講清楚:
為什麼需要懂它?因為企業導入 RAG 時最常見的一個錯誤,就是把全公司的文件一腦兒丟進向量資料庫,對所有人開放同一個問答機器人。原本在雲端硬碟裡只有人資看得到的薪資表,現在實習生問一句「誰領的錢最多」就可能被撈出來。OWASP(開放式 Web 應用程式安全計畫,一個制定資安風險清單的非營利組織)在 2025 年版的 LLM 十大風險中,把「向量與嵌入弱點(LLM08)」獨立列為一項,其中第一個風險就是「未授權存取與資料外洩」。權限感知檢索就是針對這個風險的解法。
它的位置是事前預防:審計日誌(Audit Log,系統把「誰在什麼時候看了什麼」記下來的紀錄)只能在事情發生後追溯,權限感知檢索則是讓不該發生的事一開始就不發生。具體做法有三種:前過濾、後過濾,以及實務上最常用的「混合式」。
要懂權限為什麼會不見,得先知道資料在系統裡長什麼樣子、怎麼被找到。
| 型態 | 例子 | 怎麼找資料 | 權限通常放在哪 |
|---|---|---|---|
| 結構化資料 | Excel、關聯式資料庫(SQL) | 下精準條件,例如「部門 = 業務部」 | 資料庫的帳號、表格、欄位權限 |
| 非結構化資料 | 純文字、Word、PDF | 全文關鍵字比對(字要一模一樣) | 檔案系統或 NAS 的存取控制清單 |
| 向量資料 | RAG 知識庫裡的文字切片 | 算「意思有多接近」(距離越短越相關) | 預設沒有,要自己放 |
幾個名詞:
很多人以為向量資料庫只存一串數字,其實一筆紀錄通常有三個部分:
重點在第 3 點「如果你有放的話」。另外要注意:向量本身也不是「看不懂所以安全」的亂碼。研究已經證實,可以從向量反推回接近原文的內容(稱為嵌入反轉攻擊,Embedding Inversion),OWASP 也把它列為 LLM08 的風險之一。
傳統的搜尋系統本來就有權限控管。你在 Google Drive 搜尋「薪資」,沒權限的檔案不會出現。但把文件搬進 RAG 時,有兩個環節會讓權限掉地上:
第一,切碎(Chunking)時權限沒跟著走。 一份 30 頁的 PDF 會被切成幾百個小段落、各自轉成向量。大部分入門教學只教「讀檔 → 切片 → 轉向量 → 存進去」,沒有一步是「把原檔的 ACL 一起帶過來」。結果所有切片被倒進同一個大鍋,不分來源、不分密級。
第二,語意搜尋讓「猜」變得很便宜。 這裡要講精準一點:真正的漏洞是「檢索層沒有權限檢查」,不是「語意搜尋穿透了權限」。就算用關鍵字搜尋,只要沒檢查權限一樣會漏;差別在於,關鍵字搜尋要猜中「薪資表」這三個字,語意搜尋只要問「誰領的錢最多」「主管年終拿多少」這種意思相近的句子就能命中。攻擊者不用知道檔名,甚至不用知道這份文件存在。
更麻煩的是,很多人的對策是在 System Prompt(系統提示詞,寫給 AI 的底層指令)裡加一句「不要透露機密」。但只要機密已經被撈進 AI 的上下文,就只剩「AI 聽不聽話」這道防線,而提示詞注入(Prompt Injection)就是專門讓 AI 不聽話的技術。
| 解法 | 怎麼做 | 問題 |
|---|---|---|
| 提示詞防守 | System Prompt 寫「不要透露」 | 資料已經在 AI 手上,靠自律不是靠權限 |
| 審計日誌 | 記錄誰問了什麼、拿到什麼 | 只能事後追溯,資料已經外洩 |
| 依部門分庫 | 人資一個庫、業務一個庫 | 跨部門專案、個人層級權限切不動,庫會越分越多 |
| 權限感知檢索 | 在檢索那一步就按使用者身分過濾 | 實作有取捨,就是本文重點 |
依部門分庫其實是權限感知檢索的一種粗糙版本,適合部門邊界非常清楚的小公司。
權限感知檢索做的事情很單純:把公司原本就有的權限規則,延伸到 AI 翻小抄的那一步。 它帶來三件事:
進入前後過濾之前,先立幾個座標。後面每一種做法的優缺點,其實都是在這幾條原則之間做取捨。
原則一:AI 不是權限邊界。 權限要在「檢索層」就決定,不能留到「生成層」讓 AI 自己判斷。理由很簡單:語言模型的行為是機率性的,可以被提示詞操弄;資料庫的過濾條件是確定性的,不會因為你講得很委屈就放行。
原則二:單一真實來源(Single Source of Truth)。 「誰能看什麼」的最終答案,只能有一個地方說了算,通常是公司原本的身分系統(例如 Active Directory,微軟的企業帳號管理系統)或檔案系統的 ACL。向量資料庫裡的權限標籤只是「副本」,副本一定會有過期的時候。
原則三:預設拒絕(Fail Closed)。 權限系統掛了、查不到、逾時——一律當作「沒權限」。寧可讓使用者少看到一筆資料,也不要讓他多看到一筆機密。
原則四:權限綁「群組」,不綁「個人」。 切片上貼的是「人資部」「主管」這類群組名稱,而不是「王小明」「李大華」。這樣有人升遷、換部門、離職時,只要在身分系統裡把他移進移出群組,向量資料庫一個字都不用改。這條原則會在後面大幅緩解「前過濾的權限同步地獄」。
原則五:不讓回應洩漏「存在性」。 沒權限的資料,對使用者來說就應該「不存在」。不要回「您無權存取」,因為這句話本身就告訴對方「這裡有東西」。
原則六:縱深防禦(Defense in Depth)。 不要期待單一一道關卡完美。這也是為什麼實務上最後會走向「混合式」:前過濾擋掉大部分,後過濾再驗一次。
想像你是一間大圖書館的館員,館裡有 10 萬本書,其中 1 萬本是只有主管能看的機密。今天一位實習生來問「公司財務營運相關的資料」,你要找資料給他,而且「絕對不能讓他看到機密帳冊」。你有三種做法。
比喻: 書上架時,你就在每本書封面貼彩色標籤(紅標:僅限主管;綠標:全員公開)。實習生來問問題時,你連看都不看紅標書架,直接走進綠標專區,在那 9 萬本書裡找最相關的 5 本。
工程角度: 系統送給向量資料庫的查詢帶有兩個條件:
語意查詢:"公司財務營運相關"
過濾條件:allowed_groups 包含使用者所屬的任一群組
向量資料庫在算語意距離之前(或同時),就把使用者沒權限的資料點排除在外。主流的向量資料庫(例如 Qdrant、Weaviate、Pinecone、pgvector)都支援這種「帶過濾條件的搜尋」。
優點:
編輯修正:我原本寫「絕對安全(Zero Leakage)」,這是誇大的。前過濾的安全性完全取決於標籤是不是最新的。如果一份文件上個月是公開、這個月被改成機密,而向量資料庫的標籤還沒同步,前過濾就會很自信地把它當公開資料交出去。「標籤過期」不只是效能問題,而是安全問題。
代價:
比喻: 你完全不管標籤,全館大搜查,挑出最相關的 5 本抱到櫃檯。這時才翻開借閱規定一本一本核對:「第 1 本機密帳冊,丟掉;第 2 本董事會紀錄,丟掉;第 3 本執行長薪資,丟掉……」最後 5 本全部丟掉,你兩手一攤:「抱歉,找不到相關資料。」
工程角度:
優點:
代價:
比喻: 標籤還是要貼,但只貼「不太會變的大分類」(部門、密級)。實習生來問時,你先走進綠標專區(前過濾),多抱幾本到櫃檯;出櫃檯前再用最新的借閱規定核對一次(後過濾),把「標籤寫綠色、但上週已經改成機密」的那幾本抽掉。
工程角度:
精驗那一步常見的做法,是接一個專門回答「誰能對什麼做什麼」的授權服務。這類服務多半受 Google 在 2019 年發表的 Zanzibar 論文啟發,開源實作有 OpenFGA、SpiceDB 等,可以當成「公司的借閱規定服務台」。
| 前過濾 | 後過濾 | 混合式 | |
|---|---|---|---|
| 權限判斷依據 | 切片上的標籤(副本) | 真實權限系統 | 標籤粗篩 + 真實系統精驗 |
| 標籤過期時 | 會洩漏 | 不受影響 | 精驗擋下 |
| 檢索空洞 | 不會 | 容易發生 | 很少發生 |
| 實作成本 | 中(要建同步機制) | 低 | 高 |
| 適合情境 | 權限很少變動 | 機密佔比低、先做原型 | 正式上線的企業知識庫 |
整個流程的關鍵是「權限有兩個來源」:左邊的主流程負責找資料,右邊兩個框是權限的副本與正本。
【此處插入架構圖截圖:混合式權限感知檢索 · 三步驟、兩個權限來源】
粗篩查的是副本,快但可能過期;精驗查的是正本,慢但一定準。右邊那條虛線「同步,可能延遲」,就是前過濾會出事的地方,也是為什麼需要第 ③ 步。
這個範例不接任何真實資料庫、不用嵌入模型,只用純 Python 把「關鍵機制」拆出來。向量我手動設成三維,三個維度可以想成「跟錢有多相關」「有多機密」「是不是規章」。真實的嵌入模型有上千維,但算距離的邏輯一模一樣。
情境:實習生 Amy 問了一句「誰領的錢最多」。
import math
from dataclasses import dataclass
@dataclass
class Chunk:
id: str
text: str
vector: list # 拿來算語意距離的座標
doc_id: str # 這個切片來自哪份原始文件
tags: set # 貼在切片上的權限標籤(副本,可能過期)
# 真實權限系統:文件層級的 ACL,這裡說了算
SOURCE_ACL = {
"salary-2026": {"hr", "exec"},
"ceo-comp": {"exec"},
"board-minutes": {"exec"},
"raise-draft": {"hr"}, # 上週從全員公開改成只有人資能看
"expense-policy": {"all"},
"finance-faq": {"all"},
}
# 身分系統:每個人屬於哪些群組
USER_GROUPS = {
"intern_amy": {"all"},
"cfo_bob": {"all", "hr", "exec"},
}
INDEX = [
Chunk("c1", "2026 年全體員工薪資明細", [0.9, 0.9, 0.1], "salary-2026", {"hr", "exec"}),
Chunk("c2", "執行長年度薪酬與分紅", [0.9, 0.85, 0.05], "ceo-comp", {"exec"}),
Chunk("c3", "董事會:調整高階主管獎金", [0.8, 0.9, 0.2], "board-minutes", {"exec"}),
Chunk("c4", "明年調薪草案(初稿)", [0.85, 0.7, 0.3], "raise-draft", {"all"}), # 標籤還沒同步!
Chunk("c5", "實習生津貼何時發放", [0.8, 0.2, 0.5], "finance-faq", {"all"}),
Chunk("c6", "差旅報帳上限與流程", [0.6, 0.1, 0.9], "expense-policy", {"all"}),
]
為什麼這樣設計?重點是「權限存在兩個地方」。SOURCE_ACL 是公司原本的權限系統,tags 是複製到向量資料庫的副本。我故意讓 c4 的副本還停在「全員公開」,模擬真實世界的同步空窗期。另外注意 tags 裡放的是群組(hr、exec),不是人名,這就是原則四。
def cosine(a, b):
"""餘弦相似度:兩個向量指向越接近,分數越接近 1。"""
dot = sum(x * y for x, y in zip(a, b))
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
def top_k(query, pool, k):
"""在 pool 裡找最像 query 的前 k 筆。向量資料庫的本業就是這件事。"""
return sorted(pool, key=lambda c: cosine(query, c.vector), reverse=True)[:k]
def can_read(user, doc_id):
"""問真實權限系統。任何查不到的情況都當作沒權限。"""
try:
return bool(USER_GROUPS[user] & SOURCE_ACL[doc_id])
except KeyError:
return False
cosine 是「餘弦相似度」(Cosine Similarity),可以想成「兩支箭頭指的方向有多一致」。can_read 裡的 except KeyError: return False 只有一行,卻是整段程式最重要的一行:使用者不在名單上、文件不在名單上,一律拒絕。這就是原則三「預設拒絕」實際落在程式裡的樣子。
def post_filter(user, query, k):
hits = top_k(query, INDEX, k) # 先搜全庫
return [c for c in hits if can_read(user, c.doc_id)] # 再問真實系統
def pre_filter(user, query, k):
pool = [c for c in INDEX if c.tags & USER_GROUPS[user]] # 先看標籤縮小範圍
return top_k(query, pool, k) # 再搜
def hybrid(user, query, k, overfetch=3):
pool = [c for c in INDEX if c.tags & USER_GROUPS[user]] # 粗篩:便宜,但可能過期
candidates = top_k(query, pool, k * overfetch) # 多抓一些候選
verified = [c for c in candidates if can_read(user, c.doc_id)] # 精驗:問真實來源
return verified[:k]
if __name__ == "__main__":
q = [0.9, 0.8, 0.2] # 「誰領的錢最多」:很跟錢有關、很機密、不太像規章
for name, fn in [("後過濾", post_filter), ("前過濾", pre_filter), ("混合式", hybrid)]:
result = fn("intern_amy", q, k=3)
print(f"{name}:", [c.text for c in result] or "(空的)")
三個函式的差別,其實只是「過濾放在搜尋的前面還是後面」以及「過濾依據是副本還是真實來源」這兩個選擇。hybrid 裡的 overfetch=3 是為了預留「被精驗剔掉」的空間,實務上這個倍數要依機密資料的比例來調。
後過濾: (空的)
前過濾: ['明年調薪草案(初稿)', '實習生津貼何時發放', '差旅報帳上限與流程']
混合式: ['實習生津貼何時發放', '差旅報帳上限與流程']
三行輸出各自對應一個結論:
你可以自己改改看:把 intern_amy 換成 cfo_bob,或把 c4 的標籤修正成 {"hr"},觀察三種做法的結果怎麼變。
權限感知檢索解決的是「不該看的資料被撈出來」,它不是萬靈丹。至少還有三件事不在它的範圍內:
如果你正在打造企業 RAG,我的建議是:原型階段用後過濾快速做出來,但第一天就把「文件編號」和「來源系統」存進每個切片的附帶資料。只要有這兩個欄位,之後要升級成混合式,就只是多加一層標籤;沒有的話,你得重新切一次整個知識庫。