Skip to content
11,687 changes: 11,687 additions & 0 deletions src/backend/fastapi_app/cars_seed_data.json

Large diffs are not rendered by default.

47 changes: 47 additions & 0 deletions src/backend/fastapi_app/postgres_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,35 @@ def to_str_for_embedding(self):
return f"Name: {self.name} Description: {self.description} Type: {self.type}"


class Car(Base):
__tablename__ = "cars"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
type: Mapped[str] = mapped_column()
brand: Mapped[str] = mapped_column()
name: Mapped[str] = mapped_column()
description: Mapped[str] = mapped_column()
price: Mapped[float] = mapped_column()
# Embeddings for different models:
embedding_3l: Mapped[Vector] = mapped_column(Vector(1024), nullable=True) # text-embedding-3-large
embedding_nomic: Mapped[Vector] = mapped_column(Vector(768), nullable=True) # nomic-embed-text

def to_dict(self, include_embedding: bool = False):
model_dict = {column.name: getattr(self, column.name) for column in self.__table__.columns}
if include_embedding:
model_dict["embedding_3l"] = model_dict.get("embedding_3l", [])
model_dict["embedding_nomic"] = model_dict.get("embedding_nomic", [])
else:
del model_dict["embedding_3l"]
del model_dict["embedding_nomic"]
return model_dict

def to_str_for_rag(self):
return f"Name:{self.name} Description:{self.description} Price:{self.price} Brand:{self.brand} Type:{self.type}"

def to_str_for_embedding(self):
return f"Name: {self.name} Brand: {self.brand} Description: {self.description} Type: {self.type}"


"""
**Define HNSW index to support vector similarity search**

Expand Down Expand Up @@ -66,3 +95,21 @@ def to_str_for_embedding(self):
postgresql_with={"m": 16, "ef_construction": 64},
postgresql_ops={"embedding_nomic": "vector_cosine_ops"},
)

cars_table_name = Car.__tablename__

cars_index_3l = Index(
f"hnsw_index_for_cosine_{cars_table_name}_embedding_3l",
Car.embedding_3l,
postgresql_using="hnsw",
postgresql_with={"m": 16, "ef_construction": 64},
postgresql_ops={"embedding_3l": "vector_cosine_ops"},
)

cars_index_nomic = Index(
f"hnsw_index_for_cosine_{cars_table_name}_embedding_nomic",
Car.embedding_nomic,
postgresql_using="hnsw",
postgresql_with={"m": 16, "ef_construction": 64},
postgresql_ops={"embedding_nomic": "vector_cosine_ops"},
)
8 changes: 5 additions & 3 deletions src/backend/fastapi_app/postgres_searcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from fastapi_app.api_models import Filter
from fastapi_app.embeddings import compute_text_embedding
from fastapi_app.postgres_models import Item
from fastapi_app.postgres_models import Base, Item


class PostgresSearcher:
Expand All @@ -19,13 +19,15 @@ def __init__(
embed_model: str,
embed_dimensions: Optional[int],
embedding_column: str,
db_model: type[Base] = Item, # which table to search; defaults to Item for backward compatibility
):
self.db_session = db_session
self.openai_embed_client = openai_embed_client
self.embed_model = embed_model
self.embed_deployment = embed_deployment
self.embed_dimensions = embed_dimensions
self.embedding_column = embedding_column
self.db_model = db_model

def build_filter_clause(self, filters: Optional[list[Filter]]) -> tuple[str, str]:
if filters is None:
Expand All @@ -47,7 +49,7 @@ async def search(
filters: Optional[list[Filter]] = None,
):
filter_clause_where, filter_clause_and = self.build_filter_clause(filters)
table_name = Item.__tablename__
table_name = self.db_model.__tablename__
vector_query = f"""
SELECT id, RANK () OVER (ORDER BY {self.embedding_column} <=> :embedding) AS rank
FROM {table_name}
Expand Down Expand Up @@ -100,7 +102,7 @@ async def search(
# Convert results to SQLAlchemy models
row_models = []
for id, _ in results[:top]:
item = await self.db_session.execute(select(Item).where(Item.id == id))
item = await self.db_session.execute(select(self.db_model).where(self.db_model.id == id))
row_models.append(item.scalar())
return row_models

Expand Down
50 changes: 44 additions & 6 deletions src/backend/fastapi_app/rag_advanced.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ def __init__(
messages: list[ResponseInputItemParam],
overrides: ChatRequestOverrides,
searcher: PostgresSearcher,
cars_searcher: PostgresSearcher,
openai_chat_client: AsyncOpenAI,
chat_model: str,
chat_deployment: Optional[str], # Not needed for non-Azure OpenAI
):
self.searcher = searcher
self.cars_searcher = cars_searcher
self.chat_params = self.get_chat_params(messages, overrides)
self.model_for_thoughts = (
{"model": chat_model, "deployment": chat_deployment} if chat_deployment else {"model": chat_model}
Expand All @@ -59,7 +61,7 @@ def __init__(
self.search_agent = Agent(
name="Searcher",
instructions=self.query_prompt_template,
tools=[function_tool(self.search_database)],
tools=[function_tool(self.search_items), function_tool(self.search_cars)],
tool_use_behavior="stop_on_first_tool",
model=openai_agents_model,
)
Expand All @@ -73,24 +75,24 @@ def __init__(
),
)

async def search_database(
async def search_items(
self,
search_query: str,
price_filter: Optional[PriceFilter] = None,
brand_filter: Optional[BrandFilter] = None,
) -> SearchResults:
"""
Search PostgreSQL database for relevant products based on user query
Search the items database for products like clothing, footwear, outdoor gear, and accessories.
Use this tool when the user asks about general products, apparel, shoes, or equipment.

Args:
search_query: English query string to use for full text search, e.g. 'red shoes'.
search_query: English query string to use for full text search, e.g. 'red hiking boots'.
price_filter: Filter search results based on price of the product
brand_filter: Filter search results based on brand of the product

Returns:
List of formatted items that match the search query and filters
"""
# Only send non-None filters
filters: list[Filter] = []
if price_filter:
filters.append(price_filter)
Expand All @@ -107,6 +109,40 @@ async def search_database(
query=search_query, items=[ItemPublic.model_validate(item.to_dict()) for item in results], filters=filters
)

async def search_cars(
self,
search_query: str,
price_filter: Optional[PriceFilter] = None,
brand_filter: Optional[BrandFilter] = None,
) -> SearchResults:
"""
Search the cars database for vehicles like sedans, SUVs, hatchbacks, crossovers, and electric cars.
Use this tool when the user asks about cars, vehicles, mileage, engine specs, or automobile brands.

Args:
search_query: English query string to use for full text search, e.g. 'luxury SUV with sunroof'.
price_filter: Filter search results based on price of the car
brand_filter: Filter search results based on car brand (e.g. 'Hyundai', 'Toyota', 'BMW')

Returns:
List of cars that match the search query and filters
"""
filters: list[Filter] = []
if price_filter:
filters.append(price_filter)
if brand_filter:
filters.append(brand_filter)
results = await self.cars_searcher.search_and_embed(
search_query,
top=self.chat_params.top,
enable_vector_search=self.chat_params.enable_vector_search,
enable_text_search=self.chat_params.enable_text_search,
filters=filters,
)
return SearchResults(
query=search_query, items=[ItemPublic.model_validate(item.to_dict()) for item in results], filters=filters
)

async def prepare_context(self) -> tuple[list[ItemPublic], list[ThoughtStep]]:
few_shots: list[ResponseInputItemParam] = json.loads(self.query_fewshots)
user_query = f"Find search results for user query: {self.chat_params.original_user_query}"
Expand All @@ -116,7 +152,9 @@ async def prepare_context(self) -> tuple[list[ItemPublic], list[ThoughtStep]]:
run_results = await Runner.run(self.search_agent, input=all_messages)
most_recent_response = run_results.new_items[-1]
if isinstance(most_recent_response, ToolCallOutputItem):
search_results = most_recent_response.output
output = most_recent_response.output
# The agents SDK serializes Pydantic models to a JSON string — parse it back
search_results = SearchResults.model_validate_json(output) if isinstance(output, str) else output
else:
raise ValueError("Error retrieving search results, model did not call tool properly")

Expand Down
24 changes: 23 additions & 1 deletion src/backend/fastapi_app/routes/api_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
RetrievalResponseDelta,
)
from fastapi_app.dependencies import ChatClient, CommonDeps, DBSession, EmbeddingsClient
from fastapi_app.postgres_models import Item
from fastapi_app.postgres_models import Car, Item
from fastapi_app.postgres_searcher import PostgresSearcher
from fastapi_app.rag_advanced import AdvancedRAGChat
from fastapi_app.rag_simple import SimpleRAGChat
Expand Down Expand Up @@ -117,13 +117,24 @@ async def chat_handler(
embed_model=context.openai_embed_model,
embed_dimensions=context.openai_embed_dimensions,
embedding_column=context.embedding_column,
db_model=Item,
)
cars_searcher = PostgresSearcher(
db_session=database_session,
openai_embed_client=openai_embed.client,
embed_deployment=context.openai_embed_deployment,
embed_model=context.openai_embed_model,
embed_dimensions=context.openai_embed_dimensions,
embedding_column=context.embedding_column,
db_model=Car,
)
rag_flow: Union[SimpleRAGChat, AdvancedRAGChat]
if chat_request.context.overrides.use_advanced_flow:
rag_flow = AdvancedRAGChat(
messages=chat_request.input,
overrides=chat_request.context.overrides,
searcher=searcher,
cars_searcher=cars_searcher,
openai_chat_client=openai_chat.client,
chat_model=context.openai_chat_model,
chat_deployment=context.openai_chat_deployment,
Expand Down Expand Up @@ -164,6 +175,16 @@ async def chat_stream_handler(
embed_model=context.openai_embed_model,
embed_dimensions=context.openai_embed_dimensions,
embedding_column=context.embedding_column,
db_model=Item,
)
cars_searcher = PostgresSearcher(
db_session=database_session,
openai_embed_client=openai_embed.client,
embed_deployment=context.openai_embed_deployment,
embed_model=context.openai_embed_model,
embed_dimensions=context.openai_embed_dimensions,
embedding_column=context.embedding_column,
db_model=Car,
)

rag_flow: Union[SimpleRAGChat, AdvancedRAGChat]
Expand All @@ -172,6 +193,7 @@ async def chat_stream_handler(
messages=chat_request.input,
overrides=chat_request.context.overrides,
searcher=searcher,
cars_searcher=cars_searcher,
openai_chat_client=openai_chat.client,
chat_model=context.openai_chat_model,
chat_deployment=context.openai_chat_deployment,
Expand Down
82 changes: 82 additions & 0 deletions src/backend/fastapi_app/setup_postgres_cars_seeddata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import argparse
import asyncio
import json
import logging
import os

import numpy as np
import sqlalchemy.exc
from dotenv import load_dotenv
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import async_sessionmaker

from fastapi_app.postgres_engine import (
create_postgres_engine_from_args,
create_postgres_engine_from_env,
)
from fastapi_app.postgres_models import Car

logger = logging.getLogger("ragapp")


async def seed_data(engine):
# Check if cars table exists
async with engine.begin() as conn:
table_name = Car.__tablename__
result = await conn.execute(
text(
f"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '{table_name}')" # noqa
)
)
if not result.scalar():
logger.error(f"{table_name} table does not exist. Please run the database setup script first.")
return

async with async_sessionmaker(engine, expire_on_commit=False)() as session:
current_dir = os.path.dirname(os.path.realpath(__file__))
with open(os.path.join(current_dir, "cars_seed_data.json")) as f:
seed_data_objects = json.load(f)
for seed_data_object in seed_data_objects:
db_car = await session.execute(select(Car).filter(Car.id == seed_data_object["id"]))
if db_car.scalars().first():
logger.info(f"Car with id {seed_data_object['id']} already exists, skipping.")
continue
attrs = {key: value for key, value in seed_data_object.items()}
attrs["embedding_3l"] = np.array(seed_data_object["embedding_3l"]) if seed_data_object.get("embedding_3l") is not None else None
attrs["embedding_nomic"] = np.array(seed_data_object["embedding_nomic"]) if seed_data_object.get("embedding_nomic") is not None else None
column_names = ", ".join(attrs.keys())
values = ", ".join([f":{key}" for key in attrs.keys()])
await session.execute(text(f"INSERT INTO {table_name} ({column_names}) VALUES ({values})"), attrs)
try:
await session.commit()
except sqlalchemy.exc.IntegrityError:
pass

logger.info(f"{table_name} table seeded successfully.")


async def main():
parser = argparse.ArgumentParser(description="Seed cars data")
parser.add_argument("--host", type=str, help="Postgres host")
parser.add_argument("--username", type=str, help="Postgres username")
parser.add_argument("--password", type=str, help="Postgres password")
parser.add_argument("--database", type=str, help="Postgres database")
parser.add_argument("--sslmode", type=str, help="Postgres sslmode")
parser.add_argument("--tenant-id", type=str, help="Azure tenant ID", default=None)

args = parser.parse_args()
if args.host is None:
engine = await create_postgres_engine_from_env()
else:
engine = await create_postgres_engine_from_args(args)

await seed_data(engine)

await engine.dispose()


if __name__ == "__main__":
logging.basicConfig(level=logging.WARNING)
logger.setLevel(logging.INFO)
load_dotenv(override=True)
asyncio.run(main())
4 changes: 2 additions & 2 deletions src/backend/fastapi_app/setup_postgres_seeddata.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ async def seed_data(engine):
if db_item.scalars().first():
continue
attrs = {key: value for key, value in seed_data_object.items()}
attrs["embedding_3l"] = np.array(seed_data_object["embedding_3l"])
attrs["embedding_nomic"] = np.array(seed_data_object["embedding_nomic"])
attrs["embedding_3l"] = np.array(seed_data_object["embedding_3l"]) if seed_data_object.get("embedding_3l") is not None else None
attrs["embedding_nomic"] = np.array(seed_data_object["embedding_nomic"]) if seed_data_object.get("embedding_nomic") is not None else None
column_names = ", ".join(attrs.keys())
values = ", ".join([f":{key}" for key in attrs.keys()])
await session.execute(text(f"INSERT INTO {table_name} ({column_names}) VALUES ({values})"), attrs)
Expand Down
Loading