この記事は 一休.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レストランで予約してください。まだ間に合います! restaurant.ikyu.com restaurant.ikyu.com 明日は ohke さんによる GoとSQL Server です。