
文本相似度计算大概是文本处理里出现频率最高的需求之一。不论是做技术内容审核还是搭建商品推荐系统甚至是在日志平台里排查重复报错总会碰上一个绕不开的问题怎么快速判断两段文本到底“像不像”。提到相似度很多人第一反应是余弦相似度或者编辑距离而我想说的是一个经常被低估、但在工业界特别好用的方案——基于 Jaccard 相似度做文本语义相似度计算。Jaccard 相似度本质上是个集合运算逻辑极其朴素两个集合的交集大小除以并集大小得到的就是相似度。放到文本场景下把句子拆成词、拆成字符片段甚至拆成哈希值然后用这个公式去衡量重叠程度。它的好处是计算简单、速度飞快、对超大规模文本比对非常友好特别适合做文本去重、近重复检测、候选集召回这类不需要理解深层语义、只关心“表面重叠”的任务。这篇文章我会从理论到实践完整走一遍包括数据怎么预处理、怎么把文本转成集合、n-gram 的 n 怎么选、大规模数据下怎么用 MinHash 近似加速、哪些场景真的适合用它以及我踩过的各种坑。内容覆盖从零开始的纯 Python 实现到工程优化适合刚接触文本相似度计算的新手也适合已经在用其他方案、想补充一套轻量级工具的老手。1. 核心思想与设计思路拆解1.1 为什么文本相似度要用集合思维先聊一个基础但重要的认知文本相似度没有“放之四海皆准”的定义不同场景下“相似”的标准完全不同。编辑距离关心的是字符层面要经过多少次增删改才能互相转换核心在“序列”适合拼写纠错这类任务。余弦相似度把文本映射到高维向量空间关注的是“方向一致性”适合语义向量的匹配。而 Jaccard 相似度关心的是“构成元素的重复率”本质上是集合视角。举个例子“我喜欢猫”和“猫我喜欢”这两句话编辑距离会觉得差异很大因为字符顺序全变了。Jaccard 相似度把两句话都拆成词集合后得到的是完全相同的集合——{我喜欢, 猫}相似度 1.0。如果你在做搜索结果聚类、新闻去重这类任务这种对词序不敏感的特性反而是优点因为改述、倒装、调换语序的情况非常多你真正关心的是“核心指代的东西是不是同一样”。这个视角带来的工程收益也很直接。集合操作在数据结构层面有大量现成的高度优化实现交集和并集的运算时间复杂度接近线性。相比之下基于向量的余弦相似度要构建词频矩阵基于序列的编辑距离要构建动态规划表在数据量上来之后这两者都比 Jaccard 重得多。文本场景里绝大多数句子长度只有几十到几百个词把它们转成集合做位运算性能优势非常明显。1.2 Jaccard 系数的数学本质和直观理解Jaccard 相似度的公式很简单对于集合 A 和集合 B相似度 J(A, B) |A ∩ B| / |A ∪ B|。分子是两个集合共同拥有的元素个数分母是两者总共涉及的不同元素个数。直观理解可以想成两个人各自的收藏夹。A 收藏了 10 部电影B 收藏了 8 部其中 5 部是共同的。那么共同喜爱的比例就是 5 除以两个人收藏过的电影总量10 8 - 5 13相似度约 0.385。如果两个人收藏完全一致相似度是 1.0完全没有共同收藏就是 0。这个性质放到文本上有个很微妙的特征它天然地压缩了“双方都有”的信息放大了“双方差异”的信息。集合 A 里有 100 个词集合 B 里有 3 个词其中 3 个都在 A 里Jaccard 相似度只有 3/100 0.03。哪怕 B 的所有内容都包含在 A 里从重叠率角度看依然不相似因为 A 的体量远大于 B。这种“非对称包含”在文本相似度计算中非常常见比如一篇长文章和它的摘要——摘要的每个词都在正文里但两者的 Jaccard 相似度会很低。另一个重要特征是它天然等于“二者有一方缺的词就被判罚”。编辑距离里一次替换只扣一份距离余弦相似度里一个词的缺失影响的是向量夹角而 Jaccard 里只要有一个词只出现在单侧分母就变大相似度就下降。所以 Jaccard 对“新增内容”非常敏感两段文本哪怕主题完全一致其中一方多列了 10 个不相关词相似度就会被明显拉低。1.3 和传统相似度算法的横向对比这里放一张我从多个项目里总结的对比表大家在技术选型的时候可以直接参考指标Jaccard 相似度余弦相似度编辑距离核心视角集合重叠向量方向序列变换词序敏感性不敏感不敏感敏感文本长度敏感性较敏感一般敏感计算复杂度接近线性需构建矩阵二次方动态规划适合场景去重、聚类、召回语义匹配、推荐拼写纠错、模糊搜索是否需要训练否否TF-IDF版本否对预处理依赖较高高较低从表中能看出来Jaccard 和余弦相似度在很多方面是“同级竞品”但 Jaccard 的实现成本低得多。余弦相似度需要先构建词频向量这个向量的维度是词表规模在中文场景下动辄几万维即便是稀疏存储也要维护索引结构。而 Jaccard 只需要 Python 内置的 set 类型交集、并集都是底层 C 代码直接处理内存效率和开发效率都高出一截。选型的关键提醒是Jaccard 解决的是“表层重叠”问题不是“语义等价”问题。“这家店性价比很高”和“这家店价格实惠质量好”这两句话语义几乎一样但 Jaccard 相似度大概率为 0因为它们没有一个词是相同的。如果在你的业务里目标文本经常用完全不同的措辞表达同一含义Jaccard 只能作为粗召回后面还得接语义模型如果任务本质是“找改述、抄写、拼接痕迹”Jaccard 能发挥非常出色的作用。2. 文本集合构造n-gram、分词与关键参数设计2.1 构造文本集合的三种主流方案Jaccard 公式本身很简单真正的设计功夫全在“如何把文本变成集合”这一步上。选不同的切分方式得到的结果可以天差地别。方案一分词后的词集合。对文本做分词将每个词作为集合中的元素。这是最直观的做法。中文里我一般直接用现成的分词库把粒度控制在词级别。优点是语义单元完整计算出来的相似度语义解释性强缺点是依赖分词质量分词不一致会直接造成假阴性。方案二字符 n-gram 集合。把文本连续切成 n 个字符的片段比如“语义词相似”在 n2 时得到“语义”“义词”“相似”。因为是无监督切分完全绕开了分词器语言无关中英文、日文、代码都能用。这是我在做通用型文本去重时最常用的方案。方案三词 n-gramshingle集合。先分词再把相邻的 n 个词拼成一个短语作为集合元素。比如“我喜欢猫”分词后是“我”“喜欢”“猫”word 2-gram 就是“我喜欢”“喜欢猫”。相比纯词集合它保留了一定的局部词序信息能区分“我喜欢猫”和“猫喜欢我”对语序敏感的场景很有效。选择建议做新闻去重、文章转载检测这类以中文长文本为主的场景首选字符 2-gram 或词集合做代码克隆检测、日志聚类这类强结构文本用字符 3-gram 或词 n-gram做短文本判重n-gram 配合词集合混合使用效果更稳定。2.2 n 值的选择逻辑和影响规律n 的取值直接决定集合的大小和区分度这是整个方案里最需要反复调参的地方。n 太小比如字符 unigramn1集合元素只有单字。任意两段由常用汉字组成的文本都会天然共享大量单字Jaccard 相似度整体偏高区分度很弱。极端情况下两篇毫不相关的文章因为都大量使用“的”“了”“是”相似度也能到 0.5 以上这是不可接受的噪声。n 太大比如字符 5-gram片段几乎不可能在不同文本之间重复出现交集趋近于零所有文本对的相似度都接近 0等于把任务变成了“完全相同的句子才能判定相似”。对你的应用来说这通常也不合适因为真实场景中转载文章会插入、删改少量内容过大的 n 会把这种轻微改动直接判为不相似。我个人的经验是中文文本用字符 2-gram 起步这个粒度能同时覆盖词的内部结构信息和词间关系如果希望更精准可以比较 2-gram 和 3-gram 的结果差异。n2 在大多数内容去重任务里能保持对“换词、改序”的鲁棒性同时保留足够的区分能力。英语文本则建议用 word 2-gram 或字符 3-gram因为英文单词本身已有明确边界再用字符切分容易切出无意义的碎片。2.3 预处理细节大小写、标点、停用词与空白字符集合构造要面对一个前提问题文本进入 Jaccard 之前要不要清洗答案是必须做而且清洗策略直接影响效果上限。大小写归一化是最基础的。在英文场景“Apple”和“apple”在语义上同一个词但作为集合元素是完全不同的两个字符串。我通常在分词前统一调用lower()。中文没有大小写问题但数字、英文词的大小写依然存在处理时不能跳过。标点符号处理要看你的保留意图。标点本身不是语义单元绝大多数情况下应该剔除。但有一个例外业务上需要区分句子边界时比如判断两段文本的断句模式是否一致标点可以作为一个特殊标记保留。默认方案建议用正则把所有非中英文、非数字的字符替换为空格。停用词表要谨慎。文本相似度往往依赖低频词提供区分度“的、了、是”这类高频停用词如果保留会大量出现在交集里拉高所有文本对的相似度基线。把它们剔除是标准操作。但绝对不能无脑套通用停用词表因为一些技术词汇、行业黑话可能被误杀你的场景需要什么样的停用词表最好是基于自己的数据统计得出。空白字符的处理上要注意全角半角差异。中文输入法下经常出现全角空格、全角标点这些字符和半角版本是两个完全不同的字符串必须在预处理阶段统一转换。我在实际项目中吃过这个亏同一句话只是有人用了全角逗号相似度就从 1.0 掉到了 0.6排查了半小时才发现是编码问题。2.4 集合去重与顺序丢失的利弊权衡把文本变成集合之后一个绕不开的现实是重复出现的词只保留一份。Jaccard 只看一个词“是否存在”完全不看它出现几次。这在某些场景下是缺陷在另一些场景下是优点。比如两篇文章都讨论同一个主题A 文章中“算法”出现了 50 次B 文章中“算法”只出现了 2 次。从语义上讲这两篇文章确实相关从集合角度看这个词对两边的影响力被拉平了这是合理的。但如果业务上需要区分“反复强调某个词”的文本比如关键词堆砌检测、电商评论真实性分析集合压缩会丢掉词频信息让特征不够充分。想保留词频又不想引入向量计算的复杂度可以用加权 Jaccard 的变体方案将每个元素重新编码为“词_词频”或“词_词频档位”再用标准 Jaccard 计算。比如“算法”出现 50 次编码为“算法_50”“算法”出现 2 次编码为“算法_2”两者命中率变低相似度下降词频差异就被捕捉到了。不过这种做法会显著增加集合基数计算规模变大只建议在确实需要词频敏感性的场景中使用。3. Python 实操从零实现到大规模优化3.1 最简实现20 行代码跑通相似度计算先来一个纯 Python 的最小实现这个版本不依赖第三方库只做一件事把两个字符串转成字符 n-gram 集合然后计算 Jaccard 相似度。def ngram_set(text, n2): # 简单的字符 n-gram 切分 text text.lower() return {text[i:in] for i in range(len(text) - n 1)} def jaccard_similarity(text_a, text_b, n2): set_a ngram_set(text_a, n) set_b ngram_set(text_b, n) intersection set_a set_b union set_a | set_b if len(union) 0: return 0.0 return len(intersection) / len(union) # 测试 t1 自然语言处理是人工智能的重要方向 t2 自然语言处理属于人工智能的关键领域 print(jaccard_similarity(t1, t2))输出结果是 0.75 左右。把“重要方向”换成“关键领域”之后字符 2-gram 的“重要”“要方”“方向”和“关键”“键领”“领域”完全不重叠但其余部分高度重合所以相似度依然可观。这个特性正是字符 n-gram 的价值所在——它允许一定程度上的措辞替换同时依然识别出整体结构高度相似。这段代码可以直接拷贝到你的项目里用。但我得说句实话这种写法在大规模场景下完全不够看。真正的工业实现要考虑三点集合构建的效率、相似度比较的批量处理、以及内存占用。3.2 搭配分词库中文语义更准确的版本字符 n-gram 对中文短句效果不错但对长文本词的语义单元优势更明显。下面是配了分词库的版本按照第二节的设计思路做了预处理、停用词过滤、词集合构建。import re import jieba STOP_WORDS set([的, 了, 是, 我, 你, 他, 在, 有, 和, 就, 不, 人, 都, 一, 一个, 上, 也, 很, 到, 说, 要, 去, 会, 着, 没有, 看, 好, 自己]) def clean_text(text): # 统一小写剔除标点 text text.lower() text re.sub(r[^\u4e00-\u9fa5a-zA-Z0-9], , text) return text def token_set(text, use_stopwordsTrue): cleaned clean_text(text) words [w.strip() for w in jieba.cut(cleaned) if w.strip()] if use_stopwords: words [w for w in words if w not in STOP_WORDS] return set(words) def jaccard_of_sets(set_a, set_b): if not set_a or not set_b: return 0.0 return len(set_a set_b) / len(set_a | set_b) text1 这家餐厅的菜品非常新鲜服务态度也很好 text2 这家店的服务很好食材也很新鲜 set1 token_set(text1) set2 token_set(text2) print(jaccard_of_sets(set1, set2))这个版本跑出来的相似度通常会比纯字符 n-gram 更低一些因为词的粒度更大、集合元素更稀疏。但这不代表效果差而是衡量语义单元的方式更严格了。词级别的 Jaccard 更接近“主题是否相同”的判断字符级别更接近“字符构成是否相同”的判断。实际业务里我经常两者都算一个做粗召回一个做精排。分词库的选择上不用纠结主流开源分词库之间差异没有想象中那么大。关键是要对业务词表做自定义补充比如产品名“某购物节”、平台昵称、代码模块名等分词器不认识就切得稀碎。遇到这种情况给分词库加载自定义词典即可。3.3 大规模场景MinHash 与 Jaccard 近似加速如果数据量到了百万级甚至亿级直接两两计算 Jaccard 是完全行不通的。100 万条文本两两比较大约是 5000 亿次集合运算单机算到天荒地老。这就要请出 MinHash。MinHash 的核心思路是先用若干哈希函数分别对集合中每个元素取哈希值取每个函数下的最小值得到一个固定长度的签名向量。根据数学推导两个集合的哈希签名完全一致的比例在概率上等于这两个集合的原始 Jaccard 相似度。也就是说通过比较固定长度的签名向量就能近似估计出 Jaccard 相似度而签名向量的比较可以用分桶策略进一步加速。一个简化的多哈希 MinHash 实现如下import hashlib def minhash_signature(text_set, num_hashes100): signatures [] for seed in range(num_hashes): # 用不同的盐值派生不同的哈希函数 min_hash None for token in text_set: h int(hashlib.md5(f{seed}:{token}.encode(utf-8)).hexdigest()[:8], 16) if min_hash is None or h min_hash: min_hash h signatures.append(min_hash) return signatures def jaccard_from_signatures(sig_a, sig_b): # 签名向量的重合比例近似 Jaccard 相似度 return sum(1 for x, y in zip(sig_a, sig_b) if x y) / len(sig_a)这段代码在实际项目中当然不是最优写法生产环境里可以直接用第三方库实现或者用带分桶的 LSH局部敏感哈希方案。但理解这个原理非常重要它揭示了 Jaccard 相似度可以转化为“固定长度向量的比较”这是它在海量数据场景依然能苟住的核心原因。我的建议是如果数据量在万级以下直接两两全量计算 Jaccard 完全没问题十万到百万级用 MinHash 生成签名后并行分桶百万以上LSH 几乎是唯一合理选择。这个分层策略省下来的计算资源非常可观。3.4 工程优化细节缓存、并行与内存策略写代码能跑和写代码能扛住生产流量之间差着好几个量级的优化工作。集合构建要缓存。文本集合的构建成本远高于集合比较本身。如果同一批文本要和多个目标文本比较务必提前把每段文本的 n-gram 集合或分词集合缓存起来避免重复计算。我在日志去重项目里因为缓存了这个步骤整体耗时缩小了约 60%。并行策略要按规模选型。千万条文本比较Python 的 GIL 会对多线程造成严重限制直接上多进程或者用第三方并行计算框架更合理。比较任务极度适合分片——把文本列表切成若干份每份分配一个进程或 Worker最后汇总结果。内存占用要注意集合的膨胀。字符 2-gram 的集合基数通常是文本长度的量级10 万条文本就有几千万个片段直接全部加载到内存会爆。一种做法是存储时只保留集合的哈希值比如单个 64 位整数碰撞概率极低内存占用大幅下降。另一种做法是把集合序列化后存到本地缓存数据库比较时按需加载牺牲部分 IO 换取内存空间。4. 应用场景展开与影响范围4.1 文本去重与转载检测这是 Jaccard 相似度最经典的应用场景。自媒体平台、新闻聚合平台、学术论文预审系统每天都在处理海量的内容去重请求。转载文章常常被改标题、改首段、插入广告直接全字段匹配没有任何意义用 Jaccard 计算全文的 n-gram 重叠度超过一定阈值就判定为同一篇文章是相当成熟的做法。实际业务中我不会对整篇文章做单一的全局相似度判断而是采用“分块 Jaccard 全局阈值”的组合策略把文章按段落切开每个段落单独计算 Jaccard只要有连续的几个段落相似度超过 0.9就认为这篇文章构成转载。这种方式能绕过“插入大段广告导致全局相似度下降”的典型对抗手段。阈值设定上没有天下之统一标准。纯转载场景相似度 0.8 以上基本可以确认但如果是同主题不同写手的原创文章0.5 可能都算高了。具体数值要结合你的数据人工标注几十个样本算出分布后再定不要拍脑袋。4.2 日志聚类与异常检测日志数据有个特点是大部分日志行是固定模板重复出现真正异常的行非常少。用 Jaccard 对日志做聚类实际上是按“日志骨架”分组找出哪些日志行属于同一种类型。这里推荐用字符 n-gram 而不是分词因为日志通常包含大量变量值IP 地址、时间戳、订单号、用户 ID。如果一个词的粒度太细两条只有 IP 不同的日志会被判定为完全不相似这显然不对。正确的做法是先把日志中的明显变量字段用正则替换为占位符比如把 IP 替换成[IP]把数字串替换成[NUM]然后再做集合化处理。我处理过某系统每天几亿条的错误日志用 Jaccard 聚类之后几千个原本看着完全不同的报错最终归类成了 30 多个真正需要人工关注的错误类型。这就是 Jaccard 在高噪声场景下的核心价值——它天然对“少量字段不同”免疫能抓住模板主干。4.3 相似标题推荐与内容召回电商平台的相似商品标题聚合、知识库的相关文档推荐这类任务的特点是输入是一个短文本输出是一组相似度较高的候选。Jaccard 在这类场景中一般扮演召回层角色。用词级别的 Jaccard 在几千条标题里找出 Top-N 候选是相对可靠的方式。因为标题很短词集合交集能直观反映商品或文档的核心实体词是否重合。碰撞率低、计算快通常我用它过完召回后再把候选交给向量检索模型做精排这样既有速度又有精度。需要注意的是短文本场景中 n-gram 集合可能为空或者并集很小导致分母为 0 或相似度虚高。代码层面必须处理空集合的情况至少返回 0。我在做标题推荐时见过因为并集只有 1 个词导致 Jaccard 为 1.0 的情况这种结果没有参考价值需要额外过滤。4.4 代码克隆检测代码克隆检测是 Jaccard 的冷门但非常好用的场景。开源代码抄袭、代码仓库内的重复模块治理都可以用这个思路。处理方式是把代码按行或者按 token 切分成集合然后用 Jaccard 比较文件相似度。代码比自然语言更容易用 Jaccard因为代码中的标识符、关键字是高度结构化的很少有同义词替换的困扰。做这个场景有一些特殊技巧注释要剔除因为注释可以随意改写不影响代码功能标识符要归一化变量名改写的躲避手段非常常见把所有标识符统一替换成占位符只保留语法结构。但要注意完整文件级别的 Jaccard 对“函数抽取”“代码块移动”非常敏感移动 100 行代码到另一个函数里会改变集合划分方式。所以代码克隆检测往往用 token 流上的窗口 n-gram 做局部相似度检测而不是直接对全文件集合求 Jaccard。5. 常见问题、参数调优与避坑实录5.1 集合为空和分母为零的边界情况这是最容易被新手忽略的问题。当两段文本经过预处理后都只剩空集合比如文本本身就是纯标点、纯停用词或者文本长度为 0 时集合的并集大小为 0除法直接报 ZeroDivisionError。处理方式有两种一种是按我前面代码里的方式并集为 0 时返回 0另一种是根据业务需要返回 1两条空文本“完全一致”。我倾向于返回 0因为在推荐、去重场景里文本内容为空本身就是一个需要剔除的异常样本不应该参与相似度计算。还有一类边界情况文本长度小于 n。比如 n2 时文本只有一个词text[i:in]一个片段都切不出来集合为空。解决方式是动态判断如果切出来的集合为空将文本本身作为单个元素或者直接放弃这次计算。5.2 阈值怎么选才靠谱我前面反复提到阈值要靠数据标注来定这里给出一个可操作的方法论。从业务数据里随机抽 100 对文本人工标注每对是否属于“同一语义”根据你的业务目标计算出每对的 Jaccard 相似度然后画出分布图看两个类别的分界点在哪里。实际操作中分界点往往不是一个清晰的数值而是重叠区间。这时不要追求唯一阈值用两条线高于上界直接判定相似低于下界直接判定不相似中间地带标记为“待人工确认”或者“送入下游模型二次判断”。三分类方案比单阈值在工业场景中实用得多。另外阈值一定要随着数据变化周期性强校验。我维护的一个去重系统年初定 0.75 的阈值跑得好好的半年后新内容风格变化大量误判涌出来重新标注后阈值降到 0.68 才恢复稳定。这不是一劳永逸的参数需要纳入监控体系。5.3 分词不一致导致的假阴性问题中文场景最大的坑就是分词器在相似文本上给出不同切分结果。比如“自然语言处理”可能被切成“自然语言_处理”也可能被切成“自然_语言_处理”两种切分在词 Jaccard 计算下就会出现交集为空的尴尬情况。解决办法有几个层面。第一在词集合基础上叠加字符 2-gram 集合两者取并集后再计算 Jaccard这样即使分词结果不同字符层面的重叠依然能兜底。第二维护自定义词典确保业务核心词在分词阶段不会七零八落。第三改用词 n-gramshingle因为词 n-gram 把相邻词的边界信息也保留了一部分分词不一致的影响会被缓冲。我见过有人为了解决分词不一致引入各种复杂模型但在 Jaccard 框架下复杂度控制住才是关键。我的建议是优先用“分词词集合 字符 2-gram 并集”的融合方案这个方案在绝大多数真实数据上都有足够稳定性而且直接复用 Python 的 set 操作即可。5.4 Jaccard 失效的语义场景识别最后一定要说清楚 Jaccard 的边界否则你会在错误场景里浪费大量时间。它的失效模式非常典型同义改写场景比如“这部电影非常好看”和“这电影太棒了”Jaccard 可能只有 0.2但人类会认为语义高度相近。这时候 Jaccard 不适用你需要词向量或深度学习模型。整体包含场景一篇文章是另一篇的超集比如“深度学习入门指南”和“深度学习入门指南附实战案例”Jaccard 会因为分母变大而明显偏低但业务上你可能希望把它们归为一类。这时要用“包含系数”一类的变体分子不变、分母用较短集合的基数处理非对称包含关系。语言混合场景中英混杂的文本里大小写、空格、分词规则都在打架Jaccard 需要极周密的预处理才能稳定输出结果成本可能超过收益。这时候不如直接考虑向量化方案。我的实践经验是不要试图让一个工具包打天下。选定 Jaccard 的任务是那些目标文本在词面层面就天然大量重叠的任务——去重、转载检测、日志聚类、代码克隆。在这些场景里它就是性价比最高的选择。6. 后续扩展建议如果你已经能用 Jaccard 跑通基础流程下一步可以琢磨三个方向。一是做加权 Jaccard引入词频、词逆文档频率这类权重信息把集合粗相似度升级成语义敏感度更强的分数。二是和 MinHash LSH 深度结合解决跨亿级数据的相似度检索问题这个方向对做内容平台的人会很值钱。三是把 Jaccard 作为特征之一接入多模态方案比如和传统向量相似度做加权融合。我个人在实际操作中的体会是Jaccard 相似度是一个你越用越能发现细节的工具。它算法再简单配上对业务场景的深刻理解效果可以比很多复杂模型更可靠。最后再分享一个小技巧——写代码的时候把集合构建函数和相似度计算函数彻底分离因为你会发现几乎所有性能瓶颈都在集合构建和存储上而不是相似度计算本身。保持这两个模块的独立性后续做缓存、做并行、做近似估计都只是在这个边界上进行替换而已。