CLOVER🍀

That was when it all began.

LuceneのSynonymFilterを使う

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入門 ―オープンソース全文検索エンジン

Apache Solr入門 ―オープンソース全文検索エンジン

以下のような類義語定義をした場合

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)
  }
}