Rime 词级 bigram 过滤器:在 Lua 里做本地上下文调频

约 6 分钟阅读插件与扩展

文章目录8 节

rime_context_filter 是一个跑在 Rime 里的词级二元模型过滤器:按"前一个词"调整候选顺序,落盘与提权分离,热路径无磁盘 IO,保存走 tmp→校验→bak→替换。本文拆它的打分公式、生命周期钩子与原子落盘策略。

输入法里加"联想"很容易做坏——常见的失败模式是:把词频、后缀匹配、字符串拼接混在一起,最后得到一个经常乱序、还悄悄覆盖用户词库的过滤器。

rime_context_filter 的定位很窄,也很清楚:只做词级二元模型(bigram),只按相邻两个有效词的搭配统计来微调候选顺序,其余交给 Rime 原生的用户词典(造词、调频、删词都不越权)。

一、它做什么、不做什么

效果可以一句话描述:当你先确认了"接下来的",之后再输入 renwu,如果"任务"在相同前文下被稳定选择过两次以上,它就会被适度提前。

边界同样重要:

  • 不做字符串后缀匹配、不做拼接伪高阶:只统计"词 A → 词 B"的相邻共现;
  • 不把分数写进 Rime 的 quality:模型分只用于同 span 内的相对重排;
  • 不替代用户词典:它只是过滤器链末端的一道排序修正。

二、打分:概率 × 置信度

1
score(B | A) = P(B | A)(加性平滑) × 置信度(count)

工程上的关键设计是"落盘 ≠ 提权":

  • count >= 1 就持久化(保留统计,便于长期观察);
  • 但真正提前候选需要 count >= promote_min_count(默认 2)且分数过门槛;
  • 单次出现只记账,不会把候选顶上去——这正是"只用过一次的词"不该改写排序的意思。

低置信度一律保持原序;promote_min_count、max_promote(每个输入范围最多提前几个)、protect_prefix(前 N 个候选冻结,可配合置顶)都可以配。

三、生命周期:在哪个钩子上学习

Rime 的过滤/通知器是这套逻辑的骨架:

1
2
3
4
select (group 0) → 更新窗口,词对暂存
update → 退格回滚 / Esc 取消(不学习)
commit → 落成 bigram;达到 save_interval 则存盘
filter → 仅用 P(w|prev) × 置信度;同 span 内稳定提权;yield 原 Candidate

几处细节值得抄:

  • select_notifier 挂在 group 0 上,保证在无分组回调之前触发,刚选的词不会被下一次更新冲掉;
  • commit_notifier 用一个 _in_commit 标志抑制提交期间的重入更新;
  • 标点、英文、空闲超时都会断开上下文(idle_timeout_sec 默认 120,用 os.time() 判断),避免"半小时前的词"还在影响现在的排序;
  • 整句无分段直接上屏时,只有整段本身是可学习的单个词才学习,否则断开。

四、性能:热路径不碰磁盘

过滤器在每个按键周期都会跑,所以:

  • 热路径(filter/score/reorder)没有任何磁盘 IO,无有效前文时直接透传;
  • 存盘按 save_interval(默认 30 次上屏)触发;save_interval = 0 表示每次上屏都存;触发条件是 dirty_commits >= interval 或 commit_count >= interval;
  • 容量上限 max_contexts = 4000、max_successors = 48,超出后淘汰低频;
  • reorder_limit = 80:只重排前 80 个候选,超出部分保持原样(后面本来也没人选);
  • 测试里给了明确的性能门槛:800 个上下文 × 每个 20 个后继,500 轮 score_candidates + 对 80 个候选重排必须 < 2.0 秒;存盘 800 个上下文 < 5.0 秒。

五、落盘:原子替换 + 损坏保护

输入法的数据文件最怕两件事:写坏、丢。这里的策略是"先保命再替换":

1
2
写 .tmp → 校验 → 主文件改名为 .bak → .tmp 改名为主文件
失败路径返回类型化原因:open_tmp | write | flush | close | rename_to_bak | rename_tmp
  • 任一步失败都保留旧数据并把 dirty 留着下次重试,同时累加 save_failures;
  • 启动时如果主文件缺失,恢复顺序是 .bak → .tmp;
  • 损坏文件不会被静默当成空表覆盖:如果损坏后仍有新学习,会写到旁路文件 context_learned.data.recovered;
  • 同步冲突文件(*.sync-conflict*)不会被自动删除或合并——这是把决定权留给用户。

数据格式是版本化的 v7:

1
2
3
4
5
6
7
return {
_meta = { version = 7, decay_at = 1735689600, saved_at = 1735689600 },
data = {
["接下来的"] = { ["任务"] = 8, ["工作"] = 3 },
["完成"] = { ["任务"] = 1 },
},
}

可以读 v5 扁平格式与 v6(_meta + data 但无 version),首次保存写成 v7。按自然日衰减(decay_rate = 0.95、decay_period_days = 1),与打字频率无关。

另外,模块级共享一个 STORE(learned/totals/meta/dirty/refs/save_failures),多个过滤器实例不会用过期快照互相覆盖;refs 在 bind/unbind 时计数,reset_store_for_tests() 供测试清理。

六、装在哪、怎么验证

必须追加到 engine/filters 末尾(uniquifier 之后)。用 "engine/filters/@after 6" 会插到 pin_cand_filter / emoji / 简繁 / uniquifier 之前,可能打乱置顶与去重;脚本对 ★/📌/emoji type 有保护,但那不能替代正确的顺序。

测试与 CI:

  • 单个 Lua 测试文件分 13 组:utf8 helper、可学习词与断句符、序列化保留 count==1、衰减、bigram 打分(专门断言不存在后缀/拼接式伪高阶)、重排稳定性与 span 隔离、保护位与去重身份、读写往返(含损坏与原子失败)、学习流程、多实例共享不互相覆盖、容量淘汰、候选对象身份与低置信度透传、路径解析、性能冒烟;
  • 具体的断言数字也很实在:prune_store(max_ctx=5) 后正好剩 5 个上下文且保留热度最高的;max_succ=10 后剩 10 个后继且 totals 与剩余和一致;重排后顺序为 任务,工作,人物,任命;分数 0.05 < PROMOTE_SCORE 时顺序不变;
  • CI 三个作业:luacheck 静态检查,外加 Lua 5.3 / LuaJIT / Lua 5.1 三套解释器各跑一遍全部测试。

七、限制

  • fini 只是尽力而为的落盘手段,进程退出仍可能丢掉未达 save_interval 的改动;
  • 旧版本曾丢弃 count == 1 的搭配,那部分历史无法无损恢复;
  • 不处理同步冲突文件;模型分不进 quality,所以它只影响同一次输入的候选顺序,不改动 Rime 自身的词频。

小结

它是"小模型也值得工程化"的样本:统计与提权解耦(可以放心记录而不用担心污染排序)、热路径零 IO、原子落盘 + 显式失败原因、跨版本兼容读取。这几条放在任何本地学习功能里都成立。

仓库:sakuradairong/rime-lua-scripts(MIT)

搜索文章

按 / 打开搜索,Esc 关闭;多个关键词用空格分隔,搜索在本地完成。