レコメンド基盤 設計決定ログ
レコメンド基盤の設計で下した主要な判断 (採用案 / 却下案 / 判断根拠) を時系列で記録する。将来 "なぜこうなっているのか" を追跡したい時、あるいは同種の判断条件が再発した時の参考にする。1 つの決定 = 1 つの ## セクション。
本ドキュメントは意図的に historical: 古い決定も削除せず、上書きが必要な場合は新しい決定を下に追加して「この判断は XXXX の決定で置き換わった」と注記する形で運用する。
仕様本体は spec、技術選定の経緯は technology-choices を参照。
2026-04-23 Surface 別 Strategy 統合を採用 (C 案)
決定: Surface ごとに独立した SurfaceStrategy struct を 1 ファイル持たせ、PreFilter / Aggregate / PostRerank を全部そのメソッドに実装する構造 (C 案) を採用する。旧 l2_derived / l3_context の独立 interface は廃止し、Surface 固有のロジック・request-scope 状態・フィルタ組み立てを struct 内部に閉じ込める。
業界標準の Multi-stage pipeline (Retrieval → Ranking → Re-ranking) は Strategy 内のメソッド分割 (PreFilter / Aggregate / PostRerank) と 1:1 対応する。
コンテキスト
MOBL-4277 で入口 API を「Surface 別 Request + 共通入口 (C 案)」に移行した際、QuickMatch だけは返り値型 (QuickMatchResponse) と入力次元 (複数 user) の違いから B 折衷で独立 method RecommendQuickMatch に切り出していた。MOBL-4289 ではこの B 折衷を解消して全 Surface を Recommender.Recommend(ctx, SurfaceRequest) 1 入口に統合しようとした。
初期実装 (A 案: l2_derived.Aggregator / l3_context.Policy の独立 interface を維持) で進めたところ、QuickMatch 専用フィールドが AggregateInput / PreFilterOutput に optional field として積み上がる god struct 構造 (神 input 問題) が露呈し、technology-choices §9 の god struct 回避方針と逆行することがわかった。
MOBL-4293 (PR #20412) で SIMILAR Surface が A 案で追加され、Surface 3 個体制 (FOR_YOU / QuickMatch / SIMILAR) になった段階で再評価。Surface ごとに request-scope state を持つ Strategy (PR20412 の similarPolicy もこの方向) が増えてきたため、全 Surface を Strategy 統合に揃える判断に倒した。
選択肢の比較
| 案 | 特徴 | 判断 |
|---|---|---|
| A. 層別独立 interface + 神 input | MOBL-4289 初期実装、後続 Surface で optional field 積み上がる | ✗ god struct 方向 |
| B. SurfaceContext 付き primitive 分離 (MOBL-4276 初版) | primitive 再利用性最大、宣言的組み立て | ✗ SurfaceContext に神 input が移動 |
| C. Strategy 統合 | Surface struct が全担当、state は struct field | ◎ 採用 |
| C'. 漸進的 primitive 化 | surface 内関数 → 2 度目で昇格 | ✗ Go 慣習で (C) に自然包含 |
| D. primitive 先行分離 | l3_filters / l2_aggregators を最初から別パッケージ | ✗ 2-3 Surface 段階では過剰抽象化 |
採用理由
- 神 input 問題が完全に解消: QuickMatch 固有の
scenarioMeta/beginnerRatio/QuickMatchConfigや SIMILAR 固有のplayedCountByScenarioを Strategy struct の field に閉じ込められる。共通 interface に optional field が積み上がらない - 真のデフォルト不在問題を回避:
defaultPolicyが前提にしていた「全 Surface 共通の default filter」は 2 周目機能 (AlreadyPlayed が外れる)・作家向け preview (Released が外れる) などで崩れる。Strategy で明示組み立てにしておけば要件変化に対して追加・削除が struct 内で完結 - Surface 増加に対する統合性: Surface = ファイル 1 つの対応で、新規追加時の interface 再設計コストが発生しない。Strategy = 業界の "Vertical-specific pipeline" そのもの
- 共通 middleware 差し込みが pipeline 側 1 箇所で済む:
SurfaceStrategyinterface に対して decorator pattern を書けば logging / A-B test / model version gate / tracing が全 Surface に効く。A/B 案では Aggregator / Policy 両方にラッパーが必要 - 概念分類は Strategy 内の method 分割として保持: 実装が 2 層 (Base Scoring + Surface Strategy) でも、業界標準の概念分類 (Retrieval / Ranking / Re-ranking) は Strategy 内の PreFilter / Aggregate / PostRerank の method 分割として保持。spec §3.1 の対応表で UZU 実装名と業界標準語を繋ぐ。
※ 当初は独自の「L1 / L2 / L3 の 3 層モデル」呼称で概念を残す判断だったが、2026-05-30 (UZU-5395) で業界標準語へ全面移行し旧呼称は廃止した (本ログ末尾の決定を参照)
却下理由
A 現状: optional field の積み上がりが後続 Surface (EventDetail / Author / Collection) で加速。technology-choices §9 の god struct 回避方針と逆行する。
B SurfaceContext: primitive を Surface が宣言する構造だが、SurfaceContext に共通 optional field を載せる結果、神 input が Context に移動するだけ。型安全性は (C) に劣る。
C' 漸進的 primitive 化: Go 慣習上 (C) の method 分割が自然に (C') の形に進化するため、別案として立てる価値が薄い。アーキテクチャ判断ではなく tactical な実装スタイル差。
D primitive 先行分離: generics + interface で型安全な primitive 設計を 2-3 Surface 段階で払うのは過剰抽象化。3 つ目以降の Surface で interface が合わない時の再設計コストが大きい。(C) → (D) は primitive 昇格で段階的に進められるが、(D) → (C) は primitive を inline 化する逆方向 refactor で手間が大きい (可逆性の非対称)。
業界用語との対応
UZU 実装名と業界標準の対応:
| UZU 実装名 | 業界標準 | 実装上の配置 |
|---|---|---|
| Base Scoring | Retrieval / Base Scoring / Pre-ranking | basescoring/ パッケージ (全 Surface 共通) |
| Strategy.PreFilter | Retrieval / Candidate Pruning / Filtering | surfaces/<surface>.go の PreFilter method |
| Strategy.Aggregate | Ranking / Contextual Scoring / Group Aggregation | surfaces/<surface>.go の Aggregate method |
| Strategy.PostRerank | Re-ranking / Post-processing / Diversification | surfaces/<surface>.go の PostRerank method |
業界の Multi-stage pipeline (Retrieval → Ranking → Re-ranking) を Surface 単位でまとめるのが案 C の本質。業界の "Vertical-specific recommender" (e.g. TikTok の "Explore" vs "Following" の分離) に近い。
将来の再評価トリガー
以下のいずれかが発火した段階で、primitive を別パッケージ (l3_filters/ など) に昇格する判断を再び検討する:
- 実装済み Surface が 4 個以上になり、同じ filter (e.g.
Released/Candidates) を 3 つ以上の Surface が重複実装している - 新しい filter / rerank を 1 箇所の Strategy に追加した時、他の Strategy にも同じものを重複コピーする作業が発生する (rule of three)
- Surface 横断で挙動を揃えたい要件 (e.g. A/B テスト arm 分割、model version 固定規約の厳格化) が出てきて、Strategy ごとに実装すると漏れる懸念が出る
これらが発火したら、MOBL-4276 を複製する形で新しい primitive 共通化検討 issue を立てる。
関連
- MOBL-4276 (Canceled): 本決定の議論が行われた元 issue。description に詳細な比較ログが残っている
- MOBL-4289: 本決定に基づく実装 issue (QuickMatch B 折衷解消 + Strategy 統合)
- MOBL-4293: SIMILAR Surface 追加 (PR #20412、MOBL-4289 に先行して A 案で merge)
- MOBL-4273: QuickMatch Strategy.Aggregate 実装 (B 折衷が入った経緯)
- MOBL-4277: C 案導入
- technology-choices §9: god struct 回避方針
- technology-choices §9.3: B 折衷廃止の経緯
2026-04-28 QuickMatch は surfaces/ に収容せず strategy/quick_match/ に切り出す (Hybrid)
決定: QuickMatch の実装は surfaces/quick_match.go ではなく独立パッケージ strategy/quick_match/ に切り出し、pipeline.Recommender の入口で QuickMatchRequest を型 assertion して strategy.quick_match.Recommend(...) に early-dispatch する。FOR_YOU / SIMILAR / 未対応 Surface は従来通り surfaces/Registry 経由で SurfaceStrategy interface (PreFilter → Aggregate → PostRerank) を流す。
この決定は 2026-04-23 の Strategy 統合 (C 案) を否定するものではなく、C 案の "public API 統合" は維持しつつ、QuickMatch の "内部実装は staged pipeline に乗らない" という構造的事実を受け入れる調整である。
コンテキスト
MOBL-4289 の実装中 (squash 前) は QuickMatch も surfaces/quick_match.go に置く方向で進めていた。並行して PR #20515 (MOBL-4286 系) が main にマージされ、QuickMatch を 統合 greedy strategy (utility = Aggregate + V/F ボーナス - P ペナルティ + MMR / N≥1 ハード制約) に再構築する変更が入った。
この統合 greedy は 全 step を同時評価する: 各 scenario を選ぶたびに Aggregate 集約スコア・履歴シグナル (V/F/P)・MMR 類似度ペナルティ・残人数の N≥1 制約を再計算する。staged な PreFilter → Aggregate → PostRerank の各 method に分割できない (PreFilter で確定すべき "残人数 N≥1" がアイテム選択ステップに依存する、Aggregate で計算した順位を PostRerank で MMR が大幅入れ替えするので Aggregate 結果が中間表現として意味を持たない、等)。
SurfaceStrategy interface に乗せようとすると以下のいずれかが必要になる:
- A. interface を緩めて
Recommend(ctx, req) ([]Item, error)の 1 method にする → FOR_YOU/SIMILAR の staged 構造のメリットが消える - B. QuickMatch だけ
PreFilterが no-op (全 scenario 返す) でAggregateが全部やる → interface が型による事実上の lie になる - C. QuickMatch は interface の外側 (=本決定)
採用理由
- 構造的事実の受容: 「staged で書ける Surface」と「全 step 同時評価が必要な Surface」は実装パラダイムが異なる。interface を緩めて両方を呑むより、別パッケージで明示的に分離するほうが読み手に正直
- C 案の public API 統合は維持:
Recommender.Recommend(ctx, SurfaceRequest)1 入口は守られる。callers は QuickMatch / FOR_YOU / SIMILAR を同じ method で叩く。dispatch は pipeline 内部の実装詳細 - PR #20515 の greedy strategy をそのまま活かせる: 別パッケージなので surfaces/ の interface 制約から自由に進化できる。MMR パラメータ・履歴シグナル重み・ハード制約の追加が surfaces/ 側に副作用しない
- 逆方向 refactor の可逆性: 将来
SurfaceStrategyinterface が拡張されて statefulgreedy も収容できる形になれば、strategy/quick_match/をsurfaces/quick_match.goに移動する逆 refactor は機械的にできる。先に統合してしまうと巻き戻しが難しい
「B 折衷解消」のスコープ修正
MOBL-4289 起票時の主目標「QuickMatch 専用 method RecommendQuickMatch を削除して B 折衷を解消」は 構造的に達成不可能だった ことが本決定で明確になった。
- public API レベル:
RecommendQuickMatchは削除しRecommend(QuickMatchRequest)に統合 → 達成 - 内部実装レベル: QuickMatch だけ別 method (
strategy.quick_match.Recommend) に dispatch → B 折衷の本質はここに残る
ただし「内部実装の B 折衷」は staged pipeline と統合 greedy の構造差から避けられないため、これを "解消" の対象とすること自体が MOBL-4276 時点での見立ての誤りだったと再評価する。MOBL-4289 の達成済みスコープは以下に書き直す:
- ✅ public API 統合 (
Recommend(SurfaceRequest)1 本) - ✅ god struct (
l2_derived/l3_context) パッケージ撤廃 - ✅ GraphQL
Recommendation/RecommendedScenarioへの型統合 (QuickMatchRecommendation系廃止) - ❌ 内部実装の B 折衷解消 (構造的に不可能、scope-out)
却下案
SurfaceStrategy interface を 1 method 化: FOR_YOU / SIMILAR の staged 構造が活きなくなる。共通 middleware (logging / A-B test) の差し込み点も曖昧になる。
QuickMatch を staged に強制嵌め込み: 上記 B 案。interface が嘘になる + PR #20515 の greedy 実装を壊す改修コスト。
全 Surface を統合 greedy 方式に揃える: FOR_YOU / SIMILAR にとって過剰。Identity 集約・mean 集約は staged で十分かつ高速。
関連
2026-05-30 内部語彙を業界標準へ全面移行 (L1/L2/L3 廃止、l1_core → basescoring)
決定: 独自の「L1 / L2 / L3 の 3 層モデル」呼称を全廃し、業界標準の recsys 語彙 (Retrieval → Ranking → Re-ranking + Base/embedding model) を一次語彙に昇格する。Go パッケージ recommendation/l1_core/ は basescoring/ に rename する (ials/ サブパッケージは配下のまま)。
この決定は 2026-04-23 の採用理由 #5「概念分類を保持」を否定するものではなく、保持する概念分類の"呼び名"を独自記号 (L1/L2/L3) から業界標準語に差し替える調整である。 概念分類自体 (PreFilter / Base Scoring / Aggregate / PostRerank の method 分割) は変わらない。
コンテキスト
doc 分割 PR (#21480) でコアモデルを base-scoring.md に切り出した際、語彙債務が顕在化した。L1/L2/L3 には 2 つの問題がある:
- 業界標準と不一致: recsys の標準語彙は
Retrieval → Ranking → Re-ranking+Base / embedding model。L1/L2/L3 は UZU 方言で、外部 recsys 人材・ML 採用に翻訳コストを課す。 - 番号が実行順と逆: 実行順は
PreFilter → Base Scoring → Aggregate → PostRerank。番号「L1→L2→L3」は存在しない順序を読者に刷り込む。
加えてコーディング AI は global corpus (two-tower / retrieval / reranking が lingua franca) で訓練されており、UZU 方言を毎セッション翻訳させると文脈コストと誤実装リスクが上がる。
内容
- コード:
l1_core/→basescoring/rename (挙動不変の機械的リファクタ)、コード内コメントの L1/L2/L3 を標準語彙へ - ドキュメント: spec / base-scoring / technology-choices / strategy-decisions / surfaces の L1/L2/L3 を全廃。spec §3.1 の対応表は「UZU 実装名 ↔ 業界標準語」の橋として役割を反転して維持
- 例外:
basescoring/ialsのLambda は L2 正則化係数は ML 用語 (ridge / L2-norm) なので保持
関連
- UZU-5395: 本決定の実装 issue
- PR #21480: コアモデルの
base-scoring.md分離 (語彙債務が顕在化したトリガー) - 2026-04-23 の決定 (上記): 本決定が部分的に上書きする元の判断 (Surface 別 Strategy 統合)
- technology-choices §9.3: B 折衷廃止の経緯 (本決定で「内部実装の B 折衷は構造的に不可避」へ再評価)