ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

中文快递地址解析实战:规则引擎与序列标注融合抽取六字段

中文快递地址解析实战:规则引擎与序列标注融合抽取六字段

做快递、电商、CRM这一类系统的人,一定绕不开一个看起来不起眼、实际上特别磨人的问题:用户填的收货地址,永远是乱七八糟的。我第一次认真面对"快递地址解析"这个需求,是因为客服后台每天收到几百条"地址无法识别"的工单,人工核对一遍要花几个小时,还经常被用户催单骂。后来我们把这六个字段——省、市、区、详细地址、收件人、电话——的自动抽取真正落地了,才把这件事从体力活变成技术活。这篇文章就把我在实际项目里做快递地址解析的完整拆解过程写出来:规则方案怎么做、模型怎么兜底、脏数据怎么治理、验收口径怎么定,以及那些踩过才知道的坑。给正在做或者准备做地址解析的读者一条可以直接参考的路线图。

1. 快递地址解析:拆的是什么,难在哪

1.1 一段典型的脏地址长什么样

先看几条真实系统里截出来的原始输入,感受一下问题:

张三13812345678广东省深圳市南山区科技园南区某栋某室 李四 收货地址:北京市海淀区某大学7号宿舍楼 152******** 王五(收)湖北省武汉市洪山区珞喻路某号 手机 13855667788 某公司市场部 刘经理 021-88886666 上海徐汇漕河泾开发区某路某弄某号

这几条地址的共同特点是:所有信息挤在一个字符串里,没有统一分隔符,地名、人名、电话号码交错出现。有的人把电话放最前面,有的人把收件人放中间,还有人在详细地址里写了"请放在前台"这种备注。快递地址解析要做的,就是从这种自由文本里准确地抽出结构化字段,落到订单系统里。

1.2 难点的本质:没有分隔符,层级还交错

如果你觉得这事儿就是"用正则抠个电话、按字典匹配省市",那大概率会低估它。我梳理下来,真正的难点集中在四类:

  • 层级缺失:用户只写"深圳南山区科技园",省和市的信息混在一起;用户只写"海淀区xxx路",前面没有北京市。
  • 地区名称歧义:"上地"可能是地名,也可能是人名;"朝阳"可能是人名,也可能是区名;"三河"可能是河北的三河市,也可能是某个小区名。
  • 规范与不规范的混用:有人写"上海市浦东新区",有人写"上海浦东",还有人写"沪浦东新区幸福路"。
  • 干扰信息:地址里混了"请在12点前送到""放驿站""转交某位老师"这类备注,还有座机号、分机号、两个手机号。

如果把这些症状总结成一句话,就是:中文地址是一种高度依赖上下文、又没有显式分隔符的短文本。解析方案的设计,本质上就是在跟"上下文歧义"和"录入自由"作斗争。

1.3 输出的标准结构:六个字段

整个系统对内对外都围绕一个统一标准结构:province、city、district、detail、name、phone,外加一个remark用于存放"放驿站""转交前台"这类衍生信息。有些业务还要postcode、landmark,但核心就是前六个。我建议一开始就别贪多,先把六字段做扎实了,后面加字段是增量改动。

2. 第一版方案:词典加正则,先把能拆的拆干净

2.1 行政区域词典与高效匹配

地址解析的地基是行政区划词典。我们当时用的是公开的行政区划代码数据,整理成三级结构:省/自治区/直辖市、地级市/地区/自治州、区/县/县级市。这里有个细节容易忽略:词典不只是存标准名,还要存别名和简称。

举几个我们实际维护过的别名:

标准名别名/简写
内蒙古自治区内蒙古、内蒙
新疆维吾尔自治区新疆
西藏自治区西藏
广西壮族自治区广西
北京市北京、京
上海市上海、沪
深圳市深圳、鹏城(慎用)
海南省海南、琼

别名表的维护是个长期活,但它能极大提升召回率。匹配时如果每次都对全量词典做线性扫描,几万条目会拖慢速度。我们用了Trie树做前缀匹配:先把省市区三级行政区划全部塞进一棵Trie,每个节点标记当前路径对应的行政级别和代码;解析时从地址串头部开始扫描,能快速切出"最长匹配"的省市区名称。

2.2 电话号码:最没有悬念的一步

在所有字段里,电话号码是最容易抽的,因为它的模式足够稳定。我用的正则大致是这样:

import re # 手机号:1开头,第二位3-9,共11位 MOBILE_PATTERN = re.compile(r'(?<!\d)(\+?86[- ]?)?1[3-9]\d{9}(?!\d)') # 座机:区号3-4位,号码7-8位,可能有分机 LANDLINE_PATTERN = re.compile(r'(?<!\d)(0\d{2,3})[- ]?(\d{7,8})([- ]?(?:\d{1,6}))?(?!\d)') def extract_phone(text): text = re.sub(r'[((]?转[))]?\s*\d{1,6}', lambda m: '', text) mobiles = MOBILE_PATTERN.findall(text) # 进一步处理 +86、空格、横线等 return mobiles

这个正则看起来简单,但有几个经验点:

  • 一定要加(?<!\d)和(?!\d)这种边界断言,否则地址里的连续数字会跟手机号粘连。
  • 处理座机时,分机号是个大坑。用户写"021-88888888转8012",如果不管分机,后面的"8012"会被当成门牌号或邮编。我上面先用一个替换把"转xxx"摘出去,存进临时变量再做解析。
  • 如果同一串里出现两个手机号,业务上要把它标成"异常订单",因为正常情况只该有一个收件人电话。这也是一种数据质量信号。

2.3 省市区逐级剥离与合并

提取完电话之后,剩下的字符串里还混着省市区和详细地址、收件人。我们的第一步策略是从左往右逐级剥离:

  1. 在剩余字符串开头匹配省/自治区/直辖市名称,命中后从原串里切掉。
  2. 在剩余字符串开头匹配地级市/地区名称。这里有个合并逻辑:如果第一步已经命中了"直辖市",比如"北京市海淀区",那么"市"这一级就落在北京市本身,接下来要直接找区。
  3. 匹配区/县/县级市名称,命中后切掉,剩下部分就是详细地址。

这个过程看起来线性,实际上到处是分支。最典型的两个问题:

问题一:用户不写省,只写"深圳市南山区",怎么办?

我们的做法是维护一张"市级单位 -> 省份"的反查表,从地址里先找到市级单位,就能把省补出来。注意这里优先匹配城市,不是优先匹配区,因为同名的区很多,比如"浦东新区"在多个省都可能存在(虽然实际只有上海有,但"开发区""高新区"这类名字更需要防误判)。

问题二:"朝阳区"和"朝阳"这种区名带姓氏的情况。

我见过不少姓"朝阳"的真实用户。规则方案里,我会优先在去掉姓名候选之后的字符串里找区名,而不是上来就匹配。怎么找姓名候选?见下一节。

2.4 收件人的抽取:看起来简单,实际最啰嗦

收件人名字的抽取,本质上是在"地址已经尽量剥干净"的剩余文本里找一段2到4个汉字的人名。难点在于:人名没有统一的语言特征,跟区名、路名、小区名都可能重叠。

我们当时的规则组合是这样的:

  • 先处理显式标记:"收件人:""收:""姓名:"这类前缀,或者"(收)"这样的后缀,直接截取。
  • 如果名字出现在地址前面或后面,且长度在2-4个汉字之间,且不跟省市区词典、常用路名后缀(路、街、道、巷、弄、里、苑、园、城、厦、场)冲突,就按"姓名"收入。
  • 遇到"某公司市场部 刘经理"这种,公司名加人名混在一起的情况,我们设置了白名单头衔词典:经理、先生、女士、小姐、老师、女士、前台、站长。用头衔倒推前面的人名边界。

要注意的是,规则抽姓名一定会有漏,所以我在整体方案里给姓名留了最大的容错空间——解析结果里姓名置信度低于阈值时,走人工复核队列,这个策略后面会细说。

3. 规则撑不住的地方,用序列标注来兜底

3.1 规则方案的三个死穴

第一条路跑通之后,我在线上抽样了5000条订单,发现规则方案在三条典型场景上表现很差:

  • 省略和倒装:"深圳南山区科技园"里,"深圳"和"南山区"之间少了"市",逐级剥离时城市匹配直接失败。
  • 新区和新地名:某些新成立的开发区、自贸区、高新区没有及时进词典,整条地址的切分顺序被打乱,后面的全跟着错。
  • 地名和人名冲突:"李朝阳朝阳区朝阳路"这种,哪个"朝阳"是人都分不清,规则一匹配区名就把人名吃掉了。

这些问题靠加规则也能修,但每修一个场景就会冒出新场景,永远在打补丁。于是我们决定上序列标注模型,把"分词"和"命名实体识别"一起解决。

3.2 把地址解析当序列标注问题来做

序列标注的思路很直观:给每个字符打一个标签,比如B-PROV(省开始)、I-PROV(省内部)、B-CITY、I-CITY、B-DIST、I-DIST、B-DETAIL、I-DETAIL、B-NAME、I-NAME、B-PHONE、I-PHONE、O(其他)。

我一开始用的是字符级特征加条件随机场,后来换成双向LSTM再加CRF解码层。字符级的好处是不需要分词器,避免了"分词错误传导到实体识别"的问题。特征上除了字符本身,还拼了以下几类:

  • 字符位置特征:是否是名字常见姓氏(赵钱孙李这种),是否数字,是否字母。
  • 词典特征:当前字符往前最长能匹配到的省/市/区名称,做一个match_level和match_length的标记。这一步很像把一个规则匹配的结果当成特征喂给模型。

训练数据来源是两个:一是之前规则方案已经解析对的线上数据,做去重和清洗后当成弱标注;二是人工标注了1万多条困难样本(就是规则解错的那类)。弱标注可以用,但必须过滤,否则会把规则的错误当正确答案学进去。

3.3 规则和模型的融合方式:置信度分流

模型上了之后,我没有直接让它替代规则,而是做了个两阶段管线:

  1. 规则优先:先用正则和词典把电话抽出来,把确定性的省市区切掉。
  2. 模型兜底:对规则解析置信度不高、或字段不完整的地址,交给序列标注模型重新打标。
  3. 合并裁决:两边的输出如果一致,直接采纳;不一致时看置信度,高置信度的一方取胜;都不高则进人工队列。

这个设计的好处是:规则方案在电话、地名匹配上仍然可靠,模型负责处理灵活性更高的语义片段,两者各管一段。我实测下来的体感是,准确的规则加模型兜底,比单一方案在全部样本上的准确率高大概三到五个百分点,而且更容易查错——规则错的就是词典缺失,模型错的就是样本分布。

4. 字典、纠错与地址补全:脏数据治理三板斧

4.1 缺省字段怎么补:宁可标记,不要瞎猜

地址里经常缺省、缺市、缺区。比如"武汉市洪山区"有市有区没省,"西湖区文三路"只有区。我见过一些团队为了让字段"看起来完整",直接用出现频率最高的省份去填,这在我看来是灾难性决策。一个地址如果本身没写省,你填一个错省,后面的物流路由、运费模板全都会跟着错。

正确的顺序是:

  • 如果只有区没有省市,先看区归属哪个市,再看市归属哪个省,从下往上用行政区划关系表补全。
  • 如果区名在市表里查不到,常见原因是这个区是个新区或功能区,这时候不补,标记为district_guess=false。
  • 补全结果必须带confidence字段,下游系统可以据此决定是直接使用还是人工确认。

我特别想强调:对地址解析系统来说,"我不确定"是一种有效的输出状态。敢于标记低置信度,比硬着头皮给一个结果更负责任。

4.2 错别字与同音字归一化

用户手打地址,错别字是常态。"海淀区"打成"海定区","乌鲁木齐"打成"鸟鲁木齐","亭湖区"打成"停湖区"。这一类问题,纯词典匹配基本必挂,我用的方法分两层:

  • 拼音模糊匹配:把省市区名称转成拼音,再对输入片段转拼音,用编辑距离做候选召回。比如"海定"和"海淀"的拼音haiding/haidian编辑距离只有1,很容易召回。
  • 字形相似候选:对常见别字维护一个"易错字映射表"。比如:定->淀,洲->州,陵->凌,塘->唐。这个表不大,但命中率极高,因为用户的错字其实是高度集中的。

归一化要在切分之前做。我们的实际流程是:先把原始串做一次"别名归一化"(包括繁体转简体),得到一个规范化的中间串,所有后续的匹配和模型推断都基于这个中间串。模型训练时也用归一化后的数据,不然模型会学到一堆错别字的特例,泛化很差。

4.3 备注和干扰信息的剥离

地址里的备注信息是最容易被忽略的脏数据源。常见的有三类:

  • 配送要求:"请放在东门快递柜""放前台谢谢""五点后送"
  • 转交信息:"转交李老师""给三楼王女士"
  • 无关字符:"(不接电话)""(公司上班时间)"

我们的处理方式是把它们摘到remark字段里,而不是直接丢掉——因为放驿站、代签收这些信息对末端配送是有用的。识别方法先用规则抓关键词(请、放、转、到、勿、代收),再用模型补充;备注的识别不要求太精准,漏掉一两条不影响主流程,但千万别把备注里出现的省市区名误当成地址主体。

5. 验收口径:准确率不是拍脑袋定的

5.1 字段级准确率与记录级准确率

做技术方案时没人问你"感觉准不准",都会问"准确率多少"。我踩过的坑是第一版只报了一个"整体准确率93%",结果业务方一问"到底是哪个字段不准"就答不上来。后来我固定了两个口径:

  • 字段级准确率:省、市、区、详细地址、姓名、电话,每个字段单独统计。计算公式是该字段解析正确的样本数 / 样本总数。
  • 记录级准确率:六个字段全部正确才算一条样本正确。记录级准确率 = 完全正确的记录数 / 总记录数。

这两个口径差异巨大。字段级都是95%以上的时候,记录级可能只有75%。原因是每个字段的3%误差叠加起来,完全正确的概率就降得很厉害。所以对外报准确率,必须先说清是哪个口径。

5.2 测试集的构建与回归机制

为了不让准确率沦为自嗨,测试集必须独立于开发样本。我们的做法是:

  1. 每月从线上随机抽3000条订单,剔除敏感信息后做人工标注。
  2. 人工标注采用双人标注,不一致的样本由第三人裁决,保证标注质量。
  3. 每周跑一次回归:用最新的代码和模型在历史测试集上跑一遍,看准确率有没有回退。

回归触发回退最常见的原因就是词典更新——新加的区名覆盖了老的自定义别名。所以我在CI里加了一条规则:新增行政区划词条时,必须跑一遍全量测试集,防止"修一个区、坏一片地址"。

另外一个经验是灰度期的影子模式。新版本上线前,会把线上流量的地址复制一份,让新旧两套解析并行跑,但结果只记录不生效。跑一周之后对比两个版本的分字段准确率,再决定是否全量切换。影子模式不用改造上游系统,只加一个旁路日志,成本很低,但能避免很多线上事故。

6. 落地过程中的几个值得记住的坑

6.1 数字在地址里的歧义

用户填"科技园188号",里面那串连续数字可能被正则误认成座机号。"青岛市南区香港中路110号"里也有连续数字。我的防护措施是:数字连续长度超过5位且电话号码正则匹配后,再去掉区号规则再验证一遍。也就是说,数字串要既满足电话模式又满足正常座机区号结构,才算电话,否则宁可把它留给详细地址。

6.2 小区名、楼盘名里的"市"和"区"

"阳光市花园""东安区公寓"这类名称是词典匹配的大坑。我之前遇到过一条地址"深圳市龙岗区大鹏新区月亮湾市花园3单元",规则把"市花园"里的"市"当成市级单位,直接切错。处理办法是:市级匹配时要看匹配片段后面跟的是区/县还是普通名词,如果是"市花园""市公寓"这类组合,就把"市"的回溯长度降级,不强行匹配。

6.3 性能问题:Trie的内存与长尾

词典膨胀后,Trie的内存和匹配速度会互相拉扯。我们维护了一套"热词缓存":在普通词典Trie之上,用LRU缓存最近一周高频命中的省市区词条和别名,命中缓存时跳过完整Trie查询。实测吞吐量从每秒几百条涨到每秒几千条。如果你的系统一天只有几万单,其实不需要做这个优化,但如果要做实时联动、校验多地址,就得考虑。

6.4 要不要做到100%:人工队列的位置

我在这个项目里最大的认知转变,是明白了解析系统天生不是一个追求100%的系统。因为它输入是自由的用户文本,注定有一部分是当前方案解不了的。与其把所有精力砸在最难的1%上,不如把人力入口做顺:低置信度地址自动埋单进人工核对队列,客服在界面上一键修正,修正结果回流作为训练样本。这个闭环让系统越用越准,而且把"解析错误"的体验从"用户收货失败"降级成"客服看一眼",价值是完全不一样的。

6.5 线上数据回流:让系统自己变聪明

最后再分享一个我坚持了很久的做法:所有人工修正的地址都要打点记录。修正前、修正后、修正人、时间戳,全部结构化存下来。这些数据既是模型的增量训练集,也是规则词典的更新线索。我们每两周做一次"修正汇聚分析",看看人工改得最多的字段和地区是哪些,然后对应更新词典、别名和纠错表。这个过程跑了一段时间之后,系统的人工介入率从最初的百分之十几降到了百分之三左右。做地址解析,永远不要觉得"规则写完了就结束了",它不是一次性开发,而是一套需要持续运营的数据流水线。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进