ARTICLE DETAIL

资讯详情

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

从编辑距离到语义向量:文本相似度算法全解析

从编辑距离到语义向量:文本相似度算法全解析 文本相似度计算是很多人走上算法工程这条路之后第一个要面对的“现实问题”。搜索去重、知识库问答、客服工单聚类、文章查重甚至代码抄袭检测全都在问同一个问题有没有一种算法能把两段文字的相似程度量化。问题本身不难理解难的是你换一个场景答案就完全不同。有人直接用编辑距离处理长文本跑到怀疑人生有人一上来就上大模型结果线上延迟扛不住。这两类我都见过。所以我更想按工程落地视角把常用的文本相似度算法拆开讲清楚。会聊原理但更会聊选型思路、计算代价和真实项目里最容易踩的坑。无论你接下来要做文本去重、检索召回还是给后续的定向优化打底这份梳理应该都能帮你少走不少弯路。1. 先摸清需求文本相似度到底在解决什么问题很多人一上来就找“最好用的相似度算法”这是个误区。文本相似度不是一道单选题而是先要判断业务里那个“像”到底是什么含义。你需要的是“字面像”还是“意思像”是“查询词和文档的匹配程度”还是“两篇长文章是否近似重复”这些需求指向的算法完全不同。1.1 从业务场景拆解“相似”的含义先看几个最常见的场景。第一个是文本去重。比如新闻网站要过滤重复稿件你关心的是两篇正文“字面重合率”高不高哪怕其中一篇改了标题、换了几个连接词还是应该被抓出来。这个场景下字符重叠和词频重叠是核心线索SimHash、Jaccard这类算法就很有优势。第二个是搜索召回。用户在搜索框输入一句话系统要从候选库里找出最相关的文档。这时候“相似”其实是“相关性”重点不是整体像不像而是query里的关键词有没有在文档中被充分体现。BM25、TF-IDF加余弦相似度这一系算法更合适。第三个是智能客服和知识库问答。用户问“为什么登录失败”库里存的标准问句是“登录不上去怎么办”。字面上几乎不重叠但业务上就是同一个问题。这种场景已经不是“字面相似”能解决的了需要语义相似度通常要靠嵌入向量模型。第四个是代码查重或短答案判分。这种场景偏爱字符级算法比如编辑距离或者n-gram集合因为要精确到某个字符或者某个词的变化不能接受“差不多就行”。你看需求一换算法跟着换。所以我的习惯是拿到项目先问三个问题文本多长数量多大期望的“相似”是字面还是语义这三个答案基本就能圈定候选算法范围。1.2 算法能力的三个层次字符层、统计层、语义层如果把文本相似度算法做一个体系化梳理我会分成三个层次。字符层是最朴素的层次直接把文本当成字符串或者字符集合来比较。编辑距离、最长公共子串、Jaccard相似度都属于这一层。它们的好处是不需要任何模型和训练数据计算逻辑一眼能看懂坏处是只能处理字面重合遇到同义词替换、语序调换就无能为力。统计层把文本变成向量或者统计特征比较的对象从字符上升到词、词频和文档频率。TF-IDF、BM25、LDA主题模型都可以归到这一层。它们能处理一部分“关键词不同但分布相似”的情况比字符层鲁棒得多但依然不理解语义。语义层用深度模型把文本编码成稠密向量让“苹果好吃”和“这种水果口感不错”这样的句子在向量空间里距离更近。Sentence-BERT、各种中文句向量模型都属于这一层。效果最好但代价是需要模型推理线上成本明显高于前两层。这三层不是替代关系而是互补关系。我见过不少成熟的线上系统流程是“BM25做召回粗排语义模型做精排编辑距离做兜底校验”。理解了这个层次再往下看具体算法整个地图就很清晰了。2. 字符级算法编辑距离与Jaccard怎么用字符级算法听起来简单但它是很多场景里最实用、最不容易出错的方案。尤其是短文本、有限候选集、对速度有硬要求的场景字符级算法几乎没有对手。这两年里我依然会在不少项目里放一个编辑距离函数当保险丝。2.1 Levenshtein编辑距离的实现逻辑Levenshtein编辑距离也叫最小编辑距离定义非常直白把字符串A变成字符串B最少需要多少次插入、删除、替换操作。比如“kitten”变“sitting”要把k替换成s把e替换成i最后加一个g所以距离是3。这个定义用来做拼写纠错、OCR结果校验、短答案判分都特别好用。实现上几乎清一色用动态规划。定义dp[i][j]表示A的前i个字符变成B的前j个字符需要的最小步数def levenshtein(a, b): 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): if a[i - 1] b[j - 1]: dp[i][j] dp[i - 1][j - 1] else: dp[i][j] 1 min( dp[i - 1][j], # 删除a[i-1] dp[i][j - 1], # 插入b[j-1] dp[i - 1][j - 1] # 替换a[i-1]为b[j-1] ) return dp[m][n]初始化那两行很关键空串变成任意字符串距离当然等于目标串长度。递推的时候如果当前字符相等就不用额外操作如果不相等就取删除、插入、替换三者的最小值加1。空间复杂度是O(m*n)如果两个字符串长度都上千速度会明显吃力。实际使用中我们会把编辑距离换算成相似度公式是1 - 编辑距离 / max(len(a), len(b))。这个值能比较直觉地表达“两段文本有多接近”。比如“自然语言处理”和“自然语言解决”编辑距离是2最长长度是7相似度约0.714。2.2 Jaccard相似度与n-gram切分Jaccard相似度是另一个从集合论里借来的算法。它的定义是两个集合的交集大小除以并集大小。但文本不是天然集合你得先把文本切碎。如果按词切中文还得先分词如果按字符n-gram切就简单得多。n-gram其实就是连续n个字符组成的滑动窗口。对“自然语言处理”取二元字符n-gram会得到“自然”“然语”“语言”“言处”“处理”这5个元素。两个文本的Jaccard相似度就是它们n-gram集合的交集除以并集。比如“我喜欢学习”和“我喜欢研究”二元n-gram分别是“我喜”“喜欢”“欢学”“学习”和“我喜”“喜欢”“欢研”“研究”交集是“我喜”“喜欢”并集是6个相似度约0.333。虽然不是一个惊艳的数字但区分度已经出来了。如果文档数量很大比如几十万篇文章要做近似去重两两算集合交集肯定扛不住。工程上通常会搭配MinHash和LSH。MinHash能估算两个集合的Jaccard相似度不用真的比较集合LSH把哈希结果分桶只对可能相似的文本做细算。这一套组合拳在爬虫去重、文章鉴定场景里非常经典。2.3 字符级算法的适用边界字符级算法最大的优点是完全不需要训练数据和模型跑起来飞快逻辑也好解释。你给业务方看一个编辑距离数字对方能秒懂你给业务方解释BERT向量可能要讲半小时。但字符级算法的天花板也很明显它只认字面。同义词替换、语序调整、口语化表达都会让相似度暴跌。“小明生病了”和“小明身体不舒服”用编辑距离算相似度可能只有0.2左右但人一眼就知道是同一个意思。所以字符级算法适合的场景是拼写纠错、代码查重、标准化短答案判分、OCR结果比对以及作为更深层算法的“兜底校验”。我的经验是不要指望一个字符级算法解决复杂业务问题但它作为量化兜底手段特别可靠。比如语义模型再怎么飘最终是否判定为同一个人可以再用编辑距离做一次字面校验防止模型把两个完全不同的句子拉到一块。3. 向量空间算法TF-IDF与余弦相似度早期做文本相似度最主流的方法就是TF-IDF加余弦相似度。现在虽然语义模型很火但TF-IDF这套组合在小规模项目、数据量小、需要快速上线、结果可解释的场景里依然是首选。3.1 TF-IDF为什么比纯词频计数靠谱先说TF词频。一个词在文档里出现次数越多对这篇文档越重要。但光看词频不行因为中文文本里大量出现“的”“了”“是”这些功能词它们到处都是对区分文档毫无帮助。这时候就要引IDF逆文档频率。IDF的思想是如果一个词在很多文档里都出现那它的区分度就很低权重应该被压低。反过来如果一个词只在少数文档中出现那这个词对文档的辨识度就很高权重应该被放大。TF-IDF就是这两个数的乘积TF-IDF TF * IDF IDF log((N 1) / (df 1)) 1其中N是总文档数df是包含该词的文档数。加1是为了避免df为0时出现除零再加1是让IDF有下限不至于让高频词权重变成负数。这个平滑设计在sklearn的TfidfVectorizer里已经内置但理解它有助于后面调参。举个例子。“贷款”这个词在金融行业文档里大量出现IDF就不会太高“狙击枪”如果只在少数武器类文档里出现IDF会很高。这样构造出来的向量每一维的数值都代表“这个词对这篇文章的独特程度”比纯词频向量科学得多。3.2 余弦相似度的计算步骤与细节有了向量下一步就是算相似度。文本相似度领域最常用的度量是余弦相似度cos(A, B) (A·B) / (|A| × |B|)为什么用余弦而不是欧氏距离因为余弦看的是向量方向也就是“词的分布比例像不像”不看向量长度。两个文本长度差异很大时词频向量长度会差很多欧氏距离会简单地把它们判为不相似但余弦相似度能去掉长度影响只比较比例的相似性。在文本场景里我们通常希望“一篇1000字的文章和它某几个段落的摘要”被识别为相关余弦显然更合适。用Python实现也非常快from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity corpus [ 自然语言处理是人工智能的重要方向, 自然语言处理技术在智能客服中应用广泛, 今天天气很好适合出门跑步 ] vectorizer TfidfVectorizer(token_patternr(?u)\b\w\b) tfidf vectorizer.fit_transform(corpus) sim_matrix cosine_similarity(tfidf) print(sim_matrix)这里要注意上面的例子没有做中文分词只是按空格和词边界切分。真正的中文场景一定要先分词否则“自然语言处理”会被当成一个整词召回会受很大影响。分词之后再用TfidfVectorizer效果完全不一样。3.3 中文场景工程要点分词、停用词与聚类中文场景用TF-IDF加余弦有三件事必须做好否则相似度结果基本没法用。第一是分词。中文没有天然空格必须靠分词工具比如jieba、HanLP或者IKAnalyzer。分词的准确性直接影响后续所有计算。我遇到过一个项目把“南京市长江大桥”切成“南京市长/江大桥”整个相似度系统的bad case多到没法看。后来换了自定义词典把专有名词维护进去效果立刻反转。第二是停用词过滤。“的”“了”“吗”这类的词几乎出现在所有文本里不滤掉会稀释真正有区分度的词。但如果你的业务里“不算”这种否定词很重要就要谨慎不要无脑滤。第三是阈值和聚类策略。相似度算出来只是一个0到1之间的数关键是你怎么用。做文章去重一般设定一个阈值比如0.85以上判定重复做聚类则要把相似度矩阵喂给聚类算法。我记得以前做新闻聚类单纯用TF-IDF余弦相似度配合层次聚类效果已经能覆盖大部分场景速度也不差。这套传统方案最大的坑在于它本质上是词袋模型丢失了词序。“我打你”和“你打我”的词频完全一样TF-IDF向量也几乎一样余弦相似度接近1但意思完全不同。这是词袋模型的天然缺陷只能靠更高层的方法去修正。4. 工程高频选择SimHash与BM25如果说前两类算法偏学术和入门那SimHash和BM25就是工程系统里出现频率极高的两个算法。一个主攻海量去重一个主攻相关性排序。它们之间没有谁替代谁的关系反而经常同时出现在同一个系统里。4.1 SimHash的海量去重原理SimHash是Google用来做网页去重的一个经典算法核心目标是在海量文本里快速找到近似重复的文档而不需要两两比较。它的流程分五步。第一步把文本分词每个词计算一个64位哈希值通常用MD5截取即第二步给每个词分配权重一般用TF-IDF第三步对每个词的64位哈希逐位处理如果某一位是1就给累积值加权重如果是0就减权重第四步把所有词的加权结果逐位累加得到一个64维的整数数组第五步对数组逐位判定正数设为1负数设为0最终生成一个64位指纹。两个指纹之间的差异用Hamming距离衡量也就是二进制位上不同的个数。通常来说64位情况下Hamming距离小于等于3就算近似重复超过3则认为是不同内容。SimHash对网页正文这种长文本效果非常好因为长文本包含大量的词随机哈希的抖动会被摊薄指纹稳定。但对短文本比如一两句话SimHash的指纹会产生明显抖动可能稍微改几个字Hamming距离就跳到十几误判率很高。所以短文本场景不要硬套SimHash。4.2 BM25的排序逻辑与参数BM25是Elasticsearch家族里的扛把子本质上是搜索引擎里“给定一个query帮你在候选文档里按相关性打分”的算法。你搜一个句子希望系统把最相关的文档排前面这个打分逻辑可以近似理解成一种“查询文本和文档文本的相似度”。BM25的简化公式如下score(D, Q) Σ IDF(qi) × [ f(qi, D) × (k1 1) / (f(qi, D) k1 × (1 - b b × |D| / avgdl)) ]公式看起来吓人其实拆开就三点。第一IDF部分词在全库越稀有权重越高这和TF-IDF一致。第二词频饱和部分一个词在文档里出现次数越多贡献越大但到一定程度后增幅变缓避免某个词出现一二十次就把文档顶到天上。第三文档长度归一化文档越短命中关键词得到的加成越多因为短文档里出现关键词通常更“精准”文档越长命中相同关键词的信息量越低。k1默认1.2控制词频饱和的斜率b默认0.75控制长度归一化的强度。实际项目里这两个参数值得调尤其在长文档和短文档并存的数据集上。比如在电商场景商品标题都很短b的影响可能不大但在混合了长文章和短摘要的数据库里b调一调排序质量会有肉眼可见的区别。4.3 SimHash和BM25怎么选我经常被问到SimHash和BM25哪个更好这就像问螺丝刀和锤子哪个更好一样。它们解决的问题不一样。SimHash解决的是“两篇文档是不是近似重复”属于去重工具产出是文档指纹。BM25解决的是“用户输入的query和候选文档有多大相关性”属于检索排序工具产出是分数序列。如果你的目标是“找出库里所有跟某篇文章高度重复的内容”用SimHash做指纹分桶如果你的目标是“用户给一段话我要从库里找出最像的十段文本”用BM25显然更直接。当然两者可以组合。实际系统里经常先用BM25做粗召回从几十万篇文档里捞出前500个候选再用语义模型精排。SimHash则可以在第二层做过滤把近似重复的结果合并掉避免排序结果里出现一堆换汤不换药的重复页面。5. 语义相似度嵌入向量成为新常态近几年做文本相似度越来越多人直接上嵌入向量方案。原因也很简单以前那些字面匹配方法在面对“换个说法意思还是同一个”的情况时确实显得力不从心。语义相似度把文本编码成向量在向量空间里度量距离很多老问题迎刃而解。5.1 同义改写是传统算法的天花板传统算法处理不了同义改写。比如文本A“这部电影非常好看” 文本B“这部片子挺棒的”用TF-IDF加余弦相似度两者的公共词只有“这部”和“片/电/影”这些弱特征算出来的相似度可能不到0.3。用编辑距离更惨字符级差异巨大。但人眼一看就知道是同一个意思。这种问题在客服、搜索、社区内容审核场景里尤其突出。用户表达习惯五花八门标准问句和实际说法之间经常没有字面重合。如果系统只能识别字面相似就会漏掉大量真实意图。这就是语义相似度模型存在的价值把“意思相近”量化成向量空间里的近距离。5.2 用句向量模型把文本编码到空间语义相似度的主流做法是用预训练语言模型把文本编码成固定长度的稠密向量。过去用Word2Vec把词向量加权平均也能得到句子向量但这种方式丢失了词序效果有限。现在更常用Sentence-BERT这类句子嵌入模型训练时强制让相似句子对的向量距离更近不相似句子的向量距离更远。使用层面其实很简单from sentence_transformers import SentenceTransformer model SentenceTransformer(paraphrase-multilingual-MiniLM-L12-v2) vec_a model.encode(这部电影非常好看) vec_b model.encode(这部片子挺棒的) cos_sim (vec_a vec_b) / (norm(vec_a) * norm(vec_b))输出向量通常是384维、768维或者1024维。这些向量不仅能算余弦相似度还能喂给聚类、分类、向量检索系统。我实际测试过通用多语言句向量模型在中英文混合场景表现不错但在垂直领域比如法律文书、医疗病历、代码注释里还是建议用对应领域数据微调过的模型。5.3 落地语义检索的完整链路语义相似度上线通常会搭一条离线编码加在线检索的链路。离线阶段把全部候选文档用模型编码成向量存进向量数据库。轻量方案可以用FAISS建索引重一点的方案可以用Milvus、Weaviate这类专业向量数据库。在线阶段用户query进来后同样编码然后通过ANN近似最近邻检索拿到Top K的候选向量再按相似度阈值过滤最后返回正文。这个链路里有一个特别容易被忽略的点向量归一化。如果向量没有做L2归一化余弦相似度和内积是有差别的排序结果也会不一样。我的习惯是编码完成后统一做L2归一化然后再存库和做内积检索这样既能保证结果等价于余弦相似度又能加速推理。但这套方案也不是银弹。通用句向量模型有时候会把“阳性”和“阴性”这种反义词搞到距离很近因为它们在语境里有强相关性。垂直领域直接部署通用模型bad case往往会达到一个比较高的比例。所以在预算允许的情况下用自己的业务数据微调一下模型效果提升通常非常明显。6. 常见问题与选型排查实录算法选对之后真正磨人的是排查效果问题。我在项目里反复遇到过相似度计算结果和预期不符的情况整理下来其实有规律可循。下面这些经验是我踩过不少坑之后攒出来的。6.1 六个高频问题和排查思路这里整理一个速查表基本覆盖了我见过的相似度项目里最常见的坑问题现象 可能原因 解决方向 编辑距离处理长文本耗时数秒 动态规划复杂度O(m*n) 改用TF-IDF/SimHash/近似算法 TF-IDF余弦相似度对同义改写几乎为0 词袋模型只认字面 加同义词扩展或语义模型 SimHash在短文本上误判率很高 64位哈希对短文本抖动大 短文本改用Jaccard或编辑距离 BM25排序结果与直觉不符 k1、b参数未调或分词不合适 调参并检查分词配置 语义向量把无关文本也拉到很近 通用模型不懂业务语境 用业务数据微调或换专用模型 两个文档分词后完全没有公共词 预处理或词典问题 先修分词、停用词再谈算法这些问题的共性是算法本身没错错在场景匹配度或者数据前处理上。多数时候排查半天最后发现不是相似度算法的问题而是分词词典缺词、停用词误删、阈值拍脑袋这些基础问题。6.2 相似度阈值怎么定阈值切得好不好直接决定一个相似度系统能不能用。很多项目上线后效果差不是算法不行而是阈值定得太随意。我一般按这个流程定阈值。第一步收集一批业务真实case人工打标为“应该相似”和“不应该相似”两类数量不用多两三百条就够。第二步用当前算法给每个case算出相似度分数把两条分布画出来看。如果两类case分布重叠区很小说明这个算法在该场景下可分性强如果重叠很大说明要么算法不合适要么特征没做好。第三步在分布重叠最小的位置附近选一个阈值初值然后用测试集微调选出F1最高的阈值。一个容易踩的坑是阈值只能固定在一个区间。有些项目今天觉得0.7合适明天bad case变多就改成0.8后天又发现漏判改回0.65。这不是调参是在打地鼠。正确的做法是先修数据再调阈值。bad case要落到数据和特征层面去定位不能只靠频繁微调阈值强行找平衡。6.3 场景选型速查表最后给一张选型速查表。这是我个人项目里反复验证过的一套组合不一定适合所有情况但至少能作为大家决策的起点使用场景 推荐方案 原因 拼写纠错/短串精确匹配 Levenshtein编辑距离 精确到字符级可解释 短文本去重/关键词匹配 Jaccard n-gram切分 简单快速不依赖分词 海量网页正文去重 SimHash Hamming距离 指纹化区间检索效率高 搜索召回/相关性排序 BM25 考虑词频饱和与文档长度 小规模文章相似度聚类 TF-IDF 余弦相似度 无需训练结果稳定 同义改写/智能问答/语义检索 句向量模型 向量检索 能处理语义等价效果上限高最后说点个人体会。我这些年折腾相似度方案最大的一个感受是文本相似度算法真正的门槛不在算法本身而在搞清楚业务到底需要哪一种“像”。字面像和语义像是两种完全不同的目标一开始没想清楚后面就会反复返工。我自己的习惯是项目刚开始先拿编辑距离和TF-IDF这种最朴素的方案搭一版基线把bad case收集起来看数据分布再决定要不要引入语义模型。很多项目跑到最后你会发现一套BM25加编辑距离的兜底组合照样能支撑起一个不错的线上效果而有些场景哪怕上了大模型也需要先把训练数据和质量评估体系建立起来。手里多握几种算法心里才有底。
返回列表