3. 基于规则的NER:正则表达式匹配、词典匹配与规则引擎设计
说到实体识别,很多人第一反应就是上深度学习模型。但说实话,在我做过的十几个实体识别项目中,纯规则方法至少能解决60%以上的实体抽取需求。尤其是那些业务规则明确、实体边界清晰的场景,规则方法又快又稳,还不需要标注数据。
这一章,我就带你看看基于规则的NER到底怎么做。我会从最简单的正则匹配讲起,再到高效的词典匹配,最后聊聊规则引擎的设计思路。嗯,这些都是我踩过坑之后总结出来的实战经验。
3.1 正则表达式匹配:最直接的武器
正则表达式,说白了就是一套字符匹配的规则语言。在NER里,我们用它来匹配特定模式的文本片段。
举个例子,你想从文本里抽取出手机号。中国的手机号是11位数字,以1开头,第二位通常是3、5、7、8、9。正则写起来很简单:
import re
text = "请联系张经理:13800138000,或者李工:15912345678"
pattern = r'1[3-9]\d{9}'
phones = re.findall(pattern, text)
print(phones) # ['13800138000', '15912345678']
我在项目中遇到过这样一个坑:正则写得太松,把不该匹配的也匹配进来了。比如上面的模式,如果文本里有"12345678901"这种不存在的号码,也会被匹配到。所以实际使用时,我一般会加上更严格的约束:
# 更严格的手机号匹配
pattern = r'(?<!\d)1[3-9]\d{9}(?!\d)'
这里的(?<!\d)和(?!\d)是零宽断言,确保手机号前后不是数字。这个小技巧,能帮你过滤掉很多误匹配。
3.2 词典匹配:Aho-Corasick算法
正则匹配适合模式固定的实体,比如手机号、邮箱、身份证号。但很多实体是枚举型的,比如地名、人名、产品名。这时候用正则一个个写就太累了,词典匹配才是正解。
最简单的词典匹配就是遍历词典,对每个词做字符串查找。但词典一大(比如几万条),性能就崩了。你想想看,一个1000字的文本,对着10万条词典做遍历,那得做多少次字符串比较?
这时候就该Aho-Corasick算法上场了。这个算法是1975年提出的,但到现在依然是多模式匹配的王者。它的核心思想是:把词典构建成一棵Trie树,然后一次扫描文本,同时匹配所有模式串。
我直接给你看代码,这样更直观:
import ahocorasick
# 构建AC自动机
A = ahocorasick.Automaton()
# 添加词典词
words = ["北京", "上海", "广州", "深圳", "杭州", "南京"]
for idx, word in enumerate(words):
A.add_word(word, (idx, word))
# 构建失败指针
A.make_automaton()
# 匹配文本
text = "我住在北京,出差去过上海和广州"
for end_index, (idx, word) in A.iter(text):
start_index = end_index - len(word) + 1
print(f"位置[{start_index}:{end_index}] 匹配到: {word}")
输出结果:
位置[3:5] 匹配到: 北京
位置[10:12] 匹配到: 上海
位置[15:17] 匹配到: 广州
这个算法的厉害之处在于:匹配时间复杂度是O(n),n是文本长度,跟词典大小无关。也就是说,哪怕你的词典从1万条涨到100万条,匹配速度几乎不变。
| 方法 | 词典大小 | 文本长度 | 耗时 |
|---|---|---|---|
| 暴力遍历 | 10万条 | 1000字 | ~850ms |
| Aho-Corasick | 10万条 | 1000字 | ~2ms |
我曾经在一个金融项目里,需要从公告文本中匹配上千种股票代码和公司简称。用AC自动机,单条文本处理时间从秒级降到了毫秒级。这个优化,直接让整个系统的吞吐量提升了两个数量级。
pickle或者joblib来做。
3.3 规则引擎设计:把规则变成可配置的
正则和词典匹配都是单点工具。但在真实项目中,实体识别往往是多条规则组合的结果。比如:
- 先做词典匹配,找出候选实体
- 再用正则过滤掉不符合格式的
- 最后用上下文规则做消歧
如果把这些逻辑都硬编码在代码里,维护起来就是噩梦。我见过一个项目,规则代码写了3000多行,每次改需求都要翻半天。所以,规则引擎就派上用场了。
一个简单的规则引擎,我习惯这样设计:
class Rule:
def __init__(self, name, patterns, action):
self.name = name
self.patterns = patterns # 多个匹配模式
self.action = action # 匹配后的处理函数
class RuleEngine:
def __init__(self):
self.rules = []
def add_rule(self, rule):
self.rules.append(rule)
def execute(self, text):
results = []
for rule in self.rules:
matches = self._match(rule.patterns, text)
if matches:
entities = rule.action(matches)
results.extend(entities)
return results
这个设计的好处是:规则和逻辑分离。你可以把规则定义在配置文件里,甚至做成一个可视化界面,让业务人员自己配规则。
举个例子,一个完整的规则配置可能是这样的:
{
"rules": [
{
"name": "手机号识别",
"patterns": [
{"type": "regex", "value": "1[3-9]\\d{9}"}
],
"action": "tag_as_phone",
"priority": 1
},
{
"name": "城市名识别",
"patterns": [
{"type": "dict", "value": "city_dict.txt"}
],
"action": "tag_as_city",
"priority": 2
},
{
"name": "地址识别",
"patterns": [
{"type": "composite", "sub_rules": ["城市名识别", "街道后缀"]}
],
"action": "tag_as_address",
"priority": 3
}
]
}
这里我用了优先级和组合规则的概念。优先级决定了规则执行的顺序,组合规则则允许你复用已有的规则。比如"地址识别"就复用了"城市名识别"的结果。
3.4 规则方法的优缺点
说了这么多,我们来总结一下规则方法的优缺点。我直接给你一张对比表:
| 维度 | 优点 | 缺点 |
|---|---|---|
| 准确性 | 规则明确时,准确率接近100% | 遇到规则覆盖不到的case,直接漏掉 |
| 可解释性 | 每条匹配都能追溯原因 | 规则多了以后,逻辑变得复杂 |
| 开发成本 | 不需要标注数据,快速上线 | 规则编写和维护需要领域知识 |
| 扩展性 | 新增实体类型只需加规则 | 规则之间可能产生冲突 |
| 性能 | AC自动机等算法非常高效 | 正则回溯可能导致性能问题 |
我个人觉得,规则方法最适合的场景是:实体边界清晰、枚举范围有限、业务规则稳定。比如身份证号、手机号、邮政编码、股票代码这些,用规则方法又快又准。
但如果实体类型多、表达方式灵活(比如人名、产品名),或者文本质量差(比如OCR识别结果),那规则方法就力不从心了。这时候,我们后面要讲的统计方法和深度学习方法才是更好的选择。
3.5 本章小结
这一章我们聊了三种规则方法:
- 正则表达式:适合模式固定的实体,注意零宽断言的使用
- Aho-Corasick算法:词典匹配的利器,性能与词典大小无关
- 规则引擎:把规则配置化,方便维护和扩展
规则方法虽然"古老",但它是实体识别的基础功。我建议你在做任何NER项目之前,先问问自己:这个问题能不能用规则解决?如果能,就别急着上模型。毕竟,能用简单方法解决的问题,何必搞复杂呢?