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_patternscontext 是 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 接進既有系統、或需要處理資料進模型前的邊界問題?歡迎聯絡