RAG 的去識別,為什麼不能只做遮蔽
大部分談 PII 去識別的文章停在同一個地方:裝上 Microsoft Presidio,它會幫你把人名、email、身分證號找出來遮掉,收工。
在 RAG 場景裡,這樣做會產生一個很難發現的問題。你把文件裡每個人名都換成 [REDACTED],資料確實安全了,但「Sarah 把這個案子轉給誰」這種問題從此答不出來,因為語料裡所有人長得一模一樣,模型分不出誰是誰。你為了安全,把資料變成了廢物。
這篇談另一種做法,程式碼在 eric0324/pii-gate。
遮蔽與假名化不是同一件事
先把選項分清楚。一個敏感欄位進到 pipeline,你其實有三種處置,而不是「遮」跟「不遮」兩種:
| 處置 | 輸出 | 救得回來嗎 | 用在哪 |
|---|---|---|---|
keep | 原封不動 | 不適用 | 這個欄位在你的語料裡本來就不敏感 |
redact | [REDACTED:CREDIT_CARD] | 不行,永久消失 | 誰都不該再看到的東西 |
pseudonymise | <PERSON_ea4090> | 可以,靠 mapping 還原 | 想遮住,但之後還要問得出來 |
差別最大的是後兩者。同一句話:
Customer Daniel Okonkwo called about a duplicate charge.
The card 4532 0151 1283 0366 was billed twice.
卡號設 redact、人名設 pseudonymise,出來會是:
Customer <PERSON_36bd6e> called about a duplicate charge.
The card [REDACTED:CREDIT_CARD] was billed twice.
卡號那格是死的。它不進 mapping,沒有任何路徑能還原,因為沒有理由讓一條 LLM pipeline 具備還原信用卡號的能力。人名那格是活的,<PERSON_36bd6e> 對應到 Daniel Okonkwo,記在語料外面,等到答案要顯示給有權限的人時,才在渲染那一刻換回真名。沒權限的人看到的一直都是代號。
關鍵是跨文件穩定
替身如果每次產生都不一樣,等於沒做。同一個 Daniel 在三份文件裡變成三個不同代號,模型會以為那是三個人,語料照樣壞掉。
所以替身必須是同一個值永遠得到同一個代號,而且要跨文件成立:
def _surrogate(self, entity: str, value: str) -> str:
digest = hmac.new(
self._secret,
f"{entity}:{value.strip().lower()}".encode(),
hashlib.sha256,
).hexdigest()
return f"<{entity}_{digest[:6]}>"
有了這個,模型看得出「這兩個是不同的人」「這個人在三份文件裡都出現過」「A 把事情轉給了 B」,只是叫不出名字。遮住身分,保留關係,這是 RAG 去識別跟一般去識別最大的差別。
為什麼是 HMAC 不是 hash
這一行值得單獨講。用 sha256(value) 也能得到穩定代號,但那是可逆的。
人名的可能組合少得可憐。任何人拿一份常見姓名清單,全部 hash 一遍建成對照表,你的代號就全開了。Email 更糟,格式高度可預測。只要一個值窮舉得完,對它做純 hash 就等於沒做。
HMAC 加了一把只有 gate 知道的金鑰,攻擊者就算拿到全部代號跟全世界的姓名清單,也對不出來。這把金鑰不進語料、不進 log,換掉金鑰等於全部代號重算。
程式在沒有金鑰時直接拒絕啟動,不給預設值:
secret = os.environ.get("PII_GATE_SECRET", "").encode()
if not secret:
print("PII_GATE_SECRET is not set. Refusing to run: surrogates would be\n"
"guessable and would not be stable across runs.", file=sys.stderr)
return 2
有預設值的安全設定,最後都會被帶到 production。
政策不寫在程式碼裡
哪些欄位要處理、信心門檻多少、抓到之後怎麼辦,這些會一直變,而且會隨語料變。寫死在程式裡的話,每次調整都要改程式、測試、部署。
所以全部丟進 YAML:
entities:
PERSON: { threshold: 0.60, action: pseudonymise }
CREDIT_CARD: { threshold: 0.50, action: redact }
LOCATION: { threshold: 0.75, action: pseudonymise }
DATE_TIME: { threshold: 0.85, action: keep }
custom_patterns:
- name: EMPLOYEE_ID
regex: '\bEMP-\d{5}\b'
score: 0.90
action: pseudonymise
context: [employee, staff, badge, payroll]
deny_list:
- name: INTERNAL_CODENAME
terms: [Project Halcyon, Blue Marlin, ORION-7]
action: redact
allow_list:
- [email protected]
- Wide World Importers
custom_patterns 的 context 是 Presidio 一個好用但少見人提的機制:附近出現這些字時,該 regex 的信心分數會被拉高。同一組數字出現在「payroll」旁邊和出現在訂單編號旁邊,風險本來就不一樣。
allow_list 常被忽略但很重要。[email protected] 是公用信箱、Wide World Importers 是資料庫名稱,它們長得像 PII 但不是,如果不排除,語料裡到處都是無意義的代號。
不確定的時候要擋,不是放行
偵測器不是二元的,它給的是信心分數。真正危險的不是它抓到的,也不是它完全沒抓到的,而是它抓到了但分數不夠高的那些。
一般做法是設個門檻,低於門檻就當作沒事。這等於把最可疑的一批默默放行。
pii-gate 的做法是再設一條 review 線。分數落在 review 線和 entity 門檻之間的 span,不處理也不放行,而是標記出來並讓程式回傳非零 exit code,讓上游 pipeline 把這份文件擋在索引外面等人工確認:
!! Below threshold, flagged for human review (2):
PHONE_NUMBER 0.40 pseudonymise '+61 3 9042 8871'
PHONE_NUMBER 0.40 pseudonymise '+44 20 7946 0958'
This document should not be embedded until a human clears these.
exit code: 1
這個例子本身就說明了為什麼需要它。Presidio 對缺乏上下文的國際電話號碼只給 0.40 分,如果門檻設在 0.60 而沒有 review 機制,這兩個電話號碼會安靜地跟著文件進向量庫。
規則檔就是控制面:把 PHONE_NUMBER 的門檻依語料實測值調到 0.40,同一份文件就乾淨通過。調校之前,閘門傾向擋下來。
三個實際踩到的坑
一、重疊 span 會讓報表說謊
Presidio 的 recognizer 會重疊,而且很常見。一個 email 在 NER 模型眼裡也像人名,一個員工編號也像地名。
Presidio 的 anonymizer 內部會挑一個贏家,輸出的文字是對的。問題在於如果你的報表和 mapping 是從原始偵測結果建的,它們會宣稱一些沒發生的事。我第一版就跑出這種東西:
<LOCATION_526f57> = EMP-51884
<PERSON_2deca6> = [email protected]
而且輸出文字被吃掉一段。修法是在分類之前先解重疊,分數高的贏,同分則長的贏:
@staticmethod
def _resolve_overlaps(results):
kept = []
for r in sorted(results, key=lambda x: (-x.score, -(x.end - x.start), x.start)):
if any(r.start < k.end and k.start < r.end for k in kept):
continue
kept.append(r)
return sorted(kept, key=lambda x: x.start)
一段文字只能有一個判決,否則報表、mapping 跟實際輸出三邊對不上,而還原功能會直接產生亂碼。
二、Presidio 對卡號跑 Luhn
我第一次寫測試資料時隨手編了一組十六位數字,結果偵測不到,一度以為 recognizer 壞了。
真相是 Presidio 的 CreditCardRecognizer 會跑 Luhn checksum,不是只比對形狀。我編的號碼過不了檢查,它拒絕是對的。
這個行為很好,誤報會少很多,但寫測試時要知道。有 checksum 的類型(卡號、部分國家的身分證號、IBAN)都要用真的通過驗證的測試值,否則你會花半小時 debug 一個沒有壞的東西。
三、別名沒解,而且不好解
Sarah Whitfield 出現一次之後,後文的 Sarah 對 NER 模型來說是另一個實體,會拿到不同的代號。更糟的是在我的測試文件裡,那個單獨的 Sarah 根本沒被抓到,直接留在輸出裡。
這不是 bug 是限制。實務上要嘛在閘門前面加一層實體解析,要嘛替每個語料維護一份姓名對照表餵進 deny list。兩種都不便宜。
同樣沒解的還有街道地址。Presidio 的 LOCATION 抓城市和國家,不抓 14 Berkeley Row,需要自己寫 recognizer。
誠實列出限制
這是我在 README 花最多篇幅的一節。理由很簡單:一個被你誇大的隱私控制,比一個你沒做的更危險。
沒做,大家知道要小心。誇大了,所有人都以為它擋住了,然後把真的敏感資料丟進去。
所以除了上面三個,還要說清楚:召回率是門檻的函數,而門檻是語料相依的,rules.yaml 裡那些數字是起點不是保證,要拿自己的標註樣本去量;目前只支援英文,其他語言要換 spaCy 模型並重新調門檻。
收尾
去識別在 RAG 裡不是一個開關,是一組取捨。
哪些欄位永遠不該還原、哪些要能還原、還原的權限在誰手上、不確定的時候要擋還是要放。這些都不是函式庫能替你決定的,Presidio 只負責找出候選,剩下的判斷是你的。
而唯一不該做的選擇,是把全部都換成 [REDACTED] 然後說問題解決了。那不是安全,那是把語料丟掉。
- 完整程式碼與測試在 github.com/eric0324/pii-gate,Docker 可部署,
./demo.sh三步走完整個流程 - 想把 AI 接進既有系統、或需要處理資料進模型前的邊界問題?歡迎聯絡