こんにちは、ホリデー株式会社の内藤です。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: :cascadedo |t| t.string "name" t.text "coordinates" t.datetime "created_at", null: false t.datetime "updated_at", null: falseend create_table "spots", force: :cascadedo |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: falseend create_table "stations", force: :cascadedo |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: falseendend
サンプルデータは db/seeds.rb
内に用意しているので、以下のコマンドを実行すれば必要なデータが作られるはずです。
$ bundle exec rake db:create $ bundle exec rake db:migrate $ bundle exec rake db:seed
おでかけスポットの情報は Elasticsearch 上では次のようなスキーマで格納することにします。
classSpot< ActiveRecord::BaseincludeElasticsearch::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'enddefas_indexed_json(options = {}) { 'id' => id, 'name' => name, 'address' => address, 'location' => "#{lat},#{lon}", } endend
Elasticsearch にドキュメントを登録するため、以下のタスクを実行します。
$ bundle exec rake environment elasticsearch:import:model CLASS='Spot' FORCE=y
これで、事前準備は完了です。
あるスポットを含んでいるエリアを取得する
では、あるスポットを含んでいるエリアを取得してみます。
今回、「中目黒エリア」を以下の青枠で囲まれた範囲と設定しました。
]
この多角形のエリアデータは以下のように表現されます。
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にはシリアライズされた状態で格納されます。
classArea< ActiveRecord::Base serialize :coordinatesend
検索クエリをインデックスする
ある座標が特定の多角形に含まれるかどうかを検索するには、Geo Polygon Queryを使います。
elasticsearch-rails
gem を使った場合、Percolator の登録は次のように行います。
# app/models/spot.rbclassSpot< ActiveRecord::Base ... # id: 検索クエリ毎にユニークなID# body: 検索条件defself.index_percolator(id, body) args = { index: self.__elasticsearch__.index_name, type: '.percolator', id: id, body: body, } self.__elasticsearch__.client.index(args) endend
このように、Percolator の登録処理は、通常のインデックス処理とほとんど変わらず、違いは .percolator
という特別な type 名を用いるということだけです。
呼び出し側の記述は以下のようになります。
classArea< ActiveRecord::Base ... defself.create_polygon_percolatorsArea.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) endendend
では、rails console を起動し、このメソッドを実行します。
$ rails console
Area.create_polygon_percolators
これで Elasticsearch 上の Spot インデックスに対して、Geo Polygon Queryの検索条件が登録されました。
実行する
では、「スターバックスコーヒー中目黒駅前店」というスポットが「中目黒エリア」に属しているかを実際に確かめてみます。 このスポットは中目黒ゲートタウンタワー内に位置するため、正しく動作していれば「含まれる」と判定されるはずです。
elasticsearch-rails
gem を使った場合、Percolator クエリを実行する処理は以下のように記述します。
classSpot< ActiveRecord::Base ... defpercolateSpot.__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を用います。
classStation< ActiveRecord::Basedefself.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) endendend
先ほどと同様に、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 を試してみようかなという方にとって少しでも参考になれば幸いです。