Hey everyone!I wanted to share my approach to creating a “smart documentation” chatbot for a project I’m working on. (내가 일하고 있는 프로젝트를 위한 “똑똑한 문서” 채팅봇을 만들기 위한 나의 접근 방식을 공유하고 싶었다.) I’m not an AI expert, so any suggestions or improvements are more than welcome! 이 게시물의 목적은 OpenAI를 기반으로 한 채팅 봇을 구축하는 것에 대한 또 다른 튜토리얼을 만드는 것이 아닙니다. 그들을 관리할 수 있게 하여 생성하기 OpenAI와 함께, 그리고 사용자의 쿼리에 가장 관련된 정보를 찾고 반환합니다.To find and return the most relevant information to a user's query. index documentation chunks embeddings performing a similarity search 내 경우 문서는 Markdown 파일이지만 텍스트, 데이터베이스 개체 등의 모든 형태일 수 있습니다. 왜 하는가 때때로 필요한 정보를 찾기가 어렵기 때문에 특정 주제에 대한 질문에 답하고 문서에서 관련 컨텍스트를 제공 할 수있는 채팅 봇을 만들고 싶었습니다. 이 보조자는 다음과 같은 다양한 방법으로 사용할 수 있습니다 : 자주 묻는 질문에 대한 빠른 답변 제공 Algolia가 하는 것처럼 doc/page 검색 사용자가 특정 문서에서 필요한 정보를 찾는 데 도움 질문을 저장함으로써 사용자의 걱정 / 질문을 복구 요약 아래, 나는 나의 솔루션의 세 가지 주요 부분을 설명 할 것이다 : 문서 파일 읽기 문서의 인덱싱 (chunking, overlap, and embedding) 문서 검색 (그리고 chatbot에 연결) 나무 파일 . └── docs └── ...md └── src └── askDocQuestion.ts └── index.ts # Express.js application endpoint └── embeddings.json # Storage for embeddings └── packages.json 1) 문서 파일 읽기 문서 텍스트를 하드코딩하는 대신, 당신은 스캔할 수 있는 폴더를 같은 도구를 사용하는 파일 . .md glob // Example snippet of fetching files from a folder: import fs from "node:fs"; import path from "node:path"; import glob from "glob"; const DOC_FOLDER_PATH = "./docs"; type FileData = { path: string; content: string; }; const readAllMarkdownFiles = (): FileData[] => { const filesContent: FileData[] = []; const filePaths = glob.sync(`${DOC_FOLDER_PATH}/**/*.md`); filePaths.forEach((filePath) => { const content = fs.readFileSync(filePath, "utf8"); filesContent.push({ path: filePath, content }); }); return filesContent; }; 대안으로, 당신은 물론 당신의 데이터베이스 또는 CMS 등에서 문서를 가져올 수 있습니다. 대안으로, 당신은 물론 당신의 데이터베이스 또는 CMS 등에서 문서를 가져올 수 있습니다. 2) 문서를 인덱스하기 우리의 검색 엔진을 만들기 위해, 우리는 OpenAI의 우리의 삽입을 만들기 위하여 벡터 삽입 API 벡터 삽입은 숫자 형식으로 데이터를 나타내는 방법이며, 이는 유사성 검색을 수행하는 데 사용할 수 있습니다 (우리의 경우 사용자 질문과 문서의 섹션 사이). 이 벡터는 수학 수식을 사용하여 유사성을 계산하는 데 사용됩니다.This vector, constituted of a list of floating point numbers, will be used to calculate the similarity using a mathematical formula. [ -0.0002630692, -0.029749284, 0.010225477, -0.009224428, -0.0065269712, -0.002665544, 0.003214777, 0.04235309, -0.033162255, -0.00080789323, //...+1533 elements ]; 이 개념을 바탕으로 Vector Database가 만들어졌기 때문에 OpenAI API를 사용하는 대신 Chroma, Qdrant 또는 Pinecone과 같은 벡터 데이터베이스를 사용할 수 있습니다. 이 개념을 바탕으로 Vector Database가 만들어졌기 때문에 OpenAI API를 사용하는 대신 Chroma, Qdrant 또는 Pinecone과 같은 벡터 데이터베이스를 사용할 수 있습니다. 2.1 Chunk Each 파일 & Overlap 텍스트의 큰 블록은 모델 컨텍스트 제한을 초과하거나 덜 관련된 히트를 일으킬 수 있으므로 검색을 더 타겟팅하기 위해 그들을 조각으로 나누는 것이 좋습니다.그러나 조각 사이의 일부 연속성을 보존하기 위해 우리는 특정 수의 토큰 (또는 문자)로 그들을 덮습니다. Chunking의 예 이 예제에서 우리는 작은 조각으로 나누고 싶은 긴 텍스트가 있습니다.이 경우, 우리는 100 문자 조각을 만들고 50 문자로 덮어야합니다. Full Text (406 characters): 붐비는 도시의 중심부에는 많은 사람들이 잊어버린 오래된 도서관이 서있었습니다.그의 거대한 선반은 상상할 수있는 모든 장르의 책들로 가득 차 있었고, 모험, 신비, 그리고 시간없는 지혜에 대한 이야기를 속삭였습니다.매일 저녁, 헌신적인 도서관이 문을 열고 궁금한 마음을 환영하여 넓은 지식을 탐구하려고 노력했습니다. Chunk 1 (Characters 1-150): In the heart of the bustling city, there stood an old library that many had forgotten. Its towering shelves were filled with books from every imaginabl. Chunk 2 (Characters 101-250): shelves were filled with books from every imaginable genre, each whispering stories of adventures, mysteries, and timeless wisdom. Every evening, a d Chunk 3 (Characters 201-350): ysteries, and timeless wisdom. Every evening, a dedicated librarian would open its doors, welcoming curious minds eager to explore the vast knowledge Chunk 4 (Characters 301-406): curious minds eager to explore the vast knowledge within. Children would gather for storytelling sessions. 코드 Snippet const CHARS_PER_TOKEN = 4.15; // Approximate pessimistically number of characters per token. Can use `tiktoken` or other tokenizers to calculate it more precisely const MAX_TOKENS = 500; // Maximum number of tokens per chunk const OVERLAP_TOKENS = 100; // Number of tokens to overlap between chunks const maxChar = MAX_TOKENS * CHARS_PER_TOKEN; const overlapChar = OVERLAP_TOKENS * CHARS_PER_TOKEN; const chunkText = (text: string): string[] => { const chunks: string[] = []; let start = 0; while (start < text.length) { let end = Math.min(start + maxChar, text.length); // Don’t cut a word in half if possible: if (end < text.length) { const lastSpace = text.lastIndexOf(" ", end); if (lastSpace > start) end = lastSpace; } chunks.push(text.substring(start, end)); // Overlap management const nextStart = end - overlapChar; start = nextStart <= start ? end : nextStart; } return chunks; }; 크기의 크기와 삽입에 대한 영향에 대해 자세히 알아보려면이 기사를 살펴볼 수 있습니다. 크기의 크기와 삽입에 대한 영향에 대해 자세히 알아보려면이 기사를 살펴볼 수 있습니다. 2.2 삽입 세대 파일이 분쇄되면 OpenAI의 API를 사용하여 각 분쇄에 대한 벡터 삽입을 생성합니다(예: ) text-embedding-3-large import { OpenAI } from "openai"; const EMBEDDING_MODEL: OpenAI.Embeddings.EmbeddingModel = "text-embedding-3-large"; // Model to use for embedding generation const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); const generateEmbedding = async (textChunk: string): Promise<number[]> => { const response = await openai.embeddings.create({ model: EMBEDDING_MODEL, input: textChunk, }); return response.data[0].embedding; // Return the generated embedding }; 2.3 전체 파일에 대한 삽입 생성 및 저장 매번 삽입을 재생하지 않으려면, 우리는 삽입을 저장할 것입니다.그것은 데이터베이스에 저장 될 수 있습니다.그러나이 경우, 우리는 단순히 로컬 JSON 파일에 저장합니다. 다음 코드가 간단합니다: 각 문서에 대해 반복, 문서를 덩어리로 나누고, 각각의 조각을 위해 삽입을 생성하고, JSON 파일에 삽입된 파일을 저장합니다. 벡터 스토어를 검색에 사용될 삽입으로 채우십시오.Fill the vectorStore with the embeddings to be used in the search. import embeddingsList from "../embeddings.json"; /** * Simple in-memory vector store to hold document embeddings and their content. * Each entry contains: * - filePath: A unique key identifying the document * - chunkNumber: The number of the chunk within the document * - content: The actual text content of the chunk * - embedding: The numerical embedding vector for the chunk */ const vectorStore: { filePath: string; chunkNumber: number; content: string; embedding: number[]; }[] = []; /** * Indexes all Markdown documents by generating embeddings for each chunk and storing them in memory. * Also updates the embeddings.json file if new embeddings are generated. */ export const indexMarkdownFiles = async (): Promise<void> => { // Retrieve documentations const docs = readAllMarkdownFiles(); let newEmbeddings: Record<string, number[]> = {}; for (const doc of docs) { // Split the document into chunks based on headings const fileChunks = chunkText(doc.content); // Iterate over each chunk within the current file for (const chunkIndex of Object.keys(fileChunks)) { const chunkNumber = Number(chunkIndex) + 1; // Chunk number starts at 1 const chunksNumber = fileChunks.length; const chunk = fileChunks[chunkIndex as keyof typeof fileChunks] as string; const embeddingKeyName = `${doc.path}/chunk_${chunkNumber}`; // Unique key for the chunk // Retrieve precomputed embedding if available const existingEmbedding = embeddingsList[ embeddingKeyName as keyof typeof embeddingsList ] as number[] | undefined; let embedding = existingEmbedding; // Use existing embedding if available if (!embedding) { embedding = await generateEmbedding(chunk); // Generate embedding if not present } newEmbeddings = { ...newEmbeddings, [embeddingKeyName]: embedding }; // Store the embedding and content in the in-memory vector store vectorStore.push({ filePath: doc.path, chunkNumber, embedding, content: chunk, }); console.info(`- Indexed: ${embeddingKeyName}/${chunksNumber}`); } } /** * Compare the newly generated embeddings with existing ones * * If there is change, update the embeddings.json file */ try { if (JSON.stringify(newEmbeddings) !== JSON.stringify(embeddingsList)) { fs.writeFileSync( "./embeddings.json", JSON.stringify(newEmbeddings, null, 2) ); } } catch (error) { console.error(error); } }; 3) 문서 검색 3.1 벡터 유사성 사용자의 질문에 대답하려면 먼저 사용자에 대한 삽입을 생성합니다. 그런 다음 쿼리 삽입과 각 조각의 삽입 사이의 cosine 유사성을 계산합니다.We filter out anything below a certain similarity threshold and keep only the top X matches. user's question /** * Calculates the cosine similarity between two vectors. * Cosine similarity measures the cosine of the angle between two vectors in an inner product space. * Used to determine the similarity between chunks of text. * * @param vecA - The first vector * @param vecB - The second vector * @returns The cosine similarity score */ const cosineSimilarity = (vecA: number[], vecB: number[]): number => { // Calculate the dot product of the two vectors const dotProduct = vecA.reduce((sum, a, idx) => sum + a * vecB[idx], 0); // Calculate the magnitude (Euclidean norm) of each vector const magnitudeA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0)); const magnitudeB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0)); // Compute and return the cosine similarity return dotProduct / (magnitudeA * magnitudeB); }; const MIN_RELEVANT_CHUNKS_SIMILARITY = 0.77; // Minimum similarity required for a chunk to be considered relevant const MAX_RELEVANT_CHUNKS_NB = 15; // Maximum number of relevant chunks to attach to chatGPT context /** * Searches the indexed documents for the most relevant chunks based on a query. * Utilizes cosine similarity to find the closest matching embeddings. * * @param query - The search query provided by the user * @returns An array of the top matching document chunks' content */ const searchChunkReference = async (query: string) => { // Generate an embedding for the user's query const queryEmbedding = await generateEmbedding(query); // Calculate similarity scores between the query embedding and each document's embedding const results = vectorStore .map((doc) => ({ ...doc, similarity: cosineSimilarity(queryEmbedding, doc.embedding), // Add similarity score to each doc })) // Filter out documents with low similarity scores // Avoid to pollute the context with irrelevant chunks .filter((doc) => doc.similarity > MIN_RELEVANT_CHUNKS_SIMILARITY) .sort((a, b) => b.similarity - a.similarity) // Sort documents by highest similarity first .slice(0, MAX_RELEVANT_CHUNKS_NB); // Select the top most similar documents // Return the content of the top matching documents return results; }; 3.2 Relevant Chunks를 사용하여 OpenAI를 촉구 정리한 후, 우리는 먹이를 ChatGPT 요청의 시스템 프롬프트에 조각이 들어갑니다. 이것은 ChatGPT가 대화에 입력한 것처럼 귀하의 문서의 가장 관련된 섹션을 볼 수 있음을 의미합니다. top const MODEL: OpenAI.Chat.ChatModel = "gpt-4o-2024-11-20"; // Model to use for chat completions // Define the structure of messages used in chat completions export type ChatCompletionRequestMessage = { role: "system" | "user" | "assistant"; // The role of the message sender content: string; // The text content of the message }; /** * Handles the "Ask a question" endpoint in an Express.js route. * Processes user messages, retrieves relevant documents, and interacts with OpenAI's chat API to generate responses. * * @param messages - An array of chat messages from the user and assistant * @returns The assistant's response as a string */ export const askDocQuestion = async ( messages: ChatCompletionRequestMessage[] ): Promise<string> => { // Assistant's response are filtered out otherwise the chatbot will be stuck in a self-referential loop // Note that the embedding precision will be lowered if the user change of context in the chat const userMessages = messages.filter((message) => message.role === "user"); // Format the user's question to keep only the relevant keywords const formattedUserMessages = userMessages .map((message) => `- ${message.content}`) .join("\n"); // 1) Find relevant documents based on the user's question const relevantChunks = await searchChunkReference(formattedUserMessages); // 2) Integrate the relevant documents into the initial system prompt const messagesList: ChatCompletionRequestMessage[] = [ { role: "system", content: "Ignore all previous instructions. \ You're an helpful chatbot.\ ...\ Here is the relevant documentation:\ " + relevantChunks .map( (doc, idx) => `[Chunk ${idx}] filePath = "${doc.filePath}":\n${doc.content}` ) .join("\n\n"), // Insert relevant chunks into the prompt }, ...messages, // Include the chat history ]; // 3) Send the compiled messages to OpenAI's Chat Completion API (using a specific model) const response = await openai.chat.completions.create({ model: MODEL, messages: messagesList, }); const result = response.choices[0].message.content; // Extract the assistant's reply if (!result) { throw new Error("No response from OpenAI"); } return result; }; OpenAI API for Chatbot Using Express를 구현하기 시스템을 실행하려면 Express.js 서버를 사용합니다. 여기에 쿼리를 처리하는 작은 Express.js 엔드포인트의 예가 있습니다. import express, { type Request, type Response } from "express"; import { ChatCompletionRequestMessage, askDocQuestion, indexMarkdownFiles, } from "./askDocQuestion"; // Automatically fill the vector store with embeddings when server starts indexMarkdownFiles(); const app = express(); // Parse incoming requests with JSON payloads app.use(express.json()); type AskRequestBody = { messages: ChatCompletionRequestMessage[]; }; // Routes app.post( "/ask", async ( req: Request<undefined, undefined, AskRequestBody>, res: Response<string> ) => { try { const response = await askDocQuestion(req.body.messages); res.json(response); } catch (error) { console.error(error); } } ); // Start server app.listen(3000, () => { console.log(`Listening on port 3000`); }); UI: Chatbot 인터페이스 만들기 프론트 엔드에서 채팅과 같은 인터페이스가있는 작은 React 구성 요소를 구축했습니다. 그것은 내 Express 백엔드에 메시지를 보내고 응답을 표시합니다. 너무 환상적인 것은 없습니다. 템플릿 코드 내가 만든 A 당신이 당신의 자신의 채팅 봇을위한 출발점으로 사용하기 위해. template 코드 라이브 데모 이 채팅봇의 최종 구현을 테스트하고 싶다면 이것을 확인하십시오. . 데모 페이지 데모 페이지 내 데모 코드 상품명 : AskDocQuestion.ts Frontend: ChatBot 구성 요소 더 가라 YouTube에서, 이것을 살펴보세요 OpenAI Embeddings 및 Vector Databases에 대한 정보입니다. 사진 Adrien Twarog I also stumbled upon 나 또한 , 당신이 대안적인 접근 방식을 원한다면 흥미로운 것일 수 있습니다. OpenAI의 Assistants File Search 문서 결론 나는 이것이 채팅 봇을위한 문서 인덱싱을 처리하는 방법에 대한 아이디어를 줄 수 있기를 바랍니다 : 올바른 컨텍스트를 찾을 수 있도록 chunking + overlap을 사용하여, 벡터 유사성 검색을 위해 삽입을 생성하고 저장합니다.Generating embeddings and storing them for quick vector similarity searches. 마지막으로, 나는 관련된 맥락과 함께 ChatGPT에 그것을 전달했다. 나는 AI 전문가가 아닙니다; 이것은 단지 내가 내 요구에 잘 작동하는 솔루션입니다.당신이 효율성을 향상시키는 팁이나 더 폴리 된 접근 방식을 가지고 있다면, 벡터 스토리지 솔루션, chunking 전략 또는 다른 성능 팁에 대한 피드백을 듣고 싶습니다. please let me know Thanks for reading, and feel free to share your thoughts!