Lucene8:TokenStreamの仕組み

ここでは特にLucene 8.2.0の場合について書いているが、この部分はほぼすべてのバージョンに共通することことと思う。

Tokenizer、TokenFilterはTokenStream

Luceneは元のテキストをトークンという単位に分割し、最終的にTermとしてインデックス(データベース)に登録する。このトークン処理では、最初にTokenizerを使って分割し、その後、0個から複数個のTokenFilterを通してそれを加工する。以下のようなイメージだ。

Tokenizer-->TokenFilter-->TokenFilter-->...

したがって、TokenFilterは前段のTokenizerあるいはTokenFilterからの入力を受け取るようになっている。このために、TokenizerとTokenFilterは共にTokenStreamの下位クラスになっている。つまり、TokenFilterは前段としてTokenStreamを受け取るようになっている。

この観点で見ると、トークンの流れは以下になる。

TokenStream-->TokenStream-->TokenStream...

これらのTokenStreamのうち、トークンを作り出すものがTokenizer、フィルタリングするものがTokenFilterということだ。

アナライザの構成

Tokenizer、TokenFilterを擁すAnalyzerは以下のように作成される。

処理の順序としては、Tokenizer->Filter0->Filter1->Filter2になる。

Analyzer analyzer = new Analyzer() {
    @Override
    public TokenStreamComponents createComponents() {
      // Tokenizerを作成する
      Tokenizer tokenizer = ....;

      // 連続したTokenFilterを作成する
      TokenFilter filter = new Filter2(new Filter1(new Filter0(tokenizer)));

      // 入口のTokenizerと出口のTokenFilterを指定する必要がある。      
      return new TokenStreamComponents(tokenizer, filter);
    }
  }
}

ちなみに、フィルターが一つも必要の無い場合は以下だ。

Analyzer analyzer = new Analyzer() {
    @Override
    public TokenStreamComponents createComponents() {
      // Tokenizerを作成する
      Tokenizer tokenizer = ....;

      // 入口・出口共にTokenizer
      return new TokenStreamComponents(tokenizer);
      // 以下でも同じ
      //return new TokenStreamComponents(tokenizer, tokenizer);
    }
  }
}

※TokenStreamComponents作成時に「出口」のTokenFilterのみを与えれば良いのではなく、なぜ「入り口」のTokenizerも必要なのかと言えば、TokenizerにReaderつまり元文書を与えなければならないからだ。これらの一連のTokenStreamのオブジェクトは再利用されるので、何度もReaderが与えられる。

トークンの属性

トークンとしては、単純に「分割された文字列」のみで、それを加工していけばいいのかと思いきや、違うようだ。TokenStreamを流れるものには、他に「Tokenizerが元文書のどの位置から文字列を切り出したのか」というオフセット情報がある。もしこれが無く、トークン文字列だけだと「元文書の中の検索一致位置をハイライトする」といったことができなくなってしまう。

あるいは、その文字列を何とみなして切り出してきたのかという情報がある。これは、TokenizerあるいはTokenfilterにもよるだろうが、その文字列が名詞なのか動詞なのかといった情報だ。Luceneのデフォルトではすべて”word”として処理されるようだ。

このようにTokenStreamを流れるトークン情報(属性)としては様々なものがあり、実際に任意の属性を作り出すこともできるようだ。何の役に立つのかとも思うが、前段と後段が了解する独自の属性を伝達することができるようになる(ようだ)。

※独自属性の作成方法については未調査。

トークン属性を見る

何もせずに、流れてくるトークン属性の観察だけを行うTokenFilterを作成してみる。以下のようなものだ。これは全く何もしないので、TokenStreamのどの位置に置いても構わない。

ここで、観察する属性をaddAttribute()で付け加えているようなコードになっているのだが、実際はそうではない。addAttribute()は、「その属性があればそれを利用するが、無ければ作りだす」という意味である。CharTermAttribute等の、org.apache.lucene.util。Attributeの下位インターフェースとしてLucene側が用意しているものは既に存在しているようで、この場合addとは言っても「付け加える」わけではない。属性にアクセスするためのオブジェクトを得ると言ったような意味である。

  public class NoopFilter extends TokenFilter {

    private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);
    private final OffsetAttribute offsetAtt = addAttribute(OffsetAttribute.class);
    private final PositionIncrementAttribute posincAtt = addAttribute(PositionIncrementAttribute.class);
    private final TypeAttribute typeAtt = addAttribute(TypeAttribute.class);

    protected NoopFilter(TokenStream input) {
      super(input);
    }

    @Override
    public final boolean incrementToken() throws IOException {
      if (!input.incrementToken()) return false;
      System.out.println("");
      System.out.println("charTerm:" + new String(termAtt.buffer(), 0, termAtt.length()));
      System.out.println("offset:" + offsetAtt.startOffset() + "," + offsetAtt.endOffset());
      System.out.println("posincAtt:" + posincAtt.getPositionIncrement());
      System.out.println("typeAtt:" + typeAtt.type());
      return true;
    }
  }

以下のような出力になる。

charTerm:吾輩
offset:0,2
posincAtt:1
typeAtt:word

charTerm:輩
offset:1,2
posincAtt:1
typeAtt:word

charTerm:輩 
offset:1,3
posincAtt:1
typeAtt:word

トークン文字列を変更してみる

ここで、CharTerm属性をいじり、余計な文字を追加してみる。先のNoopFilterはそのままにして、新たに以下のフィルタを作成し、NoopFilterの前段に入れる。

  public class AppendFilter extends TokenFilter {
    private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);    
    protected AppendFilter(TokenStream input) {
      super(input);
    }

    @Override
    public final boolean incrementToken() throws IOException {
      if (!input.incrementToken()) return false;
      termAtt.append("]");
      return true;
    }
  }
      TokenFilter filter = new NoopFilter(new AppendFilter(tokenizer));

こんな出力になる。

charTerm:吾]
offset:0,1
posincAtt:1
typeAtt:word

charTerm:吾輩]
offset:0,2
posincAtt:1
typeAtt:word

charTerm:輩]
offset:1,2
posincAtt:1
typeAtt:word

トークンを削除する

では、何らかの条件に一致したトークンを削除するにはどうすればよいのか。これは、org.apache.lucene.analysis.FilteringTokenFilterのサブクラスとして作成すれば簡単だ。この中のaccept()メソッドをオーバライドして、「現在のトークンを受け入れるかどうか」を決めればよい。例えば以下になる。

  public static class DropSomethingFilter extends FilteringTokenFilter {
    private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);
    protected DropSomethingFilter(TokenStream input) {
      super(input);
    }
    @Override
    public final boolean accept() {
      String s = new String(termAtt.buffer(), 0, termAtt.length());
      if (受け入れる) return ftrue;
      return false;
    }
  }

FilteringTokenFilterの仕組みとしては以下のようなものだ。日本語でコメントをつけてみた。トークンを「落とす」ためには、その文字列を空文字列にしたのではだめで、position incrementをいじる必要がある。これは、「次のトークンまでいくつ移動するか」という意味らしい。通常は1になっているのだが、これを2にすると、「一つ落とす」という意味になるようである。

public abstract class FilteringTokenFilter extends TokenFilter {

  private final PositionIncrementAttribute posIncrAtt = addAttribute(PositionIncrementAttribute.class);

  // いくつ落とすかの数
  private int skippedPositions;

  public FilteringTokenFilter(TokenStream in) {
    super(in);
  }

  /** 下位でこれを実装し、現在のトークンを受け入れるかを決める */
  protected abstract boolean accept() throws IOException;

  @Override
  public final boolean incrementToken() throws IOException {
    skippedPositions = 0;
    while (input.incrementToken()) {
      if (accept()) {
        // 受け入れトークンがあった
        if (skippedPositions != 0) {
          // 落とす数があれば、それをposition incrementに加算する
          posIncrAtt.setPositionIncrement(posIncrAtt.getPositionIncrement() + skippedPositions);
        }
        return true;
      }
      // 受け入れないトークンがあった。現在のposition incrementを「落とす数」に追加する
      skippedPositions += posIncrAtt.getPositionIncrement();
    }

    return false;
  }

  // このフィルタを再利用する場合に、「落とす数」を0にしておく
  @Override
  public void reset() throws IOException {
    super.reset();
    skippedPositions = 0;
  }

  // 終了時にもきちんとposition incrementを設定しないといけないようだ。
  @Override
  public void end() throws IOException {
    super.end();
    posIncrAtt.setPositionIncrement(posIncrAtt.getPositionIncrement() + skippedPositions);
  }
}

トークンを追加する場合

これについては以下に説明があるのだが、まだ詳しくはみていない。