Building an Agentic RAG Shopping Assistant for BotaniCart
A deep dive into how I built a 5-tool LangChain agent for BotaniCart that intelligently routes between ChromaDB semantic search and live Firestore queries — because keyword search is terrible for plants, and intent-based queries need a smarter approach.
Building an Agentic RAG Shopping Assistant for BotaniCart
April 2026 · Personal Project

I've been building BotaniCart — a plant e-commerce platform — for a while now. The storefront was working fine: Firebase Auth, Firestore for products, React frontend, the usual stack. But I kept running into the same problem every time I thought about adding a chat assistant.
The problem wasn't building a chatbot. The problem was that keyword search is terrible for plants.
When someone types "I need something low maintenance for my shaded Chennai apartment" into a
search bar, they get nothing useful. There's no product field called
low_maintenance_for_shaded_chennai_apartment. The query has intent, context, and implied
constraints — none of which map cleanly onto Firestore filters.
Worse, the plants that would answer that query — a Snake Plant, a Pothos, a ZZ Plant — are
stored in Firestore as structured documents with fields like sunlight: "indirect" and
details.maintenance: "Requires minimal care; water sparingly...". The semantic gap between the
user's natural language and those field values is enormous. A .where() filter will never bridge
it.
That's what got me thinking about RAG.
The Idea
I wanted a chat agent that could handle two fundamentally different types of questions:
Type 1 — Live inventory queries
"Show me plants under ₹500" "Are succulents in stock?"
These need real-time data. Price changes. Stock runs out. A vector store snapshot from last week is worse than useless here — it gives the user stale confidence. This has to hit Firestore directly, every time.
Type 2 — Knowledge queries
"What plant works for a forgetful person?" "Why are my Monstera's leaves turning yellow?" "What's safe if I have cats?"
These need semantic understanding. The answer isn't in a Firestore document — it's in the relationship between the question's intent and a body of plant knowledge that doesn't change week to week. This is exactly what a vector store is built for.
The insight was simple: these are two different problems that need two different tools. The agent's job is to decide which one to call, not to smash both problems into the same solution.
The Architecture
I built the agent on LangChain's create_tool_calling_agent with Gemini 2.5 Flash. The agent
has 5 tools:
1. plant_knowledge_search → ChromaDB (semantic RAG)
2. pet_safe_plant_search → ChromaDB (filtered, pet_safe=True)
3. search_products → Firestore (live inventory)
4. get_care_guides → Firestore (care guides collection)
5. get_categories → Firestore (category listing)Why create_tool_calling_agent and not create_react_agent?
ReAct agents work by prompting the model to output a rigid text format:
Thought: I need to search for products
Action: search_products
Action Input: "plants under ₹500"
Observation: [results]The problem is that Gemini Flash frequently ignores this format and jumps straight to a "Final Answer" — especially on simpler queries where it "feels" confident answering from memory. When that happens, zero tools get called, zero real data gets retrieved, and the response is pure hallucination dressed up as helpfulness.
create_tool_calling_agent bypasses this entirely. Instead of parsing free-text output, it
uses Gemini's native function-calling API — the model returns a structured JSON call object
directly, and LangChain invokes the tool from that. No format, no parsing, no brittle
string matching. The model either calls a tool or it doesn't, and you can enforce the former
through the system prompt.
The routing itself happens entirely through the tool descriptions. Gemini reads them to decide what to call — and being explicit about what each tool is NOT for improved routing more than anything else:
Tool(
name="search_products",
description=(
"Search BotaniCart's live product catalog in Firestore. Use this for live "
"price, stock availability, filtering by category, type, or price range. "
"Returns real-time product data with current prices and stock. "
"Do NOT use for general plant knowledge, care tips, or suitability questions."
),
...
)That last sentence — "Do NOT use for..." — does a surprising amount of work. Without it, the
model would route vague queries like "best plant for a dark room" to search_products, which
returns Firestore documents sorted by price rather than by suitability. With it, those queries
correctly go to plant_knowledge_search.
The RAG Pipeline
Embedding model: Google's models/gemini-embedding-001
Vector store: ChromaDB (local, persistent)
Total documents: 148
Three document types
1. Product blobs (105 documents)
Don't embed raw JSON fields. Build rich prose per product instead:
# Bad — what raw Firestore gives you
{"title": "Snake Plant", "subCategory": "Low Light", "price": 349}
# Good — what you feed the embedder
"""
Snake Plant (Sansevieria trifasciata) — ₹349. Category: Indoor Plants > Low Light.
A near-indestructible houseplant that thrives in indirect light and tolerates
neglect well. Ideal for beginners or offices with no windows. Non-toxic to humans,
mildly toxic to pets. Watering: once every 2-3 weeks. Air purifying.
"""Embedding quality depends entirely on the richness of the text. A 6-word product description embeds as a near-zero-information vector. A paragraph embeds the plant's character.
2. Care guide chunks (33 documents)
Three markdown guides — watering, troubleshooting, beginner/low-light — split at ## and ###
headings using LangChain's MarkdownHeaderTextSplitter. This keeps semantically related content
together rather than splitting mid-sentence at a fixed character count.
3. FAQ pairs (10 documents)
Hand-written question-answer pairs covering the most common queries: forgetful watering, pet safety, Chennai low-light conditions, yellow leaves, Monstera care. These act as high-precision anchors — when a user query matches one closely, retrieval is almost always correct.
The rate-limit problem
The free tier for Gemini embeddings is 100 requests per minute. 148 documents at one request each would hit that wall around document 100 and fail mid-build, corrupting the vector store.
The fix: batch into groups of 80, sleep 65 seconds between batches.
BATCH_SIZE = 80
DELAY_SECONDS = 65
# First batch — creates the store
vectorstore = Chroma.from_documents(documents[:BATCH_SIZE], embedding_fn, ...)
# Remaining batches — appends
for i in range(BATCH_SIZE, len(documents), BATCH_SIZE):
batch = documents[i:i + BATCH_SIZE]
time.sleep(DELAY_SECONDS)
vectorstore.add_documents(batch)Not elegant, but it works and it's a one-time cost. ChromaDB auto-persists to disk, so the 65-second pause only happens on the first build.
How ChromaDB stores and retrieves vectors
ChromaDB uses HNSW (Hierarchical Navigable Small World) as its index structure. Think of it
as a layered graph where each node (document) is connected to its nearest neighbors at multiple
levels of granularity. At query time, the algorithm enters the graph at a high level, narrows
toward the target region, then searches precisely at the bottom layer — finding approximate
nearest neighbors in O(log n) time rather than brute-forcing all 148 vectors.
The actual data lives in a local SQLite file (metadata + document text) plus binary .bin files
(the raw embedding vectors). The HNSW graph structure is also serialized to disk. All of it
lives in a chroma_db/ directory — nothing leaves your machine, no cloud calls at query time.
What I Learned
The dataset is everything.
The BotaniCart frontend uses a dataset.json that was originally a UI mock — product names,
prices, and 6-word descriptions like "Beautiful flowering plant for indoors." Feeding that into
an embedder produces vectors that are almost meaningless. I had to fetch the real Firestore
documents, enrich each one into a prose paragraph, and rebuild from scratch. Garbage in, garbage
out applies with brutal precision to RAG.
Tool descriptions are prompt engineering.
I spent more time rewriting tool descriptions than any other part of the system. The first version
had descriptions like "Search products in Firestore" — vague enough that the model used
search_products for everything, including care queries it should have routed to RAG. Adding
explicit inclusion criteria ("use for price filters, stock queries") and explicit exclusion
criteria ("do NOT use for general plant knowledge") improved routing accuracy more than any
model change.
The Firestore filter mismatch was silent and total.
The original _parse_query() function mapped user keywords to category names like
"Succulents & Cacti" and "Tropical Plants" — strings that looked right but didn't match
anything in the actual database. Every .where('category', '==', ...) filter returned zero
results silently. Firestore doesn't throw an error when a filter matches nothing; it just returns
an empty stream. The agent would get back [], fail to find products, and skip tool use entirely
on retry — making it look like a model failure when it was actually a data mapping bug.
The fix: fetch your actual data first, check the real field values, then write the mappings. Obvious in retrospect. Invisible without the dataset in front of you.
Eval logging from day one.
Every interaction writes to a RAGAS-ready JSONL file:
{
"timestamp": "2026-04-26T10:23:11",
"session_id": "test-1745123456",
"query": "What plant is good for a dark apartment?",
"response": "Based on your low-light conditions...",
"tools_used": ["plant_knowledge_search"],
"latency_ms": 1842
}The /admin/tools-breakdown endpoint reads the log and shows the exact tool split across all
sessions. Building this early meant I could immediately see that 0 out of 5 test queries used
any tools — which pointed directly at the Firestore mapping bug and the ReAct routing failure.
Without it I'd have been debugging blind.
Where This Stands
This is far from a finished product. The routing logic is solid for the cases I've tested, but I haven't stress-tested it against ambiguous or adversarial queries — the kind real users throw at a system without thinking twice. Things like "my plant is dying" (no plant named, no category implied) or "what's popular right now" (intent unclear, could route to Firestore trends or RAG knowledge). The eval pipeline is set up but hasn't run at scale, so I don't have hard numbers on retrieval quality yet — only anecdotal spot-checks.
There's also no graceful degradation when ChromaDB returns weak matches. If the top-k results have low cosine similarity to the query, the agent still surfaces them — it has no signal that the retrieval was poor. A confidence threshold with a fallback ("I don't have good information on that, but here are related products...") would make the failure mode much less jarring.
In short: the architecture feels right, but "feels right" isn't the same as "proven." The next phase is less about adding features and more about breaking what exists and seeing where it cracks.
I'd genuinely love feedback on the two-tool routing approach — whether splitting "live inventory" vs "knowledge" queries this way holds up as a general pattern for agentic commerce assistants, or if there's a smarter architecture I'm missing. If you've built something similar (or tried and hit walls), I'd love to hear about it in the comments or on LinkedIn.
LinkedIn post: [link] (will update)
Links: AI Agent · Storefront · Live Demo
Enjoyed this post?
Thanks for reacting!
Read next
Back to all posts
I Built a Desktop App to Watch My Own Security Camera Footage
How I reverse-engineered the Godrej Ace Pro camera format and built a free, open-source Electron app to browse, play, and export footage on my own terms.
My Cognizant GenC Next Internship — An Honest Account
A first-hand account of my Cognizant GenC Next internship from January to May 2026 — what the program looks like, what a day actually feels like, and what I'd tell anyone heading into it.
Inside Cognizant's Digital Nurture Hiring — A 2025-26 Intern's Perspective
Everything a CSE student needs to know about Cognizant's Digital Nurture program — from aptitude rounds to final interviews, straight from a 2025-26 intern.