CLOVER🍀

That was when it all began.

Hibernate Searchでシノニム(類義語)を使う

Hibernate Searchを使って、シノニム(類義語)を使ってみます。

シノニムは、類義語や同義語を意味するもので、Luceneで使う場合ではシノニムとして定義した類似の単語を使って見かけは違う単語でも検索でヒットさせることができるようになります。

先ほどはKuromojiでユーザ定義辞書を書きましたが、ここでも定義ファイルを書きます。また、Analyzerはアノテーションで定義します…。

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

準備

Maven依存関係の定義。

    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-entitymanager</artifactId>
      <version>4.3.8.Final</version>
    </dependency>
    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-search-orm</artifactId>
      <version>5.0.1.Final</version>
    </dependency>
    <dependency>
      <groupId>org.apache.lucene</groupId>
      <artifactId>lucene-analyzers-kuromoji</artifactId>
      <version>4.10.3</version>
    </dependency>

    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>5.1.34</version>
      <scope>runtime</scope>
    </dependency>

    <dependency>
      <groupId>junit</groupId>
      <artifactId>junit</artifactId>
      <version>4.12</version>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>org.assertj</groupId>
      <artifactId>assertj-core</artifactId>
      <version>1.7.1</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

persistence.xmlJPAで使うテーブルの定義は省略。

あ、今回AnalyzerのメインにKuromojiを利用しますが、別にシノニム自体は形態素解析とは関連しません。Kuromojiを使っているのは、単にAnalyzerを形態素解析N-gramにするかで、形態素解析を選んだだけのことです。

シノニムの定義を用意する

シノニムを使うために、同じ意味として扱う単語を用意します。

今回は、このようなものを用意。
src/test/resources/my-synonyms.txt

# 「#」はコメント
スプリング => Spring
ブート => Boot
マクドナルド,マック,マクド

書き方は2通りあります。

まず、「=>」を使った書き方が左の単語(複数可?)を右に出てきた単語として扱う、という書き方です。上記の例だと、「スプリング」は「Spring」となります。
この書き方は、片方向展開というらしいです。

もうひとつ、すべて「,」で区切った場合は、設定次第ですが並べた単語すべてに展開されます。上記の例だと、「マクドナルド,マック,マクド」と書いていますが、「マック」でも「マクド」でも「マクドナルド」「マック」「マクド」の3つに展開されることになります。
この書き方は、双方向展開というらしいです。

なお、「#」はコメントです。

Entityを定義する

では、このシノニム定義を使ったEntityおよびAnalyzerを定義します。

Analyzerは、@AnalyzerDefアノテーションで定義します。
src/main/java/org/littlewings/hibernate/synonym/Article.java

package org.littlewings.hibernate.synonym;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Table;

import org.apache.lucene.analysis.cjk.*;
import org.apache.lucene.analysis.core.*;
import org.apache.lucene.analysis.ja.*;
import org.apache.lucene.analysis.synonym.*;
import org.apache.lucene.analysis.util.*;
import org.hibernate.search.annotations.*;

@AnalyzerDef(name = "japaneseAnalyzerWithSynonym",
             tokenizer = @TokenizerDef(factory = JapaneseTokenizerFactory.class,
                                       params = @Parameter(name = "mode", value = "search")),
             filters = {
                 @TokenFilterDef(factory = JapaneseBaseFormFilterFactory.class),
                 @TokenFilterDef(factory = JapanesePartOfSpeechStopFilterFactory.class,
                                 params = @Parameter(name = "tags", value = "org/apache/lucene/analysis/ja/stoptags.txt")),
                 @TokenFilterDef(factory = CJKWidthFilterFactory.class),
                 @TokenFilterDef(factory = StopFilterFactory.class,
                                 params = @Parameter(name = "words", value = "org/apache/lucene/analysis/ja/stopwords.txt")),
                 @TokenFilterDef(factory = JapaneseKatakanaStemFilterFactory.class),
                 @TokenFilterDef(factory = LowerCaseFilterFactory.class),
                 @TokenFilterDef(factory = SynonymFilterFactory.class,
                                 params = {@Parameter(name = "synonyms", value = "my-synonyms.txt"),
                                           @Parameter(name = "format", value = "solr"),
                                           @Parameter(name = "ignoreCase", value = "true"),
                                           @Parameter(name = "expand", value = "true"),
                                           @Parameter(name = "tokenizerFactory", value = "org.apache.lucene.analysis.ja.JapaneseTokenizerFactory"),
                                           @Parameter(name = "mode", value = "normal")})
             })
@Entity
@Table(name = "article")
@Indexed
@Analyzer(definition = "japaneseAnalyzerWithSynonym")
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column
    @Field
    private String contents;

    public Article() { }

    public Article(String contents) {
        this.contents = contents;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getContents() { return contents; }
    public void setContents(String contents) { this.contents = contents; }
}

Analyzerの定義はこちら。

@AnalyzerDef(name = "japaneseAnalyzerWithSynonym",
             tokenizer = @TokenizerDef(factory = JapaneseTokenizerFactory.class,
                                       params = @Parameter(name = "mode", value = "search")),
             filters = {
                 @TokenFilterDef(factory = JapaneseBaseFormFilterFactory.class),
                 @TokenFilterDef(factory = JapanesePartOfSpeechStopFilterFactory.class,
                                 params = @Parameter(name = "tags", value = "org/apache/lucene/analysis/ja/stoptags.txt")),
                 @TokenFilterDef(factory = CJKWidthFilterFactory.class),
                 @TokenFilterDef(factory = StopFilterFactory.class,
                                 params = @Parameter(name = "words", value = "org/apache/lucene/analysis/ja/stopwords.txt")),
                 @TokenFilterDef(factory = JapaneseKatakanaStemFilterFactory.class),
                 @TokenFilterDef(factory = LowerCaseFilterFactory.class),
                 @TokenFilterDef(factory = SynonymFilterFactory.class,
                                 params = {@Parameter(name = "synonyms", value = "my-synonyms.txt"),
                                           @Parameter(name = "format", value = "solr"),
                                           @Parameter(name = "ignoreCase", value = "true"),
                                           @Parameter(name = "expand", value = "true"),
                                           @Parameter(name = "tokenizerFactory", value = "org.apache.lucene.analysis.ja.JapaneseTokenizerFactory"),
                                           @Parameter(name = "mode", value = "normal")})
             })

KuromojiのAnalyzerの定義に近いですが、最後にシノニム用のフィルタを付けています。

もう少し解説します。

                 @TokenFilterDef(factory = SynonymFilterFactory.class,
                                 params = {@Parameter(name = "synonyms", value = "my-synonyms.txt"),
                                           @Parameter(name = "format", value = "solr"),
                                           @Parameter(name = "ignoreCase", value = "true"),
                                           @Parameter(name = "expand", value = "true"),
                                           @Parameter(name = "tokenizerFactory", value = "org.apache.lucene.analysis.ja.JapaneseTokenizerFactory"),
                                           @Parameter(name = "mode", value = "normal")})

各パラメータの意味ですが、

  • synonyms … シノニムの定義ファイル(クラスパス上からロード)
  • format … シノニム定義ファイルの記述フォーマット(solrまたはwordnet)を指定。solrを選ぶと、パーサーにSolrSynonymParserが使われる
  • ignoreCase … シノニム定義ファイルに登録した単語に、lower caseをかける場合はtrue
  • expand … 「,」で区切って登録した単語を、すべて展開する場合はtrue
  • tokenizerFactory … シノニムの定義ファイルに対してのTokenizerを指定
  • それ以外 … tokenizerFactoryへのパラメータが指定可能

となります。

expandだけもう少し説明すると、先の例で

マクドナルド,マック,マクド

と定義しましたが、expandをtrueにすると、「マクドナルド」でも「マック」でも「マクド」でも上記の3つに展開されますが、falseにした場合は3つのどれを入力しても「マクドナルド」になります。

あと、tokenizerFactoryに対しての注意事項ですが、Kuromojiを使う場合は「normal」モードで登録すること。デフォルトの「search」モードのままだと、場合によってはエラーになって起動しません。

で、こうやって定義したAnalyzerをEntityに指定します。名前は、「japaneseAnalyzerWithSynonym」としました。

@Analyzer(definition = "japaneseAnalyzerWithSynonym")
public class Article {

使ってみる

では、テストコードで確認してみます。

テストコードの雛形は、このように用意。

src/test/java/org/littlewings/hibernate/synonym/SynonymTest.java

package org.littlewings.hibernate.synonym;

import static org.assertj.core.api.Assertions.*;

import java.util.*;
import javax.persistence.*;

import org.hibernate.search.jpa.*;
import org.hibernate.search.query.dsl.*;

import org.junit.*;

public class SynonymTest {
    private static EntityManager em;

    @BeforeClass
    public static void setUpClass() {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("search.pu");
        em = emf.createEntityManager();
    }

    @After
    public void tearDown() {
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        em.createQuery("DELETE FROM Article").executeUpdate();

        tx.commit();
    }

    @AfterClass
    public static void tearDownClass() {
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        em.createNativeQuery("TRUNCATE TABLE article").executeUpdate();

        tx.commit();
    }

    private void addArticles(Article... articles) {
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        Arrays.stream(articles).forEach(em::persist);

        tx.commit();
    }

    // ここに、テストを書く
}

で、だいぶ露骨なシノニムの例でしたが試していきましょう。

まずは、「Spring Boot」で登録したドキュメントに対して、「スプリング ブート」で検索してみます。

        addArticles(new Article("はじめてのSpring Boot"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("スプリング ブート").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:spring contents:boot");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("はじめてのSpring Boot");

投げたクエリは、

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:spring contents:boot");

と「spring」「boot」の2つのトークンになっていますね。

逆も可能です。

        addArticles(new Article("はじめてのスプリング ブート"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("spring boot").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:spring contents:boot");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("はじめてのスプリング ブート");

続いて、「マクドナルド」を「マック」で検索。

        addArticles(new Article("ファーストフードといえば、マクドナルド"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("マック").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:マクドナルド contents:マック contents:マクド");
        // expandをfalseにすると、以下になる
        // assertThat(luceneQuery.toString())
        //    .isEqualTo("contents:マクドナルド");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("ファーストフードといえば、マクドナルド");

この場合は、3つのトークンに展開されています。

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:マクドナルド contents:マック contents:マクド");

コメントアウトしていますが、expandをfalseにするとクエリの展開結果が変わります。もちろん、インデックスに対する結果も変わります。

        // expandをfalseにすると、以下になる
        // assertThat(luceneQuery.toString())
        //    .isEqualTo("contents:マクドナルド");

Kuromojiのユーザ定義辞書と組み合わせる

シノニムというからには、同じものを指す言葉なわけですが…ここで、Twitter上でいろんな名前で呼ばれているしょぼちむさんにご登場いただきましょう。

先ほどのシノニム定義ファイルに、1行追加します。
src/test/resources/my-synonyms.txt

# 「#」はコメント
スプリング => Spring
ブート => Boot
マクドナルド,マック,マクド
レッドキング,アイドル,ちむさん,ネカマ,しょぼちむ

このあたりが、よく見かける呼び名でしょうか…。

しかし、このまま先ほどのコードに適用すると、Kuromojiは「しょぼちむ」や「ちむさん」という単語を形態素解析しようとして妙な結果になります。

そこで、これらの単語をユーザ定義辞書に登録しましょう。
src/test/resources/my-userdict.txt

しょぼちむ,しょぼちむ,ショボチム,カスタム人名
ちむさん,ちむさん,チムサン,カスタム人名

人名…?

それはさておき、これをAnalyzerに適用します。が、シノニム定義ファイルを読む時にtokenFactoryを指定していることを考えると、tokenizerとシノニムのフィルタのtokenizerFactoryの両方に適用する必要がありそうですね。

というわけで、Anlyzerの定義をこのように変更しました。

@AnalyzerDef(name = "japaneseAnalyzerWithSynonym",
             tokenizer = @TokenizerDef(factory = JapaneseTokenizerFactory.class,
                                       params = {@Parameter(name = "mode", value = "search"),
                                                 @Parameter(name = "userDictionary", value = "my-userdict.txt")}),
             filters = {
                 @TokenFilterDef(factory = JapaneseBaseFormFilterFactory.class),
                 @TokenFilterDef(factory = JapanesePartOfSpeechStopFilterFactory.class,
                                 params = @Parameter(name = "tags", value = "org/apache/lucene/analysis/ja/stoptags.txt")),
                 @TokenFilterDef(factory = CJKWidthFilterFactory.class),
                 @TokenFilterDef(factory = StopFilterFactory.class,
                                 params = @Parameter(name = "words", value = "org/apache/lucene/analysis/ja/stopwords.txt")),
                 @TokenFilterDef(factory = JapaneseKatakanaStemFilterFactory.class),
                 @TokenFilterDef(factory = LowerCaseFilterFactory.class),
                 @TokenFilterDef(factory = SynonymFilterFactory.class,
                                 params = {@Parameter(name = "synonyms", value = "my-synonyms.txt"),
                                           @Parameter(name = "format", value = "solr"),
                                           @Parameter(name = "ignoreCase", value = "true"),
                                           @Parameter(name = "expand", value = "true"),
                                           @Parameter(name = "tokenizerFactory", value = "org.apache.lucene.analysis.ja.JapaneseTokenizerFactory"),
                                           @Parameter(name = "mode", value = "normal"),
                                           @Parameter(name = "userDictionary", value = "my-userdict.txt")})
             })

@TokenizerDefにユーザ定義辞書「userDictionary」を加え、

             tokenizer = @TokenizerDef(factory = JapaneseTokenizerFactory.class,
                                       params = {@Parameter(name = "mode", value = "search"),
                                                 @Parameter(name = "userDictionary", value = "my-userdict.txt")}),

シノニムの@TokenFilterDefでのtokenizerFactoryへの追加パラメータとして、「userDictionary」を指定。

                 @TokenFilterDef(factory = SynonymFilterFactory.class,
                                 params = {@Parameter(name = "synonyms", value = "my-synonyms.txt"),
                                           @Parameter(name = "format", value = "solr"),
                                           @Parameter(name = "ignoreCase", value = "true"),
                                           @Parameter(name = "expand", value = "true"),
                                           @Parameter(name = "tokenizerFactory", value = "org.apache.lucene.analysis.ja.JapaneseTokenizerFactory"),
                                           @Parameter(name = "mode", value = "normal"),
                                           @Parameter(name = "userDictionary", value = "my-userdict.txt")})

テストコードはこのように。「しょぼちむ」を「アイドル」で検索します。

        addArticles(new Article("しょぼちむさんは、有名人です"));

        FullTextEntityManager ftem = Search.getFullTextEntityManager(em);
        QueryBuilder queryBuilder =
            ftem.getSearchFactory().buildQueryBuilder().forEntity(Article.class).get();

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("アイドル").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

        List<Article> articles = jpaQuery.getResultList();

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:レッドキング contents:アイドル contents:ちむさん contents:ネカマ contents:しょぼちむ");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("しょぼちむさんは、有名人です");

双方向展開でシノニムを書いたので、全部に展開されましたね。

というわけで、シノニムを使った例でした。

シノニムもいいことばかりではなく、当然メンテナンスは必要ですし、更新したらインデックスへの適用も必要です。また、いくつか注意事項もあるようなので、詳しくはSolr本などを見ておきましょう。

[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン (Software Design plus)

[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン (Software Design plus)