ReFeed
Five and a half months on one consumer product
A mobile app that re-ranks the YouTube feed around what you actually came to do — semantic retrieval over the cards on screen, with clickbait penalised and habit rewarded.
- Development span
- 5.5 months
- Nov 2025 – May 2026, 32 commits
- Application code
- 8,506 lines
- TypeScript, TSX and Python, excluding dependencies
- Ranking signals combined
- 5
- semantic, popularity, freshness, habit, clickbait
- Payment rails
- 2
- Stripe webhooks and Apple in-app purchase receipt validation
Figures with a dotted underline carry their source. Hover one to see whether it was verified in code or reported in project documentation.
Context
The situation
A feed is ranked to maximise time spent. That objective is legitimate for the platform and frequently wrong for the person watching, who came to learn one specific thing and leaves forty minutes later having learned something else.
The problem
The usual responses are blunt: block the site, or install an extension that hides things. Both treat the feed as an enemy rather than as a ranked list with the wrong objective function. The interesting question is whether the same cards, reordered around a stated intent, become useful.
What I built
Do not replace the feed — re-rank it. A React Native WebView loads YouTube and injects a script that extracts the rendered cards and posts them over the bridge. The backend embeds each card through a dedicated embedding service, indexes it in Qdrant, and scores it against the session intent. A second injected script writes the new order back into the DOM.
A recommendation feed is optimised for time spent, which is not the same thing as being useful to the person watching. ReFeed does not build a new platform or block anything: it reads the cards YouTube has already rendered, embeds them, retrieves against a stated intent, and reorders the feed in place. Ranking is a weighted combination of semantic similarity, popularity on a log scale, recency and how often a card has been seen before, with a clickbait score subtracted. It was the longest-running thing I have built — five and a half months of iteration on onboarding, session flow, the paywall and the ranker — and it carries a real subscription path on both Stripe and Apple in-app purchase.
Architecture
How it fits together
- YouTube feedinput
rendered in a WebView
- Injected scriptdeterministic
extracts cards, posts over the RN bridge
- Embedding servicelanguage model
separate HTTP boundary
- Qdrantdeterministic
vector index over the cards
- Hybrid rankerdeterministic
semantic + popularity + freshness + habit − clickbait
- User profiledeterministic
vector centroid with recency decay
- Reorder scriptoutput
writes the new order back into the DOM
Key decisions
The choices that shaped it
- 01
Re-rank the feed rather than replace it
Building a separate content app means solving discovery, catalogue and playback from scratch, and nobody switches. Reordering the feed already on screen means the product is one WebView and two injected scripts — and it inherits everything the platform is good at while changing only the objective the cards are sorted by.
frontend/scripts/inject.js
- 02
A weighted sum, with each term doing one job
The final score is 4× semantic similarity, plus 2× popularity on a log scale, plus 1.5× a recency bucket, plus 1× how often the card has been seen, minus 2× the clickbait score. Semantic fit dominates by design; popularity is logged so a million-view video does not simply win; and the habit term is there because a channel you return to is a weak but real signal of intent.
backend/hybrid_ranker.py
- 03
Two ways to penalise clickbait, and they behave differently
The ranker subtracts the clickbait score. The scoring path multiplies instead — semantic × (1 − 0.4 × clickbait) — so a card with no semantic fit cannot be rescued by being honest, and a strong match is discounted proportionally rather than pushed to the bottom. The multiplicative form is the better-behaved of the two.
backend/scoring.py
- 04
Calibrate to something a person can read
Raw scores in [0,1] mean nothing on screen. A linear calibration maps them to 0–100 with a stated slope and offset, clamped at both ends, so the number shown to the user is stable across sessions rather than relative to whatever else was in the feed that day.
backend/calibration.py
- 05
The user profile is a decayed centroid, not a history
The profile is built from the vectors of cards already indexed, weighted down by how long ago they were last seen. It needs no separate feature store and no training step, and it forgets on its own — which for an intent-driven product is the correct default.
backend/user_model.py
- 06
Benchmark the two halves separately
The benchmark times the query embedding and the vector search independently across repeated runs and reports percentiles. Ranking latency is felt directly — the feed visibly reorders — and knowing which half is slow is the difference between optimising the model and optimising the index.
backend/bench.py
- 07
Both payment rails, because mobile forces it
A Stripe webhook promotes an account on subscription events, verifying the signature when the endpoint secret is configured. Apple in-app purchases are validated server-side against Apple’s production endpoint with the sandbox fallback, because a digital subscription sold inside an iOS app cannot go through Stripe.
backend/payments.py · backend/app_store.py
Evidence
What it actually looked like

State an intent, and the feed is reordered in place. The interface is in French; the ranking is the same either way.
InterfaceMy contribution
Sole author — mobile app, injection layer, backend, ranker and both payment paths.
What I owned
- React Native app with an injected-script bridge into a YouTube WebView
- DOM extraction and in-place reordering of the rendered feed
- A dedicated embedding service behind an HTTP boundary, and Qdrant indexing
- The hybrid ranker, the clickbait scorer and the score calibration
- User profile modelling from Qdrant vectors with recency decay
- Benchmark tooling measuring embedding and retrieval latency separately
- Subscription handling on both Stripe webhooks and Apple in-app purchase receipts
- Migration of the user store and the vector store from local containers to hosted services
Stack and limits
Built with
- React Native
- Expo
- TypeScript
- WebView injection
- Python
- FastAPI
- Qdrant
- NumPy
- Docker
- Stripe
- Apple StoreKit
Techniques
- Semantic retrieval over a live DOM
- Hybrid ranking with weighted signal combination
- Score calibration
- Vector-centroid user profiling with recency decay
- Latency benchmarking split by pipeline stage
What it is not
- No usage, revenue or user figures are claimed. The payment paths are implemented and were exercised in development; nothing in the repository evidences customers, and I am not going to imply any.
- The ranker depends on YouTube’s rendered DOM. A markup change breaks extraction, which is the structural fragility of the whole approach.
- The clickbait scorer is a hand-written French lexicon, not a learned classifier. It catches the obvious cases and nothing subtle.
- The weights in the ranker were tuned by feel against my own feed. There is no held-out evaluation and no A/B comparison against the original ordering.
- The benchmark measures latency, not ranking quality. Whether the reordering is actually better is not something the repository proves.
What carried forward
The part that transferred was not the ranker. It was learning that a scoring function nobody can read is a scoring function nobody trusts — which is why the score is calibrated to a stable 0–100 rather than shown raw, and why every later system I built makes its numbers traceable to something a person can check.
Next
July 2026
Obligation Ledger
The model proposes a termination date. The service throws it away and recomputes it from verified quotes. Nothing is approvable until every value has been found on its page.
Read case study→July – August 2026
Omnivision
Assistive tools describe scenes but carry no memory, and cannot tell recognising something from being confident about it. I built the part that refuses.
Read case study→