Personalization of Search Ranking in E-Commerce
Without personalization, a search engine shows the same results to all users. ML ranking considers browsing history, purchases, returns, and session context—reordering results individually. For example, one of our clients, a marketplace with 500,000 products, after implementing personalized ranking achieved a 15% increase in CTR and a 12% increase in average order value. The conversion gain from search ranges from 8–15%. Our stack: PyTorch, LightGBM, Hugging Face, Anthropic for LLMs. We use LambdaMART with custom features—a proven algorithm for ranking tasks. We have over 5 years of experience and have completed more than 20 projects for e-commerce, including marketplaces with product assortments from 10,000 to 1 million units.
How Personalized Ranking Works
The LambdaMART algorithm (LightGBM ranker) is trained on implicit feedback: clicks, purchases, view time. The feature vector includes five groups of features: relevance (BM25, exact match), product quality (rating, reviews, stock), business metrics (margin, promo, sales velocity), personalization (user browsing history, CTR in category), and session context (number of queries, device, time of day).
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
import lightgbm as lgb
class SearchPersonalizationEngine:
"""
LambdaMART (LightGBM ranker) for personalized search.
Trained on implicit feedback: clicks, purchases, view time.
"""
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:
"""Build feature vector for (query, product) pair"""
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)
Why Consider Behavioral Signals?
Users with different histories see the same results—that's lost sales. For example, if a user frequently buys electronics, they should see laptops and smartphones higher than stationery. Without behavioral signals, the model cannot account for such preferences. We implemented behavioral features: category views, brand purchases, category CTR, average order value. This gave a conversion boost of 8–12% in our clients' projects.
Comparison of Ranking Approaches
| Approach | Personalization Consideration | Performance | Implementation Complexity |
|---|---|---|---|
| TF-IDF / BM25 | No | High | Low |
| Learning to Rank (LambdaMART) | Yes | Medium | Medium |
| Neural Rankers (Transformers) | Yes | Low (at large scale) | High |
LambdaMART offers the optimal balance between personalization quality and maintenance costs. Neural rankers require GPUs and large datasets, while BM25 does not account for individual preferences.
Metrics Before and After Implementation
| Metric | Before | After |
|---|---|---|
| Search CTR | 3.2% | 3.7% |
| Conversion Rate | 2.1% | 2.4% |
| NDCG@5 | 0.62 | 0.71 |
Example LightGBM ranker configuration
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 and Expansion
Queries often contain typos, synonyms, or ambiguity. We use LLMs (Anthropic Claude) for correction, extracting brand, category, price, and seasonality. This allows correct processing of 95% of queries without additional rules.
from anthropic import Anthropic
class QueryUnderstandingLayer:
"""Search query processing: correction, expansion, intent"""
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
How A/B Testing Improves Ranking
Before deploying a new model, we run an A/B test: part of the traffic goes to control, part to treatment. Metrics: CTR, Conversion Rate, NDCG@5, Revenue per Search. Deterministic assignment (by user_id hash) ensures clean experimentation.
class SearchRankingExperiment:
"""A/B/n tests for ranking algorithms"""
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)
How to Implement Personalized Ranking
The implementation process consists of the following stages:
- Audit of current search results and metric collection (CTR, CR, NDCG).
- Develop data collection schema: query logs, clicks, purchases, user history.
- Feature engineering: generate 50+ features (relevance, quality, business, personalization, context).
- Train LambdaMART model with cross-validation and hyperparameter tuning.
- Deploy on Kubernetes with Triton Inference Server.
- A/B testing: compare new model with current for 1–2 weeks.
- Optimization and documentation, team training.
Typical Mistakes in Implementation
- Using relevance only without personalization—results do not change.
- Too many features without regularization—overfitting and degradation on new data.
- Wrong quality metric: NDCG is better suited for ranking than MSE.
- Lack of online validation via A/B tests—offline metrics do not guarantee production success.
Personalized search is especially effective for head queries (top 20% of queries account for 80% of traffic). For tail queries, semantic search via vector index is more important than personalization. Typical metric improvement: CTR +12%, Conversion Rate +8%, Revenue per Search +10% with correct feature engineering.
Contact us to evaluate your project. We will conduct a free audit of your current search results and offer an optimal solution. Order a pilot project: model training on your data in 2 weeks.







