Когда слов недостаточно, поможет семантический поиск на Elasticsearch

В IT-сообществе только и разговоров об эмбеддингах, metric learning, косинусных расстояниях и семантическом поиске. На конференциях все рассказывают про нейросети и векторные пространства. Но если заглянуть под капот и посмотреть, что реально работает в поиске крупных маркетплейсов и e-commerce платформ, то там, как правило, он — добрый, старый полнотекстовый индекс.

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

Меня зовут Игорь Самарин, я Machine Learning Engineer из команды поиска в Купере, где уже полтора года занимаюсь проектами, связанными с векторами. В этой статье я расскажу, как на самом деле работает поиск внутри компании, поведаю о полнотекстовом поиске — его сильных сторонах и недостатках. Затем объясню специфику векторного поиска и разберу, какие именно проблемы старого подхода он решает и продемонстрирую, как обучить векторную модель на своих данных, чтобы она понимала специфику каталога. А в конце вас ждут реальные результаты из A/B тестов и небольшой панч о перспективах. 

Поиск в многомерном хаосе: 35 тысяч ретейлеров и 150 тысяч уникальных запросов в сутки

Представьте, вы зашли в приложение Купер (веб или мобильное) и начали искать товар. Вводите запрос и ожидаете от системы максимально релевантных предложений. Внешне вроде просто, но давайте посчитаем. В нашей базе более 35 тысяч ретейлеров, каждый со своим каталогом: от продуктов питания до косметики и товаров для дома — спектр огромный. Ежесуточно в эту систему отправляются около 150 тысяч уникальных запросов — люди любят искать разные штуки, называя их совершенно разными словами.

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

  • Для справки: привычная пиковая нагрузка на систему у нас достигает 2000 RPS, а на ответ дается 300 миллисекунд, максимум. 

Вот в таких условиях и рождается вопрос: как вообще это делать? И главное — как делать это хорошо?

Путь запроса от ввода к результатам: четыре этапа, каждый со своей задачей

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

Этап 1: Приводим к общему виду

Первое, что мы делаем — стандартизируем запрос. Приводим его к нижнему регистру, удаляем лишние пробелы, избавляемся от знаков препинания. Звучит скучно, но это важно. Пользователь может ввести «МОЛОКО!!!», «молоко » или «молоко&» — и все эти вариации должны работать одинаково.

Этап 2: Исправляем опечатки 

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

  • Пример: пользователь написал «сгущонка» через О вместо Е. Модель видит проблему, понимает, что это ошибка или невнимательность, и сразу исправляет на правильное «сгущенка». 

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

Этап 3: Добавляем синонимы

Третий этап — расширение запроса синонимами. Люди часто ищут одну и ту же позицию (вещь, товар), называя разными словами.

  • Пример: «сгущенка» и «сгущенное молоко» — это один и тот же сладкий продукт. 

Для этого мы собираем пул синонимов и расширяем запрос: вручную (добавляя в базу слова-аналоги) или автоматически, при помощи алгоритмов, которые отлавливают соответствия.

Этап 4: Полнотекстовый поиск 

После стандартизации и коррекции ошибок можно переходить к полнотекстовому булевому поиску, для которого мы используем Elasticsearch — распределенную аналитическую систему с открытым исходным кодом, построенную на базе Apache Lucene.

  • Правило здесь простое: если все слова из запроса входят в карточку — показываем товар, если нет — скрываем.

Пример:

Почему? Потому что в карточке товара «Молоко 2,5% пастеризованное» с набором атрибутов вроде названия, бренда, категории слова «сгущенное» попросту нет. 

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

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

Когда слов недостаточно: встречайте векторный поиск

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

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

  • Отсюда вопрос: а можно ли вообще искать товары не по точному совпадению слов, а по смыслу?

  • Ответ: да, если научить систему понимать смысл, а не слово!

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

Чтобы измерить уровень сходства текстов используется косинусная близость (cosine similarity). Человеческим языком — это значение того, насколько близко два вектора «смотрят» в одном направлении. Для нормированных векторов это просто скалярное произведение. Звучит математически сложно, но на практике это означает:

  • «сгущенное молоко» и «молоко» окажутся далеко в векторном пространстве;

  • «молоко» и «телевизор» — тоже далеко;

  • «корм для кошек» и «корм для собак» — снова далеко, потому что система увидит семантическое различие.

Векторы создает нейросетевая модель, конкретно — Sentence Transformer, обученная преобразовывать текст в векторные представления. Заметим, однако: мы не используем готовую модель «из коробки». Мы берем готовую мультиязычную модель E5-large (которая уже знает, как работать с текстом) и дообучаем на своих данных. 

Почему? Потому что готовая модель обучена на общих текстах интернета. А нам нужна модель, которая понимает специфику именно нашего каталога: как люди ищут молоко, какие слова используют, какие товары покупают.

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

От покупок к векторам: как подготовить данные для обучения

Любая модель машинного обучения хороша настолько, насколько хороши данные, на которых ее обучают. В этом отношении нам очень помогает контрастивное обучение, благодаря которому модель условно понимает: что такое хорошо, что такое плохо.

Контрастивное обучение — это такой supervised-подход, означающий: мы не просто скармливаем модели тексты в надежде, что она что-то поймет, а показываем ей конкретные примеры:

  • вот это пара — хорошая, запрос и товар подходят друг другу;

  • а вот эта — плохая, запрос и товар не связаны.

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

Отрицательные примеры — это товары, которые не являются релевантными для конкретного запроса. Здесь есть нюанс, о нем мы поговорим ниже.

На этом этапе достаточно знать: модель учится на контрастах между хорошими (запрос–товар, купили) и плохими парами (запрос–товар, не купили). И чем точнее эти контрасты подобраны, тем эффективнее обучается модель.

Как структурировать информацию о товаре

Как вы понимаете, просто записать запрос и название товара — мало. Нужно дать модели как можно больше контекста. Поэтому мы используем укороченную карточку товара, включающую:

  • название товара;

  • название бренда;

  • мастер-категорию;

  • родительскую категорию.

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

Вот как это выглядит на практике:

Запрос: [query] Молоко

Товар (структурированный):

[title] Молоко 3,5% Parmalat [brand] Parmalat [master_cat] Молоко [parent_cat] Продукты питания

Видите, как помечен каждый элемент? «[Title]» — это название товара, «[brand]» — бренд и так далее. 

Почему мы так заморачиваемся со структурой? Потому что модель должна понимать: «Молоко 3,5% Parmalat» — это не просто молочный товар, это молоко конкретного бренда — Parmalat в категории «Продукты питания» (родительская категория). Такая структурированная информация помогает модели создавать более точные векторные представления.

Функция потерь: учим модель отличать хорошее от плохого

Следующий вопрос: как именно учить модель на этих примерах? Здесь нужна функция потерь — математическое выражение, которое показывает системе, насколько хорошо та работает, и в каком направлении ей развиваться.

Для этого мы используем InfoNCE loss (Information Noise-Contrastive Estimation), специально разработанную функцию потерь для контрастивного обучения.

В формулу InfoNCE входят четыре ингредиента:

  • вектор запроса — как модель представила пользовательский запрос в векторном пространстве;

  • вектор релевантного товара — как модель представила товар, который пользователь купил;

  • вектор нерелевантного товара — как модель представила товар, который пользователь не купил;

  • температурный гиперпараметр — число, которое регулирует «жесткость» обучения (насколько сильно модель штрафуется за ошибки).

InfoNCE учит модель максимизировать сходство между запросом и релевантным товаром, одновременно минимизируя сходство с нерелевантными товарами. Проще говоря, модель должна научиться говорить: «Вот этот товар точно подходит к этому запросу, а вот эти точно не подходят».

Hard negatives: почему простой выбор отрицательных примеров саботирует обучение

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

Наивный подход: берем любые товары из батча

Представим, что у нас есть батч из трех примеров:

Запрос

Релевантный товар

молоко

Молоко Простоквашино

малоко

Молоко Зеленое село

телевизор

Телевизор Sony

Все три пары — это положительные примеры (запрос и купленный товар).

По наивной логике для первого запроса («молоко») нерелевантными товарами будут все остальные товары в батче, потому что они не в паре именно с этим запросом:

  • молоко «Зеленое село» (не в паре с запросом 1);

  • Телевизор Sony (не в паре с запросом 1).

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

Проблема 1: Слишком простые отрицательные примеры

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

Проблема 2: Ложноотрицательные примеры — еще хуже

Сложнее с молоком «Зеленое село». Технически фраза не в паре с нашим запросом в этом батче, поэтому система считает ее нерелевантной для запроса «молоко». Но на самом деле — это релевантный товар! Это же молоко! Просто другой бренд. Модель учится отстраивать молоко «Зеленое село» от запроса «молоко». И это убивает качество обучения. Система разучивается находить релевантные товары.

Решение 1: Батч только из близких категорий

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

Вместо того чтобы добавлять телевизор в батч с молоком, добавим сметану. Потому что молоко и сметана — это близкие по смыслу товары (оба молочные), но все же разные.

Вот как выглядит улучшенный батч:

Запрос 1

Запрос 2

Запрос 3

молоко

малоко

сметана

Молоко Простоквашино

Молоко Простоквашино

Сметана Простоквашино

Теперь для запроса «молоко» нерелевантным товаром является только сметана Простоквашино. Молоко «Зеленое село» мы не берем как отрицательный пример, так-как оно из той же мастер-категории, что и релевантный товар.

  • Логика следующая: если отрицательный товар из другой категории (немолочные товары), то он точно нерелевантен для запроса про молоко. Но даже она не идеальна.

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

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

Решение 2: Дополнительные hard negatives примеры

Отрицательные примеры из категории релевантного товара

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

Пример: у нас есть запрос «Корм для кошек» и релевантный товар «Корм для кошек Whiskas». Мы идем в категорию «Товары для животных» и ищем позиции, в названии которых отсутствуют одновременно все токены из запроса (слова «корм», «для», «кошек»). Находим: «Корм для собак Whiskas».

Почему это хороший отрицательный пример? Потому что слова «кошка» и «собака» часто встречаются в одном контексте, т. е., вместе со словами «собака» / «кошка» часто стоят одинаковые слова, например, «корм», «для», «взрослых» и т. д. (из-за этого векторы для слов «собака» / «кошка» становятся близкими друг к другу). Так, модель учится различать их по смыслу, понимая разницу между кошачьим и собачьим кормом.

Товары, которые технически совпадают, но нерелевантны

Еще один источник хороших отрицательных примеров — конец полнотекстовой выдачи.

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

Классический пример: введите запрос «Яблоко» и найдите на последних страницах настольную игру «Яблочко-твист». Слово «яблоко» в названии есть! Технически это совпадение для полнотекстового поиска. Но купить фрукт и купить игру — это совершенно разные намерения.

Такие товары — идеальные hard negatives. Модель учится на них понимать: «да, слово совпадает, но контекст совершенно другой».

Валидация примеров через большую языковую модель

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

Что делать, если нерелевантных примеров много, а памяти - нет

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

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

Это требует колоссального объема видеопамяти. А видеопамять — это дорого и всегда ограниченно. Ее недостаточно, чтобы обработать такой большой батч, и нам приходится уменьшать размер батча. Но если уменьшить батч, мы теряем отрицательные примеры! Получается замкнутый круг.

Выход — техника Cross-batch Memory (память между батчами). Идея простая и элегантная, как скворечник.

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

  2. На текущем шаге обучения используем сохраненные векторы в качестве отрицательных примеров. Они уже вычислены, их не нужно пересчитывать.

  3. После каждого шага обучения обновляем очередь — добавляем новые релевантные товары из текущего батча и удаляем самые старые.

Результат: получаем массу отрицательных примеров без дополнительного потребления видеопамяти. Батч сохраняет нормальный размер, а модель учится на большом количестве примеров.Такое решение не требует дополнительной видеопамяти и позволяет расширить объем батча до нужных размеров, не теряя в качестве обучения. Все довольны!

Как оценить модель и выбрать способ внедрения

Модель обучена. Как оценить ее и начать использовать в реальной системе?

Как оценивают качество модели

Сначала про оценку. Обычно мы проверяем модель на отложенной выборке и смотрим на две метрики:

  • Recall@K — полнота на первых K позициях. Проще говоря: «Из всех релевантных товаров, сколько мы нашли в топ-K результатов?» Если пользователь может купить 10 разных молочных продуктов, а мы показали ему 8 из них в топ-20 результатов, то Recall@20 = 80%.

  • NDCG@K — это метрика, которая учитывает позицию релевантного товара. Товар в позиции 1 ценнее, чем в позиции 10. NDCG@K показывает, насколько хорошо ранжированы результаты.

Вместе эти метрики дают полную картину: нашли ли мы нужные товары и в правильном ли порядке их расставили.

Два способа запустить векторный поиск

Теперь перед нами встает выбор: как внедрить эту модель в боевую систему?

Способ 1: полный стек (сложный, мощный, дорогой)

Идеальный вариант для крупных систем:

  • развернуть векторную базу данных со всеми векторами всех товаров;

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

  • при каждом поиске генерировать вектор запроса и искать ближайшие векторы товаров в БД.

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

Способ 2: простой и быстрый (ограниченный, но работающий)

Альтернатива, которую выбрали мы. Для нее нужна только система поиска с полнотекстовым индексом (в нашем случае это Elasticsearch, у вас может быть что-то свое).

Как это работает?

Офлайн (один раз в сутки или реже, например, через Airflow):

  1. берем обученную модель;

  2. для каждого товара находим k ближайших к нему запросов;

  3. записываем эти запросы в специальное текстовое поле товара.

Онлайн (в реальном времени, при каждом поиске):

  1. пользователь вводит запрос;

  2. мы проверяем, входит ли этот запрос в текстовое поле товара;

  3. если входит — показываем товар, если нет — не показываем.

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

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

Техническая реализация: от векторов к простому текстовому полю

Двигаемся к финалу: как это все работает на практике?

Поиск ближайших запросов через FAISS

В офлайн процессе:

  1. берем обученную модель и все векторы запросов (векторы строим по запросам из истории) и товаров;

  2. используем FAISS для поиска k ближайших запросов к каждому товару.

FAISS невероятно быстр и эффективен — может найти ближайших соседей даже в огромных пространствах за миллисекунды.

Постобработка: чистим данные

Сырые результаты нужно почистить, для этого избавляемся от дубликатов. Если запрос «молоко 2,5%» и слова уже есть в названии товара, удаляем. Зачем добавлять то, что система и так находит через полнотекстовый поиск? Это просто балласт.

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

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

Настройка Elasticsearch: два анализатора

В Elasticsearch создаем новое поле vector_search типа text с двумя анализаторами:

  • анализатор для поля (он работает при индексировании) и просто разделяет ближайшие запросы по пробелам. Если в поле написано «спрайт газировка_спрайт спруйт», то система индексирует три отдельных токена;

  • анализатор для запроса (он работает при поиске), для чего берет пользовательский запрос и заменяет пробелы на подчеркивания. Если пользователь вводит «газировка спрайт», система превращает это в «газировка_спрайт» для поиска в индексе.

Благодаря совпадению анализаторов система находит сохраненные векторные запросы в поле.

Результаты A/B теста: цифры, которые говорят сами за себя

Теперь самое интересное — результаты. Мы запустили A/B тест и получили вот такую картину:

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

  • выросла CTR из начала выдачи в корзину — товары в топе результатов стали релевантнее, люди чаще их кликают и добавляют в корзину;

  • выросла CTR из просмотра выдачи в корзину — общая конверсия улучшилась, люди находят то, что ищут;

  • практически нулевая нагрузка на систему — никаких новых сервисов, никакой видеопамяти, просто одно текстовое поле в Elasticsearch.

Это именно то, что нам было нужно: хорошие результаты с минимальными затратами.

Заключение: планы 

Как любая увлеченная команда, мы не собираемся останавливаться. Дорожная карта, которую мы нарисовали для себя, достаточно амбициозна. В наших ближайших планах перейти к полноценному генерированию векторов запросов в реальном времени и приближенному поиску похожих товаров (ANN — approximate nearest neighbor search). Ожидается, что это даст системе большую гибкость и точность. К этому хотим добавить возможность комбинировать полнотекстовый и векторный поиск в зависимости от типа запроса. 

Например:

  • если пользователь ищет точный код товара или конкретный бренд — полнотекстовый поиск выигрывает;

  • если ищет «удобные туфли на каблуке» или «корм для кошек с лососем» — лучше векторный поиск.

Хочется добиться, чтобы система могла самостоятельно выбирать, какой метод использовать (или комбинировать оба) для каждого конкретного запроса. 

На этом все! Если у вас есть вопросы, с удовольствием пообщаюсь в комментариях!

Комментарии (0)