rails + ElasticSearchで全文検索だけでなく地理情報も扱う

### 背景

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

位置情報サービスを構築するとき、データベースは何を使うでしょうか。 
[mongoDB](http://www.mongodb.org/)には[地理空間インデックス](http://docs.mongodb.org/manual/core/geospatial-indexes/)が用意されているため、有効な選択肢になり得ます。

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

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

### 準備

railsからElasticSearchを利用する準備は[railsから全文検索エンジンelasticsearchを利用する](http://k-shogo.github.io/article/2013/09/03/rails-elasticsearch/)を参考にしてください。  

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

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

### テーブル定義

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

```ruby:create_pins.rb
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](https://github.com/karmi/tire)のgemを使用するので、`Tire::Model`をincludeします。

```ruby:app/models/pin.rb
include Tire::Model::Search
include Tire::Model::Callbacks
```

マッピングの設定に関して、`name`と`address`は日本語での検索を行いたいので、`analyzer`に`kuromoji`を指定します。
ここでのポイントは`indexes :location, type: :geo_point, lat_lon: true`です。
これは`location`というフィールドに地理情報を扱うための[geo-point-type](http://www.elasticsearch.org/guide/reference/mapping/geo-point-type/)を指定しています。
ただし、位置情報は`latitude`と`longitude`に格納していました。
そのためElasticSearchに渡す情報を加工することにします。

```ruby:app/models/pin.rb
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`を定義します。

```ruby:app/models/pin.rb
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を以下のように設定することが出来ます。

```json
{
  "name": "テスト",
  "address": "日本, 東京都新宿区戸山３丁目１９−１",
  "location": {
    "lat": 35.70675638058483,
    "lon": 139.71004486083984
  }
}
```

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

```ruby:app/models/pin.rb
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
```

検索は`PinsController`の`index`で行うことにしましょう。

```ruby:app/controllers/pins_controller.rb
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`に検索フォームをつけました。

```haml:app/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
```

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