LuceneのNew TokenStream API

今回はLuceneのanalysisパッケージのNew TokenStream APIを紹介します。名称にはNewとついていますが、もう2年以上前のLucene2.9で提供されたAPIで、従来の用途への影響を最小限に抑えつつ、それまでのものより柔軟な拡張が可能とする為に新しく作られました。このAPIに関する記事は関口さんもblogでも書かれていますし、LuceneのanalysisパッケージのJavadoc(ページ真ん中あたりから)にも詳細に書かれています。今回、今更ながらこのAPIを紹介するのは、TokenizerやTokenFilterをカスタマイズもしくは利用するのに知っておくべきポイントだからというのもあるのですが、それより私自身がTokenizer関連のコードを読み解くのが難しかった為、まずここを理解しなければ、と思ったのが正直なところです。

この記事では、このAPIJavadocの和訳(ほぼGoogle翻訳・・・)とそれらを利用するScalaのコードとサンプルコードを見ながら進めていきます。原文の訳文とそうでない文は文字色を変えて区別できるようにしています。使うコードは全て<こちら>にあります。動かしながら見てもらえると分かりやすくなると思います。sbtが必要なので、入ってない方はこちらに導入方法が載っていますので、参考にして入れてみて下さい。jar入れて起動スクリプトにパスを通すだけなので簡単です。記事の訳に誤りや分かりにくい箇所がたくさんあると思うので、指摘してもらえると嬉しいです。また、正確さを求める場合は原文のJavadocを読んで頂くようお願いします。それと、この記事の内容はLuceneの3.5時点のものですので、ご注意を。それでは冒頭の紹介の部分から始めましょう。

New TokenStream API

Luceneの2.9からの新しいTokenStream APIを紹介します。それまでのAPIはTokenを生成するために使用されました。Tokenは位置増分とTermテキストのような、さまざまなプロパティのgetterメソッドとsetterメソッドを持っています。このアプローチは、標準のインデックス作成のフォーマットのためには十分でしたが、それはFlexible Indexing(プラガブルで拡張可能なカスタムインデックスフォーマットの為のLuceneのIndexer作成をまとめた用語)の為には十分汎用的ではありませんでした。

完全にカスタマイズ可能なIndexerは、ユーザーがディスク上にカスタムデータ構造を格納できるようになることを意味します。したがってAPIには、文書からIndexerへのカスタムデータを転送することができる必要があります。
→これらはLucene 4で提供される予定です。LUCENE-1458LUCENE-2111

AttributeとAttributeSource

そこでLucene2.9から、AttributeとAttributeSourceと呼ばれるクラスの新しいペアを導入しています。Attributeはトークン文字列に関する情報の特定部分として機能します。例えば、TermAttributeは、トークンのタームテキストが含まれており、OffsetAttributeには、トークンの開始と終了の文字オフセットが含まれています。 AttributeSourceは、制約のあるAttributeのコレクションで、各Attributeの型のインスタンスは1つだけある可能性があります。 TokenStreamはAttributeSourceを拡張するようになり、それはTokenStreamにAttributeを追加できることを意味します。TokenFilterがTokenStream拡張するので、すべてのフィルタもAttributeSourceです。

Luceneは現在、Tokenクラスが持っていた変数を置き換える、すぐに使える次の6つ(3.5では8つ)のAttributeを提供しています:

Attribute名 役割
TermAttribute トークンのターム文字列。3.1よりdeprecatedで今後のリリースで廃止される予定
OffsetAttribute トークンの開始/終了のオフセット
PositionIncrementAttribute トークンの位置増分情報
PayloadAttribute トークンが任意で持つ事の出来るペイロード(バイト配列で保存する任意のデータ)
TypeAttribute トークンの種類。デフォルトは'word'
FlagsAttribute(※) トークンに紐づくビットフラグ。This API is experimentalとあるので、まだ積極的に使わない方がいいかも
CharTermAttribute TermAttributeの代わりにこれを使う事
KeywordAttribute Tokenがキーワードかどうか

※TermAttributeの実装のTermAttributeImplは、AttributeFactoryのデフォルトの実装では、TermAttributeが渡されるとCharTermAttributeImplが返されるようになっており、既に使えなくなっています。

新しいTokenStream APIの利用

効率的に新しいAPIを利用するために知っておくいくつかの重要な点をここにまとめます。まず以下の例(Exampleの章)を見て、その後、このセクションに戻ってくるといいでしょう。

    1. AttributeSourceは特定のAttributeインスタンスを1つだけ持つことができることを覚えておいて下さい。さらに、TokenStreamと複数のTokenFilterのチェーンで使用されている場合、チェーンの全てのTokenFilterはTokenStreamのAttributeを共有します。
      →テストコード書きました。まず、Attributeは種類ごとにインスタンスを一つだけしか持てないという点を、Tokenizerに同じAttributeをaddAttributeした時に、同じインスタンスが返されている事を確認しています。→<確認用コード>
      次にTokenStreamのチェーン内のAttributeが共有される事ですが、これはTokenizerとTokenFilterのいずれからaddAttributeした場合でも、同じインスタンスが返されている事を確認しています。→<確認用のコード>

    2. Attributeのインスタンスは、ドキュメントのすべてのトークンで再利用されています。従って、TokenStream/TokenFilterはincrementToken()で適切なAttribute(s)に更新する必要があります。消費者(一般的にLuceneのインデクサー)は、Attributeのデータを消費し、ストリームの終わりに到達したことを示すfalseが返されるまで、再度incrementToken()を呼び出します。これはincrementToken()の各呼び出しでTokenStream/TokenFilterは、安全にAttributeインスタンス内のデータを上書きできることを意味します。
      →<このコード>で確認出来るように、TokenizerとTokenFilterのincrementToken()で、同じAttributeインスタンスが使われます。TokenizerからTokenFilterにAttributeを直接渡すのではなく、それぞれの処理が勝手に参照すれば良くて、それぞれが更新すれば良いという仕組みになっています。
    3. パフォーマンス上の理由からTokenStream/TokenFilterは、初期化中にAttributeをadd/getする必要があります。例えば、コンストラクタでAttributeを作成してインスタンス変数に参照を保持します。incrementToken()でaddAttribute()/getAttribute()を呼び出す代わりにインスタンス変数を使用する事で、ドキュメントでのトークンごとのAttributeルックアップを避けることができます。
      →TokenStream/TokenFilterのコンストラクタでAttributeをaddして使おうね、incrementTokenで毎回addして参照しないでね、パフォーマンスが悪くなるからという事です。
    4. AttributeSourceのすべてのメソッドはべき等で、何度呼び出しても常に同じ結果が得られることを意味します。これは、addAttribute()を知るためには、特に重要です。このメソッドは引数として、Attributeの型(Class)を受け取り、インスタンスを返します。同じタイプのAttributeが以前追加されていた場合は、すでに存在するインスタンスを返し、そうでない場合は新しいインスタンスが作成されて返されます。したがってTokenStreams/TokenFilterは、同じアトリビュートの型で複数回addAttribute()を安全に呼び出すことができます。TokenStreamの消費者は、通常getAttribute()の代わりにaddAttribute()を呼び出す必要があります、なぜなら、TokenStreamがAttributeを保持していない場合に失敗するからです(getAttribute()は、Attributeが欠落している場合、IllegalArgumentExceptionをスローする)。より高度なコードは、単にhasAttribute()でチェックすることができ、もしTokenStreamが保持しているなら、条件付きで特別なパフォーマンスを得るための処理を省略することができます。
      →addAttributeを何回呼び出しても同じ結果を返すという点は、1の確認用コードでインスタンスを一つしか持てない事を既に確認しました。ここのポイントは、Attributeを取得したい場合は、通常addAttributeを使うという事です。getAttributeではありません。使う事は出来ますが、Attributeが登録されてない場合は例外が投げられます。→<確認用コード>

Example

この例では、WhiteSpaceTokenizerを作成し、2文字以下のすべての単語を抑制するLengthFilterを使用します。LengthFilterはLuceneコアの一部であり、新しいTokenStream APIの使用法を例示するためにここで説明されます。
その後、カスタムAttribute、PartOfSpeechAttributeを開発し、チェーンに新しいカスタムAttributeを利用する別のフィルタを追加し、それをPartOfSpeechTaggingFilterが呼び出します。

Whitespace tokenization

という事で、まず最初はWhitespaceTokenizerを使っただけのAnalyzerを作ります。→<コード>
このAnalyzerはtokenStreamメソッドで、WhitespaceTokenizerを返すだけです。まずはこれを使ってAttributeのインスタンスを利用してみましょう。最初に上のAnalyzerのインスタンスを作って、tokenStreamメソッドを呼び出します。→<コード>
次に文書が正しく各Tokenとして処理されているか確認します。→<確認用コード>
Tokenの内容は原文では標準出力に出力していましたが、この記事ではScalaTestを使って結果を確認しています。また、原文はTermAttributeを使っていますが、3.5時点でdeprecatedなので、この記事ではCharTermAttributeを使っています。15行目でCharTermAttributeのインスタンスを取得して、20行目で利用しています。CharTermAttributeでは、直接文字列を取得するメソッドがdeprecatedなので、buffer()とlength()をStringのコンストラクタで使って文字列を取得します。length()を指定しないと、CharTermAttributeはcharの配列を使い回す関係上、一番長いToken以外は、後ろに他のTokenの文字が入ってしまうので、注意が必要です。
結果は原文と同様にスペースで区切られたToken「"This", "is", "a", "demo", "of", "the", "new", "TokenStream", "API"」となります。

Adding a LengthFilter

2文字以下の全てのTokenを出力しないようにします。LengthFilterをchainに追加することで容易に実現できます。さっき作ったAnalyzerのtokenStream()メソッドのみを変更する必要があります。
という事で、WhitespaceTokenizerをLengthFilterのコンストラクタに渡してchainにしました。→<確認用コード>
LengthFilterは許可する文字数を指定するようになっており、ここでは2文字以下のTokenを除外したいので、3〜Integer.MAX_VALUEを指定します。
※原文のLengthFilterのコンストラクタの引数は3つですが、このコンストラクタはdeprecatedになっています。第一引数が追加されており、これにより、PositionIncrementAttributeを適切に記録するかどうかが変わります。原文では位置増分の情報は使っていないので不要なのですが、折角なのでどのように動いているかを確認するために、この記事ではtrueにしておきます。

ではこれを<利用するコード>を見ましょう。
最初のサンプルとの違いはPositionIncrementAttributeを使って位置増分も確認するようにしているところです。結果は2文字以下のTokenが削られて、List( ("This",1), ("demo",3), ("the",2), ("new",1), ("TokenStream",1), ("API",1) )と同じになります。削られた数もPositionIncrementAttributeで確認できます。

原文ではこの後でLengthFilterのコードがのっていますが、3.5時点の<LengthFilter>では主な実装が<FilteringTokenFilter>に移動してしまっているのと、原文で確認しているTokenizerとTokenFilterでのAttributeインスタンスの共有は、Using New TokenStream APIの章で既に確認しているので、この記事では省略します。比較的簡単なコードなので、興味のある方はリンクをはっていますのでそちらで確認してみて下さい。

Adding a custom Attribute

次に、品詞のタグ付けのために、PartOfSpeechAttributeという独自のカスタムAttributeを実装します。まず、新しいAttributeのインターフェースを定義する必要があります。
という事でtraitとPartOfSpeechクラスと関連クラスを作りました。まず品詞の列挙型はscalaのEnumerationを使って定義しています。→<コード>
インターフェースは原文のコードとほぼ同じです。→<コード>

次に実装クラスを記述する必要があります。クラスの名称は重要です:デフォルトではLuceneは、接尾辞が"Impl"という名称のクラス(インターフェース名Impl)があるかどうかをチェックします。この例ではこれから実装するクラスのPartOfSpeechAttributeImplを呼び出します。
これは通常の動作です。しかし、これらの命名規則を変更することができるexpert-API(AttributeSource.AttributeFactory)もあります。Factoryでは、引数としてAttributeインタフェースを受け取り、実際のインスタンスを返します。デフォルトの動作を変更する必要がある場合には独自のファクトリを実装することができます。
※ここに書かれているように独自のAttributeFactoryを用意すれば、既存の実装の変更(特定パッケージを優先的に探すとか)も可能ですが、そうするにはTokenizerに独自AttributeFactoryのインスタンスを渡すように修正しなければいけなくて、solrから使うなら、変えたい種類すべてのTokenizerFactoryを独自に作る必要がありそうですね。。。
→<実装クラスのPartOfSpeechAttributeImplのコード>

これは、単純なAttributeの実装で、Tokenの品詞を格納するだけ単一の変数を持っています。それは、新しいAttributeImplクラスを拡張し、抽象メソッドのcopyTo()、equals()、hashCode()、clear()を実装しています。それぞれのトークンにこの新しいPartOfSpeechAttributeを設定することができるTokenFilterが必要です。この例では、1文字目が大文字の単語を'Noun'として、それ以外を'Unknown'とする、非常に単純なフィルタ(PartOfSpeechTaggingFilter)を見せます。→<PartOfSpeechTaggingFilterのコード>

LengthFilter(2.9当時)のように、この新しいフィルタは、コンストラクタで必要なAttributeにアクセスし、インスタンス変数に参照を設定します。あなたは新しい属性のインターフェースを渡す必要があるだけで、正しいクラスが自動的に解決されてインスタンス化することに注意してください。次にchainにフィルタを追加する必要があります:
→<フィルタを追加したコード>
一番外側に今回作ったFilterを追加しています。早速動作確認してみましょう。→
→<確認用コード>

出力内容に品詞情報を追加して確認してみます。結果は、List( ("This",Noun,1), ("demo",Unknown,3), ("the",Unknown,2), ("new",Unknown,1), ("TokenStream",Noun,1), ("API",Noun,1) )に等しくなります。

品詞タグ付けの改善

各単語は、現在、割り当てられたPartOfSpeechタグが続きます。もちろん、これは素朴な品詞のタグ付けです。単語'This'にも名詞としてタグ付けされるべきではなく、それは文の最初の語であるだけで大文字で綴られています。実際にこれは練習のための良い機会です。新しいAPIの使い方を練習する為に、読者は既に、文の最初のトークンかどうかの場合に、各単語に指定できるAttributeとTokenFilterを書くことができます。その後PartOfSpeechTaggingFilterは、この知識を利用することができて、文章の最初の単語でない場合にのみ、大文字から始まる単語を名詞としてタグ付けすることができます(これが正しい動作ではないのは分かっているが、それは良い練習です)。

このあと原文にはFirstTokenOfSentenceAttributeImplを作ろうというヒントもあるので、これを参考に<FirstTokenOfSentenceAttributeインターフェース>と<実装クラス>、そしてこれらに値を設定する<FirstTokenOfSentenceFilter>を作ってみましょう。新しく作ったAttributeのインターフェースと実装は、最初の単語かどうかの値を保持するだけの役割なので、難しくないと思います。そして、先ほどのPartOfSpeechTaggingFilterを、このAttributeを使って、文章の最初の単語の場合には品詞としないように修正してみましょう。修正したコードが<こちら>]>です。PartOfSpeechTaggingFilterを継承して、品詞の判定部分のみoverrideしています。さらにこれらを使うようにしたAnalyzerが<こちら>です。先にFirstTokenOfSentenceAttributeが設定されるように、組み込む順番をPartOfSpeechTaggingFilter2より前にしています。順番を入れ替えると正常に動作しませんので気をつけて下さい。

最後に<動作確認用のコード>です。先ほどのAnalyzerを使って、文章の最初の単語の'This'がNounとならずにUnknownとなりました。

読んでみて

最初はとにかく、クラス間でAttributeをどのように利用しているかが分かりづらく、理解が難しかったのですが、実際に動かしてみてからTokenizerやTokenFilterのコードを見てみると、随分理解しやすくなりました。また、新しいAPIでコードの見通しが悪くなった反面、既存コードに影響を与えずに拡張が出来るように考えられた作りになっている事が分かります。折角学んだので、まだ何も思いついてないですが、何か思いついたら独自のAttributeとFilterを作ってみて、また紹介したいと思います。
次回は今回詳細に見なかったAttributeSourceのコードを見ながら、より細かいところについて見ていく予定です。(記事はもっともっと短くします・・・)