A custom ML-based product recommendation engine built from raw clickstream in ClickHouse. Three use cases, one pipeline:
- Product page — "Similar items" — ALS item-factor cosine similarity
- Cart / product page — "Frequently viewed together" — 90-day session co-occurrence
- Homepage — "Recommended for you" — personalised ALS predictions per user
No external recommender service. No prebuilt event matrices. Everything is computed from raw events with implicit's ALS, materialised back into ClickHouse, and served by a thin FastAPI layer.
- ClickHouse for the heavy lifting — joining 700M-row events with 800M-row item denormalisation in Python is a non-starter. Aggregation runs as ClickHouse SQL; Python only loads the resulting interaction matrix (≈50–100M rows).
- ALS via
implicit— fast, well-supported, handles implicit feedback (views/carts/purchases) natively. 128-factor model, ~2 minutes to train on 500k users × 100k items. - Pre-compute, don't predict at request time — the API just reads pre-computed top-N lists out of ClickHouse. Latency is microseconds, and we filter inactive/OOS items at read time.
- Cold-start — falls back to category-aware popularity for new users / unknown items.
clickstream events (ClickHouse)
│
┌──────────────┼──────────────┐
▼ ▼ ▼
materialize.py train.py precompute.py
(nightly) (weekly) (weekly, post-train)
│ │
als_model.pkl user_recs / item_similar / item_pairs
│
▼
api.py
GET /recommend/...
Interaction score per (user, item) over a rolling 180-day window:
| Event | Weight |
|---|---|
| view_item | 1 |
| add_to_wishlist | 3 |
| add_to_cart | 3 |
| begin_checkout | 4 |
| purchase | 5 |
Tune in materialize.py if your domain calls for different weights (e.g. a high-AOV catalogue may want to lean harder on purchase).
Aggregates 180 days of raw events into a user-item interaction matrix in ClickHouse. Also rebuilds 90-day session co-occurrence pairs and 30-day popularity.
python3 materialize.pyDuration: ~5–10 min on a typical ClickHouse cluster.
Trains a matrix factorization model with implicit. Saves to als_model.pkl.
python3 train.pyDuration: ~2–3 min for 500k users × 100k items, 128 factors, 20 iterations.
Tunable via env:
| Var | Default | What |
|---|---|---|
ALS_FACTORS |
128 | Embedding dimensions |
ALS_ITERATIONS |
20 | Training iterations |
ALS_REGULARIZATION |
0.01 | L2 regularisation |
MODEL_PATH |
als_model.pkl |
Where to save the model |
Writes top-50 personalised recs per user and top-20 similar items per product back into ClickHouse.
python3 precompute.pyDuration: ~5–7 min depending on catalogue size.
0 2 * * * python3 materialize.py >> logs/materialize.log 2>&1
0 4 * * 0 python3 train.py >> logs/train.log 2>&1
30 4 * * 0 python3 precompute.py >> logs/precompute.log 2>&1
uvicorn api:app --host 0.0.0.0 --port 8003
# or
docker compose up| Endpoint | Source table | Use case |
|---|---|---|
GET /recommend/user/{user_id}?n=20 |
recommendations.user_recs |
Personalised homepage feed |
GET /recommend/similar/{item_id}?n=10 |
recommendations.item_similar |
"Similar items" on PDP |
GET /recommend/together/{item_id}?n=10 |
recommendations.item_pairs |
"Frequently viewed together" |
GET /health |
— | Liveness |
All endpoints filter out inactive / OOS items at read time and fall back to popularity for unknown users/items. Results are Redis-cached (1h for user feeds, 6h for item-level).
curl http://localhost:8003/recommend/user/1466796?n=10
curl http://localhost:8003/recommend/similar/46301?n=10
curl http://localhost:8003/recommend/together/46301?n=10The pipeline expects three logical sources in ClickHouse — rename the table identifiers in materialize.py / precompute.py / api.py to match yours:
- events table with columns:
event_id,user_id,event_type,event_date,platform - event→items denorm table with:
event_id,item_id,event_date, optionalitem_category_name - product catalog with:
id_product(or equivalent),active,quantity
The shipped SQL uses placeholder identifiers (events.events, events.event_items_denorm, catalog.products) that you swap for your actual table names.
pip install -r requirements.txt
cp .env.example .env # fill in ClickHouse credentials
# Create the output tables
python3 -c "
import sys, re
sys.path.insert(0, '.')
from db import get_client
client = get_client()
sql = open('setup_tables.sql').read()
for stmt in [s.strip() for s in re.sub(r'--[^\n]*', '', sql).split(';') if s.strip()]:
client.command(stmt)
print('OK:', stmt[:60])
"| Variable | Default | Description |
|---|---|---|
CLICKHOUSE_HOST |
— | ClickHouse host (required) |
CLICKHOUSE_PORT |
8443 |
Native HTTPS port |
CLICKHOUSE_USER |
default |
|
CLICKHOUSE_PASSWORD |
— | |
CLICKHOUSE_SECURE |
true |
Use TLS |
MODEL_PATH |
als_model.pkl |
Where train.py saves the model |
USER_RECS_N |
50 |
Top-N user recs to precompute |
ITEM_SIMILAR_N |
20 |
Top-N similar items to precompute |
REDIS_URL |
redis://localhost:6379 |
Optional Redis for response caching |
MIT — see LICENSE.