Как закешировать интеллект: LLM Cache в поиске Яндекс Лавки. CatBoost.. CatBoost. IT-компании.. CatBoost. IT-компании. llm.. CatBoost. IT-компании. llm. llm-архитектура.. CatBoost. IT-компании. llm. llm-архитектура. Блог компании Яндекс.. CatBoost. IT-компании. llm. llm-архитектура. Блог компании Яндекс. доставка.. CatBoost. IT-компании. llm. llm-архитектура. Блог компании Яндекс. доставка. Машинное обучение.. CatBoost. IT-компании. llm. llm-архитектура. Блог компании Яндекс. доставка. Машинное обучение. Управление разработкой.
Как закешировать интеллект: LLM Cache в поиске Яндекс Лавки - 1

Меня зовут Алексей Щекалёв, я работаю в команде машинного обучения Яндекс Лавки. Как думаете, что общего между пакетом молока, айфоном, дрелью и лабубу? Похоже на начало анекдота, но для нас это серьёзный технический вопрос. Ответ на него определяет, найдут ли пользователи то, что ищут, или разочарованно закроют приложение.

Мы столкнулись с этим вопросом в 2025 году, когда наш отлаженный поиск по продуктовому каталогу сломался о новую модель продаж. Тяжёлые модели понимали новые товары, но работали слишком медленно для рантайма, а быстрые не справлялись. Переобучать весь стек на каждый новый ассортимент было бы слишком дорого и долго. Казалось, что компромисс «качество vs скорость» неразрешим, но мы нашли третий путь.

Как всё начиналось

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

Как закешировать интеллект: LLM Cache в поиске Яндекс Лавки - 2

На старте, в 2020–2021 годах, когда Лавка была сервисом доставки продуктов за 15 минут, сложные решения не требовались. Каталог даркстора (маленького склада, откуда курьер везёт товары клиенту) составлял порядка 10³ позиций. Хлеб, молоко, чипсы — всё было на виду. 

Тогда поиск прекрасно работал на классических алгоритмах: BM25 и Ахо — Корасик. Это был надёжный, быстрый, но абсолютно «деревянный» полнотекстовый поиск. Система смотрела исключительно на токены, и, если пользователь вводил «молоко», мы искали товары, в названии или описании которых есть эта последовательность букв. Искать по смыслу мы тогда не умели. 

В 2022 году за поиск взялась профильная ML-команда. Как и многие, мы начали с классики — CatBoost. Механизм отбора кандидатов остался текстовым, но мы добавили этап ранжирования: брали найденных кандидатов и с помощью CatBoost поднимали вверх более кликабельные и конверсионные позиции. Выдача стала точнее, метрики подросли, но «слепота» к смыслу запроса никуда не делась.

Прорыв случился в 2023 году, когда мы внедрили семантический поиск. Теперь можно было ввести запрос вроде «вкусненькое», и если раньше мы бы нашли только товары с этим токеном в названии, то семантический поиск возвращал десерты, сладости и тому подобное. Мы научились искать не по совпадению слов, а по смыслу запроса.

В 2024 году мы внедрили многостадийный поиск с персональным ранжированием в три этапа: 

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

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

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

Этот пайплайн замечательно работал до тех пор, пока ассортимент Яндекс Лавки оставался стабильным.

Кризис масштабирования

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

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

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

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

Генерация кандидатов

Чтобы определить релевантное подмножество из всего ассортимента, нужно сначала ответить на вопрос: а что вообще такое релевантный товар для конкретного запроса?

Разметка релевантности

Мы составили инструкцию, в которой чётко описали критерии релевантности, и отправили задачу в Яндекс Задания. Пользователи разметили несколько сотен тысяч пар «запрос — товар» на четыре класса. 

Вот, например, запрос «шоколадное мороженое»:

Как закешировать интеллект: LLM Cache в поиске Яндекс Лавки - 3
  • Рел+ — точное совпадение. Товар «Мороженое „48 копеек“ шоколадное» полностью соответствует интенту.

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

  • Рел− — созвучие, но промах, например шоколадка. С точки зрения текста здесь чёткое пересечение, но пользователь хотел мороженое, а не плитку шоколада. В поиске такое скорее показывать не нужно.

  • Нерел — например, салфетки, то есть мусор, который не имеет никакой связи с запросом.

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

Получив размеченный датасет, мы обучили модель с ранним связыванием на базе BERT. Архитектурно она работает так: на вход модель получает единую последовательность «поисковый запрос + описание товара». На выходе BERT выдаёт векторный эмбеддинг текста. Поверх этого эмбеддинга стоит полносвязная нейронная сеть, которая превращает его в скаляр от 0 до 1. Получается оценка релевантности, где единица — очень релевантный товар, а ноль — нерелевантный.

Как закешировать интеллект: LLM Cache в поиске Яндекс Лавки - 4

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

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

Дистилляция в DSSM

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

Мы взяли обученный BERT и с его помощью собрали разметку примерно из 107 релевантных пар «запрос — товар». На этих данных обучили DSSM — двухбашенную модель. Одна «башня» независимо обрабатывает текст запроса и сжимает его в вектор (эмбеддинг запроса), вторая делает то же самое с описанием товара. Релевантность определяется через скалярное произведение двух получившихся векторов.

Как закешировать интеллект: LLM Cache в поиске Яндекс Лавки - 5

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

DSSM применима в рантайме и даёт хорошее качество на популярных запросах. Пока ассортимент Лавки менялся незначительно (добавлялись йогурты с новыми вкусами, новые чипсы), модель прекрасно справлялась. Но у неё есть фундаментальные ограничения. Она тяжело обобщает за пределами обучающей выборки, требует постоянного дообучения и плохо работает на новом ассортименте, как в нашем случае.

Причина в том, что DSSM обучается с нуля, в отличие от BERT, который предобучен на большом корпусе текстов. DSSM знает ровно то, что мы ей показали. Если приходит запрос, которого не было в обучающих данных, модель выдаёт эмбеддинги, не соответствующие реальным поисковым запросам.

Неизбежный (?) компромисс

Получается дилемма. Модели с ранним связыванием дают отличное качество на новом ассортименте и новых запросах, но неприменимы в рантайме. В то же время двухбашенные модели (DSSM) работают быстро и применимы в рантайме, но плохо обобщают.

Оба подхода работают, оба нужны, но каждый имеет критический недостаток именно в нашей ситуации. По сути, мы столкнулись с компромиссом «качество — latency — гибкость». Модель с ранним связыванием даёт качество и гибкость (понимает новый ассортимент без переобучения), но проигрывает по latency. DSSM даёт скорость, но жертвует и качеством на сложных запросах, и гибкостью — каждый новый ассортимент требует цикла переобучения.

Чтобы DSSM начала понимать новый ассортимент, её нужно переобучить на новых данных. Но недостаточно переобучить только модель генерации кандидатов, придётся переобучить и фильтрующую формулу, и ранжирующую. Это достаточно долгая, рутинная работа. А если придёт ещё один новый ассортимент? Скажем, начнёт продаваться компьютерная электроника — тогда всё нужно делать по новой.

Вместо этого мы задались вопросом: а какие вообще особенности лавкового поиска? Что отличает его, скажем, от поиска на маркетплейсах или от большого Яндекс Поиска?

Концентрация спроса

Мы посмотрели на сырую статистику и увидели несколько важных цифр: 10⁵ товаров в общем ассортименте (по сравнению с маркетплейсами или веб-поиском это, по сути, ничто); 10³ товаров в конкретном дарксторе — ещё на два порядка меньше; 10⁶ уникальных поисковых запросов в месяц — число большое, но стоит посмотреть на их распределение.

Как закешировать интеллект: LLM Cache в поиске Яндекс Лавки - 6

Мы посчитали для каждого поискового запроса, сколько товаров добавляют в корзину, и увидели экстремальную концентрацию спроса:

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

  • 2000 запросов покрывают 80% добавлений в корзину.

  • ~10⁵ запросов покрывают 97% добавлений в корзину.

Оставшиеся 3% — длинный хвост из сотен тысяч уникальных, редких запросов.

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

LLM Cache

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

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

Офлайн-этап: подготовка кеша

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

Сначала мы приводим запросы к нижнему регистру и обрезаем пробелы (trim). Опечатки не исправляем. Затем для каждого запроса считаем количество добавлений в корзину и сортируем по убыванию. И наконец, оставляем лишь те запросы, которые кумулятивно дают 98% добавлений в корзину.

Получается сравнительно немного — порядка 10⁵. При желании кеш можно расширить до 10⁶, и он покроет практически любой запрос, который когда-либо приходил в поиск.

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

  1. Декартово произведение — самый ресурсоёмкий этап. Берём топ запросов и строим пары с каждым товаром из ассортимента: Pairs = Queries_top × Items_all. При 10⁵ запросов и 10⁵ товаров получается 10¹⁰ пар, образуются миллиарды комбинаций. В рантайме это было бы нереально, а в офлайне вычислимо, хотя и требует серьёзных ресурсов. Тут возможны разные оптимизации, но самый простой способ — скорить все пары полным перебором.

  2. Каждую пару прогоняем через LLM. Модель выдаёт для каждой пары скор релевантности — число от 0 до 1, где значения ближе к 1 соответствуют классам «рел+» и «рел» из нашей разметки, а ближе к 0 — классам «рел−» и «нерел». Берём товары, попавшие в «рел+» и «рел», а всё остальное просто отбрасываем. Как именно скор связан с классами и где проходит порог отсечения, разберём чуть ниже.

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

Порог отсечения выбираем по разметке. Когда дообучали BERT, мы присваивали классам REL+, REL, REL−, NON-REL скоры от 0 до 1 (REL+ = 1, NON-REL = 0, промежуточным классам — значения между ними) и обучали модель на MSELoss. Благодаря этому выходные скоры модели лежат примерно в том же диапазоне и группируются вокруг значений, соответствующих каждому классу разметки.

Это даёт простое правило выбора порога: берём скор, соответствующий классу «рел», и немного опускаем его вниз, чтобы компенсировать разброс модели. В кеш попадают все пары со скором выше этого порога — так мы гарантированно забираем REL+ и REL, отсекая REL− и NON-REL.

Как закешировать интеллект: LLM Cache в поиске Яндекс Лавки - 7

Модель, определяющая релевантность, может быть любой. Мы используем BERT на несколько сотен миллионов параметров, дообученный на вручную собранной разметке, но можно взять локальную LLM на ~10⁹ параметров или даже обратиться к LLM через API. Модель покрупнее, предобученная на большом объёме знаний о мире, и без дообучения будет хорошо понимать, какие товары релевантны каким запросам.

Рантайм: быстрая отдача результатов

В рантайме схема проще.

Когда приходит поисковый запрос, мы первым делом проверяем, есть ли он в кеше.

  • Если он попал в кеш, то мы достаём из кеша список товаров со скорами релевантности и отправляем на персональное ранжирование. Модель сортирует товары с учётом предпочтений конкретного пользователя. Путь короткий: кандидаты из кеша → ранжирование.

  • Если запрос не попал в кеш, включается фолбэк: классический рантайм-пайплайн — DSSM и BM25 для генерации кандидатов, затем фильтрация, затем ранжирование. Качество выдачи чуть ниже, чем у модели с ранним связыванием, но мы хотя бы что-то находим. А поскольку таких запросов меньшинство, нагрузка на этот пайплайн остаётся в пределах нормы.

Как закешировать интеллект: LLM Cache в поиске Яндекс Лавки - 8

Важный момент: при попадании в кеш мы пропускаем фильтрацию и рантайм-генерацию кандидатов. Они уже отобраны тяжёлой моделью в офлайне — остаётся только персонализировать выдачу.

Результаты

С этим пайплайном нам удалось достичь заметных результатов. 

Лавка: качество и скорость

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

Мы адаптировали поиск под супермаркеты без переобучения всего стека. Раньше для поддержки новых запросов вроде «iPhone», «лабубу», «чайник» пришлось бы заново переобучать модели генерации кандидатов, фильтрации и ранжирования, дожидаясь накопления логов. Теперь достаточно забросить новое множество товаров в офлайн-пайплайн и проскорить. Декартово произведение порождает миллиарды или десятки миллиардов пар. Это требует вычислительных ресурсов, но, в отличие от рантайма, жёсткого ограничения по latency здесь нет.

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

Товары по пути

В поиске появились товары из аптек, зоомагазинов, цветочных магазинов и других ритейлов по пути следования курьера. Чтобы понять, какие запросы характерны для нового ассортимента, мы подсмотрели у коллег из Яндекс Еды, по каким запросам пользователи добавляют подобные товары в корзину. Именно логи Яндекс Еды стали источником топ-запросов, так как в Лавке таких запросов ещё просто не было. Мы отправили их в механизм кеширования, и модель, обученная на большом объёме знаний, прекрасно справилась: определила, какие товары релевантны аптечным запросам, а какие — зоотоварам и так далее.

Эксперимент с ритейлом Яндекс Еды

Мы пошли дальше. Задача, которую мы решаем в Лавке, не уникальна, ведь в ритейле Яндекс Еды есть немало партнёров с сопоставимыми объёмом ассортимента и характером поисковых запросов. Мы построили кеш по товарам крупных сетевых партнёров, не переобучая модель специально под них. Просто применили существующий пайплайн и построили индекс не по товарам Лавки, а по чужому каталогу. Эксперимент показал прирост качества поиска по сравнению с базовыми механиками, которые работали у этих партнёров.

Это подтвердило, что подход универсален и не привязан к конкретному ассортименту.

Где можно переиспользовать LLM Cache

Описанный подход применим, если выполняются три условия:

  1. Небольшой ассортимент — порядка 10⁵ SKU. При таком ассортименте полный перебор пар «запрос — товар» в офлайне считается прямолинейно. Если каталог значительно больше — десятки миллионов товаров, как у крупных маркетплейсов, — полный перебор становится слишком дорогим даже в офлайне, но подход всё равно применим: вместо перебора по всему ассортименту можно офлайн отобрать топ-N кандидатов на запрос более простым методом и прогонять через LLM уже их. Покрытие кеша будет ниже, и появятся дополнительные компромиссы, но общая схема сохраняется. Для продуктовых ритейлеров, аптек, зоомагазинов, магазинов электроники декартово произведение работает без таких ухищрений.

  2. Концентрированный спрос, когда малая часть запросов приносит основную долю добавлений в корзину. Это типично для большинства e-commerce-сервисов с ограниченным каталогом.

  3. Доступ к модели для оценки релевантности. Подойдёт BERT, локальная LLM на ~10⁹ параметров, LLM через API. Главное — чтобы модель умела определять релевантность пары «запрос — товар». Чем мощнее модель, тем лучше качество кеша, но даже относительно небольшой дообученный BERT даёт хорошие результаты.

Заключение: третий путь

Как правило, при построении поиска выбирают одно из двух: коробочный полнотекстовый поиск вроде BM25, который работает быстро, но без понимания смыслов, или сложный ML-пайплайн с генерацией кандидатов, фильтрацией и ранжированием. Второй куда умнее, но дорог в поддержке и теряет эффективность при смене ассортимента.

LLM Cache — промежуточный вариант. Он уже понимает смыслы, но при этом так же прост в интеграции, как обычный полнотекстовый поиск. Он даёт хорошее качество поверх BM25 и достаточно прост в реализации. Выдачу из кеша можно дополнительно переранжировать персонально, докидывать кандидатов, есть масса возможностей для развития и тонкой настройки.

Интеллект модели и скорость её работы совсем необязательно должны быть связаны в одном моменте времени. Можно быть умными в офлайне и быстрыми в онлайне.

Автор: AShchekalev

Источник