lucene版本:6.5.1
有限状态传感器,FST(Finite State Transducer)在lucene中扮演着非常重要的一个角色,在4.0后的版本lucene大量使用了这种数据结构,主要是用于在庞大的字典中快速的定位term的位置。
那么为什么使用FST呢?
考虑到这样一个场景,在lucene中倒排索引是核心,而其带来的问题就是term的字典是非常大,如何在保证term查询效率的前提下,能够尽量减少空间的使用,这便是FST所要解决的问题。相对于hashmap和treemap来说,fst在查询效率上稍逊于两者,而空间上的使用则是远优于前两者。其主要有以下几种特性:
- 确定:同一种状态最多只能有一个转移可以访问到。
- 无环:不可能重复遍历同一个状态。
- 接受机:有限状态机只能接受特定的状态转移到终止状态。
FST与TRIE的关系
TRIE可以看做是一个FST,唯一的一个不同是TRIE只共享前缀,而FSA不仅共享前缀还共享后缀。
假设我们有一个这样的Set: mon,tues,thurs。FSA是这样的:
相应的TRIE则是这样的,只共享了前缀。
那么FST在lucene中是如何实现的呢?
在lucene中,FST相关的源码保存于包org.apache.lucene.util.fs中,其中builder.java内定义了相关的类以及建立和查询的过程。FST可以简单的看作是图,其中的节点我们成为状态机的状态而边则是状态机的“转移”。我们看一个简单的例子:
public void test () {
try {
String inputValues[] = {"cat", "deep", "do", "dog", "dogs"};
long outputValues[] = {5, 7, 17, 18, 21};
PositiveIntOutputs outputs = PositiveIntOutputs.getSingleton();
Builder<Long> builder = new Builder<Long>(FST.INPUT_TYPE.BYTE1, outputs);
IntsRefBuilder scratchInts = new IntsRefBuilder();
for (int i = 0; i < inputValues.length; i++) {
builder.add(Util.toIntsRef(new BytesRef(inputValues[i]), scratchInts), outputValues[i]);
}
FST<Long> fst = builder.finish();
Long value = Util.get(fst, new BytesRef("cat"));
System.out.println(value); // 18
} catch (Exception e) {
;
}
}
以上的示例是建立以及查询一个fst的整个过程。首先我们明确几个问题:
- term在fst或者在底层索引中的保存形式?
在lucene中,term的字符串会以BytesRef的形式进行保存,也就是16进制的形式。比如说对于字符串“cat”来说的term。在底层我们发现其实是{63 61 67}。那么为什么要这样处理呢?我的理解是可能是节约空间,我们就以cat为例子:
字符串:[Vint = 3][c][a][t]
字节流:[63][61][67]
会节省一个存储空间
其中源码的主要结构为:
- Class ByteRef:term的类型,里面包含了String到ByteRef的转换函数等。
- Class builder: 里面包含了建立fst的主要函数 add()。
- Class UnCompileNode:新插入的节点。
- Class Arc :输入的“转移”条件。
我们这里一起学习以下builder.java函数中的add()函数。
public void add(IntsRef input, T output) throws IOException {
//这里的 input为一组16进制的数字,通常我们可以通过new BytesRef(『String』)接口获取。
if (output.equals(NO_OUTPUT)) {
output = NO_OUTPUT;
}
assert lastInput.length() == 0 || input.compareTo(lastInput.get()) >= 0: "inputs are added out of order lastInput=" + lastInput.get() + " vs input=" + input;
assert validOutput(output);
//System.out.println("\nadd: " + input);
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;
}
// 同LastInput相比获取最长前缀字符串。
int pos1 = 0;
int pos2 = input.offset;
final int pos1Stop = Math.min(lastInput.length(), input.length);
while(true) {
frontier[pos1].inputCount++;
//System.out.println(" incr " + pos1 + " ct=" + frontier[pos1].inputCount + " n=" + frontier[pos1]);
if (pos1 >= pos1Stop || lastInput.intAt(pos1) != input.ints[pos2]) {
break;
}
pos1++;
pos2++;
}
final int prefixLenPlus1 = pos1+1;
// 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;
}
// 2.从prefixLenPlus1, 进行freeze冰冻操作, 添加并构建最小FST
freezeTail(prefixLenPlus1);
// init tail states for current input
// 3.将当前input剩下的部分插入,构建arc转移(前缀是共用的,不用添加新的状态)。
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;
}
// push conflicting outputs forward, only as far as
// needed
// 4.如果有冲突的话,重新分配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) {
// same input more than 1 time in a row, mapping to
// multiple outputs
lastNode.output = fst.outputs.merge(lastNode.output, output);
} else {
// this new arc is private to this new input; set its
// arc output to the leftover output:
frontier[prefixLenPlus1-1].setLastOutput(input.ints[input.offset + prefixLenPlus1-1], output);
}
// save last input
lastInput.copyInts(input);
//System.out.println(" count[0]=" + frontier[0].inputCount);
}
freezeTail(prefixLenPlus1)函数的作用
首先来说,对于新插入的UnCompileNode首先会保存到fronter数组,然后在将“静态”也就是不可改变的部分以CompileNode形式插入到fst中。
那么为什么会有部分是不变的呢?
因为输入是预先排序好的,比如插入完所有以字母“a”开始的term后,该部分就变成了静态的,因为以“b”为开始的term不会对上面的产生改变。