Elasticsearch の Percolator を使った地理属性判別システムの構築

こんにちは、ホリデー株式会社の内藤です。Holiday ( https://haveagood.holiday/ ) というサービスの開発を行っています。

先日開催した Cookpad TechConf 2016 では、『おでかけスポット検索のむずかしさ - Holiday を支える検索技術』という題で発表を行いました。

www.slideshare.net

この発表では、

  • おでかけスポットの検索では、全文検索だけでは満足のいく結果は得られない
  • 地理空間検索に拡張することでよりよい検索体験を作ることが可能
  • これを実現するための Elasticsearch の機能を紹介

というような内容を紹介しました。

例えば、我々が「中目黒」を思い浮かべた時にイメージするエリア内の住所には、「中目黒」という文字列が含まれていません。 また、「奥渋谷」のように住所としては特定できない「だいたいこの辺り」というようなエリアも存在します。 あるいは、「中目黒駅」と調べたユーザは、「駅からの距離を考慮した並び順で結果を見たい」と考えていそうだと想像できるものの、文字列からは距離を計測することはできません。

このように、検索クエリを文字列として扱うだけでは、検索クエリに込められたユーザの意図を汲みとった検索結果を返すことが難しくなります。

以前の記事 でも紹介したように、Elasticsearch には位置情報を利用した検索機能が用意されています。 これらの機能を活用することで、全文検索と地理空間検索を組み合わせて、より満足度の高い検索体験を作ることができます。

ただ、これら地理空間検索機能は、公式ガイド 『The Definitive Guide』 でも言及されているように、文字列マッチなどの検索条件に比べて計算コストの高い処理になります。

Geo-filters are expensive — they should be used on as few documents as possible. First remove as many documents as you can with cheaper filters, like term or range filters, and apply the geo-filters last.

このような高コストな処理を検索時に毎回行うのはあまり望ましくありません。 できるならば高負荷な処理は事前に済ませておき、検索時には低コストな処理のみを行いたいという思いがあります。

今回は、このニーズを満たすために、Elasticsearch に用意されている Percolator という機能を使ってみようと思います。

Percolator とは

通常の検索時には、あらかじめ登録したドキュメントに対して検索クエリを発行することで、目的の情報を探します。 一方で Percolator はこれとは全く逆で、あらかじめ登録しておいた検索クエリに対してドキュメントを当てることで、合致する検索条件がないかを判定します。

よくある使い方としては、例えばECサイトにおいて、ある商品に興味があるユーザに対して、その商品が入荷された時にお知らせを送るというようなケースがよく取り上げられます。

今回は上記のような使い方ではなく、あるスポットが「どういったエリアに属するのか」や「どの駅の周辺に位置しているのか」といった属性情報を、Percolator の仕組みを使って取得してみようと思います。

なお、実行環境は以下のとおりです。

  • Elasticsearch 2.1 *1
  • Ruby 2.2.4
  • Rails 4.2.6
    • クライアントには elasticsearch-rails gem を使用

本稿で使用するサンプルコードは、GitHub 上で公開しています。

データを準備する

まずは必要なデータを準備します。

今回は、以下のようなスキーマを使います。

ActiveRecord::Schema.define(version: 20160314093426) do

  create_table "areas", force: :cascade do |t|
    t.string   "name"
    t.text     "coordinates"
    t.datetime "created_at",  null: false
    t.datetime "updated_at",  null: false
  end

  create_table "spots", force: :cascade do |t|
    t.string   "name"
    t.string   "address"
    t.decimal  "lat",        precision: 9, scale: 6
    t.decimal  "lon",        precision: 9, scale: 6
    t.datetime "created_at",                         null: false
    t.datetime "updated_at",                         null: false
  end

  create_table "stations", force: :cascade do |t|
    t.string   "name"
    t.decimal  "lat",        precision: 9, scale: 6
    t.decimal  "lon",        precision: 9, scale: 6
    t.datetime "created_at",                         null: false
    t.datetime "updated_at",                         null: false
  end

end

サンプルデータは db/seeds.rb 内に用意しているので、以下のコマンドを実行すれば必要なデータが作られるはずです。

$ bundle exec rake db:create
$ bundle exec rake db:migrate
$ bundle exec rake db:seed

おでかけスポットの情報は Elasticsearch 上では次のようなスキーマで格納することにします。

class Spot < ActiveRecord::Base
  include Elasticsearch::Model

  index_name "#{Rails.env}-#{Rails.application.class.to_s.downcase}-#{self.name.downcase}"

  mapping do
    indexes :id, type: 'string', index: 'not_analyzed'
    indexes :name, type: 'string', analyzer: 'kuromoji'
    indexes :address, type: 'string', analyzer: 'kuromoji'
    indexes :location, type: 'geo_point'
  end

  def as_indexed_json(options = {})
    { 'id'          => id,
      'name'        => name,
      'address'     => address,
      'location'    => "#{lat},#{lon}",
    }
  end
end

Elasticsearch にドキュメントを登録するため、以下のタスクを実行します。

$ bundle exec rake environment elasticsearch:import:model CLASS='Spot' FORCE=y

これで、事前準備は完了です。

あるスポットを含んでいるエリアを取得する

では、あるスポットを含んでいるエリアを取得してみます。

今回、「中目黒エリア」を以下の青枠で囲まれた範囲と設定しました。

f:id:qtoon:20160317081243p:plain:h300]

この多角形のエリアデータは以下のように表現されます。

areas = [
  {
    name: "中目黒エリア",
    coordinates: [
      [139.69300746917725, 35.64788092832728], # 最初と最後は同じ値を指定する
      [139.6939516067505, 35.644114489675275],
      [139.69064712524414, 35.64059201137997],
      [139.69892978668213, 35.63846449878154],
      [139.70317840576172, 35.643033349486984],
      [139.70073223114014, 35.64721832699104],
      [139.69300746917725, 35.64788092832728]  # 最初と最後は同じ値を指定する
    ]
  }
]

Area には名称 name と多角形の頂点を表す座標データ coordinates を持ちます。 この coordinates[経度, 緯度] からなる配列で、DBにはシリアライズされた状態で格納されます。

class Area < ActiveRecord::Base
  serialize :coordinates
end

検索クエリをインデックスする

ある座標が特定の多角形に含まれるかどうかを検索するには、Geo Polygon Query を使います。

elasticsearch-rails gem を使った場合、Percolator の登録は次のように行います。

# app/models/spot.rb
class Spot < ActiveRecord::Base
  ...

  # id: 検索クエリ毎にユニークなID
  # body: 検索条件
  def self.index_percolator(id, body)
    args = {
      index: self.__elasticsearch__.index_name,
      type: '.percolator',
      id: id,
      body: body,
    }
    self.__elasticsearch__.client.index(args)
  end
end

このように、Percolator の登録処理は、通常のインデックス処理とほとんど変わらず、違いは .percolator という特別な type 名を用いるということだけです。

呼び出し側の記述は以下のようになります。

class Area < ActiveRecord::Base
  ...

  def self.create_polygon_percolators
    Area.all.each do |area|
      id = "area-polygon-#{area.id}" # e.g. `area-polygon-1`
      body = {
        query: {
          filtered: {
            query: {
              match_all: {}
            },
            filter: {
              geo_polygon: {
                location: {
                  points: area.coordinates
                }
              }
            }
          }
        }
      }
      Spot.index_percolator(id, body)
    end
  end
end

では、rails console を起動し、このメソッドを実行します。

$ rails console

Area.create_polygon_percolators

これで Elasticsearch 上の Spot インデックスに対して、Geo Polygon Query の検索条件が登録されました。

実行する

では、「スターバックスコーヒー中目黒駅前店」というスポットが「中目黒エリア」に属しているかを実際に確かめてみます。 このスポットは中目黒ゲートタウンタワー内に位置するため、正しく動作していれば「含まれる」と判定されるはずです。

f:id:qtoon:20160317081313p:plain:h300

elasticsearch-rails gem を使った場合、Percolator クエリを実行する処理は以下のように記述します。

class Spot < ActiveRecord::Base
  ...

  def percolate
    Spot.__elasticsearch__.client.percolate(
      index: Spot.__elasticsearch__.index_name,
      type: Spot.__elasticsearch__.document_type,
      body: {
        doc: {
          location: {
            lat: lat,
            lon: lon,
          }
        }
      }
    )
  end

では、rails console を起動し、以下の処理を実行してみます。

$ rails console

spot = Spot.find_by(name: 'スターバックスコーヒー中目黒駅前店')

spot.latlon
=> [35.643602, 139.699077]

spot.percolate
=> {
  "took" => 1,
  "_shards" => { "total" => 5, "successful" => 5, "failed" => 0 },
  "total" => 1,
  "matches" => [
    { "_index" => "development-espercolatorsample::application-spot",
      "_id" => "area-polygon-1"
    }
  ]
}

このように、 レスポンス内の matches フィールドに先ほど指定したIDが含まれていることがわかります。 matches には、このドキュメントを解とする検索条件のIDが含まれます。

では、「恵比寿ガーデンプレイス」が「中目黒エリア」に含まれているかどうかを確かめてみましょう。

spot = Spot.find_by(name: '恵比寿ガーデンプレイス')

spot.latlon
=> [35.642186, 139.713309]

spot.percolate
=> {
  "took" => 1,
  "_shards" => { "total" => 5, "successful" => 5, "failed" => 0 },
  "total" => 0,
  "matches" => []
}

当然これは「含まれていない」と判断されます。

以上のように、事前に登録しておいた Geo Polygon Query に対してスポット情報(緯度経度)を投げることで、そのスポットを解とする検索条件を取得することができました。 あとは、検索条件のIDからエリアIDを抽出すれば、あるスポットがどのエリアに含まれるのかを知ることができます。

周辺の駅を特定する

次に、あるスポットがどの駅の周辺にあるのかを判別します。

検索クエリをインデックスする

ある中心点からの一定距離内に含まれる地点を検索するのには、 Geo Distance Query を用います。

class Station < ActiveRecord::Base
  def self.create_distance_percolators(radius: 1000)
    Station.all.each do |station|
      id = "station-distance-#{station.id}-#{radius}" # e.g. `station-distance-1-1000`
      body = {
        query: {
          filtered: {
            filter: {
              geo_distance: {
                location: {
                  lat: station.lat,
                  lon: station.lon,
                },
                distance: "#{radius}meters",
              }
            }
          }
        }
      }
      Spot.index_percolator(id, body)
    end
  end
end

先ほどと同様に、rails console を起動し、このメソッドを実行します。

$ rails console

Area.create_polygon_percolators

これで Geo Distance Query の検索条件も、Percolator として登録することができました。

実行する

では、「スターバックスコーヒー中目黒駅前店」というスポットが、どの駅の周辺(半径1000m以内)にあるのかを調べてみます。

spot = Spot.find_by(name: 'スターバックスコーヒー中目黒駅前店')

spot.latlon
=> [35.643602, 139.699077]

spot.percolate
=> {
  "took" => 1,
  "_shards" => { "total" => 5, "successful" => 5, "failed" => 0 },
  "total" => 2,
  "matches" =>[
    { "_index" => "development-espercolatorsample::application-spot",
      "_id" => "station-distance-1-1000" # station_id: 1 => 中目黒駅
    },
    { "_index" => "development-espercolatorsample::application-spot",
      "_id" => "station-distance-3-1000" # station_id: 3 => 代官山駅
    },
    { "_index" => "development-espercolatorsample::application-spot",
      "_id" => "area-polygon-1"
    }
  ]
}

このように、中目黒駅(ID: 1)と代官山駅(ID: 3)がレスポンスに含まれています。 これは、このスポットがこれらの駅から半径1000m以内の位置に存在していることを示しています。

また、実行結果をよく見てみると、先の段落で作成した Geo Polygon Query についても、マッチした条件に含まれていることが分かります。

Percolator を使うと、あるドキュメントを一度投げるだけで、そのドキュメントを「検索条件に合致している」とみなすすべての検索クエリを一気に取得することができるのです。

まとめ

Percolator では、これまで検索時に使っていたクエリを .percolator という type 名でインデックスするだけで使えるようになります。 後は、#percolate メソッドを呼び出すだけで、そのドキュメントの属性情報を取得することが可能です。

以上のように、Percolator を用いるとコンテンツの属性情報を得るコードを新規に書かず Elasticsearch 内に共通化できるので、シンプルな形で実現することができます。

今回紹介した使い方以外にも、不適切なワードを含むコンテンツの投稿を検知したり、投稿内容からキーワードを抽出して自動的にタギングするというようなことも可能です。

この記事が、Percolator を試してみようかなという方にとって少しでも参考になれば幸いです。

*1:執筆時点の最新版である2.2系ではバグがあって動きません。詳しくは issue をご覧ください。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/