In digital wallets, the cold-start problem isn't just a UX inconvenience—it's a regulatory and business risk. A new user who receives irrelevant or non-compliant financial product recommendations during onboarding may never activate their account. Traditional collaborative filtering fails entirely when behavioral history is sparse or nonexistent. Yet most enterprise RAG implementations treat all users identically, retrieving from the same vector store with the same strategy regardless of data maturity. This article demonstrates how to build an Adaptive Recommendation Engine that dynamically switches retrieval strategies based on real-time user maturity assessment, using LangGraph state machines, ChromaDB multi-collection architecture, and specialized cold-start agents.
The Real-Time Use Case: New User Activation Flow
Scenario: A digital wallet acquires 50K new users monthly. During the first 72 hours post-KYC, users ask questions like "What should I do with my first deposit?" or "Is this app safe for savings?" Behavioral signals are near-zero: no transaction history, no product clicks, no risk assessment completion.
Why Standard RAG Fails:
Semantic search on "savings" returns high-yield products inappropriate for unverified KYC levels.
No user profile embedding exists to personalize ranking.
Generic responses erode trust during the critical activation window.
The Solution: A User Maturity Classifier Agent that routes queries through fundamentally different retrieval pipelines—demographic inference, knowledge-graph-guided exploration, or full semantic RAG—based on real-time signal availability.

Part 1: The Cold-Start Taxonomy & Strategy Matrix
Cold-start isn't binary. We model four maturity states, each with a distinct retrieval strategy:
| Maturity State | Signal Availability | Retrieval Strategy | Primary Data Source |
|---|---|---|---|
| True Cold | Zero behavior, incomplete KYC | Demographic + Regulatory Default | Pre-approved onboarding content collection |
| Warm-Cold | KYC complete, <3 transactions | Inferred Profile + Exploratory | Demographic embeddings + product knowledge graph |
| Warming | 3-20 transactions, partial preferences | Hybrid Semantic + Behavioral Boost | User profile collection + product catalog |
| Mature | Rich history, explicit preferences | Full Personalized RAG | User preference vectors + full product catalog |
Key Insight: Cold-start isn't solved by better embeddings. It's solved by knowing when NOT to use embeddings and falling back to structured, regulator-safe defaults.
Part 2: Multi-Collection ChromaDB Architecture
import chromadb
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
client = chromadb.PersistentClient(path="./chroma_wallet")
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# Collection 1: Full product catalog (mature users)
product_store = Chroma(client=client, collection_name="financial_products",
embedding_function=embeddings)
# Collection 2: User profiles (warming/mature users)
user_profile_store = Chroma(client=client, collection_name="user_profiles",
embedding_function=embeddings)
# Collection 3: Onboarding & regulatory defaults (cold users)
# NEVER contains personalized recommendations—only pre-approved educational content
onboarding_store = Chroma(client=client, collection_name="onboarding_safe_content",
embedding_function=embeddings)
# Collection 4: Demographic cohort prototypes (warm-cold users)
# Pre-computed embeddings for "young_professional_PH", "retiree_SG", etc.
cohort_store = Chroma(client=client, collection_name="demographic_cohorts",
embedding_function=embeddings)Part 3: Adaptive State Schema
The state carries both the user's current maturity assessment AND the strategy-specific retrieval results.
from typing import Annotated, List, Dict, Optional, Literal
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
from langchain_core.documents import Document
MaturityLevel = Literal["true_cold", "warm_cold", "warming", "mature"]
class WalletColdStartState(TypedDict):
messages: Annotated[List, add_messages]
user_id: str
# === MATURITY ASSESSMENT ===
maturity_level: MaturityLevel
signal_inventory: Dict[str, bool] # {"has_kyc": True, "has_transactions": False, ...}
inferred_demographic: Optional[str] # e.g., "young_professional_PH"
# === STRATEGY-SPECIFIC RETRIEVAL ===
retrieved_docs: List[Document]
retrieval_strategy_used: str
# === OUTPUT ===
ranked_recommendations: List[Dict]
confidence_score: float # Lower for cold-start; surfaced to UI
evaluation_reasoning: str
# Orchestration
agent_trace: List[str]Part 4: Multi-Agent Implementation
Agent A: User Maturity Classifier
This agent runs FIRST and determines the entire downstream pipeline. It examines structured metadata, NOT embeddings.
from langchain_openai import ChatOpenAI
from datetime import datetime, timedelta
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
def maturity_classifier_node(state: WalletColdStartState):
"""Assess user maturity from structured signals. NO LLM needed for basic classification."""
# In production, fetch from your user service / DB
user_meta = get_user_metadata(state["user_id"])
signals = {
"has_kyc": user_meta.get("kyc_status") == "approved",
"has_risk_assessment": user_meta.get("risk_assessment_completed", False),
"transaction_count": user_meta.get("transaction_count_30d", 0),
"days_since_signup": (datetime.utcnow() - user_meta["signup_date"]).days,
"has_explicit_preferences": len(user_meta.get("stated_goals", [])) > 0,
"has_behavioral_history": user_meta.get("click_history_count", 0) > 5
}
# Deterministic classification—no hallucination risk
if not signals["has_kyc"]:
level = "true_cold"
elif signals["has_kyc"] and signals["transaction_count"] < 3:
level = "warm_cold"
elif signals["transaction_count"] < 20 and not signals["has_explicit_preferences"]:
level = "warming"
else:
level = "mature"
# For warm-cold: infer demographic from KYC data (age, location, occupation)
inferred_demo = None
if level == "warm_cold":
inferred_demo = infer_demographic_cohort(user_meta) # Rule-based or lightweight model
return {
"maturity_level": level,
"signal_inventory": signals,
"inferred_demographic": inferred_demo,
"agent_trace": [f"classifier: maturity={level}, signals={signals}"]
}Agent B: Strategy-Specific Retrieval Router
A single node that dispatches to the correct retrieval strategy based on maturity level.
def adaptive_retriever_node(state: WalletColdStartState):
query = state["messages"][-1].content
level = state["maturity_level"]
if level == "true_cold":
# STRATEGY: Safe onboarding content only. No product recommendations.
docs = onboarding_store.similarity_search(query, k=3)
strategy = "onboarding_safe_defaults"
elif level == "warm_cold":
# STRATEGY: Cohort-based retrieval + exploratory product suggestions
# Find closest demographic cohort prototype
cohort_docs = cohort_store.similarity_search(
state["inferred_demographic"] or "general_new_user", k=1
)
# Then retrieve products appropriate for inferred cohort's risk profile
cohort_risk = cohort_docs[0].metadata.get("typical_risk_tolerance", "conservative")
docs = product_store.similarity_search(
query, k=8,
filter={"risk_rating": {"$in": ["low", "medium"]},
"eligible_kyc_levels": {"$in": ["basic", "full"]}}
)
# Prepend cohort context so synthesizer understands the inference
docs = cohort_docs + docs
strategy = "cohort_inferred_exploratory"
elif level == "warming":
# STRATEGY: Hybrid—semantic search boosted by emerging behavioral signals
docs = product_store.similarity_search(query, k=10)
# TODO: Re-rank by early behavioral affinity (click dwell time, saves)
strategy = "hybrid_semantic_behavioral"
else: # mature
# STRATEGY: Full personalized RAG
user_docs = user_profile_store.get(ids=[state["user_id"]], include=["documents"])
docs = product_store.similarity_search(query, k=15)
strategy = "full_personalized_rag"
return {
"retrieved_docs": docs,
"retrieval_strategy_used": strategy,
"agent_trace": state["agent_trace"] + [f"retriever: strategy={strategy}, docs={len(docs)}"]
}Agent C: Confidence-Aware Synthesizer
Cold-start responses MUST communicate uncertainty. This agent adjusts tone and disclosure based on maturity.
from langchain_core.prompts import ChatPromptTemplate
synth_prompt = ChatPromptTemplate.from_messages([
("system", """You are a Digital Wallet Financial Advisor.
USER MATURITY: {maturity_level}
RETRIEVAL STRATEGY: {strategy}
SIGNAL GAPS: {missing_signals}
RESPONSE RULES BY MATURITY:
- true_cold: ONLY provide educational/onboarding content. NEVER recommend specific products.
End with: "Complete your profile to get personalized suggestions."
- warm_cold: Frame recommendations as "exploratory options based on similar users."
Always disclose: "These are general suggestions. Your preferences will refine results over time."
- warming: Normal recommendations but note areas where more data would improve accuracy.
- mature: Full confident recommendations with standard disclaimers.
Retrieved Content: {docs}"""),
("human", "{query}")
])
def confidence_synthesizer_node(state: WalletColdStartState):
missing = [k for k, v in state["signal_inventory"].items() if not v]
chain = synth_prompt | llm
response = chain.invoke({
"maturity_level": state["maturity_level"],
"strategy": state["retrieval_strategy_used"],
"missing_signals": ", ".join(missing) if missing else "None",
"docs": "\n---\n".join(d.page_content for d in state["retrieved_docs"]),
"query": state["messages"][-1].content
})
# Confidence score reflects maturity + retrieval quality
base_confidence = {"true_cold": 0.3, "warm_cold": 0.5, "warming": 0.7, "mature": 0.9}
confidence = base_confidence[state["maturity_level"]]
return {
"ranked_recommendations": [{"content": response.content}],
"confidence_score": confidence,
"evaluation_reasoning": f"Maturity={state['maturity_level']}, Strategy={state['retrieval_strategy_used']}",
"messages": [("assistant", response.content)]
}Step 5: Compile the Adaptive Graph
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.postgres import PostgresSaver
workflow = StateGraph(WalletColdStartState)
workflow.add_node("classify_maturity", maturity_classifier_node)
workflow.add_node("adaptive_retrieve", adaptive_retriever_node)
workflow.add_node("compliance_gate", compliance_validator_node) # Same as previous article
workflow.add_node("synthesize", confidence_synthesizer_node)
workflow.add_edge(START, "classify_maturity")
workflow.add_edge("classify_maturity", "adaptive_retrieve")
workflow.add_edge("adaptive_retrieve", "compliance_gate")
# Compliance gate still applies—but fallback differs by maturity
def post_compliance_route(state: WalletColdStartState):
if state.get("compliance_passed", True):
return "synthesize"
# Cold users get onboarding redirect; mature users get safe fallback
if state["maturity_level"] in ("true_cold", "warm_cold"):
return "synthesize" # Synthesizer already handles safe framing
return "safe_fallback"
workflow.add_conditional_edges("compliance_gate", post_compliance_route)
workflow.add_edge("synthesize", END)
app = workflow.compile(checkpointer=PostgresSaver.from_conn_string("postgresql://..."))Part 5: Measuring Cold-Start Quality Beyond CTR
Cold-start requires different metrics per maturity stage:
| Maturity Stage | Primary Metric | Target | Why Not CTR |
|---|---|---|---|
| True Cold | Onboarding Completion Rate | >80% | Clicks on products before KYC = regulatory risk |
| Warm-Cold | Profile Completion Rate + First Transaction Rate | >60% / >40% | Exploration clicks without conversion = failed inference |
| Warming | Preference Calibration Score | Correlation(ranked_position, engagement) > 0.5 | Need to validate behavioral signals are improving relevance |
| Mature | Conversion Rate × LTV | Business target | Standard metric applies |
Implementation: Tag every recommendation event with maturity_level and retrieval_strategy_used in your analytics pipeline. Run separate funnel analyses per cohort.
Production Hardening for Cold-Start
Demographic Inference Guardrails: Never infer sensitive attributes (race, religion). Use only regulator-permitted signals (age bracket, jurisdiction, declared occupation). Log inference basis for audit.
Onboarding Content Versioning: The
onboarding_safe_contentcollection must be versioned and approved by compliance. Includeapproved_dateandexpiry_datemetadata. Auto-expire stale content.Maturity Transition Triggers: When a user crosses from
warm_cold→warming, trigger an async job to generate their first user profile embedding from accumulated signals. Don't wait for the next query.Confidence Score Surfacing: Expose
confidence_scoreto the frontend. Render low-confidence responses with visual cues ("Based on limited info...") to manage expectations and build trust.Cold-Start A/B Testing: Test cohort inference strategies against pure onboarding defaults. Measure activation rate, not engagement. Some cohorts may perform worse than generic content—kill them fast.
Conclusion
Cold-start in fintech isn't a retrieval problem—it's a state awareness problem. By modeling user maturity as a first-class state variable, maintaining separate retrieval collections per maturity stage, and routing through adaptive agents that respect signal boundaries, you transform cold-start from a failure mode into a guided onboarding experience. The key architectural insight: your graph should know what it doesn't know. When signals are absent, the system shouldn't hallucinate personalization—it should gracefully degrade to safe, compliant, and explicitly transparent guidance. That transparency is what converts cold users into mature, trusting customers.

Join the conversation! Your thoughts help the community grow.