Рекомендательную систему на эмбеддингах стоит чинить с профиля пользователя. Если список интересов сжимается в один средний вектор, разные темы смешиваются, а короткий запрос вроде «IT» превращается в слишком размытый сигнал. Практический базовый вариант: хранить несколько векторов интересов, объединять кандидатов с учетом веса каждого интереса, затем проверить псевдорелевантное обогащение, или PRF, и расширить короткие формулировки через LLM.
Качество нужно оценивать на верхушке ленты. ROC-AUC покажет общую способность отличать релевантные посты от нерелевантных, но для первых 10 рекомендаций полезнее смотреть NDCG@10 и P@10. Эти метрики сразу покажут, попадает ли полезный контент туда, где его увидит пользователь.
Рабочий порядок выглядит так: зафиксировать baseline, добавить NDCG@10 и P@10, заменить один профильный вектор на несколько, проверить PRF с контролем topic drift, затем подключить LLM для расширения интересов и тегирования контента. После каждого шага нужно сравнивать одинаковые тестовые выборки, задержку ответа и качество топа.
Почему один эмбеддинг для списка интересов не работает
Эмбеддинг кодирует смысл текста в числовой вектор. Рекомендатель сравнивает вектор профиля пользователя с векторами постов, чаще всего через cosine similarity. Проблема появляется, когда в одном профиле оказываются интересы с разными подтемами, уровнем абстракции и пользовательскими намерениями.
Простая схема строит профиль так:
q = normalize((e1 + e2 + ... + en) / n)
score(post) = cosine(q, e_post)
Такое усреднение работает, если e1, e2 и остальные векторы описывают близкие темы. Для разнородного списка среднее значение становится центром между несколькими смысловыми областями. Центр пространства может быть достаточно далек от каждого конкретного интереса.
На результат влияют и технические детали: количество постов на каждый тег, длина текстов, нормы векторов, популярность контента, свежесть интереса и качество самого тега. Если пользователь выбрал один широкий интерес, система часто получает еще более слабый сигнал.
Пример деградации: запрос «IT» и случайные рекомендации
Ниже приведен гипотетический сценарий, который помогает увидеть механизм ошибки. Пользователь указал интерес «IT». В базе под этим тегом находятся посты о программировании, видеокартах, сетях, кибербезопасности, локальных LLM и корпоративном ПО.
- Посты о программировании тяготеют к одному кластеру эмбеддингов.
- Материалы о железе образуют другой кластер.
- Кибербезопасность и сети имеют пересечения, но сохраняют отдельную лексику.
- Посты об AI частично пересекаются с разработкой, но могут заметно отличаться по терминологии.
Если система усреднит эмбеддинги всех этих материалов, профиль окажется где-то между кластерами:
q_IT = normalize((e_programming + e_hardware + e_networks + e_security + e_AI) / 5)
Вектор q_IT описывает широкий центр категории, но не сообщает системе, какая подтема нужна пользователю сейчас. Поиск может выбрать популярные и общие публикации, которые имеют умеренное сходство с каждым кластером, но не подходят конкретному интересу. Первые 10 элементов в таком случае выглядят почти случайным набором. При плохом индексе или перекосе популярности качество может оказаться ниже случайного baseline для целевой подтемы.
Ситуация усугубляется, если в профиле смешаны интересы «IT», «фотография» и «финансы». Один вектор скрывает не только подтемы внутри IT, но и факт существования нескольких самостоятельных намерений. Система видит среднюю точку, хотя пользователь ожидает ленту, где разные направления представлены отдельно и предсказуемо.
Поэтому первый вопрос при отладке звучит не «какую модель эмбеддингов выбрать», а «сколько независимых сигналов мы потеряли при агрегации». Иногда смена модели дает небольшой прирост, тогда как отказ от безусловного усреднения меняет саму структуру поиска.
Какие метрики использовать для оценки качества рекомендаций
Для оценки понадобится тестовая выборка с пользователями, моментом показа, списком кандидатов и сигналом релевантности. Сигналом может быть клик, дочитывание, сохранение или другая целевая реакция. Правило разметки нужно зафиксировать до сравнения подходов, иначе метрики будут отражать разные определения полезного поста.
Тестовую выборку лучше разделить по времени: ранние события использовать для построения профиля, более поздние - для проверки. Так система не увидит будущее поведение пользователя при расчете результата. Отдельные срезы для коротких интересов, широких тегов и новых пользователей покажут, где именно происходит деградация.
ROC-AUC: общая разделяющая способность
ROC-AUC можно интерпретировать как вероятность того, что случайный релевантный объект получит более высокий score, чем случайный нерелевантный. Метрика оценивает все пары релевантный-нерелевантный и не ограничивается первыми позициями.
Представим 1000 кандидатов: 100 релевантных и 900 нерелевантных. Пусть первые 10 позиций заняли нерелевантные посты, затем идут все 100 релевантных, после них - остальные 890 нерелевантных. У релевантных объектов будет всего 1000 неправильно упорядоченных пар, а общее число пар составит 90 000.
AUC = 1 - 1000 / 90000 = 0.9889
ROC-AUC почти равен 0,99, хотя пользователь не увидит ни одного релевантного поста в первом экране. P@10 и NDCG@10 в таком примере равны нулю. Это не ошибка самой ROC-AUC: она отвечает на другой вопрос, о порядке всего списка.
ROC-AUC полезен как диагностическая метрика для общей разделяющей способности и сравнения моделей на полном наборе кандидатов. Он плохо подходит как единственный критерий ленты, которую пользователь просматривает сверху вниз. Практические принципы измерения качества поисковых систем и подбор тестовых наборов разобраны в руководстве по оценке качества RAG-систем.
NDCG@10 и P@10: фокус на верхушке ленты
P@10 показывает долю релевантных объектов в первых 10 позициях:
P@10 = число релевантных постов в топ-10 / 10
Если в первой десятке три полезных поста, P@10 равен 0,3. Метрика проста, легко объясняется команде и хорошо показывает плотность полезного контента. Она не различает позиции: релевантный пост на первом месте и такой же пост на десятом дают одинаковый вклад.
NDCG@10 учитывает позицию каждого релевантного объекта. Для бинарной разметки DCG считается так:
DCG@10 = сумма (2^rel_i - 1) / log2(i + 1)
Здесь i начинается с 1, а rel_i - оценка релевантности на позиции i. Для бинарных меток она равна 0 или 1. Затем DCG делится на идеальное значение IDCG, полученное при сортировке релевантных объектов в начале списка.
Сравним два списка с тремя релевантными постами. В первом релевантность по позициям выглядит так: [1, 0, 1, 0, 0, 1, 0, 0, 0, 0]. Здесь P@10 равен 0,3, DCG примерно равен 1,856, идеальный DCG для трех объектов - 2,131, поэтому NDCG@10 составляет около 0,87.
Во втором списке релевантные посты стоят на позициях 8, 9 и 10: [0, 0, 0, 0, 0, 0, 0, 1, 1, 1]. P@10 все еще равен 0,3, но NDCG@10 падает примерно до 0,42. Метрика фиксирует разницу, которую P@10 не видит.
Для ленты разумно использовать NDCG@10 как главную метрику порядка, P@10 как понятный контрольный показатель, а ROC-AUC - как дополнительную проверку качества полного ранжирования. К ним полезно добавить latency p95, покрытие рекомендаций, долю дублей и разбивку по сегментам пользователей. Высокий NDCG при резком росте задержки не дает готового решения для продукта.
Мульти-векторное представление интересов
Мульти-векторная схема сохраняет несколько самостоятельных запросов вместо одного усредненного профиля. Каждый вектор отвечает за отдельный интерес, кластер или аспект интереса. Поиск получает несколько сигналов и может собрать ленту с лучшим покрытием тем.
Для пользователя с интересами программирование, железо и кибербезопасность профиль можно представить так:
Q = {q_programming, q_hardware, q_security}
Вектор можно строить из текста тега, описания интереса, истории действий или небольшой подборки постов. Способ зависит от доступных данных. Явно выбранный тег дает декларативный сигнал, клики и сохранения помогают оценить текущий вес интереса, а кластеры поведения выявляют темы, которые пользователь не назвал напрямую.
Как разбить интересы на несколько векторов
- Отдельный вектор на тег. Подходит, если теги достаточно конкретны и их словарь контролируется. Для «Python» и «PostgreSQL» отдельные запросы обычно информативнее, чем их среднее с «железом».
- Кластеры интересов. Историю прочитанных постов можно сгруппировать в embedding-пространстве. Для первого эксперимента разумно проверить число кластеров
kиз набора{2, 4, 6, 8}, а не выбирать одно значение без сравнения. - Несколько аспектов одного интереса. Интерес к локальным LLM может включать модели, запуск, GPU, VRAM и оценку качества. Отдельные векторы сохраняют эти различия.
- Иерархия. Верхний уровень описывает широкую область, нижний - подтему. При слабом сигнале система использует общий вектор, при накоплении действий переключается на более конкретный.
Мульти-вектор не требует хранить бесконечный список. Можно ограничить профиль числом активных векторов, например 3-8, и удалять слабые или давно не подтвержденные интересы. Вес каждого вектора стоит обновлять по свежести сигналов, частоте действий и явному выбору пользователя.
Архитектурные идеи с несколькими модальностями, памятью и латентными намерениями разобраны в разборе гибридного рекомендера RecGPT-V3. Это отдельный архитектурный кейс, но он хорошо показывает, почему один сжатый сигнал часто теряет структуру пользовательского намерения.
Агрегация результатов от нескольких векторов
Сначала нужно получить кандидатов по каждому запросу. Если для каждого из m векторов взять top-K, итоговый набор формируется объединением результатов, удалением дублей и повторным ранжированием:
C = union(topK(q_1), topK(q_2), ..., topK(q_m))
Самый простой скор для документа d выглядит так:
S_sum(d) = сумма w_j * sim(q_j, e_d)
Вес w_j отражает силу интереса. Если один вектор связан с недавними сохранениями, а другой - со старым выбранным тегом, их вклад может различаться. Суммирование хорошо работает при необходимости сбалансировать несколько тем, но сильный общий интерес способен вытеснить редкую тему.
Альтернативный вариант:
S_max(d) = max(w_j * sim(q_j, e_d))
Максимальный скор сохраняет документы, релевантные хотя бы одному интересу. Он полезен для профиля с независимыми темами, но может создать слишком узкую ленту, если один вектор случайно получил высокий score.
Перед суммированием стоит привести scores к сопоставимой шкале. Распределения сходства по разным векторам могут отличаться из-за длины запроса и плотности соответствующего кластера. Подойдут нормализация по статистикам валидационной выборки, rank-based score или небольшой learning-to-rank слой с признаками:
- сходство документа с каждым вектором;
- вес и свежесть интереса;
- позиция документа в исходной выдаче;
- свежесть поста и его качество;
- число повторов автора, темы или уже показанного материала.
Для простого baseline можно использовать round-robin: брать по одному кандидату из выдачи каждого вектора, пока не набран общий топ. Такой способ дает равное представительство тем, но не учитывает разницу в релевантности. Learning-to-rank требует размеченных примеров, зато способен научиться, когда показывать широкий интерес, а когда - конкретную подтему.
Стоимость поиска растет примерно пропорционально числу самостоятельных запросов. Поэтому ограничивайте число активных векторов, объединяйте близкие кластеры и не отправляйте каждый слабый интерес в индекс. Время нужно измерять отдельно для генерации кандидатов, дедупликации и финального ранжирования.
Псевдорелевантное обогащение (PRF) на собственной базе постов
PRF, или pseudo-relevance feedback, использует результаты первого поиска как предположительно релевантные документы. Система берет их эмбеддинги, смешивает с исходным запросом и выполняет второй поиск. Метод не требует ручной разметки каждого документа, потому что начальная выдача сама становится источником дополнительного контекста.
Как реализовать PRF в рекомендательной системе
- Построить исходный вектор
q0из интереса пользователя или одного мульти-векторного компонента. - Получить начальные кандидаты и выбрать top-K, например
K = 10. - Удалить уже просмотренные посты, дубли, материалы с низким качеством и документы, которые нарушают фильтры продукта.
- Посчитать средний эмбеддинг оставшихся кандидатов и смешать его с исходным вектором.
- Выполнить второй поиск и передать объединенный набор на финальное ранжирование.
Базовая формула выглядит так:
q1 = normalize(alpha * q0 + (1 - alpha) * mean(embed(d_i)))
Коэффициент alpha сохраняет исходный интерес. При значении, близком к 1, PRF влияет слабо. При меньшем значении система сильнее доверяет первым найденным постам. Для первой серии экспериментов можно сравнить K из набора {5, 10, 20} и alpha из набора {0.6, 0.8, 0.9}. Универсальной пары нет, поэтому выбор нужно делать по NDCG@10, P@10 и задержке.
candidates_0 = search(q0, K)
pseudo = filter(candidates_0)
q1 = normalize(alpha * q0 + (1 - alpha) * mean(embed(pseudo)))
candidates_1 = search(q1, K2)
Для запроса «IT» первый поиск может собрать преимущественно материалы о программировании. PRF усилит лексику этой подтемы, и второй поиск найдет больше постов о разработке ПО. Если первые результаты случайно относятся к видеокартам, тот же механизм сдвинет выдачу в сторону железа.
При мульти-векторном профиле безопаснее запускать PRF отдельно для каждого q_j. Если сначала смешать посты по программированию, сетям и железу, второй проход снова получит размытый центр. Итоговые выдачи можно объединить теми же способами, что и результаты базового мульти-векторного поиска.
Итеративный поиск встречается и в более сложных системах, где каждый следующий проход уточняет исходный запрос. Общий принцип разобран в статье о многошаговом семантическом поиске. Для рекомендательной ленты PRF обычно проще: достаточно одного контролируемого дополнительного прохода.
Ограничения PRF
PRF предполагает, что верхушка первичной выдачи хотя бы частично релевантна. Если это предположение не выполняется, система усиливает ошибку. Такой сдвиг называют topic drift: запрос постепенно удаляется от исходного интереса.
- Ошибочный старт. Широкий тег «IT» может привести к популярным материалам о железе, хотя пользователь читает про программирование.
- Популярностный перекос. Несколько часто показываемых постов начнут определять новый вектор сильнее, чем редкие, но релевантные документы.
- Дубли. Похожие публикации в top-K создают ложное ощущение уверенности и сужают расширенный запрос.
- Дополнительная задержка. Второй поиск увеличивает время ответа и нагрузку на индекс.
- Замкнутый feedback loop. Ошибка первичной выдачи повторяется в следующих рекомендациях и закрепляется историей кликов.
Снизить риск помогают высокий вес исходного запроса, фильтр похожих документов, ограничение числа постов одного автора или темы и проверка уверенности первичной выдачи. PRF можно отключать для коротких профилей с низким сигналом или запускать только при достаточном разрыве между первым и последующими score.
LLM-обогащение интересов и тегов
LLM полезна там, где пользовательский интерес слишком короткий или неоднозначный для надежного поиска. Модель может разложить «IT» на подтемы, синонимы и связанные понятия, а затем система построит отдельный эмбеддинг для каждой группы. Генерируемые формулировки должны расширять сигнал, а не заменять данные о реальном поведении пользователя.
Расширение пользовательских интересов с помощью LLM
Для короткого интереса «IT» модель может предложить программирование, разработку ПО, hardware, сети, кибербезопасность и искусственный интеллект. Это набор возможных фасетов, а не доказательство того, что пользователь одинаково интересуется всеми шестью направлениями.
Пример запроса к LLM:
Ты помогаешь расширять интересы пользователя для рекомендательной системы.
Входной интерес: "IT"
Верни только JSON со структурой:
{
"facets": [
{
"name": "программирование и разработка ПО",
"type": "подтема",
"weight": 0.0
}
],
"synonyms": [],
"ambiguities": []
}
Правила:
- предложи не более 8 фасетов;
- отделяй подтемы, синонимы и связанные области;
- не утверждай, что пользователь интересуется фасетом без поведенческого сигнала;
- укажи неоднозначность короткого интереса;
- используй веса только как начальные оценки.
Результат нужно проверить по схеме, ограничить максимальное число фасетов и сопоставить с контролируемым словарем тегов. Фасеты с низким доверием можно использовать как дополнительные кандидаты с малым весом. После кликов и сохранений веса обновляются по реальному поведению, а не по предположению LLM.
На каждый запрос пользователя вызывать модель не стоит. Расширение лучше выполнять при создании или изменении профиля, кэшировать по нормализованному тексту интереса и версии модели, а при повторном показе использовать уже сохраненные фасеты. Для чувствительных профилей подойдет локальная LLM, если качество и скорость отвечают требованиям системы.
Обогащение контента: теги и описания через LLM
Посты часто содержат полезный смысл в заголовке и основном тексте, но не имеют единообразных тегов. LLM может извлечь ключевые сущности, предложить короткое описание, определить тематику и привести синонимы к единому словарю. Эти поля добавляются к исходному тексту перед построением эмбеддинга или используются как отдельные признаки ранжирования.
Например, публикация о запуске локальной LLM может получить теги «локальный инференс», «GPU», «VRAM», «модель» и «настройка». Пользовательский интерес «локальные AI-системы» после такого обогащения сопоставится с постом по нескольким общим понятиям, даже если исходный текст использует другую формулировку.
У обогащения должны быть отдельные поля:
- оригинальный текст поста;
- сгенерированные теги и краткое описание;
- версия промпта и LLM;
- статус проверки тегов;
- версия эмбеддинга, построенного после изменения метаданных.
Оригинал нельзя заменять сгенерированным описанием. LLM способна добавить неподтвержденную сущность или неверно определить тему, поэтому новые теги проходят словарную проверку, ограничения по формату и выборочную ручную проверку. Изменение принятого набора тегов должно запускать пересчет эмбеддинга, иначе индекс будет хранить старое представление документа.
Генерацию тегов и описаний лучше запускать асинхронно при загрузке или обновлении контента. Это сохраняет предсказуемую задержку ленты. В онлайновом запросе остаются поиск по индексу, агрегация результатов и финальный ranker.
Сравнение подходов и практические рекомендации
Три метода воздействуют на разные части системы. Мульти-вектор меняет профиль пользователя и способ поиска, PRF уточняет запрос на основе собственной выдачи, а LLM-обогащение исправляет недостаток смысла в коротких интересах и метаданных постов.
| Подход | Что меняется | Сложность и стоимость | Основной риск |
|---|---|---|---|
| Мульти-вектор | Профиль хранит несколько независимых сигналов | Средняя: больше поисковых запросов и шаг агрегации | Неправильные веса, дубли и несопоставимые scores |
| PRF | Исходный запрос уточняется top-K постами | Средняя: дополнительный проход по индексу | Topic drift при слабой первичной выдаче |
| LLM-обогащение | Расширяются интересы, теги и описания | Средняя или высокая: расходы на генерацию и контроль качества | Ошибочные фасеты, галлюцинации и зависимость от версии модели |
С чего начать: пошаговый план
- Зафиксируйте baseline. Сохраните текущий encoder, настройки индекса, размер candidate pool и правила фильтрации. Посчитайте ROC-AUC, NDCG@10, P@10, latency p95, долю дублей и покрытие по темам.
- Соберите срезы. Отдельно проверьте короткие интересы, широкие категории, пользователей с одним интересом, пользователей с несколькими темами и холодный старт. Ошибка «IT» может теряться в среднем результате по всей аудитории.
- Добавьте мульти-вектор. Начните с одного вектора на явный интерес. Для широких тегов проверьте кластеризацию или несколько фасетов. Объединяйте top-K по каждому вектору, удаляйте дубли и сравнивайте сумму с максимумом и round-robin.
- Проверьте PRF. Запустите один дополнительный проход с
Kиз набора{5, 10, 20}и несколькими значениямиalpha. Отключайте PRF для слабых первичных выдач и отдельно измеряйте прирост на коротких интересах. - Подключите LLM к данным. Сначала расширьте короткие пользовательские интересы и сохраните фасеты в кэше. Затем добавьте теги и описания для контента. Генерацию, валидацию и пересчет эмбеддингов держите отдельными этапами.
- Проведите A/B-тест. Сравнивайте baseline, мульти-вектор, мульти-вектор с PRF и вариант с LLM-обогащением при одинаковых правилах показа. Главными критериями оставьте NDCG@10 и P@10, а ROC-AUC, latency p95 и долю дублей используйте как контрольные показатели.
Для каждой версии полезно хранить не только итоговый список, но и объяснимый trace: какой вектор дал кандидата, какой score получил пост, сработал ли PRF, какие LLM-фасеты участвовали. Без такого журнала трудно понять, почему верхушка изменилась, а спорное улучшение нельзя надежно воспроизвести.
Подводные камни и ограничения
- Качество эмбеддингов не компенсирует слабый индекс. Если ANN-поиск не вернул релевантный документ в candidate pool, финальный ranker не сможет поднять его в топ.
- Разные шкалы score ломают агрегацию. Сходство по короткому тегу и сходство по длинному описанию могут иметь разные распределения. Суммировать их без калибровки рискованно.
- Офлайн-метрики могут переобучить систему на клики. Популярный пост получает много реакций из-за позиции и известности, а не из-за семантической релевантности. Нужны временное разделение данных и контроль позиционного смещения.
- PRF усиливает начальную ошибку. Сохраняйте существенный вклад
q0, ограничивайте влияние top-K и проверяйте результат на каждом тематическом срезе. - LLM не знает скрытого намерения пользователя. Расширение «IT» перечисляет возможные области, но не выбирает актуальную подтему без поведенческого сигнала.
- Генерируемые теги могут содержать ошибки. Храните их отдельно от оригинала, проверяйте по словарю и версионируйте каждое изменение.
- Цена растет вместе с числом проходов. Мульти-вектор увеличивает число запросов, PRF добавляет второй поиск, а LLM требует отдельного бюджета на обработку профиля и контента.
- Верхушка ленты требует отдельного контроля. Общий ROC-AUC может улучшиться при ухудшении первых 10 позиций. Решение принимайте по NDCG@10 и P@10 с разбивкой по аудитории.
Практический дефолт для проблемного профиля выглядит так: хранить отдельные векторы интересов, получать кандидатов по каждому сигналу, калибровать scores и выполнять финальное ранжирование. PRF стоит добавлять одним проходом при уверенной первичной выдаче. LLM лучше использовать для офлайнового расширения коротких интересов и подготовки тегов, а результат проверять по схеме, словарю и пользовательскому поведению. Если NDCG@10 не растет, сначала ищите ошибку в разметке, candidate pool, агрегации и популярностном смещении, затем меняйте модель эмбеддингов.