Elasticsearch で位置情報を扱う

この記事は [CBcloud Advent Calendar 2020](https://qiita.com/advent-calendar/2020/cbcloud) の記事です。

弊社物流テックな企業なのに記事に特色出てないなと思ったのでそれっぽい記事書いてみたいなと思います。
今回は位置情報 x キーワードによる検索を Elasticsearch で実現させた方法を記していこうと思います。

# Elasticsearch と位置情報
Elasticsearch といえば検索エンジンとして有名ですが、実は位置情報を扱うことも出来ます。
[Geo-point](https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-point.html) という形式でドキュメントに位置情報を保存すると解釈してくれます。

ドキュメントにも記述がありますが緯度（lat）経度（lng）を 6 種類の形式のどれかで保存すると扱うことが出来ます。
※ それぞれの良し悪しはわかってないです。。

できることとしては以下のようなことが上げられます。

- **geo_distance 句を用いた X km 圏内の検索**
- 距離によるソート
- 距離の算出
- aggregation によるエリア内の集計

実際のプロダクトでも使っている「geo_distance 句を用いた X km 圏内の検索」を中心に説明していきます。

## ドキュメントのマッピング
ドキュメントを Elasticesarch に投入する際のマッピングは以下のようにしています。
geopoint は String 型（`"#{lan},#{lng}"`）の形式で保存しています。

```json:mapping.json
{
    properties: {
      name: { type: "text", store: true },
      name_kana: { type: "text", store: true },
      location: { type: "geo_point", store: true },
    },
}
```

```json:import
[
  { index: { _index: shops, _type: "_doc", _id: id } },
  {
    name: "テスト商店",
    name_kana: "てすとしょうてん",
    location: "33.333333,137.12345",
  }
]
```
※ 緯度経度は適当です

## X km 圏内の検索

geo_distance 句を使って x km 圏内のドキュメントを検索する方法を実現させていきます。
例えばある地点（lat, lng で指定の場所）から 50 km 圏内のドキュメントを返すようにするであれば `distance: "50km"` と指定するだけで実現できます。
ちなみに geo_distance は半径 X km 圏内の検索に使われますが、
geo_bounding_box（四角のエリア指定してその内部を検索）など他にも位置情報検索クエリがあります。

また、keyword の部分に検索ワードを入れるとワードと位置情報による検索の実現ができます。
must とか should, filter の違いについての詳しい説明は割愛しますが AND や OR 条件を表します。

```json:query
GET shops/_search
{
  query: {
    bool: {
      must: [
        {
          bool: {
            should: [
              { match: { name: keyword } }
            ]
          }
        }
      ],
      filter: [
        {
          geo_distance: {
            distance: "50km",
            location: {
              lat: 34.444444,
              lon: 138.12345
            }
          }
        }
      ]
    }
  }
}
```

## 距離計算
クエリの最中に `script_fileds` を仕込むとある地点からの距離を算出してくれます。
Elasticsearch のバージョンによってサポートしているスクリプト言語や書き方が異なるので注意が必要です。
`lang` というキーを指定すると選べますがデフォルトで `painless` が使用されます。 
ここでは Elasticsearch 7 系の書き方で紹介します。

```json:query
GET shops/_search
{
   "script_fields" : {
      "distance" : {
         "script" : {
           "source": "doc['location'].arcDistance(params.lat, params.lon)",
           "params": {
                "lat": 33,
                "lon": 137
            }
         }
      }
   }
}
```

以下結果

```json
{
  "_index" : "shops",
  "_type" : "_doc",
  "_id" : "xxxxxxx",
  "_score" : 1.0,
  "fields" : {
    "distance" : [
      232561.3814788525
    ]
  }
}
```


## ソート
ソートはクエリも簡単で以下のように書きます。
位置情報を使っているとたいてい距離順に表示すると思うのですがこんな感じでできます。
ちなみに sort 句に `_geo_distance` を用いるとソート時に計算した距離をそのまま返してくれるので script を用いて計算する必要ありません。
`sort` というキーが結果に含まれるのでそれを取り出すだけ。

```json:query
{
  query: {
    sort: [
    {
      _geo_distance: {
        location: {
          lat: 33.33333,
          lon: 137.1234567
        }
      }
    }
  }
}
```


# おまけ: Kibana を用いた可視化
`/app/maps` にアクセスすると地図が表示されます。
以下はサンプルで表示したドキュメントを地図上にマッピングした結果です。

![20200329090052.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119099/44b681c4-1a94-f02c-d177-156697816ccd.png)

Kibana でクエリを書くとエリア指定もできます。
たとえば「フォッサマグナ以東で佐渡ヶ島以南のドキュメント」を調べたいときは以下のようなクエリを入力します。

```
longitude >= 138.23553 and latitude <= 37.0670589
```

![20200329090312.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/119099/a7aed9bf-813e-085d-bc04-6b4f121476d2.png)



# まとめ
Elasticsearch で位置情報とキーワード検索を組み合わせを実現させる方法を説明していきました。
冒頭にも書きましたが、物流テック企業をやっていると「距離によって…」みたいなロジックが多々あるため検索と同時に要件を満たせるのが非常に助かります。
それでいて今の所パフォーマンスに関しても問題ないので結果用いて正解だったなと思います。
今後、検索可能なドキュメント数や増えていったときにどうなるかが怖くもあり楽しみです。。