teachyou.ai academy
← All posts
RAG

RAG for E-Commerce: Product Search That Understands Intent

Pramod Dutta · May 12, 2026 · 14 min read

Type "waterproof jacket for toddlers under 2000 rupees" into most e-commerce search bars and watch it fall apart. You get raincoats for adults, jackets priced at 8000, and maybe one relevant hit buried on page three. Classic keyword search treats that query as five disconnected tokens to match against a product title. It has no idea "waterproof" is a material property, "toddlers" is an age constraint, and "under 2000" is a hard price filter. This is the single biggest reason shoppers abandon site search and go straight to Amazon or Google instead. Retrieval-Augmented Generation flips this problem on its head — instead of matching strings, it retrieves products based on meaning and lets a language model reason about what the shopper actually wants. This article walks through how RAG product search actually works, why it beats both plain keyword search and plain vector search, and how to build one yourself, with real code you can adapt today.

Why Keyword Search Fails Shoppers

Traditional e-commerce search stacks (Elasticsearch, Solr, Algolia's basic tier) are built on inverted indexes. They tokenize a query, look up which documents contain those tokens, and rank by term frequency, sometimes with synonyms bolted on. This works fine when the shopper types exactly the words that appear in your catalog. It falls apart the moment intent gets complex.

Consider these real query patterns from any mid-size online store:

  • "something to wear to a beach wedding" — no product ever has "beach wedding" in its title, but a linen shirt or a floral sundress is exactly right.
  • "gift for a coffee-obsessed dad" — this requires understanding "gift," "coffee," and inferring a category (mugs, grinders, subscriptions) plus a persona.
  • "quiet blender for apartment" — "quiet" rarely appears as a searchable attribute even though it's a top filter for many buyers.
  • "laptop bag not too bulky" — negation ("not too bulky") is something keyword search cannot represent at all.

Merchandisers try to patch this with synonym dictionaries and manual curation, but that approach doesn't scale past a few hundred SKUs, and it never captures compositional intent — multiple constraints stacked in one sentence. This is exactly the gap RAG closes.

What RAG Actually Adds to Search

RAG in the classic sense pairs a retriever with a generator: you embed a query, fetch semantically similar chunks from a vector store, then feed those chunks to an LLM as context so it can produce a grounded answer. If you've gone through Introduction to RAG, you already know this pipeline for question-answering over documents. Product search is the same architecture pointed at a different kind of document — the product catalog.

The important shift is that RAG for product search rarely stops at "retrieve and generate a paragraph." Instead it's used for two distinct jobs that often run together:

  1. Semantic retrieval — turning "waterproof jacket for toddlers under 2000 rupees" into a vector, searching product embeddings, and getting back candidates that are conceptually close even if no keyword overlaps.
  2. Structured intent extraction — using the LLM to pull out filters (category, price ceiling, age group, material) so those constraints get applied as hard filters or re-ranking signals, not just fuzzy similarity.

The generation step, in this context, isn't writing prose about the products — it's synthesizing a response ("Here are 3 waterproof jackets under 2000 rupees for toddlers, plus 2 close alternatives slightly above budget") or driving downstream UI like filter chips and a "why this matched" explanation.

Architecture: The Three-Layer Pipeline

A production-grade RAG product search system has three layers that map cleanly onto standard RAG concepts, adapted for catalogs.

Layer 1 — Ingestion and embedding. Every product needs a rich text representation before it can be embedded meaningfully. Concatenating just the title is not enough. You want title, category path, key attributes, materials, and even normalized review themes ("runs small," "great for gym") folded into one embeddable document per SKU or per SKU-variant.

Layer 2 — Hybrid retrieval. Pure vector search on product embeddings tends to drift — it will happily return a "raincoat for adults" as similar to "waterproof jacket for toddlers" because the embedding space clusters on "waterproof jacket" more strongly than on the toddler constraint. The fix is hybrid retrieval: combine vector similarity with metadata filters (age_group=toddler, price<=2000) and often a keyword/BM25 signal too, then blend the scores.

Layer 3 — LLM reasoning and re-ranking. The retrieved candidate set (say, top 30) gets handed to an LLM along with the parsed intent. The LLM's job here is narrow and specific: rank candidates by true fit, explain mismatches, and decide whether to relax a constraint ("no exact match under 2000, but here are 2 within 15% of budget"). This is a classic RAG generation step, just constrained to structured output instead of free text.

Here's what that pipeline looks like end-to-end in code, using a common stack (an embeddings API, a vector database, and an LLM call for intent parsing and re-ranking):

import json
from dataclasses import dataclass

@dataclass
class Product:
    sku: str
    title: str
    category: str
    price: float
    attributes: dict  # e.g. {"age_group": "toddler", "material": "waterproof"}

def build_embedding_text(p: Product) -> str:
    """Flatten a product into rich text for embedding."""
    attr_str = ", ".join(f"{k}: {v}" for k, v in p.attributes.items())
    return f"{p.title}. Category: {p.category}. Attributes: {attr_str}."

def parse_intent(query: str, llm_client) -> dict:
    """Use the LLM to extract structured filters from a free-text query."""
    prompt = f"""
    Extract shopping intent from this query as JSON with keys:
    category, max_price, min_price, required_attributes (list),
    excluded_attributes (list), free_text_intent (string).

    Query: "{query}"
    Return only valid JSON, no explanation.
    """
    response = llm_client.complete(prompt, temperature=0)
    return json.loads(response)

def hybrid_search(query: str, intent: dict, vector_index, catalog):
    query_vector = vector_index.embed(query)

    # Vector similarity search, over-fetch to leave room for filtering
    candidates = vector_index.search(query_vector, top_k=50)

    # Apply hard metadata filters extracted by the LLM
    filtered = []
    for c in candidates:
        product = catalog[c.sku]
        if intent.get("max_price") and product.price > intent["max_price"]:
            continue
        if intent.get("category") and intent["category"].lower() not in product.category.lower():
            continue
        required = intent.get("required_attributes", [])
        if required and not all(
            attr.lower() in str(product.attributes).lower() for attr in required
        ):
            continue
        filtered.append((c.score, product))

    filtered.sort(key=lambda x: x[0], reverse=True)
    return [p for _, p in filtered[:20]]

Note the over-fetch pattern: pull 50 vector candidates, then narrow with hard filters, rather than filtering first and embedding second. Filtering before retrieval risks eliminating a great semantic match because a metadata field was missing or inconsistently tagged — a very common problem in real catalogs where attribute data is incomplete.

Chunking Strategy for Product Catalogs

Chunking is usually discussed in the context of long documents — splitting a PDF into 500-token windows. Product catalogs need a completely different chunking philosophy because a "chunk" here is a semantic unit, not a fixed-length text window.

A few patterns that work well in practice:

  • One embedding per SKU, not per variant. If a T-shirt comes in 8 colors and 5 sizes, don't create 40 embeddings. Embed the parent product once, and store variant-level metadata (color, size, stock) separately for filtering after retrieval.
  • Separate "structured" and "unstructured" chunks. Structured chunks hold attributes (brand, price, material) formatted consistently for reliable extraction. Unstructured chunks hold marketing copy and review summaries, which carry more semantic nuance ("perfect for humid climates") that a shopper's natural language query is more likely to match against.
  • Review-derived chunks as a separate index. Reviews often contain the real-world language shoppers use ("runs small," "battery dies fast," "great for beginners") that product descriptions never mention. Embedding a summarized version of review themes per product and searching that index in parallel with the catalog index catches queries like "a laptop that doesn't overheat," which no manufacturer description would ever phrase that way.
def chunk_product_for_index(p: Product, review_summary: str = None):
    """Produce multiple embeddable chunks per product, tagged by source."""
    chunks = []

    structured = build_embedding_text(p)
    chunks.append({"sku": p.sku, "source": "catalog", "text": structured})

    if review_summary:
        chunks.append({
            "sku": p.sku,
            "source": "reviews",
            "text": f"Customer feedback themes for {p.title}: {review_summary}"
        })

    return chunks

Keeping source as metadata lets you weight catalog-derived matches differently from review-derived matches during re-ranking — a review chunk might indicate strong fit even if it never mentions the query's literal keywords.

Handling Intent That Spans Multiple Constraints

The hardest queries in e-commerce search stack three or more constraints in a single sentence: category, a soft attribute, a hard filter, and sometimes a negation. "Running shoes, not too flashy, under 6000, good for wide feet" has four distinct constraints, and getting all four right simultaneously is where most naive vector search implementations quietly fail — they'll nail the "running shoes" part and silently drop the rest because cosine similarity averages across the whole query vector rather than satisfying each constraint independently.

The fix is to decompose the query before embedding anything, using the LLM purely as a constraint parser, then apply each constraint through the channel best suited to it:

  • Hard numeric constraints (price, size) → SQL-style filters, never vector similarity.
  • Categorical constraints (category, brand, color) → exact or fuzzy metadata match.
  • Soft/subjective constraints ("not too flashy," "good for wide feet") → vector similarity against the review or description embedding, since these rarely map to a clean attribute field.
  • Negations ("not too bulky") → either an excluded-attributes filter or a negative example passed to a re-ranking prompt.
def decompose_and_search(query: str, llm_client, vector_index, catalog):
    intent = parse_intent(query, llm_client)

    # Hard + categorical filters run first, cheaply, over full catalog
    hard_filtered = [
        p for p in catalog.values()
        if (not intent.get("max_price") or p.price <= intent["max_price"])
        and (not intent.get("category") or intent["category"].lower() in p.category.lower())
    ]

    # Soft constraints go through vector similarity on the reduced set
    soft_query_text = intent.get("free_text_intent", query)
    soft_vector = vector_index.embed(soft_query_text)

    scored = []
    for p in hard_filtered:
        product_vector = vector_index.get_vector(p.sku)
        score = vector_index.cosine_similarity(soft_vector, product_vector)
        scored.append((score, p))

    scored.sort(key=lambda x: x[0], reverse=True)
    return scored[:20]

Running hard filters first, then vector similarity on the reduced candidate pool, is both faster and more accurate than the reverse order — you're never diluting the semantic ranking with irrelevant items that happened to score high on embedding similarity alone.

Re-Ranking with an LLM: The Generation Step

Once you have 15-20 well-filtered candidates, the actual "generation" half of RAG earns its keep. This is where you pass the shopper's original query plus the candidate list to an LLM and ask it to do what a good human shop assistant would: rank by true relevance, and be honest about tradeoffs.

def rerank_with_llm(query: str, candidates: list, llm_client):
    candidate_summaries = "\n".join(
        f"- SKU {p.sku}: {p.title}, price {p.price}, attrs {p.attributes}"
        for p in candidates
    )

    prompt = f"""
    A shopper searched: "{query}"

    Candidate products:
    {candidate_summaries}

    Rank the top 5 by genuine fit to the query. For each, give a one-line
    reason. If nothing fits perfectly, say so and suggest the closest
    alternative with an honest caveat. Return JSON:
    {{"ranked": [{{"sku": "...", "reason": "..."}}], "caveat": "..."}}
    """
    return json.loads(llm_client.complete(prompt, temperature=0.2))

This step is also where you catch retrieval mistakes before the shopper sees them. If the vector search returned a men's raincoat despite a "toddler" constraint, the re-ranking prompt — which sees the full attribute dict, not just an embedding score — will typically demote or exclude it and say why. That "why this matched" reasoning is worth surfacing in the UI directly; shoppers trust search results more when they can see the logic ("matches: waterproof, under budget; note: only available in size 3T, not 2T").

Evaluating Whether Your RAG Search Actually Works

It's easy to ship a RAG search pipeline that looks impressive in a demo and then quietly underperforms your old keyword search on real traffic. Evaluation needs to happen at each layer, not just end-to-end.

  • Retrieval recall — for a labeled set of queries with known "correct" products, measure whether the correct product appears anywhere in the top-K candidates before re-ranking. If recall is low, the problem is embeddings or chunking, not the LLM.
  • Constraint satisfaction rate — for queries with hard constraints (price, category), measure what percentage of final results actually satisfy them. This catches bugs where filters silently get skipped.
  • Re-ranking precision — of the final top-5 shown to the user, what fraction would a human judge as genuinely relevant? This is where you catch the LLM being too generous or hallucinating a fit that isn't real.
  • Latency budget — RAG search adds an LLM call (intent parsing) plus often a second one (re-ranking) on top of vector search. Track p95 latency separately for each stage; a slow intent-parsing call can double your total response time even if retrieval itself is fast.
  • Zero-result rate — track how often the pipeline returns nothing after hard filtering. A rising zero-result rate usually means your catalog attribute coverage is incomplete, not that the RAG logic is wrong.

A practical habit: log the parsed intent JSON alongside every query in production. When a shopper complains a search "didn't work," the first thing to check is whether intent parsing extracted the right constraints — most failures trace back there, not to the vector search or the LLM re-ranking.

Cost and Latency Tradeoffs Worth Knowing Upfront

Every RAG product search adds at least one LLM call per query (intent parsing), and often a second (re-ranking). At real e-commerce traffic volumes this is not free, and it is not instant. A few practical mitigations:

  • Cache intent parsing for repeated or near-duplicate queries. Search logs are heavily skewed — a small number of query patterns account for a large share of traffic. Caching the parsed intent JSON for common queries (or query templates like "gift for [X]") avoids redundant LLM calls.
  • Use a smaller, faster model for intent parsing than for re-ranking. Extracting structured filters from a query is a much easier task than judging nuanced product fit, so a lighter model is usually sufficient there, keeping the more capable model for the step that actually needs judgment.
  • Skip the LLM re-ranking step for high-confidence, high-volume queries. If hard filters plus vector similarity already produce a clean, well-separated ranking (a large score gap between position 5 and position 6, for instance), you can skip the extra LLM call and serve directly — reserving the more expensive re-ranking pass for ambiguous or long-tail queries where it actually changes the outcome.
  • Pre-compute embeddings, not intent. Product embeddings are stable and can be batch-computed offline whenever the catalog updates. Query intent, by contrast, must be computed live — so don't accidentally push catalog-side work into the request path.

Common Pitfalls When Building This for Real

A few mistakes show up repeatedly in production RAG search builds, worth naming explicitly:

  • Embedding only the product title. Titles are often terse and keyword-stuffed for SEO, not descriptive of actual fit. Always enrich with category, attributes, and where possible normalized review language before embedding.
  • Treating vector similarity as a ranking signal on its own. As shown above, cosine similarity alone will happily surface off-target products that share surface-level vocabulary. It needs to be one input among several, not the final ranking.
  • Letting the LLM invent products or attributes. Always constrain the re-ranking prompt to choose only from the candidate list you provide, with attributes drawn directly from your catalog data — never let it free-generate a product description, or you risk hallucinated claims about stock, price, or specs reaching a shopper.
  • Ignoring stale embeddings. Prices, stock status, and even attributes change constantly in a live catalog. If your embedding index isn't refreshed in step with catalog updates, you'll retrieve products that are out of stock or filtered incorrectly on outdated price data. Metadata (price, stock) should generally live outside the embedding and be joined at query time, precisely so it can update independently of the (more expensive to recompute) embedding.
  • Skipping the fallback path. When hard filters eliminate everything, don't return an empty page. Relax constraints progressively (drop price ceiling first, then secondary attributes) and be transparent about what was relaxed, mirroring what a good in-store associate would do rather than a search engine that just gives up.

Where to Go From Here

RAG-powered product search is not a single trick — it's a composition of standard RAG components (embedding, retrieval, generation) applied deliberately to the specific shape of catalog data: structured attributes, volatile pricing and stock, and shopper queries that stack multiple constraints in one breath. Get the fundamentals of chunking, hybrid retrieval, and grounded generation right, and the e-commerce-specific patterns above — intent decomposition, hard-filter-first hybrid search, constrained re-ranking, and honest fallback behavior — become straightforward extensions rather than a separate discipline to learn from scratch.

If the underlying architecture here — embeddings, vector stores, retrieval, and grounded generation — was new to you, that's exactly the ground covered in our Introduction to RAG course, and it's the right starting point before tackling a production e-commerce search build like the one outlined here.