Cloud Firestore を活用したバッチレコメンドシステムを開発した話

こんにちは,株式会社 High Link で業務委託(副業)として働いている,機械学習(ML)エンジニアの柏木(@asteriam)です.

High Link では,カラリア香りの定期便という toC サービスを提供していて,ML エンジニアは,データを武器にした非連続的な事業成長を支える技術開発を担っています.具体的には推薦システムや診断といった部分に ML が活用されています.

ハイリンク 機械学習・データエンジニア向け 紹介資料

今回はよりサービス改善がしやすい環境にすべく,機械学習API(ML-API)と ML パイプラインの役割を分離させ,ロジック改善を回しやすい環境を作っているという話になります.その一環として,パイプラインの結果保存先(レコメンドシステム用の DB として)に Cloud Firestore(以下,Firestore とします) を採用し,活用し始めています.

今までのレコメンドシステムの話

こちらの過去ブログでも紹介していますが,

tech.high-link.co.jp

レコメンドシステムとして,日時で Cloud Workflows(ML パイプライン)を実行し,以下の処理を行っています.

  1. BigQuery からのデータ取得
  2. Cloud Functions での前処理
  3. データを GCS に保存
  4. データロードのために Cloud Run(API サーバー)の再起動

Cloud Workflows の中で必要な計算を行う Cloud Functions と ML-API として動いている Cloud Run が共存していて,1つのパイプラインで連続して処理が行われていきます.

これはレコメンドリストの生成と,その結果をロードして本体アプリケーションに返す ML-API が密結合な状態になっていました.

ML-API とパイプラインの分離

今までのシステムの課題や制約

1つのパイプライン上で上記のような処理が行われていましたが,現状と将来的なものまで含めて以下のような課題感がありました.

  1. モデルのアップデートを行おうとすると,API を含めた処理全体の書き換えが必要で開発コストがそれなりにかかる
    • オンデマンドに計算する必要があるサービスも存在していて,それと合わせる形になっていた
  2. これから多数のユーザー毎にパーソナライズレコメンドをすることを考えると,スケールしにくい状況になっている
    • API では,レコメンドに必要な全データをロードしてメモリに展開し計算していたが,ユーザー×アイテムを考えると効率的ではなく,そのうち限界がくる可能性が高い
  3. AB テストを実施するためのハードルが高く,改善活動が素早くできない
    • レコメンドリスト生成のパイプラインと API が密結合になっているため,ユーザーの振り分けとその時のロジックを同時に実装する必要がある

上記理由などから,まずはバッチレコメンドシステムで良いサービスにおいて,ML-API とパイプラインの役割を分離することにしました.一部はオンデマンドに計算する必要があるサービスもあり,それは現状そのままの状態にしています.

また,レコメンドはサービスとしても期待されいて,ユーザーへの価値貢献も高いため,ここをより高速に改善できる土壌を整備しておくことが今後の展開においても重要であると考えています.

分離後の ML-API の役割

ML-API は本体アプリケーションとレコメンドリスト保存用の DB との間に位置し,本体からのリクエストに対して,DB からデータを取り出し,レコメンドリストとメタデータをレスポンスとして本体に返すようになっています.

ML-API を本体アプリケーションと DB の間に挟むことで,以下のような役割も担っています.

  • AB テストのユーザー振り分けを行う
  • レコメンドリストから対象外のアイテムを除外する
  • レコメンドリストの表示順を変更する

など,ML 側で一部機能をハンドリングできるようになります.また,レコメンドリストやメタデータなどのログを出力できるようにすることで,後々の分析にも活用できる仕組みを担っています.

地味にこの部分が大きく,開発チームに頼らずに ML チーム主導で必要なログを出力する機能を開発したりもできます.

一方で,API を配置することでレイテンシーが発生する可能性もありますが,上記役割のメリットが大きいのと,DB として後ほど紹介する GCP だと Firestore や Bigtable のような Key-Value Store を採用することで,読み込み速度がそれほど気にならないようになっています.

分離後のパイプラインの役割

以前から動いていましたが,よりバッチレコメンドシステムにおけるパイプラインの役割を明確にしました.

  • データ抽出
  • レコメンドリストの生成(前処理〜モデル学習〜予測)
  • 結果を DB に保存

API とは完全に独立してこれらの役割にフォーカスするようにしました.

これにより,今後新しいモデルやロジックを試したい場合には,その処理を行うコンポーネントを開発するだけで良くなりました.またパイプライン処理が失敗した場合についても,再度パイプラインの部分だけ処理を流せば済みます.以前の API も含まれた形だと,API のデプロイ失敗などの場合でも上手く切り離して実行するのが大変だったため,一から再実行をする必要がありました.

余談ですが,今後複雑なモデルを組むためにパイプラインを Cloud Workflows から Vertex AI Pipelines に移行するとしても,今回の分離でコンポーネントの移植がしやすいと考えています.

システムの変更前後

システムの変更前後のアーキテクチャは下図の通りです.

変更後はデプロイ単位をパイプラインと API で分けるようになり,API はレコメンドリストの情報を Firestore から取得するようになっています.

レコメンドリストの情報は過去分は全て GCS に保存しておき,最新のデータだけ Firestore から参照できるようになっています.ユーザーやアイテムに対して,どのような結果がレコメンドされていたかはストレージに全て保存してあるので,過去のデータを分析することもできます.

レコメンド結果保存用のデータベース選定

結論は先にも述べている通り Firestore を使うことにしましたが,その選定の過程を紹介したいと思います.

システム的な変更点として,レコメンドシステム用の DB を用意したという点があります.

今までは,GCS に置かれたレコメンド結果の csv ファイルを ML-API がロードし,それを元にオンデマンドで結果を返す仕組みになっていましたが,レコメンド結果を事前に Firestore に保存しておいて,ML-API は本体のアプリケーションからリクエストがあった時に Firestore から読み込んで結果を返す仕組みに変更しました.

バッチレコメンドの仕組みを考えた時に,実現方法は大きく2通りあると思います.

  1. 本体アプリケーションに DB からレコメンド結果を取得して貰う方法
    • パイプラインで計算されたレコメンド結果を SQL や NoSQL などの DB に直接保存し,本体のアプリケーションにはそこを直接参照して貰う方法です.
    • この場合は,本体のアプリケーション側にデータを取ってくる処理を実装して貰う必要があります.ML の責任範囲としてはパイプラインから DB にデータを入れるまでとなります.(運用メンバーが少ない場合には,ML の負担を下げることができる)
  2. ML-API を本体アプリケーションと DB の間に置き,ML-API がレコメンド結果を取得して返す方法
    • 今回我々が採用した方法になります.
    • この場合,ML の責任範囲が ML-API まで伸びますが,ML 側でのレコメンドリストのハンドリングや AB テストのハンドリングといったメリットもあります.

カラリアでは,DB は使用していないものの, API を間に挟む方法で運用されていたこともあり,そのままこちらを採用し,DBに何を使うかという技術選定の話になりました.

Firestore と Bigtable の比較

レコメンドシステム用に使う DB としては,パフォーマンスやレイテンシーを考えた時に,NoSQL データベースが望ましいため,候補として Firestore と Bigtable が挙がりました.それぞれの特徴は以下の通りになります.

Firestore の特徴

まず簡単に Cloud Firestore の特徴について説明すると,マネージドな NoSQL データベースであり,ドキュメントに対して高速にアクセスが可能であったり,柔軟なスケーラビリティがあるデータベースになります.

  • スキーマレスなデータベース
    • ドキュメントとコレクションの階層構造でデータを管理する
  • インデックスの設計が可能
    • クエリ処理のパフォーマンスを向上させるために,インデックスを使用することができる
  • スケーラビリティ
    • データ増加に対して,データの分散とスケーリングを自動的に処理することができる

Bigtable の特徴

Bigtable はより大規模なデータに最適化されていて,高スループット・低レイテンシーでのアクセスが可能な NoSQL データベースになります.今話題の LLM による Embedding などのデータを保存し即時の計算に利用する場合はこちらを採用することになると思います.

  • スループット / 低レイテンシー
    • 大量データの読み書きが可能で,非常に低いレイテンシーでデータにアクセスが可能
      • データアクセスに対して,非常に高速でバラツキも小さい
  • 柔軟なスキーマ設計が可能
    • 列(カラム)指向のデータベースであり,柔軟なスキーマ設計が可能
    • メタデータをカラム単位で付与でき,行単位でデータは保存されている
  • スケーラビリティ
    • 自動スケーリングが可能
    • 必要に応じた容量の追加なども可能

比較検証

複数サービスの結果を分けて保存できるか,それらが簡単に実装できるかなどは実際にサンプルコードを書いて Firestore と Bigtable どちらも検証し,それが問題なく可能であることがわかりました.なので,現状私たちのチームでやりたいことは,Firestore でも Bigtable でも同じことが出来るため,判断材料としては,コスト面とレイテンシーを見て考えました.

Firestore Bigtable
コスト
レイテンシー △ or ○

コストは実際の想定しているサービス利用で見積りを行ってみると Firestore が Bigtable の数10分の1で,圧倒的に Firestore が低コストでした.

レイテンシーに関しては,比較方法として Firestore, Bigtable それぞれからのレコメンドリスト呼び出し処理を100回実行し,その処理時間の平均・分散・標準偏差を算出し比較しました.結果は,Bigtable の方が性能は良かったですが,Firestore でも許容できる範囲内の結果となりました.

検証の結果,現状のカラリアのレコメンドサービスの状況を加味すると,Bigtable は Too much だと判断し,Firestore を採用することにしました.

テーブル設計(ドキュメント-コレクションの設計)

Firestore を用いる場合は,ドキュメントとコレクションの構造設計が肝であり,クエリのアクセス性やパフォーマンスまたは,データの格納方法などを考慮した適切な階層構造が必要になってきます.

カラリアにおけるテーブル設計

前提として以下の要件がありました.

  • 複数サービスのレコメンド結果を保存したい
  • 環境(production, staging)毎に結果を保存したい
  • AB テストに対応するために ”control” と “treatment” の結果をそれぞれ保存したい

これらの要件に対応するために,以下の方法で設計しています.

  • 複数サービスと環境毎に対応するために,ルートレベルでコレクションを分割する方法を取りました.ルートレベルで {サービス名}-{環境名} の形式でデータを分けるようにしました.
    • ルートコレクション直下はドキュメントが来るのでここは,一意のキーとなる値を入れます(e.g. ユーザーID, アイテムIDなど).
  • さらにその下にサブコレクションを作成し,AB テスト用の ”control” と “treatment” で分割しました.AB テスト用のコレクション配下は比較的自由にサービスに合わせて保存していく方針で良く,最終的なレコメンド結果は recommend_results のフィールドでアイテム情報(item_id, affinity)を保存するようにしています.

データ構造の画像

トップコレクションでサービス毎に分けているため,1つのコレクションにアクセスが集中する(パフォーマンスの影響)ことが無いようにしています.

補足:AB テストのサブコレクションを用意した理由

AB テストに対応するために ”control” と “treatment” のサブコレクションを用意した理由ですが,

  • 明示的にコレクションを区切ることで,クエリ抽出する際の範囲を分けることができる
    • 不要なデータを抽出する必要がない
  • サブコレクションではなく,フィールドに追加した場合はデータが上書きされてしまう
    • データ更新時に更新内容で全てが上書きされてしまうので,過去のデータが残らない

あと最後に気になる点と良い点を挙げておきます.

  • 気になる点
    • データ投入時のバッチ投入の上限が500件になので,件数が多いと保存処理が結構かかってしまうという点があります.
  • 良い点
    • Firestore は地味にコンソール上で結果を確認できるのでありがたいです!

運用してみての感想

まだ運用して数ヶ月で,現在2つのサービスで今回紹介したバッチレコメンドの仕組みの中で Firestore を運用していますが,バッチでのデータ書き込み時の Too many requests のエラーなどは起きておらず問題なく運用できています.コストを大きく下げることもできたので,良い結果になっています.

一方で,ユーザー×アイテムでの組み合わせでレコメンドシステムを提供する場合,やはり Firestore にデータを保存する処理がそれなりにかかる結果となっています.今後はユーザー数・アイテム数共に増加していくことになるので,その際にどれぐらい処理がかかってしまうか,またそれによる影響がどこにどれだけ出るかなどを見積り,適切に対応できるようにしたいと思っています.

また,元々やりたかった ML-API とパイプラインを分離できたことは非常に大きく,これによって今後はよりモデル改善の方にも力を注げるようになるので,ユーザー体験の向上に伴うサービスグロースをやっていきたいと思います!

まとめ

改めて今回の取り組みでは,

  • 新しく ML-API とパイプラインを分離した
  • 新しくレコメンドシステム用のデータベースとして,Firestore を選定し活用している

を行いました.

この取り組みを通して,以前より AB テストを実施しやすい環境になったので,今後はよりモデル開発→デプロイの改善サイクルを素早く回しサービスグロースに貢献していきたいと思います!

レコメンドシステム用のデータベースとして,Firestore を活用したテックブログはあまり世に出ていないので,是非参考にして頂けると嬉しいです!


herp.careers

参考