假設一份契約有 30 頁,「王小明」出現了 47 次。如果每次都產生不同的 Token:
第 1 頁:立約人林建成……
第 3 頁:立約人陳志豪就前開事項……
第 8 頁:張雅婷應於期限內清償……
模型會認為這是三個不同的人。摘要出來的內容會是錯的,而且錯得很難發現——因為每一句話單獨看都合理。
所以一致性不是「加分項」,是正確性的前提。
層級一:文件內一致。 同一份文件裡的同一個實體對到同一個 Token。這是最低要求。
層級二:案件內一致。 同一個案件的所有文件(起訴狀、答辯狀、附件)之間一致。這是做跨文件分析(D17)的前提。
層級三:全域一致。 所有文件、所有案件之間都一致。
層級三聽起來最方便,但它是安全上最糟的選擇。 因為它讓 Token 變成一個穩定的全域識別碼——攻擊者只要在任何一個地方確認了「林建成 = 某人」,就能關聯到所有文件。這等於把去識別化降級成了「換一個名字繼續當識別碼」。
所以正確的目標是層級二:案件內一致,跨案件不可關聯。 這就是 D13 提到的 scoped determinism。
import hmac, hashlib
def get_token(real_value: str, entity_type: str, scope_id: str,
key: bytes) -> str:
material = b"\x00".join([
scope_id.encode(),
entity_type.encode(),
real_value.encode(),
])
digest = hmac.new(key, material, hashlib.sha256).digest()
idx = int.from_bytes(digest[:8], "big")
return generate_from_index(entity_type, idx)
把 entity_type 也放進去,是為了避免跨類型碰撞——一個剛好等於某個帳號的字串,不應該跟人名共用同一個 Token 空間。
這個函式是純函數:同樣輸入永遠得到同樣輸出,不需要查資料庫。所以文件內、案件內一致性是自動達成的,不需要維護狀態。
真正的困難不在雜湊,在於判斷兩個字串是不是同一個實體。
王小明
王 小明 ← 中間有空白
王小明先生 ← 帶稱謂
小明 ← 簡稱
甲方(即王小明) ← 定義式引用
上開王君 ← 法律文書慣用語
這六個都指向同一個人。如果只做字串比對,會產生六個不同的 Token。
需要一個正規化層:
TITLES = ["先生", "女士", "小姐", "君", "氏", "律師", "法官"]
PARTY_REFS = ["甲方", "乙方", "丙方", "原告", "被告", "上訴人", "相對人"]
def normalize_entity(name: str) -> str:
s = unicodedata.normalize("NFKC", name)
s = re.sub(r"\s+", "", s)
for t in TITLES:
if s.endswith(t):
s = s[: -len(t)]
return s
稱謂和空白好處理。難的是後三種:
簡稱。 「小明」要對應到「王小明」,需要在文件範圍內維護一個實體表,用後綴匹配。
定義式引用。 「甲方(即王小明)」建立了一個別名,之後所有「甲方」都指向王小明。這需要抓出定義句型並記錄別名。
ALIAS_PATTERN = re.compile(
r"(?P<alias>甲方|乙方|丙方|原告|被告|上訴人|相對人)"
r"[((](?:即|下稱|以下簡稱)?\s*(?P<real>[^))]{2,10})[))]"
)
def extract_aliases(text):
aliases = {}
for m in ALIAS_PATTERN.finditer(text):
aliases[m.group("alias")] = normalize_entity(m.group("real"))
return aliases
法律文書慣用語。 「上開王君」「本件被告」這類指代,需要語意層才能解。這是 D6 那個 NER 模型可以擴充的方向,但坦白說解不乾淨。
案件裡有兩個「陳美玲」:借款人的配偶,以及承辦的律師。
如果只用姓名做 key,這兩個人會共用同一個 Token。結果是模型會以為律師和配偶是同一人。
需要用更多欄位做區辨。實務上的作法是優先用強識別碼建立實體身分:
def entity_key(attrs: dict, scope_id: str) -> str:
"""優先用強識別碼,退化才用姓名"""
for field in ("tw_id", "passport", "account_no"):
if attrs.get(field):
return f"{field}:{attrs[field]}"
# 沒有強識別碼,用姓名 + 角色
role = attrs.get("role", "unknown")
return f"name:{attrs['name']}:{role}"
如果連角色都判斷不出來,那就是一個該標記為需人工複核的情境,不要猜。猜錯的代價是實體合併,而實體合併會讓整份摘要失真。
因為一致性是正確性的前提,它應該被測試:
def check_consistency(original, deidentified, entity_map):
"""檢查每個實體的出現次數在去識別化前後是否一致"""
errors = []
for real, token in entity_map.items():
n_real = original.count(real)
n_token = deidentified.count(token)
if n_real != n_token:
errors.append({
"entity": real,
"original_count": n_real,
"token_count": n_token,
})
return errors
次數不一致代表有漏替換(有些出現位置沒被偵測到)或過度替換。這是一個能自動抓到偵測漏抓的檢查,而且不需要人工標註——比 D8 的評測便宜得多,可以對每一份實際文件都跑。
我很推薦把這個檢查放進正式管線,不只是測試環境。它是一個廉價的第二道防線。
明天談跨文件查核:多份附件之間怎麼在去識別後仍能比對。
我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。
IG:@aid3fend
有想討論的架構細節或不同意見,留言或私訊都歡迎。