← Projects

Tidepool

A streaming recommender that ranks Bluesky's live post stream into a personal feed of recruiting posts.

85ms
serving latency, p99
<2s
feature freshness, p95
19 min
nightly training, down from 48

Solo project

Python, Kafka, Flink, FAISS, PyTorch, Spark, Delta Lake, Airflow, AT Protocol

What it is

During recruiting season, the useful posts (new grad and intern postings, referral offers, notes on interview processes) were buried in noise. Tidepool reads Bluesky's live stream of public posts and ranks it into a personal feed of recruiting posts worth reading. I built it for my own internship search.

Bluesky suited this for two reasons. Its whole network of public posts is streamed openly (the "firehose"), so there's real, high-volume data to work with. And anyone can publish a custom feed: a small service returns a ranked list of post IDs, and it shows up in the Bluesky app like any other feed. Tidepool appears in my app as a feed, and everything behind it is my own pipeline.

Architecture

Tidepool architecture
Tidepool architectureBluesky's public firehose, about 2,500 events a second, flows into Kafka. A Flink job filters each post, embeds it and computes features, writing embeddings to a FAISS index and features to a feature store. Kafka also archives every post to Delta Lake. When the Bluesky app asks for my feed, the feed server pulls the 200 most similar recent posts from FAISS and has a PyTorch ranker score them with features from the feature store. Every night an Airflow pipeline builds a training set from Delta Lake with Spark, retrains the ranker, and promotes the new model only if it beats the current one.Bluesky firehose~2,500 events/sKafkaraw postsFlink jobembed, featuresFAISSrecent postsFeature storeDelta Lakepost historyFeed serverPythonmodelRankerPyTorch · 200 candidatesp99=85msBluesky appmy feedNightly retrainAirflow: Spark, train, evaluate123archiveget feed4top 200 by similarity56featurestraining datanew model
  • client
  • service
  • database or storage
  • queue or stream
  • model
  • scheduled job
  • external
  • request
  • async: event, message, webhook
  1. An ingest worker reads Bluesky's firehose, about 2,500 events a second.
  2. Posts land in Kafka, which absorbs bursts and lets consumers restart without losing anything.
  3. A Flink job filters, embeds and scores each post, writing embeddings to FAISS and features to the feature store.
  4. The Bluesky app asks the feed server for my feed.
  5. The server pulls the 200 most similar recent posts from FAISS.
  6. The ranker scores them, using features from the feature store, and the best come back as my feed.

There are three paths, each with its own time budget. Designing them separately kept each one simple:

PathJobBudget
Streaming (Kafka, Flink)Turn raw posts into features and embeddingsSeconds: p95 under 2s
Serving (FAISS, ranker)Answer "what should I see now?"Milliseconds: 85ms p99
Batch (Spark, Airflow)Retrain the ranker on historyMinutes, once a night
Three paths, three budgets.

From firehose to features

Kafka sits between the firehose and everything else. The firehose is bursty and external, and if the Flink job is redeployed, posts wait in Kafka instead of being dropped.

For each post, the Flink job drops non-English posts and obvious junk, computes a text embedding, and extracts features: keywords like "intern", "new grad" and "referral", company mentions, links to job boards, and the author's posting history. A small spam classifier in the stream removes engagement bait and repost spam before it reaches the index.

Freshness is the time from a post appearing on the firehose to its features being ready for ranking: p95 under 2 seconds. A recruiting post is most useful in its first few hours, so fresh matters more than complete.

Retrieve, then rank

Like most production recommenders, serving has two stages. A cheap one narrows thousands of recent posts to 200, and an expensive one orders those 200 carefully. Ranking every recent post on each request would blow the latency budget.

Serving one feed request
Serving one feed requestThe Bluesky app asks the feed server for my feed. The server pulls the 200 nearest posts to my interest vector from FAISS, fetches their features in one batch, and scores all 200 in one forward pass of the ranker. It sorts the results, keeps one post per author, blends in recency, and returns the ranked list.Bluesky appFeed serverFAISSFeature storeRankerget my feed1top 200 similar2200 post IDsfeatures for all 2003score all 200 in one pass4scoressort, dedupe authors5ranked posts85ms p99 end to end
  • client
  • service
  • database or storage
  • model
  • request
  1. The Bluesky app asks for my feed.
  2. FAISS returns the 200 posts nearest to a vector that represents my interests, in a few milliseconds.
  3. The server fetches features for all 200 in one batch.
  4. A small PyTorch ranker scores all 200 in one batched forward pass, using the embedding plus keyword, author and recency features.
  5. The server sorts the results, keeps one post per author so a prolific account can't flood the feed, and blends in recency.

Nightly retraining

Delta Lake stores the history of posts and features as versioned tables, so each night's training set can be rebuilt exactly. Spark joins posts with labels: the ones I engaged with or marked relevant, and the ones I scrolled past. Airflow runs the steps in order (build the data, train, evaluate) and promotes the new model only if it beats the current one on a held-out set, so one bad night of data can't quietly make the feed worse.

The first version of the nightly run took 48 minutes. Most of that was Spark rereading the full history and shuffling large joins. Partitioning the Delta tables by date, processing only new partitions, and tightening the joins brought it to 19 minutes.

Key decisions

DecisionInstead ofWhy
Kafka in front of processingReading the firehose directlyAbsorbs bursts; consumers restart without losing posts
Flink for featuresHourly batch featuresRecruiting posts are time-sensitive
Retrieve, then rankScoring every recent postStays inside the latency budget
One batched forward passScoring posts one at a time200 candidates in one pass
Promote only if betterAlways deploy the latest modelA bad night can't degrade the feed
Incremental, date-partitioned Spark jobsRecomputing everything nightly48 → 19 minutes
The main choices, and what they replaced.

Results

firehose
~2,500 events/s
feature freshness
p95 under 2s
candidates scored
200 per request
serving latency
85ms p99
nightly training
48 → 19 min