From 38cbd1f892344e30fa0b91047e1acc092773f38b Mon Sep 17 00:00:00 2001 From: Mo Omer Date: Mon, 3 Feb 2025 14:16:17 -0600 Subject: [PATCH] Add Jupyter Notebook and Data associated with 'LLM RAG for Beginners: A Practical Guide with Elasticsearch and OpenSearch' post --- nodejs/jupyter-labs/RAG/.gitattributes | 1 + nodejs/jupyter-labs/RAG/.gitignore | 3 + nodejs/jupyter-labs/RAG/RAG.ipynb | 814 ++++++++++++++++++ nodejs/jupyter-labs/RAG/README.md | 14 + .../RAG/assets/bonsai-cluster-details.png | Bin 0 -> 40757 bytes .../jupyter-labs/RAG/assets/index-diagram.svg | 1 + .../RAG/assets/sequenceDiagram.svg | 1 + .../RAG/movie-data/conversations.json | 3 + .../jupyter-labs/RAG/movie-data/corpus.json | 3 + nodejs/jupyter-labs/RAG/movie-data/index.json | 3 + .../jupyter-labs/RAG/movie-data/speakers.json | 3 + .../RAG/movie-data/utterances.jsonl | 3 + 12 files changed, 849 insertions(+) create mode 100644 nodejs/jupyter-labs/RAG/.gitattributes create mode 100644 nodejs/jupyter-labs/RAG/.gitignore create mode 100644 nodejs/jupyter-labs/RAG/RAG.ipynb create mode 100644 nodejs/jupyter-labs/RAG/README.md create mode 100644 nodejs/jupyter-labs/RAG/assets/bonsai-cluster-details.png create mode 100644 nodejs/jupyter-labs/RAG/assets/index-diagram.svg create mode 100644 nodejs/jupyter-labs/RAG/assets/sequenceDiagram.svg create mode 100644 nodejs/jupyter-labs/RAG/movie-data/conversations.json create mode 100644 nodejs/jupyter-labs/RAG/movie-data/corpus.json create mode 100644 nodejs/jupyter-labs/RAG/movie-data/index.json create mode 100644 nodejs/jupyter-labs/RAG/movie-data/speakers.json create mode 100644 nodejs/jupyter-labs/RAG/movie-data/utterances.jsonl diff --git a/nodejs/jupyter-labs/RAG/.gitattributes b/nodejs/jupyter-labs/RAG/.gitattributes new file mode 100644 index 0000000..6594b0a --- /dev/null +++ b/nodejs/jupyter-labs/RAG/.gitattributes @@ -0,0 +1 @@ +*.json* filter=lfs diff=lfs merge=lfs -text diff --git a/nodejs/jupyter-labs/RAG/.gitignore b/nodejs/jupyter-labs/RAG/.gitignore new file mode 100644 index 0000000..4877454 --- /dev/null +++ b/nodejs/jupyter-labs/RAG/.gitignore @@ -0,0 +1,3 @@ +archive/ +.idea/ +.ipynb_checkpoints/ diff --git a/nodejs/jupyter-labs/RAG/RAG.ipynb b/nodejs/jupyter-labs/RAG/RAG.ipynb new file mode 100644 index 0000000..8c1f622 --- /dev/null +++ b/nodejs/jupyter-labs/RAG/RAG.ipynb @@ -0,0 +1,814 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "a0c7cb80-cf6e-4cb0-852b-98a127bb9295", + "metadata": {}, + "source": [ + "# LLM RAG for Beginners: A Practical Guide with Elasticsearch and OpenSearch\n", + "\n", + "Have you ever tried searching for a movie quote with just a vague phrase or feeling? We've all been there, and sometimes, finding exactly what we're looking for can be tough. Retrieval-Augmented Generation (RAG) offers a more intuitive approach, allowing us to search with the fluidity of human-ish memory.\n", + "\n", + "RAG blends the power of Large Language Models (LLMs) with the precision of information retrieval systems like Elasticsearch and OpenSearch. It moves beyond simple keyword matching, using LLMs to understand the nuances of our search intent and deliver relevant results along with their context.\n", + "\n", + "For example, imagine trying to recall that iconic line from _The Fifth Element_ where Zorg says, \"Time not important, only life important.\" Even if you only remember the phrase \"life important,\" RAG can pinpoint the exact quote and provide context." + ] + }, + { + "cell_type": "markdown", + "id": "6ed42b72-9e65-4b14-a019-e8224182221c", + "metadata": {}, + "source": [ + "## Pre-requisites\n", + "\n", + "Before we dive into building our RAG pipeline, let's get our tech-stack in order. We'll be using the following:\n", + "\n", + "- **Bonsai.io Sandbox:** Bonsai.io provides fully managed OpenSearch clusters, making it incredibly easy to get started without any complex installation or configuration. We'll leverage a free Bonsai Sandbox for this tutorial. You can sign up for an account and launch a cluster at [bonsai.io](https://bonsai.io).\n", + " \n", + "> Important:\n", + "> \n", + "> Once your Bonsai sandbox cluster is created, you'll see your credentials in the cluster overview page:\n", + "> \n", + "> \n", + "\n", + "- **Cornell Movie-Dialogs Corpus:** This rich dataset contains conversations extracted from movie scripts. We'll use this corpus to populate our OpenSearch indexes. \n", + "\n", + "> Important:\n", + "> \n", + "> The Cornell Movie-Dialogs Corpus is part of Cornell's ConvoKit project, a toolkit for analyzing conversations. You can find the dataset and learn more about ConvoKit at [https://github.com/CornellNLP/ConvoKit](https://github.com/CornellNLP/ConvoKit). \n", + "> \n", + "> Download the `movie-corpus.zip` file from [this link](https://zissou.infosci.cornell.edu/convokit/datasets/movie-corpus/movie-corpus.zip) and extract it to a location that can be referenced by our code later on.\n", + "\n", + "- **OpenAI Text API**: OpenAI's 4o-mini model is perfect for our small, focused, prompts, and is quite affordable!\n", + "\n", + "> Important:\n", + "> For this tutorial, you'll need an OpenAI API Key to use. The OpenAI API costs money to use, and so the steps below may incur charges against your account. \n", + ">\n", + "> See [OpenAI's documentation](https://help.openai.com/en/articles/7039783-how-can-i-access-the-chatgpt-api) for details on how to create an OpenAI API Key and associated pricing.\n" + ] + }, + { + "cell_type": "markdown", + "id": "6ac30c39-2ed4-4bd1-a2d2-3b944e98e9ac", + "metadata": {}, + "source": [ + "## Understanding Retrieval-Augmented Generation (RAG)\n", + "\n", + "Retrieval-Augmented Generation (RAG) enhances LLMs by connecting them to external knowledge sources. Think of it as giving your LLM a library card to access a vast collection of information, allowing it to generate responses grounded in factual data. \n", + "\n", + "But, while you might remember details about all of the books you've read at the library, the LLM has a limited ability to keep information in its \"working memory\" (or, *context*) - so we need to help it by filtering out the external knowledge to what is most relevant to the task at-hand.\n", + "\n", + "To that end, RAG involves two key steps:\n", + "\n", + "1. **Retrieval:** Finding the most relevant information from your knowledge base. \n", + "2. **Generation:** Feeding in the most relevant information to the LLM, in order for it to generate a comprehensive response with the added context. \n", + "\n", + "Essentially, RAG combines the power of information retrieval with the generative capabilities of LLMs, resulting in a search experience that is both accurate and insightful.\n" + ] + }, + { + "cell_type": "markdown", + "id": "31fd5b50-3fc4-4c44-9005-7e387db9b39b", + "metadata": {}, + "source": [ + "## Setting Up Your OpenSearch Environment\n", + "\n", + "Now that you have a Bonsai Sandbox cluster up and running, let's get our movie data indexed in OpenSearch. We'll be using the Cornell Movie-Dialogs Corpus, which we downloaded in our pre-requisites. But first, let's visualize how we'll organize this data." + ] + }, + { + "cell_type": "markdown", + "id": "495c8002-c51e-44a5-a364-a6a0da41c9e0", + "metadata": {}, + "source": [ + "### Understanding the Data Structure\n", + "\n", + "Since this particular dataset is a bit denormalized, we'll create and use two indexes:\n", + "\n", + "- **`speakers`**: Details about the speaking characters in each movie.\n", + "- **`utterances`**: A detailed index of all the conversations within the movies, line by line, with speaker and movie identified." + ] + }, + { + "cell_type": "markdown", + "id": "a02ab0c5-b1b3-4933-8db9-7b8711c2488a", + "metadata": {}, + "source": [ + "### Creating and Indexing the Movie Data\n", + "\n", + "We'll use the OpenSearch JavaScript client and the Deno TypeScript kernel for Jupyter notebooks to interact with our OpenSearch cluster. You'll need the credentials from your Bonsai Sandbox to connect to the cluster. \n", + "\n", + "Now, let's create the index mappings for our `speakers` and `utterances` indexes:\n", + "\n", + "> Tip:\n", + ">\n", + "> Remember to set the `BONSAI_CLUSTER_URL` environment variable to safely access your Bonsai Cluster's credentials within your code!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "999d31cb-be82-463d-af1d-93edbac5dea8", + "metadata": {}, + "outputs": [], + "source": [ + "import { Client } from \"npm:@opensearch-project/opensearch\";" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50265119-658a-44bb-be83-c932c32b7cc3", + "metadata": {}, + "outputs": [], + "source": [ + "const client = new Client({\n", + " node: process.env['BONSAI_CLUSTER_URL'],\n", + "});" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "65911164-87a4-4df4-84c9-5d496c2533cf", + "metadata": {}, + "outputs": [], + "source": [ + "const speakersIndexName = \"speakers\";\n", + "\n", + "const speakersIndexBody = {\n", + " settings: {\n", + " number_of_shards: 1,\n", + " number_of_replicas: 0,\n", + " },\n", + " mappings: {\n", + " properties: {\n", + " speakerId: { type: \"keyword\" },\n", + " movieId: { type: \"keyword\" },\n", + " gender: { type: \"keyword\" },\n", + " script: { type: \"text\" },\n", + " movieName: { type: \"text\" },\n", + " },\n", + " },\n", + "};\n", + "\n", + "await client.indices.create({ index: speakersIndexName, body: speakersIndexBody });" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "995245ce-d4f2-41f4-9abb-35c7930b851f", + "metadata": {}, + "outputs": [], + "source": [ + "const utterancesIndexName = \"utterances\";\n", + "\n", + "const utterancesIndexBody = {\n", + " settings: {\n", + " number_of_shards: 1,\n", + " number_of_replicas: 0,\n", + " },\n", + " mappings: {\n", + " properties: {\n", + " id: { type: \"keyword\" },\n", + " conversationId: { type: \"keyword\" },\n", + " text: { type: \"text\" },\n", + " speaker: { type: \"text\" },\n", + " movieId: { type: \"keyword\" },\n", + " replyTo: { type: \"keyword\" },\n", + " },\n", + " },\n", + "};\n", + "\n", + "await client.indices.create({ index: utterancesIndexName, body: utterancesIndexBody });" + ] + }, + { + "cell_type": "markdown", + "id": "61de493e-9a3d-431e-9e17-e1ed634eedc8", + "metadata": {}, + "source": [ + "Next, we'll use the bulk API to index the movie data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "caeb7dcf-f5a7-4e41-a611-57a7f6b2a4d0", + "metadata": {}, + "outputs": [], + "source": [ + "async function indexSpeakers(filePath: string) {\n", + " const data = JSON.parse(await Deno.readTextFile(filePath));\n", + " const actions = [];\n", + "\n", + "for (const [speakerId, speaker] of Object.entries(data)) {\n", + " actions.push({ create: {} });\n", + " actions.push({\n", + "\t speakerId: speakerId,\n", + " name: speaker.meta.character_name,\n", + " movieId: speaker.meta.movie_idx,\n", + " movieName: speaker.meta.movie_name,\n", + " gender: speaker.meta.gender,\n", + " });\n", + " }\n", + "\n", + " await client.bulk({ index: \"speakers\", body: actions });\n", + "}\n", + "\n", + "await indexSpeakers(\"./movie-data/speakers.json\");" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "79f5e364-6f73-44ea-a4fa-27878db275b2", + "metadata": {}, + "outputs": [], + "source": [ + "import JSONL from \"npm:jsonl-parse-stringify\";\n", + "\n", + "class Utterance {\n", + " id: string;\n", + " conversationId: string;\n", + " text: string;\n", + " speaker: string;\n", + " meta: { \n", + " movie_id: string;\n", + " parsed: Array<{ [key: string]: any }>;\n", + " };\n", + " reply_to: string;\n", + " timestamp?: string;\n", + " vectors: Array;\n", + "}\n", + "\n", + "async function indexUtterances(filePath: string, limit: number, movieIdFilter?: RegExp) {\n", + " const data = JSONL.default.parse(await Deno.readTextFile(filePath));\n", + " const actions = [];\n", + "\n", + " for (let i = 0; i < limit; i++) {\n", + " const utterance: Utterance = data[i];\n", + "\n", + " if (movieIdFilter && !utterance.meta.movie_id.match(movieIdFilter)) {\n", + " continue;\n", + " }\n", + " \n", + " actions.push({ create: {} });\n", + " actions.push({\n", + " id: utterance.id,\n", + " conversationId: utterance.conversationId,\n", + " text: utterance.text,\n", + " speaker: utterance.speaker,\n", + " movieId: utterance.meta.movie_id,\n", + " replyTo: utterance[\"reply-to\"],\n", + " timestamp: utterance.timestamp,\n", + " });\n", + " }\n", + "\n", + " await client.bulk({ index: \"utterances\", body: actions });\n", + "}\n", + "\n", + "await indexUtterances(\"./movie-data/utterances.jsonl\", 31500, new RegExp(/^m\\d$/));" + ] + }, + { + "cell_type": "markdown", + "id": "33ce69a7-7a8d-4363-a359-9a15b9210352", + "metadata": {}, + "source": [ + "> Note:\n", + "> \n", + "> For the purposes of this demonstration, we're only indexing a handful of movies' utterance data, filtered by a regular expression on their corpus ID." + ] + }, + { + "cell_type": "markdown", + "id": "4043af04-0903-456b-908a-cf37827a78da", + "metadata": {}, + "source": [ + "Once the data is indexed, we can perform basic OpenSearch queries to test our setup. For example, we can search for movies by title, actors, or keywords within the scripts." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58359dd2-2e06-42b1-88a0-cbc071f988c7", + "metadata": {}, + "outputs": [], + "source": [ + "const query = {\n", + " query: {\n", + " match: {\n", + " text: \"life important\" \n", + " }\n", + " }\n", + "};\n", + "\n", + "const response = await client.search({\n", + " index: \"utterances\",\n", + " body: query,\n", + "});\n", + "\n", + "console.log(response.body.hits);" + ] + }, + { + "cell_type": "markdown", + "id": "156ed391-e470-407c-b14a-b927947bf9c0", + "metadata": {}, + "source": [ + "## Building the RAG Pipeline\n", + "\n", + "With our OpenSearch environment set up and movie data indexed, we're ready to assemble the pieces of our RAG pipeline. This involves three main steps:\n", + "\n", + "1. Query Parsing with an LLM\n", + "2. Retrieving Relevant Documents\n", + "3. Generating the Response" + ] + }, + { + "cell_type": "markdown", + "id": "e7eba28b-1bb0-4aef-ac71-f08f3d1b6859", + "metadata": {}, + "source": [ + "### Step 1: Query Parsing with an LLM\n", + "\n", + "The first step is to understand what the user is asking. We'll use an LLM to analyze their natural language query and extract two key pieces of information:\n", + "\n", + "- **Query Category:** What type of information are they looking for? For this scenario, we're expecting a \"quote recall,\" request, but we could expand to other categories like \"significant event identification\" or \"plot explanation\" in the future.\n", + "- **Query Content:** This is the heart of the query - the specific words or phrases the user remembers.\n", + "- **Movie**: If available, the LLM should extract the likely movie title intended.\n", + "- **Quote**: We're short-cutting a step here, since we're targeting the \"quote recall\" functionality, and asking the LLM to include the quote if available!\n", + "\n", + "To do this, we'll guide the LLM through carefully crafted prompts. Here's the one we'll be working with. " + ] + }, + { + "cell_type": "markdown", + "id": "1e34c733-5400-40c7-8af3-6c9dc0b563b6", + "metadata": {}, + "source": [ + "\n", + "> You are a helpful AI assistant that can analyze search queries related to movies. \n", + "> \n", + "> Here's a user query: {user_query}\n", + "> \n", + "> Based on this query, identify the following:\n", + "> \n", + "> - **Category:** Choose from the following categories: \"quote recall\", \"significant event\", \"plot explanation\", \"character information\". If none of these fit, choose \"unknown\".\n", + "> - **Content:** Extract the specific phrase or words related to the identified category.\n", + "> - **Movie:** If the query contains a probable movie title, extract it into this field.\n", + "> - **Quote:** If the query contains part of a quote, line, or utterance or describes one, extract it into this field to be used in a keyword match. Don't infer what the intended quote is, but rather extract the user's intended quote query from the overall query.\n", + ">\n", + "> Provide your answer in JSON format:\n", + "> \n", + "> {\n", + "> \"category\": \"...\",\n", + "> \"content\": \"...\",\n", + "> \"movie\": \"...\",\n", + "> \"quote\": \"...\"\n", + "> }\n", + "> " + ] + }, + { + "cell_type": "markdown", + "id": "5e029b39-9f6c-45c2-ba99-8594426d6534", + "metadata": {}, + "source": [ + "> Tip:\n", + ">\n", + "> Try tweaking your RAG prompts for performance! Getting these right often requires iteration and continuous testing, as LLM models tend to evolve over time!" + ] + }, + { + "cell_type": "markdown", + "id": "af542399-0ee0-4ef4-b7f8-3e41ae5107f1", + "metadata": {}, + "source": [ + "Let's see this in action with some TypeScript code:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a3d6571a-59dd-4dce-a33f-7fe9a1c465d6", + "metadata": {}, + "outputs": [], + "source": [ + "import OpenAI from \"https://deno.land/x/openai@v4.69.0/mod.ts\";\n", + "\n", + "const openai = new OpenAI({\n", + " apiKey: process.env['OPENAI_API_KEY'], // This is the default and can be omitted\n", + "});" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3cbf939a-c1f4-49a2-a093-261748d24402", + "metadata": {}, + "outputs": [], + "source": [ + "const userQuery = \"what's that line from The Fifth Element about life being important?\";" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0418a3a-5f63-4068-947f-2bd02bb01c50", + "metadata": {}, + "outputs": [], + "source": [ + "const prompt = `\n", + "You are a helpful AI assistant that can analyze search queries related to movies.\n", + "\n", + "Here's a user query: ${userQuery}\n", + "\n", + "Based on this query, identify the following:\n", + "\n", + "- **Category:** Choose from the following categories: \"quote recall\", \"significant event\", \"plot explanation\", \"character information\". If none of these fit, choose \"unknown\".\n", + "- **Content:** Extract the specific phrase or words related to the identified category. It's important *not* to try to answer the question directly, yourself. Try to only extract the intended query.\n", + "- **Movie:** If the query contains a probable movie title, extract it into this field.\n", + "- **Quote:** If the query contains part of a quote, line, or utterance or describes one, extract it into this field to be used in a keyword match. Don't infer what the intended quote is, but rather extract the user's intended quote query from the overall query.\n", + "\n", + "Provide your answer in JSON format. An example response is below.\n", + "\n", + "{\n", + " \"category\": \"...\",\n", + " \"content\": \"...\",\n", + " \"movie\": \"...\",\n", + " \"quote\": \"...\"\n", + "}\n", + "\n", + "`;" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "415dc1e6-9f95-4b46-adb5-63aadaa09d4b", + "metadata": {}, + "outputs": [], + "source": [ + "const response = await openai.chat.completions.create({\n", + " messages: [{ role: 'user', content: prompt }],\n", + " model: 'gpt-4o-mini',\n", + "});" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d4cd82ba-f7b2-41cf-8c10-7f54016bbe14", + "metadata": {}, + "outputs": [], + "source": [ + "const parsedQuery = JSON.parse(response.choices[0].message.content);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2c13256c-ef78-432c-ae0b-d0af7960d485", + "metadata": {}, + "outputs": [], + "source": [ + "console.log(parsedQuery); " + ] + }, + { + "cell_type": "markdown", + "id": "8605cb5a-a0c1-4c45-b152-a0e55e496ebe", + "metadata": {}, + "source": [ + "By accurately parsing the query, we set the stage for finding the most relevant information in our OpenSearch index." + ] + }, + { + "cell_type": "markdown", + "id": "85b4e6e2-ccd4-4715-8614-9b0f255b1ae3", + "metadata": {}, + "source": [ + "### Step 2: Retrieving Relevant Documents\n", + "\n", + "Now that we understand the user's request, let's find the relevant documents in our OpenSearch index. Since we're dealing with movie *quotes*, we'll focus on the `utterances` index, but we'll also extract the most likely movie title from our `speakers` index, since it contains the `movieName` and `movieId` in its dataset." + ] + }, + { + "cell_type": "markdown", + "id": "46c957ae-1b71-4344-af7f-28a5b0123966", + "metadata": {}, + "source": [ + "#### Discovering our intended movie\n", + "\n", + "If the LLM was able to extract an intended movie, let's use that to find the ID of the most probable movie in our corpus, if it was provided by the user. This will help improve our likely accuracy in quote-finding!\n", + "\n", + "OpenSearch's keyword search, powered by the BM25 algorithm, will help us pinpoint the best match:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26551439-879f-4f36-b9dc-72d34bb598e9", + "metadata": {}, + "outputs": [], + "source": [ + "let possibleMovieResults: null | string = null;\n", + "\n", + "if (parsedQuery.movie && parsedQuery.movie.length > 0) {\n", + " possibleMovieResults = await client.search({\n", + " \"_source\": false,\n", + " index: \"speakers\",\n", + " size: 1,\n", + " body: {\n", + " fields: [\"movieName\", \"movieId\"],\n", + " query: {\n", + " match: {\n", + " movieName: {\n", + " query: parsedQuery.movie\n", + " }\n", + " }\n", + " }\n", + " }\n", + " });\n", + "}" + ] + }, + { + "cell_type": "markdown", + "id": "29735782-0df9-42b5-8993-2bd20fb15189", + "metadata": {}, + "source": [ + "Next, we'll try and find the target quote. If we were able to find a `possibleMovie` match, we'll boost any quote results that come from that movie with an OpenSearch [boolean query](https://opensearch.org/docs/latest/query-dsl/compound/bool/), otherwise we'll revert to doing a keyword match query:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a3ae9211-1fdc-48a6-8a87-fccb8eab81dd", + "metadata": {}, + "outputs": [], + "source": [ + "// If we have a possible result, let's store it here!\n", + "let possibleMovieId: null | string = null;\n", + "\n", + "if (possibleMovieResults?.body?.hits &&\n", + " possibleMovieResults.body.hits.hits.length > 0 &&\n", + " possibleMovieResults.body.hits.hits[0].fields.movieId &&\n", + " possibleMovieResults.body.hits.hits[0].fields.movieId.length > 0) {\n", + " \n", + " possibleMovieId = possibleMovieResults.body.hits.hits[0].fields.movieId[0]\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "eb0bb783-bd80-4ad5-bed5-966f2c8b8870", + "metadata": {}, + "outputs": [], + "source": [ + "// We'll reference the possibleMovieId if available\n", + "let quoteQuery!: string = null;\n", + "\n", + "if (possibleMovieId) {\n", + " quoteQuery = {\n", + " query: {\n", + " bool: {\n", + " must: {\n", + " match: {\n", + " text: {\n", + " query: parsedQuery.quote\n", + " }\n", + " }\n", + " },\n", + " should: {\n", + " match: {\n", + " movieId: {\n", + " query: possibleMovieId\n", + " }\n", + " }\n", + " },\n", + " }\n", + " }\n", + " }\n", + "} else {\n", + " quoteQuery = {\n", + " query: {\n", + " match: {\n", + " text: { query: parsedQuery.quote }\n", + " }\n", + " }\n", + " }\n", + "}\n", + "\n", + "console.log(`Our quote query: ${JSON.stringify(quoteQuery)}`);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4cc1607d-3cf7-4271-9ab4-13c26a96fa25", + "metadata": {}, + "outputs": [], + "source": [ + "let quoteResults = await client.search({\n", + " index: \"utterances\",\n", + " body: quoteQuery,\n", + "});" + ] + }, + { + "cell_type": "markdown", + "id": "4fe20e5e-8e0e-4456-a571-3993b87058ed", + "metadata": {}, + "source": [ + "And, investigating our results, we should see our expected quote float up to the top of our result set!" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4d065f95-6407-40eb-960d-e5958df41087", + "metadata": {}, + "outputs": [], + "source": [ + "console.log(quoteResults.body.hits);" + ] + }, + { + "cell_type": "markdown", + "id": "b6a6989f-78f1-4f59-a77f-334ab53f6773", + "metadata": {}, + "source": [ + "> Info:\n", + "> \n", + "> We can fine-tune the utterance results search by adjusting the number of results (adjusting the `size` parameter), adding additional filters (like character), or combining multiple fields for a more refined search." + ] + }, + { + "cell_type": "markdown", + "id": "a548d60a-6c63-4d18-b0d4-83c8e10eae31", + "metadata": {}, + "source": [ + "### Step 3: Generating the Response\n", + "\n", + "This is where it all comes together. The goal is to use the retrieved documents to craft a comprehensive response for the user.\n", + "\n", + "This involves providing the LLM with clear instructions and the relevant context. This can be achieved through a prompt like this:\n", + "\n", + "> You are a helpful AI assistant that can provide information about movies.\n", + ">\n", + "> A user is looking for a movie quote that contains the following phrase: \"...\". Their original query was for the content: \"...\".\n", + ">\n", + "> Here is the most relevant utterance: \"...\"\n", + "> That utterance is from the movie: \"...\"\n", + ">\n", + "> Based on this utterance, provide the following information to the user:\n", + ">\n", + "> - **Movie Quote:** The exact quote containing the provided phrase.\n", + "> - **Movie Title:** The title of the movie where the quote appears.\n", + ">\n", + "> Format your response in a clear, concise, cohesive natural language response, and try to reference their original query to add a justification. That is, don't just use the format of bullet-points noted above. If your response includes their quote, try to use correct grammar around the inclusion.\n", + ">\n", + "> Absolutely DO NOT include any prefixed or suffixed niceties like, \"Certainly, based on your original query...\". Your words will be displayed directly to the user, so think of yourself as an iframe.\n", + ">\n", + "> The only additional context that could be helpful in this scenario would already be included in this prompt.\n", + "\n", + "> Important:\n", + "> \n", + "> Although we're stopping at our first result for this demonstration, by looping in surrounding utterances, we could provide the user with additional details like what was happening when their quote was uttered in the movie!\n", + "\n", + "Here's how to send this prompt to the LLM using the OpenAI API:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1755bdb-2be5-49b8-a0c8-9baba7d5ba9e", + "metadata": {}, + "outputs": [], + "source": [ + "// Select our top quote result, for demonstration:\n", + "let bestQuoteResult = quoteResults.body.hits.hits[0];\n", + "console.log(bestQuoteResult);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff9ae6ee-92c3-4f1b-9df1-65c13ea3508c", + "metadata": {}, + "outputs": [], + "source": [ + "// If the best result is from our target movie, we're good to go!\n", + "// otherwise, we'll need to grab the movie from the our speakers index.\n", + "//\n", + "// Note: data denormalization or using more complex queries would allow us to skip this step!\n", + "let bestQuoteMovie!: string;\n", + "\n", + "if (possibleMovieId === bestQuoteResult._source.movieId) {\n", + " bestQuoteMovie = possibleMovieResults.body.hits.hits[0].fields.movieName[0];\n", + "} else {\n", + " const movieResults = await client.search({\n", + " \"_source\": false,\n", + " index: \"speakers\",\n", + " size: 1,\n", + " body: {\n", + " fields: [\"movieName\", \"movieId\"],\n", + " query: {\n", + " match: {\n", + " movieId: {\n", + " query: bestQuoteResult._source.movieId\n", + " }\n", + " }\n", + " }\n", + " }\n", + " });\n", + " bestQuoteMovie = movieResults.body.hits.hits[0].fields.movieName[0];\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc58a7a6-26bc-4cbf-84ba-1f21a7a8ab4f", + "metadata": {}, + "outputs": [], + "source": [ + "const prompt = `\n", + "You are a helpful AI assistant that can provide information about movies.\n", + "\n", + "A user is looking for a movie quote that contains the following phrase: \"${parsedQuery.quote}\". Their original query was for the content: \"${parsedQuery.content}\".\n", + "\n", + "Here is the most relevant utterance: ${JSON.stringify(bestQuoteResult._source.text)}\n", + "That utterance is from the movie: \"${bestQuoteMovie}\"\n", + "\n", + "Based on this utterance, provide the following information to the user:\n", + "\n", + "- **Movie Quote:** The exact quote containing the provided phrase.\n", + "- **Movie Title:** The title of the movie where the quote appears.\n", + "\n", + "Format your response in a clear, concise, cohesive natural language response, and try to reference their original query to add a justification. That is, don't just use the format of bullet-points noted above. If your response includes their quote, try to use correct grammar around the inclusion.\n", + "\n", + "Absolutely DO NOT include any prefixed or suffixed niceties like, \"Certainly, based on your original query...\". Your words will be displayed directly to the user, so think of yourself as an iframe.\n", + "\n", + "The only additional context that could be helpful in this scenario would already be included in this prompt.\n", + "`;\n", + "console.log(prompt);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c0246bce-8759-43ad-aaf3-8f9f7e95c768", + "metadata": {}, + "outputs": [], + "source": [ + "const response = await openai.chat.completions.create({\n", + " messages: [{ role: 'user', content: prompt }],\n", + " model: 'gpt-4o-mini',\n", + "});" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aa0fd4d6-dd2c-40df-b045-d996a25a6d28", + "metadata": {}, + "outputs": [], + "source": [ + "const generatedResponse = response.choices[0].message.content;\n", + "console.log(generatedResponse);" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ab3e864a-06ae-4b1c-a557-82367582d253", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Deno", + "language": "typescript", + "name": "deno" + }, + "language_info": { + "codemirror_mode": "typescript", + "file_extension": ".ts", + "mimetype": "text/x.typescript", + "name": "typescript", + "nbconvert_exporter": "script", + "pygments_lexer": "typescript", + "version": "5.6.2" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/nodejs/jupyter-labs/RAG/README.md b/nodejs/jupyter-labs/RAG/README.md new file mode 100644 index 0000000..cf12c70 --- /dev/null +++ b/nodejs/jupyter-labs/RAG/README.md @@ -0,0 +1,14 @@ +# LLM RAG for Beginners, with Bonsai + +This example is related to +the [LLM RAG for Beginners: A Practical Guide with Elasticsearch and OpenSearch](https://bonsai.io/blog) +post. + +## Pre-requisites + +The accompanying [Jupyter](https://jupyter.org/) Notebook makes use of +the [Deno Jupyter Kernel](https://docs.deno.com/runtime/reference/cli/jupyter/), which must +be installed prior to use! + +Once installed, start your Jupyter Lab via `jupyter lab` and open up the [RAG.ipynb](./RAG.ipynb) file in the Jupyter +UI! \ No newline at end of file diff --git a/nodejs/jupyter-labs/RAG/assets/bonsai-cluster-details.png b/nodejs/jupyter-labs/RAG/assets/bonsai-cluster-details.png new file mode 100644 index 0000000000000000000000000000000000000000..e8e939617010c9f0f5940966a8a952a6ab361f55 GIT binary patch literal 40757 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
y6cHICefI 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 literal 0 HcmV?d00001 diff --git a/nodejs/jupyter-labs/RAG/assets/index-diagram.svg b/nodejs/jupyter-labs/RAG/assets/index-diagram.svg new file mode 100644 index 0000000..0c1932f --- /dev/null +++ b/nodejs/jupyter-labs/RAG/assets/index-diagram.svg @@ -0,0 +1 @@ +

movies

scripts

utterances

\ No newline at end of file diff --git a/nodejs/jupyter-labs/RAG/assets/sequenceDiagram.svg b/nodejs/jupyter-labs/RAG/assets/sequenceDiagram.svg new file mode 100644 index 0000000..90c2139 --- /dev/null +++ b/nodejs/jupyter-labs/RAG/assets/sequenceDiagram.svg @@ -0,0 +1 @@ +OpenSearchLLMMovieAPIUserOpenSearchLLMMovieAPIUserMovie quote queryParse queryQuery category & contentGenerate embeddingsEmbeddingsSearch with embeddingsRelevant documentsPrompt + ContextGenerated responseMovie quote and context \ No newline at end of file diff --git a/nodejs/jupyter-labs/RAG/movie-data/conversations.json b/nodejs/jupyter-labs/RAG/movie-data/conversations.json new file mode 100644 index 0000000..d7cec31 --- /dev/null +++ b/nodejs/jupyter-labs/RAG/movie-data/conversations.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc3b038ede8c112ef605434d20e0c599609e8aca3559ac77dfed0f5a4924a5ba +size 16235683 diff --git a/nodejs/jupyter-labs/RAG/movie-data/corpus.json b/nodejs/jupyter-labs/RAG/movie-data/corpus.json new file mode 100644 index 0000000..2a63e7d --- /dev/null +++ b/nodejs/jupyter-labs/RAG/movie-data/corpus.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a2b98c8bcae8ab86ac5ca36f66cb7e6b15ce996284bce8b7ec82ee4379a71059 +size 40358 diff --git a/nodejs/jupyter-labs/RAG/movie-data/index.json b/nodejs/jupyter-labs/RAG/movie-data/index.json new file mode 100644 index 0000000..91896e2 --- /dev/null +++ b/nodejs/jupyter-labs/RAG/movie-data/index.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7c8057923254f889bd83a1c926e7a3861405c4355c11c4cc1a954dc93beff452 +size 579 diff --git a/nodejs/jupyter-labs/RAG/movie-data/speakers.json b/nodejs/jupyter-labs/RAG/movie-data/speakers.json new file mode 100644 index 0000000..75de5a6 --- /dev/null +++ b/nodejs/jupyter-labs/RAG/movie-data/speakers.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc240a5805f6e56cc91cf0ee28515a4ec700b7e616d9d1114b1bb82c3ad228a5 +size 1374303 diff --git a/nodejs/jupyter-labs/RAG/movie-data/utterances.jsonl b/nodejs/jupyter-labs/RAG/movie-data/utterances.jsonl new file mode 100644 index 0000000..1ce1448 --- /dev/null +++ b/nodejs/jupyter-labs/RAG/movie-data/utterances.jsonl @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:938917ae1d7b0ee0a82d1cf1588c35ba2dd5ecfb68573b56a144e4dca57f11f4 +size 359532269