1. 关于 ngram别再只停留在n 个词的表面理解你可能早就听说过 ngram在很多 NLP 入门教程里它都是第一章就出现的基础概念但说实话大部分资料讲得太浅了。有的说ngram 就是连续的 n 个词有的给个ngram_range(1, 3)就完事了搞得很多人以为这是个很 trivial 的东西。其实恰恰相反ngram 是很多实用系统的底层支撑你手机上输入法的联想候选、搜索引擎的纠错提示、垃圾短信识别、甚至推荐系统里的 Item 相似度计算背后都站着 ngram。我最早接触 ngram 是在做搜索日志分析的时候。当时要解决一个问题用户在搜索框里输错了一个词比如把蓝牙耳机打成蓝呀耳机怎么自动识别并纠正后来才发现在这个场景里字符级别的 bigram 和 trigram 组合起来能做非常漂亮的特征工程配合简单的统计模型就能达到让人惊喜的效果。所以我决定把这篇文章写得实在一点。这里不是科普 ngram 的教科书定义而是从这玩意儿到底能干什么以及怎么用好它两个角度展开。想入门的读者看完能建立清晰的心智模型实际在做文本处理、搜索、推荐相关工作的从业者可以直接拿后面实操部分的方案去用。适合谁看两类人。一类是刚接触 NLP 或者数据挖掘想理解 ngram 在真实系统里价值的学习者另一类是已经在处理文本数据但遇到的数据稀疏、OOVOut of Vocabulary词表外新词、特征爆炸等问题想找一套可落地的思路的工程师。2. ngram 的数学本质别只看连续 n 个词要看联合概率2.1 表层定义与深层模型先过一遍最基础的定义。ngram 就是一段文本中连续出现的 n 个项。这里的项可以是字、词、拼音、字符、甚至二进制字节取决于你所在的场景。按项的类型分有字符级 ngram 和词级 ngram按 n 的大小分有 unigram1-gram、bigram2-gram、trigram3-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-n1),...,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 应该取多少我的回答是先搞清楚你要抓什么规律。n1unigram抓的是词频分布适合做主题识别、停用词过滤、关键词抽取。它的毛病是分不清苹果好吃和好吃苹果的差别。n2bigram抓的是相邻共现关系能反映一部分词序。搜索场景下做候选召回非常高效因为搜索引擎不需要理解整句话意图它只需要依赖用户输入的词和文档中的词大概率会相邻出现这个规律。n3trigram抓的是三词窗口内的短距离依赖关系。输入法的拼音转汉字、语音识别的语言模型重打分基本都是 trigram 起步。为什么因为中文常用词的第三阶上下文信息还是比较充分的。n4、54-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 相似召回和去重。比如商品 AApple 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, modechar, stopwordsNone): 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拆成2024会失去数值整体含义。但在拼音纠错场景你恰恰需要把lanya拆成字母级 trigram所以我把这个选择做成参数。接下来是 ngram 统计器。用滑窗法生成 ngram用双层的 dict 结构保存计数和条件概率class NGramModel: ngram 统计与查询模型 支持 1-5 阶统计支持条件概率计算、相似度计算 def __init__(self, n3, modechar, stopwordsNone): 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:iself.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(fcontext 长度必须为 {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_k5): 根据前 n-1 个词返回最可能的 top_k 后续词 这是输入法联想的基础能力 context tuple(context) if len(context) ! self.n - 1: raise ValueError(fcontext 长度必须为 {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(keylambda x: x[1], reverseTrue) 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:iself.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, alpha0.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.n1:]) 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, n2, flush_threshold500000): self.n n self.flush_threshold flush_threshold self.buffer Counter() self.model NGramModel(nn, modechar) def process_file(self, filepath): with open(filepath, r, encodingutf-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:iself.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_buckets1024): 把 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, n2): correct_phrases: 正确的词条/短语列表 self.n n self.lm NGramModel(n3, modechar) # 高阶语言模型用于排序 # 训练语言模型正确的文本语料 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:i2]) self.index[bg].append(phrase) def _candidates(self, query, top_k10): 召回阶段用 bigram Jaccard 相似度召回候选 query_tokens self.lm.preprocessor.tokenize(query) if len(query_tokens) 2: return [] query_bigrams { tuple(query_tokens[i:i2]) 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:i2]) 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(), keylambda x: x[1], reverseTrue) return [phrase for phrase, _ in ranked[:top_k]] def correct(self, query, alpha0.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:i2]), tokens[i2] ) 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:i2]) for i in range(len(query_tokens)-1)} c_bg {tuple(cand_tokens[i:i2]) 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 太低语言模型的零概率惩罚会过度。实测 alpha0.7 在拼写错误为主场景里效果最好。如果你要做的是同义词替换而非拼写纠错建议把 alpha 降到 0.5 以下更多依赖语言模型判断。最后再分享一个实际体会ngram 不是越复杂越好它本质上是一个跟数据量配套的技术。数据量小的时候老老实实用 bigram 加插值平滑效果比硬上 5-gram 还稳定。数据量真正上来以后你会发现几个阶数的 ngram 组合在一起配合合理的剪枝和索引已经能在绝大多数业务场景中扛住主力任务根本不需要一上来就搬深度学习模型。我后来做纠错、做分类、做召回都是先跑一版 ngram 基线用统计方法把效果压到极限再考虑要不要上更重的模型。这个习惯帮我省了大量试错时间你也可以试试。
企业数字化 ERP 产品动态
相关推荐
KV Cache复用:大模型推理显存与吞吐的破局关键 1. 为什么KV Cache复用成了大模型推理的“命门”——从显存墙到计算效率的双重困局 你有没有遇到过这样的场景:本地部署一个7B参数的LLM,刚加载完模型,显存占用就飙到12GB;等真正开始生成文本,每解码一个token… · 2026/11/2 16:39:20
Chroma向量数据库在RAG架构中的实践指南 1. RAG与向量存储技术概述 在构建基于大语言模型(LLM)的智能应用时,检索增强生成(Retrieval-Augmented Generation, RAG)已成为解决模型"幻觉"问题的关键技术方案。其核心思想是将外部知识库通过向量化检索的方式动态注入到生成过程中,而向量数… · 2026/11/2 16:39:20
AI Agent从工具到伙伴:记忆、规划与工程落地全解析 1. 从工具到伙伴,这个说法背后的技术逻辑
1.1 工具阶段:LLM只是查资料和写草稿的“高级函数” 先说清楚一个很多人不愿意承认的事实:大部分团队用LLM的方式,还停留在把模型当成一个“更聪明的搜索引擎”或“文本生成接口”。用户… · 2026/11/2 16:39:20
Java还能学吗?AI时代Java工程师的出路与收藏指南 本文分析Java在AI时代的现状与前景,指出Java后端岗位需求依然旺盛,但初级岗位竞争激烈,高级岗位需求增长。AI辅助编程正淘汰只会执行任务的程序员,而能设计架构、处理复杂问题的高级Java工程师依然抢手。未来Java将向云原生、AI应… · 2026/11/3 19:28:23
Godot 从零做围棋05——board前期准备 在之前的教程中,我们已经完成了布局设计、棋盘绘制、棋子类的创建。下面我们就要开始创建board类。 这个类是处理游戏规则、逻辑的。不参与其他内容。创建类的过程前几期讲过,这里不再赘述。常量、状态的准备(在处理逻辑之前,… · 2026/11/3 19:28:23
零售与数仓(一):促销后销售增长了,活动就值得继续吗? 一场促销结束,销售额增长了,运营希望继续,采购准备增加备货。经营者却还需要回答:增长是否划算,能否持续,以及下一轮究竟应该改变什么?
本文用一家销售咖啡豆和杯子的零售店串起经营分析。案例… · 2026/11/3 19:28:23
[ adb ] adb常用调试命令 !!此文章会不定时更新adb的命令!!adb root (获取超级管理员权限)重启设备(手机、车机)里的 ADB 守护进程(adbd),并让它以 root 权限运行重点&… · 2026/11/3 19:28:23
java 线程池用法 推荐创建线程池的方式示例代码:ThreadPoolExecutor executor new ThreadPoolExecutor(2, // 核心线程数5, // 最大线程数60L, // 空闲线程存活时间TimeUnit.SECONDS, // 时间单位new ArrayBlo… · 2026/11/3 19:28:23
网络安全-Javascript基础 网络安全-Javascript基础一般简称JS,然后用于嵌套在网页中,给网页添加动态效果用的node凭条,开发服务器也是用js
后期会用到recat、vue框架。常见的前端
HTML(结构层) 利用语言去搭建网页
CSS(样式层)利用样式美化网页… · 2026/11/3 19:27:23
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46