久々に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・オープンソース・全文検索システムの構築
- 作者: 関口宏司
- 出版社/メーカー: 技術評論社
- 発売日: 2006/05/17
- メディア: 大型本
- 購入: 5人 クリック: 156回
- この商品を含むブログ (32件) を見る
- 作者: 伊勢幸一,池邉智洋,栗原由樹,山下拓也,谷口公一,井原郁央
- 出版社/メーカー: ソフトバンククリエイティブ
- 発売日: 2009/08/21
- メディア: 単行本
- 購入: 44人 クリック: 857回
- この商品を含むブログ (51件) を見る
最後に、今回書いたソースです。
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)) } } }