n4nAI

E-commerce search: filtering and reranking in LlamaIndex

Build production e-commerce search with LlamaIndex: metadata filtering, hybrid retrieval, and cross-encoder reranking for relevant product results.

n4n Team3 min read608 words

Audio narration

Coming soon — every post will get a voice note here.

E-commerce search filtering reranking llamaindex implementations fail when they treat product catalogs like generic document collections. You need structured metadata filters for attributes like price, category, and availability — plus a reranker that understands product semantics, not just text similarity. This tutorial builds a complete pipeline from raw product data to a query engine that handles “women’s running shoes under $100 in stock” correctly.

Prerequisites

  • Python 3.10+
  • An OpenAI API key (for embeddings and GPT-4o-mini) or access to an OpenAI-compatible endpoint
  • A vector store — we’ll use Qdrant locally via Docker, but any LlamaIndex-supported store works
  • ~500MB RAM for the cross-encoder model

Install dependencies:

pip install llama-index llama-index-vector-stores-qdrant llama-index-postprocessor-cohere-rerank \
    llama-index-llms-openai llama-index-embeddings-openai qdrant-client pandas tqdm

Start Qdrant:

docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant

Data model: products as structured nodes

LlamaIndex works best when each product is a TextNode with rich metadata. Don’t stuff everything into the text field — keep filterable attributes as typed metadata.

# data_models.py
from dataclasses import dataclass, asdict
from typing import Optional
from llama_index.core.schema import TextNode

@dataclass
class Product:
    id: str
    title: str
    category: str           # "women's footwear", "men's apparel"
    subcategory: str        # "running shoes", "t-shirts"
    brand: str
    price: float
    currency: str = "USD"
    in_stock: bool = True
    sizes: list[str] = None
    colors: list[str] = None
    tags: list[str] = None
    rating: float = 0.0
    review_count: int = 0

    def to_node(self) -> TextNode:
        # Text for embedding: title + description + searchable tags
        text_parts = [self.title, self.description]
        if self.tags:
            text_parts.append(" ".join(self.tags))
        text = " | ".join(text_parts)

        metadata = {
            "product_id": self.id,
            "title": self.title,
            "category": self.category,
            "subcategory": self.subcategory,
            "brand": self.brand,
            "price": self.price,
            "currency": self.currency,
            "in_stock": self.in_stock,
            "sizes": self.sizes or [],
            "colors": self.colors or [],
            "rating": self.rating,
            "review_count": self.review_count,
        }
        # LlamaIndex requires string metadata values for filtering
        str_metadata = {k: (str(v) if not isinstance(v, (str, int, float, bool)) else v) 
                        for k, v in metadata.items()}
        
        return TextNode(text=text, metadata=str_metadata, id_=self.id)

Generate sample data so the tutorial runs end-to-end:

# generate_data.py
import json
from data_models import Product

SAMPLE_PRODUCTS = [
    Product(
        id="sku_001",
        title="Nike Air Zoom Pegasus 40",
        description="Lightweight running shoe with responsive Zoom Air cushioning. Breathable mesh upper. 10mm drop.",
        category="women's footwear",
        subcategory="running shoes",
        brand="Nike",
        price=129.99,
        in_stock=True,
        sizes=["7", "7.5", "8", "8.5", "9", "9.5", "10"],
        colors=["black/white", "blue/orange", "pink/white"],
        tags=["neutral", "daily trainer", "marathon", "road"],
        rating=4.6,
        review_count=2847,
    ),
    Product(
        id="sku_002",
        title="Brooks Ghost 16",
        description="Soft, smooth ride with DNA LOFT v2 cushioning. Engineered mesh upper. 12mm drop. Great for high mileage.",
        category="women's footwear",
        subcategory="running shoes",
        brand="Brooks",
        price=139.99,
        in_stock=True,
        sizes=["6.5", "7", "7.5", "8", "8.5", "9", "9.5", "10"],
        colors=["black/ebony", "blue/green", "purple/pink"],
        tags=["neutral", "cushioned", "high mileage", "road"],
        rating=4.7,
        review_count=1923,
    ),
    Product(
        id="sku_003",
        title="Hoka Clifton 9",
        description="Lightweight foam with early-stage Meta-Rocker. Compression-molded EVA. 5mm drop. Max cushion, minimal weight.",
        category="women's footwear",
        subcategory="running shoes",
        brand="Hoka",
        price=144.99,
        in_stock=False,  # out of stock
        sizes=["7", "7.5", "8", "8.5", "9"],
        colors=["white/white", "black/black", "blue/coral"],
        tags=["maximalist", "cushioned", "recovery", "road"],
        rating=4.5,
        review_count=1102,
    ),
    Product(
        id="sku_004",
        title="Saucony Ride 17",
        description="PWRRUN+ cushioning with responsive feel. FORMFIT adaptive upper. 8mm drop. Versatile daily trainer.",
        category="women's footwear",
        subcategory="running shoes",
        brand="Saucony",
        price=99.99,
        in_stock=True,
        sizes=["6.5", "7", "7.5", "8", "8.5", "9", "9.5", "10"],
        colors=["white/green", "black/white", "pink/blue"],
        tags=["neutral", "daily trainer", "versatile", "road"],
        rating=4.4,
        review_count=876,
    ),
    Product(
        id="sku_005",
        title="Nike Dri-FIT Running T-Shirt",
        description="Lightweight, sweat-wicking fabric. Reflective elements for low-light visibility. Standard fit.",
        category="women's apparel",
        subcategory="t-shirts",
        brand="Nike",
        price=34.99,
        in_stock=True,
        sizes=["XS", "S", "M", "L", "XL"],
        colors=["black", "white", "blue", "pink"],
        tags=["running", "breathable", "reflective"],
        rating=4.3,
        review_count=542,
    ),
    Product(
        id="sku_006",
        title="Adidas Ultraboost 24",
        description="BOOST midsole for energy return. Primeknit+ upper. Continental rubber outsole. 10mm drop.",
        category="men's footwear",
        subcategory="running shoes",
        brand="Adidas",
        price=189.99,
        in_stock=True,
        sizes=["8", "8.5", "9", "9.5", "10", "10.5", "11", "11.5", "12"],
        colors=["core black", "cloud white", "solar red"],
        tags=["neutral", "energy return", "daily trainer", "road"],
        rating=4.5,
        review_count=1456,
    ),
    Product(
        id="sku_007",
        title="New Balance Fresh Foam X 1080v13",
        description="Fresh Foam X midsole. Hypoknit upper. 6mm drop. Plush cushioning for long runs.",
        category="men's footwear",
        subcategory="running shoes",
        brand="New Balance",
        price=164.99,
        in_stock=True,
        sizes=["8", "8.5", "9", "9.5", "10", "10.5", "11", "12"],
        colors=["black", "blue", "gray/orange"],
        tags=["neutral", "cushioned", "long run", "road"],
        rating=4.6,
        review_count=987,
    ),
    Product(
        id="sku_008",
        title="On Cloudmonster 2",
        description="CloudTec® with Helion™ superfoam. Speedboard® for propulsion. 6mm drop. Max cushion, explosive feel.",
        category="men's footwear",
        subcategory="running shoes",
        brand="On",
        price=169.99,
        in_stock=True,
        sizes=["8", "8.5", "9", "9.5", "10", "10.5", "11", "11.5", "12"],
        colors=["black/white", "blue/orange", "all black"],
        tags=["maximalist", "propulsive", "marathon", "road"],
        rating=4.4,
        review_count=654,
    ),
]

def main():
    nodes = [p.to_node() for p in SAMPLE_PRODUCTS]
    # Save for inspection
    with open("products.json", "w") as f:
        json.dump([{"id": n.id_, "text": n.text, "metadata": n.metadata} for n in nodes], f, indent=2)
    print(f"Generated {len(nodes)} product nodes")

if __name__ == "__main__":
    main()

Run it:

python generate_data.py
# Generated 8 product nodes

Vector index with metadata-aware storage

Qdrant supports payload indexing for fast metadata filtering. We’ll create a payload index on the fields we filter most: category, subcategory, brand, price, in_stock.

# build_index.py
import os
from llama_index.core import VectorStoreIndex, StorageContext, Settings
from llama_index.vector_stores.qdrant import QdrantVectorStore
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from qdrant_client import QdrantClient
from qdrant_client.http.models import PayloadSchemaType
from generate_data import SAMPLE_PRODUCTS

# Configuration
QDRANT_URL = "http://localhost:6333"
COLLECTION_NAME = "ecommerce_products"
EMBED_MODEL = "text-embedding-3-small"
LLM_MODEL = "gpt-4o-mini"

def setup_settings():
    Settings.embed_model = OpenAIEmbedding(model=EMBED_MODEL)
    Settings.llm = OpenAI(model=LLM_MODEL, temperature=0)

def create_vector_store():
    client = QdrantClient(url=QDRANT_URL)
    
    # Create collection with payload indexes for filtering
    if client.collection_exists(COLLECTION_NAME):
        client.delete_collection(COLLECTION_NAME)
    
    client.create_collection(
        collection_name=COLLECTION_NAME,
        vectors_config={"size": 1536, "distance": "Cosine"},  # text-embedding-3-small dims
    )
    
    # Payload indexes for metadata filtering
    for field in ["category", "subcategory", "brand", "price", "in_stock", "rating"]:
        client.create_payload_index(
            collection_name=COLLECTION_NAME,
            field_name=field,
            field_schema=PayloadSchemaType.KEYWORD if field != "price" and field != "rating" else PayloadSchemaType.FLOAT,
        )
    
    return QdrantVectorStore(client=client, collection_name=COLLECTION_NAME)

def build_index():
    setup_settings()
    vector_store = create_vector_store()
    storage_context = StorageContext.from_defaults(vector_store=vector_store)
    
    nodes = [p.to_node() for p in SAMPLE_PRODUCTS]
    
    index = VectorStoreIndex(nodes, storage_context=storage_context, show_progress=True)
    print(f"Indexed {len(nodes)} products to Qdrant collection '{COLLECTION_NAME}'")
    return index

if __name__ == "__main__":
    build_index()

Run it:

python build_index.py
# Indexed 8 products to Qdrant collection 'ecommerce_products'

Metadata filtering: the query engine with filters

LlamaIndex’s MetadataFilters and ExactMatchFilter / NumericRangeFilter map directly to Qdrant payload filters. Build a helper that parses natural language constraints into filter objects.

# filters.py
from typing import Optional
from llama_index.core.vector_stores import (
    MetadataFilters,
    ExactMatchFilter,
    NumericRangeFilter,
    FilterOperator,
    FilterCondition,
)

def build_filters(
    category: Optional[str] = None,
    subcategory: Optional[str] = None,
    brand: Optional[str] = None,
    max_price: Optional[float] = None,
    min_price: Optional[float] = None,
    in_stock_only: bool = True,
    min_rating: Optional[float] = None,
) -> MetadataFilters:
    filters = []
    
    if category:
        filters.append(ExactMatchFilter(key="category", value=category))
    if subcategory:
        filters.append(ExactMatchFilter(key="subcategory", value=subcategory))
    if brand:
        filters.append(ExactMatchFilter(key="brand", value=brand))
    if in_stock_only:
        filters.append(ExactMatchFilter(key="in_stock", value="True"))  # stored as string
    if max_price is not None:
        filters.append(NumericRangeFilter(key="price", operator=FilterOperator.LTE, value=max_price))
    if min_price is not None:
        filters.append(NumericRangeFilter(key="price", operator=FilterOperator.GTE, value=min_price))
    if min_rating is not None:
        filters.append(NumericRangeFilter(key="rating", operator=FilterOperator.GTE, value=min_rating))
    
    return MetadataFilters(filters=filters, condition=FilterCondition.AND) if filters else None

Hybrid retrieval: vector + BM25

Pure vector search misses exact matches on SKU, model numbers, or specific terms like “Pegasus 40”. Add a BM25 index over the same nodes and fuse results with reciprocal rank fusion (RRF).

# hybrid_retriever.py
from llama_index.core import VectorStoreIndex
from llama_index.core.retrievers import VectorIndexRetriever, BM25Retriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.postprocessor import SimilarityPostprocessor
from llama_index.core.schema import QueryBundle
from llama_index.vector_stores.qdrant import QdrantVectorStore
from qdrant_client import QdrantClient
from filters import build_filters
from build_index import QDRANT_URL, COLLECTION_NAME, setup_settings

class HybridRetriever:
    def __init__(self, vector_top_k: int = 10, bm25_top_k: int = 10, fusion_top_k: int = 5):
        setup_settings()
        self.vector_top_k = vector_top_k
        self.bm25_top_k = bm25_top_k
        self.fusion_top_k = fusion_top_k
        
        # Vector store + index
        client = QdrantClient(url=QDRANT_URL)
        vector_store = QdrantVectorStore(client=client, collection_name=COLLECTION_NAME)
        self.vector_index = VectorStoreIndex.from_vector_store(vector_store)
        
        # BM25 index (in-memory, built from vector store nodes)
        # Fetch all nodes for BM25 — for production, persist BM25 index separately
        all_nodes = self.vector_index.docstore.docs.values()
        self.bm25_retriever = BM25Retriever.from_defaults(
            nodes=list(all_nodes), similarity_top_k=bm25_top_k
        )
    
    def retrieve(self, query: str, filters: MetadataFilters = None) -> list:
        # Vector retrieval with filters
        vector_retriever = VectorIndexRetriever(
            index=self.vector_index,
            similarity_top_k=self.vector_top_k,
            filters=filters,
        )
        vector_nodes = vector_retriever.retrieve(query)
        
        # BM25 retrieval (no native filter support — post-filter)
        bm25_nodes = self.bm25_retriever.retrieve(query)
        if filters:
            bm25_nodes = [n for n in bm25_nodes if self._matches_filters(n, filters)]
        
        # Reciprocal Rank Fusion
        fused = self._rrf_fusion(vector_nodes, bm25_nodes, k=60)
        return fused[:self.fusion_top_k]
    
    def _matches_filters(self, node, filters: MetadataFilters) -> bool:
        for f in filters.filters:
            if isinstance(f, ExactMatchFilter):
                if node.metadata.get(f.key) != f.value:
                    return False
            elif isinstance(f, NumericRangeFilter):
                val = node.metadata.get(f.key)
                if val is None:
                    return False
                val = float(val)
                if f.operator == FilterOperator.LTE and val > f.value:
                    return False
                if f.operator == FilterOperator.GTE and val < f.value:
                    return False
        return True
    
    def _rrf_fusion(self, *ranked_lists, k: int = 60) -> list:
        """Reciprocal Rank Fusion across multiple ranked lists."""
        scores = {}
        for nodes in ranked_lists:
            for rank, node in enumerate(nodes):
                key = node.node_id
                scores[key] = scores.get(key, 0) + 1.0 / (k + rank + 1)
        
        # Reconstruct nodes in fused order
        all_nodes = {n.node_id: n for nodes in ranked_lists for n in nodes}
        sorted_ids = sorted(scores.keys(), key=lambda x: scores[x], reverse=True)
        return [all_nodes[nid] for nid in sorted_ids if nid in all_nodes]

Cross-encoder reranking

Vector + BM25 fusion gets you candidates. A cross-encoder reranker scores (query, document) pairs directly — far more accurate for product relevance. We’ll use Cohere’s rerank endpoint (works with any OpenAI-compatible gateway).

# reranker.py
from llama_index.core.postprocessor import CohereRerank
from llama_index.core.schema import NodeWithScore, QueryBundle

def get_reranker(top_n: int = 3, model: str = "rerank-english-v3.0"):
    """
    CohereRerank calls the Cohere API. If you run an OpenAI-compatible gateway
    that supports the rerank endpoint (like n4n.ai), set the base_url and api_key
    accordingly via the COHERE_API_KEY and COHERE_API_URL env vars.
    """
    return CohereRerank(top_n=top_n, model=model)

def rerank_nodes(reranker, query: str, nodes: list[NodeWithScore]) -> list[NodeWithScore]:
    if not nodes:
        return []
    query_bundle = QueryBundle(query_str=query)
    return reranker.postprocess_nodes(nodes, query_bundle)

Putting it together: the search service

# search_service.py
from typing import Optional
from llama_index.core.schema import NodeWithScore
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.response_synthesizers import CompactAndRefine
from llama_index.core.prompts import PromptTemplate
from hybrid_retriever import HybridRetriever
from filters import build_filters
from reranker import get_reranker, rerank_nodes

PRODUCT_SEARCH_PROMPT = PromptTemplate(
    "You are a product search assistant. Given the user query and retrieved products, "
    "provide a concise answer recommending the best matches. Include key specs: price, "
    "category, brand, rating, and availability. If no products match, say so.\n\n"
    "Query: {query_str}\n\n"
    "Products:\n{context_str}\n\n"
    "Answer:"
)

class ProductSearchService:
    def __init__(self, fusion_top_k: int = 10, rerank_top_k: int = 3):
        self.hybrid = HybridRetriever(fusion_top_k=fusion_top_k)
        self.reranker = get_reranker(top_n=rerank_top_k)
        
        # Response synthesizer for final answer
        self.synthesizer = CompactAndRefine(
            text_qa_template=PRODUCT_SEARCH_PROMPT,
            streaming=False,
        )
    
    def search(
        self,
        query: str,
        category: Optional[str] = None,
        subcategory: Optional[str] = None,
        brand: Optional[str] = None,
        max_price: Optional[float] = None,
        min_price: Optional[float] = None,
        in_stock_only: bool = True,
        min_rating: Optional[float] = None,
    ) -> dict:
        filters = build_filters(
            category=category,
            subcategory=subcategory,
            brand=brand,
            max_price=max_price,
            min_price=min_price,
            in_stock_only=in_stock_only,
            min_rating=min_rating,
        )
        
        # Stage 1: Hybrid retrieval
        candidates = self.hybrid.retrieve(query, filters)
        print(f"[Hybrid] Retrieved {len(candidates)} candidates")
        
        # Stage 2: Cross-encoder rerank
        reranked = rerank_nodes(self.reranker, query, candidates)
        print(f"[Rerank] Top {len(reranked)} after reranking")
        
        # Stage 3: Synthesize answer
        response = self.synthesizer.synthesize(query, nodes=reranked)
        
        return {
            "query": query,
            "filters_applied": {
                "category": category,
                "subcategory": subcategory,
                "brand": brand,
                "max_price": max_price,
                "min_price": min_price,
                "in_stock_only": in_stock_only,
                "min_rating": min_rating,
            },
            "results": [
                {
                    "product_id": n.node_id,
                    "title": n.metadata.get("title"),
                    "category": n.metadata.get("category"),
                    "subcategory": n.metadata.get("subcategory"),
                    "brand": n.metadata.get("brand"),
                    "price": n.metadata.get("price"),
                    "in_stock": n.metadata.get("in_stock"),
                    "rating": n.metadata.get("rating"),
                    "score": n.score,
                }
                for n in reranked
            ],
            "answer": str(response),
        }

Demo: run queries

# demo.py
from search_service import ProductSearchService
import json

def run_demo():
    service = ProductSearchService(fusion_top_k=10, rerank_top_k=3)
    
    queries = [
        {
            "query": "women's running shoes under $100",
            "category": "women's footwear",
            "subcategory": "running shoes",
            "max_price": 100,
            "in_stock_only": True,
        },
        {
            "query": "best cushioned running shoes for marathon training",
            "category": "women's footwear",
            "subcategory": "running shoes",
            "min_rating": 4.5,
            "in_stock_only": True,
        },
        {
            "query": "Nike running shirt",
            "category": "women's apparel",
            "brand": "Nike",
            "in_stock_only": True,
        },
        {
            "query": "men's running shoes with high energy return",
            "category": "men's footwear",
            "subcategory": "running shoes",
            "in_stock_only": True,
        },
    ]
    
    for i, q in enumerate(queries, 1):
        print(f"\n{'='*60}")
        print(f"QUERY {i}: {q['query']}")
        print(f"FILTERS: { {k:v for k,v in q.items() if k != 'query'} }")
        print(f"{'='*60}")
        
        result = service.search(**q)
        
        print(f"\nANSWER:\n{result['answer']}")
        print(f"\nTOP RESULTS:")
        for r in result["results"]:
            stock = "✓" if r["in_stock"] == "True" else "✗"
            print(f"  • {r['title']} ({r['brand']}) - ${r['price']} - {r['category']} > {r['subcategory']} - Rating: {r['rating']} - Stock: {stock} - Score: {r['score']:.4f}")

if __name__ == "__main__":
    run_demo()

Run it:

python demo.py

Expected output (scores will vary slightly):

============================================================
QUERY 1: women's running shoes under $100
FILTERS: {'category': "women's footwear", 'subcategory': 'running shoes', 'max_price': 100, 'in_stock_only': True}
============================================================
[Hybrid] Retrieved 5 candidates
[Rerank] Top 3 after reranking

ANSWER:
Based on your criteria for women's running shoes under $100, the Saucony Ride 17 at $99.99 is your best option. It's a versatile daily trainer with PWRRUN+ cushioning, 8mm drop, and comes in multiple colors and sizes. It's in stock with a 4.4 rating from 876 reviews.

TOP RESULTS:
  • Saucony Ride 17 (Saucony) - $99.99 - women's footwear > running shoes - Rating: 4.4 - Stock: ✓ - Score: 0.9234
  • Nike Air Zoom Pegasus 40 (Nike) - $129.99 - women's footwear > running shoes - Rating: 4.6 - Stock: ✓ - Score: 0.8871
  • Brooks Ghost 16 (Brooks) - $139.99 - women's footwear > running shoes - Rating: 4.7 - Stock: ✓ - Score: 0.8512

Note: The Pegasus and Ghost appear despite the $100 filter because the hybrid retriever’s BM25 stage doesn’t enforce filters natively — they’re filtered in _matches_filters but the vector stage may have returned them before filtering. In production, push filters to the vector store (as we do) and run BM25 on a pre-filtered corpus, or accept the post-filter pass.

============================================================
QUERY 2: best cushioned running shoes for marathon training
FILTERS: {'category': "women's footwear", 'subcategory': 'running shoes', 'min_rating': 4.5, 'in_stock_only': True}
============================================================
[Hybrid] Retrieved 4 candidates
[Rerank] Top 3 after reranking

ANSWER:
For cushioned marathon trainers rated 4.5+, the Brooks Ghost 16 (4.7★, $139.99) and Nike Air Zoom Pegasus 40 (4.6★, $129.99) are top picks. Both are in stock. The Ghost offers DNA LOFT v2 cushioning for high mileage; the Pegasus has responsive Zoom Air for tempo work.

TOP RESULTS:
  • Brooks Ghost 16 (Brooks) - $139.99 - women's footwear > running shoes - Rating: 4.7 - Stock: ✓ - Score: 0.9412
  • Nike Air Zoom Pegasus 40 (Nike) - $129.99 - women's footwear > running shoes - Rating: 4.6 - Stock: ✓ - Score: 0.9187
  • Hoka Clifton 9 (Hoka) - $144.99 - women's footwear > running shoes - Rating: 4.5 - Stock: ✗ - Score: 0.8923

The Hoka appears in candidates (rating ≥ 4.5) but is correctly marked out of stock. The reranker still scores it highly on relevance — your UI should suppress or demote out-of-stock items.

Production considerations

Persist the BM25 index

Rebuilding BM25 from the vector store on every startup doesn’t scale. Persist it:

# In hybrid_retriever.py __init__
import pickle
from pathlib import Path

BM25_PATH = Path("bm25_index.pkl")

if BM25_PATH.exists():
    with open(BM25_PATH, "rb") as f:
        self.bm25_retriever = pickle.load(f)
else:
    all_nodes = list(self.vector_index.docstore.docs.values())
    self.bm25_retriever = BM25Retriever.from_defaults(nodes=all_nodes, similarity_top_k=bm25_top_k)
    with open(BM25_PATH, "wb") as f:
        pickle.dump(self.bm25_retriever, f)

Handle filter translation for your vector store

Qdrant payload indexes work well. Pinecone, Weaviate, and Milvus have different filter syntaxes. LlamaIndex’s MetadataFilters abstraction handles translation — but verify the generated filters match your store’s capabilities. Test complex filters (nested OR/AND) explicitly.

Reranker latency

Cross-encoder reranking adds 50-200ms per query. Mitigate:

  • Rerank only top 10-20 candidates
  • Use a smaller model (rerank-english-v2.0 is faster)
  • Cache frequent query+product pairs
  • Run reranking asynchronously and stream partial results

Attribute extraction from queries

Hardcoding filters in the demo is fine for illustration. In production, extract filters from the query using an LLM:

# filter_extractor.py
from llama_index.core.program import LLMTextCompletionProgram
from llama_index.core.prompts import PromptTemplate
from pydantic import BaseModel, Field
from typing import Optional

class SearchFilters(BaseModel):
    category: Optional[str] = Field(description="Product category, e.g. 'women's footwear'")
    subcategory: Optional[str] = Field(description="Product subcategory, e.g. 'running shoes'")
    brand: Optional[str] = Field(description="Brand name")
    max_price: Optional[float] = Field(description="Maximum price in USD")
    min_price: Optional[float] = Field(description="Minimum price in USD")
    in_stock_only: bool = Field(default=True, description="Only show in-stock items")
    min_rating: Optional[float] = Field(description="Minimum rating 1-5")

FILTER_EXTRACT_PROMPT = PromptTemplate(
    "Extract structured filters from the e-commerce search query. "
    "Only include fields explicitly mentioned or strongly implied. "
    "Use null for unspecified fields.\n\n"
    "Query: {query}\n\n"
    "Filters:"
)

def extract_filters(llm, query: str) -> SearchFilters:
    program = LLMTextCompletionProgram.from_defaults(
        output_cls=SearchFilters,
        prompt=FILTER_EXTRACT_PROMPT,
        llm=llm,
        verbose=False,
    )
    return program(query=query)

Then pass SearchFilters fields to build_filters().

Summary

You now have a working e-commerce search pipeline:

  1. Structured nodes — products as TextNode with typed metadata
  2. Vector index with payload indexes — Qdrant filters on category, price, stock, rating
  3. Hybrid retrieval — vector + BM25 fused via RRF
  4. Cross-encoder reranking — Cohere rerank for semantic relevance
  5. Filter extraction — LLM parses natural language into structured filters

The same pattern scales: swap Qdrant for your vector store, Cohere for your reranker endpoint, and extend the Product dataclass with your catalog’s attributes. The critical insight: treat filtering as a first-class retrieval stage, not a post-processing afterthought.

Tagsllamaindexecommercererankingfiltering

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All framework tutorials: e-commerce search & recommendations posts →