Retail AI9 min read
Building a Multi-Agent Retail Merchandiser with Google ADK and Vertex AI
How we orchestrated parallel vector search, re-ranking, and Feature Store enrichment into an AI system that makes merchandising decisions at catalog scale
- Google ADK
- Vertex AI
- Ranking API
- Feature Store
- FastAPI
- Cloud Run
A senior merchandiser at a large retailer holds years of category expertise: they know which products are substitutable, how to rank search results by margin and inventory, and how to respond to stock-out events. Doing this manually at catalog scale — thousands of SKUs, dozens of category updates per day — is impossible. This agent automates the decision loop.
Agent Architecture
Merchandiser Query (natural language or catalog event)
↓
Planner Agent (Gemini 2.5 Flash)
├── Interprets intent: "find substitutes for SKU-4421 (out of stock)"
└── Generates execution plan
↓
Parallel Sub-Agents (ADK multi-agent routing)
├── Vector Search Agent
│ └── Vertex AI ANN Index → top-150 candidates
├── Re-Ranker Agent
│ └── Vertex AI Ranking API → ranking_score per candidate
└── Substitution Agent
└── Similarity scoring + price band matching
↓
Feature Store Enrichment (real-time, <2ms)
├── inventory_status (in_stock | low | out_of_stock)
├── current_price, margin_tier
└── click_through_rate (last 7 days)
↓
Gemini Strategy Layer
├── Ranks final candidates by combined score
├── Generates merchandising narrative ("recommend SKU-7822 as primary substitute")
└── Outputs structured decision for catalog systemADK Multi-Agent Routing
Google ADK's multi-agent framework handles the parallel execution. Each sub-agent is a separate ADK agent with its own tools and system prompt. The root planner decides which agents to invoke and in what order:
1from google.adk.agents import Agent, LlmAgent
2from google.adk.tools import FunctionTool
3
4# Sub-agent: vector search specialist
5vector_search_agent = LlmAgent(
6 name="vector_search_agent",
7 model="gemini-2.5-flash",
8 instruction="""You are a product similarity specialist.
9 Given a product query, find the 150 most semantically similar
10 products using the Bringo vector index.""",
11 tools=[
12 FunctionTool(search_product_index),
13 FunctionTool(get_product_metadata),
14 ],
15)
16
17# Sub-agent: re-ranking specialist
18reranker_agent = LlmAgent(
19 name="reranker_agent",
20 model="gemini-2.5-flash",
21 instruction="""You are a search relevance specialist.
22 Re-rank the provided candidates using the Vertex AI Ranking API,
23 integrating real-time Feature Store signals.""",
24 tools=[
25 FunctionTool(rerank_with_ranking_api),
26 FunctionTool(get_feature_store_signals),
27 ],
28)
29
30# Root planner orchestrates everything
31planner = LlmAgent(
32 name="senior_merchandiser",
33 model="gemini-2.5-flash",
34 instruction=MERCHANDISER_SYSTEM_PROMPT,
35 sub_agents=[vector_search_agent, reranker_agent, substitution_agent],
36)Feature Store Real-Time Lookup
The Feature Store provides real-time signals that change by the hour: inventory levels, price changes, click-through rates. These signals update the ranking so a product that just went out of stock drops in priority automatically — no manual intervention.
1from google.cloud.aiplatform_v1beta1 import FeaturestoreOnlineServingServiceClient
2
3serving_client = FeaturestoreOnlineServingServiceClient(
4 client_options={"api_endpoint": f"{REGION}-aiplatform.googleapis.com"}
5)
6
7def get_feature_store_signals(product_ids: list[str]) -> dict:
8 """Batch real-time lookup — <2ms for up to 100 products."""
9 request = {
10 "entity_type": ENTITY_TYPE_PATH,
11 "entity_ids": product_ids,
12 "feature_selector": {
13 "id_matcher": {"ids": ["inventory_status", "current_price", "ctr_7d"]}
14 },
15 }
16 response = serving_client.read_feature_values_batch(request)
17
18 signals = {}
19 for entity_view in response.entity_view:
20 signals[entity_view.entity_id] = {
21 feat.feature_id: feat.value.string_value or feat.value.double_value
22 for feat in entity_view.data
23 }
24 return signalsSubstitution Engine
When a product goes out of stock, the substitution agent finds replacements by combining embedding similarity with business rules: the substitute must be in the same price band (within 15%), same category, and available in stock. The ranking_score from the Ranking API serves as the primary sort key.
1def find_substitutes(
2 oos_product: dict,
3 candidates: list[dict],
4 feature_signals: dict,
5) -> list[dict]:
6 """Score and rank substitution candidates."""
7 scored = []
8 for c in candidates:
9 if c["product_id"] == oos_product["product_id"]:
10 continue # Skip the OOS product itself
11
12 signals = feature_signals.get(c["product_id"], {})
13
14 # Exclude OOS candidates
15 if signals.get("inventory_status") == "out_of_stock":
16 continue
17
18 # Price band filter: within 15% of original price
19 price_ratio = c["price"] / oos_product["price"]
20 if not (0.85 <= price_ratio <= 1.15):
21 continue
22
23 # Combined score: ranking API score + inventory bonus
24 inventory_bonus = 0.15 if signals.get("inventory_status") == "in_stock" else 0
25 combined_score = c["ranking_score"] + inventory_bonus
26
27 scored.append({**c, "substitution_score": combined_score})
28
29 return sorted(scored, key=lambda x: x["substitution_score"], reverse=True)[:10]Business Impact
- Search precision improvement
- +15–25%
- Feature Store real-time lookup
- <2ms
- Manual catalog curation decisions
- 0
- Stock-out events trigger auto-substitution
- 100%