Feed internals¶
Building a feed¶
flowchart LR
Q["POST /v1/feed<br/>channels"] --> U[unique channels]
U --> H[popularity.hit]
U --> P{cached page age}
P -- "< FEED_FRESH_TTL" --> USE[use]
P -- "< FEED_STALE_TTL" --> USE2[use + refresh in background]
P -- "older / missing" --> FETCH[fetch now, all in parallel]
USE & USE2 & FETCH --> B["build_feed (Rust, GIL released)<br/>signals · rank · render"]
B --> OUT[response bytes]
- Channels are de-duplicated (order kept) and counted in the popularity ranking.
- All pages are resolved concurrently with the rules above. Each page is used as the bytes the page cache holds; a page fetched now comes with its cache encoding, so nothing is decoded or encoded in Python.
-
telegram_me_native.build_feed()— Rust,rust/telegram-me-native/src/feed.rs— runs on a worker thread with the GIL released and does the rest in one call:- reads every page in one pass, keeping each post as the JSON text it was cached as, and drops a post already seen under the same username;
- picks the ranking signals out of each post's footer and content: parsed
view counter (
1.2K,15 400), channel subscribers, positive and negative reaction totals, media counts, plain text, publication time, edited flag; - ranks them (
rank.rs, below); - writes the response: every post with its
channel,_scoreand, when they apply,_duplicatesand_topic.
A page that is not valid JSON (a corrupted cache entry) is left out and logged. GraphQL's
feeddecodes the same bytes.
Ranking¶
Everything below happens in Rust.
Text¶
Texts are tokenised into lower-cased alphanumeric words; URLs, mentions, numbers, short words and Russian/Ukrainian/English stop words are dropped. Words are stemmed by removing a common Cyrillic inflection ending and keeping the first five characters ("ракеты", "ракетой" → "ракет"), which matches inflected forms without any language model.
Quality¶
| Signal | How |
|---|---|
| Engagement (weight 0.45) | Views are projected to their eventual value from the post's age (views / (1 − e^(−age/24h))) and compared, in log scale, with the channel's other posts in the batch: a robust z-score using the median and the median absolute deviation, squashed with a sigmoid. A post that does unusually well for its channel scores high whatever the channel's size. Channels with fewer than three posts in the batch are compared with all posts on views per subscriber instead. |
| Reactions (0.20) | Net positive reactions per view (Telegram Stars count as positive), saturating at 2 %. |
| Content (0.15) | Media (photos + 2·videos + gifs + 2·polls, log-scaled) and text length. |
| Audience (0.10) | Log of subscribers relative to the largest channel in the batch. |
| Sentiment (0.10) | One minus the share of negative reactions. |
| Edited (+0.02) | Small bonus. |
Freshness¶
freshness = 0.5 ^ (age_hours / FEED_HALF_LIFE_HOURS) (default 24 h)
score = 10 · quality · (0.1 + 0.9 · freshness) ∈ [0, 10]
Duplicates¶
The same story is often posted by several channels, sometimes with an added
headline or signature. Every text with at least ten words gets a 64-bit
SimHash of its three-word shingles and the set of those shingles. Two posts
are duplicates when their SimHash values differ in at most 20 bits (a cheap
pre-filter) and at least 70 % of the shorter text's shingles occur in the
other (containment). Duplicates are grouped transitively (union-find); the
best-scoring post of each group stays in the feed and lists the others in
_duplicates. Disable with
FEED_COLLAPSE_DUPLICATES=false.
Diversity¶
Posts are placed greedily. The adjusted score of a candidate is
score · (1 − d · max cosine similarity to the last 10 placed posts)
· (1 − d/2) ^ (posts of the same channel among the last 5 placed)
with d = FEED_DIVERSITY (default
0.5; 0 gives plain score order). Similarity is the cosine of TF-IDF vectors.
Because penalties never raise a score, candidates are visited in base-score
order and the scan stops as soon as no remaining base score can beat the best
adjusted one, so the result is exact without comparing every pair at every
step.
Freshness still dominates: a channel that posted hours ago will fill most of the top of a feed whose other channels posted weeks ago — diversity spreads posts of comparable value, it does not promote stale ones.
Topics¶
Telegram does not expose channel categories, so subjects are inferred from the
texts: TF-IDF vectors over the batch (smoothed IDF), clustered greedily in
feed order — a post joins the most similar topic when the cosine similarity
to its centroid is at least 0.3, otherwise it starts a new one. Topics with at
least two posts are reported on their posts as
_topic: {"id": …, "keywords": […]}; keywords are the heaviest centroid terms
in their most frequent written form.
Performance¶
Tokenisation and the duplicate search run in parallel on all cores (rayon);
hash tables use FxHash. Measured with
cargo run --release --example bench (Apple Silicon, 30-word texts):
| Posts | Ranking time |
|---|---|
| 120 (a 6-channel feed) | ~1 ms |
| 500 | ~4 ms |
| 2 000 (100 channels) | 17–24 ms |
A whole 100-channel feed (reading the pages, the signals, ranking and the
response) takes 43 ms on 8 cores and 106 ms of CPU on one in
scripts/audit.py, whose aliases make most stories duplicates; see the
Rust roadmap. It runs on a worker thread
with the GIL released, so the event loop keeps serving other requests
meanwhile.
Popularity and the refresher¶
Each feed request adds 1 to the score of every channel it contains. Scores
decay exponentially with FEED_POPULARITY_HALF_LIFE (1 hour): every minute
the refresher multiplies all scores by 0.5^(elapsed / half-life) and forgets
channels below 0.05. With Redis the ranking is a sorted set shared by all
instances (ZINCRBY, ZUNIONSTORE … WEIGHTS).
Every 5 seconds the refresher:
- takes or renews leadership (
SET … NX PX 30000with a random token, renewed by a Lua script that checks the token) — only the leader continues; - reads the
FEED_REFRESH_TOPmost popular channels; -
refreshes each channel whose last refresh is older than
interval(score) = clamp(FEED_REFRESH_MAX_INTERVAL / (1 + score), FEED_REFRESH_MIN_INTERVAL, FEED_REFRESH_MAX_INTERVAL)— a channel requested ~20 times per half-life is refreshed every 30 s, a channel requested once every 10 minutes. At most 16 channels are refreshed at a time, so a tick leaves upstream slots to user requests and does not flood the parser and Redis.
Leadership moves automatically when the leader stops (it releases the lock) or dies (the lock expires within 30 s). Without Redis every process is its own leader for its own memory cache.
Refreshed pages land in the shared page cache, so the next feed request — on any server — finds them fresh.