一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

Solr JSON Facetのススメ

この記事は一休.comアドベントカレンダー2017の6日目です。

一休.comレストラン 検索・集客担当のにがうりです。

一休.com、一休.comレストランともに、検索には主にSolrを利用しています。 (一部、RDBで検索しているところもあります)
RDB(SQL)ベースでの検索と比べると色々とメリットがありますが、その中でもファセットナビゲーションに必要な機能が揃っているのは大きな魅力と言えるでしょう。

ファセット例

f:id:ikyu_com:20171204014502p:plain:w300 f:id:ikyu_com:20171204014901p:plain:w250

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)
11AAAA洋食洋食-フレンチ東京都銀座
12BBBB和食和食-京料理和食和食-懐石料理東京都赤坂
13CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座
14DDDDその他その他-ラウンジその他その他-ブッフェ東京都品川
15EEEE和食和食-寿司東京都銀座
16FFFF和食和食-寿司和食和食-天ぷら東京都銀座
17GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜

※ 以下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つの問題があります。

  1. "prefecture":["東京都", 6,"神奈川県", 1] のように、1つの配列に対して key1, value1, key2, value2 ... という入り方をしているため、処理がしにくい
  2. 本来親子関係であるべき、都道府県と市区町村の親子関係が判断できない

このうち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}]}}
              /**** 〜 以下略 〜 ****/
              ]}}}

ご覧の通り、

  1. {"val":key, "count":value}の組み合わせで表現されているため処理がしやすい
  2. 都道府県と市区町村の親子関係が表現できている

と、見事に前述の問題が解決できています。

ただし、ジャンル - サブジャンルの親子関係については、「和食」の下に「その他」が混在するという、期待とは裏腹な状態になっています。
残念ながら、こちらは親子関係の親が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-110111AAAA洋食洋食-フレンチ東京都銀座1101クリスマスディナーディナー8000110
11-110211AAAA洋食洋食-フレンチ東京都銀座1102クリスマスランチランチ4000100
11-110311AAAA洋食洋食-フレンチ東京都銀座1103アフタヌーンティーランチ2500000
11-110411AAAA洋食洋食-フレンチ東京都銀座1104平日限定スパークリング飲み放題!ディナー4000001
12-120112BBBB和食和食-京料理和食和食-懐石料理東京都赤坂1201おばんざいのセットランチ3000000
12-120212BBBB和食和食-京料理和食和食-懐石料理東京都赤坂1202おまかせコースディナー7000100
12-120312BBBB和食和食-京料理和食和食-懐石料理東京都赤坂1203おまかせコース飲み放題付ディナー9000101
13-130113CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1301【ワンドリンク付】プリフィクスランチランチ3000000
13-130213CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1302極上の短角牛ステーキ300グラム!ランチ4000000
13-130313CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1303【飲み放題付き】選べるパスタ・ステーキを含む6種のディナーディナー8000100
13-130413CCCC洋食洋食-ステーキ・グリル料理洋食洋食-イタリア料理東京都銀座1304【Xmas】乾杯シャンパン付!上州牛の極上ステーキとデザートのセットディナー3000000
14-140114DDDDその他その他-ラウンジその他その他-ブッフェ東京都品川1401ブッフェランチランチ2000000
14-140214DDDDその他その他-ラウンジその他その他-ブッフェ東京都品川1402【忘年会におすすめ!!】50種類から好きに選べるディナーブッフェ!ディナー5000011
15-150115EEEE和食和食-寿司東京都銀座1501握り10貫ディナー7000100
15-150215EEEE和食和食-寿司東京都銀座1502握り8貫。お造り、焼き物付きディナー8500100
16-160116FFFF和食和食-寿司和食和食-天ぷら東京都銀座1601握りと天ぷらのコースディナー5000001
16-160216FFFF和食和食-寿司和食和食-天ぷら東京都銀座1602握りのコースディナー4500001
17-170117GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜1701【夜景確定】クリスマスディナーディナー9000010
17-170217GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜1702クリスマスディナーディナー7000100
17-170317GGGGその他その他-ラウンジ和食和食-寿司神奈川県横浜1703平日限定ディナーディナー5000001

このデータに対して、都道府県、市区町村の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 です。