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 0000000..e8e9396 Binary files /dev/null and b/nodejs/jupyter-labs/RAG/assets/bonsai-cluster-details.png differ 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