S Technologies's Logo

Color

Indigo
Red
Green
Teal
Blue
Purple
Rose

Mode

Light

Engineering guide

How Ulkaa Personalises The Feed Cheaply

Language as a soft prior, a topic tree derived from hashtags, Not interested as a training label, and exploration sized from research: a personalised feed that fits a small server, with every costly tier behind its own switch.

24 min readFeedPersonalisationTopicsExploration

Goals and the constraint

The first guide described how the home feed picks, scores and pages posts. Interest in that ranker came from raw hashtags and the categories of Pages and groups, and there was no way to say "less of this". This guide covers the layer built on top: language, topics, feedback and exploration.
The target machine is a 2 vCPU, 3 GB virtual server meant to carry about 500 concurrent users. That rules out a model server on the request path, and it shaped every other choice. The goals were these:
  • Language first. Someone who keeps engaging with Hindi posts should see Hindi first, without losing the English and Bangla posts of their friends.
  • Topics from posts, not from a list we wrote. A tree such as sports, football, Brazil, the men's team, Neymar, grown from what people actually post. People pick the top levels; deeper levels stay hidden, are learned from behaviour and can be edited.
  • Interested and Not interested on every post, and a topic picker after sign-up.
  • Exploration on purpose. Posts from neighbouring and distant topics, in amounts taken from published studies rather than guessed.
  • Every costly part behind a switch, with its resource cost written next to it, so the operator decides what the server can afford.

The evidence behind the defaults

Before writing code we collected the studies each default rests on and checked every reference against its DOI record. Where a paper reports a figure we could not confirm from an abstract, this guide describes the finding in words instead of quoting the number. The ids point to the reference list at the end.
DecisionWhat the evidence saysReferencesStrength
Keep at least three of every five discovery items relevantIn a user study, people tolerated a moderate share of exploratory items; satisfaction fell once exploration dominated the list.R1; context R3, R10, R11Moderate (one conference user study)
Adjacent topics take about a quarter of discoveryDiversifying along a topic taxonomy improved perceived list quality at moderate levels, even though accuracy metrics dropped.R2; context R9, R12Low to moderate (2005, book lists)
Far topics take a small shareProduction exploration systems on a large video platform; short-term A/B metrics understate what exploration is worth later.R3, R4; context R5, R14Moderate (production, but video rather than social)
Uncertainty-guided exploration for new peopleContextual bandits adapt quickly where data is scarce; elicitation studies cover what to ask before any history exists.R5, R53, R55; context R54Moderate
Sliding-window diversity re-rankMaximal marginal relevance trades relevance against similarity; a sliding window was deployed on a long social feed.R6, R8; context R9, R12Moderate
Specialists and generalists get different mixesListening diversity on a music service was linked to retention (observational).R7Low to moderate
Not interested is a training labelAdding negative feedback to the training objective made recommendations respond to it measurably.R27Moderate
Explicit negatives are strong but decay; skips are weakerSkips work as passive negatives; audits measured how far platform buttons remove a topic; Meta documents a temporary, similarity-based effect.R28, R29; documentation R30Moderate; R30 is not peer reviewed
Language is a soft prior, not a filterBilingual users switch language with what they express; access language is one personalisation factor among several; author history helps identification.R35, R36, R38Moderate
Language identification is imperfect on short textShort, romanised and code-mixed posts are harder; token-level models exist, one reporting about 92% word-level accuracy for romanised Bengali-English on its own test data.R37, R39, R40, R41, R42Moderate; no Bangla or Hindi short-post benchmark found
A linear text classifier instead of a transformerLinear models are often on par with deep classifiers on topic tasks while being orders of magnitude faster; quantisation shrinks them by about two orders of magnitude in memory.R43, R44; context R45, R50Strong for topic tasks
Stabilise tags first; curate the top two levelsTag distributions settle as more people tag; automatic hierarchy induction from co-occurrence or tags is possible but places parents imperfectly.R47, R48, R49Moderate
Decaying interests that propagate to parentsPreferences drift over time; microblog interest profiles use temporal and hierarchical representations.R51, R52Moderate
ExplanationsMany people did not know their feed was curated and changed how they used it once they saw it.R31, R33, R34Moderate
A small non-personalised holdoutLong-running randomised control groups and randomised feed interventions are how ranking effects have been measured.R16, R17, R26Strong method precedent

Language as a soft prior

A post gets a language when it is saved, stored in post.lang as one base language code (en, bn, hi, ur and so on), or as mixed or und. There is one value per language across scripts: Bangla in Bengali letters and Bangla typed in Latin letters (Banglish) are both bn, and Hindi and Hinglish are both hi. Any language can be the answer: a base code is ISO 639-1 where one exists, else ISO 639-3, checked and named through the runtime's ICU data rather than a hand-written list. The languages that need extra help are a data file, not code, so adding one means adding one entry with its scripts, a few marker words and, where people type it in Latin letters, a small word list. The detector in feedLanguage.ts is pure, deterministic and cheap.
The reason for one value per language is the reader, not the script. Someone who reads Bangla reads it in both forms, so splitting them halves the evidence about that person and needed a patch to give the sibling form partial credit. With one unit the history, the rank factor and the explanation all talk about the same thing.
  1. Strip what carries no language: hashtags, URLs, mentions and link targets, then split the text into runs of one Unicode script.
  2. Route each run by script. A script used by exactly one supported language decides for free: Bengali letters give Bangla, Tamil letters give Tamil, Hangul gives Korean.
  3. A script shared by several languages goes to a small voter of native function words and marker letters. Devanagari separates Hindi, Marathi and Nepali; Arabic script separates Urdu, Arabic and Persian; Cyrillic separates Russian and Ukrainian; Han letters next to kana mean Japanese. With no evidence the author’s language history decides, and failing that the first candidate in the registry.
  4. A Latin run goes to a data-driven romanised voter. Each language that people commonly type in Latin letters has a lexicon of frequent words, with spelling variants folded together, and weak character n-grams are used only for words not in any lexicon. English is the default competitor. If the evidence is too thin and the author has no history, the post is und. A Latin run inside a post that also has another script borrows that script’s language as a prior.
  5. Combine the runs. Runs that resolve to the same language add up, so Bangla letters plus Banglish are simply Bangla. The shares follow the matrix-language idea from code-switching research: the grammar of the sentence comes from one language, so native-script words and English grammar words (articles, pronouns, prepositions, auxiliaries) count fully, while English content words dropped into a sentence and unknown Latin words such as names and brands count for much less. The top language wins unless a second one holds a real share while the top stays below a dominance share, in which case the post is mixed.
Most of the work in the Latin step is keeping false positives down. A word that appears in several lexicons splits its weight between them. A word that is also an English word, such as "to" or "main", gives the romanised side only a small share. Short words count for less unless they are grammar words. Neutral words that many communities share (an acknowledgement, a laugh, a religious phrase said by Muslims of any language) count for nobody, so a Banglish post with a common religious phrase is never pulled towards Urdu or Arabic. Romanised Hindi and Urdu share most of their core vocabulary, so the shared words live under Hindi and Urdu keeps only the spellings that Urdu writers favour; an Urdu-reading author’s history then settles the ambiguous posts. A media-only post inherits the author’s main language.
Behind those steps sit two small general-purpose detectors written in plain JavaScript, tinyld (62 languages) and franc (187 languages), used only for what the steps above cannot decide: a Latin-only post whose words are mostly in no lexicon, a script no listed language uses, or a shared script with little native evidence, where they may name a language outside the list, such as Bulgarian or Serbian Cyrillic. Measured on our own corpus, neither is safe alone on Latin text: both turn romanised Bangla with few known words into Vietnamese, Romani or Venetian, one of them with full confidence. They almost never agree on such text, while real Spanish or Polish gets the same answer from both, so a Latin answer needs the two to agree on at least 25 letters, and they are never allowed to name English or a romanised language, which stay with the word lists. A short post in a less common language gets its author’s usual language, or no language, rather than a random guess. Both load only when first needed, together about 40 MB of memory, and a post that reaches them costs well under a millisecond more.
Each reader has decayed language counters, kept in Redis and persisted to user_language, one row per base language. Values written before the change, such as a counter for Banglish, are folded into the base language when read. The rank factor grows with the share ss that the post's language holds in those counters:
L  =  Lmin⁡+(Lmax⁡−Lmin⁡)s,s=wℓ∑jwjL \;=\; L_{\min} + \left(L_{\max} - L_{\min}\right)\sqrt{s}, \qquad s = \frac{w_{\ell}}{\sum_{j} w_j}
(1)

The square root lifts small shares quickly, so a second language a person reads now and then is not buried. The floor is above zero: a language is demoted, never removed. A Banglish post and a Bengali-script post earn the same credit from the same reader.

A post marked mixed or und, or a reader with no history, gets exactly 1. That neutral default matters more than the formula: misreading a Banglish post can only shift its score a little, and it can never hide a friend's post because of the language it was written in.

A topic tree derived from posts

Topics are rows in a topic table with a parent, a depth, an English and a Bangla label, and a status: auto, curated or hidden. A nightly job builds the automatic part from 90 days of hashtag statistics. The maths lives in feedTopicTree.ts and is pure, so the same input always gives the same tree, ties included.

One path through the topic tree

  1. Sportslevel 1sibling: Technologyw·d^4
  2. Footballlevel 2sibling: Cricketw·d^3
  3. Brazillevel 3sibling: Argentinaw·d^2
  4. Brazil Men's Teamlevel 4w·d
  5. Neymarlevel 5w

    Resolves from: #Neymar · নেইমার · नेमार · neymar jr

Curated by an admin, offered in the pickerHidden level, promoted from behaviourHidden level, still collecting evidence
The path comes from the seeded multilingual corpus. Levels 1 and 2 are curated and shown in the topic picker; deeper levels are hidden and are promoted only when a person's own engagement supports them. Every hidden level is still visible and editable on the account site. Bars: what one engagement on Neymar adds at each level, drawn with a discount of one half per level.

Parents by subsumption

A tag pp becomes the parent of a tag cc when most posts carrying cc also carry pp, but not the other way round, and pp is the bigger tag:
P(p∣c)≥τ1,P(c∣p)<τ2,np>ncP(p \mid c) \ge \tau_1, \qquad P(c \mid p) < \tau_2, \qquad n_p > n_c
(2)

Conditional-probability subsumption over co-occurrence counts. Tags are placed largest first, so a parent always exists before its child and no cycle can form.

Every ancestor of the right parent also subsumes the child, so among several candidates the deepest wins. Depth and the number of children per node are capped; a tag whose best parents are full tries the next one and otherwise becomes a root, which an admin can move.

Three gates before a tag becomes a node

  • Size. A minimum number of posts and of distinct authors.
  • Stability. The tag's distribution over the tags it appears with must change little from one month to the next, measured as a smoothed KL divergence DKL(qt ∥ qt−1)<δD_{\mathrm{KL}}(q_t \,\|\, q_{t-1}) < \delta. A fad that spikes for a week and vanishes does not get a node.
  • Crowd agreement. A spam score mixes author concentration (one account posting most of the tag), disagreement on co-tags and burstiness.
A small language community naturally has fewer authors and less agreement data. A tag whose posts are mostly in a minority language of the corpus therefore skips the agreement term and needs only part of the size thresholds. Without that exemption the tree would quietly become an English tree.

Why an admin curates the top two levels

Automatic hierarchies place parents imperfectly, and the top of the tree is what people see in the picker. So the first two levels are curated by hand (pin, rename, merge, hide) and are kept as given whatever their numbers. Deeper levels stay automatic proposals. A per-node sensitive mark keeps topics such as politics and religion out of far exploration.

Aliases across scripts

English, Bangla, Hindi and romanised spellings of one topic are separate rows in topic_alias pointing at the same node, so #Neymar, নেইমার and नेमार land in one place. Hashtags are normalised before lookup (#Real_Madrid, #real-madrid and #RealMadrid meet), and Bangla and Devanagari letters keep their vowel signs.

Tagging a post at save

The first tier that finds anything wins, and the post stores its leaves plus every ancestor in post.topic_ids (an integer array with a GIN index):
  1. Hashtags, through the alias map.
  2. The text, against an in-memory Aho-Corasick automaton of every label and alias. A Latin alias must sit on word boundaries ("messi" never matches inside "messing"); a Bangla or Devanagari alias needs a boundary only at its start, because case endings attach to the end of the word.
  3. The optional classifier, only above its confidence threshold.
  4. The optional embedding tier, only in background runs, never on the request path.
  5. The container (a Page’s category, a group’s topics), then the author’s most used topic.
Only published public posts enter the per-topic Redis sets the candidate query reads from, and a post that stops being public leaves them.

Hidden levels and the interest model

The picker after sign-up shows popular level-one topics, then level two of whatever was picked, and can be skipped. Everything deeper is learned. A profile holds picked nodes, learned nodes, negative topic and author weights and reading languages, capped at a fixed number of nodes per person, picked ones kept first.
Weights decay with a half-life HH. An entry stores the weight valid at the moment it was written, so reading it later is one multiplication and nothing is rewritten as time passes, the same trick the heat counter uses:
w(t)  =  w(t0)⋅2−(t−t0)/H,Δwk  =  a d kw(t) \;=\; w(t_0)\cdot 2^{-(t - t_0)/H}, \qquad \Delta w_{k} \;=\; a\, d^{\,k}
(3)

An engagement of size a on a node adds a to it and a·d^k to the ancestor k levels up, with a discount d below one.

A hidden child becomes visible when its decayed weight reaches a share θ\theta of all its siblings' weight and at least mm engagements stand behind it. Promotion cascades: a promoted node's own children are tested next. A root with any learned weight is visible by itself, so someone who skipped the picker still gets topics.
Redis holds the live profile; user_interest and user_language in PostgreSQL are the persisted copy, written in batches every 60 seconds from a dirty set. A missing hash is rebuilt from PostgreSQL. Two changes by the same person at the same moment may lose one of them; these are approximate signals and we chose not to pay for locking.

Ranking and mixing

Three new candidate branches sit next to the trending branch in the one candidate statement: exact (the viewer’s own nodes), adjacent (siblings and parents of those nodes) and far (other roots). Each reads a capped, heat-ordered Redis set per topic and falls back to the GIN index when a set is short. Every row still goes through decide.

Topic match by shared prefix

Topics are paths from the root, so similarity is the length of the shared prefix. For a post path pp and the viewer's nodes uu with weights wuw_u:
m  =  max⁡u lcp⁡(p,u)∣u∣⋅wumax⁡vwv,T  =  Tmin⁡+(Tmax⁡−Tmin⁡)mm \;=\; \max_{u}\ \frac{\operatorname{lcp}(p, u)}{|u|}\cdot\frac{w_u}{\max_v w_v}, \qquad T \;=\; T_{\min} + \left(T_{\max} - T_{\min}\right) m
(4)

A post inside one of the viewer's nodes matches fully; one that only shares the root matches partly. With no topic data the factor is exactly 1.

The score of the first guide is multiplied by the new factors, each neutral when its data is missing:
S′  =  S⋅L⋅T⋅(1−Ptopic)⋅(1−Pauthor)S' \;=\; S \cdot L \cdot T \cdot \left(1 - P_{\text{topic}}\right)\cdot\left(1 - P_{\text{author}}\right)
(5)

Language, topic match and two negative penalties. With every topic switch off, S' is S.

rank.ts
export function topicMultiplier(c: RankInput, t: TopicRankSignals): number {
	let m = 1
	if (t.langWeights) m *= langFactor((c.lang ?? null) as FeedLang | null, t.langWeights, t.config)
	if (t.topics) {
		const path = t.topics.path(c)
		m *= topicFactor(topicMatch(path, t.topics.nodes), t.config)
		m *= 1 - negativePenalty(path, t.topics.negative, t.now, t.topics.skip, t.config)
		m *= 1 - negativePenalty([c.author], t.topics.negativeAuthors, t.now, undefined, t.config)
	}
	return Number.isFinite(m) && m > 0 ? m : 0
}

The discovery container and its split

The first guide reserved every fourth slot of the ranked list for suggestions. That container stays exactly as it was and still sizes the whole mix. Inside it, a slot plan splits the discovery slots into exact, adjacent and far, by default 70, 25 and 5 percent. Two rules shape where exploration may go: it never takes the first two discovery slots, and it holds at most two of any five consecutive discovery slots, so at least three of every five stay relevant. Far items take the later exploration positions.

Where exploration may sit on a page

The fifteen discovery slots, in order

  1. E
  2. E
  3. E
  4. A
  5. E
  6. A
  7. E
  8. E
  9. A
  10. E
  11. E
  12. A
  13. E
  14. X
  15. E

Ringed: the first two discovery slots, which exploration never takes. 5 of 15 explore, and no run of five holds more than two of them, so at least three of every five stay relevant.

The whole ranked list

  1. N
  2. N
  3. N
  4. E
  5. N
  6. N
  7. N
  8. E
  9. N
  10. N
  11. N
  12. E
  13. N
  14. N
  15. N
  16. A
  17. N
  18. N
  19. N
  20. E
  21. N
  22. N
  23. N
  24. A
  25. N
  26. N
  27. N
  28. E
  29. N
  30. N
  31. N
  32. E
  33. N
  34. N
  35. N
  36. A
  37. N
  38. N
  39. N
  40. E
  41. N
  42. N
  43. N
  44. E
  45. N
  46. N
  47. N
  48. A
  49. N
  50. N
  51. N
  52. E
  53. N
  54. N
  55. N
  56. X
  57. N
  58. N
  59. N
  60. E

Three quarters of the list stays with the network. Exploration is 5 of 60 slots, spread through the page and placed lower rather than at the top.

Network (friends, follows, groups, workspaces)Discovery: exact topic matchDiscovery: adjacent topicDiscovery: far topic
Computed from the default settings with plain rounding: 15 discovery slots, of which 10 exact, 4 adjacent and 1 far. In production the fractional counts are rounded stochastically with a generator seeded per viewer and minute, so the far share averages out over pages instead of rounding to zero or one every time. A slot whose pool is empty falls back to the next pool; a viewer with no network gets a page that is all discovery, planned the same way.
Because only a quarter of the list is discovery, the far share of the whole page is small, around one to two percent with the defaults, and the friends-and-follows share is unchanged. A slot whose pool has nothing that fits borrows from the next pool in a fixed order, so a thin topic never leaves a hole.

Walking up when a topic runs out

Deep nodes are sparse for a long time. The exact branch splits its demand over the viewer's nodes by weight (largest remainder), then draws each share from the node itself and, when that runs out, from its parent, grandparent and so on up to the root. Supply is shared, so what one node takes is gone for the next. Someone interested only in Neymar still gets a full page; it fills from Brazil, then football.

Sliding-window diversity

With the diversity switch on, each slot takes the candidate with the best marginal value against the last WW placed items instead of the best score, where relevance is the score over the best score and similarity is one minus the topic distance:
v(x)  =  λ S′(x)Smax⁡′  −  (1−λ)max⁡y∈last W lcp⁡(x,y)max⁡(∣x∣,∣y∣)v(x) \;=\; \lambda\,\frac{S'(x)}{S'_{\max}} \;-\; (1 - \lambda)\max_{y \in \text{last } W}\ \frac{\operatorname{lcp}(x, y)}{\max(|x|, |y|)}
(6)

Similarity is the shared-prefix ratio of two topic paths, so a post from the same narrow topic as one just shown pays the most. The author rules of the first guide still apply on top.

The window is a setting. Looking only a few items back is what makes the cost linear in the page, which matters more on this server than the slightly better global spread a determinantal method would give.

Thompson sampling for adjacent and far topics

Which neighbouring or distant topic to try is a bandit problem. Each candidate node has a Beta(αn,βn)\mathrm{Beta}(\alpha_n, \beta_n) state for this viewer, built from the exploration impressions and engagements stored in feed:ts:<user>. One draw per node, the largest wins, an engagement counts as a success and a skip as a failure:
θn∼Beta(αn,βn),n∗=arg⁡max⁡nθn\theta_n \sim \mathrm{Beta}(\alpha_n, \beta_n), \qquad n^{\ast} = \arg\max_n \theta_n
(7)

A node with little data has a wide distribution and gets tried; one that keeps failing narrows towards zero and stops being picked.

The random generator is seeded from the viewer and the current minute, so a page can be replayed exactly in a test, and the slot plan uses the same generator to round fractional slot counts.

Cold start

A person with a small network or no topic profile counts as new, and the far share rises to its own setting (12 percent by default). Someone with no network posts at all gets a page that is entirely discovery, planned by the same rules. The trending branch needs no history, so the first page is never empty.

Feedback as a training label

Every feed surface offers Interested and Not interested (POST /v1/post/:id/feedback). The viewer must be able to see the post, otherwise the answer is 404. The tap is one post_feedback row written at once, and undo deletes it.
  • Not interested hides the post through the existing hide path, and writes a negative weight on the post's deepest topic, a spread share of it on related topics, and a negative weight on the author. It is a label of its own, not one more feature mixed into the score.
  • Interested is a strong engagement along the post's topic path and language.
  • Skips are posts served in the last 48 hours and not engaged with, read from the existing seen set. They need no new client event and count far less than a tap.
P  =  min⁡ ⁣(Pmax⁡, 1−e−(∑knk(t)+∑ksk(t)))P \;=\; \min\!\Bigl(P_{\max},\ 1 - e^{-\left(\sum_k n_k(t) + \sum_k s_k(t)\right)}\Bigr)
(8)

Decayed Not interested weights n and skip weights s on a post's path (or on its author). Negatives decay more slowly than interests, skips much faster; the cap keeps a penalty from erasing a post entirely.

The Redis profile changes immediately, so the next page already shows fewer posts like it. Switching from one signal to the other reverses the first before applying the second. PostgreSQL follows within a minute.

Explanations

With the explanation switch on, every ranked item carries a structured reason, feed_why, next to the old free-text feed_reason, which stays as it was. The reason is decided from the candidate branch and the topic match, and the copy is data:
ReasonWhat the person reads
friendsYour friend posted this.
followingYou follow the person or Page who posted this.
interestYou are interested in {topic}.
adjacentIt is related to {topic}, which you are interested in.
exploreSomething new, outside your usual topics.
languageIt is popular in {topic}, a language you read.
trendingIt is popular on Ulkaa right now.
groupIt was posted in a group you are in.
workspaceIt was posted in your workspace.
After a tap the post says "You'll see fewer posts like this" or "You'll see more posts like this". These are short on purpose. The reason names the strongest single cause, not the whole score.

An audit holdout

A personalisation layer that cannot be compared with its absence cannot be judged. With the holdout switch on, a person is in the audit group when FNV1a(username) mod 100<h\mathrm{FNV1a}(\text{username}) \bmod 100 < h, with hh a setting (1 by default). That group gets exactly the old ranking. Both arms count pages, impressions and engagements in Redis, and the admin flag page shows them side by side.
Assignment by hash is stable: the same person stays in the same arm across devices and days, and no assignment table is needed. The cost is a small share of people without personalisation, which is the price of being able to check whether the layer helps.

Costs and the switch model

Every tier is a platform flag at account.ulkaa.com/admin/flag, and every flag carries a resource note that the admin page shows under its description and again in the confirm dialog. Dependencies are applied in code: everything needs the base ranking switch, exploration and the classifier need topics, and the semantic tier needs the classifier. A flag that cannot be read counts as off.

Nine switches, nine written costs

  1. Explainfeed.explainno extra resource

    Structured reasons and "why am I seeing this"

    Estimate: none. Needs feed.ranking.

  2. Holdoutfeed.holdoutno extra resource

    A small audit group on the old ranking

    Estimate: none; lowers personalisation for the holdout_pct share of users. Needs feed.ranking.

  3. Diversityfeed.diversityR

    Sliding-window re-rank on topic distance

    Estimate: O(400x8) per request, under 1 ms. Needs feed.ranking.

  4. Languagefeed.languageSM

    Language tag at save, reading-language affinity

    Estimate: microseconds per post, about 100 B per user in Redis, no worker. Needs feed.ranking.

  5. Feedbackfeed.feedbackMP

    Interested and Not interested, negative weights

    Estimate: 1 Redis write per tap; 60 s PostgreSQL batch. Needs feed.ranking.

  6. Explorefeed.exploreRM

    Adjacent and far slots, Thompson sampling

    Estimate: 3 to 6 extra pipelined Redis commands per feed request. Needs feed.ranking + feed.topic.

  7. Topicfeed.topicSMPW

    Topic tree, tagging, picker, interest-weighted candidates

    Estimate: under 1 ms per post; nightly build 5 to 60 s on 1 vCPU; Redis about 20 to 40 MB of topic sets plus about 1 KB per active user; GIN index about 1 to 2% of the post table. Needs feed.ranking.

  8. Classifierfeed.topic_modelW

    Nightly classifier for untagged posts

    Estimate: nightly 10 to 120 s of CPU, under 5 MB of RAM. Needs feed.ranking + feed.topic.

  9. Semanticfeed.semanticWX

    Embedding sidecar for posts no cheaper tier could tag

    Estimate: 400 to 700 MB more RAM and 1 vCPU during batches; unbenchmarked. Needs feed.ranking + feed.topic_model.

Feed request pathPost save pathRedis memory or commandsPostgreSQL rows or indexBackground worker jobSeparate process
Each rung is one admin switch. The notes are the estimates written next to the switch on the admin page; the project's capacity tool is what measures and replaces them. Indentation grows with the footprint, not with any score.
  • Failure means the old feed. Every read in the topic layer degrades to "no topic data", and the multipliers are exactly 1 without data, so a Redis outage or a missing tree ranks like the first guide's feed.
  • Numbers are settings. The mix percentages, window size, tree thresholds, node cap and half-lives are integer platform settings read once per process and cached for 30 seconds.
  • Heavy work stays off the request path. The tree build, the retag walk, classifier training and embedding calls run on the worker. The embedding tier lives in a separate compose profile behind a small HTTP contract, and an unset address makes every call return nothing at once.
The classifier is a multinomial naive Bayes over hashed word unigrams, word bigrams and character bigrams: one model for the roots and one per root for its children, stored sparse and pruned to stay small. That is a linear model in log space, the family the evidence table supports, written in plain TypeScript with no native dependency.

How this compares with large platforms

The table below uses only what each platform documents itself, on pages opened on 2026-10-17. A tag points to the source list under the table. "Not publicly documented" means the authors found no primary page for that cell on that date; it does not prove the platform has not published one. Several pages refused our requests, including most X help pages, Reddit’s company blog and Pinterest’s engineering blog, so those cells may be documented elsewhere.
FacebookInstagramXYouTubeRedditTikTokPinterestUlkaa
How interests are modelledModels predict how relevant and valuable each post is to you [META-FB]Predicts what you are likely to engage with; Explore retrieves from accounts and media like those you engaged with [META-IG, META-EXP]Community embeddings (SimClusters), graph embeddings (TwHIN) and a neural ranker [X-ALGO]Deep candidate-generation and ranking networks; signals include viewing, likes, dislikes and surveys [YT-2016, YT-HOW]Votes, time in communities, subscriptions and interests picked at sign-up feed recommendations [REDDIT-REC]User interactions plus video information such as captions, sounds and hashtags; onboarding categories seed the feed [TT-FYP]Graph convolutional embeddings (PinSage) and a sequence model of recent actions (PinnerFormer) [PIN-SAGE, PIN-FORMER]Decayed weights on nodes of a topic tree derived from posts, plus reading-language weights
Explicit negative feedbackNot interested temporarily lowers a post and posts like it; hiding a post [META-FB]Not interested demotes similar content in Feed and filters it in Explore [META-IG, META-EXP]Negative feedback and reports carry negative weights in the published ranking configuration [X-ML]Not interested, Don’t recommend channel and dislike; feedback can be deleted later [YT-HELP]A “show fewer posts like this” button stops that community and lowers similar ones [REDDIT-REC]Not interested; hide a creator or a sound [TT-FYP]See fewer like this on a Pin; hiding a Pin lets you give a reason [PIN-HIDE, PIN-NEWS]Not interested is a training label: a decaying negative on the topic, its neighbours and the author, plus a hide; undo reverses it
Language handlingNot publicly documentedNot publicly documentedNot publicly documentedNot publicly documentedNot publicly documentedLanguage preference is a signal, weighted below active signals [TT-FYP]Not publicly documentedOne language per base code, tagged at save across scripts (romanised text counts as its language); a bounded rank factor, never a filter
Exploration and diversityAims for a balanced mix of content types [META-FB]Not publicly documentedNot publicly documentedNot publicly documentedSorts content before finalising the feed so many similar posts do not appear in a row [REDDIT-REC]Avoids two videos in a row from the same creator or sound; mixes in diverse videos on purpose [TT-FYP]Not publicly documentedA fixed slot template, Thompson sampling for adjacent and far topics, sliding-window MMR
Explanation to users"Why am I seeing this post?" explains how past interactions shape ranking [META-WHY]Explore documents its retrieval and ranking stages and the signals behind each prediction [META-EXP]Not publicly documentedNot publicly documentedRecommended posts show a reason, such as being popular now or similar to past interactions [REDDIT-REC]“Why this video” lists reasons: your interactions, followed accounts, recent and popular content in your region [TT-WHY]The Pin menu shows why a Pin appeared [PIN-HIDE, PIN-NEWS]A structured reason on each post, with the topic or language named
User-editable interestsFavorites and Snooze prioritise or pause chosen people, Pages and groups [META-FB]A list of content you marked interested or not; Explore content types can be tuned [META-IG, META-EXP]Not publicly documentedWatch and search history can be paused, edited or deleted [YT-HOW]Recommendations from communities you do not subscribe to can be turned off [REDDIT-REC]Pick categories at sign-up; refresh resets For You as for a new user; keyword filters [TT-FYP, TT-REFRESH]A tuner switches boards, topics, followed accounts and history on or off; activity-based picks can be disabled [PIN-NEWS, PIN-TUNE]The whole topic path, hidden levels included, editable; reset and export
Auditability and holdoutA reverse-chronological Following tab [META-FB]The Following feed can be shown latest first [META-IG]Ranking code published under AGPL-3.0 [X-ALGO]Turning watch history off limits what shapes Home [YT-HELP]Not publicly documentedInvited experts can review source code at its Transparency Center [TT-FYP]Not publicly documentedA hashed audit group on the old ranking, with per-arm counters on the admin page
Published evidenceSystem card [META-FB]System cards [META-IG, META-EXP]Open-source repositories [X-ALGO, X-ML]Research paper [YT-2016]Help-centre article [REDDIT-REC]Newsroom explainers [TT-FYP, TT-WHY, TT-REFRESH]Research papers [PIN-SAGE, PIN-FORMER, PIN-REP]This guide, its references and a seeded offline evaluator

Sources

These are different trade-offs, not a ranking. The large platforms learn from far more data, and their learned models and embeddings capture similarity that a hashtag tree cannot. Their systems run at a scale where a fraction of a percent is measurable. What a small, rule-based design offers in return is cost, a feed that fits a small server, and transparency: every factor can be written down, every switch has a published cost, the interest profile is readable and editable in full, and a holdout is built in.

Offline evaluation

Unit tests cover each pure module. Above them sits a seeded offline evaluator that runs the whole ranking over synthetic people with known interests. The seed is deterministic: about 300 extra personas and roughly 6,000 posts over 90 days, a power-law author activity, a one-way follow graph and Pages and groups per topic. The corpus is a tree of about 12 roots, 40 second-level and 120 deep nodes with phrase templates and hashtags in English, Bangla, Hindi, Banglish and Hinglish; about a quarter of the posts have no hashtags, some are media-only, some carry spam tags and some mix languages. Reactions, comments and saves follow each persona’s interests and are replayed through the normal engagement hook.
The evaluator fails the run when any of these checks break:
  • The exact, adjacent and far shares stay within tolerance of the settings.
  • The language-match rate on pages, judged by base language, and a cross-script check: a reader whose history comes only from Bengali-script posts must give Banglish posts full language credit, and the same for Hindi and Hinglish.
  • At least three of every five discovery items are relevant.
  • A narrow topic that runs out fills by walking up the tree.
  • After Not interested, the share of that topic and author drops, then recovers slowly.
  • Hidden-level learning promotes a child once the evidence supports it.
  • The holdout arm equals the old ranking.
  • No row reaches a page without passing decide.
  • Latency per feed page.

Limits and open questions

  • No Bangla or Hindi short-text benchmark. Every Bangla and Hindi source we found is about language identification or general text classification. Topic accuracy on short posts has to be measured on our own data.
  • Language ID on romanised text is imperfect. Romanised Bangla, Hindi and Urdu share words with each other and with English. Treating a language and its romanised form as one unit removes the cost of confusing those two, but not the confusion between different languages. That is why the result is a bounded prior with neutral defaults, never a filter. The general detectors add their own limits: short Latin posts get no detector answer at all, and languages close to a bigger neighbour (Bulgarian next to Russian, Malay next to Indonesian) are weaker than the large languages.
  • The tree depends on hashtag culture. Communities that rarely tag get thinner trees. Label matching, container and author fallbacks and admin curation narrow the gap; they do not close it.
  • Deep nodes stay sparse. Walk-up fill keeps pages full, but a five-level interest often behaves like a three-level one for months.
  • Evidence from other services may not transfer. Most exploration findings come from video and music. Our own holdout is how we find out whether they hold for a friend-based feed.
  • Resource notes are estimates. They come from reasoning about each tier and are replaced by measurements from the capacity tool; the semantic tier is the least certain of them.

References

Journal articles first, then conference papers, preprints and documentation. The bracket gives the venue type and, for journals where it was confirmed from a SCImago snapshot, the quartile as recorded; no quartile is shown where it was not confirmed. Results from other platforms may not transfer to ours.
  1. R10. Kotkov, D., Wang, S., & Veijalainen, J. (2016). A survey of serendipity in recommender systems. Knowledge-Based Systems, 111. [journal; Q1 (Artificial Intelligence, 2022 data; SCImago snapshot 2024-04-11)] https://doi.org/10.1016/j.knosys.2016.08.014
  2. R11. Kaminskas, M., & Bridge, D. (2016). Diversity, serendipity, novelty, and coverage: A survey and empirical analysis of beyond-accuracy objectives in recommender systems. ACM Transactions on Interactive Intelligent Systems, 7(1). [journal; Q2 (Artificial Intelligence and HCI, 2023 data; SCImago snapshot 2024-05-11)] https://doi.org/10.1145/2926720
  3. R12. Kunaver, M., & Požrl, T. (2017). Diversity in recommender systems: A survey. Knowledge-Based Systems, 123. [journal; Q1 (Artificial Intelligence, 2022 data; SCImago snapshot 2024-04-11)] https://doi.org/10.1016/j.knosys.2017.02.009
  4. R14. Klimashevskaia, A., Jannach, D., Elahi, M., & Trattner, C. (2024). A survey on popularity bias in recommender systems. User Modeling and User-Adapted Interaction, 34. [journal; Q1 (Computer Science Applications; Q2 in HCI, 2023 data; SCImago snapshot 2024-10-09)] https://doi.org/10.1007/s11257-024-09406-0
  5. R16. Huszár, F., Ktena, S. I., O'Brien, C., Belli, L., Schlaikjer, A., & Hardt, M. (2022). Algorithmic amplification of politics on Twitter. Proceedings of the National Academy of Sciences, 119(1). [journal; Q1 (Multidisciplinary, 2022 data; SCImago snapshot 2023-11-17)] https://doi.org/10.1073/pnas.2025334119
  6. R17. Guess, A. M., Malhotra, N., Pan, J., Barberá, P., ... Tucker, J. A. (2023). How do social media feed algorithms affect attitudes and behavior in an election campaign? Science, 381. [journal; Q1 (Multidisciplinary, 2021 data; SCImago snapshot 2022-12-16)] https://doi.org/10.1126/science.abp9364
  7. R26. Kohavi, R., Longbotham, R., Sommerfield, D., & Henne, R. M. (2009). Controlled experiments on the web: Survey and practical guide. Data Mining and Knowledge Discovery, 18. [journal; Q1 (Information Systems, 2023 data; SCImago snapshot 2024-08-12)] https://doi.org/10.1007/s10618-008-0114-1
  8. R33. Nunes, I., & Jannach, D. (2017). A systematic review and taxonomy of explanations in decision support and recommender systems. User Modeling and User-Adapted Interaction, 27. [journal; Q1 (Computer Science Applications; Q2 in HCI, 2023 data; SCImago snapshot 2024-10-09)] https://doi.org/10.1007/s11257-017-9195-0
  9. R34. Zhang, Y., & Chen, X. (2020). Explainable recommendation: A survey and new perspectives. Foundations and Trends in Information Retrieval, 14. [journal; Q1 (Information Systems, 2023 data; SCImago snapshot 2024-04-16)] https://doi.org/10.1561/1500000066
  10. R38. Carter, S., Weerkamp, W., & Tsagkias, M. (2013). Microblog language identification: Overcoming the limitations of short, unedited and idiomatic text. Language Resources and Evaluation, 47. [journal; Q1 (Linguistics and Language, 2023 data; SCImago snapshot 2024-04-13)] https://doi.org/10.1007/s10579-012-9195-y
  11. R39. Jauhiainen, T., Lui, M., Zampieri, M., Baldwin, T., & Lindén, K. (2019). Automatic language identification in texts: A survey. Journal of Artificial Intelligence Research, 65. [journal; Q1 (Artificial Intelligence, 2023 data; SCImago snapshot 2024-04-22)] https://doi.org/10.1613/jair.1.11675
  12. R41. Jamatia, A., Das, A., & Gambäck, B. (2019). Deep learning-based language identification in English-Hindi-Bengali code-mixed social media corpora. Journal of Intelligent Systems, 28. [journal; Q3 (Artificial Intelligence, Information Systems, Software, 2023 data; SCImago snapshot 2025-03-08)] https://doi.org/10.1515/jisys-2017-0440
  13. R45. Sen, O., Fuad, M., Islam, M. N., Rabbi, J., Masud, M., ... Iftee, M. A. R. (2022). Bangla natural language processing: A comprehensive analysis of classical, machine learning, and deep learning-based methods. IEEE Access, 10. [journal; Q1 (Computer Science (misc.), 2023 data; SCImago snapshot 2025-03-21)] https://doi.org/10.1109/access.2022.3165563
  14. R49. Tsui, E., Wang, W. M., Cheung, C. F., & Lau, A. S. M. (2010). A concept-relationship acquisition and inference approach for hierarchical taxonomy construction from tags. Information Processing & Management, 46. [journal; Q1 (Information Systems, 2021 data; SCImago snapshot 2023-01-15)] https://doi.org/10.1016/j.ipm.2009.05.009
  15. R50. Silla, C. N., & Freitas, A. A. (2011). A survey of hierarchical classification across different application domains. Data Mining and Knowledge Discovery, 22. [journal; Q1 (Information Systems, 2023 data; SCImago snapshot 2024-08-12)] https://doi.org/10.1007/s10618-010-0175-9
  16. R51. Piao, G., & Breslin, J. G. (2018). Inferring user interests in microblogging social networks: A survey. User Modeling and User-Adapted Interaction, 28. [journal; Q1 (Computer Science Applications; Q2 in HCI, 2023 data; SCImago snapshot 2024-10-09)] https://doi.org/10.1007/s11257-018-9207-8
  17. R52. Koren, Y. (2010). Collaborative filtering with temporal dynamics. Communications of the ACM, 53. [journal; Q1 (Computer Science (misc.), 2024 data; SCImago snapshot 2025-12-08)] https://doi.org/10.1145/1721654.1721677
  18. R55. Son, L. H. (2016). Dealing with the new user cold-start problem in recommender systems: A comparative review. Information Systems, 58. [journal; Q1 (Information Systems, 2023 data; SCImago snapshot 2024-08-29)] https://doi.org/10.1016/j.is.2014.10.001
  19. R54. Rashid, A. M., Karypis, G., & Riedl, J. (2008). Learning preferences of new users in recommender systems. ACM SIGKDD Explorations Newsletter, 10. [journal] https://doi.org/10.1145/1540276.1540302
  20. R1. Schnabel, T., Bennett, P. N., Dumais, S. T., & Joachims, T. (2018). Short-term satisfaction and long-term coverage: Understanding how users tolerate algorithmic exploration. Proc. 11th ACM WSDM. [conference] https://doi.org/10.1145/3159652.3159700
  21. R2. Ziegler, C.-N., McNee, S. M., Konstan, J. A., & Lausen, G. (2005). Improving recommendation lists through topic diversification. Proc. 14th WWW. [conference] https://doi.org/10.1145/1060745.1060754
  22. R3. Su, Y., Wang, X., Le, E. Y., Liu, L., Li, Y., Lu, H., ... Chen, M. (2024). Long-term value of exploration: Measurements, findings and algorithms. Proc. 17th ACM WSDM. [conference] https://doi.org/10.1145/3616855.3635833
  23. R4. Yi, X., Wang, S.-C., He, R., Chandrasekaran, H., Wu, C., Heldt, L., Hong, L., Chen, M., & Chi, E. H. (2023). Online matching: A real-time bandit system for large-scale recommendations. Proc. 17th ACM RecSys. [conference] https://doi.org/10.1145/3604915.3608792
  24. R5. Li, L., Chu, W., Langford, J., & Schapire, R. E. (2010). A contextual-bandit approach to personalized news article recommendation. Proc. 19th WWW. [conference] https://doi.org/10.1145/1772690.1772758
  25. R6. Huang, Y., Wang, W., Zhang, L., & Xu, R. (2021). Sliding spectrum decomposition for diversified recommendation. Proc. 27th ACM SIGKDD. [conference] https://doi.org/10.1145/3447548.3467108
  26. R7. Anderson, A., Maystre, L., Anderson, I., Mehrotra, R., & Lalmas, M. (2020). Algorithmic effects on the diversity of consumption on Spotify. Proc. The Web Conference 2020. [conference] https://doi.org/10.1145/3366423.3380281
  27. R8. Carbonell, J., & Goldstein, J. (1998). The use of MMR, diversity-based reranking for reordering documents and producing summaries. Proc. 21st ACM SIGIR. [conference] https://doi.org/10.1145/290941.291025
  28. R9. Steck, H. (2018). Calibrated recommendations. Proc. 12th ACM RecSys. [conference] https://doi.org/10.1145/3240323.3240372
  29. R27. Wang, Y., Halpern, Y., Chang, S., Feng, J., Le, E. Y., ... Bi, S. (2023). Learning from negative user feedback and measuring responsiveness for sequential recommenders. Proc. 17th ACM RecSys. [conference] https://doi.org/10.1145/3604915.3610244
  30. R28. Pan, Y., Gao, C., Chang, J., Niu, Y., Song, Y., Gai, K., Jin, D., & Li, Y. (2023). Understanding and modeling passive-negative feedback for short-video sequential recommendation. Proc. 17th ACM RecSys. [conference] https://doi.org/10.1145/3604915.3608814
  31. R29. Liu, A., Wu, S., & Resnick, P. (2024). How to train your YouTube recommender to avoid unwanted videos. Proc. ICWSM, 18. [conference] https://doi.org/10.1609/icwsm.v18i1.31363
  32. R31. Eslami, M., Rickman, A., Vaccaro, K., Aleyasen, A., Vuong, A., Karahalios, K., Hamilton, K., & Sandvig, C. (2015). "I always assumed that I wasn't really that close to [her]": Reasoning about invisible algorithms in news feeds. Proc. ACM CHI 2015. [conference] https://doi.org/10.1145/2702123.2702556
  33. R35. Rudra, K., Rijhwani, S., Begum, R., Bali, K., Choudhury, M., & Ganguly, N. (2016). Understanding language preference for expression of opinion and sentiment: What do Hindi-English speakers do on Twitter? Proc. EMNLP 2016. [conference] https://doi.org/10.18653/v1/d16-1121
  34. R36. Boeker, M., & Urman, A. (2022). An empirical investigation of personalization factors on TikTok. Proc. ACM Web Conference 2022. [conference] https://doi.org/10.1145/3485447.3512102
  35. R37. Lui, M., & Baldwin, T. (2014). Accurate language identification of Twitter messages. Proc. 5th Workshop on Language Analysis for Social Media (LASM). [conference] https://doi.org/10.3115/v1/w14-1303
  36. R40. Zhang, Y., Riesa, J., Gillick, D., Bakalov, A., Baldridge, J., & Weiss, D. (2018). A fast, compact, accurate model for language identification of codemixed text. Proc. EMNLP 2018. [conference] https://doi.org/10.18653/v1/d18-1030
  37. R43. Joulin, A., Grave, E., Bojanowski, P., & Mikolov, T. (2017). Bag of tricks for efficient text classification. Proc. EACL 2017 (short papers). [conference] https://doi.org/10.18653/v1/e17-2068
  38. R47. Halpin, H., Robu, V., & Shepherd, H. (2007). The complex dynamics of collaborative tagging. Proc. 16th WWW. [conference] https://doi.org/10.1145/1242572.1242602
  39. R53. Rashid, A. M., Albert, I., Cosley, D., Lam, S. K., McNee, S. M., Konstan, J. A., & Riedl, J. (2002). Getting to know you: Learning new user preferences in recommender systems. Proc. 7th ACM IUI. [conference] https://doi.org/10.1145/502716.502737
  40. R42. Mandal, S., Das, S. D., & Das, D. (2018). Language identification of Bengali-English code-mixed data using character & phonetic based LSTM models. arXiv:1803.03859. [preprint] https://arxiv.org/abs/1803.03859
  41. R44. Joulin, A., Grave, E., Bojanowski, P., Douze, M., Jégou, H., & Mikolov, T. (2016). FastText.zip: Compressing text classification models. arXiv:1612.03651. [preprint] https://arxiv.org/abs/1612.03651
  42. R48. Treeratpituk, P., Khabsa, M., & Giles, C. L. (2013). Graph-based approach to automatic taxonomy generation (GraBTax). arXiv:1307.1718. [preprint] https://arxiv.org/abs/1307.1718
  43. R30. Meta (n.d., accessed 2026-10-17). Facebook Feed AI system (Transparency Center system card). Not peer reviewed. [documentation] https://transparency.meta.com/features/explaining-ranking/fb-feed/