
マーケティングオートメーション
イベント
マガジン
技術ブログ
はじめに こんにちは、MA部MAシステム開発ブロックのまんさくです。普段はマーケティングオートメーションシステムの開発・運用を担当しています。 ZOZOTOWNでは、アプリのPush通知やLINE、メールでキャンペーンを配信しています。MA部は、これらの配信を担うマーケティングオートメーション(MA)システムを開発しています。 配信には大きく2種類あります。特定の会員セグメントへ一括で送る「バッチ配信」と、会員一人ひとりの行動に合わせて送る「パーソナライズ配信」です。本記事で扱うのは後者です。その配信可否を決める前段階のジョブについて、DB・ジョブ起動基盤・メッセージングの3つの層で改善に取り組みました。うまくいった2つの改善と、検証の結果見送った1つの改善を、判断の根拠とあわせて紹介します。 次のような方に読んでいただければと思います。 PostgreSQLで更新の多いテーブルのbloatやパフォーマンス劣化に悩んでいる方 Cloud Tasksを使ったシャード分割処理の構成と流量制御を検討している方 Pub/Subのordering keyとスケールの関係、特にカーディナリティを上げる際のトレードオフを知りたい方 目次 はじめに 目次 背景・課題 1. 過剰な書き込みを削減する 何が起きていたか なぜ深刻だったか autovacuumの設定と限界 対策1:書き込みを減らす 対策2:インデックスを小さくする ここまでの効果と残る課題 対策3:収集の起点を小さいテーブルへ移す 2. Cloud SchedulerジョブをCloud Tasksへ移行する 最初はシンプルなScheduler増加を選んだ Cloud Tasksへの集約 タイムアウトと運用上の注意 3. Pub/Sub ordering keyのカーディナリティを上げる試み なぜordering keyを使っているか 高カーディナリティ化の狙い 受信側はスケールしたが、送信側が重い 見送りの判断と残る選択肢 まとめ 背景・課題 パーソナライズ配信では、会員ごとの配信候補を収集する処理と、候補を最適化して配信判断へ渡す処理が連携して動きます。ここでの最適化は、会員ごとに配信する時間帯を調整したり、Push通知・LINE・メールのどのチャネルで届けるかを選んだりする処理です。基盤の全体像とリプレイスの経緯は、次の記事の「リプレイス後のアーキテクチャ」で紹介しています。 techblog.zozo.com この基盤はGoで実装し、Google Cloud上で動かしています。DBはAlloyDB、ジョブの起動にはCloud Scheduler、処理間の連携にはPub/Subを使っています。 負荷を分散するため、会員IDを100で割った余りによって100グループに分け、それを2グループずつまとめた50シャード単位で毎分処理しています。 この構成で運用を続けるなかで、3つの層それぞれに課題が表面化しました。本記事で扱う内容を先にまとめます。 層 課題 対策 結果 DB(AlloyDB) 実際には配信しない候補まで書き込み・更新を繰り返し、テーブルとインデックスが肥大化 書き込み回数の削減とインデックス再設計 採用 ジョブ起動(Cloud Scheduler) 50個のジョブがシャード範囲を直書きしており、流量調整も監視も煩雑 Cloud Tasksへ集約 採用 メッセージング(Pub/Sub) ordering keyがシャードと結びつき、下流の並列度がシャード数で頭打ち ordering keyを会員ID単位へ細分化 見送り 以降では、それぞれの対策を紹介します。 1. 過剰な書き込みを削減する 1つ目は、配信候補テーブルへの過剰な書き込みの削減です。 何が起きていたか ここまで配信候補テーブルと呼んできたものは、AlloyDB(PostgreSQL互換)上の offer_candidates テーブルです。最適化の過程で扱う候補を1行ずつ持ち、実際には配信しない判定で終わる行も多いため、行数が膨らみます。処理状態や配信時刻に加えてパーソナライズ判定用の項目も持つため、1行あたりのサイズも大きめです。 問題の中心は、候補の配信時刻を付け直す処理でした。同一の会員・チャネルに紐づく複数行へ、同じ内容のUPDATEを繰り返し発行していました。本来まとめられるはずの更新が、処理のたびにばらばらと走っていました。書き込みが必要以上に増え、テーブルとインデックスのbloat、HOT更新の効きにくさ、VACUUMの遅延が重なっていました。 ここでいうbloatとは、削除・更新によって使われなくなった領域がファイル内に残り、実データの量に対してテーブルやインデックスのサイズが膨らんだ状態を指します。PostgreSQLでは更新のたびに古い行が残るため、回収が追いつかないと領域が積み上がっていきます。 なぜ深刻だったか PostgreSQLではUPDATEは実質的に「新しい行バージョンの追加」です。古いバージョンはVACUUMが回収するまで残ります。更新列がインデックスに含まれるとHOT(Heap-Only Tuple)更新も効きにくく、インデックス側にもエントリが増えます。大きなインデックスが複数ある状態では、1回のUPDATEのコストも、その後の回収コストも両方上がります。 回収をさらに難しくしていたのが、リードレプリカ参照です。ここで効いてくるのが hot_standby_feedback というパラメータです。onにすると、レプリカは「いま自分が読んでいる最も古い行」をプライマリへ伝え、プライマリはその行を回収せずに残します。レプリカ上のクエリが途中で消えた行を参照して失敗する事態を防ぐ仕組みです。 このパラメータをoffにはできません。offにすると、プライマリのVACUUMとレプリカ上の長いクエリが衝突し、キャンセルが多発して処理が進まなくなるためです。onのままにすると、レプリカで長いクエリが走っているあいだプライマリのVACUUMが死んだ行を回収しにくくなります。 どこまで回収できるかはxmin horizonという基準で決まります。実行中のトランザクションが見ている可能性のある行は、まだ回収できないためです。この基準を押し戻しているセッションを1日分記録してみると、最も多く現れていたのはレプリカ側の処理ではなく、収集処理そのものでした。2分を超えるトランザクションがシャードごとに並走し、そのあいだは死んだ行を回収できません。つまり書き込みを減らすことは、生成されるデッドタプルの量を減らすだけでなく、回収を妨げる時間そのものを縮めることでもありました。 bloatの影響は読み取りにも及びます。 EXPLAIN にIndex Only Scanと出ていても、Heap Fetches(ヒープへの参照)が多ければインデックスだけでは完結せず、本体ページを大量に読みます。実効コストはSeq Scanに近づきます。bloatが進むとvisibility mapが効きにくくなるため、Index Only Scanを選んでいてもHeap Fetchesが増えやすくなります。 これらは独立した問題ではなく、互いを悪化させる循環になっていました。 autovacuumの設定と限界 デッドタプルの回収は主にautovacuumに依存しています。 offer_candidates には、デフォルトより早め・強めに動かす設定を入れています。 ALTER TABLE offer_candidates SET ( autovacuum_vacuum_scale_factor = 0 . 01 , autovacuum_analyze_scale_factor = 0 . 01 , autovacuum_vacuum_cost_limit = 1000 ); VACUUMの起動判定は、おおむね次の式です。 autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × ライブ行数 thresholdのデフォルトは50、scale_factorのデフォルトは0.2です。scale_factorを0.01に下げているため、デッドタプルが行数の約1%に達したあたりでVACUUMが検討されます。デフォルトの約20%まで待たずに動き始める、という意図です。analyzeも同様に0.01へ下げ、統計情報の更新を早めます。 autovacuum_vacuum_cost_limit = 1000 は、1回のコスト計算あたりに許す仕事量の上限です。値を大きくすると、スリープを挟む前により多く進めるため、VACUUM自体は積極的になります。その分、稼働中のI/O負荷も上がりやすい点には注意が必要です。あわせて、プライマリ側では autovacuum_work_mem を大きめに取り、VACUUM中のメモリ不足で効率が落ちにくいようにしています。 それでもautovacuumには限界があります。テーブル本体のVACUUMに付随するインデックス掃除は、基本的に直列で進みます。手動の VACUUM ならインデックス掃除を並列化できますが、日常運用の主戦場であるautovacuumではそれが使えません。インデックス本数が多いほど1回のautovacuumが長引き、その間に次のデッドタプルが溜まりやすくなります。 そのため、書き込み回数を減らす、インデックスを必要最小限にする、HOTが効く形へ寄せる、という3方向で改善を進めています。前の2つは適用済みで、最後の1つは収集起点を移す構成変更として進行中です。 対策1:書き込みを減らす まずアプリケーション側で、書き込み回数そのものを減らしました。方針は次のとおりです。 配信時刻の付け直しは、会員とチャネルの組ごとに必要な分だけ行う。同じ組に複数行あっても、何度も書き換えない まだ処理すべきでない組は、その回の対象から外す。途中段階で何度も同じ更新を繰り返さない 配信対象外と判定できる処理を後工程から前工程へ移し、不要な行のINSERT自体を抑える。後からDELETEするより、そもそも書き込まない方がDB負荷は小さい 処理の流れは、まず配信対象になりうる会員を探し、その後に更新対象の候補行を拾う、という二段構えです。会員抽出側の条件も同じルールに揃えています。 対策2:インデックスを小さくする 書き込み削減とあわせて、インデックスも見直しました。前述のとおり、autovacuumではインデックス掃除が直列に進むため、更新の多いテーブルでは本数そのものが運用コストになります。 まず未使用・非効率だったインデックスを3本削除しました。そのうえで、参照頻度の高い状態の行だけを対象とする部分インデックスを2本追加しています。なお本記事では、DDLやインデックス定義の列名を、役割が伝わりやすいものに置き換えています。 会員IDと配信時刻で探すクエリ向け: (member_id, due_at) シャード範囲と配信時刻で会員を拾うクエリ向け: ((member_id % 100), due_at) に必要列を INCLUDE したカバリングインデックス どちらも WHERE status = 'waiting' 相当の述語付きです。適用後の EXPLAIN では、主要な2本のクエリがこれらのインデックスを使うことを確認しました。 インデックスのサイズは次のように変わりました。 見直し前 見直し後 収集用インデックスの合計 約23GB(3本) 約9.6GB(2本) 主キー 7,046MB 7,046MB UUIDのユニーク制約 7,676MB 7,676MB 見直しの対象外である主キーとユニーク制約のサイズが変わっていないので、テーブル全体の規模が大きく動いたわけではありません。そのうえで収集用のインデックスだけが3分の1近くまで小さくなりました。特に大きかったのは、状態と配信時刻を含む1本が単独で14GBを占めていたことです。 運用上の注意点も2つあります。 1つ目は、想定したインデックスが選ばれないケースです。ユニークキーでの探索を期待していても、plannerが別プランを選ぶことがあります。部分インデックスの述語と、実際のWHERE句・統計情報の組み合わせを見ながら調整が必要でした。 2つ目は、スキーマ管理にpsqldefを使う場合の差分です。ステータスは可読性のため文字列で持っています。その結果、部分インデックスの述語はPostgreSQLカタログの正規化形で書く必要があります。 status = 'waiting' と書くだけでは足りず、 status::text = 'waiting'::text のように書かないと毎回DROP / CREATEの差分が出続けます。数値やenumならこの種の差分は出にくい一方、ログやアドホックな調査では文字列の方が追いやすい、というトレードオフです。 ここまでの効果と残る課題 ここまでの対策だけでも、現在の運用上はSLAを担保できる状態になりました。一方で、前述のリードレプリカ制約により、根本のbloat要因は残っています。回収できないまま書き込みが続くと、ファイルの末尾が伸びていきます。VACUUMがファイルを小さくできるのは、末尾のページがまとめて空いたときだけです。このテーブルでは末尾に新しい行が入り続けるため、その状態にはほとんどなりません。途中にできた空きは次の書き込みに再利用されるだけで、ファイルサイズは下がりません。テーブルとインデックスのサイズは、「回収が間に合わなかった時点の最大値」で切り上がっていきます。 実測で課題になっているのが、先に追加した部分インデックス2本の肥大と、VACUUM自体の長時間化です。この2本は、状態が waiting の行だけを対象にして小さく保つ意図でした。ところが先ほどの約9.6GBは均等な内訳ではなく、カバリングインデックス側だけで8GBを超えています。配信を待っている行の実数から考えれば、本来これほど必要ありません。回収が追いつかず、過去の肥大が切り上がったまま残っている状態です。肥大したインデックスはスキャンを遅くします。そして遅くなったクエリがさらにVACUUMを止めるため、悪化が加速します。Index Only Scanに見えてもHeap Fetchesが増える状態も、この連鎖の一部です。加えて、今後もデータの更新量はさらに増える想定であり、いまのままでは余裕が足りません。 対策3:収集の起点を小さいテーブルへ移す そこで、収集の起点を、調べに行く会員だけを持つ小さな「きっかけテーブル」へ移す構成変更に取り組んでいます。 候補本体の大きな行はそのまま残します。きっかけテーブルは会員を探すきっかけだけを持ち、収集対象かどうかの判定は、従来どおり候補本体側で行います。早すぎる行や古い行が残っても空振りするだけで済むよう、意図して選んでいます。 きっかけテーブルには、候補を書き込むときに同時に行を登録します。候補の配信時刻を付け直したときも、既存行を書き換えるのではなく、新しい時刻の行を別途登録します。起点のテーブルまで頻繁に更新していては、bloat対策として本末転倒だからです。古い時刻の行は空振りになるだけで、新しい時刻の行が収集を起こすため、配信を取りこぼすことはありません。 収集処理が読んだ行は、結果に応じて次のように扱います。 候補の処理に成功した行は削除する 失敗した行は残し、次の実行で拾い直す 候補が見つからず空振りした行は、レプリカ遅延の許容内なら残す。許容を超えたら恒久的に不要とみなして削除する 失敗や許容内の空振りを残すのは、毎分全件を見ていたころの自己修復性を保つためです。逆に許容を超えた空振りを消すのは、きっかけテーブルが空振り行で膨らみ続けるのを防ぐためです。 削除するときは、読んだときの登録時刻と一致する行だけを消します。収集処理が動いているあいだに同じ会員・時刻の候補が追加されると、その行の登録時刻は新しくなります。その行は削除条件から外れるので残り、追加された候補は次の実行で拾われます。登録時刻を見ずに消すと、読んだ後に増えた候補を起こすものがなくなります。 ここまでの動きを支える列は、次の4つだけです。 CREATE TABLE offer_candidate_due_members ( member_group smallint NOT NULL , due_at TIMESTAMP WITH TIME ZONE NOT NULL , member_id bigint NOT NULL , created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP , PRIMARY KEY (member_group, due_at, member_id) ); 主キーは (member_group, due_at, member_id) の順にしています。収集処理は担当するグループを等価条件で列挙して引きます。この形なら、各グループの先頭から「いま処理すべき時刻」まで読んだところで走査が止まります。先頭列を範囲条件にすると、B-treeは due_at で打ち切れず、未来の行まで毎回なめてしまいます。収集はこの主キーだけで足りるので、専用のインデックスは持ちません。 このテーブルにも配信候補テーブルと同じautovacuum設定を入れています。全行が1日で「INSERT →(再登録があれば登録時刻のUPDATE)→ 読まれる → DELETE」を辿るため、1日の終わりには中身のほとんどがデッドタプルになるからです。 そのため fillfactor も80に下げています。HOT更新は同じページ内に新しい行バージョンを置く余白を必要とするので、その分を空けておきます。1行が細くページの埋まりやすいテーブルなので、配信候補テーブルより効きやすい調整です。 収集起点が移ることで、配信候補テーブル側のインデックスは次のように変わります。太字がbloatの主因でした。 変更前 変更後 主キー id id ユニーク uuid uuid 収集用 (member_id, due_at) WHERE status='waiting' (member_id) 収集用 (member_id % 100, due_at) WHERE status='waiting' 削除 部分インデックス2本を削除でき、更新の対象になる due_at と status はどのインデックスにも含まれなくなります。これで状態遷移や時刻の付け直しがHOT更新の候補になります。HOTの成立には同じページ内に新しい行バージョンを置く空きも必要なため、すべての更新がHOTになるわけではありません。それでも、HOTになった更新はインデックスにdead entryを追加しないため、VACUUMが回収すべき仕事量は減っていきます。 2. Cloud SchedulerジョブをCloud Tasksへ移行する 2つ目は、ジョブ起動基盤の改善です。 最初はシンプルなScheduler増加を選んだ 配信ジョブの性能を上げるため、処理をシャード分割して分散させる方針を取りました。リリースを優先し、まずはCloud Schedulerのジョブをシャード数分用意して、各ジョブが配信候補の収集処理を直接呼ぶシンプルな形にしました。 立ち上げとしては合理的でしたが、運用が続くと次のつらさが出てきました。 シャード分割や流量制限を変えるたびに、Schedulerジョブの作り直しが必要になる 流量を落としたいときに、ジョブを手動でenable / disableする手間がかかる Schedulerが50本あると、監視もコンソール上の見通しも悪くなる 導入してしばらくは、障害時にPagerDutyのアラートもシャード数分飛び、Ackするだけで手が塞がった 通知の爆発については、Schedulerが50本のままでも、PromQL側でまとめて1通知にするよう監視を直して吸収しました。ジョブ自体の集約とは別に、先に運用面の痛みを下げました。 Cloud Tasksへの集約 そこで、Cloud Schedulerは毎分1回だけ振り分け用のエンドポイントを呼ぶ形に変えました。振り分け側は会員IDの剰余空間(現状は100分割)を shardSize 刻みに切り、各シャードの収集処理をCloud Tasksキューへ積みます。Tasks側のタスクが、実際の収集処理をOIDC付きで呼び出します。 流量制御はキュー側に集約できます。分割粒度( shardSize )はSchedulerのクエリパラメータで渡せるため、アプリの再デプロイなしに変えられます。剰余の母数そのもの(100)は、配信候補テーブルのインデックス定義( member_id % 100 )に埋め込まれているため、簡単には変えられません。 採用している主なパラメータは次のとおりです。 項目 値 役割 Scheduler 毎分1ジョブ / attempt_deadline=60s 振り分けだけを起動 shardSize 2(現状) 100空間を50タスクに分割 max_concurrent_dispatches 50 同時に走る収集処理数の上限。負荷調整時はここを下げる max_dispatches_per_second 500 秒間の起動上限 リトライ max 4回、backoff 1s〜10s 一時失敗の再試行 enqueue並行度 50(ベストエフォート) 全シャードのenqueue完了を待って2xx。設計上は通常1秒未満 Cloud Traceで1週間分の実行を見ると、振り分け処理の所要時間は大半が1秒未満で、まれに数秒かかることがあります。Schedulerの attempt_deadline は60秒なので、この程度のばらつきは吸収できます。ただし振り分けが遅れた分だけ収集処理の開始も遅れるため、極端に長い場合は監視で気づけるようにしています。 振り分け側では重複防止をしません。毎分必ずタスクを積み、二重に来た分は収集処理側の実行中ガード(HTTP 204)で吸収します。enqueueに失敗したシャードは、次の毎分サイクルで再度積まれる前提です。 監視も、Scheduler 50本それぞれの状態を見る形から次の観点へ整理しました。 振り分けジョブ自体の未起動 / 実行失敗 収集処理のシャード別連続エラー enqueue失敗(ベストエフォートで2xxを返すため、別途必須) Cloud Tasksキューの滞留 収集処理のジョブSLA超過 運用開始後は、キュー滞留アラートの継続時間を収集処理のSLA(20分)に揃えたり、一過性のenqueue失敗で誤発火しないよう条件を入れたりと、閾値のチューニングも続けています。 タイムアウトと運用上の注意 注意したいのが、Cloud TasksからCloud Runの収集処理を呼ぶときのタイムアウトです。Cloud Tasks側の dispatch_deadline と、Cloud Run側のリクエストタイムアウトは揃える必要があります。 Cloud TasksのGoクライアントのリファレンス にも、 dispatch_deadline はアプリハンドラのタイムアウトより数秒長い程度に設定する、という推奨があります。App Engineターゲット向けの記載ですが、期限が切れるとCloud Tasksが応答を待つのをやめて再試行する動きは共通です。Cloud Runを呼ぶ場合にも同じ理屈が当てはまります。Cloud Tasks側だけ先に切れると、Cloud Run上では処理が続いているのにTasksが失敗とみなして再送し、二重実行の温床になります。 ただし、期限を揃えれば二重実行がなくなるわけではありません。Cloud Runはタイムアウトで504を返した後も、ハンドラの処理がすぐに止まるとは限らないためです。重なった実行を最終的に吸収するのは前述の実行中ガードです。期限合わせは、無駄な失敗判定と再送を減らすための調整です。収集処理のように時間が伸びうる処理では、この関係を意識して設計する必要があります。 shardSize の変更にも注意が必要です。範囲の重なる新旧のタスクが並走しないよう、振り分けを一時停止し、キューが空になるのを確認します。キューが空でも、前述のとおり収集処理が続いていることはあり得ます。実行中ガードはシャード範囲を単位にしているため、範囲の形が変わると旧タスクの抑止には使えません。そのため、走っている収集処理が終わるまで待ちます。 下流の最適化処理が終わるのも待ちます。収集処理から送るメッセージの順序も、シャード範囲を単位にしているためです。範囲が変わると同じ会員のメッセージが別の単位で流れるので、前の範囲を処理し終えてから変更します。 3. Pub/Sub ordering keyのカーディナリティを上げる試み 3つ目は、Pub/Subのordering keyに関する改善です。こちらは実装・検証まで進めたうえで、採用を見送った事例です。 なぜordering keyを使っているか ordering keyは、パーソナライズ配信の整合性を保つために使っています。同一会員の候補を順序付きで扱う必要があるためです。 当初は複数会員の候補を1メッセージにまとめていたため、ordering keyをシャード範囲と結びつける必要がありました。その結果、受信側の並列度がシャード数で頭打ちになっていました。前章の shardSize 変更で、下流の処理完了まで待つ必要があるのも、この結合が理由です。 高カーディナリティ化の狙い 並列度を上げるため、次の方針で実装して検証しました。 メッセージを細かくし、ordering keyをシャード範囲から会員IDへ変える 同一会員内の順序は、送信側の並びと受信側の処理順で担保する 受信側では細かいメッセージをまとめて処理し、DBへまとめて渡せる形を残す 送信側・受信側の流量制御(outstanding messages)もあわせて調整する メッセージを細かくすると受信側はスケールしやすい一方、DB側でまとめて処理する利点とのバランスは取りにくくなります。そこで、送信は細かく、受信で再バッチする形を採りました。 受信側はスケールしたが、送信側が重い 受信側は期待どおりスケールしました。一方で、送信側の負荷が想定以上に上がりました。 次の図は、STG環境で取得したCloud Runのメトリクスです。左の3つの山が変更前、右の2つの山が変更後です。この処理は毎分起動して候補をまとめてpublishするため、1リクエストの所要時間はもともと秒から分のオーダーです。ここで見るべきは絶対値ではなく、同じ仕事に対する変化です。 変更後はCPU使用率が100%に到達し、リクエストのテールレイテンシも跳ね上がりました。注目したいのはLatency breakdownの内訳です。伸びているのは青色のUser execution、つまりアプリケーション自身の実行時間でした。ネットワークやルーティングではなく、publish処理そのものが重くなっていました。 変更前にもUser executionが伸びる山はありました。変更後に新しく現れた現象ではなく、スパイクがより高く鋭くなった、という変化です。 原因はGoクライアントの実装から見えてきました。publishのbundleは「同じbatch内のmessageは同じordering keyを持つ」前提で組まれています。次のコメントは cloud.google.com/go/pubsub の Topic.publishMessageBundle にあるものです。 extract the ordering key for this batch. since messages in the same batch share the same ordering key, it doesn't matter which we read from. ( googleapis/google-cloud-go pubsub/topic.go より引用) つまり、ordering keyが異なるメッセージは同じbundleにまとめられません。keyのカーディナリティが高く、各keyあたりのpublish rateが低い場合、batch効率は落ちやすくなります。結果として、同じメッセージ数でもRPC数は増えます。クライアント内部でpublishを管理するschedulerの状態やタイマーも膨らみ、publisher側の負荷につながります。先ほどのUser executionの伸びは、この増加分として説明できます。なお、これは公式ドキュメントに明記された挙動ではなく、実装からの推論です。 この推論は、Pub/Subトピック側のメトリクスからも確認できました。 上段のPublished message countは、publishしたメッセージ数です。1メッセージ1候補にしたため、変更後は毎秒20,000近くまで増えています。ここは設計どおりです。 問題は下段のPublish requestsで、こちらはpublishのリクエスト数、つまりRPCの数です。変更後は毎秒15,000から18,000に達しています。メッセージ数が毎秒20,000程度であることと合わせると、1回のRPCで運べているメッセージはごくわずかです。bundleがほとんどまとまらないまま送信されていた、という状態です。 送信側と受信側の流量制御パラメータや、上流の shardSize も変えて試しました。しかし、いずれも負荷の出方を変えるだけで、bundleが分かれる構造そのものには効きませんでした。 見送りの判断と残る選択肢 受信側のスケール自体は成功しました。しかし、パラメータ調整では回避できない送信側の負荷増と実行時間の悪化に見合うだけの利点がないと判断し、本番採用は見送りました。ordering keyのカーディナリティは、下流の並列度だけでなくpublishスループットにも効きます。ドキュメントが勧める「キーを細かくする」方向と、クライアント実装上のbatch効率は、単純には両立しません。 並列度の頭打ち自体は残っている課題です。今回試したのはシャード範囲と会員IDという両端で、その中間にあたる粒度は試せていません。keyを今の50通りより細かく、会員IDよりは粗く保てば、並列度とbatch効率が両立する粒度を見つけられるはずです。 中間の粒度は、いまの収集分割のまま試せます。 shardSize を1にすれば、シャード範囲をキーにしたままでも、現行の100分割で100通りになります。キーを独立させるなら、送信側で member_id % N をordering keyにすれば足ります。収集ジョブの分割やインデックスを変えなくても、Nを選べます。 収集の分割とPub/Subのキー空間が結びついているのは、複数会員を1メッセージにまとめていた名残です。キーを独立させれば、収集側の流量調整とordering keyのカーディナリティを別々に動かせます。中間の最適点を探すのは、その先の課題として残しています。 まとめ 本記事では、毎分50シャードで動くパーソナライズ配信ジョブに対する、3つの負荷・運用改善を紹介しました。 配信候補テーブルへの過剰なUPDATE / INSERTを抑え、インデックスを必要最小限の部分インデックスへ再設計して、現行運用のSLAを担保できる状態まで改善した。一方で、リードレプリカ制約下では部分インデックスのbloatが残りうるため、収集起点を小さいテーブルへ移す構成変更にも取り組んでいる Cloud Scheduler 50本を、振り分け1本とCloud Tasksへ集約し、流量調整と監視を再デプロイなしで扱いやすい形にした Pub/Subのordering keyを細かくする改修は、受信側のスケールには効いた一方、Goクライアントのbatch実装により送信側の負荷が増え、本番採用は見送った うまくいった改善だけでなく、見送りの判断とその根拠も、同様の構成を運用する方の参考になれば幸いです。今後は、きっかけテーブルへの構成変更を本番へ適用し、bloatの根本要因にどこまで効くのかを実測で確かめていきます。ordering keyについては、シャード範囲と会員IDの間の粒度を検証していきます。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに こんにちは、データシステム部MA推薦ブロックの佐藤( @rayuron )です。私たちは、主にZOZOTOWNのメール配信のパーソナライズなど、マーケティングオートメーションに関するレコメンドシステムを開発・運用しています。 以前、テックブログで工数やAI活用による工数削減を計測する仕組みを、タスク管理ツールの GitHub Projects で構築する方法をご紹介しました。 前回の記事 が計測の仕組みの作り方を扱ったのに対し、本記事では仕組みを1年間運用して得られた結果とその考察、そして今後の展望をご紹介します。 目次 はじめに 目次 背景と課題 計測の仕組みと運用 計測の仕組み 運用の整備 結果と考察 AIによる工数削減とAI活用率の推移 作業内容別の効率 定性的な変化 体感と実測のギャップ 今後の展望 Spec: 仕様の明文化 Eval: 成果物の品質の評価 AIが自律的に動く状態の構築 ソウゾウのナナメウエの創出 おわりに 背景と課題 昨今のAIの進化は目覚ましく、私たちの業務でもAIを活用する機会が増えています。AIを使うことで、業務の工数削減や品質向上など、多くのメリットを享受できます。特に工数削減については、「タスクを捌く時間がなんとなく短くなった」という体感がありました。一方で、実際どれだけ効果があったのかは把握できていませんでした。そこで、私たちはAI活用の効果のうち工数削減を計測することにしました。 AIによる工数削減を測るには、AIを使った時と使わなかった時の工数を比較する必要があります。しかし、現実にはある1つのタスクに対してAIを使う場合と使わない場合を同時に観測できません。AIの利用回数やPull Request(以下PR)のマージ数などの指標は取得できるものの、これらは活動量を示すものであり、工数削減に「どれだけ効果があったか」を直接測るものではありません。また、MLチームの業務にはモデル開発の実験・ドキュメント作成・プロジェクトマネジメント業務・登壇準備など、PRにならない業務が多く、PRベースの計測ツールでは網羅的に計測できませんでした。 AIの効果が測れないままでは、投資対効果を説明できませんし、どのパターンの業務がAIと相性が良いのかも感覚的にしか分からず、改善の方向性も定まりません。そこで私たちは、効果を測る仕組みを作り、得られたフィードバックをもとにAI活用の改善を進めることにしました。 計測の仕組みと運用 まず取り組みを見ていただく前に、私たちの通常業務を紹介します。後述する結果はチームのタスクの内容に大きく依存するため、先に知っていただくと結果を解釈しやすくなると思います。 私たちMA推薦ブロックは、エンジニア3人の小規模チームです。メール・LINE・Pushなどの配信をパーソナライズするレコメンドシステムを開発・運用しており、業務例は以下の通りです。 施策起案/要件定義:施策インパクトの事前分析・要件定義 システム/モデル設計:推薦モデル・システムの設計 システム/モデル実装:推薦モデル・システムの実装、テスト、デプロイ 効果検証:A/Bテストダッシュボード作成、効果検証レポーティング作成 運用保守:オンコール対応、パッケージ更新、問い合わせ対応 その他:プロジェクトマネジメント、チームの仕組み作り、目標設定、勉強会、登壇など 開発にはGitHubを使用しており、ドキュメント管理には Confluence を使っています。AIツールとしては、 Claude Code ・ Codex ・ Devin などを必要に応じて使い分けており、メインではClaude Codeを使っているメンバーが多いです。 計測の仕組み 背景と課題で述べたとおり、同一タスクにおいてAIを使った場合と使わなかった場合を同時に観測できません。そこで、タスクの担当者にAIを使わなかった場合の想定工数を見積もってもらい、実際にかかった工数との差を「AIによる削減工数」として記録することにしました。 具体的には以下の仕組みを作成し運用しています。 全タスクをGitHub Issueで管理しGitHub Projectsに紐付け、カスタムフィールド「AI削減工数」「AI活用の成功・失敗事例」に記録する GitHub ProjectsのデータをBigQueryへ毎日自動エクスポートし、ダッシュボード上で可視化する 全体像は以下の通りです。記録したデータは日次で BigQuery へエクスポートされ、BIツールの Data Studio(旧Looker Studio) のダッシュボードから確認できます。 この計測方法について、詳細は以下のテックブログで紹介しています。 techblog.zozo.com Issue作成とフィールド入力には、Claude Codeの Skill をそれぞれ用意して、運用負荷を下げています。例えば、Issueを標準テンプレートで作成する /create-issue や、AIとのセッションログを解析してAI活用フィールドを自動入力する /fill-ai-usage です。なお、入力内容そのものをAIに判断させるわけではありません。あくまで担当者の判断を補助するために使います。例えば、以下のようなタスクで工数削減があった場合、担当者は次のように記録します。 Issueタイトル:MLパイプラインの実装 AI削減工数:2時間 AI活用の成功・失敗事例:パイプラインのステータスをポーリングするスクリプトを書いて、エラー時にClaude Codeで自動修正させて再実行させるループを作った AI活用の成功・失敗事例の欄には、Issueのタスクそのものではなく、タスクを進める中でAIをどう活用したかを記録します。こうした記述が、工数削減にどうAIが効いたかを後から振り返るナレッジになります。 運用の整備 計測の仕組みを作っただけでは、チーム全員が使いこなせるようにならず、改善のサイクルも回りません。そこで、以下の運用を整備しました。 チーム目標への組み込み:「AIで社員を0.5人増やす」を半期のチーム目標に設定した。削減した時間を1か月あたりの営業日数×8時間と比較することで、チーム全体で「1か月あたり0.5人増えた」と言える状態を目指した 週次振り返り: 毎週ダッシュボードで削減実績を確認し、ナレッジを共有する。削減工数が最も多かったメンバーに社内のピアボーナスを送った AI活用会: AI活用において、活用方法を知っているかどうかの差が大きいため、全員がAIの機能や使い方を「知っている」レベルに揃えることを目的に、毎週1時間開催してきた。その後はチーム外のメンバーにも拡大した。現在は、社内のAI活用レベル指標 AZARS に基づき、既存業務のうちAI活用の効果が大きいものを特定し、改善を進めている デモ会: AI活用で削減できた時間を使い、施策提案のためのプロトタイプ作りと他チームへの提案をしている 以下は、週次振り返りで使っていたダッシュボードの一例です。 結果と考察 このような取り組みの結果、以下のような変化がありました。 AIによる工数削減とAI活用率の推移 FY2025H1とFY2025H2を比較すると、AIによる工数削減とAI活用率の計測結果は以下の通りです。 期間 AIによる工数削減 完了件数 平均AI活用率 FY2025H1(2025/04〜09) 212.6時間 269件 45.0% FY2025H2(2025/10〜2026/03) 439.9時間 260件 71.5% 合計 652.5時間 529件 -- ※ AI活用率は「AI削減工数が記録されたIssue件数 ÷ 完了したIssue全件数」で算出しています。 月別に見ると、削減工数は2025年10月の90.2時間と2026年3月の96.8時間がピークでした。1人月を各月の営業日数×8時間とすると、2025年10月は約0.51人月、2026年3月は約0.58人月に相当します。半期のチーム目標に掲げた月0.5人分は、この2つのピーク月で達成できました。Issue単位のAI活用率は2025年6月の28.0%から、2026年3月には88.6%まで上がりました。また、削減工数の伸びはFY2026に入っても続いています。2026年7月時点の集計では、2026年4〜6月の3か月だけで253.6時間を削減しており、6月単月の114.5時間は計測を開始してから最も大きい月間削減です。 H1からH2で削減工数は全体で見ると212.6時間から439.9時間へ、約2.1倍に伸びました。AI活用件数は121件から186件へ1.54倍、活用1件あたりの平均削減は1.76時間から2.37時間へ1.35倍です。つまり、AIを適用するタスクが増える「広がり」と、1件のタスクの中でAIに任せる工程が増える「深まり」の両方が寄与したと考えています。 ただし、この伸びは12か月の時系列を見た変化であり、活用率の上昇が削減を生んだという因果を示すものではありません。MA推薦ブロックは2025年2月に立ち上がったチームで、FY2025H1はチームの仕組み作りや環境の整備といった、AIの効きにくいタスクの比率が高い時期でした。さらに、この1年で利用しているAIの性能自体も大きく向上しています。チームの工夫と、タスクの変化、AIの性能向上が重なった結果として解釈していただきたいです。 作業内容別の効率 以下のグラフに示す作業内容別では、システム開発の削減工数が42.5時間から103.0時間へ伸びました。伸び率ではテスト・QAが4.2時間から47.0時間へ約11倍、テックブログ執筆・登壇が4.0時間から53.3時間へ約13倍となりました。 総量だけでなく効率も以下のグラフで確認します。1営業日あたりの削減工数は、AI削減工数の合計を実作業日数の合計で割った値です。作業内容ごとに見ると、上位はテックブログ執筆・登壇の0.53時間/営業日、分析・レポーティング作成の0.49、社内活動の0.42でした。下位はプロジェクトマネジメントと要件定義がともに0.14、設計が0.19でした。 ※ なお、この指標は小さいIssueほど高く出やすいですが、作業内容ごとのIssueの粒度に大きな差はないことを確認しています。 この傾向から、成果物が明確で作業に時間のかかるタスクほどAIの効果は出やすく、意思決定や対人調整を中心とするタスクほど効果は出にくいと考えています。 効果が出やすいタスクの中でも、コード生成やドラフト作成は特に速さを実感しやすい作業です。実際にAIによる開発量が増えていることは、外部ツールでも確認できています。開発生産性の計測サービスである Findy Team+ のデータを見ると、AI活用が広がったH2に、PR作成数はH1の約1.5倍、デプロイ頻度も1営業日あたり1.3件から2.1件へ増えました。 定性的な変化 数字には表れない、以下のような変化もありました。 チームのナレッジが蓄積された:AI活用の記録やデモ会での議論を通じて、タスクごとのAIの利用例を後から参照できるようになり、ナレッジの共有が進んだ メンバーのAIの活用レベルが上がった:AI活用の成功・失敗を経て、AIのより効率的な使い方を学び、実践できるようになった チーム横断の交流が生まれた:勉強会をチーム外へ広げた結果、他部署との交流が増え、AI活用のナレッジが社内に広がった プロトタイプ駆動の業務の進め方が生まれた:AIはアイデアをすぐ形にできるので、自分たちやステークホルダーからのフィードバックをすぐ得られる。この相性の良さを活かしたデモ会からは、デモを起点に案件化を判断する実例も生まれた また、失敗の記録からも学びがありました。1つ目は2.0時間、2つ目は1.0時間の工数増として記録されたものです。 Claude Codeに方針を委ねようとして、結局出力がよくわからなくなり最終的に自分が方針決めをした。 GitHub Projectsのフィールド埋めをコマンド経由で自動化しようとしたところ手動でやった方が良いことに気づいた。設計時間分のロスが発生した。 当たり前と言われれば当たり前ですが、これらは方針決めのような曖昧なタスクをAIに委ねると逆に時間を失うこと、自動化にも判断が必要なことを示しています。こうした失敗事例も、チーム内で共有することで、次のタスクで同じ失敗を繰り返さないようにしました。 体感と実測のギャップ メンバーからは、半期振り返りで以下のような声が上がりました。 AIで社員を0.5人増やすことが目標であったが、分析やコーディング業務では既に「俺が3人分になる…」ケースがある 一方、計測した工数削減を見ると、ピーク月でも96.8時間で約0.58人月の削減にとどまっています。エンジニア3人のチームにおける0.58人月の削減を、3人で3.58人分の業務をこなしたとみなすと、1人あたりは約1.2倍となり、体感の3人分とはギャップがあります。 このギャップについては、以下のように解釈しています。 実装をAIが一瞬で終わらせる体験は脳に強く残り、その印象に引っ張られ、業務全体が速くなったように感じる タスクが半日で終わってもその分2倍働くのは難しく、空いた時間は別のタスクに使われる 「理解してから実装する」が「実装されたものを理解する」に逆転し、実装時のフロー状態に入れない 並列でタスクを回せる分、コンテキストスイッチの回数と時間あたりのインプット量が増え、脳が疲れて作業の速度が落ちる 人間のレビューの待ち時間や対人コミュニケーションのコストが変わらない場合、AIで作業を速くこなせるほど人間の対応頻度が増えるので人間がボトルネックになる 今後の展望 FY2026H1の目標は、AIによる工数削減を月1人分に引き上げることです。これまでのように各自のタスクにAIを使って速く終わらせるだけでは、この目標に届かないというのがチームの共通認識です。特に、考察で見えたのは私たちのチームの伸びを止めているのがAIの性能というよりは、人間や運用体制だということでした。人間の集中力や、コンテキストスイッチへの耐性は簡単には変えられません。変えられるのは人間によるAIの効果的な使い方や運用体制だと考え、具体的には以下のように改善を進めています。 Spec: 仕様の明文化 1つ目は、実行前に仕様と完了条件を明文化して、AIの成果物への理解度を上げることです。結果と考察で触れた失敗事例が示すとおり、仕様と完了条件が固まっていないタスクをAIへ投げると、出力の良し悪しを判断できず、かえって時間がかかります。仕様の明文化に加えて、仕様の検討時に過去の類似タスクを参考として提案する仕組みも作り始めています。 Eval: 成果物の品質の評価 2つ目は、成果物の品質評価を自動化しやすい形に整備することです。品質のうち定量化できるものはテストとして実装し、自動的に評価します。一方、定量化が難しいものについては判断基準をガイドラインとして整備し、それに沿って評価できるようにします。これにより、成果物の生成だけでなく評価においても人間の介入を減らせると考えています。 このような動きをレビューの自動化にもつなげるつもりです。レビューの目的は、規範適合・検証の代行・意思決定と合意形成・知識の伝達などと整理できます。規範適合や検証の代行といったタスクはAIと自動的なテストに任せ、人間は意思決定と合意形成を中心に行うことでAIと人間の役割を分担します。 以下の図の左側が現在の状態です。デプロイの前に第三者によるレビューを必須としているため、AIで1人の実装が速くなっても、チーム全体のスピードは上がりにくい構造です。右側のように自動テストとAIレビューを挟むことで、この構造を変えていきます。 成果物の定量化と自動的なテストが進むほど、人間からAIへ委譲できるタスクは増えると考えています。そして、人間の確認を要するタスクが減れば、レビュー待ちは少なくなり、各自は自走して高速にタスクを進められると考えています。 AIが自律的に動く状態の構築 3つ目は、人が毎回指示しなくてもAIが自律的に動き、使うほど賢くなっていく状態を作ることです。SpecとEvalが整備されたあとは、人間のトリガーを待たずにAIが安全に動き、人間の判断やAIの成功・失敗のフィードバックを適用しながら自ら改善していく状態を目指しています。 私たちのチームでは、自律的なAIが活躍でき、効果の大きそうな以下の領域から着手し、次のような状態を作ろうとしています。 アラート対応:アラートを検知するとIssueを自動作成し、AIエージェントが原因調査から修正対応までを行い、アラートを解決する 定型的な改修:トリガーとなるイベントを受けて、仕様が明確な定型的な改修をAIが行い、リリース前の状態を作る データ分析:人間とAIが仮説を作り、データの前処理・分析・可視化までを行い、結果をレポートとしてまとめる モデル開発:人間とAIが実験計画を作り、分析→実装→実験を繰り返し、モデルの精度を改善して、結果をレポートとしてまとめる ソウゾウのナナメウエの創出 最後は、AIで生まれた余白の一部を、意図的に新しい価値づくりへ投資することです。考察で見たとおり、削減した時間をすべて次のタスクの前倒しに使うと脳が疲れるだけになりかねません。実際に、デモ会ではビジネスサイドへの提案を文書から動くデモへ変える取り組みを続けています。こういった活動を広げることで、ZOZOが企業理念でZOZOらしさとして掲げる ソウゾウのナナメウエ なアイデアの実現に挑戦し続けられるチームでありたいと考えています。 おわりに 本記事では、AIによる工数削減を計測して見えた結果と考察、今後の展望をご紹介しました。 現在ZOZOでは一緒にサービスを作り上げてくれる方を募集しています。ご興味がある方は以下のリンクからぜひご応募ください! corp.zozo.com
はじめに こんにちは。データシステム部・MA推薦ブロックの住安( @kosuke_sumiyasu )です。 私たちのチームは、ZOZOTOWNのメール・LINE・プッシュ通知といったマーケティングオートメーション(MA)の推薦システムを開発・運用しています。目指しているのは、ユーザーひとりひとりに最適な配信を届けることです。 ZOZOTOWNで本番運用されている推薦モデルは、価格・ブランド・カテゴリ・カラーといった テーブル特徴量 のみを学習に用いていました。そのため、商品画像が持つ視覚情報(シルエット・質感・カラー・柄)を活用できていませんでした。「オーバーサイズシルエット」や「光沢感」「チェック柄」といった、人が画像から読み取れる「見た目の好み」を推薦に反映できていなかったのです。 下図は、四角い縁のメガネを好むユーザーを例に、画像から「見た目」を捉えることで目指した推薦の姿を示したものです。従来のモデルではカテゴリは「メガネ」で合っていても、丸縁やサングラスといった「見た目」の異なる商品が混ざってしまいます。一方、画像から「見た目」を捉えられれば、ユーザーが好みそうな四角い縁のメガネを中心に推薦できます。 そこで私たちは、 商品画像から視覚的特徴を捉えた画像特徴量を生成する仕組み を構築し、既存の推薦モデルに特徴量として組み込むことで、「見た目の好み」を捉えるマルチモーダル推薦システムを実現しました。実際に、この推薦モデルをあるメール配信施策に適用しました。A/Bテストの結果、メール経由サイト流入率(CTR)・メール経由購入率(CVR)・経由売上(メール経由で発生した売上)のすべてで有意な改善が得られました。しかもこの画像特徴量は特定の施策にとどまらず、全社のどの推薦・検索モデルからでも利用できる共通の基盤として提供しています。 本記事では、この取り組みの背景にある課題、画像特徴量を生成・提供する仕組み、そして推薦モデルへの特徴量の組み込みで工夫した点を中心に紹介します。マルチモーダルな特徴量を推薦に活かしたい方の参考になれば幸いです。 目次 はじめに 目次 背景・課題 前提となる推薦システム 課題1: 推薦モデルが「見た目」を捉えられていない 課題2: 画像Embeddingを全社で利用できる基盤がない アプローチの全体像 画像Embeddingを安定供給する仕組みの構築 差分更新によるコスト削減 モデル・バージョンを管理し、VIEWで全社へ提供する 推薦モデルのマルチモーダル化によるパーソナライズ精度向上 モデルの選定 事前学習済みモデルを使用した理由 Item Towerへの組み込み Gated Multimodal Unit(GMU)で画像の寄与度を動的に制御する 特徴量単位の Dropout(Feature/Modality Dropout)で特定特徴量への依存を抑える 定量評価(オフライン) 効果 「見た目の好み」の反映による主要指標の改善 全社共通の画像Embedding基盤の整備 まとめ 今後の展望 最後に 背景・課題 前提となる推薦システム ZOZOTOWNのMAにおけるパーソナライズされたアイテム推薦の一部では、 Two-Towerモデル を使用しています。これは、ユーザーを表現するUser Towerと商品を表現するItem Towerの2つのニューラルネットワークからなります。学習済みの各Towerを使うことで、ユーザーと商品の特徴量をそれぞれEmbeddingに変換できます。このEmbeddingは、特徴を捉えた数値ベクトルで、意味の近いものほどベクトルも近くなる性質を持ちます。両Towerの出力を同じ潜在空間上にマッピングするように学習することで、ユーザーとアイテムの近さをコサイン類似度で測れるようになります。推薦時は、任意のユーザーのEmbeddingと各商品のEmbeddingの類似度を計算し、類似度が高い商品から順に推薦します。 ZOZOでは、このEmbeddingを Embedding基盤 として一元管理し、どの部署からでも利用できるようにしています。私たちの 汎用推薦システム も、この基盤を使用して配信する商品を選定しています。 課題1: 推薦モデルが「見た目」を捉えられていない このItem Towerの特徴量は、価格・ブランド・カテゴリ・カラーなどの テーブル特徴量 のみでした。そのため、 ユーザーの視覚的な嗜好を推薦に反映できない という課題が残っていました。同じカテゴリ・ブランドの商品でも、ユーザーが好むシルエットや柄、質感はさまざまです。しかし従来の推薦モデルは見た目の情報を持たないため、「興味のあるカテゴリやブランドは合っているけれど、見た目の趣味は違う」という結果になりがちでした。例えば筆者は、結婚式用に無地のパステルカラーのネクタイを探していたのですが、柄物ばかりが推薦されてしまい、改善の余地を感じていました。 課題2: 画像Embeddingを全社で利用できる基盤がない 商品画像が持つ視覚情報を推薦に活かすには、それを数値ベクトルに変換した 画像Embedding として扱うのが有効です。しかし当時は、商品画像すべてを画像Embedding化する仕組みも、それを全社で共有する基盤も存在していませんでした。そのため、各チームが検索や推薦で画像特徴量を使いたくても、それぞれが独自に実装する必要があり、開発工数の増加や品質のばらつきが生じます。そこで本プロジェクトでは、 画像Embeddingを常に使える状態で組織に提供し続ける基盤 を構築し、それを推薦モデルに組み込むことで「見た目の好み」を捉えられるようにすることを目指しました。 アプローチの全体像 課題を解決するために、大きく2つに取り組みました。 画像Embeddingを安定供給する仕組みの構築 :商品画像から視覚的特徴を表す画像Embeddingを日次バッチで生成し、BigQueryのVIEWで提供する。どの推薦・検索モデルからでも、常に最新の画像特徴量を利用できる 推薦モデルのマルチモーダル化によるパーソナライズ精度向上 :その画像Embeddingを推薦モデルのアイテム特徴量として組み込み、「見た目の好み」を捉えてパーソナライズ精度を高める マルチモーダル推薦は、次の3つのパイプラインで実現しています。 パイプライン 役割 generate-image-embedding 商品画像から画像Embeddingを生成し、BigQueryへ保存する train-product-recommendation 画像Embeddingを特徴量に加えてTwo-Towerモデルを学習する generate-product-embedding 学習済みモデルでユーザー・商品のEmbeddingを生成する このうち、train-product-recommendationとgenerate-product-embeddingは、もともと運用している既存のパイプラインです。今回はそこに、画像Embeddingを生成するgenerate-image-embeddingを新たに追加しました。あわせて、train-product-recommendationのモデルアーキテクチャと入力特徴量を変更しています。 これらのパイプラインで生成したユーザー・商品のEmbeddingを使って、施策ごとに配信商品を選定します。 以降では、本記事の中心である「画像Embeddingを安定供給する仕組みの構築」と「推薦モデルのマルチモーダル化によるパーソナライズ精度向上」を詳しく紹介します。 画像Embeddingを安定供給する仕組みの構築 画像Embeddingの生成パイプラインは、 Agent Platform Pipelines(旧Vertex AI Pipelines) 上に実装し、日次バッチで実行しています。全体像は次のとおりです。 処理は大きく4ステップで構成されます。 Embedding化の対象とするアイテム集合を取得する 商品画像を取得し、Cloud Storage(以下GCS)へ保存する 事前学習済みの画像モデルで画像Embeddingを生成する 生成したEmbeddingをBigQueryへ保存し、VIEWとして提供する 画像Embeddingの生成(ステップ3)には、 Hugging Face で公開されている事前学習済みモデル( SigLIP 2 )をGPU上で利用しています。画像の保存先にはGCS、Embeddingの保存先にはBigQueryを使っています。なお、SigLIP 2を採用した理由は、のちほど「モデルの選定」で説明します。 この中で工夫した「差分更新によるコスト削減」と「全社への提供」を順に紹介します。 差分更新によるコスト削減 ZOZOTOWNで扱う商品画像は、サイト上でアクティブな商品に限っても数千万枚の規模にのぼります。これらをすべてEmbedding化すると計算コストが大きいため、各商品(商品×カラー)につき代表の1枚に絞ってEmbedding化しています。それでも対象は数百万枚あり、さらに新着商品を考えると、毎日およそ数十万枚を新たにEmbedding化する必要があります。 これらを毎日すべて計算し直すと、GCSからマシンへ画像を転送するオペレーション料金や、推論時間の増加に伴うマシン料金がかさみ、個々は小さくても積み重なると無視できないコストになります。そこで、すべての画像を毎日計算し直す 全件更新 ではなく、未処理分のみを計算する 差分更新 を採用しています。具体的には、次の2つのステップで「まだ処理していないものだけ」を対象にします。 画像の保存(ステップ2) :すでにGCSへダウンロード済みの画像は除外し、未取得の商品画像のみを保存する Embedding生成(ステップ3) :すでに計算済みのEmbeddingは除外し、未計算の商品画像のみを対象とする これにより、新着商品だけを処理すればよくなり、ダウンロードコストと計算コストを抑えられます。また、GCSはAgent Platform Pipelinesの実行リージョンと同じRegionalバケットを使うことで、リージョン間レプリケーション費用やエグレス料金も抑えています。 モデル・バージョンを管理し、VIEWで全社へ提供する 画像Embeddingを全社の共通資産として提供するうえで重要になるのが、 モデルとバージョンの管理 です。精度改善のためにモデルを差し替えたり、複数のモデル・バージョンをA/Bテストで並行させたりすることがあります。そのたびに、利用者が「いまどのモデル名・バージョンが最新で有効か」を追いかけてクエリを書き換えるのは負担が大きく、更新への追従漏れも起こる可能性があります。そこで、利用者がそれらを意識しなくても、常に最新の有効なEmbeddingを取得できる仕組みを用意しました。 具体的には、次の3つのテーブル・VIEWでモデルとバージョンを管理しています。 テーブル / VIEW 種別 役割 product_image_embedding_raw テーブル 生成したEmbeddingを、商品ID・モデル名・モデルバージョン・生成日とあわせて追記する。過去分も残すため、複数のモデル・バージョンが共存する model_manifest テーブル 提供対象とするモデル・バージョンにアクティブフラグを立てる product_image_embedding VIEW model_manifestのアクティブなバージョンに絞り、商品ID × モデル名ごとに最新のEmbeddingを返す product_image_embedding_rawテーブルを直接参照する場合は、利用者がクエリのたびにモデル名やバージョンをWHERE句で指定する必要があります。これをVIEWにまとめることで、利用者はproduct_image_embeddingのVIEWを参照するだけで、常にアクティブなモデル・バージョンの最新Embeddingを取得できます。一方でproduct_image_embedding_rawテーブルにはバージョンごとの履歴が残ります。そのため、モデルのA/BテストではTreatment用のVIEWを用意することで、特定バージョンを指定した検証にも対応できます。 この仕組みによって、追跡性と再現性を確保しつつ、A/Bテストにも対応できます。当初の施策にとどまらず、検索や他の推薦面でも安心して利用できる全社共通の資産として提供できるようになりました。 推薦モデルのマルチモーダル化によるパーソナライズ精度向上 画像特徴量を活かしてパーソナライズ精度を高めるために工夫した点を紹介します。工夫したポイントは2つあります。1つ目が「画像Embedding生成モデルの選定」、2つ目が「生成した画像Embeddingを推薦モデルに組み込む方法」です。特に後者が重要で、画像特徴量は単純に足すだけでは効果が薄く、シンプルな2つの工夫を加えることでモデルの精度を大きく改善できました。 モデルの選定 画像Embeddingの生成には、事前学習済みの SigLIP 2 を採用しています。SigLIP 2は、 CLIP から派生したモデルです。CLIP系のモデルは、画像を扱うImage Encoderと、説明テキストを扱うText Encoderの2つから構成されます。学習時は、対応する画像と説明テキストのペアは近づけ、対応しないペアは遠ざけます。こうした対比的な学習をcontrastive学習と呼び、これにより画像と言語が同じ空間で結びつきます。なお、CLIPがsoftmaxベースの損失を用いるのに対し、採用したSigLIP系はこれをsigmoid損失に置き換えている点が特徴です。 画像が言語の意味と対応づけて学習されるため、得られる画像Embeddingは「柄」「シルエット」「質感」といった視覚的特徴を捉えやすいと考えられます。 CLIP系のモデルの中でSigLIP 2を選んだのは、論文記載のとおり、ゼロショットの分類・検索タスクのベンチマークで良い結果が示されているためです。 事前学習済みモデルを使用した理由 ZOZOの商品画像でファインチューニングする選択肢もありましたが、今回は事前学習済みモデルをそのまま使う方針としました。理由は次の3点です。 テキスト側の教師データがない :CLIP系の追加学習に必要な、画像とペアになる説明テキストを大規模に用意できていない まず有効性を検証したい :画像特徴量が推薦に効くかは未検証のため、まずは低コストに効果を確かめたい 基盤モデルの進化が速い :将来、高性能なモデルへ載せ替える余地を残したい Item Towerへの組み込み 画像Embeddingは、まずItem Towerの入力としてそのまま使えるように整えます。下図のように、画像EmbeddingをItem Towerの入力特徴量の1つ(image_embedding)として追加します。User Tower側は変更せず、Item Tower側にのみ画像特徴量を加えています。 使用した画像Embeddingは768次元です。これを価格やカラーといった他のテーブル特徴量とそのまま結合すると、画像だけで次元の大部分を占めてしまい、他の特徴量の影響が埋もれてしまいます。そこで、画像Embeddingを2層の多層パーセプトロン(768 → 256 → 128)で128次元に圧縮してから、他の特徴量と結合します。これにより、画像とテーブル特徴量の次元のバランスを取りつつ、画像から推薦に効く表現を学習できるようにしています。 ただし、この「圧縮してそのまま結合する」方法だけでは、期待したほどの精度改善が得られませんでした。そこで、さらなる精度改善に向けて次の2つの機構を導入しています。 Gated Multimodal Unit(GMU)で画像の寄与度を動的に制御する 次元を揃えて結合するだけでは、画像をどれだけ重視するかが全商品で一律になってしまいます。しかし本来、画像をどれだけ重視すべきかは商品によって異なります。例えば、Tシャツは柄が選択の決め手になるため画像を重視したい一方、靴下はカラーやブランドといったテーブル特徴量で十分なことが多いです。そこで、画像特徴量の寄与度だけをアイテムごとに動的に調整できるよう、 GMU を参考にしたゲート機構を導入しました。 論文の2モダリティ版GMUは2つのモダリティをゲート値で線形補間するため、片方を強調するともう片方が抑制されるトレードオフを持ちます。これに対して本実装は、 他の特徴量はそのままで、画像特徴量にのみsigmoidゲートを掛ける一方向型のゲート を採用しました。これは、2モダリティ版GMUからもう片方を抑制する項を取り除いた独自の変種で、アイテムごとに画像特徴量の重みづけだけを調整できます。これにより、カテゴリやブランドなどのアイテム情報から、その商品で画像特徴量をどれだけ重視するかを動的に決められます。 一方向型にした理由は、既存のテーブル特徴量(カテゴリ・価格など)は複数のA/Bテストで有効性が実証されており、その表現力をそのまま維持した状態で、画像特徴量を追加したかったためです。 特徴量単位の Dropout(Feature/Modality Dropout)で特定特徴量への依存を抑える もう1つの工夫が、学習のたび、入力の一部をランダムにマスクすることで、特定の特徴量への過度な依存を防ぐDropoutです。よく使われるDropoutは個々のニューロン単位でマスクしますが、今回は特徴量単位でマスクする Feature Dropout を行います。なかでも画像Embeddingは、モダリティ全体を1単位としてマスクし、これを特に Modality Dropout と呼びます。実際には、テーブル特徴量(価格・ブランド・カテゴリ・カラーなど)は各フィールドを、画像Embeddingはモダリティをまるごと1つの塊として、それぞれ独立かつランダムにマスクします。 なぜこれが効くのかを、カラーと画像Embeddingを例に説明します。カラーからもユーザーが好む大まかな色味は学習できますが、画像Embeddingを使えば、より詳細なカラーやシルエット、柄まで捉えられる可能性があります。しかし画像Embeddingは複雑で扱いが難しいため、モデルは学習しやすいカラーにばかり頼り、画像Embeddingを十分に活用しないことがあります。そこでカラーをマスクすると、モデルは画像Embeddingからも学ばざるを得なくなり、画像Embeddingの特徴が使われない状態を防げます。逆に、画像Embeddingに偏りすぎる場合も画像Embeddingをマスクすれば、カラーなどのテーブル特徴量から学べます。こうして、どちらか一方に偏らず、画像Embeddingも含めた幅広い手がかりをバランスよく使う、堅牢なモデルになります。 定量評価(オフライン) これらの工夫により、画像特徴量なしのベースラインと比べて、オフラインのRecall@100は段階的に改善しました。 構成 Recall@100(ベースライン比) ベースライン(画像特徴量なし) — + 画像特徴量あり(単純結合のみ) +1.06% + 画像特徴量あり(Feature/Modality Dropout) +11.3% + 画像特徴量あり(Feature/Modality Dropout + GMU) +12.0% 効果 「見た目の好み」の反映による主要指標の改善 構築したマルチモーダル推薦システムを、1配信あたり約700万人を対象とするメール配信施策のアイテム推薦ロジックに適用し、A/Bテストで効果を検証しました。Control(画像Embeddingなし)とTreatment(画像Embeddingあり)を比較し、CTR・CVRはz検定、経由売上はt検定を用いて有意水準5%で評価しました。 その結果、 CTR・CVR・経由売上のすべてで統計的に有意な改善 が確認され、TreatmentがControlを上回りました。以下はTreatmentのControlに対する相対改善率です。 指標 相対改善率 有意差 CTR(メール経由流入数 / 配信数) 約 9.9% あり(勝ち) CVR(メール経由購入数 / 配信数) 約 14.3% あり(勝ち) 経由売上(メール経由の受注金額 / 配信数) 約 10.3% あり(勝ち) ユーザーの「見た目の好み」を捉えた推薦が、実際の流入・購入・売上の改善に結びつくことを確認できました。この結果を受けて本番リリースを決定し、現在は本番環境で稼働しています。 全社共通の画像Embedding基盤の整備 共通基盤の構築により、画像Embeddingを使いたいチームは、生成パイプラインを自前で用意する必要がなく、VIEWを参照するだけで常に最新のEmbeddingを利用できます。これにより、検索や他の推薦面を担当するチームも、開発工数をかけずに効果検証を始められます。さらに、基盤側でモデルを改善すれば、利用側は追加対応なしでその精度向上を受けられます。モデルの差し替えやバージョン管理を基盤の内側に閉じ込めたことで、利用者は中身を意識せずに使い続けることができます。 まとめ 本記事では、商品画像の視覚情報を推薦に活かすマルチモーダル推薦システムの構築を紹介しました。事前学習済みモデルを活用し、少ない工数で画像特徴量を追加して、その有効性まで確かめられました。さらに、生成した画像特徴量を全社で利用できる資産として提供できたことも、大きな成果だと考えています。これにより、画像特徴量を試したい部署は、自分たちで実装しなくてもすぐに効果検証を始められます。そして「画像」という新しい特徴量の軸を手に入れたことで、ここを足がかりに推薦をさらに良くしていけるはずです。 今後の展望 画像Embeddingのさらなる活用と推薦の精度向上に向けて、次のような展開を考えています。 画像Embeddingの活用箇所の拡大 :整備した共通基盤を活かし、検索・他推薦面へも展開する 画像ベースの候補生成への活用 :閲覧・購入した商品と視覚的に似た商品を、推薦候補とする モデルの高度化 :事前学習済みモデルから、ZOZOのデータでファインチューニングしたモデルへ置き換え、ファッションに特化した表現の獲得を目指す 画像の前処理の工夫 :商品領域をバウンディングボックスで検出してクロップ(切り出し)し、周辺の背景ノイズを除いて視覚的特徴をより正確に捉える 最後に ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com

























