Docs / feed-serving.md · mirrored from the repo
Namaste Ji — Feed Serving & Personalization
Status: design. Researched + agreed direction (2026-07-18); the engine (
packages/feed) is built and Lab-proven, the serving wiring is not. Living doc; decisions carryFS-*IDs.This doc owns how the feed is actually served: the three-loop architecture, per-user slate precompute + buffering, live in-session adaptation, time-bound content injection, the user representation (multi-interest vectors + negatives), and the offline LLM “slow brain”. It operationalizes DISTRIBUTION.md (
DS-*— the why and the objective) using the engine specified in console/FEED-LAB.md (FL-*), the client design in FRONTEND.md (FE-*), and the signal pipeline in EVENTS-AND-ANALYTICS.md (EV-*). Read those first; this doc does not restate them.
1. Research verdicts (what the literature says about our hypothesis)
The founding hypothesis — represent users as vectors over the shared multimodal embedding space, use negatives, and let offline LLMs prepare buffered per-user batches — is validated by 2023–2026 research and production art, with two corrections:
- Multi-interest, not one vector (FS-1). A single averaged taste vector lands
in a meaningless midpoint between distinct interests (PinnerSage,
arXiv:2007.03634; off-manifold drift formalized
in RecSys’23 10.1145/3604915.3608837).
Fix: cluster engaged items over our frozen Cohere embeddings; represent each
cluster by its medoid (a real item embedding — stays on-manifold); weight by
importance = Σ w_action · ξ^Δdays(ξ≈0.9). Decay lives on importance weights, never baked into the vector (Okura et al., KDD’17: decayed vector averaging is worse than plain averaging). Our catalog’s low intrinsic dimensionality (deity × occasion × language × style) ⇒ 2–6 clusters per user, cap 5, merge below the ~0.85 within-vein similarity floor found by Atlas. - Negatives are penalties, never query arithmetic (FS-2).
query = pos − α·negretrieves noise: multimodal/CLIP-family spaces cannot represent negation (arXiv:2501.09425); Rocchio’s negative term has been known-weak for decades. What works (Qdrantbest_scorepattern): 1–3 negative centroids from confident negatives only (fast skip after a ≥50%-visible impression, explicit hide — never mere non-engagement), applied at rank time asscore −= γ · max_j sim(item, neg_j)(γ≈0.3, policy-tunable) plus a hard filter atsim > θ≈0.9. Negative centroids expire after 30 days.
The strongest-evidence component is the cross-modal probe trick (FS-3): LLM-written affirmative text queries constrained to our attribute vocabulary, embedded as retrieval vectors — Amazon GPT4Rec (arXiv:2304.03879, +75% Recall@K) and Google’s deployed interest-exploration system (RecSys’24, arXiv:2405.16363, controlled-vocabulary generation). Because the space is multimodal, the same text probe retrieves images directly. Probes must be affirmative — never “Ganesha but not dark tones”.
The LLM-buffered-batches idea decomposes into four individually-validated offline patterns: NL user profiles (Meta EmbSum arXiv:2405.11441, Google interest journeys arXiv:2305.15498), incremental profile updates (PURE), constrained probe generation (above), and listwise slate re-ranking (RankZephyr-class: a small model matches GPT-4 at this). See §7.
2. The three-loop architecture
At our scale the industry’s four-stage funnel collapses: retrieval + pre-ranking
exist to amortize model cost over billion-item corpora; with a small first-party
catalog, precomputed embeddings, and a deterministic rank(), the online path is
filter + deterministic re-rank, everything intelligent in precompute (FS-4).
This is also the only shape that fits Workers CPU budgets and Vectorize’s
eventual-consistency model — Vectorize never sits on the hot path (FS-5;
extends FL-4/FL-15: the D1 embedding copy + in-Worker store serve retrieval until
eligible pools exceed ~50k, then Vectorize becomes the offline/near-line pre-filter).
Structural gift: no follow graph ⇒ no celebrity fan-out problem ⇒ pure push (precomputed per-user slates) is safe, costing O(DAU), not O(publishes) (FS-6).
LOOP 1 · offline brain nightly + festival-window triggers
global pools per (language × festival-window × day-part):
evergreen · festival/campaign (ramp→takeover→hard-cut) · exploration
(impression-capped, Beta priors seeded from embedding neighbours)
+ ALL LLM work (§7) → KV pools, D1 stats/priors
LOOP 2 · per-user slate precompute pre-dawn (staggered) + on session end
sample 2–3 interest clusters (importance × calendar relevance)
→ query per medoid + probe vectors → ∪ context/fresh/rare pools
→ seen-set filter → rank() with slot quotas
→ versioned slate (50–100 ids + light metadata) → KV feed:{user}:{v}
LOOP 3 · online serving Workers; zero model calls, zero Vectorize
first paint from KV slate head (or overnight pre-positioned page)
client: ~20–30-item metadata buffer, prefetch only next 2–3 images,
refill at ≤5 unseen; in-session re-rank on-device (same rank()) +
server re-rank at refill; slate versioning = freshness handoff
3. User representation — UserState v2
Extends packages/feed UserState (v1 fields remain for compatibility; taste stays
as the cold-start/fallback vector). New fields (FS-7):
| Field | Spec |
|---|---|
interest_clusters | ≤5 × { medoid_content_id, vector (f32), importance, attributes {deity?, occasion?, language?, style?}, last_engaged_at } — agglomerative clustering (cosine, merge threshold ≈0.85) over last-90-day engaged items; importance = Σ w·ξ^Δdays, ξ=0.9, weights: share_intent=5 · save=3 · like/open=2 · long-dwell=1 |
negative_centroids | ≤3 × { vector, n, updated_at } — confident negatives only; 30-day expiry |
probe_vectors | ≤8 × { vector, text, kind: 'interest'|'exploration', generated_at } — from the slow brain (§7) |
nl_profile_ref | pointer to the current NL profile artifact (D1) |
Rebuilt by the Loop-2 Workflow from the event stream (EV); in-session, v1’s
applyEvents nudges remain the transient layer on top. Cluster sampling at retrieval:
2–3 clusters ∝ importance × calendar_relevance (festival proximity multiplies the
matching cluster — the calendar is this product’s sequence signal).
4. Ranking & policy — FeedPolicy v2
rank()’s additive shape survives unchanged (FL-2’s value-model evolution path
intact). New policy fields (FS-8):
negative_penalty: { gamma: 0.3, hard_filter_sim: 0.9 }— the FS-2 term.slot_quotas(per 10-card page):{ festival: 2 (ramp→takeover on the day), exploration: 1–2, personalized: rest }— quotas are explicit slots at assembly, the Netflix/Spotify slot-and-curated-pool pattern; a festival card competes for festival slots, not against evergreen scoring (FS-9). Existingevent_rampsupplies the boost curve within the festival pool; time-bound decay past the window is a hard cut, not a half-life.exploration_priors: { neighbor_k, prior_strength }— new items inherit a Beta share-rate prior from their k nearest catalog neighbours with stats (IDProxy/Dynamic-Prior-TS pattern), then update per impression/share; exploration slots Thompson-sample from the under-exposed pool under a per-item daily impression cap (FS-10). This replaces any bandit infrastructure — the anti-virality rarity boost already doubles as an exploration bonus.- Diversity stays MMR + sliding-window attribute caps (YouTube ran exactly this at scale for years pre-DPP); DPP is a deferred refinement (FS-11).
Seen-state: an exact per-user ID set, 90-day window (~40–80 KB, one D1 row/DO) — bloom filters (age-partitioned) only if it outgrows ~50 KB (FS-12).
5. Slates, buffering, and live adaptation
- Slate: 50–100 ranked
content_ids + light metadata per item (cluster tag + truncated embedding + facets), versioned, in KVfeed:{user_pseudo_id}:{version}. Serving maps it through the unchangedPlaylistPagecontract. - First paint: slate head from KV (single-digit ms). Morning ritual: the client pre-positions the first page including images overnight on unmetered Wi-Fi (DS-7/FE-1) — first paint needs no network.
- Client buffer (FS-13): hold ~20–30 items of metadata; prefetch only the next 2–3 images (measurement studies: >40% of aggressive prefetch is wasted bytes — decisive on metered Indian data plans); request refill when ≤5 unseen remain.
- In-session adaptation, two tiers (FS-14):
(a) on-device — the same
rank()(FE-1’s isomorphism is the point) re-orders the remaining buffer after every interaction: skip ⇒ demote same-cluster items, share/long-dwell ⇒ promote — pure arithmetic on shipped tags, zero round-trips (EdgeRec pattern: ~300 ms adaptation); (b) server-side — each refill carries the session’s FeedEvents; the Worker re-ranks the remaining slate + next pool chunk with session state (running seen-set, per-attribute fatigue). - Freshness handoff (FS-15): a fresh ranking is a new slate version; the
client finishes its current page then continues on the new version. The in-view page
is never mutated.
request_id(EV-5) ={slate_version}:{page}— the training-data join key for free.
6. Loop 1 — the offline brain (pools & priors)
Nightly + festival-window-triggered Workflow: assemble the day’s global pools per
(language × geo-relevant festival window × day-part) from the catalog + events
calendar; recompute circulation/rarity stats and Beta posteriors from the event lake
(EV); refresh prior centroids (lab_centroids pattern). Day-parts collapse to simple
IST slots (morning greeting ≈ 5–10am, evening devotional) — one timezone. All
Vectorize/embedding-heavy work happens here, where eventual consistency is irrelevant.
7. The LLM slow brain — per-user curation artifacts
Nightly (or 2×/day pre-dawn) batch job per 7-day-active user; Heimdall-shaped (Workflow + OpenAI Batch API + Langfuse prompts + versioned artifact). Cost shape: one mini-model call/user/day, ~500–1500 tokens ⇒ fractions of a cent per user-day (FS-16).
Inputs: interest clusters (attribute profiles + top items), negative-centroid attributes, 7-day event summary, next-72h calendar, previous NL profile. Outputs — one JSON artifact (D1, KV-mirrored):
- NL user profile — 3–5 named interests + sending persona, incrementally updated; human-readable in the BO console. Doubles as DPDP algorithmic-due-diligence evidence and gives Heimdall an auditable surface (FS-17).
- 3–8 affirmative probe texts constrained to the attribute vocabulary, incl. 1–2
deliberate adjacent-interest exploration probes; embedded once in batch → §3
probe_vectors. - (optional, phase D) the curated buffer: listwise re-rank of ~100 pre-retrieved candidates into tomorrow’s 20–30-card batch, ~70/20/10 exploit/adjacent/explore, festival slots pinned.
Serving reads artifacts; live vector retrieval is the fallback when stale. The ritual framing falls out free: “your morning batch is ready” is an appointment mechanic and end-of-batch a designed stop point — aligned with time-to-a-good-send (DS-2), the ethical version of habit-forming (FS-18).
8. Compliance tie-in (EV)
The contextual fallback is the same code path: rank() with the cold-start
context floor and behavioral pools off (candidate_mix.behavioral_knn = 0, no
UserState v2) is the DPDP-compliant non-personalized feed for minors and
consent-withdrawn users — one policy flag, not a second system (FS-19). Slates key
on user_pseudo_id (EV-11); NL profiles live in the PII plane, region-pinnable.
9. What NOT to build (research-supported)
Trained two-tower / MIND / ComiRec (frozen-embedding clustering captures most value below ~10⁵ users) · deep sequence models (session-kNN-class baselines win at small scale; our sequence signal is the calendar) · DPP (MMR + window caps is the proven pre-DPP production state) · semantic IDs / HSTU / OneRec (research-scale; our catalog fits in a prompt) · per-request LLM calls (DS-8) · bandit infrastructure (fixed quota + seeded Beta priors, re-tuned offline) · query-vector subtraction (FS-2) · bloom filters before the exact seen-set outgrows ~50 KB (FS-12).
10. Phased build plan
Every phase is Lab-testable (FL-9 two-arm diffs) before touching production; each PR small and single-purpose.
| Phase | What | Depends on |
|---|---|---|
| A — wire the spine | consumer-api reads production FeedPolicy from KV, runs retriever + rank() per request, per-user UserState v1 in D1; replaces bucket-shuffle behind unchanged PlaylistPage. sim.ts is the template. | EVENTS PRs 1–3 (schema v2, interactions, /api/events) so applyEvents has real signals |
| B — slates + buffer | Loop-2 precompute Workflow → KV slates; client metadata buffer + refill protocol; on-device in-session re-rank; overnight pre-positioning | A |
| C — UserState v2 | interest-cluster medoids + importance decay; negative centroids + γ-penalty in rank(); neighbor-seeded Beta priors + slot quotas (FeedPolicy v2 — tunable in Feed Lab, measurable in Retrieval Bench) | A (B parallelizable) |
| D — LLM slow brain | probe generation + NL profiles + curated buffers as a nightly Kernel-style Workflow; prompts in Langfuse; artifacts in BO console | C |
| E — measurement | policy A/B via label mechanism + Statsig exposures; Lab two-arm diff as the promotion gate | A, EV analytics surfaces |
11. Decisions log
| ID | Decision |
|---|---|
| FS-1 | User = ≤5 interest clusters (medoid embeddings over frozen Cohere space); decay on importance weights (ξ=0.9), never on vectors; single taste vector demoted to cold-start/fallback |
| FS-2 | Negatives = re-rank penalty −γ·max sim(item, neg_centroid) + hard filter; confident negatives only, 30-day expiry; query-vector subtraction banned |
| FS-3 | LLM-written affirmative text probes, constrained to the attribute vocabulary, embedded as retrieval vectors — the cross-modal bridge |
| FS-4 | Online path = filter + deterministic re-rank only; all intelligence in precompute |
| FS-5 | Vectorize never on the hot path; in-Worker D1-copy store until eligible pools > ~50k, then Vectorize as offline pre-filter |
| FS-6 | Pure push: precomputed per-user versioned slates in KV (no follow graph ⇒ no fan-out problem); cost O(DAU) |
| FS-7 | UserState v2: interest_clusters, negative_centroids, probe_vectors, nl_profile_ref; rebuilt offline, nudged in-session |
| FS-8 | FeedPolicy v2: negative_penalty, slot_quotas, exploration_priors; additive score shape preserved |
| FS-9 | Time-bound content competes for dedicated festival slots (quota + ramp→takeover→hard cut), not against evergreen scoring |
| FS-10 | Exploration = 1–2 TS-sampled slots/page from the impression-capped pool; new-item priors seeded from embedding-neighbour share rates |
| FS-11 | Diversity stays MMR + sliding-window attribute caps; DPP deferred |
| FS-12 | Seen-set = exact 90-day ID set per user; blooms only past ~50 KB |
| FS-13 | Client: ~20–30-item metadata buffer, images prefetched only 2–3 ahead, refill at ≤5 unseen |
| FS-14 | Two-tier in-session adaptation: on-device rank() re-order (instant) + server re-rank at refill (session events) |
| FS-15 | Freshness by slate versioning; in-view page never mutated; request_id = slate_version:page |
| FS-16 | Slow brain = nightly batch job for 7-day-actives; one mini-model call/user/day; artifacts versioned + Langfuse-prompted |
| FS-17 | NL user profiles are human-auditable in the BO and serve as DPDP algorithmic-due-diligence evidence |
| FS-18 | Appointment mechanics (morning batch, designed stop point); no compulsion mechanics — aligned with time-to-a-good-send |
| FS-19 | Contextual fallback (minors / consent-withdrawn) = same rank() with behavioral pools zeroed — one flag, not a second system |
12. Open questions
- OQ-1: cluster algorithm details — agglomerative vs k-medoids at our history lengths; validate merge threshold against Atlas’s vein floor in the Lab.
- OQ-2: slate refresh cadence vs KV write cost at scale — pre-dawn only vs +post-session; measure.
- OQ-3: on-device re-rank payload — truncated-embedding dims vs cluster-tag-only (bundle-size/adaptation-quality trade; FE-2 budget).
- OQ-4: curated-buffer (§7 output 3) value over probes+profile alone — Lab A/B before building phase D’s re-rank stage.
- OQ-5: when Loop 2 moves from cron-over-actives to event-triggered (session-end Queue) — DAU threshold.
13. Key references
PinnerSage arXiv:2007.03634 · kNN-Embed arXiv:2205.06205 · negation blindness arXiv:2501.09425 · Qdrant best_score article · GPT4Rec arXiv:2304.03879 · LLM interest exploration arXiv:2405.16363 · EmbSum arXiv:2405.11441 · interest journeys arXiv:2305.15498 · Instagram Explore Meta blog · Twitter Home Mixer repo · EdgeRec arXiv:2005.08416 · Netflix in-session RecSys’22 · Spotify algotorial blog · YouTube DPP CIKM’18 · exploration LTV arXiv:2305.07764 · Dynamic-Prior TS arXiv:2602.00943 · Okura KDD’17 paper · Vectorize limits docs