AI-візуальний пошук товарів за фото
Уявіть: клієнт фотографує кросівки на вулиці та завантажує знімок у додаток — система за секунду знаходить аналоги в каталозі. Без текстового опису та категорій. Це visual search — задача, яку ми вирішуємо для ecommerce: від fashion-маркетплейсів до каталогів запчастин. Нижче — архітектура, застосована в production.
Як CLIP справляється з пошуком за фото?
CLIP (OpenAI) видає ембединги розмірності 768 (ViT-L/14) — єдиний векторний простір для зображень і тексту. Це дозволяє шукати не тільки image-to-image, а й text-to-image: «червоні кросівки Nike» → схожі товари. Zero-shot точність на 10k товарів — 74% Recall@10. Для специфічних доменів (мода, меблі, electronics) ми проводимо fine-tuning з learning rate 1e-6, заморожуючи text encoder. Це піднімає точність до 86% (CLIP paper).
Чому ми використовуємо Qdrant, а не FAISS?
У production-сценаріях критичні динамічні фільтри (ціна, бренд, категорія) та latency p99. Qdrant підтримує комбіновані фільтри через must-умови під час пошуку, тоді як FAISS вимагає перестворення індексу при зміні фільтрів. На каталозі в 1M товарів Qdrant (HNSW) дає latency 25ms — у 2–3 рази швидше аналогів при тій же точності.
Архітектура: embedding + vector search
import torch import torch.nn.functional as F from transformers import CLIPProcessor, CLIPModel from PIL import Image import numpy as np import qdrant_client from qdrant_client.models import Distance, VectorParams, PointStruct class VisualSearchEngine: """ CLIP-embedding + Qdrant vector DB для поиску за фото. CLIP вміє text→image і image→image пошук з коробки. """ def __init__( self, qdrant_url: str = 'http://localhost:6333', collection_name: str = 'products', embedding_dim: int = 768 # CLIP ViT-L/14 ): self.clip_model = CLIPModel.from_pretrained( 'openai/clip-vit-large-patch14' ).eval().cuda() self.clip_processor = CLIPProcessor.from_pretrained( 'openai/clip-vit-large-patch14' ) self.client = qdrant_client.QdrantClient(url=qdrant_url) self.collection_name = collection_name self._ensure_collection(embedding_dim) def _ensure_collection(self, dim: int) -> None: if not self.client.collection_exists(self.collection_name): self.client.create_collection( collection_name=self.collection_name, vectors_config=VectorParams( size=dim, distance=Distance.COSINE ) ) @torch.no_grad() def embed_image(self, image: Image.Image) -> np.ndarray: inputs = self.clip_processor( images=image, return_tensors='pt' ).to('cuda') emb = self.clip_model.get_image_features(**inputs) return F.normalize(emb, dim=-1).cpu().numpy().squeeze() @torch.no_grad() def embed_text(self, text: str) -> np.ndarray: inputs = self.clip_processor( text=[text], return_tensors='pt', padding=True ).to('cuda') emb = self.clip_model.get_text_features(**inputs) return F.normalize(emb, dim=-1).cpu().numpy().squeeze() def index_product( self, product_id: str, product_image: Image.Image, metadata: dict ) -> None: embedding = self.embed_image(product_image) self.client.upsert( collection_name=self.collection_name, points=[PointStruct( id=hash(product_id) % (2**63), vector=embedding.tolist(), payload={ 'product_id': product_id, 'category': metadata.get('category'), 'price': metadata.get('price'), 'brand': metadata.get('brand'), **metadata } )] ) def search_by_image( self, query_image: Image.Image, top_k: int = 20, filters: dict = None, # {'category': 'shoes', 'max_price': 5000} score_threshold: float = 0.65 ) -> list[dict]: query_embedding = self.embed_image(query_image) # Строїмо фільтри Qdrant qdrant_filter = None if filters: from qdrant_client.models import Filter, FieldCondition, MatchValue, Range conditions = [] for key, value in filters.items(): if key == 'max_price': conditions.append( FieldCondition(key='price', range=Range(lte=value)) ) elif key == 'min_price': conditions.append( FieldCondition(key='price', range=Range(gte=value)) ) else: conditions.append( FieldCondition(key=key, match=MatchValue(value=value)) ) qdrant_filter = Filter(must=conditions) results = self.client.search( collection_name=self.collection_name, query_vector=query_embedding.tolist(), limit=top_k, query_filter=qdrant_filter, score_threshold=score_threshold, with_payload=True ) return [ { 'product_id': r.payload['product_id'], 'score': round(r.score, 4), 'metadata': {k: v for k, v in r.payload.items() if k != 'product_id'} } for r in results ] def search_by_text( self, query_text: str, top_k: int = 20 ) -> list[dict]: """Text-to-image пошук: 'червоні кросівки Nike' → результати""" query_embedding = self.embed_text(query_text) results = self.client.search( collection_name=self.collection_name, query_vector=query_embedding.tolist(), limit=top_k, with_payload=True ) return [{'product_id': r.payload['product_id'], 'score': r.score} for r in results] Кропування об'єкта перед пошуком
from rembg import remove as rembg_remove def prepare_search_query( user_image: Image.Image, remove_background: bool = True, crop_to_object: bool = True ) -> Image.Image: if remove_background: # rembg → RGBA rgba = rembg_remove(user_image) if crop_to_object: # Авто-кроп по bounding box непрозорих пікселів bbox = rgba.getbbox() # (left, upper, right, lower) if bbox: rgba = rgba.crop(bbox) # Білий фон під об'єктом background = Image.new('RGB', rgba.size, (255, 255, 255)) background.paste(rgba, mask=rgba.split()[3]) return background return user_image Fine-tuning CLIP на fashion-домені
from transformers import CLIPModel, CLIPProcessor import torch from torch.optim import AdamW def finetune_clip_for_domain( model: CLIPModel, train_loader, # (image_tensor, text_tensor) пари num_epochs: int = 10, learning_rate: float = 1e-6 # дуже малий LR — CLIP вже добре навчений ) -> CLIPModel: """ Fine-tuning тільки visual encoder. Text encoder заморожуємо — він нам потрібен для text→image пошуку. """ for param in model.text_model.parameters(): param.requires_grad = False optimizer = AdamW( filter(lambda p: p.requires_grad, model.parameters()), lr=learning_rate, weight_decay=0.01 ) for epoch in range(num_epochs): model.train() for batch_images, batch_texts in train_loader: outputs = model( input_ids=batch_texts['input_ids'].cuda(), attention_mask=batch_texts['attention_mask'].cuda(), pixel_values=batch_images.cuda() ) # InfoNCE loss logits_per_image = outputs.logits_per_image labels = torch.arange( logits_per_image.shape[0], device='cuda' ) loss = (F.cross_entropy(logits_per_image, labels) + F.cross_entropy(logits_per_image.T, labels)) / 2 optimizer.zero_grad() loss.backward() optimizer.step() return model Продуктивність
| Розмір каталогу | Метод | Latency пошуку | Точність (R@10) |
|---|---|---|---|
| 10 000 товарів | CLIP + Qdrant | 8ms | 74% |
| 100 000 товарів | CLIP + Qdrant | 12ms | 74% |
| 1M товарів | CLIP + Qdrant (HNSW) | 25ms | 73% |
| 10 000 товарів | CLIP fine-tuned + Qdrant | 8ms | 86% |
Що входить в роботу
Ми постачаємо:
- Код пайплайну індексації — на Python, з використанням PyTorch і Qdrant.
- ML-модель — fine-tuned CLIP (якщо потрібно) з model card і метриками.
- API-сервіс — REST/gRPC ендпоінти з підтримкою фільтрів і порогів схожості.
- Документацію — інструкції з розгортання, налаштування та моніторингу.
- Навчання команди — 2–3 воркшопи з експлуатації пайплайну та донавчання.
- Підтримку — місяць після запуску, включаючи виправлення багів та оптимізацію latency.
Процес роботи: від запиту до production
- Аналітика — вивчаємо ваш каталог: якість зображень, розподіл класів, необхідність fine-tuning.
- Прототип — за 2–3 тижні робимо MVP на 1000 товарах, заміряємо точність.
- Розробка — реалізуємо production-пайплайн з Qdrant або pgvector.
- Тестування — навантажувальне тестування (1000 RPS), перевірка на нештатних фото (розмиття, фон).
- Деплой — розгортаємо у вашому хмарі або on-prem.
Терміни
| Задача | Термін |
|---|---|
| CLIP zero-shot visual search (готовий каталог) | 2–3 тижні |
| Fine-tuning + індексація великого каталогу | 5–8 тижнів |
| Повна система з multimodal search (фото + текст) | 8–13 тижнів |
Типовий бюджет рішень: zero-shot від 300 тис. руб., full fine-tuning — від 700 тис. руб. Терміни та бюджет залежать від обсягу каталогу та вимог до кастомізації. Ми оцінюємо ваш проект за 2 дні — зв'яжіться з нами. Отримайте консультацію з архітектури visual search: досвід більше 5 років у computer vision та 10+ впроваджених систем гарантують результат.







