☰
基于N-Gram的文本相似度算法:原理、实现与工程化实践
2026/10/7 11:59:57 网站建设 项目流程

做文本相似度计算的时候,N-Gram是一个绕不开的老牌算法。我在实际项目里接过不少“重复内容识别”“标题去重”“短文本匹配”之类的需求,试过一堆花哨的方案之后,反而经常回到N-Gram上。不是说它万能,而是它简单、稳定、不依赖重型依赖,很多场景下性价比极高。这篇文章就沿着我自己的实操路径,把基于N-Gram的文本相似度算法从原理到落地讲透,适合刚接触文本匹配的新手,也适合想快速选型的开发老手。

1. N-Gram算法的核心原理与设计思路

1.1 什么是N-Gram

N-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}大小是6,Jaccard就是2/6≈0.333。

Dice系数的公式是:

D(A, B) = 2 * |A ∩ B| / (|A| + |B|)

同样是上面的例子,交集大小是2,|A|和|B|都是4,Dice就是2*2/(4+4)=0.5。可以看到,Dice系数对重合部分更敏感,同样的重合度下算出来的数值比Jaccard偏高一些。有些业务场景里希望相似度阈值看起来更“友好”,就会选Dice。

余弦相似度的思路是把N-Gram集合映射成向量空间:先统计所有N-Gram单元,构造一个词频向量,然后计算向量夹角的余弦值。这种方式的好处是可以引入TF-IDF权重,让那些比较罕见的N-Gram单元在相似度计算中占有更高权重,而不是单纯数重合个数。代价是要构建一个全局词典,代码复杂度明显更高。

2.2 指标选型对比与我的倾向

我在项目里花了比较多时间对比这三种指标的效果。做一个简单归纳:

指标侧重计算复杂度适用场景我的评分
Jaccard集合重合占比低短文本去重、评论聚类4.5/5
Dice集合重合浓度低标题比对、近似检测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:i+n] for i in range(len(padded) - n + 1)]

这个函数里我选择保留句子原始顺序,返回一个列表而不是集合。为什么要加首尾标记?因为短文本在N较大时,可能切不出足够的N-Gram片段,导致集合太稀疏。比如一个三字词“苹果汁”在N=3时,如果按原始窗口切只有“苹果汁”一个单元,加了下划线后就有“苹”“苹果”“苹果汁”“果汁”“汁”五个单元,信息量完全不一样。这个技巧是用N-Gram时的关键细节。

3.2 核心相似度函数实现与参数解释

接下来是相似度计算。我用Jaccard为例,把上面的列表转换成集合,直接做交集并集运算:

def jaccard_similarity(text_a, text_b, n=2): 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。这个选择不是拍脑袋,而是经过多组实验对比的经验值。N=1时,所有单字符都参与比较,像“我喜”和“欢吃”这种完全无关的文本,因为单个字符重合很多,相似度也会偏高,区分度不够。N=3时,短文本匹配会过于苛刻,比如标题只有6个字时,三元组数量很少,交集往往很小,相似度普遍偏低。N=2处于一个比较平衡的位置,既保留局部字符顺序信息,又不会太稀疏。

3.3 实测案例:用真实中文文本跑一遍

我在本地用一个简单例子演示效果。我找了三条中文句子:

  • A:“苹果手机系统非常流畅”
  • B:“苹果手机系统很流畅”
  • C:“今天天气真不错”

用上面的函数算两两相似度,结果为:

A vs B: 0.636 A vs C: 0.090 B vs C: 0.100

A和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, n=2)) for g in grams: inverted_index.setdefault(g, []).append(text_id) def search_similar(query_text, index, n=2): 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(), key=lambda x: x[1], reverse=True)

倒排索引的精准讲法是把“文本对n-gram”拉成“n-gram对文本”的映射,算相似度时只访问与query有交集的文档,大大减少无效计算。这是N-Gram工程化里最重要的优化手段,没有之一。

4. 常见问题与排查技巧实录

4.1 N值选择导致的分歧

有一个我特别想指出的问题:N值过大导致短文本间完全无交集。比如两个只有4个字的标题,取N=4时每条文本只有一个N-Gram单元,稍微差一个字就判定为0相似度。这会让相似度分布极端化,不是0就是1,缺少中间梯度,几乎没法用。

解决办法就是前面提到的首尾填充标记。加了下划线之后,4字文本在N=4时也能产出多个单元,给相似度计算提供缓冲。但仍然建议短文本优先用N=2,而不是盲目上大N。我自己的判断标准是:文本平均长度小于等于10个字符时,用N=2;文本平均长度在10到30个字符时,可以用N=2或N=3做对比测试;长文本场景可以尝试N=3甚至N=4,因为文本足够长,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比较。我在一个标题归并项目里同时用了N=2和N=3两个粒度,最后把两个相似度做加权平均。经验是这个组合比单纯用N=2的召回率高不少,同时误报率也比N=4更低。原理很简单:N=2负责捕捉大体形状的相似,N=3负责捕捉细节片段的重合,两者互补。

更灵活的做法是为每条文本动态设定候选N值集合,分别计算N-Gram集合相似度,取最大值或加权平均值。这个思路在处理“有的文本特别短、有的特别长”的不均匀数据时非常有用。短文本主要靠N=2,长文本则可以参考N=3甚至N=4的结果,自动获得比较合理的度量。代价是计算量会上升,生产环境里需要权衡。

7.2 结合停用词与TF-IDF进行加权

纯粹集合重合度有一个天然的缺陷:高频字符片段对相似度的贡献共享过大。例如“的”“了”“是”这些高频字所在的N-Gram单元,在大量文本中都出现,即使两条文本在核心内容上完全不同,也可能因为都包含“的是”“的了”这类高频N-Gram单元,获得虚高的相似度。

解决办法是引入权重。统计语料中每个N-Gram单元出现的文档频率,频率越高权重越低——这正是TF-IDF的思想。然后相似度计算从“交集大小”改为“交集权重和除以总权重”。这种加权N-Gram相似度比普通版本更能体现核心语义,尤其适合用来做长文本语义去重。我用它做过一轮新闻聚类,效果显著比普通同AUC高不少。

权重型方案的一个不便之处是需要统计全量语料的文档频率,并且语料发生变化时文档频率也要更新。我在项目里是把文档频率表落库,定期离线更新,线上只做增量的近似更新。这样既能保证权重不过时,也不用每来一条新文本都重新统计全量。

7.3 线上环境的稳定策略

文本相似度服务上线后,要面对的是qps压力和文本格式的持续变化。我建议封一个独立的相似度计算服务,对外暴露一个简单的API:传入两条文本,返回相似度分值。内部可以灵活切换算法版本,对外完全透明。这样改动算法细节时,不需要上游业务感知,也不会影响已上线的其他模块。

负责服务稳定性时,别忘了给接口加上文本长度限制。超长文本会在构建N-Gram时消耗大量时间和内存,可能拖垮服务。我在生产环境设定单条文本上限1万字符,超出部分截断处理。再长的文本说明数据本身已经超出正常业务范围,直接截断或拒绝都比硬计算来得安全。

这个方向后续如果要做进一步扩展,我建议优先考虑接入增量式索引更新和多语言字符规范化,这两个点对真实业务场景的提升非常直接。我自己正在做的一个版本就是在N-Gram基础上叠加了字符规范化层,已经能让之前算法完全无法处理的全角半角混合文本,也稳定纳入相似度计算范围了。

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

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

立即咨询