🔍

Meilisearchを使ってFlutter×Firestoreの全文検索を実現する

2022/12/16に公開約18,500字

はじめに

Firestoreは非常に便利で高速なNoSQLデータベースですが、一方で検索面があまり得意ではありません。whereクエリで単一フィールドもしくは複合フィールド(発行クエリによる)での絞り込みや、文字検索では全文一致・前方一致・後方一致までは何とか実現できますが、SQLのLIKE検索のような部分一致や、全文検索をFirestore単体で実現することはできません。

Google Cloudの公式ドキュメントにも、全文検索についてはサードパーティツールを利用するよう明記されています。

Firestore では、ネイティブ インデックスの作成やドキュメント内のテキスト フィールドの検索をサポートしていません。さらに、コレクション全体をダウンロードして、クライアント側でフィールドを検索することは現実的ではありません。
https://cloud.google.com/firestore/docs/solutions/search?hl=ja

公式ドキュメントには、Elastic・Algolia・Typesenseが並べられておりMeilisearchの記載はまだありませんが、2022年にFirebase Extensionsも提供されたのでいずれ追加されることでしょう。

今回は最近追加されたFirebase Extensionsと公式のFlutter SDKを使ってMeilisearchを使ったFirestoreの全文検索機能を作っていきます。

全文検索のExtensions

元々、似たようなFirestoreの全文検索を実現するExtensionとして、冒頭の公式ドキュメントでも言及したElastic・Algolia・Typesenseがありましたが、Firebase Summit 2022 にてMeilisearchが4つ目の選択肢として追加されました。

Firebase ExtensionsのMarketplaceから一覧で確認できます。ちなみにMaketplace自体もFirebase Summit 2022で同時に発表されました。
https://extensions.dev/extensions?q=search

Firebase Extensionsは Blazeプラン でしか利用できませんのでご注意ください。

これらのExtensionを活用するメリットは、Firestoreのドキュメントと各サービスのインデックスを同期すること です。ドキュメント作成・編集・削除時にリアルタイムな変更同期を行うことで、Firestoreのミラーを作ることができます。実際、クライアントから検索リクエストを投げる対象は検索サービス側になるので、FirestoreをREADすることはありません。当然FirestoreのREADコストは発生しないですが、代わりに検索サービス側のリクエスト数にカウントされるため、そちらで費用発生することがあります。
図にすると以下のイメージです。

この同期処理を実装する必要がない恩恵は大きいですが、一方であくまで同期部分のみでサーバを提供するわけではありませんので、フルマネージドで提供されていないMeilisearch[1]やElasticでは自前サーバを用意して実行環境を構築する必要があります。

Meilisearchが登場した背景・位置づけ

本記事を執筆するにあたり色々と調べていったわけですが、やはり気になるのが「なぜAlgolia筆頭にデファクトが築かれつつあるこの業界で、敢えてこの赤い海に飛び込んだのか」という点でした。サービス提供者目線で見ると、どんな勝算を見込んでいるのか気になります。

Meilisearch was inspired by Algolia's product and the algorithms behind it.
https://docs.meilisearch.com/learn/what_is_meilisearch/comparison_to_alternatives.html#meilisearch-vs-algolia

そもそも、MeilisearchはAlgoliaにインスパイアされており、アルゴリズムも後追いしているとドキュメントに明記されています。先人(Algolia)が培った検索アルゴリズムやデータ構造を調査して開発しているので、Algoliaをベースとした新しい検索エンジンという位置づけのようです。

後続サービスのため、いかにAlgoliaの不満を抱えている開発者を取り込めるかが重要であり、当然マイグレーションガイドも提供されています。AlgoliaのExport機能も提供されていて移行に手こずる心配は無さそうです。
https://docs.meilisearch.com/learn/getting_started/algolia_migration.html

目指す未来

Meilisearch is dedicated to all types of developers. Our goal is to deliver a developer-friendly tool, easy to install, and to deploy. Because providing an out-of-the-box awesome search experience for the end-users matters to us, we want to give everyone access to the best search experiences out there with minimum effort and without requiring any financial resources.
https://docs.meilisearch.com/learn/what_is_meilisearch/comparison_to_alternatives.html#conclusions

Algoliaは先進的でパワフルな検索機能を持っている一方で、費用面が高額というデメリットがありマーケットの対象もビッグカンパニーとされているようです。確かに費用面については、Algoliaを利用している人の他の記事などでも似たような声をいくつか見つけましたので大きな課題なのは間違いないのでしょう(サービス構造に依存するので一概には言えないとは思いますが)。

一方で、Meilisearchの対象はあらゆる開発者と記載されており、開発者にとって使いやすく、インストールやデプロイが容易なツールを提供することを重要視しています。平たくいうと、労力や費用をかけずにユーザーが最高な体験をできることを目指しているようです。

Meilisearchを選ぶ理由

やはり、最近のデファクトはAlgoliaかなと思います。自前サーバの構築が不要でGUIもすでに用意されており至れり尽くせりな一方で、一般的に費用面が高いという声が聞かれます(もちろんサービス構造に依存する部分が大きい前提で)。
Meilisearchの検索リクエストは上限10万回/月まで利用でき、単純計算してAlgoliaの10倍で、お財布に少しやさしいです。前述の通り、元々Algoliaの費用面での課題に対するソリューションとして開発されてきたので、この費用形態が大きく変わることはないのだと思っています。また、登場背景から見てもAlgoliaをベースに作られているためある程度ロジックが似ているはずで、パフォーマンスや検索性能が同程度以上であればMeilisearchも候補に入ってくると思います。

他にもさまざまな側面で他サービスとの比較はできるとは思いますが、私は 「安価」「デプロイ方法が多様」 という2点が主なメリットなのだと認識しています。
今回もMeilisearchを選定した大きな理由がデプロイ方法で、会社ポリシー等の諸事情により海外SaaSを利用するハードルが高い状況でした。一方で、自前ホスティングであれば導入ハードルが低く、Algoliaの代替となるサービスとしてピッタリはまるのでは?という期待を込めて触ってみた形です。まとめると主な特徴は以下です。

主な特徴

  • Algoliaより安価
    • 検索リクエストの無料枠は上限10万回/月まで利用できる(Algoliaは1万回/月が上限)
    • 無料枠の範囲を超えると、Algoliaでは1,000リクエストごとに$1発生する
  • 自前ホスティングでき、インストーラーやドキュメントが充実しており簡単
    • Algoliaのようなフルマネージドなサービスは会社ポリシーなどの都合により導入しづらいケースがある(弊社がそう)

Meilisearchのドキュメントにも他サービスとの比較表は存在し、私たち開発者が欲しい情報を網羅的に提供してくれています。以下の図はその中の一部です。

https://docs.meilisearch.com/learn/what_is_meilisearch/comparison_to_alternatives.html#comparison-table

実体験ベースのElasticとの比較記事

もちろん、選択肢としてElasticやその他のサービスも上がってくると思いますが、今回は以下の記事で肌感がある程度分かったのでとくに検討候補にはいれませんでした。こちらのnoteが現場レベルでの移行体験記となっており非常に分かりやすかったです。
https://note.com/penmarkjp/n/n4d69756b5b2d

さて、これを読んでいる皆さんは、特にElasticsearchの説明部分について理解できましたでしょうか。「形態素解析」「N-Gram」という言葉が出てきましたが、これはいずれも、日本語の全文検索の技術を語る上では常識といえる言葉なのですが、ご存知でしたか? Elasticsearchで検索システムを構築するには、最低でもこれらの言葉の意味がわかるぐらいの知識は必要です。

AlgoliaやMeilisearchはノータイムで全文検索機能を享受できる一方で、Elasticを利用するためには検索システムを構築するための最低限の知識が必要になる(学習コストが必要になる)というイメージでしょうか。

Elasticsearchは、設定が柔軟に行なえ、コストもインスタンスの時間課金というわかりやすさがあります。また、広く使われておりわりと枯れている印象でした。一方、設定が複雑で、使いこなすには一定の学習コストが必要です。
https://zenn.dev/moga/articles/build-hotel-search-system#検索データを保持するデータストア

こちらの mogaさん 記事でも、「設定が複雑で、使いこなすには一定の学習コストが必要」との記載あり、コスト削減の効果は期待できるものの初期の学習コストがかかるのは間違い無さそうです。

全文検索機能を実装する

本セクションでは、Meilisearchを使って基本的な全文検索機能を実装します。
本記事では、以下の図の構造で3ステップに分けて作っていきます。

サンプルとして扱うデータセットは、都道府県47つのドキュメントを持つ以下のコレクションをFirestoreに作成しました。

/prefectures:
 - [document_id]
  - name: String (ex. 神奈川)
  - hiragana: String (ex. かながわ)
  - roman: String (ex. kanagawa)
 - ...

1. Meilisearchの実行環境を作る

まずはサーバーの実行環境を作ります。非常に簡単です。手元のマシンやDockerはもちろんAWSなどのクラウドへのデプロイ方法がすべてドキュメントのQuick Startに記載されており、数分でセットアップできます。
https://docs.meilisearch.com/learn/getting_started/quick_start.html#setup-and-installation

今回はFirebase Extensionsを使う(Cloud Functinosで同期する)ので、同じ東京リージョンのGCE(Google Compute Engine)で環境を用意してみました。Meilisearchのイメージがすでに用意されているので、行う作業はイメージ作成→インスタンス作成のみです。ドキュメント通りに進めればとくに困ることはないと思うので手順は割愛します。
https://docs.meilisearch.com/learn/cookbooks/gcp.html

Master KeyとAPI Key

https://docs.meilisearch.com/learn/security/master_api_keys.html

Meilisearchはデフォルトでは保護されておらず、どこからでも認証なしにリクエストを受け付ける形となっています。Master Keyを環境変数に指定するとエンドポイントが保護され、リクエストにはAuthorizationヘッダへのBearerトークン付与が必要になります。

コマンド例
# Master Keyの生成(再生成)やドメイン指定などができる
$ meilisearch setup

# 環境変数に指定するとエンドポイントが保護される
$ export MEILI_MASTER_KEY="xxx"

$ export -p | grep MEILISEARCH
declare -x MEILISEARCH_ENVIRONMENT="production"
declare -x MEILISEARCH_MASTER_KEY="xxx"
declare -x MEILISEARCH_SERVER_PROVIDER="gcp"

$ meilisearch
888b     d888          d8b 888 d8b                                            888
8888b   d8888          Y8P 888 Y8P                                            888
88888b.d88888              888                                                888
888Y88888P888  .d88b.  888 888 888 .d8888b   .d88b.   8888b.  888d888 .d8888b 88888b.
888 Y888P 888 d8P  Y8b 888 888 888 88K      d8P  Y8b     "88b 888P"  d88P"    888 "88b
888  Y8P  888 88888888 888 888 888 "Y8888b. 88888888 .d888888 888    888      888  888
888   "   888 Y8b.     888 888 888      X88 Y8b.     888  888 888    Y88b.    888  888
888       888  "Y8888  888 888 888  88888P'  "Y8888  "Y888888 888     "Y8888P 888  888

Database path:          "./data.ms"
Server listening on:    "http://127.0.0.1:7700"
Environment:            "development"
Commit SHA:             "3ebd88c03ba7a4e0f1b1c2b7cd4b2937cc85b2aa"
Commit date:            "2022-10-10T12:46:54Z"
Package version:        "0.29.1"

Thank you for using Meilisearch!

We collect anonymized analytics to improve our product and your experience. To learn more, including how to turn off analytics, visit our dedicated documentation page: https://docs.meilisearch.com/learn/what_is_meilisearch/telemetry.html

Anonymous telemetry:    "Enabled"
Instance UID:           "0d8ab5e0-3676-42a2-be1e-98929d43293c"

No master key found; The server will accept unidentified requests. If you need some protection in development mode, please export a key: export MEILI_MASTER_KEY=xxx

Documentation:          https://docs.meilisearch.com
Source code:            https://github.com/meilisearch/meilisearch
Contact:                https://docs.meilisearch.com/resources/contact.html

A Master Key has been set. Requests to Meilisearch won't be authorized unless you provide an authentication key.

meilisearchコマンドを打って、上記の文言が表示されていればしっかり保護されています。試しに、Authorizationヘッダを付けずに叩いてみるとしっかりエラーが返っていますね。

curl -X GET '34.146.142.6/tasks'
{"message":"The Authorization header is missing. It must use the bearer authorization method.","code":"missing_authorization_header","type":"auth","link":"https://docs.meilisearch.com/errors#missing_authorization_header"}

ただ、Master Keyではインスタンス内のすべての操作ができてしまうため、これを外に持ち出すのは微妙です。代わりにAPI Keyが用意されているので、こちらを利用して最小限の権限で操作できるようにします。

上記をまとめると、デフォルトで生成されるキーは以下の3つです。

  • Master Key
    • API Keyの管理などすべての操作を行うユニークなキー
  • Default Search API Key
    • search権限のみが付与されたクライアントから参照する際に利用するキー
  • Default Admin API Key
    • キー操作を除くすべての操作の権限が付与されたキー

後者2つのAPI Keyの確認方法が一見わかりづらいですが、以下の/keysエンドポイントをインスタンス内で叩くと出力されます。

curl -X GET 'http://localhost:7700/keys' -H 'Authorization: Bearer [Master Key]'
API Keyの出力結果例
{
  "results": [
    {
      "name": "Default Search API Key",
      "description": "Use it to search from the frontend",
      "key": "[Default Search API Keyのキーが入る]",
      "uid": "b0c89836-51c3-4256-89db-c93640264c75",
      "actions": [
        "search"
      ],
      "indexes": [
        "*"
      ],
      "expiresAt": null,
      "createdAt": "2022-10-24T15:36:25.159441938Z",
      "updatedAt": "2022-10-24T15:36:25.159441938Z"
    },
    {
      "name": "Default Admin API Key",
      "description": "Use it for anything that is not a search operation. Caution! Do not expose it on a public frontend",
      "key": "[Default Admin API Keyのキーが入る]",
      "uid": "0b73300c-5442-4006-8bce-a507898a765d",
      "actions": [
        "*"
      ],
      "indexes": [
        "*"
      ],
      "expiresAt": null,
      "createdAt": "2022-10-24T15:36:24.936882433Z",
      "updatedAt": "2022-10-24T15:36:24.936882433Z"
    }
  ],
  "offset": 0,
  "limit": 20,
  "total": 2
}

今回は、Default Search API KeyをFlutter側で利用し、Default Admin API KeyをExtensions側で利用します。インデックスの同期にDefault Admin API Keyはやや過剰な気がしますが、必要な最小権限がドキュメントとして明記されていないことと[2]、インデックスの作成・更新・削除等々の操作が必要になるため、ある程度の権限が必要になりそうだと予測してそのまま利用することにしました。下図は、Extensionsのキャプチャですが、ご覧の通りマスクできるのでその点でも問題無さそうです。

2. MeilisearchのFireabse Extensionをインストールする

こちらも大した操作は無く、以下からGUI操作でインストールして完了です。

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

一応Meilisearchの記事にも手順が公開されているので、気になったらざっと流し見してみると良いかもしれません。

https://blog.meilisearch.com/firebase-meilisearch/

料金

Extension共通の定常料金($0.01/month)と、今回のExtensionで生成される以下のサービスでの使用料金が発生します。

  • Cloud Functions
    • Firestoreのドキュメント変更をトリガーにMeilisearchとインデックスを同期する
  • Cloud Secret Manager
    • Meilisearch API Keyを格納しCloud Functionsから参照

Backfill

Extensionを新規でインストールしても、それより前に作られているドキュメントについてはインデックス同期がされません。これについては、他のExtension同様にBackfillの機能がnpxスクリプトで提供されています。詳細はこちらのMarkdownをご参照ください。
https://github.com/meilisearch/firestore-meilisearch/blob/main/guides/IMPORT_EXISTING_DOCUMENTS.md

上記のドキュメントに記載がありますが、以下のnpxコマンドでBackfillできます。各項目はExtensionで設定した値をそのまま利用するので、コンソールと見比べながらコピペすると良いと思います。

npx firestore-meilisearch \
  --project <project_id> \
  --source-collection-path prefectures \
  --index prefectures \
  --batch-size 300 \
  --host <host_address>  \
  --api-key <api_key> \
  --fields-to-index name,hiragana,roman \
  --non-interactive


上手く実行されると、以下のログが出力されます。既存のFirestoreドキュメント(47件の都道府県)が正常にインポートされてそうですね。

{"severity":"INFO","message":"Imported 47 documents in 1 batches."}

データが格納されたことを確認

一応、エンドポイントを叩いて確認してみます。tasksエンドポイントで確認できます。

curl -X GET 'http://34.84.205.58/tasks' -H 'Authorization: Bearer [Admin API Key]'
tasksの出力結果
{
  "results": [
    {
      "uid": 0,
      "indexUid": "prefectures",
      "status": "succeeded",
      "type": "documentAdditionOrUpdate",
      "details": {
        "receivedDocuments": 47,
        "indexedDocuments": 47
      },
      "duration": "PT0.688123993S",
      "enqueuedAt": "2022-12-14T14:20:17.704797281Z",
      "startedAt": "2022-12-14T14:20:17.713994482Z",
      "finishedAt": "2022-12-14T14:20:18.402118475Z"
    }
  ],
  "limit": 20,
  "from": 0,
  "next": null
}

良いですね、ちゃんと入っています。
ついでにSearchのエンドポイントも使えるか試してみます。

If using the GET route to perform a search, all parameters must be URL-encoded.
This is not necessary when using the POST route or one of our SDKs.
https://docs.meilisearch.com/reference/api/search.html#search-parameters

ちなみに、Search APIにはGETとPOSTの両方のRouteがあるらしいのですが、GETではエンコードの必要があるのでPOSTを使うのが推奨されています。SDKはすべてPOSTを叩くようになっているようです。

curl \
  -X POST 'http://34.84.205.58/indexes/prefectures/search' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer [Search API Key]' \
  --data-binary '{ "q": "愛" }'
searchの出力結果
{
  "hits": [
    {
      "_firestore_id": "1bRTQIEfar22lSIkdjfz",
      "hiragana": "えひめ",
      "roman": "ehime",
      "name": "愛媛"
    },
    {
      "_firestore_id": "c5NOkLGkzr3fQCDUKejN",
      "hiragana": "あいち",
      "roman": "aichi",
      "name": "愛知"
    }
  ],
  "estimatedTotalHits": 2,
  "query": "愛",
  "limit": 20,
  "offset": 0,
  "processingTimeMs": 0
}

こちらも問題無さそうです🙆‍♂️

インデックス同期を確認する

47件のデータが格納された状態で、Firestore側のデータを変更した時にその変更がMeilisearchに適用されることを確認します。
ユースケースとしては既存データのインデックスを生成する事が多いのかなと思い、便宜上先にBackfillを取り扱いましたが、これが今回のExtensionの本来の意義となります。

試しにドキュメントID 1bRTQIEfar22lSIkdjfz のフィールドを一部変更してみると、Extensionで作成されたCloud Functions関数の ext-firestore-meilisearch-indexingWorker が正常に処理されることを確認できます。

その後に更新後の内容でAPIを叩いても、正常に検索結果が返ってきました。

3. クライアントからSDKでリクエストを投げる

最後に、FlutterアプリからSDKを使って叩きます。Flutterの公式SDKが用意されていますので、こちらを利用してクライアント側を実装します。
https://pub.dev/packages/meilisearch

利用も簡単でとくに記載することはありません。

Future<void> main() async {
  // クライアントのセットアップ
  final client = MeiliSearchClient(
    'https://xxx',
    '[YOUR_SEARCH_API_KEY]',
  );

  // 指定したインデックスUIDと検索したいワードを指定するだけ
  final searchResults = await client.index('prefectures').index.search('愛');

  // `SearchResult`というクラスで返却され、件数や発行したクエリ、検索結果等が入っています。
  // 検索結果は`hits`に`List<Map<String, dynamic>>`で入っているのでデコードして完了です。
  // output: 愛媛, 愛知
}

SearchResultクラス
class SearchResult {
  SearchResult({
    this.hits,
    this.offset,
    this.limit,
    this.processingTimeMs,
    this.query,
    this.estimatedTotalHits,
    this.facetDistribution,
  });

  /// Results of the query
  final List<Map<String, dynamic>>? hits;

  /// Number of documents skipped
  final int? offset;

  /// Number of documents to take
  final int? limit;

  /// Processing time of the query
  final int? processingTimeMs;

  /// Total number of matches
  final int? estimatedTotalHits;

  /// Distribution of the given facets
  final dynamic facetDistribution;

  /// Query originating the response
  final String? query;

  factory SearchResult.fromMap(Map<String, dynamic> map) {
    return SearchResult(
      hits: (map['hits'] as List?)?.cast<Map<String, dynamic>>(),
      query: map['query'] as String?,
      limit: map['limit'] as int?,
      offset: map['offset'] as int?,
      processingTimeMs: map['processingTimeMs'] as int?,
      estimatedTotalHits: map['estimatedTotalHits'] as int?,
      facetDistribution: map['facetDistribution'] as dynamic,
    );
  }
}

レポジトリはこちらです。ついでにAlgoliaも試せるようにしています。
https://github.com/HTsuruo/flutter_fulltext_search

検索性能が少し微妙かも

これはちょっとよく分からないのですが、Algoliaと比較して検索性能があまり良くないなと感じることがありました。インデックス同期の辺りにもしかしたら問題があるのかもしれないですが、私が手元でAlgoliaとMeilisearchを検索性能を比較したところ、明らかにAlgoliaの方が期待した結果を返していました。

たとえば、「島」をキーワードとして検索すると、本来「島」が含まれる都道府県は5つですが、以下の結果となります。

Algolia Meilisearch

日本語が原因なのかもよく分からず調べてみると、日本語サポートのディスカッションをいくつか見つけました。バージョン0.27.0から日本語サポートがされているようですが、その検索性能はまだ開発途中なのかもしれません。
この辺りはよく分かっておらず、間違ってましたらコメントにてご指摘いただけますと幸いです🙏
https://github.com/meilisearch/product/discussions/532
https://github.com/meilisearch/meilisearch/discussions/2391

まとめ

Meilisearchは前評判通りセットアップ周りの環境が整っておりまた、ドキュメントがかなり充実していてこの手の全文検索サービスをこれまで使ったことが無くともスムーズに導入することができました。また、Firebase ExtensionのおかげでFirestoreとの面倒な同期処理を行う手間もなく、比較的すぐにクライアントサイドの開発に着手できた印象でした。Dart SDKもとくに躓く点は無かった印象です。一方で、前述した検索性能については少し不安で、この辺りが解消できると安心して使えるようになるのかもしれません。

また、Meilisearchは自前デプロイが基本ではありますが、GUIツールも公式から mini-dashboard として提供されており、運用に必要なツール類も1通り揃っている気がしました。従来のOSSプランに加えクラウドプラン(フルマネージド)もプレビュー版で提供されているので、今後の進化が楽しみです。

さいごに、今回の記事執筆に辺りせっかくなのでAlgoliaも同じ条件で導入してみましたが、フルマネージドはもちろんGUIも元々備わっているので、手間目線だと段違いにAlgoliaは楽ですね。現在、Algoliaを使っている人でとくに課題感を感じていないのであればわざわざ移行するまでもないと思いますが、前述したマイグレーションツールもあるのでいざとなったときの選択肢として頭に入れておくと良いかもしれません。

参考

脚注
  1. 執筆時点の2022年12月で、従来のOSSプランに加えクラウドプラン(フルマネージド)もプレビュー版で提供されております。将来は利用者のユースケースに合わせて選択できるようになることでしょう。 ↩︎

  2. AlgoliaのExtensionsはAPIキーで必要な権限が明記されているので、その権限を持つAPIキーを作成して利用するのが良いです。 ↩︎

Discussion

ログインするとコメントできます