ちょっと話題の記事

Elasticsearch の位置検索(Geolocation)を学ぶ

2017.10.25

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

ども、藤本です。

最近、Elasticsearch を使うプロダクト開発に本格参画し、Elasticsearch を改めて勉強しています。機能レベルで理解していても、要求を実現するためにインデックス構造、クエリを設計・実装するのは難しいですが、それ以上に面白い!今回は位置検索について調べたことをまとめました。

概要

GPS 対応デバイスの普及に伴い、位置情報による検索は多くのシステムに必要となってきました。例えば、お腹すいた時に現在地から近い食事処を検索します。ただ位置情報で検索できればいいわけではなく、定食屋、焼肉屋、カレー屋などカテゴリで絞りたいですし、今現在オープンしているお店だけに絞りたいですし、近さとともに食事処の評価・スコアを踏まえてソートして欲しいです。ユーザーはワガママです。

著名な RDB でも位置情報の検索にも対応しています。例えば、MySQL では、geometry型があり、位置情報を扱い、検索することができます。ただ MySQL の位置検索では自由なソート、自由な検索条件が難しく、ユーザーのワガママをアプリケーション側で実装する必要があります。

一方、Elasticsearch でも位置情報を扱うことができます。Elasticsearch はただ位置情報を扱えるだけでなく、Elasticsearch 本来の柔軟なクエリを損なうことなく利用することができ、ユーザーのワガママを Elasticsearch で解決することができるかもしれません。

Elasticsearch と位置情報

Elasticsearch の位置情報の取り扱いは Definitive Guide で章で取り扱われているぐらい力を入れている?項目となります。ちゃんと学びたい方は Definitive Guide は非常に参考になります。

というと終わってしまうので簡単に情報をまとめました。

データタイプ

Elasticsearch の位置情報は 2つのデータタイプを扱うことができます。一つはgeo_pointという緯度・経度による位置(例えば、今いる場所、お店の場所など)、もう一つはgeo_shapeという緯度・経度の配列による範囲(例えば、東京都の範囲、日本の領域など)です。今回はgeo_pointに関してまとめます。

geo_pointタイプ

geo_pointは前述した通り、緯度・経度の 2つの数値により成り立ちます。geo_pointタイプのフィールドに配列で経度・緯度 2つの数値でもよいですし、文字列内に,(カンマ)区切りで緯度・経度 2つの数値でもよいですし、Object でlatlonで数値を持ってもいいです。

例えば、location フィールドがgeo_pointタイプだとして、↓のどれでも OK です。

{ "location": [139.7725301, 35.6972434] }
{ "location": "35.6972434, 139.7725301" }
{ "location": {
    "lat": 35.6972434, 
    "lon": 139.7725301
}

注意点としては、2つです。

  • dynamic mapping で自動検出されないのでマッピング設定が必須
  • 配列と文字列で緯度・経度の順番が逆

検索

大きく 4つの検索方法が存在します。ざっくり絵に描いてみました。

検索

円形範囲検索(geo_distanceクエリ)

あるポイントから一定距離の範囲で検索します。中心となる緯度・経度とそこからの距離を指定します。

ドーナツ型範囲検索(geo_distance_rangeクエリ)

あるポイントから一定距離の距離から距離の範囲で検索します。中心となる緯度・経度とそこから Min/Max の距離を指定します。

四角範囲検索(geo_bound_boxクエリ)

あるポイントからポイントの四角の範囲で検索します。北東端と南西端それぞれの緯度・経度を指定します。

任意の範囲検索(geo_polygonクエリ)

あるポイントを繋いでできた多角形の範囲で検索します。繋ぎたいポイントの緯度・経度で配列で指定します。

また円形範囲検索、ドーナツ範囲検索においては同時に中心からの距離も知りたいかと思います。もちろん取得可能です。

集計

大きく 2つの検索方法が存在します。こちらもざっくり絵に描いてみました。

集計

円形範囲集計(geo_distance Aggregation)

あるポイントからの距離の範囲(ドーナツ型)単位で集計します。中心となる緯度・経度と各範囲の距離を指定します。

四角範囲集計(geohash_grid Aggregation)

規定の範囲(Geohash)の四角の範囲で集計します。規定の範囲は変更できませんが、1(5,009.4km x 4,992.6km)〜12(3.7cm x 1.9cm)の値で規定の範囲を指定できます。

試してみた

それでは位置情報のデータ登録、検索、集計を試してみましょう。

データ登録

まずはデータ登録です。今回はクラスメソッドの日本のオフィスの緯度・経度情報を登録します。前述した通り、geo_point型は dynamic mapping で検出されませんので、事前にインデックスのマッピング設定を行います。

PUT classmethod
{
  "mappings": {
    "type": {
      "properties": {
        "name": {
          "type": "keyword"
        },
        "location": {
          "type": "geo_point"
        }
      }
    }
  }
}

続いて、データ登録です。配列指定の場合は経度が先です。

POST classmethod/type/_bulk
{"index": {}}
{"name": "Akihabara", "location": [139.7733195, 35.6972498]}
{"index": {}}
{"name": "Iwamoto-Cho", "location": [139.7769758,35.6939821]}
{"index": {}}
{"name": "Sapporo", "location": [141.3534235,43.0644717]}
{"index": {}}
{"name": "Osaka", "location": [135.4940354,34.6890398]}
{"index": {}}
{"name": "Fukuoka", "location": [130.4218306,33.5903928]}

検索

続いて検索です。今回はgeo_distance検索を行います。秋葉原駅から 1km 圏内のオフィスを検索します。

request
GET classmethod/_search
{
  "query": {
    "geo_distance": {
      "distance": "1km",
      "location": {
        "lat": 35.6983573,
        "lon": 139.7709256
      }
    }
  }
}
response
{
  "took": 3,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": 1,
    "hits": [
      {
        "_index": "classmethod",
        "_type": "type",
        "_id": "AV9NKJx0GlWksJ6sN7ay",
        "_score": 1,
        "_source": {
          "name": "Iwamoto-Cho",
          "location": [
            139.7769758,
            35.6939821
          ]
        }
      },
      {
        "_index": "classmethod",
        "_type": "type",
        "_id": "AV9NKJx0GlWksJ6sN7ax",
        "_score": 1,
        "_source": {
          "name": "Akihabara",
          "location": [
            139.7733195,
            35.6972498
          ]
        }
      }
    ]
  }
}

岩本町オフィスと、秋葉原オフィスがヒットしました。ただどちらが近いのか分かりません。

距離でソート、もしくは距離を取得したい場合は特殊なソートキーの_geo_distanceソートを使用します。

request
GET classmethod/_search
{
  "query": {
    "geo_distance": {
      "distance": "1km",
      "location": {
        "lat": 35.6983573,
        "lon": 139.7709256
      }
    }
  },
  "sort": [
    {
      "_geo_distance": {
        "location": {
          "lat": 35.6983573,
          "lon": 139.7709256
        }
      }
    }
  ]
}
response
{
  "took": 10,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 2,
    "max_score": null,
    "hits": [
      {
        "_index": "classmethod",
        "_type": "type",
        "_id": "AV9NKJx0GlWksJ6sN7ax",
        "_score": null,
        "_source": {
          "name": "Akihabara",
          "location": [
            139.7733195,
            35.6972498
          ]
        },
        "sort": [
          248.78544511741777
        ]
      },
      {
        "_index": "classmethod",
        "_type": "type",
        "_id": "AV9NKJx0GlWksJ6sN7ay",
        "_score": null,
        "_source": {
          "name": "Iwamoto-Cho",
          "location": [
            139.7769758,
            35.6939821
          ]
        },
        "sort": [
          731.5673533835386
        ]
      }
    ]
  }
}

直線距離で秋葉原オフィスまで 248m、岩本町オフィスまで 731m です。

集計

続いて集計です。今回はgeohash_grid Aggregationで集計を行います。範囲は 5(4.9km x 4.9km単位)で集計します。

request
GET classmethod/_search
{
  "size": 0, 
  "aggs": {
    "1": {
      "geohash_grid": {
        "field": "location",
        "precision": 5
      }
    }
  }
}
response
{
  "took": 0,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 5,
    "max_score": 0,
    "hits": []
  },
  "aggregations": {
    "1": {
      "buckets": [
        {
          "key": "xn77h",
          "doc_count": 2
        },
        {
          "key": "xpssb",
          "doc_count": 1
        },
        {
          "key": "xn0m7",
          "doc_count": 1
        },
        {
          "key": "wvuxp",
          "doc_count": 1
        }
      ]
    }
  }
}

4つのグループで返ってきました。xn77hが 2件ヒットしているので秋葉原近辺なんでしょうが、他がサッパリ分かりません!返ってきた範囲を分かりやすくするのが、geo_bounds Aggregation、geo_centroid Aggregation です。geo_bounds Aggregation は集計範囲となる四角形の対角となる 2点の緯度・経度の情報を付加します。geo_centroid Aggregation は集計範囲となる中心の 緯度・経度の情報を付加します。今回はgeo_bounds Aggregation を使用します。

request
GET classmethod/_search
{
  "size": 0, 
  "aggs": {
    "1": {
      "geohash_grid": {
        "field": "location",
        "precision": 5
      },
      "aggs": {
        "2": {
          "geo_bounds": {
            "field": "location"
          }
        }
      }
    }
  }
}
response
{
  "took": 3,
  "timed_out": false,
  "_shards": {
    "total": 5,
    "successful": 5,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": 5,
    "max_score": 0,
    "hits": []
  },
  "aggregations": {
    "1": {
      "buckets": [
        {
          "2": {
            "bounds": {
              "top_left": {
                "lat": 35.69724979810417,
                "lon": 139.77331942878664
              },
              "bottom_right": {
                "lat": 35.69398207124323,
                "lon": 139.77697578258812
              }
            }
          },
          "key": "xn77h",
          "doc_count": 2
        },
        {
          "2": {
            "bounds": {
              "top_left": {
                "lat": 43.06447166483849,
                "lon": 141.3534234277904
              },
              "bottom_right": {
                "lat": 43.06447166483849,
                "lon": 141.3534234277904
              }
            }
          },
          "key": "xpssb",
          "doc_count": 1
        },
        {
          "2": {
            "bounds": {
              "top_left": {
                "lat": 34.68903978355229,
                "lon": 135.49403532408178
              },
              "bottom_right": {
                "lat": 34.68903978355229,
                "lon": 135.49403532408178
              }
            }
          },
          "key": "xn0m7",
          "doc_count": 1
        },
        {
          "2": {
            "bounds": {
              "top_left": {
                "lat": 33.59039276372641,
                "lon": 130.4218305554241
              },
              "bottom_right": {
                "lat": 33.59039276372641,
                "lon": 130.4218305554241
              }
            }
          },
          "key": "wvuxp",
          "doc_count": 1
        }
      ]
    }
  }
}

集計値に加えて北東端、南西端の緯度・経度情報が付加されました。

まとめ

いかがでしたでしょうか?今回は非常に簡単に利用できました。今回は位置情報に絞って検索、集計を行いましたが、その他、クエリ、Aggreagation との組み合わせはもちろん可能であり、様々な要件を 1クエリで実装することができます。例えば、カテゴリを絞りたいようであれば、terms クエリを、今現在オープンしているお店に絞りたいようであれば range クエリを、ソートに距離と合わせて評価・スコアを加味させたい場合はスクリプティングソートで実装できるのではないでしょうか。

参考サイト