diff --git a/README.md b/README.md index 7b6325dd..cd376788 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/Azure-Samples/rag-postgres-openai-python) [![Open in Dev Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Dev%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/azure-samples/rag-postgres-openai-python) -This project creates a web-based chat application with an API backend that can use OpenAI chat models to answer questions about the items in a PostgreSQL database table. The frontend is built with React and FluentUI, while the backend is written with Python and FastAPI. +This project creates a web-based chat application with an API backend that can use OpenAI chat models to answer questions about the rows in a PostgreSQL database table. The frontend is built with React and FluentUI, while the backend is written with Python and FastAPI. This project is designed for deployment to Azure using [the Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/), hosting the app on Azure Container Apps, the database in Azure PostgreSQL Flexible Server, and the models in Azure OpenAI. @@ -191,6 +191,7 @@ Additionally, we have added a [GitHub Action](https://github.com/microsoft/secur Further documentation is available in the `docs/` folder: +* [Customizing the data](docs/customize_data.md) * [Deploying with existing resources](docs/deploy_existing.md) * [Monitoring with Azure Monitor](docs/monitoring.md) * [Load testing](docs/loadtesting.md) diff --git a/docs/README.md b/docs/README.md index 2b60af51..f351eb9a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ page_type: sample urlFragment: rag-postgres-openai-python --- -This project creates a web-based chat application with an API backend that can use OpenAI chat models to answer questions about the items in a PostgreSQL database table. The frontend is built with React and FluentUI, while the backend is written with Python and FastAPI. +This project creates a web-based chat application with an API backend that can use OpenAI chat models to answer questions about the rows in a PostgreSQL database table. The frontend is built with React and FluentUI, while the backend is written with Python and FastAPI. This project is designed for deployment to Azure using [the Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/), hosting the app on Azure Container Apps, the database in Azure PostgreSQL Flexible Server, and the models in Azure OpenAI. diff --git a/docs/customize_data.md b/docs/customize_data.md new file mode 100644 index 00000000..0fe9afc4 --- /dev/null +++ b/docs/customize_data.md @@ -0,0 +1,55 @@ +# Customizing the data + +This guide shows you how to bring in a table with a different schema than the sample table. + +## Define the table schema + +1. Update seed_data.json file with the new data +2. Update the SQLAlchemy models in postgres_models.py to reflect the new schema +3. Add the new table to the database: + + ```shell + python src/backend/fastapi_app/setup_postgres_database.py + ``` + + These scripts will run on the local database by default. They will run on the production database as part of the `azd up` deployment process. + +## Add embeddings to the seed data + +If you don't yet have any embeddings in `seed_data.json`: + +1. Update the references to models in update_embeddings.py +2. Generate new embeddings for the seed data: + + ```shell + python src/backend/fastapi_app/update_embeddings.py --in_seed_data + ``` + + That script will use whatever OpenAI host is defined in the `.env` file. + You may want to run it twice for multiple models, once for Azure OpenAI embedding model and another for Ollama embedding model. Change `OPENAI_EMBED_HOST` between runs. + +## Add the seed data to the database + + + ```shell + python src/backend/fastapi_app/setup_postgres_seeddata.py + ``` + +## Update the LLM prompts + +3. Update the question answering prompt at `src/backend/fastapi_app/prompts/answer.txt` to reflect the new domain. +4. Update the function calling definition in `src/backend/fastapi_app/query_rewriter.py` to reflect the new schema and domain. Replace the `brand_filter` and `price_filter` with any filters that are relevant to your new schema. +5. Update the query rewriting prompt at `src/backend/fastapi_app/prompts/query.txt` to reflect the new domain and filters. +6. Update the query rewriting examples at `src/backend/fastapi_app/prompts/query_fewshots.json` to match the new domain and filters. + +## Update the API + +The FastAPI routes use type annotations to define the schema of the data that they accept and return, so you'll need to update the API to reflect the new schema. + +1. Modify `ItemPublic` in `src/backend/fastapi_app/api_models.py` to reflect the new schema. +2. Modify `RAGContext` if your schema uses a string ID instead of integer ID. + +## Update the frontend + +1. Modify the Answer component in `src/frontend/src/components/Answer/Answer.tsx` to display the desired fields from the new schema. +2. Modify the examples in `/workspace/src/frontend/src/components/Example/ExampleList.tsx` to examples for the new domain. diff --git a/src/backend/fastapi_app/postgres_searcher.py b/src/backend/fastapi_app/postgres_searcher.py index 5a5a8293..1953d2a1 100644 --- a/src/backend/fastapi_app/postgres_searcher.py +++ b/src/backend/fastapi_app/postgres_searcher.py @@ -41,10 +41,10 @@ async def search( self, query_text: str | None, query_vector: list[float] | list, top: int = 5, filters: list[dict] | None = None ): filter_clause_where, filter_clause_and = self.build_filter_clause(filters) - + table_name = Item.__tablename__ vector_query = f""" SELECT id, RANK () OVER (ORDER BY {self.embedding_column} <=> :embedding) AS rank - FROM items + FROM {table_name} {filter_clause_where} ORDER BY {self.embedding_column} <=> :embedding LIMIT 20 @@ -52,7 +52,7 @@ async def search( fulltext_query = f""" SELECT id, RANK () OVER (ORDER BY ts_rank_cd(to_tsvector('english', description), query) DESC) - FROM items, plainto_tsquery('english', :query) query + FROM {table_name}, plainto_tsquery('english', :query) query WHERE to_tsvector('english', description) @@ query {filter_clause_and} ORDER BY ts_rank_cd(to_tsvector('english', description), query) DESC LIMIT 20 @@ -91,12 +91,12 @@ async def search( ) ).fetchall() - # Convert results to Item models - items = [] + # Convert results to SQLAlchemy models + row_models = [] for id, _ in results[:top]: item = await self.db_session.execute(select(Item).where(Item.id == id)) - items.append(item.scalar()) - return items + row_models.append(item.scalar()) + return row_models async def search_and_embed( self, @@ -107,7 +107,7 @@ async def search_and_embed( filters: list[dict] | None = None, ) -> list[Item]: """ - Search items by query text. Optionally converts the query text to a vector if enable_vector_search is True. + Search rows by query text. Optionally converts the query text to a vector if enable_vector_search is True. """ vector: list[float] = [] if enable_vector_search and query_text is not None: diff --git a/src/backend/fastapi_app/rag_advanced.py b/src/backend/fastapi_app/rag_advanced.py index 98f64019..b3a253fe 100644 --- a/src/backend/fastapi_app/rag_advanced.py +++ b/src/backend/fastapi_app/rag_advanced.py @@ -78,7 +78,7 @@ async def prepare_context( query_response_token_limit=500, ) - # Retrieve relevant items from the database with the GPT optimized query + # Retrieve relevant rows from the database with the GPT optimized query results = await self.searcher.search_and_embed( query_text, top=chat_params.top, diff --git a/src/backend/fastapi_app/rag_simple.py b/src/backend/fastapi_app/rag_simple.py index dc98a34b..638f03ea 100644 --- a/src/backend/fastapi_app/rag_simple.py +++ b/src/backend/fastapi_app/rag_simple.py @@ -35,9 +35,9 @@ def __init__( async def prepare_context( self, chat_params: ChatParams ) -> tuple[list[ChatCompletionMessageParam], list[Item], list[ThoughtStep]]: - """Retrieve relevant items from the database and build a context for the chat model.""" + """Retrieve relevant rows from the database and build a context for the chat model.""" - # Retrieve relevant items from the database + # Retrieve relevant rows from the database results = await self.searcher.search_and_embed( chat_params.original_user_query, top=chat_params.top, diff --git a/src/backend/fastapi_app/setup_postgres_seeddata.py b/src/backend/fastapi_app/setup_postgres_seeddata.py index 4b1e0777..28956c58 100644 --- a/src/backend/fastapi_app/setup_postgres_seeddata.py +++ b/src/backend/fastapi_app/setup_postgres_seeddata.py @@ -21,41 +21,34 @@ async def seed_data(engine): # Check if Item table exists async with engine.begin() as conn: + table_name = Item.__tablename__ result = await conn.execute( text( - "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'items')" # noqa + 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("Items table does not exist. Please run the database setup script first.") + 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: - # Insert the items from the JSON file into the database + # Insert the objects from the JSON file into the database current_dir = os.path.dirname(os.path.realpath(__file__)) with open(os.path.join(current_dir, "seed_data.json")) as f: - catalog_items = json.load(f) - for catalog_item in catalog_items: - db_item = await session.execute(select(Item).filter(Item.id == catalog_item["id"])) + seed_data_objects = json.load(f) + for seed_data_object in seed_data_objects: + db_item = await session.execute(select(Item).filter(Item.id == seed_data_object["id"])) if db_item.scalars().first(): continue - item = Item( - id=catalog_item["id"], - type=catalog_item["type"], - brand=catalog_item["brand"], - name=catalog_item["name"], - description=catalog_item["description"], - price=catalog_item["price"], - embedding_ada002=catalog_item["embedding_ada002"], - embedding_nomic=catalog_item.get("embedding_nomic"), - ) - session.add(item) + attrs = {key: value for key, value in seed_data_object.items()} + row = Item(**attrs) + session.add(row) try: await session.commit() except sqlalchemy.exc.IntegrityError: pass - logger.info("Items table seeded successfully.") + logger.info(f"{table_name} table seeded successfully.") async def main(): diff --git a/src/backend/fastapi_app/update_embeddings.py b/src/backend/fastapi_app/update_embeddings.py index 35a640e3..15165751 100644 --- a/src/backend/fastapi_app/update_embeddings.py +++ b/src/backend/fastapi_app/update_embeddings.py @@ -1,3 +1,4 @@ +import argparse import asyncio import json import logging @@ -33,44 +34,37 @@ async def update_embeddings(in_seed_data=False): logger.info(f"Updating embeddings in column: {embedding_column}") if in_seed_data: current_dir = os.path.dirname(os.path.realpath(__file__)) - items = [] + rows = [] with open(os.path.join(current_dir, "seed_data.json")) as f: - catalog_items = json.load(f) - for catalog_item in catalog_items: - item = Item( - id=catalog_item["id"], - type=catalog_item["type"], - brand=catalog_item["brand"], - name=catalog_item["name"], - description=catalog_item["description"], - price=catalog_item["price"], - embedding_ada002=catalog_item["embedding_ada002"], - embedding_nomic=catalog_item.get("embedding_nomic"), - ) + seed_data_objects = json.load(f) + for seed_data_object in seed_data_objects: + # for each column in the JSON, store it in the same named attribute in the object + attrs = {key: value for key, value in seed_data_object.items()} + row = Item(**attrs) embedding = await compute_text_embedding( - item.to_str_for_embedding(), + row.to_str_for_embedding(), openai_client=openai_embed_client, embed_model=common_params.openai_embed_model, embed_deployment=common_params.openai_embed_deployment, embedding_dimensions=common_params.openai_embed_dimensions, ) - setattr(item, embedding_column, embedding) - items.append(item) - # write to the file + setattr(row, embedding_column, embedding) + rows.append(row) + # Write updated seed data to the file with open(os.path.join(current_dir, "seed_data.json"), "w") as f: - json.dump([item.to_dict(include_embedding=True) for item in items], f, indent=4) + json.dump([row.to_dict(include_embedding=True) for row in rows], f, indent=4) return async with async_sessionmaker(engine, expire_on_commit=False)() as session: async with session.begin(): - items_to_update = (await session.scalars(select(Item))).all() + rows_to_update = (await session.scalars(select(Item))).all() - for item in items_to_update: + for row_model in rows_to_update: setattr( - item, + row_model, embedding_column, await compute_text_embedding( - item.to_str_for_embedding(), + row_model.to_str_for_embedding(), openai_client=openai_embed_client, embed_model=common_params.openai_embed_model, embed_deployment=common_params.openai_embed_deployment, @@ -84,4 +78,8 @@ async def update_embeddings(in_seed_data=False): logging.basicConfig(level=logging.WARNING) logger.setLevel(logging.INFO) load_dotenv(override=True) - asyncio.run(update_embeddings()) + + parser = argparse.ArgumentParser() + parser.add_argument("--in_seed_data", action="store_true") + args = parser.parse_args() + asyncio.run(update_embeddings(args.in_seed_data))