この記事は、ElasticsearchのGEO系検索のうち、GeoShape DSL(geo_shape)を試してみたものになります。
次の記事の続きです。
また、次の別の記事でGeoPandasを使って(あまりシェープファイルやGeoJSONといったGIS用データ形式がわかっていなくても)、オープンデータのシェープファイルをGeoJSONに変換する例を扱っているのですが、これを下敷きに、先のElasticsearchのGEO検索記事よりもリアリティのある検索を試してみています。
縁もゆかりもなくて恐縮ですが、元にしたデータで私の中で「琵琶湖」がポイントになったことから「滋賀県野洲市」にからめて記事にしてあります。
この記事でのElasticsearch GEO系検索のお試し事項イメージ
◆ElasticsearchのMapping設定
ここでは、exampleというインデックスとしています。 また、geo_shape(おおよそGeoJSON形式)のフィールドとして、「location」という名前のフィールドを用意しています。 「location_point」の方はgeo_point形式を意識しています。geo_pointは今回の例でいうと、駅データなど、「地点」のデータがあてはまります。
なお、ここでは、Elasticsearch6.8で試しているのですが、ver7系だと、「"strategy": "quadtree"」というところは不要になります。多分ですが...試していません。ごめんなさい。
PUT /example?include_type_name=false { "mappings": { "properties": { "location": { "type": "geo_shape", "strategy": "quadtree" }, "location_point": { "type": "geo_point" } } } }
◆データインポート用のPythonプログラム
前提
GeoPandas、Pandas および PythonのElasticsearchクライアントを使用しています。PythonのElasticsearchクライアントはbulkロードに使っている。
【参考リンク】PythonのElasticsearchクライアントはこちらです。 elasticsearch-py | Elastic
また、GeoPandasの前の記事で紹介した、国土地理院のデータを使っていますので、前の記事も参考にデータをダウンロードしておいてください。
ダウンロードしたファイルの次のファイルなどをインプットにしています。 (今回の記事を試すだけなら、これらのデータの知識がなくてもなんとかなります。)
- inwatera_jpn.shp
- polbnda_jpn.shp
- raill_jpn.shp
- riverl_jpn.shp
- rstatp_jpn.shp
これらのデータを元に、グラフとしてデータをプロットするとこんな感じ↓になるようなデータです。
シェープファイルのGeoJSON形式変換〜Elasticsearchバルクロードのサンプルプログラム
前述の国土地理院の 地球地図日本の「全レイヤ」データ*1 をカレントディレクトリにおいて、次のPythonプログラムを実行してください。
なお、そこそこの処理なので、処理時間を要すると思います。私の手元の環境だと、2、3分というところ。
また、(かなりはしょった記事であることから行間を読んでいただく必要があるので)ここまで読んでいただいている方なら自明と思いますが、以下のプログラムでは、ローカルのElasticsearchのインデックスを更新しますので、ご注意ください。
import geopandas as gpd from matplotlib import pyplot as plt import pandas as pd import json from elasticsearch import Elasticsearch from elasticsearch.helpers import bulk import sys # 行政区のデータをロードして、dissolveで都道府県レベルの固まりのデータに変換する bnd = gpd.read_file('polbnda_jpn.shp') pref = bnd.dissolve(by='nam').reset_index() # 河川、駅、水域(沼とか湖らしい)のデータを読み込み # 名前(namフィールド)が"UNK" (UNKNOWNの略と思われる)ものは除外して、名前が同じものをdissolve(GeoJSONデータを結合して、よりおおきなひとかたまりのGoeJSONにする...ための準備)する riverl = gpd.read_file('riverl_jpn.shp').query('"UNK" not in nam').dissolve(by='nam').reset_index() rstatp = gpd.read_file('rstatp_jpn.shp').query('"UNK" not in nam') inwatera = gpd.read_file('inwatera_jpn.shp').query('"UNK" not in nam').dissolve(by='nam').reset_index() # 琵琶湖と野洲市 BIWAKO = inwatera[inwatera['nam'].str.contains('BIWA')] YASU_CITY = bnd[bnd['laa'].str.contains('Yasu Shi')] # ここまでの途中状況確認として、 # 琵琶湖と野洲市周辺をメリハリつけつつ、日本全体をmatplotlibで可視化してみる(ラッパーとして、Pandasのplotを使っているので楽チン) # ※可視化は不要、matplotlibのインストールがめんどくさいという人は、コメントアウトしてください。 ax = pref.plot(color='#fffacd',edgecolor='gray') riverl.plot(ax=ax,color='blue') BIWAKO.plot(ax=ax,color='cyan') YASU_CITY.plot(ax=ax,color='orange') # 白地図が表示される ... (見てのとおり、コメントアウトしているので、コメントを外してください) #plt.show() # ここからElasticsearchにバルクロード ------------------------------ # 都道府県、駅、河川の3つのデータと今回の試験用にBIWAKOを縦に連結。 # (ここでいう連結は、図形の結合ではなくて、単にレコードの積み上げのこと) # → 駅は「Point」、他2つは複数の頂点を持つデータ(PolygonやLineStrings)ですが、 # Elasticsearchからすると、いずれもGeoJSON形式のフィールドを持つ「ドキュメント」なので、これらをいっしょくたに扱える。 gdf = pd.concat([BIWAKO,pref,riverl,rstatp]) # ★ Elasticsearchのエンドポイント endpoint = 'http://localhost:9200' indexname = 'example' es = Elasticsearch(endpoint) # GeoJSON化(文字列)した上で、Pythonのdictとして読み込み直しして、このあと取り扱いやすくする。 _geojson = json.loads(gdf.to_json()) list_of_features = _geojson['features'] # 以下、ElasticsearchのPythonクライアントを利用 # プロダクトレベルだともっと手順をふむ必要はあるが、ここではサンプルプログラムなので、最小限の例とした。 # geometry, coordinatesあたりが予約語になっている。 # いっしょくたに扱えると言ったが、「Point」形式はGeoJSONの全ての項目は不要なので座標部分のみピックアップする。 def locpoint(i): # GeoJSONの「Point」形式のデータ(ここでは「駅」データ)を考慮して、Elasticsearchのgeo_point形式に必要な部分(緯度経度のXY座標に対応する2要素の配列)をピックアップしています。 if i.get("geometry"): if i["geometry"]["type"] == 'Point': return i["geometry"]["coordinates"] return None actions = [ { "location" : i["geometry"], #Elasticsearchの Mapping設定参照 "location_point": locpoint(i), #Mapping設定参照 "myprops":i["properties"], # GeoJSONでは、propertiesというフィールド名が約束事と思われるが、Elasticsearchではそこまでは規定されていないので、理解の確認もかねてあえて「myprops」というフィールドに読み替え "_index": indexname, "_type": "_doc", } for i in list_of_features] #gdf.head()['geometry'].apply(lambda s: print(dir(s))) bulk(client=es,actions=actions)
GEO系検索おためし
事前確認:データがインポートできているか確認
本題の前に、データがインポートできているかmatch_all(検索条件無しで全件検索対象検索)確認してみます。
POST /example/_search?filter_path=took,hits.total { "query": {"match_all": {}} }
問題なければ、次のような値が戻ってきます。 (537件データが登録されている)
{ "hits" : { "total" : 537 } }
事前準備: 検索条件使い回し用の「野洲市」のシェープのElasticsearchドキュメント登録
GEO系検索、特にgeo_shapeをターゲットに検索する検索クエリでは、検索条件自体も「geo_shape」型の条件を指定することになります。
必須というわけではないですが、今回は野洲市のシェープを使って、野洲市に存在する駅は?、野洲市を流れる川は?、野洲市は何県に存在する? といった何回も「野洲市」のシェープを指定することになるので、「野洲市」のシェープ自体を「GEO検索の検索条件ショートカット用のドキュメント」として登録しておきます。
↓
// 野洲市のShapeデータ登録 // 型の定義 PUT /shapes?include_type_name=false { "mappings": { "properties": { "location": { "type": "geo_shape" } } } } // ドキュメント登録 // ドキュメントのIDは、「yasu_shi」としている! PUT /shapes/_doc/yasu_shi { "location": { "type": "Polygon", "coordinates": [ [ [ 135.959375020739, 35.14532027377727 ], [ 135.97456524189104, 35.18740934990323 ], [ 135.997587920824, 35.18877334774057 ], [ 136.00304690655102, 35.184681354228445 ], [ 136.004233642578, 35.162662531995906 ], [ 136.00589507301697, 35.14804826945223 ], [ 136.009217933894, 35.142007707600804 ], [ 136.01315256268703, 35.138129836126645 ], [ 136.015192923838, 35.13717206489428 ], [ 136.01670837402295, 35.13693237304693 ], [ 136.01779174804696, 35.137935638427656 ], [ 136.01950073242196, 35.13673400878911 ], [ 136.02040100097696, 35.137599945068416 ], [ 136.02220153808597, 35.13639831542969 ], [ 136.02330017089798, 35.137001037597685 ], [ 136.02619934081997, 35.13206863403317 ], [ 136.02670288085898, 35.13040161132814 ], [ 136.02920532226597, 35.13019943237296 ], [ 136.02940368652298, 35.12913513183592 ], [ 136.02929687499997, 35.12659835815432 ], [ 136.03179931640597, 35.124267578124986 ], [ 136.03089904785196, 35.1184005737305 ], [ 136.03419494628898, 35.1136665344238 ], [ 136.03610229492196, 35.11293411254883 ], [ 136.042297363281, 35.109733581543004 ], [ 136.04930114746097, 35.110000610351584 ], [ 136.058395385742, 35.10553359985346 ], [ 136.06520080566398, 35.10546874999997 ], [ 136.066299438477, 35.106334686279276 ], [ 136.07090759277298, 35.09740066528321 ], [ 136.077697753906, 35.09199905395512 ], [ 136.07710266113298, 35.08653259277336 ], [ 136.076095581055, 35.08539962768548 ], [ 136.077392578125, 35.081398010253935 ], [ 136.0791015625, 35.076133728027266 ], [ 136.079406738281, 35.067199707031286 ], [ 136.078994750977, 35.06560134887701 ], [ 136.079406738281, 35.06079864501947 ], [ 136.074295043945, 35.05680084228519 ], [ 136.072799682617, 35.05220031738283 ], [ 136.07209777832, 35.052799224853466 ], [ 136.05569458007798, 35.05646514892577 ], [ 136.05529785156298, 35.048198699951186 ], [ 136.054000854492, 35.0425338745117 ], [ 136.05250549316398, 35.03620147705082 ], [ 136.049606323242, 35.03173446655269 ], [ 136.04089355468798, 35.025333404540966 ], [ 136.03829956054696, 35.02619934082027 ], [ 136.02729797363295, 35.033798217773366 ], [ 136.01550292968804, 35.05066680908204 ], [ 136.00619506835898, 35.059131622314524 ], [ 136.00309753418003, 35.05906677246086 ], [ 135.99710083007804, 35.06273269653317 ], [ 135.99710083007804, 35.06506729125977 ], [ 135.99800109863304, 35.06486511230468 ], [ 136.00210571289102, 35.069267272949226 ], [ 136.00689697265597, 35.07073211669917 ], [ 136.00979614257795, 35.07920074462893 ], [ 136.01219177246097, 35.08366775512696 ], [ 136.00929260253898, 35.08653259277336 ], [ 136.00149536132804, 35.090801239013665 ], [ 136.00390625000003, 35.10113525390627 ], [ 135.99679565429696, 35.11100006103522 ], [ 135.99169921875003, 35.10946655273443 ], [ 135.99340820312503, 35.11753463745119 ], [ 135.99200439453097, 35.117267608642614 ], [ 135.98770141601602, 35.120067596435526 ], [ 135.98590087890597, 35.120067596435526 ], [ 135.98350524902304, 35.122001647949226 ], [ 135.97560119628903, 35.12593460083011 ], [ 135.959375020739, 35.14532027377727 ] ] ] } }
野洲市を横切る川(など): intersects
GET /example/_search?filter_path=**.type,hits.total,**.nam,**.laa { "query": { "bool": { "must": { "match_all": {} }, "filter": { "geo_shape": { "location": { "relation": "intersects", "indexed_shape": { "index": "shapes", "id": "yasu_shi", "path": "location", "type": "_doc" } } } } } } }
↓ 検索結果 (記事作成にあたりちょっとズルをしたので、ここまでの手順だと含まれないデータが含まれているかもしれませんがご容赦ください。以下の例も同様です。)
{ "hits" : { "total" : 4, "hits" : [ { "_source" : { "location" : { "type" : "LineString" }, "myprops" : { "nam" : "YASU G." } } }, { "_source" : { "location" : { "type" : "Polygon" }, "myprops" : { "nam" : "BIWA KO" } } }, { "_source" : { "location" : { "type" : "Polygon" }, "myprops" : { "nam" : "Shiga Ken" } } }, { "_source" : { "location" : { "type" : "MultiLineString" }, "myprops" : { "nam" : "HINO G." } } } ] } }
HINO G. やYASU G.がヒットするようです。期待どおりかな?
滋賀県はもちろんですが、琵琶湖(BIWA KO) も、野洲市の区域内に重なるので、ヒットしていますね。
JR西日本の野洲駅がヒットすることを期待しましたが、そもそもインポートしたGeoJSONデータに入っていないようです。名無しのデータを排除したからかな。
Point型の扱いが気になりますが、ここまでの手順で駅データも約100件ほどインポートできているはずなので、HACHINOHE、OMAGARIなどのデータを探して試してみてください。
野洲市を含むもの: contains
relationに「contains」を指定します。
GET /example/_search?filter_path=**.type,hits.total,**.nam { "query": { "bool": { "must": { "match_all": {} }, "filter": { "geo_shape": { "location": { "relation": "contains", "indexed_shape": { "index": "shapes", "id": "yasu_shi", "path": "location", "type": "_doc" } } } } } } }
↓ 検索結果
{ "hits" : { "total" : 1, "hits" : [ { "_source" : { "location" : { "type" : "Polygon" }, "myprops" : { "nam" : "Shiga Ken" } } } ] } }
なんと!、滋賀県シェープが戻ってきました。 (ご存知でしたでしょうが...)
でも当たり前かもしれませんが、他の都道府県のシェープが登録されているのに、しっかり滋賀県のみが応答されたということは非常に面白いですね。
野洲市に(完全に)含まれるもの: within
GET /example/_search?filter_path=**.type,hits.total,**.nam { "query": { "bool": { "must": { "match_all": {} }, "filter": { "geo_shape": { "location": { "relation": "within", "indexed_shape": { "index": "shapes", "id": "yasu_shi", "path": "location", "type": "_doc" } } } } } } }
↓検索結果
{ "hits" : { "total" : 0 } }
今回のデータでは0件になりました。 野洲駅はどうかなと思いましたが、先述のとおり野洲駅は元データに含まれていませんでしたので、結果としては正しいです。
野洲川はどうかなと思いましたが、可視化してみると、今回のデータで「野洲川」として扱われているデータは隣の市にもまたがっていますね。なので、これも正解。
野洲市と重なる部分がないもの: disjoint
GET /example/_search?filter_path=**.type,hits.total,**.nam { "query": { "bool": { "must": { "match_all": {} }, "filter": { "geo_shape": { "location": { "relation": "disjoint", "indexed_shape": { "index": "shapes", "id": "yasu_shi", "path": "location", "type": "_doc" } } } } } } }
↓ 検索結果
{ "hits" : { "total" : 533, "hits" : [ { "_source" : { "location" : { "type" : "MultiPolygon" }, "myprops" : { "nam" : "Hiroshima Ken" } } }, { "_source" : { "location" : { "type" : "MultiPolygon" }, "myprops" : { "nam" : "Hokkai Do" } } } ... 以下略
match_allで577件のデータに対して、intersectsの4件を引いた533件ということで、理屈にはあいそうです。
まとめ
今回、できるだけ実体験に近いデータを用いて、GeoShapeクエリを試してみました。
いろいろできそうで面白い便利な機能ですね。
ただし、検索レスポンスの考慮およびもともと全文検索エンジンの位置付けからすると、行列計算でポリゴンどおしの関係を計算するのではなくて、それ相当の検索ができるようにインデックス時に転置インデックスになんらかの変換をして格納したデータを検索していると思います。つまり近似値による検索です。*2
大半のユースケースでは実行上問題なく、そもそも全文検索要件とGEO検索をハイレベルに両立できるので非常にありがたいのですが、ポリゴンどおしの当たり判定の要件が非常に厳しい要件の場合には、注意が必要ですね。
今回のデータとElasticsearchの機能仕様では特に出くわしませんでしたが、