
做文本相似度计算的时候N-Gram是一个绕不开的老牌算法。我在实际项目里接过不少“重复内容识别”“标题去重”“短文本匹配”之类的需求试过一堆花哨的方案之后反而经常回到N-Gram上。不是说它万能而是它简单、稳定、不依赖重型依赖很多场景下性价比极高。这篇文章就沿着我自己的实操路径把基于N-Gram的文本相似度算法从原理到落地讲透适合刚接触文本匹配的新手也适合想快速选型的开发老手。1. N-Gram算法的核心原理与设计思路1.1 什么是N-GramN-Gram是自然语言处理里非常基础的一个模型核心思想很朴素把一段文本按长度为N的窗口连续切割成子串序列。拿中文来说“我喜欢吃苹果”按字符切成二元组bigram就是“我喜”“喜欢”“欢吃”“吃苹”“苹果”这五个单元。如果按三元组trigram切就是“我喜欢”“喜欢喝”“欢吃苹”“吃苹果”。这里的“N”就是窗口大小。窗口越小切出来的单元越短匹配越松窗口越大切出来的单元越长能保留更多上下文但匹配条件也越苛刻。这个“粒度”直接影响后续相似度计算的结果是整套算法最核心的一个旋钮。我见过不少刚接触N-Gram的人会把它当成一个“分词工具”来理解其实不完全对。分词是把文本按语义切成语义单元而N-Gram是纯机械的滑动窗口切割不需要任何词典和语义知识。这个特性恰恰是它最大的优势在不知道文本语言结构的情况下照样能算相似度。中文、英文、数字混合文本不用专门适配直接按字符或者按词切就能跑。1.2 为什么文本相似度场景经常选N-Gram先说我踩过的坑。有一段时间我做电商评论去重接到的数据是几百万条用户评价里面充斥着“质量很好”“质量很不错”“质量真的太棒了”这类语义几乎一样但字面差异很大的文本。用精确匹配肯定不行用编辑距离在大数据量下又跑不动用词向量得先准备预训练模型在当时的业务条件下根本不现实。N-Gram在这里的优势在于它把文本相似度拆成了“局部子串重合度”的比较。两条文本只要在局部上有连续相同的片段就能捕捉到相似信号。比如“质量很好”和“质量很不错”字符级bigram分别是“质量”“量很”“很好”和“质量”“量很”“很不”“不错”“错”交集有“质量”和“量很”两个已经体现出一定的相似度。这就是N-Gram的“局部敏感”特征不怕词语换个说法只要局部片段有重合就有响应。另外N-Gram对错别字的容忍度也很有意思。比如“苹果手机”和“苹裹手机”字符级bigram交集有“手机”这一个片段至少能判断出二者有部分关联。如果按完整词匹配这两个文本可能完全判为不相似。这种容错特性在用户生成内容的场景里特别实用。1.3 字符级N-Gram与词语级N-Gram的选择逻辑这里有个关键分叉口是按“字符”切还是按“词语”切。按字符切的叫字符级N-Gram按词语切的叫词语级N-Gram。字符级的好处是零依赖不需要分词器直接遍历字符串就能构建N-Gram集合。坏处是中文场景下字符级N-Gram会丢掉部分词边界信息比如“武汉/市长/江大桥”和“武汉市/长江大桥”这种有歧义的分词结果字符级N-Gram实际上天然绕开了分词歧义但也会让相似度计算变得偏向“字面重合”而非“语义重合”。词语级N-Gram的好处是每个切片都更贴近语义单元比如“武汉市”“长江大桥”这样的词语组合直接比较词语序列的N-Gram相似度结果更容易和人类直觉对齐。坏处是必须先做分词分词器质量直接决定后续结果。如果分词错了后面的N-Gram再准也没用。而且分词本身有耗时在大数据量场景下会成为瓶颈。以我自己的经验规律大概是这样短文本、标题类、评论类这种噪声大的数据优先用字符级长文本、新闻文章、报告这种语言相对规范的优先用词语级。两种方案我都上线跑过没有哪一个绝对优于另一个关键看数据长什么样。2. 基于N-Gram的相似度计算方案与选型2.1 从N-Gram集合到相似度数值有了N-Gram集合之后怎么量化两条文本的相似程度核心思路是“集合重合度比较”。给每条文本生成一个N-Gram集合然后看这两个集合的重合程度有多高。最常用的三个指标是Jaccard相似度、Dice系数和余弦相似度。Jaccard相似度是两个集合交集大小除以并集大小公式是J(A, B) |A ∩ B| / |A ∪ B|这个值域在0到1之间0表示完全无重合1表示完全重合。假设文本A的bigram集合是{a,b,c,d}文本B的集合是{c,d,e,f}交集{c,d}大小是2并集{a,b,c,d,e,f}大小是6Jaccard就是2/6≈0.333。Dice系数的公式是D(A, B) 2 * |A ∩ B| / (|A| |B|)同样是上面的例子交集大小是2|A|和|B|都是4Dice就是2*2/(44)0.5。可以看到Dice系数对重合部分更敏感同样的重合度下算出来的数值比Jaccard偏高一些。有些业务场景里希望相似度阈值看起来更“友好”就会选Dice。余弦相似度的思路是把N-Gram集合映射成向量空间先统计所有N-Gram单元构造一个词频向量然后计算向量夹角的余弦值。这种方式的好处是可以引入TF-IDF权重让那些比较罕见的N-Gram单元在相似度计算中占有更高权重而不是单纯数重合个数。代价是要构建一个全局词典代码复杂度明显更高。2.2 指标选型对比与我的倾向我在项目里花了比较多时间对比这三种指标的效果。做一个简单归纳指标侧重计算复杂度适用场景我的评分Jaccard集合重合占比低短文本去重、评论聚类4.5/5Dice集合重合浓度低标题比对、近似检测4/5余弦TF-IDF加权重合程度中长文本、有区分度需求4/5为什么我给Jaccard评这么高因为它更“苛刻”容易误报的场景会被压得更低。做重复评论检测时我用Jaccard阈值0.5能明显过滤掉大量噪声而用Dice系数同样的阈值会把很多不太像的文本也圈进来阈值要重新调。当然这不算Dice的缺点而是各自分布特性不同调参时心里要有数。余弦加TF-IDF的方案强是强但涉及全局词典的构建和更新在增量数据场景下很麻烦。如果词典不更新新出现的高频N-Gram单元没有对应维度如果更新又要全量重算向量工程成本不小。所以我通常只在地实时场景用或者文本差异本身很大的场景才考虑。2.3 为什么N-Gram能避开分词难题中文NLP里永远绕不开分词。Python生态里好用的分词器不少但不管用哪个都会有边界错误。N-Gram的一个聪明之处在于它在很多场景下根本不需要分词。拿“武汉市长江大桥”这个经典的歧义句来说。分词可能会得到“武汉/市长/江大桥”也可能得到“武汉市/长江大桥”。如果基于分词结果做后续计算歧义会传导到相似度结果上。但用字符级N-Gram直接按字符滑动窗口切比如bigram得到“武汉”“汉市”“市长”“长江”“江大”“大桥”。这些片段不管分词的边界在哪里都已经被切出来了。之后做集合比较时歧义不再影响结果。这不是说N-Gram可以完全替代分词而是在“文本相似度”这个任务上字符级N-Gram已经足够把“局部文本重合”这个信号提取出来没必要多引入分词这层不确定性。这也是我向团队同学安利N-Gram时最常用的理由。3. 从零实现N-Gram文本相似度完整实操3.1 环境准备与基础代码结构实现N-Gram相似度用Python最顺手。不需要第三方库标准库足够应付核心逻辑。我的工程里一般按三个模块组织代码文本预处理模块处理空白字符、特殊符号、大小写转换N-Gram构建模块给定文本和N值输出N-Gram集合或序列相似度计算模块给定两个集合输出相似度数值预处理这一步容易被忽略但其实影响很大。拿大小写来说英文文本如果不统一转小写Apple和apple会被切成完全不同的N-Gram单元。特殊符号同理逗号和句号如果保留会让本应相似的两条文本带上无关噪声。我通常会把标点符号统一替换为空格多余空白压缩掉英文统一转小写。中文没有大小写问题但全角半角字符最好统一不然“好”和“好”中的全角空格也会产生不一样的特征。一个基础的N-Gram构建函数长这样def build_ngrams(text, n): # 清理多余空白并统一小写英文场景 cleaned .join(text.split()).lower() # 在首尾添加标记让短文本也能稳定产出N-Gram padded _ * (n - 1) cleaned _ * (n - 1) return [padded[i:in] for i in range(len(padded) - n 1)]这个函数里我选择保留句子原始顺序返回一个列表而不是集合。为什么要加首尾标记因为短文本在N较大时可能切不出足够的N-Gram片段导致集合太稀疏。比如一个三字词“苹果汁”在N3时如果按原始窗口切只有“苹果汁”一个单元加了下划线后就有“苹”“苹果”“苹果汁”“果汁”“汁”五个单元信息量完全不一样。这个技巧是用N-Gram时的关键细节。3.2 核心相似度函数实现与参数解释接下来是相似度计算。我用Jaccard为例把上面的列表转换成集合直接做交集并集运算def jaccard_similarity(text_a, text_b, n2): if not text_a or not text_b: return 0.0 gram_a set(build_ngrams(text_a, n)) gram_b set(build_ngrams(text_b, n)) if not gram_a or not gram_b: return 0.0 intersection len(gram_a gram_b) union len(gram_a | gram_b) return intersection / union注意到我用的是集合而不是列表。列表可以保留频次信息但Jaccard定义本身只关心存在与否不关心出现次数。如果要计算余弦相似度或使用TF-IDF权重则需要保留频次信息或使用Counter。两种场景务必要区分开。N值的默认参数设成2。这个选择不是拍脑袋而是经过多组实验对比的经验值。N1时所有单字符都参与比较像“我喜”和“欢吃”这种完全无关的文本因为单个字符重合很多相似度也会偏高区分度不够。N3时短文本匹配会过于苛刻比如标题只有6个字时三元组数量很少交集往往很小相似度普遍偏低。N2处于一个比较平衡的位置既保留局部字符顺序信息又不会太稀疏。3.3 实测案例用真实中文文本跑一遍我在本地用一个简单例子演示效果。我找了三条中文句子A“苹果手机系统非常流畅”B“苹果手机系统很流畅”C“今天天气真不错”用上面的函数算两两相似度结果为A vs B: 0.636 A vs C: 0.090 B vs C: 0.100A和B的相似度明显高于它们和C的相似度说明算法能把“意思差不多、字面略有差异”的文本判为相似同时也不会把无关文本误判为相似。这个例子中A和B的bigram集会包含“苹果”“果手”“手机”“机系”“系统”“统非”“非常”“常流”“流畅”等由于B只是把“非常”换成了“很”只有“很流”和“流畅”受影响其余片段全部重合所以相似度过半。多提一句实际业务里的阈值一般设在0.4到0.6之间。低于0.4的通常是弱相关的噪声高于0.6的往往是稳的重复或近似文本。具体阈值要看业务对“宁可误报还是宁可漏报”的容忍度误报代价低就调低阈值漏报代价低就调高。3.4 生产环境的优化方案把上述代码用在小数据量上没问题一旦数据规模上来就要考虑优化。我在百万级评论数据上跑过一轮直接两两比较完全不可行1万条数据两两比较就是近5000万次计算根本扛不住。这里聊一下我实操中用到的两个优化方向。第一个是**倒排索引Inverted Index**思路先遍历所有文本把每个N-Gram单元作为key包含该单元的文本ID列表作为value构建索引。然后处理新文本时只需找它包含的N-Gram单元对应的ID列表再做交集或累积打分。这样不用和全库比较只和“至少共享一个N-Gram单元”的候选文本比较能把计算量缩小几个数量级。第二个是归一化预处理。把文本统一转小写、去标点、按规则规范化后再构建索引能减少N-Gram单元的变体数量索引更紧凑候选集更干净。通过实践发现做了归一化之后候选文本数量大约降低20%到30%效果相当明显。伪代码思路大致是inverted_index {} for text_id, text in enumerate(all_texts): grams set(build_ngrams(text, n2)) for g in grams: inverted_index.setdefault(g, []).append(text_id) def search_similar(query_text, index, n2): grams set(build_ngrams(query_text, n)) # 统计每个候选文本与query的公共gram数量 candidate_score {} for g in grams: for text_id in index.get(g, []): candidate_score[text_id] candidate_score.get(text_id, 0) 1 # 按分数排序再精算相似度 return sorted(candidate_score.items(), keylambda x: x[1], reverseTrue)倒排索引的精准讲法是把“文本对n-gram”拉成“n-gram对文本”的映射算相似度时只访问与query有交集的文档大大减少无效计算。这是N-Gram工程化里最重要的优化手段没有之一。4. 常见问题与排查技巧实录4.1 N值选择导致的分歧有一个我特别想指出的问题N值过大导致短文本间完全无交集。比如两个只有4个字的标题取N4时每条文本只有一个N-Gram单元稍微差一个字就判定为0相似度。这会让相似度分布极端化不是0就是1缺少中间梯度几乎没法用。解决办法就是前面提到的首尾填充标记。加了下划线之后4字文本在N4时也能产出多个单元给相似度计算提供缓冲。但仍然建议短文本优先用N2而不是盲目上大N。我自己的判断标准是文本平均长度小于等于10个字符时用N2文本平均长度在10到30个字符时可以用N2或N3做对比测试长文本场景可以尝试N3甚至N4因为文本足够长N大一点不会出现集合太稀疏的问题。4.2 空文本与脏数据文本相似度计算最常见的崩溃点其实是空文本。如果库里存在空字符串build_ngrams函数会返回空列表转换成集合同样是空集交并比运算直接抛异常。我在函数里加了if not text的判空保护返回0.0。这个细节听着简单但第一次跑全量数据时我没处理程序在跑到几千条之后就崩了排查了半天才发现是几条空白评论导致。另一类脏数据是“看似不同实则相同”的文本比如“苹果手机”和“苹果 手机”中间多了空格。如果预处理不清理空格N-Gram单元会有差异。所以预处理阶段要统一做“将多个连续空白字符压缩成单个空格”的处理甚至直接删除空白字符。到底删还是压取决于业务里空格是否有语义。中文文本里空格基本没有意义删掉更省心。4.3 相似度阈值怎么定阈值是算法落地时绕不开的问题。我发现很多初学者会直接抄网上别人用的0.5或者0.6但不同数据分布的阈值完全不同。定阈值有一个通用方法抽一组已知标准答案样本人工标出哪些是重复、哪些不重复然后用算法遍历不同的阈值比如从0.3到0.7逐步增加0.05计算每个阈值下的准确率和召回率找到最合适的平衡点。我在电商短评场景里跑过一轮最终定在0.55附近但换到另一个长文本场景就变成了0.4更合适。没有一劳永逸的阈值这个操作步骤建议每次项目都做一遍。4.4 与编辑距离、SimHash的横向对比很多人在选文本相似度方案时会纠结N-Gram到底比编辑距离好还是比SimHash好。我自己的使用感受是它们不是同一种东西适用于不同场景。编辑距离Levenshtein适合短文本的精确近似匹配比如用户输入纠错、关键词模糊匹配。但它在长文本上的计算复杂度是O(m*n)稍长一点就卡顿。SimHash适合海量文本的去重能把文本降维成64位或128位指纹再用汉明距离比较相似度。但SimHash对“短文本”非常不友好因为文本太短时指纹的区分度不够。N-Gram介乎两者之间它不需要训练、不需要分词、实现成本极低在短中文本场景下表现稳定且能借助集合运算天然并行化。如果是处理标题、评论、描述这类长度在几十字以内的文本我更倾向于用N-Gram。如果是全量网页级别、单文本几千字的场景SimHash那边效率更高。如果只是做精确到“差几个字符”级别的匹配编辑距离更精准。选型时先看文本长度和语料规模再决定用哪条路线。5. 从算法到业务可以把N-Gram用在哪些地方5.1 重复内容检测与文章去重这个场景是我个人最常遇到的。很多内容平台需要做“疑似重复文章”的识别。两篇文章可能标题略微不同、首尾段落不同但中间有连续段落是完全相同的。用整篇文章的N-Gram集合算相似度就可以判断出是否存在大范围的重复片段。实操时我不建议对整篇文本直接构建一个巨大集合那样计算量和内存都吃不消。更好的做法是分段构建。比如把文章按段落切分每段算一个N-Gram指纹再两两段之间计算相似度命中超过一定数量就判定为疑似重复。这个方法比全文集合更精细也能定位到底是哪些段落重复了业务上报得更准。5.2 短文本聚类与评论聚合短文本聚类的典型场景是“把意思相近的评论归成一组”。N-Gram相似度适合作为聚类的距离度量。先两两计算相似度超过阈值的判定为“同一类”然后再把类内文本合并提炼。这个流程听起来直接但数据量一大就很容易卡在两两比较上。所以我一般配合上面的倒排索引先粗筛再精算能够显著提速。另外N-Gram相似度还可以作为聚类特征的补充维度。我参与过一个需求要把用户反馈按问题类型分组本来只靠关键词分类效果很差。后来把N-Gram相似度作为信号把和某个种子问题相似的文本自动归并到同一主题下整个归并过程才顺了起来。5.3 数据清洗中的“近似重复发现”数据清洗场景里经常要处理“重复采集”的数据。不同来源的同一篇内容可能带有不同的页眉页脚、版权声明、时间戳。这类噪声比较小整篇相似度很高用N-Gram很容易识别。我一般会把正文抽取后的文本统一做一次N-Gram去重比如将N取3再结合Jaccard阈值把同一来源的不同版式文本识别出来保留最完整的一份。顺带说一个我自己遇到的坑在清洗时有两条文本其实内容是同一篇报道但其中一条在开头插了一长串引导语后续内容完全一样。这时候用整篇文本的bigram相似度会被引导语干扰导致相似度偏低。解决方法是优先比较“头部/尾部连续N-Gram”或先提取正文主体再计算相似度。先定位公共子串再用N-Gram验证比单纯全篇比较更稳。6. 性能优化与工程化实战心得6.1 内存管理与批量计算的注意事项N-Gram的集合量级不小。一条100字的文本按bigram切分大约产生99个单元单条很少但百万条文本的N-Gram总存储量会来到亿级内存占用必须提前评估。我的经验是把“是否保留完整N-Gram序列”和“是否只保留不重复集合”区分开。如果只需要相似度计算直接用去重集合别保留完整序列如果后续要做更精细的分析比如定位重复片段才另行保留序列。如果数据是增量更新的还要考虑N-Gram索引的更新策略。全量重建简单但耗时不可控增量更新需要为每个文本ID维护增删标志删除旧文本时要移除其对应的N-Gram单元记录。这块往往比算法本身更磨人。遇到这种需求我习惯先用全量重建跑通流程再逐步替换为增量更新避免一上来就把复杂度拉满。6.2 并行计算与哈希加速N-Gram相似度的计算天然适合并行化。每条文本的N-Gram构建互不依赖可以多线程或多进程并行。最直接的方法是用Python的multiprocessing.Pool.map把批量文本分片每个进程独立处理后合并结果。我在8核机器上跑速度大约能提升5倍左右瓶颈从CPU转换成了内存带宽和磁盘IO。另一个加速技巧是把N-Gram单元哈希成整数再存储。比如为每个N-Gram单元分配一个64位哈希ID用整数集合替换字符串集合不仅内存占用大幅下降交并运算的速度也会快很多。实践中这个优化能让计算耗时缩短约30%。如果文本量再大甚至可以考虑用Redis里的Set做分布式集合运算把计算分散到多台机器上。6.3 工程中最容易忽略的三个细节给刚接触这块的朋友提三个我亲测很痛的点。第一全角半角不统一。全角字母和中文字符混排很常见如果不统一转换同一个字符会以两种编码形态出现在N-Gram集合中导致本应重合的两个片段无法匹配。第二换行符和制表符。很多从数据库导出的文本会夹杂\r\n和\t如果预处理时只清理空格不管换行就会生成大量带特殊符号的N-Gram单元拖慢索引构建速度。第三N-Gram集合顺序问题。如果使用集合而非列表会丢失N-Gram在原文中的先后顺序。大部分相似度计算场景没问题但如果你还希望了解“文本中哪些位置重复了”就不能只用集合要保留带位置的N-Gram序列。所以编码前想清楚下游到底要什么不然返工很麻烦。7. 把N值选择和数据预处理做到位的进阶建议7.1 动态N值从小到大多尺度比较进阶一点的玩法是不要拘泥于单一N值而是同时使用多个N值做多尺度N-Gram比较。我在一个标题归并项目里同时用了N2和N3两个粒度最后把两个相似度做加权平均。经验是这个组合比单纯用N2的召回率高不少同时误报率也比N4更低。原理很简单N2负责捕捉大体形状的相似N3负责捕捉细节片段的重合两者互补。更灵活的做法是为每条文本动态设定候选N值集合分别计算N-Gram集合相似度取最大值或加权平均值。这个思路在处理“有的文本特别短、有的特别长”的不均匀数据时非常有用。短文本主要靠N2长文本则可以参考N3甚至N4的结果自动获得比较合理的度量。代价是计算量会上升生产环境里需要权衡。7.2 结合停用词与TF-IDF进行加权纯粹集合重合度有一个天然的缺陷高频字符片段对相似度的贡献共享过大。例如“的”“了”“是”这些高频字所在的N-Gram单元在大量文本中都出现即使两条文本在核心内容上完全不同也可能因为都包含“的是”“的了”这类高频N-Gram单元获得虚高的相似度。解决办法是引入权重。统计语料中每个N-Gram单元出现的文档频率频率越高权重越低——这正是TF-IDF的思想。然后相似度计算从“交集大小”改为“交集权重和除以总权重”。这种加权N-Gram相似度比普通版本更能体现核心语义尤其适合用来做长文本语义去重。我用它做过一轮新闻聚类效果显著比普通同AUC高不少。权重型方案的一个不便之处是需要统计全量语料的文档频率并且语料发生变化时文档频率也要更新。我在项目里是把文档频率表落库定期离线更新线上只做增量的近似更新。这样既能保证权重不过时也不用每来一条新文本都重新统计全量。7.3 线上环境的稳定策略文本相似度服务上线后要面对的是qps压力和文本格式的持续变化。我建议封一个独立的相似度计算服务对外暴露一个简单的API传入两条文本返回相似度分值。内部可以灵活切换算法版本对外完全透明。这样改动算法细节时不需要上游业务感知也不会影响已上线的其他模块。负责服务稳定性时别忘了给接口加上文本长度限制。超长文本会在构建N-Gram时消耗大量时间和内存可能拖垮服务。我在生产环境设定单条文本上限1万字符超出部分截断处理。再长的文本说明数据本身已经超出正常业务范围直接截断或拒绝都比硬计算来得安全。这个方向后续如果要做进一步扩展我建议优先考虑接入增量式索引更新和多语言字符规范化这两个点对真实业务场景的提升非常直接。我自己正在做的一个版本就是在N-Gram基础上叠加了字符规范化层已经能让之前算法完全无法处理的全角半角混合文本也稳定纳入相似度计算范围了。