ElasticSearchで地理情報も扱う

背景

昨今のスマートフォンの普及は目覚ましく、位置情報を利用したサービスも増えてきました。

位置情報サービスを構築するとき、データベースは何を使うでしょうか。
mongoDBには地理空間インデックスが用意されているため、有効な選択肢になり得ます。

位置情報と全文検索を組み合わせて検索したいときはどうでしょうか。
mongoDB + ElasticSearchなどの複数のデータベースという選択肢もありますが、条件の組み合わせ処理が煩雑になってしまいます。

今回はElasticSearchで全文検索と位置情報の両方を扱ってみます。

準備

railsからElasticSearchを利用する準備は“railsから全文検索エンジンelasticsearchを利用する"を参考にしてください。

今回作成するサンプルの要点をまとめます。

  • 保存する単位は単一の位置情報を持つ「ピン」とする
  • ピンは「ピンのタイトル」と「住所の文字列」、「位置情報」を持つ
  • ピンはタイトルや住所などで全文検索することが出来る
  • ピンは特定の位置情報を中心として半径N km以内の条件で検索することが出来る
  • 全文検索と位置情報の検索は組み合わせることが出来る

テーブル定義

まずテーブルの定義をします。ピンに自由につける名前をname、住所の情報をaddressとします。
位置情報はlatitudelongitudeです。

Migration

class CreatePins < ActiveRecord::Migration
  def change
    create_table :pins do |t|
      t.string :name
      t.string :address
      t.decimal :latitude, precision: 20, scale: 15
      t.decimal :longitude, precision: 20, scale: 15

      t.timestamps
    end
  end
end

次にmodelの設定を行います。
今回もtireのgemを使用するので、Tire::Modelをincludeします。

include Tire::Model::Search
include Tire::Model::Callbacks

マッピングの設定に関して、nameaddressは日本語での検索を行いたいので、analyzerkuromojiを指定します。
ここでのポイントはindexes :location, type: :geo_point, lat_lon: trueです。
これはlocationというフィールドに地理情報を扱うためのgeo-point-typeを指定しています。
ただし、位置情報はlatitudelongitudeに格納していました。
そのためElasticSearchに渡す情報を加工することにします。

tire do
  mapping do
    indexes :name, analyzer: :kuromoji
    indexes :address, analyzer: :kuromoji
    indexes :location, type: :geo_point, lat_lon: true
  end
end

ElasticSearchに渡す情報をカスタマイズするには.to_indexed_jsonを定義します。

def to_indexed_json
  {
    name: name,
    address: address,
    location: location
  }.to_json
end

def location
  {lat: latitude.to_f, lon: longitude.to_f}
end

これでElasticSearchに渡るJSONを以下のように設定することが出来ます。

{
  "name": "テスト",
  "address": "日本, 東京都新宿区戸山3丁目19−1",
  "location": {
    "lat": 35.70675638058483,
    "lon": 139.71004486083984
  }
}

最後に検索ためのメソッドを用意します。

def self.search(params)
  tire.search(load: true) do
    query {
      string "name:#{params[:search]} address:#{params[:search]}"
    } if params[:search].present?
    filter :geo_distance, {
      distance: "#{params[:distance].present? ? params[:distance].to_f : 10}km",
      location: {lat: params[:lat].to_f, lon: params[:lon].to_f}
    } if params[:lat].present? && params[:lon].present?
  end
end

検索はPinsControllerindexで行うことにしましょう。

class PinsController < ApplicationController
  before_action :set_pin, only: [:show, :edit, :update, :destroy]

  def index
    # @pins = Pin.all
    @pins = Pin.search params
  end

~以下略~

views/pins/index.html.hamlに検索フォームをつけました。

= form_tag pins_path, method: :get do
  search query
  = text_field_tag :search, params[:search]
  lat
  = text_field_tag :lat, params[:lat]
  lon
  = text_field_tag :lon, params[:lon]
  distance
  = text_field_tag :distance, params[:distance]
  = submit_tag "Search", name: nil

これで位置情報と全文検索を組み合わせて利用可能です。