lucene随笔-FST(Finite State Transducer)有限状态传感器

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的整个过程。首先我们明确几个问题:

  1. 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不会对上面的产生改变。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值