越南语一个元音可叠加两个变音符号,Unicode提供多种合法编码(预组合/组合),数据库精确匹配时两者不等导致查询失败。
用户搜索 Tiếng Việt,明明数据库里精确存在这个词,却返回空结果。把两个字符串复制出来比较,在任何字体下看起来都完全一致。它们并不相等,而越南语受这个问题的影响比任何其他拉丁文字都严重,因为它在一个元音上叠加了两个附加符号。
所有的报告如出一辙:越南语姓名的精确匹配搜索失败、GROUP BY 把明显是同一个值的内容生成两行、唯一性约束放过了重复项、去重失效,或者 WHERE name = ? 用粘贴的值返回零行,而用 LIKE '%…%' 匹配片段却能查到。每一个都是同一个 bug:同一个词的两种编码方式。
原因出在越南语的结构上。越南语正字法把元音质量符号(ê 上的抑扬符、ơ 和 ư 上的horn 符号、ă 上的短音符号)与声调符号(尖音符、沉音符、钩符、波浪符、下加点)组合在一起。这相当于在一个基础字母上叠加两个附加符号,而 Unicode 为同一种结果提供了多种合法写法。
同一个词,三种字节序列
以 Việt 为例。关键字符是 ệ——带抑扬符和下加点的字母 e。
Fully precomposed (NFC):
V U+0056
i U+0069
ệ U+1EC7 LATIN SMALL LETTER E WITH CIRCUMFLEX AND DOT BELOW
t U+0074
-> 4 code points, UTF-8: 56 69 E1 BB 87 74 (6 bytes)
Fully decomposed (NFD):
V U+0056
i U+0069
e U+0065
U+0323 COMBINING DOT BELOW (combining class 220)
U+0302 COMBINING CIRCUMFLEX ACCENT (combining class 230)
t U+0074
-> 6 code points, UTF-8: 56 69 65 CC A3 CC 82 74 (8 bytes)
Partially composed (the one that breaks everything):
V U+0056
i U+0069
ê U+00EA LATIN SMALL LETTER E WITH CIRCUMFLEX
U+0323 COMBINING DOT BELOW
t U+0074
-> 5 code points, UTF-8: 56 69 C3 AA CC A3 74 (7 bytes)
同一个词的三种表示:6、8 和 7 字节。len() 返回 4、6 和 5。任何一个字节或码点的相等性检查都会说三者互不相同。在任何支持越南语的字体中渲染,三者完全无法区分。
注意分解形式中的组合类编号,正是它们使规范排序成为可能。下加点的组合类是 220,抑扬符的组合类是 230,因此规范排序始终把下加点放在抑扬符之前,无论用户先输入哪个。没有这条规则,分解形式也会有两种,就没有任何规范化能够统一它们了。
不同形式从何而来
输入法。越南语输入法如 UniKey 提供预组合输出("Unicode dựng sẵn")和组合输出("Unicode tổ hợp")的明确选择。同一网站上的两个用户可能正在输入同一个词的真实不同字节序列,双方都没有做错任何事。
操作系统。macOS 文件名以分解形式存储,因此从文件列表中读取的任何内容都是分解的,而同一台机器上输入到网页表单的内容通常则是组合的。
晚期转换的老编码。越南语文本在 TCVN3、VNI-Windows 和 VISCII 中存在了多年。转换器的输出可能是预组合或部分组合的 Unicode,因此从旧系统导入的内容经常与在线应用不一致。
PDF 和 OCR 提取。从 PDF 提取的文本经常以符号分离的形式出现,因为这就是它们在页面上的排版方式。
在文本进入的每个边界处规范化。API 处理器、表单提交、文件导入、消息消费者。使用 NFC,不是 NFKC——你要的是保留文本的规范形式,NFC 和 NFKC 的区别原因就在于此。
用相同的函数规范化查询。只规范化存储而不规范化查询什么都解决不了;不匹配只是转移了位置。
回填已存储的数据。在 PostgreSQL 13 或更高版本中,UPDATE t SET name = normalize(name, NFC) WHERE NOT name IS NORMALIZED 可以一条语句完成,而且谓词会阻止对已经规范化的行进行不必要的修改。
在类型系统的边缘添加检查。CHECK (name IS NORMALIZED) 约束把下一次未规范化的写入变成插入时的错误,而不是六个月后的工单。
保留一个折叠列用于召回。用户经常在非越南语键盘上不带附加符地输入越南语,因此 viet 应该能找到 Việt。在 NFC 原文旁边存储一个去附加符的派生列,两列都搜索,精确附加符匹配排名高于折叠匹配——实现去附加符不敏感搜索的一般模式如下。
import unicodedata
def nfc(s: str) -> str:
return unicodedata.normalize("NFC", s)
def fold(s: str) -> str:
"""Accent-stripped key for recall. Keep alongside the original, never instead."""
d = unicodedata.normalize("NFD", s)
stripped = "".join(c for c in d if not unicodedata.combining(c))
# đ / Đ has no combining decomposition and must be handled explicitly.
return stripped.replace("đ", "d").replace("Đ", "D").casefold()
assert nfc("Vie\u0323\u0302t") == nfc("Vi\u1ec7t")
assert fold("Tiếng Việt") == "tieng viet"
这个函数中关于 đ 的那行不是事后补救。越南语字母 đ(U+0111)是一个带横线的基字母,不是基字母加组合符号,因此它没有规范分解,strip 循环会原样保留它。每个不懂越南语的开发者写的附加符折叠实现都漏掉了这一行,结果就是折叠后的键包含了一个无键盘用户永远不会输入的字符。
规范化解决不了的问题
规范化统一了同一拼写的不同编码。它无法统一两个不同的拼写,而越南语恰好有一个活跃的异拼。
在声调符号落在元音组合上的词中,有两种并存的惯例:较老的风格把符号放在第一个元音上,较新的风格按照语音规则放置。因此 hòa 和 hoà 是同一个词,都是真实的人写的,而它们的 NFC 形式确实不同,因为符号附在了不同的字母上。thủy 和 thuỷ、quý 和 qúy 也是同理。
没有任何规范化形式能合并这些,因为 Unicode 是正确的——它们确实是不同的字符序列。需要在另一层处理:
上一节的附加符折叠召回列会自动将它们折叠——无论哪种情况都是 hoa——这是拥有这样一个列的最有力论据。
对于小词汇量(如姓名或地名)的精确匹配,为受影响的元音组合添加一个显式别名表。这是一个有界列表,不是开放性问题。
不要尝试在存储的数据中"纠正"一种惯例为另一种。两者都在当前使用中,把一个人的名字改写成你偏好的惯例与完全剥离附加符是同一类错误。
排序是另一个独立的问题
规范化为 NFC 使相等性生效。它不能让排序生效,而越南语排序的特殊性足以让码点排序不仅仅是轻微错误。
越南语字母表把 ă、â、đ、ê、ô、ơ 和 ư 视为独立的字母,放在它们派生自的字母旁边——a、ă、â、b、c、d、đ、e、ê 如此排列。然后声调符号作为更次要的区分叠加其上,因此单词首先按基字母分组,再在组内按声调排序。码点排序两者都做不到:预组合字符散布在 U+1EA0 到 U+1EF9 以及 Latin-1 范围内,因此它产生的顺序反映的是 Unicode 的分配历史,与越南语毫无关系。
正确的越南语排序(基字母优先,再论声调):
an, ăn, ân, ba, ca, da, đa, em, êm, on, ôn, ơn
码点排序:
an, ba, ca, da, em, on, ân, ôn, ăn, đa, êm, ơn
>>> sorted(["an", "ăn", "ân", "ba", "đa", "da"])
['an', 'ba', 'da', 'ân', 'ăn', 'đa'] # 三处错误
使用 ICU 排序器并指定 vi locale——PyICU、Intl.Collator("vi"),或 PostgreSQL 中的 COLLATE "vi-VN-x-icu"——显式传递 locale 而不是依赖服务器默认值。上一节的附加符折叠列只用于召回;用它排序是错误的,因为折叠恰恰丢弃了越南语排序所基于的区分。
NFC 和 NFKC 规范化:实际差异是什么
组合字符与预组合字符:实际差异是什么
在不破坏精确度的前提下构建去附加符不敏感搜索