实体识别落地:规则和模型不是二选一,是分层

实体识别落地:规则和模型不是二选一,是分层

实体识别落地:规则和模型不是二选一,是分层

一、"正则表达式就够了"和"BERT 肯定比规则好"——两种声音都高估了自己的方案

NER(命名实体识别)落地方案的争论通常在两个极端间摇摆:

纯规则派说:我们的实体类型只有 15 种,正则表达式的 F1 能到 85%,引入 BERT 增加 100ms 延迟只提升 3 个点,不值得。

纯模型派说:你看你的正则表达式漏掉了所有口语化的实体表达,而且维护 200 条规则的人力成本比训练一个模型高得多。

两派都没错——但他们都忽视了一个关键事实:不同的实体类型适合不同的识别策略。日期、金额、身份证号天生适合规则;产品名、昵称、疾病症状天生适合模型。成熟的 NER 方案必须分层。

二、NER 分层架构的设计原理

flowchart TB Input["输入文本"] --> Layer1["第一层:规则层<br/>(高精度,确定性匹配)"] Layer1 -->|命中| Output1["规则识别的实体<br/>(置信度 100%)"] Layer1 -->|未命中| Layer2["第二层:词典+模糊匹配层<br/>(中等精度,模糊边界)"] Layer2 -->|命中| Output2["词典匹配的实体<br/>(置信度 80-95%)"] Layer2 -->|未命中| Layer3["第三层:模型推理层<br/>(低精度高,覆盖未知实体)"] Layer3 --> Output3["模型预测的实体<br/>(置信度 70-90%)"] Output1 --> Merge["实体合并与冲突消解"] Output2 --> Merge Output3 --> Merge Merge --> Final["最终实体列表"] style Layer1 fill:#c8e6c9 style Layer2 fill:#fff9c4 style Layer3 fill:#e3f2fd

分层逻辑

规则层在最前面(处理确定性实体),模型层在最后面(兜底未知实体)。这不是按"技术先进性"排序,而是按错误成本的梯度排序——规则层几乎不错但覆盖率有限,模型层覆盖率高但可能产生假阳性。把两者的结果放在一起,用置信度加权的冲突消解逻辑合并。

三、分层 NER 系统的工程实现

import re import json from typing import List, Dict, Tuple, Optional, Set from dataclasses import dataclass, field from enum import Enum class EntityType(Enum): """实体类型枚举——必须集中定义以避免跨层类型不匹配""" DATE = "date" MONEY = "money" PERSON = "person" ORG = "organization" LOCATION = "location" ID_NUMBER = "id_number" PHONE = "phone" PRODUCT = "product" @dataclass class Entity: """NER 实体统一数据结构 所有层产出的实体都使用相同的数据结构。 这种设计确保各层之间可以无缝合并和对比, 不会出现"规则层用 dict、模型层用 namedtuple"导致的接口混乱 """ text: str # 实体文本 entity_type: EntityType start: int # 在原文本中的起始位置 end: int # 在原文本中的结束位置 confidence: float # 置信度 [0, 1] source: str # 来源标识:"rule" | "dictionary" | "model" @property def span(self) -> Tuple[int, int]: return (self.start, self.end) def overlaps_with(self, other: "Entity") -> bool: """判断两个实体是否在文本位置上重叠""" return not (self.end <= other.start or other.end <= self.start) class RuleBasedExtractor: """ 规则层实体提取器 适用实体类型:日期、金额、身份证、电话号码等高度格式化的实体。 这些实体有明确的、可被正则表达式精确捕获的形态特征。 错误模式:漏召回(而非误识别)。规则层的假阳性率接近 0, 但覆盖率受限于规则的完备程度。 """ # 规则字典:实体类型 → (正则模式, 优先级) # 优先级用于冲突消解:ID_NUMBER 可能被误识别为 DATE, # 通过设置更高优先级来避免 PATTERNS: Dict[EntityType, Tuple[str, int]] = { EntityType.DATE: ( r'\d{4}[-/年]\d{1,2}[-/月]\d{1,2}[日号]?', 5 ), EntityType.MONEY: ( r'(?:¥|¥|USD|EUR)?\s*\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?\s*(?:元|万|亿|万元|亿美元)?', 3 ), EntityType.ID_NUMBER: ( r'\d{6}(?:19|20)\d{2}(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])\d{3}[\dXx]', 10 ), EntityType.PHONE: ( r'(?:\+86[-\s]?)?1[3-9]\d{9}', 8 ), } def extract(self, text: str) -> List[Entity]: """从文本中提取规则匹配的实体""" entities = [] for entity_type, (pattern, priority) in self.PATTERNS.items(): for match in re.finditer(pattern, text): # 不使用 priority 字段直接,但留作未来扩展 entities.append(Entity( text=match.group(), entity_type=entity_type, start=match.start(), end=match.end(), confidence=1.0, # 规则匹配的实体的置信度固定为 1.0 source="rule", )) return entities class DictionaryExtractor: """ 词典层实体提取器 适用实体类型:人名、地名、机构名等有一定集合范围的实体。 介于规则层和模型层之间——比规则层更灵活(不需要形态规律), 比模型层更快(O(词条数 × 文本长) 的字符串匹配而非模型推理) 瓶颈:词典规模和质量。词典太小 → 漏召回;词典太大 → 误召回 (如"苹果"可能匹配水果而非公司名) """ def __init__(self): # 词典存储:{实体类型: {词条: 置信度}} self._dictionaries: Dict[EntityType, Dict[str, float]] = {} def load_dictionary(self, entity_type: EntityType, entries: Dict[str, float]): """ 加载词典 entries 格式:{"词条": 置信度}。 置信度不是二值(0/1),而是浮点数,因为不同词条 的歧义程度不同:"北京"作为地名的置信度高于"朝阳" """ self._dictionaries[entity_type] = entries def extract(self, text: str) -> List[Entity]: """从文本中匹配词典实体(最长匹配优先)""" entities = [] for entity_type, entries in self._dictionaries.items(): for word, confidence in entries.items(): # 查找所有出现位置 start = 0 while True: pos = text.find(word, start) if pos == -1: break entities.append(Entity( text=word, entity_type=entity_type, start=pos, end=pos + len(word), confidence=confidence, source="dictionary", )) start = pos + 1 return entities class ModelBasedExtractor: """ 模型层实体提取器(抽象接口) 实际的模型推理实现在子类中,这里只定义接口。 这种设计允许在不修改提取管道的情况下 替换底层模型(如 BERT → RoBERTa → 领域微调模型) """ def extract(self, text: str) -> List[Entity]: """子类实现具体的模型推理""" raise NotImplementedError class LayeredNER: """ 分层 NER 引擎——编排三层提取器的结果 合并策略(Conflict Resolution): 当两个实体在文本位置上重叠时,按以下优先级选择: 1. source 优先级:rule > dictionary > model 2. 同 source 时:confidence 高的胜出 3. 同 confidence 时:更早出现的(start 小的)胜出 这个策略体现了 "规则优先、模型兜底" 的设计哲学 """ def __init__(self): self.rule_extractor = RuleBasedExtractor() self.dict_extractor = DictionaryExtractor() self.model_extractor: Optional[ModelBasedExtractor] = None def set_model(self, extractor: ModelBasedExtractor): """注入模型提取器(可选)""" self.model_extractor = extractor def extract(self, text: str) -> List[Entity]: """分层提取并合并实体""" all_entities: List[Entity] = [] # 第一层:规则 all_entities.extend(self.rule_extractor.extract(text)) # 第二层:词典 all_entities.extend(self.dict_extractor.extract(text)) # 第三层:模型 if self.model_extractor: all_entities.extend(self.model_extractor.extract(text)) # 冲突消解 return self._resolve_conflicts(all_entities) def _resolve_conflicts(self, entities: List[Entity]) -> List[Entity]: """ 实体冲突消解 处理重叠实体的核心逻辑: 按 start 排序后,遍历所有实体,如果当前实体与 已接受的实体有重叠,按优先级规则决定保留哪个 """ if not entities: return [] # 按起始位置排序 sorted_entities = sorted(entities, key=lambda e: (e.start, -len(e.text))) # Source 优先级映射 source_priority = {"rule": 0, "dictionary": 1, "model": 2} accepted: List[Entity] = [] for candidate in sorted_entities: is_overlapping = False for existing in accepted: if candidate.overlaps_with(existing): # 优先保留 source 优先级更高的 cand_pri = source_priority.get(candidate.source, 99) exist_pri = source_priority.get(existing.source, 99) if cand_pri > exist_pri: # candidate 优先级更低 is_overlapping = True break elif cand_pri == exist_pri: # 同 source,保留 confidence 更高的 if candidate.confidence <= existing.confidence: is_overlapping = True break else: # candidate 更好,替换 existing accepted.remove(existing) accepted.append(candidate) is_overlapping = True break else: # candidate 优先级更高 accepted.remove(existing) accepted.append(candidate) is_overlapping = True break if not is_overlapping: accepted.append(candidate) return accepted # ============================================================ # 使用示例 # ============================================================ if __name__ == "__main__": ner = LayeredNER() # 加载词典 ner.dict_extractor.load_dictionary(EntityType.PERSON, { "张三": 0.95, "李四": 0.90, "王五": 0.92, }) ner.dict_extractor.load_dictionary(EntityType.ORG, { "阿里巴巴": 0.98, "腾讯": 0.97, }) text = "张三于2025年7月6日在阿里巴巴消费¥1,500.00元" entities = ner.extract(text) for entity in sorted(entities, key=lambda e: e.start): print(f" [{entity.source}] {entity.entity_type.value}: {entity.text} (confidence={entity.confidence})") ## 四、分层 NER 的系统边界与架构权衡 **规则层维护成本随复杂度指数增长**。 规则层的初始 F1 可能高达 95%,但每增加一种新的实体变体形态,就需要增加一条或多条正则规则。当规则数超过 100 条时,规则间的冲突和覆盖关系变得难以管理。建议当规则层维护时间超过模型层微调时间的 50% 时,考虑将该实体类型的识别工作全量移交模型层。 **词典层的覆盖天花板**。 词典的覆盖率受到实体集合有限性本身的限制。对于"产品名"、"昵称"、"事件名"等开放集合的实体类型,词典永远无法达到 80% 以上的召回率。对于封闭集合(如中国省份名、化学元素名),词典可以达到接近 100% 的召回率——此时引入模型层不仅无益,还会增加延迟和误召风险。 **模型层与规则层的协作模式**。 当前实现采用的是"模型兜底"策略(规则没命中 → 模型推理),但也可以采用"模型增强"策略(规则和模型并行推理 → 置信度加权投票)。前者的延迟低(大多数实体被规则命中时不调用模型),后者的准确率高(两个独立的信号源可以相互纠正)。选择取决于你的延迟预算和准确率要求。见证奇迹的时刻是一个被模型纠正的规则层假阴性——它证明了分层架构的价值不在于各层独立工作,而在于它们相互校验。 ## 五、总结 分层 NER 的三个核心设计原则: 1. **规则处理确定性、模型覆盖开放性**:日期/金额/身份证号走规则层,产品名/昵称/症状走模型层。 2. **Source 优先级决定冲突消解**:规则优先级最高(几乎不错),模型优先级最低(可能产生假阳性)。 3. **选型判断依据是实体类型的集合性质**:封闭集合(省名、元素名)一律用规则和词典,开放集合(人名、事件名)必须引入模型。