ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

文本相似度算法全解析:从编辑距离到BERT的工程实践

文本相似度算法全解析:从编辑距离到BERT的工程实践 做搜索、做推荐、做查重几乎每个跟文本打交道的技术人员迟早都会撞上同一个需求怎么判断两段文本到底像不像。这个问题的专业说法就是“文本相似度计算”它几乎是所有NLP落地场景里最基础也最实用的一块地基。无论是搭建问答系统时做相似问匹配、给文章做去重还是做日志聚类、辅助检索排序文本相似度算法都能派上用场。我最早接触这块是为了处理一个客服问答系统的相似问题合并后来又在推荐召回、知识库去重里反复用到踩了不少坑也积累了一些可以直接套用的经验。这篇文章我会把常见的文本相似度算法从头到尾梳理一遍分成“基础经典算法”“向量化与语义匹配”“工程选型与实战排查”三条线来讲重点说清楚每个算法适合什么场景、时间复杂度怎么样、为什么这么设计以及实战中容易踩的坑。无论你是刚开始接触NLP的学生还是已经在做推荐、检索、搜索的工程师这篇文章都可以当成一份快速上手的参考资料。1. 先看整体文本相似度到底在解决什么问题1.1 怎么定义“像不像”文本相似度看起来是一个很自然的问题但真要动手做的时候第一步就卡住了什么叫“像”在不同业务里“相似”的定义完全不一样。拿两个例子来说。“我要退款”和“我想退钱”这两句话字面不一样但表达的意思基本一致属于语义相似。“苹果价格”和“苹果手机价格”字面上只差两个字但含义完全不同术语上叫“短文本信息量低字面相近不一定语义相近”。反过来说“今天天气不错”和“今天天气真好”字面接近语义也接近算相似没问题。所以在做算法选型之前先要明确你想要的“相似”到底处于哪个层级字面相似字符层面重叠度高比如标题去重、URL去重、代码片段去重。词法相似分词后词语层面的重合度高比如文档去重、论文查重。语义相似意思相同但表达形式不同比如FAQ匹配、搜索Query意图统一。主题相似话题相关但角度不同比如新闻推荐、文章聚类。不同的“相似”定义决定了算法选型完全不同。字面相似可以用编辑距离、Jaccard这种经典算法解决词法相似用TF-IDF配合余弦相似度最顺手语义相似就得上Word2Vec、BERT这一套深度模型了。这也是我写这篇文章想强调的第一件事不要一上来就选最火的模型而是先搞明白业务需要什么层级的相似度否则很容易做出一个“精确却没用”的方案。1.2 相似度算法的三大流派市面上能查到的文本相似度算法非常多但归纳起来只有三大流派。第一类是“基于字符/集合的方法”核心思想是统计重叠度。它把文本拆成字符、n-gram或者词集合然后用编辑距离、交集并集比值等方式计算相似程度。这类方法实现简单、速度快、可解释性强适合短语、标题、短句级的字面匹配。缺点是它对同义词、语序变化、句式调整完全无能为力。第二类是“基于向量空间的方法”核心思想是把文本映射成向量后再用数学上的向量距离来衡量相似度。最经典的是TF-IDF向量加余弦相似度文本去重和检索场景至今仍大量使用。这类方法能捕捉词频和文档区分度对中文分词后的文本效果不错但它仍然是“词袋模型”对语义关系理解有限。第三类是“基于语义模型的方法”核心思想是让模型从大规模语料里学习词的上下文语义再通过词向量或者句向量来表示文本。Word2Vec、BERT、Sentence-BERT都属于这一类。它的优势是能理解同义词、近义词甚至逻辑关系但它的代价是需要训练语料、GPU资源、推理时间线上部署成本明显更高。这三类没有绝对的好坏只有合不合适。我在实际项目中的经验是能用第一类解决的绝不上第二类能用第二类解决的先别急着上第三类。文本相似度最终比拼的是工程取舍而不是谁的模型更炫。2. 从字符和集合出发最经典的几种基础算法2.1 编辑距离最直观的“改动次数”编辑距离也叫Levenshtein距离是最容易理解的一种文本相似度算法。它的核心思想非常朴素把一个字符串变成另一个字符串最少需要多少次插入、删除、替换操作这个次数就是编辑距离。距离越小说明两个文本越相似。举个例子“kitten”变成“sitting”需要把k替换成s、把e替换成i、在末尾插入g总共3次操作所以编辑距离是3。实际实现用的是动态规划维护一个二维数组dp[i][j]表示字符串a的前i个字符转换到字符串b的前j个字符需要的最少操作数。def edit_distance(a: str, b: str) - int: n, m len(a), len(b) dp [[0] * (m 1) for _ in range(n 1)] for i in range(1, n 1): dp[i][0] i for j in range(1, m 1): dp[0][j] j for i in range(1, n 1): for j in range(1, m 1): if a[i - 1] b[j - 1]: dp[i][j] dp[i - 1][j - 1] else: dp[i][j] min( dp[i - 1][j] 1, # 删除a[i-1] dp[i][j - 1] 1, # 插入b[j-1] dp[i - 1][j - 1] 1 # 替换 ) return dp[n][m]这个算法理解起来不难但有几个使用细节我得提醒你。首先编辑距离计算的是“绝对次数”字符串越长距离的绝对值就越大所以直接拿原始距离做阈值不够客观一般要除以两个字符串的最大长度做归一化。其次它的时间复杂度是O(n*m)两个一两千字的文档对比就会有百万级的运算量所以它更适合短文本比如搜索词纠错、地址标准化、问答对的模糊匹配。我在实际项目里用它做过商品名称的清洗归并。两个商品标题只要单词编辑距离很小基本可以判定为同一款商品的重复录入。这个场景下文本短、噪声大、语义接近编辑距离用起来非常稳。2.2 Jaccard 相似度与 n-gram用“集合”的眼光看文本Jaccard相似度是另一个非常经典的方法它的计算方式可以用一句话概括两个集合交集的大小除以并集的大小。如果两个集合完全一样Jaccard就是1完全没有交集就是0。它天然是一种0到1之间的相似度分数不需要额外归一化。但问题来了文本不是集合怎么转成集合常见的做法是n-gram。n-gram就是把文本按滑动窗口切成n个字符或n个词的片段然后把这些片段放进一个集合。比如“我喜欢你”按二元字符切分会得到“我喜”、“喜欢”、“欢你”三个元素。n越小集合越细粒度更容易捕获字符级别的重叠n越大保留的上下文信息越多但对噪声也更敏感。def jaccard_similarity(a: str, b: str, n: int 2) - float: set_a {a[i:i n] for i in range(len(a) - n 1)} set_b {b[i:i n] for i in range(len(b) - n 1)} inter len(set_a set_b) union len(set_a | set_b) return inter / union if union 0 else 0.0Jaccard算法最大的优势是快集合运算在Python里直接用set实现性能很好基本是毫秒级。它特别适合处理内容天然有大量重复片段的场景比如判断两篇文章是否存在大段重复、检测爬虫抓取的正文模板是否相似、给电商评论区做同款评论聚类。我在做内容安全审核时经常用字符2-gram的Jaccard来过滤批量复制的垃圾内容效果比单纯用编辑距离快很多也稳定很多。不过要记住Jaccard对“顺序”不敏感。“我打你”和“你打我”切出来的字符n-gram集合完全不同但如果是词级别的unigram集合两者又完全一样了。所以具体使用时要结合业务想突出字面重合字符n-gram更合适想突出关键词重合词集合更合适。2.3 SimHash工业界的大规模去重利器当文本数据量到了百万、千万级编辑距离和Jaccard就会遇到性能瓶颈。这时候就需要一种能为每篇文本生成固定长度指纹并且能快速比较相似度的算法SimHash就是最经典的方案。SimHash的思路很巧妙。它先把文本分词并为每个词赋予一个权重比如TF-IDF值或者词频。然后对每个词计算一个64位的哈希值对于哈希值的每一位如果该位是1就加一次词的权重如果是0就减去一次词的权重。所有词都处理完后每个维度上会得到一个累加值最后把正数变成1、负数变成0得到一个64位的指纹。两篇文本的相似度就用它们指纹之间的“海明距离”来表示距离越小越相似。用Python实现SimHash的核心逻辑其实不长def simhash(text: str, hash_bits: int 64) - int: weights [0] * hash_bits for word, weight in tokenize_with_weight(text): h hash(word) # 实际中常使用 murmurhash for i in range(hash_bits): mask 1 i if h mask: weights[i] weight else: weights[i] - weight result 0 for i in range(hash_bits): if weights[i] 0: result | (1 i) return result我做过一个文章库去重项目数据量将近500万篇如果拿TF-IDF向量两两算余弦相似度复杂度是O(n^2)直接算到天荒地老。换成SimHash以后每篇文本生成一个64位指纹再用抽屉原理把指纹分块建倒排索引只需要比对候选集里的少数几篇文本单机就能在几分钟内找出所有近似重复的文章。这个方案在信息流、新闻聚合、自媒体内容审核场景里应用非常广泛。SimHash需要注意的一个点是它对长文本效果很好但对很短的句子效果不稳定。因为短文本信息量少很容易出现不同句子哈希出相近指纹的情况误报率会上升。所以短文本我不太推荐用SimHash直接用Jaccard或者编辑距离反而更准。3. 向量化的分水岭TF-IDF 与余弦相似度的组合3.1 TF-IDF 怎么把文本变成向量前面讲的算法本质上都在统计“重叠”它们没有把文本的全局信息用上。这里说的全局信息是指某个词在整个语料库里的稀有程度。TF-IDF就是为了解决这个问题而设计的。TF-IDF由两部分组成。TF是词频表示某个词在当前文档中出现的次数出现越多说明它对这篇文档越重要。IDF是逆文档频率计算公式是log(语料库总文档数 / 包含该词的文档数)。一个词如果只在极少数文档里出现IDF就大说明区分能力强如果几乎所有文档都有它IDF就趋近于0说明它没什么区分度比如“的”“了”“是”这类停用词。把TF和IDF相乘就得到了每个词的TF-IDF权重。一篇文档可以表示成一个向量向量的每个维度对应一个词维度的值就是这个词的TF-IDF权重。这样一来文本相似度的问题就转化成了数学上的向量相似度问题。分词和预处理是这个环节最容易被低估的部分。我在中文场景下一般用jieba做分词简单稳定然后清理掉停用词、空白字符、数字和特殊符号。预处理做得好不好直接决定了TF-IDF向量化之后的效果。如果停用词没清干净高权重的词全是“我们”“可以”“那种”算出来的相似度就会失真如果分词太碎又会把“机器学习”切成“机器”和“学习”丢失短语信息。如果你处理的不是中文而是日志、代码、英文文本还可以考虑不分词直接按空格切配合字符n-gram来缓解未登录词问题。具体哪种预处理组合最好没有银弹我的习惯是拿一批标注了相似与否的样本分别跑一遍选效果最好的线。3.2 余弦相似度的计算与实操细节有了文本的TF-IDF向量之后常见的相似度度量有欧几里得距离、曼哈顿距离、余弦相似度等。但在文本场景里最常用也最好用的是余弦相似度因为它计算的是两个向量之间的“夹角余弦值”更关注方向而不是长度。公式是cos(A, B) (A·B) / (||A|| * ||B||)。为什么关注方向不重要长度因为文档的长短差异会导致词频的绝对值差异很大两篇内容相关的文章一篇500字一篇5000字词频绝对值完全不同但它们的词语分布比例可能是接近的。余弦相似度恰好能把长度归一化掉只比较分布形态的相似程度。from collections import Counter import math def cosine_similarity(vec_a: dict, vec_b: dict) - float: common set(vec_a.keys()) set(vec_b.keys()) dot sum(vec_a[w] * vec_b[w] for w in common) norm_a math.sqrt(sum(v * v for v in vec_a.values())) norm_b math.sqrt(sum(v * v for v in vec_b.values())) if norm_a 0 or norm_b 0: return 0.0 return dot / (norm_a * norm_b)实操中我有几个固定的优化习惯。第一TF-IDF向量一定要做L2归一化也就是让向量的模等于1这样余弦相似度的点积结果直接就是相似度分数省去每次除模的消耗。第二向量用稀疏字典或者稀疏矩阵存不要用稠密数组。中文词表动不动就几十万维稠密数组内存直接爆掉。第三在线服务里通常不会实时去算整个语料库的两两相似度而是先通过倒排索引召回候选集候选集很小之后再用余弦相似度精排。这套“粗排召回加精排”的思路在实际系统中非常通用。4. 能理解语义的现代方案词向量与预训练模型4.1 Word2Vec给每个词一个“上下文位置”TF-IDF虽然好用但它有个天生的缺陷它把每个词当成独立的符号“苹果”和“香蕉”在向量空间里毫无关系但实际上它们是相近的水果词。于是就有了Word2Vec这类词嵌入方法。Word2Vec的核心思想是分布式假设一个词的含义由它周围的词决定。它通过一个浅层神经网络在大规模语料上训练让每个词学到一个低维稠密向量比如100维或300维。在这个向量空间里语义相近的词距离就近“苹果”和“香蕉”会落在相近的区域“国王”减“男人”加“女人”甚至能接近“女王”这就是所谓的词向量线性关系。用Word2Vec计算文本相似度时一般有两种做法。最简单高效的是把一句话中所有词的词向量按TF-IDF权重做加权平均得到句向量然后算余弦相似度。另一种做法是使用专门训练的文档向量模型比如Doc2Vec直接把整篇文档映射成一个向量。我在搭建一个FAQ问答系统时曾经用过“Word2Vec加权平均”来做相似问句召回。它的优势在于即使两个问句没有共同词汇只要表达含义相近向量距离也会比较近。比如“手机没电了怎么办”和“手机电量耗尽如何解决”TF-IDF算出来相似度可能很低但词向量加权平均后能明显识别出它们语义相近。缺点也很明显它没考虑词序句子级别的信息会被平均操作稀释掉而且对每个词都是同样的权重常见词容易把噪声带进来。所以我现在用它多半是作为“召回层”的候选之一不会单独拿它做最终判定。真正的语义融合最强方案还得看大规模预训练模型。4.2 BERT 等预训练模型的相似度计算从2018年开始BERT这类预训练模型改变了NLP的玩法。它的原理比较复杂简单理解就是用超大规模的文本数据训练一个深度Transformer网络让模型学会根据上下文理解每个词的语义。区别于Word2VecBERT是动态词向量同一个词在不同语境下有不同的向量表示这正好解决了“一词多义”的问题。用BERT计算文本相似度常见的有三种方案。第一种是把两句话拼成“[CLS] 句子A [SEP] 句子B”输入BERT后取[CLS]位置的输出向量经过一个分类头直接输出相似度得分。这是最经典的句子对分类方案效果通常最好。第二种是分别把两句话编码成句向量然后算余弦相似度。第三种是先用BERT生成每个词的向量再做池化得到句向量比如取全部token向量的均值或最大池化。在实际工程里我不太建议直接拿原始BERT做在线两两相似度计算。一方面是推理速度太慢一个BERT-base模型在CPU上跑一次前向推理大概需要几十到几百毫秒高并发根本扛不住另一方面如果全量文本库有几十万条在线两两做BERT推理的计算量完全不可接受。工业界的标准做法是离线把每篇文本的句向量算好存进向量数据库比如faiss、milvus或者qdrant在线查询时直接用向量检索工具找近似最近邻。一个常见的流程是上线前把语料库里的文本用预训练模型离线向量化建立索引线上来了一条新请求同样做向量化然后走向量检索返回topK条候选再用更精细的排序模型或者规则做二次筛选。这套流程在语义搜索、智能客服、知识库匹配里已经很成熟了。5. 选型与实战不同场景该用哪个算法5.1 业务场景对应的算法选择微信里经常有读者问我做文本相似度到底用哪个算法好。我的回答永远是先看数据量再看相似度层级最后看线上算力。三者都确定了选型表基本就出来了。下面这张表是我根据多次项目经验整理出来的你可以直接对照参考业务场景相似度层级数据量级推荐方案原因商品/地址短文本去重字面万级编辑距离、Jaccard短文本精准、速度快文章/新闻/评论查重词法百万级SimHash、TF-IDF余弦支持大规模指纹比对日志聚类、监控分组字面词法千万级Jaccard SimHash多级对计算性能要求苛刻搜索Query归一化语义十万级Word2Vec加权处理同一含义不同说法智能客服/FAQ匹配语义万级以内BERT句向量向量检索需要较高准确率推荐系统内容召回主题百万级TF-IDF / BERT向量召回平衡效果和性能这个表不是我拍脑袋写的每个场景我都踩过对应方案的调整。比如智能客服场景里如果知识库就几百条直接用BERT句子对分类效果最稳因为样本少意味着可以手工标注一批高质量数据微调一个专用模型。而日志聚类场景里文本量大、规律性强反而用Jaccard配合SimHash更实用因为日志文本重复度极高语义建模在这里是杀鸡用牛刀。5.2 我踩过的坑和调参心得第一个坑是直接在原始文本上算相似度不做任何预处理。中文文本如果不分词、不过滤停用词很多算法效果都会大打折扣。我做新闻查重时最初忽略了停用词结果“本报讯”“记者”这类高频词占据了太高的权重导致两篇完全不同来源的新闻因为都含这些词被判成相似。后来在预处理里加入一个标准中文停用词表并且针对新闻语料补齐了“记者”“编辑”“电”等专有噪声词问题才解决。第二个坑是阈值定得太“拍脑袋”。很多人直接把相似度阈值定成0.8、0.9但不同算法的分数分布完全不一样。编辑距离归一化后在0到1之间Jaccard在长文本里经常只有0.2、0.3BERT句向量的余弦相似度分布又完全不一样。我的经验是先抽取一批正负样本画出分数分布直方图找到正负样本分界明显的区间再定阈值。如果有条件最好在标注样本上用网格搜索选阈值而不是靠直觉。第三个坑是只用一个全局算法处理所有文本。长文本和短文本的相似度计算策略应该分开。我在做内容平台去重时先用机审判断文本长度长度在200字以下的走Jaccard快速过滤200字以上的走SimHash指纹比对这样既保证了短文本的召回又控制了长文本的性能。6. 常见问题与排查技巧实录6.1 为什么中文文本相似度总是不准中文文本相似度不准的原因八成出在分词和编码上。中文不像英文天然按空格分词“南京市长江大桥”这种歧义句子让任何分词器都会头疼。分词结果一旦错了后面的统计或者向量化都会跟着歪。排查思路是这样的先看分词结果是否合理。jieba分词虽然常用但它对专业领域词、品牌词、生僻词支持一般。如果文本里有大量领域词汇比如“强化学习”“翼支付”“碳酸氢钠”建议在分词器的用户词典里手动添加这些词效果提升非常明显。其次是检查是否做了简繁体统一、全半角转换这两项不做同一个字会在不同编码里被当成完全不同的字符相似度直接被拉低。我还有一个调试技巧不要只看相似度数字一定要把两个文本的公共词、差异词打印出来人工看一眼。很多时候数字不对但公共词列表一眼就能看出问题比如“苹果手机”和“苹果好吃”被某次错误分词切出了很多假公共词。6.2 数据规模扩大后性能怎么优化文本相似度计算很容易遇到性能瓶颈尤其是数据量涨到百万级以后。我的建议是分层优化不要指望一个算法打天下。第一步是数据裁剪。在做全量两两对比之前先通过length过滤、simhash分桶、倒排索引召回等手段缩小候选集。比如用SimHash把64位指纹分成4段只要两篇文本的指纹在任意一段完全相同才它们可能相似否则直接跳过。这个“抽屉原理”技巧能把百万级的全量对比压缩到只需比较几千个候选对。第二步是计算优化。如果是TF-IDF向量用稀疏矩阵做矩阵乘法比双重for循环快几个数量级。如果是编辑距离小文本可以上GPU批处理或者用NumPy向量化把多组距离计算一次算完。如果是BERT句向量离线批量推理比在线逐条推理高效得多一次喂几百条文本GPU吞吐率能顶上在线模式的好几倍。第三步是上缓存。如果线上请求频繁把常见的短文本相似度计算做成缓存同一条query直接命中缓存返回几乎零延迟。这套思路在客服系统里非常常见用户问题就那几千个常见问法大部分请求都可以靠缓存扛住。6.3 阈值怎么定才合理阈值定不好再好的算法也白搭。我曾经有个项目上下浮动0.05的阈值断案结果能差出几万条数据。我的标准流程是先取大约1000条正样本和1000条负样本计算每个样本的相似度分数然后把分数按0.05的间隔分桶观察每个桶里正负样本的分布。理想情况下正样本集中在高分桶负样本集中在低分桶中间的分界点就是理想的阈值区间。如果正负样本分布高度重叠那说明算法选型有问题再调阈值也没用。另外一个细节是不同长度区间的文本应该使用不同阈值。短文本信息量少相似度分数普遍偏高比如两个只有10个字的句子Jaccard很容易到0.8以上长文本则相反一篇1000字的文章和另一篇1000字的文章即便主题相同词集合重叠度也很有限。所以我会把文本按长度分箱每个箱独立定阈值这样整体准确率会明显提升。最后聊一个我自己的判断标准。如果两个算法的准确率差不多我永远选解释性更强、维护成本更低的那一个。文本相似度这种基础能力在业务系统里往往不是只跑一次而是要长期陪伴线上服务演进。一个能让运营同事也看懂的算法远比一个黑盒模型更容易落地、更容易排查问题。所以我自己在实际项目中往往是“Jaccard/编辑距离打底TF-IDF负责主要场景BERT做最后一道语义兜底”这套组合既稳定又给未来留了升级空间。
返回列表