Closed14

Firestore で格安な全文検索・ページネーションを実現するには

9sako69sako6

モチベ

Firestore を使った Web アプリを開発しており、全文検索に対応したくなった。
Firestore は全文検索に対応していないので、公式では Elasticsearch, Algolia 等の使用を勧めている
しかし、それらを使うにしても、検索だけ RDB を使うにしても基本的に固定費がかかる。最小無料、従量課金制を実現したい。

実現したいこと

  • 全文検索
    • クエリは2つのフィールドに LIKE 検索をかける、ORDER BYする、くらい
    • 今後検索条件が増える見込みはあまりない
  • offset, limit を指定してのページネーション
  • 従量課金制

妥協できる点

  • レイテンシの増大 (何秒まで許容できるかは動かしながら探る)
  • インデックス反映までの時間

データ量

私のユースケースでは、検索対象のドキュメントのサイズは 30 MiB (1024文字 * 3 (日本語) * 10,000ドキュメント) あればおさまる程度。

9sako69sako6

結論

BigQuery に Firestore のデータを入れ、LIKE 検索とページネーションを捌く。
なお、Firebase 公式が出している、BigQuery と簡単にデータを同期できる firebase/firestore-bigquery-export@0.1.51 extension は、Firebase Emulator が対応していない機能を使っているために動作せず、なおかつ Firebase Emulator 側で対応される目処がないので採用しなかった[1]

なお、BigQuery Emulator も使っていない。Storage Write API で書き込みできなかったからだ[2]
ローカル環境、CI 環境ではそれぞれ Firebase Emulator から本物の BigQuery に読み書きするように設定した[3]

脚注
  1. https://zenn.dev/link/comments/988e2d044f3068 ↩︎

  2. https://zenn.dev/link/comments/bbf2c2a9d507bf ↩︎

  3. https://zenn.dev/link/comments/f81348ead94777 ↩︎

9sako69sako6

flexsearch, AWS EFS, AWS Lambda を使った全文検索

というときに見つけたのが flexserach, AWS Lambda, AWS EFS を使って作る面白い全文検索。

https://medium.com/@chrisswhitneyy/full-text-search-in-aws-without-the-price-tag-server-less-full-text-search-with-persistent-c2478bc94015

私のユースケースだとレイテンシーが数秒超えそうな匂いがするが、実際どんなもんかを検証したい。

Google Cloud 版

Google Cloud の Cloud Filestore + Cloud Functions でも実装できないかと思ったが、Filestore は今回のユースケースだと高額になってしまった。Filestore の料金説明には

インスタンス容量: 未使用の場合でも、割り当てられたストレージ容量に対して課金されます。

とあり、最低 1 TiB の割り当てしかできないのでインスタンスタイプ「リージョン」だと $460.80/月。

9sako69sako6

BigQuery を使う

BigQuery にデータを保持しておき、SQL で全文検索なりページネーションクエリを捌けばいいのでは?
Firestore のデータを BigQuery に流し込む公式の extension があってお手軽に実装できる説がある。

https://extensions.dev/extensions/firebase/firestore-bigquery-export

今回のユースケースではストレージ料金は無視できるほど安い。なんなら無料枠に収まる。
問題はクエリの料金だが検索対象のサイズが小さいので問題なさそうな見込み。

extension のインストール。

firebase ext:install firebase/firestore-bigquery-export --project=PROJECT_ID

同期したいコレクションのパスは途中で質問される。ワイルドカードを使わない場合、コレクション1つに対して1つの拡張インストールが必要っぽい。

Collection path: What is the path of the collection that you would like to export? You may use {wildcard} notation to match a subcollection of all documents in a collection (for example: chatrooms/{chatid}/posts). Parent Firestore Document IDs from {wildcards}  can be returned in path_params as a JSON formatted string.
? Enter a value for Collection path: (posts)

初期設定のためにいくつか質問されるが、インストールが完了すると extensions/firestore-bigquery-export.env ファイルが生成される。設定内容はこのファイルに記載されているので、変更したければファイルをいじればいい。

extensions/firestore-bigquery-export.env
BIGQUERY_PROJECT_ID=<PROJECT_ID>
COLLECTION_PATH=works
DATASET_ID=firestore_export
DATASET_LOCATION=asia-northeast1
EXCLUDE_OLD_DATA=yes
firebaseextensions.v1beta.function/location=us-central1
MAX_DISPATCHES_PER_SECOND=100
TABLE_ID=works
TABLE_PARTITIONING=NONE
TIME_PARTITIONING_FIELD_TYPE=omit
USE_COLLECTION_GROUP_QUERY=yes
USE_NEW_SNAPSHOT_QUERY_SYNTAX=yes
WILDCARD_IDS=true

各オプションの意味:

  • BIGQUERY_PROJECT_ID
    • BigQuery インスタンスを置くプロジェクト ID。Firebase と別プロジェクトにすることも可能な模様
  • COLLECTION_PATH
    • エクスポートしたいコレクションのパス
  • DATASET_ID
    • BigQuery dataset ID
  • DATASET_LOCATION
    • BigQuery dataset を作るロケーション
  • EXCLUDE_OLD_DATA
    • onDocumentUpdate イベントで変更される前のスナップショットを含めるかどうか
    • BigQuery 上では old_data というカラムで保存されるようだ
  • firebaseextensions.v1beta.function/location
    • (推測)extension で自動生成される functions のロケーション
  • MAX_DISPATCHES_PER_SECOND
    • 1秒あたりの最大同期ドキュメント数
  • TABLE_ID
    • BigQuery のテーブルの prefix として使われる文字列
  • TABLE_PARTITIONING
    • パーティショニングするかどうかと、パーティショニングの粒度
    • パーティショニングはクエリのパフォーマンスに影響するのでした方がいいだろう
  • TIME_PARTITIONING_FIELD_TYPE
    • パーティショニングのカラム名
  • USE_COLLECTION_GROUP_QUERY
    • コレクショングループクエリを有効にするか。つまり、対象のコレクションがサブコレクションの場合は有効にする必要がある
  • USE_NEW_SNAPSHOT_QUERY_SYNTAX
    • スナップショットを新しい syntax で生成する。パフォーマンス向上らしい
  • WILDCARD_IDS
    • 全てのドキュメントを同期する

firebase.json には拡張が自動的に設定されている。

diff --git a/firebase.json b/firebase.json
index 006b2dd..833d57d 100644
--- a/firebase.json
+++ b/firebase.json
@@ -32,5 +32,8 @@
       "enabled": true,
       "host": "0.0.0.0"
     }
+  },
+  "extensions": {
+    "firestore-bigquery-export": "firebase/firestore-bigquery-export@0.1.51"
   }
 }
9sako69sako6

デプロイ。

firebase deploy --only extensions --project=PROJECT_ID

Firestore でドキュメントを追加すると BigQuery にも反映された。Firestore ドキュメントの中身は data フィールドに JSON として格納されている。


あとやりたいこと:

参考になりそうな記事

https://note.shiftinc.jp/n/nc6cad687ea74

9sako69sako6

JSON を任意の View に変換する

下記の手順に従う。

extensions/firestore-bigquery-export/guides/GENERATE_SCHEMA_VIEWS.md at master · firebase/extensions

スキーマ定義のファイルを作って

schema.json
{
  "fields": [
    {
      "name": "title",
      "type": "string"
    },
    {
      "name": "description",
      "type": "string"
    }
  ]
}

コマンドを実行するとエラーになった。

$ npx @firebaseextensions/fs-bq-schema-views \
  --non-interactive \
  --project=<PROJECT_ID> \
  --big-query-project=<PROJECT_ID> \    
  --dataset=firestore_export \   
  --table-name-prefix=works \            
  --schema-files=./schema.json 
Need to install the following packages:
@firebaseextensions/fs-bq-schema-views@0.4.7
Ok to proceed? (y) 
npm WARN deprecated inflight@1.0.6: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
npm WARN deprecated glob@8.1.0: Glob versions prior to v9 are no longer supported
npm WARN deprecated glob@7.1.5: Glob versions prior to v9 are no longer supported
error: unknown option '--big-query-project=<PROJECT_ID>'

--big-query-project を抜いて実行したらできた。

結果
npx @firebaseextensions/fs-bq-schema-views \
  --non-interactive \
  --project=<PROJECT_ID> \
  --dataset=firestore_export \
  --table-name-prefix=works \
  --schema-files=./schema.json
(node:38565) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
BigQuery creating schema view works_schema_schema_changelog:
Schema:
{"fields":[{"name":"title","type":"string","extractor":"title"},{"name":"description","type":"string","extractor":"description"}]}
Query:
SELECT
  document_name,
  document_id,
  timestamp,
  operation,
  JSON_EXTRACT_SCALAR(data, '$.title') AS title,
  JSON_EXTRACT_SCALAR(data, '$.description') AS description
FROM
  `<PROJECT_ID>.firestore_export.works_raw_changelog`
BigQuery created schema view works_schema_schema_changelog

BigQuery creating schema view works_schema_schema_latest:
Schema:
{"fields":[{"name":"title","type":"string","extractor":"title"},{"name":"description","type":"string","extractor":"description"}]}
Query:
-- Given a user-defined schema over a raw JSON changelog, returns the
-- schema elements of the latest set of live documents in the collection.
--   timestamp: The Firestore timestamp at which the event took place.
--   operation: One of INSERT, UPDATE, DELETE, IMPORT.
--   event_id: The event that wrote this row.
--   <schema-fields>: This can be one, many, or no typed-columns
--                    corresponding to fields defined in the schema.
SELECT
  document_name,
  document_id,
  timestamp,
  operation,
  title,
  description
FROM
  (
    SELECT
      document_name,
      document_id,
      FIRST_VALUE(timestamp) OVER(
        PARTITION BY document_name
        ORDER BY
          timestamp DESC
      ) AS timestamp,
      FIRST_VALUE(operation) OVER(
        PARTITION BY document_name
        ORDER BY
          timestamp DESC
      ) AS operation,
      FIRST_VALUE(operation) OVER(
        PARTITION BY document_name
        ORDER BY
          timestamp DESC
      ) = "DELETE" AS is_deleted,
      FIRST_VALUE(JSON_EXTRACT_SCALAR(data, '$.title')) OVER(
        PARTITION BY document_name
        ORDER BY
          timestamp DESC
      ) AS title,
      FIRST_VALUE(JSON_EXTRACT_SCALAR(data, '$.description')) OVER(
        PARTITION BY document_name
        ORDER BY
          timestamp DESC
      ) AS description
    FROM
      `<PROJECT_ID>.firestore_export.works_raw_latest`
  )
WHERE
  NOT is_deleted
GROUP BY
  document_name,
  document_id,
  timestamp,
  operation,
  title,
  description
BigQuery created view works_schema_schema_latest
done.

works_schema_schema_latest テーブルが作られた。最高!

9sako69sako6

ローカルではエミュレータを使う

ローカルでの開発や CI でのテストのために、Firebase と BigQuery をそれぞれエミュレータで動かしてデータの同期をしたい。

https://note.shiftinc.jp/n/nb19116e23665
https://github.com/goccy/bigquery-emulator

BigQuery エミュレータと Firebase Local Emulator をそれぞれ起動した。
エミュレータ用のプロジェクト ID は demo-bq-firestore にした。demo-* にするとデモプロジェクトとなり、実際のプロジェクトは使用されない
BigQuery エミュレータは起動してる。

$ bq --api http://localhost:9050 query --project_id=demo-bq-firestore 'SELECT 1'
+-------+
| $col1 |
+-------+
|     1 |
+-------+

Emulator に --project オプションで指定すると demo-bq-firestore が使われるようになる。

firebase emulators:start --project=demo-bq-firestore
i  emulators: Detected demo project ID "demo-bq-firestore", emulated services will use a demo configuration and attempts to access non-emulated services for this project will fail.
⚠  Extensions: The following Extensions make calls to Google Cloud APIs that do not have Emulators. demo-bq-firestore is a demo project, so these Extensions may not work as expected.
┌─────────────────────────┬──────────────────────────┬──────────────────────────────┬────────────────────────────────────────────┐
│ API Name                │ Instances using this API │ Enabled on demo-bq-firestore │ Enable this API                            │
├─────────────────────────┼──────────────────────────┼──────────────────────────────┼────────────────────────────────────────────┤
│ bigquery.googleapis.com │ firestore-bigquery-expo… │ No                           │ https://firebase.tools/l/LoMXec6nyrFK9XGx6 │
└─────────────────────────┴──────────────────────────┴──────────────────────────────┴────────────────────────────────────────────┘

Enabled on demo-bq-firestore

が No になっており、Firebase Emulator は BigQuery に対応していないのでそれはそう。

試しに Firestore にドキュメントを作ると、同期に失敗してエラーが出る。

⚠  functions: Error: Failed to determine service account. Initialize the SDK with service account credentials or set service account ID as an app option.
9sako69sako6

Firebase Emulator と BigQuery Emulator を接続することを一旦やめて、Firebase Emulator と本物の BigQuery が接続できるかを検証してみる。
結局これになってしまった。

Error: Failed to determine service account. Initialize the SDK with service account credentials or set service account ID as an app option.

Firebase Emulator 起動時の警告

⚠  Function 'fsimportexistingdocs is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠  Function 'syncBigQuery is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠  Function 'initBigQuerySync is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠  Function 'setupBigQuerySync is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠  functions: The functions emulator is configured but there is no functions source directory. Have you run firebase init functions?
⚠  functions: The following emulators are not running, calls to these services from the Functions emulator will affect production: database, hosting, pubsub, storage
i  firestore: Firestore Emulator logging to firestore-debug.log
✔  firestore: Firestore Emulator UI websocket is running on 9150.
i  ui: Emulator UI logging to ui-debug.log
i  functions: Watching "/Users/9sako6/.cache/firebase/extensions/firebase/firestore-bigquery-export@0.1.51/functions" for Cloud Functions...
✔  functions: Loaded functions definitions from source: fsexportbigquery, fsimportexistingdocs, syncBigQuery, initBigQuerySync, setupBigQuerySync.
✔  functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-fsexportbigquery]: firestore function initialized.
⚠  Unsupported function type on ext-firestore-bigquery-export-cnf7-fsimportexistingdocs. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i  functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-fsimportexistingdocs]: function ignored because the unknown emulator does not exist or is not running.
⚠  Unsupported function type on ext-firestore-bigquery-export-cnf7-syncBigQuery. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i  functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-syncBigQuery]: function ignored because the unknown emulator does not exist or is not running.
⚠  Unsupported function type on ext-firestore-bigquery-export-cnf7-initBigQuerySync. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i  functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-initBigQuerySync]: function ignored because the unknown emulator does not exist or is not running.
⚠  Unsupported function type on ext-firestore-bigquery-export-cnf7-setupBigQuerySync. Expected either an httpsTrigger, eventTrigger, or blockingTrigger.
i  functions[asia-northeast1-ext-firestore-bigquery-export-cnf7-setupBigQuerySync]: function ignored because the unknown emulator does not exist or is not running.

この警告は Firebase Emulator 側がサポートしていない機能を使っているために出る。
https://github.com/firebase/firebase-tools/issues/6693#issuecomment-1894374152
https://github.com/firebase/firebase-tools/issues/4884

9sako69sako6

https://note.shiftinc.jp/n/nc6cad687ea74

SHIFT さんの記事にあったバージョンに合わせると、上記エラーも警告も出なくなった。

firebase/firestore-bigquery-export@0.1.51 -> firebase/firestore-bigquery-export@0.1.31

が、下記エラーが出るようになった。

https://github.com/firebase/extensions/issues/1533

この問題はすでに修正してくれているようなので、修正が取り込まれているバージョンに切り替えて試す。
0.1.34 にて修正が取り込まれたようだ。
しかし残念なことに、0.1.33 から下記警告が出るようになり、service account に関するエラーも再発した。したがって 0.1.34 に上げることができない。uh...

⚠  Function 'syncBigQuery is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
⚠  Function 'setupBigQuerySync is missing a trigger in extension.yaml. Please add one, as triggers defined in code are ignored.
Error when mirroring data to BigQuery FirebaseFunctionsError: Failed to determine service account. Initialize the SDK with service account credentials or set service account ID as an app option.
9sako69sako6

0.1.32 から 0.1.33 にかけての差分はこちら。

https://github.com/firebase/extensions/compare/firestore-bigquery-export-v0.1.32...firestore-bigquery-export-v0.1.33

影響がありそうな変更

https://github.com/firebase/extensions/commit/0fd9b79cca9357986ac3d5bb7855f8ecd84f242f

Firebase Emulator 側が対応してくれるのが一番いい。しかし、issue は2年くらいオープンなままなのでサポートを期待するのはきつそう。

https://github.com/firebase/firebase-tools/issues/4884

firebase/firestore-bigquery-export を使うのは諦めて、自前で BigQuery に連携することにした。

9sako69sako6

BigQuery の書き込み・読み込み

https://cloud.google.com/functions/docs/tutorials/bigquery?hl=ja

デザイン

  • Firestore のドキュメント追加・更新イベントをトリガーとして、検索用 BigQuery に書き込む
    • 書き込みは Storage Write API でやる
  • 高頻度な更新・削除は BigQuery のユースケースにそぐわないため、ドキュメントは常にレコードに追加一辺倒。ただし、inserted_at カラムを用意し、アプリではそれが最新のものだけを取得して扱うようにする。
schema.json
 [
 ...
  {
    "name": "inserted_at",
    "type": "TIMESTAMP",
    "description": "The timestamp when the record was inserted into the BigQuery table",
    "defaultValueExpression": "CURRENT_TIMESTAMP()"
  }
]

上記のスキーマ定義と併せて、書き込み側では JSONWriterdefaultMissingValueInterpretation を設定することで、inserted_at は自動的に現在のタイムスタンプが入るようになっている。

    const writer = new managedwriter.JSONWriter({
      connection,
      protoDescriptor,
      defaultMissingValueInterpretation:
        protos.google.cloud.bigquery.storage.v1.AppendRowsRequest.MissingValueInterpretation.DEFAULT_VALUE,
    });

読み取り側では、inserted_at が最新のものだけを扱う。

    const query = `
      SELECT id, title, description
      FROM (
        SELECT
          id,
          title,
          description,
          ROW_NUMBER() OVER (PARTITION BY id ORDER BY inserted_at DESC) AS record_num
        FROM \`${bigqueryProjectId.value()}.${bigqueryDatasetId.value()}.works\`
      )
      WHERE record_num = 1 AND (title LIKE @word OR description LIKE @word)
    `;
9sako69sako6

ローカルの開発環境においても、Firebase Emulator + 本物 BigQuery を使っている。
BigQuery Emulator を使いたかったが、Storage Write API を使った場合に書き込めなかったので諦めた。README を見る限りは対応してそうだけどなあ。

version: 0.6.2

Supports gRPC-based read/write using BigQuery Storage API. Supports both Apache Avro and Arrow formats.

https://github.com/goccy/bigquery-emulator

ほぼ自分しか開発していないため現状は問題ない。

9sako69sako6

注意点として、たとえ new BigQuery() する際に本物の BigQuery がある projectId を指定しても、疎通できなかった。GOOGLE_CLOUD_QUOTA_PROJECT も一時的に書き換える必要がある。

  try {
    // NOTE: 設定しないとエミュレータを使う場合にエミュレータのプロジェクトIDにクエリが飛ぶ
    process.env.GOOGLE_CLOUD_QUOTA_PROJECT = bigqueryProjectId.value();

    const bigquery = new BigQuery({
      projectId: bigqueryProjectId.value(),
      ...(process.env.CI
        ? {
            keyFilename: bigqueryCredentialsPath.value(),
          }
        : {}),
    });

  // ...
  } finally {
    process.env.GOOGLE_CLOUD_QUOTA_PROJECT = projectID.value();
  }

環境

  • @google-cloud/bigquery-storage@4.8.0
  • @google-cloud/bigquery@7.8.0
  • firebase-functions@5.0.1
  • firebase-admin@12.1.1
このスクラップは5ヶ月前にクローズされました