GCP Lesson 90 of 98

GCP Enterprise Architecture: Retail Recommendation Engine

A recommendation engine is the single highest-leverage piece of machine learning most retailers ever ship. Done well, it lifts revenue per session by double digits without touching acquisition spend. Done badly, it recommends the umbrella the customer already bought, tanks page-load latency, and quietly trains itself on a feedback loop of its own bad guesses. This article is a complete, reusable GCP reference architecture for getting it right — from the first clickstream event to the ranked carousel that renders on the product page, and the Looker dashboards the merchandising team actually trusts.

In a nutshell

Think of the best sales associate in a physical store. They remember what you browsed last visit, they notice what’s in your hand right now, they know what’s actually on the shelf behind them, and — in the two seconds before you drift off — they suggest the one thing you’re most likely to want next. A recommendation engine is that associate, except it does the job for millions of shoppers at once, personalises for people it has never met, and returns its answer in roughly the time it takes a web page to paint (well under a tenth of a second).

This lesson is not about one magic service or one model. It is a reference architecture — a proven blueprint of how a dozen GCP services fit together so the whole thing is fresh, fast, cheap enough, and measurable. The trick is to see it as four cooperating parts: an ingestion plane that catches every click, an analytics/feature plane (BigQuery) that turns raw clicks into signals, a serving plane that ranks products in milliseconds, and an insight plane (Looker) that proves it made money. Data flows left to right through all four.

The reassuring part for a beginner: you do not need to be a machine-learning researcher to build this. The fastest path uses Google’s managed retail recommender (Vertex AI Search for commerce, formerly Recommendations AI), which handles the genuinely hard ML — cold-start, freshness, ranking — for you. You wire up the plumbing (events in, catalogue in, recommendations out) and let the managed model do the science. The lesson also covers the custom path (a two-tower model on Vertex AI) for when your business logic outgrows the managed box.

Level: Advanced · Time: ~34 min

Prerequisites — helpful to have first. You’ll get the most from this if you’re comfortable with GCP fundamentals (projects, IAM, service accounts) and have met the four building blocks below at least once. Each has a dedicated deep-dive in this course:

After this lesson you’ll be able to:

The business scenario

Picture a mid-market omnichannel retailer — call the segment “₹500 crore to ₹5,000 crore annual GMV.” They sell across a web storefront, native mobile apps, and a few hundred physical stores with a loyalty programme that ties the channels together. They already have a data warehouse, a tag manager firing clickstream events, and a product catalogue that changes daily as SKUs go in and out of stock. What they do not have is a recommendation system that earns its keep.

The symptoms are familiar across the whole size range, from a single-brand DTC shop to a large multi-banner group:

The business goal is concrete and modest enough to be credible: increase revenue per visit by serving relevant, fresh, in-stock recommendations on the homepage, product detail page (PDP), cart, and post-purchase email — and prove the lift with a clean A/B framework. The technical goal is a system that ingests behavioural events in near-real-time, keeps a unified view of customer and catalogue, serves a ranked list in well under 100 ms at the edge, and gives merchandisers a Looker cockpit to monitor and steer it.

GCP is a natural fit here because the hard parts — a managed recommender that handles cold-start, a warehouse that doubles as a feature store, and a streaming bus — are first-party services that integrate without glue code you have to maintain.

Architecture overview

The system is best understood as four planes that share data but scale and fail independently: an ingestion plane, an analytics/feature plane, a serving plane, and a insight plane. Picture the diagram as a left-to-right flow with a vertical “data backbone” running through the middle.

GCP retail recommendation engine reference architecture: clickstream sources feed Pub/Sub and Dataflow into a central BigQuery backbone, which fans out to Vertex AI Search for commerce and a custom Vertex AI Endpoint behind a Cloud Run recommendations API with a Memorystore cache, while Looker reads BigQuery for insight — request path numbered one to eight.

Reading the diagram — the request path, 1 → 8. When a shopper opens a product page:

  1. The storefront’s BFF (backend-for-frontend) calls the internal recommendations API (Cloud Run) with the visitor/user ID, the surface (“PDP”), and the current product context.
  2. The API computes a cache key and checks Memorystore (Redis).
  3. On a hit, it returns the cached ranking immediately — the single-digit-millisecond happy path.
  4. On a miss, it calls the Vertex AI Predict endpoint (the managed Search-for-commerce serving config, or the custom endpoint).
  5. Candidate products are filtered against a near-live in-stock set so nothing out-of-stock is ever shown.
  6. The ranked, filtered list is written back to Redis with a short TTL and returned to the BFF, which paints the carousel.
  7. Meanwhile the view event the shopper just generated flows through Pub/Sub → Dataflow → BigQuery and into the recommender, so the very next call reflects what they just did.
  8. Looker reads BigQuery to show coverage, click-through, attach rate, A/B results, and drift.

Ingestion (left edge). Every customer touchpoint — web SDK, mobile SDK, server-side checkout service, in-store POS, and the loyalty CRM — emits structured events. View, add-to-cart, purchase, and search events flow into Pub/Sub topics. Pub/Sub is the shock absorber: it decouples bursty client traffic from downstream processing and fans the same event stream out to multiple consumers. A Dataflow streaming pipeline subscribes, validates and enriches events (resolving anonymous IDs to loyalty IDs where possible), and writes them two places at once: into BigQuery for analytics, and into Vertex AI Search for commerce (the service formerly branded Recommendations AI) as real-time user events so the model sees behaviour within seconds.

Analytics and features (centre — the backbone). BigQuery is the gravitational centre of the architecture. It is the system of record for raw events, the place where the product catalogue is curated and joined to inventory, and the engine that computes engineered features (recency/frequency/monetary aggregates, category affinity, session embeddings). A scheduled BigQuery pipeline publishes the cleaned product catalogue to Vertex AI Search for commerce. The same BigQuery features feed a Vertex AI Feature Store so that custom models can be trained and served on consistent online/offline features.

Serving (right side). Two complementary serving paths exist, and choosing between them is the central architectural decision (covered in When to use it). Path A is the managed Vertex AI Search for commerce / Recommendations AI Predict API: you call a serving config (e.g. “recommended for you”, “frequently bought together”, “others you may like”) and it returns a ranked, optionally personalised list, handling cold-start and freshness internally. Path B is a custom two-tower model trained in Vertex AI, deployed to a Vertex AI Endpoint, with a Vertex AI Vector Search index for fast approximate-nearest-neighbour candidate retrieval and a re-ranking model on top. Both paths sit behind an internal recommendations API (Cloud Run) that the storefront’s BFF (backend-for-frontend) calls; results are cached in Memorystore (Redis) keyed by user + surface + context, with a short TTL to keep them fresh.

Insight (top-right). Looker sits on top of BigQuery via a governed semantic model (LookML). Merchandisers and analysts get dashboards for recommendation coverage, click-through and attach rate by surface, A/B experiment results, and model drift. Looker’s modelling layer is what turns the raw event tables into trustworthy, consistent metrics that the business and the data-science team agree on.

The end-to-end request path for a personalised carousel: the browser requests a PDP, the BFF calls the Cloud Run recommendations API with the user/visitor ID, surface, and current product context, the API checks Redis, on a miss it calls the Vertex AI Predict endpoint, filters the candidates against a live in-stock set, writes the result to Redis, and returns it. Meanwhile the view event the customer just generated is already flowing through Pub/Sub into both BigQuery and the recommender, so the next call reflects what they just did.

Component breakdown

Component GCP Service Role in the architecture Key configuration choices
Event bus Pub/Sub Durable, fan-out ingestion of all behavioural events Separate topics per event type; schema-validated messages; dead-letter topic; ordering keys only where strictly needed
Stream processing Dataflow (Apache Beam) Validate, enrich, deduplicate, identity-stitch, dual-write Streaming engine; exactly-once to BigQuery via Storage Write API; windowed dedup; DLQ for malformed events
Warehouse / feature engine BigQuery System of record, catalogue curation, feature computation, A/B analytics Partition event tables by date, cluster by user/product; BI Engine reservation for Looker; scheduled queries for feature refresh
Managed recommender Vertex AI Search for commerce (Recommendations AI) Cold-start-safe, freshness-aware personalised recommendations Import catalogue + user events; choose model type per surface; set optimisation objective (CTR / revenue / conversion)
Custom modelling Vertex AI (Training, Endpoints, Feature Store) Two-tower retrieval + re-ranker when business logic exceeds the managed model Custom training on TFX/Keras; online Feature Store; autoscaling endpoints with min replicas for latency
Candidate retrieval Vertex AI Vector Search Millisecond ANN lookup over item embeddings for the custom path ScaNN index; tuned leaf_node_embedding_count; deployed index with autoscaling
Serving API Cloud Run Stateless recommendations service: cache, fallback, business filters Min instances to avoid cold starts; concurrency tuned; per-surface fallback rails
Online cache Memorystore for Redis Sub-millisecond cache of computed recommendation lists Short TTL (30–120 s); key = visitor+surface+context hash; Standard HA tier
Inventory / catalogue source BigQuery + Cloud Storage Authoritative product + inventory feed Hourly inventory delta; full catalogue reconcile daily
Insight & governance Looker Semantic model, dashboards, experiment readouts, drift monitoring LookML metrics; row-level access; PDTs for heavy aggregates
Secrets & config Secret Manager API keys, model/serving-config IDs, connection strings Versioned secrets; accessed via workload identity, never baked into images

A few components deserve a closer look.

Pub/Sub as the decoupler. The reason events go to Pub/Sub first, rather than straight to BigQuery, is resilience and fan-out. A flash sale can 10x event volume in seconds; Pub/Sub absorbs that without back-pressuring the storefront, and the same stream feeds Dataflow, the recommender, and any future consumer (fraud, real-time inventory) without re-instrumenting clients. The dead-letter topic ensures a single malformed event schema deploy doesn’t silently drop data.

BigQuery as both warehouse and feature engine. This is the design choice that keeps the architecture lean. Rather than standing up a separate feature platform, the engineered features (RFM aggregates, 30-day category affinity, trending-in-your-region signals) are SQL on partitioned, clustered event tables. For the custom-model path those features are materialised into Vertex AI Feature Store so that the exact same feature definitions are available at training time (offline) and serving time (online), eliminating training/serving skew — the most common cause of “the model looked great in the notebook and flopped in production.”

The managed recommender’s surface model. Vertex AI Search for commerce maps to retail surfaces directly: “Recommended for You” (homepage, personalised), “Others You May Like” and “Similar Items” (PDP), “Frequently Bought Together” (cart/PDP), and “Recently Viewed”. Each is a serving config backed by a model with an explicit business objective. Picking revenue per session vs click-through rate as the objective materially changes behaviour — CTR optimisation can over-favour cheap, high-engagement items, so cart and PDP surfaces usually optimise for conversion/revenue while discovery surfaces optimise for engagement.

Memorystore with deliberately short TTLs. Caching recommendations is in tension with freshness. The resolution is a short TTL (tens of seconds) plus cache keys that include behavioural context, so a customer who just added an item gets a fresh computation while a thundering herd on a popular PDP is still absorbed. The cache exists for tail-latency protection and cost control, not to serve stale results for minutes.

Implementation guidance

Project and environment layout. Use a multi-project structure governed by Terraform and an organisation hierarchy: a host project for shared VPC and DNS, plus dev / staging / prod service projects. Recommendation workloads live in the service projects; BigQuery datasets for raw, curated, and feature layers are separated so IAM can grant analysts curated access without exposing raw PII. A dedicated looker connection service account reads only the curated and feature datasets.

Infrastructure as Code. Terraform is the right default on GCP (Deployment Manager is effectively legacy; Config Connector/KCC is an option if you are all-in on GKE/Kubernetes, but Terraform is more common for this mix). Structure it as composable modules:

Keep all model IDs, serving-config IDs, and connection strings in Secret Manager, referenced by Terraform outputs — never hard-code them in the Cloud Run image.

Networking. Run a Shared VPC from the host project. Cloud Run uses a Serverless VPC Access connector to reach Memorystore and any private endpoints. Enable Private Service Connect / Private Google Access so traffic to BigQuery, Pub/Sub, and Vertex AI stays on Google’s backbone rather than the public internet. Front the public-facing storefront with the Global External Application Load Balancer and Cloud Armor (WAF + rate limiting); the recommendations API itself is internal and is only reached by the BFF, not exposed directly to browsers. Put VPC Service Controls around the analytics projects to create a perimeter that prevents data exfiltration from BigQuery/Vertex AI even with valid credentials.

Identity wiring. Every workload uses a dedicated, least-privilege service account; no service account keys — use workload identity (for GKE) or the attached runtime service account (for Cloud Run/Dataflow). Concretely: the Dataflow SA gets pubsub.subscriber + bigquery.dataEditor (on the raw dataset only) + the Retail event-write role; the Cloud Run SA gets the Vertex AI predict/Retail user role, redis access, and secretmanager.secretAccessor; the Looker SA gets bigquery.dataViewer on curated/feature datasets plus bigquery.jobUser. Human access is via Google Groups bound to IAM roles, never individual grants, so onboarding/offboarding is a group membership change.

Event contract. Standardise on a single event schema (validated by Pub/Sub schemas) carrying visitor_id, optional user_id (loyalty), event_type, product_details, session_id, timestamp, and channel. This is the contract both BigQuery analytics and the Retail user-event API consume, so investing in it up front avoids divergent event definitions later.

CI/CD. Cloud Build (or GitHub Actions) builds the Dataflow and Cloud Run images, runs terraform plan on PRs with manual approval to apply to prod, and runs LookML validation against a Looker dev branch. Model retraining is orchestrated by Vertex AI Pipelines on a schedule, with the managed recommender retraining automatically as fresh events arrive.

Worked example: wiring one path end to end

The prose above is the blueprint; here is what the load-bearing pieces actually look like. Nothing below needs a running project to read — all IDs are placeholders — but every command and schema is real and schema-correct, so you can adapt them directly.

1. The event contract, as a Pub/Sub Avro schema. One schema, registered once, every client obeys it. This is the single most important artefact in the whole system:

{
  "type": "record",
  "name": "RetailEvent",
  "namespace": "com.example.retail",
  "fields": [
    { "name": "visitor_id", "type": "string" },
    { "name": "user_id", "type": ["null", "string"], "default": null },
    { "name": "event_type", "type": { "type": "enum", "name": "EventType",
        "symbols": ["view", "add_to_cart", "purchase", "search", "detail_page_view"] } },
    { "name": "session_id", "type": "string" },
    { "name": "event_ts", "type": { "type": "long", "logicalType": "timestamp-micros" } },
    { "name": "channel", "type": { "type": "enum", "name": "Channel",
        "symbols": ["web", "ios", "android", "pos"] } },
    { "name": "product_ids", "type": { "type": "array", "items": "string" }, "default": [] },
    { "name": "search_query", "type": ["null", "string"], "default": null },
    { "name": "revenue_micros", "type": ["null", "long"], "default": null }
  ]
}

2. Register the schema and the schema-validated topics. Bind every topic to that one schema, so a client that emits a malformed event is rejected at publish time — long before it can poison BigQuery or the model:

# Register the event contract (Avro) once
gcloud pubsub schemas create retail-event \
  --type=avro \
  --definition-file=retail-event.avsc

# One topic per event type, all validated by the same schema, JSON on the wire
for ev in view add-to-cart purchase search; do
  gcloud pubsub topics create "events-${ev}" \
    --schema=retail-event \
    --message-encoding=json
done

# A dead-letter topic + a subscription that quarantines poison messages
gcloud pubsub topics create events-dlq

gcloud pubsub subscriptions create dataflow-view-sub \
  --topic=events-view \
  --dead-letter-topic=events-dlq \
  --max-delivery-attempts=5 \
  --ack-deadline=30

IAM gotcha: for the dead-letter topic to work, the Pub/Sub service agent (service-PROJECT_NUMBER@gcp-sa-pubsub.iam.gserviceaccount.com) needs roles/pubsub.publisher on events-dlq and roles/pubsub.subscriber on the source subscription. Forget this and messages that exceed max-delivery-attempts are simply redelivered forever instead of being quarantined.

3. The BigQuery backbone — partitioned and clustered. The single choice that keeps feature queries scanning kilobytes instead of terabytes. require_partition_filter makes it a hard rule that every query names a date range:

CREATE TABLE IF NOT EXISTS retail_raw.events (
  visitor_id     STRING    NOT NULL,
  user_id        STRING,
  event_type     STRING    NOT NULL,
  session_id     STRING,
  event_ts       TIMESTAMP NOT NULL,
  channel        STRING,
  product_ids    ARRAY<STRING>,
  search_query   STRING,
  revenue_micros INT64
)
PARTITION BY DATE(event_ts)
CLUSTER BY user_id, event_type
OPTIONS (
  partition_expiration_days = 400,
  require_partition_filter  = TRUE
);

4. Features as SQL, refreshed by a scheduled query. No separate feature platform — recency/frequency/monetary (RFM) signals are just an aggregate over the clustered table. The WHERE DATE(event_ts) … clause both satisfies require_partition_filter and prunes the scan to the last 90 partitions:

CREATE OR REPLACE TABLE retail_features.user_rfm
CLUSTER BY user_id AS
SELECT
  user_id,
  DATE_DIFF(CURRENT_DATE(), DATE(MAX(event_ts)), DAY)                 AS recency_days,
  COUNTIF(event_type = 'purchase')                                   AS frequency_90d,
  ROUND(SUM(IF(event_type = 'purchase', revenue_micros, 0)) / 1e6, 2) AS monetary_inr
FROM retail_raw.events
WHERE DATE(event_ts) >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
  AND user_id IS NOT NULL
GROUP BY user_id;

5. The serving API on Cloud Run — kept warm. --min-instances hides cold starts, internal-only ingress keeps it off the public internet, and the serving-config ID comes from Secret Manager, never the image:

gcloud run deploy recommendations-api \
  --image=asia-south1-docker.pkg.dev/PROJECT_ID/reco/recommendations-api:1.4.0 \
  --region=asia-south1 \
  --no-allow-unauthenticated \
  --min-instances=3 --max-instances=100 \
  --concurrency=80 --cpu=2 --memory=1Gi \
  --vpc-connector=reco-connector \
  --vpc-egress=private-ranges-only \
  --service-account=reco-api@PROJECT_ID.iam.gserviceaccount.com \
  --set-secrets=SERVING_CONFIG_ID=reco-serving-config:latest

6. The same resources in Terraform. In production you would not click these into being; you would declare them. A representative slice of the module set:

# Pub/Sub: the schema and a topic bound to it
resource "google_pubsub_schema" "retail_event" {
  name       = "retail-event"
  type       = "AVRO"
  definition = file("${path.module}/schemas/retail-event.avsc")
}

resource "google_pubsub_topic" "events_view" {
  name = "events-view"
  schema_settings {
    schema   = google_pubsub_schema.retail_event.id
    encoding = "JSON"
  }
}

# BigQuery: partitioned + clustered raw event table
resource "google_bigquery_table" "events" {
  dataset_id          = google_bigquery_dataset.raw.dataset_id
  table_id            = "events"
  deletion_protection = true

  time_partitioning {
    type          = "DAY"
    field         = "event_ts"
    expiration_ms = 400 * 24 * 60 * 60 * 1000
  }
  clustering = ["user_id", "event_type"]
  schema     = file("${path.module}/schemas/events-bq.json")
}

# Cloud Run: warm serving, internal ingress, private egress
resource "google_cloud_run_v2_service" "reco_api" {
  name     = "recommendations-api"
  location = "asia-south1"
  ingress  = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"

  template {
    service_account = google_service_account.reco_api.email
    scaling {
      min_instance_count = 3
      max_instance_count = 100
    }
    max_instance_request_concurrency = 80
    containers {
      image = "asia-south1-docker.pkg.dev/${var.project_id}/reco/recommendations-api:1.4.0"
      resources {
        limits = { cpu = "2", memory = "1Gi" }
      }
    }
    vpc_access {
      connector = google_vpc_access_connector.reco.id
      egress    = "PRIVATE_RANGES_ONLY"
    }
  }
}

# Memorystore: HA Redis on the shared VPC
resource "google_redis_instance" "reco_cache" {
  name               = "reco-cache"
  tier               = "STANDARD_HA"
  memory_size_gb     = 5
  region             = "asia-south1"
  authorized_network = var.shared_vpc_self_link
  redis_version      = "REDIS_7_0"
}

That is the skeleton: an event contract enforced at the edge, a warehouse that can’t be queried carelessly, a warm serving tier, and an HA cache — all declared as code. Everything in Going deeper is about what happens inside those boxes.

Enterprise considerations

Security and Zero Trust. The perimeter is defence-in-depth: Cloud Armor at the edge, an internal-only recommendations API, Shared VPC with private connectivity to all data services, and VPC Service Controls around the analytics estate. Identity is the new perimeter — every call is authenticated with a least-privilege service account and authorised per-dataset; no long-lived keys. PII handling matters because behavioural data is personal: use BigQuery column-level access and dynamic data masking so analysts see hashed user IDs unless they have explicit need, apply Sensitive Data Protection (DLP) to scan for accidental PII in event payloads, and keep raw and curated datasets in separate IAM domains. CMEK (customer-managed keys via Cloud KMS) encrypts BigQuery, Pub/Sub, and Storage where compliance requires key control. Audit everything with Cloud Audit Logs exported to a locked log sink.

Cost optimisation. The largest line items are BigQuery and Vertex AI serving. Tactics that move the needle: partition and cluster event tables so feature queries scan kilobytes not terabytes; consider BigQuery editions with slot reservations + autoscaling once on-demand spend is predictable; use a BI Engine reservation so Looker dashboards hit cache rather than re-scanning; set Cloud Run min instances just high enough to hide cold starts (latency vs idle cost trade-off); keep Memorystore right-sized — a high cache hit rate directly reduces Vertex AI Predict calls, which are billed per request. Lifecycle-tier raw events to colder storage after the feature window. For the custom model, batch predictions where real-time isn’t required and reserve online endpoints for the surfaces that truly need sub-100 ms.

Scalability. Each plane scales independently. Pub/Sub and BigQuery are effectively limitless for this workload; Dataflow autoscales workers to event volume; Cloud Run scales to traffic with concurrency tuning; Vertex AI endpoints autoscale on QPS (set a sensible min/max). The Redis cache flattens read amplification on hot products. The architecture comfortably spans a single-brand shop doing thousands of events a day to a large group doing tens of thousands of events a second during a sale, with the only changes being reservation sizes and replica counts.

Reliability and DR (RTO/RPO). Pub/Sub retains and replays messages, so a downstream outage doesn’t lose data (effective RPO near zero for events in flight; default retention up to 7 days). BigQuery is regional with automatic replication and supports cross-region dataset copies and time-travel (7-day) for recovery from bad writes — for stricter DR, configure scheduled cross-region copies of curated/feature datasets. The serving plane is designed to degrade gracefully: if the Vertex AI endpoint is unavailable, the Cloud Run API falls back to a cached or BigQuery-derived “popular in category / trending” rail so the page never renders empty — this is the most important reliability property, because a broken recommender should be invisible to the shopper. Target RTO < 1 hour for full personalised serving (failover region for endpoints) and RTO near zero for a degraded-but-functional experience via fallback rails. Run the serving plane multi-region behind the global load balancer for true high availability.

Observability. Use Cloud Monitoring/Logging/Trace for golden signals on the serving path: recommendation API p50/p95/p99 latency, cache hit ratio, Vertex AI Predict error rate, and Dataflow system lag (the freshness SLO — how many seconds between event and model visibility). Business observability lives in Looker: coverage (what fraction of sessions got a recommendation), CTR and attach rate per surface, revenue attributed to recommendations, and model drift indicators (shifts in recommended-category distribution). Alert on data-freshness lag and on a drop in recommendation coverage — both are leading indicators of customer-visible degradation.

Governance. Catalogue the estate in Dataplex / Data Catalog with tags for PII and data domains. Looker’s semantic layer is the governance keystone: defining “attach rate” and “recommendation-attributed revenue” once in LookML means finance, merchandising, and data science argue about strategy, not about whose number is right. Enforce row-level security in Looker so a banner manager sees only their banner. Keep an experiment registry so every A/B test’s design, dates, and result are recorded and recommendation changes are tied to measured lift, not vibes.

Reference enterprise example

Company: Saanjh Living — a fictional ₹1,800-crore-GMV omnichannel home and lifestyle retailer (furniture, decor, kitchenware) with a web store, iOS/Android apps, 140 stores, and a 6-million-member loyalty programme. Catalogue: ~90,000 active SKUs with high churn (seasonal decor, fast-moving kitchenware). Pain: the homepage showed identical top-sellers to everyone, PDP cross-sell was a static “you may also like” curated by hand, and merchandising couldn’t quantify recommendation impact.

What they built. Saanjh adopted the managed path first (Path A) to get to value fast. Web and app SDKs and the checkout service publish to four Pub/Sub topics (view, add_to_cart, purchase, search) averaging ~9,000 events/sec at peak (Diwali) and ~1,200/sec on a normal weekday. A Dataflow streaming job dual-writes to a partitioned/clustered BigQuery events_raw dataset and to Vertex AI Search for commerce as user events. A nightly BigQuery scheduled query reconciles the full catalogue and an hourly query pushes inventory deltas, so the recommender never surfaces out-of-stock SKUs. Surfaces: “Recommended for You” on home (objective: CTR), “Frequently Bought Together” on cart (objective: revenue), “Similar Items” on PDP (objective: conversion).

The Cloud Run recommendations API (min 3 instances per region, two regions) sits behind the app BFF, caches in Memorystore with a 60-second TTL, and falls back to a BigQuery “trending in category” rail on any endpoint error. Looker, with a 200-slot BI Engine reservation, gives merchandising a daily cockpit and powers the A/B readout.

Decisions and numbers.

Outcome. Saanjh rolled the treatment to 100%, kept the A/B framework permanently to gate future model changes, and the merchandising team now steers objectives per surface from a Looker dashboard rather than filing tickets. The fallback rail proved its worth during a regional Vertex AI hiccup — shoppers saw “trending” recommendations and never knew the personalised model had blipped. Twelve months in, they began the custom Vector Search bundle recommender as Phase 2, reusing the same Pub/Sub + BigQuery + Feature Store backbone with zero re-instrumentation.

When to use it

Use this architecture when you have meaningful behavioural volume (thousands of events a day and up), a catalogue that changes faster than nightly batch can track, multiple surfaces to personalise, and a real need to prove lift. It scales down to a single-brand DTC store (drop the multi-region serving and the custom path) and up to a large multi-banner group (add Vector Search, per-banner Looker row-level security, and reserved slots).

Prefer the managed Vertex AI Search for commerce path when you want fast time-to-value, your team is small, and cold-start + freshness are your hard problems — it handles all three without you owning a model lifecycle. Reach for the custom Vertex AI two-tower + Vector Search path when the business logic is genuinely beyond a catalogue recommender: bundle/“complete the look” reasoning, multi-objective ranking that blends margin and inventory-clearance goals, or recommendations over a non-product entity (content, services). You can run both side by side — managed for the standard surfaces, custom for the differentiated one — on the same data backbone.

Anti-patterns to avoid:

Alternatives. If you are already deep in a different stack, the equivalent patterns are Amazon Personalize + Kinesis + Redshift + QuickSight on AWS, or Azure Personalizer/Azure AI + Event Hubs + Synapse/Fabric + Power BI on Azure. The GCP version’s distinctive strength is that BigQuery doubles as warehouse and feature engine and the managed retail recommender removes the hardest ML lifecycle work — which is precisely why it is the pragmatic default for retailers who want lift, not a research project. For very small catalogues or low traffic, a simpler heuristic (“bought together” computed in BigQuery and served from Redis) may be all you need until volume justifies the full engine.

Going deeper

This section is for the reader who has to operate the thing, not just draw it. It unpacks the four questions that decide whether the architecture actually works in production: where the latency goes, how the custom model really works, why the ingest is exactly-once, and what it costs.

The latency budget, millisecond by millisecond

“Under 100 ms” is not a wish; it is a budget you spend. The number that matters is p99 (the slowest 1% of requests), not the average — because a shopper on a slow request is still a real shopper, and tail latency is what shows up as jank in the carousel. Here is a representative budget for the cache-miss path (the hit path is a fraction of this):

Step Typical spend (p99) What buys it down
Load balancer + BFF hop ~5–10 ms Regional LB; BFF co-located with the API
Cloud Run request handling ~3–8 ms min-instances (no cold start); tuned concurrency
Redis cache lookup ~1–3 ms Same-region Memorystore, HA tier
Vertex AI Predict (miss only) ~30–60 ms Warm endpoint / managed serving config; min-replicas
Inventory / business filter ~2–5 ms In-memory in-stock set, refreshed async
Serialise + return ~2–4 ms Compact payload; only the fields the UI needs

Three levers dominate. min-instances on Cloud Run removes the cold-start tax that would otherwise add hundreds of milliseconds to the unlucky first request after a scale-up. A warm Vertex AI endpoint (min replicas ≥ 1, or the always-warm managed serving config) is the single biggest line item — this is why you never let a busy surface scale its endpoint to zero. The cache is what keeps the expensive Predict call off the critical path for the 70–90% of requests that hit it; a 5-point improvement in hit ratio can shave real money off the Vertex bill and pull the p99 down, because hits skip the two slowest rows entirely.

A representative managed Predict request (Retail API) — this is what step 4 of the diagram sends:

{
  "userEvent": {
    "eventType": "detail-page-view",
    "visitorId": "gv-8f2c9d14ab",
    "productDetails": [{ "product": { "id": "SKU-77413" } }]
  },
  "pageSize": 20,
  "params": { "returnProduct": true, "priceRerankLevel": "low-price-reranking", "diversityLevel": "medium-diversity" },
  "filter": "filterOutOfStockItems"
}

Note the "filter": "filterOutOfStockItems" — the managed recommender can enforce the in-stock rule for you if your catalogue’s availability is kept current, and priceRerankLevel/diversityLevel are the dials that trade revenue against discovery without retraining anything.

How the custom two-tower model actually works

When the managed model isn’t expressive enough, the custom path is almost always a two-tower architecture. The name is literal: two neural networks trained together.

The payoff comes at serving time. You pre-compute every item’s embedding once and load them into Vertex AI Vector Search. At request time you only have to embed the query (fast) and ask Vector Search for the nearest item vectors (also fast). A small re-ranker then reorders those few hundred candidates using richer features and business rules (margin, inventory clearance, diversity). Retrieval is cheap and approximate; ranking is expensive and precise — so you do the cheap thing over millions of items and the expensive thing over a few hundred.

Vector Search / ScaNN internals — why it’s fast

Comparing a query vector to millions of item vectors exactly would blow the latency budget. Vector Search uses approximate nearest neighbour (ANN) via Google’s ScaNN algorithm, which trades a sliver of recall for a huge latency win. Mentally: the index partitions the embedding space into “leaf” buckets; at query time it only searches the handful of buckets nearest the query rather than every vector. Two knobs govern the trade-off:

Tune these against a recall@k vs latency curve on your own data; there is no universal best. Index updates come in two flavours — batch (rebuild periodically) and streaming (upsert embeddings continuously) — and for a fast-churning catalogue you want streaming so a new SKU is retrievable within minutes, not the next rebuild.

Exactly-once ingest, and why it matters

If a purchase event is counted twice, your attribution is wrong and finance stops trusting the dashboard. The old path — legacy streaming inserts (tabledata.insertAll) — was at-least-once with best-effort dedup. The modern path is the BigQuery Storage Write API, which Dataflow uses to get exactly-once semantics: each row is committed once even if a worker retries. In Beam terms you use the Storage Write API sink in EXACTLY_ONCE mode; combine it with a windowed dedup on a stable event ID for defence in depth. This is unglamorous plumbing, but “the numbers are trustworthy” is the entire reason the insight plane exists.

Cold-start, without the guesswork

Cold-start is two distinct problems. New items have no interaction history, so the model leans on content features (category, brand, price, text/image embeddings) and popularity priors until real signal accrues. New/anonymous users get session-based personalisation keyed on visitor_id — the events from the last few minutes are often more predictive than a stale profile anyway. A mature system also runs a little exploration (an epsilon-greedy or contextual-bandit slice) so fresh SKUs get some impressions and can earn their way up, rather than being buried forever by a rich-get-richer feedback loop. The managed recommender does most of this internally; on the custom path you build it yourself.

Cost math you control

Two levers dwarf the rest. BigQuery on-demand is billed by bytes scanned (per-TiB, region-dependent), which is why partition + cluster is not a nicety — it is the cost model. A require_partition_filter table with 400 daily partitions means a 90-day feature query scans roughly 90/400 ≈ 22% of the table; without the filter, a careless SELECT scans 100% and costs ~4.5× more for the same answer. Cluster on the columns you filter/group by and BigQuery prunes further within each partition. Once spend is predictable, move to slot reservations with autoscaling to cap it. Vertex serving is billed per prediction / per node-hour, so cache hit ratio is a direct cost lever: every cache hit is a Predict call you didn’t pay for. Reserve online endpoints for the surfaces that truly need sub-100 ms and run everything else — email recommendations, overnight bundle refreshes — as batch prediction, which is far cheaper per item.

Quotas, IAM, and API caveats worth knowing before you commit

Practice challenges

Work these top to bottom — they escalate from “wire one box” to “design a degradation strategy”. Every solution is real and schema-correct; adapt the placeholders.

1. (Beginner) Register the event contract and a validated topic. Register the Avro schema retail-event and create a events-view topic that rejects any message not matching it, with JSON on the wire.

<details> <summary>Solution</summary>

gcloud pubsub schemas create retail-event \
  --type=avro --definition-file=retail-event.avsc

gcloud pubsub topics create events-view \
  --schema=retail-event --message-encoding=json

Why: binding the topic to a schema moves validation to publish time, so a broken client fails loudly at the edge instead of silently corrupting BigQuery and the model downstream. </details>

2. (Beginner) Create a cost-safe event table. Create retail_raw.events partitioned by event date, clustered by user_id, event_type, that refuses to run a query without a partition filter.

<details> <summary>Solution</summary>

CREATE TABLE retail_raw.events (
  visitor_id STRING NOT NULL, user_id STRING, event_type STRING NOT NULL,
  session_id STRING, event_ts TIMESTAMP NOT NULL, channel STRING,
  product_ids ARRAY<STRING>, search_query STRING, revenue_micros INT64
)
PARTITION BY DATE(event_ts)
CLUSTER BY user_id, event_type
OPTIONS (require_partition_filter = TRUE, partition_expiration_days = 400);

Why: require_partition_filter = TRUE makes an accidental full-table scan impossible, which is the cheapest insurance policy in the whole architecture. </details>

3. (Intermediate) Build the RFM feature table. Write a scheduled-query statement that materialises 90-day recency, purchase frequency, and monetary value per user_id — and make sure it prunes the scan.

<details> <summary>Solution</summary>

CREATE OR REPLACE TABLE retail_features.user_rfm CLUSTER BY user_id AS
SELECT
  user_id,
  DATE_DIFF(CURRENT_DATE(), DATE(MAX(event_ts)), DAY)                  AS recency_days,
  COUNTIF(event_type = 'purchase')                                    AS frequency_90d,
  ROUND(SUM(IF(event_type='purchase', revenue_micros, 0))/1e6, 2)      AS monetary_inr
FROM retail_raw.events
WHERE DATE(event_ts) >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY)
  AND user_id IS NOT NULL
GROUP BY user_id;

Why: the WHERE DATE(event_ts) … predicate both satisfies require_partition_filter and limits the scan to ~90 partitions, so the feature refresh reads a slice, not the whole history. </details>

4. (Intermediate) Deploy a warm, internal serving API. Deploy recommendations-api on Cloud Run with 3 warm instances, reachable only via the internal load balancer, egressing privately to reach Memorystore. State the trade-off min-instances represents.

<details> <summary>Solution</summary>

gcloud run deploy recommendations-api \
  --image=asia-south1-docker.pkg.dev/PROJECT_ID/reco/recommendations-api:1.4.0 \
  --region=asia-south1 --no-allow-unauthenticated \
  --min-instances=3 --max-instances=100 --concurrency=80 \
  --vpc-connector=reco-connector --vpc-egress=private-ranges-only \
  --service-account=reco-api@PROJECT_ID.iam.gserviceaccount.com

Why: min-instances=3 pays idle-instance cost around the clock to buy away cold-start latency on the critical path — the classic latency-vs-cost dial. Internal ingress keeps the API off the public internet; only the BFF can reach it. </details>

5. (Advanced) Design cache keys and TTL. Propose a Redis key scheme and TTL that keeps a just-active shopper’s results fresh, absorbs a thundering herd on a hot PDP, and never serves an out-of-stock item from cache.

<details> <summary>Solution</summary>

Key = hash(visitor_id : surface : product_context : inventory_epoch), TTL 30–90 s, plus request coalescing (single-flight) so concurrent misses for the same key trigger one Predict call, not thousands.

Why: including behavioural product_context means a shopper who just acted gets a fresh computation, while a shared hot key still collapses the herd; folding an inventory_epoch into the key invalidates the entry the moment stock changes, so the cache can’t outlive the catalogue. </details>

6. (Advanced) Add a graceful-degradation fallback. Sketch the serving logic and the BigQuery query for a “trending in category” rail that renders when the Vertex endpoint errors, so the carousel is never empty.

<details> <summary>Solution</summary>

Serving logic: try Redis → try Vertex Predict → on error/timeout, serve trending_by_category[category] (a small table refreshed every few minutes and held in memory / Redis). The trending query:

CREATE OR REPLACE TABLE retail_features.trending_by_category CLUSTER BY category AS
SELECT category, product_id,
       COUNT(*) AS interactions_24h,
       RANK() OVER (PARTITION BY category ORDER BY COUNT(*) DESC) AS rnk
FROM retail_raw.events e
JOIN retail_catalog.products p USING (product_id)
WHERE DATE(e.event_ts) >= DATE_SUB(CURRENT_DATE(), INTERVAL 1 DAY)
  AND p.in_stock
GROUP BY category, product_id
QUALIFY rnk <= 50;

Why: a generic-but-relevant rail is strictly better than an empty one; because it’s precomputed and in-stock-filtered, the fallback adds negligible latency and can never surface an unavailable SKU. This is the reliability property that makes a recommender outage invisible to shoppers. </details>

Common beginner mistakes

These are the misconceptions that lead people astray — the wrong mental model, and the right one to replace it with. (They complement the terse anti-pattern list under When to use it.)

Glossary

GCPArchitectureEnterpriseReference Architecture
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments