一份客户名单,41,806 行,300 多个销售录了三年。
拿到手第一件事是去重。按公司名完全相等跑一遍,删掉 6,412 行,剩下 35,394 行。我以为剩下的都能用了,直到把这三行并排放在一屏里:
北京某某科技有限公司
北京某某科技有限责任公司
北京某某科技(集团)有限公司
同一家公司,三个客户编号,三个销售各跟一段,回款记录散在三个地方。精确匹配只能证明两个名字完全相同,证明不了它们是两家公司。
抽 500 行人工看,重复的写法就那么几类:
形态 | 占比 | 例子 |
|---|---|---|
后缀不一致 | 41% | 有限公司 / 有限责任公司 / 股份有限公司 |
括号注释 | 22% | (集团)、(华东)、【已停用】 |
空格与全半角 | 18% | 「北京 某某」/「北京某某」 |
简称与全称 | 12% | 某某科技 / 北京某某科技有限公司 |
错别字 | 7% | 科技写成科枝 |
按这个分布,任何单一规则都清不干净。只能分层:一层比一层松,每层都留下"这一层合并了什么"的明细。模糊匹配的每一次合并都可能是错的,没有明细就没法回滚。
不涉及相似度,只做字符串归一:去空格、全角转半角、剥掉括号及内容、统一后缀。
import re
SUFFIX = r"(有限责任公司|股份有限公司|集团有限公司|有限公司|公司)$"
BRACKET = r"[((\[【][^))\]】]*[))\]】]"
def normalize(name: str) -> str:
s = name.strip().replace(" ", "")
s = s.translate(str.maketrans("()【】", "()[]"))
s = re.sub(BRACKET, "", s)
s = re.sub(r"\s+", "", s)
return re.sub(SUFFIX, "", s)这一层几乎不会出错,代价是它只认字面完全一致的——去掉后缀之后仍然不同的,一个都认不出来。
规范化后仍不相等的,算相似度。直接全表两两比对是 35,394² ≈ 12.5 亿次,纯 Python 跑不完。实际做法是取规范化名字的前两个字做桶,只在桶内比,再加一道长度差过滤。
from difflib import SequenceMatcher
from collections import defaultdict
def bucket_key(s: str) -> str:
return s[:2] if len(s) >= 2 else s
buckets = defaultdict(list)
for idx, row in enumerate(rows):
buckets[bucket_key(row["norm"])].append(idx)
pairs = []
for idxs in buckets.values():
for i in range(len(idxs)):
for j in range(i + 1, len(idxs)):
a, b = rows[idxs[i]]["norm"], rows[idxs[j]]["norm"]
if abs(len(a) - len(b)) > 3:
continue
r = SequenceMatcher(None, a, b).ratio()
if r >= 0.88:
pairs.append((idxs[i], idxs[j], round(r, 3)))0.88 这个阈值是试出来的。0.80 会把「北京宏远科技」和「北京宏达科技」并成一家;0.92 又漏掉「科枝」这类单字错。
「某某科技」规范化后 4 个字,对上「北京某某科技有限公司」的 8 个字,长度差超了 3,第二层直接跳过。这类简称只能靠拼音兜底。
from pypinyin import lazy_pinyin
def initials(s: str) -> str:
return "".join(p[0] for p in lazy_pinyin(s) if p)
for idxs in buckets.values():
sig = defaultdict(list)
for i in idxs:
sig[initials(rows[i]["norm"])].append(i)
for k, group in sig.items():
if len(group) > 1:
print("待确认:", k, [rows[i]["name"] for i in group])这一层我没有让它自动合并。bjhykj 既能是「北京宏远科技」也能是「北京华宇科技」,首字母的碰撞率太高。它只产出一个待确认清单,人工过。
三层跑完,41,806 行合并成 33,102 个客户主体,减少 8,704 行,占 20.8%。
层 | 处理方式 | 合并组数 | 误合并 | 备注 |
|---|---|---|---|---|
精确 | 完全相等 | 6,412 | 0 | 最安全 |
规范化 | 后缀/括号/空格归一 | 2,109 | 0 | 吃掉剩余重复的 37% |
编辑距离 | 0.88 + 分桶 + 长度过滤 | 4,187 | 6/200 抽样 | 必须人工抽检 |
拼音首字母 | 仅召回 | 341 组 | — | 不自动合并 |
第一处是阈值。一开始设 0.80,想着"宁可多并,回头再拆"。结果把「上海嘉禾贸易」和「上海嘉禾商贸」并了——这两家真是两家公司,一个做进出口一个做内贸,共用仓库但独立核算。业务方发现时已经并了三个月,拆分的工作量比当初去重还大。在主数据上,漏并的代价远小于错并。
第二处是顺序。我最初把拼音层放在编辑距离之前,大量简称提前进了候选池,编辑距离那层的桶被打散,跑出来比不做还乱。分层必须从严格到宽松,一层层收紧候选集,顺序错了等于白做。
这套流程现在在 PRIVO 里是一步操作:表丢进去按三层跑,每层的合并明细单独出一份表,人工只审最后一层的清单。省掉的是写脚本、调阈值、对中间结果的时间;省不掉的是最后那道人工确认——主数据的误合并,没人能替你担。
这套规则只对中文公司名成立。我拿同样的阈值跑过一批日本客户,误合并率 17%,后缀规则和分词方式完全不同,得整段重写。另外它解决的是"同一主体的不同写法",解决不了母子公司同名不同后缀的控股关系——那属于实体对齐,不是字符串相似度能覆盖的范围。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。