Привет, Хабр! Меня зовут Николай, и я ML-инженер в команде контента в Купере, где уже два года занимаюсь задачами автоматчинга. Этот материал — адаптация моего недавнего выступления, на котором я рассказывал, как мы стараемся сэкономить бизнесу время и деньги.
Речь далее пойдет о матчинге товаров в ритейле: от ручного ввода до ML-пайплайнов
с эмбеддингами и ранкерами. Если что-то покажется неясным или возникнет желание подискутировать о деталях, велком в комменты.

Что такое матчинг

Матчинг (product matching) — это процесс сопоставления входящих офферов
с существующими продуктами в базе. Оффер — это сырой товар от поставщика, который после матчинга превращается в стандартизованную карточку на витрине.
Без автоматизации контент-менеджеры тратят часы на ручной поиск атрибутов (бренд, модель, спецификации и т. д.) в интернете или копируя их из аналогов. Это затягивает процесс до нескольких часов, а если поток офферов большой, то очередь может накапливаться и на создание карточки уходят недели.
Автоматчинг все упрощает и позволяет ощутимо сократить расходы. Если, например,
к нам приходят идентичные товары, мы не создаем каждый раз новую карточку, а линкуем несколько одинаковых офферов к одному продукту. С точки зрения менеджера, интерфейс прост: слева оффер, справа список из 10 продуктов, который он прокликивает, выбирая те, что посчитает совпадением. Не нужно ничего вводить: выбрал матч/не матч — 2-3 минуты и карточка готова.

Если рассматривать процесс точки зрения продукта, выглядит он так:
Из складской системы продавца в систему интеграции ритейлеров приходит оффер.
Оттуда его забирает команда разработки контента, проверяет, активен ли ритейлер и передает нам в Kafka.
DAG в Airflow матчит приходящие офферы каждые 30 минут и отдает самые вероятные команде разработки.
Те, контент-менеджерам, которые уже в упомянутой ранее системе выбирают хоть один матч.
Если матч есть — оффер линкуется к продукту и появляется на витрине. В противном случае он уходит на автосоздание.
Так как матчинг в нашей постановке — это довольно простая задача, метрик всего две — hit@k и покрытие.
-
hit@k — это значение того, есть ли истинный матч в топ-N кандидатов.
Мы игнорируем количество и позиции матчей (внутри выбранного top-N), фокусируясь лишь на их наличии/отсутствии.Пример: в топ-3 списке единственный матч находится на 3 ей позиции.
В таком случае hit@3 будет равен 1. Если матчей больше одного, то hit@3 все еще будет равен 1. И только если в top-3 нет ни единого матча — тогда hit@3 будет равен 0. Покрытие (coverage) — доля офферов с найденными кандидатами.
Не всегда 100% — применяем трешхолд, чтобы не заспамить менеджеров мусором.
Подход не нов, но он работает, снижая стоимость и сокращая время создания карточки. Вообще, по статистике ML-матчинг данных в e-commerce способен экономить до 30% средств.
Историческая эволюция: от штрихкодов к ML-пайплайнам
До внедрения ML у нас в контенте все держалось на штрихкодах. Это простой
и одновременно довольно точный метод матчинга (~98%), однако покрытие у него всего 14%, потому что:
ритейлеры не всегда привязывают штрихкоды;
у двух одинаковых товаров могут быть разные штрихкоды (ошибся ритейлер, разный поставщик — один товар и т. д.).
Бизнес рос, обработка данных требовала новых скоростей и в контент пришел ML — появился Matching 1.0.
Matching 1.0, первый ML-подход, в котором было три модели:
кандидатная на FastText (word embedding для быстрого поиска);
реранкер для переранжирование кандидатов на Catboost;
финальная классификация (матч/не матч) на парах оффер-продукт.
Индекс строился из продуктов и хранился в Faiss. Результативность такой системы была достаточно высока — hit@5 ~74%, coverage ~55%, стать еще лучше ей мешало низкое качество кандидатов от FastText.

Matching 2.0: пришел на смену первой версии, как ответ на потребность в еще более умной и быстрой модели: FastText в нем заменили на encoder-only трансформер (LaBSE-en-ru). Однако пайплайн остался прежним: матчинг по штрихкодам все еще был в силе,
а на входе модель все так же принимала только три поля:
title (название продукта);
brand (бренд);
pack size (количество шт. в упаковке).
Метрики почти не изменились: hit@5 остался около 74%, покрытие выросло до 65%.

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

Чтобы внедрить новую модель в старый DAG, пришлось бы переписать большое количество легаси-кода, а, с учетом накопившихся проблем, результат такого подхода виделся неоднозначным. Было решено писать новый DAG. А так как прежний пайплайн давал примерно те же метрики — около 74% hit в топ-5, — то отсутствие реранкера никто особо не заметил: он просто не влиял на показатели. В итоге Matching 2.0 ушел
в продакшн без него.
А вот в Matching 3.0 мы реранкер вернули — он снова стал переранжировать кандидатов для большей точности. Плюс добавили отдельный DAG для пересчета индекса:
он регулярно записывает новые продукты в индекс Faiss и, конечно, обновили сам энкодер. Главное, чего мы хотели добиться, — чтобы он учитывал поле extra — это такой «контейнер» от ритейлера, куда попадает вся дополнительная информация о продукте, которую продавец считает необходимой.

Как мы пришли к идее очищать extra
Однако у поля extra существовала одна большая проблема — там лежало все, что каждый ритейлер считает нужным хранить для себя. От ссылок и ID до полезностей вроде описаний и спецификаций. У нас нет для него жесткого шаблона, так что у каждого ритейлера оно уникально.
Вот пример того, как выглядит поле extra от ритейлеров — сплошной хаос, забитый лишним: айдишники, ссылки на фото, случайные метки и куча другого мусора.

Первое, что пришло в голову, — взять крупную модель вроде USER-bge-m3, которая имеет большое окно контекста, сделать минимальную предобработку, вырезать ID и ссылки, и загрузить в нее все остальное для генерации эмбеддингов. Однако на практике из-за огромного контекстного окна (и массы параметров), модель долго инференсилась и обучалась. А нам была очень важна скорость инференса - это было одно из ключевых требований
Параллельно мы тестировали компактный вариант с эвристической очисткой extra — правилами вроде приоритизации атрибутов и удаления шумных частей.
Мы предположили, что если в начало текста помещать короткие атрибуты, то модель сможет ухватить больше полезной инфы, потому что длинные описания окажутся в конце. А если придется обрезать контекст, то под нож попадет именно этот «хвост». Плюс мы добавили ключи к цифровым атрибутам, а для строковых, наоборот, убрали, чтобы не засорять текст лишними метками. Такая комбинация (небольшая модель + обрезанный контекст) работала намного быстрее, а метрики — точность и полнота — оставались на том же уровне: в итоге мы остановились на этом варианте— легкая модель плюс сложная предобработка.

Обучение модели
Теперь о пайплайне обучения модели: мы использовали кастомный лосс на базе InfoNCE — это лосс основанный на лоссе Sentence-BERT. В корне он отличается лишь тем,
что поверх берется ReLU.

На вход подаем название, бренд и количество в упаковке, а потом цепляем распаршенное поле extra — и на этом строим как индекс в Faiss, так и эмбеддинги для офферов.
Что это дало на выходе? Плюс 6% к hit@5 и целых +15% к охвату. Еще накинули Matryoshka loss, чтобы сжать размерность эмбеддингов втрое, с 768 до 256, — индекс стал компактнее, а поиск шустрее.
При выборе модели мы использовали бенчмарк MTEB и ключевыми для нас были не только метрики и рейтинг, но и размер модели, т.к. он напрямую влияет на скорость инференса. У нас же матчинг должен работать в реал-тайм, день в день: в среднем 30–100 тысяч офферов ежедневно, а в пиках — до миллиона-полутора. Плюс мы учитывали, что в будущем добавим ранкер, которому тоже нужно время на переранжирование кандидатов. Из-за этого выбор в итоге пал на меньшую модель — LaBSE-ru-turbo,
и она как раз вписалась в эти рамки, балансируя качество и производительность.

Ранкер, про который забыли
Перепробовав несколько базовых моделей, мы в итоге остановились на той,
что используем для эмбеддингов, — LaBSE-ru-turbo: она уже доказала свою надежность
в пайплайне. После экспериментов с разными лоссами — RankNet, ListNet
и их вариациями, выбрали кросс-энтропию, т. к. ранжирующие лоссы не дали прироста по метрикам, а у кросс-энтропии пайплайн обучения выходил проще и чище, без заморочек.
Для ранжирования взяли вероятности классов и отобрали по ним кандидатов: два класса, match и no match, а на вход подали пару оффер-продукт. Данные собрали так: взяли 10 тысяч офферов, для каждого вытащили по 100 ближайших продуктов из Faiss — и это стало нашей обучающей выборкой.
И снова сложность: модель не хочет сходиться, лосс не падает, обучение буксует. Сначала подумали на дисбаланс классов — типичная проблема в матчинге, когда на 99 негативов приходится всего один позитив. Стали урезать негативы: сначала до 5, потом до 3, до 2 — но без результата. Оказалось, проблема глубже: в индексе полно дубликатов,
из-за которых данные шумят.
Что я имею в виду, когда говорю о дублях? Мир неидеален, наша модель эмбеддингов тоже, плюс legacy добавляет хаоса (дедупликацией результатов старого матчинга мы почти не занимаемся) — в итоге в базе есть какое-то число почти идентичных продуктов
с одинаковым названием, описанием, атрибутами. Но в выборке для одного оффера первый такой продукт помечен как positive, а его близнец — как negative. Это вносит хаос в обучение, модель путается и не сходится.
Решение: прогнали все продукты через Faiss, нашли для каждого топ-100 ближайших по расстоянию, отобрали самые близкие (топ 10%) и выкинули
их из выборки как потенциальные дубли. После этого модель ожила — лосс начал падать, обучение пошло как по маслу.
Но даже после того, как мы вычистили дубли из выборки, осталась вторая проблема: модель реранкера все равно не могла обогнать чистый поиск Faiss по метрикам — hit@5
и покрытие просто не росло. Покопавшись, мы нашли ответ: реранкер учится на тех же данных, что выдает Faiss, — в основном на топ-10 кандидатах. У нас разметка всегда ограничивается топ-10, контент-менеджеры кликают только по этим вариантам,
а топ-50 и выше мы им не показываем. В итоге модель не видит разнообразия,
и ее прогнозы остаются на уровне базового поиска.
Сначала было решено сдавать данные на ручную разметку асессорам, но это растянулось бы надолго — нужно готовить датасет, писать инструкции, проверять качество. Вместо этого мы просто подняли VLLM-сервер с Vikhr-Nemo-12B и скормили ей пары оффер-продукт. Затем просили модель оценить, с какой вероятностью пара — это один и тот же товар, и эти вероятности использовали как мягкие метки для обучения реранкера.
И, сработало: метрики трансформера выросли на 4% в hit@5 по сравнению с чистой выдачей Faiss, а CatBoost, который мы тренировали параллельно, дал плюс 1%. В итоге оставили обе модели — CatBoost как fallback, на случай сбоев. Бывает, что DAG в Airflow падает по техпричинам, офферы накапливаются, и пар для переранжирования становится слишком много — трансформер может не успеть за день. Тогда вступает CatBoost: за счет быстрой работы он спасает в таких fallback-сценариях.
Последний штрих в Matching 3.0: автоматический пересчет индекса
Вы наверняка спросите, зачем морочиться, почему не сделать пересчет вручную раз
в пару месяцев и забыть?
Дело в динамике: мы часто подключаем новых ритейлеров со свежими вертикалями товаров, которых раньше в индексе не было. Если их несколько, нужно быстро добавлять продукты в Faiss. Скажем, в первую неделю для одного ритейлера товары заводим вручную, а уже на следующей, когда подключаем похожего, они попадают в матчинг автоматически.
Второй момент: даже в старых продуктах иногда меняются описания, тексты или атрибуты и, чтобы поиск оставался актуальным, это нужно отражать в индексе. Если не пересобирать его регулярно, охват офферов начинает падать, так как система упускает свежие изменения и не находит матчи там, где они должны быть.
Технически реализовать это оказалось проще простого, ведь у нас есть плоский индекс
в Faiss — без сложных, усложняющих апдейты, структур. Мы написали DAG, который запускается раз в неделю: он берет актуальные эмбеддинги, заменяет их на обновленные, добавляет новые для свежих товаров и заново собирает весь индекс. В итоге матчинг ловит офферы по самым недавним добавлениям, не заставляя ждать ручных обновлений.
Что в итоге?
Matching 3.0 поднял hit@5 на 10%, охват — на 15%, а благодаря рефакторингу и чистке техдолга DAG теперь работает в два раза быстрее — вместо 30 минут он укладывается
в 15, и это при том же расписании запуска.

Если разобрать кейс по частям, где сколько и чего добавилось относительно изначального состояния, то основной рост пришелся на апгрейд эмбеддера: +6% к hit@5 и +15%
к охвату, в основном за счет того, что мы наконец эффективно задействовали поле extra
с атрибутами и описаниями. Реранкер добавил еще 4% к hit@5, сделав ранжирование кандидатов более качественным. А пересчет индекса — по грубым оценкам, где-то +1%
к охвату, учитывая, что еженедельно база пополняется примерно 1% новых продуктов.
Что касается планов, то в приоритете — визуальный матчинг: иногда офферы приходят голыми, без текста или атрибутов, но с картинками, из которых можно вытащить немало полезной информации.
Второй пункт — системная зачистка дубликатов в базе, это поможет не только нам,
но и другим командам, избавив от шума в данных. И наконец, будем итеративно дорабатывать эмбеддер, опираясь на инсайты от обучения ранкера: уберем дубли
и добавим hard-негативов для лучшей дискриминации.
В итоге автоматический матчинг по сравнению с чисто ручным пайплайном снизил стоимость создания карточки товара в 10 раз. Раньше на это могла уйти неделя, особенно в периоды пиковой загрузки, а теперь максимум день: оффер прилетает утром — вечером продукт уже на витрине. Матчинг — это по-настоящему мощный инструмент!
Буду рад ответить на ваши вопросы в комментариях.