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
- Générer K fonctions de hash (K=128, fixées au démarrage)
- 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)
- 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
Recommandations
Performance & Tests
Priority
Medium-term (3-6 months)
Related Issues
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
Partie 1 : Projection
podcast_popularityTable mise à jour par les events (Play, Subscribe, Unsubscribe).
Schema SQL
Projector
Formule de popularité (inspirée podCloud)
Partie 2 : Fonctionnalités Découverte
1. Recherche par titre
podcast_popularity.titlepopularity_scoreGET /discover/search?q=...2. Trending amélioré
plays_8d,plays_32d,popularity_scoreGET /trending?period=week3. Nouveaux podcasts populaires
first_subscribed_atrécent +popularity_scoreGET /discover/newPartie 3 : Recommandations MinHash/LSH
Principe
"Les utilisateurs qui ont des abonnements similaires aux tiens écoutent aussi..."
Pourquoi MinHash ?
Algorithme MinHash
Implémentation
LSH Banding avec Index Inversé SQL
Au lieu de comparer toutes les paires, on indexe les signatures par bandes en DB.
Requête temps réel (< 50ms avec index)
Estimation ressources (100k users × 500 abos max)
user_band_bucketsbucket_hashPartie 4 : Scoring des Recommandations
Deux types de recommandations
neighbor_ratio × log(total_subscribers)neighbor_ratio / log(total_subscribers)Calcul des scores
Freshness boost
Affichage
Partie 5 : Background Job
Workflow
Elixir Implementation Notes
Task.async_stream/3withmax_concurrency: System.schedulers_online() * 2Privacy
Acceptance Criteria
Découverte
podcast_popularity(projection)Recommandations
user_band_bucketsavec indexes/recommendationsavec les deux sectionsPerformance & Tests
Priority
Medium-term (3-6 months)
Related Issues