読者です 読者をやめる 読者になる 読者になる

Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

RailsでElasticsearch: アグリゲーション(ファセット)と Post Filter

elasticsearch 検索

f:id:nipe880324:20151017170217p:plain:w420

RailsでElasticsearchを使って検索機能を実装してきました。
今回は、「アグリゲーション(ファセット)と Post Filter」について説明します。

「アグリゲーション」は、SQLでいうGROUP BYのようなもので、最小値、最大値、平均値などを求めたり、カテゴリ毎の数を数えたりなどドキュメントを集約させるものです。Elasticsearchでは、従来あった集約機能の「ファセット」では複雑な集約を処理できなかったので、新しくアグリゲーションを作ったぽいです。

具体的には、下記画像の左側のサイドバーで検索結果を絞り込めるようにします。
f:id:nipe880324:20151021235800p:plain:w420



動作確認

  • Mac OS X 10.11 El Capitan
  • elasticsearch 1.7.2
  • Rails 4.2.3
  • elasticsearch-dsl 0.1.2
  • elasticsearch-model 0.1.8
  • elasticsearch-rails 0.1.8

アグリゲーションのためのElasticsearch

アグリゲーションの形式

アグリゲーションの形式は次の様に記載します。

"aggregations": {
  "<アグリゲーション名>": {
    "<アグリゲーションタイプ>": {
      <アグリゲーションタイプに応じた記載>
    }
  }
}

アグリゲーション名は、caetgorymin_priceなど自分で自由にアグリゲーションの名前を決めます。
アグリゲーションタイプは、Aggregations - Elasticsearchのサイドバーに記載されている、Min Aggregation、Max Aggregation、Terms Aggregation、Filter Aggregationなどどのようにアグリゲーションするかによってアグリゲーションタイプを分けます。


カテゴリで集約

カテゴリのアグリゲーションを作成してみます。
Marvel Senseを開き、下記クエリを実行してください。
アグリゲーション名はcategory、アグリゲーションタイプはTerms Aggregationを利用しています。
Terms Aggregationでは、fieldでカテゴリ名(category.name)でグルーピングし、sizeで最大10件表示すると指定しています。
結果として、aggregations.category.bucketにグルーピングした結果が入ります。

GET restaurant_development/_search
{
  "query": { "match_all": {} },
  "size": 0, // hitsを表示したくないのであえてsizeは0に指定
  "aggs": {
    "category": {
      "terms": {
        "field": "category.name",
        "size": 10
      }
    }
  }
}

// 結果
{
   "took": 1,
   "timed_out": false,
   "_shards": {
    ...
   },
   "hits": {
      "total": 600,
      "max_score": 0,
      "hits": []
   },
   "aggregations": {
      "category": {
         "doc_count_error_upper_bound": 0,
         "sum_other_doc_count": 0,
         "buckets": [
            {
               "key": "イタリアン",
               "doc_count": 200
            },
            {
               "key": "定食",
               "doc_count": 200
            },
            {
               "key": "居酒屋",
               "doc_count": 200
            }
         ]
      }
   }
}


検索結果のみフィルタする

一番最初にfilterdによって、検索結果やアグリゲーションの結果を絞り込むことができることを説明しましたが、検索結果は絞りたいが、アグリゲーションはナビゲーション的な役割を示しているので絞りたくないという場合が往々にあります。
そういう場合に、post_filterキーを利用して、検索結果のみを絞りことができます。

例えば、以下のようにfilterd内にカテゴリの検索条件を指定してしまうと、検索結果とともに、アグリゲーションも絞りこまれてしまっています。

GET restaurant_development/_search
{
  "query": {
    "filtered": {
      "query": { "match_all": {} },
      "filter": {
        "bool": { // bool filterは複数のフィルタを使用できるようにする
          "must": [ // mustは各フィルタがAND条件、shouldは各フィルタがOR条件
            { "term": { "closed": "false" } },
            { "term": { "category.name": "定食" } }
          ]
        }
      }
    }
  },
  "size": 0,
  "aggregations": {
    "category": {
      "terms": { "field": "category.name", "size": 10 }
    }
  }
}

// 結果
{
   "took": 5,
   "timed_out": false,
   "_shards": {
      "total": 5,
      "successful": 5,
      "failed": 0
   },
   "hits": {
      "total": 200, // 検索結果は、category.nameとclosedで絞られて600件から200件になっている
      "max_score": 0,
      "hits": []
   },
   "aggregations": {
      "category": {
         "doc_count_error_upper_bound": 0,
         "sum_other_doc_count": 0,
         "buckets": [ // アグリゲーションの結果もcategory.nameとclosedで絞られて「定食」のみになっている
            {
               "key": "定食",
               "doc_count": 200
            }
         ]
      }
   }
}

このように、アグリゲーションの結果も絞り込んでしまいたく場合は次のようにpost_filterを使います。

GET restaurant_development/_search
{
  "query": {
    "filtered": {
      "query": { "match_all": {} },
      "filter": {
        "bool": {
          "must": [
            { "term": { "closed": "false" } }
          ]
        }
      }
    }
  },
  "size": 0,
  "aggregations": {
    "category": {
      "terms": { "field": "category.name", "size": 10 }
    }
  },
  "post_filter": {
    "bool": {
      "must": [
        { "term": { "category.name": "定食" } }
      ]
    }
  }
}

// 結果
{
   "took": 4,
   "timed_out": false,
   "_shards": {
      "total": 5,
      "successful": 5,
      "failed": 0
   },
   "hits": {
      "total": 200, // 検索結果はcateogyr.nameとclosedで絞りこまれている
      "max_score": 0,
      "hits": []
   },
   "aggregations": {
      "category": {
         "doc_count_error_upper_bound": 0,
         "sum_other_doc_count": 0,
         "buckets": [ // アグリゲーションの結果は、closedのみで絞りこまれている
            {
               "key": "定食",
               "doc_count": 200
            },
            {
               "key": "イタリアン",
               "doc_count": 100
            },
            {
               "key": "居酒屋",
               "doc_count": 100
            }
         ]
      }
   }
}


カテゴリのアグリゲーションの実装
次のようにカテゴリで検索結果を絞り込めるようにします。
f:id:nipe880324:20151021235853p:plain:w420


アグリゲーションのクエリを作成

まずは、アグリゲーションのクエリを作成します。アグリゲーション名は「category」、アグリゲーションタイプには「Terms Aggregation」を利用します。
elasticsearch-ruby/elasticsearch-dsl/lib/elasticsearch/dsl/search/aggregations/terms.rbを参考にして、search_definitionにカテゴリのアグリゲーションを追加します。
また、検索結果のみにカテゴリの検索条件を適用させたいのでpost_filterも利用しています。

# app/models/restaurant.rb

def self.search(params = {})
  # 検索パラメータを取得
  ...
  category_name = params[:category]

  search_definition = Elasticsearch::DSL::Search.search {
    ...

    # アグリゲーション - 集約をする
    # @see https://www.elastic.co/guide/en/elasticsearch/guide/current/aggregations.html
    aggregation :category do
      terms field: 'category.name', size: 10
    end

    # Post Filter - 検索結果のみにフィルターをしたい場合に使う。アグリゲーションに対してフィルターされない
    # @see https://www.elastic.co/guide/en/elasticsearch/guide/current/_post_filter.html
    post_filter {
      bool {
        must {
          term 'category.name' => category_name
        } if category_name.present?
      }
    } if category_name.present?
  }
  ...
end


カテゴリのアグリゲーションの表示

カテゴリのアグリゲーションを画面に表示させる前に、Elasticsearchからのレスポンスがどのように構造になっているか確認します。基本的には、Marvel Senseで実行した結果と同じ構造になっています。

# rails console
resposne = __elasticsearch__.search(search_definition)

# aggregationsキーにアグリゲーションの結果が入っている
response.aggregations #=> アグリゲーション結果が入っている

# 自分が指定したアグリゲーション名(category)のbucketsに値が入っている
response.aggregations.category.buckets
# =>
# [
#   {"key"=>"定食", "doc_count"=>200},
#   {"key"=>"イタリアン", "doc_count"=>100},
#   {"key"=>"居酒屋", "doc_count"=>100}
# ]


では、画面にこれらを表示させるようにします。
例によってアグリゲーションのカテゴリリンクを作成するcategory_aggs_linkヘルパーメソッドを作成しています。

<!-- app/views/top/index.html.erb -->
<!-- 検索フォーム -->
<%= form_tag root_path, method: :get, enforce_utf8: false do %>
  ...
<% end %>

<br>

<!-- アグリゲーションの表示 -->
<div class="col-xs-3">
  <!-- カテゴリ -->
  <div class="panel panel-default">
    <p class="panel-heading">
      <%= category_aggs_link(name: 'カテゴリ一覧', all: true) %>
    </p>
    <div class="list-group">
      <% @restaurants.aggregations.category.buckets.each do |bucket| %>
        <li class="list-group-item">
          <%= category_aggs_link(name: bucket['key'], count: bucket.doc_count) %>
        </li>
      <% end %>
    </div>
  </div>
</div>

<!-- 検索結果の表示 -->
<div class="col-xs-9">
  ...


アグリゲーションのカテゴリリンクを表示するcategory_aggs_linkメソッドを作成します。
また、query_string:cateogyを追加して、カテゴリの条件を引き継げるようにしておきます。

# app/helpers/application_helper.rb
module ApplicationHelper
  ...

  # アグリゲーションのカテゴリリンクを返す
  #   name:  [String]  リンクのテキスト名
  #   count: [Integer] カテゴリのドキュメント数。リンクのテキスト名に追加されて表示される (オプション)
  #   all:   [Boolean] true: カテゴリ一覧のリンク、false: カテゴリ一覧以外のリンク(オプション)
  def category_aggs_link(name:, count: nil, all: false)
    # 表示するリンク名
    link_text = count ? "#{name}(#{count})" : name

    if all # カテゴリ一覧のリンク
      params = query_string.except(:category) # クエリストリングからcategoryを抜く
      url = params.empty? ? '/' : "?#{params.to_query}" # urlを作成
      link_to link_text, url # aタグ作成
    else # カテゴリ一覧以外のリンク
      current = query_string.fetch(:category, nil) # 現在選択されているカテゴリ名
      if name == current
        name # 現在選択されているカテゴリはテキストで表示
      else
        params = query_string.merge(category: name)
        url = "?#{params.to_query}"
        link_to link_text, url
      end
    end
  end

  # 各リンクで引き継ぐクエリストリングパラメータ
  # 表示件数やソート順などのリンクを押した時に`q`や`closed`などのパラメータは引き続き設定したままにするために使用
  # 下記で`:page`は設定しないので、リンクを押した時に、ページは0ページ目ににクリアされる
  def query_string
    params.slice(:q, :closed, :per, :sort, :category)
  end
end


画面の確認

カテゴリのアグリゲーションを表示させます。また、リンクを押すと、そのカテゴリで検索結果が絞られます。
このときpost_filterを使っているので、アグリゲーションの結果は絞られていません。
f:id:nipe880324:20151021235853p:plain:w420


例によって、RailsログからElasticsearchのクエリを確認すると、aggregationsでカテゴリ名で集約、post_filterでカテゴリ名でフィルタしていることがわかります。

Started GET "/?category=%E5%AE%9A%E9%A3%9F" for ::1 at 2015-10-18 22:19:59 +0900
Processing by TopController#index as HTML
  Parameters: {"category"=>"定食"}
  Restaurant Search (5.8ms) {index: "restaurant_development", type: "restaurant", body: {query: {filtered: {query: {match_all: {}}, filter: {term: {closed: "false"}}}}, post_filter: {bool: {must: [{term: {"category.name"=>"定食"}}]}}, aggregations: {category: {terms: {field: "category.name", size: 10}}}, sort: [{"created_at"=>{order: "desc"}}]}, size: 40, from: 0}
  Rendered top/index.html.erb within layouts/application (23.7ms)
Completed 200 OK in 59ms (Views: 51.8ms | ActiveRecord: 0.0ms | Elasticsearch: 5.8ms)


複数カテゴリの検索に対応
現在は1つのカテゴリでしか絞れないので、次のように複数のカテゴリで絞れるように修正します。
f:id:nipe880324:20151021235921p:plain:w420

デリミタの定義

デリミタ(セパレータ)を+にし、/?category=定食+イタリアンといったようなURLで複数のカテゴリを検索できるようにします。

このとき注意しないといけないのが、カテゴリ名に+が入らないようにしないといけません。
カテゴリ名に+が入ってしまうと、誤ってデリミタで分割してしまいそのカテゴリでうまく検索できなくなってしまいます。

カテゴリ名に+が入らないようにバリデーションを追加しておきます。

# app/models/restaurant.rb
class Restaurant < ActiveRecord::Base
  ...

  # デリミタ: 複数カテゴリなどの検索条件に使用する
  DELIMITER = '+'
  ...
end

# app/models/category.rb
class Category < ActiveRecord::Base
  has_many :restaurants

  # +を含んでいるカテゴリ名はinvalidとする
  # REgexp.newで作成される正規表現 /\/+/
  validates :name, format: { without: Regexp.new("\\#{::Restaurant::DELIMITER}") }
end

# うまくバリデーションが設定されているか確認
Category.new(name: 'hoge').valid? #=> true
Category.new(name: 'hoge+hoge').valid? #=> false


カテゴリリンクを複数のカテゴリを処理できるように修正します。
チェックボックス方式にして、現在選択されているカテゴリは、チェックボックスをON、選択されていないカテゴリはチェックボックスをOFFにします。

# app/helpers/application_helper.rb

# カテゴリリンクのパターン
#   カテゴリ一覧のリンク    => params[:category]を覗いたリンクを作成
#   カテゴリ一覧以外のリンク(現在選択されている)  => params[:category]の値からカテゴリ名を削除
#   カテゴリ一覧以外のリンク(現在選択されていない) => params[:category]の値にカテゴリ名を追加
# 引数
#   name:  [String]  リンクのテキスト名
#   count: [Integer] カテゴリのドキュメント数。リンクのテキスト名に追加されて表示される (オプション)
#   all:   [Boolean] true: カテゴリ一覧のリンク、false: カテゴリ一覧以外のリンク(オプション)
def category_aggs_link(name:, count: nil, all: false)
  # 表示するリンク名
  link_text = count ? "#{name}(#{count})" : name

  if all # カテゴリ一覧のリンク
    params = query_string.except(:category) # クエリストリングからcategoryを抜く
    url = params.empty? ? '/' : "?#{params.to_query}" # aタグのリンク
    link_to link_text, url # aタグ作成
  else # カテゴリ一覧以外のリンク
    # 現在選択されているカテゴリ名の取得ために、カテゴリ名をデリミタで分割する。値がない場合もあるので、tryを使う
    # query_string[:categoyr]は nil や '定食' 、 '定食+イタリアン' などの値がくる
    currents = query_string[:category].try(:split, Restaurant::DELIMITER) || []

    # 複数のカテゴリで結合できるように、現在選択されている場合はパラメータからカテゴリ名を削除、選択されていない場合はカテゴリ名を追加する
    if name.in?(currents) # 現在選択されているカテゴリ名
      category_names = currents - [name]
      params = category_names.empty? ? query_string.except(:category) : query_string.merge(category: category_names.join(Restaurant::DELIMITER))
      url = params.empty? ? '/' : "?#{params.to_query}"
      link_to url do
        "<input type='checkbox' checked='checked'> #{link_text}".html_safe
      end
    else # 現在選択されていないカテゴリ名
      category_names = (currents + [name]).uniq
      params = query_string.merge(category: category_names.join(Restaurant::DELIMITER))
      url = "?#{params.to_query}"
      link_to url do
        "<input type='checkbox'> #{link_text}".html_safe
      end
    end
  end
end


そして、searchメソッド複数カテゴリに対応できるようにします。
カテゴリ名パラメータをデリミタで分割し、post_filtershouldで各カテゴリ名をOR検索するようにします。

# Elasticsearchからのレスポンスを返す
def self.search(params = {})
  # 検索パラメータを取得
  ...
  # カテゴリ名をデリミタで分割する。値がない場合もあるので、tryを使う
  category_names = params[:category].try(:split, Restaurant::DELIMITER) || []

  # 検索クエリを作成(Elasticsearch::DSLを利用)
  search_definition = Elasticsearch::DSL::Search.search {
    ...

    # Post Filter - 検索結果のみにフィルターをしたい場合に使う。アグリゲーションに対してフィルターされない
    post_filter {
      bool {
        # 複数のカテゴリでOR検索したいので、shouldに変更し、eachで回す
        category_names.each do |category_name|
          should { term 'category.name' => category_name }
        end
      }
    } if category_names.present?
  }
  ...
end

動作確認

次の通り、サイドバーのカテゴリ一覧がチェックボックスになり、複数を選択できるようになりました。
f:id:nipe880324:20151021235921p:plain:w420

Railsログでも、post_filter: {bool: {should: [{term: {"category.name"=>"定食"}}, {term: {"category.name"=>"イタリアン"}}]}}複数のカテゴリ名で検索結果を絞っていることがわかります。

Started GET "/?category=%E5%AE%9A%E9%A3%9F%2B%E3%82%A4%E3%82%BF%E3%83%AA%E3%82%A2%E3%83%B3&per=80" for ::1 at 2015-10-18 22:27:39 +0900
Processing by TopController#index as HTML
  Parameters: {"category"=>"定食+イタリアン", "per"=>"80"}
  Restaurant Search (10.7ms) {index: "restaurant_development", type: "restaurant", body: {query: {filtered: {query: {match_all: {}}, filter: {term: {closed: "false"}}}}, post_filter: {bool: {should: [{term: {"category.name"=>"定食"}}, {term: {"category.name"=>"イタリアン"}}]}}, aggregations: {category: {terms: {field: "category.name", size: 10}}}, sort: [{"created_at"=>{order: "desc"}}]}, size: 80, from: 0}
  Rendered top/index.html.erb within layouts/application (29.8ms)
Completed 200 OK in 58ms (Views: 46.4ms | ActiveRecord: 0.0ms | Elasticsearch: 10.7ms)


都道府県のアグリゲーションと複数都道府県の検索
カテゴリと同じように都道府県もアグリゲーションを行います。また、複数都道府県で絞り込めるようにします。
また、アグリゲーション同士の検索条件は連動するようにします。例えば、「カテゴリ:定食屋」で絞りこむと、都道府県のアグリゲーションは「カテゴリ:定食屋」の集計したものを表示するようにします。逆もしかりです。
f:id:nipe880324:20151022000118p:plain:w420


都道府県のアグリゲーション

画面に都道府県(pref)のアグリゲーションを表示、絞り込むようにできるようにします。
例によって、pref_aggs_linkヘルパーメソッドを使用してリンクを表示します。

<!-- app/views/top/index.html.erb -->

...
<!-- アグリゲーションの表示 -->
<div class="col-xs-3">
  <!-- カテゴリ -->
  <div class="panel panel-default">
    ...
  </div>

  <!-- 都道府県 -->
  <div class="panel panel-default">
    <p class="panel-heading">
      <%= pref_aggs_link(name: '都道府県一覧', all: true) %>
    </p>
    <div class="list-group">
      <% @restaurants.aggregations.pref.buckets.each do |bucket| %>
        <li class="list-group-item">
          <%= pref_aggs_link(name: bucket['key'], count: bucket.doc_count) %>
        </li>
      <% end %>
    </div>
  </div>
</div>
...


次に、アグリゲーションの都道府県のリンクを作成するpref_aggs_linkヘルパーメソッドを実装します。
category_aggs_linkpref_aggs_linkはキー名がcategoryprefだけが異なるだけなので、aggs_linkメソッドに共通部分を切り出しました。
また、query_stringメソッド:prefキーを追加しています。

# app/helpers/application_helper.rb
module ApplicationHelper
  ...

  # アグリゲーションのカテゴリリンクを作成
  def category_aggs_link(name:, count: nil, all: false)
    aggs_link(key: 'category', name: name, count: count, all: all)
  end

  # アグリゲーションの都道府県リンクを作成
  def pref_aggs_link(name:, count: nil, all: false)
    aggs_link(key: 'pref', name: name, count: count, all: all)
  end

  # アグリゲーションのリンクを作成
  # リンクのパターン
  #   一覧のリンク    => params[key]を除いたリンクを作成
  #   一覧以外のリンク(現在選択されている)  => params[key]の値からカテゴリ名を削除
  #   一覧以外のリンク(現在選択されていない) => params[key]の値にカテゴリ名を追加
  # 引数
  #   name:  [String]  リンクのテキスト名
  #   count: [Integer] カテゴリのドキュメント数。リンクのテキスト名に追加されて表示される (オプション)
  #   all:   [Boolean] true: リンク、false: 一覧以外のリンク(オプション)
  def aggs_link(key:, name:, count: nil, all: false)
    # 表示するリンク名
    link_text = count ? "#{name}(#{count})" : name

    if all # 一覧のリンク
      params = query_string.except(key)
      url = params.empty? ? '/' : "?#{params.to_query}"
      link_to link_text, url
    else # 一覧以外のリンク
      currents = query_string[key].try(:split, Restaurant::DELIMITER) || [] # 現在選択されているカテゴリ名
      # 複数のカテゴリで結合できるように、現在選択されている場合はパラメータからカテゴリ名を削除、選択されていない場合はカテゴリ名を追加する
      if name.in?(currents) # 現在選択されているカテゴリ名
        aggs_names = currents - [name]
        params = aggs_names.empty? ? query_string.except(key) : query_string.merge(key => aggs_names.join(Restaurant::DELIMITER))
        url = params.empty? ? '/' : "?#{params.to_query}"
        link_to url do
          "<input type='checkbox' checked='checked'> #{link_text}".html_safe
        end
      else # 現在選択されていないカテゴリ名
        aggs_names = (currents + [name]).uniq
        params = query_string.merge(key => aggs_names.join(Restaurant::DELIMITER))
        url = "?#{params.to_query}"
        link_to url do
          "<input type='checkbox'> #{link_text}".html_safe
        end
      end
    end
  end

  # 各リンクで引き継ぐクエリストリングパラメータ
  def query_string
    # :prefを追加
    params.slice(:q, :closed, :per, :sort, :category, :pref)
  end
end


最後に、検索クエリを修正します。parmas[:pref]を受け取るようにし、アグリゲーションとPost Filterにprefを追加します。

# app/models/restaurant.rb

def self.search(params = {})
  # 検索パラメータを取得
  ...
  pref_names     = params[:pref].try(:split, Restaurant::DELIMITER) || []

  search_definition = Elasticsearch::DSL::Search.search {
    ...

    # アグリゲーション
    # @see https://www.elastic.co/guide/en/elasticsearch/guide/current/aggregations.html
    aggregation :category do
      terms field: 'category.name', size: 10
    end
    # アグリゲーションにprefを追加。sizeは都道府県が47なので、47にしておきます
    aggregation :pref do
      terms field: 'pref.name', size: 47
    end

    # Post Filter - 検索結果のみにフィルターをしたい場合に使う。アグリゲーションに対してフィルターされない
    # Post Filter - 検索結果のみにフィルターをしたい場合に使う。アグリゲーションに対してフィルターされない
    # @see https://www.elastic.co/guide/en/elasticsearch/guide/current/_post_filter.html
    if category_names.present? || pref_names.present?
      post_filter {
        # (カテゴリ1 OR カテゴリ2 OR ...) AND (都道府県1 OR 都道府県2 OR ...) となる
        bool {
          # カテゴリのフィルタ
          must {
            bool {
              category_names.each { |category_name|
                should { term 'category.name' => category_name }
              }
            }
          } if category_names.present?

          # 都道府県のフィルタ
          must {
            bool {
              pref_names.each { |pref_name|
                should { term 'pref.name' => pref_name }
              }
            }
          } if pref_names.present?
        }
      }
    end
  }
  ...
end


アグリゲーション同士の検索条件の連携

post_filterで検索条件を指定したので、検索結果はうまく表示されるようになりました。
しかし、サイドバーのアグリゲーションのカウント表示はアグリゲーション内でフィルタを行っていないのでうまく表示されていないので表示するようにします。

具体的には、カテゴリを選択すると、都道府県アグリゲーション内で選択されたカテゴリでフィルタします。
また、都道府県を選択すると、カテゴリアグリゲーション内で選択された都道府県でフィルタをします。

アグリゲーションにフィルタを実施するには、Filter Aggregationを使います。
elasticsearch-ruby/elasticsearch-dsl/lib/elasticsearch/dsl/search/aggregations/filter.rbを参考にし、フィルタをアグリゲーションに追加します。

# app/models/restaurant.rb

# Elasticsearchのクエリを作成し、検索を実施する
def self.search(params = {})
  ...

  search_definition = Elasticsearch::DSL::Search.search {
    query {
      ...
    }

    sort {
      ...
    }

    # アグリゲーション
    # @see https://www.elastic.co/guide/en/elasticsearch/guide/current/aggregations.html
    aggregation :category do
      # categoryアグリゲーションのフィルタ条件
      condition = Elasticsearch::DSL::Search::Filters::Bool.new {
        if pref_names.present?
          pref_names.each { |pref_name|
            should { term 'pref.name' => pref_name }
          }
        else
          must { match_all }
        end
      }

      # アグリゲーションのフィルタを行う
      filter condition do
        aggregation :category do
          terms field: 'category.name', size: 10
        end
      end
    end

    aggregation :pref do
      # prefアグリゲーションのフィルタ条件
      condition = Elasticsearch::DSL::Search::Filters::Bool.new {
        if category_names.present?
          category_names.each { |category_name|
            should { term 'category.name' => category_name }
          }
        else
          must { match_all }
        end
      }

      # アグリゲーションのフィルタを行う
      filter condition do
        aggregation :pref do
          terms field: 'pref.name', size: 47
        end
      end
    end

    # Post Filter
    post_filter {
      ...
    }
  }
  ...
end


フィルタアグリゲーションを追加したことでレスポンスの結果の構造が変わりましたので、ビューファイルを修正します。

<!-- app/views/top/index.html -->
<!-- アグリゲーションの表示 -->
<div class="col-xs-3">
  <!-- カテゴリ -->
  <div class="panel panel-default">
    ...
      <!-- category から category.category に変更 -->
      <% @restaurants.aggregations.category.category.buckets.each do |bucket| %>
    ...
  </div>

  <!-- 都道府県 -->
  <div class="panel panel-default">
    ...
      <!-- pref から pref.pref に変更 -->
      <% @restaurants.aggregations.pref.pref.buckets.each do |bucket| %>
    ...
  </div>
</div>

動作確認

これで、カテゴリと都道府県のアグリゲーション同士が連動するようになりました。
f:id:nipe880324:20151022000118p:plain:w420

Elasticsearchのクエリが長くなってきて見づらくなってきましたが一応次のようになります。

Started GET "/?category=%E5%AE%9A%E9%A3%9F%2B%E3%82%A4%E3%82%BF%E3%83%AA%E3%82%A2%E3%83%B3&pref=%E7%A5%9E%E5%A5%88%E5%B7%9D%E7%9C%8C" for ::1 at 2015-10-18 23:02:08 +0900
Processing by TopController#index as HTML
  Parameters: {"category"=>"定食+イタリアン", "pref"=>"神奈川県"}
  Restaurant Search (9.4ms) {index: "restaurant_development", type: "restaurant", body: {query: {filtered: {query: {match_all: {}}, filter: {term: {closed: "false"}}}}, post_filter: {bool: {must: [{bool: {should: [{term: {"category.name"=>"定食"}}, {term: {"category.name"=>"イタリアン"}}]}}, {bool: {should: [{term: {"pref.name"=>"神奈川県"}}]}}]}}, aggregations: {category: {filter: #<Elasticsearch::DSL::Search::Filters::Bool:0x007fbc27bda188 @hash={bool: {should: [{term: {"pref.name"=>"神奈川県"}}]}}, @args={}, @options={}, @block=#<Proc:0x007fbc27bd9df0@/Users/shoji/GDrive/rails/rails_samples/elasticsearch_test/app/models/restaurant.rb:107>>, aggregations: {category: {terms: {field: "category.name", size: 10}}}}, pref: {filter: #<Elasticsearch::DSL::Search::Filters::Bool:0x007fbc27bd2aa0 @hash={bool: {should: [{term: {"category.name"=>"定食"}}, {term: {"category.name"=>"イタリアン"}}]}}, @args={}, @options={}, @block=#<Proc:0x007fbc27bd2938@/Users/shoji/GDrive/rails/rails_samples/elasticsearch_test/app/models/restaurant.rb:127>>, aggregations: {pref: {terms: {field: "pref.name", size: 47}}}}}, sort: [{"created_at"=>{order: "desc"}}]}, size: 40, from: 0}
  Rendered top/index.html.erb within layouts/application (35.8ms)
Completed 200 OK in 96ms (Views: 85.1ms | ActiveRecord: 0.0ms | Elasticsearch: 9.4ms)

まとめ

今回はアグリゲーション(ファセット)とPost Filterを使い、よくあるファセット検索を実装しました。
次は、「ハイライト機能」について説明します。


参考文献