FirestoreのドキュメントをMeilisearchで全文検索するFirebase Extensionを使ってみた

先日のFirebase Summitで、[Meilisearch](https://www.meilisearch.com/)を簡単に組み込めるFirebase Extensionが発表された。
https://extensions.dev/extensions/meilisearch/firestore-meilisearch

「Firestoreを全文検索したい！けど、[Algoria](https://www.algolia.com/)は検索1回あたりいくらのPay as you goなので、フリーミアムのB2Cサービスではコスパが合わない…」と思っていた自分にはピッタリだと思い、早速試してみることにした。

ちなみに現在は[Elastic Cloud](https://www.elastic.co/)を使っており、割と満足しているが、日本語を全文検索するために必要なtokenizerの設定などが難しかった。Meilisearchはデフォルトで日本語に対応しているところが嬉しい。


# 準備

この記事は「なるべく手間をかけたくない！必要なことだけ学びたい！」という人を想定して書いているので、[Meilisearch Cloud](https://www.meilisearch.com/)でセットアップする方法だけ紹介する。これはフルマネージドなMeilisearchサーバを月額約30ドル〜という価格で提供してくれる公式のSaaSだ。当然だが、この価格にはマージンが含まれているので、自分でAWSやGCPを契約してセットアップした方がコストを抑えられるだろう。

Sign upしてCreate a projectを選択すると、新しいプロジェクト(= Meilisearchインスタンスだと思う)を作成できる。
このプロジェクトの中に複数のインデックスを作成できるので、名前はサービス名とかの広い名称にしておくのが良いだろう。

[![Image from Gyazo](https://i.gyazo.com/527a883f1ccfe83462bb2793d34b8cd0.png)](https://gyazo.com/527a883f1ccfe83462bb2793d34b8cd0)

2022/10/23現在、Meilisearch CloudはBeta版である。画面のスクショはきっとすぐに古くなるだろう。

# Extensionのインストール

https://extensions.dev/extensions/meilisearch/firestore-meilisearch

上記リンクからインストールすると、いくつかの項目を埋めるだけで「検索を試せる状態」まで持っていける。コードを書く必要はない。

入力項目についてはドキュメントをきちんと読めば分かるが、一応補足しておく。
[![Image from Gyazo](https://i.gyazo.com/94702391e10ac8b979fe4cc2f132172b.png)](https://gyazo.com/94702391e10ac8b979fe4cc2f132172b)

- Cloud Functions location
このExtensionをインストールすると、Cloud Function(以下CF)が１つデプロイされる。それはFirestoreのドキュメントが変更されたことをトリガーして、Meilisearchにインデックスの更新リクエストを行うためのものだ。
この項目では、そのCFのデプロイ先リージョンを選べる。CFには日本リージョンが出来たのでつい選びそうになってしまうが、Meilisearchには日本リージョンがないので注意されたし。

- Collection path
example: my_collection
全文検索の対象にするコレクションの名前。サブコレクションの名前でも可。1回のインストールにつき1つのコレクションを選べる。複数のコレクションを検索したい場合(例：userとpost)、このExtensionを２度インストールする必要がある。

- Fields to index in Meilisearch （省略可）
example: name,description,...
**省略することを推奨する。** もし何か書いた場合、指定したフィールドだけがMeilisearchに送信される。
「検索するフィールドを絞りたい」のであればsearchableAttributesを、
「ユーザーに見せたくないフィールドがある」のであればdisplayAttributesを、
それぞれ設定することを推奨する(後述)。

- Meilisearch Index Name
example: my_index
インデックスの名前。Meilisearchでは[Index UID](https://docs.meilisearch.com/learn/core_concepts/indexes.html#index-uid)と呼ばれている概念。

- Meilisearch host
example: my-meilisearch-host.com
Meilisearch Cloudでプロジェクトを作った後に表示される https://~~.meilisearch.io のようなURL。

- Meilisearch API key （省略可）
Meilisearch Cloudでプロジェクトを作った後に表示されるAdmin Key。ドキュメントによってはPrivate Keyとも呼ばれる概念。

2022/10/23現在、これ以外の設定はFirebaseダッシュボード上からは出来ない。インデックスの設定を変更したい場合、Meilisearchのドキュメントを読んで、MeilisearchのAPIから行う必要がある。

ボタンを押してから3〜5分くらい待つと、Extensionがインストールされる。

# 既存データのインポート

これだけで「更新があったドキュメント」はインデックスされる。しかし、あなたのFirestoreに既に100個のドキュメントが作られてしまっている場合、その100個を検索するために既存データをインポートする必要がある。

インポートのためのツールは[firestore-meilisearch](https://www.npmjs.com/package/firestore-meilisearch)というnpmで公開されている。このスクリプトはCF上で実行されるのではなく、あなたのマシンで実行することを想定して作られている。CFにデプロイすることも不可能ではないが、トリガーの方法はよく考える必要があるだろう。最初の一度しか実行しないのだから、手元で実行してしまうのが賢明だ。

ツールの仕様については下記のドキュメントに書かれている。
https://github.com/meilisearch/firestore-meilisearch/blob/main/guides/IMPORT_EXISTING_DOCUMENTS.md

ドキュメントではサラッと流されているが、GCPのサービスアカウントを用意する必要がある。コレクション一覧を取得するAPIを使ったりするので、多分Firestoreのadminっぽい権限が必要だと思う。
サービスアカウントを作成したら、JSON形式の秘密鍵をダウンロードして、マシンのどこかにおいておく。そうしたら、次のコマンドでインポートを実行できる。

```sh
export GOOGLE_APPLICATION_CREDENTIALS="秘密鍵のパス"
npx firestore-meilisearch \
  --project <project_id> \
  --source-collection-path <collection_name> \
  --index <index_uid> \
  --batch-size <100/default=300> \
  --host <host_address> \
  --api-key <api_key> \
  --non-interactive
```

上手くインポートが完了したら、次のようなメッセージがコンソールに出る。warningの方は無視してよい。

```sh
{"severity":"WARNING","message":"Warning, FIREBASE_CONFIG and GCLOUD_PROJECT environment variables are missing. Initializing firebase-admin will fail"}
{"severity":"INFO","message":"Imported 352 documents in 1 batches."}
```

では、先ほどMeilisearch hostに入力したURLをブラウザで開いて、検索を試してみよう。
MeilisearchのダッシュボードにもAdmin Keyを設定する必要がある。Admin Keyを知らない人がアクセスしても検索は使えないので、このURLは特に秘匿せずともよい。

[![Image from Gyazo](https://i.gyazo.com/f41db6c5064e27ac056d8414a60f57c2.gif)](https://gyazo.com/f41db6c5064e27ac056d8414a60f57c2)

ノーコードで全文検索が出来てしまった！

このダッシュボードのUIは本当に優れていると思う。検索が早いのがよく分かるし、サムネイルなんかも自動で拾ってくれている。
何にもしてないのに何かすごいことを成し遂げたような気分になれるのが、Meilisearchのすごいところだ。

# searchableAttributesを設定する

上のGIFでは上手く検索できているように見えるが、実は大きな欠陥がある。Meilisearchのデフォルトでは全てのフィールドが検索対象になってしまうので、変なメタデータやURLの一部まで検索にヒットしてしまうのだ。

特定のフィールドだけを検索対象にするには、インデックスにsearchableAttributesを設定する。詳しくはドキュメントを見てほしい。

https://docs.meilisearch.com/learn/getting_started/customizing_relevancy.html#searchable-attributes

サンプルコード。名前なんかは適宜書き換えて使って下さい。

```sh
curl \
  -X PATCH 'https://ホスト/indexes/post/settings' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer ここにMeilisearchのAdminKeyを入れる' \
  --data-binary '{
    "searchableAttributes": [
      "title",
      "description"
    ]
  }'
```

# sortableAttributesを設定する

例えば投稿を日付順でソートしたいような場合、 sortableAttributesを設定する。

FirestoreのTimestampは、JSONにシリアライズするとこんな感じのオブジェクトになってしまう。
[![Image from Gyazo](https://i.gyazo.com/238b10a7ae833d61c0eed8f486b3fc7b.png)](https://gyazo.com/238b10a7ae833d61c0eed8f486b3fc7b)

常識的に考えれば、これをUnix Timestampに変換する必要があると思うだろう。しかし、実は次のように設定すればFirestoreのTimestampでそのままソートできる。
例として、 `user` というインデックスの `createdAt` というフィールドに `firestore.Tiestamp` で作成日が指定されているとする。

```sh
curl \
  -X PATCH 'https://ホスト/indexes/user/settings' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer ここにMeilisearchのAdminKeyを入れる' \
  --data-binary '{
    "sortableAttributes": [
      "createdAt._seconds"
    ]
  }'
```

作成日順にソートされた検索結果を得たい場合、このようにする。

```sh

curl \
  -X POST 'https://ホスト/indexes/user/search' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer ここにMeilisearchのSearchKeyを入れる' \
  --data-binary '{
    "q": "検索キーワード",
    "sort": [
      "createdAt._seconds:desc"
    ]
  }'
```


# その他

一部のデータを特定のユーザーしか検索できないようにしたい。
そういう場合は、テナントトークンを発行する。

https://blog.meilisearch.com/multi-tenancy/

全文検索だけでなく、もっと複雑な検索クエリを書きたい。
そういう場合、大抵のことはサーチパラメータで解決できる。

https://docs.meilisearch.com/reference/api/search.html#search-parameters