第 8 章 真实文本不是干净字符串
8.1 看起来相同的两次搜索,为什么匹配不上?
Section titled “8.1 看起来相同的两次搜索,为什么匹配不上?”假设差旅 FAQ 中有 café,读者也输入了看起来一模一样的词,精确查找却没有命中。先别急着换检索模型,应该检查更基础的问题:程序收到的,真是相同的数据吗?
第 3 章使用了规则和版本明确的 tokenizer,也展示了未知片段怎样丢失拼写信息。真实文本还多了一道更早的边界:屏幕显示字形,字符串保存 Unicode 码点,文件保存编码后的字节,tokenizer 再按自己的规则分组。不先说清楚数的是哪一层,“字符数”就不是一个完整的说法。
本章只做一件范围明确的事:为小型中英文样例生成可复现、忽略大小写的 检索键(search key)。我们希望选定的等价写法能匹配,多余空白不再造成差异,同时保留数字、否定词和标点;还要知道究竟丢掉了什么。这不是通用清洗配方,也不是翻译系统或命令解释器。
8.2 从一个看得见的符号,追到编码字节
Section titled “8.2 从一个看得见的符号,追到编码字节”例 8.1 — 一个重音字母,两种底层字符串。 屏幕上的 é 可以是单个码点 U+00E9,也可以是 e(U+0065)后接一个组合尖音符(U+0301)。组合标记会改变前面字母的显示形态。在合适的字体中,两种写法通常都显示为一个带重音的字母,但精确字符串比较看到的是两组不同序列。
码点(code point) 是 Unicode 编码空间中有编号的位置,这里用 U+ 和十六进制数字表示。十六进制采用 0—9、A—F 共十六个符号;这个编号是标识,不是字母的某种测量值。一个 字节(byte) 有八个比特,共有 256 种可能值。 编码(encoding) 规定怎样把码点写成字节。UTF-8 用一到四个字节表示一个有效的 Unicode 标量值,而一个显示单位又可能包含多个这样的值。
表 8.1 — 四个极小样例,计数单位已经不同。 字节之间的空格只为便于阅读。最后一列沿用第 3 章未改动的 toy-longest-v1:它不含这些片段,也不做规范化。
| 显示文字 | 码点序列 | 码点数 | UTF-8 字节,十六进制 | 字节数 | 教学 Token ID |
|---|---|---|---|---|---|
é | U+00E9 | 1 | C3 A9 | 2 | [0] |
é | U+0065 U+0301 | 2 | 65 CC 81 | 3 | [0,0] |
中 | U+4E2D | 1 | E4 B8 AD | 3 | [0] |
🙂 | U+1F642 | 1 | F0 9F 99 82 | 4 | [0] |
数一数第二行的字节对:65、CC、81 是三个字节,底层却只有两个码点,通常还只显示成一个字母。Python 对这个字符串执行 len 得到 2,执行 len(text.encode('utf-8')) 则得到 3。这两个数都不能替某个别的 tokenizer 定义 Token 数。本书教学规则恰好对每个不支持的码点输出一个未知 Token,所以前两行的 ID 序列长度不同;解码器也无法从未知 ID 还原重音。
Unicode 的 字素簇(grapheme cluster) 规则,用来近似读者感知的文本单位,例如字母及其组合标记。有些 emoji 也由多个码点组成。光标移动或屏幕选择范围可能沿字素簇边界行进,Python 下标却按码点计数。不同文字和应用还可能需要调整边界规则,因此码点数不是读者口中“几个字”的通用答案。Unicode UAX #29,第 3 节
tokenizer 又提供另一套边界:它可以把多个字母合成一片,也可以拆开一个词,或者直接处理字节。假设一种规则每个字节对应一个 Token,那么表中前两行会得到两个、三个 Token;本书教学规则却得到一个、两个。这里比较的是明确给出的规则,不是在断言某个未指明的当前模型怎样切分。完整子词训练算法留到第 19 章。
8.3 检索键应该消去哪些区别?
Section titled “8.3 检索键应该消去哪些区别?”精确匹配是有用的起点,因为它保留每个码点。但在重音算例中,它也让两种规范等价的写法无法相遇。Unicode 规范化(normalization) 提供标准化方法,让选定的等价序列采用一致表示。
NFC 进行规范分解与组合;本例中,两种重音写法都会变成 U+00E9。 NFD 则采用规范分解,结果是 e 后接组合重音。这些操作处理的是规范等价关系,不是判断两个短语意思是否相同。它们不会把 lodging 翻译成 hotel,不会删除否定词,也不会发现哪个制度日期适用。
兼容形式 NFKC 和 NFKD 还会合并兼容性差异。例如 NFKC 把带圈数字 ① 变成 1,把全角字母 A 变成 A。这可能方便搜索,但研究原始字形或精确标识符的任务可能需要保留区别。规范化通常不可逆;已经规范化的片段拼接后,也可能需要再次规范化。Unicode UAX #15,第 1.1—1.4 节
大小写和空白的处理,是这四种形式之外的独立选择。为实现忽略大小写的查找,本章采用 Unicode 大小写折叠(case folding)。它服务于无大小写比较,所做的事可能超出把单个字母改成小写。例如 Straße 与 STRASSE 都变成 strasse。合并空白则让制表符和连续空格都充当一个分隔符。这两项操作都不能自动套用于密码、签名文本、源代码或区分大小写的订单 ID。
表 8.2 — search-key-v1 的处理契约。 这是从字符串到字符串的纯函数,Python 使用的 Unicode 数据库版本另行记录。
| 阶段 | 明确规则 | 保留或丢失什么 |
|---|---|---|
| 输入 | 必须是字符串,否则抛出 TypeError | 不把数字或缺失值悄悄转成文本 |
| 规范形式 | 执行 NFC | 保留规范等价关系,丢掉原本采用哪种组合写法的信息 |
| 忽略大小写 | 执行 casefold,再做一次 NFC | 丢掉大小写区别;重新组合折叠可能产生的序列 |
| 空白 | 使用无分隔参数的 Python split(),再用一个 ASCII 空格连接 | 合并识别出的空白并去掉两端空白,丢掉布局 |
| 输出 | 返回键,不改动原记录 | 不提供对应原文的位置映射 |
处理顺序也属于版本契约,NFC 与空白处理不能都含糊地叫作“清洗”。我们也没有承诺删除所有看不见的符号:零宽空格 U+200B 不被这里的 Python 操作视为空白,因此会保留。本次运行的 Unicode 字符数据库是 15.0.0,查阅的规范则是对应 Unicode 17.0.0 的修订版 57。这里选用的是早已分配的字符,并在声明的运行环境中测试,不声称该环境实现了全部新版本特性。Python 3.12:unicodedata
8.4 纯函数的输入、输出与失败,都能单独检查
Section titled “8.4 纯函数的输入、输出与失败,都能单独检查”纯函数(pure function) 在相同的已声明规则下,对相同输入返回相同结果,并且不改变外部状态。因此,它很容易测试:可以用一个小输入单独定位失败。共享实现不读文件、不发网络请求,也不改订单记录:
import unicodedata
def search_key(raw): if not isinstance(raw, str): raise TypeError("raw must be a string") composed = unicodedata.normalize("NFC", raw) folded = unicodedata.normalize("NFC", composed.casefold()) return " ".join(folded.split())isinstance 检查输入类型,normalize 选择约定的 Unicode 形式。split() 取出识别到的空白之间的片段,join 再用一个普通空格把它们接起来。函数不修改任何文件或应用;调用方可以同时保留 raw 和返回的键。实践路径中的 Python Primer已经解释了函数、字符串和异常,再读这段代码就有了所需基础。
例 8.2 — 运行之前,先判断输出。 表 8.3 中,\t 表示输入字符串里的制表符,\n 表示换行,并非字面的反斜杠字符。各项预期值先由作者给定,再由实现检查。
表 8.3 — 双语输入及精确检索键。
| 输入 | 输出 | 检查哪些区别还在 |
|---|---|---|
Beijing\tLODGING\n750 | beijing lodging 750 | 750 不变,布局和字母大小写丢失 |
北京\t住宿\n750 元 | 北京 住宿 750 元 | 显式分隔变为单个空格 |
cafe 后接 U+0301 | café | 重音保留,表示方式改变 |
Straße / STRASSE | 两者都为 strasse | 不同原始拼写映射到同一个键 |
A-104 | a-104 | NFC 保留宽度,大小写仍被折叠 |
A-104 | a-104 | 不能用它替代区分大小写的标识符 |
Do NOT cancel A-104. | do not cancel a-104. | 否定词和标点仍在 |
不要取消 A-104。 | 不要取消 a-104。 | 中文否定也保留 |
| 一个制表符、一个换行和一个空格 | 空字符串 | 不再含有检索证据 |
保留否定词是必要的信息保留选择,却不能证明后面的系统理解了否定。函数也会让 北京住宿 保持无空格,让 北京 住宿 保持一个空格,不会替中文发明词边界。第 9 章的小型词法 tokenizer 会在检索键之上,另外规定词典匹配规则。
一个有用的不变量是 幂等性(idempotence):再次执行同样的规范化,结果应保持不变。固定数据中的每个保留案例都会检查这一点,同时检查精确输出、类型错误和多对一碰撞。这些测试展示指定行为,并不是对全部 Unicode 序列的穷尽合规测试。配套 text-contract.json 保存原始值、预期值与反例,实践会直接打印这些结果。
8.5 匹配改善了,抽取位置却可能坏掉
Section titled “8.5 匹配改善了,抽取位置却可能坏掉”例 8.3 — 少了一个码点,位置也跟着改变。 原文依次为 cafe、一个组合尖音符、一个空格、750。按从 0 开始的码点下标,三位数字从原文位置 6 开始:c 为 0,a 为 1,f 为 2,e 为 3,重音为 4,空格为 5。NFC 后,é 只占一个位置,所以 750 从位置 5 开始。
如果抽取器在规范化文本中找到数字区间 [5,8),也就是包含下标 5、不包含下标 8,就不能把这组下标原封不动套回原文。那样切出的会是一个空格后接 75。任务若要求引用原始片段,就需要保留原文及两种表示之间的位置映射。只剩规范化字符串,无法重建最初的拼写或空白。
- 文本片段é:U+00E9 → C3 A9(2 字节)
- 文本片段e + ◌́:U+0065 U+0301 → 65 CC 81(3 字节)
两者经 NFC 都得到 U+00E9,却无法据此还原最初用了哪种表示。
- 应用保存的记录原文:c a f e ◌́ ␠ 7 5 0;750 从 6 开始
- 处理步骤NFC 后:c a f é ␠ 7 5 0;750 从 5 开始
若结果要引用原文片段,必须保留原文及位置映射。
图 8.1 展示同一操作带来的改善和代价:两种重音写法能匹配了,原文位置却不再对齐。这不是 NFC 实现出了错,而是改变表示后没有保留映射的结果。
更激进的“清洗”可能删除短词和标点。但删掉 not,会把 do not cancel 改成 do cancel;删货币符号或小数点,可能改变金额;删掉所有空格,可能把不同的英文词连成一体。不能按输出短了多少、整齐了多少评价清洗,而应检查任务所需的区别是否保留下来。
8.6 解码正确,歧义依然存在
Section titled “8.6 解码正确,歧义依然存在”字节可以完全正确,意思却仍不明确。英文 I saw her duck 中,duck 既可能指鸭子,也可能指她低头躲闪的动作。规范化大小写、重音和空白,无法替我们选择其中一种读法。中文“苹果”也可能在一句话里指水果,在另一句话里指公司。要看什么区别,由上下文、任务要求和已有证据决定;给某个拼写永远贴同一个标签,并不总够用。
任务粒度也会改变信息保留要求。找相关制度文档,需要文档标识;给整句问题分类,需要类别标签;抽取 750 及其单位,需要准确片段和来源版本;生成回答,则需要足够证据说明哪个限额适用,不能只有一串规范化数字。第 9 章会明确比较这些输出契约。一个文本处理函数不能仅因为返回了字符串,就暗中声称自己完成了其中任何任务。
差旅案例应同时保留 600 和 750,并在检索键之外保存文档版本、生效日期等元数据。转成小写并不能判断哪个制度适用。对于虚构订单 A-104,也应在忽略大小写的检索键之外保留权威标识。本练习只观察文本,不查询真实订单,更不执行取消。
8.7 本章小结
Section titled “8.7 本章小结”屏幕显示单位、Unicode 码点、编码字节和 tokenizer 片段属于不同层次。规范化可以让选定的等价字符串匹配;兼容规范化、大小写折叠和空白合并又会分别抹去更多区别。把选择写成纯函数并固定版本,才能逐项检查。精确位置和标识符需要各自的信息保留规则,正确处理文本也不会消除语言歧义。
现在,可以明确说出哪一份检索文本进入了计算。第 9 章会用这项契约建立三种便宜的词法基线,检查它们究竟找到了哪些文档。
8.8 习题
Section titled “8.8 习题”- 对
e后接U+0301,写出码点数、UTF-8 字节数和 NFC 后的码点数。为什么这几个数都不能决定某个未指明模型的 Token 数? - 一个服务用
search_key(order_id)替换原本保存的订单 ID。从本章给出一个具体改变或碰撞,并提出更好的存储契约。 - 例 8.3 的抽取器为
750返回规范化区间[5,8)。正确的原文区间是什么?若要处理任意文本,还需保留什么? - 某项“改进”把
不要取消订单变成取消订单,还把 600 和 750 都替换成通用数字标记。它对某些任务可能合理吗?为什么会破坏当前制度问答和操作意图理解? - 开发者声称,13 项规范化测试通过,就证明实现完全符合 Unicode 规范,而且输出与输入语义等价。请分开说明已有证据和两项尚不成立的结论。
8.9 参考解答
Section titled “8.9 参考解答”- 有两个码点、三个 UTF-8 字节,字节为
65 CC 81。NFC 后只有一个码点U+00E9,编码占两个字节。tokenizer 可以使用字节、码点或更大的片段,还另有词表与未知项规则;必须知道版本和算法,才能数 Token。 A-104会变成a-104,区分大小写的系统可能把它们视为两个标识。Straße与STRASSE还展示了多对一碰撞。应保留原始权威 ID,另存可选检索键,匹配后再返回原记录,并处理歧义;不能只为方便匹配就用检索键覆盖标识符。- 原文区间是
[6,9)。对于任意规范化与空白改动,应保留位置映射,或者直接对原始表示做抽取。只知道“用了 NFC”,不足以恢复原来的组合写法与空格。 - 如果评价契约允许丢掉这些信息,只做粗粒度主题归组的任务可能有意忽略部分金额。但当前制度问答必须区分 600 与 750,操作意图理解必须区分禁止与指令。这个转换恰好抹掉了所需区别,字符串更短并不是这些任务下的改善。
- 测试说明当前实现符合选定输入的预期、在这些案例上具有幂等性,并拒绝错误类型。完整 Unicode 合规需要更广泛且有明确规范的覆盖。语义等价则是另一项主张:函数既不理解整句,也不检查被删去的大小写、布局或位置信息是否真的与用户任务无关。
8.10 参考文献
Section titled “8.10 参考文献”- Unicode UAX #15,修订版 57。第 1.1—1.4 节规定规范与兼容规范化及其边界,稳定性条款说明已分配字符在版本之间的关系。
- Unicode UAX #29,修订版 47。第 3 节解释字素簇边界及其与读者感知字符的关系。本书词法 tokenizer 并非该切分标准的实现。
- Python 3.12:
unicodedata。说明规范化函数及运行时 Unicode 数据库版本。本书自行设计检索键选择与信息损失案例,精确字符串保存在共享数据中。