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),
http://lucene.apache.org/core/4_4_0/analyzers-common/org/apache/lucene/analysis/ngram/NGramTokenizer.html
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.
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リファレンスでこのクラスについて、「これにがっかりするなら〜」みたいなくだりが書いてあったところが、非常に気になります…。