HC

Project 01 / 08· Live

CineMatch

Personalized Recommendation System

Movie recommendations that improve with each rating, built as a two-stage retrieve-then-rank pipeline against real product tradeoffs (cold start, fallbacks, ranker downtime).

CineMatch

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.

CineMatch architecture
CineMatch architecture
  • Retrieval. Each movie is embedded with OpenAI text-embedding-3-small using 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):

ModelNDCG@10MRRHit Rate@10
Popularity baseline0.7160.8751.000
Vector retrieval only0.7980.9381.000
Two-stage, linear ranker0.7950.9501.000
Two-stage, LambdaMART0.8141.0001.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.