Как мы довели поиск товаров по изображению до 98% совпадений: FastAPI, DINOv2, Qdrant и поиск на фото полки

от автора

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

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

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

Что нужно было построить

Задача была прикладная: по изображению найти соответствующий товар в каталоге.

Дополнительно хотелось закрыть еще один сценарий: определить, присутствует ли конкретный SKU на фотографии полки.

По сути, у нас было два связанных кейса:

1. «image-to-catalog search»

   Пользователь загружает изображение товара, сервис возвращает наиболее релевантные позиции каталога.

2. «SKU-on-shelf detection»

   Пользователь загружает фото полки и передает SKU, а сервис отвечает, есть ли этот товар на изображении и где именно он находится.

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

Как устроено решение

Сервис построен на «FastAPI» и состоит из трех основных слоев:

— «PostgreSQL» хранит карточки товаров и метаданные.

— «Qdrant» хранит векторный индекс изображений.

— «DINOv2» строит embedding по изображению.

Схема работы для обычного поиска выглядит так:

1. API получает изображение файлом или по URL.

2. Энкодер преобразует его в embedding.

3. Qdrant ищет ближайшие вектора по cosine similarity.

4. Результаты группируются по товару.

5. Из PostgreSQL подтягиваются данные карточек.

6. Клиент получает готовую выдачу.

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

Почему «один товар = один вектор» почти всегда проигрывает

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

У карточки товара обычно есть:

— основное изображение;

— дополнительные фото;

— боковые и фронтальные ракурсы;

— фото упаковки под разным углом;

— иногда старые и новые версии визуала.

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

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

Это дало сразу несколько эффектов:

— вырос шанс корректного совпадения по неосновному ракурсу;

— уменьшилось количество «почти правильных» результатов;

— улучшился recall без изменения клиентского API.

По сути мы перешли от модели «товар представлен одной точкой» к модели «товар представлен облаком точек в векторном пространстве».

Почему пришлось делать дедупликацию выдачи

Как только у одного товара появляется несколько векторов, возникает следующая проблема: поиск начинает возвращать один и тот же товар несколько раз.

С точки зрения Qdrant это нормально: он ищет ближайшие вектора, а не бизнес-сущности.

С точки зрения пользователя это плохой UX: вместо списка товаров он видит список очень похожих картинок одного и того же SKU.

Поэтому после поиска мы добавили дедупликацию по «product_id»:

— из всех найденных совпадений для товара выбирается лучший score;

— на выдачу идет одна карточка товара;

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

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

Почему мы остановились на DINOv2

Изначально проект проектировался с поддержкой двух энкодеров: «CLIP» и «DINOv2». Это было логичное решение на старте.

CLIP хорошо подходит для мультимодальных сценариев, zero-shot задач и связки «текст + изображение». Для быстрого старта visual search это удобный и понятный выбор: экосистема зрелая, модель распространенная, интеграция относительно простая.

Но по мере работы стало понятно, что наш основной кейс другой. Мы решаем не текстово-визуальный поиск, а «image-to-image» матчинг внутри закрытого товарного каталога, где:

— много визуально похожих SKU;

— важны мелкие отличия упаковки;

— один и тот же товар может быть снят под разными углами;

— запросы часто далеки от идеальных студийных изображений;

— в shelf-сценарии приходится работать еще и с crop-ами после детекции.

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

Поэтому рабочий контур был переведен на «DINOv2», а текущая реализация использует «facebook/dinov2-large».(*Facebook запрещен в РФ, владелец — компания Meta, признанная экстремистской и запрещенная в России). 

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

— «DINOv2» дает более стабильные эмбеддинги для визуально близких товаров;

— модель лучше переносит различия в фоне, масштабе и композиции кадра;

— снижается количество ложных совпадений между похожими, но разными SKU;

— лучше работает сценарий с вырезанными регионами товара на фото полки;

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

Иными словами, CLIP был полезным этапом эволюции проекта, но для нашей прикладной задачи победил DINOv2.

Для задач визуального сходства товаров важны не только «узнаваемость объекта», но и устойчивость к:

— фону;

— масштабу;

— артефактам изображения;

— небольшим искажениям;

— различиям между студийным фото и реальным снимком.

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

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

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

Почему производительность пришлось решать одновременно с качеством

Как только в пайплайн приходит более тяжелая модель, сразу встает вопрос latency.

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

Это касается и энкодера, и детектора:

— запросы можно обрабатывать параллельно;

— GPU используется эффективнее;

— сервис лучше держит нагрузку;

— рост качества не превращается в неприемлемое время ответа.

С инженерной точки зрения это был обязательный шаг.

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

Как появился сценарий поиска товара на фото полки

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

Здесь пайплайн уже другой.

1. На вход приходит фото полки.

2. «YOLO-World» находит области, похожие на товары.

3. Каждая найденная область вырезается в crop.

4. Для каждого crop считается embedding.

5. Он сравнивается не со всем каталогом, а с эталонными векторами конкретного SKU.

6. Если similarity превышает порог, считаем, что товар найден.

Почему это важно:

— мы переходим от «поиска похожей картинки» к ответу на бизнес-вопрос;

— система начинает решать задачи shelf analytics;

— можно не просто найти товар, а локализовать его на изображении.

Здесь особенно хорошо проявилось преимущество индексации нескольких изображений товара.

Чем богаче reference-набор для SKU, тем выше шанс корректно распознать его на реальной полке.

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

Резюмируя, 98% совпадений — это результат не одной «волшебной» настройки, а комбинации решений. 

Наибольший вклад дали:

— переход на «DINOv2»;

— индексация всех изображений товара, а не только основного;

— дедупликация выдачи по «product_id»;

— инкрементальная синхронизация каталога;

— распараллеливание инференса через пулы моделей;

— отдельный detection-пайплайн для фото полки.

Именно комбинация этих решений превратила проект из базового image search в более зрелый сервис.

Что есть сейчас и что дальше

На текущий момент у нас работает платформа для двух сценариев:

— поиск товара по изображению;

— поиск конкретного SKU на фотографии полки.

Текущий результат по внутренней проверке на рабочей выборке составляет 98% совпадений при поиске товаров.

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

— зафиксировать контрольную выборку;

— автоматизировать расчет метрик;

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

— отделить разовые удачные прогоны от стабильного benchmark-процесса.

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

Подробнее о нашей работе в этом направлении расскажу в следующих публикациях.

ссылка на оригинал статьи https://habr.com/ru/articles/1034664/