All posts Building an Agentic RAG Shopping Assistant for BotaniCart

Building an Agentic RAG Shopping Assistant for BotaniCart

Vinodhariharan Ravi
Vinodhariharan Ravi
· May 22, 2026 · 9 min read · 30 views

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


Image

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!

Found this useful? Share it.