CLOVER🍀

That was when it all began.

NGramとEdgeNGramなTokenizerとTokenFilterを使ってみる

Luceneをちょこちょこ勉強している間に、Lucene 4.4.0がリリースされましたね。オフィシャルサイトのトップからは、3.6系のダウンロードリンクが消えてしまいましたよ。

Apache Lucene
http://lucene.apache.org/

で、今回はN-Gramを使ってみようと思ったのですが、N-Gramを扱うTokenizerとTokenFilterは、4.4.0でけっこう変わったみたいですね。以前のTokenizer、TokenFilterには問題があったみたいです。

http://d.hatena.ne.jp/snkken/20090628/1246193267
http://blog.goo.ne.jp/13th-floor/e/f694fdb1319134b40e4dee5ed8c15a92

種類としては、NGramTokenizerとTokenFilterEdgeNGramTokenizerとTokenFilterがあります。以前のクラスは、Lucene43NGramTokenizer、Lucene43EdgeNGramTokenizerとして残っているみたいです。TokenFilterはありませんが。

NGramとEdgeNGramの違いは、NGramが与えられたトークンをすべてN-Gramで分割するのに対し、EdgeNGramは指定された範囲の分解が1回終わると、そこでやめてしまうということでしょうか。

新しいNGramTokenizerについての説明は、こんな感じ。

This tokenizer changed a lot in Lucene 4.4 in order to:

tokenize in a streaming fashion to support streams which are larger than 1024 chars (limit of the previous version),
count grams based on unicode code points instead of java chars (and never split in the middle of surrogate pairs),
give the ability to pre-tokenize the stream before computing n-grams.

http://lucene.apache.org/core/4_4_0/analyzers-common/org/apache/lucene/analysis/ngram/NGramTokenizer.html

1,024文字以上が扱えるようになったよ、Unicodeコードポイントベースでカウントするようになったよ、N-Gramを計算する前に、トークン化することができるようになったよ、みたいな?

これにがっかりするようなら、Lucene43NGramTokenizerを使ってくれということなのですが

Although highly discouraged, it is still possible to use the old behavior through Lucene43NGramTokenizer.

http://lucene.apache.org/core/4_4_0/analyzers-common/org/apache/lucene/analysis/ngram/NGramTokenizer.html

何が問題なんですか…?

EdgeNGramの方も、同じようなことが書いているのですが、以前は分解する方向をSideでFRONT、BACKと指定できていたらしいですが、今は指定できなくなりFRONTのみになっています。

ちなみに、N-Gramに分解する際には、スペースは除去することなく、そのままトークンにしてしまうので、この点は注意らしいです。

では、使ってみましょう。

build.sbt

name := "lucene-ngram"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.10.2"

organization := "littlewings"

libraryDependencies ++= Seq(
  "org.scala-lang" % "scala-reflect" % "2.10.2",
  "org.apache.lucene" % "lucene-analyzers-common" % "4.4.0",
  "org.apache.lucene" % "lucene-analyzers-kuromoji" % "4.4.0",
  "org.apache.lucene" % "lucene-queryparser" % "4.4.0"
)

ちょっと、遊び心でScalaのReflectionが入っています。それから、QueryParserとKuromojiはいろいろ試している時に加えたものなので、NGramが使いたいだけであればanalyzers-commonがあれば問題ありません。

ソースコードは、TokenizerとTokenFilterの両方を使うものを用意しました。
src/main/scala/LuceneNGram.scala

import scala.reflect.runtime.universe

import java.io.Reader

import org.apache.lucene.analysis.{Analyzer, TokenFilter, Tokenizer, TokenStream}
import org.apache.lucene.analysis.core.WhitespaceTokenizer
import org.apache.lucene.analysis.ja.JapaneseTokenizer
import org.apache.lucene.analysis.ngram.EdgeNGramTokenFilter
import org.apache.lucene.analysis.ngram.EdgeNGramTokenizer
import org.apache.lucene.analysis.ngram.NGramTokenFilter
import org.apache.lucene.analysis.ngram.NGramTokenizer
import org.apache.lucene.analysis.ngram.Lucene43EdgeNGramTokenizer
import org.apache.lucene.analysis.ngram.Lucene43NGramTokenizer
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute
import org.apache.lucene.search.{BooleanQuery, Query}
import org.apache.lucene.queryparser.classic.QueryParser
import org.apache.lucene.util.Version

object LuceneNGram {
  def main(args: Array[String]): Unit = {
    val texts = Array(
      /** 分解する文字列を列挙 **/
    )

    val analyzers = Array(
      /** TokenizerとTokenFilterを列挙 **/
    )

    for {
      text <- texts
      (analyzer, c) <- analyzers
    } {
      println(s"===== Tokenizer or Filter[${c.getSimpleName}], Text[$text] START =====")

      val tokenStream = analyzer.tokenStream("", text)

      val charTermAttr = tokenStream.getAttribute(classOf[CharTermAttribute])

      tokenStream.reset()
      Iterator
        .continually(tokenStream)
        .takeWhile(_.incrementToken())
        .foreach(t => println(charTermAttr))

      println(s"===== Tokenizer or Filter[${c.getSimpleName}] Text[$text]END =====")

      /*
      val query = new QueryParser(Version.LUCENE_44, "", analyzer).parse(text)
      println(s"Query => $query, Class => ${query.getClass.getName}")

      def booleanQueryTrace(q: Query): Unit = q match {
        case bq: BooleanQuery =>
          for (clause <- bq.asInstanceOf[BooleanQuery].getClauses) {
            println(s"BooleanQuery => ${clause.getQuery.getClass.getName}, Occur => ${clause.getOccur.name}")
            booleanQueryTrace(clause.getQuery)
          }
        case _ => println(s"OtherQuery => $q")
      }

      booleanQueryTrace(query)
      */
    }
  }

  private def tokenizerToAnalyzer[T <: Tokenizer : universe.TypeTag](tokenizer: (Version, Reader) => T):
    /** TokenizerをAnalyzerにラップするメソッド **/
  }

  private def filterToAnalyzer[T <: TokenFilter : universe.TypeTag](tokenFilter: (Version, Tokenizer) => T):
    /** TokenFilterをAnalyzerにラップするメソッド **/
  }
}

で、NGramもEdgeNGramもTokenizerとTokenFilterしかないので、Analyzerがありません。ここは、目当てのTokenizerまたはTokenFilterを受け取って、Analyzerにラップするメソッドを用意しました。

  private def tokenizerToAnalyzer[T <: Tokenizer : universe.TypeTag](tokenizer: (Version, Reader) => T):
  (Analyzer, Class[T]) = {
    val mirror = universe.runtimeMirror(Thread.currentThread.getContextClassLoader)
    (new Analyzer {
      protected def createComponents(fieldName: String, reader: Reader): Analyzer.TokenStreamComponents =
      new Analyzer.TokenStreamComponents(tokenizer(Version.LUCENE_44, reader))
    }, mirror.runtimeClass(universe.typeOf[T]).asInstanceOf[Class[T]])
  }

  private def filterToAnalyzer[T <: TokenFilter : universe.TypeTag](tokenFilter: (Version, Tokenizer) => T):
  (Analyzer, Class[T]) = {
    val mirror = universe.runtimeMirror(Thread.currentThread.getContextClassLoader)
    (new Analyzer {
      protected def createComponents(fieldName: String, reader: Reader): Analyzer.TokenStreamComponents = {
        val luceneVersion = Version.LUCENE_44
        val tokenizer = new WhitespaceTokenizer(luceneVersion, reader)
        val tokenStream: TokenStream = tokenFilter(luceneVersion, tokenizer)
        new Analyzer.TokenStreamComponents(tokenizer, tokenStream)
      }
    }, mirror.runtimeClass(universe.typeOf[T]).asInstanceOf[Class[T]])
  }

ムダに、Scalaのリフレクションを使っています(笑)。というか、これのおかげで本当にムダにハマりました…。まあ、ScalaのリフレクションでJavaのClassを取る方法を思い出したから、いいかぁ、と。

上限境界とContext Boundで、型を絞り込みつつリフレクションを使っています。

TokenFilterを受け取る場合は、WhitespaceTokenizerをそえるようにしています。スペースが除去されませんしね。

というわけで、こんなAnalyzerを用意しましたと。

    val analyzers = Array(
      // NGramの場合、minGramとmaxGramを指定しなければ1, 2になる
      tokenizerToAnalyzer((v, r) => new NGramTokenizer(v, r, 2, 2)),
      // EdgeNGramの場合、minGramとmaxGramを指定しない場合は、1, 1(Uni-Gram)になる
      tokenizerToAnalyzer((v, r) => new EdgeNGramTokenizer(v, r, 2, 2)),
      tokenizerToAnalyzer((v, r) => new Lucene43NGramTokenizer(r, 2, 2)),
      tokenizerToAnalyzer((v, r) => new Lucene43EdgeNGramTokenizer(v, r, 2, 2)),
      tokenizerToAnalyzer((v, r) => new NGramTokenizer(v, r, 1, 3)),
      tokenizerToAnalyzer((v, r) => new EdgeNGramTokenizer(v, r, 1, 3)),
      tokenizerToAnalyzer((v, r) => new Lucene43NGramTokenizer(r, 1, 3)),
      tokenizerToAnalyzer((v, r) => new Lucene43EdgeNGramTokenizer(v, r, 1, 3)),
      filterToAnalyzer((v, ts) => new NGramTokenFilter(v, ts, 2, 2)),
      filterToAnalyzer((v, ts) => new EdgeNGramTokenFilter(v, ts, 2, 2)),
      filterToAnalyzer((v, ts) => new NGramTokenFilter(v, ts, 1, 3)),
      filterToAnalyzer((v, ts) => new EdgeNGramTokenFilter(v, ts, 1, 3))
    )

N-Gram分割するテキストはこちら。

    val texts = Array(
      "This is a pen.",
      "オープンソース、全文検索エンジン",
      "Apache Solrで遊びましょう",
      "石川遼"
    )

まあ、いくつか分解していますけど、どれも結果はそんなに変わらないので、「オープンソース全文検索エンジン」の結果を載せましょう。
NGramTokenizer(2, 2)

===== Tokenizer or Filter[NGramTokenizer], Text[オープンソース、全文検索エンジン] START =====
オー
ープ
プン
ンソ
ソー
ース
ス、
、全
全文
文検
検索
索エ
エン
ンジ
ジン
===== Tokenizer or Filter[NGramTokenizer] Text[オープンソース、全文検索エンジン]END =====

EdgeNGramTokenizer(2, 2)

===== Tokenizer or Filter[EdgeNGramTokenizer], Text[オープンソース、全文検索エンジン] START =====
オー
===== Tokenizer or Filter[EdgeNGramTokenizer] Text[オープンソース、全文検索エンジン]END =====

Lucene43NGramTokenizer(2, 2)

===== Tokenizer or Filter[Lucene43NGramTokenizer], Text[オープンソース、全文検索エンジン] START =====
オー
ープ
プン
ンソ
ソー
ース
ス、
、全
全文
文検
検索
索エ
エン
ンジ
ジン
===== Tokenizer or Filter[Lucene43NGramTokenizer] Text[オープンソース、全文検索エンジン]END =====

Lucene43EdgeNGramTokenizer(2, 2)

===== Tokenizer or Filter[Lucene43EdgeNGramTokenizer], Text[オープンソース、全文検索エンジン] START =====
オー
===== Tokenizer or Filter[Lucene43EdgeNGramTokenizer] Text[オープンソース、全文検索エンジン]END =====

NGramTokenizer(1, 3)

===== Tokenizer or Filter[NGramTokenizer], Text[オープンソース、全文検索エンジン] START =====
オ
オー
オープ
ー
ープ
ープン
プ
プン
プンソ
ン
ンソ
ンソー
ソ
ソー
ソース
ー
ース
ース、
ス
ス、
ス、全
、
、全
、全文
全
全文
全文検
文
文検
文検索
検
検索
検索エ
索
索エ
索エン
エ
エン
エンジ
ン
ンジ
ンジン
ジ
ジン
ン
===== Tokenizer or Filter[NGramTokenizer] Text[オープンソース、全文検索エンジン]END =====

EdgeNGramTokenizer(1, 3)

===== Tokenizer or Filter[EdgeNGramTokenizer], Text[オープンソース、全文検索エンジン] START =====
オ
オー
オープ
===== Tokenizer or Filter[EdgeNGramTokenizer] Text[オープンソース、全文検索エンジン]END =====

Lucene43NGramTokenizer(1, 3)

===== Tokenizer or Filter[Lucene43NGramTokenizer], Text[オープンソース、全文検索エンジン] START =====
オ
ー
プ
ン
ソ
ー
ス
、
全
文
検
索
エ
ン
ジ
ン
オー
ープ
プン
ンソ
ソー
ース
ス、
、全
全文
文検
検索
索エ
エン
ンジ
ジン
オープ
ープン
プンソ
ンソー
ソース
ース、
ス、全
、全文
全文検
文検索
検索エ
索エン
エンジ
ンジン
===== Tokenizer or Filter[Lucene43NGramTokenizer] Text[オープンソース、全文検索エンジン]END =====

Lucene43EdgeNGramTokenizer(1, 3)

===== Tokenizer or Filter[], Text[オープンソース、全文検索エンジン] START =====
オ
オー
オープ
===== Tokenizer or Filter[Lucene43EdgeNGramTokenizer] Text[オープンソース、全文検索エンジン]END =====

NGramTokenFilter(2, 2)

===== Tokenizer or Filter[NGramTokenFilter], Text[オープンソース、全文検索エンジン] START =====
オー
ープ
プン
ンソ
ソー
ース
ス、
、全
全文
文検
検索
索エ
エン
ンジ
ジン
===== Tokenizer or Filter[NGramTokenFilter] Text[オープンソース、全文検索エンジン]END =====

EdgeNGramTokenFilter(2, 2)

===== Tokenizer or Filter[EdgeNGramTokenFilter], Text[オープンソース、全文検索エンジン] START =====
オー
===== Tokenizer or Filter[EdgeNGramTokenFilter] Text[オープンソース、全文検索エンジン]END =====

NGramTokenFilter(1, 3)

===== Tokenizer or Filter[NGramTokenFilter], Text[オープンソース、全文検索エンジン] START =====
オ
オー
オープ
ー
ープ
ープン
プ
プン
プンソ
ン
ンソ
ンソー
ソ
ソー
ソース
ー
ース
ース、
ス
ス、
ス、全
、
、全
、全文
全
全文
全文検
文
文検
文検索
検
検索
検索エ
索
索エ
索エン
エ
エン
エンジ
ン
ンジ
ンジン
ジ
ジン
ン
===== Tokenizer or Filter[NGramTokenFilter] Text[オープンソース、全文検索エンジン]END =====

EdgeNGramTokenFilter(1, 3)

===== Tokenizer or Filter[EdgeNGramTokenFilter], Text[オープンソース、全文検索エンジン] START =====
オ
オー
オープ
===== Tokenizer or Filter[EdgeNGramTokenFilter] Text[オープンソース、全文検索エンジン]END =====

NGramとEdgeNGramの違いは、けっこう簡単ですね。EdegeNGramの方は、1回しかやらないので。

今の本流のNGramとLucene43の動きの違いは、開始位置から指定した範囲を分解したら次の位置に進むNGramと
NGramTokenizer(1, 3)

===== Tokenizer or Filter[NGramTokenizer], Text[石川遼] START =====
石
石川
石川遼
川
川遼
遼
===== Tokenizer or Filter[NGramTokenizer] Text[石川遼]END =====

最初にMinimum分を全部分解したら、最初に戻って次の範囲をやり直すのがLucene43という感じですね。
Lucene43NGramTokenizer(1, 3)

===== Tokenizer or Filter[Lucene43NGramTokenizer], Text[石川遼] START =====
石
川
遼
石川
川遼
石川遼
===== Tokenizer or Filter[Lucene43NGramTokenizer] Text[石川遼]END =====

今回のTokenizerを使ってQueryParserにかけても、PhraseQueryにはならずTermQueryになるみたいですよ。

その他、Tokenizerの方は、スペースを何も考慮していないので、トークンの中に普通にスペースが入ります。

===== Tokenizer or Filter[NGramTokenizer], Text[This is a pen.] START =====
Th
hi
is
s 
 i
is
s 
 a
a 
 p
pe
en
n.
===== Tokenizer or Filter[NGramTokenizer] Text[This is a pen.]END =====

Filterの方は、WhitespaceTokenizerと組み合わせたので、その辺はなくなりますが。

===== Tokenizer or Filter[NGramTokenFilter], Text[This is a pen.] START =====
Th
hi
is
is
pe
en
n.
===== Tokenizer or Filter[NGramTokenFilter] Text[This is a pen.]END =====

要は、事前になんとかしろってことですね。

動きはとりあえずわかりましたが、あとはインデックス作るなりして検索とかしてみないと、苦労どころはわかんないんでしょうね。

あとは、APIリファレンスでこのクラスについて、「これにがっかりするなら〜」みたいなくだりが書いてあったところが、非常に気になります…。