В прошлой статье я рассказывал, как устроена рекомендательная система в нашем 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 человек указали свои интересы текстом.
Важно, что для оценки вектора интересов нам нужны сразу две вещи:
пользователь должен указать интересы;
у пользователя должна быть история оценок постов.
В итоге для эксперимента использовали 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».
По шагам:
Считаем обычный вектор от текста интереса.
Ищем в базе K ближайших постов. У нас K=10.
Берём их эмбеддинги и подмешиваем к исходному вектору.
Нормализуем.
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.
Заходите, напишите свои интересы и посмотрите, что получится. Заодно попадёте в выборку для следующего эксперимента.
Если напишете одним словом — теперь это не так безнадёжно:‑)

