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.0is 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:
- Structured nodes — products as
TextNodewith typed metadata - Vector index with payload indexes — Qdrant filters on category, price, stock, rating
- Hybrid retrieval — vector + BM25 fused via RRF
- Cross-encoder reranking — Cohere rerank for semantic relevance
- 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.