CLOVER🍀

That was when it all began.

Hibernate SearchでExplanation(スコアの計算式)を取得する

Luceneを使ったアプリケーションで、(見る気になるかどうかはなんともですが)どうしてDocumentがヒットしたのか、スコアなどを見るにはExplationを利用します。

Hibernate Searchでも取得できるのかなと思ったら、こちらに記載がありました。

Understanding results
http://docs.jboss.org/hibernate/search/5.0/reference/en-US/html_single/#_understanding_results

Projectionを利用するようです。

では、こちらを試してみましょう。

準備

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>

その他、テストコードでJUnitとAssertJを利用。

persistence.xmlは省略、デフォルトのAnalyzerはKuromojiのJapaneseAnalyzerとします。

Entityクラスは、このように。
src/main/java/org/littlewings/hibernate/explanation/Book.java

package org.littlewings.hibernate.explanation;

import javax.persistence.*;

import org.hibernate.search.annotations.*;

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

    @Column
    @Field
    private String title;

    @Column
    @Field
    private Integer price;

    @Column
    @Field
    private String summary;

    public Book() { }

    public Book(String title, Integer price, String summary) {
        this.title = title;
        this.price = price;
        this.summary = summary;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public Integer getPrice() { return price; }
    public void setPrice(Integer price) { this.price = price; }
    public String getSummary() { return summary; }
    public void setSummary(String summary) { this.summary = summary; }
}

Explanationを取得してみる

まずは、テストを書くための雛形コードを用意。
src/test/java/org/littlewings/hibernate/explanation/ExplanationTest.java

package org.littlewings.hibernate.explanation;

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

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

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

import org.junit.*;

public class ExplanationTest {
    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 Book").executeUpdate();

        tx.commit();
    }

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

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

        tx.commit();
    }

    private void addBooks(Book... books) {
        EntityTransaction tx = em.getTransaction();
        tx.begin();

        Arrays.asList(books).stream().forEach(em::persist);

        tx.commit();
    }

    // ここに、テストコードを書く
}

テストコードを書くといっても、さすがにExplanationの結果をassertしてもあまり嬉しくないので、その点は標準出力に今回は書き出すこととします。

では、Explanationを取得してみます。

まずはEntityを登録。

        addBooks(new Book("はじめてのSpring Boot 「Spring Framework」で簡単Javaアプリ開発",
                          2700,
                          "Spring"),
                 new Book("高速スケーラブル検索エンジン ElasticSearch Server",
                          3024,
                          "全文検索"),
                 new Book("[改訂新版] Apache Solr入門 〜オープンソース全文検索エンジン",
                          3888,
                          "全文検索"),
                 new Book("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築",
                          3200,
                          "全文検索"),
                 new Book("Javaエンジニア養成読本 [現場で役立つ最新知識、満載!]",
                          2138,
                          "Java"),
                 new Book("改訂2版 パーフェクトJava",
                          3486,
                          "Java"));

これに対して、クエリを組み立てます。

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

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("title").matching("全文検索 Lucene").createQuery();
        Query jpaQuery =
            ftem
            .createFullTextQuery(luceneQuery, Book.class)
            .setProjection(FullTextQuery.DOCUMENT_ID,
                           FullTextQuery.DOCUMENT,
                           FullTextQuery.EXPLANATION,
                           FullTextQuery.SCORE,
                           FullTextQuery.OBJECT_CLASS,
                           FullTextQuery.SPATIAL_DISTANCE,
                           FullTextQuery.THIS);

この時、FullTextQueryに対してProjectionの設定を行います。

Projectionは、Entityの一部のフィールドを取得する時に使うものだと思っていましたが、こういうケースでも使うんですねぇ…。

何が取得できるかどうかは、上記の通りFullTextQueryに定義されている…ではなくて、正確にはFullTextQueryが実装しているProjectionConstantsインターフェースに定義されているString定数を使用します。

結果は、なんとObjectの配列のListで返ってきます。

        List<Object[]> results = jpaQuery.getResultList();

        assertThat(results)
            .hasSize(3);

この例では、登録したEntityに対してLuceneのクエリで3件ヒットします、と。

で、先ほどProjectionで指定した定数の説明ですが、変数に代入とかした方がわかりやすいかなぁと。

        int documentId = (int)results.get(0)[0];
        org.apache.lucene.document.Document document =
            (org.apache.lucene.document.Document)results.get(0)[1];
        org.apache.lucene.search.Explanation explanation =
            (org.apache.lucene.search.Explanation)results.get(0)[2];
        float score = (float)results.get(0)[3];
        Class<Book> entityClass = (Class<Book>)results.get(0)[4];
        Double spartialDistance = (Double)results.get(0)[5];
        Book entity= (Book)results.get(0)[6];

まあ、一応書くと…

  • DOCUMENT_ID … ヒットしたLuceneのDocumentのID
  • DOCUMENT … ヒットしたLuceneのDocumentそのもの
  • EXPLANATION … LuceneのExplanationクラスのインスタンス(このクエリのExplanation)
  • SCORE … ヒットした際のスコア(Explanationのサマリと同じ)
  • OBJECT_CLASS … EntityのClassクラス
  • SPATIAL_DISTANCE … LuceneのGeospatial search APIhttp://lucene.apache.org/core/4_10_3/spatial/index.html)を使う時に、距離が得られる…?(今回未使用)
  • THIS … ヒットしたEntity

という感じです。

ひとつだけassertしてみると、こんな感じ。

        assertThat(documentId)
            .isEqualTo(3);
        assertThat(score)
            .isGreaterThan(0.95F);
        assertThat(spartialDistance)
            .isNull();
        assertThat(entity.getTitle())
            .isEqualTo("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築");

今回のケースでは、SPATIAL_DISTANCEは取得できないのでnullです。

投げたLuceneのクエリは、こちらでしたね。

        org.apache.lucene.search.Query luceneQuery =
            queryBuilder.keyword().onField("title").matching("全文検索 Lucene").createQuery();

あとは、他にヒットした結果を含めて、Explanationを出力してみましょう。

        results
            .stream()
            .forEach(result -> {
                System.out.println("== Book Title[" + ((Book)result[6]).getTitle() + "]");
                System.out.println(result[2]);
            });

どれがどれかわからなくならないように、ヒットしたBookのタイトルを付けて…。

結果は、このように(※そういえばソートしてない…)。

== Book Title[Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築]
0.95023906 = (MATCH) sum of:
  0.29461613 = (MATCH) weight(title:全文 in 3) [DefaultSimilarity], result of:
    0.29461613 = score(doc=3,freq=1.0), product of:
      0.5568161 = queryWeight, product of:
        1.6931472 = idf(docFreq=2, maxDocs=6)
        0.32886457 = queryNorm
      0.5291085 = fieldWeight in 3, product of:
        1.0 = tf(freq=1.0), with freq of:
          1.0 = termFreq=1.0
        1.6931472 = idf(docFreq=2, maxDocs=6)
        0.3125 = fieldNorm(doc=3)
  0.20300525 = (MATCH) weight(title:検索 in 3) [DefaultSimilarity], result of:
    0.20300525 = score(doc=3,freq=1.0), product of:
      0.4622077 = queryWeight, product of:
        1.4054651 = idf(docFreq=3, maxDocs=6)
        0.32886457 = queryNorm
      0.43920785 = fieldWeight in 3, product of:
        1.0 = tf(freq=1.0), with freq of:
          1.0 = termFreq=1.0
        1.4054651 = idf(docFreq=3, maxDocs=6)
        0.3125 = fieldNorm(doc=3)
  0.4526177 = (MATCH) weight(title:lucene in 3) [DefaultSimilarity], result of:
    0.4526177 = score(doc=3,freq=1.0), product of:
      0.69015926 = queryWeight, product of:
        2.0986123 = idf(docFreq=1, maxDocs=6)
        0.32886457 = queryNorm
      0.6558163 = fieldWeight in 3, product of:
        1.0 = tf(freq=1.0), with freq of:
          1.0 = termFreq=1.0
        2.0986123 = idf(docFreq=1, maxDocs=6)
        0.3125 = fieldNorm(doc=3)

== Book Title[[改訂新版] Apache Solr入門 〜オープンソース全文検索エンジン]
0.3317476 = (MATCH) product of:
  0.4976214 = (MATCH) sum of:
    0.29461613 = (MATCH) weight(title:全文 in 2) [DefaultSimilarity], result of:
      0.29461613 = score(doc=2,freq=1.0), product of:
        0.5568161 = queryWeight, product of:
          1.6931472 = idf(docFreq=2, maxDocs=6)
          0.32886457 = queryNorm
        0.5291085 = fieldWeight in 2, product of:
          1.0 = tf(freq=1.0), with freq of:
            1.0 = termFreq=1.0
          1.6931472 = idf(docFreq=2, maxDocs=6)
          0.3125 = fieldNorm(doc=2)
    0.20300525 = (MATCH) weight(title:検索 in 2) [DefaultSimilarity], result of:
      0.20300525 = score(doc=2,freq=1.0), product of:
        0.4622077 = queryWeight, product of:
          1.4054651 = idf(docFreq=3, maxDocs=6)
          0.32886457 = queryNorm
        0.43920785 = fieldWeight in 2, product of:
          1.0 = tf(freq=1.0), with freq of:
            1.0 = termFreq=1.0
          1.4054651 = idf(docFreq=3, maxDocs=6)
          0.3125 = fieldNorm(doc=2)
  0.6666667 = coord(2/3)

== Book Title[高速スケーラブル検索エンジン ElasticSearch Server]
0.081202105 = (MATCH) product of:
  0.2436063 = (MATCH) sum of:
    0.2436063 = (MATCH) weight(title:検索 in 1) [DefaultSimilarity], result of:
      0.2436063 = score(doc=1,freq=1.0), product of:
        0.4622077 = queryWeight, product of:
          1.4054651 = idf(docFreq=3, maxDocs=6)
          0.32886457 = queryNorm
        0.5270494 = fieldWeight in 1, product of:
          1.0 = tf(freq=1.0), with freq of:
            1.0 = termFreq=1.0
          1.4054651 = idf(docFreq=3, maxDocs=6)
          0.375 = fieldNorm(doc=1)
  0.33333334 = coord(1/3)

1件目でassertしていたスコアは

        assertThat(score)
            .isGreaterThan(0.95F);

この部分が該当します。

== Book Title[Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築]
0.95023906 = (MATCH) sum of:

あと、Explanationの読み方自体は、(怪しいですが)以前書いたことがあります。

Luceneのスコア計算式を表示する
http://d.hatena.ne.jp/Kazuhira/20130806/1375802797

よろしければ、どうぞ。