Привет, Хабр! На связи команда Ad-Hoc аналитики X5 Tech.
В этой статье расскажем, как мы научили поиск извлекать важные сущности из запросов пользователей. Полный разбор реализации NER (Named Entity Recognition) для продуктового ритейла, шаг за шагом: как мы размечали данные, считали метрики на уровне токенов и сущностей — и почему для коротких и длинных запросов потребовались разные архитектурные решения.
Представьте: покупатель открывает мобильное приложение, нажимает на строку поиска и вводит всего несколько букв — «моло…». Пока он ещё не успел закончить слово, поисковая система уже должна предложить релевантные товары: «Молоко 3,2 % “Домик в деревне” 1 л», «Молочный коктейль 250 мл», а может, даже разложить фильтры по жирности, бренду и упаковке. Именно в этот момент решается судьба покупки: насколько точен и «умён» поиск — настолько выше вероятность, что человек кликнет именно туда, дойдёт до листинга, положит товар в корзину и оформит заказ. По открытым отчётам известно, что сессии, в которых задействована строка поиска, конвертируются в покупку в 2–3 раза лучше. Но тут есть и Ахиллесова пята — всё это должно случиться за сотни миллисекунд. Каждые дополнительные 100 мс отнимают около 7 % конверсии, а неправильная подсказка уводит пользователя в нерелевантный раздел каталога и заставляет рыться в бесконечных результатах.
Чтобы автодополнение пользовательского ввода действительно угадывало его намерение, поисковый движок обязан моментально вычленить в оборванном тексте несколько уровней смысла. Для продуктового ритейла критичны четыре сущности:

Сущность |
Что это такое |
Зачем в поиске |
TYPE |
Категория товара («молоко», «сыр», «кофе») |
Моментальный фильтр по категориям |
BRAND |
Торговая марка, под которой продаётся линейка товаров («Домик в деревне», «Nescafe Gold») |
Даёт брендовый фильтр и приоритизацию SKU, повышая лояльность и средний чек (брендовые товары часто дороже) |
VOLUME |
Количество продукта в упаковке («500 г», «250 мл», «5 × 0,33 л») |
Влияет на сортировку и задаёт правильный размер товара в выдаче |
PERCENT |
Процентная характеристика товара (жирность, доля ключевого компонента) |
Важный атрибут для молочки, шоколада и так далее, позволяет точнее упорядочить карточки и исключать нерелевантные |
Именно выделение этих сущностей из свободного пользовательского текста — цель нашего исследования. Мы расскажем, как решали задачу распознавания именованных сущностей в пользовательских запросах. Это основа для семантического поиска: система должна отличать бренд от типа товара, а объём — от процента жирности. Звучит почти тривиально, но, как всегда, дьявол кроется в деталях и паре случайных опечаток.

На самом деле около 80 % реальных запросов — однословные, ещё 15% состоят из двух слов, длинные фразы — редкость. Даже такие короткие тексты часто содержат ошибки или неполные слова: «слк фрут няня 200 мл», «моло», «йогур». Напоследок, цифры многозначны: «3%» — это жирность, а «500» рядом с сыром — уже граммы. Поэтому извлечение TYPE, BRAND, VOLUME и PERCENT из сжатых, шумных запросов превращается в полноценную NER‑задачу с жёсткими требованиями к скорости и точности.
Когда поисковая система хорошо и, главное, быстро срабатывает, то это напрямую влияет на поведение пользователя. Если человек введёт «кефир 1 л» и ему отобразятся литровые товары, то он с большей вероятностью кликнет на один из них. В то же время, когда модель понимает, что речь идёт о конкретном бренде, к примеру, «Parmalat», то это уменьшит вероятность того, что пользователю отобразятся страницы с неподходящими ему товарами. Как итог, получаем, что качественное определение сущностей позволяет пользователям намного быстрее находить товары. Это улучшает клиентский опыт, формируя привязанность клиента к сервису, что увеличивает частоту заказов и средний чек. А учитывание опечаток и неполных слов позволяет системе быть полезной в реальных условиях, особенно на мобильных устройствах, где часто совершаются ошибки.
Чтобы справиться с вышепоставленной задачей, мы взглянули на несколько подходов — от регулярных выражений до предобученных трансформеров вроде rubert‑tiny2 и rubert-base-cased, а также протестировали классическую BiLSTM-CRF.
Работа с данными
Всё начинается с генерации синтетического набора запросов. Справочники каталога товаров уже содержат всё, что будет полезно для NER-модели: бренды, типы объёмов и другие характеристики.
Разметка была выполнена автоматически по BIO-схеме:
B — первый токен сущности, I — остальные, O — фон. Для задач, где особенно важны чёткие границы, есть расширение BILOU (где добавлены метки U — одиночная сущность и L — конец). Ещё есть flat-теггирование, когда каждой сущности присваивается только тег без указания позиции. Однако прирост точности от применения BILOU не превышает нескольких процентов, а при flat-схеме часто теряются начало и конец, поэтому базового BIO оказалось достаточно.
Допустим, поступил запрос «Купить молоко домик в деревне»: после BIO-разметки токены получают метки O B-TYPE B-BRAND I-BRAND I-BRAND.
Каталог под рукой? Нет? У нас припасён план Б, В и даже Г:
Подход |
Где хорош |
Где подводит |
Ручная разметка |
Человек учитывает контекст |
Достаточно неэффективный метод, может расти количество ошибок из-за усталости |
Регулярные выражения |
Достаточно быстрый метод |
Не учитывается контекст, закрывается маленькая доля выборки |
Предобученные NER-модели |
Захватывают большинство брендов и категорий |
Нужен GPU, в специализированном контексте требует тщательного дообучения |
LLM-разметка |
Высокая точность, помнит контекст, исправляет опечатки |
Платные API-запросы, при увеличении итераций расходы растут |
Чтобы теги оставались корректными даже при ошибочных запросах, добавим соответствующие правила в нашу разметку:
есть ошибка в одной букве слова («сфр» → «сыр»);
переставлены две буквы («йогрут»);
комбинация первых двух («иогрут»);
в конце потеряно 1–2 буквы («моло», «огуре»);
в начале пропущена 1 буква («йца», «олоко»).
Так корпус сохраняет правдоподобные опечатки, не ломая разметку.
Для NER нам не нужен массивный NLP конвейер, достаточно лишь трёх шагов:
Нормализация: приведение к нижнему регистру, обработка пунктуации и опечаток.
Балансировка классов: приводим количество сэмплов из разных классов к одному числу.
Токенизация: помощь в обработке запросов, содержащих опечатки и неполные слова.
Мы сознательно не используем лемматизацию, стемминг и не выбрасываем стоп-слова: в поисковом запросе каждая буква может быть важна.
Как измерить качество NER-модели?
По духу NER — обычная многоклассовая классификация, так что базовые метрики те же: precision, recall, f1-score. Но считать их можно двумя способами, выбор которых сильно влияет на итоговое значение:
Token-Level Metrics — каждый токен, чья метка совпала с эталоном, добавляет +1 в TP, то есть частичные совпадения не считаются полной ошибкой.
Entity-Level Metrics — здесь производительность модели оценивается на уровне целых сущностей. В этом случае важна не только правильная классификация токенов, но и то, чтобы вся сущность была предсказана верно.

Для фразы «молоко домик в деревне» эталонная BIO-разметка выглядит как B-TYPE, B-BRAND, I-BRAND, I-BRAND, но пусть модель предсказывает метки B-TYPE, B-BRAND, O, I-BRAND, тогда:
-
Token-Level: TP = 3, FP = 0, FN = 1
Precision = 1, Recall = 0.75, F1 = 0.86
Здесь внушительные 86 %, хотя бренд «домик в деревне» модель отделила разрывом.
-
Entity-Level: Эталонная разметка содержит две сущности, наша модель предсказала лишь одну (комбинация B-BRAND, O, I-BRAND рвёт бренд на куски, поэтому данная сущность не засчитывается).
Precision = 1, Recall = 0.5, F1 = 0.67
Обратим внимание на то, что token-F1 ≈ 86 %, а entity-F1 всего 67 %. Вот тут кроется коварство: на уровне токенов всё блестит, а сущность-то распалась на крошки. Именно поэтому в итоговых результатах мы будем ориентироваться на CoNLL-style entity F1 — он честно показывает, справилась ли модель с выделением сущности или нет.
Бейзлайн на регулярных выражениях
Начнём с самого простого — одного большого регулярного выражения, которое было собрано из словарей категорий, брендов, единиц измерения и процентов. Логика делится на две части:
-
Конструктор паттернов
Для каждой сущности собираем собственный список: названия категорий в TYPE, бренды в BRAND, типовые объёмы («г», «кг», «мл», «л», «шт») в VOLUME, символ «%» в PERCENT. Числовые значения обрабатываются отдельно ― особенно для объёмов и процентов. Строки с символами («№5», «3%») ищутся точным совпадением, слова вроде «кефир» ― лемматизированным подшаблоном, чтобы подобрать окончания «кефира», «кефиром».
Всё это собирается в единый regex с флагами IGNORECASE (для учёта регистра букв при поиске) и UNICODE.
-
Извлечение сущностей
Алгоритм идёт слева направо. Применяются основные паттерны, дополнительно обрабатываются числовые значения: если после числа в тексте встречается символ «%», это считается показателем жирности — сущность типа PERCENT. Если же за числом идут слова вроде «г», «мл», «шт» и другие подобные обозначения, число определяется как объём (VOLUME). Чтобы разметка оставалась чистой и непротиворечивой, перекрывающиеся совпадения исключаются: приоритет всегда остаётся за первыми найденными сущностями в порядке их появления в тексте.
BiLSTM-CRF — классика, которая «тащит» короткие запросы
После того, как мы изучили бейзлайн, перейдём к модели, которая уже много лет фигурирует в NER — Bidirectional LSTM-CRF (см. классическую статью Bidirectional LSTM-CRF Models for Sequence Tagging).
Идея проста и элегантна: рекуррентная сеть пробегает предложение слева-направо и справа-налево, это позволяет учитывать контекст с двух сторон. CRF-слой на выходе не даёт тэгам «скакать»: после B-TYPE может идти только I-TYPE или O, если появляется другой B-LABEL, это трактуется как начало новой сущности. Он ищет глобально наиболее вероятную цепочку меток, а не решает судьбу каждого токена в одиночку.
Как итог для «быстрых» коротких запросов (а их у нас 80 %) BiLSTM-CRF оказался идеальным кандидатом: минимальная задержка, приличный F1 (около 62 %) и никакой зависимости от гигантских языковых моделей.
Трансформеры: fine-tuning RuBERT-base vs RuBERT-tiny2
Когда пользователь набирает целое предложение в запросе, модель должна сохранять контекст всех слов сразу. Классическим RNN-архитектурам этого не хватает. Пока предложение короткое: память хранит пару-тройку токенов без искажений, но по мере роста длины последовательности контекст размазывается. Тогда на сцену выходят трансформеры, которые за последние 5 лет являются де-факто “золотым стандартом” NER. Мы протестировали две русскоязычные версии BERT DeepPavlov / rubert-base-cased и cointegrated / rubert-tiny2 и довели их до ума под наш синтетический корпус:
DeepPavlov / rubert-base-cased — полноразмерная BERT-модель, обученная на новостных и социальных текстах. Основана она на bert-base-cased c 12 слоями, hidden size = 768 и имеет около 180 млн параметров. Даёт наилучшее качество на наших данных.
cointegrated / rubert-tiny2 — компактная BERT-модель, представляющая собой дистиллированную версию BERT c 3 слоями и hidden size = 312. Содержит порядка 29 млн параметров. Обучалась, «перенимая знания» у более тяжёлой multilingual-версии. Компактность позволяет учиться и работать в несколько раз быстрее, при этом качество теряется лишь умеренно.
Какие результаты?
В сравнительной таблице видим главную проблему регулярных выражений – с точностью всё хорошо, но опечатки, склонение, неполные слова сильно занижают полноту. BiLSTM-CRF показывает стабильно средние метрики, а модели bert имеют проблему, обратную регулярным выражениям, охватывают большое количество реальных сущностей, теряя в точности.
|
RegEx |
BiLSTM-CRF |
Rubert-base-cased |
Rubert-tiny2 |
Precision |
0.84 |
0.71 |
0.50 |
0.46 |
Recall |
0.33 |
0.63 |
0.78 |
0.75 |
F1 |
0.48 |
0.62 |
0.60 |
0.58 |
Другие наблюдения:
Полноразмерный rubert-base-cased действительно показывает самый высокий F1-score на запросах от трёх слов: чем богаче контекст, тем больше модель использует своё «языковое прошлое».
rubert-tiny2 учится в четыре-пять раз быстрее и заметно экономнее обращается с ресурсами на инференсе, чем rubert-base-cased, а уступка в качестве измеряется единицами процентных пунктов.
Обе Bert модели начинают «галлюцинировать» теги на однословных строках ― пытаются дорисовать сущности там, где их нет. Пусть пользователь ввёл однословный запрос — “телефон”, и по нашей обучающей схеме это слово должно получить отметку O, потому что телефонов в справочниках типов товара нет. BERT, полагаясь на общие языковые знания, радостно ставит B-TYPE. BiLSTM-CRF при этом спокойно говорит “не знаю, значит O”, поэтому на небольших строках именно рекуррентная сеть сохраняет более высокую точность.
Если важен каждый процент F1 и железо позволяет — берём rubert-base-cased. Когда приоритет на скорости отклика и экономии памяти, а потери качества должны быть минимальны — выбираем rubert-tiny2. В связке с лёгкой BiLSTM-CRF для однословных запросов, компактная модель даёт ровный, предсказуемый результат без тяжёлого ценника на продакшн-серверы.

Что делать с сущностями?
Пусть мы научили нашу модель находить сущности в запросах, распознавать опечатки и получать разметку в формате BIO, но что делать с распознанной сущностью B-Type «сыр», если она ввелась с опечаткой? Здесь можно предложить несколько подходов. Один из них — использование расстояния Левенштейна. Расстояние Левенштейна — это минимальное количество вставок, удаления или замены одного символа в строке для превращения одной строки в другую. Подход, на первый взгляд, выглядит вполне рабочим: находим расстояния между распознанной сущностью и всевозможными категориями/брендами, хранящимися в базе данных и берём минимальное расстояние. Но здесь есть свой подводный камень: если пользователь ищет макароны, а категория в приложении называется «Макаронные изделия», то расстояние Левенштейна для этой категории будет больше, чем к любому слову из 8 букв.
Чтобы обойти ограничения расстояния Левенштейна, мы перешли к более смысловому способу — использованию эмбеддингов и косинусного расстояния. Идея такая: вместо того чтобы сравнивать строки по буквам, мы превращаем распознанную сущность и категории в векторы фиксированной длины. Мы используем CLS-эмбеддинг — это вектор, связанный с первым специальным токеном, который модель BERT обычно применяет для задач классификации. Он как бы суммирует смысл всей фразы.
Чтобы измерить, насколько совпадают направления векторов в пространстве, мы считаем косинусное расстояние между эмбеддингом распознанной сущности и эмбеддингами всех возможных классов из базы. Потом выбираем тот, у кого расстояние максимальное (то есть угол минимальный, значит вектора ближе всех друг к другу). Такой способ устойчив к опечаткам, сокращениям, разным формулировкам и синонимам — главное, чтобы общий смысл совпадал.
Для двух векторов A и B косинусное расстояние вычисляется по формуле:
Ниже представлена кодовая реализация сопоставления сущностей. Перед запуском кода предполагается, что у вас уже есть загруженные encoder и tokenizer, например rubert-tiny2.
Показать код
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
import torch
# Функция для получения эмбеддингов CLS-токена
def embed_bert_cls_batch(texts, model, tokenizer):
t = tokenizer(texts,
padding=True,
truncation=True,
return_tensors='pt')
t = {k: v.to(model.device) for k, v in t.items()}
with torch.no_grad():
output = model(**t)
embeddings = output.last_hidden_state[:, 0, :] # CLS-токен
embeddings = torch.nn.functional.normalize(embeddings)
return embeddings.cpu().numpy()
# Поиск наиболее похожего класса для каждой сущности
def correct_entity_rubert_batch(model, tokenizer, entities, classes):
entities_emb = embed_bert_cls_batch(entities, model, tokenizer)
classes_emb = embed_bert_cls_batch(classes, model, tokenizer)
similarities = cosine_similarity(entities_emb, classes_emb)
best_indices = similarities.argmax(axis=1)
return [classes[i] for i in best_indices]
# Список распознанных сущности
entities = ["сыр гауд", "макароны", "кефир"]
# Список реальных категорий
classes = ["Сыры", "Макаронные изделия", "Молочная продукция"
... "Кофе", "Сок", "Шоколад"]
best_matches = correct_entity_rubert_batch(encoder,
tokenizer,
entities,
classes)
print(best_matches)
А теперь подведём итоги
Мы разобрались, как «заставить» мобильный поиск понимать обрывки вида «моло…» и превращать их в точные подсказки. На этом пути мы прошли всё — от колючих регулярных выражений, видящих ровно то, что им задали, до компактного RuBERT-tiny2, способного вытягивать сущности даже из исковерканного текста.
Каждый подход оказался полезен в своём диапазоне: regex даёт мгновенный baseline и бесплатно закрывает очевидные объёмы и проценты. BiLSTM-CRF ловит контекст и идеально подходит для небольших запросов, а RuBERT-tiny2, благодаря своей глубокой архитектуре, уверенно вытягивает сущности из длинных и сложных фраз. Тем самым получаем поисковый движок, который после большинства введённых запросов знает, какую категорию, бренд, упаковку и жирность показать. Дальше остаётся приятная часть: внедрить модель в поиск, поставить A/B-тест и наблюдать, как растут конверсия поиска и средний чек. И пусть следующему пользователю достаточно будет набрать всего «кеф…», чтобы корзина пополнилась именно тем кефиром, который он хотел.
Над статьёй работали: Менейлюк Андрей, Бобринский Павел, Дмитрий Емельянов, Анастасия Калиманова, Полушкин Андрей, Сахнов Александр.
Комментарии (2)
Elpi
28.08.2025 14:10Был такой старый советский мультик про неумеху-школяра и "двоих из ларца". Они в конце концов начали за него и есть то, что добыли. Неумеха почему-то расстроился...
Вы для чего это все "накрутили"? И за кого вы держите пользователя? Кто пишет запросы вида "моло" и почему на них вообще надо реагировать.
Справедливости ради нужно сказать, что поисковик Х5 (я давно пользуюсь "Впрок" лучший на рынке. Но этот ваш текст расстраивает.
Я уже давно (последние несколько лет) сразу захожу в раздел "Мои покупки" и обычно включают там чек-бокс "Скидки". Этого более чем достаточно. Ну и акции изредка просматриваю. Поэтому и не понимаю, зачем вам все вышеизложенное?
adrozhzhov
У вас на КДПВ ёмкость указана явно. Это 900мл
А определяется как 1L
Это модель сама решила, или у рекламного отдела нахваталась?