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() } } }