Skip to content

feat: découverte communautaire améliorée avec recommandations MinHash #201

@podclaude

Description

@podclaude

Objectif

Améliorer les pages de découverte avec trending amélioré, recherche, et recommandations personnalisées basées sur collaborative filtering (MinHash/LSH).

Philosophie

  • Minimum d'infos stockées sur les podcasts (titre + feed URL uniquement)
  • Pointer vers les sources, pas les copier
  • Respecter le trafic des créateurs de podcasts
  • Privacy-first : recommandations basées sur abonnements publics uniquement

Partie 1 : Projection podcast_popularity

Table mise à jour par les events (Play, Subscribe, Unsubscribe).

Schema SQL

CREATE TABLE podcast_popularity (
  feed_url TEXT PRIMARY KEY,
  title TEXT,

  -- Stats d'activité (mises à jour par events)
  plays_8d INT DEFAULT 0,
  plays_32d INT DEFAULT 0,
  subscribers_count INT DEFAULT 0,
  first_subscribed_at TIMESTAMPTZ,

  -- Dates extraites du RSS (via cache)
  oldest_episode_at TIMESTAMPTZ,
  latest_episode_at TIMESTAMPTZ,
  second_latest_at TIMESTAMPTZ,
  third_latest_at TIMESTAMPTZ,

  -- Score précalculé
  popularity_score FLOAT,

  updated_at TIMESTAMPTZ
);

CREATE INDEX idx_popularity_score ON podcast_popularity(popularity_score DESC);

Projector

defmodule BaladosSyncProjections.PodcastPopularityProjector do
  use Commanded.Projections.Ecto

  project %PlayRecorded{} = event, _metadata, fn multi ->
    # 1. Fetch RSS via cache (async, non-blocking)
    # 2. Upsert podcast_popularity
    # 3. Increment plays
    # 4. Recalculate score
  end

  project %UserSubscribed{} = event, _metadata, fn multi ->
    # Similar flow + increment subscribers
  end

  project %UserUnsubscribed{} = event, _metadata, fn multi ->
    # Decrement subscribers + recalculate
  end
end

Formule de popularité (inspirée podCloud)

def calculate_score(stats) do
  base = stats.plays_32d / max(stats.subscribers_count + days_since(stats.latest_episode_at), 1)
  performance = (stats.plays_8d * 4) / max(stats.plays_32d, 1)
  regularity = compute_regularity(stats)
  age = days_since(stats.oldest_episode_at) |> max(1)

  base * f((performance * regularity) / age)
end

defp f(x), do: 0.01 + (x / 100) + (2 * x * x) / (1 + x * x)

Partie 2 : Fonctionnalités Découverte

1. Recherche par titre

  • Recherche dans podcast_popularity.title
  • Résultats triés par popularity_score
  • Route: GET /discover/search?q=...

2. Trending amélioré

  • Filtres par période : aujourd'hui / cette semaine / ce mois
  • Utilise plays_8d, plays_32d, popularity_score
  • Pagination
  • Route: GET /trending?period=week

3. Nouveaux podcasts populaires

  • Basé sur first_subscribed_at récent + popularity_score
  • "Découvert cette semaine" / "Découvert ce mois"
  • Route: GET /discover/new

Partie 3 : Recommandations MinHash/LSH

Principe

"Les utilisateurs qui ont des abonnements similaires aux tiens écoutent aussi..."

Pourquoi MinHash ?

  • Complexité O(n) vs O(n²) pour comparaison naïve
  • Scalable dès le départ (pas de refactoring futur)
  • Mémoire fixe par utilisateur (~512 bytes pour 128 hash)
  • Approximation de Jaccard acceptable (erreur < 5%)

Algorithme MinHash

  1. Générer K fonctions de hash (K=128, fixées au démarrage)
  2. Pour chaque utilisateur, calculer sa "signature" :
    • Pour chaque hash function, calculer min(hash(feed_url)) parmi ses abonnements
    • Résultat : vecteur de K valeurs (la signature)
  3. Similarité entre 2 users ≈ % de signatures identiques

Implémentation

# Hash functions (générées une fois, stockées en config/ETS)
@num_hashes 128
@prime 4_294_967_311  # Premier > 2^32

def generate_hash_functions do
  # Use seeded PRNG for reproducibility
  :rand.seed(:exsss, {42, 42, 42})
  for _ <- 1..@num_hashes do
    {Enum.random(1..0xFFFFFFFF), Enum.random(0..0xFFFFFFFF)}
  end
end

# Calcul signature utilisateur
def compute_signature([], _hash_fns), do: nil  # Skip users with no subscriptions
def compute_signature(feed_urls, hash_fns) do
  Enum.map(hash_fns, fn {a, b} ->
    feed_urls
    |> Enum.map(fn url ->
      url_hash = :erlang.phash2(url, 0xFFFFFFFF)
      rem(a * url_hash + b, @prime)
    end)
    |> Enum.min()
  end)
end

# Similarité entre signatures
def similarity(sig1, sig2) do
  Enum.zip(sig1, sig2)
  |> Enum.count(fn {a, b} -> a == b end)
  |> Kernel./(length(sig1))
end

# Stockage binaire (512 bytes par user)
def encode(sig), do: sig |> Enum.map(&<<&1::32>>) |> IO.iodata_to_binary()
def decode(bin), do: for <<val::32 <- bin>>, do: val

LSH Banding avec Index Inversé SQL

Au lieu de comparer toutes les paires, on indexe les signatures par bandes en DB.

CREATE TABLE user_band_buckets (
  user_id INT REFERENCES users(id),
  band_idx SMALLINT,      -- 0-63 (64 bandes de 2 valeurs)
  bucket_hash INT,        -- hash de la bande
  PRIMARY KEY (user_id, band_idx)
);

CREATE INDEX idx_bucket ON user_band_buckets(bucket_hash, band_idx);
CREATE INDEX idx_user ON user_band_buckets(user_id);  -- For cleanup/updates

Requête temps réel (< 50ms avec index)

SELECT other.user_id, COUNT(*) as shared_bands
FROM user_band_buckets mine
JOIN user_band_buckets other
  ON mine.bucket_hash = other.bucket_hash
  AND mine.band_idx = other.band_idx
WHERE mine.user_id = $1 AND other.user_id != $1
GROUP BY other.user_id
ORDER BY shared_bands DESC
LIMIT 20;

Estimation ressources (100k users × 500 abos max)

Métrique Valeur
Table user_band_buckets 6.4M lignes (~80 MB)
Index bucket_hash ~50 MB
Calcul initial signatures ~2 min (parallélisé)
Requête temps réel < 50ms

Partie 4 : Scoring des Recommandations

Deux types de recommandations

Type Description Score
Mainstream Podcasts populaires que tu ne connais pas neighbor_ratio × log(total_subscribers)
Pépite Podcasts niche avec forte affinité neighbor_ratio / log(total_subscribers)

Calcul des scores

def recommendation_scores(podcast, user_neighbors) do
  neighbor_ratio = neighbors_subscribed / total_neighbors
  total_subs = total_subscribers(podcast)

  %{
    mainstream: neighbor_ratio * :math.log(max(total_subs, 2)),
    niche: neighbor_ratio / :math.log(max(total_subs, 2))
  }
end

Freshness boost

def freshness_boost(podcast) do
  case days_since(podcast.first_subscribed_at) do
    d when d < 7 -> 1.5   # Découvert cette semaine
    d when d < 30 -> 1.2  # Découvert ce mois
    _ -> 1.0
  end
end

Affichage

📈 Populaires que tu pourrais aimer
   • Podcast A (1.2k abonnés, 8/20 voisins)
   • Podcast B (800 abonnés, 6/20 voisins)

💎 Pépites à découvrir
   • Podcast X (12 abonnés, 4/20 voisins) ← 33% affinité !
   • Podcast Y (28 abonnés, 3/20 voisins)

Partie 5 : Background Job

Workflow

┌─────────────────────────────────────────────────────────────┐
│  Job périodique (toutes les 6h ou quotidien)                │
├─────────────────────────────────────────────────────────────┤
│  1. Charger abonnements publics (stream par batch de 1000)  │
│  2. Calculer signature MinHash par utilisateur              │
│  3. Calculer bandes LSH et stocker dans user_band_buckets   │
│  4. Cleanup old entries for deleted users                   │
└─────────────────────────────────────────────────────────────┘

Elixir Implementation Notes

  • Use Task.async_stream/3 with max_concurrency: System.schedulers_online() * 2
  • Stream users in batches of 1000 to avoid loading all into memory
  • Use Oban for job scheduling with retry/timeout handling
  • Store hash functions in ETS (read-only, shared across processes)

Privacy

  • Abonnement public = opt-in implicite pour les recommandations
  • Ne jamais exposer "qui" a recommandé quoi
  • Résultats agrégés uniquement
  • GDPR : users can request deletion of their signatures

Acceptance Criteria

Découverte

  • Table podcast_popularity (projection)
  • Projector pour Play/Subscribe/Unsubscribe events
  • Formule de popularité implémentée
  • Recherche par titre fonctionnelle
  • Filtres temporels sur /trending
  • Page "Nouveaux podcasts" basée sur découverte récente

Recommandations

  • Module MinHash (hash functions, signatures, similarité)
  • Hash functions déterministes (seeded PRNG ou config)
  • Table user_band_buckets avec indexes
  • Job de précalcul des bandes (Oban)
  • Requête SQL optimisée pour trouver les voisins
  • Scoring mainstream vs pépite
  • Freshness boost pour nouveaux podcasts
  • Endpoint /recommendations avec les deux sections
  • Page web affichant les suggestions

Performance & Tests

  • Performance < 50ms pour requête recommandations
  • Performance < 200ms pour trending/search
  • Tests unitaires MinHash (propriétés Jaccard)
  • Tests du projector popularity
  • Tests de charge (simulation 10k+ users)

Priority

Medium-term (3-6 months)

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    discoveryDiscovery featuresenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions