Meilisearch を使って地理情報を検索する

## はじめに
今回はMeilisearchを使って地理情報を検索してみるというただそれだけの記事です。

### Meilisearch とは
Rustで実装されている全文検索エンジンです。
最近 v1 がリリースされました。

https://www.meilisearch.com/

いろんな言語向けのライブラリが公式で提供されています。
今回は雑に利用したいのでPythonのライブラリを利用します。

### 利用するデータ
せっかくなら住所から緯度経度(GeoCoding)・緯度経度から住所(Reverce GeoCoding)をやってみようじゃん？ということで良さげなデータないかなと探していたところ、国土交通省が位置参照情報を公開していることに気づいたのでこのデータを利用したいと思います。

アドレス・ベース・レジストリを利用しようかとも思ったんですが、住居表示の位置参照拡張な関係でジオコーディング的なものをしようとすると少し情報量が不足しそうだなということで利用をやめました。


## 試していく

今回は北海道のデータだけで一旦試します。

先に Meilisearch本体の起動とMeilisearchのPythonライブラリ・pandasをインストールしておきます。

Meilisearchのインストール
https://docs.meilisearch.com/learn/getting_started/installation.html#installation

MeilisearchのPythonライブラリ・pandasのインストール
```shell
pip install meilisearch pandas
```


### Index作成

```Python
import meilisearch

client = meilisearch.Client('http://127.0.0.1:7700')

response = client.create_index("geocoding-test")

print(response)
```
実行するとこんな感じのresponseが表示されます。

```
{'taskUid': 0, 'indexUid': 'geocoding-test', 'status': 'enqueued', 'type': 'indexCreation', 'enqueuedAt': '2023-02-28T07:37:07.48041Z'}
```

### Documentを追加
実際のデータを入れていきます。
一気に入れようとするとペイロードが大きすぎるので、10000件ずつ追加するようにdataflameをsliceしながら流し込みます。
住所の号以降はないため重複する可能性があります。そのため、idは住所のhash文字列にしています
```Python
import pandas as pd
import codecs
import meilisearch
import hashlib

client = meilisearch.Client('http://127.0.0.1:7700')

index = client.index("geocoding-test")

with codecs.open("./csvs/01000-20.0a/01_2021.csv", "r", "Shift-JIS", "replace") as f:
    df = pd.read_csv(f, index_col=None, header=0, delimiter=",", na_filter=False,
                     usecols=["都道府県名", "市区町村名", "大字・丁目名", "小字・通称名", "街区符号・地番", "緯度",
                              "経度"])
    k = 10000
    n = df.shape[0]
    sliced_df_list = [df.loc[i:i + k - 1, :] for i in range(0, n, k)]
    for i, sliced_df in enumerate(sliced_df_list):
        print([{
            "id": hashlib.md5("{}{}{}{}{}".format(row[1], row[2], row[3], row[4], row[5]).encode("utf-8")).hexdigest(),
            "address": "{}{}{}{}{}".format(row[1], row[2], row[3], row[4], row[5]),
            "_geo": {
                "lat": row[6],
                "lng": row[7]
            }
        } for row in sliced_df.itertuples()])
```

その後、http://127.0.0.1:7700/ にアクセスすると、こんな感じで追加されていることが確認できると思います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/275457/90a86c57-b24f-8566-0c6e-8b5bc063efd0.png)

### Indexの設定

filter/sort/searchともにaddressとgeo以外の情報を使ってほしくはないので限定してあげる設定を流し込みます。
設定詳細はドキュメントをご確認ください
https://docs.meilisearch.com/reference/api/settings.html#update-settings

```python
import meilisearch

client = meilisearch.Client('http://127.0.0.1:7700')
index = client.index("geocoding-test")

index.update_settings({
    "searchableAttributes": ["address", "_geo"],
    "filterableAttributes": ["address","_geo"],
    "sortableAttributes": ["address","_geo"]
})
```

### 住所 -> 緯度・経度をやってみる

実際に住所で検索して緯度・経度情報が出てくるか確かめてみましょう
サッポロファクトリーの住所を使って試してみます
`address: 北海道札幌市中央区北１条東４丁目１`
`lat: 43.064382220645605`
`lng: 141.3620405669338`
```python
import meilisearch

client = meilisearch.Client('http://127.0.0.1:7700')
index = client.index("geocoding-test")
index.search('北海道札幌市中央区北１条東４丁目', {
    "limit": 3
})
```
結果はこんな感じになりました
```json
{
	"hits": [
		{
			"address": "北海道札幌市中央区北五条西四丁目1",
			"_geo": {
				"lat": 43.06727,
				"lng": 141.349588
			},
			"id": "f2f40922fe17a661ad7ddee406fb3d3c"
		},
		{
			"address": "北海道札幌市中央区北三条西四丁目1",
			"_geo": {
				"lat": 43.064864,
				"lng": 141.350201
			},
			"id": "3fe777d4f200f02110516344c49cbf19"
		},
		{
			"address": "北海道札幌市中央区北二条西四丁目1",
			"_geo": {
				"lat": 43.063678,
				"lng": 141.350501
			},
			"id": "dc96bee3106b543bf48eb3951314dea7"
		}
	],
	"query": "北海道札幌市中央区北１条東４丁目",
	"processingTimeMs": 61,
	"limit": 3,
	"offset": 0,
	"estimatedTotalHits": 260
}
```

なんか全然違う住所にヒットしてますね 🤔
これはUIで見るとわかりやすいのですが、番地情報として入っている半角英数字の1に引っ張られてしまっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/275457/37c76788-9f97-6ccd-a5fb-e72dbf8227ce.png)

これに英数字 <-> 全角英数字、全角英数字 <-> 漢数字、英数字 <-> 漢数字の書き換えをSynonymsに登録してみます

[`kanjize`](https://github.com/nagataaaas/Kanjize)と[`mojimoji`](https://github.com/studio-ousia/mojimoji)というライブラリのお世話になって、自動生成をしてみます。

```python
from kanjize import int2kanji
from mojimoji import han_to_zen
import meilisearch
synonyms = {}

client = meilisearch.Client('http://127.0.0.1:7700')
index = client.index("geocoding-test")
for i in range(99):
    kanji = int2kanji(i+1)
    zenkaku = han_to_zen(str(i+1))
    synonyms[kanji + "条"] = [str(i+1) + "条", zenkaku + "条"]
    synonyms[str(i+1) + "条"] = [kanji + "条", zenkaku + "条"]
    synonyms[zenkaku + "条"] = [str(i+1) + "条", kanji + "条"]
    synonyms[kanji + "丁目"] = [str(i+1) + "丁目", zenkaku + "丁目"]
    synonyms[str(i+1) + "丁目"] = [kanji + "丁目", zenkaku + "丁目"]
    synonyms[zenkaku + "丁目"] = [str(i+1) + "丁目", kanji + "丁目"]
index.update_settings({"synonyms":synonyms})
```
このコードを使って
```json
{'一条': ['1条', '１条'], '1条': ['一条', '１条'], '１条': ['1条', '一条'],....}
```
のような感じのものを生成し、synonymsとして設定します。

設定したものを確認すると以下のようになっていました
```json
{'1 dīng mù': ['1 dīng mù', 'yīdīng mù'],
 '1 tiáo': ['1 tiáo', 'yītiáo'],
 '10 dīng mù': ['10 dīng mù', 'shí dīng mù'],
 '10 tiáo': ['10 tiáo', 'shítiáo'],
 '11 dīng mù': ['11 dīng mù', 'shíyī dīng mù'],
...
}
```

これに関しては中国語として認識してしまっているのでは...と思っていますが一旦そのまま進めます。今後の課題としてTokenizer周りは色々と試してみる予定です。(別Qiita/Zennにて結果はご報告できればと思います)

この状態で改めて同じコードで検索をかけてみると

```json
{
	"hits": [
		{
			"address": "北海道札幌市中央区北一条東四丁目1",
			"_geo": {
				"lat": 43.064088,
				"lng": 141.362405
			},
			"id": "2c8930b20d77f1f2961c94baa4388331"
		},
		{
			"address": "北海道札幌市中央区北一条東四丁目4",
			"_geo": {
				"lat": 43.063425,
				"lng": 141.36232
			},
			"id": "e508e2ff11489e5420aa883bd6ff12ae"
		},
		{
			"address": "北海道札幌市中央区北一条東四丁目8",
			"_geo": {
				"lat": 43.064088,
				"lng": 141.362405
			},
			"id": "c6e688c34360de21137622343c177486"
		}
	],
	"query": "北海道札幌市中央区北１条東４丁目",
	"processingTimeMs": 252,
	"limit": 3,
	"offset": 0,
	"estimatedTotalHits": 3
}
```

一番上に正しい結果が返ってくるようになりましたね 🤔
正直かなり解せないのですがUI上でもしっかりと認識していることがわかります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/275457/33aa4112-3c8c-d82d-1405-a40dc7478dcc.png)

このあたりは先ほども書きましたが改めて調べてみようと思っています。(MilisearchのTokenizer周りがどのようになっているかをしっかりと理解していないため)

### 緯度・経度 -> 住所をやってみる
住所 -> 緯度・経度はSynonymsを設定することで結構うまくいったので、次は緯度・経度 -> 住所を試してみます。

サッポロファクトリーの住所を使って試してみます
`lat: 43.064382220645605`
`lng: 141.3620405669338`
`address: 北海道札幌市中央区北１条東４丁目１`

Meilisearchの検索機能内にある Placeholder Search ( https://docs.meilisearch.com/reference/api/search.html#placeholder-search-3 ) と GeoSearch ( https://docs.meilisearch.com/learn/advanced/geosearch.html#geosearch )を利用します

今回はGeoSearchの中でもgeoPointをベースにしたsortを利用して、全データの中から一番近いデータを引き出します。
```python
import meilisearch

client = meilisearch.Client('http://127.0.0.1:7700')
index = client.index("geocoding-test")
index.search('', {
    "sort": ["_geoPoint(43.064382220645605, 141.3620405669338):asc"],
})
```

```json
{
	"hits": [
		{
			"address": "北海道札幌市中央区北一条東四丁目1",
			"_geo": {
				"lat": 43.064088,
				"lng": 141.362405
			},
			"id": "2c8930b20d77f1f2961c94baa4388331",
			"_geoDistance": 44
		},
		{
			"address": "北海道札幌市中央区北一条東四丁目8",
			"_geo": {
				"lat": 43.064088,
				"lng": 141.362405
			},
			"id": "c6e688c34360de21137622343c177486",
			"_geoDistance": 44
		},
		{
			"address": "北海道札幌市中央区北一条東三丁目6",
			"_geo": {
				"lat": 43.063915,
				"lng": 141.36113
			},
			"id": "dfd28e5623fc87efe9757f97cb5e8aa7",
			"_geoDistance": 90
		}
	],
	"query": "",
	"processingTimeMs": 132,
	"limit": 3,
	"offset": 0,
	"estimatedTotalHits": 1000
}
```

すこしずれているにもかかわらず、しっかりと正しい住所が返ってきているようです。
他の座標も試してみましたが概ね正しいデータが返ってきているように見受けられました

一応簡易的なジオコーダーのようなものを作るという目標自体は概ね達成できたのではないかなと思います。

## おわりに
今回はMeilisearchを使って簡易的なジオコーダーのようなものを作るということに挑戦してみました。
synonymsを登録することである程度使い物になるものができたのでは？と思っています。
北海道のデータのみでお試しでやってみましたが、後日時間をとって全国のデータを入れてMeilisearchがどれぐらい遅くなるかを試してみようかと思います。

また、お店の情報などをMeilisearchに登録することで近くにあるお店を探すなどの使い方ももちろんできると思います。(評価とかもデータとして入れれるので検索に合わせることももちろんできます)
各言語向けのSDKが用意されているので、次回は「地図と合わせて使ってみる」みたいなことに挑戦してみようかなと考えています。

ElasticSearchよりも手軽で簡単に利用できるMeilisearchをみなさんも試してみてはいかがでしょうか！

### データの加工元
[位置参照情報　国土交通省](https://nlftp.mlit.go.jp/cgi-bin/isj/dls/_choose_method.cgi)