01
Problem
Movie recommendation systems that only use ratings drift into the same handful of popular titles for everyone. A pure content-similarity approach ranks by "closest embedding" and misses that users trust results more when a movie they already know is in the top three. And most side projects skip the operational bits (auth, rate limiting, graceful degradation) that decide whether a demo is trustworthy for real people.
CineMatch tries to hit all three: personalise beyond popularity, keep results legible, and stay usable when something in the stack is down.
02
System design
Two-stage retrieve-then-rank pipeline. The frontend calls a Go API on Cloud Run, which fans out to Supabase (Postgres + pgvector) for retrieval and a Python FastAPI ranker for re-scoring.
- Retrieval. Each movie is embedded with OpenAI
text-embedding-3-smallusing title, year, genres, and overview. Supabase pgvector runs a cosine kNN over an HNSW index and returns the 50 nearest neighbours to the user's taste vector. - Ranking. The Python service re-scores those 50 candidates with either a hand-tuned linear formula or a LightGBM LambdaMART model. Top 20 return to the frontend.
- Auth and data. Supabase magic-link auth. Direct browser reads use Supabase's PostgREST layer with row-level security on every table. The Go API holds the service-role key and is the only surface that writes across users.
Stage-2 re-ranking runs at about 0.9 ms p95, measured by eval/benchmark_latency.py.
03
Key decisions and tradeoffs
Retrieval store: Supabase pgvector instead of a dedicated vector DB. Managed Postgres with vector support means one datastore for user data, ratings, and embeddings. HNSW recall is competitive with dedicated vector stores at this scale (~10K titles). Trade-off: less flexibility on advanced index tuning than a dedicated engine.
Two-stage over single-stage. Vector-only retrieval hits NDCG@10 of 0.798 and MRR 0.938 alone. Adding a re-ranker moves NDCG@10 to 0.814 and MRR to 1.000. That is a modest lift on synthetic data. It buys much more on real data where user preferences aren't captured by a single embedding distance.
Learned ranker over linear. The linear ranker actually tied the LambdaMART on NDCG@10 (0.795 vs 0.814) but was easier to reason about. LambdaMART wins because the synthetic users have non-linear preferences (era, vote-average sweet spot, recency dependent on genre) that a fixed-weight formula cannot represent. Both are deployed and toggleable via a scoring profile flag.
Fallbacks over hard failures. If the ranker is unreachable, the Go API returns candidates in original similarity order. If Supabase is unreachable, an in-memory cache of popular movies keeps the site functional. Cold-start users (no ratings yet) get popularity-ranked recommendations until they interact.
04
Product decisions
Editorial framing over "AI-generated for you" language. The UI describes results as ranked, not predicted. This sets honest expectations and matches how people actually experience recommendations.
Explainable ordering. Each result shows contributing signals (genre overlap, similarity, quality) so users can tell when the system misreads their taste and correct with a follow-up rating.
Rate limits are per-endpoint, not global. Ten to thirty requests per minute depending on the route. Read paths are more generous than write paths.
05
Results
Offline evaluation on synthetic data (200 users across 8 taste profiles, 8,871 interactions total, with 40 users and 1,695 interactions held out for test):
| Model | NDCG@10 | MRR | Hit Rate@10 |
|---|---|---|---|
| Popularity baseline | 0.716 | 0.875 | 1.000 |
| Vector retrieval only | 0.798 | 0.938 | 1.000 |
| Two-stage, linear ranker | 0.795 | 0.950 | 1.000 |
| Two-stage, LambdaMART | 0.814 | 1.000 | 1.000 |
Numbers are simulated. LambdaMART leads NDCG@10 at +14 percent over popularity. Stage-2 re-rank runs at about 0.9 ms p95.
06
What I would improve next
- Real-user eval, not just synthetic. Instrument the deployed site with an A/B between LambdaMART and linear, log interactions, and rebuild the eval harness against that. Synthetic data validates the pipeline; real data validates the model.
- Explore for cold-start. Replace popularity fallback with an epsilon-greedy or Thompson Sampling loop so new users converge on their taste faster than five ratings.
- Cheaper embeddings. Fine-tune a smaller open embedding model on the synthetic pairwise preferences and A/B against
text-embedding-3-small. Would remove the per-title OpenAI API cost from the batch backfill. - Ranker service redundancy. Cloud Run scale-to-zero is fine most of the time but occasionally cold-starts. A second warmed instance behind the same URL would remove the sub-second stall on the first request after idle.
