CLOVER🍀

That was when it all began.

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

先ほど、Hibernate Searchを使って日本語検索を形態素解析、N-gramでそれぞれ試してみました。

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

Hibernate Searchで試す、日本語検索 - N-gram編
http://d.hatena.ne.jp/Kazuhira/20150110/1420891449

最後、これのまとめということで、形態素解析とN-gramを合わせて使ってみます。

Entityの変更

今回は、インデキシング対象としていたEntityに変更を入れます。
src/main/java/org/littlewings/hibernate/japanese/Article.java

package org.littlewings.hibernate.japanese;

import javax.persistence.*;

import org.apache.lucene.analysis.cjk.CJKAnalyzer;
import org.apache.lucene.analysis.ja.JapaneseAnalyzer;
import org.hibernate.search.annotations.*;

@Entity
@Table(name = "article")
@Indexed
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column
    @Fields({
            @Field(analyzer = @Analyzer(impl = JapaneseAnalyzer.class)),
            @Field(name = "contents_cjk", analyzer = @Analyzer(impl = CJKAnalyzer.class))
        })
    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; }
}

@Fieldsアノテーションを使用し、2つ@Fieldを定義しました。

    @Column
    @Fields({
            @Field(analyzer = @Analyzer(impl = JapaneseAnalyzer.class)),
            @Field(name = "contents_cjk", analyzer = @Analyzer(impl = CJKAnalyzer.class))
        })
    private String contents;

それぞれ、JapaneseAnalyzerとCJKAnalyzerです。これで、ひとつのデータベースカラムのソースに対して、2つインデックス対象のカラムを、それぞれ別々のトークン化戦略で定義したことになります。

@Fieldの名前ですが、デフォルトの方(nameを指定しない方)をプロパティ名そのまま(contents)でJapaneseAnalyzerを適用し、CJKAnalyzerを適用する方を「contents_cjk」としました。

どちらかはAnalyzerを指定しなくてもよかったのですが、今回は明示してみました。

よって、persistence.xmlでのAnalyzer指定は削除しました。
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.lucene_version" value="LUCENE_4_10_2" />
    </properties>
  </persistence-unit>
</persistence>

この場合は、デフォルトのAnalyzerは「org.apache.lucene.analysis.standard.StandardAnalyzer」となります。

形態素解析とN-gramを合わせて使ってヒット率を上げる

ここからは基本となるクエリには、すべてフレーズクエリを使用します。

先ほど、形態素解析とN-gramでそれぞれフレーズクエリを試してみましたが、今回はそれを合わせて使ってヒット率を上げることを考えたいと思います。

「デジタル一眼レフ」の例。

        addArticles(new Article("デジタル一眼レフ"));

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

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder
            .bool()
            .should(queryBuilder.phrase().onField("contents").boostedTo(2).sentence("一眼レフ").createQuery())
            .should(queryBuilder.phrase().onField("contents_cjk").sentence("一眼レフ").createQuery())
            .createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

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

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:\"一眼 レフ\"^2.0 contents_cjk:\"一眼 眼レ レフ\"");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("デジタル一眼レフ");

@Fieldsでの定義により、2つのフィールドで検索が可能になっているので、これでそれぞれのクエリをORで結合します。

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder
            .bool()
            .should(queryBuilder.phrase().onField("contents").boostedTo(2).sentence("一眼レフ").createQuery())
            .should(queryBuilder.phrase().onField("contents_cjk").sentence("一眼レフ").createQuery())
            .createQuery();

これで、形態素解析で組み立てたフレーズクエリと、N-gramで組み立てたフレーズクエリのORをとったことになります。それぞれトークン化の方法は異なりますが、どちらかにはひっかかるだろう的な感じですね。

なお、制度は形態素解析の方が高いと思われるため、形態素解析のフィールドの方にboostで2をかけています。スコアでのソートの場合は、形態素解析でのヒットの方が優先されるはずです。

よって、生成されたクエリはこのような形になります。

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:\"一眼 レフ\"^2.0 contents_cjk:\"一眼 眼レ レフ\"");

続いて、「水」。

        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
            .bool()
            .should(queryBuilder.phrase().onField("contents").boostedTo(2).sentence("æ°´").createQuery())
            .should(queryBuilder.keyword().wildcard().onField("contents_cjk").matching("æ°´*").createQuery())
            .createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

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

        assertThat(luceneQuery.toString())
            .isEqualTo("contents:æ°´^2.0 contents_cjk:æ°´*");
        assertThat(articles)
            .hasSize(3);
        assertThat(articles.get(0).getContents())
            .isEqualTo("この町の水はおいしい");
        assertThat(articles.get(1).getContents())
            .isEqualTo("飲料水");
        assertThat(articles.get(2).getContents())
            .isEqualTo("水性ボールペン");

相変わらず長さ1の「水」ではN-gramの方が意味をなさなくなるので、ここはワイルドカードを利用しました…。

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder
            .bool()
            .should(queryBuilder.phrase().onField("contents").boostedTo(2).sentence("æ°´").createQuery())
            .should(queryBuilder.keyword().wildcard().onField("contents_cjk").matching("æ°´*").createQuery())
            .createQuery();

もうちょっと長い例で考えてみる

実際に検索キーワードを入力するフィールドで複数の単語を入力する時って、ANDで検索するものでしょうか?ORでしょうか?

Java 全文検索 形態素解析

まあ、選べた方がいいという話もあろうかと思いますが。

また、検索エンジンでの検索結果としては、「全文検索」みたいな単語の組み合わせだとフレーズクエリになっていた方が嬉しかろうと思うのですが、どうでしょう。

完全に要件の話ですね。

ま、ここでは

  • 入力されたワードは、スペース区切りでAND検索
  • ワードそのものは、フレーズクエリで検索

と仮定して、形態素解析とN-gramを合わせて使ってみます。

こういうドキュメントがあったとして

        addArticles(new Article("Luceneは、Javaで実装された全文検索エンジンです"),
                    new Article("LuceneとSolrは、同じソースリポジトリで開発されています"),
                    new Article("最近では、Elasticsearchが全文検索エンジンとして人気です"),
                    new Article("Hibernate Searchを使うと、JPAと統合して全文検索を行うことができます"),
                    new Article("Luceneで、文書に含まれる全文を対象とした検索を行います"));

検索キーワードを「全文検索エンジン Lucene」とします。

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

        String keyword = "全文検索エンジン Lucene";

このキーワードを、スペースでsplitしてそれぞれフレーズクエリとして組み、そのANDをとります。

        BooleanJunction<? extends BooleanJunction> morphologicalBool = queryBuilder.bool();
        Arrays
            .stream(keyword.split("\\s+"))
            .map(k -> queryBuilder.phrase().onField("contents").boostedTo(2).sentence(k).createQuery())
            .forEach(q -> morphologicalBool.must(q));
        org.apache.lucene.search.Query morphologicalQuery = morphologicalBool.createQuery();

        BooleanJunction<? extends BooleanJunction> cjkBool = queryBuilder.bool();
        Arrays
            .stream(keyword.split("\\s+"))
            .map(k -> queryBuilder.phrase().onField("contents_cjk").sentence(k).createQuery())
            .forEach(q -> cjkBool.must(q));
        org.apache.lucene.search.Query cjkQuery = cjkBool.createQuery();

それぞれ、形態素解析とN-gramに対して。

最後に、それをORで結合してクエリの完成。

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder
            .bool()
            .should(morphologicalQuery)
            .should(cjkQuery)
            .createQuery();
        Query jpaQuery = ftem.createFullTextQuery(luceneQuery, Article.class);

結果。

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

        assertThat(luceneQuery.toString())
            .isEqualTo("(+contents:\"全文 検索 エンジン\"^2.0 +contents:lucene^2.0) (+contents_cjk:\"全文 文検 検索 索エ エン ンジ ジン\" +contents_cjk:lucene)");
        assertThat(articles)
            .hasSize(1);
        assertThat(articles.get(0).getContents())
            .isEqualTo("Luceneは、Javaで実装された全文検索エンジンです");

…今回は、N-gramの出番がなかったかも。

「+」が入っているのは、AND検索という意味です。

一応、「Lucene」と「全文検索」を含み、かつ「全文」と「検索」が連続して登場するもの(N-gramの場合は「文検」もありますが)のみがヒットします。

というわけで、ひとつのフィールドで形態素解析とN-gramを同時に使ってみる例をご紹介しました。

この一連のエントリでは、あくまでLuceneで用意されているもののみしか使っていないので、ユーザー定義辞書や類義語は全く登場していませんが、このあたりも含めると面白かったりするのでしょうか…?

ひとまず、ここまでということで!