CLOVER🍀

That was when it all began.

Hibernate Searchで試す、日本語検索 - 形態素解析編

ここ最近、Luceneネタをそれほど扱っていませんでしたが、Hibernate Searchをちょっとずつ見始めているので、これを機にHibernate Searchを使って日本語検索を試してみようと思いまして。

Hibernate Searchを使うと、JPA(というかHibernate)とLuceneを統合して全文検索を行うことができます。

Hibernate Search
http://hibernate.org/search/

これを使って、日本語を含めた全文検索を行ってみます。日本語検索ということで、形態素解析N-gramを。

※続きも書きました
Hibernate Searchで試す、日本語検索 - N-gram
http://d.hatena.ne.jp/Kazuhira/20150110/1420891449

Hibernate Searchで試す、日本語検索 - 形態素解析N-gram
http://d.hatena.ne.jp/Kazuhira/20150110/1420893906

ところで、これを書いている自分のスキルはまあこんな感じです。

  • JPA … ちょっと遊んだことある
  • Hibernate Search … Getting Startedレベルで試した程度
  • 検索エンジン … 仕事で近くのチームが使っているものの、自身はあまり触らず
  • Lucene … ちょっと遊んだことある

とまあ、Hibernate Searchも検索エンジンもうっすい感じにしか経験ないのですが、勉強を兼ねてということで。

環境準備

それでは、まずはHibernate Searchを使う準備をしましょう。

今回はJPAと統合する前提で進めます。

用意するテーブルは面倒なので、この程度にしました。データベースは、MySQLを使用しています。

CREATE TABLE article(
  id INT AUTO_INCREMENT,
  contents VARCHAR(255),
  PRIMARY KEY(id)
);

検索がしたいだけなので、あまりカラムがいっぱいあっても意味ないので…。

Maven依存関係は、このように定義しました。

    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-entitymanager</artifactId>
      <version>4.3.7.Final</version>
    </dependency>
    <dependency>
      <groupId>org.hibernate</groupId>
      <artifactId>hibernate-search-orm</artifactId>
      <version>5.0.0.Final</version>
    </dependency>
    <dependency>
      <groupId>org.apache.lucene</groupId>
      <artifactId>lucene-analyzers-kuromoji</artifactId>
      <version>4.10.2</version>
    </dependency>

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

Java SE環境で動かします。形態素解析器はKuromojiとします。

Entity定義とpersistence.xml

Entity定義は、このようにしました。
src/main/java/org/littlewings/hibernate/japanese/Article.java

package org.littlewings.hibernate.japanese;

import javax.persistence.*;

import org.hibernate.search.annotations.*;

@Entity
@Table(name = "article")
@Indexed
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; }
}

@Fieldはひとつだけ、至って単純ですね。

persistence.xmlは、このように定義しました。
*実行は、JUnitテストケースで行うことを想定しています
src/test/resources/META-INF/persistence.xml

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd"
             version="2.1">
  <persistence-unit name="search.pu" transaction-type="RESOURCE_LOCAL">
    <provider>org.hibernate.ejb.HibernatePersistence</provider>
    <class>org.littlewings.hibernate.japanese.Article</class>
    <properties>
      <property name="javax.persistence.jdbc.url" value="jdbc:mysql://localhost:3306/practice?useUnicode=true&amp;characterEncoding=utf-8&amp;characterSetResults=utf-8&amp;useServerPrepStmts=true&amp;useLocalSessionState=true&amp;elideSetAutoCommits=true&amp;alwaysSendSetIsolation=false" />
      <property name="javax.persistence.jdbc.user" value="kazuhira" />
      <property name="javax.persistence.jdbc.password" value="password" />

      <!-- Hibernate -->
      <property name="hibernate.dialect" value="org.hibernate.dialect.MySQL5InnoDBDialect" />
      <property name="hibernate.show_sql" value="true" />
      <property name="hibernate.format_sql" value="true" />

      <!-- Hibernate Search -->
      <property name="hibernate.search.default.directory_provider" value="ram" />
      <property name="hibernate.search.analyzer" value="org.apache.lucene.analysis.ja.JapaneseAnalyzer" />
      <property name="hibernate.search.lucene_version" value="LUCENE_4_10_2" />
    </properties>
  </persistence-unit>
</persistence>

Luceneのインデックスの保存先はメモリ(RAM)、デフォルトのAnalyzerはKuromojiのJapanseAnalyzerです。

テストコードを書いて試す

それでは、用意したEntityを使って、Hibernate Searchを使ったテストコードを書いていこうと思います。これにあたり、テストのサポートメソッドを含めたクラスを定義。

テストコードには、JUnitとAssertJを使用しています。
src/test/java/org/littlewings/hibernate/japanese/JapaneseSearchTest.java

package org.littlewings.hibernate.japanese;

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

    // 以降に、テストを書いていく!
}

テストが終了したら、都度DELETE。最後にTRUNCATE。あと、Entityを追加するためのヘルパーメソッドを付けています。

まずは検索してみる

以降、テストを書きつつ、KuromojiをAnalyzerとして使っているので、形態素解析しながら検索してみましょう。Hibernate Search自体の使い方については、特に書きません。

形態素解析しているので、日本語の単語がけっこうわかってくれます。

データ登録。

        addArticles(new Article("あなたとJAVA、今すぐダウンロード"),
                    new Article("東京都は、日本の中枢だ"),
                    new Article("すもももももももものうち"),
                    new Article("京都は歴史のある都市だ"),
                    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("東京都は、日本の中枢だ");

ちゃんと、「東京都」が入ったドキュメントがヒットしています。

ちなみに、今使っているのは(Hibernate Searchでいう)キーワードクエリです。

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("contents").matching("東京").createQuery();

Luceneのクエリに当てはめると、TermQueryができあがる模様。

もうちょっといろいろ試してみましょう。

「京都」で検索。

        addArticles(new Article("あなたとJAVA、今すぐダウンロード"),
                    new Article("東京都は、日本の中枢だ"),
                    new Article("すもももももももものうち"),
                    new Article("京都は歴史のある都市だ"),
                    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("京都は歴史のある都市だ");

「東京都」はヒットしていません。

全角小文字入力で「java」。

        addArticles(new Article("あなたとJAVA、今すぐダウンロード"),
                    new Article("東京都は、日本の中枢だ"),
                    new Article("すもももももももものうち"),
                    new Article("京都は歴史のある都市だ"),
                    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("java").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

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

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:java");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("あなたとJAVA、今すぐダウンロード");

それでも、半角英字、大文字で入力したドキュメントにヒットします。内部のフィルタで正規化されるおかげですね。

OR検索だった?

これまでのキーワードクエリは、形態素解析された単語のOR検索になります。

よって、以下のようなドキュメントを登録して、「東京スカイツリー」で検索してみると

        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:東京 contents:スカイ contents:ツリー");
        assertThat(articles)
            .hasSize(2);
        assertThat(articles.get(0).getContents())
            .isEqualTo("東京スカイツリー");
        assertThat(articles.get(1).getContents())
            .isEqualTo("ツリーといえば、クリスマス?");

両方のドキュメントがヒットします。

これは、「東京スカイツリー」自体を(辞書に登録されていないので)単語として認識できないので、「東京」と「スカイ」と「ツリー」に分割され、うち「ツリー」に両方のドキュメントがヒットした形ですね。

単語の登場順は考慮されない

今度は少し趣向を変えて、「全文検索」で検索してみます。

        addArticles(new Article("あなたとJAVA、今すぐダウンロード"),
                    new Article("東京都は、日本の中枢だ"),
                    new Article("すもももももももものうち"),
                    new Article("京都は歴史のある都市だ"),
                    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:全文 contents:検索");
        assertThat(articles)
            .hasSize(2);
        assertThat(articles.get(0).getContents())
            .isEqualTo("全文検索入門");
        assertThat(articles.get(1).getContents())
            .isEqualTo("文書に含まれる全文を対象とした検索");

この場合、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("きゃりーぱみゅぱみゅ").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

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

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:く contents:ー contents:ぱみゅぱみゅ");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("きゃりーぱみゅぱみゅ");

結果が、「く」「ー」「ぱみゅぱみゅ」ですね…。

また、コストによる単語分割結果として、以下のエントリが面白いことを書いていたので、ちょっとマネしてみました。

Solr + kuromoji で単語の切れ方がおかしかったのでガッツリ調べてみた、理由と調べ方その方法を公開します!
http://blog.yoslab.com/entry/2014/09/12/005207

デジタル一眼レフ」と「一眼レフ」で、分割結果が変わるといいます。

試してみましょう。まずは「デジタル一眼レフ」。

        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:レフ");
        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("一眼レフ");

確かに、切れ方が変わりますね…。

微妙な検索結果

例えば、このように「水」を含むドキュメントをいろいろ登録して、「水」で検索してみます。

        addArticles(new Article("飲料水"),
                    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(2);
        assertThat(articles.get(0).getContents())
            .isEqualTo("飲料水");
        assertThat(articles.get(1).getContents())
            .isEqualTo("この町の水はおいしい");

「水性ボールペン」はヒットしませんでした。

これが嬉しいかどうかは、要件次第です。ヒットしないのは「水性ボールペン」の形態素解析結果は「水性」と「ボールペン」になり、「水」が単語として存在しないからですが、(ワイルドカードなしでは)「水」では「水性ボールペン」が検索できないことになります。

ちなみに、自分の仕事の時は、別の検索エンジンで「水」で「水性ボールペン」がヒットしてしまい、これが嫌だという話をもらったことがあります。「水」って、割と鬼門…。

フレーズクエリを使ってみる

先ほど、キーワード検索ではOR検索になる、と書きました。「全文検索」が、「全文」と「検索」のどちらかがどこかに登場すればよい、みたいな結果でしたね。

これを、「全文」と「検索」が連続して登場することを条件にするには、フレーズクエリを使用します。

先ほどの「全文検索」を、以下のように書き直してみます。

        addArticles(new Article("あなたとJAVA、今すぐダウンロード"),
                    new Article("東京都は、日本の中枢だ"),
                    new Article("すもももももももものうち"),
                    new Article("京都は歴史のある都市だ"),
                    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.phrase().onField("contents").sentence("全文検索").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("全文検索入門");

ここがフレーズクエリを使うようになりました。

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.phrase().onField("contents").sentence("全文検索").createQuery();

結果としては、「全文検索入門」のみがヒットするようになりましたね。

もうひとつ、先ほどの「東京スカイツリー」も。

        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.phrase().onField("contents").sentence("東京スカイツリー").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.phrase().onField("contents").sentence("一眼レフ").createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

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

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:\"一眼 レフ\"");
        assertThat(articles)
            .hasSize(0);

単語の分割結果が違うのに、連続性を要求するようになったからですね…。

とまあ、形態素解析を使うと辞書のメンテナンスを頑張ることになったり、(形態素解析に限りませんが)今回使いませんでしたが類義語なども駆使して頑張ることになるわけですね。

ああ、日本語検索って難しい。