`import unicodedata` 主要就是拿來做「去掉重音符號」。
為什麼要這樣做?因為真實世界裡,人搜尋時常常會偷懶。
文件裡可能寫的是 `café`、`résumé`、`diagnóstico`,
但使用者實際打進搜尋框的往往是 `cafe`、`resume`、`diagnostico`。
所以如果我們想讓資料比較容易被搜到,
就常需要先把這些重音符號攤平,
讓查詢字形和資料字形更容易對上。
目前對應的程式是:
def strip_accents(text: str) -> str:
normalized = unicodedata.normalize("NFKD", text)
return "".join(ch for ch in normalized if not unicodedata.combining(ch))## 1. 這段在做什麼
像西文裡的字:
– `diagnóstico`
– `información`
– `México`
裡面的 `ó / á / é` 這類字,其實可以拆成:
– 一個基底字母
– 加上一個重音符號
`unicodedata.normalize(“NFKD”, text)`
會先把字元分解成「基底字母 + 結合符號」。
這裡的 `NFKD` 比較適合整體看成
`Normalization Form Compatibility Decomposition`。
重點不是把 `N` 和 `F` 分開背,而是先把前半段一起理解成:
– `Normalization Form` = Unicode 的一種正規化形式
也就是說:
– `Normalization` 是大方向:
把不同寫法整理成比較一致的表示方式
– `Form` 是具體形式:Unicode 下面有好幾種正規化形式,
例如 `NFC / NFD / NFKC / NFKD`
– `K` 代表這是 compatibility 版本的 normalization;
它不是靠發音來縮寫,而是 Unicode 規格裡
約定用來標記 compatibility form 的代號
– 記憶口訣:`K = OK to flatten compatibility forms`
– `D` 代表 `Decomposition`,
表示它偏向把字元拆開,而不是重新合成
可以先把它白話記成:
– `NFKD` = 用 compatibility 規則做的「拆開型」正規化
一個最小對比例子是:
– `NFD`:會把 `é` 拆成 `e` + 重音符號
– `NFKD`:也會把 `é` 拆開,
而且還會比 `NFD` 更願意把一些相容字形攤平成一般形式
如果想看得更具體,可以直接跑這段 Python:
import unicodedata
samples = ["é", "A", "fi", "㎏", "①"]
# "fi" 看起來像 f + i,但其實是單一連字字元。
# "㎏" 看起來像 k + g,但其實是單一 kg 符號字元。
# 也就是說:眼睛看起來像兩個字,Unicode 內部未必真的是兩個字元。
for text in samples:
nfd = unicodedata.normalize("NFD", text)
nfkd = unicodedata.normalize("NFKD", text)
print("SRC ", repr(text), [hex(ord(ch)) for ch in text])
print("NFD ", repr(nfd), [hex(ord(ch)) for ch in nfd])
print("NFKD", repr(nfkd), [hex(ord(ch)) for ch in nfkd])
print("---")你會看到重點差異像這樣:
這組例子剛好能看出差別:
– `é`:`NFD` 和 `NFKD` 都會拆成 `e` + 重音符號
– `A`:`NFD` 不動全形字母,`NFKD` 會攤平成一般半形 `A`
– `fi`:`NFD` 不動連字,`NFKD` 會攤平成 `fi`;
也就是把「看起來像一個連字字元」攤平成一般兩個字母
– `㎏`:`NFD` 不動單一的 kg 符號字元,`NFKD` 會攤平成一般 `kg`
– `①`:這類圈號數字也屬於相容字形,`NFD` 不動它,
`NFKD` 會把它攤平成一般 `1`
實務上你可以直接把它記成:
`NFKD` 的重點就是「把字元盡量拆開」。
對這份教學最重要的不是背縮寫,
而是知道它會把像 `ó` 這種字,拆成比較基礎的成分,
這樣後面才有辦法把重音符號單獨辨認出來並移除。
例如概念上:
– `ó` 會分解成 `o` + 重音符號
– `é` 會分解成 `e` + 重音符號
但要注意:很多介面在顯示這種結果時,
仍會把它們疊在一起畫出來,
所以你直接 `print()` 或直接看回傳值時,
畫面上常常還是像同一個 `ó`,不代表它沒有拆開。
真正看得出來的方法,是把結果攤開看:
import unicodedata
text = "ó"
normalized = unicodedata.normalize("NFKD", text)
print(normalized)
print(list(normalized))
print([hex(ord(ch)) for ch in normalized])
print([unicodedata.name(ch) for ch in normalized])你會看到像這樣:
其中 `LATIN SMALL LETTER O`
就是普通小寫英文字母 `o` 的 Unicode 正式名稱。
這才表示它真的已經拆成兩個 code point:
– `o`
– `COMBINING ACUTE ACCENT`
接著:
unicodedata.combining(ch)可以判斷某個字元是不是「結合符號」;
在這裡最常見的就是重音符號。
所以這行:
"".join(ch for ch in normalized if not unicodedata.combining(ch))就是把那些重音符號濾掉,只留下基底字母。
## 2. 最小範例
import unicodedata
def strip_accents(text: str) -> str:
normalized = unicodedata.normalize("NFKD", text)
return "".join(ch for ch in normalized if not unicodedata.combining(ch))
print(strip_accents("diagnóstico"))
print(strip_accents("información"))
print(strip_accents("México"))輸出:
## 3. 為什麼這支腳本要這樣做
在這份 synonyms pipeline 裡,西文輸出有一條規則是:
– `es_mx` 要全小寫
– `es_mx` 不用重音符號
所以程式裡會先做:
“`python
strip_accents(value.strip()).lower()
“`
這樣:
– `Diagnóstico` -> `diagnostico`
– `México` -> `mexico`
比較適合拿去做 BM25 檢索時的字形統一。
## 4. 常見問題
### Q1. 這是不是把中文也轉掉?
不會。
這段主要影響的是有重音的拉丁字母。
中文通常不會因為這段被改掉。
### Q2. `lower()` 和 `strip_accents()` 是一樣的事嗎?
不是。
– `lower()`:只管大小寫
– `strip_accents()`:只管把重音符號移除
補充:`strip_accents` 是這份教學裡自己定義的函式,
不是 Python 字串內建 method,
所以要寫成 `strip_accents(text).lower()`,
不是 `text.lower().strip_accents()`。
例如:
– `É` 先 `lower()` 會變成 `é`
– 再 `strip_accents()` 才會變成 `e`
### Q3. 為什麼不用 regex?
因為重音字元不只是表面上幾個固定字母。
`unicodedata.normalize(“NFKD”, …)` 是比較穩的 Unicode 正規化做法,
比手寫一串 `á -> a`、`é -> e` 的替換表更通用。
補充:`strip_accents()` 不會移除一般標點符號,
例如 apostrophe(撇號)、逗號、句點、連字號;
它處理的重點是 Unicode 的 combining marks。
## 5. 一句話版
`import unicodedata` 在這裡的用途,
就是把像 `diagnóstico` 這種有重音的字,
穩定轉成 `diagnostico`。
推薦hahow線上學習python: https://igrape.net/30afN