CLOVER🍀

That was when it all began.

LuceneのGroupingを使ってみる

LuceneのGrouping機能について。

grouping
http://lucene.apache.org/core/4_4_0/grouping/index.html

検索結果を、特定のフィールドでグルーピングする機能みたいです。

SolrでのGroupingの良いイメージがこちらにあったので、理解の参考にさせていただきました。

SolrのResult Grouping
http://d.hatena.ne.jp/shinobu_aoki/20111019/1319049699

その他、英語のドキュメントとしてこれらを参考にしています。

http://www.slideshare.net/lucenerevolution/grouping-and-joining-in-lucenesolr
http://www.searchworkings.org/blog/-/blogs/24078

では、使っていってみましょう。

準備。
build.sbt

name := "lucene-grouping"

version := "0.0.1"

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",
  "org.apache.lucene" % "lucene-grouping" % "4.4.0"
)

今回のサンプルだと、QueryParserは不要なことに後で気付きました…。

ドキュメントに登録するデータは、こんな感じです。

  private def registryDocuments(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit =
    for (indexWriter <- new IndexWriter(directory,
                                        new IndexWriterConfig(luceneVersion, analyzer))) {
      indexWriter.addDocument(book("978-4894714991",
                                   "Effective Java 第2版",
                                   "2008",
                                   "java",
                                   3780))
      indexWriter.addDocument(book("978-4774139906",
                                   "パーフェクトJava",
                                   "2009",
                                   "java",
                                   3780))
      indexWriter.addDocument(book("978-4844330844",
                                   "Scalaスケーラブルプログラミング第2版",
                                   "2011",
                                   "scala",
                                   4830))
      indexWriter.addDocument(book("978-4798125411",
                                   "Scala逆引きレシピ (PROGRAMMER’S RECiPE)",
                                   "2012",
                                   "scala",
                                   3360))
      indexWriter.addDocument(book("978-4822284237",
                                   "Scalaプログラミング入門",
                                   "2010",
                                   "scala",
                                   3360))
      indexWriter.addDocument(book("978-4873114811",
                                   "プログラミングScala",
                                   "2011",
                                   "scala",
                                   3990))
      indexWriter.addDocument(book("978-4774147277",
                                   "プログラミングGROOVY",
                                   "2011",
                                   "groovy",
                                   3360))
      indexWriter.addDocument(book("978-4839927271",
                                   "Groovyイン・アクション",
                                   "2008",
                                   "groovy",
                                   5800))
      indexWriter.addDocument(book("978-4274069130",
                                   "プログラミングClojure 第2版",
                                   "2013",
                                   "clojure",
                                   3570))
    }

  private def book(isbn13: String,
                   title: String,
                   year: String,
                   language: String,
                   price: Int): Document = {
    val document = new Document
    document.add(new StringField("isbn13", isbn13, Field.Store.YES))
    document.add(new TextField("title", title, Field.Store.YES))
    document.add(new StringField("year", year, Field.Store.YES))
    document.add(new StringField("language", language, Field.Store.YES))
    document.add(new StringField("price", price.toString, Field.Store.YES))
    document
  }

毎度恒例、書籍データ。

簡単に使う

Groupingを使う場合に、簡易APIとして用意されているのがGroupingSearchクラスです。

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

作成したサンプルを、とりあえず載せますね。

  private def groupingQuerySimply(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit =
    for (reader <- DirectoryReader.open(directory)) {
      val indexSearcher = new IndexSearcher(reader)

      val groupField = "language"
      val groupSort = new Sort(new SortField("language",
                                             SortField.Type.STRING,
                                             false))
      // val groupSort = Sort.RELEVANCE
      val fillSortFields = true
      val requiredTotalGroupCount = true
      val groupLimit = 100
      val docPerGroup = 100

      val groupingSearch = new GroupingSearch(groupField)
      groupingSearch
        .setGroupSort(groupSort)
        .setFillSortFields(fillSortFields)
        .setAllGroups(requiredTotalGroupCount)  // グループの数を取得するようにするかどうか
        .setGroupDocsLimit(docPerGroup)  // グループ内で、いくつドキュメントを取得するか

      val query = new MatchAllDocsQuery
      val topGroups: TopGroups[BytesRef] =
        groupingSearch.search(indexSearcher,
                              query,
                              0,
                              groupLimit)  // いくつのグループを取得するか

      println("===== groupingQuerySimply =====")

      if (requiredTotalGroupCount) {
        println(s"totalGroupCount = ${topGroups.totalGroupCount}")
      }
      println(s"totalHitCount = ${topGroups.totalHitCount}")

      for {
        group <- topGroups.groups
        h <- group.scoreDocs
      } {
        val hitDoc = indexSearcher.doc(h.doc)
        val groupValue = 
          new String(group.groupValue.bytes, StandardCharsets.UTF_8)
        println { s"Score,N[${h.score}:${h.doc}] : Group[$groupValue] Doc => " +
                  hitDoc
                    .getFields
                    .asScala
                    .map(_.stringValue)
                    .mkString(" ", " | ", "")
                }
      }
    }

いくつかコメントに記載していますが、GroupingSearchクラスを使う場合には

  • どのフィールドでグルーピングするのか(groupField)
  • いくつグループを取得するのか(groupLimit)
  • グループ内でいくつドキュメントを取得するのか(GroupingSearch#setGroupDocsLimit)
  • グループのソート順(groupSort)とグループ内のソート順(今回は使っていませんが、おそらくGroupingSearch#setSortWithinGroup)

などを指定します。Limit系の値を取得可能な数より低く設定した場合は、その数に絞り込まれることになります。また、グループの数自体を取得したい場合は、GroupingSearch#setAllGroupsにtrueを指定する必要があります。

先の書籍データに対する実行結果は、こちらです。グルーピングはlanguageフィールドを指定しました。まあ、本に対してプログラミング言語でカテゴリ分けした感じですね。

===== groupingQuerySimply =====
totalGroupCount = 4
totalHitCount = 9
Score,N[1.0:8] : Group[clojure] Doc =>  978-4274069130 | プログラミングClojure 第2版 | 2013 | clojure | 3570
Score,N[1.0:6] : Group[groovy] Doc =>  978-4774147277 | プログラミングGROOVY | 2011 | groovy | 3360
Score,N[1.0:7] : Group[groovy] Doc =>  978-4839927271 | Groovyイン・アクション | 2008 | groovy | 5800
Score,N[1.0:0] : Group[java] Doc =>  978-4894714991 | Effective Java 第2版 | 2008 | java | 3780
Score,N[1.0:1] : Group[java] Doc =>  978-4774139906 | パーフェクトJava | 2009 | java | 3780
Score,N[1.0:2] : Group[scala] Doc =>  978-4844330844 | Scalaスケーラブルプログラミング第2版 | 2011 | scala | 4830
Score,N[1.0:3] : Group[scala] Doc =>  978-4798125411 | Scala逆引きレシピ (PROGRAMMER’S RECiPE) | 2012 | scala | 3360
Score,N[1.0:4] : Group[scala] Doc =>  978-4822284237 | Scalaプログラミング入門 | 2010 | scala | 3360
Score,N[1.0:5] : Group[scala] Doc =>  978-4873114811 | プログラミングScala | 2011 | scala | 3990

Group[XX]と出ているところが、グルーピングされているところになります。

もう少し、プリミティブに使う

先ほどのGroupingSearchクラスは、先の例のような使い方をする場合は、TermFirstPassGroupingCollectorクラスとTermSecondPassGroupingCollectorクラスを使って、IndesSearcherに対して2回検索要求を出すというのを隠したものになっています。
*設定を変えると、BlockGroupingCollectorクラスを使うようになるみたいですが

ちょっと長いですが、こちらもサンプルを載せます。やっていることは、先ほどのGroupingSearchクラスを使ったものとだいたい同じです。違うのは、こちらはグループ内でのソートの指定(withinGroupSort)を行っていることと、スコアの計算はさせていないことですね。

  private def groupingQueryPrimitive(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit =
    for (reader <- DirectoryReader.open(directory)) {
      val indexSearcher = new IndexSearcher(reader)

      val groupField = "language"
      val groupSort = new Sort(new SortField("language",
                                             SortField.Type.STRING,
                                             false))
      // val groupSort = Sort.RELEVANCE
      val fillSortFields = true
      val requiredTotalGroupCount = true
      val groupLimit = 100
      val docPerGroup = 100

      val termFirstPassGroupingCollector =
        new TermFirstPassGroupingCollector(groupField, groupSort, groupLimit)
      val termAllGroupsCollector = new TermAllGroupsCollector(groupField, 128)

      val firstCollector =
        if (requiredTotalGroupCount)
          MultiCollector.wrap(termFirstPassGroupingCollector,
                              termAllGroupsCollector)
        else termFirstPassGroupingCollector


      val query = new MatchAllDocsQuery
      indexSearcher.search(query, firstCollector)

      val searchGroups: Collection[SearchGroup[BytesRef]] =
        termFirstPassGroupingCollector.getTopGroups(0, fillSortFields)

      val withinGroupSort = new Sort(new SortField("price",
                                                   SortField.Type.STRING,
                                                   true))
      val termSecondPassGroupingCollector =
        new TermSecondPassGroupingCollector(groupField,
                                            searchGroups,
                                            groupSort,
                                            withinGroupSort,
                                            docPerGroup,
                                            false,
                                            false,
                                            fillSortFields)

      indexSearcher.search(query, termSecondPassGroupingCollector)

      val topGroups: TopGroups[BytesRef] =
        termSecondPassGroupingCollector.getTopGroups(0)

      println("===== groupingQueryPrimitive =====")

      if (requiredTotalGroupCount) {
        println(s"totalGroupCount = ${termAllGroupsCollector.getGroups.size}")
      }
      println(s"totalHitCount = ${topGroups.totalHitCount}")

      for {
        group <- topGroups.groups
        h <- group.scoreDocs
      } {
        val hitDoc = indexSearcher.doc(h.doc)
        val groupValue = 
          new String(group.groupValue.bytes, StandardCharsets.UTF_8)
        println { s"Score,N[${h.score}:${h.doc}] : Group[$groupValue] Doc => " +
                  hitDoc
                    .getFields
                    .asScala
                    .map(_.stringValue)
                    .mkString(" ", " | ", "")
                }
      }
    }

同じく、実行結果。

totalGroupCount = 4
totalHitCount = 9
Score,N[NaN:8] : Group[clojure] Doc =>  978-4274069130 | プログラミングClojure 第2版 | 2013 | clojure | 3570
Score,N[NaN:7] : Group[groovy] Doc =>  978-4839927271 | Groovyイン・アクション | 2008 | groovy | 5800
Score,N[NaN:6] : Group[groovy] Doc =>  978-4774147277 | プログラミングGROOVY | 2011 | groovy | 3360
Score,N[NaN:0] : Group[java] Doc =>  978-4894714991 | Effective Java 第2版 | 2008 | java | 3780
Score,N[NaN:1] : Group[java] Doc =>  978-4774139906 | パーフェクトJava | 2009 | java | 3780
Score,N[NaN:2] : Group[scala] Doc =>  978-4844330844 | Scalaスケーラブルプログラミング第2版 | 2011 | scala | 4830
Score,N[NaN:5] : Group[scala] Doc =>  978-4873114811 | プログラミングScala | 2011 | scala | 3990
Score,N[NaN:3] : Group[scala] Doc =>  978-4798125411 | Scala逆引きレシピ (PROGRAMMER’S RECiPE) | 2012 | scala | 3360
Score,N[NaN:4] : Group[scala] Doc =>  978-4822284237 | Scalaプログラミング入門 | 2010 | scala | 3360

…前にCollectorについて勉強しておいて、ホントによかったです。

ファセット

ちょっと毛色が変わりますが、Grouping機能にはFacet機能がついています。前にFacetに関するエントリを書いたのですが、

Luceneのファセットを使ってみる
http://d.hatena.ne.jp/Kazuhira/20130828/1377702600

こちらはあらかじめFacet用のインデックスを作っておく必要があったのに対して、こちらは普通のインデックスからグルーピングしてFacetを作ります。

ドキュメントがないので、参考にしたのはこちら。

http://www.searchworkings.org/blog/-/blogs/faceting-%26-result-grouping
http://blog.trifork.com/tag/grouping/

まあ、全く同じ記事なんですけどね…。

そちらを参考にしつつ、作成したのがこちらのサンプル。

  private def groupingQueryFacet(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit =
    for (reader <- DirectoryReader.open(directory)) {
      val indexSearcher = new IndexSearcher(reader)

      val groupField = "isbn13"
      val facetField = "language"
      val facetFieldMultivalued = false
      val facetPrefix: BytesRef = null
      val initialSize = 128
      val offset = 0
      val facetLimit = 100
      val minCount = 0

      val termGroupFacetCollector =
        TermGroupFacetCollector.createTermGroupFacetCollector(groupField,
                                                              facetField,
                                                              facetFieldMultivalued,
                                                              facetPrefix,
                                                              initialSize)

      val query = new MatchAllDocsQuery

      indexSearcher.search(query, termGroupFacetCollector)

      val result = termGroupFacetCollector.mergeSegmentResults(offset + facetLimit,
                                                               minCount,
                                                               false)

      println("===== groupingQueryFacet =====")
      
      println(s"Total Facet Total Count = ${result.getTotalCount}")
      println(s"Total Facet Total Missing Count = ${result.getTotalMissingCount}")

      for (facetEntry <- result.getFacetEntries(offset, facetLimit).asScala) {
        println(s"Facet Value = ${new String(facetEntry.getValue.bytes, "UTF-8")}, " +
                s"Count = ${facetEntry.getCount}")
      }
    }

groupFieldとfacetFieldの関係が最初わからなかったのですが、どうもfacetFieldでFacetを作り、その中でgroupFieldでグループ化された数をカウントする機能みたいです。なので、最初にFacetで割った時に、Facet内でgroupFieldでユニークにならないフィールドを指定した場合は、マージされたカウント数になってしまうので注意してください。

たとえば、極端なことをするとgroupFieldとfacetFieldに同じフィールドを指定すると、各Facet内の数は1になるはずです。

では、サンプルを。

  private def groupingQueryFacet(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit =
    for (reader <- DirectoryReader.open(directory)) {
      val indexSearcher = new IndexSearcher(reader)

      val groupField = "isbn13"
      val facetField = "language"
      val facetFieldMultivalued = false
      val facetPrefix: BytesRef = null
      val initialSize = 128
      val offset = 0
      val facetLimit = 100
      val minCount = 0

      val termGroupFacetCollector =
        TermGroupFacetCollector.createTermGroupFacetCollector(groupField,
                                                              facetField,
                                                              facetFieldMultivalued,
                                                              facetPrefix,
                                                              initialSize)

      val query = new MatchAllDocsQuery

      indexSearcher.search(query, termGroupFacetCollector)

      val result = termGroupFacetCollector.mergeSegmentResults(offset + facetLimit,
                                                               minCount,
                                                               false)

      println("===== groupingQueryFacet =====")
      
      println(s"Total Facet Total Count = ${result.getTotalCount}")
      println(s"Total Facet Total Missing Count = ${result.getTotalMissingCount}")

      for (facetEntry <- result.getFacetEntries(offset, facetLimit).asScala) {
        println(s"Facet Value = ${new String(facetEntry.getValue.bytes, "UTF-8")}, " +
                s"Count = ${facetEntry.getCount}")
      }
    }

TermGroupFacetCollectorが、この機能のメインとなるクラスですね。facetLimitでは取得するFacetの数を指定し、minCountでFacet内にいくつドキュメントがあったら結果に含めるかを指定します。minCountに大きな値を指定すると、それを下回る集計結果となったFacetは、結果から除外されます。

実行結果はこちら。languageフィールドでFacetを作成し、その中でisbn13フィールドでGroupingして数を数えています。

===== groupingQueryFacet =====
Total Facet Total Count = 9
Total Facet Total Missing Count = 0
Facet Value = clojure, Count = 1
Facet Value = groovy, Count = 2
Facet Value = java, Count = 2
Facet Value = scala, Count = 4

今回は指定していませんが、facetPrefixを使うことで指定のキーワードで始まるものをFacetの数に含めるようにすることができるのだとか。

最後に、今回作成したコードを貼っておきます。
src/main/scala/LuceneGrouping.scala

import scala.collection.JavaConverters._

import java.nio.charset.StandardCharsets
import java.util.Collection

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.search.{IndexSearcher, MatchAllDocsQuery, MultiCollector, Query, Sort, SortField}
import org.apache.lucene.store.{Directory, RAMDirectory}
import org.apache.lucene.util.{BytesRef, Version}

import org.apache.lucene.search.grouping.{GroupingSearch, SearchGroup, TopGroups}
import org.apache.lucene.search.grouping.term.{TermAllGroupsCollector, TermFirstPassGroupingCollector, TermSecondPassGroupingCollector}
import org.apache.lucene.search.grouping.term.TermGroupFacetCollector

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

    for (directory <- new RAMDirectory) {
      registryDocuments(directory, luceneVersion, analyzer)

      groupingQuerySimply(directory, luceneVersion, analyzer)
      groupingQueryPrimitive(directory, luceneVersion, analyzer)
      groupingQueryFacet(directory, luceneVersion, analyzer)
    }
  }

  private def groupingQuerySimply(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit =
    for (reader <- DirectoryReader.open(directory)) {
      val indexSearcher = new IndexSearcher(reader)

      val groupField = "language"
      val groupSort = new Sort(new SortField("language",
                                             SortField.Type.STRING,
                                             false))
      // val groupSort = Sort.RELEVANCE
      val fillSortFields = true
      val requiredTotalGroupCount = true
      val groupLimit = 100
      val docPerGroup = 100

      val groupingSearch = new GroupingSearch(groupField)
      groupingSearch
        .setGroupSort(groupSort)
        .setFillSortFields(fillSortFields)
        .setAllGroups(requiredTotalGroupCount)  // グループの数を取得するようにするかどうか
        .setGroupDocsLimit(docPerGroup)  // グループ内で、いくつドキュメントを取得するか

      val query = new MatchAllDocsQuery
      val topGroups: TopGroups[BytesRef] =
        groupingSearch.search(indexSearcher,
                              query,
                              0,
                              groupLimit)  // いくつのグループを取得するか

      println("===== groupingQuerySimply =====")

      if (requiredTotalGroupCount) {
        println(s"totalGroupCount = ${topGroups.totalGroupCount}")
      }
      println(s"totalHitCount = ${topGroups.totalHitCount}")

      for {
        group <- topGroups.groups
        h <- group.scoreDocs
      } {
        val hitDoc = indexSearcher.doc(h.doc)
        val groupValue = 
          new String(group.groupValue.bytes, StandardCharsets.UTF_8)
        println { s"Score,N[${h.score}:${h.doc}] : Group[$groupValue] Doc => " +
                  hitDoc
                    .getFields
                    .asScala
                    .map(_.stringValue)
                    .mkString(" ", " | ", "")
                }
      }
    }

  private def groupingQueryPrimitive(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit =
    for (reader <- DirectoryReader.open(directory)) {
      val indexSearcher = new IndexSearcher(reader)

      val groupField = "language"
      val groupSort = new Sort(new SortField("language",
                                             SortField.Type.STRING,
                                             false))
      // val groupSort = Sort.RELEVANCE
      val fillSortFields = true
      val requiredTotalGroupCount = true
      val groupLimit = 100
      val docPerGroup = 100

      val termFirstPassGroupingCollector =
        new TermFirstPassGroupingCollector(groupField, groupSort, groupLimit)
      val termAllGroupsCollector = new TermAllGroupsCollector(groupField, 128)

      val firstCollector =
        if (requiredTotalGroupCount)
          MultiCollector.wrap(termFirstPassGroupingCollector,
                              termAllGroupsCollector)
        else termFirstPassGroupingCollector


      val query = new MatchAllDocsQuery
      indexSearcher.search(query, firstCollector)

      val searchGroups: Collection[SearchGroup[BytesRef]] =
        termFirstPassGroupingCollector.getTopGroups(0, fillSortFields)

      val withinGroupSort = new Sort(new SortField("price",
                                                   SortField.Type.STRING,
                                                   true))
      val termSecondPassGroupingCollector =
        new TermSecondPassGroupingCollector(groupField,
                                            searchGroups,
                                            groupSort,
                                            withinGroupSort,
                                            docPerGroup,
                                            false,
                                            false,
                                            fillSortFields)

      indexSearcher.search(query, termSecondPassGroupingCollector)

      val topGroups: TopGroups[BytesRef] =
        termSecondPassGroupingCollector.getTopGroups(0)

      println("===== groupingQueryPrimitive =====")

      if (requiredTotalGroupCount) {
        println(s"totalGroupCount = ${termAllGroupsCollector.getGroups.size}")
      }
      println(s"totalHitCount = ${topGroups.totalHitCount}")

      for {
        group <- topGroups.groups
        h <- group.scoreDocs
      } {
        val hitDoc = indexSearcher.doc(h.doc)
        val groupValue = 
          new String(group.groupValue.bytes, StandardCharsets.UTF_8)
        println { s"Score,N[${h.score}:${h.doc}] : Group[$groupValue] Doc => " +
                  hitDoc
                    .getFields
                    .asScala
                    .map(_.stringValue)
                    .mkString(" ", " | ", "")
                }
      }
    }

  private def groupingQueryFacet(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit =
    for (reader <- DirectoryReader.open(directory)) {
      val indexSearcher = new IndexSearcher(reader)

      val groupField = "isbn13"
      val facetField = "language"
      val facetFieldMultivalued = false
      val facetPrefix: BytesRef = null
      val initialSize = 128
      val offset = 0
      val facetLimit = 100
      val minCount = 0

      val termGroupFacetCollector =
        TermGroupFacetCollector.createTermGroupFacetCollector(groupField,
                                                              facetField,
                                                              facetFieldMultivalued,
                                                              facetPrefix,
                                                              initialSize)

      val query = new MatchAllDocsQuery

      indexSearcher.search(query, termGroupFacetCollector)

      val result = termGroupFacetCollector.mergeSegmentResults(offset + facetLimit,
                                                               minCount,
                                                               false)

      println("===== groupingQueryFacet =====")
      
      println(s"Total Facet Total Count = ${result.getTotalCount}")
      println(s"Total Facet Total Missing Count = ${result.getTotalMissingCount}")

      for (facetEntry <- result.getFacetEntries(offset, facetLimit).asScala) {
        println(s"Facet Value = ${new String(facetEntry.getValue.bytes, "UTF-8")}, " +
                s"Count = ${facetEntry.getCount}")
      }
    }

  private def registryDocuments(directory: Directory, luceneVersion: Version, analyzer: Analyzer): Unit =
    for (indexWriter <- new IndexWriter(directory,
                                        new IndexWriterConfig(luceneVersion, analyzer))) {
      indexWriter.addDocument(book("978-4894714991",
                                   "Effective Java 第2版",
                                   "2008",
                                   "java",
                                   3780))
      indexWriter.addDocument(book("978-4774139906",
                                   "パーフェクトJava",
                                   "2009",
                                   "java",
                                   3780))
      indexWriter.addDocument(book("978-4844330844",
                                   "Scalaスケーラブルプログラミング第2版",
                                   "2011",
                                   "scala",
                                   4830))
      indexWriter.addDocument(book("978-4798125411",
                                   "Scala逆引きレシピ (PROGRAMMER’S RECiPE)",
                                   "2012",
                                   "scala",
                                   3360))
      indexWriter.addDocument(book("978-4822284237",
                                   "Scalaプログラミング入門",
                                   "2010",
                                   "scala",
                                   3360))
      indexWriter.addDocument(book("978-4873114811",
                                   "プログラミングScala",
                                   "2011",
                                   "scala",
                                   3990))
      indexWriter.addDocument(book("978-4774147277",
                                   "プログラミングGROOVY",
                                   "2011",
                                   "groovy",
                                   3360))
      indexWriter.addDocument(book("978-4839927271",
                                   "Groovyイン・アクション",
                                   "2008",
                                   "groovy",
                                   5800))
      indexWriter.addDocument(book("978-4274069130",
                                   "プログラミングClojure 第2版",
                                   "2013",
                                   "clojure",
                                   3570))
    }

  private def book(isbn13: String,
                   title: String,
                   year: String,
                   language: String,
                   price: Int): Document = {
    val document = new Document
    document.add(new StringField("isbn13", isbn13, Field.Store.YES))
    document.add(new TextField("title", title, Field.Store.YES))
    document.add(new StringField("year", year, Field.Store.YES))
    document.add(new StringField("language", language, Field.Store.YES))
    document.add(new StringField("price", price.toString, Field.Store.YES))
    document
  }

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