http://loose-bits.com/2011/09/20/pivot-facets-solr.html
Solrのファセット機能は、ある単語や語句、フィールドの値に対する検索結果をさらに細分化して、それらの集計数を返してくれます。 ファセットはそれ自身強力な集計ツールであると同時に、検索結果をもう一段深く絞り込む際のプレビューのように機能します。
Solr4.0より前は、ファセット機能は1階層の検索条件に対してのみ有効でした。つまり、あるクエリのもとに「'foo'フィールドについての集計」のようなことしかできなかったのです。 Solr4.0からピボットファセットという(決定木とも呼ばれる)機能が導入されました。これにより、「'bar'フィールドの値それぞれに対して、'foo'フィールドの値を集計する」といったことができるようになりました。独立した複数のSolrフィールドにまたがった多階層ファセットのことです。
# 訳注
例えば、各都道府県の物件それぞれに対して、物件種別ごとに集計するなど (都道府県フィールド×物件種別フィールド)
東京
賃貸: xxx件
売買: yyy件
神奈川
賃貸: xxx件
売買: yyy件
...
Solr4以前は単に
東京 xxx件
神奈川 yyy件
...
のような集計(単一のフィールド値についての集計)しかできなかった。
Solr4で導入されたピボットファセットは2階層の集計に限らず、複数階層の条件を掛けあわせた集計が可能。
決定木が必要とされるシーンには度々出くわしますし、また現在仕事において複数の軸による集計結果を必要としています。 典型的には、時間軸上の年次それぞれについて、あるフィールド値での集計をする必要があるのです。しかしながら、我々は今Solr 1.4.1を使用しており、さらには4.0に移行する見込みも薄い状況です。取りうる手段は、単純にそのフィールドの上位n件の値を取得し、その値それぞれに対して2階層目のファセットクエリ(年次での集計)を投げることです。つまり、上位20件の結果を得るには、1 + 20回のクエリを投げることになります。これは明らかに最適ではありません。特に、Webアプリケーションの裏側でブロッキングなHTTPリクエストによって実装するような場合には。
1 + n回のクエリによるアプローチよりも良い方法を求めて、あまり知られていないかもしれないけど実はSolr1.4.1にもファセット機能のようなものがないかと調査を始めました。 調査、実験、ハッキングを重ねて行ったところ、"擬似"ピボットファセットとでもいうようなものに辿り着きました。Solr1.4.1を用いて、真のピボットファセットの十分な近似を得ることができます。
この記事では、まず、Solr4.0における本当のピボットファセットについて検証し、その後Solr1.4.1での近似ピボットファセットで用いる要素技術やテクニックについて見ていきます。
ピボットファセットはSOLR-792で実装されました。よい紹介記事がSolr.plサイトにあります。実践的な基本動作を見るにあたって、Solr4.0の配布物に含まれるexampleセットアップを用いましょう。("solr_4.0_path/solr/example"に配置されています。)
Solrプロセスを起動します。
# Start Solr as non-daemon.
$ cd solr_4.0_path/solr/example
$ java -jar start.jar
次に、一連のドキュメントデータをアップロードしたいと思います。同じく配布物に含まれている"exampledocs/books.csv"ファイルを使います。CSVエディタを使って少し修正しますが。CSVフォーマットは右記のようになります: 1行目をフィールド名とし、2行目以降がデータ行です。オリジナルの"exampledocs/books.csv"ファイルからはいくつかのフィールド名を変更したことに注意してください。下記のデータは新規ファイルに書き出してください。私は"sample_books.csv"というファイル名にしました。
ここでは単純化するため_s
フィールドを使っていることにも注意です。通常textフィールドとすべきところをstringフィールドにしています。Solrファセットは"保存された"語句ではなく、"インデックスされた"語句についての結果しか返しません。stringフィールドはどちらにとっても同じです(インデックスされる)。現実の構成では、ファセット検索のためにtext
フィールドをstring
フィールドにコピーするcopyField
ディレクティブを使うことになるでしょう。
# Create CSV file.
$ vim sample_books.csv
id,cat_s,name_s,price_f,inStock_b,author_s,series_s,sequence_i,genre_s
0553573403,book,A Game of Thrones,7.99,true,George R.R. Martin,A Song of Ice and Fire,1,fantasy
0553579908,book,A Clash of Kings,7.99,true,George R.R. Martin,A Song of Ice and Fire,2,fantasy
055357342X,book,A Storm of Swords,7.99,true,George R.R. Martin,A Song of Ice and Fire,3,fantasy
0553293354,book,Foundation,7.99,true,Isaac Asimov,Foundation Novels,1,scifi
0812521390,book,The Black Company,6.99,false,Glen Cook,The Chronicles of The Black Company,1,fantasy
0812550706,book,Enders Game,6.99,true,Orson Scott Card,Ender,1,scifi
0441385532,book,Jhereg,7.95,false,Steven Brust,Vlad Taltos,1,fantasy
0380014300,book,Nine Princes In Amber,6.99,true,Roger Zelazny,the Chronicles of Amber,1,fantasy
0805080481,book,The Book of Three,5.99,true,Lloyd Alexander,The Chronicles of Prydain,1,fantasy
080508049X,book,The Black Cauldron,5.99,true,Lloyd Alexander,The Chronicles of Prydain,2,fantasy
curlコマンドでこのファイルをSolrにアップロードします。
# Upload to Solr.
$ curl http://localhost:8983/solr/update/csv \
--data-binary @sample_books.csv \
-H 'Content-type:text/plain; charset=utf-8'
# Commit the upload.
$ curl http://localhost:8983/solr/update \
--data-binary '<commit/>' \
-H 'Content-type:application/xml'
これで10件のサンプルドキュメントに対して以下のURLで検索することができます。http://localhost:8983/solr/admin/form.jsp
環境が整いましたので、ジャンルごとの価格集計をピボットクエリで試してみましょう。
# Pivot query.
$ curl http://localhost:8983/solr/select --data indent=on\
\&wt=json\
\&q=*%3A*\
\&rows=0\
\&facet=true\
\&facet.mincount=1\
\&facet.sort=index\
\&facet.pivot=price_f,genre_s
(読みやすくするために適当に改行とエスケープ文字を加えています)
このクエリによりfacet_pivot
フィールドに決定木の結果が返ってきます。
{
/* ... */,
"facet_counts":{
/* ... */,
"facet_pivot":{
"price_f,genre_s":[{
"field":"price_f",
"value":5.99,
"count":2,
"pivot":[{
"field":"genre_s",
"value":"fantasy",
"count":2}]},
{
"field":"price_f",
"value":6.99,
"count":3,
"pivot":[{
"field":"genre_s",
"value":"fantasy",
"count":2},
{
"field":"genre_s",
"value":"scifi",
"count":1}]},
{
"field":"price_f",
"value":7.95,
"count":1,
"pivot":[{
"field":"genre_s",
"value":"fantasy",
"count":1}]},
{
"field":"price_f",
"value":7.99,
"count":4,
"pivot":[{
"field":"genre_s",
"value":"fantasy",
"count":3},
{
"field":"genre_s",
"value":"scifi",
"count":1}]}]}}}
直感的に理解できる結果ですね。ファセットクエリのそのままの結果だと思います。
しかしここで、重要な問いに向かいます。Solr1.4.1でこの結果を近似的に得ることは可能でしょうか。facet.pivot
クエリオプションが実装されていないバージョンのSolrで、です。
Solr1.4.1のファセット機能は4.0に比べて非常に限られています。それでも"擬似"ピボットクエリを組み立てる部品として使えるものがいくつかあります。
facet.field
: 通常のファセットフィールドとして指定するオプション (訳注: ファセット検索で集計対象となるフィールド)facet.query
: 通常のファセットクエリとして指定するオプション (訳注: ファセット検索を行うベースとなる検索条件)fq
: フィールドクエリ(フィールド値を絞り込む)- Local Params: Solrの機能であるローカルパラメータをいくつか用います。
tag
:fq
にタグ付けし、任意の名前をつけるkey
: ファセットフィールドにタグ付けし、任意の名前をつける(フィールド名の代わりになる)ex
: ファセットフィールド/ファセットクエリからタグ付けしたfq
条件を除外する(訳注: 指定したfq
を絞込み条件に含めないことができる)
このテクニックではファセットフィールド、もしくはファセットクエリのどちらでも用いることができる点に注目です。この記事ではファセットフィールドの例のみ取り扱いますが、ファセットクエリの場合も同様に適用できます。
ここでは、Solr1.4.1ディストリビューションを用います。上記Solr4.0のときと同じようにセットアップし、10件のドキュメントを含むCSVファイルを起動したサーバーにアップロードします。単純化するために(問題に集中するために)、Solr1.4.1サーバーは8984番ポートで起動することにしました。8983番ポートのSolr4.0サーバーと同時に起動しておくためです。 下記のコマンドを実行しました。
# Start Solr as non-daemon.
$ cd solr_1.4.1_path/solr/example
$ java -Djetty.port=8984 -jar start.jar
# (Copy sample_books.csv)
# Upload to Solr.
$ curl http://localhost:8984/solr/update/csv \
--data-binary @sample_books.csv \
-H 'Content-type:text/plain; charset=utf-8'
# Commit the upload.
$ curl http://localhost:8984/solr/update \
--data-binary '<commit/>' \
-H 'Content-type:application/xml'
これで、サンプルデータをアップロード済みのSolr1.4.1サーバーが8984番ポートで起動している状態となります。(アドレス、ポートは適宜読み替えてください。)
ピボットファセットを実現するにあたって、まずはじめに、特定の絞込み条件をファセットクエリから除外してみようと思います。Solr Wikiにて、絞込み条件に対してタグ付けし、条件から除外する方法について基本事例が掲載されています。
まずはgenre_s:scifi
という絞込み条件をつけた上で価格を集計するという単純なファセットクエリを試してみましょう。
# Restricted facet query.
$ curl http://localhost:8984/solr/select --data \
\indent=on\
\&wt=json\
\&q=*%3A*\
\&rows=0\
\&fq=genre_s:scifi\
\&facet=true\
\&facet.mincount=1\
\&facet.sort=index\
\&facet.field=price_f
このクエリ結果のfacet_fields
を見てみましょう。ここで分かるのは、2件のヒット(numFound
)しかないということ、集計カウントの合計も2件ということです(2件のSF本に対して価格ごとに集計したことになります)。
{
/* ... */,
"response":{"numFound":2, /* ... */},
"facet_counts":{
/* ... */,
"facet_fields":{
"price_f":[
"6.99",1,
"7.99",1]},
/* ... */,
}
ドリルダウン検索のような状況で、Solr開発者はしばしば以下のような要望を持つことがあります。通常の検索には全ての指定した条件を用いて絞込みを行い、ファセット検索では一部の条件を緩めた情報が欲しいという要望です。
これを実現するために、Solrではfq
にたいしてタグ付けし、特定のファセットフィールドに限ってそのfq
を除外した条件で絞込みをかけることができます。
それでは、実際にfq
にSCIFI_FQという名前でタグ付けし、ex
オプションを用いてファセットの集計条件から除外してみましょう。さらに、key
オプションを指定してそのファセットの集計結果には"PRICE_KEY"という名前をつけます。
# Tag fq and exclude only from the field facet.
$ curl http://localhost:8984/solr/select --data \
\indent=on\
\&wt=json\
\&q=*%3A*\
\&rows=0\
\&fq={\!tag=SCIFI_FQ}genre_s:scifi\
\&facet=true\
\&facet.mincount=1\
\&facet.sort=index\
\&facet.field={\!key=PRICE_KEY\ ex=SCIFI_FQ}price_f
コマンドラインで実行するにあたって、感嘆符や改行・スペース文字はエスケープしています。 それではこちらの結果を見てみましょう。
{
/* ... */,
"response":{"numFound":2, /* ... */},
"facet_counts":{
/* ... */,
"facet_fields":{
"PRICE_KEY":[
"5.99",2,
"6.99",3,
"7.95",1,
"7.99",4]},
/* ... */,
}
まずここで分かることは、"SCIFI_FQ"としてタグ付けした条件を除外するよう指定しましたが、ファセットクエリ全体のnumFound
には影響しないことです。numFound
は依然2のままです。(訳注: SF本に絞りこまれている)
しかし、この条件を除外するよう指定したファセットフィールドに限っては、実際に(SFという条件を外したことにより)全ドキュメントに対して集計されています。そして、このファセットフィールドの集計にはフィールド名("price_f")の代わりに"PRICE_KEY"という名前がついています。
上記のタグ付け/結果フィールドのキー名指定/条件除外の基本テクニックを頭に入れた上で、当初の目的に立ち返りましょう。Solr1.4.1を使ってジャンルごとの価格集計というピボットクエリを組み立てることです。これを2回のクエリで行います。
- 価格に対するファセットクエリを行い、上位の価格を抽出する
- それぞれの価格の値を1つずつ条件から除外するため、タグ付けした
fq
を作成する。その上で、キー名を指定したファセットフィールドにより、ジャンルについての集計を複数行い、これを決定木の"葉"とする
1番目のクエリは非常に基本的なファセットフィールドクエリとなります。
# First level facet field query.
$ curl http://localhost:8984/solr/select --data indent=on\
\&wt=json\
\&q=*%3A*\
\&rows=0\
\&facet=true\
\&facet.mincount=1\
\&facet.sort=index\
\&facet.field=price_f
このクエリにより、4つの値を抽出できます: "5.99", "6.99", "7.95", "7.99"
{
/* ... */,
"response":{"numFound":10, /* ... */},
"facet_counts":{
/* ... */,
"facet_fields":{
"price_f":[
"5.99",2,
"6.99",3,
"7.95",1,
"7.99",4]},
/* ... */,
}
この4種類の値それぞれに対してタグ付けしたfq
条件を作成します。
fq={!tag=FQ5.99}price_f:5.99
fq={!tag=FQ6.99}price_f:6.99
fq={!tag=FQ7.95}price_f:7.95
fq={!tag=FQ7.99}price_f:7.99
このfq
それぞれを除外条件として指定することで、次の階層(ジャンル)のファセット結果を得られます。
4種類の値それぞれについてのピボットファセットの結果を得るには、その価格 以外の 条件を絞込み条件から外してしまえばよいということです。ファセット検索のパラメータに当てはめてみるとこうなります。
facet.field={!key=5.99_GENRE ex=FQ6.99,FQ7.95,FQ7.99}genre_s
facet.field={!key=6.99_GENRE ex=FQ5.99,FQ7.95,FQ7.99}genre_s
facet.field={!key=7.95_GENRE ex=FQ5.99,FQ6.99,FQ7.99}genre_s
facet.field={!key=7.99_GENRE ex=FQ5.99,FQ6.99,FQ7.95}genre_s
要点は、カンマ区切りで複数の除外条件を指定できることです。"5.99_GENRE"というキー名のファセットを見てください。"FQ5.99"以外のfq
を絞込み条件から除外しています。これはつまり、そのファセットフィールドの集計結果は、fq=price_f:5.99
という条件でのみ絞り込んだときの集計となります。少々複雑な二重否定の論理を応用した形ですが、実際にこれでうまくいきます。
実際に2番目のクエリを構築してみましょう。
# Second level pivot query.
$ curl http://localhost:8984/solr/select --data \
indent=on\
\&wt=json\
\&q=*%3A*\
\&rows=0\
\&facet=true\
\&facet.mincount=1\
\&facet.sort=index\
\&fq={\!tag=FQ5.99}price_f:5.99\
\&fq={\!tag=FQ6.99}price_f:6.99\
\&fq={\!tag=FQ7.95}price_f:7.95\
\&fq={\!tag=FQ7.99}price_f:7.99\
\&facet.field={\!key=5.99_GENRE\ ex=FQ6.99\,FQ7.95\,FQ7.99}genre_s\
\&facet.field={\!key=6.99_GENRE\ ex=FQ5.99\,FQ7.95\,FQ7.99}genre_s\
\&facet.field={\!key=7.95_GENRE\ ex=FQ5.99\,FQ6.99\,FQ7.99}genre_s\
\&facet.field={\!key=7.99_GENRE\ ex=FQ5.99\,FQ6.99\,FQ7.95}genre_s
この結果、下記のキー名で決定木の"葉"を得られます: "5.99_GENRE", "6.99_GENRE", "7.95_GENRE", "7.99_GENRE"
{
/* ... */,
"response":{"numFound":10, /* ... */},
"facet_counts":{
/* ... */,
"facet_fields":{
"5.99_GENRE":[
"fantasy",2],
"6.99_GENRE":[
"fantasy",2,
"scifi",1],
"7.95_GENRE":[
"fantasy",1],
"7.99_GENRE":[
"fantasy",3,
"scifi",1]},
/* ... */,
}
Solr4.0でのピボットクエリの結果と比べてみてください。Solr1.4.1のクエリを2つ組み合わせて、同じ結果を得ることができました。どちらも下記のように価格ごとのジャンル集計という決定木を表現できます。
大勝利!
"価格ごとのジャンル"の例はいささか単純なので、Solr1.4.1の通常のファセットクエリを複数回実行することでもまったく同じ結果を得ることができます。しかし、この擬似ピボットファセットテクニックが本当に力を発揮するのは"fooごとのbar"タイプのクエリで、最初の"foo"フィールドの値の種類が巨大な場合です。例えば、第一階層の結果が10種類あったとします。この場合11回のクエリを必要とします。(1回は"foo"フィールドの上位10件の値を取得するため、そして残りは、10種類の値それぞれにたいして"bar"フィールドの値を集計するため。) 擬似ピボットファセットテクニックではこれが2回のクエリに抑えられます。
この手法は広く一般的にも応用可能です。この記事で見た例ではファセットフィールドのみ使用していますが、このテクニックはファセットクエリにも同じように機能します。また、分散検索構成においてもこの手法はサポートされています。
さらに、このテクニックは2階層以上の決定木にも応用できます。Solr4.0の世界では単にフィールド指定を付加するだけででき、facet.pivot=price_f,genre_s,inStock_b
と指定するだけで、決定木はさらに"在庫あり/なし"で細分化されます。Solr1.4.1では、3階層目のクエリを行う必要があり、ファセットフィールドキーの数は先程のタグ付けしたfq
と第2階層のfq
の組合せの数ほど必要になります。つまり、この例では3階層目のファセットフィールドキーとして、"6.99_fantasy_INSTOCK", "6.99_scifi_INSTOCK", etc. のようになります。この時点で、美しいとは言えず、醜いクエリになってしまいますが、それでも、このテクニックでは階層が深くなるにつれてクエリが1回ずつ増えていくだけで済むことを示しています。
ここで、クエリの複雑さについての話題に触れておきます。このような種類のクエリハックはプログラマティックに構築し、正確さを担保するべきであると指摘しておきます。curlを用いて手動のクエリを例示しましたがそれは避けるべきでしょう。上記のように1階層目の値が4種類ほどの例であっても内容を追うのが大変であり、これよりも値の種類が増えたり、階層が深くなるようなピボットには手を出さないようにしましょう。プログママティックにクエリを構築することのもう一つの利点は、タグ名やキー名を数値などの単純なキーで命名できることです。そして、最終的な決定木の結果をプログラムで再現できます。
最後に、パフォーマンスについての注意書きです。この擬似ピボットファセット手法はSolrサーバーの負荷を軽減するわけではありません。本来複数のクエリが必要となるところを1回のクエリに収めているというだけのことです。
上記の手法により、ピボットファセットはSolr1.4.1においても、n階層の決定木を構築するためにn回のクエリコストで実現できることが分かりました。WebアプリケーションとSolr間のラウンドトリップの回数を減らすことがゴールであるならば、また、4.0以前のSolrでピボットファセットが必要ならば、この手法こそがそのゴールへの切符となります。