← все статьи
9 мин

Кейс: AI-поиск для маркетплейса: +34% конверсии из поиска

Поиск — это продукт внутри продукта. Для маркетплейса это особенно верно: пользователь, который не нашёл нужное, уходит к конкуренту. Не потому что товара нет — потому что поиск его не показал. Один из наших клиентов — региональный маркетплейс с ~2 млн SKU и 500K ежемесячных активных пользователей — столкнулся именно с этим.

Их поиск был технически исправен. Elasticsearch работал стабильно, индексирование было настроено. Проблема была в том, что классический BM25-поиск по ключевым словам принципиально не справляется с тем, как реально ищут люди: запросами вроде «зимняя куртка для ребёнка 5 лет» или «что подарить маме на день рождения». Для такого каталога gap между тем, что пользователь хочет, и тем, что находит система — огромный.

// Диагностика: что ломалось

Мы начали с анализа поисковых логов — 30 дней запросов, ~4 млн событий. Картина оказалась хуже, чем ожидал клиент.

Null-result rate — 12%. Каждый восьмой поиск заканчивался пустой выдачей. Часть запросов — опечатки или синонимы, которые fuzzy search обрабатывал плохо. Часть — семантически корректные запросы, для которых товары в каталоге есть, но не совпадают по ключевым словам.

Казахский язык — отдельная проблема. Около 35% пользователей вводили запросы на казахском. Стандартный Elasticsearch-анализатор не понимает морфологию казахского языка: «балаға пальто» (пальто для ребёнка) не находило «балалар пальто» (детское пальто). Quality score казахских запросов — 0.42 по внутренней метрике релевантности.

Конверсия search-to-purchase — 2.1%. Среднерыночный показатель для аналогичных маркетплейсов — 3–4%. Разрыв напрямую переводился в недополученный GMV.

Самое неприятное открытие: пользователи, которые не нашли нужное с первого запроса и переформулировали его, конвертировались в 2.8 раза хуже, чем те, кто нашёл сразу. Поиск не просто терял продажи — он ухудшал восприятие всего продукта.

// Архитектура: гибридный поиск

Чистый семантический поиск не решил бы проблему — он хуже BM25 на точных запросах с артикулами и брендовыми названиями. Чистый BM25 оставляет нерешёнными семантические запросы. Правильный ответ — гибрид.

Мы построили трёхуровневую систему:

  • Уровень 1 — Query Understanding. Классификация интента (навигационный / транзакционный / информационный), извлечение сущностей (бренд, категория, атрибуты — размер, цвет, возраст). Это определяет, как взвешивать результаты дальше.
  • Уровень 2 — Dual Retrieval. Параллельный запуск BM25 (Elasticsearch) и семантического поиска (vector similarity по embeddings). Каждый возвращает top-100 кандидатов. RRF (Reciprocal Rank Fusion) для объединения двух ранкингов.
  • Уровень 3 — Re-ranking. Cross-encoder модель оценивает релевантность пары (запрос, документ) для top-50 объединённых кандидатов и формирует финальный порядок.

// Embeddings: выбор модели

Мы тестировали несколько моделей: sentence-transformers/paraphrase-multilingual-mpnet-base-v2, intfloat/multilingual-e5-large, и несколько fine-tuned вариантов на русскоязычных e-commerce данных.

multilingual-e5-large выиграл по трём критериям: лучший баланс качества на русском и казахском, разумный inference time (~12ms на GPU), хорошее качество на длинных запросах с атрибутами. Размер модели — 560M параметров, что требует GPU-инференса для production latency.

Для казахского языка один только multilingual-e5-large оказался недостаточен: модель обучена на относительно малом объёме казахских данных. Мы добавили custom казахский токенизатор с поддержкой агглютинативной морфологии и словарь синонимов для продуктовых категорий.

# Embedding pipeline: индексирование и поиск
# Используем multilingual-e5-large через sentence-transformers

from sentence_transformers import SentenceTransformer
from elasticsearch import Elasticsearch
import numpy as np

class HybridSearchEngine:
    def __init__(self, es_client: Elasticsearch, model_name: str = "intfloat/multilingual-e5-large"):
        self.es = es_client
        self.model = SentenceTransformer(model_name, device="cuda")
        self.index_name = "products_v2"
        self.vector_dim = 1024  # e5-large output dimension

    def encode_query(self, query: str) -> np.ndarray:
        # e5-large требует префикс "query: " для асимметричного поиска
        return self.model.encode(f"query: {query}", normalize_embeddings=True)

    def encode_document(self, text: str) -> np.ndarray:
        # Документы индексируются с префиксом "passage: "
        return self.model.encode(f"passage: {text}", normalize_embeddings=True)

    def hybrid_search(self, query: str, size: int = 50) -> list[dict]:
        query_embedding = self.encode_query(query)

        # Гибридный запрос: BM25 + kNN в одном ES-запросе (ES 8.x)
        response = self.es.search(
            index=self.index_name,
            size=size,
            body={
                "retriever": {
                    "rrf": {
                        # BM25 retriever
                        "retrievers": [
                            {
                                "standard": {
                                    "query": {
                                        "multi_match": {
                                            "query": query,
                                            "fields": [
                                                "title^3",
                                                "title.kazakh^3",
                                                "description^1",
                                                "brand^2",
                                                "category_path^1.5",
                                                "attributes.*^1"
                                            ],
                                            "type": "best_fields",
                                            "fuzziness": "AUTO"
                                        }
                                    }
                                }
                            },
                            # Vector kNN retriever
                            {
                                "knn": {
                                    "field": "embedding",
                                    "query_vector": query_embedding.tolist(),
                                    "num_candidates": 200,
                                    "k": size
                                }
                            }
                        ],
                        # RRF параметры: rank_constant=60 — стандарт для e-commerce
                        "rank_window_size": size,
                        "rank_constant": 60
                    }
                },
                "_source": ["id", "title", "brand", "price", "category", "image_url"]
            }
        )
        return [hit["_source"] | {"_score": hit["_score"]}
                for hit in response["hits"]["hits"]]

// Query Understanding: извлечение интента и сущностей

Query understanding — самая влиятельная часть системы для пользовательского опыта. Запрос «Nike Air Max 42 размер» должен обрабатываться иначе, чем «удобные кроссовки для бега». В первом случае — навигационный интент с точными атрибутами, во втором — транзакционный с семантической близостью как основным сигналом.

# Query Understanding: классификация интента + NER
from dataclasses import dataclass
from typing import Literal

@dataclass
class QueryUnderstanding:
    intent: Literal["navigational", "transactional", "informational"]
    language: Literal["ru", "kz", "mixed"]
    entities: dict  # brand, category, size, color, age_group, price_range
    normalized_query: str
    bm25_weight: float   # вес BM25 в финальном ранкинге
    semantic_weight: float

def understand_query(raw_query: str) -> QueryUnderstanding:
    lang = detect_language(raw_query)
    normalized = normalize_query(raw_query, lang)
    entities = extract_entities(normalized, lang)

    # Эвристика весов на основе извлечённых сущностей
    # Точные атрибуты (артикул, бренд, размер) → больше веса BM25
    # Описательные запросы → больше веса семантики
    has_exact_attrs = bool(entities.get("brand") or entities.get("sku") or entities.get("size"))
    bm25_weight = 0.7 if has_exact_attrs else 0.35
    semantic_weight = 1.0 - bm25_weight

    intent = classify_intent(normalized, entities)

    return QueryUnderstanding(
        intent=intent,
        language=lang,
        entities=entities,
        normalized_query=normalized,
        bm25_weight=bm25_weight,
        semantic_weight=semantic_weight,
    )

def normalize_query(query: str, lang: str) -> str:
    """
    Казахский: агглютинативные окончания → нормальная форма.
    Русский: морфологическая нормализация через pymorphy3.
    Общее: транслитерация, опечатки, синонимы категорий.
    """
    if lang == "kz":
        return kazakh_stemmer.normalize(query)
    elif lang == "ru":
        return russian_lemmatizer.normalize(query)
    else:  # mixed
        return normalize_mixed(query)

// Казахский язык: кастомный токенизатор

Казахский — агглютинативный язык: слово «балаларға» (детям) — это корень «бала» (ребёнок) + аффикс множественного числа + аффикс дательного падежа. Стандартный токенизатор видит одно слово там, где морфологически их три. Это критично для поиска: «балалар пальто» и «балаға пальто» должны находить одни и те же товары из категории «детские пальто».

Мы разработали казахский токенизатор на основе списка морфем и правил аффиксации (около 120 продуктивных аффиксов). Он не использует ML — только правила — что даёт предсказуемое поведение и zero latency overhead. Параллельно создали словарь синонимов для 400 наиболее частых категорий и атрибутов на казахском.

Результат для казахского поиска: quality score с 0.42 → 0.81. Это не идеально, но достаточно, чтобы казахскоязычные пользователи перестали испытывать существенно худший опыт по сравнению с русскоязычными.

// Re-ranking: cross-encoder

Cross-encoder — принципиально другой подход по сравнению с bi-encoder (embeddings). Bi-encoder кодирует запрос и документ независимо, cross-encoder обрабатывает пару (запрос, документ) вместе, что даёт значительно лучшее качество оценки релевантности — но за счёт latency.

Мы использовали cross-encoder/ms-marco-MiniLM-L-6-v2 с fine-tuning на данных клиента. Fine-tuning был критичен: базовая модель не знает специфику e-commerce на русском и казахском.

Latency cross-энкодера на top-50 документов — 45–60ms на GPU. При нашем SLA p95 < 200ms это вписывается: BM25 + vector retrieval занимают ~80ms, cross-encoder — ~50ms, остальное — overhead и сеть. Итого p95 = 178ms.

// A/B тест и результаты

Новую систему запустили в A/B: 20% трафика на новый поиск, 80% на старый. Через две недели перешли на 50/50. Через месяц — полный rollout.

  • Конверсия search-to-purchase: 2.1% → 2.8% (+34%). Это основная метрика проекта. Измеряли за 30 дней после полного rollout, с учётом сезонности.
  • Null-result rate: 12% → 3%. Снижение в 4 раза. Большинство оставшихся 3% — запросы с артикулами несуществующих товаров.
  • Казахский quality score: 0.42 → 0.81. Паритет с русским (0.86) практически достигнут.
  • Latency p95: 178ms — укладываемся в SLA < 200ms с запасом.
  • ROI: окупился за 6 недель по приросту GMV от повышения конверсии. Это без учёта долгосрочного эффекта на retention пользователей, которые нашли нужное.

+0.7 процентных пункта конверсии звучит скромно. Для маркетплейса с 500K MAU и средним чеком в несколько тысяч тенге — это существенный прирост выручки ежемесячно.

// Что оказалось сложнее, чем ожидалось

Latency под нагрузкой. В тестах p95 был 140ms. В production под реальным трафиком — 178ms. Причина: GPU батчинг работает иначе при случайном distribution запросов, чем при синтетическом тесте. Мы потратили неделю на оптимизацию батчинга и кеширования эмбеддингов популярных запросов (top-10K запросов покрывают ~40% трафика).

Evaluation dataset. Чтобы измерить качество поиска объективно, нужен labeled dataset: запрос + релевантные товары. Такого датасета у клиента не было. Мы построили его полуавтоматически: из кликовых логов (пользователь кликнул — значит считает релевантным) + ручная разметка 2K запросов внешними асессорами. Это заняло 3 недели и оказалось критичным для измерения реального прогресса.

Казахская морфология. Мы недооценили edge cases. Казахский имеет ~9 падежей и активное словообразование. Несколько аффиксов в нашем токенизаторе давали неверную нормализацию для определённых классов слов. Итерации заняли больше времени, чем планировалось.

// Когда это имеет смысл

Гибридный AI-поиск — не серебряная пуля. Он имеет смысл при следующих условиях: каталог больше 100K SKU, значимая доля запросов — описательные или семантические (а не артикулы), пользователи ищут на языках с богатой морфологией, есть GPU-инфраструктура или бюджет на managed ML inference.

Если у вас интернет-магазин с 5K товаров и пользователи ищут преимущественно по точным названиям — хороший BM25 с правильно настроенными аналитическими фильтрами решит задачу за меньшие деньги.

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


← все статьи Следующая →RAG в продакшне: что реально ломается