☰
基于Jaccard相似度的文本语义相似:原理、优化与大规模工程实践
2026/10/16 18:18:33 网站建设 项目流程

很多人第一次看到“[文本语义相似] 基于Jaccard相似度”这个标题的时候,大概率会跟我当初一样,心里先打个问号:Jaccard相似度不是集合运算吗?它跟“语义”能沾上什么边?

先直接给结论:Jaccard相似度本身不包含任何语义信息,它只算字面重叠的比例。但在特定的文本匹配场景下——比如短文本去重、关键词重叠检测、评论模板识别——它速度快、可解释性强、不需要训练,反而是比深度语义模型更务实的选择。这篇内容我就围绕标题里的两个关键词展开:文本语义相似这个诉求,和Jaccard相似度这个实现手段,聊聊我做过的方案落地、踩过的坑,以及什么场景下该用它、什么场景下应该果断换掉。

如果你正在做文本匹配类的功能,或者刚开始接触文本相似度计算想找个性价比高的切入方案,这篇文章能提供一条完整可复现的路线。

1. 标题里的“语义”和Jaccard真实能力之间的差距——先搞清楚再说“相似”

“语义相似”这四个字很容易让人误以为我们要做多高深的事情。但实际做文本相似度任务时,第一步应该是拆解业务到底需要什么层面的相似——是字面相似就能满足,还是非得理解语义不可。

1.1 “语义相似”在日常需求里到底是什么

举几个我实际遇到的例子:

  • 某社区平台做重复帖检测,用户发了两遍同样的内容,只是第二遍多打了个空格、换了个标点。
  • 某电商后台做商品标题聚合,大量商家从同一货源拿货,标题只有品牌名略微不同。
  • 某客服系统做问题分类,用户反复问“怎么退款”“退款流程是什么”“我要申请退款”,三句话字面差很多,但指向同一类意图。

第三种情况才是真正的语义相似,前两种本质上都是“长得像不像”的问题。我不止一次看到团队拿到需求后直接上BERT或引入预训练模型做向量化,结果数据量不大、实时性要求高、又没有GPU资源,最终效果还不如一个Jaccard系数来得直接。

所以拿到这个标题时,我会先给“语义”做个降级定义:在大多数中小规模文本场景里,语义相似约等于“关键词重叠 + 语序一致”。而这两点恰好是Jaccard能部分覆盖的。

1.2 把文本看成集合的直觉逻辑

Jaccard相似度之所以能被用在文本领域,是因为一个很自然的预处理思路:把句子切分成词或字符片段,再把它们装进一个集合里。

给定两个文本 A 和 B,Jaccard相似度公式就一行:

J(A, B) = |A ∩ B| / |A ∪ B|

分子是同时出现在两个文本里的元素个数,分母是两边所有元素去重后的总数。一旦文本转成集合,求交集和并集就是O(n)级别的时间复杂度,非常快。

我拿一段真实数据演示一下:

  • 文本A:“今天天气很好适合出门”
  • 文本B:“今天天气不错适合跑步”

按单个字划分集合:

  • A = {今, 天, 天, 气, 很, 好, 适, 合, 出, 门},去重后 {今, 天, 气, 很, 好, 适, 合, 出, 门}
  • B = {今, 天, 气, 不, 错, 适, 合, 跑, 步}

交集:{今, 天, 气, 适, 合},共5个 并集:{今, 天, 气, 很, 好, 适, 合, 出, 门, 不, 错, 跑, 步},共13个

J = 5 / 13 ≈ 0.3846

同样两句话,如果按词划分:

  • A = {今天, 天气, 很好, 适合, 出门}
  • B = {今天, 天气, 不错, 适合, 跑步}

交集:{今天, 天气, 适合},共3个 并集:{今天, 天气, 很好, 适合, 出门, 不错, 跑步},共7个

J = 3 / 7 ≈ 0.4286

看到差别了吗?分词的粒度直接影响相似度数值。这引出了Jaccard在文本落地时第一个需要认真考虑的问题——到底用什么粒度切分。

1.3 为什么Jaccard常被误解为“不支持语义”

有一类典型的反驳观点是:两句完全同义但用词全不同的句子,比如“今天很开心”和“今日心情愉悦”,Jaccard算出来就是0,这种算法太弱了。

这种批评对,但也不全对。处理文本相似度其实和做菜一样,得看食材选什么。Jaccard的定位从来就不是通用语义理解,它是词汇重叠度度量器。你喂给它的是词或字,它反馈的就是词和字的重叠比例。

想要让它在语义维度上不至于完全失灵,业界常用的补救方案是预处理阶段做归一化——包括同义词替换、近义词扩展、词形还原等等。这个思路的正确姿势是:让输入文本先向“标准表达”靠拢,再让Jaccard去做它擅长的集合重叠计算。我后续会单独讲这块的落地实操。

2. 从公式到第一版代码:最小可用的实现方案

标题既然写“基于Jaccard相似度”,那实现环节自然是重头戏。这一节先给出真能跑的最小可用版本,再拆解每个设计决策背后的理由。

2.1 一个直接了当的Python实现

def jaccard_similarity(text_a, text_b, tokenizer=None): """ 计算两个文本的Jaccard相似度。 参数: text_a: 文本A text_b: 文本B tokenizer: 切分函数,默认按字切分 返回: float,0到1之间的相似度分数 """ if tokenizer is None: tokenizer = lambda s: list(s) tokens_a = set(tokenizer(text_a)) tokens_b = set(tokenizer(text_b)) if not tokens_a or not tokens_b: return 0.0 intersection = tokens_a & tokens_b union = tokens_a | tokens_b return len(intersection) / len(union)

这段代码的核心逻辑就三行:转集合、求交集、求并集。但这版实现只适合验证思路,在真实业务里还差得远,我继续往下补充。

2.2 为什么默认按字切而不按词切

很多初学者默认应该用分词器——因为直觉上“词语”才是语义的最小单位。但我在实际项目里发现,按字切分有几个独特优势:

一是不需要额外依赖。中文分词器比如结巴、盘古这类工具,引入后还需要加载词典、处理新词问题。而按字切分在任何环境下都能稳定运行。

二是对错别字和变形更鲁棒。按词切分时,“退款”和“退费”是两个词,相似度为0;按字切分时,两者共享“退”和“款/费”两个字符中的部分,至少能算出一点重叠。

三是短文本下的表现更稳定。按词切分在句子极短时,集合元素很少,交集要么是0要么是1,相似度要么0要么1,区分度太差。字级别的粒度相当于把特征维度增大了。

当然按字切分也有代价:它对语序完全不敏感。“小明打了小红”和“小红打了小明”按字切分后集合完全一样,Jaccard等于1。如果业务场景对语序敏感,就需要在选择中额外用N-gram来保留局部顺序信息。这就是我马上要讲的部分。

2.3 实用版:用代码支持多种切分粒度

一个工程上更合理的设计是让切分器可配置,这样就能根据业务灵活切换:

import re from collections import Counter def ngram_tokenize(text, n=2): """ 字符级别的N-gram切分。 n=1时等价于按字切分,n=2时为 bigram。 """ text = re.sub(r'\s+', '', text) if len(text) < n: return [text] return [text[i:i+n] for i in range(len(text) - n + 1)] def jaccard_similarity(text_a, text_b, n=1): tokens_a = set(ngram_tokenize(text_a, n)) tokens_b = set(ngram_tokenize(text_b, n)) if not tokens_a or not tokens_b: return 0.0 inter = len(tokens_a & tokens_b) union = len(tokens_a | tokens_b) return inter / union

调用方式示例:

print(jaccard_similarity("今天天气很好适合出门", "今天天气不错适合跑步", n=1)) # 输出约为 0.3846 print(jaccard_similarity("今天天气很好适合出门", "今天天气不错适合跑步", n=2))

bigram的效果我这里先卖个关子,下一节专门讲。

3. N-gram和预处理:让Jaccard在“语义”维度上更可用的一层优化

如果你拿字级Jaccard直接交付业务,多半会被怼“相似度分数不够合理”——因为大量真正语义相近的句子分数偏低。这一节讲的优化手段,就是在不改动算法核心的前提下,把分数往“语义合理”的方向拉一拉。

3.1 bigram如何弥补语序信息的缺失

按字切分的集合完全忽略了顺序,“AB”和“BA”得分完全一样。这在很多场景里是不合理的——比如对比“产品质量差”和“质量差产品”,字面元素完全相同但语义完全相反。

bigram(双字组合)的做法很简单:把“今天天气不错”切成 {今天, 天天, 天气, 气不, 不错}。每个元素包含了相邻两个字符,这样“今天”和“天天”是两个不同元素,语序在元素层面被部分保留了。

实测一组数据更能说明问题:

文本对字级Jaccardbigram Jaccard
猫坐在垫子上 / 垫子上的猫1.00.11
我要退货退款 / 如何申请退款0.330.10
北京到上海机票 / 上海到北京机票1.00.09
深度学习入门教程 / 机器学习入门教程0.600.33

看到bigram那列数值都缩小了,但这恰恰是“分辨力变强”的体现——第一行和第三行,字级Jaccard直接给了满分,显然是误判;bigram虽然分数变低,但至少把不同语序区分开了。

在短文本聚类、标题去重这类场景里,我一般用bigram的Jaccard做初筛。因为n太大时稀疏性会急剧上升——“深度”是一个元素,“深度学”又是一个元素,稍微换个词,交集就趋近于零。

3.2 停用词与高频词的取舍——过滤得越狠,分数越真实

文本里总有大量“的”“了”“是”“在”这类高频功能词,它们几乎出现在所有句子中。在集合层面,它们是“全民公共元素”,会同时拉高交并比。拉高分子也拉高分母,但核心问题是:它们的存在会让不同语义的句子相似度虚高。

举例:

  • “这是一个很好的产品”
  • “这是一个很差的开始”

按字切分,两者共享“这、是、一、个、很”五个字,总元素约11个,Jaccard ≈ 0.45,光靠停用词撑起了近一半的相似度。

解决办法是引入停用词表,在切分后过滤掉这类词。我用的策略分两层:

  1. 基础停用词表:网络上公开的常用中文停用词表基本够用,覆盖“的、了、是、在、与、和”等。
  2. 业务专属低频停用词:从自己数据里用TF-IDF挑出那些出现在大量文本里但无助区分的词。这个步骤我用了一段小代码实现:
from collections import Counter def extract_business_stopwords(texts, min_doc_freq=0.8): """ 从语料中统计文档频率,找出频繁出现但信息量低的词。 参数: texts: 文本列表 min_doc_freq: 出现在超过该比例文本中的词被视为停用词 """ total_docs = len(texts) doc_freq = Counter() for text in texts: tokens = set(ngram_tokenize(text, n=1)) doc_freq.update(tokens) business_stopwords = { word for word, freq in doc_freq.items() if freq / total_docs >= min_doc_freq } return business_stopwords

这一步做完后,相似度的“信噪比”会明显提升。我见过一个案例,某商品标题去重场景做完全词过滤后,误匹配率直接下降了一半。

3.3 同义词归一化:让Jaccard“作弊”学会语义

真正想让Jaccard靠近语义,最有效的手段不是换模型,而是在输入侧做同义词映射。把表达不同但意思相同的词替换为标准词,再喂给集合运算。

举个实际做法,我维护了一张同义词映射表:

synonym_map = { "怎么": "如何", "咋": "如何", "怎样": "如何", "退货": "退款", "退掉": "退款", "不要了": "退款", "取消订单": "退款", "价格": "价钱", "费用": "价钱", "多少钱": "价钱", } def normalize_text(text): for word, standard in synonym_map.items(): text = text.replace(word, standard) return text

经过这层归一化,“怎么退款”和“退款流程如何”就变成了“如何退款”vs“退款流程如何”,字级Jaccard从0.25提高到0.5。同义词映射的维护可以用规则沉淀,也可以从历史数据中挖掘高频同现词对。

提示:同义词替换要警惕过度替换带来的误伤。“快速退款”里的“退款”被替换成“退款”没影响,但“取消订单”被替换成“退款”后,会和真正的退款需求混淆。映射表必须做人工审核,尤其是业务强相关词。

3.4 阈值标定的实验方法

经过N-gram和预处理之后,最后一步是确定“多少分算相似”。阈值定太严漏掉目标,定太松全是垃圾结果。我推荐一个朴实但有效的标定方法:

  1. 从真实数据里抽样200~500对文本,人工打标为“相似/不相似”。
  2. 用当前配置算出每一对的Jaccard分数。
  3. 画分布图,观察两个群体的分界线。

如果“相似”群体的分数集中在0.4以上,“不相似”群体集中在0.3以下,那阈值取0.35~0.4就比较稳。

实际工程里数据分布不会这么干净。你可能会发现相似群体里有一部分分数很低——这时候要做的不是调低阈值,而是回溯检查预处理:是不是有该归一化没归一化的同义词?是不是切分粒度不对?特征的优化永远比阈值调整更健康。

4. 海量文本下不可忽视的工程化改造:位运算、MinHash与去重方案

单纯用Python里的set做交集和并集,在文本总量几千、几万时毫无压力。但只要上到线上系统——比如每天新增几十万条短文本需要实时去重——问题就来了:两两比较是O(n²)复杂度,等算完,数据又要更新了。

这一节聊真正工程意义上Jaccard怎么落地。

4.1 从集合到二进制向量:Bitset加速

Jaccard公式的本质是集合容斥,而集合运算在计算机里还可以用位向量表示。先把语料里所有可能的元素(字或bigram)映射到一个固定长度的二进制位,比如第0位代表“今天”,第1位代表“天气”。每个文本就变成一个01序列。

Python里用整数的位运算做Jaccard非常快:

def jaccard_from_bits(bits_a, bits_b): """bits_a和bits_b均为整数,用二进制位的交集并集成计算""" inter = (bits_a & bits_b).bit_count() # 交集元素个数 union = (bits_a | bits_b).bit_count() # 并集元素个数 return inter / union if union else 0.0

这个写法比列表或集合的循环快出好几个量级,因为位运算是由CPU直接支持的。如果元素空间不大(比如几千个bigram),一个文本就可以压缩成几十个字节,内存大幅下降。

不过位向量也有局限:元素总量大但每个文本元素稀疏时,位向量会变得很长且很稀疏。这时就该MinHash出场了。

4.2 MinHash如何解决大规模两两比较的性能瓶颈

MinHash的基本思想是:Jaccard相似度越大时,两个集合经过同一个随机映射函数后得到的最小哈希值相等的概率越高。

直觉理解方式——假设你给每个词随机分配一个0到1之间的哈希值,集合的最小哈希值相当于集合中最“幸运”的那个元素。两个集合重复元素越多,最“幸运”的元素就越可能是同一个,最小哈希值相同的概率就越高。

所以不需要真正计算交集并集,只需要构造一个固定长度的签名向量:

import hashlib import random class MinHash: def __init__(self, num_permutations=128, seed=42): self.num_perm = num_permutations self.seed = seed random.seed(seed) # 生成多个哈希函数参数 self.hash_funcs = [ (random.randint(1, 10007), random.randint(1, 10007)) for _ in range(num_permutations) ] def _get_values(self, tokens): """计算一组token的minhash签名""" signature = [] tokens = set(tokens) for a, b in self.hash_funcs: min_val = float('inf') for token in tokens: hash_val = (a * hash(token) + b) % 10007 min_val = min(min_val, hash_val) signature.append(min_val) return signature def similarity(self, tokens_a, tokens_b): sig_a = self._get_values(tokens_a) sig_b = self._get_values(tokens_b) equal_count = sum(1 for x, y in zip(sig_a, sig_b) if x == y) return equal_count / len(sig_a)

实现原理一句话:用多个独立的哈希函数,把每个集合压缩成固定长度的“签名”。签名的相似度是对Jaccard相似度的无偏估计。签名长度越长,估计越精准。128维的签名在实际应用中误差可控制在2%~3%左右。

这种变换的杀手锏在于——签名向量是固定长度的,可以用分桶索引来快速找候选对,不必所有数据两两全比较。LSH(局部敏感哈希)这类结构在网络、推荐系统领域用得很多,这里点到为止。

4.3 短文本大规模去重的经典流水线

综合上面的思路,我实际用的短文本去重流水线是这么设计的:

  1. 规整化:统一全半角、大小写、去除空白和标点。
  2. 增强预处理:N-gram切分 + 业务停用词过滤 + 同义词归一化。
  3. MinHash签名:生成128维签名向量,存入向量数据库或内存索引。
  4. 候选集查询:对一条新文本,先通过分桶索引召回候选对,再用精确Jaccard在候选集里验证。
  5. 阈值判定:精算分数超过阈值即判定重复。

这样查询一条新文本的时间复杂度从O(n)降到了近乎常数。线上实测在百万级文本库上去重一条记录耗时毫秒级,准确率和全量精确计算的差异可以忽略不计。

5. Jaccard不是万能的:它的边界和什么时候该换方案

没有任何一个相似度算法能覆盖所有场景。Jaccard的适用边界需要写得很明确,否则你会在错误的场景里浪费大量时间。

5.1 四个典型“别用Jaccard”的场景

  • 语义翻译型匹配:“别来无恙”和“你还好吗”字面重合几乎为零,但语义高度一致。指望Jaccard算出0.8是痴人说梦,这种场景就该找预训练向量模型。
  • 长文本全局相似度:两篇千字文章可能主题相近、行文完全不同。字面重叠比例会被大量的背景描述稀释,Jaccard得分趋近于零,信息量极低。
  • 多语言混合文本:中英混合的文本在切分时元素空间复杂,交集本身就不可靠。更别提拼音、繁体、emoji混杂的情况。
  • 需要连续可解释分数:Jaccard输出的是0到1的标量,没有中间层次的解释能力。业务方较真问你“为什么0.42是相似但不是高度相似”,你会很难回答。

5.2 什么场景下Jaccard反而是最优解

反过来,下面的场景我会坚定选择Jaccard:

场景为什么Jaccard合适
商品标题去重标题普遍短、关键词集中、字符重叠与语义强相关
评论/弹幕垃圾识别广告变体文本高度雷同,稍有改动但重叠度极高
新闻稿件聚合同源新闻稿件多为主体重叠+增删句
爬虫网页正文去重大批量页面模板相似,字面重叠足够判定
日志模板匹配日志中变量替换后模板本体保持不变

这几类场景的共同特点是:相似度主要由字面重叠驱动,语义包含在字面中。Jaccard简单直接的优势——无需训练、无监督、可解释——正好命中。

5.3 Jaccard和向量模型的实际配合方式

做复杂业务时,我很少让Jaccard单独工作,也从不直接抛弃它。一个成熟的方案往往是分层的:

第一层,用Jaccard做粗筛。非常快的计算能把两两比较的空间缩小一个量级,把明显不相关的文本全部排除。

第二层,对粗筛出的候选集,用向量模型做精排。大规模向量计算成本高,但如果输入数量已经降到几十个,开销就完全可控。

这样既保留了Jaccard的效率和可解释性,又引入了语义模型的深度理解能力。实测在FAQ问答匹配场景里,这种粗筛+精排的组合比单独使用向量模型的End-to-End方案,在同样准确性下能省掉70%以上的计算资源。

6. 写在最后的实操经验

文章内容到这就差不多了,按惯例分享几个自己真实操作中的经验,省得你在同样的坑里再摔一遍。

第一,Jaccard相似度的分数绝对值意义不大,关键是相对排序。不同预处理方案算出来的分数不可直接横向对比。同一对文本,字级算出来可能是0.5,bigram算出来可能是0.2,不代表后者变“差”了,只是分布的尺度和离散度不同。所以做交付时,一定要带上“当前方案的基准分布”一起解释。

第二,阈值设置永远要跟业务代价挂钩。如果你做的系统是“宁肯多召回,不能漏掉重复内容”,阈值就得相对调低,宁可后续人工审核多一点;如果系统是“用户直接看到相似推荐”,阈值就得调高,别让用户觉得“明明完全不同的两篇文章也被标成相似”。这个取舍没有标准答案,必须由产品来拍板。

第三,预处理里引入的同义词映射表,是一个长期维护的资产。每处理一批数据,把错判案例找出来,回填映射表,这个表的价值会越滚越大。我见过一个持续迭代了两年的老系统,就靠一张不断生长的映射表和Jaccard,扛住了业务最初期的全部需求,直到数据量真正大到需要向量模型时才做切换。

如果你正在做一个文本相似度功能,我的建议很简单:先别急着追逐大模型,拿Jaccard跑通闭环、摸清数据分布、把预处理的坑踩一遍,再在那条充分理解问题的路上做升级。很多场景,它的精度和效率已经足够用了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询