
中文文本清洗这活儿看着简单写起来处处是坑。前阵子有位做数据清洗的同行丢给我一段代码说同一个函数在测试样本上跑得好好的换了一批用户评论就漏字漏得离谱还有人名里的生僻字被整段抹掉。我看了两眼就笑了——他把zhon的汉字字符集直接拼进正则以为那就是全部汉字结果那批数据里混进了扩展区的字。Zhon这个库在中文文本处理圈子里出镜率不低它提供的是一批现成的正则模式常量汉字、中文标点、停顿标点、拼音音节、注音符号、词典条目格式等等配合 Python 标准库的re就能快速搭起一套中文清洗流程。但它本质上只是个模式仓库不是一个封装好的工具库很多边界情况得自己兜。这篇就把我这些年用Zhon踩过的坑和对应的解法捋一遍适合已经上手或者正准备上手Zhon做中文文本预处理的人也适合那些正则写烦了想抄现成的开发者。1. 先把 Zhon 的定位摆正它是模式仓库不是文本处理框架刚接触Zhon的人最容易犯的错是把它当成jieba那种调用一个函数就完事的库。实际上它更像一本翻开的参考手册里面装着一堆字符串常量你得自己决定怎么把它们塞进正则、什么时候用哪个。定位搞不清楚后面所有的困惑基本都源于这里。1.1 Zhon 里到底装了什么常量、字符集与正则片段Zhon的模块划分很朴素主要就几个zhon.hanzi管汉字和中文标点zhon.pinyin管拼音音节和声韵母zhon.zhuyin管注音符号zhon.cedict管词典条目格式zhon.misc收一些不好归类的杂项模式。每个模块里导出的都是普通字符串比如hanzi.characters就是一长串汉字字符hanzi.stops是一串表示停顿的标点hanzi.non_stops是逗号顿号引号这类非停顿标点。关键在于这些字符串的设计用途是被放进方括号[...]里做字符类或者直接当正则片段用。也就是说zhon.hanzi.characters不是汉字列表这么简单的语义它是一段可以安全放进正则字符类的原始物料。理解这一点后面关于转义、关于性能的讨论才立得住。我建议刚装完就花五分钟做个体检把每个常量的规模摸清楚别等出问题再回头查from zhon import hanzi, pinyin print(len(hanzi.characters)) # 字符数 print(ord(min(hanzi.characters)), ord(max(hanzi.characters))) # 码位范围 print(repr(hanzi.stops)) # 停顿标点长什么样 print(repr(hanzi.non_stops)[:200]) # 非停顿标点太长就截断看 print(len(pinyin.syllable)) # 拼音模式串的长度跑完这几行你对自己手上这套物料就有底了。我见过有人抱怨Zhon匹配不了某个字结果一查那个字的码位根本不在hanzi.characters覆盖的范围内。这不是 bug是设计边界。1.2 装了却导不进来三个最常见的安装层故障pip install zhon之后import zhon报ModuleNotFoundError这种情况我遇到过好几次原因基本跑不出三类。第一类是大小写。包在 PyPI 上的名字是小写zhon导入语句也必须是小写。有人在代码里写成import Zhon或者from Zhon import hanzi在 macOS 那种默认大小写不敏感的文件系统上可能侥幸能过一上 Linux 服务器立刻炸。这类问题的迷惑性在于我本地明明是好的排查时先看导入语句的大小写。第二类是解释器错位。机器上装了多个 Pythonpip和python指向的不是同一个包装进了 A 环境代码在 B 环境跑。这个用python -c import sys; print(sys.executable)和pip -V对照一下路径就能定位。第三类是虚拟环境没激活就装了包或者反过来激活了环境但用了全局的pip。听起来低级但真到了赶进度的时候十次里有三次是这个。排查顺序建议先确认sys.executable再确认pip -V最后才怀疑包本身。顺序反了会浪费大量时间。1.3 文档示例直接抄过去为什么会翻车官方给的典型示例是re.findall([{}].format(hanzi.characters), text)这种写法。这在玩具数据上没问题但直接搬到生产代码里有两个隐患。一是没有预编译。如果这段匹配在循环里被调用几千次每次re.findall都会走一遍模式编译而Zhon的汉字字符集动辄两万个字符编译开销不小。解决办法是把re.compile提到模块级只编译一次。二是没有转义。hanzi.characters里全是汉字本身不含正则元字符直接塞进方括号是安全的但hanzi.punctuation这类标点集合就不好说了里面可能包含-、^、]这类在字符类里有特殊含义的符号。一旦它们出现在字符类的非首尾位置-会被解释成范围连接符]会提前闭合字符类整个正则就废了。稳妥的做法是统一套一层re.escape()import re from zhon import hanzi _hanzi_re re.compile([{}].format(re.escape(hanzi.characters))) _punct_re re.compile([{}].format(re.escape(hanzi.punctuation))) def keep_only_hanzi(text: str) - str: return .join(_hanzi_re.findall(text))re.escape在 Python 3.7 之后只转义真正有特殊含义的字符汉字不会被加反斜杠所以不会让模式串无谓膨胀。这个细节值得记住早期版本会把所有非字母数字都转义模式串长度会翻几倍。2. 汉字匹配漏字字符集覆盖不到的地方才是真战场用了Zhon还是漏字是我被问得最多的一类问题。绝大多数情况下原因不是库有问题而是数据里出现了字符集覆盖范围之外的汉字。2.1 一个昵称校验暴露的漏网之鱼有个做社区产品的开发者在昵称校验里用了^[汉字]$这种模式测试阶段一切正常上线后被用户投诉昵称显示成空白。他百思不得其解把出问题的昵称复制出来一看里面有个字看着像汉字实际码位在扩展 B 区。zhon.hanzi.characters覆盖的主要是 CJK 基本区也就是 U4E00 到 U9FFF 这一段。这个区间收了两万多个常用和次常用汉字覆盖日常语料绰绰有余。但人名、地名、古籍、方言用字里经常出现扩展 A 区U3400–U4DBF甚至扩展 B 区U20000 起的字。这些字在某些字体下能正常显示肉眼完全看不出异常正则却一个都匹配不上。判断自己是不是踩了这个坑最快的办法是把匹配失败的字符打出来看码位def find_unmatched(text, pattern): hits [] for ch in text: if not pattern.match(ch): hits.append((ch, hex(ord(ch)))) return hits跑一遍如果输出的码位落在0x3400以上、0x4E00以下或者超过0xFFFF基本可以确诊。2.2 自己拼一份覆盖更全的字符集解决思路很直接在Zhon的基础上补上扩展区。我通常写一个生成函数把常用区段拼成一个完整字符串然后复用同一套预编译逻辑CJK_RANGES [ (0x3400, 0x4DBF), # 扩展 A (0x4E00, 0x9FFF), # 基本区 (0xF900, 0xFAFF), # 兼容汉字 (0x20000, 0x2A6DF), # 扩展 B (0x2A700, 0x2B73F), # 扩展 C (0x2B740, 0x2B81F), # 扩展 D (0x2B820, 0x2CEAF), # 扩展 E (0x2CEB0, 0x2EBEF), # 扩展 F (0x30000, 0x3134F), # 扩展 G ] def build_cjk_charset(): chars [] for lo, hi in CJK_RANGES: chars.extend(chr(c) for c in range(lo, hi 1)) return .join(chars) _CJK build_cjk_charset() _CJK_RE re.compile([{}].format(re.escape(_CJK)))代价是内存和编译时间。拼完大概十几万字符re.compile会慢一些但只发生一次。如果只是想判断是不是汉字而不是做批量替换用区间判断比正则更划算我下面会讲到。这里有个取舍要想清楚扩展区字符在实际业务里的出现频率极低如果你的场景是评论区过滤扩大范围带来的收益有限反而可能引入一些不该放行的兼容汉字如果是人名地名相关的功能那就必须补全。别为了技术上更完备去做无谓的扩表。2.3 用 unicodedata 做兜底判断而不是主路径另一种思路是绕开字符集直接用 Unicode 属性判断。标准库unicodedata提供了name()和category()理论上能精确识别汉字import unicodedata def is_han(ch: str) - bool: try: return unicodedata.name(ch).startswith(CJK UNIFIED IDEOGRAPH) except ValueError: return False这段代码逻辑上没问题但性能很差。unicodedata.name()每次调用都要查表构造字符串再走一次startswith比区间比较慢两个数量级。我实测过对一个十万字的文本做逐字符判断基于unicodedata.name的方案要跑好几秒而区间比较是毫秒级。所以我的做法是主路径用区间比较unicodedata只用来做抽样校验或者排查工具。比如写个脚本随机抽几千个字分别用两套逻辑判断看结果是否一致用来验证自己拼的字符集有没有漏段。把慢的东西放在不常跑的路径上这个原则在文本处理里通用。3. 标点清洗删错和漏删都源于同一个误解中文标点不在 ASCII 范围内这句话说起来像常识但写代码时忘掉它的人一大把。我见过太多代码前脚用string.punctuation清洗标点后脚发现中文逗号句号一个都没删掉。3.1 string.punctuation 在中文语境下几乎是空的Python 的string.punctuation只包含 32 个 ASCII 标点符号中文的全角标点一个都不在里面。全角逗号 UFF0C、句号 U3002、顿号 U3001、书名号 U300A/U300B全在别的码位区段。用string.punctuation处理中文文本效果约等于没处理。Zhon在这件事上给得很到位hanzi.punctuation把中文常用标点收全了而且进一步切分成stops停顿类和non_stops非停顿类。这个切分不是随便做的它有明确的语言学依据停顿标点意味着一个语义单元的结束通常对应句子边界非停顿标点只是句内分隔不构成边界。做分句、做句子级切分的时候这个区分至关重要。顺带一提标点还有全角半角之分。用户输入里混着半角逗号和全角逗号是常态尤其是从不同终端复制粘贴过来的文本。清洗前先做一次归一化能省掉后面一堆麻烦FULL2HALF {i: i - 0xFEE0 for i in range(0xFF01, 0xFF5F)} FULL2HALF[0x3000] 0x20 # 全角空格 def normalize_width(text: str) - str: return text.translate(FULL2HALF)这个映射表把全角 ASCII 区UFF01–UFF5E整体平移到半角差值固定是 0xFEE0。比unicodedata.normalize(NFKC)更可控因为 NFKC 会顺带改动一批兼容字符包括某些汉字变体和特殊符号容易误伤。3.2 stops 和 non_stops 该在什么环节用这两个常量最大的价值在于分句。用stops切出来的才是真正的句子用punctuation切出来的是碎片。举个具体场景做文本摘要或者情感分析输入是一段带引号的产品评论如果按全部标点切一句他说这个不行太贵了。会被切成四段语义全散了。我通常的分句写法是这样import re from zhon import hanzi _SENT_SPLIT re.compile(([{}]).format(re.escape(hanzi.stops))) def split_sentences(text: str): parts _SENT_SPLIT.split(text) result [] buf for p in parts: if not p: continue buf p if _SENT_SPLIT.fullmatch(p): if buf.strip(): result.append(buf.strip()) buf if buf.strip(): result.append(buf.strip()) return result用捕获组把分隔符保留下来再手动拼回去这样切出来的句子带着原标点。re.split配捕获组的坑在于它会在结果里插入分隔符本身位置是交替的拼接时必须按顺序处理不能简单地偶数索引取内容。3.3 省略号和连续标点引发的空串问题中文省略号……是两个 U2026 连写感叹号叠用、问号叠用也很常见。用[标点]去切连续标点会被一次性吃掉这通常是我们想要的行为但如果模式写成[标点]没有连续标点就会切出多个空串后面接strip()和空值判断时容易出问题。还有一种情况是标点前后夹杂空格或换行。用户手打的文本里你好 这种空格在标点前的写法非常普遍。清洗时应该先做一次空白规整把标点前后的空白统一处理掉再走分句逻辑。顺序反了的话切出来的句子里会残留空格后面送去分词又是一轮麻烦。我吃过一次亏先分句后去空格结果每句话的开头都带一个不可见的全角空格关键词匹配全部失效查了两个小时才发现。4. 拼音相关声调、ü 和大小写三座大山zhon.pinyin.syllable这个模式串长得吓人它是把所有合法声母韵母组合展开之后拼起来的巨型分组。它能干的事很明确判断一段拉丁字母串是不是合法的拼音音节。但输入数据很少长得这么规矩于是问题就来了。4.1 带声调符号的输入一律匹配失败最典型的场景是用户输入或者语料里带着带调拼音比如nǐ hǎo、zhōng wén。这些字符是i加上组合用的重音符或者直接是预组合字符 U01D0 之类。Zhon的拼音模式针对的是不带调的拉丁字母带调字符一个都过不了。标准解法是做 Unicode 规范化把预组合字符拆成基字符加组合标记再剥掉组合标记import unicodedata def strip_tones(text: str) - str: nfd unicodedata.normalize(NFD, text) return .join(c for c in nfd if not unicodedata.combining(c))这段代码能把nǐ变成ni把zhōng变成zhong。看起来完美但有个致命的副作用ü在 NFD 下会被拆成u加一个分音符组合标记剥完之后变成u于是lü变成了lunüe变成了nue。拼音里lu和lü是两个完全不同的音节这个错误会直接污染后续的匹配和检索。我的处理办法是先占位再还原用一个文本里不可能出现的字符做中转def strip_tones_safe(text: str) - str: holder \u0000 table {} for i, ch in enumerate(üǖǘǚǜÜǕǗǙǛ): table[ch] holder str(i) holder temp .join(table.get(c, c) for c in text) temp strip_tones(temp) for i, ch in enumerate(üüüüüÜÜÜÜÜ): temp temp.replace(holder str(i) holder, ch) return temp写法有点糙但思路是对的先把会丢信息的字符保护起来做完整形剥离后再还原。实际项目里我会把它封装成一个模块级函数配上单元测试因为这种细节一旦错了很难在联调时发现。4.2 ü、v、u: 三种写法必须统一拼音输入法里ü常常被写成v因为键盘上没这个键有些老系统用u:表示。这三种形式在同一个数据集里共存是常态。做匹配之前必须统一我的习惯是全部转成v作为中间表示因为它是纯 ASCII后面无论做正则还是做哈希都省心。统一之后再进Zhon的拼音模式就不会出现明明拼对了却匹配不上的情况。注意转换要在去声调之后做顺序反了会漏掉带调的ǚ之类。4.3 音节切分的歧义本质上是无解的很多人想用pinyin.syllable直接切分连续拼音串比如把xian切成xi和an。这里必须泼一盆冷水单靠模式匹配做不了这个决定因为xian本身就是一个合法音节先而xian西安也是合法组合。正则引擎只能给出这段是不是合法音节给不出应该怎么切。真正靠谱的做法是结合上下文。如果原始数据里拼音之间本来就有分隔符空格、撇号、连字符那按分隔符切分是唯一正确的路径不要自作聪明再去拆。如果没有分隔符就只能借助词典做最大匹配或者干脆回到汉字层面对齐。Zhon的拼音模式在这个环节的正确用法是校验而不是切分把它当成合法音节判定器别指望它做分词。5. 正则性能两万个字符的字符类是颗隐形炸弹Zhon的便利性建立在把大字符集塞进正则这个前提上而这个前提同时也是性能问题的根源。我见过不少项目在数据量上来之后突然变慢profile 一跑热点全在几条巨型字符类的正则上。5.1 字符类编译的真实成本re.compile对普通模式是毫秒级但面对两万个字符的字符类构建内部匹配结构的时间会显著上升。单次调用你感觉不到可如果这段代码在循环里、在 Web 请求路径上、在每条记录的处理函数里被反复调用累积起来就非常可观。这里最容易犯的错误是在函数内部编译# 反例每次调用都重新编译 def clean(text): return re.sub([{}].format(hanzi.characters), , text)正确的做法是把编译结果提到模块级或者用functools.lru_cache缓存。这个改动通常能把批量处理的耗时降下来三成以上属于零成本的优化。还有一个更隐蔽的坑模式串本身在运行时拼装。[{}].format(...)每次都要生成一个两万字符的新字符串再交给re内部缓存查表。re模块确实有内部缓存但容量有限模式串一多就会互相挤掉缓存命中率掉下来编译开销又回来了。所以显式预编译是必要的不能指望内部缓存。5.2 什么时候该放弃正则改用集合判断这个字符是不是汉字这种单字符判断用正则是杀鸡用牛刀。re.match的调用开销包括参数解析、匹配器状态初始化等等一次几微秒看着不多乘以一千万次就是几十秒。替代方案是用frozenset做成员判断_HANZI_SET frozenset(build_cjk_charset()) def is_han_fast(ch: str) - bool: return ch in _HANZI_SETfrozenset的哈希查找是 O(1)实测下来比正则匹配快一个数量级。内存占用大概十几兆如果只是基本区那就是几百 KB完全可以接受。我用一张表把几种方案的适用场景列清楚方便你按需选择方案单字符判断批量替换内存占用说明reZhon字符集较慢快低适合整段文本批量清洗预编译re模式较慢最快低生产代码的默认选择frozenset成员判断最快慢中适合逐字符过滤、校验码位区间比较快慢极低适合嵌入式或极致节省内存5.3 大文本处理时的内存与分块策略处理超大文件时把整个文本读进内存再丢给re.sub会撑爆内存。这时候要改成流式分块处理但分块有个陷阱切块位置如果落在一个汉字或者一个标点中间会导致跨块的模式匹配失败。稳妥的分块方式是按行读或者按固定大小读之后回退到最近的换行符。Zhon的汉字字符集匹配的是单字符不存在跨块的匹配问题所以按任意字节边界切都不会错——前提是你的切分点落在字符边界上。如果直接用二进制方式按字节切多字节字符被劈开解码就报错了。用文本模式的迭代器读行或者用io.TextIOWrapper指定编码就能规避。编码问题上还有一个老坑文件带 BOM 的时候第一行的第一个字符会带上 UFEFF肉眼看不见但它不是汉字会被清洗掉导致首行的长度和预期差一位。读取时统一用utf-8-sig编码能自动去掉 BOM。6. 和上下游工具配合时的边界Zhon很少单独用通常是清洗流水线里的一环前后还挂着分词、向量化、检索这些东西。衔接处的边界条件往往比Zhon本身更容易出问题。6.1 清洗和分词的先后顺序会改变结果先清洗还是先分词这个问题我被问过很多次。答案取决于你要什么但有一条经验值得记标点删除会让相邻的词粘连。原文是北京上海如果把逗号删掉就变成北京上海分词器很可能切出一个词来语义完全变了。所以我的一般做法是先按标点切句保留分隔再做必要清洗去空白、去特殊符号最后才做分词。这样每个句子里词与词之间的边界不会被破坏。如果下游是向量化粘连的影响更明显一个虚假的词会污染整个向量。给一个我常用的处理顺序供参考步骤操作使用Zhon的部分1读取并去 BOM无2全角半角归一化hanzi.punctuation辅助识别3按停顿标点切句hanzi.stops4句内非停顿标点替换为空格hanzi.non_stops5逐字符过滤非法字符hanzi.characters或自建扩展集6分词交给下游注意第 4 步用的是替换成空格而不是删除目的就是保住词边界。这个细节很多教程没提但它对下游质量的影响很大。6.2 简繁混排与词典模式的实际用法zhon.cedict里提供的是匹配词典条目格式的正则通常用来解析词典文件。它的价值在于词典文件的格式有固定结构繁体、简体、拼音、释义四段用特定符号分隔。手写正则容易在边界上出错比如释义里包含空格或者斜杠时把字段切错。实际使用中要注意两点。一是文件编码词典文件通常是纯 UTF-8带 BOM 的话首行会解析失败用utf-8-sig打开。二是简繁转换不能靠正则做。Zhon给的是模式不是映射表真要处理简繁对照还得引入专门的转换表或者词典本身的数据。我用它做过一次语料对齐把词典里的简繁对应关系提取出来做成一个映射表然后对混排文本做统一。这个思路挺好用因为词典本身的质量比网上随便找的映射表高得多覆盖也更全。6.3 混合语言文本里最容易翻车的地方中英混排是中文文本处理的经典难题。英文标点混在中文句子里用Zhon的中文标点集去清洗英文句号、逗号一个都不会被处理。反过来用string.punctuation又漏掉所有全角标点。我的处理方式是两套并用先中文后英文或者干脆合并成一个字符集统一处理import string import re from zhon import hanzi _ALL_PUNCT re.compile([{}].format( re.escape(hanzi.punctuation string.punctuation) ))这样做的问题是英文句点也被当标点处理掉了小数点、域名、版本号全遭殃。所以更细的做法是分场景正文清洗用全量标点集涉及数字和英文标识符的字段单独走规则。文本处理里没有放之四海皆准的清洗函数只有贴合业务规则的清洗函数这一点我越做越有体会。Zhon提供的是一套可靠的积木但拼成什么形状得看你的数据长什么样、下游要什么。先看数据再选工具顺序别反了。我在实际项目里的体会是Zhon这类库真正的价值不在于省下写正则的那几分钟而在于它的模式是经过检验的、边界考虑得比较全的。你自己手写汉字范围大概率想到 U4E00–U9FFF 就收手了你手写中文标点大概率漏掉顿号和间隔号。用现成的物料把精力留在业务逻辑上这才是划得来的地方。至于扩展区、性能、拼音歧义这些慢慢补就是了踩过一次基本就记住了。