У меня 23 Telegram-канала. Не потому что я такой организованный — просто постепенно добавлял, пока не понял, что читаю примерно 3% из них, а остальное листаю вниз в поисках чего-то интересного.

Проблема известная. Телеграм устроен как хронологическая лента — свежее сверху, и неважно, интересно это тебе или нет. Алгоритмов нет. Персонализации нет. Только время публикации.

Я решил это починить. Так появился CleanNews— бот, который парсит твои Telegram-каналы и выдаёт персонализированный дайджест. В этой статье расскажу, как устроен рекомендательный движок: 5 сигналов, pgvector, контрастный вектор и жизнь без обучающего датасета в 10 миллионов записей.

Задача

Формально: у пользователя N каналов, в них M постов за последние сутки. Нужно вернуть топ-K постов, которые пользователь с наибольшей вероятностью лайкнет.

На практике: N ≈ 10–30, M ≈ 50–300, K = 10. Маленькие числа. Никакого MapReduce. Никаких GPU-кластеров.

Оценки бинарны — пользователь ставит 👍 или 👎, третьего не дано.

Почему не тяжёлый ML

Честный ответ: потому что не надо.

Нейронные рекомендательные системы типа YouTube DNN или TikTok-like модели требуют тысяч оценок на пользователя только чтобы выйти из холодного старта. У нас новый пользователь — это часто 0 оценок. Пять каналов. Три поста в первом сеансе.

Более практичный подход — разложить персонализацию на понятные компоненты, каждый из которых решает конкретную проблему:

- Нет истории? Используем популярность поста и текст интересов.

- Есть несколько оценок? Уже можно строить тематический профиль.

- Есть 20+ оценок? Добавляем семантическое сходство через эмбеддинги.

Каждый компонент — это сигнал. Их пять.

Пять сигналов

Сигнал — функция (BatchPostFeatures, UserProfile) → Vec<f32>. Принимает батч постов-кандидатов и профиль пользователя, возвращает сырые скоры. Дальше движок нормализует каждый сигнал независимо и суммирует с весами.

Сигнал 1 — InteractionRateSignal

Самый простой. Объективный. Не зависит от конкретного пользователя вообще.

score(post) = reactions_total / views

Это просто engagement rate: сколько людей поставили реакцию относительно просмотров. Популярный пост в канале с 50k подписчиков с IR = 0.08 заслуживает внимания вне зависимости от того, кто смотрит.

Тонкость: ноль в IR — это не «пост непопулярный», а «пост только что опубликован, просмотров ещё нет». Такие посты заменяем медианой ненулевых значений по батчу. Если весь батч с нулями — оставляем нули, пусть равноправно конкурируют.

Этот сигнал работает на холодном старте. Всегда.

Сигнал 2 — ContentPerChannelSignal

Персонализированный. Учитывает, какой формат контента нравится пользователю.

Признаки поста: длина текста (короткий/средний/длинный), тип медиа (нет/фото/видео/документ), наличие цитат, прогнозов, мнений, количество ссылок и цифр.

Для каждого признака считаем предпочтение через байесовское сглаживание:

Pref(feature = value) = [n_liked(f=v) + α·p_like] / [n(f=v) + α]  −  p_like

где α = 2.0 (лапласовское сглаживание), p_like = count(rating==5) / n_rated  — базовая вероятность лайка

Сглаживание критично при малых выборках. Если видели длинные посты только 3 раза, и все 3 лайкнули — это не «пользователь обожает длинные посты», это шум. Лапласовское сглаживание притягивает оценку к базовой p_like пропорционально уверенности.

Отдельная фишка — канальные профили. Если в канале «Новости экономики» пользователь оценил 20+ постов, строим отдельный профиль именно для этого канала. Потому что вкус внутри одного канала может отличаться от глобального.

Интерполяция между глобальным и канальным профилем:

reliability = min(channel_ratings / 20, 1.0)

score = (1 − reliability) · score_global + reliability · score_channel

При 0 оценок в канале — только глобальный. При 20+ — только канальный.

Сигнал 3 — EmbeddingSimilaritySignal

Вот здесь начинается интересное.

Каждый пост эмбеддируется моделью BAAI/bge-m3 (1024-мерное пространство, мультиязычная). Та же модель эмбеддирует интересы пользователя и его историю оценок.

Идея: если пользователь лайкает посты про AI-стартапы и дизлайкает посты про политику, в пространстве эмбеддингов это должно выражаться как направление «туда» и «сюда».

Первый наивный подход — взять центроид всех лайкнутых постов и считать косинусное сходство с кандидатами. Не работает.

Проблема: cosine similarity между центроидом лайков и центроидом дизлайков у нас ≈ 0.985. Оба кластера — «новости». Оба плотные. Лайки немного больше про технологии, дизлайки немного больше про политику — но это «немного» теряется в высокоразмерном пространстве.

Решение — контрастный вектор:

liked_centroid = normalize(Σ_{r=5} e_i)

disliked_centroid = normalize(Σ_{r≤2} e_i · weight(r))

weight(r=1) = 2,  weight(r=2) = 1

E_profile = normalize(liked_centroid − disliked_centroid)

Вычитаем направление дизлайков из направления лайков. Получаем вектор, который указывает именно туда, что отличает лайки от дизлайков, а не в «среднюю новость».

Рейтинги 3 и 4 игнорируем — нейтральные, предпочтения не выражают.

Скоринг кандидата:

score(post) = dot(e_post, E_profile)

При L2-нормализованных векторах это косинусное сходство ∈ [−1, 1]. Пост в направлении дизлайков получает отрицательный сырой скор — это нормально, движок нормализует.

Холодный старт: нет лайков → нет профиля → сигнал возвращает нули. Работает только при наличии хоть какой-то истории.

Сигнал 4 — UserTagsSignal

Тематический. Каждый пост размечен тегами из фиксированного словаря 15 категорий: НАУКА, ТЕХНОЛОГИИ, ПОЛИТИКА, ЭКОНОМИКА и т.д.

Профиль строится аналогично ContentPerChannel, но для тегов:

TagPref(t) = P(like | t ∈ tags(post)) − p_like

Дополнительно: пользователь может явно указать теги, которые хочет видеть чаще, или наоборот — исключить. Явные предпочтения добавляются поверх инфeрированных:

profile[tag] += 1.0   // явный лайк-тег

profile[tag] -= 1.0   // явный дизлайк-тег

Пример: тег ПОЛИТИКА встречался только в дизлайках → TagPref = -0.3. Но пользователь явно добавил ПОЛИТИКА в «буст» → TagPref = -0.3 + 1.0 = +0.7. Явное перекрыло инфeрированное.

Этот сигнал работает на холодном старте, если пользователь выставил явные теги при регистрации.

Сигнал 5 — InterestSimilaritySignal

Тоже работает без истории оценок — и это намеренно.

При регистрации пользователь пишет свои интересы произвольным текстом: «AI, стартапы, климатические технологии». Этот текст эмбеддируется той же BAAI/bge-m3, вектор сохраняется в PostgreSQL.

score(post) = dot(e_post, interest_vector)

Семантически близкие к тексту интересов посты получают высокий скор — без единой оценки в истории.

Разница с EmbeddingSimilaritySignal:

EmbeddingSimilarity

InterestSimilarity

Источник

История оценок

Текст интересов

Учитывает дизлайки

Да (контрастный вектор)

Нет

Холодный старт

Нет

Да

Обновляется

При каждой оценке

При изменении профиля

Вместе два сигнала дополняют друг друга: InterestSimilarity запускает персонализацию с нуля, EmbeddingSimilarity уточняет её через реальные оценки.

Холодный старт

Отдельно стоит разобрать, что происходит при n_rated == 0:

Сигнал

Работает?

InteractionRateSignal

✅ Всегда

ContentPerChannelSignal

❌ Нет истории

EmbeddingSimilaritySignal

❌ Нет лайков

UserTagsSignal

✅ Если есть явные теги

InterestSimilaritySignal

✅ Если задан текст интересов

Без тегов и интересов при нулевой истории ранжирование = популярность. Это честно и понятно пользователю.

С заполненными интересами — с первого поста лента уже персонализирована семантически. Именно поэтому онбординг в боте просит написать интересы: это не декорация, это включение реального сигнала.

Реализация: Rust и параллельные запросы

Сам recsys написан на Rust (axum + tokio-postgres). Главная причина — SLA на ответ ≈ 20ms, и хотелось статической типизации generics для DI без vtable-оверхеда.

На один HTTP-запрос к /api/v1/recommendations — до 7 параллельных SQL-запросов через tokio::try_join!:

// 4 источника параллельно

let (positive_only, rated, explicit, interests) = tokio::try_join!(

    prefs_service.fetch_positive_only(user_id),

    rated_service.fetch_all(user_id, rated_start, rated_end),

    explicit_service.fetch(user_id),

    interests_service.fetch(user_id),

)?;

// посты отдельно — им нужен positive_only из prefs

let posts = post_service

    .fetch_all(user_id, candidates_start, candidates_end, positive_only)

    .await?;

Внутри post_service.fetch_all тоже try_join! — content, embeddings, tags и IR грузятся параллельно.

Признаки хранятся не как Vec<Post>, а как параллельные векторы (columnar storage):

// НЕ так:

struct Post { post_id: i64, length: LengthCategory, embedding: Vec<f32>, ... }

// А так:

struct PostFeaturesSnapshot {

    post_ids:  Vec<i64>,

    lengths:   Vec<LengthCategory>,

    embeddings: Vec<Vec<f32>>,  // все эмбеддинги батча

}

Сигналы работают поэлементно по одному полю — CPU-кеш не промахивается, SIMD-оптимизации применяются сами.

Типичный duration_ms в ответе: 12–25ms.

Что не сработало и что сделали бы иначе

Embeddings через Python-сервис. Сначала вычисляли BAAI/bge-m3 на CPU в отдельном Python-сервисе. Медленно. Сейчас батчинг, но всё равно — узкое место при парсинге большого канала впервые.

Нормализация min-max внутри батча. Работает, но при маленьких батчах (5 постов) нормализация превращает небольшой разброс в [0, 1] и теряет уверенность. Лучше было бы хранить статистику распределения скоров по хистории и нормализовать относительно неё.

Веса сигналов. Сейчас фиксированные. Хочется A/B тестирование или хотя бы Bayesian Optimization по метрике retention — но это задача, когда пользователей станет больше.

Итого

Система персонализации без BigData — это выбор правильного масштаба инструмента. Пять сигналов, pgvector в PostgreSQL, контрастный вектор вместо наивного центроида — и лента начинает отличать «мои» посты от «не моих» после 10–15 оценок.

Для маленького продукта это работает. Когда пользователей станет больше, можно переходить к более тяжёлым подходам — основа уже заложена через архитектуру с портами (можно подменить любой сигнал).

Попробовать можно здесь: https://t.me/noiseoffbot