この記事は一休.comアドベントカレンダー2017の6日目です。
一休.comレストラン 検索・集客担当のにがうりです。
一休.com、一休.comレストランともに、検索には主にSolrを利用しています。 (一部、RDBで検索しているところもあります)
RDB(SQL)ベースでの検索と比べると色々とメリットがありますが、その中でもファセットナビゲーションに必要な機能が揃っているのは大きな魅力と言えるでしょう。
ファセット例
Solr5.xからは、旧来のファセットとは異なるJSON Facetという機能が新たに提供されており、特に問題(後述の注意点を参照)が無いのであれば、こちらのほうが利用しやすいでしょう。
しかし、JSON FacetはSolrのサイト上では言及がなく、開発者のサイトがドキュメントになっている状況のためか、いまいちマイナーな存在に留まっているように感じます。
このエントリでは JSON Facetについて、旧来のファセットとの比較を混ぜながら、基本的な使い方、応用例、注意点について紹介します。
なお、本エントリで利用しているバージョンはエントリ作成時点の最新版、7.1.0を前提としています。
基本的な使い方
レストランを登録した ikyu-advent-2017-restaurant
コアに対し、以下のようなデータが入っているとします
レストランID (restaurant_id) | レストラン名 (restaurant_name) | ジャンル1 (genres) | サブジャンル1 (sub_genres) | ジャンル2 (genres) | サブジャンル2 (sub_genres) | 都道府県 (prefecture) | 市区町村 (city) |
---|---|---|---|---|---|---|---|
11 | AAAA | 洋食 | 洋食-フレンチ | 東京都 | 銀座 | ||
12 | BBBB | 和食 | 和食-京料理 | 和食 | 和食-懐石料理 | 東京都 | 赤坂 |
13 | CCCC | 洋食 | 洋食-ステーキ・グリル料理 | 洋食 | 洋食-イタリア料理 | 東京都 | 銀座 |
14 | DDDD | その他 | その他-ラウンジ | その他 | その他-ブッフェ | 東京都 | 品川 |
15 | EEEE | 和食 | 和食-寿司 | 東京都 | 銀座 | ||
16 | FFFF | 和食 | 和食-寿司 | 和食 | 和食-天ぷら | 東京都 | 銀座 |
17 | GGGG | その他 | その他-ラウンジ | 和食 | 和食-寿司 | 神奈川県 | 横浜 |
※ 以下3点に留意
- 都道府県 - 市区町村は親子関係であること
- ジャンル - サブジャンルも親子関係であること
- さらに、ジャンル-サブジャンルはそれぞれ2つ登録可能であること (MultiValueにしている)
試しに、このデータが入った状態のクエリを実行してみましょう
http://localhost:8983/solr/ikyu-advent-2017-restaurant/select?echoParams=none&q=*:*&fl=restaurant_id,restaurant_name,genres,sub_genres,prefecture,city&rows=2
結果
{ "responseHeader": { "status": 0, "QTime": 0 }, "response": { "numFound": 7, "start": 0, "docs": [{ "restaurant_id": "11", "restaurant_name": "AAAA", "genres": ["洋食"], "sub_genres": ["洋食-フレンチ"], "prefecture": "東京都", "city": "銀座" }, { "restaurant_id": "12", "restaurant_name": "BBBB", "genres": ["和食", "和食"], "sub_genres": ["和食-京料理", "和食-懐石料理"], "prefecture": "東京都", "city": "赤坂" }] } }
データが取得できました。ジャンル、サブジャンルは配列で返却されています。
従来のファセットを実行
このデータに対して、従来の方法でファセットを取得してみましょう。
取得対象はジャンル、サブジャンル、都道府県、市区町村の4つです。 (冗長になるためレストラン一覧の取得は抑制)
クエリ
http://localhost:8983/solr/ikyu-advent-2017-restaurant/select?echoParams=none&q=*:*&rows=0&facet=true&facet.field=prefecture&facet.field=city&facet.field=genres&facet.field=sub_genres
結果
{ "responseHeader": { "status": 0, "QTime": 0 }, "response": { "numFound": 7, "start": 0, "docs": [] }, "facet_counts": { "facet_queries": {}, "facet_fields": { "prefecture": [ "東京都", 6, "神奈川県", 1 ], "city": [ "銀座", 4, "品川", 1, "横浜", 1, "赤坂", 1 ], "genres": [ "和食", 4, "その他", 2, "洋食", 2 ], "sub_genres": [ "和食-寿司", 3, "その他-ラウンジ", 2, "その他-ブッフェ", 1, "和食-京料理", 1, "和食-天ぷら", 1, "和食-懐石料理", 1, "洋食-イタリア料理", 1, "洋食-ステーキ・グリル料理", 1, "洋食-フレンチ", 1 ] }, "facet_ranges": {}, "facet_intervals": {}, "facet_heatmaps": {} } }
取得できているのは良いのですが、大きく2つの問題があります。
- "prefecture":["東京都", 6,"神奈川県", 1] のように、1つの配列に対して key1, value1, key2, value2 ... という入り方をしているため、処理がしにくい
- 本来親子関係であるべき、都道府県と市区町村の親子関係が判断できない
このうち2についてはジャンル-サブジャンルのように子階層に親階層の情報を付与してあげることで回避可能ですが、1については我慢するしかありません。
しかし、JSON Facetならこの両方が解決できます。
JSON Facetを実行
クエリ
http://localhost:8983/solr/ikyu-advent-2017-restaurant/select?echoParams=none&q=*:*&rows=0&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{city:{type:terms,field:city,limit: -1}}}}&json.facet={genres:{type:terms,field:genres,limit:-1,facet:{sub_genres:{type:terms,field:sub_genres}}}}
※ 都道府県/市区町村のファセット指定を見やすく加工すると以下の通り
json.facet={ prefecture: { /* レスポンス時の項目名(任意) */ type: terms, /* ファセットの単位を値に */ field: prefecture, /* ファセットの対象となる項目(都道府県) */ limit: -1, /* 全件取得 */ facet: { city: { /* ここから子階層 */ type: terms, field: city, /* ファセットの対象となる項目(市区町村) */ limit: -1 } } } }
結果
{ "responseHeader":{ "status":0, "QTime":9}, "response":{"numFound":7,"start":0,"docs":[] }, "facets":{ "count":7, "prefecture":{ "buckets":[{ "val":"東京都", "count":6, "city":{ "buckets":[ {"val":"銀座", "count":4}, {"val":"品川", "count":1}, {"val":"赤坂", "count":1}]}}, { "val":"神奈川県", "count":1, "city":{ "buckets":[{ "val":"横浜", "count":1}]}}]}, "genres":{ "buckets":[{ "val":"和食", "count":4, "sub_genres":{ "buckets":[ {"val":"和食-寿司", "count":3}, {"val":"その他-ラウンジ", "count":1}, {"val":"和食-京料理", "count":1}, {"val":"和食-天ぷら", "count":1}, {"val":"和食-懐石料理", "count":1}]}} /**** 〜 以下略 〜 ****/ ]}}}
ご覧の通り、
- {"val":key, "count":value}の組み合わせで表現されているため処理がしやすい
- 都道府県と市区町村の親子関係が表現できている
と、見事に前述の問題が解決できています。
ただし、ジャンル - サブジャンルの親子関係については、「和食」の下に「その他」が混在するという、期待とは裏腹な状態になっています。
残念ながら、こちらは親子関係の親がMultiValueになっている限り回避はできません。
従来のファセット同様個別にファセットの指定を行い、アプリケーション側で親子関係を処理する他無さそうです。
応用例
ところで、一休.comレストランはレストランの「プラン」を予約するサイトです。
つまり、予約検索で表示される一覧は「レストラン」単位ですが、実際に検索しているデータはプラン単位です。
そのため、データもレストランではなくプランが軸になります。 (実際には更に日付、時間、人数、席の有無といった軸も考慮する必要がありますが、複雑になるためここでは割愛します)
ikyu-advent-2017-plan
コアのデータ
id | レストランID (restaurant_id) | レストラン名 (restaurant_name) | ジャンル1 (genre) | サブジャンル1 (sub_genre) | ジャンル2 (genre) | サブジャンル2 (sub_genre) | 都道府県 (prefecture) | 市区町村 (city) | プランID (plan_id) | プラン名 (plan_name) | 時間帯 (time) | 価格 (price) | 個室 (private_room) | 夜景確定 (nightview) | 飲み放題 (free_flow) |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
11-1101 | 11 | AAAA | 洋食 | 洋食-フレンチ | 東京都 | 銀座 | 1101 | クリスマスディナー | ディナー | 8000 | 1 | 1 | 0 | ||
11-1102 | 11 | AAAA | 洋食 | 洋食-フレンチ | 東京都 | 銀座 | 1102 | クリスマスランチ | ランチ | 4000 | 1 | 0 | 0 | ||
11-1103 | 11 | AAAA | 洋食 | 洋食-フレンチ | 東京都 | 銀座 | 1103 | アフタヌーンティー | ランチ | 2500 | 0 | 0 | 0 | ||
11-1104 | 11 | AAAA | 洋食 | 洋食-フレンチ | 東京都 | 銀座 | 1104 | 平日限定スパークリング飲み放題! | ディナー | 4000 | 0 | 0 | 1 | ||
12-1201 | 12 | BBBB | 和食 | 和食-京料理 | 和食 | 和食-懐石料理 | 東京都 | 赤坂 | 1201 | おばんざいのセット | ランチ | 3000 | 0 | 0 | 0 |
12-1202 | 12 | BBBB | 和食 | 和食-京料理 | 和食 | 和食-懐石料理 | 東京都 | 赤坂 | 1202 | おまかせコース | ディナー | 7000 | 1 | 0 | 0 |
12-1203 | 12 | BBBB | 和食 | 和食-京料理 | 和食 | 和食-懐石料理 | 東京都 | 赤坂 | 1203 | おまかせコース飲み放題付 | ディナー | 9000 | 1 | 0 | 1 |
13-1301 | 13 | CCCC | 洋食 | 洋食-ステーキ・グリル料理 | 洋食 | 洋食-イタリア料理 | 東京都 | 銀座 | 1301 | 【ワンドリンク付】プリフィクスランチ | ランチ | 3000 | 0 | 0 | 0 |
13-1302 | 13 | CCCC | 洋食 | 洋食-ステーキ・グリル料理 | 洋食 | 洋食-イタリア料理 | 東京都 | 銀座 | 1302 | 極上の短角牛ステーキ300グラム! | ランチ | 4000 | 0 | 0 | 0 |
13-1303 | 13 | CCCC | 洋食 | 洋食-ステーキ・グリル料理 | 洋食 | 洋食-イタリア料理 | 東京都 | 銀座 | 1303 | 【飲み放題付き】選べるパスタ・ステーキを含む6種のディナー | ディナー | 8000 | 1 | 0 | 0 |
13-1304 | 13 | CCCC | 洋食 | 洋食-ステーキ・グリル料理 | 洋食 | 洋食-イタリア料理 | 東京都 | 銀座 | 1304 | 【Xmas】乾杯シャンパン付!上州牛の極上ステーキとデザートのセット | ディナー | 3000 | 0 | 0 | 0 |
14-1401 | 14 | DDDD | その他 | その他-ラウンジ | その他 | その他-ブッフェ | 東京都 | 品川 | 1401 | ブッフェランチ | ランチ | 2000 | 0 | 0 | 0 |
14-1402 | 14 | DDDD | その他 | その他-ラウンジ | その他 | その他-ブッフェ | 東京都 | 品川 | 1402 | 【忘年会におすすめ!!】50種類から好きに選べるディナーブッフェ! | ディナー | 5000 | 0 | 1 | 1 |
15-1501 | 15 | EEEE | 和食 | 和食-寿司 | 東京都 | 銀座 | 1501 | 握り10貫 | ディナー | 7000 | 1 | 0 | 0 | ||
15-1502 | 15 | EEEE | 和食 | 和食-寿司 | 東京都 | 銀座 | 1502 | 握り8貫。お造り、焼き物付き | ディナー | 8500 | 1 | 0 | 0 | ||
16-1601 | 16 | FFFF | 和食 | 和食-寿司 | 和食 | 和食-天ぷら | 東京都 | 銀座 | 1601 | 握りと天ぷらのコース | ディナー | 5000 | 0 | 0 | 1 |
16-1602 | 16 | FFFF | 和食 | 和食-寿司 | 和食 | 和食-天ぷら | 東京都 | 銀座 | 1602 | 握りのコース | ディナー | 4500 | 0 | 0 | 1 |
17-1701 | 17 | GGGG | その他 | その他-ラウンジ | 和食 | 和食-寿司 | 神奈川県 | 横浜 | 1701 | 【夜景確定】クリスマスディナー | ディナー | 9000 | 0 | 1 | 0 |
17-1702 | 17 | GGGG | その他 | その他-ラウンジ | 和食 | 和食-寿司 | 神奈川県 | 横浜 | 1702 | クリスマスディナー | ディナー | 7000 | 1 | 0 | 0 |
17-1703 | 17 | GGGG | その他 | その他-ラウンジ | 和食 | 和食-寿司 | 神奈川県 | 横浜 | 1703 | 平日限定ディナー | ディナー | 5000 | 0 | 0 | 1 |
このデータに対して、都道府県、市区町村のJSON Facetを実行してみましょう
JSON Facetを実行
クエリ
http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{city:{type:terms,field:city}}}}
結果
{ "responseHeader":{ "status":0, "QTime":0}, "response":{"numFound":20,"start":0,"docs":[] }, "facets":{ "count":20, "prefecture":{ "buckets":[{ "val":"東京都", "count":17, "city":{ "buckets":[{ "val":"銀座", "count":12}, { "val":"赤坂", "count":3}, { "val":"品川", "count":2}]}}, { "val":"神奈川県", "count":3, "city":{ "buckets":[{ "val":"横浜", "count":3}]}}]}}}
これはいけません。1行の単位がプランになった関係で、ファセットの数も「プランの数」になってしまいました。
Result Groupingを使いデータをレストラン単位で表現するようにしましょう
&group=true&group.field=restaurant_id&group.ngroups=true&group.truncate=true
※ Result Groupingについては本稿の主旨とは異なるため説明は割愛します。 エメラルドアオキロックさんのエントリ がオススメ
Result Grouping + JSON Facetを実行
クエリ
http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{city:{type:terms,field:city}}}}&group=true&group.field=restaurant_id&group.truncate=true&group.ngroups=true
group.truncate=trueでファセットもグルーピングの単位で返却、group.ngroups=true でグループ単位の検索件数も返却になります。
結果
{ "responseHeader":{ "status":0, "QTime":1}, "grouped":{ "restaurant_id":{ "matches":20, "ngroups":7, "groups":[]}}, "facets":{ "count":7, "prefecture":{ "buckets":[{ "val":"東京都", "count":6, "city":{ "buckets":[{ "val":"銀座", "count":4}, { "val":"品川", "count":1}, { "val":"赤坂", "count":1}]}}, { "val":"神奈川県", "count":1, "city":{ "buckets":[{ "val":"横浜", "count":1}]}}]}}}
無事、ファセットの件数がレストラン単位になりました。
プランの情報をJSON Facetで取得
グルーピングはそのままに、プランの情報である夜景確定もファセットで取得してみます
クエリ
http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&json.facet={nightview:{type:terms,field:nightview,limit:-1}}}&group=true&group.field=restaurant_id&group.truncate=true&group.ngroups=true
結果
{ "responseHeader":{ "status":0, "QTime":5}, "grouped":{ "restaurant_id":{ "matches":20, "ngroups":7, "groups":[]}}, "facets":{ "count":7, "private_room":{ "buckets":[{ "val":false, "count":5}, { "val":true, "count":2}]}}}
夜景確定はプラン毎に異なる情報であるにも関わらず、レストランの数が返ってしまいました。このようなケースでは &group.truncate=true
では無理があるようです。
レストラン単位のResult Groupingにプランのファセットも思惑どおり追加する方法
クエリ
http://localhost:8983/solr/ikyu-advent-2017-plan/select?echoParams=none&q=*:*&rows=0&group=true&group.field=restaurant_id&json.facet={prefecture:{type:terms,field:prefecture,limit:-1,facet:{restaurant_count:"unique(restaurant_id)"},city:{type:terms,field:city,facet:{restaurant_count:"unique(restaurant_id)"}}}}}&json.facet={nightview:{type:terms,field:nightview,limit:-1,facet:{restaurant_count:"unique(restaurant_id)"}}}}&group.ngroups=true
&group.truncate=true
を外し、restaurant_count: "unique(restaurant_id)"
を追加しています。restaurant_id でユニークを取った数がrestaurant_countとして返却される、という理屈です。
結果
{ "responseHeader":{ "status":0, "QTime":3}, "grouped":{ "restaurant_id":{ "matches":20, "ngroups":7, "groups":[]}}, "facets":{ "count":20, "prefecture":{ "buckets":[{ "val":"東京都", "count":17, "restaurant_count":6}, { "val":"神奈川県", "count":3, "restaurant_count":1}]}, "nightview":{ "buckets":[{ "val":false, "count":17, "restaurant_count":7}, { "val":true, "count":3, "restaurant_count":3}]}}}
これで、都道府県 / 市区町村はレストランの数、夜景確定はtrue / falseそれぞれに「該当するプランを持っているレストランの数」が返却されました。
注意点
値の信頼性について
場合によっては大きな問題を招く可能性があります。
SolrのJSON Facetは必ずしも正確なカウント数を返さない
ただし、Shardingをしていないかぎりは問題ないはずです。
機能の安定性について
公式に言及が無い機能のため安定性が気になるところでしたが、幸い、導入してから1年ほど安定稼働しています
おわりに
以上、いまいちマイナー?なJSON Facetについての紹介でした。
最後に宣伝です。
クリスマスのお店を決め兼ねている方、唐突に忘年会の幹事に指名されてしまった方、是非、一休.comレストランで予約してください。まだ間に合います!
明日は ohke さんによる GoとSQL Server です。