文章目录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 | select (group 0) → 更新窗口,词对暂存 |
几处细节值得抄:
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 | 写 .tmp → 校验 → 主文件改名为 .bak → .tmp 改名为主文件 |
- 任一步失败都保留旧数据并把
dirty留着下次重试,同时累加save_failures; - 启动时如果主文件缺失,恢复顺序是
.bak→.tmp; - 损坏文件不会被静默当成空表覆盖:如果损坏后仍有新学习,会写到旁路文件
context_learned.data.recovered; - 同步冲突文件(
*.sync-conflict*)不会被自动删除或合并——这是把决定权留给用户。
数据格式是版本化的 v7:
1 | return { |
可以读 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)