Recommendation System Architecture

Production-grade guide to recommendation system architecture covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.

Recommendation systems are the backbone of user engagement in digital platforms, transforming raw interactions into personalized experiences. They matter when content, products, or services must be surfaced efficiently to individuals at scale—whether in e-commerce, media streaming, social networking, or search. A robust architecture must handle real-time scoring, batch processing, feedback loops, and model iteration, all while adapting to changing user behavior and content availability.

Core Components of a Recommendation System

A recommendation system architecture is defined by the flow of data from raw signals to ranked outputs. The system must ingest user and item features, capture interactions, compute relevance scores, and serve results with low latency. The foundational layer consists of a data ingestion pipeline, feature store, model serving, and ranking engine.

Data Ingestion Pipeline

User interactions—clicks, views, purchases, likes, time spent—are captured in event logs. These logs are ingested into a stream-processing engine using a reliable, scalable protocol like Apache Kafka or Amazon Kinesis. Each event is a JSON object with a consistent schema:

{
  "event_id": "a1b2c3d4",
  "user_id": 42017,
  "item_id": 9876,
  "timestamp": "2024-05-15T14:32:17Z",
  "event_type": "click",
  "session_id": "sess-88392"
}

Events are published to Kafka topics such as user_events, item_events, and interaction_logs. Consumers process these events to build user profiles, update item metadata, and trigger model training.

A typical ingestion job runs in a containerized environment using Docker and orchestrated via Kubernetes. The consumer is a Python service using confluent-kafka:

from confluent_kafka import Consumer
import json

consumer = Consumer({
    'bootstrap.servers': 'kafka1:9092,kafka2:9092',
    'group.id': 'recommendation-ingest',
    'auto.offset.reset': 'earliest'
})

consumer.subscribe(['user_events'])

while True:
    msg = consumer.poll(timeout=1.0)
    if msg is None:
        continue
    if msg.error():
        print(f"Consumer error: {msg.error()}")
        continue

    event = json.loads(msg.value())
    # Write to downstream systems: PostgreSQL, Redis, or a feature store
    with psql.connect("dbname=recommendations user=app") as conn:
        with conn.cursor() as cur:
            cur.execute(
                "INSERT INTO interactions (user_id, item_id, event_type, timestamp) "
                "VALUES (%s, %s, %s, %s)",
                (event['user_id'], event['item_id'], event['event_type'], event['timestamp'])
            )
            conn.commit()

Failure modes: if the Kafka consumer fails to commit offsets, events are reprocessed; if the PostgreSQL connection drops, messages accumulate in a retry queue.

Feature Store and Feature Engineering

Features are the inputs to the recommendation model. They are stored in a centralized feature store accessible to training and serving. Features include user-level (e.g., total_views, avg_engagement_time), item-level (popularity_score, category_avg_rating), and context-specific (time_since_last_visit, device_type).

Features are computed and registered in batches or in real time. A feature pipeline uses a SQL-based engine (e.g., dbt, Spark SQL) to materialize features into tables.

-- Compute user feature: average time spent per session
CREATE TABLE user_features AS
SELECT
  user_id,
  AVG(time_spent) AS avg_time_per_session,
  COUNT(*) AS total_sessions,
  MAX(last_session_timestamp) AS last_activity
FROM user_sessions
GROUP BY user_id;

Features are versioned and tagged. A feature is identified by a unique name and version:

user_features/avg_time_per_session/v3
item_features/popularity_score/v2

A feature registry is maintained as a metadata table:

CREATE TABLE feature_registry (
  feature_name TEXT PRIMARY KEY,
  version VARCHAR(20),
  source_table TEXT,
  pipeline_query TEXT,
  created_at TIMESTAMPTZ,
  description TEXT
);

The feature store is queried at serving time using a gRPC or REST API. A typical request:

GET /v1/feature/user_features/avg_time_per_session/v3?user_id=42017 HTTP/1.1
Host: feature-store.example.com
Authorization: Bearer <token>

Response:

{
  "feature": "user_features/avg_time_per_session/v3",
  "value": 142.3,
  "timestamp": "2024-05-15T14:30:00Z"
}

Silent failure: if the feature version is not found, the system defaults to the latest version but logs a warning.

Model Training and Evaluation

Recommendation models are trained on historical interaction data. The training pipeline is batch-oriented, using tools like Apache Spark or Dask, and runs daily or hourly.

A typical training job reads from a fact table of interactions and materialized features:

from pyspark.sql import SparkSession

spark = SparkSession.builder.appName("RecommendationTraining").getOrCreate()

interactions = spark.read.table("interactions")
user_features = spark.read.table("user_features")
item_features = spark.read.table("item_features")


training_data = interactions \
    .join(user_features, on="user_id") \
    .join(item_features, on="item_id") \
    .withColumn("label", col("event_type").eq("purchase").cast("double")) \
    .select("user_id", "item_id", "avg_time_per_session", "popularity_score", "label")

# Train model using LightGBM
from lightgbm import LGBMClassifier
from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    training_data.drop("label").toPandas(),
    training_data.select("label").toPandas(),
    test_size=0.2
)

model = LGBMClassifier(n_estimators=100, learning_rate=0.1)
model.fit(X_train, y_train)

# Save model to disk
import joblib
joblib.dump(model, "/models/recommendation/v1.2.pkl")

Model evaluation is done on holdout data. Metrics include AUC, precision@k, recall@k, and NDCG.

from sklearn.metrics import roc_auc_score, precision_score, ndcg_score

y_pred = model.predict_proba(X_test)[:, 1]
auc = roc_auc_score(y_test, y_pred)

# Precision at top 10 items
top_k = 10
precision_at_k = precision_score(y_test, (y_pred > 0.5).astype(int), k=top_k)

# NDCG@10
ndcg = ndcg_score(y_test.reshape(1, -1), y_pred.reshape(1, -1), k=top_k)

Models are versioned and registered in a model registry. A model is identified by name and version:

recommendation_model/batch/lightgbm/v1.2

The registry is a table:

CREATE TABLE model_registry (
  model_name TEXT PRIMARY KEY,
  version TEXT,
  artifact_path TEXT,
  created_at TIMESTAMPTZ,
  metrics JSONB,
  trained_on DATETIME,
  description TEXT
);

When a new model is trained, it is evaluated and promoted to production if it exceeds a threshold (e.g., AUC > 0.85).

Serving Pipeline: Real-Time and Batch

The serving pipeline delivers recommendations to users in real time and batch. It consists of a ranking engine, a cache layer, and a serving API.

Real-Time Serving

For real-time recommendations, a user requests a list of items. The system computes scores for candidate items and returns a ranked list.

A typical request:

POST /v1/recommendations HTTP/1.1
Host: recommendation-service.example.com
Content-Type: application/json
Authorization: Bearer <user-token>

{
  "user_id": 42017,
  "context": {
    "device_type": "mobile",
    "time_of_day": "evening",
    "location": "San Francisco"
  },
  "num_results": 20
}

The response:

{
  "user_id": 42017,
  "timestamp": "2024-05-15T14:33:10Z",
  "recommendations": [
    {
      "item_id": 9876,
      "score": 0.93,
      "rank": 1,
      "reason": "user_profile_match"
    },
    {
      "item_id": 10123,
      "score": 0.87,
      "rank": 2,
      "reason": "popular_item"
    }
  ]
}

The service uses a Python backend with FastAPI and a caching layer (Redis):

from fastapi import FastAPI
import redis

app = FastAPI()
r = redis.Redis(host="redis.example.com", port=6379, db=0)

@app.post("/v1/recommendations")
async def recommend(request: RecommendationRequest):
    user_id = request.user_id
    context = request.context
    num_results = request.num_results

    # Try cache first
    cache_key = f"recs:{user_id}:{context['device_type']}:{num_results}"
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # Fetch features
    user_features = await fetch_feature("user_features/avg_time_per_session/v3", user_id)
    item_features = await fetch_feature("item_features/popularity_score/v2", None)

    # Load model from disk
    model = joblib.load("/models/recommendation/v1.2.pkl")

    # Generate candidate items
    candidates = await get_candidate_items(user_id, context)

    # Score candidates
    features = []
    for item in candidates:
        row = {
            "user_id": user_id,
            "item_id": item["item_id"],
            "avg_time_per_session": user_features["value"],
            "popularity_score": item["popularity_score"]
        }
        features.append(row)

    X = pd.DataFrame(features)
    scores = model.predict_proba(X)[:, 1]

    # Combine and sort
    results = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)[:num_results]

    # Format response
    response = {
        "user_id": user_id,
        "timestamp": datetime.now().isoformat(),
        "recommendations": [
            {
                "item_id": item["item_id"],
                "score": score,
                "rank": i + 1,
                "reason": "model_score"
            }
            for i, (item, score) in enumerate(results)
        ]
    }

    # Cache response
    r.setex(cache_key, 3600, json.dumps(response))

    return response

Failure modes: if the model file is missing, the service falls back to a default model. If Redis is unreachable, the system uses the cache as a secondary store.

Batch Serving

Batch recommendations are precomputed and stored. They are used for email digests, homepage banners, or nightly updates.

A batch job runs nightly:

# Run in a Kubernetes job
kubectl run recommendation-batch-job --image=python:3.11 \
  --command -- python3 /app/batch_recommendations.py \
  --start-date "2024-05-14" \
  --end-date "2024-05-15"

The script:

def generate_batch_recommendations(start_date, end_date):
    with psql.connect("dbname=recommendations user=app") as conn:
        cur = conn.cursor()
        cur.execute("""
            SELECT user_id
            FROM users
            WHERE last_active > %s
            ORDER BY total_engagement_score DESC
            LIMIT 10000
        """, (start_date,))

        user_ids = [row[0] for row in cur.fetchall()]

    # For each user, compute recommendations
    results = []
    for user_id in user_ids:
        recs = recommend(user_id, {"device_type": "desktop", "time_of_day": "morning"})
        results.append({
            "user_id": user_id,
            "recommendations": recs["recommendations"],
            "generated_at": datetime.now()
        })

    # Save to table
    with psql.connect("dbname=reports user=app") as conn:
        with conn.cursor() as cur:
            for res in results:
                cur.execute("""
                    INSERT INTO user_recommendations_batch (user_id, recommendations, generated_at)
                    VALUES (%s, %s, %s)
                """, (res["user_id"], res["recommendations"], res["generated_at"]))
            conn.commit()

Batch results are indexed and served via a REST API or stored in a data warehouse (e.g., Snowflake, BigQuery).

Feedback Loop and Model Iteration

Recommendations are not static. The system must collect feedback and use it to improve models.

Feedback is captured as explicit and implicit signals:

These are ingested into the same event pipeline. A feedback loop runs daily:

-- Compute click-through rate for recommendations
CREATE TABLE recommendation_performance AS
SELECT
  r.user_id,
  r.item_id,
  COUNT(*) AS impressions,
  SUM(CASE WHEN e.event_type = 'click' THEN 1 ELSE 0 END) AS clicks,
  AVG(e.time_spent) AS avg_time
FROM recommendations r
JOIN user_events e ON r.recommendation_id = e.recommendation_id
WHERE e.timestamp >= '2024-05-15'
GROUP BY r.user_id, r.item_id;

The performance data is used to:

The system tracks model drift and data quality. A monitoring job checks for:

When a model fails to meet thresholds, it is flagged and rolled back.

Failure Modes and Sharp Edges

The architecture is not just a pipeline—it is a system that observes, adapts, and corrects itself.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.