做中文编辑器纠错引擎这件事,最初是因为我们团队在维护内部文档系统时,被错别字折磨得不轻。英文拼写检查有现成的库,换上就能用,但中文内容里“部署”写成“布署”、“再接再厉”写成“再接再励”这类问题,通用方案基本束手无策。于是我们决定自己做一套中文编辑器纠错引擎,项目代号龍魂系统,产品注册标识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。
| 阈值 | 检出率 | 误报率 | 单条语气均检查耗时 |
|---|---|---|---|
| 1 | 76.8% | 1.1% | 28ms |
| 2 | 85.2% | 5.7% | 41ms |
检出率提升了8.4个百分点,看起来很诱人,但误报率翻了五倍。很多用法正常的句子(比如“报案”写成“报案”本身没问题但被错误召回)开始出现波浪线。最终我们没有放开阈值,而是通过加强上下文评分的方式提升检出率。
实验二:加上品牌词库和用户词库后的误报变化。
| 配置 | 误报率 |
|---|---|
| 仅基础词典 | 3.8% |
| 基础词典+品牌词库 | 2.1% |
| 基础词典+品牌词库+用户词库 | 1.3% |
品牌词库的效果立竿见影。“蔚来”建议改成“未来”、“阿里”建议改成“啊里”这类错误几乎绝迹。用户词库则让个人写作里的专有名词不再被打扰,误报率进一步下降。
实验三:词频权重beta从0.3调到0.6。
| beta | 检出率 | 误报率 |
|---|---|---|
| 0.2 | 78.1% | 1.4% |
| 0.4 | 80.3% | 1.2% |
| 0.6 | 78.9% | 1.5% |
中间值最合适。权重太低,低频正确词被语言模型压过,误报上升;权重太高,高频错误词获得过多保护,检出率下降。这个实验告诉我们,调参不能只盯一个指标,要综合看。
5.4 线上反馈闭环:用户行为是最好的标注
评测集只能保证上线前的基本质量,真正的调优动力来自线上反馈。我们在插件里悄悄记录了两类行为:用户忽略了哪些建议、用户接受了哪些建议。忽略次数多的建议会进入“疑似误报”池,经过人工确认后加入黑名单或者调整规则;接受次数多的建议会反哺易错词表,变成高频纠错规则。
这个反馈闭环跑起来之后,引擎的误报率每季度都能下降一个台阶。一开始的1.1%误报率,经过三轮迭代降到了0.6%左右,而检出率基本稳定在78%上下。说实话,这个水平离“完美”还很远,但已经足够让用户不反感这个功能了。
最后再补一句。做纠错引擎这件事,真正难的不是某个算法有多精巧,而是你愿不愿意花时间去整理那些脏乱的易错词表、混淆集和规则。它们看起来不像算法那样“高大上”,但恰恰是这些东西决定了引擎在真实场景里是好用还是添乱。CNSH走到今天,一半靠的是工程架构,另一半靠的是那份持续维护的词典库。如果你也在做类似的工具,建议从一开始就把词库和质量数据的建设当成和引擎本身同等重要的事。