Luceneで類義語の定義を行う、SynonymFilterを使ってみました。Solrでもよく目にするので、知っておいて損はないでしょう。
主に使うのは、
- SynonymFilterクラス
- SynonymMapクラス
- SynonymMap.Builderクラス
になります。Solrの記法を使って類義語の定義を書く場合は、SynonymMap.Builderクラスのサブクラスである、SolrSynonymParserクラスを使います。
*他に、WordnetSynonymParserクラスというサブクラスもあるみたいですが、こちらは端折りました…
使い方としては、SynonymMap.BuilderクラスまたはSolrSynonymParserクラスでSynonymMapクラスのインスタンスを作成し、それをSynonymFilterに渡すことで類義語を理解できるFilterを作成することができます。
準備
とりあえず、いつものようにbuild.sbt
name := "lucene-synonym" version := "0.0.1-SNAPSHOT" scalaVersion := "2.10.2" organization := "littlewings" libraryDependencies += "org.apache.lucene" % "lucene-analyzers-kuromoji" % "4.3.1"
それでは、母体となるプログラムを。SynonymMap.BuilderとSolrSynonymParserのどちらを使うかは起動引数で分けるようにしていますが、詳細はこの後で。
src/main/scala/LuceneSynonym.scala
import java.io.{InputStreamReader, Reader, StringReader} import java.nio.charset.StandardCharsets import org.apache.lucene.analysis.{Analyzer, Tokenizer, TokenStream} import org.apache.lucene.analysis.core.{LowerCaseFilter, StopFilter, WhitespaceAnalyzer} import org.apache.lucene.analysis.ja.{JapaneseAnalyzer, JapaneseTokenizer} import org.apache.lucene.analysis.synonym.{SolrSynonymParser, SynonymFilter, SynonymMap} import org.apache.lucene.analysis.tokenattributes.CharTermAttribute import org.apache.lucene.util.{CharsRef, Version} object LuceneSynonym { def main(args: Array[String]): Unit = { val synonymMap = args.toList.headOption match { case Some("solr") => createSynonymMapBySolrParser("synonym.txt") case _ => createSynonymMap } val analyzer = new SynonymMapAnalyzer({ reader => new JapaneseTokenizer(reader, null, true, JapaneseTokenizer.Mode.SEARCH)}, synonymMap) for (str <- Array("iPhone", "iphone", "アイフォン", "Android", "アンドロイド", "Hello World", "Hello Another World", "expand", "duplicate", "dup")) { val tokenStream = analyzer.tokenStream("", new StringReader(str)) println(s"========== Analyze Token[$str] START ==========") try { val charTermAttr = tokenStream.addAttribute(classOf[CharTermAttribute]) tokenStream.reset() Iterator .continually(tokenStream) .takeWhile(_.incrementToken()) .foreach { ts => println(s"token: ${charTermAttr.toString}") } tokenStream.end() } finally { tokenStream.close() } println(s"========== Analyze Token[$str] END ==========") } } private def createSynonymMap: SynonymMap = { /* SynonymMap.BuilderでSynonymMapを作成する */ } private def createSynonymMapBySolrParser(path: String): SynonymMap = { /* SolrSynonymParserでSynonymMapを作成する */ } } class SynonymMapAnalyzer(tokenizerFactory: Reader => Tokenizer, synonymMap: SynonymMap) extends Analyzer { override def createComponents(fieldName: String, reader: Reader): Analyzer.TokenStreamComponents = { val tokenizer = tokenizerFactory(reader) val stream = /* SynonymFilterを作成する */ new Analyzer.TokenStreamComponents(tokenizer, stream) } }
なお、最終的に作成したSynonymMapは、それを使うSynonymFilterに渡すわけですが、あくまでFilterなのでAnalyzerは自分で作るなりしないといけません。今回は、こちらがその部分ですね。
class SynonymMapAnalyzer(tokenizerFactory: Reader => Tokenizer, synonymMap: SynonymMap) extends Analyzer { override def createComponents(fieldName: String, reader: Reader): Analyzer.TokenStreamComponents = { val tokenizer = tokenizerFactory(reader) val stream = /* SynonymFilterを作成する */ new Analyzer.TokenStreamComponents(tokenizer, stream) } }
SynonymMap.Builder
それでは、まずはSynonymMap.Builderを使うやり方から。SynonymFilterの使い方自体も、合わせて。
インスタンスは、普通にnewして作成します。
val builder = new SynonymMap.Builder(true)
ここで引数にbooleanを取りますが、これは同じ類義語の定義があった場合にまとめてしまうかそうでないかです。trueを指定した場合は、同じ定義は1度しか追加されません。
続いて、類義語の定義を追加していきます。
staticメソッドであるSynonymMap.Builder.joinでCharsRefのインスタンスを設定して、SynonymMap.Builderのaddメソッドに渡します。
builder.add(SynonymMap.Builder.join(Array("iPhone"), new CharsRef), SynonymMap.Builder.join(Array("あいふぉん", "アイフォーン", "アイフォン"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("Android"), new CharsRef), SynonymMap.Builder.join(Array("あんどろいど", "アンドロイド", "ドロイド"), new CharsRef), false)
ちょっとわかりにくいですが、joinメソッドの引数であるCharsRefはそのまま戻り値になるので、joinメソッドの引数であるString配列の内容が追加されて戻ります。
というわけで、addメソッドは
- 第1引数に、類義語の変換対象単語
- 第2引数に、類義語の変換後の単語
- 第3引数に、入力された単語を残すかどうか(残す場合はtrue)
を指定することができます。
なお、
builder.add(SynonymMap.Builder.join(Array("Hello", "World"), new CharsRef), SynonymMap.Builder.join(Array("こんにちは", "世界", "!", "ようこそ", "いらっしゃいませ"), new CharsRef), true)
のように入力を2つの単語で取るようにした場合は、フレーズのように連続で登場しないと類義語として扱われないようです。
また、ここで入力として取る単語は、元の入力文字列が単語分割されたものに対して設定することになるので、その点に注意しましょう。
joinメソッドの他に、String配列ではなく、単純なStringとAnalyzerを引数に取り、その解析結果をCharsRefとして返却するanalyzeメソッドもあるようです。
類義語を登録したら、最後にbuildメソッドを呼び出してSynonymMapを取得します。
builder.build
続いて、SynonymFilterについて。こちらは、作成したSnynonymMapをコンストラクタに渡して、インスタンスを作成します。
class SynonymMapAnalyzer(tokenizerFactory: Reader => Tokenizer, synonymMap: SynonymMap) extends Analyzer { override def createComponents(fieldName: String, reader: Reader): Analyzer.TokenStreamComponents = { val tokenizer = tokenizerFactory(reader) val stream = new SynonymFilter(tokenizer, synonymMap, false) new Analyzer.TokenStreamComponents(tokenizer, stream) } }
この時、コンストラクタにbooleanを取るのですが、これは大文字・小文字を区別するかどうかの指定で、trueにすると区別しなくなります。が、SynonymMapを作成する時に、単語を小文字で登録する必要があります。
では、ちょっと試してみましょう。
SynonymFilterでは大文字、小文字を区別する(false)、という設定で以下のようにSynonymMapを作成します。あ、ここでは登録内容が重複しているものは、1度しか登録しないようにしています。
val builder = new SynonymMap.Builder(true) builder.add(SynonymMap.Builder.join(Array("iPhone"), new CharsRef), SynonymMap.Builder.join(Array("あいふぉん", "アイフォーン", "アイフォン"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("Android"), new CharsRef), SynonymMap.Builder.join(Array("あんどろいど", "アンドロイド", "ドロイド"), new CharsRef), false) builder.add(SynonymMap.Builder.join(Array("Hello", "World"), new CharsRef), SynonymMap.Builder.join(Array("こんにちは", "世界", "!", "ようこそ", "いらっしゃいませ"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("expand"), new CharsRef), SynonymMap.Builder.join(Array("展開します", "展開されます"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("duplicate"), new CharsRef), SynonymMap.Builder.join(Array("ダブってます", "ダブリです"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("duplicate"), new CharsRef), SynonymMap.Builder.join(Array("ダブってます", "ダブリです"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("dup"), new CharsRef), SynonymMap.Builder.join(Array("入力単語が", "見えますが、"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("dup"), new CharsRef), SynonymMap.Builder.join(Array("ダブってそうに", "これは、重複になりません"), new CharsRef), true) builder.build }
そして、入力文字列をこのように取って
for (str <- Array("iPhone", "iphone", "アイフォン", "Android", "アンドロイド", "Hello World", "hello world", "Hello Another World", "expand", "duplicate", "dup")) {
実行すると、結果はこんな感じになります。
> run [info] Running LuceneSynonym ========== Analyze Token[iPhone] START ========== token: iPhone token: あいふぉん token: アイフォーン token: アイフォン ========== Analyze Token[iPhone] END ========== ========== Analyze Token[iphone] START ========== token: iphone ========== Analyze Token[iphone] END ========== ========== Analyze Token[アイフォン] START ========== token: アイフォン ========== Analyze Token[アイフォン] END ========== ========== Analyze Token[Android] START ========== token: あんどろいど token: アンドロイド token: ドロイド ========== Analyze Token[Android] END ========== ========== Analyze Token[アンドロイド] START ========== token: アンドロイド ========== Analyze Token[アンドロイド] END ========== ========== Analyze Token[Hello World] START ========== token: Hello token: こんにちは token: World token: 世界 token: ! token: ようこそ token: いらっしゃいませ ========== Analyze Token[Hello World] END ========== ========== Analyze Token[hello world] START ========== token: hello token: world ========== Analyze Token[hello world] END ========== ========== Analyze Token[Hello Another World] START ========== token: Hello token: Another token: World ========== Analyze Token[Hello Another World] END ========== ========== Analyze Token[expand] START ========== token: expand token: 展開します token: 展開されます ========== Analyze Token[expand] END ========== ========== Analyze Token[duplicate] START ========== token: duplicate token: ダブってます token: ダブリです ========== Analyze Token[duplicate] END ========== ========== Analyze Token[dup] START ========== token: dup token: 入力単語が token: ダブってそうに token: 見えますが、 token: これは、重複になりません ========== Analyze Token[dup] END ========== [success] Total time: 1 s, completed 2013/07/17 23:34:33
SynonymMap.Builder#addメソッドの第3引数がtrueの場合は、入力された単語そのものも解析結果に含まれるようになっています。
出力結果の方に定義されている単語を入力しても、何も起こりません。
========== Analyze Token[アンドロイド] START ========== token: アンドロイド ========== Analyze Token[アンドロイド] END ==========
また、重複を許すようにすると
val builder = new SynonymMap.Builder(false)
こちらみたいな重複定義は
builder.add(SynonymMap.Builder.join(Array("duplicate"), new CharsRef), SynonymMap.Builder.join(Array("ダブってます", "ダブリです"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("duplicate"), new CharsRef), SynonymMap.Builder.join(Array("ダブってます", "ダブリです"), new CharsRef), true)
結果が2倍に出力されるようになります。
========== Analyze Token[duplicate] START ========== token: duplicate token: ダブってます token: ダブってます token: ダブリです token: ダブリです ========== Analyze Token[duplicate] END ==========
SolrSynonymParser
Solrを使う時の、類義語の設定で登場するやつです。類義語の定義を、設定ファイルで書くことができ、そのパーサーになります。
使い方は、こんな感じ。設定ファイルの内容を単語分割するので、Analyzerが必要になります。が、変に単語分割されると困るんじゃないかと…SynonymFilterFactoryを確認したところ、デフォルトはWhitespaceAnalyzerだったので、今回はそちらを利用しました。変にJapaneseAnalyzerとかを使って、単語分割してしまった場合はコケてしまったので…。
private def createSynonymMapBySolrParser(path: String): SynonymMap = { val parser = new SolrSynonymParser(true, true, new WhitespaceAnalyzer(Version.LUCENE_43)) parser.add(new InputStreamReader(getClass.getResourceAsStream(path), StandardCharsets.UTF_8)) parser.build }
SolrSynonymParserは、2つbooleanの引数を取りますが、第1引数は重複定義をまとめるかどうかです。SynonymMap.Builderの引数と同じですね。
もうひとつは、類義語を展開するかどうかです。
この説明をするには、そもそも設定ファイルの書き方の話をしなくてはいけませんね。Solr本の説明を、そのまま借りますが(笑)。
Apache Solr入門 ―オープンソース全文検索エンジン
- 作者: 関口宏司,三部靖夫,武田光平,中野猛,大谷純
- 出版社/メーカー: 技術評論社
- 発売日: 2010/02/20
- メディア: 大型本
- 購入: 18人 クリック: 567回
- この商品を含むブログ (22件) を見る
以下のような類義語定義をした場合
Microsoft => マイクロソフト Yahoo => ヤフー Google => グーグル マクドナルド, マック, マクド
「Microsoft」と単語が入力された場合は「マイクロソフト」となり、「Yahoo」は「ヤフー」と変換されることになります。
これに対して、最後の行は、「マクドナルド」、「マック」、「マクド」のいずれの単語を入力されてもこの3つに展開されることになります。この部分の、展開されるというのがSolrSynonymParserの第2引数をtrueにした場合の動きになります。
反対にfalseにした場合は、「マクドナルド」、「マック」、「マクド」のいずれを入力しても「マクドナルド」になることになります。
では、試してみましょう。
類義語の定義ファイルを、こんな感じで設定します。
src/main/resources/synonym.txt
Android, アンドロイド => ドロイド, あんどろいど iPhone, アイフォーン, アイフォン => あいふぉん Hello World => こんにちは 世界 正規化されると、これだけになります, 展開します, 展開されます, expand duplicate => ダブってます duplicate => ダブってます dup => 入力単語が, ダブってそうに dup => 見えますが, これは重複になりません
expandだけが、展開および正規化可能な形式になってます。
で、同じく入力文字列はこちらで
for (str <- Array("iPhone", "iphone", "アイフォン", "Android", "アンドロイド", "Hello World", "hello world", "Hello Another World", "expand", "展開します", "duplicate", "dup")) {
SolrSynonymParserで重複定義はまとめ、類義語の展開を行う設定で、大文字、小文字は区別するSynonymFilterで実行すると、こういう結果になります。
> run solr [info] Running LuceneSynonym solr ========== Analyze Token[iPhone] START ========== token: あいふぉん ========== Analyze Token[iPhone] END ========== ========== Analyze Token[iphone] START ========== token: iphone ========== Analyze Token[iphone] END ========== ========== Analyze Token[アイフォン] START ========== token: あいふぉん ========== Analyze Token[アイフォン] END ========== ========== Analyze Token[Android] START ========== token: ドロイド token: あんどろいど ========== Analyze Token[Android] END ========== ========== Analyze Token[アンドロイド] START ========== token: ドロイド token: あんどろいど ========== Analyze Token[アンドロイド] END ========== ========== Analyze Token[Hello World] START ========== token: こんにちは token: 世界 ========== Analyze Token[Hello World] END ========== ========== Analyze Token[hello world] START ========== token: hello token: world ========== Analyze Token[hello world] END ========== ========== Analyze Token[Hello Another World] START ========== token: Hello token: Another token: World ========== Analyze Token[Hello Another World] END ========== ========== Analyze Token[expand] START ========== token: 正規化されると、これだけになります token: 展開します token: 展開されます token: expand ========== Analyze Token[expand] END ========== ========== Analyze Token[duplicate] START ========== token: ダブってます ========== Analyze Token[duplicate] END ========== ========== Analyze Token[dup] START ========== token: 入力単語が token: ダブってそうに token: 見えますが token: これは重複になりません ========== Analyze Token[dup] END ==========
重複の排除については特に言うことはないのですが、入力単語が同じで、展開後の結果が異なるものについては、SynonymMap.Builderとは微妙に違う結果になりましたね。
SynonymMap.Builderだと
builder.add(SynonymMap.Builder.join(Array("dup"), new CharsRef), SynonymMap.Builder.join(Array("入力単語が", "見えますが、"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("dup"), new CharsRef), SynonymMap.Builder.join(Array("ダブってそうに", "これは、重複になりません"), new CharsRef), true)
だったのが、SolrSynonymParserだと
dup => 入力単語が, ダブってそうに dup => 見えますが, これは重複になりません
という書き方で、同じ統合のされ方をします。
で、最後に類義語の展開をオフ(false)にしてみると
val parser = new SolrSynonymParser(true, false, new WhitespaceAnalyzer(Version.LUCENE_43))
expandを入れてたところの出力結果が、これだけになります。
========== Analyze Token[expand] START ========== token: 正規化されると、これだけになります ========== Analyze Token[expand] END ==========
1番左の定義に、まとめられましたね。
なお、SolrSynonymParserだと大文字、小文字を区別する設定がありませんが、区別しないようにする場合は、インスタンス作成時のAnalyzerにLowerCaseFilterを適用済みにする必要があるみたいです。
実際、SynonymFilterFactoryがそんな実装でした。
Analyzer analyzer = new Analyzer() { @Override protected TokenStreamComponents createComponents(String fieldName, Reader reader) { Tokenizer tokenizer = factory == null ? new WhitespaceTokenizer(Version.LUCENE_50, reader) : factory.create(reader); TokenStream stream = ignoreCase ? new LowerCaseFilter(Version.LUCENE_50, tokenizer) : tokenizer; return new TokenStreamComponents(tokenizer, stream); } };
なかなかよい勉強になりました。
src/main/resources/synonym.txt
Android, アンドロイド => ドロイド, あんどろいど iPhone, アイフォーン, アイフォン => あいふぉん Hello World => こんにちは 世界 正規化されると、これだけになります, 展開します, 展開されます, expand duplicate => ダブってます duplicate => ダブってます dup => 入力単語が, ダブってそうに dup => 見えますが, これは重複になりません
src/main/scala/LuceneSynonym.scala
import java.io.{InputStreamReader, Reader, StringReader} import java.nio.charset.StandardCharsets import org.apache.lucene.analysis.{Analyzer, Tokenizer, TokenStream} import org.apache.lucene.analysis.core.{LowerCaseFilter, StopFilter, WhitespaceAnalyzer} import org.apache.lucene.analysis.ja.{JapaneseAnalyzer, JapaneseTokenizer} import org.apache.lucene.analysis.synonym.{SolrSynonymParser, SynonymFilter, SynonymMap} import org.apache.lucene.analysis.tokenattributes.CharTermAttribute import org.apache.lucene.util.{CharsRef, Version} object LuceneSynonym { def main(args: Array[String]): Unit = { val synonymMap = args.toList.headOption match { case Some("solr") => createSynonymMapBySolrParser("synonym.txt") case _ => createSynonymMap } val analyzer = new SynonymMapAnalyzer({ reader => new JapaneseTokenizer(reader, null, true, JapaneseTokenizer.Mode.SEARCH)}, synonymMap) for (str <- Array("iPhone", "iphone", "アイフォン", "Android", "アンドロイド", "Hello World", "hello world", "Hello Another World", "expand", "duplicate", "dup")) { val tokenStream = analyzer.tokenStream("", new StringReader(str)) println(s"========== Analyze Token[$str] START ==========") try { val charTermAttr = tokenStream.addAttribute(classOf[CharTermAttribute]) tokenStream.reset() Iterator .continually(tokenStream) .takeWhile(_.incrementToken()) .foreach { ts => println(s"token: ${charTermAttr.toString}") } tokenStream.end() } finally { tokenStream.close() } println(s"========== Analyze Token[$str] END ==========") } } private def createSynonymMap: SynonymMap = { val builder = new SynonymMap.Builder(true) builder.add(SynonymMap.Builder.join(Array("iPhone"), new CharsRef), SynonymMap.Builder.join(Array("あいふぉん", "アイフォーン", "アイフォン"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("Android"), new CharsRef), SynonymMap.Builder.join(Array("あんどろいど", "アンドロイド", "ドロイド"), new CharsRef), false) builder.add(SynonymMap.Builder.join(Array("Hello", "World"), new CharsRef), SynonymMap.Builder.join(Array("こんにちは", "世界", "!", "ようこそ", "いらっしゃいませ"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("expand"), new CharsRef), SynonymMap.Builder.join(Array("展開します", "展開されます"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("duplicate"), new CharsRef), SynonymMap.Builder.join(Array("ダブってます", "ダブリです"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("duplicate"), new CharsRef), SynonymMap.Builder.join(Array("ダブってます", "ダブリです"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("dup"), new CharsRef), SynonymMap.Builder.join(Array("入力単語が", "見えますが、"), new CharsRef), true) builder.add(SynonymMap.Builder.join(Array("dup"), new CharsRef), SynonymMap.Builder.join(Array("ダブってそうに", "これは、重複になりません"), new CharsRef), true) builder.build } private def createSynonymMapBySolrParser(path: String): SynonymMap = { val parser = new SolrSynonymParser(true, false, new WhitespaceAnalyzer(Version.LUCENE_43)) parser.add(new InputStreamReader(getClass.getResourceAsStream(path), StandardCharsets.UTF_8)) parser.build } } class SynonymMapAnalyzer(tokenizerFactory: Reader => Tokenizer, synonymMap: SynonymMap) extends Analyzer { override def createComponents(fieldName: String, reader: Reader): Analyzer.TokenStreamComponents = { val tokenizer = tokenizerFactory(reader) val stream = new SynonymFilter(tokenizer, synonymMap, false) new Analyzer.TokenStreamComponents(tokenizer, stream) } }