В прошлой статье я рассказывал, как устроена рекомендательная система в нашем Telegram‑боте @noiseoffbot. Особенно внимательный читатель заметил «маленькую» несостоятельность одного из рекомендательных сигналов, а конкретно, вектора интересов пользователя. Именно о нем мы сегодня и поговорим.

Раньше пользователь мог написать в боте что‑то вроде:

спорт, финансы

или:

IT

или:

квантовая физика, рецепты

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

Если интерес один — например, «IT», — вектор получается довольно общим. В нём просто мало информации.

А если интересов несколько и они сильно отличаются друг от друга, ситуация ещё интереснее. Что общего у «спорта», «машинного обучения» и «рецептов борща»? Для человека ответ очевиден: это просто три разных интереса.

Но для эмбеддера мы склеиваем их в один текст и получаем одну точку в пространстве.

Получается что‑то вроде среднего между всеми темами. И вполне может оказаться, что эта средняя точка не соответствует ни одной из них.

На этом месте можно было бы сказать: «Ну да, вектор плохой, давайте несколько векторов».

Но как же без Data Driven. А насколько хорошо текущий вектор вообще работает? С этого момента и родился материал для новой статьи.

Сначала линейка

Понять, стал ли какой‑то вариант лучше, без baseline довольно сложно.

У нас уже была история оценок пользователей: какие посты они лайкали 👍, а какие дизлайкали 👎.

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

Тогда возьмем ROC‑AUC. AUC отвечает на вопрос: как часто вектор интересов поставит лайкнутому посту более высокий балл, чем дизлайкнутому?

0.5 — примерно случайное угадывание.

1.0 — идеальный порядок.

Однако у AUC есть особенность. Она перебирает все пары постов и считает все ошибки одинаково. Перепутать посты на третьем и четвёртом месте — то же самое, что перепутать на трёхсотом и четырёхсотом.

А человек открывает ленту и видит первые несколько постов. Всё, что ниже, он просто не долистает.

Поэтому рядом с AUC считаем ещё две метрики, обе только по первой десятке:

  • P@10 — сколько постов из первых десяти оказались лайкнутыми. Просто доля попаданий.

  • NDCG@10 — то же самое, но с учётом позиции. Попадание на первом месте весит больше, чем на десятом.

Получается два разных взгляда. AUC говорит, насколько вектор в принципе отличает интересное от неинтересного. NDCG и P@10 — что человек увидит на самом деле.

Про данные

На момент эксперимента в боте было 69 пользователей.

Из них 14 человек указали свои интересы текстом.

Важно, что для оценки вектора интересов нам нужны сразу две вещи:

  1. пользователь должен указать интересы;

  2. у пользователя должна быть история оценок постов.

В итоге для эксперимента использовали 13 пользователей.

На них пришлось 2213 пар оценок — лайкнутый пост против дизлайкнутого.

Почти для всех этих постов уже были рассчитаны эмбеддинги: 2207 из 2213.

Получается такая воронка:

все пользователи 69 
  │ 
  └── указали интересы 14 
      │ 
      └── есть история для оценки 13 
          │ 
          └── оценённых пар лайк/дизлайк 2213

Первый прогон

per-user AUC:   0.5174
NDCG@10:        0.5896
P@10:           0.5867

Почти монетка.

Но среднее тут мало что говорит. Интереснее по сегментам.

У тех, кто написал интересы коротко — «IT», “спорт” — AUC вышел 0.41.

Это ниже 0.5. Хуже случайного угадывания.

То есть вектор не просто бесполезен. Он ставит неинтересные посты выше интересных.

Объяснение простое: одно слово даёт слишком общую точку. Написал «IT» — получил акции IT‑компаний и корпоративные слияния. Тема совпала, интерес нет.

Вот как это выглядело в ленте у человека, который написал одно слово «IT»:

Интерес: «IT»

Что показывал baseline:
  • Акции Nvidia выросли на 4% после отчёта
  • Microsoft закрывает сделку по покупке студии
  • IT-компании просят продлить налоговые льготы
  • Рынок труда в IT: зарплаты замедлили рост

Формально всё про IT. По факту человек хотел читать не про это.

У тех, кто написал много разных тем, чуть лучше. Но тоже ничего хорошего.

Итого линейка: 0.5174 в среднем, 0.41 на коротких интересах. С этим и сравниваем дальше.

Гипотезы

Каждая гипотеза далее — отдельный способ построить вектор или использовать его.

А мульти‑вектор

Самое очевидное. Не склеивать интересы в один текст, а резать на части.

Было так: "спорт, ML, борщ" → эмбеддер → один вектор на 1024 измерения.

Стало так: три отдельных текста → три вектора. Хранятся набором.

Скор поста считаем не по среднему, а по максимуму:

score(post) = max( cos(post, v_спорт),
                   cos(post, v_ML),
                   cos(post, v_борщ) )

Смысл в том, что пост про футбол не должен получать штраф за то, что человек ещё и борщ любит. Достаточно совпасть с одним интересом из набора.

Побочный эффект — вектор больше не живёт в семантической пустоте между темами. Каждая точка стоит там, где реально что‑то есть.

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

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

J обогащение похожими постами

Лечит, когда человек написал интерес в одно слово.

Решение подсмотрено у поисковиков, называется pseudo‑relevance feedback. Логика такая: раз мы не знаем, что человек имел в виду, посмотрим, что в нашей базе вообще похоже на «IT».

По шагам:

  1. Считаем обычный вектор от текста интереса.

  2. Ищем в базе K ближайших постов. У нас K=10.

  3. Берём их эмбеддинги и подмешиваем к исходному вектору.

  4. Нормализуем.

v_final = normalize( α·v_текст + (1−α)·centroid(top-K постов) )

Таким образом, вектор перестаёт быть абстрактной точкой «про IT вообще» и переезжает туда, где живёт реальный контент нашей ленты.

Про параметры. K=10 и α=0.7 взяли не из воздуха, но и не подбирали по сетке.

Логика была такая: при малом K обогащение упирается в один‑два случайных поста, при большом — вектор уползает к среднему по ленте. Десять постов дают устойчивый центроид и при этом остаются в теме. α держит основной вес на том, что человек написал сам. Обогащение — добавка, а не замена. ЭТО ручной выбор, подбирать гиперпараметры на 13 пользователях смысла нет — просто запомним шум.

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

Отдельно мерили дрейф — насколько далеко вектор уехал от исходного текста. Считаем как косинусное расстояние между вектором «до» и «после»: 1 − cos(v_текст, v_final).

Ноль — вектор не сдвинулся. Единица — уехал совсем в другое место.

У J дрейф вышел 0.10. Точка сдвинулась, но осталась в окрестности исходного смысла.

И тут главный вопрос к методу: Мы обогащаем вектор постами из базы. А потом на постах из этой же базы меряем AUC.

Если в top‑K для обогащения попадёт пост, который пользователь лайкнул и который лежит в тестовой выборке — метрика взлетит по построению. Вектор буквально подтянут к правильному ответу. Поэтому при обогащении мы исключаем из кандидатов все посты, которые этот пользователь когда‑либо оценивал. Обогащение идёт только по постам, которых не было в его истории. Без этого фильтра цифры получались заметно выше, и это была бы не метрика))

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

I привязка к тегам

У нас все посты уже размечены тегами. Логично попробовать использовать эту разметку.

Идея: не работать с векторами вообще, а сопоставить текст интересов с нашей таксономией. «Спорт» → тег sport, “крипта” → тег crypto. Дальше пост получает прибавку, если его теги пересеклись с тегами пользователя.

Плюс в том, что сигнал становится понятным. Можно посмотреть глазами и объяснить, почему пост попал в выдачу.

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

Вектор хотя бы отличает пост про марафон от поста про трансферы в футболе. Тег sport — нет.

B обогащение через LLM

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

На вход подаём текст интересов, на выход просим список смежных тем и ключевых слов. «IT» превращается в «программирование, разработка, гаджеты, технологии, стартапы».

Дальше всё как раньше: склеиваем расширенный текст и считаем один вектор.

Работает это потому, что модель знает про мир больше, чем наша база постов. Она подтягивает те формулировки, которые человек имел в виду, но не стал писать.

«Но», постоянное «но» при работе с LLM. Модель может дописать то, чего человек не заявлял. Написал «финансы» — получил «криптовалюты, трейдинг, NFT». Вроде рядом, а вроде и нет. Мы мерили дрейф вектора, и у B он вышел 0.28. Для сравнения, у J — 0.10. То есть вектор уезжает от исходного текста втрое дальше.

Поэтому к B пришлось делать отдельную проверку верности. Без неё LLM легко выигрывает метрику, просто подмешав популярные темы.

Ещё одна цена — это вызов модели в момент сохранения интересов. Таймауты, ретраи, деньги за токены.

B+A LLM плюс мульти‑вектор

А что если LLM не расширяет текст в одну кучу, а разбивает интересы на под‑темы? И к каждой под‑теме генерит свои ключевые слова.

Дальше на каждую под‑тему делаем отдельный вектор. То есть B, а поверх него A.

Получается вот что. Из «IT» модель делает три под‑темы: разработка, железо, индустрия. Каждая становится своей точкой в пространстве.

Разница с обычным B принципиальная. В B все дописанные слова снова усредняются в один вектор, и мы возвращаемся к той же проблеме, с которой начинали. В B+A они остаются раздельными.

Важно, что минусы тут такие же, что и для B.

Результаты

Вариант

Что делает

AUC

Δ

NDCG@10

P@10

baseline

один вектор на всё

0.5174

0.5896

0.5867

I

привязка к тегам

0.5102

−0.0072

0.5612

0.5603

A

мульти‑вектор

0.6213

+0.1039

0.6823

0.6701

B

LLM в один вектор

0.6815

+0.1641

0.7910

0.7824

J

обогащение постами

0.7018

+0.1844

0.7734

0.7650

B+A

LLM → под‑темы → мульти

0.7526

+0.2352

0.8245

0.8137

A+J

A + J вместе

0.8025

+0.2851

0.7546

0.7238

Теги не сработали. I остался на уровне baseline: −0.0072 при выборке в 13 человек — отсутствие эффекта. Причина понятна. Интересы схлопываются в две‑три широкие категории, которые есть у половины ленты. Сигнал есть, различающей силы нет.

A и J по отдельности работают, а вместе работают лучше. +0.1039 и +0.1844, в сумме +0.2883, а совместный прирост даёт +0.2851 — почти идеальное сложение, значит болячки они лечат разные, и это подтверждает исходную гипотезу про два независимых провала.

Разбивка на под‑темы работает. B+A обходит обычный B на +0.0711 по AUC. Значит дело не в том, что LLM дописала больше слов, а именно в раздельности.

Но посмотрим на A+J по верху ленты: NDCG 0.7546 и P@10 0.7238. Это хуже, чем у одного J (0.7734 и 0.7650).

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

Мы думаем, дело в максимуме. A берёт лучшее совпадение по набору, и наверх легко пролезает пост, который очень сильно попал в один узкий интерес.

Для AUC это хорошо — порядок в целом правильный. А верхушка ленты получается перекошенной в одну тему из трёх.

Проверять эту версию отдельно мы пока не стали. Но факт: лидер по AUC и лидер по топ-10 — разные варианты, и это не случайность.

Итого лидеров два. По общему порядку — A+J (0.8025). По верху ленты — B+A (0.8245 и 0.8137).

И то же самое словами. Вот лента того же человека с интересом «IT» после A+J:

Интерес: «IT»

Было (baseline):        Стало (A+J):
Акции Nvidia +4%        Вышел Postgres 18
Сделка Microsoft        Разбор утечки в npm-пакете
Налоговые льготы        Бенчмарк новых ARM-серверов
Зарплаты в IT           Как устроен новый планировщик Go

Ограничения

Для оценки и использования результата статьи важно понимать:

  • Выборка маленькая.

  • Меряем на том, что сами показали. Оценки есть только у постов, которые ранкер уже вывел в ленту. Абсолютные числа завышены, но порядок вариантов честный — все считались на одной выборке.

  • Офлайн не равно онлайн. Эксперимент выбирает кандидата. Доказать может только живая лента.

Продакшен

Чтобы не рисковать, выбрали A+J, так как он не может выдумать интерес. Он двигает вектор только к тем постам, которые реально лежат у нас в базе. Дрейф 0.10.

B+A выигрывает верх ленты, но каждое сохранение интересов превращается в вызов LLM. Плюс дрейф 0.28 и постоянная проверка, не дописала ли модель лишнего. Мы разменяли чуть более красивый топ-10 на «нечему ломаться и нечего галлюцинировать». Плюс между ними маленький разрыв.

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

Итоги

  • Интуиция про два провала подтвердилась цифрами. Короткий интерес даёт вектор хуже монетки (0.41), разнотемный — размазанную точку.

  • Обе болячки лечатся отдельно, и лечатся независимо: A даёт +0.10, J даёт +0.18, вместе +0.29.

  • Теги не заменяют вектор, вообще не влияют.

  • LLM помогает, но не бесплатно. И работает заметно лучше, когда результат режется на под‑темы, а не сваливается в один текст.

Если убрать наши конкретные буквы, остаётся две вещи, которые переносятся на любой похожий проект.

  • Не усредняйте разнородное. Один вектор на несколько тем — это точка там, где ничего нет. Держите набор и берите максимум.

  • Короткий запрос лечится своим же контентом. PRF не требует ни LLM, ни внешних данных. Считаете ближайшие документы, подмешиваете, нормализуете.

Всё это уже в последнем релизе в @noiseoffbot.

Заходите, напишите свои интересы и посмотрите, что получится. Заодно попадёте в выборку для следующего эксперимента.

Если напишете одним словом — теперь это не так безнадёжно:‑)