SolrのResult Grouping

今回はSolrのResult Grouping(Field Collapsing)の機能について紹介します。

この機能は検索結果を特定のフィールドでグループ化します。SQLのgroup byみたいなものと思ってもらうと分かりやすいでしょう。具体的な例だとGoogle検索の以下のような表現が良い例ですね。

Solrでは上記のGoogle検索と同じ事が、通常の検索にgroup=true&group.field=グループ化する項目名というパラメータを付けるだけで行えます。Result Groupingの詳細な情報は、Solrのwiki「FieldCollapsing」に詳しく書かれていますのでそちらを見て頂く事にして、ここでは一覧を簡単に紹介するだけにとどめ、主要なパラメータについて試しながら紹介していきたいと思います。

パラメータ一覧

パラメータ名 指定する値 説明
group true/false trueでこの機能を有効にする。省略時はfalse
group.field 項目名 グループ化する項目。
3.xでは文字列系(StrField, TextFieldなど)の項目以外は指定できない。またmulti-valuedの項目も指定できない
group.func function query グループ化する為のfunction queryを指定。4.0からの機能
group.query query 項目ではなく条件でグループ化するためのクエリーを指定。
同名のパラメータで異なるクエリーを指定可能。
使用例:group.query=price:[0 TO 999]&group.query=price:[1000 TO *]
検索結果を1000円未満と1000円以上でグループ化
rows 数値 一度の結果で返す最大数。formatがgroupedの場合はドキュメント数ではなくグループ数を示す
start 数値 結果の表示開始位置。rowsと同様にグループ数なので注意
group.limit 数値 グループ化したドキュメントの表示最大数。省略時は1
group.offset 数値 グループ化したドキュメントの表示開始位置。省略時は0
sort ソート条件 検索結果のソート順。省略時はLuceneが算出するスコア順
group.sort ソート条件 グループ化したドキュメントの表示順。省略時はLuceneが算出するスコア順
group.format grouped/simple 検索結果の表示形式。省略時はgrouped
group.main true/false trueで検索結果の表示形式を、通常の検索結果と同じ形式にする。使えなくなるパラメータ有り(詳細は後述)
省略時はfalse
group.ngroups true/false trueでグループ単位の検索結果総数を結果に加える。省略時はfalse
group.truncate true/false trueでファセットカウントをドキュメント数ではなくグループ単位の数にする。省略時はfalse
group.cache.percent 0〜100 1以上の値を指定する事でグループ化のための検索をキャッシュする。
(1以上の値を設定してもquery result cacheにもキャッシュされず、詳細は不明・・・。パフォーマンス計測の結果、効果はあったが、wikiには簡単なqueryに適用するとパフォーマンスに悪影響が出るかも、と書いてる・・・。)
省略時はfalse

これらのパラメータは、大きく分けると以下のようなグループに分けられます。

  1. 基本的なパラメータ
  2. 結果のフォーマット
  3. ファセットの為の機能

これらをそれぞれ試してみます。今回サンプルデータとして、日本語のwikipediaの記事(jawiki-latest-pages-articles.xml.bz2)を使わせてもらいました。

基本的なパラメータ

まず、Result Groupingを見る前に、比較の為に通常の検索結果を見てみます。(以降すべて「うどん」の検索結果です。)
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on

<response>
 <lst name="responseHeader">
  ... (経過時間や指定したパラメータなどのヘッダ情報) ...
 </lst>
 <result name="response" numFound="2491" start="0"> ・・・①
  <doc>
   <str name="contributor-id">24321</str>
   <str name="contributor-name">千葉県民</str>
   <str name="id">767255</str>
   <date name="timestamp">2006-11-16T23:25:10Z</date>
   <str name="title">田舎うどん</str>
   <str name="url">
   http://ja.wikipedia.org/wiki/%E7%94%B0%E8%88%8E%E3%81%86%E3%81%A9%E3%82%93
   </str>
  </doc>
  <doc>
   <str name="contributor-id">40160</str>
   <str name="contributor-name">中村派</str>
   <str name="id">540810</str>
   <date name="timestamp">2006-05-17T13:09:14Z</date>
   <str name="title">天ぷらうどん</str>
   <str name="url">
    http://ja.wikipedia.org/wiki/%E5%A4%A9%E3%81%B7%E3%82%89%E3%81%86%E3%81%A9%E3%82%93
   </str>
  </doc>
  ... (以下hitしたドキュメントの情報が続く) ...
 </result>
</response>

上記が通常の検索結果で、①のresult要素の子要素のdocがhitしたドキュメントです。また①のresult要素のnumFound属性が、hitしたドキュメントの総数です。では、条件は同じまま、contributor-id(wikipediaのcontributorに割り当てられている内部ID)でグループ化してみます。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped"> ・・・①
  <lst name="contributor-id">
   <int name="matches">2491</int> ・・・②
   <arr name="groups"> ・・・③
    <lst>
     <str name="groupValue">24321</str> ・・・④
     <result name="doclist" numFound="3" start="0"> ・・・⑤
      <doc>
       <str name="contributor-id">24321</str>
       <str name="contributor-name">千葉県民</str>
       <str name="id">767255</str>
       <date name="timestamp">2006-11-16T23:25:10Z</date>
       <str name="title">田舎うどん</str>
       <str name="url">
        http://ja.wikipedia.org/wiki/%E7%94%B0%E8%88%8E%E3%81%86%E3%81%A9%E3%82%93
       </str>
      </doc>
     </result>
    </lst>
    ... (以下hitしたグループの情報が続く) ...
   </arr>
  </lst>
 </lst>
</response>

Result Groupingのパラメータを追加する事で、結果がcontributorごとにグループ化され、それぞれのcontributorごとに1つの結果のみが返されるようになりました。また、通常の検索と比べてかなり構造も変わりました。

  • ①は通常検索のresult要素と同じで、結果部分のroot要素です。
  • ②は通常検索のnumFound属性と同じで、この検索でhitしたドキュメントの総数です(グループ数ではありません)
  • ③の子要素のlst要素が、それぞれのグループごとにまとめられた結果の要素です。
  • ④はグループ化した項目の値です(contributorのID)
  • ⑤の要素はグループ化したドキュメントを保持します。最大でgroup.limitで指定した数を保持します。numFoundは、グループ内のhitしたドキュメントの総数です。

次はgroup.limitを指定してみます。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&group.limit=2

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">
  <lst name="contributor-id">
   <int name="matches">2491</int>
   <arr name="groups">
    ... (中略) ...
    <lst>
     <str name="groupValue">103575</str>
     <result name="doclist" numFound="4" start="0"> ・・・①
      <doc>
       <str name="contributor-id">103575</str>
       <str name="contributor-name">Pcs34560</str>
       <str name="id">24461</str>
       <date name="timestamp">2003-11-07T16:50:09Z</date>
       <str name="title">饂飩</str>
       <str name="url">http://ja.wikipedia.org/wiki/%E9%A5%82%E9%A3%A9</str>
      </doc>
      <doc>
       <str name="contributor-id">103575</str>
       <str name="contributor-name">Pcs34560</str>
       <str name="id">24460</str>
       <date name="timestamp">2011-09-21T13:21:13Z</date>
       <str name="title">うどん</str>
       <str name="url">
        http://ja.wikipedia.org/wiki/%E3%81%86%E3%81%A9%E3%82%93
       </str>
      </doc>
     </result>
    </lst>
     ... (以下hitしたグループの情報が続く) ...
   </arr>
  </lst>
 </lst>
</response>

二つ表示されるようになりました。上の①のnumFoundでグループ内には全部で4件hitしたドキュメントがある事が分かります。この情報を利用すればGoogle検索と同じように「○○をもっと見る」が実現できますね。
次はグループ化した時のページング処理に必要となる、group.ngroupsを指定してみます。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&group.ngroups=true

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">
  <lst name="contributor-id">
   <int name="matches">2491</int> ・・・①
   <int name="ngroups">1244</int> ・・・②
   <arr name="groups">
    <lst>...</lst>
    <lst>...</lst>
    <lst>...</lst>
     ... (ç•¥) ...
   </arr>
  </lst>
 </lst>
</response>

上記①は、通常の検索hit総件数(ドキュメント数)で、②が総グループ数となります。1ページの表示最大数はグループ数で数えられる為、Result Groupingの検索のページングには②の数値を用います。

結果のフォーマット

これまで見てきたResult Groupingの検索結果の構造は、そうではない検索結果の構造と比較すると、結構違いがあります。パッと見構造が複雑ですし、結果の解析もその分大変になります。それらを軽減する為に、これまで見てきたものを合わせて3種類の形式が用意されています。あとの2種類はいずれもこれまでよりシンプルな構造になりますが、出来る事が変わります。パフォーマンスはどれもあまり変わらなかったので、用途に合ったフォーマットを選ぶと良いでしょう。選択できるフォーマットは以下の3つです。

No 名称 説明
1 grouped これまで見てきた構造(デフォルトの形式)
2 simple groupedよりシンプルな構造
3 main 通常の検索結果とほぼ同じ構造

groupedはこれまで見てきた形式ですが、おさらいしましょう。こんな構造でした。

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">
  <lst name="contributor-id">
   <int name="matches">2491</int>
   <int name="ngroups">1244</int>
   <arr name="groups">
    <lst>
     <str name="groupValue">24321</str>
     <result name="doclist" numFound="3" start="0">
      <doc>
       <str name="contributor-id">24321</str>
       ... (ç•¥) ...
      </doc>
      <doc>... (ç•¥) ...</doc>
      <doc>... (ç•¥) ...</doc>
      ... (以下group.limitを上限としてdoc要素を繰り返す) ...
     </result>
    </lst>
    <lst>...</lst>
    <lst>...</lst>
     ... (以下グループ単位でlimitを上限としてlst要素を繰り返す) ...
   </arr>
  </lst>
 </lst>
</response>

2番目のsimpleは、途中までの構造はgroupedと同じですが、ドキュメントがグループごとにまとめられず、全て平らな構造になっているのが特徴です。また、groupedとは事なり、ページあたりの表示数はグループ数ではなくドキュメント数となります。その為、ngroupsの指定と取得も出来るのですが、あまり使い道はないでしょう。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&group.ngroups=true&group.format=simple

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">
  <lst name="contributor-id">
   <int name="matches">2491</int>
   <int name="ngroups">1244</int>
   <result name="doclist" numFound="2491" start="0">
    <doc>... (ç•¥) ...</doc>
    <doc>... (ç•¥) ...</doc>
    <doc>
     <str name="contributor-id">30901</str>
     <str name="contributor-name">”D”</str>
     <str name="id">457105</str>
     <date name="timestamp">2006-02-23T11:08:35Z</date>
     <str name="title">冷凍うどん</str>
     <str name="url">
      http://ja.wikipedia.org/wiki/%E5%86%B7%E5%87%8D%E3%81%86%E3%81%A9%E3%82%93
     </str>
    </doc>
    ↓グループ化されずフラットに扱われる
    <doc>
     <str name="contributor-id">14864</str>
     <str name="contributor-name">逃亡者</str>
     <str name="id">473792</str>
     <date name="timestamp">2006-03-09T19:52:36Z</date>
     <str name="title">肉うどん</str>
     <str name="url">
      http://ja.wikipedia.org/wiki/%E8%82%89%E3%81%86%E3%81%A9%E3%82%93
     </str>
    </doc>
    <doc>
     <str name="contributor-id">14864</str>
     <str name="contributor-name">逃亡者</str>
     <str name="id">1332131</str>
     <date name="timestamp">2010-12-29T12:09:31Z</date>
     <str name="title">タヌキ (曖昧さ回避)</str>
     <str name="url">
      http://ja.wikipedia.org/wiki/%E3%82%BF%E3%83%8C%E3%82%AD_%28%E6%9B%96%E6%98%A7%E3%81%95%E5%9B%9E%E9%81%BF%29
     </str>
    </doc>
    <doc>... (ç•¥) ...</doc>
    <doc>... (ç•¥) ...</doc>
    <doc>... (ç•¥) ...</doc>
    <doc>... (ç•¥) ...</doc>
    <doc>... (ç•¥) ...</doc>
   </result>
  </lst>
 </lst>
</response>

simpledの用途は、検索結果はフラットに表示したいが検索結果をグループごとにまとめて表示したい、といった時に使えるでしょう。ただし、このように表示したい場合なら、大抵の場合は次のmainを使うのが良いでしょう。mainはほぼ通常の検索結果と同じ構造でレスポンスを返します。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&group.ngroups=true&group.main=true

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped"/> ・・・①
 <result name="response" numFound="2491" start="0">
  <doc>
   <str name="contributor-id">24321</str>
   <str name="contributor-name">千葉県民</str>
   <str name="id">767255</str>
   <date name="timestamp">2006-11-16T23:25:10Z</date>
   <str name="title">田舎うどん</str>
   <str name="url">
    http://ja.wikipedia.org/wiki/%E7%94%B0%E8%88%8E%E3%81%86%E3%81%A9%E3%82%93
   </str>
  </doc>
  <doc>
   <str name="contributor-id">40160</str>
   <str name="contributor-name">中村派</str>
   <str name="id">540810</str>
   <date name="timestamp">2006-05-17T13:09:14Z</date>
   <str name="title">天ぷらうどん</str>
   <str name="url">
    http://ja.wikipedia.org/wiki/%E5%A4%A9%E3%81%B7%E3%82%89%E3%81%86%E3%81%A9%E3%82%93
   </str>
  </doc>
  <doc>
   <str name="contributor-id">30901</str>
   <str name="contributor-name">”D”</str>
   <str name="id">457105</str>
   <date name="timestamp">2006-02-23T11:08:35Z</date>
   <str name="title">冷凍うどん</str>
   <str name="url">
    http://ja.wikipedia.org/wiki/%E5%86%B7%E5%87%8D%E3%81%86%E3%81%A9%E3%82%93
   </str>
  </doc>
  <doc>...</doc>
  <doc>...</doc>
  <doc>...</doc>
  <doc>...</doc>
  <doc>...</doc>
  <doc>...</doc>
  <doc>...</doc>
 </result>
</response>

通常の検索結果との違いは、上記の①の部分だけです。この唯一の違いの要素は空要素で何の属性も持たないため、何に使うのか分かりません。。Result Groupingを示す目印でしょうか。。この形式の注意点は、ngroupsが取得できなくなる事です。グループ単位の総件数を取得したい場合は、simpleもしくはgroupedを使って下さい。(3.4時点ではまだ取得できませんが、@johtaniさんに教えてもらったこのページによると将来的に取得できるようになるかもしれません)また、groupedもしくはsimpleのフォーマット指定は無視されます。

ファセットの為の機能

ファセット検索でのファセットごとのカウントに使う機能も提供されています。通常はマッチするドキュメント単位の数がファセットカウントに紐付けられますが、この値をグループ単位にする事が出来ます。まずはResult Groupingとファセットをそのまま使ってみましょう。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&facet=true&facet.date=timestamp&facet.date.start=NOW%2FMONTH-2YEARS&facet.date.end=NOW%2FMONTH-1MONTH&facet.date.gap=%2B1MONTH

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">... (検索結果省略) ...</lst>
 <lst name="facet_counts">
  <lst name="facet_queries"/>
  <lst name="facet_fields"/>
  <lst name="facet_dates">
   <lst name="timestamp">
    <int name="2009-10-01T00:00:00Z">11</int>
    <int name="2009-11-01T00:00:00Z">9</int>
    <int name="2009-12-01T00:00:00Z">11</int>
    <int name="2010-01-01T00:00:00Z">13</int>
    <int name="2010-02-01T00:00:00Z">9</int>
    <int name="2010-03-01T00:00:00Z">19</int>
    <int name="2010-04-01T00:00:00Z">7</int>
    <int name="2010-05-01T00:00:00Z">15</int>
    <int name="2010-06-01T00:00:00Z">18</int>
    <int name="2010-07-01T00:00:00Z">31</int>
    <int name="2010-08-01T00:00:00Z">21</int>
    <int name="2010-09-01T00:00:00Z">25</int>
    <int name="2010-10-01T00:00:00Z">29</int>
    <int name="2010-11-01T00:00:00Z">30</int>
    <int name="2010-12-01T00:00:00Z">36</int>
    <int name="2011-01-01T00:00:00Z">64</int>
    <int name="2011-02-01T00:00:00Z">69</int>
    <int name="2011-03-01T00:00:00Z">54</int>
    <int name="2011-04-01T00:00:00Z">80</int>
    <int name="2011-05-01T00:00:00Z">111</int>
    <int name="2011-06-01T00:00:00Z">145</int>
    <int name="2011-07-01T00:00:00Z">236</int>
    <int name="2011-08-01T00:00:00Z">425</int>
    <str name="gap">+1MONTH</str>
    <date name="start">2009-10-01T00:00:00Z</date>
    <date name="end">2011-09-01T00:00:00Z</date>
   </lst>
  </lst>
  <lst name="facet_ranges"/>
 </lst>
</response>

うどんと書かれた記事の、最終更新日の月ごとのドキュメント数が取得できました。ただし、グループごとにまとめて表示する場合は、表示する件数はドキュメント数より少なくなってしまいます。そこで、ファセットカウントをグループ数にしてみましょう。
/solr/select/?q=content%3A%E3%81%86%E3%81%A9%E3%82%93&version=2.2&start=0&rows=10&indent=on&group=true&group.field=contributor-id&group.truncate=true&facet=true&facet.date=timestamp&facet.date.start=NOW%2FMONTH-2YEARS&facet.date.end=NOW%2FMONTH-1MONTH&facet.date.gap=%2B1MONTH

<response>
 <lst name="responseHeader">...</lst>
 <lst name="grouped">... (検索結果省略) ...</lst>
 <lst name="facet_counts">
  <lst name="facet_queries"/>
  <lst name="facet_fields"/>
  <lst name="facet_dates">
   <lst name="timestamp">
    <int name="2009-10-01T00:00:00Z">4</int>
    <int name="2009-11-01T00:00:00Z">7</int>
    <int name="2009-12-01T00:00:00Z">5</int>
    <int name="2010-01-01T00:00:00Z">8</int>
    <int name="2010-02-01T00:00:00Z">8</int>
    <int name="2010-03-01T00:00:00Z">7</int>
    <int name="2010-04-01T00:00:00Z">4</int>
    <int name="2010-05-01T00:00:00Z">9</int>
    <int name="2010-06-01T00:00:00Z">11</int>
    <int name="2010-07-01T00:00:00Z">14</int>
    <int name="2010-08-01T00:00:00Z">11</int>
    <int name="2010-09-01T00:00:00Z">19</int>
    <int name="2010-10-01T00:00:00Z">15</int>
    <int name="2010-11-01T00:00:00Z">17</int>
    <int name="2010-12-01T00:00:00Z">21</int>
    <int name="2011-01-01T00:00:00Z">40</int>
    <int name="2011-02-01T00:00:00Z">38</int>
    <int name="2011-03-01T00:00:00Z">31</int>
    <int name="2011-04-01T00:00:00Z">43</int>
    <int name="2011-05-01T00:00:00Z">54</int>
    <int name="2011-06-01T00:00:00Z">76</int>
    <int name="2011-07-01T00:00:00Z">113</int>
    <int name="2011-08-01T00:00:00Z">200</int>
    <str name="gap">+1MONTH</str>
    <date name="start">2009-10-01T00:00:00Z</date>
    <date name="end">2011-09-01T00:00:00Z</date>
   </lst>
  </lst>
  <lst name="facet_ranges"/>
 </lst>
</response>

group.truncate=trueを指定する事でファセットカウントが減り、グループ単位になりました。ここでは紹介しませんが、StatsComponentの結果にも有効です。

こんな感じでResult Groupingは簡単に使えます。3.5と4.0から部分的に分散検索に対応しますし、パフォーマンスの改善予定もあるようなので、今後使う機会が増えそうですね。