Кейс: 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 с правильно настроенными аналитическими фильтрами решит задачу за меньшие деньги.
Если ваш контекст похож на описанный — мы готовы начать с аудита поисковых логов. Это бесплатно и занимает неделю: результат — конкретные цифры потенциального прироста и оценка стоимости внедрения.