Sift
A staged recommendation service over the Yelp Open Dataset. Given a user, it finds businesses they might like from every business in the metro.
Why I built it
I wanted to build a recommender as a complete system rather than stop after training a model in a notebook. Scoring every business with a detailed model takes too long, so Sift became an exercise in deciding what each stage should do, how to evaluate it, and how much time it was allowed to take.
The result is a small API backed by a retrieval, ranking, and reranking pipeline, along with the offline machinery needed to produce its training data and serving features correctly.
How it works
A request starts with 14,568 businesses in the metro. Exact ALS retrieval keeps 500 candidates, LightGBM reorders all 500 using user and business features, and a final reranking stage removes closed or already-reviewed businesses before applying a category-diversity limit.
The ranking stage deliberately keeps the full pool. An earlier version cut to 50 before filtering, which meant a valid request for 50 recommendations sometimes returned only 33–40. Letting reranking see all 500 means it can return exactly k whenever enough eligible candidates exist.
The part I cared about most
The hardest problem was making the features honest. Every feature on a training row must be computed only from events that happened before that row's timestamp. Sift enforces this through one as-of read path and includes a test that deliberately attempts to leak future information and must fail.
The same feature definitions are materialized in two forms: historical values in Parquet for training and current values in Redis for serving. This keeps the training and serving implementations from quietly drifting apart.
What surprised me
Filtering businesses that a user had already reviewed lowered recall@10 by roughly 42%. Looking closer made the result more useful, not less: 949 of the ranker's 2,579 top-10 hits were places the user had already reviewed, even though those places were only about 1.3% of the candidate pool.
More than a third of the apparent success was predicting return visits rather than helping someone discover somewhere new.
I kept the filter and documented the metric regression deliberately. It was a good reminder that a higher offline number is only valuable when it measures the behavior the product actually wants.
What I learned
Several ideas that looked better on paper did not survive measurement. Exact vector search was already lossless at about 1 ms p99 for this catalog, so HNSW added complexity without a useful speedup. A two-tower model did not beat ALS at its evaluation gate. On AWS, adding a second Uvicorn worker made latency worse; limiting an uncounted OpenBLAS thread pool mattered more.
The deployed service reached a warm server-side p99 of 95.41 ms at 20 requests per second with persistent connections. I tore the AWS deployment down after measuring it on August 1, 2026 to keep the showcase's cost bounded. The repository retains the Docker and Terraform setup, as well as the full measurement context.