Персонализация без Big Data: как мы ранжируем новости в Telegram с помощью pgvector и пяти сигналов

от автора

У меня накопилось 23 Telegram-канала.

Не потому что я специально их коллекционировал. Просто со временем подписывался на новые. В какой-то момент понял, что читаю всего несколько процентов того, что приходит. Остальное просто пролистываю в поисках чего-то интересного.

Проблема здесь простая. Telegram показывает посты только по времени публикации. Самые новые всегда наверху. Неважно, интересна тебе тема или нет. Никакой персонализации нет. Есть только хронология.

Из-за этого появился CleanNews — бот, который собирает посты из ваших Telegram-каналов и формирует персональную подборку.

В этой статье я покажу, как работает его рекомендательный движок. В основе — пять независимых сигналов, pgvector и контрастный вектор. И всё это без огромного обучающего датасета на миллионы записей.

Задача

Формально задача выглядит так.

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

На практике числа небольшие:

  • N ≈ 10–30 каналов;

  • M ≈ 50–300 новых постов;

  • K = 10 рекомендаций.

Это совсем не тот масштаб, где нужны MapReduce или GPU-кластеры. Оценки тоже максимально простые. Пользователь либо ставит 👍, либо 👎. Других вариантов нет.

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

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

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

Каждый сигнал — это отдельная функция.

Она получает список постов-кандидатов и профиль пользователя, а затем выставляет каждому посту свою оценку.

Дальше всё просто. Движок отдельно нормализует оценки каждого сигнала, применяет веса и складывает их. В итоге получается общий рейтинг постов.

Сигнал №1 — InteractionRateSignal

Это самый простой сигнал.

Он вообще не учитывает конкретного пользователя. Только то, насколько пост оказался интересен аудитории канала.

Формула выглядит так:

\mathrm{score}(\mathrm{post}) = \frac{\mathrm{reactions\_total}}{\mathrm{views}}

Это обычный engagement rate — отношение количества реакций к числу просмотров.

Например, если у канала 50 тысяч подписчиков и пост набрал высокий engagement rate, скорее всего, он действительно интересный. Такой пост стоит показать независимо от предпочтений конкретного пользователя.

Есть одна проблема. Если пост только что вышел, просмотров у него ещё нет. Тогда формула даст ноль. Но это не означает, что пост плохой. Просто его ещё никто не успел посмотреть. Если у поста нулевой IR, подставляем медианное ненулевое значение среди остальных постов в текущем батче. Если весь батч состоит из новых постов без просмотров, оставляем нули. Тогда все они находятся в одинаковых условиях и конкурируют между собой честно. Главный плюс этого сигнала в том, что он работает сразу. Даже если пользователь только зарегистрировался и ещё ничего не оценил.

Сигнал №2 — ContentPerChannelSignal

Теперь появляется персонализация.

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

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

  • длина текста (короткий, средний или длинный);

  • тип медиа (нет, фото, видео, документ);

  • есть ли цитаты;

  • есть ли прогнозы;

  • есть ли авторское мнение;

  • сколько ссылок;

  • сколько чисел в тексте.

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

Используем байесовское сглаживание:

\mathrm{Pref}(\mathrm{feature}=v)= \frac{n_{\mathrm{liked}}(f=v)+\alpha\,p_{\mathrm{like}}} {n(f=v)+\alpha} - p_{\mathrm{like}}

где

\begin{aligned} \alpha &= 2.0,\\ p_{\mathrm{like}} &= \frac{\operatorname{count}(\mathrm{rating}=5)} {n_{\mathrm{rated}}}. \end{aligned}

Здесь plike — базовая вероятность того, что пользователь вообще поставит лайк.

Сглаживание здесь очень важно. Представьте, что пользователь видел всего три длинных поста. И все три лайкнул. Без сглаживания можно сделать вывод, что длинные посты ему очень нравятся. Но три примера — слишком маленькая выборка. Это вполне может быть случайностью. Лапласовское сглаживание не позволяет делать такие поспешные выводы. Пока данных мало, оценка остаётся ближе к общей вероятности лайка. И только когда истории становится больше, модель начинает сильнее доверять наблюдениям.

Есть ещё одна особенность. Кроме общего профиля пользователя мы можем строить профиль отдельно для каждого канала. Это нужно потому, что предпочтения могут отличаться. Например, человек любит короткие новости в одном канале, но читает длинные аналитические материалы в другом. Если пользователь поставил больше двадцати оценок внутри одного канала, появляется достаточно данных, чтобы использовать отдельный профиль именно для него.

Дальше мы плавно объединяем глобальный профиль и профиль канала.

Сначала считаем надёжность канального профиля:

\mathrm{reliability}= \min\left( \frac{\mathrm{channel\_ratings}}{20}, 1.0 \right)

Затем вычисляем итоговую оценку:

\mathrm{score} = (1-\mathrm{reliability}) \cdot \mathrm{score}_{\mathrm{global}} + \mathrm{reliability} \cdot \mathrm{score}_{\mathrm{channel}}

Если оценок внутри канала ещё нет, используется только глобальный профиль. Когда оценок становится двадцать или больше, система полностью переходит на профиль этого канала. Так получается учитывать не только общие предпочтения пользователя, но и особенности каждого источника.

Сигнал №3 — EmbeddingSimilaritySignal

Здесь начинается семантическое сравнение.

Каждый пост преобразуется в эмбеддинг с помощью модели BAAI/bge-m3. Она строит вектор размерностью 1024 и поддерживает несколько языков. Этой же моделью мы получаем эмбеддинги интересов пользователя и всех постов, которые он уже оценил. Идея простая: если пользователь регулярно лайкает посты про AI-стартапы и ставит дизлайки политическим новостям, это должно отражаться как разные направления в пространстве эмбеддингов.

Первое решение кажется очевидным — взять центроид всех лайкнутых постов и считать косинусное сходство с каждым кандидатом. На практике такой подход почти не работает. В нашем случае косинусное сходство между центроидами лайков и дизлайков оказалось около 0.985. Причина простая: и те, и другие посты всё равно остаются новостями. Одни чаще про технологии, другие — про политику, но эта разница почти исчезает в высокоразмерном пространстве.

Поэтому вместо обычного центроида мы строим контрастный вектор. Сначала вычисляем центр лайков

\mathbf{liked\_centroid} = \operatorname{normalize} \left( \sum_{r=5}\mathbf{e}_i \right)

Затем центр дизлайков

\mathbf{disliked\_centroid} = \operatorname{normalize} \left( \sum_{r\le2} \mathbf{e}_i\cdot\operatorname{weight}(r) \right)

где

\operatorname{weight}(1)=2,\qquad \operatorname{weight}(2)=1.

После этого строим профиль пользователя:

\mathbf{E}_{\mathrm{profile}} = \operatorname{normalize} \left( \mathbf{liked\_centroid} - \mathbf{disliked\_centroid} \right)

Мы не просто ищем направление к понравившимся постам, а вычитаем направление дизлайков. Благодаря этому профиль начинает отражать именно различия между тем, что пользователь любит и не любит, а не усреднённую «новость вообще». Оценки 3 и 4 мы не используем — они считаются нейтральными и почти не говорят о предпочтениях пользователя.

Оценка нового поста считается так:

\mathrm{score}(\mathrm{post}) = \mathbf{e}_{\mathrm{post}} \cdot \mathbf{E}_{\mathrm{profile}}

Так как все эмбеддинги заранее L2-нормализованы, скалярное произведение эквивалентно косинусному сходству и лежит в диапазоне [-1, 1]. Если пост оказывается ближе к направлению дизлайков, он получает отрицательный скор. Это нормально — на следующем этапе все сигналы всё равно нормализуются перед объединением. Если истории лайков ещё нет, профиль построить невозможно, поэтому сигнал просто возвращает нули.

Сигнал 4 — UserTagsSignal

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

Предпочтение для тега считается так:

\mathrm{TagPref}(t) = P(\mathrm{like}\mid t\in\mathrm{tags}(\mathrm{post})) - p_{\mathrm{like}}

То есть мы смотрим, насколько наличие конкретного тега увеличивает или уменьшает вероятность лайка по сравнению со средней вероятностью лайка пользователя.

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

\mathrm{profile}[\mathrm{tag}] += 1.0

а для нежелательных уменьшаем

\mathrm{profile}[\mathrm{tag}] -= 1.0

Например, если тег ПОЛИТИКА раньше встречался только среди дизлайков, модель могла вычислить TagPref = -0.3. Но если пользователь явно добавил этот тег в список интересов, итоговое значение станет 0.7. В таком случае явный выбор пользователя полностью перекрывает выводы модели.

Этот сигнал тоже помогает при холодном старте. Если пользователь выбрал интересующие темы во время регистрации, рекомендации начинают учитывать их ещё до появления первых оценок.

Это уже ближе к стилю хороших технических статей: меньше дробления, меньше разговорных фраз и более плотная подача материала.

Сигнал 5 — InterestSimilaritySignal

Этот сигнал тоже работает без истории оценок, и это сделано специально.

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

Дальше всё просто. Для каждого поста вычисляется косинусное сходство между его эмбеддингом и вектором интересов пользователя:

\mathrm{score}(\mathrm{post}) = \mathbf{e}_{\mathrm{post}} \cdot \mathbf{interest\_vector}

Чем ближе пост к интересам пользователя в семантическом пространстве, тем выше его оценка. При этом системе не нужны предыдущие лайки или дизлайки — достаточно текста, который пользователь указал при регистрации.

На первый взгляд этот сигнал похож на EmbeddingSimilaritySignal, но между ними есть важное отличие.

EmbeddingSimilarity

InterestSimilarity

Источник данных — история оценок пользователя

Источник данных — текст интересов

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

Использует только интересы

Не работает при холодном старте

Работает сразу после регистрации

Обновляется после каждой новой оценки

Обновляется только при изменении профиля

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

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

Отдельно стоит разобрать ситуацию, когда пользователь ещё не поставил ни одной оценки, то есть n_rated = 0. В этом случае разные сигналы работают по-разному.

Сигнал

Работает?

InteractionRateSignal

✅ Всегда

ContentPerChannelSignal

❌ Нет истории

EmbeddingSimilaritySignal

❌ Нет лайков

UserTagsSignal

✅ Если пользователь выбрал теги

InterestSimilaritySignal

✅ Если пользователь указал интересы

Если пользователь не заполнил интересы и не выбрал теги, ранжирование строится только на популярности постов. Это простое и понятное поведение.

Если же интересы заполнены, персонализация начинает работать уже с первого запуска. Именно поэтому во время регистрации бот просит рассказать о своих интересах. Это не просто элемент онбординга, а полноценный источник данных для рекомендательной системы.

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

Сам рекомендательный сервис написан на Rust с использованием axum и tokio-postgres. Главная причина такого выбора — требование отвечать примерно за 20 мс. Кроме того, хотелось использовать статическую типизацию и generics без дополнительного overhead от виртуальных таблиц.

Во время обработки запроса к /api/v1/recommendations сервис одновременно выполняет до семи SQL-запросов. Вместо последовательного выполнения используется tokio::try_join!, который позволяет загружать независимые данные параллельно:

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),)?;

После этого загружаются сами посты:

let posts = post_service    .fetch_all(user_id, candidates_start, candidates_end, positive_only)    .await?;

Внутри post_service.fetch_all также используется try_join!: признаки контента, эмбеддинги, теги и статистика взаимодействий запрашиваются параллельно. Это позволяет сократить общее время ожидания без усложнения логики.

Ещё одно решение касается хранения данных. Вместо массива структур вида

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

используется структура с отдельными массивами для каждого признака:

struct PostFeaturesSnapshot {    post_ids: Vec<i64>,    lengths: Vec<LengthCategory>,    embeddings: Vec<Vec<f32>>,}

Такой формат лучше подходит для вычислений. Каждый сигнал работает только с нужным ему массивом, процессор эффективнее использует кэш, а компилятор может автоматически применять SIMD-оптимизации. В результате типичное время ответа сервиса составляет 12–25 мс.

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

Не все решения оказались удачными. Некоторые вещи со временем пришлось пересмотреть.

Эмбеддинги через Python-сервис

Изначально эмбеддинги с помощью BAAI/bge-m3 вычислялись в отдельном Python-сервисе на CPU.

Такой вариант оказался медленным. Позже мы добавили батчинг, и производительность выросла, но полностью проблему это не решило. При первом разборе большого Telegram-канала вычисление эмбеддингов по-прежнему остаётся самым узким местом системы.

Нормализация внутри батча

Сейчас каждый сигнал нормализуется с помощью min-max только внутри текущего батча.

В большинстве случаев этого достаточно, но есть неприятный побочный эффект. Если в батче всего несколько постов, например пять, даже небольшой разброс между оценками растягивается на весь диапазон [0, 1]. Из-за этого система выглядит более уверенной в своих оценках, чем есть на самом деле.

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

Веса сигналов

Сейчас все веса сигналов заданы вручную.

Это работает, пока пользователей немного, но со временем хочется перейти к более гибкому подходу. Например, можно подбирать веса с помощью A/B-тестов или Bayesian Optimization, используя в качестве целевой метрики удержание пользователей. Пока аудитория небольшая, такая настройка просто не окупится.

Итоги

Главная идея этого проекта — подбирать инструмент под масштаб задачи.

Если у вас десятки каналов и несколько сотен новых постов в день, совсем не обязательно строить сложную рекомендательную систему с тяжёлым машинным обучением. В нашем случае оказалось достаточно пяти независимых сигналов, pgvector в PostgreSQL и контрастного вектора вместо обычного центроида. Уже после 10–15 оценок лента начинает достаточно хорошо отличать интересные пользователю посты от остальных.

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

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

ссылка на оригинал статьи https://habr.com/ru/articles/1059600/