{ "cells": [ { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "# Azure AI Search as a vector database + Azure Functions for GPT integration in ChatGPT" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "This notebook provides step by step instuctions on using Azure AI Search (f.k.a Azure Cognitive Search) as a vector database with OpenAI embeddings, then creating an Azure Function on top to plug into a Custom GPT in ChatGPT. \n", "\n", "This can be a solution for customers looking to set up RAG infrastructure contained within Azure, and exposing it as an endpoint to integrate that with other platforms such as ChatGPT.\n", "\n", "Azure AI Search is a cloud search service that gives developers infrastructure, APIs, and tools for building a rich search experience over private, heterogeneous content in web, mobile, and enterprise applications. \n", "\n", "Azure Functions is a serverless compute service that runs event-driven code, automatically managing infrastructure, scaling, and integrating with other Azure services." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Prerequisites:\n", "For the purposes of this exercise you must have the following:\n", "- Azure user with permission to create [Azure AI Search Service](https://learn.microsoft.com/azure/search/) and Azure Function Apps\n", "- Azure subscription ID and a resource group.\n", "- [OpenAI Key](https://platform.openai.com/account/api-keys) " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Architecture\n", "Below is a diagram of the architecture of this solution, which we'll walk through step-by-step.\n", "\n", "![azure-rag-architecture.png](../../../../images/azure-rag-architecture.png)\n", "\n", "\n", "> Note: This architecture pattern of vector data store + serverless functions can be extrapolated to other vector data stores. For example, if you would want to use something like Postgres within Azure, you'd change the [Configure Azure AI Search Settings](#configure-azure-ai-search-settings) step to set-up the requirements for Postgres, you'd modify the [Create Azure AI Vector Search](#create-azure-ai-vector-search) to create the database and table in Postgres instead, and you'd update the `function_app.py` code in this repository to query Postgres instead of Azure AI Search. The data preparation and creation of the Azure Function would stay consistent. \n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Table of Contents:" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "1. **[Setup of Environment](#set-up-environment)**\n", " Setup environment by installing and importing the required libraries and configuring our Azure settings. Includes:\n", " - [Install and Import Required Libraries](#install-and-import-required-libraries)\n", " - [Configure OpenAI Settings](#configure-openai-settings)\n", " - [Configure Azure AI Search Settings](#configure-azure-ai-search-settings)\n", " \n", "\n", "2. **[Prepare Data](#prepare-data)** Prepare the data for uploading by embedding the documents, as well as capturing additional metadata. We will use a subset of OpenAI's docs as example data for this.\n", " \n", "3. **[Create Azure AI Vector Search](#create-azure-ai-vector-search)** Create an Azure AI Vector Search and upload the data we've prepared. Includes:\n", " - [Create Index](#create-index): Steps to create an index in Azure AI Search.\n", " - [Upload Data](#upload-data): Instructions to upload data to Azure AI Search.\n", " - [Test Search](#test-search): Steps to test the search functionality.\n", " \n", "4. **[Create Azure Function](#create-azure-function)** Create an Azure Function to interact with the Azure AI Vector Search. Includes:\n", " - [Create Storage Account](#create-storage-account): Steps to create a storage account for the Azure Function.\n", " - [Create Function App](#create-function-app): Instructions to create a function app in Azure.\n", " \n", "5. **[Input in a Custom GPT in ChatGPT](#input-in-a-custom-gpt-in-chatgpt)** Integrate the Azure Function with a Custom GPT in ChatGPT. Includes:\n", " - [Create OpenAPI Spec](#create-openapi-spec): Steps to create an OpenAPI specification for the Azure Function.\n", " - [Create GPT Instructions](#create-gpt-instructions): Instructions to create GPT-specific instructions for the integration.\n", "\n", "\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Set up environment\n", "We'll set up our environment by importing the required libraries and configuring our Azure settings." ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## Install and import required libraries\n", "We categorize these libraries into standard Python libraries, third-party libraries, and Azure-related libraries for readability." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "! pip install -q wget\n", "! pip install -q azure-search-documents \n", "! pip install -q azure-identity\n", "! pip install -q openai\n", "! pip install -q azure-mgmt-search\n", "! pip install -q pandas\n", "! pip install -q azure-mgmt-resource \n", "! pip install -q azure-mgmt-storage\n", "! pip install -q pyperclip\n", "! pip install -q PyPDF2\n", "! pip install -q tiktoken" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Standard Libraries\n", "import json \n", "import os\n", "import platform\n", "import subprocess\n", "import csv\n", "from itertools import islice\n", "import uuid\n", "import shutil\n", "import concurrent.futures\n", "\n", "# Third-Party Libraries\n", "import pandas as pd\n", "from PyPDF2 import PdfReader\n", "import tiktoken\n", "from dotenv import load_dotenv\n", "import pyperclip\n", "\n", "# OpenAI Libraries (note we use OpenAI directly here, but you can replace with Azure OpenAI as needed)\n", "from openai import OpenAI\n", "\n", "# Azure Identity and Credentials\n", "from azure.identity import DefaultAzureCredential, InteractiveBrowserCredential\n", "from azure.core.credentials import AzureKeyCredential \n", "from azure.core.exceptions import HttpResponseError\n", "\n", "# Azure Search Documents\n", "from azure.search.documents import SearchClient, SearchIndexingBufferedSender \n", "from azure.search.documents.indexes import SearchIndexClient \n", "from azure.search.documents.models import (\n", " VectorizedQuery\n", ")\n", "from azure.search.documents.indexes.models import (\n", " HnswAlgorithmConfiguration,\n", " HnswParameters,\n", " SearchField,\n", " SearchableField,\n", " SearchFieldDataType,\n", " SearchIndex,\n", " SimpleField,\n", " VectorSearch,\n", " VectorSearchAlgorithmKind,\n", " VectorSearchAlgorithmMetric,\n", " VectorSearchProfile,\n", ")\n", "\n", "# Azure Management Clients\n", "from azure.mgmt.search import SearchManagementClient\n", "from azure.mgmt.resource import ResourceManagementClient, SubscriptionClient\n", "from azure.mgmt.storage import StorageManagementClient" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## Configure OpenAI settings\n", "\n", "Before going through this section, make sure you have your OpenAI API key.\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "openai_api_key = os.environ.get(\"OPENAI_API_KEY\", \"\") # Saving this as a variable to reference in function app in later step\n", "openai_client = OpenAI(api_key=openai_api_key)\n", "embeddings_model = \"text-embedding-3-small\" # We'll use this by default, but you can change to your text-embedding-3-large if desired" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## Configure Azure AI Search Settings\n", "You can locate your Azure AI Search service details in the Azure Portal or programmatically via the [Search Management SDK](https://learn.microsoft.com/rest/api/searchmanagement/).\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Prerequisites:\n", "- Subscription ID from Azure\n", "- Resource Group name from Azure\n", "- Region in Azure" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Update the below with your values\n", "subscription_id=\"\"\n", "resource_group=\"\"\n", "\n", "## Make sure to choose a region that supports the proper products. We've defaulted to \"eastus\" below. https://azure.microsoft.com/en-us/explore/global-infrastructure/products-by-region/#products-by-region_tab5\n", "region = \"eastus\"\n", "credential = InteractiveBrowserCredential()\n", "subscription_client = SubscriptionClient(credential)\n", "subscription = next(subscription_client.subscriptions.list())" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Create and Configure Azure AI Search Service\n", "Below we'll generate a unique name for the search service, set up the service properties, and create the search service." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Initialize the SearchManagementClient with the provided credentials and subscription ID\n", "search_management_client = SearchManagementClient(\n", " credential=credential,\n", " subscription_id=subscription_id,\n", ")\n", "\n", "# Generate a unique name for the search service using UUID, but you can change this if you'd like.\n", "generated_uuid = str(uuid.uuid4())\n", "search_service_name = \"search-service-gpt-demo\" + generated_uuid\n", "## The below is the default endpoint structure that is created when you create a search service. This may differ based on your Azure settings.\n", "search_service_endpoint = 'https://'+search_service_name+'.search.windows.net'\n", "\n", "# Create or update the search service with the specified parameters\n", "response = search_management_client.services.begin_create_or_update(\n", " resource_group_name=resource_group,\n", " search_service_name=search_service_name,\n", " service={\n", " \"location\": region,\n", " \"properties\": {\"hostingMode\": \"default\", \"partitionCount\": 1, \"replicaCount\": 1},\n", " # We are using the free pricing tier for this demo. You are only allowed one free search service per subscription.\n", " \"sku\": {\"name\": \"free\"},\n", " \"tags\": {\"app-name\": \"Search service demo\"},\n", " },\n", ").result()\n", "\n", "# Convert the response to a dictionary and then to a pretty-printed JSON string\n", "response_dict = response.as_dict()\n", "response_json = json.dumps(response_dict, indent=4)\n", "\n", "print(response_json)\n", "print(\"Search Service Name:\" + search_service_name)\n", "print(\"Search Service Endpoint:\" + search_service_endpoint)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "#### Get the Search Service API Key\n", "Now that we have the search service up and running, we need the [Search Service API Key](https://learn.microsoft.com/en-us/azure/search/search-security-api-keys?tabs=rest-use,portal-find,portal-query), which we'll use to initiate the index creation, and later to execute the search." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Retrieve the admin keys for the search service\n", "try:\n", " response = search_management_client.admin_keys.get(\n", " resource_group_name=resource_group,\n", " search_service_name=search_service_name,\n", " )\n", " # Extract the primary API key from the response and save as a variable to be used later\n", " search_service_api_key = response.primary_key\n", " print(\"Successfully retrieved the API key.\")\n", "except Exception as e:\n", " print(f\"Failed to retrieve the API key: {e}\")" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "# Prepare data\n", "We're going to embed and store a few pages of the OpenAI docs in the oai_docs folder. We'll first embed each, add it to a CSV, and then use that CSV to upload to the index.\n", "\n", "In order to handle longer text files beyond the context of 8191 tokens, we can either use the chunk embeddings separately, or combine them in some way, such as averaging (weighted by the size of each chunk).\n", "\n", "We will take a function from Python's own cookbook that breaks up a sequence into chunks." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def batched(iterable, n):\n", " \"\"\"Batch data into tuples of length n. The last batch may be shorter.\"\"\"\n", " # batched('ABCDEFG', 3) --> ABC DEF G\n", " if n < 1:\n", " raise ValueError('n must be at least one')\n", " it = iter(iterable)\n", " while (batch := tuple(islice(it, n))):\n", " yield batch\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now we define a function that encodes a string into tokens and then breaks it up into chunks. We'll use tiktoken, a fast open-source tokenizer by OpenAI.\n", "\n", "To read more about counting tokens with Tiktoken, check out [this cookbook](https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken). \n", "\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def chunked_tokens(text, chunk_length, encoding_name='cl100k_base'):\n", " # Get the encoding object for the specified encoding name. OpenAI's tiktoken library, which is used in this notebook, currently supports two encodings: 'bpe' and 'cl100k_base'. The 'bpe' encoding is used for GPT-3 and earlier models, while 'cl100k_base' is used for newer models like GPT-4.\n", " encoding = tiktoken.get_encoding(encoding_name)\n", " # Encode the input text into tokens\n", " tokens = encoding.encode(text)\n", " # Create an iterator that yields chunks of tokens of the specified length\n", " chunks_iterator = batched(tokens, chunk_length)\n", " # Yield each chunk from the iterator\n", " yield from chunks_iterator" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Finally, we can write a function that safely handles embedding requests, even when the input text is longer than the maximum context length, by chunking the input tokens and embedding each chunk individually. The average flag can be set to True to return the weighted average of the chunk embeddings, or False to simply return the unmodified list of chunk embeddings.\n", "\n", "> Note: there are other, more sophisticated techniques you can take here, including:\n", "> - using GPT-4o to capture images/chart descriptions for embedding.\n", "> - keeping text overlap between the chunks to minimize cutting off important context.\n", "> - chunking based on paragraphs or sections.\n", "> - adding more descriptive metadata about each article." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "## Change the below based on model. The below is for the latest embeddings models from OpenAI, so you can leave as is unless you are using a different embedding model..\n", "EMBEDDING_CTX_LENGTH = 8191\n", "EMBEDDING_ENCODING='cl100k_base'" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def generate_embeddings(text, model):\n", " # Generate embeddings for the provided text using the specified model\n", " embeddings_response = openai_client.embeddings.create(model=model, input=text)\n", " # Extract the embedding data from the response\n", " embedding = embeddings_response.data[0].embedding\n", " return embedding\n", "\n", "def len_safe_get_embedding(text, model=embeddings_model, max_tokens=EMBEDDING_CTX_LENGTH, encoding_name=EMBEDDING_ENCODING):\n", " # Initialize lists to store embeddings and corresponding text chunks\n", " chunk_embeddings = []\n", " chunk_texts = []\n", " # Iterate over chunks of tokens from the input text\n", " for chunk in chunked_tokens(text, chunk_length=max_tokens, encoding_name=encoding_name):\n", " # Generate embeddings for each chunk and append to the list\n", " chunk_embeddings.append(generate_embeddings(chunk, model=model))\n", " # Decode the chunk back to text and append to the list\n", " chunk_texts.append(tiktoken.get_encoding(encoding_name).decode(chunk))\n", " # Return the list of chunk embeddings and the corresponding text chunks\n", " return chunk_embeddings, chunk_texts" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Next, we can define a helper function that will capture additional metadata about the documents. This is useful to use as a metadata filter for search queries, and capturing richer data for search. \n", "\n", "In this example, I'll choose from a list of categories to use later on in a metadata filter." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "## These are the categories I will be using for the categorization task. You can change these as needed based on your use case.\n", "categories = ['authentication','models','techniques','tools','setup','billing_limits','other']\n", "\n", "def categorize_text(text, categories):\n", " # Create a prompt for categorization\n", " messages = [\n", " {\"role\": \"system\", \"content\": f\"\"\"You are an expert in LLMs, and you will be given text that corresponds to an article in OpenAI's documentation.\n", " Categorize the document into one of these categories: {', '.join(categories)}. Only respond with the category name and nothing else.\"\"\"},\n", " {\"role\": \"user\", \"content\": text}\n", " ]\n", " try:\n", " # Call the OpenAI API to categorize the text\n", " response = openai_client.chat.completions.create(\n", " model=\"gpt-4o\",\n", " messages=messages\n", " )\n", " # Extract the category from the response\n", " category = response.choices[0].message.content\n", " return category\n", " except Exception as e:\n", " print(f\"Error categorizing text: {str(e)}\")\n", " return None" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now, we can define some helper functions to process the .txt files in the oai_docs folder within the data folder. You can use this with your own data as well and supports both .txt and .pdf files." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def extract_text_from_pdf(pdf_path):\n", " # Initialize the PDF reader\n", " reader = PdfReader(pdf_path)\n", " text = \"\"\n", " # Iterate through each page in the PDF and extract text\n", " for page in reader.pages:\n", " text += page.extract_text()\n", " return text\n", "\n", "def process_file(file_path, idx, categories, embeddings_model):\n", " file_name = os.path.basename(file_path)\n", " print(f\"Processing file {idx + 1}: {file_name}\")\n", " \n", " # Read text content from .txt files\n", " if file_name.endswith('.txt'):\n", " with open(file_path, 'r', encoding='utf-8') as file:\n", " text = file.read()\n", " # Extract text content from .pdf files\n", " elif file_name.endswith('.pdf'):\n", " text = extract_text_from_pdf(file_path)\n", " \n", " title = file_name\n", " # Generate embeddings for the title\n", " title_vectors, title_text = len_safe_get_embedding(title, embeddings_model)\n", " print(f\"Generated title embeddings for {file_name}\")\n", " \n", " # Generate embeddings for the content\n", " content_vectors, content_text = len_safe_get_embedding(text, embeddings_model)\n", " print(f\"Generated content embeddings for {file_name}\")\n", " \n", " category = categorize_text(' '.join(content_text), categories)\n", " print(f\"Categorized {file_name} as {category}\")\n", " \n", " # Prepare the data to be appended\n", " data = []\n", " for i, content_vector in enumerate(content_vectors):\n", " data.append({\n", " \"id\": f\"{idx}_{i}\",\n", " \"vector_id\": f\"{idx}_{i}\",\n", " \"title\": title_text[0],\n", " \"text\": content_text[i],\n", " \"title_vector\": json.dumps(title_vectors[0]), # Assuming title is short and has only one chunk\n", " \"content_vector\": json.dumps(content_vector),\n", " \"category\": category\n", " })\n", " print(f\"Appended data for chunk {i + 1}/{len(content_vectors)} of {file_name}\")\n", " \n", " return data\n", "\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We'll now use this helper function to process our OpenAI documentation. Feel free to update this to use your own data by changing the folder in `process_files` below.\n", "\n", "Note that this will process the documents in chosen folder concurrently, so this should take <30 seconds if using txt files, and slightly longer if using PDFs." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "## Customize the location below if you are using different data besides the OpenAI documentation. Note that if you are using a different dataset, you will need to update the categories list as well.\n", "folder_name = \"../../../data/oai_docs\"\n", "\n", "files = [os.path.join(folder_name, f) for f in os.listdir(folder_name) if f.endswith('.txt') or f.endswith('.pdf')]\n", "data = []\n", "\n", "# Process each file concurrently\n", "with concurrent.futures.ThreadPoolExecutor() as executor:\n", " futures = {executor.submit(process_file, file_path, idx, categories, embeddings_model): idx for idx, file_path in enumerate(files)}\n", " for future in concurrent.futures.as_completed(futures):\n", " try:\n", " result = future.result()\n", " data.extend(result)\n", " except Exception as e:\n", " print(f\"Error processing file: {str(e)}\")\n", "\n", "# Write the data to a CSV file\n", "csv_file = os.path.join(\"..\", \"embedded_data.csv\")\n", "with open(csv_file, 'w', newline='', encoding='utf-8') as csvfile:\n", " fieldnames = [\"id\", \"vector_id\", \"title\", \"text\", \"title_vector\", \"content_vector\",\"category\"]\n", " writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n", " writer.writeheader()\n", " for row in data:\n", " writer.writerow(row)\n", " print(f\"Wrote row with id {row['id']} to CSV\")\n", "\n", "# Convert the CSV file to a Dataframe\n", "article_df = pd.read_csv(\"../embedded_data.csv\")\n", "# Read vectors from strings back into a list using json.loads\n", "article_df[\"title_vector\"] = article_df.title_vector.apply(json.loads)\n", "article_df[\"content_vector\"] = article_df.content_vector.apply(json.loads)\n", "article_df[\"vector_id\"] = article_df[\"vector_id\"].apply(str)\n", "article_df[\"category\"] = article_df[\"category\"].apply(str)\n", "article_df.head()\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We now have an `embedded_data.csv` file with six columns that we can upload to our vector database! " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Create Azure AI Vector Search" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## Create index\n", "We'll define and create a search index using the `SearchIndexClient` from the Azure AI Search Python SDK. The index incorporates both vector search and hybrid search capabilities. For more details, visit Microsoft's documentation on how to [Create a Vector Index](https://learn.microsoft.com/azure/search/vector-search-how-to-create-index?.tabs=config-2023-11-01%2Crest-2023-11-01%2Cpush%2Cportal-check-index)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "index_name = \"azure-ai-search-openai-cookbook-demo\"\n", "# index_name = \"\"\n", "\n", "index_client = SearchIndexClient(\n", " endpoint=search_service_endpoint, credential=AzureKeyCredential(search_service_api_key)\n", ")\n", "# Define the fields for the index. Update these based on your data.\n", "# Each field represents a column in the search index\n", "fields = [\n", " SimpleField(name=\"id\", type=SearchFieldDataType.String), # Simple string field for document ID\n", " SimpleField(name=\"vector_id\", type=SearchFieldDataType.String, key=True), # Key field for the index\n", " # SimpleField(name=\"url\", type=SearchFieldDataType.String), # URL field (commented out)\n", " SearchableField(name=\"title\", type=SearchFieldDataType.String), # Searchable field for document title\n", " SearchableField(name=\"text\", type=SearchFieldDataType.String), # Searchable field for document text\n", " SearchField(\n", " name=\"title_vector\",\n", " type=SearchFieldDataType.Collection(SearchFieldDataType.Single), # Collection of single values for title vector\n", " vector_search_dimensions=1536, # Number of dimensions in the vector\n", " vector_search_profile_name=\"my-vector-config\", # Profile name for vector search configuration\n", " ),\n", " SearchField(\n", " name=\"content_vector\",\n", " type=SearchFieldDataType.Collection(SearchFieldDataType.Single), # Collection of single values for content vector\n", " vector_search_dimensions=1536, # Number of dimensions in the vector\n", " vector_search_profile_name=\"my-vector-config\", # Profile name for vector search configuration\n", " ),\n", " SearchableField(name=\"category\", type=SearchFieldDataType.String, filterable=True), # Searchable field for document category\n", "]\n", "\n", "# This configuration defines the algorithm and parameters for vector search\n", "vector_search = VectorSearch(\n", " algorithms=[\n", " HnswAlgorithmConfiguration(\n", " name=\"my-hnsw\", # Name of the HNSW algorithm configuration\n", " kind=VectorSearchAlgorithmKind.HNSW, # Type of algorithm\n", " parameters=HnswParameters(\n", " m=4, # Number of bi-directional links created for every new element\n", " ef_construction=400, # Size of the dynamic list for the nearest neighbors during construction\n", " ef_search=500, # Size of the dynamic list for the nearest neighbors during search\n", " metric=VectorSearchAlgorithmMetric.COSINE, # Distance metric used for the search\n", " ),\n", " )\n", " ],\n", " profiles=[\n", " VectorSearchProfile(\n", " name=\"my-vector-config\", # Name of the vector search profile\n", " algorithm_configuration_name=\"my-hnsw\", # Reference to the algorithm configuration\n", " )\n", " ],\n", ")\n", "\n", "# Create the search index with the vector search configuration\n", "# This combines all the configurations into a single search index\n", "index = SearchIndex(\n", " name=index_name, # Name of the index\n", " fields=fields, # Fields defined for the index\n", " vector_search=vector_search # Vector search configuration\n", "\n", ")\n", "\n", "# Create or update the index\n", "# This sends the index definition to the Azure Search service\n", "result = index_client.create_index(index)\n", "print(f\"{result.name} created\") # Output the name of the created index" ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "## Upload Data\n", "\n", "Now we'll upload the articles from above that we've stored in `embedded_data.csv` from a pandas DataFrame to an Azure AI Search index. For a detailed guide on data import strategies and best practices, refer to [Data Import in Azure AI Search](https://learn.microsoft.com/azure/search/search-what-is-data-import).\n" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Convert the 'id' and 'vector_id' columns to string so one of them can serve as our key field\n", "article_df[\"id\"] = article_df[\"id\"].astype(str)\n", "article_df[\"vector_id\"] = article_df[\"vector_id\"].astype(str)\n", "\n", "# Convert the DataFrame to a list of dictionaries\n", "documents = article_df.to_dict(orient=\"records\")\n", "\n", "# Log the number of documents to be uploaded\n", "print(f\"Number of documents to upload: {len(documents)}\")\n", "\n", "# Create a SearchIndexingBufferedSender\n", "batch_client = SearchIndexingBufferedSender(\n", " search_service_endpoint, index_name, AzureKeyCredential(search_service_api_key)\n", ")\n", "# Get the first document to check its schema\n", "first_document = documents[0]\n", "\n", "# Get the index schema\n", "index_schema = index_client.get_index(index_name)\n", "\n", "# Get the field names from the index schema\n", "index_fields = {field.name: field.type for field in index_schema.fields}\n", "\n", "# Check each field in the first document\n", "for field, value in first_document.items():\n", " if field not in index_fields:\n", " print(f\"Field '{field}' is not in the index schema.\")\n", "\n", "# Check for any fields in the index schema that are not in the documents\n", "for field in index_fields:\n", " if field not in first_document:\n", " print(f\"Field '{field}' is in the index schema but not in the documents.\")\n", "\n", "try:\n", " if documents:\n", " # Add upload actions for all documents in a single call\n", " upload_result = batch_client.upload_documents(documents=documents)\n", "\n", " # Check if the upload was successful\n", " # Manually flush to send any remaining documents in the buffer\n", " batch_client.flush()\n", " \n", " print(f\"Uploaded {len(documents)} documents in total\")\n", " else:\n", " print(\"No documents to upload.\")\n", "except HttpResponseError as e:\n", " print(f\"An error occurred: {e}\")\n", " raise # Re-raise the exception to ensure it errors out\n", "finally:\n", " # Clean up resources\n", " batch_client.close()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Test search\n", "Now that the data is uploaded, we'll test both vector similarity search and hybrid search locally below to make sure it is working as expected." ] }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ "You can test both a pure vector search and hybrid search. Pure vector search passes in `None` to the `search_text` below and will only search on vector similarity. Hybrid search will combines the capabilities of traditional keyword-based search by passing in the query text `query` to the `search_text` with vector-based similarity search to provide more relevant and contextual results. " ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "query = \"What model should I use to embed?\"\n", "# Note: we'll have the GPT choose the category automatically once we put it in ChatGPT\n", "category =\"models\"\n", "\n", "search_client = SearchClient(search_service_endpoint, index_name, AzureKeyCredential(search_service_api_key))\n", "vector_query = VectorizedQuery(vector=generate_embeddings(query, embeddings_model), k_nearest_neighbors=3, fields=\"content_vector\")\n", " \n", "results = search_client.search( \n", " search_text=None, # Pass in None if you want to use pure vector search, and `query` if you want to use hybrid search\n", " vector_queries= [vector_query], \n", " select=[\"title\", \"text\"],\n", " filter=f\"category eq '{category}'\" \n", ")\n", "\n", "for result in results: \n", " print(result)\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Create Azure Function\n", "\n", "Azure Functions are an easy way to build an API on top of our new AI search. Our code (see the `function_app.py` file in this folder, or linked [here](https://github.com/openai/openai-cookbook/blob/main/examples/chatgpt/rag-quickstart/azure/function_app.py)) does the following:\n", "\n", "1. Takes in an input of the user's query, search index endpoint, the index name, the k_nearest_neighbors*, the search column to use (either content_vector or title_vector), and whether it should use a hybrid query\n", "2. Takes the user's query and embeds it.\n", "3. Conducts a vector search and retrieves relevant text chunks.\n", "4. Returns those relevant text chunks as the response body. \n", "\n", "*In the context of vector search, k_nearest_neighbors specifies the number of \"closest\" vectors (in terms of cosine similarity) that the search should return. For example, if k_nearest_neighbors is set to 3, the search will return the 3 vectors in the index that are most similar to the query vector.\n", "\n", "> Note that this Azure Function _does not have any authentication_. However, you can set authentication on it following docs [here](https://learn.microsoft.com/en-us/azure/azure-functions/security-concepts?tabs=v4)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Create storage account" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can create a new storage account using the code below, but feel free to skip that block and modify the subsequent steps to use an existing storage account. This may take up to 30 seconds." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "## Update below with a different name\n", "storage_account_name = \"\"\n", "\n", "## Use below SKU or any other SKU as per your requirement\n", "sku = \"Standard_LRS\"\n", "resource_client = ResourceManagementClient(credential, subscription_id)\n", "storage_client = StorageManagementClient(credential, subscription_id)\n", "\n", "# Create resource group if it doesn't exist\n", "rg_result = resource_client.resource_groups.create_or_update(resource_group, {\"location\": region})\n", "\n", "# Create storage account\n", "storage_async_operation = storage_client.storage_accounts.begin_create(\n", " resource_group,\n", " storage_account_name,\n", " {\n", " \"sku\": {\"name\": sku},\n", " \"kind\": \"StorageV2\",\n", " \"location\": region,\n", " },\n", ")\n", "storage_account = storage_async_operation.result()\n", "\n", "print(f\"Storage account {storage_account.name} created\")\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Create Function App\n", "This Function App is where the python code will execute once it is triggered via a GPT Action. To read more about Function Apps, see the docs [here](https://learn.microsoft.com/en-us/azure/azure-functions/functions-overview?pivots=programming-language-csharp). " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "To deploy Function Apps, we'll need to use the Azure CLI and Azure Functions Core Tools. \n", "\n", "> The below will attempt to install it and run it based on your platform type in your virtual environment, but if that does not work, read the Azure documentation to figure out how to install [Azure Function Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/create-first-function-cli-python?tabs=linux,bash,azure-cli,browser) and [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli). After doing that, run the below `subprocess.run` commands in your terminal after navigating to this folder." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "First we'll make sure we have the relevant tools in the environment in order to run the Azure commands necessary. This may take a few minutes to install." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "os_type = platform.system()\n", "\n", "if os_type == \"Windows\":\n", " # Install Azure Functions Core Tools on Windows\n", " subprocess.run([\"npm\", \"install\", \"-g\", \"azure-functions-core-tools@3\", \"--unsafe-perm\", \"true\"], check=True)\n", " # Install Azure CLI on Windows\n", " subprocess.run([\"powershell\", \"-Command\", \"Invoke-WebRequest -Uri https://aka.ms/installazurecliwindows -OutFile .\\\\AzureCLI.msi; Start-Process msiexec.exe -ArgumentList '/I AzureCLI.msi /quiet' -Wait\"], check=True)\n", "elif os_type == \"Darwin\": # MacOS\n", " # Install Azure Functions Core Tools on MacOS\n", " if platform.machine() == 'arm64':\n", " # For M1 Macs\n", " subprocess.run([\"arch\", \"-arm64\", \"brew\", \"install\", \"azure-functions-core-tools@3\"], check=True)\n", " else:\n", " # For Intel Macs\n", " subprocess.run([\"brew\", \"install\", \"azure-functions-core-tools@3\"], check=True)\n", " # Install Azure CLI on MacOS\n", " subprocess.run([\"brew\", \"update\"], check=True)\n", " subprocess.run([\"brew\", \"install\", \"azure-cli\"], check=True)\n", "elif os_type == \"Linux\":\n", " # Install Azure Functions Core Tools on Linux\n", " subprocess.run([\"curl\", \"https://packages.microsoft.com/keys/microsoft.asc\", \"|\", \"gpg\", \"--dearmor\", \">\", \"microsoft.gpg\"], check=True, shell=True)\n", " subprocess.run([\"sudo\", \"mv\", \"microsoft.gpg\", \"/etc/apt/trusted.gpg.d/microsoft.gpg\"], check=True)\n", " subprocess.run([\"sudo\", \"sh\", \"-c\", \"'echo \\\"deb [arch=amd64] https://packages.microsoft.com/repos/microsoft-ubuntu-$(lsb_release -cs)-prod $(lsb_release -cs) main\\\" > /etc/apt/sources.list.d/dotnetdev.list'\"], check=True, shell=True)\n", " subprocess.run([\"sudo\", \"apt-get\", \"update\"], check=True)\n", " subprocess.run([\"sudo\", \"apt-get\", \"install\", \"azure-functions-core-tools-3\"], check=True)\n", " # Install Azure CLI on Linux\n", " subprocess.run([\"curl\", \"-sL\", \"https://aka.ms/InstallAzureCLIDeb\", \"|\", \"sudo\", \"bash\"], check=True, shell=True)\n", "else:\n", " # Raise an error if the operating system is not supported\n", " raise OSError(\"Unsupported operating system\")\n", "\n", "# Verify the installation of Azure Functions Core Tools\n", "subprocess.run([\"func\", \"--version\"], check=True)\n", "# Verify the installation of Azure CLI\n", "subprocess.run([\"az\", \"--version\"], check=True)\n", "\n", "subprocess.run([\n", " \"az\", \"login\"\n", "], check=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Now, we need to create a `local.settings.json` file with our key environment variables for Azure" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "local_settings_content = f\"\"\"\n", "{{\n", " \"IsEncrypted\": false,\n", " \"Values\": {{\n", " \"AzureWebJobsStorage\": \"UseDevelopmentStorage=true\",\n", " \"FUNCTIONS_WORKER_RUNTIME\": \"python\",\n", " \"OPENAI_API_KEY\": \"{openai_api_key}\",\n", " \"EMBEDDINGS_MODEL\": \"{embeddings_model}\",\n", " \"SEARCH_SERVICE_API_KEY\": \"{search_service_api_key}\",\n", " }}\n", "}}\n", "\"\"\"\n", "\n", "with open(\"local.settings.json\", \"w\") as file:\n", " file.write(local_settings_content)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Check the `local.settings.json` file and make sure that the environment variables match what you expect. \n", "\n", "Now, give your app a name below, and you are ready to create your Function App and then publish your function. " ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Replace this with your own values. This name will appear in the URL of the API call https://.azurewebsites.net\n", "app_name = \"\"\n", "\n", "subprocess.run([\n", " \"az\", \"functionapp\", \"create\",\n", " \"--resource-group\", resource_group,\n", " \"--consumption-plan-location\", region,\n", " \"--runtime\", \"python\",\n", " \"--name\", app_name,\n", " \"--storage-account\", storage_account_name,\n", " \"--os-type\", \"Linux\",\n", "], check=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Once we've created the Function App, we now want to add the configuration variables to the function app to use in the function. Specifically, we need the `OPENAI_API_KEY`, the `SEARCH_SERVICE_API_KEY`, and the `EMBEDDINGS_MODEL` as these are all used in the `function_app.py` code." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# Collect the relevant environment variables \n", "env_vars = {\n", " \"OPENAI_API_KEY\": openai_api_key,\n", " \"SEARCH_SERVICE_API_KEY\": search_service_api_key,\n", " \"EMBEDDINGS_MODEL\": embeddings_model\n", "}\n", "\n", "# Create the settings argument for the az functionapp create command\n", "settings_args = []\n", "for key, value in env_vars.items():\n", " settings_args.append(f\"{key}={value}\")\n", "\n", "subprocess.run([\n", " \"az\", \"functionapp\", \"config\", \"appsettings\", \"set\",\n", " \"--name\", app_name,\n", " \"--resource-group\", resource_group,\n", " \"--settings\", *settings_args\n", "], check=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We are now ready to publish your function code `function_app.py` to the Azure Function. This may take up to 10 minutes to deploy. Once this is finished, we now have an API endpoint using an Azure Function on top of Azure AI Search." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "subprocess.run([\n", " \"func\", \"azure\", \"functionapp\", \"publish\", app_name\n", "], check=True)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Input in a Custom GPT in ChatGPT\n", "Now that we have an Azure Function that queries this Vector Search Index, let's put it as a GPT Action!\n", "\n", "See documentation [here](https://openai.com/index/introducing-gpts/) on GPTs and [here](https://platform.openai.com/docs/actions) on GPT Actions. Use the below as the instructions for the GPT and as the OpenAPI spec for the GPT Action.\n" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Create OpenAPI Spec\n", "Below is a sample OpenAPI spec. When we run the block below, a functional spec should be copied to the clipboard to paste in the GPT Action.\n", "\n", "Note that this does not have any authentication by default, but you can set up Azure Functions with OAuth by following the pattern in [this cookbook](https://cookbook.openai.com/examples/chatgpt/gpt_actions_library/gpt_middleware_azure_function#part-2-set-up-auth) in the Authentication section or looking at the documentation [here](https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization)." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "\n", "spec = f\"\"\"\n", "openapi: 3.1.0\n", "info:\n", " title: Vector Similarity Search API\n", " description: API for performing vector similarity search.\n", " version: 1.0.0\n", "servers:\n", " - url: https://{app_name}.azurewebsites.net/api\n", " description: Main (production) server\n", "paths:\n", " /vector_similarity_search:\n", " post:\n", " operationId: vectorSimilaritySearch\n", " summary: Perform a vector similarity search.\n", " requestBody:\n", " required: true\n", " content:\n", " application/json:\n", " schema:\n", " type: object\n", " properties:\n", " search_service_endpoint:\n", " type: string\n", " description: The endpoint of the search service.\n", " index_name:\n", " type: string\n", " description: The name of the search index.\n", " query:\n", " type: string\n", " description: The search query.\n", " k_nearest_neighbors:\n", " type: integer\n", " description: The number of nearest neighbors to return.\n", " search_column:\n", " type: string\n", " description: The name of the search column.\n", " use_hybrid_query:\n", " type: boolean\n", " description: Whether to use a hybrid query.\n", " category:\n", " type: string\n", " description: category to filter.\n", " required:\n", " - search_service_endpoint\n", " - index_name\n", " - query\n", " - k_nearest_neighbors\n", " - search_column\n", " - use_hybrid_query\n", " responses:\n", " '200':\n", " description: A successful response with the search results.\n", " content:\n", " application/json:\n", " schema:\n", " type: object\n", " properties:\n", " results:\n", " type: array\n", " items:\n", " type: object\n", " properties:\n", " id:\n", " type: string\n", " description: The identifier of the result item.\n", " score:\n", " type: number\n", " description: The similarity score of the result item.\n", " content:\n", " type: object\n", " description: The content of the result item.\n", " '400':\n", " description: Bad request due to missing or invalid parameters.\n", " '500':\n", " description: Internal server error.\n", "\"\"\"\n", "pyperclip.copy(spec)\n", "print(\"OpenAPI spec copied to clipboard\")\n", "print(spec)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Create GPT Instructions\n", "\n", "Feel free to modify instructions as you see fit. Check out our docs [here](https://platform.openai.com/docs/guides/prompt-engineering) for some tips on prompt engineering." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "instructions = f'''\n", "You are an OAI docs assistant. You have an action in your knowledge base where you can make a POST request to search for information. The POST request should always include: {{\n", " \"search_service_endpoint\": \"{search_service_endpoint}\",\n", " \"index_name\": {index_name},\n", " \"query\": \"\",\n", " \"k_nearest_neighbors\": 1,\n", " \"search_column\": \"content_vector\",\n", " \"use_hybrid_query\": true,\n", " \"category\": \"\"\n", "}}. Only the query and category change based on the user's request. Your goal is to assist users by performing searches using this POST request and providing them with relevant information based on the query.\n", "\n", "You must only include knowledge you get from your action in your response.\n", "The category must be from the following list: {categories}, which you should determine based on the user's query. If you cannot determine, then do not include the category in the POST request.\n", "'''\n", "pyperclip.copy(instructions)\n", "print(\"GPT Instructions copied to clipboard\")\n", "print(instructions)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We now have a GPT that queries a vector database! " ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# Recap\n", "We've now successfully integrated Azure AI Search with GPT Actions in ChatGPT by doing the following:\n", "1. embedded them using OpenAI's embeddings, while adding some additional metadata using gpt-4o.\n", "2. uploaded that data to Azure AI Search.\n", "3. created an endpoint to query it using Azure Functions.\n", "4. incorporated it into a Custom GPT. \n", "\n", "Our GPT can now retrieve information to help answer user queries, making it much more accurate and customized to our data. Here's the GPT in action:" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# ![azure-rag-quickstart-gpt.png](../../../../images/azure-rag-quickstart-gpt.png)\n" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.11.9" }, "orig_nbformat": 4 }, "nbformat": 4, "nbformat_minor": 2 }