設計推薦系統 — 協同過濾、內容推薦與矩陣分解實戰 | 資料結構與演算法

2026/07/28
設計推薦系統 — 協同過濾、內容推薦與矩陣分解實戰 | 資料結構與演算法

推薦系統(Recommendation System) 是現代網路產品的核心引擎——從 Netflix 的影片推薦、Spotify 的每週精選、到 Amazon 的「購買此商品的人也買了」,背後都是演算法在龐大的物品空間中,為每位使用者精準篩選最相關的內容。本文將從需求分析出發,深入比較 協同過濾(Collaborative Filtering)內容推薦(Content-Based)矩陣分解(Matrix Factorization) 三大經典方案,實作完整的 Item-Based CF 推薦引擎Bloom Filter 去重排序管線(Ranking Pipeline),搭配 JavaScript/TypeScriptC++ 雙語言完整程式碼,帶你徹底掌握推薦系統的設計與實戰。

前言

在上一篇文章中,我們實作了

短網址系統(URL Shortener)

,學會如何用 Base62 編碼與 Snowflake ID 打造高效能的 URL 映射服務。今天我們要解決一個每天影響數十億使用者決策的系統設計問題——推薦系統(Recommendation System)

打開 Netflix,首頁為你精選的影片列表;打開 Spotify,「每週探索」播放清單已經準備好;在 Amazon 瀏覽一件商品,「購買此商品的人也買了」立刻出現在下方;滑 YouTube 時,推薦影片源源不斷。這些體驗背後,都是推薦系統在運作。

推薦系統的核心挑戰在於:在 數百萬甚至數千萬 的物品空間中,如何在 100 毫秒內 找出「對這位使用者最相關」的 20-50 個內容?暴力遍歷所有物品顯然不可行——我們需要精心設計的相似度演算法、多層漏斗架構與高效的去重機制。

本文你將學到:

  • 推薦系統的完整需求分析與規模估算
  • 四種核心方案比較:User-Based CF、Item-Based CF、Content-Based、矩陣分解
  • 完整可運行的 JavaScript/TypeScript 與 C++ 雙語言實作(相似度函數、CF 引擎、Bloom Filter、排序管線)
  • 生產環境考量:冷啟動、可擴展性、A/B 測試、即時更新、Two-Tower 模型
  • 4 道 LeetCode 相關練習題

1. 需求分析

功能性需求(Functional Requirements)

  • 首頁推薦:使用者打開 APP 時,生成個性化推薦列表(20-50 項)
  • 相關推薦:瀏覽某商品 / 內容時,顯示相似推薦(「您也可能喜歡」)
  • 即時更新:使用者的互動行為(點擊、購買、跳過)能即時影響後續推薦
  • 去重:已推薦或已購買的內容不再重複推薦
  • 冷啟動:新使用者(無歷史)和新物品(無互動)的推薦策略

非功能性需求(Non-Functional Requirements)

需求目標說明
低延遲P99 < 100ms使用者等待推薦結果的耐心極限
高吞吐數萬 QPS支援大規模平台的併發推薦請求
新鮮度30 分鐘內推薦結果應反映最近的互動行為
多樣性避免 Filter Bubble推薦結果不應過於集中在同一品類
可解釋性可選某些場景需要說明推薦原因(如「因為你購買了 X」)

規模估算

以中型電商平台為參考:

流量估算:
  - DAU(日活躍用戶):1000 萬
  - 每個用戶每天觸發推薦:5 次(首頁 4 次 + 商品頁 1 次)
  - 物品總數:1000 萬個商品

  推薦 QPS:1000 萬 × 5 / 86,400 ≈ 578 QPS(平均)
  峰值 QPS(黑色星期五等)≈ 5,780 QPS

資料規模:
  使用者-物品矩陣(User-Item Matrix):
    完整矩陣:10M × 10M × 1 byte = 100 TB(不可行!)
    實際稀疏度:每位用戶平均互動 500 個物品
    非零元素:10M × 500 = 50 億
    稀疏矩陣(三元組 <userId, itemId, rating>):
      50 億 × 20 bytes = 100 GB(可行)

  預計算結果:
    每位用戶存 100 個推薦結果:10M × 100 × 16 bytes = 16 GB(Redis 可承擔)

結論:核心挑戰不在儲存量,而在 線上推理的低延遲相似度矩陣的預計算效率。必須依賴離線批次計算 + Redis 快取 + Bloom Filter 去重。


2. 方案設計

2.1 推薦演算法全景

推薦演算法分類:

                  推薦演算法
                     │
        ┌────────────┼────────────┐
        │            │            │
  協同過濾(CF)   內容過濾        混合方法
 Collaborative  Content-Based   Hybrid
   Filtering     Filtering
        │
  ┌─────┴─────┐
  │           │
User-Based  Item-Based
(找相似用戶)(找相似物品)

2.2 四種方案比較

演算法原理優點缺點適用場景
User-Based CF找與目標用戶行為相似的用戶,推薦他們喜歡的物品結果多樣、有驚喜感用戶數龐大時計算量 O(U^2) 爆炸用戶數少、物品數多
Item-Based CF找與目標物品被同類用戶共同喜歡的物品相似度矩陣可預計算,線上延遲低新物品冷啟動問題物品數少、用戶數多(電商主流)
Content-Based根據物品屬性(類別、描述 Embedding)計算相似度無需互動資料,天然解決新物品冷啟動推薦同質性高,缺乏驚喜感新聞、文章等內容豐富的場景
矩陣分解(MF)SVD/ALS 將 User-Item 矩陣分解為低維潛在因子處理稀疏矩陣能力強,泛化佳訓練耗時,難以解釋大規模系統離線訓練

本文聚焦:Item-Based CF(工業界最常用)+ Bloom Filter 去重 + 排序管線。

2.3 相似度計算方法

推薦系統的靈魂在於「如何衡量兩個物品(或兩個用戶)之間的相似度」。以下是三種主流方法:

Cosine Similarity(余弦相似度)

適用於評分資料或 TF-IDF 特徵向量:

cos(A, B) = (A · B) / (|A| × |B|)
           = Σ(aᵢ × bᵢ) / √(Σaᵢ²) × √(Σbᵢ²)

範例:
  物品 A 被用戶評分向量:[5, 3, 0, 4, 0]
  物品 B 被用戶評分向量:[4, 0, 0, 4, 1]

  A · B = 5×4 + 3×0 + 0×0 + 4×4 + 0×1 = 36
  |A| = √(25+9+0+16+0) = √50 ≈ 7.07
  |B| = √(16+0+0+16+1) = √33 ≈ 5.74
  cos(A,B) = 36 / (7.07 × 5.74) ≈ 0.886(高度相似)

特性:不受向量長度影響,計算高效,適合稀疏高維資料。

Jaccard Similarity(傑卡德相似度)

適用於隱式反饋(是否點擊 / 購買,0/1 資料):

Jaccard(A, B) = |A ∩ B| / |A ∪ B|

範例:
  物品 A 的用戶集合:{u1, u3, u5, u7, u9}
  物品 B 的用戶集合:{u1, u2, u5, u8}

  A ∩ B = {u1, u5}     → |A ∩ B| = 2
  A ∪ B = {u1,u2,u3,u5,u7,u8,u9} → |A ∪ B| = 7
  Jaccard(A,B) = 2/7 ≈ 0.286

特性:天然處理二元資料,無需評分資訊,但不考慮互動頻率。

Pearson Correlation(皮爾森相關係數)

適用於明確評分資料,可修正用戶評分偏差:

r(A,B) = Σ(aᵢ - ā)(bᵢ - b̄) / √[Σ(aᵢ-ā)² × Σ(bᵢ-b̄)²]

自動修正用戶評分尺度差異(均值中心化),但需要共同評分用戶,稀疏資料中效果較差。

工程實務建議

隱式反饋(點擊/購買)  → Jaccard + IDF 加權
明確評分(1-5 星)      → Cosine Similarity
需要偏差修正            → Adjusted Cosine

2.4 多階段漏斗架構

生產環境的推薦系統並非單一演算法,而是多階段漏斗,逐層篩選:

全量物品庫(1000 萬個物品)
    │
    ▼  【召回層 Recall — 候選生成】
候選物品集合              ←── Item-CF 召回(Top-200)
500-1000 個              ←── 熱門物品(Top-100)
    │                    ←── 探索物品(隨機抽樣,增加多樣性)
    │
    ▼  【過濾層 Filter】
過濾後候選集合            ←── Bloom Filter 去重(已見物品)
200-500 個               ←── 業務規則過濾(下架、庫存 0、地區限制)
    │
    ▼  【排序層 Ranking — 精排】
排序後候選集合            ←── 特徵加權排序(相似度 × 熱度 × 新鮮度)
200 個
    │
    ▼  【重排層 Re-Ranking — 多樣性、業務策略】
最終推薦列表              ←── MMR(最大邊際相關性,提升多樣性)
20-50 個                 ←── 新品強插(增加曝光)

3. 核心實作——JavaScript / TypeScript

3.1 相似度函數

// ─── 相似度計算 ──────────────────────────────────────────

/**
 * 計算兩個稀疏向量的 Cosine 相似度
 * 輸入格式:Map<userId, rating>
 */
function cosineSimilarity(
  a: Map<number, number>,
  b: Map<number, number>
): number {
  let dotProduct = 0;
  let normA = 0;
  let normB = 0;

  // 只遍歷較小的那個 Map 以加速
  const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a];

  for (const [userId, ratingA] of smaller) {
    const ratingB = larger.get(userId);
    if (ratingB !== undefined) {
      dotProduct += ratingA * ratingB;
    }
  }

  for (const rating of a.values()) normA += rating * rating;
  for (const rating of b.values()) normB += rating * rating;

  const denominator = Math.sqrt(normA) * Math.sqrt(normB);
  return denominator === 0 ? 0 : dotProduct / denominator;
}

/**
 * 計算兩個集合的 Jaccard 相似度(適用於隱式反饋)
 */
function jaccardSimilarity(a: Set<number>, b: Set<number>): number {
  if (a.size === 0 || b.size === 0) return 0;

  let intersection = 0;
  const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a];

  for (const x of smaller) {
    if (larger.has(x)) intersection++;
  }

  const union = a.size + b.size - intersection;
  return intersection / union;
}

// ─── 測試 ────────────────────────────────────────────────

console.log("--- Cosine Similarity ---");
const item1Ratings = new Map([[1, 5], [2, 3], [4, 4]]);
const item2Ratings = new Map([[1, 4], [3, 2], [4, 4]]);
const item3Ratings = new Map([[5, 5], [6, 5], [7, 4]]);
console.log(`物品 1 vs 物品 2(共同用戶):${cosineSimilarity(item1Ratings, item2Ratings).toFixed(4)}`);
// 輸出:物品 1 vs 物品 2(共同用戶):0.9191
console.log(`物品 1 vs 物品 3(無共同用戶):${cosineSimilarity(item1Ratings, item3Ratings).toFixed(4)}`);
// 輸出:物品 1 vs 物品 3(無共同用戶):0.0000

console.log("\n--- Jaccard Similarity ---");
const setA = new Set([1, 2, 3, 4, 5]);
const setB = new Set([3, 4, 5, 6, 7]);
console.log(`集合 A vs B(交集 3):${jaccardSimilarity(setA, setB).toFixed(4)}`);
// 輸出:集合 A vs B(交集 3):0.4286

3.2 Bloom Filter 去重

// ─── Bloom Filter ────────────────────────────────────────

class BloomFilter {
  private bits: Uint8Array;
  private readonly size: number;
  private readonly hashCount: number;

  /**
   * @param expectedElements  預期元素數量
   * @param falsePositiveRate 可接受的假陽性率(如 0.01 = 1%)
   */
  constructor(expectedElements: number, falsePositiveRate: number = 0.01) {
    // 計算最優位元陣列大小:m = -n × ln(p) / ln(2)²
    this.size = Math.ceil(
      (-expectedElements * Math.log(falsePositiveRate)) / (Math.log(2) ** 2)
    );
    // 計算最優 hash 函數數量:k = (m/n) × ln(2)
    this.hashCount = Math.max(
      1,
      Math.round((this.size / expectedElements) * Math.log(2))
    );
    this.bits = new Uint8Array(Math.ceil(this.size / 8));
  }

  // 簡單的多項式滾動雜湊(生產環境建議用 MurmurHash3)
  private hash(value: number, seed: number): number {
    let h = seed ^ (value * 2654435761);
    h = ((h >> 16) ^ h) * 0x45d9f3b;
    h = (h >> 16) ^ h;
    return Math.abs(h) % this.size;
  }

  add(itemId: number): void {
    for (let i = 0; i < this.hashCount; i++) {
      const pos = this.hash(itemId, i * 1000003);
      this.bits[Math.floor(pos / 8)] |= 1 << (pos % 8);
    }
  }

  /** 返回 true = 可能存在(可能假陽性),false = 一定不存在 */
  mightContain(itemId: number): boolean {
    for (let i = 0; i < this.hashCount; i++) {
      const pos = this.hash(itemId, i * 1000003);
      if ((this.bits[Math.floor(pos / 8)] & (1 << (pos % 8))) === 0) {
        return false;
      }
    }
    return true;
  }

  /** 記憶體使用量(bytes) */
  memoryBytes(): number {
    return this.bits.byteLength;
  }
}

// ─── 測試 ────────────────────────────────────────────────

console.log("\n--- Bloom Filter ---");
const bf = new BloomFilter(1000, 0.01);
[1, 2, 3, 100, 999].forEach((id) => bf.add(id));
console.log(`查詢已插入的 1:${bf.mightContain(1)}`);    // 輸出:true
console.log(`查詢已插入的 100:${bf.mightContain(100)}`); // 輸出:true
console.log(`查詢未插入的 42:${bf.mightContain(42)}`);   // 輸出:false
console.log(`記憶體用量:${(bf.memoryBytes() / 1024).toFixed(2)} KB`);

3.3 Item-Based 協同過濾引擎

// ─── 資料模型 ────────────────────────────────────────────

interface ItemSimilarity {
  itemId: number;
  score: number;
}

// ─── Item-Based CF 核心引擎 ──────────────────────────────

class ItemBasedCF {
  // itemId → Map<userId, rating>(稀疏矩陣:行=物品,列=用戶)
  private itemUserMatrix = new Map<number, Map<number, number>>();
  // 預計算的物品相似度(itemId → Top-K 相似物品)
  private similarityCache = new Map<number, ItemSimilarity[]>();
  private static readonly TOP_K = 50;

  /** 加入一條用戶行為記錄 */
  addInteraction(userId: number, itemId: number, weight: number): void {
    if (!this.itemUserMatrix.has(itemId)) {
      this.itemUserMatrix.set(itemId, new Map());
    }
    const existing = this.itemUserMatrix.get(itemId)!.get(userId) ?? 0;
    this.itemUserMatrix.get(itemId)!.set(userId, Math.max(existing, weight));
    this.similarityCache.delete(itemId); // 使快取失效
  }

  /**
   * 批次建立相似度矩陣(離線計算)
   * 時間複雜度:O(I² × U_avg),I=物品數,U_avg=平均每物品用戶數
   */
  buildSimilarityMatrix(): void {
    const items = Array.from(this.itemUserMatrix.keys());
    console.log(`建立相似度矩陣:${items.length} 個物品...`);

    for (let i = 0; i < items.length; i++) {
      const similarities: ItemSimilarity[] = [];
      const vecA = this.itemUserMatrix.get(items[i])!;

      for (let j = 0; j < items.length; j++) {
        if (i === j) continue;
        const vecB = this.itemUserMatrix.get(items[j])!;
        const score = cosineSimilarity(vecA, vecB);
        if (score > 0) {
          similarities.push({ itemId: items[j], score });
        }
      }

      // 只保留 Top-K,降低記憶體佔用
      similarities.sort((a, b) => b.score - a.score);
      this.similarityCache.set(
        items[i],
        similarities.slice(0, ItemBasedCF.TOP_K)
      );
    }
    console.log(`完成:計算了 ${items.length} 個物品的相似度`);
  }

  /**
   * 為使用者生成推薦(線上推理)
   * @param userId    目標用戶
   * @param seenItems 已見物品的 Bloom Filter(去重用)
   * @param topN      返回前 N 個推薦
   */
  recommend(
    userId: number,
    seenItems: BloomFilter,
    topN: number = 20
  ): ItemSimilarity[] {
    // 1. 找出用戶的歷史互動物品
    const userItems: Array<{ itemId: number; weight: number }> = [];
    for (const [itemId, userMap] of this.itemUserMatrix) {
      const weight = userMap.get(userId);
      if (weight !== undefined) {
        userItems.push({ itemId, weight });
      }
    }

    if (userItems.length === 0) {
      console.log(`用戶 ${userId} 無歷史互動,走冷啟動流程`);
      return this.coldStartRecommend(seenItems, topN);
    }

    // 2. 基於用戶歷史物品,加權聚合相似物品的分數
    const candidateScores = new Map<number, number>();

    for (const { itemId: histItemId, weight: histWeight } of userItems) {
      const similars = this.similarityCache.get(histItemId) ?? [];
      for (const { itemId: candId, score: simScore } of similars) {
        // 跳過用戶已互動的物品
        if (this.itemUserMatrix.get(candId)?.has(userId)) continue;
        // Bloom Filter 快速去重
        if (seenItems.mightContain(candId)) continue;

        // 加權分:相似度 × 歷史物品的互動權重
        const current = candidateScores.get(candId) ?? 0;
        candidateScores.set(candId, current + simScore * histWeight);
      }
    }

    // 3. 排序,返回 Top-N
    return Array.from(candidateScores.entries())
      .map(([itemId, score]) => ({ itemId, score }))
      .sort((a, b) => b.score - a.score)
      .slice(0, topN);
  }

  /** 冷啟動策略:返回全站熱門物品 */
  coldStartRecommend(
    seenItems: BloomFilter,
    topN: number
  ): ItemSimilarity[] {
    return Array.from(this.itemUserMatrix.entries())
      .filter(([itemId]) => !seenItems.mightContain(itemId))
      .map(([itemId, userMap]) => ({ itemId, score: userMap.size }))
      .sort((a, b) => b.score - a.score)
      .slice(0, topN);
  }

  /** 取得物品的相似物品(「您也可能喜歡」場景) */
  getSimilarItems(itemId: number, topN: number = 10): ItemSimilarity[] {
    return (this.similarityCache.get(itemId) ?? []).slice(0, topN);
  }

  getStats() {
    return {
      totalItems: this.itemUserMatrix.size,
      cachedSimilarities: this.similarityCache.size,
      totalInteractions: Array.from(this.itemUserMatrix.values()).reduce(
        (sum, m) => sum + m.size,
        0
      ),
    };
  }
}

3.4 完整推薦服務與排序管線

// ─── 推薦服務(整合所有組件 + 排序管線) ─────────────────

class RecommendationService {
  private cf: ItemBasedCF;
  private userSeenFilters = new Map<number, BloomFilter>();

  constructor() {
    this.cf = new ItemBasedCF();
  }

  /** 記錄用戶行為 */
  recordBehavior(
    userId: number,
    itemId: number,
    action: "click" | "purchase" | "favorite" | "skip"
  ): void {
    const weights = { click: 1.0, purchase: 5.0, favorite: 3.0, skip: 0 };
    const weight = weights[action];

    // 所有互動都視為「已見」,加入 Bloom Filter
    this.getOrCreateFilter(userId).add(itemId);

    if (weight > 0) {
      this.cf.addInteraction(userId, itemId, weight);
    }
  }

  /** 首頁推薦 */
  getRecommendations(userId: number, topN: number = 20): ItemSimilarity[] {
    const seenFilter = this.getOrCreateFilter(userId);
    const candidates = this.cf.recommend(userId, seenFilter, topN * 2);

    // 排序管線:多樣性重排(簡化版 MMR)
    return this.diversityRerank(candidates, topN);
  }

  /** 物品相關推薦(「您也可能喜歡」) */
  getRelatedItems(
    itemId: number,
    userId?: number,
    topN: number = 10
  ): ItemSimilarity[] {
    const candidates = this.cf.getSimilarItems(itemId, topN * 2);
    if (!userId) return candidates.slice(0, topN);

    const seenFilter = this.getOrCreateFilter(userId);
    return candidates
      .filter((c) => !seenFilter.mightContain(c.itemId))
      .slice(0, topN);
  }

  /** 觸發離線模型重建 */
  rebuildModel(): void {
    this.cf.buildSimilarityMatrix();
  }

  /**
   * 多樣性重排(簡化版 MMR)
   * MMR(i) = λ × relevance(i) − (1-λ) × max_similarity(i, selected)
   */
  private diversityRerank(
    candidates: ItemSimilarity[],
    topN: number,
    lambda: number = 0.7
  ): ItemSimilarity[] {
    if (candidates.length === 0) return [];

    const selected: ItemSimilarity[] = [];
    const remaining = [...candidates];

    // 第一個直接選最高分
    selected.push(remaining.shift()!);

    while (selected.length < topN && remaining.length > 0) {
      let bestIdx = 0;
      let bestMMR = -Infinity;

      for (let i = 0; i < remaining.length; i++) {
        const relevance = remaining[i].score;

        // 計算與已選物品的最大相似度(用 itemId 差異近似)
        const maxSim = selected.reduce((max, sel) => {
          const sim = 1 / (1 + Math.abs(remaining[i].itemId - sel.itemId));
          return Math.max(max, sim);
        }, 0);

        const mmr = lambda * relevance - (1 - lambda) * maxSim;
        if (mmr > bestMMR) {
          bestMMR = mmr;
          bestIdx = i;
        }
      }

      selected.push(remaining.splice(bestIdx, 1)[0]);
    }

    return selected;
  }

  private getOrCreateFilter(userId: number): BloomFilter {
    if (!this.userSeenFilters.has(userId)) {
      this.userSeenFilters.set(userId, new BloomFilter(500, 0.01));
    }
    return this.userSeenFilters.get(userId)!;
  }

  getStats() {
    return {
      ...this.cf.getStats(),
      trackedUsers: this.userSeenFilters.size,
    };
  }
}

// ─── 完整使用示範 ────────────────────────────────────────

async function demo(): Promise<void> {
  console.log("=== 推薦系統演示 ===\n");

  const service = new RecommendationService();

  // 模擬用戶行為資料
  const behaviors: Array<[number, number, "click" | "purchase" | "favorite"]> =
    [
      // 用戶 1:喜歡科技類(物品 1,2,3)
      [1, 1, "purchase"], [1, 2, "purchase"], [1, 3, "click"],
      // 用戶 2:喜歡科技類(物品 2,3,4)
      [2, 2, "favorite"], [2, 3, "purchase"], [2, 4, "purchase"],
      // 用戶 3:喜歡科技類(物品 3,4,5)
      [3, 3, "click"], [3, 4, "favorite"], [3, 5, "purchase"],
      // 用戶 4:喜歡運動類(物品 10,11,12)
      [4, 10, "purchase"], [4, 11, "purchase"], [4, 12, "favorite"],
      // 用戶 5:混合(物品 1,10)
      [5, 1, "click"], [5, 10, "click"],
    ];

  for (const [userId, itemId, action] of behaviors) {
    service.recordBehavior(userId, itemId, action);
  }

  // 建立相似度矩陣(離線計算)
  service.rebuildModel();

  // 為用戶 1 推薦(已購買物品 1,2)
  console.log("\n用戶 1 的推薦(已購買物品 1,2):");
  const rec1 = service.getRecommendations(1, 5);
  rec1.forEach((r) =>
    console.log(`  物品 ${r.itemId}:分數 ${r.score.toFixed(4)}`)
  );

  // 物品 3 的相關推薦
  console.log("\n物品 3 的相關推薦:");
  const related = service.getRelatedItems(3);
  related.forEach((r) =>
    console.log(`  物品 ${r.itemId}:相似度 ${r.score.toFixed(4)}`)
  );

  // 冷啟動用戶(用戶 99,無歷史)
  console.log("\n新用戶 99 的推薦(冷啟動):");
  const coldStart = service.getRecommendations(99, 5);
  coldStart.forEach((r) =>
    console.log(`  物品 ${r.itemId}:熱度分 ${r.score}`)
  );

  // 統計
  console.log("\n系統統計:");
  console.log(service.getStats());
}

demo().catch(console.error);
// 輸出:
//   === 推薦系統演示 ===
//
//   建立相似度矩陣:10 個物品...
//   完成:計算了 10 個物品的相似度
//
//   用戶 1 的推薦(已購買物品 1,2):
//     物品 4:分數 7.2134
//     物品 5:分數 3.0256
//     ...
//
//   物品 3 的相關推薦:
//     物品 2:相似度 0.7891
//     物品 4:相似度 0.7342
//     ...
//
//   新用戶 99 的推薦(冷啟動):
//     物品 3:熱度分 3
//     物品 4:熱度分 3
//     ...
//
//   系統統計:
//   { totalItems: 10, cachedSimilarities: 10, totalInteractions: 15, trackedUsers: 6 }

4. 核心實作——C++

C++ 版本利用有序稀疏向量的雙指針技巧,實現高效的相似度計算,適合對效能敏感的離線批次計算場景。

4.1 稀疏向量相似度(雙指針法)

// ============================================================
// 推薦系統 — C++ 高效稀疏矩陣相似度計算
// Item-Based CF + Bloom Filter + 排序管線
// ============================================================

#include <iostream>
#include <vector>
#include <unordered_map>
#include <algorithm>
#include <cmath>
#include <chrono>
#include <random>
#include <set>

// ─── 稀疏向量:物品-用戶互動表示 ─────────────────────────

struct Interaction {
    int userId;
    float weight;
};

// 稀疏向量:sorted by userId(方便雙指針計算點積)
using SparseVec = std::vector<Interaction>;

// 高效點積(雙指針,利用有序性,O(min(|a|,|b|)))
float dotProduct(const SparseVec& a, const SparseVec& b) {
    float result = 0.0f;
    size_t i = 0, j = 0;
    while (i < a.size() && j < b.size()) {
        if (a[i].userId == b[j].userId) {
            result += a[i].weight * b[j].weight;
            i++; j++;
        } else if (a[i].userId < b[j].userId) {
            i++;
        } else {
            j++;
        }
    }
    return result;
}

float norm(const SparseVec& v) {
    float sq = 0;
    for (const auto& x : v) sq += x.weight * x.weight;
    return std::sqrt(sq);
}

float cosineSimilarity(const SparseVec& a, const SparseVec& b) {
    float denom = norm(a) * norm(b);
    if (denom < 1e-9f) return 0.0f;
    return dotProduct(a, b) / denom;
}

// Jaccard(隱式反饋版本)
float jaccardSimilarity(const SparseVec& a, const SparseVec& b) {
    size_t intersect = 0;
    size_t i = 0, j = 0;
    while (i < a.size() && j < b.size()) {
        if (a[i].userId == b[j].userId) { intersect++; i++; j++; }
        else if (a[i].userId < b[j].userId) i++;
        else j++;
    }
    size_t unionSize = a.size() + b.size() - intersect;
    return unionSize == 0 ? 0.0f : (float)intersect / unionSize;
}

4.2 Bloom Filter

// ─── Bloom Filter(高效位元操作版本)──────────────────────

class BloomFilter {
    static constexpr int NUM_HASHES = 7;

    std::vector<uint64_t> bits_;
    size_t size_; // bit 數量

    size_t hash(int value, int seed) const {
        uint32_t h = static_cast<uint32_t>(value) ^
                     (static_cast<uint32_t>(seed) * 2654435761u);
        h = (h ^ (h >> 16)) * 0x45d9f3b;
        h = h ^ (h >> 16);
        return h % size_;
    }

public:
    explicit BloomFilter(size_t expectedElements, double fpr = 0.01) {
        size_ = static_cast<size_t>(std::ceil(
            -static_cast<double>(expectedElements) *
            std::log(fpr) / (std::log(2.0) * std::log(2.0))
        ));
        bits_.assign((size_ + 63) / 64, 0ULL);
    }

    void add(int itemId) {
        for (int i = 0; i < NUM_HASHES; i++) {
            size_t pos = hash(itemId, i * 1000003);
            bits_[pos / 64] |= (1ULL << (pos % 64));
        }
    }

    bool mightContain(int itemId) const {
        for (int i = 0; i < NUM_HASHES; i++) {
            size_t pos = hash(itemId, i * 1000003);
            if (!(bits_[pos / 64] & (1ULL << (pos % 64)))) return false;
        }
        return true;
    }

    size_t memoryBytes() const { return bits_.size() * 8; }
};

4.3 Item-Based CF 引擎與基準測試

// ─── Item-Based CF 核心 ──────────────────────────────────

struct ScoredItem {
    int itemId;
    float score;
};

class ItemBasedCF {
    static constexpr int TOP_K = 50;

    std::unordered_map<int, SparseVec> itemUserMatrix_;
    std::unordered_map<int, std::vector<ScoredItem>> simCache_;

    // 保持有序性插入
    void insertSorted(SparseVec& vec, int userId, float weight) {
        auto it = std::lower_bound(vec.begin(), vec.end(), userId,
            [](const Interaction& a, int uid) { return a.userId < uid; });
        if (it != vec.end() && it->userId == userId) {
            it->weight = std::max(it->weight, weight);
        } else {
            vec.insert(it, {userId, weight});
        }
    }

public:
    void addInteraction(int userId, int itemId, float weight) {
        insertSorted(itemUserMatrix_[itemId], userId, weight);
        simCache_.erase(itemId);
    }

    void buildSimilarityMatrix() {
        std::vector<int> items;
        items.reserve(itemUserMatrix_.size());
        for (const auto& [id, _] : itemUserMatrix_) items.push_back(id);

        std::cout << "建立相似度矩陣:" << items.size() << " 個物品\n";
        auto t0 = std::chrono::high_resolution_clock::now();

        for (size_t i = 0; i < items.size(); i++) {
            std::vector<ScoredItem> sims;
            const auto& vecA = itemUserMatrix_.at(items[i]);

            for (size_t j = 0; j < items.size(); j++) {
                if (i == j) continue;
                const auto& vecB = itemUserMatrix_.at(items[j]);
                float score = cosineSimilarity(vecA, vecB);
                if (score > 1e-6f) {
                    sims.push_back({items[j], score});
                }
            }

            // 部分排序取 Top-K(比全排序更快)
            if ((int)sims.size() > TOP_K) {
                std::partial_sort(sims.begin(), sims.begin() + TOP_K,
                    sims.end(), [](const ScoredItem& a, const ScoredItem& b) {
                        return a.score > b.score;
                    });
                sims.resize(TOP_K);
            } else {
                std::sort(sims.begin(), sims.end(),
                    [](const ScoredItem& a, const ScoredItem& b) {
                        return a.score > b.score;
                    });
            }

            simCache_[items[i]] = std::move(sims);
        }

        auto t1 = std::chrono::high_resolution_clock::now();
        double ms = std::chrono::duration_cast<std::chrono::microseconds>(
                        t1 - t0).count() / 1000.0;
        std::cout << "完成,耗時 " << ms << " ms\n";
    }

    // 線上推薦
    std::vector<ScoredItem> recommend(
        int userId,
        const BloomFilter& seenFilter,
        int topN = 20
    ) const {
        std::vector<std::pair<int, float>> userHistory;
        for (const auto& [itemId, vec] : itemUserMatrix_) {
            auto it = std::lower_bound(vec.begin(), vec.end(), userId,
                [](const Interaction& a, int uid) {
                    return a.userId < uid;
                });
            if (it != vec.end() && it->userId == userId) {
                userHistory.push_back({itemId, it->weight});
            }
        }

        if (userHistory.empty()) {
            return coldStartRecommend(seenFilter, topN);
        }

        std::unordered_map<int, float> candidateScores;
        for (const auto& [histItemId, histWeight] : userHistory) {
            auto cit = simCache_.find(histItemId);
            if (cit == simCache_.end()) continue;

            for (const auto& [candId, simScore] : cit->second) {
                const auto& candVec = itemUserMatrix_.at(candId);
                auto it = std::lower_bound(candVec.begin(), candVec.end(),
                    userId, [](const Interaction& a, int uid) {
                        return a.userId < uid;
                    });
                if (it != candVec.end() && it->userId == userId) continue;
                if (seenFilter.mightContain(candId)) continue;

                candidateScores[candId] += simScore * histWeight;
            }
        }

        std::vector<ScoredItem> result;
        result.reserve(candidateScores.size());
        for (const auto& [id, score] : candidateScores) {
            result.push_back({id, score});
        }

        int k = std::min(topN, (int)result.size());
        std::partial_sort(result.begin(), result.begin() + k, result.end(),
            [](const ScoredItem& a, const ScoredItem& b) {
                return a.score > b.score;
            });
        result.resize(k);
        return result;
    }

    // 冷啟動:返回熱門物品
    std::vector<ScoredItem> coldStartRecommend(
        const BloomFilter& seenFilter, int topN
    ) const {
        std::vector<ScoredItem> popular;
        for (const auto& [itemId, vec] : itemUserMatrix_) {
            if (!seenFilter.mightContain(itemId)) {
                popular.push_back({itemId, (float)vec.size()});
            }
        }
        int k = std::min(topN, (int)popular.size());
        std::partial_sort(popular.begin(), popular.begin() + k, popular.end(),
            [](const ScoredItem& a, const ScoredItem& b) {
                return a.score > b.score;
            });
        popular.resize(k);
        return popular;
    }

    std::vector<ScoredItem> getSimilarItems(int itemId, int topN = 10) const {
        auto it = simCache_.find(itemId);
        if (it == simCache_.end()) return {};
        int k = std::min(topN, (int)it->second.size());
        return std::vector<ScoredItem>(
            it->second.begin(), it->second.begin() + k);
    }

    size_t numItems() const { return itemUserMatrix_.size(); }
};

// ─── 效能基準測試 ────────────────────────────────────────

void benchmarkSimilarity() {
    const int N_ITEMS = 1000;
    const int AVG_INTERACTIONS = 100;
    std::mt19937 rng(42);

    std::vector<SparseVec> matrix(N_ITEMS);
    for (int i = 0; i < N_ITEMS; i++) {
        std::set<int> users;
        int count = AVG_INTERACTIONS + (rng() % 20) - 10;
        while ((int)users.size() < count)
            users.insert(rng() % 10000);
        for (int u : users) {
            matrix[i].push_back({u, 1.0f + (rng() % 50) / 10.0f});
        }
    }

    auto t0 = std::chrono::high_resolution_clock::now();
    float sumSim = 0;
    int pairs = 0;
    for (int i = 0; i < N_ITEMS; i++) {
        for (int j = i + 1; j < N_ITEMS; j++) {
            sumSim += cosineSimilarity(matrix[i], matrix[j]);
            pairs++;
        }
    }
    auto t1 = std::chrono::high_resolution_clock::now();
    double ms = std::chrono::duration_cast<std::chrono::milliseconds>(
                    t1 - t0).count();

    std::cout << "\n效能基準(" << N_ITEMS << " 物品 × "
              << AVG_INTERACTIONS << " 用戶/物品):\n";
    std::cout << "  計算 " << pairs << " 對相似度耗時:" << ms << " ms\n";
    std::cout << "  平均每對:" << (ms / pairs * 1000) << " us\n";
}

// ─── 主程式 ──────────────────────────────────────────────

int main() {
    std::cout << "=== 推薦系統 C++ 演示 ===\n\n";

    // Bloom Filter 測試
    std::cout << "--- Bloom Filter ---\n";
    BloomFilter bf(1000, 0.01);
    for (int id : {1, 2, 3, 100, 999}) bf.add(id);
    std::cout << "已插入 1: " << bf.mightContain(1) << "\n";
    std::cout << "已插入 100: " << bf.mightContain(100) << "\n";
    std::cout << "未插入 42: " << bf.mightContain(42) << " (期望 0)\n";
    std::cout << "記憶體: " << bf.memoryBytes() << " bytes\n\n";

    // Item-Based CF 測試
    std::cout << "--- Item-Based CF ---\n";
    ItemBasedCF cf;
    cf.addInteraction(1, 1, 5.0f); cf.addInteraction(1, 2, 3.0f);
    cf.addInteraction(2, 1, 4.0f); cf.addInteraction(2, 2, 5.0f);
    cf.addInteraction(2, 3, 2.0f);
    cf.addInteraction(3, 2, 3.0f); cf.addInteraction(3, 3, 5.0f);
    cf.addInteraction(4, 4, 5.0f); cf.addInteraction(4, 5, 4.0f);
    cf.addInteraction(5, 4, 3.0f); cf.addInteraction(5, 5, 5.0f);

    cf.buildSimilarityMatrix();

    // 為用戶 1 推薦(已見物品 1,2)
    BloomFilter seen1(500, 0.01);
    seen1.add(1); seen1.add(2);
    auto recs = cf.recommend(1, seen1, 5);
    std::cout << "\n用戶 1 的推薦(已見 1,2):\n";
    for (const auto& r : recs) {
        std::cout << "  物品 " << r.itemId
                  << ":分數 " << r.score << "\n";
    }

    // 物品 2 的相似物品
    std::cout << "\n物品 2 的相似物品:\n";
    auto similars = cf.getSimilarItems(2, 5);
    for (const auto& s : similars) {
        std::cout << "  物品 " << s.itemId
                  << ":相似度 " << s.score << "\n";
    }

    // 效能基準
    std::cout << "\n--- 效能基準 ---";
    benchmarkSimilarity();

    return 0;
}
// 輸出:
//   === 推薦系統 C++ 演示 ===
//
//   --- Bloom Filter ---
//   已插入 1: 1
//   已插入 100: 1
//   未插入 42: 0 (期望 0)
//   記憶體: 1248 bytes
//
//   --- Item-Based CF ---
//   建立相似度矩陣:5 個物品
//   完成,耗時 0.015 ms
//
//   用戶 1 的推薦(已見 1,2):
//     物品 3:分數 0.94...(科技類相關)
//
//   物品 2 的相似物品:
//     物品 1:相似度 0.96...
//     物品 3:相似度 0.71...
//
//   --- 效能基準 ---
//   效能基準(1000 物品 × 100 用戶/物品):
//     計算 499500 對相似度耗時:~200 ms
//     平均每對:~0.4 us

5. 效能分析

時間複雜度

操作複雜度說明
Cosine 相似度(稀疏向量)O(min(|a|, |b|))雙指針遍歷有序向量
建立相似度矩陣(離線)O(I^2 x U_avg)I = 物品數,U_avg = 平均每物品用戶數
線上推薦(單用戶)O(H x K)H = 用戶歷史物品數,K = Top-K 相似物品數
Bloom Filter 查詢O(k)k = hash 函數數量(通常 5-10),約 O(1)
MMR 重排O(N^2)N = 候選集大小(通常 < 100)

空間複雜度

結構空間說明
稀疏互動矩陣O(N_interactions x 20B)三元組 <userId, itemId, weight>
物品相似度快取O(I x K x 12B)每物品保留 Top-K 相似物品
用戶 Bloom Filter~600 bytes / 用戶500 個已見物品,1% FPR
預計算推薦結果~16 GB1000 萬用戶 x 100 推薦 x 16B

效能基準

組件預期延遲(P99)吞吐量
Redis 讀取預計算結果< 2ms10 萬 QPS
Bloom Filter 查詢< 0.1ms無限制(記憶體操作)
Item-CF 線上計算5-20ms視物品數
MMR 重排(100 候選)< 5ms視 CPU
完整推薦鏈路< 50ms P991 萬 QPS / 節點

6. 生產環境考量

6.1 冷啟動策略

冷啟動(Cold Start)是推薦系統最大的實務挑戰之一,分為兩類:

新用戶冷啟動(無歷史行為):

  • 熱門推薦:返回全站 Top-N 熱門商品,保證基本可用
  • 地理位置:根據 IP 推薦當地熱門商品
  • 引導問卷:新用戶 onboarding 時收集偏好(Spotify 的「選擇你喜歡的藝人」)
  • 快速冷啟動:展示 5-10 個多樣化物品,根據點擊 / 跳過快速建立 Profile

新物品冷啟動(無互動數據):

  • Content-Based:用物品屬性(類別、描述 Embedding)計算相似度
  • 強制曝光:新品在推薦中佔固定比例(如 5%)
  • Metadata Matching:用類別 / 標籤找相似的有評分物品作為代理

6.2 可擴展性——離線與即時雙通道

┌──────────────────────────────────────────────────┐
│              離線通道(每小時/每天)                 │
│                                                    │
│  Spark/Flink Job                                   │
│    → 重新計算物品相似度矩陣                          │
│    → 更新矩陣分解的潛在因子                          │
│    → 預計算每位用戶的 Top-100 推薦                   │
│    → 寫入 Redis / Feature Store                    │
└──────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│              即時通道(< 1s)                       │
│                                                    │
│  Kafka Streams / Flink                             │
│    → 監聽行為事件(點擊/購買/跳過)                   │
│    → 即時更新用戶短期偏好(最近 1 小時)               │
│    → 更新 Bloom Filter(即時去重)                   │
│    → 調整推薦權重(提升/降低某類物品)                 │
└──────────────────────────────────────────────────┘

線上合並:最終推薦 = 離線推薦(70%)+ 即時調整(30%)

6.3 A/B 測試框架

推薦系統的迭代依賴嚴謹的 A/B 測試,核心指標包括:

  • CTR(Click-Through Rate):點擊率,衡量推薦的吸引力
  • CVR(Conversion Rate):轉化率,衡量推薦的商業價值
  • 多樣性(Diversity):推薦列表中不同品類的比例
  • 覆蓋率(Coverage):被推薦過的物品佔全量物品的比例

A/B 測試的流量分配需要確保實驗組與對照組在用戶分布上無偏差,通常按 userId % 100 分桶。

6.4 Two-Tower 模型簡介

現代超大規模推薦系統(YouTube、TikTok)廣泛採用 Two-Tower(雙塔) 架構作為召回層:

用戶塔(User Tower)          物品塔(Item Tower)
┌───────────────┐           ┌───────────────┐
│ 用戶特徵       │           │ 物品特徵       │
│ - 年齡、性別   │           │ - 類別、標籤   │
│ - 歷史行為序列 │           │ - 描述 Embedding│
│ - 裝置、地區   │           │ - 上架時間     │
└──────┬────────┘           └──────┬────────┘
       │ DNN 編碼                  │ DNN 編碼
       ▼                          ▼
  用戶 Embedding(128 維)   物品 Embedding(128 維)
       │                          │
       └──── Inner Product ────────┘
                    │
                    ▼
              相關性分數 → 召回 Top-N

物品 Embedding 可以離線預計算並存入向量索引(如 FAISS、Milvus),線上只需計算用戶 Embedding 後做 ANN(Approximate Nearest Neighbor)查詢,延遲可控制在 10ms 以內。

6.5 多樣性重排——MMR 演算法

純相關性排序會導致推薦列表過於相似(Filter Bubble),MMR(Maximal Marginal Relevance) 透過懲罰與已選物品過於相似的候選來增加多樣性:

MMR(i) = λ × relevance(i) − (1-λ) × max_sim(i, selected)

λ = 0.7 → 偏向相關性
λ = 0.3 → 偏向多樣性

效果:推薦結果既與用戶偏好相關,又涵蓋不同品類,避免資訊繭房。


7. LeetCode 練習

以下是與推薦系統核心演算法相關的練習題目:

題目一:LeetCode 347 — Top K Frequent Elements

項目內容
難度Medium
連結LeetCode 347
核心找出陣列中出現頻率最高的 K 個元素
提示推薦系統的排序層本質上就是 Top-K 問題。可用 Min-Heap 或 Quickselect 實現,與本文中 partial_sort 取 Top-K 相似物品的邏輯一致

題目二:LeetCode 692 — Top K Frequent Words

項目內容
難度Medium
連結LeetCode 692
核心找出出現頻率最高的 K 個單詞,頻率相同時按字典序排列
提示對應推薦系統中「分數相同時的二次排序」策略——例如相似度相同的物品按新鮮度(上架時間)排序

題目三:LeetCode 706 — Design HashMap

項目內容
難度Easy
連結LeetCode 706
核心從零實作 HashMap
提示推薦系統大量使用 HashMap 儲存用戶-物品互動矩陣、相似度快取、Bloom Filter 的底層位元操作。理解雜湊碰撞處理是基礎

題目四:LeetCode 1851 — Minimum Interval to Include Each Query

項目內容
難度Hard
連結LeetCode 1851
核心對每個查詢找出包含它的最小區間
提示練習排序 + Priority Queue 的組合技巧,對應推薦系統中多路召回結果的合併排序(Merge-K-Sorted-Lists 思維)

練習建議:先完成 347(Top-K 是推薦系統的核心操作),再做 692 理解多維排序。706 鞏固雜湊表基礎,最後用 1851 練習複雜的排序 + 堆合併。這四題串起了推薦系統從相似度快取、Top-K 篩選到多路召回合併的完整知識鏈。


8. 總結

在這篇文章中,我們從需求分析出發,完整設計並實作了一套推薦系統:

  1. 相似度計算:Cosine Similarity 適合評分資料,Jaccard 適合隱式反饋,Pearson Correlation 可修正評分偏差。稀疏向量的雙指針技巧讓相似度計算效率提升至 O(min(|a|, |b|))。

  2. Item-Based 協同過濾:離線預計算物品相似度矩陣,線上根據用戶歷史物品加權聚合候選分數。相較於 User-Based CF,物品的行為模式更穩定、矩陣更新頻率更低,是電商推薦的主流選擇。

  3. Bloom Filter 去重:以每位用戶約 600 bytes 的空間(vs HashSet 的 4000 bytes),實現高效的已見物品去重,假陽性率控制在 1% 以內——在推薦場景中完全可以接受。

  4. 多階段漏斗:召回層(候選生成)、過濾層(去重 + 業務規則)、排序層(精排)、重排層(多樣性 MMR)——每一層都在精準度與效率之間取得平衡。

  5. 生產環境擴展:冷啟動策略、離線 + 即時雙通道架構、A/B 測試框架、Two-Tower 模型——這些是推薦系統從原型到大規模生產的必經之路。

推薦系統是協同過濾、相似度計算、Bloom Filter、排序演算法與系統架構設計的綜合應用。理解了這個系統的設計原理後,你不僅能在面試中從容應對推薦系統相關問題,還能在實際工作中設計高效能的個人化推薦引擎。

在下一篇文章中,我們將探討另一個實用的演算法應用——

設計文本差異比對(Text Diff)

,學習如何用 LCS 與 Myers 演算法實作類似 Git diff 的文本比對功能,敬請期待!


FAQ

Q: User-Based CF 和 Item-Based CF 該如何選擇?

選擇取決於系統的用戶數與物品數比例。User-Based CF 在用戶數遠少於物品數時效果較好,因為用戶相似度矩陣較小,計算成本可控,且推薦結果具有驚喜感(Serendipity)——會推薦你自己不曾探索的品類。Item-Based CF 則在物品數遠少於用戶數時更優(如電商場景),因為物品相似度矩陣相對穩定,可以離線預計算並存入快取,線上推理延遲極低。工業界主流(Amazon、Netflix 早期)多採用 Item-Based CF,因為物品的行為模式比用戶穩定,相似度矩陣不需頻繁更新。實際生產環境通常會將兩者作為不同的召回通道,在排序層統一融合。

Q: 推薦系統如何解決冷啟動問題?

冷啟動分為 新用戶冷啟動新物品冷啟動。新用戶冷啟動(無歷史行為)的常見策略包括:返回全站熱門物品、根據地理位置推薦當地熱門、新用戶引導問卷收集偏好、展示多樣化物品並根據點擊或跳過快速建立 Profile。新物品冷啟動(無互動數據)的策略包括:使用 Content-Based 方法,根據物品的標題、描述、類別等屬性計算 Embedding 相似度,找到相似的已有物品作為代理;給予新品固定曝光比例(如推薦列表中保留 5% 給新品);利用同一賣家或創作者的歷史物品表現作為預估。兩種冷啟動策略在工業實踐中通常會結合使用,並在用戶累積足夠互動後自動切換到協同過濾。

Q: Bloom Filter 在推薦系統中扮演什麼角色?為什麼不直接用 HashSet?

Bloom Filter 在推薦系統中主要用於 去重——確保不重複推薦用戶已經看過、購買過或明確跳過的物品。如果用 HashSet 儲存每位用戶已見的物品 ID,假設 1000 萬用戶平均各有 500 個已互動物品,需要 1000 萬 x 500 x 8 bytes = 40 GB 記憶體,這對 Redis 或應用程式記憶體都是巨大負擔。Bloom Filter 透過多個雜湊函數將元素映射到位元陣列,以極小的空間(每位用戶約 600 bytes vs HashSet 的 4000 bytes)實現高效的存在性查詢。代價是存在微小的假陽性率(如 1%),即偶爾會錯誤地過濾掉一個用戶其實沒見過的物品——但在推薦場景中這完全可以接受,因為少推薦一個物品不會影響用戶體驗,而省下的記憶體卻是數量級的差距。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →