从算法原理到工程落地全解析)
我是在一次报表合并时被“Fuzzy 去重”这个词打醒的。销售系统导出的客户名单要跟 CRM 主数据做关联三张表里“张三”“张 三”“ZHANG SAN”看着完全是三条记录业务上却是同一个人。当时我最先想到的去重方式很快被证明没用——标准的精确去重要求字段完全一致才保留一条结果一条重复都没标出来。后来才明白这类场景要的不是“相等判断”而是“相似度判断”这就是模糊匹配去重也是很多数据清洗、内容查重、备份存储场景背后真正在用的东西。这篇文章我把 Fuzzy 去重从算法原理到工程落地完整拆一遍覆盖数组/SQL 清洗里的精确去重、备份系统的字节级去重、视频查重的感知哈希以及一套能直接跑的 Python 模糊去重脚本适合正在做数据治理、后端清洗或者内容系统的朋友参考。1. 先分清你是哪种去重五个场景可能说的不是同一件事“去重”这个词在开发里到处都是但不同语境下它解决的问题差得很远。早年我在公司内部做过一次技术分享标题就叫“去重简史”现场让大家举手说自己做的去重是哪种结果前端、后端、算法、运维各说各话。所以聊 Fuzzy 去重之前必须先分清你所在的场景属于哪一类。1.1 数组去重和 C 语言题里的“去重”是精确匹配最基础的去重是编程题里那种给一个数组去掉重复元素。前端会写[...new Set(array)]C 语言就手写双重循环或者先排序再相邻比较对象数组可能用Map以某个 id 字段做键。这套思路的核心是“完全相等”比较的是值本身要么要么strcmp返回 0判定逻辑非常简单。问题是这种去重到了真实业务数据面前几乎不堪一击。因为现实数据里的“重复”很少长得一模一样两个字段可能只差一个空格可能全角和半角不一样可能是繁体简体混用甚至可能是同一个人的姓名在不同系统里写法不同。数组去重解决不了这些它只适合代码内部对集合元素做规范化处理这类限定场景。1.2 SQL 清洗里的去重精确匹配是常规动作比数组去重更常用的是 SQL 层的去重。面试里最常见的三板斧DISTINCT、GROUP BY、还有窗口函数ROW_NUMBER() OVER(PARTITION BY ... ORDER BY ...)。做数仓清洗时聚合去重基本都是这个套路。这类操作处理的是“键完全一致的重复行”性能挺好语义也很明确。但 SQL 精确去重有个天然短板它不认识“脏数据”。比如名字列里有一行是“张三”另一行是“张 三”中间多了个空格或者手机号一列是138 0013 8000一列是13800138000。用DISTINCT去重这些统统算两条。这时候 SQL 工程师就会很痛苦——加正则表达式清洗可以处理简单的空格和全半角问题但遇到“王晓明”和“王小明”这种同音不同字或者两家系统里同一个地址表述完全不同SQL 的基本函数就无能为力了。这种场景里你需要把数据拉到应用层用模糊匹配算法做相似度判定再决定哪些记录应该合并成一组。这就是 Fuzzy 去重最典型的用武之地。1.3 备份系统的块级去重存储层在做“字节级模糊”还有一个容易被忽略的去重场景在备份存储里。大家用的备份软件、重删设备比如 Veeam 配 Data Domain 做备份归档底层都会做“重复数据删除”英文叫 deduplication。这里的去重对象不是表记录而是文件里的数据块。存储层去重和业务层去重有个关键区别它不是做“逻辑相等”而是在字节流上做“指纹匹配”。Data Domain 把数据切成不定长的块对所有块计算哈希指纹如果发现新来的块指纹和已有的块指纹一致就不再落盘只记录一个引用。所以同样的数据哪怕换个文件名、换个目录、时间戳变了只要块内容没变照样能识别出来重复。这其实是非常底层、非常严格的一致性判断比字符串相似度严苛得多。这里就牵扯到网上那个很热的说法“Veeam 和 Data Domain 集成时建议不开 Veeam 的去重”。原因后面第 5 章我会单独讲本质上就是当底层重删设备已经做了块级去重之后如果上层备份软件再做一层自己的去重两者的切块方式不统一反而会把底层的去重率拉低。1.4 内容平台的“近似查重”你不逐字节比靠感知近两年短视频行业的去重热度很高很多人做视频剪辑、发多平台内容会找“视频去重软件”。这跟前面几种又不一样。平台方做内容查重面对的是成百上千万条视频MD5 这种精确哈希基本没用——因为同一个视频换个编码、加个滤镜、裁剪掉一截边缘MD5 就完全不同了。所以平台用的是感知哈希Perceptual Hash。它把视频画面缩放到固定尺寸计算每个像素块的亮度关系生成一个可以互相比较的指纹只要两条视频指纹的汉明距离小于阈值就判定它们“感知上重复”。这里的“模糊”是视觉意义上的不需要一个字节相同但人眼看着像算法就认为重复。从数组去重到 SQL 去重再到备份存储和视频查重你会发现“去重”其实是一个谱系最左端是精确相等最右端是感知相似。而 Fuzzy 去重通常指中间这一段——用相似度算法来处理那些“逻辑上是同一件事、字面上不完全相同”的数据这也是本文要聊的重点。2. Fuzzy 的三种核心算法编辑距离、n-gram 相似度与感知哈希模糊去重不是某个单一算法而是一族相似度度量方法。实际选型时候的常见误区是“拿一个算法打天下”但其实文本长度不同、脏数据形态不同适用的算法完全不同。我按适用场景由小到大把最常用的三类拆开讲。2.1 Levenshtein 编辑距离处理错别字和格式碎片Levenshtein 距离的定义很朴素把一个字符串变成另一个字符串最少需要多少次插入、删除、替换操作。比如“小猫”变成“小猫咪”需要插入一个“咪”距离就是 1“西安”变成“西-安”距离也是 1只是多了一个字符的差异。计算方式常用动态规划。假设两个字符串长度分别为 m 和 n开一个 (m1)×(n1) 的矩阵状态转移就是三个操作里取最小def levenshtein(a: str, b: str) - int: m, n len(a), len(b) dp [[0] * (n 1) for _ in range(m 1)] for i in range(m 1): dp[i][0] i for j in range(n 1): dp[0][j] j for i in range(1, m 1): for j in range(1, n 1): cost 0 if a[i - 1] b[j - 1] else 1 dp[i][j] min( dp[i - 1][j] 1, # 删除 dp[i][j - 1] 1, # 插入 dp[i - 1][j - 1] cost # 替换 ) return dp[m][n] def similarity(a: str, b: str) - float: d levenshtein(a, b) return 1 - d / max(len(a), len(b))编辑距离对短字符串特别友好。姓名、手机号、公司名这种字段通常就几个到十几个字符DP 开销完全可以忽略。实际用的时候有个关键细节绝对距离不好用要归一化。比如“A”“B”距离是 1“ABCDEFG”“ABXDEFG”距离也是 1但后者的相似度显然更高。所以我习惯用1 - d / max(len(a), len(b))这种相似度形式再配合阈值判断。注意 Levenshtein 处理的是字符层面的差异它对“词语顺序颠倒”这种问题很无力。“北京朝阳路”和“朝阳路北京”编辑距离很大但人类一眼就知道说的是同一类地址信息。2.2 集合相似度与 shingling长文本更抗局部变动文本越长编辑距离的计算成本越不可控而且它对“局部插入、删词”极其敏感。比如地址字段“北京市朝阳区建国路 88 号”和“北京市朝阳区建国路 88 号院”编辑距离是 1还行但如果是“北京市朝阳区建国路 88 号 3 号楼 502 室”和“北京市朝阳区建国路 88 号 3 号楼 502”长度差了 3 个字符归一化后的相似度就明显下降。更长文本更适合用 n-gram Jaccard 相似度。思路是把句子切成连续的 n 个字符片段shingle然后计算两个集合的交集和并集比例def char_ngrams(s: str, n: int 2): return {s[i:i n] for i in range(len(s) - n 1)} def jaccard(a: str, b: str) - float: A, B char_ngrams(a), char_ngrams(b) return len(A B) / len(A | B)同样拿上面两个地址来比bigram 集合里“北京市”“京市朝”“市朝阳”“朝阳区”“阳区建”“区建国”“建国路”“国路”这些片段大部分都在Jaccard 值会比编辑距离稳健很多。这条路径对中文特别友好因为中文不像英文有天然空格分词按字符切 n-gram 是最简单且不需要额外词典的做法。当然如果场景是英文通常先用空格分词再切词 n-gram 会更好。n 的取值也有讲究n 太小集合里全是常见字符组合任何两句中文都很像n 太大又过度敏感。经过多次实测中文短文本用 bigram 最稳长文本用 trigram 更好。2.3 SimHash 和感知哈希给海量文本与视频做“指纹”如果数据量到了百万、千万级别不管是编辑距离还是 Jaccard 都撑不住两两比较。这时候要用哈希指纹 汉明距离的思路SimHash 是文本领域最经典的做法。SimHash 的核心过程是降维先把文本分词每个词算一个哈希值然后对每一位根据当前词是否在该位为 1 做加权累加最后把所有累加结果正负转成 0/1得到一个 64 位指纹。两个文本越相似它们的 64 位指纹的汉明距离就越小。实际工程里汉明距离小于等于 3 通常就可以认为近似重复。感知哈希本质上也是这个思路只是输入从文本换成了画面。视频查重工具会把视频帧缩放到固定尺寸比如 8×8 或 32×32对像素亮度做哈希生成指纹同一段视频哪怕缩放、裁剪、加个水印、调个色调指纹仍然接近汉明距离照样在阈值以内。这三类算法并不互斥。我在实际项目里的组合方式是短字段用编辑距离长文本用 n-gram Jaccard大规模数据做第一道粗筛用 SimHash最后用精确方法复核。模糊去重的核心不是复现某一个算法而是组合出一套合适你的“比对链路”。3. 动手跑一个真实场景客户信息表的模糊去重脚本理论讲完现在上一段能直接落地的代码。我用一个非常典型的场景两张表合并后发现同一个人可能有不同写法比如姓名多了空格、手机号格式不一致、地址表述不同。目标是识别出哪些是同一个客户并给出一个可以回写业务库的分组结果。3.1 数据准备和归一化去空格、全半角、繁简转换不管用什么相似度算法第一步永远是归一化。这一步做得好后面算法压力小一半。我见过很多新人上来直接跑 fuzz结果因为一个全角空格相似度被拉低一大截。归一化我至少会做四件事去首尾和中间空白、全角转半角、统一大小写、能转繁体就转简体。Python 里全半角可以结合unicodedata.normalize来做里面还顺带处理了带声调的字符分解问题。示例数据如下records [ {id: 101, name: 张三, phone: 138 0013 8000, city: 北京市}, {id: 102, name: 张 三, phone: 13800138000, city: 北京}, {id: 103, name: 王小明, phone: 13912345678, city: 上海市}, {id: 104, name: 王晓明, phone: 139 1234 5678, city: 上海}, {id: 105, name: 李四, phone: 13700001111, city: 广州市}, ]归一化函数可以这样写import re import unicodedata def normalize_text(s: str) - str: if not isinstance(s, str): s str(s) # 全角转半角 s unicodedata.normalize(NFKC, s) # 去掉所有空白字符 s re.sub(r\s, , s) # 统一小写英文场景有用中文不影响 s s.lower() return s如果在真实业务里遇到繁体数据建议加一个opencc或者zhconv库做繁简转换效果立竿见影。注意 NFKC 会把全角数字“”转成半角“138”还能处理一些特殊 Unicode 字符这个对手机号清格特别有用。每一条记录处理完我会额外新增一个norm_phone字段统一后用于后面的分桶键。3.2 用联合字段避免 O(n²) 爆炸“两两全部比较”是新手最容易踩的坑。1 万条数据两两比较就是 4999.5 万次调用哪怕单次耗时 0.1 毫秒也要超过 8 分钟。要避免这个坑不能只靠一对一比对必须先分桶。常见做法是选一个“重复对内大概率相同、不同记录间又能区分开”的字段做阻塞键。对客户表来说手机号后 4 位是个不错的候选同一人即使手机号前缀变了后 4 位往往不变不同人撞后 4 位的概率大约千分之一左右。def build_groups(records): buckets {} for r in records: norm_phone normalize_text(r[phone]) if len(norm_phone) 4: key norm_phone[-4:] else: key norm_phone buckets.setdefault(key, []).append(r) return buckets然后只在同一个桶内部做相似度比对。这样 1 万条如果平均 20 条一个桶总共 500 个桶比对次数大概只有 9 万次左右比全量少 500 倍。分桶这一步是模糊去重里性价比最高的优化没有之一。3.3 RapidFuzz 计算姓名、地址相似度再用并查集合并分桶之后桶内数据量通常已经很小了这时可以放心用 RapidFuzz 库内部有高度优化的 C 实现比单纯手写 Levenshtein 快得多。from rapidfuzz import fuzz def compute_similarity(a: str, b: str) - int: # 返回值的取值范围是 0-100 return fuzz.ratio(normalize_text(a), normalize_text(b))fuzz.ratio对地址、长句这种场景如果不够稳可以换fuzz.token_sort_ratio它会先把字符串分词排序再比较能解决字段里词序颠倒的问题。但要注意中文姓名场景下分词排序意义不大主要还是靠ratio或者partial_ratio。相似度算完接下来最重要的一步是分组合并。如果只做“两两判定”会出现一个问题A 和 B 相似、B 和 C 相似但 A 和 C 不像如果把 A、B 归为一组C 又跟 B 是一组最终 C 应该并入 A 这组吗工程上最直接的解法是用并查集Union-Find把相似度超过阈值的两条记录不断合并进同一个集合最终每个集合就是一个去重组。class UnionFind: def __init__(self, n): self.parent list(range(n)) self.size [1] * n def find(self, x): while self.parent[x] ! x: self.parent[x] self.parent[self.parent[x]] x self.parent[x] return x def union(self, a, b): ra, rb self.find(a), self.find(b) if ra rb: return if self.size[ra] self.size[rb]: ra, rb rb, ra self.parent[rb] ra self.size[ra] self.size[rb]判断逻辑完整代码如下def fuzzy_dedup(records, threshold85): uf UnionFind(len(records)) buckets build_groups(records) for bucket in buckets.values(): n len(bucket) for i in range(n): for j in range(i 1, n): ni normalize_text(bucket[i][name]) nj normalize_text(bucket[j][name]) score fuzz.ratio(ni, nj) if score threshold: uf.union(bucket[i][id], bucket[j][id]) # 整理分组结果 groups {} for r in records: root uf.find(r[id]) groups.setdefault(root, []).append(r[id]) return groups上面代码里union(bucket[i][id], bucket[j][id])传的是记录的 idUnionFind 内部初始化为 n 个节点所以这里要求 id 和数组下标对齐如果 id 是字符串或者不连续建议先把记录映射成连续下标或者在 UnionFind 里用字典维护 parent。用并查集并不是因为模糊相似度在数学上满足传递性而是工程上它能把连锁关系合并成一个组避免同一条记录同时归属多个组。3.4 调阈值和复核机制宁可漏不要错阈值怎么定这是模糊去重里争议最大的地方。经验值方面短字段姓名、品牌名相似度 85 以上基本可以判重90 以上很稳长文本地址建议放到 75 到 80而 SimHash 汉明距离小于等于 3 才比较可信。不同阈值的效果差异我整理成一张参考表阈值召回表现误判表现适用场景60-70几乎全召回但大量不同记录被误合并误判严重业务上难接受只做候选生成不做最终判断75-80能查出“同一个地址但写法不同”少量误判需要人工复核地址、长文本场景85-90只合并“非常明显重复”的记录误判很少姓名、短文本、电话核对场景90漏掉很多真实重复几乎没有误判高可靠要求的最终确认我的原则是“宁可漏不要错”。去重的结果通常会影响主数据、标签、报表误合并两个真实客户带来的业务影响远比漏合并严重得多。所以线上阈值我会定得保守一些宁可让一部分重复数据待查也不让系统自动把两个不同的人捏成一个。另外要留一个人工复核队列。凡是相似度落在 70 到阈值之间、但没到自动合并门槛的丢到待确认列表里由运营或者审核人员人工处理。模糊去重在真实系统里永远不是纯自动化的仪式它本质上是把“机器处理不了的那一小部分”筛出来交给人类。4. 上了生产之后性能、误杀和增量更新跑通一个脚本只是开始。一旦数据量从几千条涨到几百万条或者每天新增几万条模糊去重的工程挑战就会从“算法选型”变成“系统设计”。这里我把生产环境里最常遇到的四个问题逐个讲透。4.1 两两比较为什么不行一组数字说明问题先说个更具体的测算。假设你有 10 万条记录如果不用分桶直接两两比较需要计算的相似度次数是 C(100000,2)约等于 50 亿次。RapidFuzz 再快一次调用也要几十微秒50 亿次就是几个小时起步。这在离线批处理里也许还能忍但没有哪个业务系统愿意等这么久。分桶之后情况完全不同。假如按手机号后 4 位分桶理论上能分出约 10000 个桶平均每个桶 10 条数据。每个桶内部比较 45 次总次数是 45×10000等于 45 万次比全量少了近 1.1 万倍。我经手过的项目里一次 5 万条的客户表去重从全量比较的 2 小时降到分桶后的 11 秒就是这一步的收益。4.2 阻塞字段的挑选三原则和失败案例分桶键选不好整个方案都会废掉。什么样的字段适合做阻塞键我总结了三原则分布足够散、重复对内同值率足够高、计算成本足够低。“分布散”是说要尽量均匀避免出现超大桶。一个反面案例是拿性别做阻塞键男女两个桶所有重复对确实都在同一桶里但每个桶里有好几万条等于没分。“重复对内同值率高”的意思是凡是真正的重复阻塞字段要大概率相同。比如拿“用户姓名全字段”做阻塞键那模糊去重就白做了因为姓名字段本身可能就被空格和错别字污染。计算成本低这个不用多说阻塞键会被反复拿来算哈希和拼串千万别放超长文本。我踩过的坑是拿完整手机号做阻塞键。当时想着手机号是强标识结果发现同一人的手机号在前后两个系统里居然不一样一个是 13 位老号码一个是 11 位新号码完整手机号分桶直接把它们分到了两个桶模糊去重完全没机会相遇。后来改成“手机号后 4 位 所在省份首字”组合键才把这类情况召回进来。4.3 增量更新不能每次全量重跑生产系统最怕听到“每次跑全量”。数据量大的时候全量重跑不仅耗时还会影响线上资源。增量更新的思路是把每个分桶的桶内数据缓存成倒排索引新数据进来时只需要计算它落入的桶和桶内已有记录做比对。具体做法可以这样设计全量跑完后保存一张映射表(block_key, record_id, normalized_text, group_id)。新记录到达时先计算它的block_key。只加载这个block_key下的存量记录和新记录做相似度比对。如果超过阈值新记录直接加入已有group_id否则给新记录新建group_id。更新倒排索引把新记录放进对应桶。这样的增量链路里单条新数据只会触发一次桶内比对几乎瞬时完成。比全量每天重跑要省太多资源了。不过有个隐患如果系统上线后数据质量规则变了比如清洗逻辑变了或者新增了繁体字段增量索引会和全量结果产生偏差。这时候还是需要定期做一次全量重跑来校准。4.4 回写让去重结果真正进入业务库模糊去重跑完之后最关键的一步是回写。很多团队在这里翻车算法跑出一份重复组名单却不知道怎么落库或者落库方式不对导致业务系统主数据被破坏。比较稳妥的回写步骤是先建映射表再更新老表。做法是在业务表里增加一列dedup_group_id把所有记录映射到某个组的根记录 id 上查询时按组聚合展示层只显示主记录。这样不会物理删除任何一条数据最大程度保留了可回溯能力。如果业务方确实要做物理合并也应该先做组映射再在二次确认后把冗余记录做归档处理。映射表建好之后日常巡检可以直接用 SQL 看每个分组的大小SELECT group_id, COUNT(*) AS cnt FROM customer_dedup_map GROUP BY group_id HAVING COUNT(*) 1 ORDER BY cnt DESC;这条查询能帮你快速发现哪些组被合并得过大。一个组如果有几十上百条记录大概率是阈值放太宽或者阻塞键选错了需要人工重点排查。5. 视频去重和备份去重里的 Fuzzy 逻辑聊到最后回头再看热搜词里的另外两个高频场景短视频去重和 Veeam/Data Domain 集成。这两个场景看起来跟“客户表去重”八竿子打不着但底层的模糊匹配思想是相通的。5.1 内容平台的视频查重感知哈希与汉明距离短视频领域说的“视频去重”从内容平台的角度看其实是在做“近似查重”。平台每天上传的视频数量巨大如果只按 MD5 去重用户把同一个视频翻转一下、加个边框、换一段 BGMMD5 就全变了系统完全没有识别能力。所以平台普遍采用感知哈希方案一般流程是对视频抽帧比如每秒抽 1 帧或均匀抽 10 帧。每帧缩放到小尺寸比如 8×8 灰度图。计算相邻像素亮度大小关系生成感知哈希值。把多帧的哈希值组合成视频指纹。用汉明距离跟指纹库比对距离小于阈值判定为重复内容。从算法上讲这和前面 SimHash 判断文本重复是一模一样的逻辑先降维再算距离最后用阈值做判定。区别只是输入从“词”变成了“像素块”从“分词权重”变成了“灰度关系”。所以当你看到有人讨论“视频查重”时不用把它想成什么新东西它就是 Fuzzy 去重思想在感知层的一次应用。不过要提醒一句这类查重系统识别的是“内容雷同”不是“文件一样”。平台判断两条视频相似不代表它们就是搬运很多剪辑作品、二次创作也会命中相似的画面指纹所以头部平台通常在感知哈希之外还会叠加更复杂的语义特征。5.2 备份存储里的“Fuzzy”内容定义分块和指纹去重再看备份存储。Veeam 配合 Data Domain 做备份是后端运维的高频组合社区里一直有个主流建议使用 Data Domain 作为目标存储时建议关闭 Veeam 自带的源端去重。很多刚入门的朋友不理解为什么“去重”这么好用的功能要关掉关键在于 Data Domain 本身已经做了非常强的目标端全局去重。它用的是内容定义分块Content-Defined ChunkingCDC不是按固定大小切块而是根据数据内容的滑动窗口哈希来决定块边界。这样即使数据流中间插入或删除了几个字节后续大部分块的边界还能对齐重复数据照样能识别出来。但 Veeam 如果开启了源端去重会在本地先把备份数据切成自己的块、算好指纹再传输到 Data Domain。问题就出在“切块方式不统一”上Veeam 切的块和 Data Domain 的 CDC 块边界对不上。Data Domain 拿到 Veeam 处理过的数据后无法在里面找到足够多匹配自己块边界的数据块全局去重率反而下降。相当于上下两层各做各的去重互相干扰。这里的“去重”本质上也是模糊匹配Data Domain 的 CDC 要识别的不是“路径相同”“文件名相同”而是“字节流在内容层面是否出现过”。固定分块好比精确匹配只要有 1 字节的偏移整块就全不匹配了可变长分块则类似模糊对齐容忍偏移只匹配内容本身。这和模糊去重里用 n-gram 容忍局部差错的逻辑本质上是同一个思路。5.3 一个通用判断在正确的层做去重把客户表去重、视频查重、备份去重放在一起看会发现一个共同规律去重要做在正确的层级上模糊度要和数据的失真方式匹配。客户表数据的失真来自人类的输入习惯空格、错别字、同音字所以用编辑距离、n-gram 这些字符级模糊匹配视频数据的失真来自剪辑、压缩、滤镜所以用感知哈希这种图像级模糊匹配备份数据的失真来自数据流的偏移和文件封装变化所以用内容定义分块这种字节级模糊对齐。一个常见错误是“所有层都做去重”。Veeam 和 Data Domain 叠加去重是个例子业务系统里也经常出现“应用层去重 数据库去重 数仓再清一次”的三重重复劳动。每增加一层去重就多一层误差和性能损耗。正确的做法是先确定主去重层其他层只做必要的预清洗不该动的地方尽量不动。我现在的习惯是拿到一批数据先不急着写去重函数而是先抽 50 条肉眼扫一遍看脏数据长什么样。是空格问题全半角还是同音错别字算法选型完全取决于你最常遇到的那种脏法。场景不同Fuzzy 的“模糊度”也不同——备份场景要模糊的是字节偏移视频场景要模糊的是画面感知而业务数据要模糊的是语言习惯。先认清你的数据是怎么脏的再决定怎么去重这比任何算法都重要。