LuceneのIndexSearcherのsearchメソッドに、Collectorという抽象クラスを取るものがあるのですが、こちらの書籍では「低レベルAPI」的な扱いをされていたので(正確には、その前身のHitCollectorが、ですが)なんとなく飛ばしていました。

Apache Lucene 入門 ~Java・オープンソース・全文検索システムの構築
- 作者: 関口宏司
- 出版社/メーカー: 技術評論社
- 発売日: 2006/05/17
- メディア: 大型本
- 購入: 5人 クリック: 156回
- この商品を含むブログ (32件) を見る
が、searchメソッドの実装とかSolrでの使い方を見ていると、なんとなく無視してちゃダメじゃない?的な気がしてきて、ちょっと使ってみることにしました。
あと、このページで使っているのを見たことも、きっかけですね。
Lucene in 5 minutes
http://www.lucenetutorial.com/lucene-in-5-minutes.html
では、とりあえず、環境準備を。
build.sbt
name := "lucene-collector" 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" ) libraryDependencies += "net.sf.supercsv" % "super-csv" % "2.1.0"
データは、こちらで作ったプログラムと同様
http://d.hatena.ne.jp/Kazuhira/20130622/1371901567
住所.jpから引っ張ってきています。
住所.jp
http://jusyo.jp/csv/new.php
データの仕様
http://jusyo.jp/csv/document.html
動作的には、前と作ったプログラムと同様にデータをインデクシング後、そのままクエリを投げられるものにしています。CSVの解析には、今回はSuperCSVを使っています。
src/main/scala/LuceneCollector.scala
import scala.collection.JavaConverters._ import scala.util.{Failure, Try} import java.nio.charset.Charset import java.nio.file.{Files, Paths} 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 org.supercsv.io.CsvListReader import org.supercsv.prefs.CsvPreference import LuceneCollector.AutoCloseableWrapper object LuceneCollector { def main(args: Array[String]): Unit = { val directory = new RAMDirectory val luceneVersion = Version.LUCENE_43 val indexer = new Indexer(directory, luceneVersion, new JapaneseAnalyzer(luceneVersion), "zenkoku.csv") indexer.execute() InteractiveQuery.queryWhile(directory, luceneVersion, new JapaneseAnalyzer(luceneVersion)) } implicit class AutoCloseableWrapper[A <: AutoCloseable](val underlying: A) extends AnyVal { def foreach(f: A => Unit): Unit = try { f(underlying) } finally { if (underlying != null) { underlying.close() } } } } class Indexer(directory: Directory, luceneVersion: Version, analyzer: Analyzer, source: String) { def execute(): Unit = { for { reader <- Files.newBufferedReader(Paths.get(source), Charset.forName("Windows-31J")) csvReader <- new CsvListReader(reader, CsvPreference.STANDARD_PREFERENCE) indexWriter <- new IndexWriter(directory, new IndexWriterConfig(luceneVersion, analyzer)) } { val count = Iterator .continually(csvReader.read()) .takeWhile(_ != null) .foldLeft(0) { (acc, tokens) => if (acc > 0 && acc % 10000 == 0) { printf("%1$,3d件…%n", acc) } indexWriter.addDocument(Address(tokens.asScala).toDocument) acc + 1 } printf("%1$,3d件、インデックスに登録しました%n", count) } } } case class Address(tokens: Seq[String]) { def toDocument: Document = { val doc = new Document doc.add(stringField("addressCd", 0)) doc.add(stringField("zipNo", 4)) doc.add(textField("prefecture", 7)) doc.add(textField("prefectureKana", 8)) doc.add(textField("city", 9)) doc.add(textField("cityKana", 10)) doc.add(textField("town", 11)) doc.add(textField("townKana", 12)) doc.add(textField("azachome", 15)) doc.add(textField("azachomeKana", 16)) doc } private def stringField(name: String, index: Int): Field = new StringField(name, Option(tokens(index)).getOrElse(""), Field.Store.YES) private def textField(name: String, index: Int): Field = new TextField(name, Option(tokens(index)).getOrElse(""), Field.Store.YES) } object InteractiveQuery { private var currentPage: Int = 1 private var offset: Int = 20 private val max: Int = 2000000 def queryWhile(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit = { println("Start Interactive Query") for (reader <- DirectoryReader.open(directory)) { val searcher = new IndexSearcher(reader) val qp: QueryParser = new QueryParser(luceneVersion, "prefecture", analyzer) // qp.setDefaultOperator(QueryParser.Operator.AND) val query = (queryString: String) => Try(qp.parse(queryString)) .recoverWith { case th => println(s"[ERROR] Invalid Query: $th") Failure(th) }.toOption val pageRegex = """page\s*=\s*(\d+)""".r val changePaging: PartialFunction[String, Unit] = { case pageRegex(page) => currentPage = Try(page.toInt).recover { case _ => 0 }.get println(s"set currentPage = $currentPage") } val offsetRegex = """offset\s*=\s*(\d+)""".r val changeOffset: PartialFunction[String, Unit] = { case offsetRegex(o) => offset = Try(o.toInt).recover { case _ => 20 }.get println(s"set offset = $offset") } val executeQuery: PartialFunction[String, Unit] = { case line => query(line).foreach { q => println(s"入力したクエリ => $q") /** Collectorを使った検索処理と結果表示処理 */ } } Iterator .continually(readLine("Lucene Query> ")) .takeWhile(_ != "exit") .withFilter(line => !line.isEmpty && !line.endsWith("\\c")) .foreach(changePaging orElse changeOffset orElse executeQuery) } } }
で、この部分
val executeQuery: PartialFunction[String, Unit] = { case line => query(line).foreach { q => println(s"入力したクエリ => $q") /** Collectorを使った検索処理と結果表示処理 */ } }
をCollectorを使って埋めていこうという感じですね。
Collectorを使ってみる
今回は、最も簡単そうなTotalHitCountCollector、TopScoreDocCollector、TopFieldCollectorを使ってみます。
いずれのCollectorも、staticメソッドのcreateメソッドでインスタンスを作成するみたいです。実際には、サブクラスが返ってきているみたいですが。
では、まずTotalHitCountCollectorクラスを使ってみましょう。こちらは名前の通り、クエリのヒット件数を返すためのCollectorです。
// ヒット件数の取得 val totalHitCountCollector = new TotalHitCountCollector searcher.search(q, totalHitCountCollector) val totalHits = totalHitCountCollector.getTotalHits
これで、ヒット件数が取得できます。ドキュメントのデータは、取得できませんが。
続いて、普通に結果をスコア順で取得する場合に使う、TopScoreDocCollectorクラスです。createメソッドの第1引数は、Collectorを使わない場合にIndexSearcher#searchメソッドに渡していた、結果の取得件数と同じですね。
// 通常の、スコア順での検索 val docCollector = TopScoreDocCollector.create(100000, true) searcher.search(q, docCollector) val topDocs = docCollector.topDocs val hits = topDocs.scoreDocs
そして、特定のフィールドなどでソートを行う場合は、TopFieldCollectorクラスを使用します。
が、このクラスには注意書きがあって
WARNING: This API is experimental and might change in incompatible ways in the next release.
http://lucene.apache.org/core/4_3_1/core/org/apache/lucene/search/TopFieldCollector.html
このAPIは実験的機能だからね!次のリリースで非互換の変更が入るかもね!と言ってくれていますが、IndexSearcherの内部では普通に使っています…。まあ、一応頭に入れておきますか。
あ、今回使っているLuceneのバージョンは、4.3.1ですから。
では、使用例。
// ソート有りの検索 val docCollector = TopFieldCollector.create(new Sort(new SortField("addressCd", SortField.Type.STRING, true), new SortField("zipNo", SortField.Type.STRING, true)), 100000, true, // fillFields false, // trackDocScores false, // trackMaxScore false) // docScoredInOrder searcher.search(q, docCollector) val topDocs = docCollector.topDocs // ここ、追加!! val hits = topDocs.scoreDocs
そんなにTopScoreDocCollectorクラスと変わらない気もしますが、createメソッドで指定できる引数にスコア計算をするかどうかとかの指定ができます。
Collectorを使って、ページングを考えてみる
このコードを書いていて、ちょっとやってみたくなったのでページングもやってみることにしました。
まずは、Collectorの機能を使わないページングの方法を考えてみます。
まあ、IndexSearcher#searchで引数を調整しても同じですね。それから、今回はデータの取得にはTopFieldCollectorクラスを使用します。
今回のプログラムのクエリを投げる部分には、こっそりこんな定義を書いておきました。
object InteractiveQuery { private var currentPage: Int = 1 private var offset: Int = 20 private val max: Int = 2000000
それを変更するPartialFunctionと
val pageRegex = """page\s*=\s*(\d+)""".r val changePaging: PartialFunction[String, Unit] = { case pageRegex(page) => currentPage = Try(page.toInt).recover { case _ => 0 }.get println(s"set currentPage = $currentPage") } val offsetRegex = """offset\s*=\s*(\d+)""".r val changeOffset: PartialFunction[String, Unit] = { case offsetRegex(o) => offset = Try(o.toInt).recover { case _ => 20 }.get println(s"set offset = $offset") }
クエリを実行するPartialFunctionを用意して、最後に合成するというアプローチです。
Iterator .continually(readLine("Lucene Query> ")) .takeWhile(_ != "exit") .withFilter(line => !line.isEmpty && !line.endsWith("\\c")) .foreach(changePaging orElse changeOffset orElse executeQuery)
はい。
で、クエリを実行するPartialFunctionをこういう定義にしてみます。
val executeQuery: PartialFunction[String, Unit] = { case line => query(line).foreach { q => println(s"入力したクエリ => $q") // ヒット件数の取得 val totalHitCountCollector = new TotalHitCountCollector searcher.search(q, totalHitCountCollector) val totalHits = totalHitCountCollector.getTotalHits printf("%1$,3d件、ヒットしました%n", totalHits) val start = (currentPage - 1) * offset if (totalHits > start) { // ソート有りの検索 val docCollector = TopFieldCollector.create(new Sort(new SortField("addressCd", SortField.Type.STRING, true), new SortField("zipNo", SortField.Type.STRING, true)), currentPage * offset, true, // fillFields false, // trackDocScores false, // trackMaxScore false) // docScoredInOrder searcher.search(q, docCollector) val end = if ((currentPage * offset) > totalHits) totalHits else currentPage * offset val topDocs = docCollector.topDocs val hits = topDocs.scoreDocs printf("ヒットレコード中の、%1$,3d〜%2$,3d件までを表示します%n", start + 1, end) (start until end).foreach { i => val h = hits(i) val hitDoc = searcher.doc(h.doc) println { s"Score,N[${h.score}:${h.doc}] : Doc => " + hitDoc .getFields .asScala .map(_.stringValue) .mkString(" ", " | ", "") } } } else { printf("開始位置が%1$,3dのため、表示するレコードがありません%n", start + 1) } } }
ちょっと、ページングっぽい感じですね。
どの範囲からDocumentを取得するのかは、自分で計算しています。
(start until end).foreach { i =>
val h = hits(i)
これを実行すると
> run [info] Running LuceneCollector 10,000件… 20,000件… 30,000件… 40,000件… 50,000件… 60,000件… 70,000件… 80,000件… 90,000件… 100,000件… 110,000件… 120,000件… 130,000件… 140,000件… 148,204件、インデックスに登録しました Start Interactive Query Lucene Query>
とまあ、クエリ待ちの状態になります。
クエリを投げると、ヒットした場合は1ページ中に表示可能な件数が表示されます。
Lucene Query> 福岡 入力したクエリ => prefecture:福岡 4,136件、ヒットしました ヒットレコード中の、 1〜 20件までを表示します Score,N[NaN:136970] : Doc => 871858500 | 871-8585 | 福岡県 | フクオカケン | 築上郡吉富町 | チクジョウグンヨシトミマチ | 広津 | ヒロツ | | Score,N[NaN:136962] : Doc => 871855000 | 871-8550 | 福岡県 | フクオカケン | 築上郡吉富町 | チクジョウグンヨシトミマチ | 小祝 | コイワイ | | Score,N[NaN:136989] : Doc => 871099300 | 871-0993 | 福岡県 | フクオカケン | 築上郡上毛町 | チクジョウグンコウゲマチ | 東下 | ヒガシシモ | |
*結果は、省略しています。
ページ位置や、表示件数を変更したければ用意したPartialFunctionで。
Lucene Query> page = 2 set currentPage = 2 Lucene Query> offset = 40 set offset = 40
Lucene Query> 福岡 入力したクエリ => prefecture:福岡 4,136件、ヒットしました ヒットレコード中の、 41〜 80件までを表示します Score,N[NaN:134948] : Doc => 839850200 | 839-8502 | 福岡県 | フクオカケン | 久留米市 | クルメシ | 御井町 | ミイマチ | | Score,N[NaN:134749] : Doc => 839850100 | 839-8501 | 福岡県 | フクオカケン | 久留米市 | クルメシ | 合川町 | アイカワマチ | |
最初、ちょっと参考にしたページ。
http://stackoverflow.com/questions/351176/paging-lucenes-search-results
http://stackoverflow.com/questions/963781/how-to-achieve-pagination-in-lucene
で、今度はCollectorのメソッドを使って、同じことをやってみます。
これには、TopFieldCollectorクラスやTopScoreDocCollectorクラスの親クラスである、TopDocsCollectorクラスの引数があるtopDocsメソッドを使用すればよさそうです。
今度は、こんな感じに。
val executeQuery: PartialFunction[String, Unit] = { case line => query(line).foreach { q => println(s"入力したクエリ => $q") // ヒット件数の取得 val totalHitCountCollector = new TotalHitCountCollector searcher.search(q, totalHitCountCollector) val totalHits = totalHitCountCollector.getTotalHits printf("%1$,3d件、ヒットしました%n", totalHits) val start = (currentPage - 1) * offset if (totalHits > start) { // ソート有りの検索 val docCollector = TopFieldCollector.create(new Sort(new SortField("addressCd", SortField.Type.STRING, true), new SortField("zipNo", SortField.Type.STRING, true)), currentPage * offset, true, // fillFields false, // trackDocScores false, // trackMaxScore false) // docScoredInOrder searcher.search(q, docCollector) val end = if ((currentPage * offset) > totalHits) totalHits else currentPage * offset val topDocs = docCollector.topDocs(start, currentPage * offset) val hits = topDocs.scoreDocs printf("ヒットレコード中の、%1$,3d〜%2$,3d件までを表示します%n", start + 1, end) hits foreach { h => val hitDoc = searcher.doc(h.doc) println { s"Score,N[${h.score}:${h.doc}] : Doc => " + hitDoc .getFields .asScala .map(_.stringValue) .mkString(" ", " | ", "") } } } else { printf("開始位置が%1$,3dのため、表示するレコードがありません%n", start + 1) } } }
範囲の指定は、これだけになってスッキリ。
val topDocs = docCollector.topDocs(start, currentPage * offset)
動作は変わらないので、割愛します。
topDocsメソッドには、他にも開始位置のみを指定できる版もあります。
これらの引数を取るtopDocsメソッドの注意事項としては、
NOTE: you cannot call this method more than once for each search execution. If you need to call it more than once, passing each time a different range, you should call topDocs() and work with the returned TopDocs object, which will contain all the results this search execution collected.
基本的には使い捨てということ。複数の範囲を指定したい場合は、引数を取らないtopDocsメソッドを使いなさいということらしいです。
とりあえず、Collectorの簡単な使い方はわかった気がします。
最後に、今回作成したコード全体を載せておきます。一部、コメントアウトも入ってます。
src/main/scala/LuceneCollector.scala
import scala.collection.JavaConverters._ import scala.util.{Failure, Try} import java.nio.charset.Charset import java.nio.file.{Files, Paths} 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 org.supercsv.io.CsvListReader import org.supercsv.prefs.CsvPreference import LuceneCollector.AutoCloseableWrapper object LuceneCollector { def main(args: Array[String]): Unit = { val directory = new RAMDirectory val luceneVersion = Version.LUCENE_43 val indexer = new Indexer(directory, luceneVersion, new JapaneseAnalyzer(luceneVersion), "zenkoku.csv") indexer.execute() InteractiveQuery.queryWhile(directory, luceneVersion, new JapaneseAnalyzer(luceneVersion)) } implicit class AutoCloseableWrapper[A <: AutoCloseable](val underlying: A) extends AnyVal { def foreach(f: A => Unit): Unit = try { f(underlying) } finally { if (underlying != null) { underlying.close() } } } } class Indexer(directory: Directory, luceneVersion: Version, analyzer: Analyzer, source: String) { def execute(): Unit = { for { reader <- Files.newBufferedReader(Paths.get(source), Charset.forName("Windows-31J")) csvReader <- new CsvListReader(reader, CsvPreference.STANDARD_PREFERENCE) indexWriter <- new IndexWriter(directory, new IndexWriterConfig(luceneVersion, analyzer)) } { val count = Iterator .continually(csvReader.read()) .takeWhile(_ != null) .foldLeft(0) { (acc, tokens) => if (acc > 0 && acc % 10000 == 0) { printf("%1$,3d件…%n", acc) } indexWriter.addDocument(Address(tokens.asScala).toDocument) acc + 1 } printf("%1$,3d件、インデックスに登録しました%n", count) } } } case class Address(tokens: Seq[String]) { def toDocument: Document = { val doc = new Document doc.add(stringField("addressCd", 0)) doc.add(stringField("zipNo", 4)) doc.add(textField("prefecture", 7)) doc.add(textField("prefectureKana", 8)) doc.add(textField("city", 9)) doc.add(textField("cityKana", 10)) doc.add(textField("town", 11)) doc.add(textField("townKana", 12)) doc.add(textField("azachome", 15)) doc.add(textField("azachomeKana", 16)) doc } private def stringField(name: String, index: Int): Field = new StringField(name, Option(tokens(index)).getOrElse(""), Field.Store.YES) private def textField(name: String, index: Int): Field = new TextField(name, Option(tokens(index)).getOrElse(""), Field.Store.YES) } object InteractiveQuery { private var currentPage: Int = 1 private var offset: Int = 20 private val max: Int = 2000000 def queryWhile(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit = { println("Start Interactive Query") for (reader <- DirectoryReader.open(directory)) { val searcher = new IndexSearcher(reader) val qp: QueryParser = new QueryParser(luceneVersion, "prefecture", analyzer) // qp.setDefaultOperator(QueryParser.Operator.AND) val query = (queryString: String) => Try(qp.parse(queryString)) .recoverWith { case th => println(s"[ERROR] Invalid Query: $th") Failure(th) }.toOption val pageRegex = """page\s*=\s*(\d+)""".r val changePaging: PartialFunction[String, Unit] = { case pageRegex(page) => currentPage = Try(page.toInt).recover { case _ => 0 }.get println(s"set currentPage = $currentPage") } val offsetRegex = """offset\s*=\s*(\d+)""".r val changeOffset: PartialFunction[String, Unit] = { case offsetRegex(o) => offset = Try(o.toInt).recover { case _ => 20 }.get println(s"set offset = $offset") } val executeQuery: PartialFunction[String, Unit] = { case line => query(line).foreach { q => println(s"入力したクエリ => $q") // ヒット件数の取得 val totalHitCountCollector = new TotalHitCountCollector searcher.search(q, totalHitCountCollector) val totalHits = totalHitCountCollector.getTotalHits printf("%1$,3d件、ヒットしました%n", totalHits) val start = (currentPage - 1) * offset if (totalHits > start) { // 通常の、スコア順での検索 // val docCollector = // TopScoreDocCollector.create(currentPage * offset, true) // ソート有りの検索 val docCollector = TopFieldCollector.create(new Sort(new SortField("addressCd", SortField.Type.STRING, true), new SortField("zipNo", SortField.Type.STRING, true)), currentPage * offset, true, // fillFields false, // trackDocScores false, // trackMaxScore false) // docScoredInOrder searcher.search(q, docCollector) val end = if ((currentPage * offset) > totalHits) totalHits else currentPage * offset // val topDocs = docCollector.topDocs val topDocs = docCollector.topDocs(start, currentPage * offset) val hits = topDocs.scoreDocs printf("ヒットレコード中の、%1$,3d〜%2$,3d件までを表示します%n", start + 1, end) hits foreach { h => val hitDoc = searcher.doc(h.doc) println { s"Score,N[${h.score}:${h.doc}] : Doc => " + hitDoc .getFields .asScala .map(_.stringValue) .mkString(" ", " | ", "") } } /* (start until end).foreach { i => val h = hits(i) val hitDoc = searcher.doc(h.doc) println { s"Score,N[${h.score}:${h.doc}] : Doc => " + hitDoc .getFields .asScala .map(_.stringValue) .mkString(" ", " | ", "") } } */ } else { printf("開始位置が%1$,3dのため、表示するレコードがありません%n", start + 1) } } } Iterator .continually(readLine("Lucene Query> ")) .takeWhile(_ != "exit") .withFilter(line => !line.isEmpty && !line.endsWith("\\c")) .foreach(changePaging orElse changeOffset orElse executeQuery) } } }