Skip to main content

RAG Retriever

The RAG retriever orchestrates the retrieval step - getting relevant dishes before we send them to the LLM.

The Retriever's Role​

User Query → Retriever → ChromaDB → Relevant Dishes → LLM → Response
↑
This component

We already built the core retriever in the ChromaDB section. Now let's see how it integrates with the rest of the system.

Complete Retriever Code​

Here's the full app/rag/retriever.py:

app/rag/retriever.py
import json
from typing import Optional
import chromadb
from chromadb.config import Settings

from app.core.config import settings


class FoodRetriever:
def __init__(self):
self.client = chromadb.HttpClient(
host=settings.chroma_host,
port=settings.chroma_port
)
self.collection = self.client.get_or_create_collection(
name="foods",
metadata={"description": "Indian vegetarian food database"}
)

def add_foods(self, foods: list[dict]) -> int:
"""Ingest food data into ChromaDB."""
ids = []
documents = []
metadatas = []

for food in foods:
ids.append(str(food["id"]))
documents.append(self._create_document(food))
metadatas.append(self._create_metadata(food))

self.collection.upsert(
ids=ids,
documents=documents,
metadatas=metadatas
)

return len(ids)

def _create_document(self, food: dict) -> str:
"""Create embedding-friendly text representation."""
parts = [
f"{food['name']}: {food['description']}",
f"Cuisine: {food['cuisine']} from {food.get('region', 'India')}",
f"Best for: {', '.join(food.get('meal_type', ['any time']))}",
f"Spice: {food.get('spice_level', 'medium')}",
]

if food.get("ingredients"):
parts.append(f"Made with: {', '.join(food['ingredients'])}")

tags = []
if food.get("is_high_protein"):
tags.append("high protein")
if food.get("is_low_carb"):
tags.append("low carb")
if food.get("is_vegan"):
tags.append("vegan")
if tags:
parts.append(f"Tags: {', '.join(tags)}")

return "\n".join(parts)

def _create_metadata(self, food: dict) -> dict:
"""Create filterable metadata."""
return {
"name": food["name"],
"cuisine": food.get("cuisine", "indian"),
"spice_level": food.get("spice_level", "medium"),
"meal_type": json.dumps(food.get("meal_type", [])),
"allergens": json.dumps(food.get("allergens", [])),
"is_vegetarian": food.get("is_vegetarian", True),
"is_vegan": food.get("is_vegan", False),
"is_high_protein": food.get("is_high_protein", False),
"is_low_carb": food.get("is_low_carb", False),
"prep_time": food.get("prep_time_minutes", 30),
}

def search(
self,
query: str,
cuisine: Optional[str] = None,
spice_level: Optional[str] = None,
exclude_allergens: Optional[list[str]] = None,
dietary_type: Optional[str] = None,
health_goals: Optional[list[str]] = None,
top_k: int = 5
) -> list[dict]:
"""Search for matching dishes."""

# Build ChromaDB where clause
where = self._build_where_clause(
cuisine=cuisine,
spice_level=spice_level,
dietary_type=dietary_type,
health_goals=health_goals
)

# Query with extra results for post-filtering
results = self.collection.query(
query_texts=[query],
n_results=top_k * 3,
where=where if where else None
)

# Post-filter and format results
return self._process_results(
results,
exclude_allergens=exclude_allergens,
top_k=top_k
)

def _build_where_clause(
self,
cuisine: Optional[str],
spice_level: Optional[str],
dietary_type: Optional[str],
health_goals: Optional[list[str]]
) -> Optional[dict]:
"""Build ChromaDB filter."""
conditions = []

if cuisine:
conditions.append({"cuisine": cuisine})

if spice_level:
conditions.append({"spice_level": spice_level})

if dietary_type == "vegan":
conditions.append({"is_vegan": True})

if health_goals:
if "high_protein" in health_goals:
conditions.append({"is_high_protein": True})
if "low_carb" in health_goals:
conditions.append({"is_low_carb": True})

if not conditions:
return None
elif len(conditions) == 1:
return conditions[0]
else:
return {"$and": conditions}

def _process_results(
self,
results: dict,
exclude_allergens: Optional[list[str]],
top_k: int
) -> list[dict]:
"""Filter and format results."""
foods = []

if not results["ids"] or not results["ids"][0]:
return foods

for i, id in enumerate(results["ids"][0]):
metadata = results["metadatas"][0][i]

# Filter out allergens
if exclude_allergens:
food_allergens = json.loads(metadata.get("allergens", "[]"))
if any(a in food_allergens for a in exclude_allergens):
continue

foods.append({
"id": id,
"name": metadata["name"],
"cuisine": metadata["cuisine"],
"spice_level": metadata["spice_level"],
"description": results["documents"][0][i],
})

if len(foods) >= top_k:
break

return foods

def format_for_prompt(self, foods: list[dict]) -> str:
"""Format retrieved foods for the LLM prompt."""
if not foods:
return "No matching dishes found in the database."

lines = []
for i, food in enumerate(foods, 1):
lines.append(f"{i}. {food['name']} ({food['cuisine']}, {food['spice_level']} spice)")
lines.append(f" {food['description']}")
lines.append("")

return "\n".join(lines)

def get_by_name(self, name: str) -> Optional[dict]:
"""Get specific dish by name."""
results = self.collection.get(
where={"name": name},
limit=1
)

if not results["ids"]:
return None

return {
"id": results["ids"][0],
**results["metadatas"][0],
"description": results["documents"][0]
}

def get_count(self) -> int:
"""Get total dishes in database."""
return self.collection.count()


# Singleton
retriever = FoodRetriever()

Using the Retriever​

In the chat endpoint:

from app.rag import retriever

# Get relevant dishes
foods = retriever.search(
query=user_message,
exclude_allergens=preferences.get("allergies"),
spice_level=preferences.get("spice_level"),
health_goals=preferences.get("health_goals")
)

# Format for prompt
context = retriever.format_for_prompt(foods)

# Send to LLM
response = openai_client.generate_response(
user_message=user_message,
context=context,
preferences=preferences
)

The Retrieval Strategy​

ChromaDB finds dishes with similar meaning:

Query: "light morning meal"
Matches: "easy to digest breakfast", "gentle way to start the day"

Step 2: Metadata Filtering​

Apply exact filters before similarity ranking:

where={"cuisine": "south_indian", "is_vegan": True}

Step 3: Post-Filtering​

Some filters are hard to express in ChromaDB (like array exclusions):

# Can't easily do this in ChromaDB
where={"allergens": {"$nin": ["dairy", "nuts"]}}

# So we do it in Python
if any(a in food_allergens for a in exclude_allergens):
continue # Skip this food

Step 4: Formatting​

Convert results to a format the LLM can understand:

1. Pesarattu (south_indian, medium spice)
Pesarattu: A protein-rich green moong dal crepe...
Cuisine: south_indian from Andhra Pradesh
Best for: breakfast

2. Idli (south_indian, mild spice)
Idli: Soft, fluffy steamed rice cakes...

Why Over-Fetch Then Filter?​

n_results=top_k * 3,  # Get 15 when we need 5

We fetch extra because:

  1. Some may be filtered out for allergens
  2. Ensures we always have enough results
  3. ChromaDB query is fast anyway

Testing the Retriever​

# In Python REPL or test file
from app.rag import retriever

# Test search
results = retriever.search(
query="spicy breakfast",
cuisine="south_indian",
exclude_allergens=["dairy"],
top_k=3
)

for r in results:
print(f"- {r['name']}: {r['spice_level']}")

# Test formatting
print(retriever.format_for_prompt(results))

Next, let's build the chat API endpoint that ties everything together.