Ciao a tutti! volevo condividere il mio approccio alla creazione di un chatbot “smart documentation” per un progetto su cui sto lavorando. I’m not an AI expert, so any suggestions or improvements are more than welcome! Lo scopo di questo post non è quello di creare un altro tutorial sulla costruzione di un chatbot basato su OpenAI. Ci sono già un sacco di contenuti su questo argomento. Dividendoli in gestibili di generare con l’apertura e per trovare e restituire le informazioni più rilevanti alla query di un utente. index documentation chunks embeddings performing a similarity search Nel mio caso, la documentazione sarà file Markdown, ma può essere qualsiasi forma di testo, oggetto di database, ecc. Ma perché ? Poiché a volte può essere difficile trovare le informazioni di cui hai bisogno, volevo creare un chatbot che potesse rispondere a domande su un argomento specifico e fornire il contesto pertinente dalla documentazione. Questo assistente può essere utilizzato in vari modi, come ad esempio: Fornire risposte rapide alle domande più frequenti Cercare un doc/pagina come Algolia fa Aiutare gli utenti a trovare le informazioni di cui hanno bisogno in un documento specifico Riscoprire le preoccupazioni / domande degli utenti memorizzando le domande poste riassunto Di seguito, elencherò le tre parti principali della mia soluzione: Leggere i file di documentazione Indice della documentazione (chunking, sovrapposizione e incorporazione) Cercare la documentazione (e collegarla a un chatbot) File albero . └── docs └── ...md └── src └── askDocQuestion.ts └── index.ts # Express.js application endpoint └── embeddings.json # Storage for embeddings └── packages.json 1 Leggere i file di documentazione Invece di codificare il testo della documentazione, è possibile eseguire la scansione di una cartella per File che utilizzano strumenti come . .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; }; In alternativa, puoi naturalmente recuperare la tua documentazione dal tuo database o CMS, ecc. In alternativa, puoi naturalmente recuperare la tua documentazione dal tuo database o CMS, ecc. Indice della documentazione Per creare il nostro motore di ricerca, useremo OpenAI per generare i nostri embeddings. Inserimento di vettori API Gli embeddings vettoriali sono un modo per rappresentare i dati in formato numerico, che può essere utilizzato per eseguire ricerche di somiglianza (nel nostro caso, tra la domanda dell'utente e le sezioni della nostra documentazione). Questo vettore, costituito da un elenco di numeri di punti galleggianti, verrà utilizzato per calcolare la somiglianza utilizzando una formula matematica. [ -0.0002630692, -0.029749284, 0.010225477, -0.009224428, -0.0065269712, -0.002665544, 0.003214777, 0.04235309, -0.033162255, -0.00080789323, //...+1533 elements ]; Sulla base di questo concetto, è stato creato il database Vector. Come risultato, invece di utilizzare l'API OpenAI, è possibile utilizzare un database vettoriale come Chroma, Qdrant o Pinecone. Sulla base di questo concetto, è stato creato Di conseguenza, invece di utilizzare l'API OpenAI, è possibile utilizzare un database vettoriale come di o . Database vettoriale Chroma Cattedrale Pinocchio 2.1 Chunk ogni file e sovrapposizione Grandi blocchi di testo possono superare i limiti del contesto del modello o causare hit meno rilevanti, quindi si consiglia di dividerli in pezzi per rendere la ricerca più mirata. Tuttavia, per preservare una certa continuità tra i pezzi, li sovrapponiamo con un certo numero di token (o caratteri). Esempio di Chunking In questo esempio, abbiamo un testo lungo che vogliamo suddividere in pezzi più piccoli. In questo caso, vogliamo creare pezzi di 100 caratteri e sovrapporliarli con 50 caratteri. Full Text (406 characters): Nel cuore della vivace città, c’era una vecchia biblioteca che molti avevano dimenticato.Le sue scaffali tortuose erano piene di libri di ogni genere immaginabile, ognuno sussurrando storie di avventure, misteri e saggezza senza tempo.Ogni sera, un bibliotecario dedicato apriva le sue porte, accogliendo le menti curiose desiderose di esplorare la vasta conoscenza all’interno.I bambini si riunivano per le sessioni di racconto. 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. Codice 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; }; Per saperne di più sul crunking, e l'impatto della dimensione sull'incollaggio, puoi controllare questo articolo. Per saperne di più sul chunking, e l'impatto della dimensione sull'incollaggio, puoi controllare . Questo articolo 2.2 Generazione incarnata Una volta che un file viene tagliato, si generano incorporazioni vettoriali per ciascun pezzo utilizzando l'API di OpenAI (ad esempio, e) il 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 Generare e salvare incorporamenti per l'intero file Per evitare la rigenerazione degli embeddings ogni volta, memorizzeremo gli embeddings. Può essere memorizzato in un database. Ma in questo caso, lo memorizzeremo semplicemente in un file JSON localmente. Il seguente codice è semplice: Iterato su ogni documento, tagliare il documento in pezzi, generare embeddings per ogni pezzo, Inserisci il file in un file JSON. Completa il vectorStore con gli embeddings da utilizzare nella ricerca. 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 Ricerca della documentazione 3.1 Similarietà vettoriale Per rispondere alla domanda di un utente, generamo prima un incorporamento per il e quindi calcolare la somiglianza cosina tra l'incorporazione della query e l'incorporazione di ciascun pezzo. filtriamo qualsiasi cosa al di sotto di una certa soglia di somiglianza e manteniamo solo le corrispondenze X superiori. 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 Prompting OpenAI con Chunks pertinenti Dopo aver mangiato, si nutre Questo significa che ChatGPT vede le sezioni più rilevanti dei tuoi documenti come se li avessi digitati nella conversazione. 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; }; Implementazione di OpenAI API per Chatbot Using Express Per eseguire il nostro sistema, utilizzeremo un server Express.js. Ecco un esempio di un piccolo endpoint Express.js per gestire la query: 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: Creare un’interfaccia Chatbot Sul frontend, ho costruito un piccolo componente React con un'interfaccia simile a chat. Invia messaggi al mio backend Express e visualizza le risposte. Template Codice Ho fatto un per voi di utilizzare come punto di partenza per il vostro chatbot. Template Codice Demo dal vivo Se vuoi testare l'implementazione finale di questo chatbot, controlla questo . demo page Sito demo Il mio codice demo Titolo originale: AskDocQuestion.ts Frontend: componenti ChatBot Andare oltre Su YouTube, guardate questo Si tratta di OpenAI Embeddings e database vettoriali. Il video di Adrien Twarog Anche io mi sono schiantato Questo potrebbe essere interessante se si desidera un approccio alternativo. Documentazione di ricerca dei file di OpenAI Assistants Conclusione Spero che questo ti dia un'idea di come gestire l'indicizzazione della documentazione per un chatbot: Utilizzando chunking + sovrapposizione in modo che sia trovato il contesto giusto, Generare embeddings e memorizzarli per ricerche veloci di somiglianza vettoriale, Infine, l'ho consegnato a ChatGPT con il contesto pertinente. Non sono un esperto di intelligenza artificiale; questa è solo una soluzione che ho trovato funziona bene per le mie esigenze. Mi piacerebbe ascoltare feedback sulle soluzioni di archiviazione vettoriale, sulle strategie di chunking o su altri suggerimenti di prestazioni. please let me know Thanks for reading, and feel free to share your thoughts!