У меня 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
