📐

geocoderとquadkeyで近くのお店のカレンダーを検索する

に公開
2025/12/25

この記事は TimeTree Advent Calendar 2025 の16日目の記事です。
https://qiita.com/advent-calendar/2025/timetree

はじめに

TimeTreeには近くのお店(スーパー・ドラッグストア・家電量販店・ホームセンター)のカレンダーを検索する機能があります。

今日は、TimeTreeのバックエンド(Ruby on RailsのAPI)で、ユーザーさんの位置情報(緯度経度)から近くのお店のカレンダーをどのように取得しているかを、使っているgemの紹介を通じてお伝えします。

この機能では主に以下の2つのgemを利用しています。

geocoder

ユーザーさんとお店との距離の計算にはgeocoderを利用しています。

お店のカレンダーには緯度と経度のカラムを用意して値を保存しておきます。
この値とユーザーさんからのリクエストに含まれる緯度経度を用いることで、2点間の距離(大円距離)を計算してくれます。

class ExampleCalendar < ApplicationRecord
  # 緯度/経度として扱うカラムを指定
  reverse_geocoded_by :latitude, :longitude

  def distance_km(target_lat:, target_lon:)
    # ユーザーさんの緯度経度の示す位置との距離をkm単位で返す
    distance_from([target_lat, target_lon], :km)
  end
end

現在TimeTreeではgeocoderをこの用途でしか利用していませんが、実際には住所から緯度経度を取得するForward Geocoding、緯度経度から住所を取得するReverse Geocoding、IPアドレスからの位置情報取得など、数多くの機能を持っています。

geocoder以外の案

球面余弦定理を用いた大円距離の計算をクエリとして投げるというアイデアもありましたが、手軽さを重視して今回はgeocoderを利用しました。以下の値に地球の半径をかけることで2点間の距離が求められます。

ACOS(
  SIN(RADIANS(user_lat)) * SIN(RADIANS(shop_lat))
  + COS(RADIANS(user_lat)) * COS(RADIANS(shop_lat))
  * COS(RADIANS(user_lon - shop_lon))
)

なお、geocoderの内部ではHaversine公式という式を使って距離を計算しているようでした。
https://github.com/alexreisner/geocoder/blob/8143fce1820539cdaf6344c3400edf5690ec1611/lib/geocoder/calculations.rb#L84-L101

quadkey

さて、geocoderさえあれば、全カレンダーに対して距離を計算し、近い順にクライアントに返すだけで近くのお店取得APIは作れます。実際、TimeTreeでも店舗数が少ない当初は問題なく動作していました。
しかし、店舗数の拡大に伴い距離計算を行うデータ数が増加し、レイテンシが無視できない状態になってきてしまいました。

この問題を解決するため、quadkeyを利用してカレンダーを絞り込む仕組みを導入しました。

quadkeyは、地理的位置を階層的なタイル座標として表現する仕組みです。
地球全体を4つのタイルに分割し、さらにそれぞれを4つに分割し...と繰り返すことで、精度の異なる位置情報を文字列で表現します。
精度レベルを上げるほどタイルは細かくなり、1タイルあたりのカバー範囲は小さくなります。

例えば、ある地点の緯度経度を精度レベル6で変換すると以下のようになります。

Quadkey.encode(latitude, longitude, 6) # => "133002"

この文字列は、精度レベル6でその地点を特定するタイル座標です。この座標の割り当て方の重要な性質は、精度レベルが1つ低い(広い範囲を示す)座標が、この文字列の左側部分一致になることです。例えば、レベル5は"13300"、レベル4は"1330"となります。

この性質により、前述のテーブルにlocation_hashカラムを作り、location_hash LIKE '1330%'のようなLIKE検索を行うと、前方一致であるためインデックスが効き、特定のタイルとその周辺のレコードを効率的に絞り込めます。

TimeTreeでは、カレンダーの位置情報を保存する際に緯度経度から前述のタイル座標を生成し、位置情報のテーブルに保存しています。
近くのお店のカレンダーを取得する際は、まずユーザーさんの位置からquadkeyを生成します。

user_quadkey = Quadkey.encode(user_lat, user_lon, precision)

次に Quadkey.neighbors でユーザーの位置のタイル自身と、その周囲8つのタイルを取得します。これはユーザーさんの実際の位置が user_quadkey のタイルの境界近くにいるケースをケアするためです。

values = Quadkey.neighbors(user_quadkey).map { |v| "#{v}%" }

これらを用いて検索条件を作り絞り込んだ後、geocoderで正確な距離を計算します。

conditions = values.map { |val| ActiveRecord::Base.sanitize_sql(["location_hash LIKE ?", val]) }.join(" OR ")
filters = ExampleCalendar.where(conditions)
                         .map { |filter| [filter.id, filter.distance_km(lat:, lon:)] }
                         .select { |_, distance| distance <= max_distance }
                         .sort_by { |_, distance| distance }

データベースで効率的に絞り込んでから正確な距離計算を行うことで、パフォーマンスを大幅に改善できました。

quadkey以外の案

緯度と経度のカラムに直接インデックスを貼り、ユーザーさんから受け取った緯度経度を中心に一定の範囲で検索を行う方法も考えられます。
しかし、経度1度あたりの距離は緯度によって変化するため、検索の精度もユーザーさんの現在地の緯度によって変化することになってしまいます。

また、quadkeyと同様に2次元の位置情報を1次元の文字列に変換する仕組みとしてgeohashも検討しました。
geohashも前方一致検索でデータを絞り込める点はquadkeyと同じですが、実際にパフォーマンスを計測したところ、現在のユースケースにおいてはquadkeyの方が高速でした。


quadkeyが60-400msであるのに対して、geohashでは150-800msになりました。

おわりに

この記事では、近くのお店検索機能を題材に、位置情報を扱う2つのgemを紹介しました。
geocoderによる距離計算と、quadkeyによる絞り込みを組み合わせることで、店舗数の増加にも対応できるパフォーマンスを実現できました。
同様の機能を実装する際の参考になれば幸いです!

TimeTree Tech Blog

Discussion