ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

中文编辑器纠错引擎CNSH:同音字形近字与上下文评分设计

中文编辑器纠错引擎CNSH:同音字形近字与上下文评分设计 做中文编辑器纠错引擎这件事最初是因为我们团队在维护内部文档系统时被错别字折磨得不轻。英文拼写检查有现成的库换上就能用但中文内容里“部署”写成“布署”、“再接再厉”写成“再接再励”这类问题通用方案基本束手无策。于是我们决定自己做一套中文编辑器纠错引擎项目代号龍魂系统产品注册标识UID9622引擎内部叫CNSH。这篇文章把整个设计、实现和调优过程整理出来给同样在做编辑器、写作工具或输入法后端的同学一个参考。1. 中文纠错为什么不能照搬英文拼写检查的那套逻辑网上不少方案喜欢直接把英文的spell checker思路平移过来词典里查不到的词就是错然后用编辑距离找最近的有效词。这套路在英文里成立是因为英文书写有天然的空格分词单词形态变化虽然有但相对规则。到了中文这里情况完全不同。1.1 英文拼写检查的成熟套路先快速回顾一下英文方案为什么好用。英文拼写检查核心就三步词表查漏oov检测、编辑距离召回候选、n-gram语言模型打分。比如用户敲了 recieve词表里没有编辑距离1范围内能找到 receive再结合上下文概率选出来。这个流程非常成熟甚至可以做得很快因为英文单词总量和形态变化都在可控范围。但问题在于这套流程的每个环节都依赖英文的一个基础事实词与词之间有空格分隔错误大多发生在字符级别的替换、插入、删除上。中文没有空格词边界本身要先靠分词解决。分词错了后面所有纠错逻辑都跟着出错。1.2 中文错误的三大来源同音、形近、分词歧义我们收集了大量真实写作场景的错误样本统计下来发现中文错误主要来自三个方向。同音字/近音字错误是最大的一类占比接近一半。这和中文输入方式强相关拼音输入时用户想到的发音是对的但选字选错了。“部署”打成“布署”“即使”打成“既使”“截止”和“截至”混用全是这个类型。这类错误的特征是拼音完全相同或仅在声调、前后鼻音上有差异但字形上毫无关联。形近字错误占比大约三成。这类错误在视觉上极其隐蔽“未”和“末”、“己”和“已”、“戊”和“戌”人在快速阅读时几乎察觉不到。手写输入和OCR场景里更加常见。这类错误的特征是字形结构相似发音不一定相关。**第三类是多字、漏字和分词歧义。**比如“一诺千金”写成“一诺千斤”是形近加同音混合而“南京市长江大桥”这种经典歧义句则是分词阶段的难题。这类错误无法靠单点词典解决必须引入上下文信息。1.3 引擎定位我们做的是编辑器中间件正因为中文错误种类复杂龍魂系统从第一天起就没打算做成一个“全知全能的语法检查器”。我们的定位很明确一个可以嵌入任何编辑器的纠错中间件提供的是“候选召回排序”的能力而不是“对错判决”。编辑器拿到结果后可以自己决定怎么展示、什么时候忽略。UID9622是龍魂系统在内部构建流水线上的注册标识。每个版本的引擎都对应一个UID方便在不同编辑器插件和云端服务之间做版本追踪。这个设计后来帮了大忙线上反馈回来的误报样例我们能直接定位到是哪个词典版本、哪个规则模块引入的问题不用整个回滚。2. CNSH引擎的三层流水线召回、修正、上下文评分各管一摊CNSH引擎的总体架构是一条三阶段流水线候选召回、规则修正、上下文评分。每层只干一件事层与层之间通过结构化数据进行交互。这样设计一是因为三个模块的更新频率完全不同二是因为出问题时能单独调试。2.1 单词粒度候选召回先别急着判断对错第一步是把输入的连续文本切开切成词级别的候选片段。我们没有用通用的分词库直接给整句分词而是用滑动窗口生成候选区。为什么因为纠错场景里原文本身就可能有错通用分词器在一个错词上会切出奇怪的结果。具体做法是对每个位置生成从2字词到6字词的所有可能窗口。每个窗口去词典和倒排索引里召回候选词并计算原始窗口文本和候选词的相似度。这一层不做任何判断只负责“这个东西像不像一个真实词”。这里的召回索引是性能关键。我们给词典建了三个索引正排Trie用于精确匹配拼音倒排索引用于同音召回形近索引用于字形召回。拼音索引是核心因为同音错误占了近一半。形近索引我们用了一个相对简单的策略后面会详细说。2.2 规则修正层易错词表、搭配约束和语法轻规则召回出来的候选集先进规则层过滤。这一层主要做三件事。第一件事是查易错词表。我们维护了一份精心整理的易错词表里面收录了常见错词、推荐改法、错误类型三个字段。比如“布署→部署同音”“按步就班→按部就班同音”“穿流不息→川流不息同音近形”。这份表不是一次性建完的而是持续从线上误报样本和公开的中学语文易错字表里补充。第二件事是搭配约束。有些词单独看没问题放进固定搭配里就是错的。“截止”和“截至”就是典型在“截止到明天”和“截至明天”里“截止”后跟“到”时是合理的但“截止昨天”这种用法就很别扭。我们在规则层维护了一批“搭配对”用局部上下文判断搭配是否正确。第三件事是轻量语法规则。目前只做了“的/地/得”的区分以及在明确句式中判断“做”和“作”的用法。这类规则容易误报所以每条规则都带了一个置信度低于阈值的规则不生效。2.3 上下文评分层n-gram打分与词频权重规则层筛完剩下的候选进入评分层。我们用的是最实用的三元语言模型加词频先验的组合打分没有上复杂神经网络。原因很简单要能离线跑、要在低配置机器上达到毫秒级响应、要可解释。为了一个编辑器纠错引擎上大模型性价比不高。评分函数大概是这样的def score_candidate(orig, cand, left_context, right_context): # 语言模型打分cand与上下文拼接后的概率 lm_score bigram_score(left_context, cand) bigram_score(cand, right_context) # 词频先验常见词优先 freq_score log(freq(cand) 1) # 字形/拼音相似度代价越低越可能是同音或形近误写 edit_cost similarity_cost(orig, cand) return alpha * lm_score beta * freq_score - gamma * edit_costalpha、beta、gamma三个权重是通过标注集调出来的。一开始我们凭感觉把alpha设得很大结果误报率居高不下后来发现是因为小领域语料里一些低频但正确的搭配被打压了。最终调下来词频先验的权重占了不小的比例语言模型反而没那么关键。2.4 模块解耦与热加载UID9622的工程形态三层模块在工程上是三个独立组件通过协议接口通信。词典和规则表支持热加载每次发布新版本会生成新的UID。编辑器插件可以主动拉取版本更新不需要重启编辑器。这个热加载机制在调试时极其好用改一条易错词规则本地刷新一下就能生效不用重新构建整个引擎。这种解耦也带来一个好处不同的编辑器插件可以只使用其中某些层。比如我们后来给某个代码编辑器做了注释纠错插件只用了召回层和规则层的同音检测关闭了形近检测因为代码注释里“未完成”写成“末完成”的情况并不多反而需要降低误报。3. 拼音和形近字纠错的核心实现从字到音的映射与形似度量如果说架构是骨架那拼音纠错和形近字纠错就是这套引擎的两条腿。这一部分把具体实现细节展开讲包括数据怎么建、距离怎么算、阈值怎么定。3.1 汉字到拼音的映射与模糊音归一化拼音纠错的前提是拿到每个汉字的标准拼音。我们自建了一份汉字拼音映射表数据来源是公开的GB2312汉字拼音库覆盖了常用字范围。每个汉字存全拼、声母、韵母、声调四个字段。多音字比如“长”、“行”、“乐”每个读音都建一条记录召回时如果拼音匹配命中了其中任意一个读音都当作候选。关键的一步是模糊音归一化。中文输入错误里前后鼻音和平翘舌混淆非常普遍比如“担心”的“担”dan被误读成“dang”。我们在建拼音倒排索引时把所有拼音先归一化一套“模糊音类”in/ing归为一类en/eng归为一类zh/z归一类ch/c归一类sh/s归一类n/l归一类f/h归一类。这样“南京”和“兰京”在模糊音索引下会互相召回因为很多方言区用户根本分不清n和l。这个归一化方案在召回阶段能大幅提高召回率代价是召回候选里混入大量无效词。比如用户写“你好”模糊音检索会把“泥好”“梨好”全捞出来。所以模糊音归一化只在召回阶段使用进入评分阶段前会计算真实拼音的编辑距离把模糊音召回但实际拼音差太远的候选过滤掉。3.2 拼音纠错的候选生成与距离度量有了拼音索引候选生成就变成了一个倒排检索问题。用户词“布署”的全拼是“bushu”我们把它归一化成“bushu”去拼音倒排表里查所有拼音为“bushu”的汉字组合召回“部署”“布署”“部属”等。召回之后用编辑距离做一次粗筛。编辑距离允许的操作包括替换、插入、删除所有操作代价统一为1。我们针对不同长度的词设置了不同阈值单字词不做拼音纠错单字同音改写的误报率太高比如“他”改成“她”这种改写没有足够上下文根本判断不了。双字词全拼长度4到6时编辑距离阈值设为1。三字及以上编辑距离阈值放宽到1极少情况下放宽到2但必须满足“声母完全相同”的强约束。这个阈值不是拍脑袋定的。我们拿3000条人工标注的同音错句做实验阈值定到1时同音错误的检出率在78%左右阈值放宽到2检出率能涨到85%但误报率从4%直接跳到11%。最终保持阈值1再加模糊音召回是最划算的组合。3.3 形近字纠错字形特征与混淆集形近字纠错没有拼音那么好做因为“长得像”本身是个模糊概念。我们的做法分两层。第一层是部件拆解笔画计数。把汉字拆成左右、上下、包围、独体等结构记录每个字的部首、总笔画数、剩余笔画数。两个字的笔画数相差在2画以内且结构类型相同才进入形近候选。这一步很快纯查表能过滤掉大部分不相关的字。第二层是人工维护的混淆集。笔画规则能找出“巳”和“已”这种差异极小的但找不出“拔”和“拨”这种部首相同、右侧部件不同的字。所以我们维护了一张形近字混淆表核心是高频错字对比如未末、己已、戊戌、拔拨、脑恼、徒陡。每一对都标注了差异描述方便在做错误解释时给用户展示“字形相近注意甄别”。实际使用中形近纠错的启用条件比拼音纠错更严格必须同时满足“该词在词表中存在”和“形近替换后的词在词表中存在”并且形近字对必须击中混淆集才允许产生候选。纯靠笔画规则找出来的形近词我们默认不产生纠错建议因为误报太严重了。3.4 候选排序把三类信号融合成一条分数一个错误的原始词经过拼音召回和形近召回可能得到多个候选。比如用户写了“急时”拼音召回可能得到“即时”“及时”“基石”形近召回几乎没有。怎么排序我们的做法是把三类信号加总成一条分语言模型分数、词频先验、替换代价。替换代价里拼音编辑距离和形近距离分别归一化到相同区间。为了让同音错误更容易被修正确认我们对“拼音完全一致”的候选额外加一个bonus对“只是形近但发音差异很大”的候选扣掉一点分。这套排序方案在多数场景下表现稳定。如果“及时”在当前上下文里出现的概率远高于“即时”排序自然会把“及时”放到第一位编辑器默认展示第一个候选即可。4. 编辑器接入实战增量计算、延迟预算和交互细节引擎本身做得再好接不进编辑器也是白搭。这一章讲CNSH引擎怎么和编辑器配合重点说三个问题什么时候触发计算、怎么保证不卡顿、以及怎么设计交互才能让人愿意用。4.1 编辑器的接入形态与事件流处理CNSH引擎对外提供的是一个本地库级别的接口编辑器插件直接调用不走网络请求。接口就两个check(text)返回纠错建议列表accept(correction_id)反馈用户接受了哪条建议。数据格式用协议缓冲区定义方便后续做跨语言封装。编辑器端的事件流处理是第一个坑。用户输入时每次按键都触发一次全量检查性能绝对扛不住而且会产生大量无用计算。我们加了两层节流键盘事件后300毫秒防抖用户停止输入后才触发检查。光标位置快速变化时跳过检查只更新已经存在的纠错标记。实测下来对于一个200字的段落单次检查耗时稳定在30毫秒左右用户基本感知不到。在低端办公本的测试环境里这个时间会涨到60毫秒仍然在可接受范围。4.2 脏区增量计算只重算用户正在输入的区间编辑器里的文本可能很长全量重算肯定不现实。我们做了脏区标记机制每次检查只处理光标附近的内容。一个具体的例子文档有5000字用户在第2000字后面打字。此时脏区就是光标前200字到光标后50字的区间总共250字。引擎只对这段区间做词汇切分和纠错之前检查过的部分完全不动。用户一旦接受了某条纠错建议被修改的位置前后各20字会被标记为脏区重新检查一遍防止修改引入了新的上下文错误。脏区的宽度需要根据文档类型调整。技术文档里错误密度低200字窗口足够自由写作场景里错误密度高窗口可以放宽到300字。窗口越宽上下文信息越足但计算量成倍增长这个取舍值得每个接入方自己实验。4.3 性能预算和缓存策略我们给自己定了一个性能预算单次检查不超过50毫秒内存占用不超过200MB索引加载时间不超过2秒。为了达到这个预算做了几件事。第一词典索引全部加载到内存用双数组Trie实现精确匹配。双数组Trie的查询是O(n)的n是词长实际查询1万次累计耗时不到10毫秒。第二对拼音倒排索引做了两级缓存。第一级缓存热词拼音的候选结果比如“部署”“即使”“截止”这类高频词不重复计算第二级缓存整句检查结果如果编辑器在短时间内重复提交相同文本直接返回缓存连检查都不用做。第三所有计算在独立工作线程里跑绝不在UI线程里做任何词典查询。编辑器UI卡顿会直接毁掉用户体验这个没有商量余地。4.4 交互设计不打断输入但让纠错可见交互层面踩过不少坑。第一版我们用的红色波浪线和编辑器的语法错误标识撞了用户分不清哪个是拼写错误哪个是语法错误。后来改成深蓝色虚线加了一个悬浮卡片里面显示“建议修改为部署”点击即可替换还额外显示错误类型标签比如“同音字错误”。第二个坑是纠错建议的弹出时机。最开始是鼠标悬停就弹出结果光标划过时弹窗乱闪特别烦人。后来改成两种触发方式键盘快捷键唤起以及选中错误词之后点击图标。默认不做任何弹出保持写作界面干净。还有一个非常关键的细节编辑器里的代码块、URL、邮箱地址必须跳过检查。我们早期没有做这个过滤代码块里到处是红色波浪线用户一度想直接卸载插件。现在增加了一个分段器识别编辑器的代码块和链接区域这些内容直接不进引擎。5. 数据评测和线上调优我们如何压低误报率纠错引擎最怕的不是漏报而是误报。用户写对了编辑器硬要说错了连续来几次用户就把功能关了。所以评测阶段我们把误报率放到和检出率同等重要的位置所有调优实验都以“不显著增加误报”为前提。5.1 标注集建设错误语料从哪来做评测需要一套带标注的错误语料。我们人工构造了1200条中文句子其中600条是含有明确错误的句子另外600条是从新闻和技术文档里摘出来的正确句子用作误报测试。错误类型的分布尽量贴近真实场景同音错误占40%形近错误占30%多字少字占15%其他类型占15%。每一条错误句子都标注了错误位置、正确写法、错误类型三个字段。正确句子则要求必须包含纠错引擎容易误判的内容比如品牌名“蔚来”、人名“王菲”、专业术语“计算机视觉”等。这套标注集一直在扩充每次从线上收集到新的误报样例经过确认后就会补进去。目前规模已经超过5000条覆盖了领域词、品牌词、文言文片段等容易出问题的类型。5.2 指标口径检出率、精确率和误报率的权衡我们看四个指标检出率召回率被正确检测出的错误占全部错误的比例。精确率建议中确实是错误的建议占全部建议的比例。误报率正确句子中被给出任何建议的比例。F1精确率和召回率的调和平均。实际中检出率和误报率很难同时优化。提高检出率的方法比如放宽编辑距离阈值、增加模糊音规则几乎必然带来误报率上涨。我们在产品决策上设定了一个底线误报率不能超过2%。在这个前提下尽可能提高检出率。原因很简单写作场景里用户对误报的容忍度极低一次误报可能就让用户不再信任这个功能。5.3 三类调优实验的具体数据分享三个实验数据看过之后你就明白为什么我们在前面反复强调阈值和权重。实验一拼音编辑距离阈值从1调到2。阈值检出率误报率单条语气均检查耗时176.8%1.1%28ms285.2%5.7%41ms检出率提升了8.4个百分点看起来很诱人但误报率翻了五倍。很多用法正常的句子比如“报案”写成“报案”本身没问题但被错误召回开始出现波浪线。最终我们没有放开阈值而是通过加强上下文评分的方式提升检出率。实验二加上品牌词库和用户词库后的误报变化。配置误报率仅基础词典3.8%基础词典品牌词库2.1%基础词典品牌词库用户词库1.3%品牌词库的效果立竿见影。“蔚来”建议改成“未来”、“阿里”建议改成“啊里”这类错误几乎绝迹。用户词库则让个人写作里的专有名词不再被打扰误报率进一步下降。实验三词频权重beta从0.3调到0.6。beta检出率误报率0.278.1%1.4%0.480.3%1.2%0.678.9%1.5%中间值最合适。权重太低低频正确词被语言模型压过误报上升权重太高高频错误词获得过多保护检出率下降。这个实验告诉我们调参不能只盯一个指标要综合看。5.4 线上反馈闭环用户行为是最好的标注评测集只能保证上线前的基本质量真正的调优动力来自线上反馈。我们在插件里悄悄记录了两类行为用户忽略了哪些建议、用户接受了哪些建议。忽略次数多的建议会进入“疑似误报”池经过人工确认后加入黑名单或者调整规则接受次数多的建议会反哺易错词表变成高频纠错规则。这个反馈闭环跑起来之后引擎的误报率每季度都能下降一个台阶。一开始的1.1%误报率经过三轮迭代降到了0.6%左右而检出率基本稳定在78%上下。说实话这个水平离“完美”还很远但已经足够让用户不反感这个功能了。最后再补一句。做纠错引擎这件事真正难的不是某个算法有多精巧而是你愿不愿意花时间去整理那些脏乱的易错词表、混淆集和规则。它们看起来不像算法那样“高大上”但恰恰是这些东西决定了引擎在真实场景里是好用还是添乱。CNSH走到今天一半靠的是工程架构另一半靠的是那份持续维护的词典库。如果你也在做类似的工具建议从一开始就把词库和质量数据的建设当成和引擎本身同等重要的事。
返回列表