1. 先把 FST 说清楚:别被“转换器”三个字唬住
FST,全称 Finite State Transducers,中文一般译作“有限状态转换器”,也有人干脆叫它“带输出的状态机”。我第一次真正用上它,是在一个词表规模接近三百万条的检索项目里:早期用哈希表存词,内存吃到几个 G;后来换成 Trie,内存降了一半,但前缀遍历一旦并发上来,响应时间抖得厉害。最后换成 FST,二进制体积压到十几兆,查询稳定在亚毫秒级,才算把这个问题按下去。所以这篇不打算从论文公式讲起,而是从一个工程从业者的角度,把它是什么、为什么这么设计、怎么落地、哪里容易翻车,一条条摊开讲。
先说结论性的定位:FST 干的活是“吃进去一串符号,吐出来一串符号,中间顺便把路径记住”。它同时具备两件事——像自动机一样判断某个输入串是否合法,像查表一样在判断过程中输出对应结果。这两件事合起来,就是它能替代大量静态词典、映射表和规则集的原因。适合谁来读?如果你做过搜索、输入法、分词、拼写纠错、文本规范化、语音识别后处理、协议解析,或者在任何一个需要“海量字符串 + 快速匹配 + 内存敏感”的场景里挣扎过,那这套东西大概率能省你不少事。完全没接触过自动机也能看,我会把前置概念补齐。
1.1 一个朴素需求:三百万条词怎么查得快又省内存
把需求还原到最朴素的形态:手上有一份词表,三百万行,每行是一个词条,可能还带一个编号、词频或者标签。线上要做三件事——给一个词,判断它在不在、返回它的附加信息;给一个前缀,把以它开头的所有词都捞出来;给一个带通配符的模式,把匹配的词找出来。这三件事听起来都是“查表”,但实现方式差别极大。
哈希表能解决第一件事,O(1) 查询很香,代价是内存。三百万个词,平均长度按 10 个字符算,光字符数据就 30MB,加上每个 entry 的指针、哈希桶、负载因子留白,实际占用轻松到 500MB 以上,而且它天然不支持前缀遍历和通配。Trie 解决了后两件事,代价是节点爆炸:每个字符一个节点,每个节点挂一个子节点表,英文小写字母场景下每个节点就是一个 26 长度的数组或一个哈希表,三百万词平均 10 个字符,节点数在两千万级别,内存反而更难看。
FST 的思路是:把词表看成一张图,字符就是图上的边,公共前缀共享同一条路径,公共后缀也共享同一条路径,再把“输出”挂到边上或终点上。共享前缀这件事 Trie 已经做了,FST 多做的是共享后缀——这一步直接把状态数砍掉一个量级。更关键的是它构建完之后是不可变的,可以序列化成一整块连续字节,加载时内存映射就行,多进程共享同一份物理内存,这在容器化部署里非常友好。
1.2 FSA 只负责“认”,FST 边认边“吐”
要理解 FST,先把它的简化版 FSA(Finite State Automaton,有限状态自动机)摆出来。FSA 是一张有向图,有唯一的起点,若干终点,每条边上标一个输入符号。你从起点出发,按输入串的字符一个个走边,走完如果停在终点,这个串就被接受,否则拒绝。它回答的只有“是/否”。
FST 在这个基础上给每条边加了第二个标签:输出符号。走一条边,既要匹配输入符号,又要产出一个输出符号。走到终点时,把沿途吐出的输出符号拼起来,就是这条输入对应的结果。这就从“判断”升级成了“翻译”。
举个具体的:把阿拉伯数字串转成中文读法。输入1、2、3,路径上分别吐出一、二、三,末状态再根据位数吐出单位。也可以做反向的映射,或者做一对多的映射,比如把公制单位转成英制。再比如形态学分析里,输入cats,输出cat +PL(原型 + 复数),这是典型的 FST 用法。
这个“边认边吐”的性质,让它天然适合做映射关系而不是简单的集合查询。词典查询其实是它的退化用法:输出固定为词条 ID,或者干脆不输出,只判断是否接受。理解了这一层,后面看各种实现细节就不会迷路。
1.3 结构选型对照:哈希表、Trie、双数组、FST
选型这件事没有绝对答案,我按实际项目里最常被拿来比较的四种结构列个表,参数取自我自己做过的几组压测(百万级词表、平均词长 8~12 字符、UTF-8 字节序存储),量级上有参考价值,具体数字随数据分布浮动。
| 结构 | 内存占用(百万词) | 精确查询 | 前缀遍历 | 通配匹配 | 增量更新 | 实现复杂度 |
|---|---|---|---|---|---|---|
| 哈希表 | 400MB~1GB | 极快,O(1) | 不支持 | 不支持 | 简单 | 低 |
| Trie | 300MB~800MB | 快,O(L) | 支持 | 支持,但要遍历 | 简单 | 低 |
| 双数组 Trie | 30~80MB | 很快,两次寻址 | 支持 | 支持 | 困难,需重建 | 中 |
| 最小 FST | 5~20MB | 快,O(L) | 支持 | 支持 | 不可变,需重建或侧挂 | 高 |
数字看清楚:差异不是百分之几十,是一个数量级。最小 FST 之所以能压这么低,核心就两条——共享后缀 + 序列化成紧凑字节。双数组 Trie 也很省,但它的更新成本高得离谱,插入一个词可能要搬迁一大片状态,只能接受全量重建。
我的经验是这么切的:数据量在十万以下、更新频繁、只要精确查询,哈希表就够,别过度设计;数据量在百万级、需要前缀和通配、基本只读,FST 是最优选;如果还要支持高频增量写,那就在 FST 之上做一层小的“侧挂结构”,新词先写到小的哈希表或者小的 Trie 里,查询时两边都查,定期合并重建。这套组合拳我在两个项目里都用过,落地成本可控。
2. 内部构件拆解:状态、弧、输出与三条工程红线
知道了 FST 大概长什么样,接下来得把它的构件拆开看。很多实现层面的坑,根源都是对这四样东西的理解有偏差:状态、弧、输出标签、权重。教科书上会给一个七元组定义,实际写代码的时候你真正要盯的就三件事——每次插入怎么找边、输出挂在哪、构建完之后怎么保证查询路径唯一。
2.1 一条弧上到底挂了哪些字段
一条弧至少包含四个信息:输入标签(走这条边要匹配什么符号)、目标状态(走完之后在哪)、输出标签(走这条边吐出什么)、以及一个标记位(标记这是不是当前状态的最后一条出边,用来做紧凑存储)。
在内存里的实现通常是这样的:每个状态维护一个出边列表,列表按输入标签有序排列,查询时在这个小列表里做二分或线性扫描。序列化到字节数组之后,每个状态只存一个偏移量,出边全部平铺在连续内存里,靠 flag 位判断“该状态的出边到此为止”。这样一次查询就是若干次跳跃,每次跳跃都在一小段连续内存里,CPU 缓存命中率高得离谱。
输出标签的位置有两种常见策略。放在弧上的好处是边走边产出,实现直观;推到终态的好处是共享程度更高,同一条前缀路径可以被多个不同的输出复用。工程里通常采用一种折中:插入结束后做一次“输出前推”,把公共输出尽可能往上顶,让后继共享,这一步在构建期做一次,查询时零成本。我见过有人把输出放在每一个中间状态上,结果 FST 体积膨胀了三倍,就是这个策略没选对。
2.2 确定性、ε-free、最小性:少一条就出乱子
这三个性质是工程红线,不是学术洁癖。
确定性指的是:对同一个状态、同一个输入符号,最多只有一条出边。如果不满足,同一个输入串可能有多条路径,查询时就得回溯或者枚举,复杂度立刻从线性涨到指数级。玩具实现里最容易犯的错就是插入时没检查已有边,直接 append,结果同一状态出现两条相同标签的弧,查询走到第一个就停了,后面那条永远走不到。
**ε-free(无空转移)**指的是:不存在输入为空的边。ε 边在理论上是好东西,写规则的时候特别方便,但在工程实现里它意味着“不消耗输入也能前进”,查询时必须做闭包计算,稍不留神就写出死循环。标准的处理是在构建期做一次 ε 消除,把 ε 边的输出合并到相邻边上。这一步做完,查询循环就变成一个干净的for char in input,没有任何嵌套。
最小性指的是:图里不存在两个等价状态。两个状态等价,当且仅当它们接受的后缀集合相同、且输出行为相同。如果存在等价状态,说明还有压缩空间。最小化算法本身不复杂——从终态开始反向往回做,每个状态算一个签名(是不是终态、终态权重、所有出边的“标签 + 目标状态所属等价类 + 输出”),签名相同的归为一类,合并。一轮不够就迭代若干轮,直到不再变化。实测在词表数据上,最小化能把状态数再砍 40%~70%,这一步是省内存的大头。
2.3 权重:把“概率/代价”塞进图里的通用手法
严格意义上的 FST 还带权重,每条弧和每个终态都有一个权重值。权重的含义由你定义:可以是代价(走这条路花多少分)、可以是概率的负对数、可以是词频的倒数。查询的时候,除了找出路径,还要按权重求和或求最小,这就把“找一条最可能的路径”这件事纳入了框架。
权重需要满足半环结构,最常见的两个:热带半环(tropical semiring),加法取 min、乘法相加,适合做最短路、最优路径;概率半环,加法相加、乘法相乘,适合做概率求和。为什么要有这个约束?因为你需要“合并路径”这件事有代数保证,否则最小化、组合这些操作就没法做。
工程里用权重最多的两个场景:拼写纠错里给不同编辑操作赋不同代价(替换比插入贵,因为键盘距离不同),语音识别解码里做语言模型打分。如果你的需求只是纯词典查询,权重可以全部设成 0,忽略它就行,别为了用而用。
3. 手搓一个能跑的最小 FST
光看概念容易飘,我把一个能跑的最小实现写出来。用 Python,不引第三方库,目的是把“插入、共享前缀、查询、最小化”这四步的骨架暴露清楚。生产环境当然不会用这个,但它能帮你把后面看开源实现时的每一个设计选择都对上号。
3.1 三十行代码把词表变成图
核心思路:状态用整数编号,0 是起点;每个状态维护一个出边列表,元素是(输入标签, 输出标签, 目标状态);插入一个词时,从起点出发逐字符匹配,能匹配上就复用,匹配不上就新建状态。
from collections import defaultdict EPS = "" # 用空串代表 epsilon class TinyFST: def __init__(self): self.arcs = defaultdict(list) # state -> [(in_label, out_label, next), ...] self.final = {} # state -> 终态输出 self.counter = 1 def _new_state(self): s = self.counter self.counter += 1 return s def add(self, word, out=""): cur = 0 for ch in word: for i, (a_in, a_out, nxt) in enumerate(self.arcs[cur]): if a_in == ch: cur = nxt break else: nxt = self._new_state() self.arcs[cur].append((ch, EPS, nxt)) cur = nxt self.final[cur] = outfor ... else这个写法是关键:循环正常走完没 break,说明没找到匹配边,就新建。这段代码里有一个隐含要求——同一个词重复插入时要幂等,也就是说走到终态后如果已经存在,就别覆盖,或者按你的语义做合并(比如取最大权重)。
跑一下:插入cat、car、card、dog。cat和car共享前两个字符的路径,状态数是 1(起点)+ c + a + t + r + d + d-o-g,明显少于独立存储。这就是共享前缀带来的第一层压缩。
3.2 挂上输出标签,让它真的“转换”
查询的实现就是把输入串走一遍:
def lookup(self, word): cur = 0 out = [] for ch in word: for (a_in, a_out, nxt) in self.arcs[cur]: if a_in == ch: if a_out != EPS: out.append(a_out) cur = nxt break else: return None if cur in self.final: tail = self.final[cur] if tail: out.append(tail) return "".join(out) return None注意这里有个容易忽略的点:终态本身不一定是“词尾”的充分条件。如果这个状态既是一个词的结尾,又是另一个词的中途(比如插入了cat和cats),那它既是终态又有出边,两个身份都得保留。我的实现里final字典记录终态输出,arcs记录出边,两者并存,天然支持。
现在让它真的做转换。把数字串转成中文读法,插入时用输入数字、输出中文:
f = TinyFST() for i, name in enumerate(["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"]): f.add(str(i), name) f.add("10", "十") print(f.lookup("7")) # 七 print(f.lookup("10")) # 十 print(f.lookup("11")) # None,未定义输出挂在终态上,所以lookup("1")会走到状态 1,取final[1]得到“一”。如果还想支持组合,比如“11”读作“十一”,就得把输出挂到弧上并做前推,那是下一步的事。
3.3 最小化:把后缀合并掉,状态数掉一个量级
上面这套结构只做了前缀共享,后缀完全没管。cat和bat的结尾at是两条独立的路径,浪费。最小化的目标就是把等价的尾状态合并。
算法走的是自底向上的路径等价类划分:
def minimize(fst): import sys # 逆拓扑序:这里简化处理,按状态号倒序(构建时状态号是递增分配的) order = sorted(fst.arcs.keys(), reverse=True) signature = {} for _ in range(10): # 迭代到稳定,实际项目里一般 3~5 轮足够 changed = False for s in order: sig = ( s in fst.final, fst.final.get(s, ""), tuple(sorted( (a_in, a_out, signature.get(nxt, nxt)) for (a_in, a_out, nxt) in fst.arcs[s] )) ) if signature.get(s) != sig: signature[s] = sig changed = True if not changed: break # 按签名分桶,同桶合并成一个代表状态 bucket = {} remap = {} for s in order: key = signature.get(s, s) if key in bucket: remap[s] = bucket[key] else: bucket[key] = s remap[s] = s # 重写出边 new_arcs = defaultdict(list) for s, arcs in fst.arcs.items(): for (a_in, a_out, nxt) in arcs: new_arcs[remap[s]].append((a_in, a_out, remap[nxt])) fst.arcs = new_arcs fst.final = {remap[s]: v for s, v in fst.final.items()} return fst这段是简化版,sorted的分桶在实际实现里用字典套字典,复杂度能压到近似线性。真实项目的做法是:状态在构建时就不会真的持有可变结构,而是构建到一个中间结构里 → 边构建边做后缀共享 → 最后序列化。所谓“边构建边共享后缀”,就是在插入新词时,一旦发现某条尾部路径已经存在,直接挂到那个已有节点上,而不是新建。这个技巧在词典类 FST 里能省掉独立的最小化步骤,速度还快,代价是要求插入顺序严格按字典序——这个点后面会专门讲,它是踩坑率最高的一条。
3.4 实测:规模、内存、构建耗时
拿一份约 120 万条的英文词表(含常见词形变化)做了个对比,运行环境是普通的 8 核开发机,Python 实现只是玩具,看相对量级:
| 阶段 | 状态数 | 弧数 | 内存(Python 对象,含开销) | 构建耗时 |
|---|---|---|---|---|
| 仅前缀共享 | 约 420 万 | 约 520 万 | 约 1.8 GB | 约 90 秒 |
| 加最小化 | 约 95 万 | 约 150 万 | 约 420 MB | 约 210 秒 |
| 序列化成字节数组 | —— | —— | 约 6 MB | —— |
最后一行是关键:一旦序列化成紧凑字节数组,Python 那些对象开销全部消失,只剩下“偏移量 + 弧数据”。生产环境的实现一般能做到每弧 4~9 字节(取决于标签和目标的位宽),百万词量级落在 5~20MB,加载后走内存映射,多进程共享。这个量级放到容器里,几乎不占常驻堆。构建耗时在 Python 里很慢,换成编译型语言做同样的活,百万词一般是 1~3 秒。
4. 拿 openfst 走一遍标准流程
自己在代码里造轮子只适合理解原理,真干活还是得用成熟工具。openfst 这套命令行工具是 FST 领域的“瑞士军刀”,编译、确定化、最小化、组合、最短路径,一条命令一件事。下面这套流程我在几个项目的原型阶段都跑过,直接抄就行。
4.1 文本格式与编译
openfst 的输入是纯文本,每行描述一条弧:源状态 目标状态 输入标签 输出标签 [权重]。终态单独一行,只写状态号。
先准备两个符号表,把符号映射成整数:
# isym.txt:输入符号表 printf '<eps> 0\nc 1\na 2\nt 3\ns 4\n' > isym.txt # osym.txt:输出符号表 printf '<eps> 0\nC 1\nA 2\nT 3\nS 4\n' > osym.txt然后写一份描述“cat→CAT、cats→CATS”的自动机:
cat > toy.txt <<'EOF' 0 1 1 1 1 2 2 2 2 3 3 3 3 0 1 1 1 1 2 2 2 2 4 3 3 4 5 4 4 5 EOF编译并查看:
fstcompile --isymbols=isym.txt --osymbols=osym.txt \ --keep_isymbols --keep_osymbols toy.txt toy.fst fstprint --isymbols=isym.txt --osymbols=osym.txt toy.fst注意--keep_isymbols --keep_osymbols这两个参数。不加上,编译完符号表就丢了,fstprint只能看到一堆数字。这个坑我见过太多人踩,尤其是拿别人的脚本改了路径之后忘了检查。
4.2 确定化、最小化、转换存储格式
手工写的自动机大概率是不确定的。跑两条命令修正:
fstdeterminize toy.fst det.fst fstminimize det.fst min.fst fstinfo min.fstfstinfo会告诉你状态数、弧数、是不是确定的、是不是无 ε 的。这三项是所有 FST 操作的前置条件,拿到任何一个外部 FST,第一件事就是fstinfo看一眼,别急着往下做。
存储格式也有讲究:
fstconvert --fst_type=const min.fst final.fstconst格式把整个 FST 摊平成一块连续内存,查询时不需要额外指针跳转,是生产环境用的格式。默认的vector格式适合调试和编辑,const适合上线。这个转换在流程里看着不起眼,但对查询延迟的影响是实打实的。
4.3 组合:把两个 FST 串起来用
组合(composition)是 FST 最强大的操作之一:A 的输出接 B 的输入,拼成一个新的 FST。拼写纠错就是这么做的——左边是编辑距离自动机(接受“和目标词距离在 k 以内”的所有串),右边是词典,组合之后就是“所有距离在 k 以内的合法词”。
fstcompose ed.fst dict.fst composed.fst fstshortestpath composed.fst best.fst fstprint --osymbols=dict_osym.txt best.fst组合的复杂度取决于两边的规模,实际用的时候要给编辑距离自动机设一个上限(比如编辑距离不超过 2),否则状态数会失控。我做过一次压测,编辑距离设成 3、词典 50 万词,组合产物状态数超过一千万,直接放弃,改成用带剪枝的搜索。这就是“能不能用”和“好不好用”的区别。
4.4 命令行操作的几个坑
第一个坑:符号表必须一致。isym.txt里<eps>必须映射到 0,这是约定,违反了fstcompile直接报错。第二个坑:文本格式的终态行只写状态号,如果后面还有权重,中间用空格隔开,不要加别的字段。第三个坑:不要在fstprint的输出上做文本处理,它有固定的格式,字段数随符号表存在与否变化,拿awk硬切很容易切错位。第四个坑:fstminimize之前一定要先fstdeterminize,顺序反了结果不对,而且不报错,静默给你一个错的 FST。
提示:任何一次 FST 转换之后,都用
fstinfo对比转换前后的状态数和弧数。如果数字反而变大了,要么是输入本身有问题,要么是参数没加对,别往下走。
5. 落到线上:词典检索、前缀补全与通配查询
原理和工具都通了,接下来是最实际的落地部分。线上场景里 FST 最常出现在三个位置:词典查询、前缀补全、模式匹配。三者的实现路径不一样,坑也不一样。
5.1 词典编译与查询路径
线上的词典 FST 一般是离线编译、随包发布或者放对象存储、启动时映射进内存。编译期的输入是一份排好序的词表,加一个附加信息列(词频、ID、标签位)。排序这一步不是可选项,而是很多实现的硬性要求——按字典序插入,共享后缀才能一次性生效,插入过程退化成“在当前尾部路径上做匹配”,不需要回溯。
单次查询的路径是这样的:从根状态的弧数组开始,按输入的第一个字节在数组里二分查找,命中就跳到目标状态,继续下一个字节。每个状态的出边数组在内存里是连续的,平均长度不大(英文词表根节点最宽,几万个,越往下越窄),二分加缓存命中,实际耗时在几十纳秒到几百纳秒之间。整词查询的复杂度是 O(词长 × log 出边数),跟词表总规模基本无关,这是它比哈希表优秀的隐性点——哈希表虽然平均 O(1),但规模上去之后缓存不友好,实测反而不如 FST 稳。
5.2 前缀补全怎么控制在 10ms 内
前缀补全的本质是“从某个状态出发,做一次深度优先遍历,收集所有终态路径”。天真的实现会一次把所有结果都收集出来再排序,几万个结果的场景下,光排序就能吃掉几百毫秒。
我的做法分三层。第一层:限制收集数量,只取前 N 个(比如 10 或 20),遍历时维护一个堆,超过上限就剪掉。第二层:按权重优先遍历,如果边上有输出权重,优先走权重小的分支(代表更热门的词),这样先命中的就是更可能的结果,不用遍历完。第三层:缓存高频前缀,对查询量集中的前缀(比如前两个字),直接在构建期把结果算好存成小数组,查询时一次取回。
这三层做完,实测在百万级词表上,前缀补全的 P99 能压到 5ms 以内,热门前缀在 1ms 以下。注意第一层的遍历写法:深度优先要用显式栈,别递归,路径深度一上去就爆栈。
注意:前缀遍历时要处理“当前状态既是终态又有出边”的情况。比如
car和card,走到r这个状态时,它本身是一个完整的词,同时还有一条d的出边。漏掉这个判断,car就永远不会出现在补全结果里。
5.3 通配与模糊匹配的遍历写法
带通配符的查询(比如c*t、c?r)在 FST 上是天然支持的,做法是维护一个“当前状态集合”,逐位置推进。普通字符就是按标签找边,通配符就是遍历该状态的所有出边,把目标状态全部加入下一轮集合。这个过程本质上是在 FST 上做子集构造,注意状态集合要去重,不然会指数膨胀。
带编辑距离的模糊查询思路类似,但状态集合里要多带一个维度:当前已经消耗的编辑次数。集合元素变成(状态, 已用编辑数),超过阈值的分支剪掉。这个写法跟单独建一个编辑距离自动机再组合,结果等价,但代码短得多,也不需要引入组合操作的复杂度。我自己更倾向这种写法,调试成本低,性能也够。
5.4 一个不能改的结构:增量更新怎么绕
FST 构建完之后是不可变的,这是它省内存的前提。要改,只能全量重建。百万词的重建耗时在秒级,如果更新频率低(比如每天一次全量发布),完全不用纠结,直接重建就行。
如果更新频繁,用侧挂方案:主 FST 只读,新词写到一个小的可变结构里(哈希表或者小的 Trie),查询时两边都查,主 FST 没命中再去侧挂结构找,删除用一个标记位做逻辑删除。侧挂结构攒到一定量(比如主 FST 的 5%)就触发一次合并重建,合并期间用双缓冲,先加载新版本再切流量。
这套方案有两个细节要注意。第一,查询要保证一致性,重建期间新旧两个版本同时存在,得有一个明确的切换点,别出现半新半旧的中间态。第二,侧挂结构要设上限,否则它会慢慢长成主结构的规模,那 FST 的优势就没了。我一般把阈值设成 3%~5%,超过就重建,宁可重建频繁一点,也别让查询路径变长。
6. 换个领域:文本规范化、形态分析与拼写纠错
FST 的适用面远不止词典。凡是“输入一种表示、输出另一种表示”并且规则可以枚举的场景,都是它的射程范围。下面这三个是我实际做过或者深度参与过的,细节比较真实。
6.1 字符级转换:全半角、罗马数字、大小写
文本规范化里的很多活都是字符级的一对一或一对多映射。全角转半角、繁简转换的单字映射、罗马数字转阿拉伯数字、音译转写,这些用 FST 做的好处是:规则集中管理,运行时不走分支判断,一次扫描全部搞定,而且可以编译成固定结构复用。
举一个全半角的例子:输入是全角字母和数字,输出是半角。构建时把每个全角字符映射到对应的半角字符,一条弧一个映射。要注意的是一对多的情况,比如某些全角符号转成两个半角字符(省略号、破折号),这时候输出标签得是一个串,而不是单个字符。实现上要么把输出符号表扩展成“串”的集合,要么插入中间状态逐步吐字符。我倾向后者,因为标准实现的输出标签都是整数,扩展成串意味着改数据结构,不划算。
还有一个坑:转换顺序会影响结果。比如先做全半角再做大小写折叠,和反过来做,对某些边界字符(德语 ß 之类)结果不一样。规则集定好之后,一定要写一组回归用例把顺序固定下来,别让人随便插规则。
6.2 形态分析:从 cats 到“cat + 复数”
形态分析是 FST 的经典战场,因为它天然是“一对多”和“多对一”的。表面形式cats分析出来是cat + 复数;反过来,生成时输入cat + 复数,输出cats。同一套规则,两个方向,用两个 FST 或者一个可逆 FST 都行。
规则怎么组织?词干加词缀的拼接,用弧上输出的方式表达:词干部分的弧输出词干本身,词缀部分的弧输出“+复数”这样的标签。表面形式的字符走输入边,输出的分析结果沿路径吐出。中间加上拼写规则(比如y变ies、双写辅音),这些规则本身就是小型的字符级 FST,跟主词表做组合。
这套东西的复杂度不在理论,在规则的维护。规则一多,交叉作用就出来了,某条规则在某个词干上不该生效,得加标记位做条件。我的经验是:规则数量超过几十条之后,一定要建立回归测试集,每次改规则都跑一遍,看哪些词的分析结果变了。靠人眼看规则表,早晚出事。
6.3 拼写纠错:编辑距离自动机与词典做组合
拼写纠错的经典做法:对每个候选词,算它和输入的编辑距离,取距离最小的。直接算,复杂度是 O(词表大小 × 词长²),百万词表根本扛不住。
用 FST 的思路是把这个过程“一次性编译”掉:构造一个编辑距离自动机(接受所有与输入距离 ≤ k 的串),让它和词典 FST 做组合,组合结果里的路径就是所有合法候选。前面 4.3 里演示过命令行做法,线上一般是在代码里做,因为输入每次都不一样,自动机得现造。
性能上的现实是:k=1 的时候还好,k=2 开始组合产物就膨胀,k=3 基本不可用。所以实际系统里一般是先用 FST 做候选召回,再用打分函数做精排,而不是指望组合一步到位。召回阶段用带剪枝的遍历(维护(状态, 已用编辑数)集合),拿到几十个候选就停,剩下的交给打分。打分阶段考虑词频、键盘距离、上下文,这些都不适合塞进 FST 里。
7. 排障实录:我踩过的坑与速查清单
这部分是我觉得整篇里最值钱的部分,因为下面每一个问题,都是我在真实项目里花时间定位出来的,而且大多数在文档里找不到直接答案。
7.1 问题速查表
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 构建时报“输入未排序” | 插入顺序不是字典序 | 构建前对输入做一次稳定排序 |
| 查询结果对,但明显变慢 | 用了可变格式,未转紧凑格式 | 转成 const 类紧凑格式再上线 |
| 某些词查不到,但明明插入了 | 终态判断漏了“既有出边又是终态” | 检查终态标记与出边是否并存 |
| 前缀遍历结果重复 | 状态集合去重缺失或路径回环 | 遍历时对状态去重,检查有无环 |
| 体积比预期大好几倍 | 输出未做前推,或未做最小化 | 构建后补一次最小化和输出前推 |
| 内存映射后查询偶发卡顿 | 首次访问触发缺页 | 启动时预热,或提前触碰关键页 |
| 多语言词表构建后乱序 | 按字符序而非字节序排序 | 统一按 UTF-8 字节序排序 |
| 组合结果爆炸 | 编辑距离阈值过大 | 降阈值,或改用带剪枝的遍历 |
7.2 三次印象深刻的排障
第一次是“查不到某个词”。现象很怪:大部分词都能查到,个别词查不到,而且这些词有个共同点——它们是另外一些词的前缀。定位了半天,问题出在终态的判断上。实现里为了省空间,给每个状态只留了一个标志位,用来标记“是不是终态”,同时用同一个标志位标记“是不是最后一条出边”。结果某个状态既是终态又有出边的时候,两个语义撞车,写回的时候终态被覆盖了。修法是拆成两个位,或者用特殊编码。这类“为了省一个字节而埋下逻辑坑”的事,在紧凑结构里太常见了。
第二次是“性能莫名抖动”。本地压测 P99 是 2ms,上了生产变成 30ms,而且抖动毫无规律。最后发现是内存映射的缺页问题:FST 文件是映射进来的,但服务启动后只访问了根节点附近的数据,其余部分第一次被访问时要触发缺页中断,从磁盘读进来。冷启动阶段的查询正好赶上这个。修法很简单,服务启动后做一次全量预热遍历,把所有页都触碰一遍。预热耗时几百毫秒,但换来了稳定的延迟。
第三次是“构建结果不对但不报错”。用命令行工具做最小化,输出看着正常,但查询结果变了。查了半天文档才确认:最小化的前提是自动机已经没有 ε 边,而我的输入里有一条 ε 边没消除。工具不报错,直接给出一个错误的图。从那以后我养成了习惯:任何操作之前先跑一次信息检查,把状态数、弧数、确定性、ε-free 四个指标打出来,做前后对比。
7.3 调优顺序:先降状态数,再谈性能
调优这件事有明确的优先级,顺序做错了就是白费劲。
第一优先级是降状态数。状态数直接决定内存占用和查询路径长度。手段包括:确保输入有序以最大化后缀共享、构建后做最小化、输出前推。这三步做完,体积通常能降到原来的三分之一甚至更少。
第二优先级是格式选择。从可变结构转成紧凑格式,查询性能的提升是几倍级的,因为省掉了指针跳转和额外的内存访问。这一步几乎零成本,转换一下就行,没有理由不做。
第三优先级是查询缓存。对高频输入做结果缓存,命中率通常在 60% 以上。注意缓存 key 要包含所有影响结果的参数,别只拿输入串做 key。
第四优先级才是并发和 IO。内存映射天然支持多进程共享,别自己造轮子去做进程间共享内存。真要提并发,先确认瓶颈是不是真的在查询上。
8. 选型与落地:什么场景别硬上 FST
最后聊聊边界。FST 很强,但绝不是万能锤子,用错场景的代价比不用更大。
8.1 适合与不适合的边界
适合的三个特征:数据基本只读或者低频更新、需要前缀或模式匹配能力、对内存敏感(多实例部署、边缘设备、容器密度高)。三个特征里占两个,就值得考虑。
不适合的情况:第一,需要高频随机写入,每次改一个词就要重建整张图,成本高得离谱,这时候哈希表或者 B 树更合适;第二,数据结构本身很稀疏、前后缀共享度低(比如随机生成的 ID、UUID),FST 压缩不了多少,反而增加构建成本;第三,只需要精确查询且规模不大(十万以下),哈希表的实现复杂度和维护成本低得多,没必要为了技术而技术。
还有一个容易被忽略的点:构建期的一次性成本。如果你的服务是短生命周期的(比如按需启动的批处理任务),每次启动都要花几秒构建 FST,那不如把构建结果持久化下来直接加载。构建和加载要分开设计,别混在一起。
8.2 落地检查清单
真要上线,按这个清单过一遍,能挡掉大部分问题:
- 输入数据按统一的字节序排过序,并且这个排序逻辑写进了构建脚本,不依赖人工。
- 构建完成后打印状态数、弧数、体积、构建耗时,纳入发布流水线的日志。
- 至少准备三组回归用例:正常词、边界词(最长、最短、含特殊字符)、负例(不该命中的)。
- 服务启动后做一次预热遍历,把内存映射的页都碰一遍。
- 监控里加上查询耗时分布和缓存命中率,别只看平均值。
- 侧挂更新结构设了明确的上限和合并策略,写进运维文档。
- 序列化格式带上版本号,防止新老版本混用导致读到脏数据。
这些条目看着琐碎,但每一条背后都有一次真实的翻车。我在实际项目里踩过的最大教训是:结构选对了,只成功了一半。另一半在于构建流程是否可重复、数据变更是否可追溯、性能退化是否能及时发现。一套跑得快但没人敢改的 FST,长期看是负债;一套构建脚本清晰、回归用例齐全、指标可观测的 FST,才是资产。如果团队里只有一个人能重建这份数据,那这件事迟早会出问题——把构建过程脚本化、文档化,比优化那 1ms 的查询延迟重要得多。