LESSON 30分

ストーリー

高橋アーキテクト
Amazonで商品を見た後、関連商品が表示される。YouTubeで動画を見た後、次に見たい動画が並ぶ。この『おすすめ』が売上の35%を生み出している
高橋アーキテクト
レコメンデーションは単なる機能じゃない。ビジネスの生命線だ。では、どう設計するか

要件の整理

interface RecommendationRequirements {
  functional: {
    itemToItem: "この商品を見た人はこちらも見ています";
    personalized: "あなたへのおすすめ";
    trending: "人気の商品/コンテンツ";
    collaborative: "似たユーザーが好んだアイテム";
  };
  nonFunctional: {
    latency: "p99 < 100ms(リアルタイム推薦)";
    freshness: "新しいアイテムが1時間以内に推薦候補に入る";
    scale: "1億ユーザー、1000万アイテム";
    accuracy: "CTR(Click Through Rate) 5%以上";
  };
}

レコメンデーションの3つのアプローチ

// 1. コンテンツベースフィルタリング
class ContentBasedFiltering {
  // アイテムの特徴量を使って類似アイテムを見つける
  findSimilar(item: Item): Item[] {
    // 特徴量ベクトルの類似度(コサイン類似度)を計算
    const itemVector = this.extractFeatures(item);
    // ジャンル、タグ、説明文から特徴を抽出
    return this.items
      .map(candidate => ({
        item: candidate,
        similarity: this.cosineSimilarity(itemVector, this.extractFeatures(candidate)),
      }))
      .sort((a, b) => b.similarity - a.similarity)
      .slice(0, 20)
      .map(r => r.item);
  }
}

// 2. 協調フィルタリング
class CollaborativeFiltering {
  // ユーザーの行動パターンから推薦する
  // 「AさんとBさんは過去に似た商品を買った → Aが買ってBが未購入の商品を推薦」
  recommend(userId: string): Item[] {
    const similarUsers = this.findSimilarUsers(userId);
    const candidateItems = this.getItemsFromSimilarUsers(similarUsers);
    return this.rankByPredictedRating(userId, candidateItems);
  }
}

// 3. ハイブリッドアプローチ(実運用推奨)
class HybridRecommender {
  recommend(userId: string, context: Context): Item[] {
    // 複数の推薦結果をブレンド
    const contentBased = this.contentFilter.recommend(userId);
    const collaborative = this.collaborativeFilter.recommend(userId);
    const trending = this.trendingService.getPopular();

    // 重み付きスコアで最終ランキング
    return this.blendAndRank(contentBased, collaborative, trending, context);
  }
}

ハイレベル設計

┌──────────────────────────────────────────────────────┐
│                                                      │
│  [ユーザーリクエスト]                                   │
│        │                                             │
│        ▼                                             │
│  ┌─────────────┐     ┌──────────────────┐           │
│  │ 候補生成     │────→│ ランキング         │           │
│  │ (Candidate)  │     │ (Ranking/Scoring) │           │
│  └──────┬──────┘     └────────┬─────────┘           │
│         │                      │                     │
│  ┌──────┴──────────────────────┴────────────┐       │
│  │              データレイヤー                  │       │
│  │                                          │       │
│  │  [ユーザープロファイル]  [アイテム特徴量]     │       │
│  │  [行動ログ]           [モデル]             │       │
│  │  [類似度キャッシュ]    [トレンドキャッシュ]   │       │
│  └──────────────────────────────────────────┘       │
│                                                      │
│  [オフラインパイプライン]                                │
│  行動ログ → 特徴量抽出 → モデル学習 → モデルデプロイ      │
│                                                      │
└──────────────────────────────────────────────────────┘

2ステージアーキテクチャ

// Stage 1: 候補生成(Candidate Generation)
// 数百万アイテムから数百件に絞り込む
class CandidateGenerator {
  async generate(userId: string): Promise<CandidateItem[]> {
    const candidates: CandidateItem[] = [];

    // 過去に見たアイテムの類似アイテム
    const recentViews = await this.getRecentViews(userId, 50);
    for (const view of recentViews) {
      const similar = await this.similarityIndex.getNearest(view.itemId, 20);
      candidates.push(...similar);
    }

    // 協調フィルタリングからの候補
    const cfCandidates = await this.collaborativeFilter.getCandidates(userId, 100);
    candidates.push(...cfCandidates);

    // トレンドアイテム
    const trending = await this.trendingService.getTopItems(50);
    candidates.push(...trending);

    // 重複排除して返す
    return this.deduplicate(candidates);
  }
}

// Stage 2: ランキング(Ranking)
// 候補をスコアリングして上位を返す
class Ranker {
  async rank(userId: string, candidates: CandidateItem[]): Promise<RankedItem[]> {
    const userProfile = await this.getUserProfile(userId);

    return candidates
      .map(candidate => ({
        ...candidate,
        score: this.calculateScore(userProfile, candidate),
      }))
      .sort((a, b) => b.score - a.score)
      .slice(0, 20); // 上位20件を返す
  }

  private calculateScore(user: UserProfile, item: CandidateItem): number {
    // 複数のシグナルを組み合わせ
    const relevance = this.relevanceModel.predict(user, item);
    const freshness = this.freshnessScore(item.publishedAt);
    const popularity = item.engagementRate;
    const diversity = this.diversityBonus(item);

    return relevance * 0.5 + freshness * 0.2 + popularity * 0.2 + diversity * 0.1;
  }
}

コールドスタート問題

// 新規ユーザーや新規アイテムへの対処
const COLD_START_STRATEGIES = {
  newUser: {
    strategy: "人気アイテム + デモグラフィック情報ベースの推薦",
    steps: [
      "1. まず全体のトレンドアイテムを表示",
      "2. 初回登録時に興味カテゴリを選択させる",
      "3. 行動データが蓄積されたら徐々にパーソナライズ",
    ],
  },
  newItem: {
    strategy: "コンテンツベースフィルタリング + ブーストスコア",
    steps: [
      "1. メタデータ(カテゴリ、タグ)から類似アイテムを特定",
      "2. 新着ブーストスコアを加算して露出を増やす",
      "3. エンゲージメントデータが溜まったら通常ランキングに移行",
    ],
  },
};

まとめ

ポイント内容
3つのアプローチコンテンツベース、協調フィルタリング、ハイブリッド
2ステージ構成候補生成(広く)→ ランキング(精緻に)
コールドスタート人気/トレンド → カテゴリ選択 → パーソナライズへ段階的移行
オフライン学習モデルの学習はバッチ処理、推論はリアルタイム

チェックリスト

  • 3つのレコメンデーションアプローチを比較できた
  • 2ステージアーキテクチャの設計を理解した
  • コールドスタート問題の対処法を把握した
  • オフラインとオンラインの役割分担を理解した

次のステップへ

次は「データパイプラインの設計」を学びます。大量のデータを収集・変換・格納するETL/ELTパイプラインの設計を掘り下げます。


推定読了時間: 30分