Персонализация ранжирования поиска в e-commerce
Без персонализации поисковый движок показывает одинаковые результаты всем пользователям. ML-ранжирование учитывает историю просмотров, покупок, возвратов и контекст сессии — переставляет выдачу индивидуально. Например, один из клиентов — маркетплейс с 500 000 товаров — после внедрения персонализированного ранжирования получил рост CTR на 15% и увеличение среднего чека на 12%. Выигрыш в конверсии из поиска составляет 8–15%. Наш стек: PyTorch, LightGBM, Hugging Face, Anthropic для LLM. Используем LambdaMART с кастомными признаками — проверенный алгоритм для задач ранжирования. Опыт работы — более 5 лет, реализовано более 20 проектов для e-commerce, включая маркетплейсы с товарным ассортиментом от 10 000 до 1 млн единиц.
Как работает персонализированное ранжирование?
Алгоритм LambdaMART (LightGBM ранкер) обучается на implicit feedback: клики, покупки, время просмотра. Feature-вектор включает пять групп признаков: релевантность (BM25, точное совпадение), качество товара (рейтинг, отзывы, остаток), бизнес-метрики (маржа, промо, скорость продаж), персонализация (история просмотров пользователя, CTR в категории), контекст сессии (количество запросов, устройство, время суток).
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
import lightgbm as lgb
class SearchPersonalizationEngine:
"""
LambdaMART (LightGBM ranker) для персонализированного поиска.
Обучается на implicit feedback: клики, покупки, время просмотра.
"""
def __init__(self):
self.ranker = lgb.LGBMRanker(
objective='lambdarank',
n_estimators=300,
learning_rate=0.05,
num_leaves=63,
min_child_samples=20,
random_state=42
)
self.feature_names = []
def build_features(self, query: str, products: pd.DataFrame,
user_history: dict, session_context: dict) -> pd.DataFrame:
"""Формирование feature-вектора для пары (query, product)"""
features = []
for _, product in products.iterrows():
feat = {}
# === Relevance features ===
feat['bm25_score'] = product.get('search_score', 0)
feat['title_match'] = int(all(
word.lower() in product.get('title', '').lower()
for word in query.split()
))
feat['exact_match'] = int(query.lower() == product.get('title', '').lower())
# === Product quality features ===
feat['rating'] = product.get('rating', 3.0)
feat['reviews_count'] = np.log1p(product.get('reviews_count', 0))
feat['in_stock'] = int(product.get('in_stock', True))
feat['days_since_added'] = product.get('days_since_added', 365)
feat['photo_count'] = min(product.get('photo_count', 1), 10)
# === Business features ===
feat['margin_score'] = product.get('margin_percentile', 0.5)
feat['is_promoted'] = int(product.get('is_promoted', False))
feat['sales_velocity_7d'] = np.log1p(product.get('sales_7d', 0))
# === Personalization features ===
sku = product.get('sku', '')
category = product.get('category', '')
brand = product.get('brand', '')
feat['user_viewed_sku'] = int(sku in user_history.get('viewed_skus', set()))
feat['user_viewed_category'] = int(category in user_history.get('viewed_categories', set()))
feat['user_purchased_brand'] = int(brand in user_history.get('purchased_brands', set()))
feat['user_purchase_count_category'] = user_history.get('category_purchase_counts', {}).get(category, 0)
feat['user_category_ctr'] = user_history.get('category_ctrs', {}).get(category, 0.05)
user_avg_price = user_history.get('avg_order_value', 0)
product_price = product.get('price', 0)
if user_avg_price > 0:
feat['price_ratio'] = product_price / user_avg_price
else:
feat['price_ratio'] = 1.0
# === Session context ===
feat['session_query_count'] = session_context.get('query_count', 1)
feat['session_has_cart'] = int(session_context.get('has_cart', False))
feat['device_mobile'] = int(session_context.get('device', 'desktop') == 'mobile')
feat['hour_of_day'] = session_context.get('hour', 12)
feat['sku'] = sku
features.append(feat)
df = pd.DataFrame(features)
self.feature_names = [c for c in df.columns if c != 'sku']
return df
def train(self, training_data: pd.DataFrame):
feature_cols = [c for c in training_data.columns
if c not in ['query_id', 'sku', 'relevance_label']]
X = training_data[feature_cols]
y = training_data['relevance_label']
groups = training_data.groupby('query_id').size().values
self.ranker.fit(X, y, group=groups)
def rank(self, query: str, products: pd.DataFrame,
user_history: dict, session_context: dict) -> pd.DataFrame:
features_df = self.build_features(query, products, user_history, session_context)
X = features_df[self.feature_names]
scores = self.ranker.predict(X)
products = products.copy()
products['rank_score'] = scores
return products.sort_values('rank_score', ascending=False)
Почему стоит учитывать поведенческие сигналы?
Пользователи с разной историей видят одинаковую выдачу — это потеря продаж. Например, если пользователь часто покупает технику, ему нужно показывать ноутбуки и смартфоны выше, чем канцтовары. Без поведенческих сигналов модель не сможет учесть такие предпочтения. Мы внедрили поведенческие признаки: просмотры категорий, покупки брендов, категорийный CTR, средний чек. Это дало прирост конверсии на 8–12% в проектах наших клиентов.
Сравнение подходов к ранжированию
| Подход | Учёт персонализации | Производительность | Сложность внедрения |
|---|---|---|---|
| TF-IDF / BM25 | Нет | Высокая | Низкая |
| Learning to Rank (LambdaMART) | Да | Средняя | Средняя |
| Neural Rankers (Transformers) | Да | Низкая (на large scale) | Высокая |
LambdaMART — оптимальный баланс между качеством персонализации и затратами на поддержку. Neural Rankers требуют GPU и больших данных, а BM25 не учитывает индивидуальные предпочтения.
Метрики до и после внедрения
| Метрика | До | После |
|---|---|---|
| CTR поиска | 3.2% | 3.7% |
| Conversion Rate | 2.1% | 2.4% |
| Revenue per Search | 120 руб | 138 руб |
| NDCG@5 | 0.62 | 0.71 |
Пример конфигурации LightGBM ranker
lgb_params = {
'objective': 'lambdarank',
'boosting_type': 'gbdt',
'metric': 'ndcg',
'num_leaves': 63,
'learning_rate': 0.05,
'feature_fraction': 0.8,
'bagging_fraction': 0.8,
'bagging_freq': 5,
'verbose': 0,
'random_state': 42
}
Query Understanding и расширение
Запросы часто содержат опечатки, синонимы или неоднозначность. Мы используем LLM (Anthropic Claude) для исправления, извлечения бренда, категории, цены и сезонности. Это позволяет корректно обрабатывать 95% запросов без дополнительных правил.
from anthropic import Anthropic
class QueryUnderstandingLayer:
"""Обработка поисковых запросов: исправление, расширение, интент"""
def __init__(self):
self.llm = Anthropic()
def parse_query(self, raw_query: str, catalog_categories: list[str]) -> dict:
response = self.llm.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
messages=[{
"role": "user",
"content": f"""Parse this e-commerce search query and return JSON.
Query: "{raw_query}"
Available categories: {catalog_categories[:20]}
Return JSON:
{{
"corrected_query": "...",
"intent": "informational|navigational|transactional",
"extracted_brand": "...",
"extracted_category": "...",
"price_filter": {{"min": null, "max": null}},
"color": null,
"size": null,
"synonyms": ["...", "..."]
}}"""
}]
)
import json
try:
return json.loads(response.content[0].text)
except Exception:
return {'corrected_query': raw_query, 'intent': 'transactional', 'synonyms': []}
def detect_seasonal_intent(self, query: str, current_month: int) -> float:
seasonal_keywords = {
'winter': [12, 1, 2],
'summer': [6, 7, 8],
'spring': [3, 4, 5],
'autumn': [9, 10, 11]
}
query_lower = query.lower()
for season, months in seasonal_keywords.items():
if season in query_lower and current_month in months:
return 1.2
return 1.0
Как A/B-тестирование улучшает ранжирование?
Перед выкатом новой модели проводим A/B-тест: часть трафика идёт на control, часть — на treatment. Метрики: CTR, Conversion Rate, NDCG@5, Revenue per Search. Детерминированное распределение (по hash user_id) гарантирует чистоту эксперимента.
class SearchRankingExperiment:
"""A/B/n тесты для алгоритмов ранжирования"""
def __init__(self, variants: dict):
self.variants = variants
def assign_user(self, user_id: str) -> str:
bucket = hash(user_id) % 100
if bucket < 50:
return 'control'
return 'treatment'
def track_metrics(self, search_logs: pd.DataFrame) -> pd.DataFrame:
return search_logs.groupby('variant').agg(
ctr=('clicked', 'mean'),
conversion_rate=('purchased', 'mean'),
avg_position_clicked=('click_position', 'mean'),
ndcg_at_5=('ndcg_5', 'mean'),
revenue_per_search=('revenue', 'mean')
).round(4)
Как внедрить персонализированное ранжирование?
Процесс внедрения состоит из следующих этапов:
- Аудит текущей поисковой выдачи и сбор метрик (CTR, CR, NDCG).
- Разработка схемы сбора данных: логи запросов, клики, покупки, история пользователей.
- Feature engineering: формирование 50+ признаков (релевантность, качество, бизнес, персонализация, контекст).
- Обучение модели LambdaMART с кросс-валидацией и подбором гиперпараметров.
- Развёртывание на Kubernetes с Triton Inference Server.
- A/B-тестирование: сравнение новой модели с текущей в течение 1–2 недель.
- Оптимизация и документирование, обучение команды.
Типичные ошибки при внедрении
- Использование только релевантности без персонализации — выдача не меняется.
- Слишком много признаков без регуляризации — оверфиттинг и падение на новых данных.
- Неправильная метрика качества: NDCG лучше подходит для ранжирования, чем MSE.
- Отсутствие онлайн-валидации через A/B-тесты — офлайн-метрики не гарантируют успеха в продакшене.
Персонализированный поиск особенно эффективен для head queries (топ-20% запросов дают 80% трафика). Для tail queries семантический поиск через векторный индекс важнее персонализации. Типичный выигрыш по метрикам: CTR +12%, Conversion Rate +8%, Revenue per Search +10% при корректном feature engineering.
Свяжитесь с нами для оценки вашего проекта. Проведём бесплатный аудит текущей поисковой выдачи и предложим оптимальное решение. Закажите пилотный проект: обучение модели на ваших данных за 2 недели.







