3、基于规则的分词:空格分词、标点分词、正则分词
规则分词是令牌化里最“古老”也最直接的方法。你不需要任何统计学模型,也不需要神经网络的权重。说白了,就是告诉程序:遇到某个字符就切一刀。这种方法看起来简单,但我做了这么多年 NLP 项目,发现很多线上 bug 恰恰就出在“简单”二字上。
3.1 空格分词:最原始的做法
空格分词的核心逻辑就一句话:text.split()。Python 里默认的 split 会按空白字符(空格、制表符、换行)切分,然后返回一个列表。
适用场景:英文文本、代码 化、已经做过预处理的结构化文本。
# 一个例子,我做过英语新闻的分词
text = "The quick brown fox jumps over the lazy dog."
tokens = text.split()
print(tokens)
# ['The', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog.']
请注意最后一个 是 "dog.",带了句点。这说明纯空格分词不会处理标点。我个人习惯在空格分词前先对标点做剥离,否则后面处理会很麻烦。
踩坑警告:我曾经在做一个多语言评论分析项目时直接用了空格分词处理中文。结果当然是一团糟——每个汉字变成了一个 ,连词都被拆散了。中文一句话里根本没有单词之间的空格。
3.2 标点分词:把标点当作天然边界
标点分词不是某种独立算法,而是一种思路:在遇到句号、逗号、问号、感叹号、引号、括号等标点符号时强制切分。通常和空格分词搭配使用。
你会想:不就是标点旁加个空格嘛?但实际的坑很多。比如 "U.S.A." 里面的点要不要切?"it's" 里的撇号是单词一部分还是标点?"3.14" 里的点代表小数点还是句号?
| 规则类型 | 例子 | 空格分词结果 | 标点分词结果(典型) | 我推荐的策略 |
|---|---|---|---|---|
| 普通句尾 | "Hello world." | ["Hello", "world."] | ["Hello", "world", "."] | 分离标点 |
| 缩写点 | "U.S.A." | ["U.S.A."] | ["U", ".", "S", ".", "A", "."] | 保留缩写 |
| 所有格 | "John's book" | ["John's", "book"] | ["John", "'s", "book"] | 不拆撒克逊所有格 |
| 数字小数点 | "3.14" | ["3.14"] | ["3", ".", "14"] | 保留数字格式 |
嗯,这里你要注意:没有一种标点规则能同时覆盖所有语言和所有业务场景。我见过最离谱的 bug 是有人用标点分词处理化学式 "H₂SO₄",结果下标数字被当作标点切掉了,整个 序列完全没法用。
实用小技巧:在做标点分词之前,先做一层“保护性替换”——把需要保留的带点符号(缩写词、数字、版本号等)临时换成占位符,分完之后再替换回来。
3.3 正则分词:灵活但需要警惕
正则分词就是用正则表达式来定义“什么是一个 ”。你可以指定 的匹配模式,也可以指定分隔符的模式。我个人觉得这是规则分词里最灵活的工具,但也是最容易写出性能炸弹的方式。
举个最常见的例子——用 \w+ 匹配连续的字母数字和下划线:
import re
text = "快速(fast)的棕色 fox 跳过 lazy 狗。"
# 匹配连续的中文或英文单词
pattern = r'[\u4e00-\u9fff\w]+'
tokens = re.findall(pattern, text)
print(tokens)
# ['快速', 'fast', '的棕色', 'fox', '跳过', 'lazy', '狗']
# 注意 "的棕色" 被打包在一起了,因为"的"和"棕色"之间没有空格
为什么会这样?因为中文文本里没有空格,而我用的模式允许连续匹配中文字符和英文单词。如果你希望中文每个字单独成 ,就得换模式:[\u4e00-\u9fff]|[a-zA-Z]+。
正则分词的核心套路:
- 正面匹配:用
re.findall(pattern, text)直接提取 。 - 分割匹配:用
re.split(delimiter_pattern, text)在分隔符处切分。 - 混合策略:先匹配需要保留的复杂 (如 URL、邮件),剩下的再按简单规则切。
我在项目中遇到过一件特别典型的事:一个日志解析脚本需要从服务器日志里提取 IP、时间、HTTP 方法、URL、状态码和响应时间。我当时做了一个正则,一次性匹配所有字段。结果上线后每天只跑几千条日志都卡爆了——因为正则引擎的回溯在长 URL 上指数级膨胀。最后我只能拆成多个小正则,分批处理,性能直接提升了 20 倍。
性能雷区:
- 避免在长文本上使用
.*或.+贪婪匹配,尤其后面还有嵌套分组时。 - 非必要不用
re.fullmatch或re.search做全量匹配,能用finditer就用。 - 对于长度未知的输入,考虑限制重复次数:
{1,100}而不是*。
3.4 三种方法的对比与组合
我把它们列成一个对比表,你写代码时可以直接对照参考:
| 维度 | 空格分词 | 标点分词 | 正则分词 |
|---|---|---|---|
| 实现复杂度 | 极低(一行 split) | 低(预处理+split) | 中等(正则表达式) |
| 性能 | 最快 | 很快 | 看表达式复杂度 |
| 中文支持 | 不支持 | 不直接支持 | 可以支持 |
| 特殊 保留 | 不能 | 需要预处理 | 容易配置 |
| 维护成本 | 几乎为零 | 低 | 高(新规则要改正则) |
你想想看,在实际生产里很少有人只用一种规则。我通常的做法是:
- 先用正则提取出需要保护的结构(URL、邮件、金额、日期等)。
- 对剩余文本做标点分词(分离标准标点,保留缩写和数字)。
- 再按空格拆分,得到 序列。
这种“三层递进”的方法我用了好几年,在英文、法文、德文、日文(假名部分)上都效果不错。不过中文的话,嗯,还是要靠更高级的分词器,后面章节会讲到。
一个真实的避坑案例:我曾经接手一个电子邮件分析系统,之前的同学用 re.split(r'\s+|[.,!?;:()]', text) 来做分词。结果所有邮件的
到这里你应该已经清楚了:规则分词的本质是“人写规则,机器执行”。它没有学习过程,所以边界情况全靠你提前想到。我个人建议永远把规则分词作为流水线的第一道工序,只做粗粒度切分。后面的精细处理交给更智能的分词器。别想着一条正则打天下——我试过,改了一个月,最后还是改成多级流水线了。
一个小经验:规则分词的代码一定要写单元测试。把你能想到的所有边界用例(空字符串、连续多个标点、Unicode 特殊字符、数字、缩写、中英文混排)都写成测试用例。每次改规则,跑一遍测试。否则上线后你会被用户反馈折磨到怀疑人生。