├── .env.example ├── .eslintrc.json ├── .github └── workflows │ └── playwright.yml ├── .gitignore ├── README-updated.md ├── README.md ├── next-env.d.ts ├── next.config.js ├── package.json ├── playwright-report └── index.html ├── playwright.config.ts ├── pnpm-lock.yaml ├── postcss.config.js ├── public ├── next.svg ├── pinecone.svg └── vercel.svg ├── src ├── app │ ├── api │ │ ├── chat │ │ │ └── route.ts │ │ ├── checkIndex │ │ │ └── route.ts │ │ ├── clearIndex │ │ │ └── route.ts │ │ └── crawl │ │ │ ├── crawler.ts │ │ │ ├── route.ts │ │ │ └── seed.ts │ ├── appContext.tsx │ ├── assets │ │ ├── icons │ │ │ ├── ellipse.tsx │ │ │ ├── pinecone.tsx │ │ │ └── user.tsx │ │ └── svg │ │ │ ├── blueEllipse.tsx │ │ │ ├── ellipse.tsx │ │ │ ├── pinecone.tsx │ │ │ ├── pineconeLogo.tsx │ │ │ ├── upArrow.tsx │ │ │ └── user.tsx │ ├── components │ │ ├── Chat │ │ │ ├── ChatInput.tsx │ │ │ ├── ChatWrapper.tsx │ │ │ ├── Messages.tsx │ │ │ └── index.tsx │ │ ├── Header.tsx │ │ └── Sidebar │ │ │ ├── Button.tsx │ │ │ ├── Card.tsx │ │ │ ├── InfoPopover.tsx │ │ │ ├── RecursiveSplittingOptions.tsx │ │ │ ├── UrlButton.tsx │ │ │ ├── index.tsx │ │ │ ├── urls.ts │ │ │ └── utils.ts │ ├── favicon.ico │ ├── globals.css │ ├── hooks │ │ └── useRefreshIndex.ts │ ├── layout.tsx │ ├── page.tsx │ ├── services │ │ ├── chunkedUpsert.ts │ │ ├── context.ts │ │ ├── embeddings.ts │ │ └── pinecone.ts │ └── utils │ │ └── truncateString.ts ├── global.css └── middleware.ts ├── tailwind.config.js ├── tests └── example.spec.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= 2 | 3 | # Retrieve the following from the Pinecone Console. 4 | 5 | # Navigate to API Keys under your Project to retrieve the API key and environment 6 | PINECONE_API_KEY= 7 | PINECONE_REGION=us-west-2 8 | PINECONE_CLOUD=aws 9 | 10 | 11 | # Navigate to Indexes under your Project to retrieve the Index name 12 | PINECONE_INDEX= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | pull_request: 7 | branches: 8 | - '**' 9 | workflow_dispatch: 10 | jobs: 11 | install: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 18 18 | - name: Setup pnpm 19 | uses: pnpm/action-setup@v3 20 | with: 21 | version: 9 # Specify the pnpm version here 22 | 23 | test: 24 | needs: install 25 | timeout-minutes: 60 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v3 29 | - uses: actions/setup-node@v3 30 | with: 31 | node-version: 18 32 | - name: Setup pnpm 33 | uses: pnpm/action-setup@v3 34 | with: 35 | version: 9 # Specify the pnpm version here 36 | - name: Install dependencies 37 | run: pnpm install 38 | - name: Build application 39 | run: pnpm run build 40 | - name: Start server 41 | run: pnpm start & 42 | - name: Install Playwright Browsers 43 | run: npx playwright install --with-deps 44 | - name: Run Playwright tests 45 | run: npx playwright test 46 | - uses: actions/upload-artifact@v3 47 | if: always() 48 | with: 49 | name: playwright-report 50 | path: playwright-report/ 51 | retention-days: 30 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | .env 37 | 38 | /test-results/ 39 | /playwright-report/ 40 | /playwright/.cache/ 41 | -------------------------------------------------------------------------------- /README-updated.md: -------------------------------------------------------------------------------- 1 | # Building a Context-Aware Chatbot with Pinecone and Vercel 2 | 3 | In this example, we'll build a full-stack application that uses Retrieval Augmented Generation (RAG) powered by [Pinecone](https://pinecone.io) to deliver accurate and contextually relevant responses in a chatbot. 4 | 5 | RAG is a powerful tool that combines the benefits of retrieval-based models and generative models. Unlike traditional chatbots that can struggle with maintaining up-to-date information or accessing domain-specific knowledge, a RAG-based chatbot uses a knowledge base created from crawled URLs to provide contextually relevant responses. 6 | 7 | Incorporating Vercel's AI SDK into our application will allow us easily set up the chatbot workflow and utilize streaming more efficiently, particularly in edge environments, enhancing the responsiveness and performance of our chatbot. 8 | 9 | By the end of this tutorial, you'll have a context-aware chatbot that provides accurate responses without hallucination, ensuring a more effective and engaging user experience. Let's get started on building this powerful tool ([Full code listing](https://github.com/pinecone-io/pinecone-vercel-example/blob/main/package.json)). 10 | 11 | ## Step 1: Setting Up Your Next.js Application 12 | 13 | First, create a new Next.js app and install the necessary packages: 14 | 15 | ```bash 16 | npx create-next-app chatbot 17 | cd chatbot 18 | npm install ai react @pinecone-database/pinecone 19 | ``` 20 | 21 | ## Step 2: Create the Chatbot 22 | In this step, we are going to build a chat interface that will render two components. One of these components will be a chatbot with context support provided by Pinecone. The other component will be a chatbot without context. Both of these components will present messages received by the `useChat` hook from the Vercel AI SDK. 23 | 24 | 25 | ### Chatbot Frontend Component 26 | 27 | Create a Chat component that will render the chat interface. This component will have two ChatWrapper components, one for the chatbot with context and one without context. 28 | When a message is sent, each of the `ChatWrapper` components will be notified and take on the responsibility of sending the message to the backend, as well as presenting with the proper messages. 29 | 30 | ```tsx 31 | // Importing necessary modules and types 32 | import AppContext from "@/appContext"; 33 | import type { PineconeRecord } from "@pinecone-database/pinecone"; 34 | import React, { ChangeEvent, FormEvent, useContext, useRef } from "react"; 35 | import ChatInput from "./ChatInput"; 36 | import ChatWrapper, { ChatInterface } from "./ChatWrapper"; 37 | 38 | // Defining the properties for the Chat component 39 | interface ChatProps { 40 | setContext: (data: { context: PineconeRecord[] }[]) => void; 41 | context: { context: PineconeRecord[] }[] | null; 42 | } 43 | 44 | // The Chat component 45 | const Chat: React.FC = ({ setContext, context }) => { 46 | // Creating references for the chat components with and without context 47 | const chatWithContextRef = useRef(null); 48 | const chatWithoutContextRef = useRef(null); 49 | 50 | // Accessing the total number of records from the application context 51 | const { totalRecords } = useContext(AppContext); 52 | 53 | // State for the chat input 54 | const [input, setInput] = React.useState("") 55 | 56 | // Function to handle message submission 57 | const onMessageSubmit = (e: FormEvent) => { 58 | // Clear the input 59 | setInput("") 60 | // Submit the message to both chat components 61 | chatWithContextRef.current?.handleMessageSubmit(e) 62 | chatWithoutContextRef.current?.handleMessageSubmit(e) 63 | } 64 | 65 | // Function to handle input change 66 | const onInputChange = (event: ChangeEvent) => { 67 | // Update the input state 68 | setInput(event.target.value) 69 | // Update the input in both chat components 70 | chatWithContextRef.current?.handleInputUpdated(event) 71 | chatWithoutContextRef.current?.handleInputUpdated(event) 72 | } 73 | 74 | // Rendering the Chat component 75 | return ( 76 | // The chat interface is divided into two sections, one for the chat with context and one without context 77 |
78 |
79 |
80 | 81 |
82 |
83 | 84 |
85 |
86 | // The chat input is rendered at the bottom of the chat interface 87 |
88 | 89 |
90 |
91 | ); 92 | }; 93 | 94 | // Exporting the Chat component 95 | export default Chat; 96 | ``` 97 | 98 | ### ChatWrapper Component 99 | 100 | 101 | The Chat component is responsible for handling the chatbot's input and message submission. It uses the useChat hook from the ai package to manage the chatbot's state. 102 | The component is divided into two parts: the Messages component that displays the chat messages, and a form for submitting new messages. The component also generates a unique ID for each message. 103 | 104 | ```tsx 105 | 106 | import type { PineconeRecord } from "@pinecone-database/pinecone"; 107 | import { useChat } from "ai/react"; 108 | import React, { ChangeEvent, FormEvent, Ref, forwardRef, useEffect, useImperativeHandle, useRef } from "react"; 109 | import { v4 as uuidv4 } from 'uuid'; 110 | import Messages from "./Messages"; 111 | 112 | export interface ChatInterface { 113 | handleMessageSubmit: (e: FormEvent) => void; 114 | handleInputUpdated: (event: ChangeEvent) => void; 115 | ref: Ref; 116 | withContext: boolean; 117 | } 118 | 119 | interface ChatProps { 120 | withContext: boolean; 121 | setContext: (data: { context: PineconeRecord[] }[]) => void; 122 | context?: { context: PineconeRecord[] }[] | null; 123 | ref: Ref 124 | } 125 | 126 | const Chat: React.FC = forwardRef(({ withContext, setContext, context }, ref) => { 127 | const { messages, handleInputChange, handleSubmit, isLoading, data } = useChat({ 128 | sendExtraMessageFields: true, 129 | body: { 130 | withContext, 131 | }, 132 | }); 133 | 134 | useEffect(() => { 135 | if (data) { 136 | setContext(data as { context: PineconeRecord[] }[]) // Logs the additional data 137 | } 138 | }, [data, setContext]); 139 | 140 | const chatRef = useRef(null); 141 | 142 | useImperativeHandle(ref, () => ({ 143 | handleMessageSubmit: (event: FormEvent) => { 144 | const id = uuidv4(); // Generate a unique ID 145 | handleSubmit(event, { 146 | data: { 147 | messageId: id, // Include the ID in the message object 148 | 149 | }, 150 | }) 151 | }, 152 | handleInputUpdated: (event: ChangeEvent) => { 153 | handleInputChange(event); 154 | }, 155 | })); 156 | 157 | return ( 158 |
159 | 160 |
chatRef.current?.handleMessageSubmit(e)} className="..."> 161 | chatRef.current?.handleInputUpdated(e)} 165 | /> 166 | 167 |
168 |
169 | ); 170 | }); 171 | 172 | Chat.displayName = 'Chat'; 173 | 174 | export default Chat; 175 | ``` 176 | 177 | ### ChatInput Component 178 | 179 | The ChatInput component is responsible for rendering the chat input field and the send button. It uses the `handleInputChange` and `handleMessageSubmit` functions from the Chat component to handle input changes and message submission. 180 | 181 | ```tsx 182 | import React, { ChangeEvent, FormEvent } from "react"; 183 | 184 | interface ChatInputProps { 185 | input: string; 186 | handleInputChange: (e: ChangeEvent) => void; 187 | handleMessageSubmit: (e: FormEvent) => void; 188 | showIndexMessage: boolean; 189 | } 190 | 191 | const ChatInput: React.FC = ({ input, handleInputChange, handleMessageSubmit, showIndexMessage }) => { 192 | return ( 193 |
194 | 200 | 201 | {showIndexMessage && ( 202 |
203 | Press ⮐ to send 204 |
205 | )} 206 |
207 | ); 208 | }; 209 | 210 | export default ChatInput; 211 | ``` 212 | 213 | 214 | 215 | ## Step 3. Adding Context 216 | 217 | As we dive into building our chatbot, it's important to understand the role of context. Adding context to our chatbot's responses is key for creating a more natural, conversational user experience. Without context, a chatbot's responses can feel disjointed or irrelevant. By understanding the context of a user's query, our chatbot will be able to provide more accurate, relevant, and engaging responses. Now, let's begin building with this goal in mind. 218 | 219 | First, we'll first focus on seeding the knowledge base. We'll create a crawler and a seed script, and set up a crawl endpoint. This will allow us to gather and organize the information our chatbot will use to provide contextually relevant responses. 220 | 221 | After we've populated our knowledge base, we'll retrieve matches from our embeddings. This will enable our chatbot to find relevant information based on user queries. 222 | 223 | Next, we'll wrap our logic into the getContext function and update our chatbot's prompt. This will streamline our code and improve the user experience by ensuring the chatbot's prompts are relevant and engaging. 224 | 225 | Finally, we'll add a context panel and an associated context endpoint. These will provide a user interface for the chatbot and a way for it to retrieve the necessary context for each user query. 226 | 227 | This step is all about feeding our chatbot the information it needs and setting up the necessary infrastructure for it to retrieve and use that information effectively. Let's get started. 228 | 229 | ## Seeding the Knowledge Base 230 | 231 | Now we'll move on to seeding the knowledge base, the foundational data source that will inform our chatbot's responses. This step involves collecting and organizing the information our chatbot needs to operate effectively. In this guide, we're going to use data retrieved from various websites which we'll later on be able to ask questions about. To do this, we'll create a crawler that will scrape the data from the websites, embed it, and store it in Pinecone. 232 | 233 | ### Create the crawler 234 | 235 | For the sake of brevity, you'll be able to find the full code for the crawler here. Here are the pertinent parts: 236 | 237 | ```ts 238 | class Crawler { 239 | private seen = new Set(); 240 | private pages: Page[] = []; 241 | private queue: { url: string; depth: number }[] = []; 242 | 243 | constructor(private maxDepth = 2, private maxPages = 1) {} 244 | 245 | async crawl(startUrl: string): Promise { 246 | // Add the start URL to the queue 247 | this.addToQueue(startUrl); 248 | 249 | // While there are URLs in the queue and we haven't reached the maximum number of pages... 250 | while (this.shouldContinueCrawling()) { 251 | // Dequeue the next URL and depth 252 | const { url, depth } = this.queue.shift()!; 253 | 254 | // If the depth is too great or we've already seen this URL, skip it 255 | if (this.isTooDeep(depth) || this.isAlreadySeen(url)) continue; 256 | 257 | // Add the URL to the set of seen URLs 258 | this.seen.add(url); 259 | 260 | // Fetch the page HTML 261 | const html = await this.fetchPage(url); 262 | 263 | // Parse the HTML and add the page to the list of crawled pages 264 | this.pages.push({ url, content: this.parseHtml(html) }); 265 | 266 | // Extract new URLs from the page HTML and add them to the queue 267 | this.addNewUrlsToQueue(this.extractUrls(html, url), depth); 268 | } 269 | 270 | // Return the list of crawled pages 271 | return this.pages; 272 | } 273 | 274 | // ... Some private methods removed for brevity 275 | 276 | private async fetchPage(url: string): Promise { 277 | try { 278 | const response = await fetch(url); 279 | return await response.text(); 280 | } catch (error) { 281 | console.error(`Failed to fetch ${url}: ${error}`); 282 | return ""; 283 | } 284 | } 285 | 286 | private parseHtml(html: string): string { 287 | const $ = cheerio.load(html); 288 | $("a").removeAttr("href"); 289 | return NodeHtmlMarkdown.translate($.html()); 290 | } 291 | 292 | private extractUrls(html: string, baseUrl: string): string[] { 293 | const $ = cheerio.load(html); 294 | const relativeUrls = $("a") 295 | .map((_, link) => $(link).attr("href")) 296 | .get() as string[]; 297 | return relativeUrls.map( 298 | (relativeUrl) => new URL(relativeUrl, baseUrl).href 299 | ); 300 | } 301 | } 302 | ``` 303 | 304 | The `Crawler` class is a web crawler that visits URLs, starting from a given point, and collects information from them. It operates within a certain depth and a maximum number of pages as defined in the constructor. The crawl method is the core function that starts the crawling process. 305 | 306 | The helper methods fetchPage, parseHtml, and extractUrls respectively handle fetching the HTML content of a page, parsing the HTML to extract text, and extracting all URLs from a page to be queued for the next crawl. The class also maintains a record of visited URLs to avoid duplication. 307 | 308 | ### Create the `seed` function 309 | 310 | To tie things together, we'll create a seed function that will use the crawler to seed the knowledge base. In this portion of the code, we'll initialize the crawl and fetch a given URL, then split it's content into chunks, and finally embed and index the chunks in Pinecone. 311 | 312 | ```ts 313 | async function seed(url: string, limit: number, indexName: string, options: SeedOptions) { 314 | try { 315 | // Initialize the Pinecone client 316 | const pinecone = new Pinecone(); 317 | 318 | // Destructure the options object 319 | const { splittingMethod, chunkSize, chunkOverlap } = options; 320 | 321 | // Create a new Crawler with depth 1 and maximum pages as limit 322 | const crawler = new Crawler(1, limit || 100); 323 | 324 | // Crawl the given URL and get the pages 325 | const pages = await crawler.crawl(url) as Page[]; 326 | 327 | // Choose the appropriate document splitter based on the splitting method 328 | const splitter: DocumentSplitter = splittingMethod === 'recursive' ? 329 | new RecursiveCharacterTextSplitter({ chunkSize, chunkOverlap }) : new MarkdownTextSplitter({}); 330 | 331 | // Prepare documents by splitting the pages 332 | const documents = await Promise.all(pages.map(page => prepareDocument(page, splitter))); 333 | 334 | // Create Pinecone index if it does not exist 335 | const indexList = await pinecone.listIndexes(); 336 | const indexExists = indexList.some(index => index.name === indexName) 337 | if (!indexExists) { 338 | await pinecone.createIndex({ 339 | name: indexName, 340 | dimension: 1536, 341 | waitUntilReady: true, 342 | }); 343 | } 344 | 345 | const index = pinecone.Index(indexName) 346 | 347 | // Get the vector embeddings for the documents 348 | const vectors = await Promise.all(documents.flat().map(embedDocument)); 349 | 350 | // Upsert vectors into the Pinecone index 351 | await chunkedUpsert(index!, vectors, '', 10); 352 | 353 | // Return the first document 354 | return documents[0]; 355 | } catch (error) { 356 | console.error("Error seeding:", error); 357 | throw error; 358 | } 359 | } 360 | ``` 361 | 362 | To chunk the content we'll use one of the following methods: 363 | 364 | 1. `RecursiveCharacterTextSplitter` - This splitter splits the text into chunks of a given size, and then recursively splits the chunks into smaller chunks until the chunk size is reached. This method is useful for long documents. 365 | 2. `MarkdownTextSplitter` - This splitter splits the text into chunks based on Markdown headers. This method is useful for documents that are already structured using Markdown. The benefit of this method is that it will split the document into chunks based on the headers, which will be useful for our chatbot to understand the structure of the document. We can assume that each unit of text under a header is an internally coherent unit of information, and when the user asks a question, the retrieved context will be internally coherent as well. 366 | 367 | ### Add the `crawl` endpoint` 368 | 369 | The endpoint for the `crawl` endpoint is pretty straightforward. It simply calls the `seed` function and returns the result. 370 | 371 | ```ts 372 | import seed from "./seed"; 373 | import { NextResponse } from "next/server"; 374 | 375 | export const runtime = "edge"; 376 | 377 | export async function POST(req: Request) { 378 | const { url, options } = await req.json(); 379 | try { 380 | const documents = await seed(url, 1, process.env.PINECONE_INDEX!, options); 381 | return NextResponse.json({ success: true, documents }); 382 | } catch (error) { 383 | return NextResponse.json({ success: false, error: "Failed crawling" }); 384 | } 385 | } 386 | ``` 387 | 388 | Now our backend is able to crawl a given URL, embed the content and index the embeddings in Pinecone. The endpoint will return all the segments in the retrieved webpage we crawl, so we'll be able to display them. Next, we'll write a set of functions that will build the context out of these embeddings. 389 | 390 | ### Get matches from embeddings 391 | 392 | To retrieve the most relevant documents from the index, we'll use the `query` function in the Pinecone SDK. This function takes a vector and returns the most similar vectors from the index. We'll use this function to retrieve the most relevant documents from the index, given some embeddings. 393 | 394 | ```ts 395 | const getMatchesFromEmbeddings = async (embeddings: number[], topK: number, namespace: string): Promise[]> => { 396 | // Obtain a client for Pinecone 397 | const pinecone = new Pinecone(); 398 | 399 | const indexName: string = process.env.PINECONE_INDEX || ''; 400 | if (indexName === '') { 401 | throw new Error('PINECONE_INDEX environment variable not set') 402 | } 403 | 404 | // Retrieve the list of indexes to check if expected index exists 405 | const indexes = await pinecone.listIndexes() 406 | if (indexes.filter(i => i.name === indexName).length !== 1) { 407 | throw new Error(`Index ${indexName} does not exist`) 408 | } 409 | 410 | // Get the Pinecone index 411 | const index = pinecone!.Index(indexName); 412 | 413 | // Get the namespace 414 | const pineconeNamespace = index.namespace(namespace ?? '') 415 | 416 | try { 417 | // Query the index with the defined request 418 | const queryResult = await pineconeNamespace.query({ 419 | vector: embeddings, 420 | topK, 421 | includeMetadata: true, 422 | }) 423 | return queryResult.matches || [] 424 | } catch (e) { 425 | // Log the error and throw it 426 | console.log("Error querying embeddings: ", e) 427 | throw new Error(`Error querying embeddings: ${e}`) 428 | } 429 | } 430 | ``` 431 | 432 | The function takes in embeddings, a topK parameter, and a namespace, and returns the topK matches from the Pinecone index. It first gets a Pinecone client, checks if the desired index exists in the list of indexes, and throws an error if not. Then it gets the specific Pinecone index. The function then queries the Pinecone index with the defined request and returns the matches. 433 | 434 | ### Wrap things up in `getContext` 435 | 436 | We'll wrap things together in the `getContext` function. This function will take in a `message` and return the context - either in string form, or as a set of `ScoredVector`. 437 | 438 | ```ts 439 | export const getContext = async ( 440 | message: string, 441 | namespace: string, 442 | maxTokens = 3000, 443 | minScore = 0.7, 444 | getOnlyText = true 445 | ): Promise => { 446 | // Get the embeddings of the input message 447 | const embedding = await getEmbeddings(message); 448 | 449 | // Retrieve the matches for the embeddings from the specified namespace 450 | const matches = await getMatchesFromEmbeddings(embedding, 3, namespace); 451 | 452 | // Filter out the matches that have a score lower than the minimum score 453 | const qualifyingDocs = matches.filter((m) => m.score && m.score > minScore); 454 | 455 | // If the `getOnlyText` flag is false, we'll return the matches 456 | if (!getOnlyText) { 457 | return qualifyingDocs; 458 | } 459 | 460 | let docs = matches 461 | ? qualifyingDocs.map((match) => (match.metadata as Metadata).chunk) 462 | : []; 463 | // Join all the chunks of text together, truncate to the maximum number of tokens, and return the result 464 | return docs.join("\n").substring(0, maxTokens); 465 | }; 466 | ``` 467 | 468 | Back in `chat/route.ts`, we'll add the call to `getContext`: 469 | 470 | ```ts 471 | const { messages } = await req.json(); 472 | 473 | // Get the last message 474 | const lastMessage = messages[messages.length - 1]; 475 | 476 | // Get the context from the last message 477 | const context = await getContext(lastMessage.content, ""); 478 | ``` 479 | 480 | ### Update the prompt 481 | 482 | Finally, we'll update the prompt to include the context we retrieved from the `getContext` function. 483 | 484 | ```ts 485 | const prompt = [ 486 | { 487 | role: "system", 488 | content: `AI assistant is a brand new, powerful, human-like artificial intelligence. 489 | The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness. 490 | AI is a well-behaved and well-mannered individual. 491 | AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user. 492 | AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation. 493 | AI assistant is a big fan of Pinecone and Vercel. 494 | START CONTEXT BLOCK 495 | ${context} 496 | END OF CONTEXT BLOCK 497 | AI assistant will take into account any CONTEXT BLOCK that is provided in a conversation. 498 | If the context does not provide the answer to question, the AI assistant will say, "I'm sorry, but I don't know the answer to that question". 499 | AI assistant will not apologize for previous responses, but instead will indicated new information was gained. 500 | AI assistant will not invent anything that is not drawn directly from the context. 501 | `, 502 | }, 503 | ]; 504 | ``` 505 | 506 | In this prompt, we added a `START CONTEXT BLOCK` and `END OF CONTEXT BLOCK` to indicate where the context should be inserted. We also added a line to indicate that the AI assistant will take into account any context block that is provided in a conversation. 507 | 508 | ### Attaching the context data to the messages 509 | 510 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | In this example, we'll build a full-stack application that uses Retrieval Augmented Generation (RAG) powered by [Pinecone](https://pinecone.io) to deliver accurate and contextually relevant responses in a chatbot. 2 | 3 | RAG is a powerful tool that combines the benefits of retrieval-based models and generative models. Unlike traditional chatbots that can struggle with maintaining up-to-date information or accessing domain-specific knowledge, a RAG-based chatbot uses a knowledge base created from crawled URLs to provide contextually relevant responses. 4 | 5 | Incorporating Vercel's AI SDK into our application will allow us easily set up the chatbot workflow and utilize streaming more efficiently, particularly in edge environments, enhancing the responsiveness and performance of our chatbot. 6 | 7 | By the end of this tutorial, you'll have a context-aware chatbot that provides accurate responses without hallucination, ensuring a more effective and engaging user experience. Let's get started on building this powerful tool ([Full code listing](https://github.com/pinecone-io/pinecone-vercel-example/blob/main/package.json)). 8 | 9 | ## Step 1: Setting Up Your Next.js Application 10 | 11 | Next.js is a powerful JavaScript framework that enables us to build server-side rendered and static web applications using React. It's a great choice for our project due to its ease of setup, excellent performance, and built-in features such as routing and API routes. 12 | 13 | To create a new Next.js app, run the following command: 14 | 15 | ### npx 16 | 17 | ```bash 18 | npx create-next-app chatbot 19 | ``` 20 | 21 | Next, we'll add the `ai` package: 22 | 23 | ```bash 24 | npm install ai 25 | ``` 26 | 27 | This command is used to install the `ai` package which is necessary for the chatbot functionality. 28 | 29 | 30 | You can use the [full list](https://github.com/pinecone-io/pinecone-vercel-example/blob/main/package.json) of dependencies if you'd like to build along with the tutorial. 31 | 32 | ## Step 2: Create the Chatbot 33 | 34 | In this step, we're going to use the Vercel SDK to establish the backend and frontend of our chatbot within the Next.js application. By the end of this step, our basic chatbot will be up and running, ready for us to add context-aware capabilities in the following stages. Let's get started. 35 | 36 | ### Chatbot frontend component 37 | 38 | Now, let's focus on the frontend component of our chatbot. We're going to build the user-facing elements of our bot, creating the interface through which users will interact with our application. This will involve crafting the design and functionality of the chat interface within our Next.js application. 39 | 40 | First, we'll create the `Chat` component, that will render the chat interface. 41 | 42 | ```tsx 43 | import React, { FormEvent, ChangeEvent } from "react"; 44 | import Messages from "./Messages"; 45 | import { Message } from "ai/react"; 46 | 47 | interface Chat { 48 | input: string; 49 | handleInputChange: (e: ChangeEvent) => void; 50 | handleMessageSubmit: (e: FormEvent) => Promise; 51 | messages: Message[]; 52 | } 53 | 54 | const Chat: React.FC = ({ 55 | input, 56 | handleInputChange, 57 | handleMessageSubmit, 58 | messages, 59 | }) => { 60 | return ( 61 |
62 | 63 | <> 64 |
65 | 71 | 72 | Press ⮐ to send 73 |
74 | 75 |
76 | ); 77 | }; 78 | 79 | export default Chat; 80 | ``` 81 | 82 | This component will display the list of messages and the input form for the user to send messages. The `Messages` component to render the chat messages: 83 | 84 | ```tsx 85 | import { Message } from "ai"; 86 | import { useRef } from "react"; 87 | 88 | export default function Messages({ messages }: { messages: Message[] }) { 89 | const messagesEndRef = useRef(null); 90 | return ( 91 |
92 | {messages.map((msg, index) => ( 93 |
99 |
{msg.role === "assistant" ? "🤖" : "🧑‍💻"}
100 |
{msg.content}
101 |
102 | ))} 103 |
104 |
105 | ); 106 | } 107 | ``` 108 | 109 | Our main `Page` component will manage the state for the messages displayed in the `Chat` component: 110 | 111 | ```tsx 112 | "use client"; 113 | import Header from "@/components/Header"; 114 | import Chat from "@/components/Chat"; 115 | import { useChat } from "ai/react"; 116 | 117 | const Page: React.FC = () => { 118 | const [context, setContext] = useState(null); 119 | const { messages, input, handleInputChange, handleSubmit } = useChat(); 120 | 121 | return ( 122 |
123 |
124 |
125 | 131 |
132 |
133 | ); 134 | }; 135 | 136 | export default Page; 137 | ``` 138 | 139 | The useful `useChat` hook will manage the state for the messages displayed in the `Chat` component. It will: 140 | 141 | 1. Send the user's message to the backend 142 | 2. Update the state with the response from the backend 143 | 3. Handle any internal state changes (e.g. when the user types a message) 144 | 145 | ### Chatbot API endpoint 146 | 147 | Next, we'll set up the Chatbot API endpoint. This is the server-side component that will handle requests and responses for our chatbot. We'll create a new file called `api/chat/route.ts` and add the following dependencies: 148 | 149 | ```ts 150 | import { Configuration, OpenAIApi } from "openai-edge"; 151 | import { Message, OpenAIStream, StreamingTextResponse } from "ai"; 152 | ``` 153 | 154 | The first dependency is the `openai-edge` package which makes it easier to interact with OpenAI's APIs in an edge environment. The second dependency is the `ai` package which we'll use to define the `Message` and `OpenAIStream` types, which we'll use to stream back the response from OpenAI back to the client. 155 | 156 | Next initialize the OpenAI client: 157 | 158 | ```ts 159 | // Create an OpenAI API client (that's edge friendly!) 160 | const config = new Configuration({ 161 | apiKey: process.env.OPENAI_API_KEY, 162 | }); 163 | const openai = new OpenAIApi(config); 164 | ``` 165 | 166 | To define this endpoint as an edge function, we'll define and export the `runtime` variable 167 | 168 | ```ts 169 | export const runtime = "edge"; 170 | ``` 171 | 172 | Next, we'll define the endpoint handler: 173 | 174 | ```ts 175 | export async function POST(req: Request) { 176 | try { 177 | const { messages } = await req.json(); 178 | 179 | const prompt = [ 180 | { 181 | role: "system", 182 | content: `AI assistant is a brand new, powerful, human-like artificial intelligence. 183 | The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness. 184 | AI is a well-behaved and well-mannered individual. 185 | AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user. 186 | AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation. 187 | AI assistant is a big fan of Pinecone and Vercel. 188 | `, 189 | }, 190 | ]; 191 | 192 | // Ask OpenAI for a streaming chat completion given the prompt 193 | const response = await openai.createChatCompletion({ 194 | model: "gpt-3.5-turbo", 195 | stream: true, 196 | messages: [ 197 | ...prompt, 198 | ...messages.filter((message: Message) => message.role === "user"), 199 | ], 200 | }); 201 | // Convert the response into a friendly text-stream 202 | const stream = OpenAIStream(response); 203 | // Respond with the stream 204 | return new StreamingTextResponse(stream); 205 | } catch (e) { 206 | throw e; 207 | } 208 | } 209 | ``` 210 | 211 | Here we deconstruct the messages from the post, and create our initial prompt. We use the prompt and the messages as the input to the `createChatCompletion` method. We then convert the response into a stream and return it to the client. Note that in this example, we only send the user's messages to OpenAI (as opposed to including the bot's messages as well). 212 | 213 | 214 | 215 | ## Step 3. Adding Context 216 | 217 | As we dive into building our chatbot, it's important to understand the role of context. Adding context to our chatbot's responses is key for creating a more natural, conversational user experience. Without context, a chatbot's responses can feel disjointed or irrelevant. By understanding the context of a user's query, our chatbot will be able to provide more accurate, relevant, and engaging responses. Now, let's begin building with this goal in mind. 218 | 219 | First, we'll first focus on seeding the knowledge base. We'll create a crawler and a seed script, and set up a crawl endpoint. This will allow us to gather and organize the information our chatbot will use to provide contextually relevant responses. 220 | 221 | After we've populated our knowledge base, we'll retrieve matches from our embeddings. This will enable our chatbot to find relevant information based on user queries. 222 | 223 | Next, we'll wrap our logic into the getContext function and update our chatbot's prompt. This will streamline our code and improve the user experience by ensuring the chatbot's prompts are relevant and engaging. 224 | 225 | Finally, we'll add a context panel and an associated context endpoint. These will provide a user interface for the chatbot and a way for it to retrieve the necessary context for each user query. 226 | 227 | This step is all about feeding our chatbot the information it needs and setting up the necessary infrastructure for it to retrieve and use that information effectively. Let's get started. 228 | 229 | ## Seeding the Knowledge Base 230 | 231 | Now we'll move on to seeding the knowledge base, the foundational data source that will inform our chatbot's responses. This step involves collecting and organizing the information our chatbot needs to operate effectively. In this guide, we're going to use data retrieved from various websites which we'll later on be able to ask questions about. To do this, we'll create a crawler that will scrape the data from the websites, embed it, and store it in Pinecone. 232 | 233 | ### Create the crawler 234 | 235 | For the sake of brevity, you'll be able to find the full code for the crawler here. Here are the pertinent parts: 236 | 237 | ```ts 238 | class Crawler { 239 | private seen = new Set(); 240 | private pages: Page[] = []; 241 | private queue: { url: string; depth: number }[] = []; 242 | 243 | constructor(private maxDepth = 2, private maxPages = 1) {} 244 | 245 | async crawl(startUrl: string): Promise { 246 | // Add the start URL to the queue 247 | this.addToQueue(startUrl); 248 | 249 | // While there are URLs in the queue and we haven't reached the maximum number of pages... 250 | while (this.shouldContinueCrawling()) { 251 | // Dequeue the next URL and depth 252 | const { url, depth } = this.queue.shift()!; 253 | 254 | // If the depth is too great or we've already seen this URL, skip it 255 | if (this.isTooDeep(depth) || this.isAlreadySeen(url)) continue; 256 | 257 | // Add the URL to the set of seen URLs 258 | this.seen.add(url); 259 | 260 | // Fetch the page HTML 261 | const html = await this.fetchPage(url); 262 | 263 | // Parse the HTML and add the page to the list of crawled pages 264 | this.pages.push({ url, content: this.parseHtml(html) }); 265 | 266 | // Extract new URLs from the page HTML and add them to the queue 267 | this.addNewUrlsToQueue(this.extractUrls(html, url), depth); 268 | } 269 | 270 | // Return the list of crawled pages 271 | return this.pages; 272 | } 273 | 274 | // ... Some private methods removed for brevity 275 | 276 | private async fetchPage(url: string): Promise { 277 | try { 278 | const response = await fetch(url); 279 | return await response.text(); 280 | } catch (error) { 281 | console.error(`Failed to fetch ${url}: ${error}`); 282 | return ""; 283 | } 284 | } 285 | 286 | private parseHtml(html: string): string { 287 | const $ = cheerio.load(html); 288 | $("a").removeAttr("href"); 289 | return NodeHtmlMarkdown.translate($.html()); 290 | } 291 | 292 | private extractUrls(html: string, baseUrl: string): string[] { 293 | const $ = cheerio.load(html); 294 | const relativeUrls = $("a") 295 | .map((_, link) => $(link).attr("href")) 296 | .get() as string[]; 297 | return relativeUrls.map( 298 | (relativeUrl) => new URL(relativeUrl, baseUrl).href 299 | ); 300 | } 301 | } 302 | ``` 303 | 304 | The `Crawler` class is a web crawler that visits URLs, starting from a given point, and collects information from them. It operates within a certain depth and a maximum number of pages as defined in the constructor. The crawl method is the core function that starts the crawling process. 305 | 306 | The helper methods fetchPage, parseHtml, and extractUrls respectively handle fetching the HTML content of a page, parsing the HTML to extract text, and extracting all URLs from a page to be queued for the next crawl. The class also maintains a record of visited URLs to avoid duplication. 307 | 308 | ### Create the `seed` function 309 | 310 | To tie things together, we'll create a seed function that will use the crawler to seed the knowledge base. In this portion of the code, we'll initialize the crawl and fetch a given URL, then split it's content into chunks, and finally embed and index the chunks in Pinecone. 311 | 312 | ```ts 313 | async function seed(url: string, limit: number, indexName: string, options: SeedOptions) { 314 | try { 315 | // Initialize the Pinecone client 316 | const pinecone = new Pinecone(); 317 | 318 | // Destructure the options object 319 | const { splittingMethod, chunkSize, chunkOverlap } = options; 320 | 321 | // Create a new Crawler with depth 1 and maximum pages as limit 322 | const crawler = new Crawler(1, limit || 100); 323 | 324 | // Crawl the given URL and get the pages 325 | const pages = await crawler.crawl(url) as Page[]; 326 | 327 | // Choose the appropriate document splitter based on the splitting method 328 | const splitter: DocumentSplitter = splittingMethod === 'recursive' ? 329 | new RecursiveCharacterTextSplitter({ chunkSize, chunkOverlap }) : new MarkdownTextSplitter({}); 330 | 331 | // Prepare documents by splitting the pages 332 | const documents = await Promise.all(pages.map(page => prepareDocument(page, splitter))); 333 | 334 | // Create Pinecone index if it does not exist 335 | const indexList = await pinecone.listIndexes(); 336 | const indexExists = indexList.some(index => index.name === indexName) 337 | if (!indexExists) { 338 | await pinecone.createIndex({ 339 | name: indexName, 340 | dimension: 1536, 341 | waitUntilReady: true, 342 | }); 343 | } 344 | 345 | const index = pinecone.Index(indexName) 346 | 347 | // Get the vector embeddings for the documents 348 | const vectors = await Promise.all(documents.flat().map(embedDocument)); 349 | 350 | // Upsert vectors into the Pinecone index 351 | await chunkedUpsert(index!, vectors, '', 10); 352 | 353 | // Return the first document 354 | return documents[0]; 355 | } catch (error) { 356 | console.error("Error seeding:", error); 357 | throw error; 358 | } 359 | } 360 | ``` 361 | 362 | To chunk the content we'll use one of the following methods: 363 | 364 | 1. `RecursiveCharacterTextSplitter` - This splitter splits the text into chunks of a given size, and then recursively splits the chunks into smaller chunks until the chunk size is reached. This method is useful for long documents. 365 | 2. `MarkdownTextSplitter` - This splitter splits the text into chunks based on Markdown headers. This method is useful for documents that are already structured using Markdown. The benefit of this method is that it will split the document into chunks based on the headers, which will be useful for our chatbot to understand the structure of the document. We can assume that each unit of text under a header is an internally coherent unit of information, and when the user asks a question, the retrieved context will be internally coherent as well. 366 | 367 | ### Add the `crawl` endpoint` 368 | 369 | The endpoint for the `crawl` endpoint is pretty straightforward. It simply calls the `seed` function and returns the result. 370 | 371 | ```ts 372 | import seed from "./seed"; 373 | import { NextResponse } from "next/server"; 374 | 375 | export const runtime = "edge"; 376 | 377 | export async function POST(req: Request) { 378 | const { url, options } = await req.json(); 379 | try { 380 | const documents = await seed(url, 1, process.env.PINECONE_INDEX!, options); 381 | return NextResponse.json({ success: true, documents }); 382 | } catch (error) { 383 | return NextResponse.json({ success: false, error: "Failed crawling" }); 384 | } 385 | } 386 | ``` 387 | 388 | Now our backend is able to crawl a given URL, embed the content and index the embeddings in Pinecone. The endpoint will return all the segments in the retrieved webpage we crawl, so we'll be able to display them. Next, we'll write a set of functions that will build the context out of these embeddings. 389 | 390 | ### Get matches from embeddings 391 | 392 | To retrieve the most relevant documents from the index, we'll use the `query` function in the Pinecone SDK. This function takes a vector and returns the most similar vectors from the index. We'll use this function to retrieve the most relevant documents from the index, given some embeddings. 393 | 394 | ```ts 395 | const getMatchesFromEmbeddings = async (embeddings: number[], topK: number, namespace: string): Promise[]> => { 396 | // Obtain a client for Pinecone 397 | const pinecone = new Pinecone(); 398 | 399 | const indexName: string = process.env.PINECONE_INDEX || ''; 400 | if (indexName === '') { 401 | throw new Error('PINECONE_INDEX environment variable not set') 402 | } 403 | 404 | // Retrieve the list of indexes to check if expected index exists 405 | const indexes = await pinecone.listIndexes() 406 | if (indexes.filter(i => i.name === indexName).length !== 1) { 407 | throw new Error(`Index ${indexName} does not exist`) 408 | } 409 | 410 | // Get the Pinecone index 411 | const index = pinecone!.Index(indexName); 412 | 413 | // Get the namespace 414 | const pineconeNamespace = index.namespace(namespace ?? '') 415 | 416 | try { 417 | // Query the index with the defined request 418 | const queryResult = await pineconeNamespace.query({ 419 | vector: embeddings, 420 | topK, 421 | includeMetadata: true, 422 | }) 423 | return queryResult.matches || [] 424 | } catch (e) { 425 | // Log the error and throw it 426 | console.log("Error querying embeddings: ", e) 427 | throw new Error(`Error querying embeddings: ${e}`) 428 | } 429 | } 430 | ``` 431 | 432 | The function takes in embeddings, a topK parameter, and a namespace, and returns the topK matches from the Pinecone index. It first gets a Pinecone client, checks if the desired index exists in the list of indexes, and throws an error if not. Then it gets the specific Pinecone index. The function then queries the Pinecone index with the defined request and returns the matches. 433 | 434 | ### Wrap things up in `getContext` 435 | 436 | We'll wrap things together in the `getContext` function. This function will take in a `message` and return the context - either in string form, or as a set of `ScoredVector`. 437 | 438 | ```ts 439 | export const getContext = async ( 440 | message: string, 441 | namespace: string, 442 | maxTokens = 3000, 443 | minScore = 0.7, 444 | getOnlyText = true 445 | ): Promise => { 446 | // Get the embeddings of the input message 447 | const embedding = await getEmbeddings(message); 448 | 449 | // Retrieve the matches for the embeddings from the specified namespace 450 | const matches = await getMatchesFromEmbeddings(embedding, 3, namespace); 451 | 452 | // Filter out the matches that have a score lower than the minimum score 453 | const qualifyingDocs = matches.filter((m) => m.score && m.score > minScore); 454 | 455 | // If the `getOnlyText` flag is false, we'll return the matches 456 | if (!getOnlyText) { 457 | return qualifyingDocs; 458 | } 459 | 460 | let docs = matches 461 | ? qualifyingDocs.map((match) => (match.metadata as Metadata).chunk) 462 | : []; 463 | // Join all the chunks of text together, truncate to the maximum number of tokens, and return the result 464 | return docs.join("\n").substring(0, maxTokens); 465 | }; 466 | ``` 467 | 468 | Back in `chat/route.ts`, we'll add the call to `getContext`: 469 | 470 | ```ts 471 | const { messages } = await req.json(); 472 | 473 | // Get the last message 474 | const lastMessage = messages[messages.length - 1]; 475 | 476 | // Get the context from the last message 477 | const context = await getContext(lastMessage.content, ""); 478 | ``` 479 | 480 | ### Update the prompt 481 | 482 | Finally, we'll update the prompt to include the context we retrieved from the `getContext` function. 483 | 484 | ```ts 485 | const prompt = [ 486 | { 487 | role: "system", 488 | content: `AI assistant is a brand new, powerful, human-like artificial intelligence. 489 | The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness. 490 | AI is a well-behaved and well-mannered individual. 491 | AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user. 492 | AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation. 493 | AI assistant is a big fan of Pinecone and Vercel. 494 | START CONTEXT BLOCK 495 | ${context} 496 | END OF CONTEXT BLOCK 497 | AI assistant will take into account any CONTEXT BLOCK that is provided in a conversation. 498 | If the context does not provide the answer to question, the AI assistant will say, "I'm sorry, but I don't know the answer to that question". 499 | AI assistant will not apologize for previous responses, but instead will indicated new information was gained. 500 | AI assistant will not invent anything that is not drawn directly from the context. 501 | `, 502 | }, 503 | ]; 504 | ``` 505 | 506 | In this prompt, we added a `START CONTEXT BLOCK` and `END OF CONTEXT BLOCK` to indicate where the context should be inserted. We also added a line to indicate that the AI assistant will take into account any context block that is provided in a conversation. 507 | 508 | ### Add the context panel 509 | 510 | Next, we need to add the context panel to the chat UI. We'll add a new component called `Context` ([full code](https://github.com/pinecone-io/pinecone-vercel-example/tree/main/src/app/components/Context)). 511 | 512 | ### Add the context endpoint 513 | 514 | We want to allow interface to indicate which portions of the retrieved content have been used to generate the response. To do this, we'll add a another endpoint that will call the same `getContext`. 515 | 516 | ```ts 517 | export async function POST(req: Request) { 518 | try { 519 | const { messages } = await req.json(); 520 | const lastMessage = 521 | messages.length > 1 ? messages[messages.length - 1] : messages[0]; 522 | const context = (await getContext( 523 | lastMessage.content, 524 | "", 525 | 10000, 526 | 0.7, 527 | false 528 | )) as ScoredPineconeRecord[]; 529 | return NextResponse.json({ context }); 530 | } catch (e) { 531 | console.log(e); 532 | return NextResponse.error(); 533 | } 534 | } 535 | ``` 536 | 537 | Whenever the user crawls a URL, the context panel will display all the segments of the retrieved webpage. Whenever the backend completes sending a message back, the front end will trigger an effect that will retrieve this context: 538 | 539 | ```tsx 540 | useEffect(() => { 541 | const getContext = async () => { 542 | const response = await fetch("/api/context", { 543 | method: "POST", 544 | body: JSON.stringify({ 545 | messages, 546 | }), 547 | }); 548 | const { context } = await response.json(); 549 | setContext(context.map((c: any) => c.id)); 550 | }; 551 | if (gotMessages && messages.length >= prevMessagesLengthRef.current) { 552 | getContext(); 553 | } 554 | 555 | prevMessagesLengthRef.current = messages.length; 556 | }, [messages, gotMessages]); 557 | ``` 558 | 559 | ## Running tests 560 | 561 | The pinecone-vercel-starter uses [Playwright](https://playwright.dev) for end to end testing. 562 | 563 | To run all the tests: 564 | 565 | ``` 566 | npm run test:e2e 567 | ``` 568 | 569 | By default, when running locally, if errors are encountered, Playwright will open an HTML report showing which 570 | tests failed and for which browser drivers. 571 | 572 | ## Displaying test reports locally 573 | 574 | To display the latest test report locally, run: 575 | ``` 576 | npm run test:show 577 | ``` 578 | 579 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | module.exports = nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vercel-pinecone-template", 3 | "version": "0.2.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test:e2e": "playwright test", 11 | "test:show": "playwright show-report" 12 | }, 13 | "dependencies": { 14 | "@ai-sdk/openai": "^0.0.60", 15 | "@edge-runtime/jest-environment": "^3.0.3", 16 | "@emotion/react": "^11.13.3", 17 | "@emotion/styled": "^11.13.0", 18 | "@material-tailwind/react": "^2.1.10", 19 | "@mui/icons-material": "^6.1.0", 20 | "@mui/material": "^6.1.0", 21 | "@pinecone-database/doc-splitter": "^0.0.1", 22 | "@pinecone-database/pinecone": "3.0.3", 23 | "@types/uuid": "^10.0.0", 24 | "ai": "^3.3.39", 25 | "base64-arraybuffer": "^1.0.2", 26 | "cheerio": "^1.0.0", 27 | "edge-runtime": "^3.0.3", 28 | "md5": "^2.3.0", 29 | "next": "^14.2.11", 30 | "node-html-markdown": "^1.3.0", 31 | "openai": "^4.61.1", 32 | "openai-edge": "^1.2.2", 33 | "react": "18.3.1", 34 | "react-dom": "18.3.1", 35 | "react-icons": "^5.3.0", 36 | "react-markdown": "^9.0.1", 37 | "react-spinners": "^0.14.1", 38 | "sswr": "^2.1.0", 39 | "svelte": "^4.2.19", 40 | "tailwindcss": "3.4.11", 41 | "typescript": "5.6.2", 42 | "unified": "^11.0.5", 43 | "uuid": "^10.0.0", 44 | "vue": "^3.5.6", 45 | "zod": "^3.23.8" 46 | }, 47 | "devDependencies": { 48 | "@playwright/test": "^1.47.1", 49 | "@types/md5": "^2.3.5", 50 | "@types/node": "22.5.5", 51 | "@types/react": "18.3.6", 52 | "@types/react-dom": "18.3.0", 53 | "eslint": "^8.0.0", 54 | "eslint-config-next": "14.2.11" 55 | } 56 | } -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, devices } from '@playwright/test'; 2 | 3 | /** 4 | * Read environment variables from file. 5 | * https://github.com/motdotla/dotenv 6 | */ 7 | // require('dotenv').config(); 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | export default defineConfig({ 13 | testDir: './tests', 14 | /* Run tests in files in parallel */ 15 | fullyParallel: true, 16 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 17 | forbidOnly: !!process.env.CI, 18 | /* Retry on CI only */ 19 | retries: process.env.CI ? 2 : 0, 20 | /* Opt out of parallel tests on CI. */ 21 | workers: process.env.CI ? 1 : undefined, 22 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 23 | reporter: 'html', 24 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 25 | use: { 26 | /* Base URL to use in actions like `await page.goto('/')`. */ 27 | // baseURL: 'http://127.0.0.1:3000', 28 | 29 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 30 | trace: 'on-first-retry', 31 | }, 32 | 33 | /* Configure projects for major browsers */ 34 | projects: [ 35 | { 36 | name: 'chromium', 37 | use: { ...devices['Desktop Chrome'] }, 38 | }, 39 | 40 | { 41 | name: 'firefox', 42 | use: { ...devices['Desktop Firefox'] }, 43 | }, 44 | 45 | { 46 | name: 'webkit', 47 | use: { ...devices['Desktop Safari'] }, 48 | }, 49 | 50 | /* Test against mobile viewports. */ 51 | // { 52 | // name: 'Mobile Chrome', 53 | // use: { ...devices['Pixel 5'] }, 54 | // }, 55 | // { 56 | // name: 'Mobile Safari', 57 | // use: { ...devices['iPhone 12'] }, 58 | // }, 59 | 60 | /* Test against branded browsers. */ 61 | // { 62 | // name: 'Microsoft Edge', 63 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 64 | // }, 65 | // { 66 | // name: 'Google Chrome', 67 | // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, 68 | // }, 69 | ], 70 | 71 | /* Run your local dev server before starting the tests */ 72 | // webServer: { 73 | // command: 'npm run start', 74 | // url: 'http://127.0.0.1:3000', 75 | // reuseExistingServer: !process.env.CI, 76 | // }, 77 | }); 78 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/pinecone.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { Metadata, getContext } from '@/services/context' 2 | import type { PineconeRecord } from '@pinecone-database/pinecone' 3 | import { Message, StreamData } from 'ai' 4 | import { openai } from '@ai-sdk/openai'; 5 | import { CoreMessage, streamText, convertToCoreMessages } from 'ai'; 6 | 7 | 8 | // IMPORTANT! Set the runtime to edge 9 | export const runtime = 'edge' 10 | 11 | export async function POST(req: Request) { 12 | try { 13 | const { messages, withContext }: { messages: CoreMessage[], withContext: boolean } = await req.json(); 14 | // Get the last message 15 | const lastMessage = messages[messages.length - 1] 16 | 17 | // Get the context from the last message 18 | const context = withContext ? await getContext(lastMessage?.content as string, '', 3000, 0.8, false) : '' 19 | 20 | // Get the chunks of text from the context 21 | const docs = (withContext && context.length > 0) ? (context as PineconeRecord[]).map(match => (match.metadata as Metadata).chunk) : []; 22 | 23 | // Join all the chunks of text together, truncate to the maximum number of tokens, and return the result 24 | const contextText = docs.join("\n").substring(0, 3000) 25 | 26 | const prompt = `AI assistant is a brand new, powerful, human-like artificial intelligence. 27 | The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness. 28 | AI is a well-behaved and well-mannered individual. 29 | AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user. 30 | AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation. 31 | AI assistant is a big fan of Pinecone and Vercel. 32 | START CONTEXT BLOCK 33 | ${contextText} 34 | END OF CONTEXT BLOCK 35 | AI assistant will take into account any CONTEXT BLOCK that is provided in a conversation. 36 | If the context does not provide the answer to question, the AI assistant will say, "I'm sorry, but I don't know the answer to that question". 37 | AI assistant will not apologize for previous responses, but instead will indicated new information was gained. 38 | AI assistant will not invent anything that is not drawn directly from the context. 39 | ` 40 | 41 | const sanitizedMessages = messages.map((message: any) => { 42 | const { createdAt, id, ...rest } = message; 43 | return rest; 44 | }); 45 | 46 | // Create a StreamData object to store the context data 47 | const data = new StreamData(); 48 | 49 | const result = await streamText({ 50 | model: openai("gpt-4o"), 51 | system: prompt, 52 | messages: convertToCoreMessages(sanitizedMessages.filter((message: Message) => message.role === 'user')), 53 | onFinish: async () => { 54 | // Append the context to the StreamData object 55 | data.append({ context }); 56 | 57 | // Ensure to close the StreamData object 58 | data.close(); 59 | } 60 | }); 61 | 62 | // Use toDataStreamResponse with the StreamData object 63 | return result.toDataStreamResponse({ 64 | data 65 | }); 66 | } catch (e) { 67 | throw (e) 68 | } 69 | } -------------------------------------------------------------------------------- /src/app/api/checkIndex/route.ts: -------------------------------------------------------------------------------- 1 | import { Pinecone } from '@pinecone-database/pinecone'; 2 | import { NextResponse } from "next/server"; 3 | 4 | export async function POST() { 5 | // Instantiate a new Pinecone client 6 | const pinecone = new Pinecone(); 7 | // Select the desired index 8 | const indexName = process.env.PINECONE_INDEX!; 9 | const index = pinecone.Index(indexName); 10 | 11 | // Use the custom namespace, if provided, otherwise use the default 12 | const namespaceName = process.env.PINECONE_NAMESPACE ?? '' 13 | const namespace = index.namespace(namespaceName) 14 | 15 | // Delete everything within the namespace 16 | const stats = await namespace.describeIndexStats() 17 | 18 | return NextResponse.json({ 19 | ...stats 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /src/app/api/clearIndex/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { Pinecone } from '@pinecone-database/pinecone' 3 | 4 | export async function POST() { 5 | // Instantiate a new Pinecone client 6 | const pinecone = new Pinecone(); 7 | // Select the desired index 8 | const index = pinecone.Index(process.env.PINECONE_INDEX!) 9 | 10 | // Use the custom namespace, if provided, otherwise use the default 11 | const namespaceName = process.env.PINECONE_NAMESPACE ?? '' 12 | const namespace = index.namespace(namespaceName) 13 | 14 | // Delete everything within the namespace 15 | await namespace.deleteAll(); 16 | 17 | return NextResponse.json({ 18 | success: true 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /src/app/api/crawl/crawler.ts: -------------------------------------------------------------------------------- 1 | import * as cheerio from 'cheerio'; 2 | import { NodeHtmlMarkdown } from 'node-html-markdown'; 3 | 4 | interface Page { 5 | url: string; 6 | content: string; 7 | } 8 | 9 | class Crawler { 10 | private seen = new Set(); 11 | private pages: Page[] = []; 12 | private queue: { url: string; depth: number }[] = []; 13 | 14 | constructor(private maxDepth = 2, private maxPages = 1) { } 15 | 16 | async crawl(startUrl: string): Promise { 17 | // Add the start URL to the queue 18 | this.addToQueue(startUrl); 19 | 20 | // While there are URLs in the queue and we haven't reached the maximum number of pages... 21 | while (this.shouldContinueCrawling()) { 22 | // Dequeue the next URL and depth 23 | const { url, depth } = this.queue.shift()!; 24 | 25 | // If the depth is too great or we've already seen this URL, skip it 26 | if (this.isTooDeep(depth) || this.isAlreadySeen(url)) continue; 27 | 28 | // Add the URL to the set of seen URLs 29 | this.seen.add(url); 30 | 31 | // Fetch the page HTML 32 | const html = await this.fetchPage(url); 33 | 34 | // Parse the HTML and add the page to the list of crawled pages 35 | this.pages.push({ url, content: this.parseHtml(html) }); 36 | 37 | // Extract new URLs from the page HTML and add them to the queue 38 | this.addNewUrlsToQueue(this.extractUrls(html, url), depth); 39 | } 40 | 41 | // Return the list of crawled pages 42 | return this.pages; 43 | } 44 | 45 | private isTooDeep(depth: number) { 46 | return depth > this.maxDepth; 47 | } 48 | 49 | private isAlreadySeen(url: string) { 50 | return this.seen.has(url); 51 | } 52 | 53 | private shouldContinueCrawling() { 54 | return this.queue.length > 0 && this.pages.length < this.maxPages; 55 | } 56 | 57 | private addToQueue(url: string, depth = 0) { 58 | this.queue.push({ url, depth }); 59 | } 60 | 61 | private addNewUrlsToQueue(urls: string[], depth: number) { 62 | this.queue.push(...urls.map(url => ({ url, depth: depth + 1 }))); 63 | } 64 | 65 | private async fetchPage(url: string): Promise { 66 | try { 67 | const response = await fetch(url); 68 | return await response.text(); 69 | } catch (error) { 70 | console.error(`Failed to fetch ${url}: ${error}`); 71 | return ''; 72 | } 73 | } 74 | 75 | private parseHtml(html: string): string { 76 | const $ = cheerio.load(html); 77 | $('a').removeAttr('href'); 78 | return NodeHtmlMarkdown.translate($.html()); 79 | } 80 | 81 | private extractUrls(html: string, baseUrl: string): string[] { 82 | const $ = cheerio.load(html); 83 | const relativeUrls = $('a').map((_, link) => $(link).attr('href')).get() as string[]; 84 | return relativeUrls.map(relativeUrl => new URL(relativeUrl, baseUrl).href); 85 | } 86 | } 87 | 88 | export { Crawler }; 89 | export type { Page }; 90 | -------------------------------------------------------------------------------- /src/app/api/crawl/route.ts: -------------------------------------------------------------------------------- 1 | import seed from './seed' 2 | import { NextResponse } from 'next/server'; 3 | 4 | export const runtime = 'edge' 5 | 6 | export async function POST(req: Request) { 7 | const { url, options } = await req.json() 8 | try { 9 | const documents = await seed(url, 1, process.env.PINECONE_INDEX!, options) 10 | return NextResponse.json({ success: true, documents }) 11 | } catch (error) { 12 | return NextResponse.json({ success: false, error: "Failed crawling" }) 13 | } 14 | } -------------------------------------------------------------------------------- /src/app/api/crawl/seed.ts: -------------------------------------------------------------------------------- 1 | import { chunkedUpsert } from '@/services/chunkedUpsert'; 2 | import { getEmbeddings } from "@/services/embeddings"; 3 | import { truncateStringByBytes } from "@/utils/truncateString"; 4 | import { Document, MarkdownTextSplitter, RecursiveCharacterTextSplitter } from "@pinecone-database/doc-splitter"; 5 | import { Pinecone, PineconeRecord } from "@pinecone-database/pinecone"; 6 | import { ServerlessSpecCloudEnum } from '@pinecone-database/pinecone'; 7 | import md5 from "md5"; 8 | import { Crawler, Page } from "./crawler"; 9 | 10 | interface SeedOptions { 11 | splittingMethod: string 12 | chunkSize: number 13 | chunkOverlap: number 14 | } 15 | 16 | const PINECONE_REGION = process.env.PINECONE_REGION || 'us-west-2' 17 | const PINECONE_CLOUD = process.env.PINECONE_CLOUD || 'aws' 18 | 19 | type DocumentSplitter = RecursiveCharacterTextSplitter | MarkdownTextSplitter 20 | 21 | async function seed(url: string, limit: number, indexName: string, options: SeedOptions) { 22 | try { 23 | // Initialize the Pinecone client 24 | const pinecone = new Pinecone(); 25 | 26 | // Destructure the options object 27 | const { splittingMethod, chunkSize, chunkOverlap } = options; 28 | 29 | // Create a new Crawler with depth 1 and maximum pages as limit 30 | const crawler = new Crawler(1, limit || 100); 31 | 32 | // Crawl the given URL and get the pages 33 | const pages = await crawler.crawl(url) as Page[]; 34 | 35 | // Choose the appropriate document splitter based on the splitting method 36 | const splitter: DocumentSplitter = splittingMethod === 'recursive' ? 37 | new RecursiveCharacterTextSplitter({ chunkSize, chunkOverlap }) : new MarkdownTextSplitter({}); 38 | 39 | // Prepare documents by splitting the pages 40 | const documents = await Promise.all(pages.map(page => prepareDocument(page, splitter))); 41 | 42 | // Create Pinecone index if it does not exist 43 | const indexList = await pinecone.listIndexes(); 44 | const indexes = indexList.indexes 45 | const indexExists = indexes && indexes.some(index => index.name === indexName) 46 | if (!indexExists) { 47 | await pinecone.createIndex({ 48 | name: indexName, 49 | dimension: 1536, 50 | waitUntilReady: true, 51 | spec: { 52 | serverless: { 53 | region: PINECONE_REGION, 54 | cloud: PINECONE_CLOUD as ServerlessSpecCloudEnum 55 | } 56 | } 57 | }); 58 | } 59 | 60 | const index = pinecone.Index(indexName) 61 | 62 | // Get the vector embeddings for the documents 63 | const vectors = await Promise.all(documents.flat().map(embedDocument)); 64 | 65 | // Upsert vectors into the Pinecone index 66 | await chunkedUpsert(index, vectors, '', 10); 67 | 68 | // Return the first document 69 | return documents[0]; 70 | } catch (error) { 71 | console.error("Error seeding:", error); 72 | throw error; 73 | } 74 | } 75 | 76 | async function embedDocument(doc: Document): Promise { 77 | try { 78 | // Generate OpenAI embeddings for the document content 79 | const embedding = await getEmbeddings(doc.pageContent); 80 | 81 | // Create a hash of the document content 82 | const hash = md5(doc.pageContent); 83 | 84 | // Return the vector embedding object 85 | return { 86 | id: hash, // The ID of the vector is the hash of the document content 87 | values: embedding, // The vector values are the OpenAI embeddings 88 | metadata: { // The metadata includes details about the document 89 | chunk: doc.pageContent, // The chunk of text that the vector represents 90 | text: doc.metadata.text as string, // The text of the document 91 | url: doc.metadata.url as string, // The URL where the document was found 92 | hash: doc.metadata.hash as string // The hash of the document content 93 | } 94 | } as PineconeRecord; 95 | } catch (error) { 96 | console.log("Error embedding document: ", error) 97 | throw error 98 | } 99 | } 100 | 101 | async function prepareDocument(page: Page, splitter: DocumentSplitter): Promise { 102 | // Get the content of the page 103 | const pageContent = page.content; 104 | 105 | // Split the documents using the provided splitter 106 | const docs = await splitter.splitDocuments([ 107 | new Document({ 108 | pageContent, 109 | metadata: { 110 | url: page.url, 111 | // Truncate the text to a maximum byte length 112 | text: truncateStringByBytes(pageContent, 36000) 113 | }, 114 | }), 115 | ]); 116 | 117 | // Map over the documents and add a hash to their metadata 118 | return docs.map((doc: Document) => { 119 | return { 120 | pageContent: doc.pageContent, 121 | metadata: { 122 | ...doc.metadata, 123 | // Create a hash of the document content 124 | hash: md5(doc.pageContent) 125 | }, 126 | }; 127 | }); 128 | } 129 | 130 | 131 | 132 | 133 | export default seed; 134 | -------------------------------------------------------------------------------- /src/app/appContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface RefreshIndexContextType { 4 | totalRecords: number; 5 | refreshIndex: () => Promise; 6 | } 7 | const defaultContext: RefreshIndexContextType = { 8 | totalRecords: 0, 9 | refreshIndex: () => Promise.resolve(), 10 | }; 11 | 12 | const AppContext = React.createContext(defaultContext); 13 | 14 | export default AppContext; -------------------------------------------------------------------------------- /src/app/assets/icons/ellipse.tsx: -------------------------------------------------------------------------------- 1 | import { EllipseSvg } from '../svg/ellipse' 2 | export const EllipseIcon = (props: any) => { 3 | return (
8 | 9 |
) 10 | 11 | } -------------------------------------------------------------------------------- /src/app/assets/icons/pinecone.tsx: -------------------------------------------------------------------------------- 1 | import { PineconeSvg } from '../svg/pinecone' 2 | export const PineconeIcon = (props: any) => { 3 | return (
8 | 9 |
) 10 | 11 | } -------------------------------------------------------------------------------- /src/app/assets/icons/user.tsx: -------------------------------------------------------------------------------- 1 | import { UserSvg } from '../svg/user' 2 | export const UserIcon = (props: any) => { 3 | return (
8 | 9 |
) 10 | 11 | } -------------------------------------------------------------------------------- /src/app/assets/svg/blueEllipse.tsx: -------------------------------------------------------------------------------- 1 | export const BlueEllipseSvg = () => { 2 | return 3 | 4 | 5 | } -------------------------------------------------------------------------------- /src/app/assets/svg/ellipse.tsx: -------------------------------------------------------------------------------- 1 | export const EllipseSvg = () => { 2 | return 3 | 4 | 5 | } -------------------------------------------------------------------------------- /src/app/assets/svg/pinecone.tsx: -------------------------------------------------------------------------------- 1 | export const PineconeSvg = () => { 2 | return 3 | 4 | 5 | 6 | 7 | 8 | } -------------------------------------------------------------------------------- /src/app/assets/svg/pineconeLogo.tsx: -------------------------------------------------------------------------------- 1 | export const PineconeLogoSvg = () => { 2 | return ( 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ) 13 | } -------------------------------------------------------------------------------- /src/app/assets/svg/upArrow.tsx: -------------------------------------------------------------------------------- 1 | export const UpArrowSvg = () => { 2 | return 3 | 4 | 5 | } -------------------------------------------------------------------------------- /src/app/assets/svg/user.tsx: -------------------------------------------------------------------------------- 1 | export const UserSvg = () => { 2 | return 3 | 4 | 5 | } -------------------------------------------------------------------------------- /src/app/components/Chat/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | import { UpArrowSvg } from '@/assets/svg/upArrow'; 2 | import React, { FormEvent } from 'react'; 3 | 4 | interface ChatInputProps { 5 | input: string; 6 | handleInputChange: (event: React.ChangeEvent) => void; 7 | handleMessageSubmit: (event: FormEvent) => void; 8 | showIndexMessage: boolean; 9 | } 10 | 11 | const styles = { 12 | container: { border: "1px solid #738FAB1F", padding: 30 }, 13 | form: { border: "1px solid #738FAB80", borderRadius: 4 }, 14 | svg: { background: "black", margin: 9, borderRadius: 4 }, 15 | message: { marginTop: 15, color: "#72788D", fontSize: 12 }, 16 | hint: { position: "absolute", top: 10, right: 35, fontSize: 12, color: "#72788D" } 17 | }; 18 | 19 | const ChatInput: React.FC = ({ input, handleInputChange, handleMessageSubmit, showIndexMessage }) => { 20 | return ( 21 |
22 |
26 |
27 | 35 |
0 ? "visible" : "hidden"}` }}>Hit enter to send
36 | 37 |
38 | 39 | {showIndexMessage &&
40 | Your index contains no vector embeddings yet. Please add some by indexing one of the demo URLs on the left. 41 |
} 42 | 43 |
44 |
45 | ); 46 | }; 47 | 48 | export default ChatInput; 49 | -------------------------------------------------------------------------------- /src/app/components/Chat/ChatWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, useImperativeHandle, FormEvent, ChangeEvent, useRef, useEffect, useState } from "react"; 2 | import {useChat, experimental_useObject as useObject} from 'ai/react'; 3 | import Messages from "./Messages"; 4 | import type { PineconeRecord } from "@pinecone-database/pinecone"; 5 | import { v4 as uuidv4 } from 'uuid'; 6 | 7 | export interface ChatInterface { 8 | handleMessageSubmit: (e: FormEvent) => void; 9 | handleInputUpdated: (event: ChangeEvent) => void; 10 | } 11 | 12 | interface ChatProps { 13 | withContext: boolean; 14 | setContext: (data: { context: PineconeRecord[] }[]) => void; 15 | context?: { context: PineconeRecord[] }[] | null; 16 | } 17 | 18 | const ChatWrapper = forwardRef(({ withContext, setContext, context }, ref) => { 19 | const [finished, setFinished] = useState(false); 20 | const { messages, input, setInput, append, handleSubmit, handleInputChange, data } = useChat({ 21 | body: { 22 | withContext 23 | }, 24 | }); 25 | 26 | useEffect(() => { 27 | if (data?.length) { 28 | const { context } = data[0] as { context?: PineconeRecord[] }; 29 | if (context) { 30 | setContext([{ context }]); 31 | } 32 | } 33 | }, [data, setContext]); 34 | 35 | const bottomChatRef = useRef(null); 36 | const chatRef = useRef(null); 37 | 38 | useEffect(() => { 39 | if (finished && withContext && context) { 40 | setContext(context) 41 | setFinished(false) 42 | } 43 | }, [context, finished, withContext, setContext]); 44 | 45 | useEffect(() => { 46 | bottomChatRef.current?.scrollIntoView({ behavior: "smooth" }); 47 | }, [messages]); 48 | 49 | useImperativeHandle(ref, () => ({ 50 | handleMessageSubmit: (event: FormEvent) => { 51 | const id = uuidv4(); 52 | handleSubmit(event, { 53 | data: { 54 | messageId: id, 55 | }, 56 | }) 57 | }, 58 | handleInputUpdated: (event: ChangeEvent) => { 59 | handleInputChange(event) 60 | }, 61 | withContext, 62 | ref: chatRef, 63 | })); 64 | 65 | return ( 66 |
67 |
0 ? "flex flex-col justify-center items-center h-full" : "overflow-auto"}`}> 68 | {context ? ( 69 | 70 | ) : ( 71 | 72 | )} 73 |
74 |
75 | ); 76 | }); 77 | 78 | ChatWrapper.displayName = 'ChatWrapper'; 79 | 80 | export default ChatWrapper; 81 | -------------------------------------------------------------------------------- /src/app/components/Chat/Messages.tsx: -------------------------------------------------------------------------------- 1 | import { EllipseIcon } from "@/assets/icons/ellipse"; 2 | import { PineconeIcon } from "@/assets/icons/pinecone"; 3 | import { UserIcon } from "@/assets/icons/user"; 4 | import { PineconeLogoSvg } from "@/assets/svg/pineconeLogo"; 5 | import { Typography } from "@mui/material"; 6 | import Popover from "@mui/material/Popover"; 7 | import type { PineconeRecord } from "@pinecone-database/pinecone"; 8 | import { Message } from "ai"; 9 | import { useRef, useState } from "react"; 10 | 11 | export default function Messages({ messages, withContext, context }: { messages: Message[], withContext: boolean, context?: { context: PineconeRecord[] }[] }) { 12 | const messagesEndRef = useRef(null); 13 | const [anchorEls, setAnchorEls] = useState<{ [key: string]: HTMLButtonElement | null }>({}); 14 | 15 | const handleClick = (event: React.MouseEvent, messageId: string, chunkId: string) => { 16 | setAnchorEls(prev => ({ ...prev, [`${messageId}-${chunkId}`]: event.currentTarget })); 17 | }; 18 | 19 | // Handle close function 20 | const handleClose = (messageId: string, chunkId: string) => { 21 | setAnchorEls(prev => ({ ...prev, [`${messageId}-${chunkId}`]: null })); 22 | }; 23 | 24 | const styles = { 25 | lightGrey: { 26 | color: "#72788D" 27 | }, 28 | placeholder: { 29 | fontSize: 12, 30 | marginTop: 10, 31 | } 32 | } 33 | 34 | return ( 35 |
36 | {messages.length == 0 && ( 37 |
38 |
39 | {withContext ? ( 40 | <> 41 |
42 | 43 |
44 |
45 | This is your chatbot powered by pinecone 46 |
47 | 48 | ) : ( 49 |
50 | Compare to a chatbot without context 51 |
52 | )} 53 |
54 |
55 | )} 56 | {messages?.map((message, index) => { 57 | const isAssistant = message.role === "assistant"; 58 | const entry = isAssistant && withContext && context && context[Math.floor(index / 2)]; 59 | 60 | return ( 61 |
65 |
66 | {message.role === "assistant" ? (withContext ? : ) : } 67 |
68 |
69 |
70 |
71 | {message.role === "assistant" ? (withContext ? "Pinecone + OpenAI Model" : "OpenAI Model") : "You"} 72 |
73 |
{message.content}
74 | {entry && entry.context.length > 0 && ( 75 |
76 |
Source:
77 | {entry.context.map((chunk, index) => { 78 | return ( 79 |
80 | 83 | handleClose(message.id, chunk.id)} 88 | disableRestoreFocus 89 | anchorOrigin={{ 90 | vertical: 'bottom', 91 | horizontal: 'center', 92 | }} 93 | transformOrigin={{ 94 | vertical: 'bottom', 95 | horizontal: 'center', 96 | }} 97 | sx={{ 98 | width: "60%", 99 | pointerEvents: 'none', 100 | }} 101 | > 102 |
103 | 104 | {chunk.metadata?.chunk} 105 | 106 |
107 |
108 |
109 | ) 110 | })} 111 |
112 | 113 | ) 114 | } 115 | { 116 | !withContext && message.role === "assistant" && (index == messages.length - 1) && (
117 | This answer may be speculative or inaccurate. 118 |
) 119 | } 120 |
121 |
122 |
123 | ) 124 | })} 125 |
126 |
127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /src/app/components/Chat/index.tsx: -------------------------------------------------------------------------------- 1 | import AppContext from "@/appContext"; 2 | import type { PineconeRecord } from "@pinecone-database/pinecone"; 3 | import React, { ChangeEvent, FormEvent, useContext, useRef } from "react"; 4 | import ChatInput from "./ChatInput"; 5 | import ChatWrapper, { ChatInterface } from "./ChatWrapper"; 6 | 7 | interface ChatProps { 8 | setContext: (data: { context: PineconeRecord[] }[]) => void; 9 | context: { context: PineconeRecord[] }[] | null; 10 | } 11 | 12 | const Chat: React.FC = ({ setContext, context }) => { 13 | 14 | const chatWithContextRef = useRef(null); 15 | const chatWithoutContextRef = useRef(null); 16 | 17 | const { totalRecords } = useContext(AppContext); 18 | 19 | const [input, setInput] = React.useState("") 20 | const onMessageSubmit = (e: FormEvent) => { 21 | setInput("") 22 | chatWithContextRef.current?.handleMessageSubmit(e) 23 | chatWithoutContextRef.current?.handleMessageSubmit(e) 24 | } 25 | 26 | const onInputChange = (event: ChangeEvent) => { 27 | setInput(event.target.value) 28 | chatWithContextRef.current?.handleInputUpdated(event) 29 | chatWithoutContextRef.current?.handleInputUpdated(event) 30 | } 31 | 32 | return ( 33 |
34 |
35 |
36 | 37 |
38 |
39 | 40 |
41 |
42 |
43 | 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default Chat; 50 | -------------------------------------------------------------------------------- /src/app/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import PineconeLogo from "../../../public/pinecone.svg"; 3 | import VercelLogo from "../../../public/vercel.svg"; 4 | 5 | export default function Header({ className }: { className?: string }) { 6 | return ( 7 |
10 | pinecone-logo{" "} 17 |
/
18 | vercel-logo 25 |
26 | ); 27 | } -------------------------------------------------------------------------------- /src/app/components/Sidebar/Button.tsx: -------------------------------------------------------------------------------- 1 | export function Button({ className, ...props }: any) { 2 | return ( 3 | 52 | handleClose()} 57 | disableRestoreFocus 58 | anchorOrigin={{ 59 | vertical: 'bottom', 60 | horizontal: 'right', 61 | }} 62 | transformOrigin={{ 63 | vertical: 'bottom', 64 | horizontal: 'right', 65 | }} 66 | sx={{ 67 | pointerEvents: 'none', 68 | }} 69 | > 70 |
71 | {card.pageContent} 72 |
73 |
74 | 75 | 76 |
77 |
78 | {/*
79 | {selected && selected.includes(card.metadata.hash) && } 80 | 81 | ID: {card.metadata.hash} 82 | 83 |
*/} 84 | 85 | ) 86 | 87 | }; 88 | -------------------------------------------------------------------------------- /src/app/components/Sidebar/InfoPopover.tsx: -------------------------------------------------------------------------------- 1 | import { Popover, PopoverContent, PopoverHandler } from "@material-tailwind/react"; 2 | import React, { useState } from 'react'; 3 | import { IoMdInformationCircleOutline } from "react-icons/io"; 4 | 5 | interface InfoPopoverProps { 6 | infoText: string; 7 | className?: string; 8 | } 9 | 10 | export const InfoPopover: React.FC = ({ infoText, className }) => { 11 | const [open, setOpen] = useState(false); 12 | 13 | const popoverTriggers = { 14 | onMouseEnter: () => setOpen(true), 15 | onMouseLeave: () => setOpen(false), 16 | }; 17 | 18 | return ( 19 |
20 | 21 | 22 |
23 |
24 | 25 |
26 | {infoText} 27 |
28 |
29 |
30 |
31 | ); 32 | }; -------------------------------------------------------------------------------- /src/app/components/Sidebar/RecursiveSplittingOptions.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { InfoPopover } from './InfoPopover'; 3 | 4 | interface RecursiveSplittingOptionsProps { 5 | chunkSize: number; 6 | setChunkSize: (value: number) => void; 7 | overlap: number; 8 | setOverlap: (value: number) => void; 9 | } 10 | 11 | export const RecursiveSplittingOptions: React.FC = ({ 12 | chunkSize, 13 | setChunkSize, 14 | overlap, 15 | setOverlap, 16 | }) => { 17 | 18 | 19 | return ( 20 |
21 |
22 |
23 |
24 | Chunk Size: {chunkSize} 25 |
26 | 30 |
31 |
32 |
33 | 34 | setChunkSize(parseInt(e.target.value))} 41 | /> 42 |
43 |
44 |
45 | Overlap:{overlap} 46 |
47 | 51 |
52 |
53 |
54 | setOverlap(parseInt(e.target.value))} 61 | /> 62 |
63 |
64 |
65 | ); 66 | }; -------------------------------------------------------------------------------- /src/app/components/Sidebar/UrlButton.tsx: -------------------------------------------------------------------------------- 1 | // UrlButton.tsx 2 | 3 | import { Button } from "./Button"; 4 | import React, { FC } from "react"; 5 | import { IconContext } from "react-icons"; 6 | import { AiOutlineLink } from "react-icons/ai"; 7 | import Link from "next/link"; 8 | 9 | export interface IUrlEntry { 10 | url: string; 11 | title: string; 12 | seeded: boolean; 13 | loading: boolean; 14 | } 15 | 16 | interface IURLButtonProps { 17 | entry: IUrlEntry; 18 | onClick: () => Promise; 19 | } 20 | 21 | const UrlButton: FC = ({ entry, onClick }) => ( 22 |
23 | 58 |
59 | ); 60 | 61 | export default UrlButton; 62 | -------------------------------------------------------------------------------- /src/app/components/Sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import AppContext from "@/appContext"; 2 | import { Button } from "@material-tailwind/react"; 3 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 4 | import CircularProgress from '@mui/material/CircularProgress'; 5 | import MenuItem from '@mui/material/MenuItem'; 6 | import Select, { SelectChangeEvent } from '@mui/material/Select'; 7 | import React, { useContext, useState } from "react"; 8 | import Header from "../Header"; 9 | import { Card, ICard } from "./Card"; 10 | import { InfoPopover } from "./InfoPopover"; 11 | import { RecursiveSplittingOptions } from "./RecursiveSplittingOptions"; 12 | import { urls } from "./urls"; 13 | import { clearIndex, crawlDocument } from "./utils"; 14 | 15 | const styles: Record = { 16 | contextWrapper: { 17 | display: "flex", 18 | padding: "var(--spacer-huge, 64px) var(--spacer-m, 32px) var(--spacer-m, 32px) var(--spacer-m, 32px)", 19 | alignItems: "flex-start", 20 | gap: "var(--Spacing-0, 0px)", 21 | alignSelf: "stretch", 22 | backgroundColor: "#FBFBFC", 23 | fontSize: 14 24 | }, 25 | textHeaderWrapper: { 26 | display: "flex", 27 | flexDirection: "column", 28 | alignItems: "flex-start", 29 | alignSelf: "stretch" 30 | }, 31 | entryUrl: { 32 | fontSize: 'small', 33 | color: 'grey', 34 | whiteSpace: 'nowrap', 35 | overflow: 'hidden', 36 | textOverflow: 'ellipsis', 37 | maxWidth: "400px" 38 | }, 39 | h4: { 40 | fontWeight: 600, marginBottom: 8, fontSize: 16 41 | }, 42 | h7: { 43 | fontSize: 12, 44 | textTransform: 'uppercase', 45 | letterSpacing: 1 46 | } 47 | } 48 | 49 | 50 | export const Sidebar: React.FC = () => { 51 | const [entries, setEntries] = useState(urls); 52 | const [cards, setCards] = useState([]); 53 | const [splittingMethod, setSplittingMethod] = useState("markdown"); 54 | const [chunkSize, setChunkSize] = useState(256); 55 | const [overlap, setOverlap] = useState(1); 56 | const [url, setUrl] = useState(entries[0].url); 57 | const [clearIndexComplete, setClearIndexCompleteMessageVisible] = useState(false) 58 | const [crawling, setCrawling] = useState(false) 59 | const [crawlingDoneVisible, setCrawlingDoneVisible] = useState(false) 60 | 61 | const { refreshIndex } = useContext(AppContext); 62 | 63 | const handleUrlChange = (event: SelectChangeEvent) => { 64 | const { 65 | target: { value }, 66 | } = event; 67 | setUrl(value) 68 | } 69 | 70 | const handleSplittingMethodChange = (event: SelectChangeEvent) => { 71 | const { 72 | target: { value }, 73 | } = event; 74 | setSplittingMethod(value) 75 | } 76 | 77 | const handleEmbedAndUpsertClick = async () => { 78 | setCrawling(true) 79 | await crawlDocument( 80 | url, 81 | setEntries, 82 | setCards, 83 | splittingMethod, 84 | chunkSize, 85 | overlap 86 | ) 87 | 88 | setCrawling(false) 89 | setCrawlingDoneVisible(true) 90 | setTimeout(() => { 91 | setCrawlingDoneVisible(false) 92 | console.log("it's time") 93 | refreshIndex() 94 | }, 2000) 95 | } 96 | 97 | const handleClearIndexClick = async () => { 98 | await clearIndex(setEntries, setCards) 99 | setClearIndexCompleteMessageVisible(true) 100 | refreshIndex() 101 | setTimeout(() => { 102 | setClearIndexCompleteMessageVisible(false) 103 | }, 2000) 104 | } 105 | 106 | const menuItems = entries.map((entry, key) => ( 107 |
111 |
{entry.title}
112 |
{entry.url}
113 |
114 |
115 | )); 116 | 117 | 118 | return ( 119 |
123 |
124 |
125 |
126 | This RAG chatbot uses Pinecone and Vercel's AI SDK to demonstrate a URL crawl, data chunking and embedding, and semantic questioning. 127 |
128 |
129 |
130 |
131 |

Select demo url to index

132 | 144 |
145 |
146 |

147 |
Chunking method
148 | 152 |

153 | 185 |
186 | {splittingMethod === "recursive" && ( 187 | 193 | )} 194 | 206 |
207 |
208 |
Index records
209 |
Clear
210 |
211 | {( 212 |
219 | Index cleared 220 |
221 | )} 222 | {( 223 |
229 | Chunking and embedding your data... 232 |
233 | )} 234 |
235 |
236 | {cards && cards.length > 0 ? 237 |
238 |
{cards.length} records:
239 |
240 | {url} 241 |
242 |
243 | : 244 |
245 | } 246 |
247 |
248 |
249 | {cards.map((card, index) => ( 250 | 251 | ))} 252 | {cards.length > 0 && (
End of results
)} 253 |
254 | 255 |
256 | ); 257 | }; 258 | -------------------------------------------------------------------------------- /src/app/components/Sidebar/urls.ts: -------------------------------------------------------------------------------- 1 | export const urls = [ 2 | { 3 | url: "https://www.wired.com/story/fast-forward-toyota-robots-learning-housework/", 4 | title: "Toyota's Robots are Learning Housework", 5 | seeded: false, 6 | loading: false, 7 | }, 8 | { 9 | url: "https://www.wired.com/story/synthetic-data-is-a-dangerous-teacher/", 10 | title: "Synthetic Data Is a Dangerous Teacher", 11 | seeded: false, 12 | loading: false, 13 | }, 14 | { 15 | url: "https://www.wired.com/story/staying-one-step-ahead-of-hackers-when-it-comes-to-ai/", 16 | title: "Staying Ahead of Hackers When It Comes to AI", 17 | seeded: false, 18 | loading: false, 19 | }] -------------------------------------------------------------------------------- /src/app/components/Sidebar/utils.ts: -------------------------------------------------------------------------------- 1 | import { ICard } from "./Card"; 2 | import { IUrlEntry } from "./UrlButton"; 3 | 4 | export async function crawlDocument( 5 | url: string, 6 | setEntries: React.Dispatch>, 7 | setCards: React.Dispatch>, 8 | splittingMethod: string, 9 | chunkSize: number, 10 | overlap: number 11 | ): Promise { 12 | setEntries((seeded: IUrlEntry[]) => 13 | seeded.map((seed: IUrlEntry) => 14 | seed.url === url ? { ...seed, loading: true } : seed 15 | ) 16 | ); 17 | const response = await fetch("/api/crawl", { 18 | method: "POST", 19 | headers: { "Content-Type": "application/json" }, 20 | body: JSON.stringify({ 21 | url, 22 | options: { 23 | splittingMethod, 24 | chunkSize, 25 | overlap, 26 | }, 27 | }), 28 | }); 29 | 30 | const { documents } = await response.json(); 31 | 32 | setCards(documents); 33 | 34 | setEntries((prevEntries: IUrlEntry[]) => 35 | prevEntries.map((entry: IUrlEntry) => 36 | entry.url === url ? { ...entry, seeded: true, loading: false } : entry 37 | ) 38 | ); 39 | } 40 | 41 | export async function clearIndex( 42 | setEntries: React.Dispatch>, 43 | setCards: React.Dispatch> 44 | ) { 45 | const response = await fetch("/api/clearIndex", { 46 | method: "POST", 47 | headers: { "Content-Type": "application/json" }, 48 | }); 49 | 50 | if (response.ok) { 51 | setEntries((prevEntries: IUrlEntry[]) => 52 | prevEntries.map((entry: IUrlEntry) => ({ 53 | ...entry, 54 | seeded: false, 55 | loading: false, 56 | })) 57 | ); 58 | setCards([]); 59 | return true 60 | } 61 | } -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pinecone-io/pinecone-rag-demo/de05d15253383b2cc7c1f38d0ac3f8b36faf4a3d/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --foreground-rgb: 0, 0, 0; 7 | --background-start-rgb: 214, 219, 220; 8 | --background-end-rgb: 255, 255, 255; 9 | } 10 | 11 | @media (prefers-color-scheme: dark) { 12 | :root { 13 | --foreground-rgb: 255, 255, 255; 14 | --background-start-rgb: 0, 0, 0; 15 | --background-end-rgb: 0, 0, 0; 16 | } 17 | } 18 | 19 | body { 20 | color: rgb(var(--foreground-rgb)); 21 | background: linear-gradient( 22 | to bottom, 23 | transparent, 24 | rgb(var(--background-end-rgb)) 25 | ) 26 | rgb(var(--background-start-rgb)); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/hooks/useRefreshIndex.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useRefreshIndex = () => { 4 | const [totalRecords, setTotalRecords] = useState(0); 5 | 6 | const refreshIndex = async () => { 7 | const response = await fetch("/api/checkIndex", { 8 | method: "POST", 9 | }); 10 | try { 11 | const stats = await response.json(); 12 | setTotalRecords(stats.totalRecordCount); 13 | } catch (e) { 14 | console.log(e) 15 | } 16 | } 17 | 18 | return { totalRecords, refreshIndex }; 19 | } 20 | 21 | export default useRefreshIndex; -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: "Pinecone - Vercel AI SDK Example", 3 | description: "Pinecone - Vercel AI SDK Example", 4 | }; 5 | 6 | import { Inter } from 'next/font/google'; 7 | const inter = Inter({ subsets: ['latin'] }) 8 | 9 | 10 | import "../global.css"; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Chat from "@/components/Chat"; 4 | import { Sidebar } from "@/components/Sidebar"; 5 | import useRefreshIndex from '@/hooks/useRefreshIndex'; 6 | import type { PineconeRecord } from "@pinecone-database/pinecone"; 7 | import React, { useEffect, useState } from "react"; 8 | import { FaGithub } from 'react-icons/fa'; 9 | import AppContext from "./appContext"; 10 | 11 | const Page: React.FC = () => { 12 | const [context, setContext] = useState<{ context: PineconeRecord[] }[] | null>(null); 13 | const { totalRecords, refreshIndex } = useRefreshIndex(); 14 | 15 | useEffect(() => { 16 | if (totalRecords === 0) { 17 | refreshIndex() 18 | } 19 | }, [refreshIndex, totalRecords]) 20 | 21 | return ( 22 | 23 |
24 |
25 |
28 | 29 |
30 | 31 |
32 |
33 | 34 | 35 | 36 |
37 |
38 |
39 | ); 40 | }; 41 | 42 | export default Page; 43 | 44 | -------------------------------------------------------------------------------- /src/app/services/chunkedUpsert.ts: -------------------------------------------------------------------------------- 1 | import type { Index, PineconeRecord } from '@pinecone-database/pinecone'; 2 | 3 | const sliceIntoChunks = (arr: T[], chunkSize: number) => { 4 | return Array.from({ length: Math.ceil(arr.length / chunkSize) }, (_, i) => 5 | arr.slice(i * chunkSize, (i + 1) * chunkSize) 6 | ); 7 | }; 8 | 9 | export const chunkedUpsert = async ( 10 | index: Index, 11 | vectors: Array, 12 | namespace: string, 13 | chunkSize = 10 14 | ) => { 15 | // Split the vectors into chunks 16 | const chunks = sliceIntoChunks(vectors, chunkSize); 17 | 18 | try { 19 | // Upsert each chunk of vectors into the index 20 | await Promise.allSettled( 21 | chunks.map(async (chunk) => { 22 | try { 23 | await index.namespace(namespace).upsert(vectors); 24 | } catch (e) { 25 | console.log('Error upserting chunk', e); 26 | } 27 | }) 28 | ); 29 | 30 | return true; 31 | } catch (e) { 32 | throw new Error(`Error upserting vectors into index: ${e}`); 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /src/app/services/context.ts: -------------------------------------------------------------------------------- 1 | import type { PineconeRecord } from "@pinecone-database/pinecone"; 2 | import { getEmbeddings } from './embeddings'; 3 | import { getMatchesFromEmbeddings } from "./pinecone"; 4 | 5 | export type Metadata = { 6 | url: string, 7 | text: string, 8 | chunk: string, 9 | } 10 | 11 | // The function `getContext` is used to retrieve the context of a given message 12 | export const getContext = async (message: string, namespace: string, maxTokens = 3000, minScore = 0.7, getOnlyText = true): Promise => { 13 | 14 | // Get the embeddings of the input message 15 | const embedding = await getEmbeddings(message); 16 | 17 | // Retrieve the matches for the embeddings from the specified namespace 18 | const matches = await getMatchesFromEmbeddings(embedding, 10, namespace); 19 | 20 | // Filter out the matches that have a score lower than the minimum score 21 | const qualifyingDocs = matches.filter(m => m.score && m.score > minScore); 22 | 23 | return qualifyingDocs 24 | } 25 | -------------------------------------------------------------------------------- /src/app/services/embeddings.ts: -------------------------------------------------------------------------------- 1 | 2 | import { OpenAIApi, Configuration } from "openai-edge"; 3 | 4 | const config = new Configuration({ 5 | apiKey: process.env.OPENAI_API_KEY 6 | }) 7 | const openai = new OpenAIApi(config) 8 | 9 | export async function getEmbeddings(input: string) { 10 | try { 11 | const response = await openai.createEmbedding({ 12 | model: "text-embedding-ada-002", 13 | input: input.replace(/\n/g, ' ') 14 | }) 15 | 16 | const result = await response.json(); 17 | return result.data[0].embedding as number[] 18 | 19 | } catch (e) { 20 | console.log("Error calling OpenAI embedding API: ", e); 21 | throw new Error(`Error calling OpenAI embedding API: ${e}`); 22 | } 23 | } -------------------------------------------------------------------------------- /src/app/services/pinecone.ts: -------------------------------------------------------------------------------- 1 | import { Pinecone, type ScoredPineconeRecord } from "@pinecone-database/pinecone"; 2 | 3 | export type Metadata = { 4 | url: string, 5 | text: string, 6 | chunk: string, 7 | hash: string 8 | } 9 | 10 | // The function `getMatchesFromEmbeddings` is used to retrieve matches for the given embeddings 11 | const getMatchesFromEmbeddings = async (embeddings: number[], topK: number, namespace: string): Promise[]> => { 12 | // Obtain a client for Pinecone 13 | const pinecone = new Pinecone(); 14 | 15 | const indexName: string = process.env.PINECONE_INDEX || ''; 16 | if (indexName === '') { 17 | throw new Error('PINECONE_INDEX environment variable not set') 18 | } 19 | // Get the Pinecone index 20 | const index = pinecone!.Index(indexName); 21 | 22 | // Get the namespace 23 | const pineconeNamespace = index.namespace(namespace ?? '') 24 | // console.log("embeddings", JSON.stringify(embeddings)) 25 | 26 | try { 27 | // Query the index with the defined request 28 | const queryResult = await pineconeNamespace.query({ 29 | vector: embeddings, 30 | topK, 31 | includeMetadata: true, 32 | }) 33 | return queryResult.matches || [] 34 | } catch (e) { 35 | // Log the error and throw it 36 | console.log("Error querying embeddings: ", e) 37 | throw new Error(`Error querying embeddings: ${e}`) 38 | } 39 | } 40 | 41 | export { getMatchesFromEmbeddings }; 42 | 43 | -------------------------------------------------------------------------------- /src/app/utils/truncateString.ts: -------------------------------------------------------------------------------- 1 | export const truncateStringByBytes = (str: string, bytes: number) => { 2 | const enc = new TextEncoder(); 3 | return new TextDecoder("utf-8").decode(enc.encode(str).slice(0, bytes)); 4 | }; -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | @keyframes slideInFromBottom { 6 | 0% { 7 | transform: translateY(100%); 8 | opacity: 0; 9 | } 10 | 100% { 11 | transform: translateY(0); 12 | opacity: 1; 13 | } 14 | } 15 | 16 | .slide-in-bottom { 17 | animation: slideInFromBottom 0.3s ease-out; 18 | } 19 | 20 | .input-glow { 21 | box-shadow: 0 0 1px #738FAB80, 0 0 1px #738FAB80; 22 | } 23 | 24 | .input-glow:hover { 25 | box-shadow: 0 0 1px #87f4f6, 0 0 2 #8b9ebe; 26 | } 27 | 28 | /* .message-glow { 29 | box-shadow: 0 0 3px #738FAB80, 0 0 5px #738FAB80; 30 | } 31 | 32 | .message-glow:hover { 33 | box-shadow: 0 0 3px #5eabac, 0 0 4px #8b9ebe; 34 | } */ 35 | 36 | @keyframes glimmer { 37 | 0% { 38 | background-position: -200px; 39 | } 40 | 100% { 41 | background-position: calc(200px + 100%); 42 | } 43 | } 44 | 45 | @keyframes shimmer { 46 | 0% { 47 | transform: translateX(-100%); 48 | } 49 | 100% { 50 | transform: translateX(100%); 51 | } 52 | } 53 | 54 | .shimmer { 55 | animation: glimmer 2s infinite linear; 56 | background: rgb(82, 82, 91); 57 | background: linear-gradient( 58 | to right, 59 | darkgray 10%, 60 | rgb(130, 129, 129) 50%, 61 | rgba(124, 123, 123, 0.816) 90% 62 | ); 63 | background-size: 200px 100%; 64 | background-repeat: no-repeat; 65 | /* color: transparent; */ 66 | } 67 | 68 | @keyframes pulse { 69 | 0%, 70 | 100% { 71 | color: white; 72 | } 73 | 50% { 74 | color: #f59e0b; /* Tailwind's yellow-500 */ 75 | } 76 | } 77 | 78 | .animate-pulse-once { 79 | animation: pulse 5s cubic-bezier(0, 0, 0.2, 1) 1; 80 | } 81 | 82 | #chunkSize, #overlap { 83 | accent-color: #1B17F5; 84 | --inverse-accent-color: #E4E8EA; 85 | 86 | } 87 | 88 | 89 | 90 | .markdown-content { 91 | white-space: nowrap; /* Keep the text on a single line */ 92 | overflow: hidden; /* Hide overflow */ 93 | text-overflow: ellipsis; /* Add ellipsis at the end of the truncated text */ 94 | display: block; 95 | } -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | export function middleware(request: NextRequest) { 5 | const requiredEnvVars = ['OPENAI_API_KEY', 'PINECONE_API_KEY', 'PINECONE_REGION', 'PINECONE_INDEX']; 6 | requiredEnvVars.forEach(envVar => { 7 | if (!process.env[envVar] && !process.env.CI) { 8 | throw new Error(`${envVar} environment variable is not defined`); 9 | } 10 | }); 11 | return NextResponse.next() 12 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const withMT = require("@material-tailwind/react/utils/withMT"); 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = withMT({ 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | screens: { 12 | sm: "640px", 13 | md: "768px", 14 | lg: "1024px", 15 | xl: "1280px", 16 | }, 17 | colors: { 18 | "button-primary": '#1B17F5', 19 | "bg-grey": '#FBFBFC', 20 | "text-primary": "#121142", 21 | "shaded-border": "#738FAB80" 22 | }, 23 | extend: { 24 | backgroundImage: { 25 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 26 | "gradient-conic": 27 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 28 | }, 29 | gridTemplateRows: { 30 | "auto-1fr": "auto 1fr", 31 | }, 32 | }, 33 | }, 34 | plugins: [], 35 | future: { 36 | removeDeprecatedGapUtilities: true, 37 | }, 38 | }); 39 | -------------------------------------------------------------------------------- /tests/example.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | import { urls } from '../src/app/components/Sidebar/urls'; 3 | 4 | test('has correct title', async ({ page }) => { 5 | await page.goto('http://localhost:3000'); 6 | 7 | await expect(page).toHaveTitle('Pinecone - Vercel AI SDK Example') 8 | }) 9 | 10 | test('renders clear index button', async ({ page }) => { 11 | await page.goto('http://localhost:3000') 12 | 13 | const clearIndexButton = await page.$('[data-testid="clear-button"]'); 14 | const clearIndexButtonCount = clearIndexButton ? 1 : 0; 15 | await expect(clearIndexButtonCount).toBe(1) 16 | }) 17 | 18 | test('Check Select menu', async ({ page }) => { 19 | // Go to your page 20 | await page.goto('http://localhost:3000'); 21 | 22 | // Check if Select is visible 23 | const select = await page.locator('data-testid=url-selector'); 24 | await expect(select).toBeVisible(); 25 | 26 | // Click on the Select box and wait for it 27 | await select.click(); 28 | await page.waitForTimeout(1000); 29 | 30 | // Check if MenuItems are rendered correctly 31 | for (let i = 0; i < urls.length; i++) { 32 | const menuItem = await page.locator(`div[data-testid="${urls[i].url}"]`); 33 | const title = await menuItem.locator('div').first().innerText(); 34 | expect(title).toBe(urls[i].title); // The title should be the title of the entry 35 | const url = await menuItem.locator('div').last().innerText(); 36 | expect(url).toBe(urls[i].url); // The url should be the url of the entry 37 | } 38 | }); 39 | 40 | 41 | // TODO - add tests for other key buttons on the homepage 42 | 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/app/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | --------------------------------------------------------------------------------