CLOVER🍀

That was when it all began.

Luceneのスコア計算式を表示する

久々にLucene。今回は、ちょっとLuceneのスコア計算式を見てみたいと思います。

オフィシャルのJavadocには、何やら難しい式が載っているのですが…よくわかりません。

TFIDFSimilarity
http://lucene.apache.org/core/4_4_0/core/org/apache/lucene/search/similarities/TFIDFSimilarity.html

まあ、とりあえず計算式を表示してみましょう。計算式の表示には、Explanationを使います。

Explanation
http://lucene.apache.org/core/4_4_0/core/org/apache/lucene/search/Explanation.html

まずは、インデックスを作るところから。

  private def registerDocuments(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit = {
    for (indexWriter <- new IndexWriter(directory,
                                        new IndexWriterConfig(luceneVersion, analyzer))) {
      indexWriter.addDocument(createDocument(Map("isbn-13" -> ("978-4894714991", false, true),
                                                 "publish-date" -> ("2008-11-27", false, true),
                                                 "title" -> ("Effective Java 第2版 (The Java Series)", true, true),
                                                 "price" -> ("3780", false, true),
                                                 "abstract" -> ("Javaプログラミング書籍の定本「Effective Java」の改訂版です。著者のGoogle, Sun Microsystemsにおけるソフトウェア開発で得た知識・経験をまとめた、JavaでプログラミングをするすべてのSE必読の書籍です。2001年の初版以降の追加項目、JavaSE6.0に対応。", true, true))))
      indexWriter.addDocument(createDocument(Map("isbn-13" -> ("978-4844330844", false, true),
                                                 "publish-date" -> ("2011-09-27", false, true),
                                                 "title" -> ("Scalaスケーラブルプログラミング第2版", true, true),
                                                 "price" -> ("4830", false, true),
                                                 "abstract" -> ("言語設計者自ら、その手法と思想を説く。Scalaプログラミングバイブル!", true, true))))
      indexWriter.addDocument(createDocument(Map("isbn-13" -> ("978-4774147277", false, true),
                                                 "publish-date" -> ("2011-07-06", false, true),
                                                 "title" -> ("プログラミングGROOVY", true, true),
                                                 "price" -> ("3360", false, true),
                                                 "abstract" -> ("GroovyはJavaと抜群の親和性を持つハイブリッド言語です。簡潔で強力な記述力と高い柔軟性を持っており、Javaを補完・強化する究極のパートナーになります。", true, true))))
      indexWriter.addDocument(createDocument(Map("isbn-13" -> ("978-4274069130", false, true),
                                                 "publish-date" -> ("2013-04-26", false, true),
                                                 "title" -> ("プログラミングClojure 第2版", true, true),
                                                 "price" -> ("3570", false, true),
                                                 "abstract" -> ("プログラミング言語Clojureの実践的な解説書の改訂2版!", true, true))))
      indexWriter.addDocument(createDocument(Map("isbn-13" -> ("978-4774127804", false, true),
                                                 "publish-date" -> ("2006-05-17", false, true),
                                                 "title" -> ("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築", true, true),
                                                 "price" -> ("3360", false, true),
                                                 "abstract" -> ("Luceneは全文検索システムを構築するためのJavaのライブラリです。Luceneを使えば,一味違う高機能なWebアプリケーションを作ることができます。", true, true))))
     }
  }

全部で、5つのドキュメントを追加しています。

あ、createDocumentの中身はこんな感じです。

  private def createDocument(source: Map[String, (String, Analyze, Store)]): Document = {
    val document = new Document

    for ((fieldName, (value, isAnalyze, isStore)) <- source) {
      val store =
        if (isStore) Field.Store.YES
        else Field.Store.NO

      if (isAnalyze) {
        document.add(new TextField(fieldName, value, store))
      } else {
        document.add(new StringField(fieldName, value, store))
      }
    }

    document
  }

Analyzeするかしないかで、TextFieldかStringFieldかで分けています。

Analyzerは、JapaneseAnalyzerを使用しています。

で、Queryを投げる部分は、こんな感じで。

        val docCollector =
          //TopScoreDocCollector.create(max, true)
          TopFieldCollector.create(new Sort(new SortField("publish-date",
                                                          SortField.Type.STRING,
                                                          true),
                                            new SortField("price",
                                                          SortField.Type.INT,
                                                          true)),
                                   max,
                                   true,
                                   true,
                                   true,
                                   true)
        searcher.search(q, docCollector)

        val topDocs = docCollector.topDocs
        val hits = topDocs.scoreDocs

        for (h <- hits) {
          val hitDoc = searcher.doc(h.doc)

          val explanation = searcher.explain(q, h.doc)

          println(s"Score,N[${h.score}:${h.doc}] : Doc => " +
                  hitDoc
                    .getFields
                    .asScala
                    .map(_.stringValue)
                    .mkString(" ", " | ", ""))
          println()
          println("Explanation As String => ")
          explanation.toString.lines.map("    " + _).foreach(println)
          println()
          println("Explanation As HTML => ")
          explanation.toHtml.lines.map("    " + _).foreach(println)
          println("---------------")
        }
          val explanation = searcher.explain(q, h.doc)

でQueryとDocumentに対する計算式、Explanationが取得できるので、これを文字列表示とHTML表示しています。

          println("Explanation As String => ")
          explanation.toString.lines.map("    " + _).foreach(println)
          println()
          println("Explanation As HTML => ")
          explanation.toHtml.lines.map("    " + _).foreach(println)

Explanation#toString、toHtmlの結果は、それぞれStringです。

これに対して、こんなQueryを投げてみます。

abstract:lucene abstract:java^2

ヒットするドキュメントは、全部で3件です。

この時、上記コードから計算式が表示されます。
*HTML表示は、ジャマになったので端折ります…

今回のQueryは2つのTermを含んでいますが、まずはひとつだけ引っかかった方。

Score,N[0.12766194:2] : Doc =>  3360 | GroovyはJavaと抜群の親和性を持つハイブリッド言語です。簡潔で強力な記述力と高い柔軟性を持っており、Javaを補完・強化する究極のパートナーになります。 | 978-4774147277 | 2011-07-06 | プログラミングGROOVY

Explanation As String => 
    0.12766196 = (MATCH) product of:
      0.25532392 = (MATCH) sum of:
        0.25532392 = (MATCH) weight(abstract:java^2.0 in 2) [DefaultSimilarity], result of:
          0.25532392 = score(doc=2,freq=2.0 = termFreq=2.0
    ), product of:
            0.787223 = queryWeight, product of:
              2.0 = boost
              1.2231436 = idf(docFreq=3, maxDocs=5)
              0.32180318 = queryNorm
            0.32433492 = fieldWeight in 2, product of:
              1.4142135 = tf(freq=2.0), with freq of:
                2.0 = termFreq=2.0
              1.2231436 = idf(docFreq=3, maxDocs=5)
              0.1875 = fieldNorm(doc=2)
      0.5 = coord(1/2)

これは、スコアとDocumentのIdですね。

Score,N[0.12766194:2]

で、こちらが計算結果なのですが

Explanation As String => 
    0.12766196 = (MATCH) product of:
      0.25532392 = (MATCH) sum of:
        0.25532392 = (MATCH) weight(abstract:java^2.0 in 2) [DefaultSimilarity], result of:
          0.25532392 = score(doc=2,freq=2.0 = termFreq=2.0
    ), product of:
            0.787223 = queryWeight, product of:
              2.0 = boost
              1.2231436 = idf(docFreq=3, maxDocs=5)
              0.32180318 = queryNorm
            0.32433492 = fieldWeight in 2, product of:
              1.4142135 = tf(freq=2.0), with freq of:
                2.0 = termFreq=2.0
              1.2231436 = idf(docFreq=3, maxDocs=5)
              0.1875 = fieldNorm(doc=2)
      0.5 = coord(1/2)

それぞれ意味を見ていきます。

tf

この部分。

              1.4142135 = tf(freq=2.0), with freq of:

DocumentのTermの個数から求められる関数で、2つ以上のDocumentがヒットした時に、Termがより多く含まれた方が上位に来るようにするための項目です。ここでは、「java」というTermが2回(freq=2.0)登場していることになっていると思われます。

idf

この部分。

              1.2231436 = idf(docFreq=3, maxDocs=5)

インデックス内のDocumentの数と、指定したTermを含むDocumentの数から求められる関数です。指定したTermを含むDocumentが少ないほど、より大きい数字になります。ここでは、全Document(maxDocs=5)中、3回登場している(docFreq=3)ことを表しています。全Documentというのは、IndexSearcherに渡すmax値のことではなく、ホントに全Documentの数っぽいですね。

fieldNorm

この部分。

              0.1875 = fieldNorm(doc=2)

doc=2って、Documentのidのことでしょうか?

書籍で見られるnormのことを言っているのだとしたら、ここでインデックス作成時のDocumentに対するBoost値、Fieldに対するBoost値、Fieldに含まれる単語数に対する正規化を行った際のスコアらしいです。Fieldに含まれる単語数が少ない方が上位に来ると。

queryNorm

この部分。

              0.32180318 = queryNorm

どの計算式を見ても同じ値が表示されているのですが、異なるQueryから計算されるスコアを比較可能なように正規化するのが目的とのことです。同じ値が設定されるので、ランキングには影響しません。

coord

この部分。

      0.5 = coord(1/2)

今回のように、2つ以上のTermで検索した時に使用される関数。指定した全Term数と、ヒットしたDocument中のTerm数で計算します。今回は、「lucene」「java」で検索し、「java」のみにヒットしているので、coord(1/2)となっています。

入力したTermがひとつの時や、約分して1になる時は表示されません。

boost

この部分。

              2.0 = boost

検索時にTermにつけた重み付けで、今回は「java」に「2」を付けているので、2.0と表示されています。

QueryにBoostを指定しなかった場合は、表示されません。

で、結局どう見るの?

まあ、あまり難しいことはわかりませんが、なんか普通に掛け算していけばスコアになりそうですね。

Explanation As String => 
    0.12766196 = (MATCH) product of:
      0.25532392 = (MATCH) sum of:
        0.25532392 = (MATCH) weight(abstract:java^2.0 in 2) [DefaultSimilarity], result of:
          0.25532392 = score(doc=2,freq=2.0 = termFreq=2.0
    ), product of:
            0.787223 = queryWeight, product of:
              2.0 = boost
              1.2231436 = idf(docFreq=3, maxDocs=5)
              0.32180318 = queryNorm
            0.32433492 = fieldWeight in 2, product of:
              1.4142135 = tf(freq=2.0), with freq of:
                2.0 = termFreq=2.0
              1.2231436 = idf(docFreq=3, maxDocs=5)
              0.1875 = fieldNorm(doc=2)
      0.5 = coord(1/2)

というスコアについてですが…

以下の部分

            0.32433492 = fieldWeight in 2, product of:
              1.4142135 = tf(freq=2.0), with freq of:
                2.0 = termFreq=2.0
              1.2231436 = idf(docFreq=3, maxDocs=5)
              0.1875 = fieldNorm(doc=2)

は、

>>> 1.4142135 * 1.2231436  * 0.1875
0.3243349109172375

となります(Python電卓♪)。fieldWeightは、tf×idf×fieldNormかな?
termFreq=2.0というのは、tf関数で計算しているので飛ばします。

次に、

            0.787223 = queryWeight, product of:
              2.0 = boost
              1.2231436 = idf(docFreq=3, maxDocs=5)
              0.32180318 = queryNorm

は、

>>> 2.0 * 1.2231436 * 0.32180318
0.787223000153296

となり、queryWeightはboost×idf×queryNormというところでしょうか。

そして、

      0.25532392 = (MATCH) sum of:
        0.25532392 = (MATCH) weight(abstract:java^2.0 in 2) [DefaultSimilarity], result of:
          0.25532392 = score(doc=2,freq=2.0 = termFreq=2.0
    ), product of:
            0.787223 = queryWeight, product of:
            〜省略〜
            0.32433492 = fieldWeight in 2, product of:
            〜省略〜

は、

>>> 0.787223 * 0.32433492
0.25532390872716004

ですね。これでsum ofが出ます。

最後に、

    0.12766196 = (MATCH) product of:
      0.25532392 = (MATCH) sum of:
        0.25532392 = (MATCH) weight(abstract:java^2.0 in 2) [DefaultSimilarity], result of:
          0.25532392 = score(doc=2,freq=2.0 = termFreq=2.0
    ), product of:
            〜省略〜
      0.5 = coord(1/2)

は、求めたsum ofにcoordをかけておしまい?

>>> 0.25532392 * 0.5
0.12766196

これが、最後のproduct ofとして表示されています。

    0.12766196 = (MATCH) product of:

上記は、ひとつのTermに対する結果でしたが、2語の場合は

Score,N[0.6585214:4] : Doc =>  3360 | Luceneは全文検索システムを構築するためのJavaのライブラリです。Luceneを使えば,一味違う高機能なWebアプリケーションを作ることができます。 | 978-4774127804 | 2006-05-17 | Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築

Explanation As String => 
    0.6585214 = (MATCH) sum of:
      0.4177997 = (MATCH) weight(abstract:lucene in 4) [DefaultSimilarity], result of:
        0.4177997 = score(doc=4,freq=2.0 = termFreq=2.0
    ), product of:
          0.61666846 = queryWeight, product of:
            1.9162908 = idf(docFreq=1, maxDocs=5)
            0.32180318 = queryNorm
          0.6775111 = fieldWeight in 4, product of:
            1.4142135 = tf(freq=2.0), with freq of:
              2.0 = termFreq=2.0
            1.9162908 = idf(docFreq=1, maxDocs=5)
            0.25 = fieldNorm(doc=4)
      0.24072169 = (MATCH) weight(abstract:java^2.0 in 4) [DefaultSimilarity], result of:
        0.24072169 = score(doc=4,freq=1.0 = termFreq=1.0
    ), product of:
          0.787223 = queryWeight, product of:
            2.0 = boost
            1.2231436 = idf(docFreq=3, maxDocs=5)
            0.32180318 = queryNorm
          0.3057859 = fieldWeight in 4, product of:
            1.0 = tf(freq=1.0), with freq of:
              1.0 = termFreq=1.0
            1.2231436 = idf(docFreq=3, maxDocs=5)
            0.25 = fieldNorm(doc=4)

となっています。最後がsum ofですね。指定した全Termを含んでいるので、coordがないので最後の計算式が不要だから?

これ、圧縮して

    0.6585214 = (MATCH) sum of:
      0.4177997 = (MATCH) weight(abstract:lucene in 4) [DefaultSimilarity], result of:
        0.4177997 = score(doc=4,freq=2.0 = termFreq=2.0
    ), product of:
          0.61666846 = queryWeight, product of:
          〜省略〜
          0.6775111 = fieldWeight in 4, product of:
          〜省略〜
      0.24072169 = (MATCH) weight(abstract:java^2.0 in 4) [DefaultSimilarity], result of:
        0.24072169 = score(doc=4,freq=1.0 = termFreq=1.0
    ), product of:
          0.787223 = queryWeight, product of:
          〜省略〜
          0.3057859 = fieldWeight in 4, product of:
          〜省略〜

とすると、ひとつめのqueryWeight×fieldWeit、

>>> 0.61666846 * 0.6775111
0.417799726669906

ふたつめのqueryWeight×fieldWeit、

>>> 0.787223 * 0.3057859
0.2407216935557

この和が、sum ofになっている気がします。

>>> 0.4177997 + 0.24072169
0.65852139

複数Termの場合は、Termごとの集計した結果の和になるのかな?coordが入る場合は、最後に掛け算するんでしょうけど。

これで、合ってるのかな…?

参考書籍)

Apache Lucene 入門 ~Java・オープンソース・全文検索システムの構築

Apache Lucene 入門 ~Java・オープンソース・全文検索システムの構築

4Gbpsを超えるWebサービス構築術

4Gbpsを超えるWebサービス構築術

最後に、今回書いたソースです。

build.sbt

name := "lucene-explanation"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.10.2"

organization := "littlewings"

libraryDependencies ++= Seq(
  "org.apache.lucene" % "lucene-analyzers-kuromoji" % "4.4.0",
  "org.apache.lucene" % "lucene-queryparser" % "4.4.0"
)

src/main/scala/LuceneExplanation.scala

import scala.collection.JavaConverters._
import scala.util.{Failure, Success, Try}

import org.apache.lucene.analysis.Analyzer
import org.apache.lucene.analysis.ja.JapaneseAnalyzer
import org.apache.lucene.document.{Document, Field, StringField, TextField}
import org.apache.lucene.index.{DirectoryReader, IndexWriter, IndexWriterConfig}
import org.apache.lucene.queryparser.classic.QueryParser
import org.apache.lucene.search.{IndexSearcher, Query, Sort, SortField, TopDocs}
import org.apache.lucene.search.{TopFieldCollector, TotalHitCountCollector, TopScoreDocCollector}
import org.apache.lucene.store.{Directory, RAMDirectory}
import org.apache.lucene.util.Version

import LuceneExplanation.AutoCloseableWrapper

object LuceneExplanation {
  type Store = Boolean
  type Analyze = Boolean

  def main(args: Array[String]): Unit = {
    val directory = new RAMDirectory
    val luceneVersion = Version.LUCENE_44
    val analyzer = new JapaneseAnalyzer(luceneVersion)

    registerDocuments(directory, luceneVersion, analyzer)

    InteractiveQuery(directory, luceneVersion).queryWhile("title", analyzer)
  }

  private def registerDocuments(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit = {
    for (indexWriter <- new IndexWriter(directory,
                                        new IndexWriterConfig(luceneVersion, analyzer))) {
      indexWriter.addDocument(createDocument(Map("isbn-13" -> ("978-4894714991", false, true),
                                                 "publish-date" -> ("2008-11-27", false, true),
                                                 "title" -> ("Effective Java 第2版 (The Java Series)", true, true),
                                                 "price" -> ("3780", false, true),
                                                 "abstract" -> ("Javaプログラミング書籍の定本「Effective Java」の改訂版です。著者のGoogle, Sun Microsystemsにおけるソフトウェア開発で得た知識・経験をまとめた、JavaでプログラミングをするすべてのSE必読の書籍です。2001年の初版以降の追加項目、JavaSE6.0に対応。", true, true))))
      indexWriter.addDocument(createDocument(Map("isbn-13" -> ("978-4844330844", false, true),
                                                 "publish-date" -> ("2011-09-27", false, true),
                                                 "title" -> ("Scalaスケーラブルプログラミング第2版", true, true),
                                                 "price" -> ("4830", false, true),
                                                 "abstract" -> ("言語設計者自ら、その手法と思想を説く。Scalaプログラミングバイブル!", true, true))))
      indexWriter.addDocument(createDocument(Map("isbn-13" -> ("978-4774147277", false, true),
                                                 "publish-date" -> ("2011-07-06", false, true),
                                                 "title" -> ("プログラミングGROOVY", true, true),
                                                 "price" -> ("3360", false, true),
                                                 "abstract" -> ("GroovyはJavaと抜群の親和性を持つハイブリッド言語です。簡潔で強力な記述力と高い柔軟性を持っており、Javaを補完・強化する究極のパートナーになります。", true, true))))
      indexWriter.addDocument(createDocument(Map("isbn-13" -> ("978-4274069130", false, true),
                                                 "publish-date" -> ("2013-04-26", false, true),
                                                 "title" -> ("プログラミングClojure 第2版", true, true),
                                                 "price" -> ("3570", false, true),
                                                 "abstract" -> ("プログラミング言語Clojureの実践的な解説書の改訂2版!", true, true))))
      indexWriter.addDocument(createDocument(Map("isbn-13" -> ("978-4774127804", false, true),
                                                 "publish-date" -> ("2006-05-17", false, true),
                                                 "title" -> ("Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築", true, true),
                                                 "price" -> ("3360", false, true),
                                                 "abstract" -> ("Luceneは全文検索システムを構築するためのJavaのライブラリです。Luceneを使えば,一味違う高機能なWebアプリケーションを作ることができます。", true, true))))
     }
  }

  private def createDocument(source: Map[String, (String, Analyze, Store)]): Document = {
    val document = new Document

    for ((fieldName, (value, isAnalyze, isStore)) <- source) {
      val store =
        if (isStore) Field.Store.YES
        else Field.Store.NO

      if (isAnalyze) {
        document.add(new TextField(fieldName, value, store))
      } else {
        document.add(new StringField(fieldName, value, store))
      }
    }

    document
  }

  implicit class AutoCloseableWrapper[A <: AutoCloseable](val underlying: A) extends AnyVal {
    def foreach(fun: A => Unit): Unit =
      try {
        fun(underlying)
      } finally {
        if (underlying != null) {
          underlying.close()
        }
      }
  }
}

object InteractiveQuery {
  def apply(directory: Directory, luceneVersion: Version): InteractiveQuery =
    new InteractiveQuery(directory, luceneVersion)
}

class InteractiveQuery private(directory: Directory, luceneVersion: Version) {
  val max: Int = 10000

  def queryWhile(defaultField: String, analyzer: Analyzer): Unit = {
    println("Start Interactive Query")

    val queryParser =
      new QueryParser(luceneVersion, defaultField, analyzer)

    val query = (queryString: String) =>
    Try { queryParser.parse(queryString) }.recoverWith {
      case th =>
        println(s"[ERROR] Invalid Query: $th")
        Failure(th)
    }.toOption

    val executeQuery = (searcher: IndexSearcher, queryString: String) => {
      query(queryString).foreach { q =>
        println(s"入力したクエリ => $q")
        println("=====")

        val totalHitCountCollector = new TotalHitCountCollector
        searcher.search(q, totalHitCountCollector)
        val totalHits = totalHitCountCollector.getTotalHits

        println(s"${totalHits}件、ヒットしました")
        println("=====")

        val docCollector =
          //TopScoreDocCollector.create(max, true)
          TopFieldCollector.create(new Sort(new SortField("publish-date",
                                                          SortField.Type.STRING,
                                                          true),
                                            new SortField("price",
                                                          SortField.Type.INT,
                                                          true)),
                                   max,
                                   true,
                                   true,
                                   true,
                                   true)
        searcher.search(q, docCollector)

        val topDocs = docCollector.topDocs
        val hits = topDocs.scoreDocs

        for (h <- hits) {
          val hitDoc = searcher.doc(h.doc)

          val explanation = searcher.explain(q, h.doc)

          println(s"Score,N[${h.score}:${h.doc}] : Doc => " +
                  hitDoc
                    .getFields
                    .asScala
                    .map(_.stringValue)
                    .mkString(" ", " | ", ""))
          println()
          println("Explanation As String => ")
          explanation.toString.lines.map("    " + _).foreach(println)
          println()
          println("Explanation As HTML => ")
          explanation.toHtml.lines.map("    " + _).foreach(println)
          println("---------------")
        }
      }
    }

    for (reader <- DirectoryReader.open(directory)) {
      val searcher = new IndexSearcher(reader)

      Iterator
        .continually(readLine("Lucene Query> "))
        .takeWhile(_ != "exit")
        .withFilter(line => !line.isEmpty && line != "\\c")
        .foreach(line => executeQuery(searcher, line))
    }
  }
}