两个料号肉眼几乎一样,关联却失败;加一道“统一字符”清洗后,关联率提高,库存却被合并到错误对象。这类问题不一定来自模型,也不一定是编码损坏。本文讨论字符串层面的规范化,重点是保留可追溯原值、定义比较语义并拦住未经批准的身份碰撞。所有料号和对象关系均为构造示例。
先声明比较什么,再决定是否改写字符
字符串相等、搜索相似和业务实体相同是三种判断。客户名称可能需要宽松召回,供应商料号却可能区分大小写和连字符。企业客户实体匹配讨论的是多条记录是否指向同一对象;这里先处理其前一道关口:字符变换究竟保留了什么,又丢掉了什么。
建议至少区分 raw_value、display_value、search_key 和 identifier_key。原值用于溯源,显示值用于可读呈现,搜索键只扩大候选范围,身份键必须服从编码发布方的规则。并非每个字段都需要四份物理副本,但这些用途不能在逻辑上被同一个“清洗后字段”混淆。
在运行清洗前,为字段登记命名空间、字符范围、是否区分大小写、空白及标点规则,以及由谁确认别名。来源系统 A 的编码 001 与来源系统 B 的 001 不会因字符串相同就自动相等;稳定记录身份应保留来源与原记录编号,不能只由转换后的名称生成。
优先确定错误代价。搜索少找一条与把两种零件库存并为一个对象,损失不同。可以允许搜索键多对一,同时禁止身份映射自动多对一。若业务没有提供编码规则,保留原标识并标记待确认,比猜测一种全局规范化策略更可靠。
NFC 和 NFKC 解决的等价问题不同
Unicode UAX #15 区分规范等价与兼容等价。NFC 采用规范分解后组合;NFKC 还处理兼容差异。[1] 例如 é 可以由单个 U+00E9 表示,也可以由 e 与 U+0301 组合重音表示,NFC 会将这两种表示归一。它并不是简单删除重音或把所有字符转成 ASCII。
兼容规范化范围更广。全角英文字母与常见拉丁字母、带圈数字与普通数字,可能在 NFKC 后得到相同结果。标准明确提醒不能对任意文本盲目使用兼容规范化,因为一些被抹去的区别可能承载语义。[1] 因而“清洗后更整齐”不是批准覆盖原值的理由。
构造一个名称搜索场景:两条显示为 Café 的记录,一条使用预组合 é,另一条使用组合序列。将搜索索引统一为 NFC,可以减少由表示形式导致的漏召回;但这仍不证明两条记录属于同一家企业。字符等价只解决表示差异,后续身份判断仍需要其他依据。
对序列号、合同编号、外部系统键和待签名原文,应先确认来源协议是否允许任何变换。即便业务选择 NFC,也应从保留的原文派生新列,而不是宣称已经无损保留原始字节。字符形式变换可能改变字节;字节级校验与业务文本比较需要分别处理。
四个料号展示一次不能自动合并的碰撞
设同一来源中有四条独立记录 R1、R2、R3、R4,其料号分别是 AB-01、AB-01、AB01、АB-01。R2 的 A、B 是全角字符;R4 的首字母是西里尔字母 А,码点 U+0410,R1 首字母则是拉丁 A,码点 U+0041。当前没有业务证据说明任何两条属于同一零件。
只应用 NFKC 时,R1 与 R2 的搜索键同为 AB-01;R3 仍为 AB01;R4 仍保留不同的首字母。结果是一个包含两条来源记录的候选碰撞组,而非已经确认的一条重复记录。若直接按搜索键保留首行,就会在没有身份依据时丢掉 R1 或 R2。
连字符也不应为了提高匹配率统一删除。在本例中再删连字符,会让 R3 加入前两条的组;这来自额外业务规则,不是 NFKC 本身的效果。排查时必须记录每一步输入、输出与规则名称,才能知道新增关联究竟由宽度转换还是标点删除造成。
正确处置取决于编码制度:若料号只允许 ASCII,应报告 R2、R4 不符合输入契约,由来源修正或批准别名;若允许更广字符,应保留它们并建立可复核的候选关联。规则不能一边允许多个原值存在,一边默默用唯一索引把它们压成一个身份。
大小写折叠和视觉混淆不能混成同一个动作
大小写不敏感比较是另一项独立选择。Python 文档说明 casefold 比 lower 更强,例如德语 ß 会折叠成 ss。[2] 因而变换可能改变长度,不是逐字符一一替换。是否允许这种等价,取决于字段语义;搜索可以接受,来源编码不一定接受。
不要把 NFKC、casefold、去空白、去重音和删标点视为可任意排列的通用配方。将被批准的操作顺序写入规则版本,并在变换后重新检查长度、允许字符及空字符串。组合不同函数也不应在未经核对时标成 Unicode 定义的某个标准算法。
视觉混淆又是不同问题。Unicode UTS #39 提供混淆检测机制,并说明 skeleton 用于内部检测,不应作为标识符规范化结果或直接展示给用户。[3] 因此对 R4 这样的相似字符,系统可以提示人工核查,不能把“可能看混”直接升级为“肯定是同一个编码”。
复核界面宜同时显示原字符串、码点、字符脚本和来源记录链接,必要时明确标出不可见字符。不要一律删除所有零宽或连接控制字符;一些语言文字需要这些字符表达差异。可以在受限编码字段中依据契约拒绝,在人名和自然语言中则需要对应的语言规则,而不是统一清空。
清洗函数相同,数据库比较结果也未必相同
应用层得到的字符串会继续进入数据库、搜索索引、缓存和导出文件。PostgreSQL 17 文档区分确定性与非确定性排序规则,后者可按配置把不同字节的文本比较为相等,例如忽略大小写或部分字符差异。[4] 因而 Python 中两个值不等,不能单独证明目标唯一索引也允许它们共存。
为需要精确身份比较的派生键,明确存储类型和比较规则,并在真实数据库中执行等值、连接、分组与唯一性测试。不要笼统宣称所有字符串字段都应使用同一排序规则:自然语言排序与编码身份比较可以有不同要求,但跨层必须知道差异发生在哪里。
碰撞检查至少按命名空间、规则版本和比较键分组,统计不同来源记录、不同原值及已有实体身份。区分原本就存在的重复、新规则引入的等价,以及业务已批准的别名。不能用数据库自身宽松的相等规则去计算“不同原值数量”,却忘记它可能已经把原值差异折叠掉;必要时用受控的字节表示核对。
将潜在碰撞组隔离到异常表,保存组成员与处置理由,正式事实表仍引用稳定记录身份。对库存或金额数据,应核对清洗前后事实行和总量是否保持;文字映射可能改变分组,但不应顺带删除订单或库存事实。下游指标改变时,要能指出具体映射版本。
先影子计算,再切换规则版本
实施时先保留原始输入及来源元数据,严格按已声明编码解码;解码失败进入异常路径,不通过忽略错误字节来得到一个看似正常的值。然后从原值分别生成旧版与新版比较键,登记规则、运行时和 Unicode 数据版本。Python 的 unicodedata.unidata_version 可读取所用字符数据库版本。[5]
本文引用 Unicode 18.0.0 文档,不意味着部署运行时已经支持同一版本。版本记录用于定位实际行为和字符覆盖,不能把所有旧字符都假定为升级后必然变化。业务自定义规则、大小写处理、排序规则及新纳入的字符,均应在升级前按实际实现比较。
影子阶段记录新增碰撞组、受影响记录数、非法输入数、搜索候选变化与人工处理量。先处理身份冲突,再决定是否让新规则参与正式查询。若只有部分字段适用,就逐字段切换;不要为了统一代码而把自然语言名称、密码、序列号和签名原文都交给同一清洗函数。
回退应能切换到原规则与对应索引,而不是尝试从被压缩的搜索键还原原文。保留原值、映射历史与下游依赖,才能重放。存储和索引维护有成本,但不可逆覆盖之后,很多字符差异无法靠逆函数找回;这项成本应在数据接入时作决定。
用应相等和应不同的样本一起验收
建立带预期结果的构造样例:预组合与组合重音在 NFC 下应相等;全角 AB 与 ASCII AB 在 NFKC 下应相等;拉丁 A 与西里尔 А 在这两种形式下仍不同;AB-01 与 AB01 在不删标点的规则下仍不同。每个断言注明是在测试字符串规则,还是测试已经批准的业务身份。
再加入空值、空字符串、纯空白、长度边界、大小写敏感编码、带圈数字和含控制字符的输入。空值不能被无意转换成文字“None”或与空串合为一个身份。应比较实际码点或字节证据,不能只靠截图,因为字体和显示环境会掩盖差异。
验证整条链的幂等性与一致性:同一原值和规则重复处理应得到同一键;再次处理已经规范化的值不应产生未经设计的新变化;应用层、入库、查询、缓存及导出重导入的结果应符合各自契约。幂等性只是必要检查,一个稳定地产生误并的规则仍然是错误规则。
最终交付包括字段契约、原值保留策略、规则版本、反例集合、碰撞处置记录和切换回退证据。搜索召回提升可以是收益,但不能替代身份正确性验收。可靠文本标准化的目标,是让明确允许的差异可比较,让必须保留的差异继续存在。
参考资料
- [1] Unicode Standard Annex #15 — Unicode Normalization Forms, revision 58, Unicode 18.0.0, 2026-08-12
- [2] Python — str.casefold (accessed 2026-10-24)
- [3] Unicode Technical Standard #39 — Unicode Security Mechanisms, revision 34, 2026-08-27
- [4] PostgreSQL 17 — Collation Support
- [5] Python — unicodedata: normalize and unidata_version (accessed 2026-10-24)
