Всем привет! Меня зовут Антон Пилькевич, я более четырёх лет занимаюсь ранжированием и текстовой релевантностью в поиске Ozon. И вот настал момент, когда у меняВсем привет! Меня зовут Антон Пилькевич, я более четырёх лет занимаюсь ранжированием и текстовой релевантностью в поиске Ozon. И вот настал момент, когда у меня

Query Prediction, или как мы отказались от ANN и полюбили обратный индекс

85ddce8ca984b3b0b3254f50cac3a5d5.jpg

Всем привет! Меня зовут Антон Пилькевич, я более четырёх лет занимаюсь ранжированием и текстовой релевантностью в поиске Ozon. И вот настал момент, когда у меня появилось время поделиться своими мыслями. В этой статье вас ждёт увлекательное путешествие в ML-мир текстового поиска Ozon, а также знакомство с флорой и фауной существующих решений в этой области.

Миссия невыполнима

Традиционный текстовый поиск, основанный на точном совпадении слов из запроса с содержимым документа, неизбежно сталкивается с ограничениями. Одно из ключевых ограничений — различие между «языком» пользовательского запроса и текстом в карточке товара. Пользователи редко формулируют запросы идеально: допускают опечатки, используют сленг или описывают товар косвенными признаками. Карточки товаров, напротив, состоят из заранее определённых атрибутов с фиксированным набором значений. Селлер может проявить творческую свободу разве что при заполнении описания товара, но в реальности этот раздел всё чаще и чаще генерируется с помощью LLM.

А теперь вспомним, что речь идёт о маркетплейсе Ozon с сотнями миллионов разнообразных товаров:

  • от товаров повседневного спроса до эксклюзивных позиций стоимостью в десятки миллионов рублей;

  • от складов в Москве до складов в Китае;

  • от кратких технических характеристик до многостраничных описаний.

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

Отдельно стоит отметить требование интерпретируемости алгоритма. Почему именно этот товар попал в выдачу? Этот вопрос возникает не только у пользователей, но и у бизнес-команд, аналитиков и разработчиков. Нужно понимать логику ранжирования и уметь объяснять её как для внутреннего пользователя, так и для внешнего.

Итого нам нужно усидеть на четырёх стульях:

  • максимально релевантная выдача;

  • учёт бизнес-правил;

  • скорость работы и потребляемые ресурсы;

  • интерпретируемость полученного решения.

Замечание: этот раздел статьи не просто так называется «Миссия невыполнима», так как усидеть на всех стульях крайне сложно. Ситуация напоминает теорему CAP. Например, получение идеальной выдачи невозможно без жертвы в виде времени ответа или срока доставки. Вы готовы ждать выдачу несколько минут или товар месяцами?

Невероятная история текстового поиска

Классическая задача текстового поиска: имеется пользовательский запрос и набор документов, по которому хотим выполнить поиск. Нам нужно отобрать топ наиболее релевантных кандидатов. В этом разделе статьи мы обсудим существующие в мире решения. Отличную иллюстрацию с ультимативным набором работ можно найти на Github — Semantic-Retrieval-Models. Я же в целях максимального упрощения, выделю и освещу отдельные решения.

Схема существующих методов для задачи текстового поиска
Схема существующих методов для задачи текстового поиска

Нумеро уно: обратный индекс

Достигнуть цели в простейшем случае можно с помощью обратного индекса. Обратный индекс — это структура данных для полнотекстового поиска. Он состоит из словаря токенов и posting lists. На верхнем уровне запись в posting lists выглядит так: token → [docID₁, docID₂, …]. Таким образом, в индексе хранится информация о том, где встречается каждый токен: в каких документах и атрибутах, на каких позициях в атрибуте. Замечание: более подробно про устройство словаря расскажем при описании нашего решения.

Алгоритм поиска по обратному индексу:

  1. Поисковый запрос разбивается на токены.

  2. Для каждого токена читается его posting list.

  3. Над posting lists выполняются булевы операции (зачастую просто пересечение) и переранжирование. Примеры простейших алгоритмов переранжирования: TF-IDF или BM25.

Схема устройства обратного индекса
Схема устройства обратного индекса

Обратный индекс — это фундамент полнотекстового поиска. При его использовании обеспечивается естественная поддержка сложной фильтрации и шардирования благодаря open-source-решениям, например, таким как Apache Lucene. А благодаря наличию собственного поискового движка компании могут быстро внедрять новые решения. Про наш O2 в Ozon на базе Apache Lucene вы уже могли читать.

984d8d6b243b200e19676c35a3f77e54.jpg

По итогу мы получаем решение, которое довольно просто интегрируется и имеет хороший перформанс, отлично интерпретируется, но имеет трудности с улучшением релевантности выдачи. Под трудностями подразумеваем учёт семантики запроса. В контексте текстовой релевантности можно ещё долго говорить про использование морфологии, токен-позиций или более специфичных proximity-признаков для классического обратного индекса. Однако мы (я) собрались здесь, чтобы поговорить про Deep Learning (DL), который отлично с этим справляется.

Хороший, плохой, злой: Approximate Nearest Neighbors aka ANN

Первое, что приходит в голову при упоминании слов «DL» и «поиск», — использование эмбеддингов в сочетании с приближённым векторным поиском, известным в русскоговорящем сообществе как Approximate Nearest Neighbors (ANN). Думаю, почти все уже пробовали этот подход или, по крайней мере читали про его устройство. Не будем углубляться в работу алгоритмов с представлением документа или запроса в виде одного вектора (single vector representation). Про устройство HNSW, Faiss, SCANN и прочих написаны десятки статей. Вместо этого поговорим про потенциальные проблемы применения такого подхода в bigtech-компаниях, например, маркетплейсах масштаба Ozon (все совпадения случайны).

  • Сложность интеграции бизнес-правил. В рантайме необходимо поддерживать фильтрацию по геодоступности, сроку доставки и другим бизнес-критериям. Если пользователь живёт, например, в Калининграде и не собирается ждать больше 7 дней, множество релевантных товаров потеряются на постфильтрации. Значит, требуется предварительный отбор большего числа кандидатов. Большее число кандидатов увеличит нагрузку и время ответа, что в итоге приведёт к неизбежному компромиссу между качеством и скоростью.

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

Тут стоит отметить, что faiss и её попытка в «шардирования» закончилась три года назад в distributed-faiss. Следующими коробочными решениями могут стать Qdrant или Milvus, которые потенциально должны решить обе упомянутые проблемы. Но всё ещё не ясно, что будет происходить с ними на масштабе 100 миллионов товаров. А если товаров будет 500 миллионов? В общем, это скользкая дорожка, которую можно выбрать для MVP и проверки гипотез, но, скорее всего, вы неизбежно придёте к необходимости модифицировать выбранное решение либо писать собственное. Например, именно это сделала Alibaba.

Отдельно хочется поговорить про multi-vector representation — в этом случае товар и запрос представляются не одним вектором, а N векторами. Столпами в этом месте являются работы ColBERT и ColBERTv2. Чтобы работать с multi-vector representation, нужно разворачивать решение из оригинальной статьи ColBERTv2. Я предполагаю, что оно плохо масштабируется на сотни миллионов документов, так как в статье используются датасеты порядка одного миллиона документов, что на два порядка меньше индустриальных... В качестве альтернативы можно использовать, например, ранее упомянутый Qdrant. Он имеет более production-ready-интерфейс, пусть и без такой эффективной реализации. Но проблемы выше никуда не денутся. Более того, они наступят ещё раньше, так как использование N векторов кратно увеличит размер индекса, и вам всё равно придётся изобретать свой велосипед.

Итого:

  • при использовании решения на основе ANN потребуется значительное время на его оптимизацию и доработку;

  • релевантность выдач будет сильно зависеть от доступных ресурсов (степень сжатия, размеры и количество векторов);

  • интерпретируемость в случае single vector representation почти отсутствует.

Примечание 1: мы разработали кастомное решение на базе HNSW, но внедрили его только для рекламного индекса, который на порядок меньше и живёт по собственным правилам.
Примечание 2: выше подразумеваем использование CPU-based-решения, о GPU мы поговорим в следующем разделе статьи.

Безвестный новичок начал сезон серой лошадкой, но теперь его знает каждый: KNN на GPU

Следующий интересный и, главное, хайповый вариант — это хранение всего индекса на GPU. Тренды задали Meta и Linkedin (а я узнал о них благодаря WazowskiRecommends). В работах предлагается посчитать скор релевантности для всех документов в индексе, то есть честный KNN (ну почти честный!). Поэтому достаточно просто поддержать все бизнес-правила (например, посчитать маску по атрибутам, и готово), приветствуется любая кастомная функция скоринга. Главная сложность работы с таким подходом: объёмы, которые может уместить GPU. Нужно хранить информацию о сотнях миллионах товаров, а также хранить метаинформацию для фильтрации либо предрасчитывать фильтры в отдельном сервисе. Векторы нужно крайне эффективно сжимать, а при росте объёмов информации снова встанет проблема с шардированием.

Примеры с потреблением памяти в различных сетапах

N векторов \ размерность вектора

64

128

256

512

1024

1M

int8: 0.06 GiB
fp16: 0.12 GiB
fp32: 0.24 GiB

int8: 0.12 GiB
fp16: 0.24 GiB
fp32: 0.48 GiB

int8: 0.24 GiB
fp16: 0.48 GiB
fp32: 0.95 GiB

int8: 0.48 GiB
fp16: 0.95 GiB
fp32: 1.91 GiB

int8: 0.95 GiB
fp16: 1.91 GiB
fp32: 3.81 GiB

100M

int8: 5.96 GiB
fp16: 11.92 GiB
fp32: 23.84 GiB

int8: 11.92 GiB
fp16: 23.84 GiB
fp32: 47.68 GiB

int8: 23.84 GiB
fp16: 47.68 GiB
fp32: 95.37 GiB

int8: 47.68 GiB
fp16: 95.37 GiB
fp32: 190.73 GiB

int8: 95.37 GiB
fp16: 190.73 GiB
fp32: 381.47 GiB

500M

int8: 29.80 GiB
fp16: 59.60 GiB
fp32: 119.21 GiB

int8: 59.60 GiB
fp16: 119.21 GiB
fp32: 238.42 GiB

int8: 119.21 GiB
fp16: 238.42 GiB
fp32: 476.84 GiB

int8: 238.42 GiB
fp16: 476.84 GiB
fp32: 953.67 GiB

int8: 476.84 GiB
fp16: 953.67 GiB
fp32: 1.86 TiB

Если предположить, что вы можете позволить себе использовать H100 c 80GB памяти, то при оооочень агрессивном сжатии все 500М документов можно уместить на одну карточку!

Но давайте чуть детальнее обсудим идеи из статей — они не так уж часто упоминаются.

Подход Meta: Mixture of Logits (MoL) и h-indexer

Первая идея, Mixture of Logits — вместо простого скалярного произведения MoL комбинирует несколько базовых скалярных произведений, что позволяет лучше учитывать взаимодействия. При достаточно богатой фантазии подход может напомнить скоринг ColBERT:

\mathrm{score}_{MoL}(x, u) = \frac{1}{\tau} \sum_{i=1}^{K_u} \sum_{j=1}^{K_x} \pi_{i, j}(x,u) \langle \mathbf{e}_u^{i}, \mathbf{e}_x^{j} \rangle,\mathrm{score}_{ColBERT}(q, d) = \sum_{i=1}^{|q|} \max_{j=1}^{|d|} \langle \mathbf{e}_q^i, \mathbf{e}_d^j \rangle.

Вместо одной пары эмбеддингов (пользователь, документ) MoL использует K_u пользовательских и K_x документных эмбеддингов одинаковой размерности (как в ColBERT!). Функция π(x,u) вычисляет веса для каждой пары компонентов на основе данных пользователя и документа. Тут стоит отметить, что ColBERT — это модель текстового поиска, поэтому в качества запроса используется текст, а MoL — это скоринг для рекомендательной системы, соответственно, запросом выступает пользователь.

Вторая идея, h-indexer, — иерархическая стратегия поиска, которая позволяет масштабировать MoL. Подход разбивает поиск на несколько шагов, сужая пространство поиска на каждом этапе. Важно отметить, что h-indexer является приближённым поиском:

  1. Случайно семплирует ~10% корпуса.

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

  3. Находит порог k-го наименьшего в выборке.

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

Подход LinkedIn: LiNR (LinkedIn Neural Retrieval)

Ключевые идеи LiNR:

  1. Attribute-based pre-filtering. Подход позволяет применять бизнес-фильтры (в нашем случае геодоступность, наличие на складе, категория) до поиска ближайших соседей, а не после. Это решает проблему ANN-поиска, когда на постфильтрации можно потерять все релевантные результаты.

  2. Multi-vector representation. Это просто круто!

  3. Агрессивные техники сжатия векторов позволяют размещать больше данных на GPU без критической потери качества. В статье, например, используется Sign-OPORP (One Permutation + One Random Projection).

Итого при использовании KNN-подходов на GPU мы получаем:

  • Честный KNN вместо приближённого ANN — никакие релевантные документы не теряются из-за аппроксимации (ну почти...).

  • Гибкость в бизнес-правилах: посчитали произвольную маску и применили до поиска по всему индексу.

  • Кастомные функции скоринга: любая метрика сходства довольно просто интегрируется на GPU.

Но остаются сложности:

  • Ограничения количества памяти GPU. Нужно хранить векторные представления миллионов товаров, а также метаданные для фильтров (их можно хранить в CPU). Отметим, что GPU-память стоит значительно дороже, чем CPU-память.

  • Необходимость компрессии. Векторы требуют квантизации, что может влиять на качество.

  • При росте каталога за пределы возможностей одной GPU возникает необходимость распределённого поиска (привет, шардирование).

  • Интерпретируемость решения сильно зависит от используемой функции скоринга.

Это красивая идея, которая активно развивается в крупнейших bigtech-компаниях. Но мы тут хотим рассказать про свои велосипеды!

Назад в будущее: document expansion

Классические подходы с эмбеддингами показывают хорошие результаты, но сложно интегрируются в продакшен‑систему. На этом фоне появляется альтернативное решение — переход к разреженным (sparse) представлениям документов, которые сохраняют структуру обратного индекса, обогащая его нейросетевыми предсказаниями.

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

Doc2query-like-подходы. Самое базовое решение без использования разреженного представления основано на простой идее: обучить seq2seq-модель предсказывать целиком запросы, по которым может быть найден документ. Далее обученная модель в офлайне генерирует запросы. На основе этих запросов строится отдельный обратный индекс. Таким образом, не требуется никаких доработок с точки зрения поискового движка. Важно: запросы не разбиваются на токены, что приводит к трудностям масштабирования на редких запросах. Отдельно отметим: seq2seq-модели склонны галлюцинировать, поэтому в офлайне требуется ещё одна стадия фильтрации кандидатов с помощью другой модели релевантности https://arxiv.org/abs/2301.03266.

SPLADE v2 (Sparse Lexical and Expansion Model) — на мой вкус, ключевая работа в этом направлении. Основная идея: обучить модель предсказывать, какие токены должны быть «предсказаны» для документа и запроса соответственно. Замечание: во второй работе авторы остановились на предсказании токенов только для документа.

SPLADE использует BERT-like-модель для кодирования документа и запроса, а затем вычисляет разреженное представление над словарём токенов. Каждому токену из словаря присваивается вес (логит) — он показывает, какова вероятность, что этот токен должен участвовать в представлении документа или запроса. Cегодняшние размеры словарей для LLM-моделей имеют порядок 32К — 128К токенов, поэтому для «разрежения» итогового вектора в статье применяют некое подобие лог-барьера — оставляют только не нулевые токены и применяют дополнительную регуляризацию во время обучения. Для расчёта сходства между запросом и документом необходимо просуммировать все логиты токенов запроса для документа.

Более подробно познакомиться с концепцией можно в статье pinecone. Там замечательные тексты и иллюстрации.

Ключевое преимущество этих подходов — минимальное усложнение запроса в онлайне. «Расширение» каждого товара генерируется один раз при построении индекса. Модель не зависит от запроса, поэтому:

  • Можно использовать модель любого размера без ограничений latency — вы ограничены только количеством GPU, используемых для инференса.

  • Каждый товар представляется набором нейросетевых токенов или полноценных запросов, по ним строится обратный индекс.

  • В онлайне достаточно разбить пользовательский запрос на токены и выполнить обычный поиск по обратному индексу.

Поскольку алгоритм поиска сводится к обратному индексу, все механизмы фильтрации, бизнес-правила и шардирование работают «из коробки» без специальных доработок.

Схематическая работа SPLADE модели
Схематическая работа SPLADE модели

В отличие от плотных эмбеддингов, разреженные представления явно говорят: «этот товар найден по токенам [огромный, розовый, слон]». Это позволяет понимать логику построения выдачи.

Несмотря на очевидные преимущества, есть важные нюансы:

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

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

  • Размер индекса. Предсказанные токены могут значительно увеличить размер индекса. Поэтому нужна агрессивная спарсификация, которая также влияет на качество получаемой выдачи.

Мы взяли за основу вариант с представлением документов при помощи предсказанных токенов, aka SPLADE. Так в Ozon появился проект под кодовым названием Query Prediction (QP).

Пункт назначения: Query Prediction

Токенизация запросов

В основе представления запросов для QP-модели лежит BPE-токенизатор, обученный на логах пользовательских запросов. Здесь есть важный нюанс: токенизаторы, как правило, обучаются на корпусе текстов, а частотность того или иного токена (или слова) определяется его встречаемостью в этом корпусе. Если при обучении использовать только уникальные запросы, популярные запросы будут представлены недостаточно качественно. Но и учитывать весь поток запросов, приходящих на сайт, тоже не лучшая идея: мы получим смещение в сторону популярных запросов, ранжирование которых преимущественно основано на кликовых факторах, а не на факторах текстовой релевантности.

Чтобы решить эту проблему, мы используем oversampling запросов. Например, если запрос «молоко» встречается в логахNраз, в нашем обучающем датасете он будет представленf(N)раз, гдеf(*)— логарифмическая или степенная функция. Это помогает модели выучивать частотные запросы, но не переобучаться только на них.

Также важно выбрать оптимальный размер словаря, сейчас наш словарь состоит из 65К токенов. Размер словаря влияет на качество обучения модели и на сложность обработки запроса поисковым движком:

  • Больше словарь → более качественное разбиение входного запроса → запрос разбивается на меньшее количество токенов → обходим меньшее число posting list.
    Пример: допустим, слово «молоко» в одном случае разбивается на три токена ["мо", "ло", "ко"], в другом — представляется одним токеном ["молоко"]. В первом случае нужно обойти и пересечь 3 posting list, а во втором — только 1.

  • Больше словарь → меньше размеры posting list → быстрее обходим posting list.
    Пояснение: часть документов будет перераспределена между новыми токенами, из-за этого уменьшится число документов в одном posting list. В нашем случае увеличение размера словаря в два раза приводило примерно к двукратному уменьшению количества документов в posting lists.

Отметим ещё один нюанс: для токенизации документов используется тот же токенизатор, что и для запросов. Мы экспериментировали с различными токенизаторами для документов и запросов, но в итоге победил подход, предложенный в SPLADEv2, вкупе с претрейном BERT-like-модели на задачу Masked Language Model. Но об этом далее.

Формирование датасета

Наверное, самая интересная часть в этом рассказе — на чём обучаться? Ответ может вас расстроить: мы просто собираем поисковые логи за три года. Сырой датасет состоит из полей:

  • запрос;

  • товар;

  • количество просмотров товара по запросу за историю;

  • количество кликов по товару по запросу за историю;

  • количество добавлений в корзину товара по запросу за историю;

  • количество заказов товара по запросу за историю.

Тут возникает следующий вопрос: как фильтровать полученный датасет. Полноценного ablation study для этой модели у нас не было. Был давний опыт с DSSM-like-моделями, который показал, что текстовая релевантность хорошо выучивается на добавлениях в корзину, поэтому используем именно этот сигнал. Почему не заказы или клики? Данных о добавлениях в корзину значительно больше, чем о заказах, что критично для обучения. Клики, в свою очередь, привели бы нас к кликбейтной выдаче.

Далее каждый запрос токенизируется обученным ранее токенизатором. Для каждой пары «токен запроса — товар» мы вычисляем вес, то есть суммированный сигнал по всем запросам. Замечание: далее query_part == токен запроса, item == товар. Формально это выглядит так:

\mathrm{weight}(\mathrm{query\_part}, \mathrm{item}) = \frac{\displaystyle\sum_{\mathrm{to\_cart}(\mathrm{query}, \mathrm{item}) > 0} \mathrm{to\_cart}(\mathrm{query}, \mathrm{item}) \cdot [\mathrm{query\_part} \in \mathrm{query}]}{\displaystyle\sum_{\mathrm{to\_cart}(\mathrm{query}, \mathrm{item}) > 0}\displaystyle\sum_{\mathrm{query\_part} \in \mathrm{query}} \mathrm{to\_cart}(\mathrm{query}, \mathrm{item})},\mathrm{target}(\mathrm{item}) = \bigl[\mathrm{weight}(\mathrm{query\_part_{i}}, \mathrm{item})\bigr]_{i=0}^{\mathrm{vocab\_size}}.

Полученный таргет нормализуется так, чтобы сумма весов для каждого товара равнялась единице. Таким образом мы получаем распределение вероятностей, после чего этот вектор можно использовать в качестве таргета для кросс-энтропии. Если говорить на языке PyTorch, то используется KLDivLoss.

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

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

  • Мы учимся на кросс-энтропию, с которой очень хорошо работают нейросети. Вспомните нашумевшую статью Stop Regressing, где авторы предлагают уйти от регрессии в пользу кросс-энтропии.

  • И последнее: обучение на кросс-энтропию избавляет нас от проблемы многих ранжирующих лоссов — подбор hard negatives. По нашему опыту, это одна из главных сложностей при обучении по-настоящему хороших DSSM-like-моделей.

Ещё один нераскрытый вопрос — как представлять товар для модели. В качестве текстового описания выступает набор атрибутов, специфичный для каждого товара. Атрибуты обрамляются спецтокенами и конкатенируются между собой.

Пример представления товара.

[attribute_season] демисезон [cat_name] брюки [attribute_brand] raspartu [attribute_sizem] 50 [attribute_type] брюки [attribute_heightm] 178 см [attribute_name] брюки мужские raspartu 318869-567 [attribute_composition] 75 п э, 23 вискоза, 2 спандекс [attribute_colorname] сине-голубой микродизайн [attribute_ageo] взрослая [attribute_annotation] гост 22595-2003 классические мужские брюки хорошего regular fit прямого кроя без стрелок. нижние карманы с отрезным бочком. задние в рамку, с застёжкой на пуговицу. подкладка переплетает пол овинок брюк предотвращает деформацию ткани в области колен, эластан в составе ткани делает брюки удобными и комфортными. брюки рекомендованы для носки в весенне-осенний период

Обучение модели

Сейчас мы используем предобученную BERT-like-модель: 24 слоя, 550 млн параметров. Модель предобучали для задачи Masked Language Modeling (MLM) на нашем корпусе товаров. На первых порах мы использовали ванильную RoBERTa из далёкого 2019 года и много мучились с подбором гиперпараметров, чтобы заставить модель эффективно сходиться. После перехода на litGPT (с некоторыми модификациями) проблемы с подбором гиперпараметров исчезли. В litGPT реализовано большинство SOTA-хаков для трансформеров, позволяющих улучшить сходимость. Эту же мысль нам транслируют в статье ModernBERT.

По сути, единственная модификация BERT, которая нужна, — это агрегация логитов, предсказанных для каждого токена документа:

def aggregate_bert_output(logits: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor: # logits: [batch_size, sequence_len, vocab_size] # attention_mask: [batch_size, sequence_len] processed_logits = processed_logits * attention_mask.unsqueeze(dim=-1) # зануляем предикты для PAD токенов processed_logits = torch.log(1 + F.relu(processed_logits)) predicts = processed_logits.max(dim=1).values return predicts

Особенная красота подхода с пулингом через максимум — это полное понимание, на какой токен документа «стриггерился» предсказанный токен. Достаточно сохранить индексы, и можно строить красивые визуализации.

Примеры визуализаций.

Пример 1

...развивающая игрушка... игровой центр 5 в 1: ксилофон, стучалка...

Запрос: игрушки стуч ##алки

Query → Document

Score

игрушкиигрушка

8.4

стучстучалка

7.1

##алкистучалка

7.3

Пример 2

...чехол для xiaomi redmi note 7, note 7 pro с рисунком, с карманом,...

Запрос: чехол на редми ноте 7

Query → Document

Score

чехол = чехол

12.2

надля

11.5

редми = редми

11.8

нотеnote

8.9

7 = 7

12.2

Пример 3

...кукла тваила monster high freak du chic g1, цирковая тематика, 12 - дюимовая коллекционная кукла...

Запрос: куклы монстр хаи g1

Query → Document

Score

куклыкукла

10.5

монстрmonster

11.6

хаиhigh

11.8

g1 = g1

11.9

Таким образом, наша модель чрезвычайно интерпретируема с точки зрения ответа на вопрос «почему именно этот товар попал в выдачу». Это позволяет, например, точечно улучшать текстовое описание документа!

Как было сказано раньше, обучение идёт на кросс-энтропию, чтобы предсказанное распределение максимально соответствовало целевому. Фактически модель учится предсказывать, какие части запросов чаще всего приводят к добавлению товара в корзину. Если попытаться подвести под это математическую базу, то после применения softmax к логитам мы получаем вероятностьP(query\_part|item). Тогда:

\mathrm{p}(\mathrm{query} | \mathrm{item}) = \prod_{\mathrm{query\_part} \in \mathrm{query}} \mathrm{p}(\mathrm{query\_part} | \mathrm{item}),\mathrm{log\_likelihood}(\mathrm{query} | \mathrm{item}) = \sum_{\mathrm{query\_part} \in \mathrm{query}} \log\bigl(\mathrm{p}(\mathrm{query\_part} | \mathrm{item})\bigr),\mathrm{qp\_score(\mathrm{query}, \mathrm{item})} = \mathrm{log\_likelihood}(\mathrm{query}, \mathrm{item}) = \log\bigl(\mathrm{p}(\mathrm{query} | \mathrm{item})\bigr).

Использование Query Prediction в поиске

Первое, о чём необходимо сказать: сейчас в проде используется смешивание кандидатов из классического обратного индекса с кандидатами из QP-кандидатами. QP-кандидаты отбираются отбираются по qp_score(query, item) и смешиваются в соотношении 4:1, после чего переранжируются LTR-моделью. Таким образом, QP в первую очередь обогащает уже существующую выдачу.

Соотношения смешивания не является «жёстким». Например, если мы нашли всего 100 кандидатов вместо желаемых 400 из классического обратного индекса и 100 кандидатов из QP-индекса, то QP-кандидаты не обрезаются. Таким образом, могут быть случаи, когда выдача целиком состоит из QP-кандидатов или наоборот.

Инференс QP

Для каждого товара берётся топ-50 наиболее вероятных токенов, они складываются в собственный индекс. При изменении текстового представления товара модель инферится заново, новое предсказание добавляется в индекс при помощи механизма realtime-обновлений. Полная реиндексация происходит раз в день.

Не сомневаюсь, что у вас возникнет вопрос, почему именно топ-50. Тут приходится балансировать между несколькими факторами:

  • Большее число токенов приводит к значительному росту размера индекса. А чтобы проводить A/B-тесты, нужно хранить в индексе более одного QP-поля.

  • Мы проверяли наивные подходы с 50, 100, 200 предсказанными токенами. Возникает другая проблема: «хвостовые» токены очень шумные — товары находятся по тем запросам, по которым находиться не должны. Если вы встречали такие примеры в выдаче, знайте, скорее всего, виновата именно эта модель.

  • Следующая идея — отсечение по порогу кумулятивной вероятности токенов. В теории идея отличная, но A/B-тесты показали, что нет ничего лучше топ-50 токенов. И даже отсечение по абсолютному порогу вероятности не смогло побить эвристику в топ-50 токенов...

Построение запроса в индекс

Когда пользователь вводит запрос, запрос разбивается на query parts при помощи токенизатора. Затем отправляется обычный запрос в обратный индекс, построенный на основе предсказанных query parts.При обходе обратного индекса применяется эвристика под названием Min Should Match: документ не возвращается в ответе, если доля найденных в документе токенов запроса ниже заданного порога. Обозначим этот порог за msm. Варьирование величины порога msm позволяет достаточно агрессивно игнорировать опечатки в низкочастотных запросах.

Важно отметить, что в индексе лежат логарифмы вероятностей, поэтому финальный скор товара вычисляется как сумма скоров по всем частям запроса:

\mathrm{qp\_score(\mathrm{query}, \mathrm{item})} = \sum_{\mathrm{query\_part} \in \mathrm{query}} \max\bigl(\log(\mathrm{p}(\mathrm{query\_part} | \mathrm{item})) - \log(10^{-6}), 0\bigr).

Здесь −log(10−6) — техническая поправка для положительности скоров, так как Apache Lucene не дружит с отрицательными числами в индексе... А логарифмы используются, чтобы избежать проблем с точностью при перемножении вероятностей.

Полученный qp_score для пары «запрос — товар» далее используется как фактор в ранжирующей модели и отвечает за текстовую релевантность вместо DSSM-like-модели. Тут важно отметить, что полученный qp_score имел относительно небольшую корреляцию (~0.6) с предыдущим dssm_score. Поэтому в этом месте есть потенциал для улучшения текстовой релевантности финальной выдачи благодаря совместному использованию QP и DSSM.

Фильтрация кандидатов

Чтобы избежать получения нерелевантных товаров-кандитатов, вместо суммы лог-вероятностей вычисляется средневзвешенная вероятность с учётом IDF — обратной частоты документа в QP-индексе. По сути, мы занижаем вес токенов, которые предсказываются почти для всех документов. Например, наша модель любит предсказывать предлоги и знаки пунктуации. Таким образом, мы получаем QP-IDF-скор по аналогии с TF-IDF.

Формально в качестве веса токена выступает:

w(\mathrm{query\_part} | \mathrm{query}) = \frac{\mathrm{idf}(\mathrm{query\_part})}{\sum_{\mathrm{query\_part} \in \mathrm{query}} \mathrm{idf}(\mathrm{query\_part})},\text{где } \mathrm{idf}(\mathrm{query\_part}) = \log{\frac{\mathrm{размер\ индекса}}{\mathrm{длина\ posting\ list\ для\ query\_part}}}.

То есть вес токена запроса зависит от длины posting list токена, размера самого индекса и количества токенов в запросе. Таким образом, по сути, мы теперь работаем со взвешенным likelihood:

\mathrm{weighted\_log\_likelihood}(\mathrm{query} | \mathrm{item}) = \sum_{\mathrm{query\_part} \in \mathrm{query}} w(\mathrm{query\_part} | \mathrm{query}) \cdot \log\bigl(\mathrm{p}(\mathrm{query\_part} | \mathrm{item})\bigr).

Для фильтрации кандидатов используется эвристика с отсечением по порогу. Документы, которые не прошли порог, не возвращаются в ответе.

\mathrm{weighted\_log\_likelihood}(\mathrm{query} | \mathrm{item}) > \mathrm{thr}.

Примеры:

[документ A] query_tokens = ['канаре', '##ика', 'поёт', '.'], total_idf = 27.882015076029656 token = 'канаре', token_score = 10.65, token_idf = 10.56, token_w = 0.379 token = '##ика', token_score = 11.33, token_idf = 5.86, token_w = 0.210 token = 'поёт', token_score = 0.00, token_idf = 9.59, token_w = 0.344 token = '.', token_score = 6.87, token_idf = 1.88, token_w = 0.067 score = 6.876278758431923 [документ B] query_tokens = ['влажный', 'корм', 'для', 'котят', 'домашних'], total_idf = 29.57261343234646 token = 'влажныи', token_score = 5.14, token_idf = 8.11, token_w = 0.274 token = 'корм', token_score = 10.97, token_idf = 6.91, token_w = 0.234 token = 'для', token_score = 11.85, token_idf = 1.00, token_w = 0.034 token = 'котят', token_score = 5.19, token_idf = 6.55, token_w = 0.221 token = 'домашних', token_score = 5.29, token_idf = 7.01, token_w = 0.237 score = 6.775961053327464

Как можно заметить, токен «для» и токен «.» имеют наименьший вес!

Большинство этих хаков были созданы, чтобы устранить такие примеры:

Исправленные примеры Но это всё не спасает нас от таких примеров:

Замечание: проблема является достаточно комплексной, так как в ней участвуют три компоненты: генерация кандидатов, переранжирование топа и реклама. При наличии, например, «идеального» ранжирования или «идеальной» рекламы пользователь никогда бы не увидел плохие товары в топе выдачи. Поэтому проблема курицы и яйца ранжирования и кандидатов здесь крайне актуальна.

Пара слов про офлайн-бенчмарки

Их просто нет. Мы смотрим на val loss.

6f359b91270c2dcdb0dc2c269a3e827e.jpg

Почему нам не подходят логи? В логах все действия пользователя совершаются со старой моделью-источником кандидатов. Пользователь не мог совершить действие с товаром, поднятым новой моделью, так как раньше он никогда не показывался по этому запросу. Логи хорошо подходят для ранжирующих моделей, так как вы можете сохранить ответ модели-источника кандидатов, чтобы после оптимизировать, например, продажи.

Поэтому наши следующие шаги после сравнения val loss — это добавление новой модели в индекс, получение продовой выдачи по отобранным запросам и разметка топа выдачи на текстовую релевантность. Но даже тут нельзя быть уверенным на 100% в результате, так как разметка текстовой релевантности далеко не всегда коррелирует с продажами, которые мы хотим растить в A/B-эксперименте. Получается, что модели доходят до A/B-экспериментов лишь на вере в val loss!

Миссия выполнена

Мы получили самое мощное улучшение метрик A/B-эксперимента в поиске за последние 4 года (для самого «ванильного» эксперимента, без упомянутых выше хаков):

  • конверсия в покупку из поиска: +2,1%;

  • среднее GMV на пользователя поиска: +1,5%;

  • количество периодов активности на пользователя: +0,4%;

  • общая рекламная выручка: +5,2%;

  • положительный пользовательский фидбэк: +6%.

После релиза QP в прод был традиционный тест с переобучением ранжирующей LTR-модели на новой модели-источнике кандидатов. Он накинул ещё +1% к конверсии в покупку из поиска.

За 2025 год мы поставили более 40 тестов с подбором различных параметров. QP-скор уже неизменно является самой важной фичей для наших ранжирующих моделей как с точки зрения SHAP-values, так и с точки зрения gain на ранжирующий val loss.

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

  • Мультимодальный QP, учитывающий не только текст, но и изображения товаров.

  • Добавление N-грамм в словарь. Это позволит учитывать порядок слов в запросе. Например, сочетание «type c» будет представлено одним токеном, а не двумя.

  • Замена IDF-весов токенов на нейросетевые скоры, чтобы веса зависели от контекста запроса, а не от состава posting lists.


Источник

Отказ от ответственности: Статьи, размещенные на этом веб-сайте, взяты из общедоступных источников и предоставляются исключительно в информационных целях. Они не обязательно отражают точку зрения MEXC. Все права принадлежат первоисточникам. Если вы считаете, что какой-либо контент нарушает права третьих лиц, пожалуйста, обратитесь по адресу [email protected] для его удаления. MEXC не дает никаких гарантий в отношении точности, полноты или своевременности контента и не несет ответственности за любые действия, предпринятые на основе предоставленной информации. Контент не является финансовой, юридической или иной профессиональной консультацией и не должен рассматриваться как рекомендация или одобрение со стороны MEXC.