深入理解N-Gram:从数学原理到搜索纠错与文本分类实战
1. 关于 ngram,别再只停留在"n 个词"的表面理解
你可能早就听说过 ngram,在很多 NLP 入门教程里它都是第一章就出现的基础概念,但说实话,大部分资料讲得太浅了。有的说"ngram 就是连续的 n 个词",有的给个ngram_range=(1, 3)就完事了,搞得很多人以为这是个很 trivial 的东西。其实恰恰相反,ngram 是很多实用系统的底层支撑,你手机上输入法的联想候选、搜索引擎的纠错提示、垃圾短信识别、甚至推荐系统里的 Item 相似度计算,背后都站着 ngram。
我最早接触 ngram 是在做搜索日志分析的时候。当时要解决一个问题:用户在搜索框里输错了一个词(比如把"蓝牙耳机"打成"蓝呀耳机"),怎么自动识别并纠正?后来才发现,在这个场景里,字符级别的 bigram 和 trigram 组合起来,能做非常漂亮的特征工程,配合简单的统计模型就能达到让人惊喜的效果。
所以我决定把这篇文章写得实在一点。这里不是科普 ngram 的教科书定义,而是从"这玩意儿到底能干什么"以及"怎么用好它"两个角度展开。想入门的读者,看完能建立清晰的心智模型;实际在做文本处理、搜索、推荐相关工作的从业者,可以直接拿后面实操部分的方案去用。
适合谁看?两类人。一类是刚接触 NLP 或者数据挖掘,想理解 ngram 在真实系统里价值的学习者;另一类是已经在处理文本数据,但遇到的数据稀疏、OOV(Out of Vocabulary,词表外新词)、特征爆炸等问题,想找一套可落地的思路的工程师。
2. ngram 的数学本质:别只看"连续 n 个词",要看联合概率
2.1 表层定义与深层模型
先过一遍最基础的定义。ngram 就是一段文本中连续出现的 n 个项。这里的"项"可以是字、词、拼音、字符、甚至二进制字节,取决于你所在的场景。按项的类型分,有字符级 ngram 和词级 ngram;按 n 的大小分,有 unigram(1-gram)、bigram(2-gram)、trigram(3-gram)以及更高阶的 4-gram、5-gram 等。
但是这只是表象。ngram 真正的价值在于它隐含着一种概率建模思想:一个长度为 m 的文本序列,我们可以用条件概率相乘来估计它出现的概率:
P(w1, w2, ..., wm) = P(w1) × P(w2|w1) × P(w3|w1,w2) × ... × P(wm|w1,...,w(m-1))
问题是,当 m 很大时,最后一个条件概率的历史条件长度太长,现实中根本不可能统计到那么多组合(这就是维度灾难最直白的体现)。这时 ngram 登场了:它做了一个 k 阶马尔可夫假设——当前位置的项只与前 n-1 个项相关,再往前就"无记忆"了。
于是公式变成:
P(w1, w2, ..., wm) ≈ P(w1) × P(w2|w1) × P(w3|w1,w2) × ... × P(wm|w(m-n+1),...,w(m-1))
用大白话说:预测下一个词时,不用看整个句子,只需要看前面的 n-1 个词就够了。这就是 ngram 的数学灵魂——它是语言模型的最简形式,也是 n-gram 语言模型的雏形。
2.2 用一个例子说清楚 unigram、bigram 和 trigram 的区别
我拿一句很口语的话来拆解:"我昨天去超市买了苹果"。
- unigram 把这句拆成:我 / 昨天 / 去 / 超市 / 买了 / 苹果,共 6 个独立的词。它丢失了所有相邻关系,相当于把句子当成一个词袋(bag of words)。在文本分类里做词频统计时够用,但词序信息完全没了。
- bigram 拆成:我昨天 / 昨天去 / 去超市 / 超市买了 / 买了苹果,共 5 个连续对。每个对都是一个独立的特征,强行保留了相邻两个词的共现关系。请注意,bigram 在计算上等于 P(昨天|我) × P(去|昨天) × P(超市|去) × ...
- trigram 拆成:我昨天去 / 昨天去超市 / 去超市买了 / 超市买了苹果,共 4 个三连串。保留了三词窗口内的信息,对词序的约束更强。
这个例子里你会发现,从 unigram 到 trigram,特征数量在减少但单个特征的信息含量在增加。这是一个关键的 trade-off:特征粒度越粗,表示能力越强,但对应的数据稀疏问题也越严重。
之前做一个评论情感分类的实验,只用 unigram 时的准确率在 0.83 左右,加入 bigram 后提升到 0.88,再加入 trigram 只提升了 0.005。这说明什么问题?在大多数字典足够大的语料上,bigram 的收益最大,trigram 开始触碰数据稀疏的天花板,四元以上如果不是超大规模语料,基本上是在自娱自乐。
3. n 的取值到底怎么定:一套拿来即用的选择心智模型
3.1 各阶 ngram 的能力边界和典型场景
很多人一上来就问"n 应该取多少?"我的回答是:先搞清楚你要抓什么规律。
n=1(unigram)抓的是词频分布,适合做主题识别、停用词过滤、关键词抽取。它的毛病是分不清"苹果好吃"和"好吃苹果"的差别。
n=2(bigram)抓的是相邻共现关系,能反映一部分词序。搜索场景下做候选召回非常高效,因为搜索引擎不需要理解整句话意图,它只需要依赖"用户输入的词和文档中的词大概率会相邻出现"这个规律。
n=3(trigram)抓的是三词窗口内的短距离依赖关系。输入法的拼音转汉字、语音识别的语言模型重打分,基本都是 trigram 起步。为什么?因为中文常用词的第三阶上下文信息还是比较充分的。
n=4、5(4-gram、5-gram)这些高阶组合只在两个地方有实用价值:一是超大规模语料下做短语识别(Google 的 Ngram Viewer 就是典型代表),二是代码碎片检测、文本去重这种"长串匹配"任务。日常业务中如果你不是有亿万级语料,不建议碰。
不同阶 ngram 的定位差异,我用一个对比表来说明:
| n 值 | 捕获的信息 | 典型应用 | 数据量需求 | 稀疏风险 |
|---|---|---|---|---|
| 1 | 词频、主题分布 | 关键词抽取、文本分类 baseline | 低 | 极低 |
| 2 | 相邻共现、局部词序 | 搜索召回、纠错候选、文本特征 | 中 | 低 |
| 3 | 三词短依赖 | 输入法、语音模型、情感分类增强 | 较高 | 中 |
| 4+ | 长串固定搭配 | 短语识别、文本指纹去重 | 极高 | 高 |
3.2 参数选择的三条经验法则
第一条:分类和匹配任务里,优先用(1, 2)组合,不够再加 3。用 sklearn 的CountVectorizer时可以设置ngram_range=(1, 2)或者(1, 3),但要注意观察特征数量增长速度。假设词典大小 V,经过特征哈希或向量化后,bigram 理论特征数是 V²,trigram 是 V³——这数字秒级膨胀,你需要尽早做好降维预案。
第二条:语言生成和纠错场景,trigram 是性价比较高的甜点区。低于 trigram,预测结果太飘;高于 trigram,统计频次开始大面积为零,反而不好。
第三条:如果语料量小于 10 万句,不要用 4-gram 以上;如果语料在 100 万句量级,可以尝试选择性加入 4-gram,但只保留高频的那部分,做剪枝。
这里要特别提醒一下,ngram 的 n 和语言类型有关系。英文里字符级 3-gram 就能较好地覆盖词根形态变化(比如 run、running、runs 的字符 3-gram 都有 "run" 这个片段)。中文里字符级 2-gram 和 3-gram 结合能直接绕过分词器的分词错误和 OOV 问题,我后面实操部分会展开讲。
4. 核心应用场景拆解:从搜索引擎到输入法的 ngram 落地
4.1 搜素引擎:Query 纠错和候选召回
搜索场景里 ngram 最经典的应用是"编辑距离+bigram 共现"的纠错方案。用户输入"蓝呀耳机",搜索引擎先在索引里找到与"蓝呀"bigram 相似的候选(通过字符级别 bigram 计算 Jaccard 相似度),再用正常词条的 bigram 频率打分,最后把"蓝牙耳机"排到前面。
这里的原理值得展开。字符级别 bigram 天然对单字替换、单字缺失、单字颠倒不敏感。比如"蓝呀"和"蓝牙"的字符 bigram 分别是:
- 蓝呀:蓝呀(一个 bigram)
- 蓝牙:蓝牙(一个 bigram)
如果按 unigram 字符算,"蓝""呀"和"蓝""牙"只有一个字相同,相似度 0.5。但如果有相邻关系,加上词的 contextual 信息,比如"耳机"前面的 bigram 是"呀耳"和"牙耳",这里"牙耳"的共现频率远高于"呀耳",据此可以推测用户打错了。
搜索候选召回的另一个做法是:用 bigram 索引代替单词索引。把每个文档拆成 bigram 序列建倒排索引,查询时同样把 query 拆成 bigram,在索引上做交并运算。这样做的好处是能容忍词序变化和分词差异,坏处是索引体积会明显膨胀。
4.2 输入法和语音识别的语言模型重打分
输入法的核心问题是:面对拼音串,要生成最可能的汉字序列。这本质上就是语言模型的解码问题。现在的输入法底层基本都是 ngram 语言模型做候选重排,通常使用 trigram,配合动态解码算法。
比如用户输入拼音 "wo yao chi fan"(我要吃饭),输入法不仅要看拼音本身,还要估计汉字转移概率。P(饭|我, 要) 远大于 P(烦|我, 要),所以候选"饭"排在"烦"前面。这就是 ngram 语言模型在起作用。如果做个性化输入法,还会根据用户自己的历史语料训练一个专属 ngram 模型,把个人用语习惯加进去。
语音识别同样如此。识别引擎声学模型会给出多个候选序列,语言模型用 ngram 计算每个序列的合理程度,做最后重打分排序。这个环节从 bigram 到 trigram 提升非常明显,但 4-gram 对整个系统的收益就小得多,加上内存占用翻几十倍,实际工程里很少用。
4.3 文本分类与情感分析的特征工程
做文本分类时,ngram 是最基础也最可靠的特征来源之一。它不像 word2vec 需要大量训练和调参,也不像 BERT 需要 GPU 推理,纯统计方法在中小数据集上就能打。
实战经验是:把 unigram 和 bigram 特征拼接,再用 TF-IDF 加权,在很多分类任务上已经能逼近小型神经网络的效果了。比如垃圾短信识别,把每条短信拆成 char-level 的 bigram 和 trigram 特征(注意这里是字符级不是词级),然后喂给逻辑回归或 XGBoost。字符级 ngram 的妙处在于它天然处理了各种变体词——比如"免息"写成"免 息"、"袋款"这种广告变体,字符级 2-gram 依然能捕捉到相似性。
情感分析场景中,trigram 尤其重要。像"不太好"和"不 太 好"在 unigram 下情感极性接近中性,但在 trigram 下它是一个强负向短语。这就是上下文窗口的作用,三词尾巴足够抓住大部分中文情感短语的形态。
4.4 推荐系统的 Item2Vec 替代方案:ngram 相似度
你可能想不到,ngram 也能做推荐。两个商品标题本身就可以看成序列,把标题做词级和字符级 ngram 混合切分,然后算 Jaccard 相似度,可以在没有行为数据的情况下做 Item 相似召回和去重。
比如商品 A:"Apple Watch Series 8 智能手表 GPS 版",商品 B:"苹果手表 S8 GPS 智能运动手表"。词级 bigram 可能有差异,但字符级 trigram 中有大量重叠片段("智能手""手表 G"),可以直接算相似度做聚合或者搭配推荐。这种方式适合冷启动场景,因为不需要用户行为数据。
5. 实操:从零实现一个面向中文的 ngram 统计与分析工具
5.1 基础架构设计
现在进入实操部分。我会实现一个完整的 ngram 统计工具,支持中文文本的词级和字符级 ngram 提取、统计、存储和查询。整体架构分四层:
- 数据预处理层:清洗、分词/分字、去除噪声
- ngram 生成层:滑窗生成指定阶数的 ngram 序列
- 统计存储层:用哈希表统计频次,支持剪枝和导出
- 查询应用层:提供共现查询、条件概率计算、相似度计算
我用 Python 实现,核心不依赖第三方库(只用标准库collections和re),方便理解原理。生产环境你可以用 PySpark 或 DuckDB 做大规模统计,但逻辑完全一致。
5.2 核心代码实现
先定义数据预处理模块。中文分词是个麻烦事,我提供两个模式:词级模式(需要分词器,这里用 jieba 示例)和字符级模式(不需要分词器,直接按字切分)。
import re from collections import Counter, defaultdict import math class TextPreprocessor: """中文文本预处理:清洗 + 分词/分字""" def __init__(self, mode='char', stopwords=None): """ mode: 'char' 字符级,'word' 词级 stopwords: 停用词集合 """ self.mode = mode self.stopwords = stopwords or set() # 中英文数字的保留模式,标点去掉 self.valid_pattern = re.compile(r'[\u4e00-\u9fa5a-zA-Z0-9]+') def clean(self, text): """去掉标点和无关符号,统一小写""" text = text.lower() # 只保留中文字符、英文字母和数字 text = ' '.join(self.valid_pattern.findall(text)) return text def tokenize(self, text): """ 切分文本为项序列。 char 模式下每个字符是一个项; word 模式下用 jieba 分词。 """ text = self.clean(text) if not text: return [] if self.mode == 'char': # 字符级:直接拆成单字 + 英文单词整体 tokens = [] for part in text.split(): if re.match(r'[a-zA-Z0-9]+', part): tokens.append(part) else: tokens.extend(list(part)) return tokens elif self.mode == 'word': import jieba tokens = [w for w in jieba.cut(text) if w.strip()] # 过滤停用词 return [w for w in tokens if w not in self.stopwords] return []tokenize 是核心入口。需要注意一个细节:字符级模式下,我把连续的英文和数字作为一个整体项,而不是拆成单个字母,原因在于数字序列比如"2024"拆成"2""0""2""4"会失去数值整体含义。但在拼音纠错场景,你恰恰需要把"lanya"拆成字母级 trigram,所以我把这个选择做成参数。
接下来是 ngram 统计器。用滑窗法生成 ngram,用双层的 dict 结构保存计数和条件概率:
class NGramModel: """ ngram 统计与查询模型 支持 1-5 阶统计,支持条件概率计算、相似度计算 """ def __init__(self, n=3, mode='char', stopwords=None): self.n = n self.mode = mode self.preprocessor = TextPreprocessor(mode, stopwords) # 计数器: {(w1, w2, ..., wn): count} self.ngram_counts = Counter() # 前缀计数器: {(w1, w2, ..., w(n-1)): count} self.prefix_counts = Counter() self.total_ngrams = 0 def train(self, texts): """ 训练:对一批文本统计 ngram 频次 texts: list[str] """ for text in texts: tokens = self.preprocessor.tokenize(text) if len(tokens) < self.n: continue # 滑窗生成 ngram for i in range(len(tokens) - self.n + 1): ngram = tuple(tokens[i:i+self.n]) self.ngram_counts[ngram] += 1 self.prefix_counts[ngram[:-1]] += 1 self.total_ngrams += 1 def get_count(self, ngram): """获取某个 ngram 的绝对频次""" if isinstance(ngram, str): ngram = tuple(ngram.split()) return self.ngram_counts.get(tuple(ngram), 0) def conditional_probability(self, context, target): """ 计算 P(target | context) context: tuple 或 list,前 n-1 个项 target: str,当前项 """ context = tuple(context) if len(context) != self.n - 1: raise ValueError(f"context 长度必须为 {self.n - 1}") prefix_count = self.prefix_counts.get(context, 0) if prefix_count == 0: return 0.0 ngram = context + (target,) return self.ngram_counts.get(ngram, 0) / prefix_count def generate_next_words(self, context, top_k=5): """ 根据前 n-1 个词,返回最可能的 top_k 后续词 这是输入法联想的基础能力 """ context = tuple(context) if len(context) != self.n - 1: raise ValueError(f"context 长度必须为 {self.n - 1}") candidates = [] prefix_count = self.prefix_counts.get(context, 0) if prefix_count == 0: return [] # 遍历所有包含该前缀的 ngram for ngram, count in self.ngram_counts.items(): if ngram[:-1] == context: prob = count / prefix_count candidates.append((ngram[-1], prob)) candidates.sort(key=lambda x: x[1], reverse=True) return candidates[:top_k] def similarity(self, text_a, text_b): """ 基于 ngram 集合的 Jaccard 相似度 用于文本去重、纠错候选排序 """ tokens_a = self.preprocessor.tokenize(text_a) tokens_b = self.preprocessor.tokenize(text_b) def to_ngram_set(tokens): if len(tokens) < self.n: return set() return {tuple(tokens[i:i+self.n]) for i in range(len(tokens) - self.n + 1)} set_a = to_ngram_set(tokens_a) set_b = to_ngram_set(tokens_b) if not set_a or not set_b: return 0.0 intersection = len(set_a & set_b) union = len(set_a | set_b) return intersection / union这里我用了Counter和双层计数器。第一层ngram_counts保存完整 ngram 的计数,第二层prefix_counts保存前缀计数,两套配合才能高效计算条件概率。注意conditional_probability里对prefix_count的检查很关键——如果前缀都没出现过,条件概率直接返回 0,而不是触发异常,这样在工程上更鲁棒。
5.3 平滑处理:解决零概率的杀手锏
ngram 统计最经典的问题就是数据稀疏。比如语料里"苹果"出现了 1000 次,但"苹果电脑"一次都没出现过,结果 P(电脑|苹果) = 0。这是不对的——"苹果电脑"是合理短语,只是语料有限没见到而已。解决这个问题需要平滑技术。
推荐一个效果好又容易实现的:Kneser-Ney 平滑的简化版——插值回退法。思路是:如果 trigram 的统计量为零,就回退用 bigram 的统计量;如果 bigram 也为零,就回退用 unigram 的统计量:
def smoothed_probability(self, context, target, alpha=0.4): """ 插值回退平滑 P_smooth = lambda * P_ngram + (1-lambda) * P_backoff """ if len(context) >= self.n: # 当前阶数超过模型阶数,回退到 n-1 阶 context = context[-(self.n-1):] if self.n > 1 else [] if self.n == 1: # unigram 不需要条件前缀 target_count = self.ngram_counts.get((target,), 0) return target_count / self.total_ngrams # 计算当前阶条件概率 context = tuple(context) ngram = context + (target,) ngram_count = self.ngram_counts.get(ngram, 0) prefix_count = self.prefix_counts.get(context, 0) if prefix_count == 0: # 前缀没出现过,完全回退 lower_component = 0 else: p_current = ngram_count / prefix_count lower_component = p_current * alpha # 回退成分:缩短 context 重新计算 shorter_context = context[1:] # 递归近似处理,生产环境可以预先缓存低阶模型 backoff_prob = self._backoff_probability(shorter_context, target) return lower_component + (1 - alpha) * backoff_prob def _backoff_probability(self, context, target): """低阶模型回退计算""" if len(context) == 0: target_count = self.ngram_counts.get((target,), 0) return target_count / self.total_ngrams context = tuple(context[-self.n+1:]) ngram = context + (target,) ngram_count = self.ngram_counts.get(ngram, 0) prefix_count = self.prefix_counts.get(context, 0) if prefix_count == 0: return self._backoff_probability(context[1:], target) return ngram_count / prefix_count插值系数 alpha 取 0.4 是我在多个语料上调出来的经验值。alpha 越接近 1,模型越相信高阶统计;越接近 0,越依赖低阶回退。语料量越大,alpha 可以调得越高;语料稀疏时,建议降到 0.3。
5.4 流式统计与剪枝:处理海量数据时的工程手段
上面这套实现内存消耗不小。当语料上了千万级,Counter会吃爆内存。两个手段配合使用:
第一个是流式统计。不把全部文本加载进内存,而是逐行读取、更新计数,定期把计数落盘合并:
class StreamingNGramCounter: """流式 ngram 计数器:边读边统计,定期刷盘""" def __init__(self, n=2, flush_threshold=500000): self.n = n self.flush_threshold = flush_threshold self.buffer = Counter() self.model = NGramModel(n=n, mode='char') def process_file(self, filepath): with open(filepath, 'r', encoding='utf-8') as f: for line in f: tokens = self.model.preprocessor.tokenize(line.strip()) for i in range(len(tokens) - self.n + 1): ngram = tuple(tokens[i:i+self.n]) self.buffer[ngram] += 1 # 缓冲到阈值就刷盘合并 if len(self.buffer) >= self.flush_threshold: self._flush() self._flush() def _flush(self): for ngram, count in self.buffer.items(): self.model.ngram_counts[ngram] += count self.buffer.clear()第二个是剪枝。统计完成后,把频次低于阈值的 ngram 删掉。比如高峰时期统计出 3 亿个 trigram,其中 2.7 亿个只出现了 1 次。保留 top 3000 万(频次 ≥ 2 或 rank 在前 10% 的),已经能覆盖 99% 的使用场景。剪枝后的模型体积能缩小 10 倍,而且因为大量噪声被去掉,预测效果往往不降反升。
6. 踩坑记录:ngram 实操中必须躲开的五个坑
6.1 数据稀疏导致条件概率失真,别硬算要平滑
最常见的问题:某个 ngram 没在语料中出现,条件概率直接是 0。这个坑在 trigram 上尤其致命,因为 V 个词的词典,理论上 trigram 的组合空间是 V³,中小语料只能覆盖极小一部分。我在做语音识别后处理时,语料只有 20 万句,trigram 覆盖率不到全部可能组合的 3%,不做平滑根本没法用。
解决办法就是上文的插值回退平滑。另外还有一个思路是"反向平滑":不只回退前缀,还可以向"后缀相似"扩展,比如在计算 P(电脑|苹果) 时,如果"苹果"作为前缀的频率低,可以把语料里所有以"苹"开头的词都算进折扣贡献里。这个做法实现复杂但在中文里效果很好。
6.2 词级 ngram 遇到分词错误,字符级 ngram 来兜底
中文 NER 和文本分类里,分词器本身就会犯错。比如"南京市长江大桥"这个经典歧义句,jieba 可能把它切成"南京市 / 长江大桥",也可能切成"南京 / 市长 / 江大桥"。词级 bigram 会随分词结果剧烈变化,但字符级 trigram 完全不受影响——不管怎么切,"南京市长江大桥"的字符 3-gram 都是固定的:南京市、京市长、市长江、长江大、江大桥。
所以我做中文 ngram 特征时,默认字符级,除非业务明确需要词级语义(比如做关键词抽取)。一个稳健的方案是两者拼接:字符级 2-gram + 字符级 3-gram 作为一个特征组,词级 1-gram + 词级 2-gram 作为另一个特征组,效果互补。
6.3 单纯频次淹没长尾信息,必须转 TF-IDF 加权
ngram 统计的原始频次有一个致命缺陷:高频无意义片段会把真正有区分度的长尾组合淹没。比如在新闻语料里,"中国""我们""进行"这些 unigram 和 bigram 频次极高,但它们对文本分类没有区分度;反而是"芯片""制裁""供应链"这种 trigram 虽然频次低,但能一锤定音。
解决办法很标准:做 TF-IDF 加权,或者用 BM25 替代。在向量化阶段,对每个 ngram 特征计算 TF-IDF 值再送入模型,而不是喂原始频次。我在实际项目中试过,原始频次进逻辑回归和 TF-IDF 加权进逻辑回归,F1 能从 0.78 提升到 0.86,差距非常明显。
6.4 短文本场景的 ngram 失效,改增量组合和 padding
短文本(比如搜索 query、短信消息)往往只有几个词甚至几个字。对于查询"蓝牙",trigram 根本生成不出来,bigram 也只有一个。这时 ngram 统计量极其稀疏,模型几乎无法工作。
两个缓解手段:一是做 padding(填充),在文本前后加特殊占位符。比如_做边界符,"蓝牙"在 trigram 下就变成_蓝牙、蓝牙_、_蓝_等组合,等于变相增加了 3 个特征。二是降低阶数,短文本场景默认用 bigram 就好,trigram 信息不足以支撑。
6.5 存储爆炸与查询变慢,前缀索引和哈希截断
当语料上亿时,ngram 存储本身就成为问题。全量 trigram 存储需要的内存可能是文本本身的几十倍。我踩过的坑是,一度想把 5-gram 全量存进 Redis,结果 Redis 实例直接 OOM。
工程上推荐三个手段:一是做 top-K 截断统计,只保存频次排序前 N 个 ngram;二是哈希截断,把 ngram 序列哈希成固定长度整数(比如 64 位),用整数做 key,查询时先哈希再查,速度提升很明显;三是带前缀索引的分片存储,把相同前缀的 ngram 分到同一个桶,查询时只需要定位到一个桶。
def ngram_hash(ngram, num_buckets=1024): """ 把 ngram 哈希到指定桶,用于分片存储 相同前缀的 ngram 倾向于落到同一组桶,便于查询 """ import hashlib key = '|'.join(ngram) # 用 md5 取前 8 字节转为整数 hash_bytes = hashlib.md5(key.encode('utf-8')).digest()[:8] hash_int = int.from_bytes(hash_bytes, 'big') # 这里做了个 trick:预留前 n-1 个词的组合哈希 prefix = '|'.join(ngram[:-1]) prefix_hash = int.from_bytes( hashlib.md5(prefix.encode('utf-8')).digest()[:8], 'big' ) return (prefix_hash % num_buckets, hash_int % num_buckets)这个函数返回两个值:前缀桶号和完整 hash。查询某个前缀下所有 ngram 时,只需要扫前缀桶号对应分片,不需要全表扫描。
7. 一个完整实战:用 ngram 构建搜索纠错系统的最小闭环
说了这么多,最后用一个完整小项目串起所有知识点。做一个中文搜索纠错系统的最小闭环:输入可能打错的 query,输出纠错后的正确 query。整体框架用"候选召回 + 排序"两阶段。
候选召回阶段:将用户 query 拆成字符级 bigram 集合,在纠错词典(从正确词条的 ngram 索引构建)里找到 Jaccard 相似度最高的前 10 个候选词。这一步的优点是利用 bigram 对单字错误的强容忍性。
排序阶段:用 ngram 语言模型的困惑度对候选做重排。对每个候选的整句,计算其 P(query|correct) 和语言模型概率 P(correct) 的加权和。具体来说:
class SearchCorrector: """基于 ngram 的搜索纠错系统""" def __init__(self, correct_phrases, n=2): """ correct_phrases: 正确的词条/短语列表 """ self.n = n self.lm = NGramModel(n=3, mode='char') # 高阶语言模型用于排序 # 训练语言模型:正确的文本语料 self.lm.train(correct_phrases) # 构建 bigram 索引用于召回 self.index = defaultdict(list) for phrase in correct_phrases: tokens = self.lm.preprocessor.tokenize(phrase) for i in range(len(tokens) - 1): bg = tuple(tokens[i:i+2]) self.index[bg].append(phrase) def _candidates(self, query, top_k=10): """ 召回阶段:用 bigram Jaccard 相似度召回候选 """ query_tokens = self.lm.preprocessor.tokenize(query) if len(query_tokens) < 2: return [] query_bigrams = { tuple(query_tokens[i:i+2]) for i in range(len(query_tokens) - 1) } scored = {} # 遍历 query 的每个 bigram,从索引里召回候选 for bg in query_bigrams: for phrase in self.index.get(bg, []): phrase_tokens = self.lm.preprocessor.tokenize(phrase) if len(phrase_tokens) < 2: continue phrase_bigrams = { tuple(phrase_tokens[i:i+2]) for i in range(len(phrase_tokens) - 1) } jaccard = len(query_bigrams & phrase_bigrams) / len(query_bigrams | phrase_bigrams) if phrase not in scored or jaccard > scored[phrase]: scored[phrase] = jaccard # 按相似度排序取 top_k ranked = sorted(scored.items(), key=lambda x: x[1], reverse=True) return [phrase for phrase, _ in ranked[:top_k]] def correct(self, query, alpha=0.7): """ 排序阶段:结合 Jaccard 相似度和语言模型打分 """ candidates = self._candidates(query) if not candidates: return query best_score = -float('inf') best_candidate = query for cand in candidates: # 语言模型得分:用候选在模型中生成概率衡量合理性 tokens = self.lm.preprocessor.tokenize(cand) lm_score = 0.0 if len(tokens) >= 3: for i in range(len(tokens) - 2): prob = self.lm.conditional_probability( tuple(tokens[i:i+2]), tokens[i+2] ) if prob > 0: lm_score += math.log(prob) else: lm_score -= 5 # 对零概率做惩罚 # 相似度得分 query_tokens = self.lm.preprocessor.tokenize(query) cand_tokens = self.lm.preprocessor.tokenize(cand) q_bg = {tuple(query_tokens[i:i+2]) for i in range(len(query_tokens)-1)} c_bg = {tuple(cand_tokens[i:i+2]) for i in range(len(cand_tokens)-1)} jaccard = len(q_bg & c_bg) / len(q_bg | c_bg) if q_bg and c_bg else 0 # 加权组合 score = alpha * jaccard + (1 - alpha) * (lm_score / max(len(tokens), 1)) if score > best_score: best_score = score best_candidate = cand return best_candidate实测一下纠错效果。我用"蓝牙耳机蓝牙音箱"的变体做测试:query 输入"蓝呀耳机",候选召回阶段会召回"蓝牙耳机"(bigram 里"蓝呀"和"蓝牙"只有一字之差,但"呀耳"和"牙耳"的 bigram 在正确语料里频繁共现)。排序阶段,语言模型给"蓝牙耳机"的打分远高于"蓝呀耳机",因为 P(耳机|蓝牙) 远大于 P(耳机|蓝呀)。最终输出"蓝牙耳机"。
调参方面:alpha 是相似度分数和语言模型分数的权重。alpha 太高,纠错容易变成纯字符匹配,缺少语义;alpha 太低,语言模型的零概率惩罚会过度。实测 alpha=0.7 在"拼写错误为主"场景里效果最好。如果你要做的是"同义词替换"而非"拼写纠错",建议把 alpha 降到 0.5 以下,更多依赖语言模型判断。
最后再分享一个实际体会:ngram 不是越复杂越好,它本质上是一个"跟数据量配套"的技术。数据量小的时候,老老实实用 bigram 加插值平滑,效果比硬上 5-gram 还稳定。数据量真正上来以后,你会发现几个阶数的 ngram 组合在一起,配合合理的剪枝和索引,已经能在绝大多数业务场景中扛住主力任务,根本不需要一上来就搬深度学习模型。我后来做纠错、做分类、做召回,都是先跑一版 ngram 基线,用统计方法把效果压到极限,再考虑要不要上更重的模型。这个习惯帮我省了大量试错时间,你也可以试试。