在講它的問題之前先說清楚:規則式偵測不能不做。 它快、可解釋、可稽核、不需要 GPU、結果穩定可重現。對於格式明確的直接識別碼,它的精確度比模型高。
問題不在於用不用,在於只用它。
import re
PATTERNS = {
"TW_ID": r"[A-Za-z][12]\d{8}",
"TW_MOBILE": r"09\d{2}[-\s]?\d{3}[-\s]?\d{3}",
"CREDIT_CARD": r"\b(?:\d[ -]?){13,19}\b",
"EMAIL": r"[\w.+-]+@[\w-]+\.[\w.-]+",
"TW_BAN": r"\b\d{8}\b",
}
def detect(text):
findings = []
for kind, pat in PATTERNS.items():
for m in re.finditer(pat, text):
findings.append({
"type": kind,
"value": m.group(),
"start": m.start(),
"end": m.end(),
})
return findings
拿去跑一般的測試資料,看起來完美。然後拿去跑真實文件,開始出事。
A123456789
Unicode 的全形英數(U+FF10–U+FF19、U+FF21–U+FF3A)跟半形是不同碼位。[A-Za-z] 完全匹配不到。
從 PDF 複製、從 Word 貼上、中文輸入法未切換——這些都會產生全形數字,而且非常常見。
解法:偵測前先做正規化(NFKC)。
import unicodedata
def normalize(text: str) -> str:
return unicodedata.normalize("NFKC", text)
但正規化會改變字串長度與位置索引。如果你要在原文上做遮罩,就必須維持一份正規化後位置 → 原文位置的對應表,否則遮罩會遮錯位置。這是實作上最容易踩的坑之一:
def normalize_with_map(text):
out, index_map = [], []
for i, ch in enumerate(text):
norm = unicodedata.normalize("NFKC", ch)
for c in norm:
out.append(c)
index_map.append(i) # 每個正規化字元對回原文的位置
return "".join(out), index_map
A123-456-789
A 123456789
A123456789 ← 全形半形混用
真實文件裡的個資不會乖乖連在一起。表格轉文字時會插入空白,PDF 抽取時會插入換行,使用者會自己加連字號。
解法:允許可選分隔符,但這會提高誤判。所以要搭配檢查碼驗證,並且對「有分隔符版本」給較低的信心分數。
PDF 抽取出來的文字常長這樣:
身分證字號:A12345
6789
換行插在號碼中間。任何單行 regex 都抓不到。
解法:在偵測前做行合併的前處理,但要小心不能把真正的段落也合併掉。實務上是用「行尾沒有標點 + 下一行開頭是數字」這類啟發式規則。
他的證號是 A123456789
vs
案號 A123456789 已結案
第一個是個資,第二個是案件編號,格式一模一樣。純規則無法區分,只能靠上下文。
反過來也成立:
本行客戶編號:0000123456
這是個資(可連結到特定人),但沒有任何格式特徵。
被告因長期洗腎,每週需就醫三次。
這是特種個資,規則式偵測連邊都沾不上。
| 情境 | 規則式表現 | 是否可用規則補救 |
|---|---|---|
| 標準格式直接識別碼 | 優秀 | — |
| 全形字元 | 失效 | 可(正規化) |
| 分隔符/空白 | 部分失效 | 可(放寬 pattern) |
| 跨行斷裂 | 失效 | 部分(前處理) |
| 格式相同但非個資 | 誤判 | 難(需上下文) |
| 無格式的識別碼 | 失效 | 否 |
| 敘述性特種個資 | 完全失效 | 否 |
| 人名 | 完全失效 | 否(字典法覆蓋率不足) |
最後三列是規則式的天花板。這就是為什麼需要語意層——明天的主題。
不要試圖用規則抓到所有東西,那會導致你不斷放寬 pattern,最後誤判高到使用者關掉它。
正確的定位是:規則式負責它擅長的那部分,並且把信心度標高;剩下的交給語意層。 兩層的結果做聯集,衝突時取較嚴格的處理。
def merge_findings(rule_hits, ner_hits):
"""位置重疊時,保留範圍較大者(較嚴格的遮罩)"""
all_hits = sorted(rule_hits + ner_hits, key=lambda x: (x["start"], -x["end"]))
merged = []
for hit in all_hits:
if merged and hit["start"] < merged[-1]["end"]:
merged[-1]["end"] = max(merged[-1]["end"], hit["end"])
else:
merged.append(dict(hit))
return merged
明天進語意式偵測,也就是我做「陽關」這個模型的原因。
我是 Fngi,專注在 AI 資安、LLM 紅隊與 AI 治理框架落地。這個系列的每日更新,以及平常的 AI 攻防筆記、實驗過程與研討會現場,會同步發在 IG:
有想討論的架構細節或不同意見,留言或私訊都歡迎。