ホリデー事業室の内藤です。
ホリデー事業室は昨年の4月に発足した部署で、Holiday(https://haveagood.holiday)という新規サービスの開発を行っています。
Holiday とは、クックパッドが長年取り組んでいる「毎日の料理を楽しみにする」分野からは少しだけ離れ、「いつもの休日を楽しくすることで人生を豊かにする」ことを目指したサービスです。
例えばこちらのおでかけプランのように、「〇〇に行くならここも行ったほうがいいよ」や「〇〇を散策するならこのコースだよね」など、おでかけのレシピを投稿したり探すことができるようになっています。
今回は、全文検索エンジン Elasticsearch を使って、全文検索と位置情報を絡めた検索についてお話したいと思います。
本稿で説明する内容は、実際に Holiday の中でも応用を加えた形で使われています。
Holiday では、複数のおでかけスポットを組み合わせて一つのおでかけプランを作ることができます。
例えばそのおでかけプランの作成画面では、登録するおでかけスポットを選ぶ時に、京都のおでかけプランなら京都のスポットを優先的に掲示することで、スムーズなプラン作成を可能にしています。
また、おでかけプランの詳細画面においては、緯度経度情報を加味したおでかけプラン同士の関連性を計算し、関連度の高いものを合わせて表示することで、週末のおでかけ先を探している人がより行き先を決めやすくなるような仕組みを作っています。
このような位置情報を応用した様々な機能が、Elasticsearch を使うことによって簡単に実現できます。
なお本稿に含まれるサンプルコードは、GitHub 上で公開しています。
https://github.com/9toon/es-geo-sample
前準備
まず始めに、必要なデータを用意します。
今回は、名称・住所・緯度経度の情報を持つおでかけスポットを用意します。
テーブル定義は以下のようになります。
create_table(:spots) {|t|
t.string :name
t.string :address
t.decimal :lat, precision: 9, scale: 6
t.decimal :lon, precision: 9, scale: 6
}
このテーブルに、いくつかのスポット情報を登録します。
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
は、日本語での全文検索を行いたいので analyzer
に kuromoji
を指定します。
また、位置情報と絡めた検索機能を利用するために、location
というフィールドを作り geo point typeを指定します。
ただし、データベース内では緯度経度情報を lat
, lon
という形式で保持しているので、Elasticsearchにデータを流し込む際には geo_point
タイプの書式に合うように加工する必要があります。
elasticsearch-model
では as_indexed_json
メソッドを使うことで、Elasticsearch に渡すデータをカスタマイズできます。
classSpot< ActiveRecord::BaseincludeElasticsearch::ModelincludeElasticsearch::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'enddefas_indexed_json(options = {})
{ 'id' => id,
'spot_name' => name,
'address' => address,
'location' => "#{lat},#{lon}",
}
endend
Elasticsearch にデータを流し込む準備ができたので、実際にドキュメントを登録します。
Spot.import(force: true)
ここまでで、前準備は終了です。
なおサンプルでは、以下のコマンドを打つことで、ここまでの処理を行うことができます。
$ bundle exec ruby import.rb
距離順での並び替え
登録しているおでかけスポットを、ある地点からの距離順にソートしてみます。
今回は、阪急京都線河原町駅(lat: 35.003765, lon: 135.769463
)からの距離が近い順に並び替えてみます。
以下のように設定することで、距離順に並び替えることができます。
classSpot< ActiveRecord::Baseclass<< selfdefsort_by_distance(lat, lon)
body = {
sort: {
_geo_distance: {
location: {
lat: lat,
lon: lon,
},
order: 'asc',
unit: 'meters',
}
}
}
Spot.__elasticsearch__.search(body)
endendend
_geo_distance
の中では、中心点を定める location
、ソート方向を定める order
、そして単位を定める unit
を指定しています。
なお今回のサンプルでは、ソート処理や絞り込み処理が適切に行われたかどうかを確かめやすくする目的で、各スポットと中心点との距離を算出する script
を含めることにします。
この script
を含めたメソッドの全体像は以下のようになります。
classSpot< ActiveRecord::Baseclass<< selfdefsort_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)
endprivatedefcalc_distance_script(lat, lon)
{ distance: {
params: {
lat: lat,
lon: lon,
},
script: "doc['location'].distance(lat,lon)",
}
}
endendend
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
を使った場合、以下のように書くことで、このフィルターを使うことができます。
classSpot< ActiveRecord::Baseclass<< selfdefspots_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)
endendend
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
)を記載でき、それぞれの条件でのスコアを合算した値を使ってソートを行います。
今回はサンプルとして、スポット名および住所で全文検索を行い、その結果を 検索クエリとの関連度
と 距離
を考慮して並び替えたものを返すメソッドを作りました。
classSpot< ActiveRecord::Baseclass<< selfdefsearch_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),
}
endendend
観光地に出向いて周辺のおでかけスポットを検索する際、徒歩で行けるくらい近いスポットについては、数メートルの違いはそれほど重要ではなかったりします。
ただ、これがもう少し遠くなり、少し歩いてでも行きたいかどうか悩むような距離にあるスポットについては、距離の違いが重要な判断材料になってきます。
そして、これがさらに遠くなって、歩くにはちょっと厳しいなという距離になってくると、今度はまた距離の重要性が低くなります。
本サンプルでは、上記の感覚を検索結果に反映するために 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エンジニアを募集! by クックパッド株式会社
Holidayで日本の休日を楽しくしたいiOSエンジニアを募集! - クックパッド株式会社の求人 - Wantedly