CLOVER🍀

That was when it all began.

Luceneでソート

Luceneの基礎を、少しずつ。今回は、ソートにチャレンジしてみたいと思います。

ソートを行うには、SortクラスとSortFieldクラスを使用し、これらをIndexSearcher#searchメソッドに叩き込みます。

Sort
http://lucene.apache.org/core/4_3_1/core/org/apache/lucene/search/Sort.html

SoftField
http://lucene.apache.org/core/4_3_1/core/org/apache/lucene/search/Sort.html

使い方は、割と簡単なようですが。

準備

では、いつも通りbuild.sbtとソースコードを。

name := "lucene-sorting"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.10.2"

organization := "littlewings"

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

src/main/scala/LuceneSorting.scala

import scala.collection.JavaConverters._

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.store.{Directory, RAMDirectory}
import org.apache.lucene.util.Version

import LuceneSorting.AutoCloseableWrapper

object LuceneSorting {
  def main(args: Array[String]): Unit = {
    val luceneVersion = Version.LUCENE_43

    for (directory <- new RAMDirectory) {
      println("ドキュメント追加")
      createIndex(directory, luceneVersion)

      println("検索開始")
      for (reader <- DirectoryReader.open(directory)) {
        val searcher = new IndexSearcher(reader)

        val query = createQuery("contents:ドキュメント OR contents:1 OR contents: 0", luceneVersion)

        println(s"Query => $query")

        printSearchResult(searcher,
                          "Non Sort",
                          searcher.search(query, null, 20))
        printSearchResult(searcher,
                          "Sort.INDEXORDER",
                          searcher.search(query, 20, Sort.INDEXORDER))
        printSearchResult(searcher,
                          "Sort.RELEVANCE",
                          searcher.search(query, 20, Sort.RELEVANCE))
        printSearchResult(searcher,
                          "Sort(num1 as STRING)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("num1",
                                                                 SortField.Type.STRING))))
        printSearchResult(searcher,
                          "Sort(num1 as INT)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("num1",
                                                                 SortField.Type.INT))))
        try {
          printSearchResult(searcher,
                            "Sort(join-nums as INT)",
                            searcher.search(query,
                                            20,
                                            new Sort(new SortField("join-nums",
                                                                   SortField.Type.INT))))
        } catch {
          case e: NumberFormatException => println(e)
        }
        printSearchResult(searcher,
                          "Sort(num1 as INT, num2 as INT)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("num1",
                                                                 SortField.Type.INT),
                                                   new SortField("num2",
                                                                 SortField.Type.INT))))

        printSearchResult(searcher,
                          "Sort(num1 as INT, num2 as INT#reverse)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("num1",
                                                                 SortField.Type.INT),
                                                   new SortField("num2",
                                                                 SortField.Type.INT,
                                                                 true))))
        printSearchResult(searcher,
                          "Sort(num2#reverse as INT, num1 as INT)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("num2",
                                                                 SortField.Type.INT,
                                                                 true),
                                                   new SortField("num1",
                                                                 SortField.Type.INT))))
        printSearchResult(searcher,
                          "Sort(contents as STRING)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("contents",
                                                                 SortField.Type.STRING))))
      }
    }
  }

  private def createIndex(directory: Directory, luceneVersion: Version): Unit = {
    val config = new IndexWriterConfig(luceneVersion, new JapaneseAnalyzer(luceneVersion))

    new IndexWriter(directory, config).foreach { writer =>
      SampleDocument.createDocs.foreach(writer.addDocument)
    }
  }

  private def createQuery(queryString: String, luceneVersion: Version): Query =
    new QueryParser(luceneVersion,
                    "num1",
                    new JapaneseAnalyzer(luceneVersion))
                      .parse(queryString)

  private def printSearchResult(searcher: IndexSearcher, name: String, docs: TopDocs): Unit = {
    println(s"$name => ヒット件数:${docs.totalHits}")

    val hits = docs.scoreDocs
    hits.take(20).foreach { h =>
      val hitDoc = searcher.doc(h.doc)
      hitDoc
        .getFields
        .asScala
        .map(_.stringValue)
        .mkString(s"  Score,DocNo[${h.score},${h.doc}] ", " | ", "")
        .foreach(print)
      println()
    }
  }

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

object SampleDocument {
  def createDocs: List[Document] = {
    List(
      create("30", "30", "30-30", "30-30のドキュメントです"),
      create("1", "1", "1-1", "1-1のドキュメントです"),
      create("1", "90", "1-90", "1-90のドキュメントです"),
      create("2", "2", "2-2", "2-2のドキュメントです"),
      create("2", "50", "2-50", "2-50のドキュメントです"),
      create("20", "20", "20-20", "20-20のドキュメントです"),
      create("10", "10", "10-10", "10-10のドキュメントです")
    )
  }

  private def create(tokens: String*): Document = {
    val doc = new Document
    doc.add(stringField("num1", tokens(0)))
    doc.add(stringField("num2", tokens(1)))
    doc.add(stringField("join-nums", tokens(2)))
    doc.add(textField("contents", tokens(3)))
    doc
  }

  private def stringField(name: String, value: String): Field =
    new StringField(name, value, Field.Store.YES)

  private def textField(name: String, value: String): Field =
    new TextField(name, value, Field.Store.YES)
}

クエリもドキュメントも超適当です。

ドキュメントは、

    List(
      create("30", "30", "30-30", "30-30のドキュメントです"),
      create("1", "1", "1-1", "1-1のドキュメントです"),
      create("1", "90", "1-90", "1-90のドキュメントです"),
      create("2", "2", "2-2", "2-2のドキュメントです"),
      create("2", "50", "2-50", "2-50のドキュメントです"),
      create("20", "20", "20-20", "20-20のドキュメントです"),
      create("10", "10", "10-10", "10-10のドキュメントです")
    )

というように、ちょっとわざとらしい感じ。クエリは

val query = createQuery("contents:ドキュメント OR contents:1 OR contents: 0", luceneVersion)

というように、contentsフィールド内の「ドキュメント」「1」「0」があれば、スコアとして上位になるようにしています。

これに対して、Sortを指定する、しないだったり、Sortの条件を変えて動きを見ていきます。

なお、今回はIndexSearcherのsearchメソッドのうち、

public TopFieldDocs search(Query query,
                  int n,
                  Sort sort)
                    throws IOException

を使用しています。Filter、使ってないし…。

IndexSearcher#search(Query, int, Sort)
http://lucene.apache.org/core/4_3_1/core/org/apache/lucene/search/IndexSearcher.html#search%28org.apache.lucene.search.Query,%20int,%20org.apache.lucene.search.Sort%29

結果出力は、こんなメソッドを用意しておりまして

  private def printSearchResult(searcher: IndexSearcher, name: String, docs: TopDocs): Unit = {
    println(s"$name => ヒット件数:${docs.totalHits}")

    val hits = docs.scoreDocs
    hits.take(20).foreach { h =>
      val hitDoc = searcher.doc(h.doc)
      hitDoc
        .getFields
        .asScala
        .map(_.stringValue)
        .mkString(s"  Score,DocNo[${h.score},${h.doc}] ", " | ", "")
        .foreach(print)
      println()
    }
  }

呼び出し元で、こんな感じで呼び出します。

        printSearchResult(searcher,
                          "Sort.INDEXORDER",
                          searcher.search(query, 20, Sort.INDEXORDER))

はい。

動作確認

では、まずはソートをしない場合。

        printSearchResult(searcher,
                          "Non Sort",
                          searcher.search(query, null, 20))

結果。

Non Sort => ヒット件数:7
  Score,DocNo[0.5187427,1] 1 | 1 | 1-1 | 1-1のドキュメントです
  Score,DocNo[0.38726074,2] 1 | 90 | 1-90 | 1-90のドキュメントです
  Score,DocNo[0.034917552,0] 30 | 30 | 30-30 | 30-30のドキュメントです
  Score,DocNo[0.034917552,3] 2 | 2 | 2-2 | 2-2のドキュメントです
  Score,DocNo[0.034917552,4] 2 | 50 | 2-50 | 2-50のドキュメントです
  Score,DocNo[0.034917552,5] 20 | 20 | 20-20 | 20-20のドキュメントです
  Score,DocNo[0.034917552,6] 10 | 10 | 10-10 | 10-10のドキュメントです

スコアとドキュメントのNoが出力されています。並び順は、スコア順ですね。

それでは、ここからSortクラスを使用します。

まずは、インデックスNoの順にソート。public static finalなフィールド、Sort.INDEXORDERをsearchメソッドに指定します。

        printSearchResult(searcher,
                          "Sort.INDEXORDER",
                          searcher.search(query, 20, Sort.INDEXORDER))

結果。

Sort.INDEXORDER => ヒット件数:7
  Score,DocNo[NaN,0] 30 | 30 | 30-30 | 30-30のドキュメントです
  Score,DocNo[NaN,1] 1 | 1 | 1-1 | 1-1のドキュメントです
  Score,DocNo[NaN,2] 1 | 90 | 1-90 | 1-90のドキュメントです
  Score,DocNo[NaN,3] 2 | 2 | 2-2 | 2-2のドキュメントです
  Score,DocNo[NaN,4] 2 | 50 | 2-50 | 2-50のドキュメントです
  Score,DocNo[NaN,5] 20 | 20 | 20-20 | 20-20のドキュメントです
  Score,DocNo[NaN,6] 10 | 10 | 10-10 | 10-10のドキュメントです

スコアの欄が「NaN」になり、スコア計算が行われなくなるみたいです。順番は、ドキュメントの番号順になっていますね。

続いて、スコア計算順?Sort.RELEVANCEをsearchメソッドに指定します。

        printSearchResult(searcher,
                          "Sort.RELEVANCE",
                          searcher.search(query, 20, Sort.RELEVANCE))

結果。

Sort.RELEVANCE => ヒット件数:7
  Score,DocNo[NaN,1] 1 | 1 | 1-1 | 1-1のドキュメントです
  Score,DocNo[NaN,2] 1 | 90 | 1-90 | 1-90のドキュメントです
  Score,DocNo[NaN,0] 30 | 30 | 30-30 | 30-30のドキュメントです
  Score,DocNo[NaN,3] 2 | 2 | 2-2 | 2-2のドキュメントです
  Score,DocNo[NaN,4] 2 | 50 | 2-50 | 2-50のドキュメントです
  Score,DocNo[NaN,5] 20 | 20 | 20-20 | 20-20のドキュメントです
  Score,DocNo[NaN,6] 10 | 10 | 10-10 | 10-10のドキュメントです

スコアこそ「NaN」ですが、並び順はソートしない場合と同じみたいですね。

では、ここから具体的にソートするフィールドとソート方法を指定していきましょう。

Sortを具体的に指示する場合は、Sortクラスのインタンス作成時に、どのフィールドでソートするかをSortFieldのインスタンスを作成して指示します。

また、ソート対象のフィールドは、

  • インデックス化の対称とする必要がある
  • トークン化するべきではない
  • 検索結果としてデータが不要であれば、Storeする必要はない

つまり、こういうことだそうな。

document.add (new Field ("byNumber", Integer.toString(x), Field.Store.NO, Field.Index.NOT_ANALYZED));

参考)
http://lucene.apache.org/core/4_3_1/core/org/apache/lucene/search/Sort.html

また、データをどのように扱ってソートするかを、Sort.Type列挙型で指定します。Sort.Typeには

  • BYTE
  • BYTES
  • CUSTOM
  • DOC
  • DOUBLE
  • FLOAT
  • INT
  • LONG
  • REWRITEABLE
  • SCORE
  • SHORT
  • STRING
  • STRING_VAL

というソート方法があります。先の例で使用したのは、DOCとSCOREですね。

それでは、順次使っていきましょう。

num1フィールドを、「STRING」としてソートします。

        printSearchResult(searcher,
                          "Sort(num1 as STRING)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("num1",
                                                                 SortField.Type.STRING))))

結果。

Sort(num1 as STRING) => ヒット件数:7
  Score,DocNo[NaN,1] 1 | 1 | 1-1 | 1-1のドキュメントです
  Score,DocNo[NaN,2] 1 | 90 | 1-90 | 1-90のドキュメントです
  Score,DocNo[NaN,6] 10 | 10 | 10-10 | 10-10のドキュメントです
  Score,DocNo[NaN,3] 2 | 2 | 2-2 | 2-2のドキュメントです
  Score,DocNo[NaN,4] 2 | 50 | 2-50 | 2-50のドキュメントです
  Score,DocNo[NaN,5] 20 | 20 | 20-20 | 20-20のドキュメントです
  Score,DocNo[NaN,0] 30 | 30 | 30-30 | 30-30のドキュメントです

続いて、num1フィールドを「INT」としてソートします。

        printSearchResult(searcher,
                          "Sort(num1 as INT)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("num1",
                                                                 SortField.Type.INT))))

結果。

Sort(num1 as INT) => ヒット件数:7
  Score,DocNo[NaN,1] 1 | 1 | 1-1 | 1-1のドキュメントです
  Score,DocNo[NaN,2] 1 | 90 | 1-90 | 1-90のドキュメントです
  Score,DocNo[NaN,3] 2 | 2 | 2-2 | 2-2のドキュメントです
  Score,DocNo[NaN,4] 2 | 50 | 2-50 | 2-50のドキュメントです
  Score,DocNo[NaN,6] 10 | 10 | 10-10 | 10-10のドキュメントです
  Score,DocNo[NaN,5] 20 | 20 | 20-20 | 20-20のドキュメントです
  Score,DocNo[NaN,0] 30 | 30 | 30-30 | 30-30のドキュメントです

Sort.Typeの指定で、ソート結果が変わったことがわかります。

また、「INT」や「FLOAT」などの型を指定した場合に、実際にその形に変換できないようなデータを渡すと、例外がスローされます。

        try {
          printSearchResult(searcher,
                            "Sort(join-nums as INT)",
                            searcher.search(query,
                                            20,
                                            new Sort(new SortField("join-nums",
                                                                   SortField.Type.INT))))
        } catch {
          case e: NumberFormatException => println(e)
        }

結果。

java.lang.NumberFormatException: Invalid shift value in prefixCoded bytes (is encoded value really an INT?)

見事に、コケます。

複数フィールドでソートする場合は、SortクラスのコンストラクタにSortFieldを複数指定します。

        printSearchResult(searcher,
                          "Sort(num1 as INT, num2 as INT)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("num1",
                                                                 SortField.Type.INT),
                                                   new SortField("num2",
                                                                 SortField.Type.INT))))

結果。

Sort(num1 as INT, num2 as INT) => ヒット件数:7
  Score,DocNo[NaN,1] 1 | 1 | 1-1 | 1-1のドキュメントです
  Score,DocNo[NaN,2] 1 | 90 | 1-90 | 1-90のドキュメントです
  Score,DocNo[NaN,3] 2 | 2 | 2-2 | 2-2のドキュメントです
  Score,DocNo[NaN,4] 2 | 50 | 2-50 | 2-50のドキュメントです
  Score,DocNo[NaN,6] 10 | 10 | 10-10 | 10-10のドキュメントです
  Score,DocNo[NaN,5] 20 | 20 | 20-20 | 20-20のドキュメントです
  Score,DocNo[NaN,0] 30 | 30 | 30-30 | 30-30のドキュメントです

逆順でソートするには、SortFieldのコンストラクタの第3引数をtrueにしましょう。

        printSearchResult(searcher,
                          "Sort(num1 as INT, num2 as INT#reverse)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("num1",
                                                                 SortField.Type.INT),
                                                   new SortField("num2",
                                                                 SortField.Type.INT,
                                                                 true))))

結果。

Sort(num1 as INT, num2 as INT#reverse) => ヒット件数:7
  Score,DocNo[NaN,2] 1 | 90 | 1-90 | 1-90のドキュメントです
  Score,DocNo[NaN,1] 1 | 1 | 1-1 | 1-1のドキュメントです
  Score,DocNo[NaN,4] 2 | 50 | 2-50 | 2-50のドキュメントです
  Score,DocNo[NaN,3] 2 | 2 | 2-2 | 2-2のドキュメントです
  Score,DocNo[NaN,6] 10 | 10 | 10-10 | 10-10のドキュメントです
  Score,DocNo[NaN,5] 20 | 20 | 20-20 | 20-20のドキュメントです
  Score,DocNo[NaN,0] 30 | 30 | 30-30 | 30-30のドキュメントです

別に、フィールドの定義順に並んでいる必要もないみたいです。

        printSearchResult(searcher,
                          "Sort(num2#reverse as INT, num1 as INT)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("num2",
                                                                 SortField.Type.INT,
                                                                 true),
                                                   new SortField("num1",
                                                                 SortField.Type.INT))))

結果。

Sort(num2#reverse as INT, num1 as INT) => ヒット件数:7
  Score,DocNo[NaN,2] 1 | 90 | 1-90 | 1-90のドキュメントです
  Score,DocNo[NaN,4] 2 | 50 | 2-50 | 2-50のドキュメントです
  Score,DocNo[NaN,0] 30 | 30 | 30-30 | 30-30のドキュメントです
  Score,DocNo[NaN,5] 20 | 20 | 20-20 | 20-20のドキュメントです
  Score,DocNo[NaN,6] 10 | 10 | 10-10 | 10-10のドキュメントです
  Score,DocNo[NaN,3] 2 | 2 | 2-2 | 2-2のドキュメントです
  Score,DocNo[NaN,1] 1 | 1 | 1-1 | 1-1のドキュメントです

ちなみに、トークン化されているフィールドを、ムリにソートしようとしてみましたが

        printSearchResult(searcher,
                          "Sort(contents as STRING)",
                          searcher.search(query,
                                          20,
                                          new Sort(new SortField("contents",
                                                                 SortField.Type.STRING))))

変化が感じられないというか、インデックスのNo順に並んでいる気がします。

Sort(contents as STRING) => ヒット件数:7
  Score,DocNo[NaN,0] 30 | 30 | 30-30 | 30-30のドキュメントです
  Score,DocNo[NaN,1] 1 | 1 | 1-1 | 1-1のドキュメントです
  Score,DocNo[NaN,2] 1 | 90 | 1-90 | 1-90のドキュメントです
  Score,DocNo[NaN,3] 2 | 2 | 2-2 | 2-2のドキュメントです
  Score,DocNo[NaN,4] 2 | 50 | 2-50 | 2-50のドキュメントです
  Score,DocNo[NaN,5] 20 | 20 | 20-20 | 20-20のドキュメントです
  Score,DocNo[NaN,6] 10 | 10 | 10-10 | 10-10のドキュメントです

ちなみに、Sortクラスのインスタンスはソート順が変わらなければ、再利用可、スレッドセーフだそうで。

Object Reuse

One of these objects can be used multiple times and the sort order changed between usages.

This class is thread safe.

また、ソートは単語を内部的にキャッシュするようです。キャッシュの大きさは、IndexReader.maxDoc()で返される長さに依存する、IntegerまたはFloatの配列を作成するようですが…。BYTES指定の場合は、下記の計算式になるみたいです。

4 * IndexReader.maxDoc() * (# of different fields actually used to sort)

4は、フィールドの名前…?

Stringを使用した場合は、上記に加えてフィールド中の各Termの値をメモリに持ち続けるようです。ユニークな単語が大量に登場するようだと、キャッシュがとても大きくなるかも…と。

キャッシュのサイズは、どのくらい多くのフィールドが定義されるかには依存しません。実際にソートに使用されるフィールドに対してのみ利用される、というみたいですね。