August 8, 202612 min read

siphon: i built the tiktok algo to figure out why i cant stop scrolling

  • machine-learning
  • recommendation-systems
  • distributed-systems
  • go
  • python

we've all been there. 2am, deep in a doomscrolling session, when out of nowhere, some part of your brain goes "damn, why is this so addicting?", and then, even being fully aware of the trap youre in, you check the time and... wtf? its 5am somehow. lmao

being the kind of person i am, i genuinely wanted an answer to that. none of that youtube guru "dopamine bad 👎" kinda answer. the actual logic and mechanism behind it. so i built a small and honest version of it and saw it come to life

heres what it does on a normal run with nobody touching it:

training run v1   AUC 0.68   -> promoted
training run v2   AUC 0.71   -> promoted
training run v3   AUC 0.73   -> promoted   (gate = 0.65)

32,000+ interactions | ~13ms per feed | 0 humans involved

that AUC number is the thing getting better. its the model getting more precise about what youd wanna watch next, and it climbed there on its own while i was not even around. that loop, the one that slowly gets better at guessing you is the whole reason youre still awake at 5am.

lets dive into every part of it :)


the one job

if we strip everything away, the app has one really simple job:

to guess what you would absolutely love to watch next, out of literally millions of videos, in the time it takes a single video to load

thats it. thats the whole game. and when you say it out loud, you immediately see the two problems:

  1. millions of videos. you cannot score all of them. theres barely any time for that, cuz if you do, the next videos already loading
  2. "you would love." it has to know you, specifically. better than your anyone does apparently

every one of these systems solves it the same way, in three moves. im gonna spend the rest of this writeup on those three, because once you see them, the whole thing stops feeling like magic and actually starts to make sense:

retrieve a few hundred plausible videos out of millions. rank those into the perfect order. learn from whatever you did about them. and then do it again, just a little smarter than last time. that dotted line at the bottom, the "next time, better" one, thats the addictive part. we'll get there in a bit

i named my implementation siphon, because thats what it does to you and your lovely day


how it knows youll like a video its never even shown you

now you might be wondering: how does it know youd like a video youve never even seen? it wasnt there, so it obviously cant have watched your reaction to it. how then?

the key here is that it doesnt think about videos as videos, or of you as you. it turns both into coordinates

imagine a map of "taste". smth like an abstract space where things that go together sit close together. cat memes over here, gym vids over there, all the crypto junkies in a corner no one likes. every video gets a point on this map based on whats in it. and every person gets a point too based on whatever it is theyve watched

if your point and a video's point are close together on the taste map, thats a match. and that is literally the whole idea. finding a good video becomes "find the points near me"

the machine that does this is called a two-tower model, and its exactly as it sounds. two separate little neural networks (towers). one turns a user into a point. one turns a video into a point. they never interact with each other. their job is to just each spit out coordinates and you check how close those coordinates are:

class TwoTower(nn.Module):
    def user_embed(self, user_feat):
        return F.normalize(self._user_tower(user_feat), dim=-1)   # you -> a point

    def item_embed(self, item_feat):
        return F.normalize(self._item_tower(item_feat), dim=-1)   # a video -> a point

    def forward(self, user_feat, item_feat):
        u = self.user_embed(user_feat)
        v = self.item_embed(item_feat)
        return (u * v).sum(dim=-1)   # how close are the two points? thats the score

that last line, (u * v).sum(), is a dot product. its the "how close are these two points" math. the higher the number, the better a match. thats the entire scoring engine of a recommender. see how it all fits on just one line?

now, the reason its two separate towers is the somewhat clever part of all this. because the video tower doesnt need you, you can compute every video's point once, ahead of time, and file them all away. then when you open the app, you only compute your point (which is like one small calculation) and go "who's near me?" against the pre-filed pile

that "whos near me in a giant pile of points" lookup is a solved problem (its called approximate nearest-neighbor or ANN search; siphon uses a vector database called Qdrant for it). thats how it searches through millions in a matter of milliseconds. because really, it isnt searching millions. it just did that stuff before the work actually called for it

youre the white dot. drag yourself around taste space, and the videos the model retrieves for you light up.

catcatcatgymgymgymcryptocryptocryptocookingcookingmusicmusiccodingcoding

retrieved: coding, gym, coding, gym. that's it. no search through the other 11. the model just scored everything by alignment and kept the top 4.

this is also google's actual published design for youtube retrieval (Yi et al., 2019) btw. two towers, dot product, precompute one side, and allat. not smth i made up, just the mini version of the real thing. i actually dropped a full breakdown of the two-tower model on X based on youtube's paper, so if you wanna go a layer deeper than this, thats your rabbit hole. its a quick read too :)

one thing to point out tho: in my build, theres a gap here. my feed service doesnt actually hand the video's coordinates back to my simulated users, so their "taste" doesnt fully steer their watching properly the way it would in the real product. the logic and algorithm is all correct and properly wired up, but theres just one wire i left dangling

ill point out every one of these as we go, because a deep dive that pretends everything is perfect is really just an advert


why the third video is always the one that gets you

retrieval hands you ~100 decent candidates. but "decent, in no particular order" is not what you feel when you doomscroll. what you feel is pacing, that feeling that the order is right. the whole "it knows what i NEED". thats the ranking part, where the retrieved pile gets sorted into the sequence that keeps your thumb at work

the ~100 candidates go through a little funnel before one of them ends up at the top of your feed:

i kept mine simple and legible on purpose. three cheap passes, and you can read every one:

  • safety filter: drop anything thats flagged.
  • freshness decay: old videos get their score bled out so your feed doesnt feel like "ive already seen ts 20 times". all because of this one line: score *= exp(-0.1 * age), once a video is over two days old
  • diversity: never play three videos from the same creator back to back, even if theyd all score well. variety keeps the surprise alive, and surprise is what keeps you stuck to the screen
// freshness: old videos lose score fast
if ageHours > _freshnessHalfLifeHours {
    c.Score *= math.Exp(-0.1 * ageHours)
}
// diversity: never 3 in a row from the same creator
if result[n-1].CreatorID != c.CreatorID || result[n-2].CreatorID != c.CreatorID {
    result = append(result, c)
}

the reason to split this into these little stages instead of one giant smartass model is boring but correct: each stage is cheap, and when the feed feels off, you can actually point out which stage did it. one big black box that just "feels right" works, but at the time of debugging, youll be the one crying. dont let the future you deal with that pain

then, the survivors get their final, most expensive score from the real model, and the best one goes to the top of your feed. thats the video you didnt ask for but somehow needed


the part thats actually alive

heres the part that makes it feel less like a program and more like a thing that really knows you

every single time you watch or skip a video, thats data for the machine. a skip isnt "nothing". for the model, a skip is seen as "not this", and a watch is "yep, more of this". all of it streams off into the system (through Kafka, if you care about the plumbing), piles up, and every so often a trainer wakes up, grabs the last 50k of those little yes/no lessons, and retrains the model on them

and even tho it's just some lines of code at the end of the day, it is very much alive. actively learning, updating over and over and over, getting a little sharper each time, until its a genuine master of pattern-matching you specifically.

but heres the one rule that makes the whole thing trustworthy. the new model doesnt just get to take over. it has to prove its better first:

if auc < _AUC_THRESHOLD:          # _AUC_THRESHOLD = 0.65
    print("below threshold, not promoting")
    return current_version        # old model keeps its job

new_version = current_version + 1  # only now does the new one go live

AUC is just a score for "how good is this model at telling a video you would watch apart from one you would skip." 0.5 is like a coin flip. 1.0 would literally be a mind reader. the gate says: you dont go live unless you clear 0.65, so the model can only ever get better or stay put. it literally cant get dumber, because a dumber one never goes live. thats the flywheel, and its exactly why the feed feels like its slowly closing in on you. because it is. and its not allowed to move backwards while it does either

two honest confessions from this exact section

one: writing this post, i went to explain the training and opened my own README, which confidently said the trainer uses something called "BPR" (a ranking loss). spoiler alert: it does not. it uses plain binary cross entropy: "was this a watch: yes or no." heres what actually happened:

i had planned to use BPR early on, it was in my design docs and everything. but partway thru the project i realized it just wasnt needed, and that it was gonna add to my pile of struggles for no real reason, so i just switched to BCE. the thing is, i never formally wrote that switch down anywhere

so when i later told my LLM to draft the README, it picked up the plan and the architecture from those old docs and wrote exactly what they said: BPR. and me being me, i skimmed the README and shipped it without properly reading it. pretty stupid of me

the README's fixed, and now the docs actually match the code. turns out teaching something is the fastest way to find out where you were lying to yourself

two: the thing that keeps train-time and serve-time honest. a classic way these systems rot is that the model trains on one description of a video and then gets served a slightly different one. this is often called as the "train/serve skew" and it makes your model slowly become stupider

siphon dodges it by making both sides read the video's coordinates from the exact same place (i.e. a 256 dimensional vector in Redis). the model cant train on a version of the video that isnt the one itll actually see


okay but how did you test this with no users

valid question. and id say its pretty reasonable to ask how you test "does it learn from human behavior" when you are just one dude with a laptop

simple. 500 bots. because lets be honest man, i dont have the reach or the engagement to round up 500 actual humans to sit and scroll my test app. that wouldve never happened

so i faked them. 500 little async programs, each with their own randomly assigned taste, that hit the real feed, watch or skip based on their personality, and drift over time the way real interest does. real enough to turn the flywheel and watch it learn

(but hey, if any of you are down, id genuinely love to build a proper UI with real videos and let actual people test this thing out. 500 real humans beats 500 bots every time, and that'd be one hell of a test)

another story from the swarm, because this was my favorite bug of the whole project

mid run, the live feed just froze randomly. stuck at 25000 interactions, no new events, and meanwhile the container said it was healthy. as it turned out, i had run all 500 bots under one asyncio.gather, and gather has a personality trait: if one task throws, it cancels all its siblings and exits. so a single bot choking on one weird video literally executed the rest of 499 of its friends, and then the whole process exited. with pure silence

# the fix: one bot's bad day cant murder the swarm anymore
try:
    await run_bot_tick(...)
except Exception:
    log.warning("bot %s hit a bad tick, backing off", user_id)
    await asyncio.sleep(1)   # keep scrolling

the lesson generalizes way past bots: gather is great for "do five things then stop". and this is exactly why its wrong for a swarm meant to run forever. things that live forever need every member to survive their own "bad day"

and the payoff to all of it: theres a single command, make quality-gate, that just asserts the latest trained model beat the bar. when that passes, its a testament to the fact that the loop turned, the model learned, and that it earned its promotion. the flywheel is real


no, i did not build tiktok

let me make one thing clear, because id hate to oversell this like ive done in the past:

no. i did not just build tiktok's entire algorithm. thats a thing an army of engineers took years and unbelievable amounts of money to build. i built this in days. obviously it is not the same thing. here are the core differences, in the simplest way i can put them:

  • the real one asks a lot more than just "did you watch". tiktok's ranking model predicts a whole bouquet at once (will you like it, share it, follow them, rewatch it) and blends all of that into one number. mine asks exactly one question: did you watch it or not. that multi-signal blending is most of the actual addictiveness, and its the main thing i didnt build
  • my users are bots. the signals come from simulated taste, not real human beings with real emotions and real problems
  • its just one box. the real thing is a planet-spanning fleet. this runs on my machine under one docker compose up

but what is real tho, and what i actually wanted to prove, is that the full flywheel runs, unattended. content goes in, (fake) people watch, their behavior streams back, the model retrains itself, has to pass the gate, gets promoted if its genuinely better, and the next feed reflects the smarter model. mind you, no human in the loop. it just gets better at guessing on its own

that loop (retrieve, rank, learn, repeat, and only ever move forward) is the honest and the shortest answer to my question. at the end of the day, there is no trick. its just a pretty simple machine pointed at you, allowed to improve and not allowed to regress, running a few thousand times a second. and clearly that turns out to be more than enough to keep you up until 5am


thankyou for reading ^^

repo: github.com/bit2swaz/siphon