CLOVER🍀

That was when it all began.

Hibernate Search+Kuromojiで、ユーザ定義辞書を使う

Hibernate Searchを使った日本語検索を少し前に書きましたが、せっかくなのでもう少しHibernate Searchネタを続けてみようと思います。マイペースで。

Luceneで形態素解析といえばKuromojiが導入が簡単ですが、辞書にない単語については結果が変なことになることもままあるわけで。そんな時には、ユーザ定義辞書を使いましょう。

というと普通なのですが、ここではHibernate SearchとKuromojiを組み合わせてユーザ定義辞書を使ってみます。

準備

とりあえず、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>

Hibenate Search、5.0.1が出ていましたね!最後の方は、テストコード向けの依存関係です。

あ、今回はpersistence.xmlとデータベースの定義は端折ります。persistence.xmlにデフォルトのAnalyzerも定義しませんので。

ユーザ定義辞書を用意する

まずは、Kuromojiで使用するユーザ定義辞書を用意します。

前に、「東京スカイツリー」と与えると「東京」「スカイ」「ツリー」に分解されてしまったことや、「きゃりーぱみゅぱみゅ」と与えると「く」「ー」「ぱみゅぱみゅ」と意味不明な結果になったことを踏まえ、これらを辞書に登録してみます。
src/test/resources/my-userdict.txt

# #以降は、コメントとして無視されます

# 単語,形態素解析後の単語(単語を分ける場合は、スペースで区切る),読み,品詞
東京スカイツリー,東京スカイツリー,トウキョウスカイツリー,カスタム名詞
きゃりーぱみゅぱみゅ,きゃりー ぱみゅぱみゅ,キャリー パミュパミュ,カスタム人名

# Lucene Kuromojiのテストコードから抜粋
日本経済新聞,日本 経済 新聞,ニホン ケイザイ シンブン,カスタム名詞
関西国際空港,関西 国際 空港,カンサイ コクサイ クウコウ,テスト名詞
朝青龍,朝青龍,アサショウリュウ,カスタム人名

コメントにも書いていますが、「#」はコメントとなります。

最後の方は、Luceneのテストコードの辞書に含まれいたものを例として貼っただけですが、自分で書いた定義はこんな感じ。

# 単語,形態素解析後の単語(単語を分ける場合は、スペースで区切る),読み,品詞
東京スカイツリー,東京スカイツリー,トウキョウスカイツリー,カスタム名詞
きゃりーぱみゅぱみゅ,きゃりー ぱみゅぱみゅ,キャリー パミュパミュ,カスタム人名

カンマ区切りで情報を書いていきます。最初に対象の単語を書き、次に形態素解析後の単語を書きます。今回は「東京スカイツリー」はそのままとしましたが、「きゃりーぱみゅぱみゅ」は「きゃりー」と「ぱみゅぱみゅ」で割ってみました(意味があるかどうかはさておき)。これは、スペースで区切ればOKです。

あとは、読みと品詞を書いておきます。

なお、ユーザ定義辞書に登録した単語は、かなり優先されるようです。

Entityの定義

では、この辞書を使うJapaneseTokenzierを使ったAnalyzerを定義します。通常のJapaneseAnalyzerは使えないので、Hibernate Searchの提供する@AnalyzerDefアノテーションを使って、Analyzerの定義を組んでいきます。結果、こうなりました。
src/main/java/org/littlewings/hibernate/userdict/Article.java

package org.littlewings.hibernate.userdict;

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.util.*;
import org.hibernate.search.annotations.*;

@AnalyzerDef(name = "japaneseAnalyzerWithUserDict",
             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)
             })
@Entity
@Table(name = "article")
@Indexed
@Analyzer(definition = "japaneseAnalyzerWithUserDict")
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を定義しているのはここですね。定義したAnalyzerの名前は、「japaneseAnalyzerWithUserDict」としました。

@AnalyzerDef(name = "japaneseAnalyzerWithUserDict",
             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)
             })

これって、Solrで使うXMLによるAnalyzer定義をアノテーションにしたみたいなものですね。LuceneのAnalyzerの各パッケージの中に、TokenizerやFilterに合わせてFactoryが提供されているので、これらを使用します。

上記は、基本的にKuromojiが提供するデフォルトのJapaneseAnalyzerとほぼ同じ定義です。

少し違うのは、「userDictionary」パラメータで、先ほど作成したユーザ定義辞書を使うようにしていることです。

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

最後に、このAnalyzerを使うように@AnalyzerアノテーションをEntityに付与します。

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

使ってみる

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

テストコードの雛形は、このように用意。
src/test/java/org/littlewings/hibernate/userdict/UserDictTest.java

package org.littlewings.hibernate.userdict;

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 UserDictTest {
    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();
    }

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

では、辞書に登録した単語を使ってテスト。

        addArticles(new Article("東京スカイツリーは、東京タワーに代わる新タワーです"),
                    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:東京スカイツリー");
        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:ぱみゅぱみゅ");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("きゃりーぱみゅぱみゅは、日本のファッションモデルでアーティストです");

結果、ちゃんと認識できて2つのトークンになりましたね。

ユーザ定義辞書ですが、作るのはそう難しくないんですけど、その後のメンテナンスや辞書を更新した後のインデックスの再作成とか考えるとそれはそれで大変ですよね、と。
※クエリだけに適用されても意味ない

ところで、Analyzerをアノテーションで定義するのはかなり面倒だったので、同じことを普通にAnalyzerクラスを継承して書こうとしたのですが、これもこれで面倒に思うようになって途中でやめました(笑)。まあ、目的は達成できたのでいいかなと。