Elasticsearch依托强大的文本分析搜索能力,在众多领域被广泛使用。它基于Apache Lucene构建,而Lucene的核心之一便是其高效的文本处理能力。在本文中,将探讨Lucene中的一项关键技术------FST(Finite State Transducer)数据结构,它不仅紧密地关联着Elasticsearch的性能,而且对提升搜索体验具有重要意义。通过对FST的深入理解,我们能够更好地把握Elasticsearch在文本分析方面的优势,进一步优化我们的搜索应用。
FST数据结构
有限状态转换器(FST)是一种用于文本处理的高效数据结构,它结合了有限状态机(FSM)和trie(前缀树)的优点。FST能够将输入序列(如字符串)转换为输出序列,这在分词、词形还原和同义词处理等文本分析任务中非常有用。
说人话就是FST数据结构可以用来存储key/value键值对。可以和我们常用的键值对结构HashMap类比一下,HashMap使用的存储结构是数组+链表+红黑树,而FST则类似于一种单向不循环图结构,下面会详述。
FST的核心概念 状态(State):FST中的每个状态可以看作是trie的一个节点,它可能包含一个输出标签。 转换(Transition):状态之间的转换由输入符号触发,并可能伴随一个输出。 输出(Output):每次转换或到达最终状态时,可以产生一个输出,这在处理同义词或构建分词器时尤为重要。
Lucene在4.0版本中引入FST数据结构,Lucene核心开发者,FST贡献者Michael McCandless提供了一个网页允许在输入值后生成对应的FST结构图。很方便也很直观。 examples.mikemccandless.com/fst
FST构建流程
我们在上述的网页上来构建FST,可以直观的感受FST的创建过程。
设我们有以下键值对,依次构建FST
- cat/1
- deep/10
- do/15
- dog/2
- dogs/8
-
首先加入cat/1键值对 可以看到生成的FST结构,结合上面说的核心概念可以用以下语言描述:首先生成一个头节点,头节点后将'cat'串拆分成单个字符依次输入,每个字符都代表一个转换状态指向下一个节点,输入最后一个t后指向一个代表结束的状态节点。此外在c状态时,该边的权重设置为1。当我们查询cat时,也是按照这个流程,从头节点开始依次匹配key的字符,到达结束的状态节点时将经过的边的权重相加输出即为对应的value。
-
加入deep/10键值对 这个步骤可以看到复用了首次加入键值对时构建的头节点和结束节点,其余状态节点转换步骤和第一次加入键值对的流程相同,其中代表value的值也是赋值给了d状态边的权重上。
-
加入do/15键值对 加入do时,也是从1,2中的头节点开始构建,这个时候会发现,d这个状态转换在第2次加入deep时已经构建,这个时候就可以直接复用,接着加入o状态转换到达结束节点,同时do的value是15,大于d边已有的权重10,因此d的权重仍然不变,o边的权重值赋值为5,在查询do时,将两边的权重相加即可以得到对应的value15。
-
加入dog/2键值对 照样头尾节点复用,公共状态复用。但此时有个问题,dog对应的value是2,第3步中d的权重为10显然不能满足dog的value存储,这个时候会回退d的权重,取共用状态key的最小value,即2。又因为deep的value是10,因此第一个e边的权重会被设为8,使得deep的权重和为10。但do的value为15,则o的权重应该为13,此时问题来了,dog应该复用do啊,但是dog的value仅仅是2,这d的权重加o的权重都干到15了,那我把g的权重赋值-13? 这样是可以的,但是Lucene中FST的权重通常是表示优先级或者相关度,因此都用非负的权重表示,这个网页上生成的也是非负表示。那么具体怎么处理的呢?其实解决方法是给o状态转换的节点也加一个结束属性,并且赋值一个结束状态权重13,当o状态转换指向的节点表示结束时,则对应输出的权重和需要加上13,因此查询do时对应的输出就是2+13=15,当查询dog时o不是结束状态,对应的输出就是2+0+0=2。
-
加入dogs/8键值对 和步骤4差不多,g状态转换后的节点会加上一个结束状态,经过g后再加上s的状态转换,并赋上权重6,查询dogs时,对应的value就是2+0+0+6=8。
FST的key是否需要排序
上面的FST构建流程中,可以发现我们输入的五个key的顺序是根据字符大小从小到大输入,那么我们在构建的时候不按照key的顺序构建可以吗?
肯定可以的。但是,有个问题,咱们使用Elasticsearch经常需要hold住PB级的数据,在这种情况下,参与构建FST的键值对可能以百万千万计,在key不排序的情况下,在构建时为了尽量多的复用相同的状态转换,最坏状态下我们得遍历整个FST找出前缀相同的状态转换,这个时间复杂度可以想象是多么可怕。 而排序后就好办多了,拿上面的构建流程来举例,我们在第5步加入dogs/8键值对时,我们只需从第4步加入的键值对的结束状态往前面回退,直到找到小于或者等于d的状态转换,直接新建或者复用之前的状态转换即可,是不是就简单多了。
FST构建源码
Lucene实现了FST的构建,我使用的是8.1.1版本的Lucene,其构建FST的方法入口是org.apache.lucene.util.fst.FSTCompiler#add(IntsRef input, T output),两个输入参数对应着key和value
java
public void add(IntsRef input, T output) throws IOException {
// 校验output类型
if (output.equals(NO_OUTPUT)) {
output = NO_OUTPUT;
}
// 校验上一次输入,排序比较,输入的key需要是经过排序后的
assert lastInput.length() == 0 || input.compareTo(lastInput.get()) >= 0
: "inputs are added out of order lastInput=" + lastInput.get() + " vs input=" + input;
assert validOutput(output);
// 校验当前输入,只允许初始化一个FST的时候输入为空
if (input.length == 0) {
// empty input: only allowed as first input. we have
// to special case this because the packed FST
// format cannot represent the empty input since
// 'finalness' is stored on the incoming arc, not on
// the node
frontier[0].inputCount++;
frontier[0].isFinal = true;
fst.setEmptyOutput(output);
return;
}
// 从上一次输入里取公共前缀
int pos1 = 0;
int pos2 = input.offset;
final int pos1Stop = Math.min(lastInput.length(), input.length);
while (true) {
frontier[pos1].inputCount++;
if (pos1 >= pos1Stop || lastInput.intAt(pos1) != input.ints[pos2]) {
break;
}
pos1++;
pos2++;
}
final int prefixLenPlus1 = pos1 + 1;
// 新插入的节点放到frontier数组,UnCompileNode表明是新插入的,以后还可能会变化,还未放入FST内。
if (frontier.length < input.length + 1) {
final UnCompiledNode<T>[] next = ArrayUtil.grow(frontier, input.length + 1);
for (int idx = frontier.length; idx < next.length; idx++) {
next[idx] = new UnCompiledNode<>(this, idx);
}
frontier = next;
}
// 从prefixLenPlus1, 进行freeze冰冻操作, 添加并构建最小FST,逻辑比较复杂
freezeTail(prefixLenPlus1);
// 将当前input剩下的部分插入,构建状态转移(前缀是共用的,不用添加新的状态)。
for (int idx = prefixLenPlus1; idx <= input.length; idx++) {
frontier[idx - 1].addArc(input.ints[input.offset + idx - 1], frontier[idx]);
frontier[idx].inputCount++;
}
final UnCompiledNode<T> lastNode = frontier[input.length];
if (lastInput.length() != input.length || prefixLenPlus1 != input.length + 1) {
lastNode.isFinal = true;
lastNode.output = NO_OUTPUT;
}
// 如果有冲突的话,重新分配output值
for (int idx = 1; idx < prefixLenPlus1; idx++) {
final UnCompiledNode<T> node = frontier[idx];
final UnCompiledNode<T> parentNode = frontier[idx - 1];
final T lastOutput = parentNode.getLastOutput(input.ints[input.offset + idx - 1]);
assert validOutput(lastOutput);
final T commonOutputPrefix;
final T wordSuffix;
if (lastOutput != NO_OUTPUT) {
// 使用common方法,计算output的共同前缀
commonOutputPrefix = fst.outputs.common(output, lastOutput);
assert validOutput(commonOutputPrefix);
// 使用subtract方法,计算重新分配的output
wordSuffix = fst.outputs.subtract(lastOutput, commonOutputPrefix);
assert validOutput(wordSuffix);
parentNode.setLastOutput(input.ints[input.offset + idx - 1], commonOutputPrefix);
node.prependOutput(wordSuffix);
} else {
commonOutputPrefix = wordSuffix = NO_OUTPUT;
}
output = fst.outputs.subtract(output, commonOutputPrefix);
assert validOutput(output);
}
if (lastInput.length() == input.length && prefixLenPlus1 == 1 + input.length) {
lastNode.output = fst.outputs.merge(lastNode.output, output);
} else {
frontier[prefixLenPlus1 - 1].setLastOutput(
input.ints[input.offset + prefixLenPlus1 - 1], output);
}
// 将当前输入保存为下一次操作的上一次输入
lastInput.copyInts(input);
}
FST优缺点
优点
- 高效搜索: FST特别适合于处理具有共同前缀的字符串集合,能够快速进行前缀搜索和匹配。
- 空间优化: 通过最小化和确定化,FST可以压缩大量的转换规则,节省空间。
- 支持复杂操作: FST可以构建复杂的映射关系,适用于分词、词形还原、拼写纠正等复杂的文本处理任务。
- 输出多样性: FST不仅能够根据输入生成对应的输出,还可以在转换过程中产生多个输出。
缺点
- 构建成本: 构建FST尤其是大型FST需要较高的时间和计算成本,首先得对key排序,后面生成的时候还得计算生成最小FST。
- 内存占用: 相比于简单的Map结构,FST可能占用更多的内存资源,Elasticsearch的节点崩溃的常见原因就是oom了,为此ES在7版本后把FST从堆内移到堆外了。
- 复杂性: FST的理解和实现相对于简单的Map结构更为复杂。
FST在Elasticsearch中的应用
FST在ES中主要应用场景有分词器,同义词处理器场景
分词器
分词器用于将文本分解成一系列的词条(tokens),这是文本分析的第一步。在Elasticsearch中,分词器可以使用FST来处理复杂的分词规则,中文常用的ik分词器就使用了FST。
- 构建FST: 根据分词规则构建FST,其中每个状态代表文本中的一个位置或字符序列,每个转换代表字符的添加。
- 文本处理: 输入文本被逐个字符地传递给FST,根据分词规则进行状态转换,直到达到最终状态。
- 生成词条: 每个最终状态生成一个或多个词条,这些词条随后用于构建倒排索引。
同义词处理器
同义词处理器用于扩展词条,以便在搜索时能够匹配到包含同义词的文档,有些搜索推荐的场景就用到这种方法,比如我在商城上搜索手机,可能会把词条中包含智能机三个字的文档结果也召回。
- 构建FST: 根据同义词映射构建FST,其中每个状态代表一个词条,每个转换代表词条之间的同义词关系。
- 词条扩展: 搜索词条被输入到FST中,FST根据同义词规则进行转换,输出原始词条的同义词集合。
- 搜索扩展: 搜索查询中的每个词条都被扩展为同义词集合,这些同义词用于查询倒排索引,以匹配更多相关文档。