浅谈拼写纠错

发布于:2025-06-15 ⋅ 阅读:(18) ⋅ 点赞:(0)

本文是学习文档,很多地方直接引用原文了:
https://www.52nlp.cn/%E8%BE%BE%E8%A7%82%E6%95%B0%E6%8D%AE%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E%E7%9A%84query%E8%87%AA%E5%8A%A8%E7%BA%A0%E9%94%99%E6%8A%80%E6%9C%AF%E5%92%8C%E6%9E%B6%E6%9E%84%E8%AF%A6%E8%A7%A3
https://juejin.cn/post/7490490874847068210
https://zhuanlan.zhihu.com/p/506668995

搜索功能几乎是现在大小系统中必不可缺的功能。不仅仅是Google,百度这种搜索引擎,很多垂直细分领域的搜索需求也很旺盛,比如电商选品网站的产品搜索,文学网站的小说搜索,影视网站的剧名搜索等等。

搜索系统最基本最核心的功能是信息检索,找到含有关键字的数据或文档,然后按照一定排序将结果给出。但都2025年了,如果你的搜索功能只是个大白框,一点拓展功能都没有,用户绝对是没有办法忍受的。对于一个成熟的搜索系统,怎样提高用户体验也非常重要。常见的一些功能有自动补全,拼写纠错,相似词推荐等等。

这里主要讲了下其中的拼写纠错(Error Correction)

拼写纠错

什么是拼写纠错?有必要吗

比如我想搜黄子韬这个明星,但我拼错了,百度会提示我正确的人名并进行搜索,这就是拼写纠错
在这里插入图片描述
拼写纠错重要吗?重要!

  • 人的记忆很奇妙,偶尔记住错误的词汇、拼写时卡壳,这是一种完全正常,且是大脑记忆机制的普遍现象。有相关的研究反映,人的大脑依赖“记忆捷径”(如读音联想、常见拼写规则等等),例如将“necessary”拼错为“neccessary”(正确应为1个c),因为英语中双写c更常见。
  • 错误检索还浪费了大量的资源,尤其是对于百度,Google这种体量的搜索系统,即使使用拼写纠错后,纠错率只有1%,也是不小的提升,更重要的是提升了用户的体验

背景

纠错技术从上世纪就有了,发展了很长时间,即使现在,也有不少的科研人员从事这方面的研究,可见这项技术的难度。(其实也很好理解,语每一门语言都经历了几百年,甚至几千年的长期演变和发展,形成了一套复杂的文法和句法规则。而且用词的要求本身就很高,差之毫厘,谬以千里)

在2000年以前,业界主要依靠长期积累的纠错规则和纠错词典来进行纠错,比如微软的文档编辑产品WORD即采用这种方法,随着机器学习技术的发展,纠错问题受到了学术界和工业界越来越多的关注,其中有两大主流方法:一种解决思路是将语言错误归类,然后采用Maxent(最大熵模型)、SVM(支持向量机)等分类方法对这些类别进行重点识别;另外一种思路是借鉴统计及其翻译(SMT)的思想,将语言纠错等价为机器翻译的过程,即错误文本翻译为正确文本,并随之出现了一系列的优化方法。

复杂的方法略过,只说下常规的纠错流程,分为错误检测、候选召回、纠错排序三个关键步骤

第一步 错误检测:识别潜在拼写问题

错误分类

对于英文,最基本的语义元素是单词,因此拼写错误主要分为两种

  • 非词错误(Non-word Error),指单词本身就是拼错的,比如将“happy”拼成“hbppy”,“hbppy”本身不是一个词。
  • 真词错误(Real-word Error),指单词虽拼写正确但是结合上下文语境确是错误的,比如“two eyes”写成“too eyes”,“too”在这里是明显错误的拼写。

对于中文,最小的语义单元是字,往往不会出现错字的情况,因为现在用户几乎都是通过输入法输入设备,不像手写汉字也许会出错。虽然汉字可以单字成词,但是两个或以上的汉字组合成的词却是更常见的语义元素,这种组合带来了类似英文的Non-word Error,比如“洗衣机”写成“洗一机”,虽然每个字是对的,但是整体却不是一个词,也就是所谓的错误词,但这种比较少见。

汉字也有类似Real-word Error的问题,比如暴力行业,暴力和行业都是正确的词,但是两个连在一起却有问题,这种才是日常中文使用中频繁出现的问题,因此很多情况下中文纠错实际上是短语纠错问题。这种情况下,我们把中文常见错误总结分为三类:

  • 用词错误,由于输入法等原因导致的选词错误,其主要表现为音近,形近等,比如上面提过的“暴力行业”实际上应该是“暴利行业”

  • 文法/句法错误,该类错误主要是由于对语言不熟悉导致的如多字、少字、乱序等错误,比如“两人家户”实际上应该是“两户人家”;

  • 知识类错误,该类错误可能由于对某些知识不熟悉导致的错误,比如“芈月传”写成“米月传”

错误判断

  • 字典比对法

    • 原理:将输入文本拆分为单词/词组,与预设词典比对。若词不在词典中,则标记为可疑错误
    • 局限:无法处理拼写正确但语义错误的词(如“peace”误写为“piece”),且依赖词典覆盖度
  • 统计和上下文建模(这部分就很难了,我也不太懂,简单看看)

    • 通过语言模型构建字符串的概率分布p(W),假设*p(W)*是字符串作为句子的概率,则概率由下边的公式计算

      P ( w 1 w 2 … w n ) = ∏ i P ( w i ∣ w 1 w 2 … w i − 1 ) P\left(w_{1} w_{2}\ldots w_{n}\right)=\prod_{i} P\left(w_{i} \mid w_{1}w_{2}\ldots w_{i-1}\right) P(w1w2wn)=iP(wiw1w2wi1)

      P(w₁w₂...wₙ) 表示词序列 w₁wₙ 的联合概率,∏ᵢ P(wᵢ | w₁w₂...wᵢ₋₁) 表示每个词 wᵢ 基于其前面所有词 w₁wᵢ₋₁ 的条件概率的乘积

      举个例子,it’s a fine day today,day出现的概率,依赖于it’s + a + fine的条件概率乘积

    • 上面的概率公式有一个很大的问题,如果w的词过多,那么排列组合起来,可能会有上千亿总组合,但是实际上绝大多数的组合不会出现,于是根据马尔科夫假设,一个词只和他前面n-1个词相关性最高,这就是n元语法模型。

      P ( w 1 w 2 … w n ) ≈ ∏ i P ( w i ∣ w i − k … w i − 1 ) P\left(w_{1} w_{2}\ldots w_{n}\right)≈\prod_{i} P\left(w_{i}\mid w_{i-k}\ldots w_{i-1}\right) P(w1w2wn)iP(wiwikwi1)

      区别在于不在取前面所有词计算概率了,只取k个词

    • 再简单一点,k=1时,就是 P ( w 1 w 2 … w n ) ≈ ∏ i P ( w i ∣ w i − 1 ) P\left(w_{1} w_{2}\ldots w_{n}\right)≈\prod_{i} P\left(w_{i}\mid w_{i-1}\right) P(w1w2wn)iP(wiwi1)

    • 若句中某词的概率显著低于候选词,则判定为错误。如“I ate a pair”中“pair”概率远低于“pear”。

  • 深度学习和混合方法(不懂略过)

第二步 候选召回:找到可能的正确词

编辑距离(Levenshtein距离)

莱文斯坦算法思想是计算从A字符串转为B字符串的最小操作(插入、删除、替换)次数,该操作次数越少,说明两个字符串越相似。

举个例子,单词:playlet -> playlist 所需操作:

playle -> playli 一次替换playli -> playlis 一次插入playlis -> playlist 一次插入

共需要三次操作。

当然这只是一个简单的例子,如果是复杂的字符串转换,就不能简单的列出来去计算这个操作次数了。通常使用动态规划的方式去计算这个操作次数,其核心思想是将大字符串拆为小字符串,计算较短字符串的编辑距离。

统计指出,80%的错误词的编辑距离是1,并且几乎所有的错误的编辑距离在2以内。

最长公共子串长度

和Levenshtein距离类似,区别是只允许增加、删除字符两个编辑操作。

playlet和playlist的最长公共子串是playlt,6

Levenshtein和最长公共子串长度,从两个截然相反的角度,分析字符串的相似程度。Levenshtein距离的大小,表示两个字符串差异的大小;而公共最长子串的大小,表示两个字符串相似的程度大小。

构建专门的候选库

  • 构建“错误→正确”映射库,比如“连衣群→连衣裙”,可以用评率或概率辅助
  • 结合同音词(拼音特征)、形近字(字形结构)、专业术语库(如医学名词)扩展候选集

语言模型或AI辅助

比如上文说的n元语法模型,分析上下文语义,把概率较高的词汇找出来,生成连贯的短语候选。

实战

这里简单实现字典+Levenshtein算法查找出候选词,不复杂,直接让AI写都很OK,简单快速过一遍即可。

我这里随便找了个字典wordnet_dict.json

import json
import re
import time

# 1. 加载字典
with open('wordnet_dict.json', 'r') as f:
    wordnet_list = json.load(f)
wordnet_set = set(word.lower() for word in wordnet_list)  # 小写化


# 2. 编辑距离计算
def edit_distance(s1, s2):
    m, n = len(s1), len(s2)
    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):
            cost = 0 if s1[i - 1] == s2[j - 1] else 1
            dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost)
    return dp[m][n]


# 3. 错误检测与纠正
def spell_check(text, wordnet_set, max_distance=2):
    tokens = re.split(r'\W+', text)  # 按非单词字符分词
    misspelled = {}
    unique_words = set(tokens)

    for word in unique_words:
        word_lower = word.lower()
        if word_lower not in wordnet_set:
            # 生成候选词(限制编辑距离≤2)
            candidates = []
            for dict_word in wordnet_set:
                if abs(len(dict_word) - len(word_lower)) > max_distance:
                    continue
                dist = edit_distance(word_lower, dict_word)
                if dist <= max_distance:
                    candidates.append((dict_word, dist))
            # 按编辑距离排序
            candidates.sort(key=lambda x: x[1])
            # misspelled[word] = [c[0] for c in candidates[:5]]  # 返回前5个候选
            misspelled[word] = [c[0] for c in candidates]  # 返回所有候选
    return misspelled


# 测试
start_time = time.time()
text = "I am writting an artical"
results = spell_check(text, wordnet_set)
end_time = time.time()
print("Time used:", end_time - start_time)
print(json.dumps(results))
  • 我用到的字典中大约有147306个词,执行耗时约1.56 s,结果如下:
{
    "writting": [
        "witting",
        "writing",
        "writhing",
        "ratting",
        "whiting",
        "wetting",
        "pitting",
        "printing",
        "spitting",
        "hitting",
        "shitting",
        "writings",
        "knitting",
        "waiting",
        "fitting",
        "written",
        "sitting",
        "rioting",
        "wilting",
        "drifting",
        "rotting"
    ],
    "artical": [
        "critical",
        "tical",
        "urtica",
        "actinal",
        "martial",
        "partial",
        "farcical",
        "vatical",
        "arrival",
        "metical",
        "nautical",
        "optical",
        "cortical",
        "article",
        "vertical",
        "acritical",
        "apical",
        "arnica",
        "artisan",
        "tactical",
        "attica"
    ]
}
  • 可以看到,距离在2内的词有很多,我该选哪一个?这就要靠第三步,纠错排序了

第三步 纠错排序 选择最佳候选

候选词太多了,我们需要按照一定的排序规则,把排名最高的候选词作为最佳纠错结果返回。

  • 对于单个词汇来说,排序规则可以使用词频,距离等多种特征,候选词按照这些特征规则进行排序,返回权重较高的词即可
  • 对于短语或者一句话,纠错就困难起来了,每个词存在上下文关系约束,这就需要通过模型或者组合策略来解决了

噪声信道模型

最早是香农为了模型化信道的通信问题,在信息熵概念上提出的模型。如果大学学计算机的,一定听过信噪比,香农公式啥的。(我忘光了),而噪声信道模型简单理解就是,给定一个输入(这个输入可能是错误的),通过解码处理,找到真正的最有可能的输入。

w ^ = argmax ⁡ w ∈ V P ( w ∣ x ) = argmax ⁡ w ∈ V P ( x ∣ w ) P ( w ) P ( x ) = argmax ⁡ w ∈ V P ( x ∣ w ) P ( w ) \begin{aligned} \hat{w} & = \underset{w\in V}{\operatorname{argmax}} P(w \mid x) \\ & = \underset{w\in V}{\operatorname{argmax}} \frac{P(x \mid w)P(w)}{P(x)} \\ & = \underset{w\in V}{\operatorname{argmax}} P(x \mid w)P(w) \end{aligned} w^=wVargmaxP(wx)=wVargmaxP(x)P(xw)P(w)=wVargmaxP(xw)P(w)

  • 其中*p(x|w)*是正确的词编辑成为错误词x的转移概率
  • 再翻译下就是: P(正确词∣错误词)=P(错误词∣正确词)×P(正确词)
  • 需要统计大量的正确词和错误词汇来计算转移矩阵的概率(语料库),再结合这个模型计算出概率最大的候选词
  • 关于这个方法,我的理解是:错误词变成正确词的概率我们很难知道,但是正确词变成错误词的概率是可以通过大规模的语料算出来的,把问题逆转下,就能得出答案

Deep & Wide模型

推荐系统和机器学习领域的经典模型架构,抖音推荐模型应该就用到了这个。

Deep & Wide模型是由 Google 在 2016 年提出的一种混合推荐系统架构,旨在结合线性模型的记忆能力和深度神经网络的泛化能力。其核心思想是通过联合训练同时优化两部分模型,以提升推荐系统的准确性和多样性,在纠错里,我们可以简单理解成:

  • Deep层:学习上下文语义表示
  • Wide层:综合字形、拼音、词频、用户点击反馈等特征计算相似度

利用搜索session

搜索系统中的session分析也能够为纠错服务。搜索session指的是用户在某一个时间段内的搜索行为,如果把搜索日志按照时间排序,对于某一个用户的搜索日志来说,可以看到用户的搜索行为是分段的。每段之间往往有较为明显的间隔,每一段我们称为一个搜索session。

一般来说,用户在一个session内的搜索行为都是为了解决一个问题,因此在此session内用户输入的query往往都是相关的。而这个可以辅助找到用户真正想要找到的词!

个性化与动态优化

利用用户画像进行辅助,如果一个用户专注于短剧,那么短剧相关的词汇优先级就很高。类似的还有地域,年龄等等,都可以用来辅助

包推荐:SpellChecker

PySpellChecker这个包主要支持英语,其他语言需额外配置词典,直接看代码:

from spellchecker import SpellChecker

spell = SpellChecker()
text = "I am writting an artical"

misspelled = spell.unknown(text.split())
for word in misspelled:
    corrections = spell.candidates(word)
    print(f"Misspelled: {word}, Correction: {corrections}")

最后输出:

Misspelled: writting, Correction: {'gritting', 'writing', 'writhing', 'witting'}
Misspelled: artical, Correction: {'vertical', 'attica', 'tactical', 'arica', 'nautical', 'atrial', 'radical', 'partial', 'critical', 'arnica', 'arnicas', 'cortical', 'metical', 'artisan', 'farcical', 'actinal', 'apical', 'arrival', 'optical', 'martial', 'tical', 'vortical', 'article'}

常见函数

函数 用途 示例
SpellChecker() 创建拼写检查器实例,支持多语言(如language='es'设置西班牙语) spell = SpellChecker(language='es')
unknown(word_list) 返回输入单词列表中所有拼写错误单词的集合 misspelled = spell.unknown(words)
correction(word) 返回单个错误单词的最可能正确拼写 best_guess = spell.correction("pythn")
candidates(word) 返回错误单词的所有候选正确拼写(集合) candidates = spell.candidates("exmple")
word_frequency.add() 添加自定义词汇到词典 spell.word_frequency.add("PyTorch")
word_frequency.load_words() 批量加载自定义词汇列表 spell.word_frequency.load_words(["TensorFlow", "NumPy"])

原理

  • Levenshtein距离
  • 概率词典与词频统计

不难发现SpellChecker原理其实和我上文中的代码很类似,但只要执行以下,就会发现,SpellChecker快了非常非常多!只能说不要低估了工业级库的优化深度。我AI了下,大致的改良地方有:

  1. SpellChecker会直接生成错误词的编辑距离2以内的所有可能词,而不是把字典中的所有词进行一遍距离变换,我让AI优化了下:
import json
import re
import time

# 1. 加载字典
with open('wordnet_dict.json', 'r') as f:
    wordnet_list = json.load(f)
wordnet_set = set(word.lower() for word in wordnet_list)  # 小写化


def generate_candidates(word, max_distance=2):
    candidates = set()
    n = len(word)

    # 编辑距离=1的操作(原代码)
    for i in range(n + 1):
        for char in 'abcdefghijklmnopqrstuvwxyz':
            # 插入
            candidates.add(word[:i] + char + word[i:])
            # 替换(需防止索引越界)
            if i < n:
                candidates.add(word[:i] + char + word[i + 1:])
        # 删除
        if i < n:
            candidates.add(word[:i] + word[i + 1:])
    # 换位
    for i in range(n - 1):
        candidates.add(word[:i] + word[i + 1] + word[i] + word[i + 2:])

    # ▼▼▼ 新增递归逻辑 ▼▼▼
    if max_distance > 1:
        new_candidates = set()
        for cand in candidates:
            # 递归生成距离-1的候选
            new_candidates |= generate_candidates(cand, max_distance - 1)
        candidates |= new_candidates  # 合并结果

    return {cand for cand in candidates if cand in wordnet_set}

# 3. 错误检测与纠正
def spell_check(text, wordnet_set, max_distance=2):
    tokens = re.split(r'\W+', text)  # 按非单词字符分词
    misspelled = {}
    unique_words = set(tokens)

    for word in unique_words:
        word_lower = word.lower()
        if word_lower not in wordnet_set:
            # 生成候选词(限制编辑距离≤2)
            misspelled[word] = list(generate_candidates(word_lower, max_distance=max_distance))
    return misspelled


# 测试
start_time = time.time()
text = "I am writting an artical"
results = spell_check(text, wordnet_set)
end_time = time.time()
print("Time used:", end_time - start_time)
print(json.dumps(results))

返回的结果一样,但是速度是0.08s,无与伦比的提升。

  • 原本的代码:时间复杂度:O(N×M×L²)(N=错误词数,M=词典大小,L=单词长度)
  • 新的方法:时间复杂度:O(N×3ᴸ)(L通常≤10,当L=10的时候,3ᴸ=59049,即使这种情况也比字典小(字典约十几万词))
{
    "artical": [
        "nautical",
        "arrival",
        "martial",
        "optical",
        "vertical",
        "radical",
        "cortical",
        "atrial",
        "tical",
        "critical",
        "vatical",
        "acritical",
        "article",
        "partial",
        "actinal",
        "farcical",
        "attica",
        "tactical",
        "apical",
        "metical",
        "arnica",
        "artisan",
        "urtica"
    ],
    "writting": [
        "wilting",
        "writhing",
        "drifting",
        "knitting",
        "printing",
        "rioting",
        "spitting",
        "pitting",
        "sitting",
        "whiting",
        "witting",
        "shitting",
        "wetting",
        "fitting",
        "hitting",
        "waiting",
        "writings",
        "written",
        "writing",
        "ratting",
        "rotting"
    ]
}
  1. 引入了词频排序,高频词优先
  2. 引入缓存机制
  3. 预过滤词典

结论:学习的时候可以手撸代码,实际使用还是直接用包!

总结下

  • 上面写了很多,其实还是很浅,实际上纠错系统仅数据部分,就可能需要进行log统计,数据库信息统计,百科数据抓取,常用字典整理,语言模型使用等等工作
  • 真的有这方面需求,可以直接找大模型平台上的现成产品+自己的一些数据资料,应该比较合适

画外音1 拼音搜索

国内普遍使用拼音输入法,无论是百度还是用的一些打字软件,我们输入拼音,甚至是首拼,就能知道我们想要的词汇,这是怎么实现的?

原理其实类似,总结下就是找到可能的词汇,再排序展示给用户

  • 找到可能得词汇

    • 汉字-拼音映射表:维护一个庞大的汉字与拼音对应词库(如“新”对应“xin”),覆盖常见词汇、专有名词及用户高频搜索词,词库包含多音字选项(如“重”对应“zhong/chong”),并支持首字母简拼(如“新华字典” → “xhzd”)

    • 分词与音节切分:系统将用户输入的拼音串拆解为有效音节(如“xinhuazid” → “xin-hua-zi-d”),并允许容错

    • 模糊匹配技术:基于各种算法,前缀树也好,Levenshtein距离也好,找到可能的候选词

  • 排序展示给用户

    • 权重动态更新机制:基于全网搜索热度,高频词会被优先映射,提升联想权重
    • 上下文与语义分析:基于自然语言处理(NLP)推测用户意图
    • 个性化适配:结合历史搜索记录,用户画像等等,优化排序

画外音2 推荐算法

在搜索的时候,我们经常会搜不到结果,实际上就是库里面没有这个产品的数据,这个时候,往往会推荐一些其他的类似产品给我们。

之前我对推荐的思路是,通过标签查找相关的产品。比如,用户搜索王者荣耀,我先分析下王者荣耀的标签,手游,在线竞技,腾讯,MOBA……然后再从这些标签的产品中找到同类产品推荐给用户。但是问题来了,既然我的库里都没有王者荣耀这个产品,那我又怎么知道这个产品的标签呢?我既然不知道标签,那怎么给用户推荐呢?

直到我看到了Jaccard算法,后来又去专门搜了推荐相关的知识!

Jaccard算法

原理

Jaccard算法的原理比较简单,对于A、B两个集合,其集合交集(共同元素)和集合并集(所有唯一元素)的比值越大,那么两个集合的相似度就越高。

使用到字符串相似度计算中,就是计算A、B两个字符串分词后的集合,其字符串集合相似度高,相当于A、B字符串相似度高

使用场景

  • 文本相似度计算
  • 兴趣标签匹配、兴趣推荐(A用户买了5个商品,B用户买了3个,C用户买了6个,计算这几个用户分别的Jaccard ,给相似度高的用户推荐他没买过但是相似用户买过的商品)

协同过滤

最早的推荐系统核心是"协同过滤",这个词第一次听到会让人感觉懵逼,但原理其实很简单:

假设小明看了视频A、B、C,小红看了视频A、C、D。系统发现小明和小红都看了A和C,那么可能会认为:

  • 应该把视频D推荐给小明(因为跟他喜好相似的小红喜欢D)
  • 应该把视频B推荐给小红(因为跟她喜好相似的小明喜欢B)

这就是"协同过滤"的基本思想:找到与你兴趣相似的用户,然后把他们喜欢的内容推荐给你。需要注意的是:在这个过程里,系统完全不需要知道视频内容是什么,只需要知道"谁看了什么"。

系统会不断地将喜好相似的用户自动归为一组,就像把爱看同类节目的观众凑到一起。一旦你加入了某个"兴趣圈子",系统就会把圈内其他人看过、而你还没接触的内容推荐给你。更巧妙的是,当你刷视频时的每一次点赞、评论、驻足观看,都在悄悄更新你的兴趣画像,让系统重新评估你应该属于哪些圈子。今天你可能因为关注美食视频而加入"吃货联盟",明天又因为看了几个旅行视频而被划入"旅行爱好者"的行列。这个过程不断循环,推荐内容也随之动态调整,让你总能看到与当前兴趣相符的新内容。

不过现在已经进入了深度学习和大数据时代,自然不会再用协同过滤这种原始的方法了!比如抖音用到过的Wide&Deep模型,双塔召回模型等等

随着AI能力的发展,我们现在已经能够比较轻松的对图片/视频/文字里面的内容进行分析了,这时候就可以对资源本身打上标签了,甚至计算向量,直接把你喜欢的内容推荐给你

如果你对推荐系统感兴趣,可以看看这个:

https://95152.douyin.com/article/15358?enter_from=channel_page&channel=transparency%EF%BC%8C