🔍

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

2022/12/16に公開

はじめに

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 a166e3e9b6d37228d532544383b994262ee73c0cbeeaa7cc28df2d2f3a5d7d9b' \
  --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