![og-image](https://hackmd.io/_uploads/S1GBRg3xC.png) # Exa AI Powered Search Tutorial: Recipe Finder Do you ever want to make a meal but get lost in a sea of different options that don't quite fit what you're in the mood for when you search for it? In this tutorial, we will create a script that allows us to search for a recipe based on the cuisine type and flavor profiles we're interested in, filter it by websites of our favorite chefs, and have it return results and additional information about the recipe in a structured format. To do this, we will be using **Exa** to search for the recipes and **OpenAI** to augment and format our results. This formatting will be achieved using OpenAI's *Function Calling* which you can learn more about [here](https://platform.openai.com/docs/guides/function-calling). This interactive notebook was made with the Deno Javascript kernel for Jupyter. If you'd like to run this notebook locally, [installing Deno](https://docs.deno.com/runtime/manual/getting_started/installation) and [connecting Deno to Jupyter](https://docs.deno.com/runtime/manual/tools/jupyter) is fast and easy. To play with this code, first we need a [Exa API key](https://dashboard.exa.ai/overview) and an [OpenAI API key](https://platform.openai.com/api-keys). Get 1000 Exa searches per month free just for [signing up](https://dashboard.exa.ai/overview)! ### Environment Setup First, we import the `Exa` and `OpenAI` libraries that we'll need to search and format our results. We then initialize these libraries with our API keys. ``` // Deno imports and initial setup import Exa from 'npm:exa-js'; import OpenAI from 'npm:openai'; // API keys for Exa and OpenAI const EXA_API_KEY = "cf693bb2-8bf9-45b4-8151-e875fcda2aa0"; // Your Exa API key const OPENAI_API_KEY = "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"; // Your OpenAI API key // Initialising the Exa and OpenAI clients with the provided API keys const exa = new Exa(EXA_API_KEY); const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); ``` ## Retrieving recipes ### Crafting a recipe query In order to retrieve the recipes we want, we're going to have to craft our ask based on a few parameters. In this case, I've decided that I want to ask for a specific meal type (e.g. 'breakfast', 'dessert', 'snack') and provide two reference flavors (e.g. 'salty', 'hearty', 'summery'). Here, we define a function that crafts a query string for finding recipes. This function takes in meal type and two flavors, then returns a string that describes what we're searching for. ``` function createRecipeQuery(mealType, flavor1, flavor2) { return `I'm looking for ${mealType} recipes that combine ${flavor1} and ${flavor2} flavors.`; } ``` ### Querying for Recipes We use our `createRecipeQuery` function by providing it with specific meal types and flavors. I've got a `brunch` coming up and want to make something `hearty` and `zesty`. I don't want too many options to choose from (after all, that's why we're building this tool), so I'm going to limit the number of search links returned to `3`. I also want to limit the results to recipes from some chefs that I recently stumbled on via Instagram, so I've put an array of their websites We then log this query to see what it looks like. ``` const mealType = 'brunch'; const flavor1 = 'hearty'; const flavor2 = 'pasta'; const linkNum = 3; const website = ['barefootcontessa.com/','halfbakedharvest.com/','skinnytaste.com/']; const recipeQuery = createRecipeQuery(mealType, cuisine1, cuisine2); console.log(recipeQuery) ``` ``` I'm looking for brunch recipes that combine hearty and zesty flavors. ``` ### Retrieving Search Results Let's create a function that fetches search results from `Exa` based on our query we just composed. We'll call it `getSearchResults`. We should also specify some additional parameters. - We want to use `neural` search instead of `keyword` search. Neural search is preferred when the query is broad and complex because it lets us retrieve high quality, semantically relevant data. Neural search is especially suitable when a topic is well-known and popularly discussed on the Internet, allowing the machine learning model to retrieve contents which are more likely recommended by real humans. - We use `highlights` to retrieve relevant extracts from the pages corresponding to the results. This will give us more context about each recipe. I have selected 5 highlights per URL and have limited the highlight length to 50 sentences, which should give us enough context to work with. - We also set `use_autoprompt` to true. This will transform our query into a Exa-optimized query, and then you allows us to receive fairly high quality results. This is extremely powerful because it means that you can input an entire user input to your LLM as a query and get relevant results. The results are then aggregated into an array, ready for further processing. ``` async function getSearchResults(query) { const results = []; const searchResponse = await exa.searchAndContents(query, { type: "neural", numResults: linkNum, include_domains: website, use_autoprompt: true, highlights: { highlightsPerUrl: 5, numSentences: 50, query: query } }); results.push(...searchResponse.results); return results; } const exaResults = await getSearchResults(recipeQuery) console.log(exaResults) ``` ``` [ { title: "Spanish Tortilla with Burrata and Herbs.", url: "https://www.halfbakedharvest.com/spanish-tortilla/", publishedDate: "2018-06-06", author: "Tieghan", id: "SBPDx1yk7QKlnNrZr9zY7A", highlights: [ "Top with prosciutto, burrata, arugula, and fresh herbs. Slice and serve warm or at room temperature."... 12 more characters, "Looking through the photos of recipes you all have made is my favorite! Fulfilled by Shop ingred"... 1738 more characters, "No messing around, an entire fridge. I’m definitely not complaining, it’s amazing to not have to coo"... 4519 more characters, "Nothing beats fresh eggs and I feel pretty grateful that we are able to care for our own chickens (w"... 4571 more characters, "If you’re unfamiliar with a Spanish tortilla, it’s simply just an opened faced omelette filled with "... 4473 more characters ], highlightScores: [ 0.03665240922607969, 0.013337078694766318, 0.008368514173162112, 0.005793750801240094, 0.004339537846317398 ], score: 0.15610501170158386 }, { title: "Huevos Pericos (Colombian Scrambled Eggs)", url: "https://www.skinnytaste.com/huevos-pericos-colombian-scrambled-eggs", publishedDate: "2018-12-26", author: "Gina", id: "OEkZa4V24JbRt3tKkvB_Jw", highlights: [ " This post may contain affiliate links. Read my disclosure policy. This Colombian classic breakfast "... 1660 more characters ], highlightScores: [ -0.01942986605754413 ], score: 0.15609616041183472 }, { title: "Light Swiss Chard Frittata", url: "https://www.skinnytaste.com/light-swiss-chard-frittata", publishedDate: "2022-04-24", author: "Gina", id: "GsAmsXSAHEyqYHleDtmqsQ", highlights: [ " This post may contain affiliate links. Read my disclosure policy. Swiss Chard Frittata is made with"... 3245 more characters ], highlightScores: [ -0.182030797968751 ], score: 0.15506601333618164 } ] ``` ### Formatting Recipe Data We then format our search results for clarity. There's a bunch of data that we may not want to use in our final output, so this allows us to focus our results on what matters most - the `title`, `URL` and `summary`. This is also a good way of minimizing our token count when we call the OpenAI API. ``` function formatRecipes(results) { return results.map(result => ({ title: result.title, url: result.url, summary: result.highlights?.join(' ') || 'No summary available.' })); } const formattedResults = formatRecipes(exaResults) console.log(formattedResults) ``` ``` [ { title: "Spanish Tortilla with Burrata and Herbs.", url: "https://www.halfbakedharvest.com/spanish-tortilla/", summary: "Top with prosciutto, burrata, arugula, and fresh herbs. Slice and serve warm or at room temperature."... 15717 more characters }, { title: "Huevos Pericos (Colombian Scrambled Eggs)", url: "https://www.skinnytaste.com/huevos-pericos-colombian-scrambled-eggs", summary: " This post may contain affiliate links. Read my disclosure policy. This Colombian classic breakfast "... 1660 more characters }, { title: "Light Swiss Chard Frittata", url: "https://www.skinnytaste.com/light-swiss-chard-frittata", summary: " This post may contain affiliate links. Read my disclosure policy. Swiss Chard Frittata is made with"... 3245 more characters } ] ``` Excellent. Our recipe details are now formatted cleanly and ready for processing. ## Extracting Recipe Details ### Preparing OpenAI API call with Function Calling To get more detailed information from our recipes, we utilize OpenAI's API to simultaneously extract data from our recipe details but also format it to our liking. Before we call the OpenAI API, we create a `systemDirective` and a `prompt`. In the directive, we pass in our recipe. We also tap into OpenAI's function calling. This allows you to describe functions (in this case, the `formatRecipes` function) and have the model intelligently choose to output a JSON object containing arguments to call one or many functions. The schema I've provided includes the following properties: - `title` - `tagline` - `flavors` (these aren't necessarily the initial flavors set in our query, but may be dependent on the LLM's analysis) - `ingredients` - `cuisine` - `winePairing` - `authorNotes` - `url` You'll see that ive also *required* each of these properties. This means that the response will contain a JSON property for each of the above, but doesn't necessarily mean that it will populate a value. For example, `winePairing` may return an empty field as not every recipe will have a wine pairing related to it. It's worthwhile noting that this API call does does not call the `formatRecipes` function; instead, the model generates JSON that you can use to call the function in your code. Although we won't be using the output to call a function (*yet*), we want to have structure to our responses to ensure consistency in our results. ``` async function getLLMResponseForRecipe(recipe) { const systemDirective = `The recipe is: ${JSON.stringify(recipe)}`; const prompt = "Given the following recipe, return JSON formatted based on the functions provided."; const completion = await openai.chat.completions.create({ messages: [ {'role': 'system', 'content': systemDirective}, {'role': 'user', 'content': prompt}, ], functions: [ { name: "formatRecipes", description: "Parse a list of recipes for title, description, ingredients and URL", parameters: { type: "object", properties: { title: { type: "string", description: "The name of the recipe" }, tagline: { type: "string", description: "A descriptive tagline for the dish - like a one sentence movie bio" }, flavors: { type: "string", description: "The type of flavors in the dish (e.g. floral, earthy, summery)" }, ingredients: { type: "string", description: "Ingredients used in the recipe" }, cuisine: { type: "string", description: "The type of cuisine (e.g. Mexican, Indian, American)" }, winePairing: { type: "string", description: "The type of wine the recipe pairs well with" }, authorNotes: { type: "string", description: "Any highlighted information about the meal from the author" }, url: { type: "string", description: "URL for the recipe" } }, required: ["title", "tagline", "flavors", "ingredients", "cuisine", "winePairing", "authorNotes", "url"] } } ], function_call: "auto", model: "gpt-3.5-turbo", }); return completion.choices[0].message.function_call.arguments; } ``` ### Aggregating Detailed Recipe Information This is great, but you'll notice that we've got 3 recipes and only one API call. While `getLLMResponseForRecipe` focuses on fetching and structuring data from a single recipe through OpenAI's API, we need to extend this functionality to a collection of recipes. We can create a `processRecipes` function that enhances the `getLLMResponseForRecipe` by enabling batch processing of multiple recipes, transforming each recipe's data from the OpenAI API into a structured, application-specific format. The `processRecipes` function starts by receiving an array of recipe objects as input. It then iterates over each recipe, processing them in parallel by calling the `getLLMResponseForRecipe` function to fetch detailed information from the OpenAI API for each one. After fetching the data, it parses the API's JSON string response into an object for every recipe. Next, it maps the parsed data into new objects with specified fields and filters out any failed or null responses. ``` async function processRecipes(recipes) { const results = await Promise.all(recipes.map(async (recipe) => { const responseString = await getLLMResponseForRecipe(recipe); const responseObject = JSON.parse(responseString); return { title: responseObject.title, tagline: responseObject.tagline, flavors: responseObject.flavors, ingredients: responseObject.ingredients, cuisine: responseObject.cuisine, winePairing: responseObject.winePairing, authorNotes: responseObject.authorNotes, url: recipe.url }; })); return results.filter(Boolean); } const llmResults = await processRecipes(formattedResults); console.log(llmResults); // This will log the array of structured data as desired ``` ``` [ { title: "Lemon Chicken Breasts", tagline: "Delicious and citrusy chicken breasts", flavors: "Citrusy", ingredients: "1/4 cup good olive oil, 3 tablespoons minced garlic, 1/3 cup dry white wine, 1 tablespoon grated lem"... 199 more characters, cuisine: "American", winePairing: "Dry white wine", authorNotes: "Serve hot with the pan juices.", url: "https://barefootcontessa.com/recipes/lemon-chicken-breasts" }, { title: "Short Rib Hash & Eggs", tagline: "A hearty and flavorful breakfast dish", flavors: "Savory, smoky", ingredients: "1 pound Yukon Gold potatoes, unpeeled, ¾-inch diced\n" + "2 teaspoons good white wine vinegar\n" + "Kosher salt "... 410 more characters, cuisine: "American", winePairing: "Syrah / Shiraz", authorNotes: null, url: "https://barefootcontessa.com/recipes/short-rib-hash-eggs" }, { title: "Tuscan Lemon Chicken", tagline: "Juicy and flavorful lemon-infused chicken", flavors: "citrus", ingredients: "1 (3-1/2 pound) chicken, flattened; Kosher salt; 1/3 cup good olive oil; 2 teaspoons grated lemon ze"... 179 more characters, cuisine: "Tuscan", winePairing: "Chardonnay", authorNotes: "Flattening the chicken makes it cook evenly and quickly on the grill.", url: "https://barefootcontessa.com/recipes/tuscan-lemon-chicken" } ] ``` And there you have it! We now have a structured output of recipe ideas from our favorite chefs that are tailored to our tastes!