recLuceneその3、アナライザの作り方

recLuceneその2の続きである。

アナライザとは何か

Luceneでの検索は、基本的には、対象とするドキュメント(RecLuceneの言葉で言えばレコード)の中に特定の言葉が含まれているかどうかを探す。

例えば、「吾輩は猫である。名前はまだ無い」という文字列を格納するレコードがあるとすれば、このレコードは、「吾輩」でも「名前」でも「吾輩」かつ「猫」でも引っかかって欲しいわけである。

しかし、これを行うためには、元の文字列を適切に分割しなければならない。もし、「吾輩は猫である。名前はまだ無い」というそのままの文字列を格納していたのでは、大変な時間がかかってしまう。

そうではなく、元の文字列を「吾輩」「猫」「名前」「無い」などと分割し、検索入力されたそれぞれの文字列と完全一致で検索した方がはるかに高速になる。

この分割を行うのがアナライザの役割である。

英語に対応するアナライザ

しかし、一つの分割方法のアナライザを、すべてのケースにて使うわけにはいかないのである。例えば、英語の文章をLuceneで検索させることを考えてみると、この場合には英単語ごとに区切るのがベストであることは容易に想像がつく。

“Lucene is the most popular search engine in the world as an open source software.”

という文章を検索したい場合、当然のことながら、Lucene、is、the、mostなどというふうに分割すればよい。

さらに、isやtheを検索するようなことは無いので、これらを除去する。これをストップワードという。以下のようになるかもしれない。

“Lucene” “most” “popular” “search” “engine” “world” “open” “source” “software”

さらに、大文字小文字どちらでも検索したいので、すべて小文字に統一する。

“lucene” “most” “popular” “search” “engine” “world” “open” “source” “software”

また英語の場合には、過去形や複数形でスペルが異なるので、それらを修正することもあるかもしれない。このような、元の文字列を分割・修正し、Luceneに格納する実際の文字列を作成するのがアナライザの役割である。そして、これを検索する場合にも、同じアナライザが用いられる。つまり、

“Lucene SOFTWARE”

という検索文字列が入力されたら、

“lucene” “software”

という二つの検索文字列に変換され、この二つが検索にかけられるわけである。それぞれが完全一致で検索される。

※おそらく実際にはもっと複雑な仕組みかもしれず、この説明は間違っているかもしれないが、おおよそはこのようなところだろう。

日本語に対応するアナライザ

しかし、前述したアナライザを日本語に使うことができないことはすぐ理解できると思う。

“吾輩は猫である。名前はまだ無い”

という文字列を入力すれば、

“吾輩は猫である。名前はまだ無い”

が返される。したがって、これがひっかかってくるであろう検索入力文字列としては、”吾輩は猫である。名前はまだ無い”だけである。これでは何の意味も無い。

日本語に対応するアナライザの方式としては二つが考えられると思う(私が知らないだけで他にもあるかもしれない)。

  • 日本語の構造にしたがって分割していくアナライザ
  • 構造を気にせず、一律に分割していくアナライザ

日本語の構造を気にするアナライザ

前者の場合には、例えば以下のように分割される。

“吾輩” “は” “猫” “で” “ある” “名前” “は” “まだ” “無い”

当然のことながら、このアナライザでは日本語の知識(日本語辞書)が必要になってくる。

そして、その日本語知識によって文字列が分割されるため、知識豊富であればあるほど適切に分割されることになる。

しかし、分割された文字列はLuceneデータベースに格納されてしまうため、後から知識を増やしたり減らしたりすることはできないのだ

知識が変わってしまえば、文字列の分割状態が変わってしまい、結果分割した文字列が一致しなくなってしまう。

日本語の構造を一切気にしないアナライザ

もうひとつの方法としては、日本語の構造を気にせず、文字列を一律に分割していく方法である。

日本語がどのような構造であるかを一切考慮せず、単純に機械的に分割していく。その一つがN-Gram法である。Nには整数が入るのだが、例として2の場合を考えてみる。この場合には、以下のように分割される。

“吾” “吾輩” “輩” “輩は” “は” “は猫” ….

2-Gram(Bi-Gram)の場合には、以上のように一文字か二文字に分割される。二文字の場合は、隣り合った二文字となる。

この場合に、「吾輩」かつ「猫」を検索文字列として入力すると、これは、

“吾” “吾輩” “輩” “猫”

に分割されるので、この4つの文字列がすべて存在するレコードが結果として現れる。

※検索文字列の中に”輩猫”は無いことに注意。「吾輩」「猫」は別々に入力したので、”輩猫”という文字列は作成されない。

recLuceneのデフォルトアナライザ指定

recLuceneのデフォルトアナライザは、この日本語用の2-gramのアナライザとしている。デフォルト指定は以下で行われている。

public class RlDefaults {

  /** デフォルトのアナライザ */
  public static Class<? extends RlAnalyzer>analyzerClass = RlAnalyzer.JpnStandard2.class;

}

RlAnalyzer.JpnStandard2というクラスが先に説明した日本語用の2-gramのアナライザである。

RlAnalyzer.JpnStandard

RlAnalyzer.JpnStandard/JpnStandard2は以下のようなものだ。Luceneのアナライザもこれと同じような構造になっているので、その構造を若干解説することにする。

ここで重要なものは、「public TokenStreamComponents createComponents()」というメソッドである。このメソッドが最終的には、TokenStreamComponentsというオブジェクトを返すことになる。recLuceneのアナライザは元々のLuceneのアナライザとは若干異なるのだが、createComponents()メソッドがTokenStreamComponentsを返すところは全く同じである。

  public static abstract class JpnStandard extends RlAnalyzer {
    final int numGrams;
    protected JpnStandard(int numGrams) {
      this.numGrams = numGrams;
    }
    @Override
    public TokenStreamComponents createComponents() {

      // 空白、改行で分割するtokenizer
      Tokenizer tokenizer = new WhitespaceTokenizer();  

      // 正規化フィルタ
      TokenFilter filter1 = new JpnNormalizeFilter(tokenizer);

      // N-Gramフィルタ
      TokenFilter filter2 = new NGramTokenFilter(filter1, 1, numGrams);

      return new TokenStreamComponents(tokenizer, filter2) {
        public Tokenizer getTokenizer() {
          System.out.println("getTokenizer");
          return source;
        }
      };
    }
  }
  }

  public static class JpnStandard2 extends JpnStandard { 
    public JpnStandard2() {
      super(2);
    }
  }

アナライザの構造とTokenStreamComponents

一般的なアナライザの構造を説明する前に、クラス階層を解説しておく。ここで登場するクラスは以下のような構造になっている。

TokenStream
  +-- Tokenizer
  +-- TokenFilter

そして、最終的なTokenStreamComponentsのコンストラクタ引数は以下だ。

    public TokenStreamComponents(Tokenizer source, TokenStream result)

Tokenizer

Tokenizerは、指定された文字列を最初に受け取り、それを分割する役割を担う。つまり、最初に”Lucene is the most popular search engine in the world as an open source software.”や”吾輩は猫である。名前はまだ無い”を受け取ってトークンに分割するのがTokenizerである。

もちろん、英語用アナライザであれば、空白で単語を区切り、各単語を「トークン」とするのが一般的だろう。

日本語の場合は異なる処理が考えられるのだが、しかし、recLuceneの標準日本語アナライザでは同じことをしている。つまり、空白で区切り、区切ったものを「トークン」としている。

TokenFilter

トークンフィルダーは、トークンについて何らかの処理を行う。英語の場合であれば、全英単語を小文字化したり、ストップワードを無視したり、過去形や複数形を現在形や単数形にするかもしれない。この結果の最終的なトークンがLuceneデータベースに格納される最終的な文字列となる。

TokenFilterは何段にも重ねることができる。つまり、「小文字にするフィルター」「ストップワードを無視するフィルター」「過去形や複数形を現在形や単数形にするフィルター」を別々に用意し、それらを連続して通すことができる。

しかし、「フィルター」という名の通りの「フィルタリング」だけではなく、一つのトークンから、複数のトークンを作成してもよい。

recLuceneでは、「空白で区切られたトークン」を「日本語正規化フィルタ」に通し、その後でN-Gramフィルタに通している。

日本語正規化フィルタとは

これは、文字種の違いを吸収するためのものである。以下のような処理を行う。

  • 半角は全角に統一する
  • カタカナをひらがなに変換する
  • 英文字の小文字を大文字に統一する

半角・全角の違い、カタカナ・ひらがなの違い、小文字・英文字の違いで検索にひっかからなくなることを避けるためのものです。

N-Gramフィルタとは

N-Gramについては既に説明した。”吾輩は猫である。云々”という一つのトークンを入力すると、”吾” “吾輩” “輩” “輩は” “は” “は猫” ….という複数のトークンに分割する。

再度createComponentsについて

以下にデフォルトアナライザのcreateComponentsのコードを再掲する。

    @Override
    public TokenStreamComponents createComponents() {

      // 空白、改行で分割するtokenizer
      Tokenizer tokenizer = new WhitespaceTokenizer();  

      // 正規化フィルタ
      TokenFilter filter1 = new JpnNormalizeFilter(tokenizer);

      // N-Gramフィルタ
      TokenFilter filter2 = new NGramTokenFilter(filter1, 1, numGrams);

      return new TokenStreamComponents(tokenizer, filter2) {
        public Tokenizer getTokenizer() {
          System.out.println("getTokenizer");
          return source;
        }
      };
    }
  }

最初のものがTokenizerであり、その後でTokenFilterを二つ重ねている。TokenFilterはソースとしてTokenStreamを受け入れる。

先に以下のクラス階層を紹介したが、

TokenStream
  +-- Tokenizer
  +-- TokenFilter

TokenStreamとしては、TokenizerとTokenFilterのどちらでもよいことがわかる。

最後にTokenStreamComponentsを作成するのだが、この引数としては、最初のTokenizerと最後のTokenFilterを指定する。

このTokenStreamComponentsの中でTokenizerに与える文字列を指定するため、最初と最後が必要になる。