Elasticsearch を使った位置情報検索

ホリデー事業室の内藤です。

ホリデー事業室は昨年の4月に発足した部署で、Holiday(https://haveagood.holiday)という新規サービスの開発を行っています。

Holiday とは、クックパッドが長年取り組んでいる「毎日の料理を楽しみにする」分野からは少しだけ離れ、「いつもの休日を楽しくすることで人生を豊かにする」ことを目指したサービスです。

例えばこちらのおでかけプランのように、「〇〇に行くならここも行ったほうがいいよ」や「〇〇を散策するならこのコースだよね」など、おでかけのレシピを投稿したり探すことができるようになっています。

今回は、全文検索エンジン Elasticsearch を使って、全文検索と位置情報を絡めた検索についてお話したいと思います。

本稿で説明する内容は、実際に Holiday の中でも応用を加えた形で使われています。

Holiday では、複数のおでかけスポットを組み合わせて一つのおでかけプランを作ることができます。 例えばそのおでかけプランの作成画面では、登録するおでかけスポットを選ぶ時に、京都のおでかけプランなら京都のスポットを優先的に掲示することで、スムーズなプラン作成を可能にしています。 f:id:qtoon:20150310133959p:plain

また、おでかけプランの詳細画面においては、緯度経度情報を加味したおでかけプラン同士の関連性を計算し、関連度の高いものを合わせて表示することで、週末のおでかけ先を探している人がより行き先を決めやすくなるような仕組みを作っています。

このような位置情報を応用した様々な機能が、Elasticsearch を使うことによって簡単に実現できます。

なお本稿に含まれるサンプルコードは、GitHub 上で公開しています。
https://github.com/9toon/es-geo-sample

前準備

まず始めに、必要なデータを用意します。 今回は、名称・住所・緯度経度の情報を持つおでかけスポットを用意します。

テーブル定義は以下のようになります。

# import.rb

create_table(:spots) {|t|
  t.string :name
  t.string :address
  t.decimal :lat, precision: 9, scale: 6
  t.decimal :lon, precision: 9, scale: 6
}

このテーブルに、いくつかのスポット情報を登録します。

# import.rb

spots = [
  { name: '清水寺',     address: '京都府京都市東山区清水1-294',     lat: 34.994401, lon: 135.783283 },
  { name: '京都御所',   address: '京都府京都市上京区京都御苑3',     lat: 35.025414, lon: 135.762125 },
  { name: '八坂神社',   address: '京都府京都市東山区祇園町北側625', lat: 35.003634, lon: 135.778525 },
  { name: '金閣寺',     address: '京都府京都市北区金閣寺町1',       lat: 35.039381, lon: 135.729230 },
  { name: '北野天満宮', address: '京都府京都市上京区北野馬喰町',    lat: 35.030428, lon: 135.735327 },
  { name: '清水寺',     address: '神奈川県海老名市国分北2丁目',     lat: 35.460435, lon: 139.398696 },
  { name: '清水寺',     address: '群馬県高崎市石原町2401',          lat: 36.309917, lon: 138.989039 },
  { name: '清水寺',     address: '岐阜県加茂郡富加町加治田985',     lat: 35.498399, lon: 136.997405 },
  { name: '清水寺',     address: '愛知県東海市荒尾町西川60',        lat: 35.028889, lon: 136.911644 },
]

spots.each do |spot|
  Spot.find_or_create_by!(spot)
end

次に、Elasticsearch のインデックス・スキーマを定義します。

name および address は、日本語での全文検索を行いたいので analyzerkuromoji を指定します。 また、位置情報と絡めた検索機能を利用するために、location というフィールドを作り geo point type を指定します。

ただし、データベース内では緯度経度情報を lat, lon という形式で保持しているので、Elasticsearchにデータを流し込む際には geo_point タイプの書式に合うように加工する必要があります。 elasticsearch-model では as_indexed_json メソッドを使うことで、Elasticsearch に渡すデータをカスタマイズできます。

# spot.rb

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

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

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

Elasticsearch にデータを流し込む準備ができたので、実際にドキュメントを登録します。

# import.rb

Spot.import(force: true)

ここまでで、前準備は終了です。

なおサンプルでは、以下のコマンドを打つことで、ここまでの処理を行うことができます。

$ bundle exec ruby import.rb

距離順での並び替え

登録しているおでかけスポットを、ある地点からの距離順にソートしてみます。 今回は、阪急京都線河原町駅(lat: 35.003765, lon: 135.769463)からの距離が近い順に並び替えてみます。

以下のように設定することで、距離順に並び替えることができます。

# spot.rb

class Spot < ActiveRecord::Base
  class << self
    def sort_by_distance(lat, lon)
      body = {
        sort: {
          _geo_distance: {
            location: {
              lat: lat,
              lon: lon,
            },
            order: 'asc',
            unit: 'meters',
          }
        }
      }

      Spot.__elasticsearch__.search(body)
    end
  end
end

_geo_distance の中では、中心点を定める location、ソート方向を定める order、そして単位を定める unit を指定しています。

なお今回のサンプルでは、ソート処理や絞り込み処理が適切に行われたかどうかを確かめやすくする目的で、各スポットと中心点との距離を算出する script を含めることにします。 この script を含めたメソッドの全体像は以下のようになります。

# spot.rb

class Spot < ActiveRecord::Base
  class << self
    def sort_by_distance(lat, lon)
      body = {
        sort: {
          _geo_distance: {
            location: {
              lat: lat,
              lon: lon,
            },
            order: 'asc',
            unit: 'meters',
          }
        },
        script_fields: calc_distance_script(lat, lon),
      }

      Spot.__elasticsearch__.search(body)
    end

    private

    def calc_distance_script(lat, lon)
      { distance: {
          params: {
            lat: lat,
            lon: lon,
          },
          script: "doc['location'].distance(lat,lon)", # 点[lat, lon] からの距離をメートル単位で算出
        }
      }
    end
  end
end

script に関する詳しい内容は、公式ドキュメントの scripting を読むと詳しく記載されています。

では実際に先ほど登録したデータで試してみます。

$ bundle exec pry

> require './spot.rb'
=> true

> spots = []

> Spot.sort_by_distance(35.003765,135.769463).records.each_with_hit { |record, hit| spots << { name: record.name, address: record.address, distance: "#{hit.fields.distance.first.to_i}m" } }

> spots
=> [
  {:name=>"八坂神社",   :address=>"京都府京都市東山区祇園町北側625", :distance=>"1008m"},
  {:name=>"清水寺",     :address=>"京都府京都市東山区清水1-294",     :distance=>"1858m"},
  {:name=>"京都御所",   :address=>"京都府京都市上京区京都御苑3",     :distance=>"2544m"},
  {:name=>"北野天満宮", :address=>"京都府京都市上京区北野馬喰町",    :distance=>"4821m"},
  {:name=>"金閣寺",     :address=>"京都府京都市北区金閣寺町1",       :distance=>"5981m"},
  {:name=>"清水寺",     :address=>"愛知県東海市荒尾町西川60",        :distance=>"127177m"},
  {:name=>"清水寺",     :address=>"岐阜県加茂郡富加町加治田985",     :distance=>"147367m"},
  {:name=>"清水寺",     :address=>"群馬県高崎市石原町2401", :        :distance=>"386772m"},
  {:name=>"清水寺",     :address=>"神奈川県海老名市国分北2丁目",     :distance=>"407190m"}
]

このように、中心点の河原町駅からの距離順に並んでいることが分かります。

中心点からの距離で絞込む

では次に、中心点から一定の範囲内にあるおでかけスポットのみを取り出してみます。 今回は、河原町駅から10km(10000m)以内にあるスポットのみを取り出します。

中心点から指定の半径内に収まるデータを絞り込むには、geo_distance フィルターを使います。 elasticsearch-model を使った場合、以下のように書くことで、このフィルターを使うことができます。

# spot.rb

class Spot < ActiveRecord::Base
  class << self
    def spots_in_range(lat, lon, radius = 10000)
      body = {
        query: {
          filtered: {
            filter: {
              geo_distance: {
                location: {
                  lat: lat,
                  lon: lon,
                },
                distance: "#{radius}meters",
              }
            }
          }
        },
        script_fields: calc_distance_script(lat, lon),
      }

      Spot.__elasticsearch__.search(body)
    end
  end
end

geo_distance の中では、中心点を定める location と、そこからの半径の長さを表す distance を指定します。

これを実行すると、以下のような結果が得られます。

$ bundle exec pry

> require './spot.rb'
=> true

> spots = []

> Spot.spots_in_range(35.003765,135.769463, 10000).records.each_with_hit { |record, hit| spots << { name: record.name, address: record.address, distance: "#{hit.fields.distance.first.to_i}m" } }

> spots
=> [
  {:name=>"金閣寺",     :address=>"京都府京都市北区金閣寺町1",       :distance=>"5981m"},
  {:name=>"北野天満宮", :address=>"京都府京都市上京区北野馬喰町",    :distance=>"4821m"},
  {:name=>"清水寺",     :address=>"京都府京都市東山区清水1-294",     :distance=>"1858m"},
  {:name=>"京都御所",   :address=>"京都府京都市上京区京都御苑3",     :distance=>"2544m"},
  {:name=>"八坂神社",   :address=>"京都府京都市東山区祇園町北側625", :distance=>"1008m"}
]

このように、全て河原町駅から10km以内に位置するスポットのみを抽出することができました。

より柔軟な位置情報検索

では最後に、少しだけ応用的な使い方として、スポット名および住所での全文検索と位置情報を組み合わせた検索を行います。

いくつかやり方はありますが、今回は function_score_query を使ってこれを実現しようと思います。

実際にサービスを運営していると、検索結果の並び順をどうするか決めるためには様々な要因を考慮する必要があることに気が付きます。

例えば、現在地周辺のおでかけスポットを検索する機能を作るとすれば、

  • 現地までの距離
  • 検索クエリとの関連度
  • 人気度

等々を考慮する必要があるでしょう。

そのため、距離順など一つの基準で並び替えてしまった場合、人気がないスポットや検索クエリとの関連が低いスポットが上位に並んでしまうということになりかねません。 この問題を解決するためには複数の条件で並び替えの順序を決める必要がありますが、その際に function_score_query が活躍します。

function score query は、functions セクションに複数の条件(function)を記載でき、それぞれの条件でのスコアを合算した値を使ってソートを行います。

今回はサンプルとして、スポット名および住所で全文検索を行い、その結果を 検索クエリとの関連度距離 を考慮して並び替えたものを返すメソッドを作りました。

# spot.rb

class Spot < ActiveRecord::Base
  class << self
    def search_by_keyword(keyword, lat, lon)
      body = {
        query: {
          function_score: {
            score_mode: 'multiply',
            query: {
              simple_query_string: {
                query: keyword,
                fields: ['spot_name', 'address'],
                default_operator: :and,
              }
            },
            functions: [
              {
                filter: {
                  query: {
                    simple_query_string: {
                      query: keyword,
                      fields: ['spot_name'],
                      default_operator: :and,
                    }
                  }
                },
                weight: 5
              },
              {
                filter: {
                  query: {
                    simple_query_string: {
                      query: keyword,
                      fields: ['address'],
                      default_operator: :and,
                    }
                  }
                },
                weight: 2
              },
              {
                gauss: {
                  location: {
                    origin: {
                      lat: lat,
                      lon: lon,
                    },
                    offset: '1500m',
                    scale: '2000m',
                  }
                }
              }
            ]
          }
        },
        script_fields: calc_distance_script(lat, lon),
      }
    end
  end
end

観光地に出向いて周辺のおでかけスポットを検索する際、徒歩で行けるくらい近いスポットについては、数メートルの違いはそれほど重要ではなかったりします。 ただ、これがもう少し遠くなり、少し歩いてでも行きたいかどうか悩むような距離にあるスポットについては、距離の違いが重要な判断材料になってきます。 そして、これがさらに遠くなって、歩くにはちょっと厳しいなという距離になってくると、今度はまた距離の重要性が低くなります。

本サンプルでは、上記の感覚を検索結果に反映するために gauss function を使っています。

この function に関する詳細な説明は公式ドキュメント the closer, the better に譲るとして、サンプル内での設定を簡単に説明すると、

  • 1500m までは徒歩圏内とみなし、この範囲に存在するスポットについては距離差がソート結果に反映されないようにする(検索クエリとの関連度のみを考慮する)
  • 1500m ~ 5500m に位置するスポットについては、ちょっと歩いてでも行く価値があるかを判断する重要な材料となりうるため、距離差をソート結果に反映する
  • 5500m 以上離れた場所に位置するスポットについては、再び距離差が重要ではなくなるため、距離差をソート結果に反映しないようにする

というような設定になっています。

実際のサービスでは、この閾値をどこに設定するか、他の条件とどうバランスをとるかを調整することで、検索結果の品質を高めていくことになります。

では、このメソッドを実行してます。

$ bundle exec pry

> require './spot.rb'
=> true

> spots = []

> Spot.search_by_keyword('京都', 35.003765,135.769463).records.each_with_hit { |record, hit| spots << { name: record.name, address: record.address, distance: "#{hit.fields.distance.first.to_i}m" } }

> spots
=> [
  {:name=>"京都御所",   :address=>"京都府京都市上京区京都御苑3",     :distance=>"2544m"}, # 「八坂神社」よりも遠い!
  {:name=>"八坂神社",   :address=>"京都府京都市東山区祇園町北側625", :distance=>"1008m"},
  {:name=>"清水寺",     :address=>"京都府京都市東山区清水1-294",     :distance=>"1858m"},
  {:name=>"金閣寺",     :address=>"京都府京都市北区金閣寺町1",       :distance=>"5981m"},
  {:name=>"北野天満宮", :address=>"京都府京都市上京区北野馬喰町",    :distance=>"4821m"}
]

このように、スポット名・住所の両方に「京都」が含まれていて、より検索クエリとの関連度が高い「京都御所」が一番上に位置しています。 一方で、それ以外のスポットについては、上で示した設定に沿うような並び順になっていることが分かります。 実際のケースでは、PV数のような人気を表す指標や価格帯など様々な要素を含めることで、より使いやすい検索結果を提供することができそうです。

まとめ

このように、Elasticsearch を使うことで簡単に全文検索と位置情報を連携した検索機能を作ることができました。

特に最後の function_score_query を使った検索は、条件を色々組み替えることでどんどんと洗練させることができそうです。 また、メソッド内のクエリの設定を書き換えるだけで結果が即座に変わるため、プロトタイピングもしやすく簡単にランキングの調整ができるので便利です。

今回の記事が、位置情報を使った検索がしたいという方にとって、少しでもお役に立てていれば幸いです。

なお Holiday では、日本の休日を楽しくしたい (iOS|Android|Rails) エンジニアを募集しています。

日本全国には、まだまだたくさんの知る人ぞ知るおでかけスポットや、休日の過ごし方があります。
そんな「日本中の魅力を発掘し様々な切り口で紹介することで、『次の休日どうしよう...』という悩みをなくしたい」という思いに共感いただける方がいらっしゃいましたら、ぜひ我々と一緒にこの問題の解決に挑戦しましょう!

ご応募はこちらから ↓

Holidayで日本の休日を楽しくしたいiOSエンジニアを募集! - クックパッド株式会社の求人 - Wantedly

/* */ @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;*/ /*}*/