├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── img ├── demo.gif ├── pinecone-dashboard.png ├── pinecone-vectors-inserted.png └── text-highlight.png ├── next.config.js ├── package.json ├── src ├── ai-util.ts ├── components │ ├── about-modal.tsx │ ├── chat-view.tsx │ ├── document-upload-modal.tsx │ ├── document-view.tsx │ └── header.tsx ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── api │ │ ├── chat.ts │ │ └── upload-document.ts │ └── index.tsx └── styletron.ts ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .next 3 | tmp 4 | .env.local 5 | .env 6 | .DS_Store 7 | next-env.d.ts -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "bracketSpacing": false, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Custom Document Semantic Search with OpenAI, Pinecone, LangChain, NextJS (Sample app) 2 | 3 | This repository is a sample application and guided walkthrough for a [semantic search](https://txt.cohere.com/what-is-semantic-search/) question-and-answer style interaction with custom user-uploaded documents. 4 | Users can upload a custom plain text document (`.txt` file) and ask the AI questions about the content. 5 | 6 | This project is tailored for web developers who are looking to learn more about integrating LLMs and vector databases into their projects. 7 | 8 | [**This guide is also available as a Medium article**](https://medium.com/@dbabbs/guide-create-a-full-stack-semantic-search-web-app-with-custom-documents-edeae2b35b3c) 9 | 10 | ![demo](./img/demo.gif) 11 | 12 | **Why create a custom semantic search application?** 13 | 14 | While [ChatGPT](https://chat.openai.com/) can handle questions about documents, it does have certain limitations: 15 | 16 | - ChatGPT has a token limit and cannot answer questions about a document that exceeds the maximum length of a ChatGPT query. 17 | - ChatGPT does not have knowledge about non-public documents (eg: creating a search application for your organization's internal knowledge base). 18 | 19 | By [Dylan Babbs](https://twitter.com/dbabbs), built with guidance from Nader Dabit's [AI Semantic Search YouTube video](https://www.youtube.com/watch?v=6_mfYPPcZ60). 20 | 21 | ## How it works 22 | 23 | 1. User clicks on the **Upload Document** button on the top right on the application. 24 | 2. User uploads a custom plain text file to the backend. 25 | 3. The backend reads the file, splits the text into chunks, creates OpenAI vector embeddings, and inserts the vector embeddings into the Pinecone database. 26 | 4. In the chat window, the user can now ask any question about the document to the AI. 27 | 5. When a question is asked, OpenAI creates a query embedding based on a question, Pinecone looks for matching vectors in the database, and the OpenAI LLM answers the questions. 28 | 6. The answer to the question is displayed in the chat window and the matching text in the document is highlighted in the left-hand panel. 29 | 30 | ## Technology overview 31 | 32 | - [OpenAI](https://platform.openai.com/): language model for interacting with the document 33 | - [Langchain](https://js.langchain.com/docs/get_started/introduction): tools and utility functions for working with LLMs 34 | - [Pinecone](https://www.pinecone.io/): vector database to store document vectors 35 | - [NextJS](https://nextjs.org/): front end framework for creating the application 36 | - [Baseweb](https://baseweb.design/): react UI components 37 | - [Typescript](https://typescriptlang.org/): strongly typed JavaScript language 38 | 39 | ## Quick start & installation 40 | 41 | 1. Clone the repo 42 | 2. Create an `.env` file with the following environment variables 43 | 44 | ``` 45 | OPENAI_API_KEY=XXXXXXXXXXXXXX 46 | PINECONE_API_KEY=XXXXXXXXXXXXXX 47 | PINECONE_ENVIRONMENT=XXXXXXXXXXXXXX 48 | PINECONE_INDEX_NAME=XXXXXXXXXXXXXX 49 | ``` 50 | 51 | 3. Install the dependencies and start a local server: 52 | 53 | ``` 54 | yarn 55 | yarn dev 56 | ``` 57 | 58 | ## Guide 59 | 60 | This is a high-level guide and walkthrough to creating the full-stack application. The full application code is available for reference in this repository. 61 | 62 | ### Step 1: Create a Pinecone vector database 63 | 64 | Head to [Pinecone](https://www.pinecone.io/), sign up for an account, and create an index. Pinecone is a fully managed and hosted vector database that runs in the cloud. With Pinecone, there's no need to setup or configure a local database. 65 | 66 | ![pinecone-dashboard](./img/pinecone-dashboard.png) 67 | 68 | The name that you give the database will be the same value you use as an environment variable in the `.env` file (`PINECONE_INDEX_NAME`). 69 | 70 | For the Dimensions field, enter `1536`. [Why 1536 dimensions?](https://github.com/langchain-ai/langchain/discussions/9124) 71 | 72 | > In the LangChain framework, when creating a new Pinecone index, the default dimension is set to 1536 to match the OpenAI embedding model text-embedding-ada-002 which uses 1536 dimensions. 73 | 74 | For the metric field, select `cosine`. 75 | 76 | Then, click **Create Index**. The new index will be created in a few minutes. You'll need to have the vector database created before we move onto the next steps. 77 | 78 | Don't forget to also update the environment variables with the Pinecone details (`PINECONE_API_KEY`, `PINECONE_ENVIRONMENT`, `PINECONE_INDEX_NAME`). 79 | 80 | ### Step 2: Start building out the web application & UI 81 | 82 | For the application's UI, we'll be using [NextJS](https://nextjs.org/) (a React framework) and Uber's [Baseweb](https://baseweb.design/) for the UI components. 83 | 84 | When cloning the repo, you'll see we are using the following file structure. (_The repo has additional files, but this diagram only shows those that are relevant._) 85 | 86 | ``` 87 | src/ 88 | ├─ components/ 89 | │ ├─ chat-view.tsx 90 | │ ├─ document-upload-modal.tsx 91 | | ├─ header.tsx 92 | ├─ pages/ 93 | │ ├─ api/ 94 | │ | ├─ chat.ts 95 | │ | ├─ upload-document.ts 96 | │ ├─ index.ts 97 | ├─ ai-utils.ts 98 | ``` 99 | 100 | The files within the [`src/components/`](./src/components/) directory are UI components: 101 | 102 | 1. the chat component which will display chats between the AI and the user. 103 | 2. the document upload modal, which provides the interface for the user to upload a custom txt file. 104 | 105 | The file [`index.tsx`](./src/pages/index.tsx) within the [`src/pages/`](./src/pages/api) directory is the main front-end file, which will be rendering the application. Here, we'll be placing most of the application's front-end logic and data fetching. 106 | 107 | The files within the [`src/pages/api/`](./src/pages/api/) directory are the [NextJS API routes](https://nextjs.org/docs/pages/building-your-application/routing/api-routes). These files are the functions which will run on the backend, such as the query to the LLM and the logic for uploading files and upserting them to the Pinecone database. 108 | 109 | ### Step 3: Enable users to upload a custom document 110 | 111 | **Document upload modal** 112 | 113 | In order to search a custom document, we'll need to make it easy for the user to upload a document to the application. 114 | 115 | Inside on the [`src/pages/index.tsx`](./src/pages/index.tsx) file, we've added the following `DocumentUploadModal` component: 116 | 117 | ```tsx 118 | // src/components/document-view.tsx 119 | 124 | ``` 125 | 126 | This code for this component lives inside the [`src/components/document-upload-modal.tsx`](./src/components/document-upload-modal.tsx) file. This front-end component will: 127 | 128 | - provide a drag and drop interface for selecting files from the user's computer 129 | - read the contents of the file 130 | - provide an input field for naming the file 131 | - create a network request to send the file to the backend 132 | 133 | **Sending the file on the front-end and handling it file on the backend** 134 | 135 | Once the user has selected the file and clicks the **Upload** button, we'll send it to the backend with a `fetch` request: 136 | 137 | ```tsx 138 | // src/components/document-upload-modal.tsx 139 | // `handleSubmit` is called when the user clicks the upload button. 140 | const handleSubmit = useCallback(async () => { 141 | setIsLoading(true); 142 | //Read contents of file 143 | const text = await readText(document); 144 | //Send file to backend 145 | const response = await fetch('/api/upload-document', { 146 | method: 'POST', 147 | body: JSON.stringify({ 148 | text, 149 | name: documentName, 150 | }), 151 | }); 152 | const json = await response.json(); 153 | }, [document, documentName, setActiveDocument]); 154 | ``` 155 | 156 | On the backend, we'll receive this request in the file [`src/pages/api/upload-document.ts`](./src/pages/api/upload-document.ts). This API route will: 157 | 158 | - create a LangChain specific [`Document`](https://js.langchain.com/docs/api/document/) object with the document's content (`pageContent`) and the name of the document as metadata 159 | - Insert the document into the Pinecone database (more on this shortly) 160 | - Send back a response to the front-end that the document has been successfully loaded. 161 | 162 | ```ts 163 | // src/pages/api/upload-document.ts 164 | import {insertDocument} from '../../ai-util'; 165 | async function handler(req: NextApiRequest, res: NextApiResponse) { 166 | const {name, text} = JSON.parse(req.body); 167 | 168 | const doc = new Document({ 169 | pageContent: text, 170 | metadata: {documentName: name}, 171 | }); 172 | 173 | const client = new Pinecone(); 174 | const index = client.Index(process.env.PINECONE_INDEX_NAME || ''); 175 | 176 | await insertDocument(index, doc); 177 | res.json({success: true}); 178 | } 179 | export default handler; 180 | ``` 181 | 182 | ### Step 4: Splitting text into chunks, creating embeddings, and upserting into Pinecone database 183 | 184 | Let's dive into the `insertDocument` function that's inside the [`src/ai-utils.ts`](./src/ai-util.ts) file. 185 | 186 | The first thing we'll do is start splitting the text content into chunks. [Chunking](https://www.pinecone.io/learn/chunking-strategies/) is the process of splitting large pieces of text into smaller groups. Through chunking, we can ensure we are splitting the larger content into semantically relevant smaller groups. This will help the LLM better understand the input data. 187 | 188 | We can use the popular LangChain [`RecursiveCharacterTextSplitter`](https://js.langchain.com/docs/api/text_splitter/classes/RecursiveCharacterTextSplitter) method to create the chunks. 189 | 190 | ```ts 191 | // src/ai-utils.ts 192 | const textSplitter = new RecursiveCharacterTextSplitter({ 193 | chunkSize: 1000, 194 | }); 195 | 196 | const chunks = await textSplitter.createDocuments([text]); 197 | ``` 198 | 199 | There are a few strategies for [choosing chunk size](https://www.pinecone.io/learn/chunking-strategies/). We are using a larger chunk size number (1000) to retain additional context in each chunk. On the other hand, choosing a smaller chunk size number will capture granular semantic information. 200 | 201 | Next, we'll create the [OpenAI vector embeddings](https://platform.openai.com/docs/guides/embeddings/embeddings) for the document. Embeddings measure the relatedness of text strings. Embeddings can be used for search (our case). The distance between two vectors measures their relatedness. 202 | 203 | Langchain includes a helper function for us to work with the OpenAI Embeddings API. We'll pass the chunks to the function: 204 | 205 | ```ts 206 | // src/ai-utils.ts 207 | const embeddingsArrays = await new OpenAIEmbeddings().embedDocuments( 208 | chunks.map((chunk) => chunk.pageContent.replace(/\n/g, ' ')), 209 | ); 210 | ``` 211 | 212 | One we have access to the embeddings, we can begin to upsert the vectors into the Pinecone database: 213 | 214 | ```ts 215 | // src/ai-utils.ts 216 | const batch = []; 217 | for (let i = 0; i < chunks.length; i++) { 218 | const chunk = chunks[i]; 219 | const vector = { 220 | id: `${documentName}_${i}`, 221 | values: embeddingsArrays[i], 222 | metadata: { 223 | ...chunk.metadata, 224 | loc: JSON.stringify(chunk.metadata.loc), 225 | pageContent: chunk.pageContent, 226 | documentName: documentName, 227 | }, 228 | }; 229 | batch.push(vector); 230 | } 231 | await index.upsert(batch); 232 | ``` 233 | 234 | The vectors have now been inserted into Pinecone! You can verify they have been uploaded successfully by checking out the index details in the Pinecone web application. You'll be able to see some items under the "Browser" tab: 235 | 236 | ![Pinecone vectors successfully inserted](./img/pinecone-vectors-inserted.png) 237 | 238 | ### Step 5: Tasking the LLM to answer the query 239 | 240 | Now, let's configure the logic of handling the question and answering within the custom document. To facilitate this, we're going to create a new API endpoint to handle this request: [`src/pages/api/chat.ts`](./src/pages/api/chat.ts) 241 | 242 | ```ts 243 | // src/pages/api/chat.ts 244 | async function handler(req: NextApiRequest, res: NextApiResponse) { 245 | const {documentName, question} = JSON.parse(req.body); 246 | const client = new Pinecone(); 247 | const index = client.Index(process.env.PINECONE_INDEX_NAME || ''); 248 | 249 | //Query Pinecone client 250 | const queryResponse = await queryPinecone(index, question, documentName); 251 | 252 | if (queryResponse.matches.length > 0) { 253 | //Query LLM 254 | const result = await queryLLM(queryResponse, question); 255 | res.json(result); 256 | } else { 257 | res.json({ 258 | result: "Sorry, I don't know the answer to that question.", 259 | sources: [], 260 | }); 261 | } 262 | } 263 | export default handler; 264 | ``` 265 | 266 | When we receive the request, we'll want to: 267 | 268 | - query the Pinecone database for top matches of the query 269 | - query the LLM to answer the question based off the custom document we've provided it 270 | 271 | **Query the Pinecone database** 272 | 273 | We'll look at the function `queryPinecone` in file [`src/ai-utils.ts`](./src/ai-util.ts). Here we'll query the Pinecone database for the relevant text content. 274 | 275 | ```ts 276 | // src/pages/api/chat.ts 277 | export async function queryPinecone( 278 | index, 279 | question: string, 280 | documentName: string, 281 | ) { 282 | const queryEmbedding = await new OpenAIEmbeddings().embedQuery(question); 283 | 284 | let queryResponse = await index.query({ 285 | topK: 10, 286 | vector: queryEmbedding, 287 | includeMetadata: true, 288 | includeValues: true, 289 | filter: {documentName: {$eq: documentName}}, 290 | }); 291 | 292 | return queryResponse; 293 | } 294 | ``` 295 | 296 | We also pass in an extra `filter` object to ensure we are only querying documents in the database that match the same `documentName` as the current document we are viewing. This is because this project's Pinecone database is storing multiple unrelated documents. 297 | 298 | **Query the LLM** 299 | 300 | If the Pinecone query response returns an array of values, that means we have some matches in the document. At this point, we'll want to query the LLM. We'll do so in the function `queryLLM` in the [`src/ai-utils.ts`](./src/ai-util.ts) file. 301 | 302 | First, we will initialize the OpenAI LLM with a temperature of `0.3`. [Temperature](https://platform.openai.com/docs/api-reference) in OpenAI is the parameter that affects the randomness of the output. A higher temperature number is useful for creative output, such as writing a novel. Since we are creating an application to answer direct questions about provided information, we want reliable output, so we'll set the temperature to a lower number (`0.3`). 303 | 304 | ```ts 305 | // src/pages/api/chat.ts 306 | const llm = new OpenAI({ 307 | temperature: 0.3, 308 | }); 309 | const chain = loadQAStuffChain(llm); 310 | ``` 311 | 312 | Next, we'll combine the content of the matching information from the Pinecone query into a single string that we can pass into the LLM. At this point, we've found the relevant information in the document about the question and passed it into the LLM. It's similar to identifying a targeted short piece of text and copying and pasting it directly into ChatGPT with the question. 313 | 314 | ```ts 315 | // src/pages/api/chat.ts 316 | const concatenatedPageContent = queryResponse.matches 317 | .map((match: any) => match.metadata.pageContent) 318 | .join(''); 319 | ``` 320 | 321 | Finally, we'll execute the OpenAI LLM query: 322 | 323 | ```ts 324 | // src/pages/api/chat.ts 325 | const result = await chain.call({ 326 | input_documents: [new Document({pageContent: concatenatedPageContent})], 327 | question: question, 328 | }); 329 | 330 | return { 331 | result: result.text, 332 | sources: queryResponse.matches.map((x) => ({ 333 | pageContent: x.metadata.pageContent, 334 | score: x.score, 335 | })), 336 | }; 337 | ``` 338 | 339 | This function will return: 340 | 341 | - `result`: the string of the answer of question that we'll be displaying as the response in the chatbot. 342 | - `sources`: an array of sources, which contain information about the matching text the response is based upon. This information is used to identify the text to highlight in the UI that displays the original document content. 343 | 344 | With this logic so far, we've calculated the answer to the query and we can send the response back to the client to display in the UI. 345 | 346 | ### Step 6: Complete the front-end 347 | 348 | All of the backend logic is now complete and we can begin building the chat interaction UI on the front-end. 349 | 350 | **Data fetching to the backend** 351 | 352 | Inside of [`src/pages/index.tsx`](./src/pages/index.tsx), let's examine the function `sendQuery`, where we implement the data fetching to the backend service we created in Step #5. 353 | 354 | Everytime the user clicks the **Send** button, we'll execute the function, which will pass the query (the question) and the document name to the backend. 355 | 356 | ```tsx 357 | // src/pages/index.tsx 358 | const sendQuery = useCallback(async () => { 359 | //Update messages state to show user's question in the chat bubble 360 | setMessages((prev) => [...prev, {role: 'user', content: input}]); 361 | 362 | //Data request 363 | const response = await fetch('/api/chat', { 364 | method: 'POST', 365 | body: JSON.stringify({ 366 | question: input, 367 | documentName: activeDocument.name, 368 | }), 369 | }); 370 | const json = await response.json(); 371 | 372 | //Update messages state to include AI's response 373 | setMessages((prev) => [...prev, {role: 'assistant', content: json.result}]); 374 | 375 | if (json.sources.length > 0) { 376 | //Update highlight text state to show sources in original document 377 | setHighlightedText(json.sources[0].pageContent); 378 | } 379 | }, [input, activeDocument]); 380 | ``` 381 | 382 | When we receive a response from the backend with the answer to the question, we will: 383 | 384 | - update the messages object state to show the response to the question in the chat UI 385 | - update the active highlighted text state, which tells the UI which text in the document viewer to highlight 386 | 387 | **Create the chat interface** 388 | 389 | We have the logic that requests a new response to the query every time the user types in a question, now let's feed the data into an interactive chat interface. 390 | 391 | Inside of [`src/pages/index.tsx`](./src/pages/index.tsx), we'll render a `ChatView` component: 392 | 393 | ```jsx 394 | // src/pages/index.tsx 395 | 402 | ``` 403 | 404 | The code for this component will live in a separate file: [`src/components/chat-view.tsx`](./src/components/chat-view.tsx). We won't dive into the details of this specific component, but it provides the components and layouts for: 405 | 406 | - displaying chat messages from the AI assistant (server) and user 407 | - handling input field state 408 | - automatically scrolling the window to show the most recent chat messages at the bottom of the page. 409 | 410 | **Document viewer and highlighting relevant text** 411 | 412 | In order to make the application's UI as useful as possible, we'll also want to also show a copy of the document on the left side of the window. This way, the user can see the document and the chat open side-by-side. We'll enable this with the `DocumentView` component inside of [`src/components/document-view.tsx`](./src/components/document-view.tsx). We'll render the component inside of of the main index page: 413 | 414 | ```jsx 415 | // src/pages/index.tsx 416 | 420 | ``` 421 | 422 | Using the `highlightedText` state variable, which contains the relevant source content that the LLM's response is based upon, we can also highlight the relevant info in the original document. This is helpful because the user can see the source of the LLM's chat response directly in the application's UI. 423 | 424 | The highlighted text is implemented by searching for the matching string within the original text content, and then wrapping the specific text with an inline style (``). 425 | 426 | ```tsx 427 | // src/components/document-view.tsx 428 | const Highlight = styled('span', ({$theme}) => ({ 429 | backgroundColor: $theme.colors.backgroundWarning, 430 | padding: '1px', 431 | })); 432 | 433 | {highlightedText}; 434 | ``` 435 | 436 | The component also provides a listener which will scroll the relevant position within the document view in which the highlighted text is located. 437 | 438 | ![highlighted text](./img/text-highlight.png) 439 | 440 | ### Finish 441 | 442 | You now have a functioning full stack chat application in which users can: 443 | 444 | - upload a custom document 445 | - ask questions about the document's content using an OpenAI LLM 446 | - view the highlighted source content of the LLM's response in a document view panel 447 | -------------------------------------------------------------------------------- /img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbabbs/semantic-search-openai-nextjs-sample/75ec904436d34b27f9dd8d6790280f95966bdb4b/img/demo.gif -------------------------------------------------------------------------------- /img/pinecone-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbabbs/semantic-search-openai-nextjs-sample/75ec904436d34b27f9dd8d6790280f95966bdb4b/img/pinecone-dashboard.png -------------------------------------------------------------------------------- /img/pinecone-vectors-inserted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbabbs/semantic-search-openai-nextjs-sample/75ec904436d34b27f9dd8d6790280f95966bdb4b/img/pinecone-vectors-inserted.png -------------------------------------------------------------------------------- /img/text-highlight.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dbabbs/semantic-search-openai-nextjs-sample/75ec904436d34b27f9dd8d6790280f95966bdb4b/img/text-highlight.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | typescript: { 3 | // !! WARN !! 4 | // Dangerously allow production builds to successfully complete even if 5 | // your project has type errors. 6 | // !! WARN !! 7 | ignoreBuildErrors: true, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "full-stack-semantic-search-demo", 3 | "version": "1.0.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "dev": "next", 7 | "build": "next build", 8 | "lint": "next lint", 9 | "start": "next start", 10 | "typecheck": "tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "@pinecone-database/pinecone": "1.0.1", 14 | "@types/node": "^18.16.1", 15 | "@types/react": "^18.2.0", 16 | "ai": "^2.2.12", 17 | "baseui": "^12.2.0", 18 | "eslint-config-next": "^13.3.1", 19 | "eslint-config-prettier": "^8.8.0", 20 | "langchain": "^0.0.147", 21 | "next": "^13.3.1", 22 | "openai": "^4.7.0", 23 | "openai-edge": "^1.2.2", 24 | "prettier": "^2.8.8", 25 | "react": "^18.2.0", 26 | "react-dom": "^18.2.0", 27 | "styletron-engine-monolithic": "^1.0.0", 28 | "styletron-react": "^6.1.0", 29 | "typescript": "^5.0.4" 30 | }, 31 | "devDependencies": { 32 | "eslint": "^8.49.0" 33 | } 34 | } -------------------------------------------------------------------------------- /src/ai-util.ts: -------------------------------------------------------------------------------- 1 | import {OpenAIEmbeddings} from 'langchain/embeddings/openai'; 2 | import {RecursiveCharacterTextSplitter} from 'langchain/text_splitter'; 3 | import {OpenAI} from 'langchain/llms/openai'; 4 | import {loadQAStuffChain} from 'langchain/chains'; 5 | import {Document} from 'langchain/document'; 6 | import type {Pinecone, QueryResponse} from '@pinecone-database/pinecone'; 7 | 8 | export const insertDocument = async (index, doc: Document) => { 9 | const text = doc.pageContent; 10 | const documentName = doc.metadata.documentName; 11 | 12 | const textSplitter = new RecursiveCharacterTextSplitter({ 13 | chunkSize: 1000, 14 | }); 15 | 16 | const chunks = await textSplitter.createDocuments([text]); 17 | 18 | const embeddingsArrays = await new OpenAIEmbeddings().embedDocuments( 19 | chunks.map((chunk) => chunk.pageContent.replace(/\n/g, ' ')), 20 | ); 21 | 22 | const batchSize = 100; 23 | let batch: any = []; 24 | for (let i = 0; i < chunks.length; i++) { 25 | const chunk = chunks[i]; 26 | const vector = { 27 | id: `${documentName}_${i}`, 28 | values: embeddingsArrays[i], 29 | metadata: { 30 | ...chunk.metadata, 31 | loc: JSON.stringify(chunk.metadata.loc), 32 | pageContent: chunk.pageContent, 33 | documentName, 34 | }, 35 | }; 36 | batch.push(vector); 37 | 38 | console.log(`vector ${i} of ${chunks.length} chunks`); 39 | 40 | if (batch.length === batchSize || i === chunks.length - 1) { 41 | await index.upsert(batch); 42 | 43 | batch = []; 44 | } 45 | } 46 | }; 47 | 48 | export async function queryPinecone( 49 | index, 50 | question: string, 51 | documentName: string, 52 | ) { 53 | const queryEmbedding = await new OpenAIEmbeddings().embedQuery(question); 54 | 55 | let queryResponse = await index.query({ 56 | topK: 10, 57 | vector: queryEmbedding, 58 | includeMetadata: true, 59 | includeValues: true, 60 | filter: {documentName: {$eq: documentName}}, 61 | }); 62 | 63 | return queryResponse; 64 | } 65 | 66 | type Source = { 67 | pageContent: string; 68 | score: number; 69 | }; 70 | export type LLMResponse = { 71 | result: string; 72 | sources: Source[]; 73 | }; 74 | 75 | export async function queryLLM( 76 | queryResponse: QueryResponse, 77 | question: string, 78 | ): Promise { 79 | const llm = new OpenAI({ 80 | temperature: 0.3, 81 | }); 82 | const chain = loadQAStuffChain(llm); 83 | 84 | const concatenatedPageContent = queryResponse.matches 85 | .map((match: any) => match.metadata.pageContent) 86 | .join(''); 87 | 88 | const result = await chain.call({ 89 | input_documents: [new Document({pageContent: concatenatedPageContent})], 90 | question: question, 91 | }); 92 | 93 | return { 94 | result: result.text, 95 | //@ts-ignore 96 | sources: queryResponse.matches.map((x) => ({ 97 | pageContent: x.metadata.pageContent, 98 | score: x.score, 99 | })), 100 | }; 101 | } 102 | -------------------------------------------------------------------------------- /src/components/about-modal.tsx: -------------------------------------------------------------------------------- 1 | import {Modal, ModalHeader, ModalBody} from 'baseui/modal'; 2 | import {useStyletron} from 'baseui'; 3 | import {ParagraphMedium} from 'baseui/typography'; 4 | import {StyledLink} from 'baseui/link'; 5 | 6 | export const AboutModal = ({ 7 | isOpen, 8 | setIsOpen, 9 | }: { 10 | isOpen: boolean; 11 | setIsOpen: (isOpen: boolean) => void; 12 | }) => { 13 | const [, theme] = useStyletron(); 14 | const handleClose = () => { 15 | setIsOpen(false); 16 | }; 17 | return ( 18 | 19 | Semantic search: OpenAI + NextJS sample 20 | 21 | 22 | This is a sample application to demonstrate semantic search with 23 | OpenAI Embeddings, LangChain, Pinecone vector database, and NextJS. 24 | 25 | 26 | To get started, click the "Upload Document" button to upload 27 | a document. Once the document is uploaded, you can begin chatting with 28 | it. 29 | 30 | 31 | 36 | Learn how this project was built 37 | 38 | . 39 | 40 | 41 | Made by{' '} 42 | 47 | Dylan Babbs 48 | 49 | . 50 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /src/components/chat-view.tsx: -------------------------------------------------------------------------------- 1 | import {styled, useStyletron} from 'baseui'; 2 | import {ParagraphSmall} from 'baseui/typography'; 3 | import {Input} from 'baseui/input'; 4 | import {Button} from 'baseui/button'; 5 | import {Skeleton} from 'baseui/skeleton'; 6 | import {ReactNode, useEffect, useRef} from 'react'; 7 | import {Document, Message} from '../pages'; 8 | 9 | const Container = styled('div', ({$theme}) => ({ 10 | background: $theme.colors.backgroundPrimary, 11 | overflow: 'auto', 12 | display: 'flex', 13 | flexDirection: 'column', 14 | })); 15 | 16 | const MessagesContainer = styled('div', { 17 | flex: 1, 18 | display: 'flex', 19 | flexDirection: 'column', 20 | gap: '12px', 21 | overflowY: 'auto', 22 | padding: '16px', 23 | }); 24 | 25 | const InputContainer = styled('div', ({$theme}) => ({ 26 | display: 'flex', 27 | gap: '16px', 28 | borderTop: `1px solid ${$theme.colors.borderOpaque}`, 29 | paddingTop: '16px', 30 | padding: '16px', 31 | })); 32 | 33 | const Message = ({ 34 | children, 35 | role, 36 | isLoading, 37 | }: { 38 | children: ReactNode; 39 | role: string; 40 | isLoading: boolean; 41 | }) => { 42 | const [css, theme] = useStyletron(); 43 | return ( 44 |
56 | 57 | {role === 'user' ? 'User:' : 'Document AI:'} 58 | 59 | {isLoading ? ( 60 | 61 | ) : ( 62 | {children} 63 | )} 64 |
65 | ); 66 | }; 67 | export const ChatView = ({ 68 | messages, 69 | input, 70 | setInput, 71 | sendQuery, 72 | activeDocument, 73 | }: { 74 | messages: Message[]; 75 | input: string; 76 | setInput: (text: string) => void; 77 | sendQuery: () => void; 78 | activeDocument: Document; 79 | }) => { 80 | const ref = useRef(); 81 | 82 | useEffect(() => { 83 | //Ensure the most recent messages are visible 84 | if (ref.current) { 85 | // @ts-ignore 86 | ref.current.scrollTo(0, ref.current.offsetHeight); 87 | } 88 | }, [messages]); 89 | 90 | return ( 91 | 92 | 93 | {messages.map(({role, content, isLoading}, index) => { 94 | return ( 95 | 100 | {content} 101 | 102 | ); 103 | })} 104 | 105 | 106 | 107 | setInput(e.target.value)} 111 | onKeyDown={(evt) => { 112 | if (evt.key === 'Enter') { 113 | sendQuery(); 114 | } 115 | }} 116 | disabled={!activeDocument} 117 | /> 118 | 121 | 122 | 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /src/components/document-upload-modal.tsx: -------------------------------------------------------------------------------- 1 | import {useState, useCallback} from 'react'; 2 | import {Modal, ModalHeader, ModalBody} from 'baseui/modal'; 3 | import {FileUploader} from 'baseui/file-uploader'; 4 | import {Input} from 'baseui/input'; 5 | import {Button, SIZE} from 'baseui/button'; 6 | import {LabelSmall} from 'baseui/typography'; 7 | import {styled, useStyletron} from 'baseui'; 8 | 9 | const acceptedFiles = ['.txt']; 10 | 11 | const FlexColumn = styled('div', { 12 | display: 'flex', 13 | flexDirection: 'column', 14 | gap: '12px', 15 | }); 16 | 17 | const readText = async (document: File): Promise => { 18 | return new Promise((resolve) => { 19 | const reader = new FileReader(); 20 | reader.onload = function (event) { 21 | // @ts-ignore 22 | resolve(event.target.result as string); 23 | }; 24 | reader.readAsText(document); 25 | }); 26 | }; 27 | 28 | export const DocumentUploadModal = ({ 29 | isOpen, 30 | setIsOpen, 31 | setActiveDocument, 32 | }: { 33 | isOpen: boolean; 34 | setIsOpen: (isOpen: boolean) => void; 35 | setActiveDocument: (document: {text: string; name: string}) => void; 36 | }) => { 37 | const [, theme] = useStyletron(); 38 | const [document, setDocument] = useState(null); 39 | const [documentName, setDocumentName] = useState(''); 40 | const [isLoading, setIsLoading] = useState(false); 41 | 42 | const handleSubmit = useCallback(async () => { 43 | setIsLoading(true); 44 | const text = await readText(document); 45 | const response = await fetch('/api/upload-document', { 46 | method: 'POST', 47 | body: JSON.stringify({ 48 | text, 49 | name: documentName, 50 | }), 51 | }); 52 | const json = await response.json(); 53 | 54 | if (json.success) { 55 | setActiveDocument({text, name: documentName}); 56 | setIsLoading(false); 57 | setIsOpen(false); 58 | setDocument(null); 59 | setDocument(false); 60 | } 61 | }, [document, documentName, setActiveDocument, setIsOpen]); 62 | 63 | return ( 64 | 65 | Upload Document 66 | 67 | 68 | {document ? ( 69 | <> 70 | Document name 71 | setDocumentName(e.target.value)} 76 | /> 77 | 84 | 85 | ) : ( 86 | <> 87 | { 90 | setDocumentName(acceptedFiles[0].name); 91 | setDocument(acceptedFiles[0]); 92 | }} 93 | /> 94 | 95 | {acceptedFiles.join(', ')} files accepted 96 | 97 | 98 | )} 99 | 100 | 101 | 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/components/document-view.tsx: -------------------------------------------------------------------------------- 1 | import {styled, useStyletron} from 'baseui'; 2 | import {ParagraphSmall} from 'baseui/typography'; 3 | import {NAV_HEIGHT, type Document} from '../pages'; 4 | import {useEffect, useRef} from 'react'; 5 | 6 | const Container = styled('div', ({$theme}) => ({ 7 | background: $theme.colors.backgroundPrimary, 8 | padding: '0 16px', 9 | overflow: 'auto', 10 | })); 11 | 12 | const Highlight = styled('span', ({$theme}) => ({ 13 | backgroundColor: $theme.colors.backgroundWarning, 14 | padding: '1px', 15 | })); 16 | 17 | const EmptyState = () => { 18 | const [, theme] = useStyletron(); 19 | return ( 20 | 27 | 28 | To get started, click the "Upload Document" button in the top 29 | right. 30 | 31 | 32 | ); 33 | }; 34 | 35 | export const DocumentView = ({ 36 | activeDocument, 37 | highlightedText, 38 | }: { 39 | activeDocument: Document; 40 | highlightedText: string | null; 41 | }) => { 42 | const [, theme] = useStyletron(); 43 | 44 | const highlightRef = useRef(); 45 | const containerRef = useRef(); 46 | const prevHighlightedText = useRef(highlightedText); 47 | 48 | useEffect(() => { 49 | if ( 50 | highlightRef.current && 51 | containerRef.current && 52 | highlightedText !== prevHighlightedText.current 53 | ) { 54 | const top = (highlightRef.current as HTMLDivElement).offsetTop; 55 | (containerRef.current as HTMLDivElement).scrollTo({ 56 | //Add some padding to the scroll 57 | top: top - NAV_HEIGHT - 8, 58 | behavior: 'smooth', 59 | }); 60 | prevHighlightedText.current = highlightedText; 61 | } 62 | }, [highlightRef, highlightedText]); 63 | 64 | if (!activeDocument) { 65 | return ; 66 | } 67 | 68 | const paragraphs: any[] = activeDocument.text.split('\n'); 69 | if (highlightedText) { 70 | highlightedText = highlightedText.trim(); 71 | const matchIndex = paragraphs.findIndex((x) => x.includes(highlightedText)); 72 | console.log(matchIndex); 73 | console.log(highlightedText); 74 | console.log(activeDocument.text); 75 | 76 | if (matchIndex !== -1) { 77 | const split = paragraphs[matchIndex].split(highlightedText); 78 | console.log(split); 79 | paragraphs[matchIndex] = ( 80 | <> 81 | {split[0]} 82 | {highlightedText} 83 | {split[1]} 84 | 85 | ); 86 | } 87 | } 88 | 89 | return ( 90 | 91 | {paragraphs.map((paragraph, index) => { 92 | return ( 93 | 97 | {paragraph} 98 | 99 | ); 100 | })} 101 | 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /src/components/header.tsx: -------------------------------------------------------------------------------- 1 | import {LabelSmall} from 'baseui/typography'; 2 | import Upload from 'baseui/icon/upload'; 3 | import {Button, KIND, SIZE} from 'baseui/button'; 4 | import {styled} from 'baseui'; 5 | import type {Document} from '../pages'; 6 | 7 | const Container = styled('div', ({$theme}) => ({ 8 | padding: '8px 16px', 9 | borderBottom: `1px solid ${$theme.colors.borderOpaque}`, 10 | display: 'flex', 11 | alignItems: 'center', 12 | justifyContent: 'space-between', 13 | })); 14 | 15 | const Group = styled('div', { 16 | display: 'flex', 17 | alignItems: 'center', 18 | justifyContent: 'space-between', 19 | gap: '8px', 20 | }); 21 | 22 | export const Header = ({ 23 | activeDocument, 24 | setUploadModalIsOpen, 25 | setAboutModalIsOpen, 26 | }: { 27 | activeDocument: Document; 28 | setUploadModalIsOpen: (isOpen: boolean) => void; 29 | setAboutModalIsOpen: (isOpen: boolean) => void; 30 | }) => { 31 | return ( 32 | 33 | 34 | Document AI{activeDocument && `: ${activeDocument.name}`} 35 | 36 | 37 | 44 | 45 | 53 | 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react'; 2 | import {Provider as StyletronProvider} from 'styletron-react'; 3 | import {LightTheme, DarkTheme, BaseProvider} from 'baseui'; 4 | import {styletron} from '../styletron'; 5 | 6 | export const COLOR_THEMES = { 7 | light: 'light', 8 | dark: 'dark', 9 | }; 10 | 11 | export type ColorTheme = (typeof COLOR_THEMES)[keyof typeof COLOR_THEMES]; 12 | 13 | export const useColorTheme = (): ColorTheme => { 14 | const [colorTheme, setColorTheme] = useState(COLOR_THEMES.light); 15 | 16 | useEffect(() => { 17 | if (window.matchMedia('(prefers-color-scheme: dark)')?.matches) { 18 | setColorTheme(COLOR_THEMES.dark); 19 | } 20 | window 21 | .matchMedia('(prefers-color-scheme: dark)') 22 | .addEventListener('change', (event) => { 23 | setColorTheme(event.matches ? COLOR_THEMES.dark : COLOR_THEMES.light); 24 | }); 25 | }, []); 26 | 27 | return colorTheme; 28 | }; 29 | 30 | function MyApp({Component, pageProps}) { 31 | const colorTheme = useColorTheme(); 32 | return ( 33 | 34 | 37 | 38 | 39 | 40 | ); 41 | } 42 | 43 | export default MyApp; 44 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | // @ts-ignore 2 | import {Server, Sheet} from 'styletron-engine-atomic'; 3 | import {styletron} from '../styletron'; 4 | import {DocumentContext, Head, Html, Main, NextScript} from 'next/document'; 5 | import {Provider as StyletronProvider} from 'styletron-react'; 6 | import React from 'react'; 7 | 8 | const MyDocument = ({stylesheets}: {stylesheets: Sheet[]}) => { 9 | return ( 10 | 11 | 12 | 15 | {stylesheets.map((sheet, i) => ( 16 |