Dans cet exemple, nous allons créer une application complète qui utilise la génération augmentée (RAG) de récupération alimentée par Pinecone pour fournir des réponses précises et contextuellement pertinentes dans un chatbot.
Le RAG est un outil puissant qui combine les avantages des modèles basés sur la récupération et des modèles génératifs. Contrairement aux chatbots traditionnels qui peuvent avoir du mal à maintenir des informations à jour ou à accéder aux connaissances spécifiques au domaine, un chatbot basé sur des chiffons utilise une base de connaissances créée à partir d'URL rampées pour fournir des réponses contextuelles pertinentes.
L'intégration du SDK AI de Vercel dans notre application nous permettra de configurer facilement le flux de travail du chatbot et d'utiliser le streaming plus efficacement, en particulier dans les environnements Edge, améliorant la réactivité et les performances de notre chatbot.
À la fin de ce tutoriel, vous aurez un chatbot compatible avec contexte qui fournit des réponses précises sans hallucination, assurant une expérience utilisateur plus efficace et plus engageante. Commençons à créer cet outil puissant (liste complète de code).
Next.js est un puissant framework JavaScript qui nous permet de créer des applications Web rendues et statiques côté serveur à l'aide de React. C'est un excellent choix pour notre projet en raison de sa facilité de configuration, de ses excellentes performances et des fonctionnalités intégrées telles que le routage et les itinéraires API.
Pour créer une nouvelle application Next.js, exécutez la commande suivante:
npx create-next-app chatbot Ensuite, nous ajouterons le package ai :
npm install aiVous pouvez utiliser la liste complète des dépendances si vous souhaitez construire avec le tutoriel.
Dans cette étape, nous allons utiliser le SDK Vercel pour établir le backend et le frontend de notre chatbot dans l'application suivante.js. À la fin de cette étape, notre chatbot de base sera opérationnel, prêt à ajouter des capacités de contexte dans les étapes suivantes. Commençons.
Maintenant, concentrons-nous sur le composant frontal de notre chatbot. Nous allons créer les éléments orientés utilisateur de notre bot, créant l'interface par laquelle les utilisateurs interagiront avec notre application. Cela impliquera la fabrication de la conception et des fonctionnalités de l'interface de chat dans notre application Next.js.
Tout d'abord, nous allons créer le composant Chat , qui rendra l'interface de chat.
import React , { FormEvent , ChangeEvent } from "react" ;
import Messages from "./Messages" ;
import { Message } from "ai/react" ;
interface Chat {
input : string ;
handleInputChange : ( e : ChangeEvent < HTMLInputElement > ) => void ;
handleMessageSubmit : ( e : FormEvent < HTMLFormElement > ) => Promise < void > ;
messages : Message [ ] ;
}
const Chat : React . FC < Chat > = ( {
input ,
handleInputChange ,
handleMessageSubmit ,
messages ,
} ) => {
return (
< div id = "chat" className = "..." >
< Messages messages = { messages } / >
< >
< form onSubmit = { handleMessageSubmit } className = "..." >
< input
type = "text"
className = "..."
value = { input }
onChange = { handleInputChange }
/ >
< span className = "..." > Press ⮐ to send < / span >
< / form >
< / >
< / div >
) ;
} ;
export default Chat ; Ce composant affichera la liste des messages et le formulaire d'entrée pour que l'utilisateur envoie des messages. Le composant Messages pour rendre les messages de chat:
import { Message } from "ai" ;
import { useRef } from "react" ;
export default function Messages ( { messages } : { messages : Message [ ] } ) {
const messagesEndRef = useRef < HTMLDivElement | null > ( null ) ;
return (
< div className = "..." >
{ messages . map ( ( msg , index ) => (
< div
key = { index }
className = { ` ${
msg . role === "assistant" ? "text-green-300" : "text-blue-300"
} ... ` }
>
< div className = "..." > { msg . role === "assistant" ? "?" : "?" } < / div >
< div className = "..." > { msg . content } < / div >
< / div >
) ) }
< div ref = { messagesEndRef } / >
< / div >
) ;
} Notre composant Page principale gérera l'état des messages affichés dans le composant Chat :
"use client" ;
import Header from "@/components/Header" ;
import Chat from "@/components/Chat" ;
import { useChat } from "ai/react" ;
const Page : React . FC = ( ) => {
const [ context , setContext ] = useState < string [ ] | null > ( null ) ;
const { messages , input , handleInputChange , handleSubmit } = useChat ( ) ;
return (
< div className = "..." >
< Header className = "..." / >
< div className = "..." >
< Chat
input = { input }
handleInputChange = { handleInputChange }
handleMessageSubmit = { handleSubmit }
messages = { messages }
/ >
< / div >
< / div >
) ;
} ;
export default Page ; Le crochet useChat utile gérera l'état des messages affichés dans le composant Chat . Ce sera:
Ensuite, nous allons configurer le point de terminaison de l'API Chatbot. Il s'agit du composant côté serveur qui traitera les demandes et les réponses de notre chatbot. Nous allons créer un nouveau fichier appelé api/chat/route.ts et ajouter les dépendances suivantes:
import { Configuration , OpenAIApi } from "openai-edge" ;
import { Message , OpenAIStream , StreamingTextResponse } from "ai" ; La première dépendance est le package openai-edge qui facilite l'interaction avec les API d'Openai dans un environnement de bord. La deuxième dépendance est le package ai que nous utiliserons pour définir les types Message et OpenAIStream , que nous utiliserons pour diffuser la réponse d'Openai au client.
Initialisez ensuite le client OpenAI:
// Create an OpenAI API client (that's edge friendly!)
const config = new Configuration ( {
apiKey : process . env . OPENAI_API_KEY ,
} ) ;
const openai = new OpenAIApi ( config ) ; Pour définir ce point de terminaison comme une fonction de bord, nous définirons et exporterons la variable runtime
export const runtime = "edge" ;Ensuite, nous définirons le gestionnaire de points de terminaison:
export async function POST ( req : Request ) {
try {
const { messages } = await req . json ( ) ;
const prompt = [
{
role : "system" ,
content : `AI assistant is a brand new, powerful, human-like artificial intelligence.
The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness.
AI is a well-behaved and well-mannered individual.
AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user.
AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation.
AI assistant is a big fan of Pinecone and Vercel.
` ,
} ,
] ;
// Ask OpenAI for a streaming chat completion given the prompt
const response = await openai . createChatCompletion ( {
model : "gpt-3.5-turbo" ,
stream : true ,
messages : [
... prompt ,
... messages . filter ( ( message : Message ) => message . role === "user" ) ,
] ,
} ) ;
// Convert the response into a friendly text-stream
const stream = OpenAIStream ( response ) ;
// Respond with the stream
return new StreamingTextResponse ( stream ) ;
} catch ( e ) {
throw e ;
}
} Ici, nous déconstruire les messages de la publication et créons notre invite initiale. Nous utilisons l'invite et les messages comme entrée sur la méthode createChatCompletion . Nous convertissons ensuite la réponse en flux et le renvoyons au client. Notez que dans cet exemple, nous n'envoyons les messages de l'utilisateur qu'à OpenAI (au lieu d'inclure également les messages du bot).
Alors que nous plongeons dans la construction de notre chatbot, il est important de comprendre le rôle du contexte. L'ajout de contexte aux réponses de notre chatbot est essentiel pour créer une expérience utilisateur conversationnelle plus naturelle. Sans contexte, les réponses d'un chatbot peuvent se sentir décousues ou non pertinentes. En comprenant le contexte de la requête d'un utilisateur, notre chatbot sera en mesure de fournir des réponses plus précises, pertinentes et engageantes. Maintenant, commençons à s'appuyer avec cet objectif à l'esprit.
Tout d'abord, nous nous concentrerons d'abord sur l'ensemencement de la base de connaissances. Nous allons créer un robot et un script de semences, et établirons un point de terminaison de rampe. Cela nous permettra de rassembler et d'organiser les informations que notre chatbot utilisera pour fournir des réponses contextuellement pertinentes.
Après avoir rempli notre base de connaissances, nous récupérerons les matchs de nos intérêts. Cela permettra à notre chatbot de trouver des informations pertinentes en fonction des requêtes utilisateur.
Ensuite, nous allons envelopper notre logique dans la fonction GetContext et mettre à jour l'invite de notre chatbot. Cela rationalisera notre code et améliorera l'expérience utilisateur en s'assurant que les invites du chatbot sont pertinentes et engageantes.
Enfin, nous ajouterons un panneau de contexte et un point de terminaison de contexte associé. Ceux-ci fourniront une interface utilisateur pour le chatbot et un moyen de récupérer le contexte nécessaire pour chaque requête utilisateur.
Cette étape consiste à alimenter notre chatbot les informations dont elle a besoin et à configurer l'infrastructure nécessaire pour qu'il puisse récupérer et utiliser efficacement ces informations. Commençons.
Nous allons maintenant passer à l'ensemencement de la base de connaissances, la source de données fondamentale qui éclairera les réponses de notre chatbot. Cette étape consiste à collecter et à organiser les informations que notre chatbot doit fonctionner efficacement. Dans ce guide, nous allons utiliser des données récupérées à partir de divers sites Web sur lesquels nous serons en mesure de poser des questions. Pour ce faire, nous allons créer un robot qui raclera les données des sites Web, les intégrera et les stockera en pincone.
Par souci de concision, vous pourrez trouver le code complet pour le Crawler ici. Voici les parties pertinentes:
class Crawler {
private seen = new Set < string > ( ) ;
private pages : Page [ ] = [ ] ;
private queue : { url : string ; depth : number } [ ] = [ ] ;
constructor ( private maxDepth = 2 , private maxPages = 1 ) { }
async crawl ( startUrl : string ) : Promise < Page [ ] > {
// Add the start URL to the queue
this . addToQueue ( startUrl ) ;
// While there are URLs in the queue and we haven't reached the maximum number of pages...
while ( this . shouldContinueCrawling ( ) ) {
// Dequeue the next URL and depth
const { url , depth } = this . queue . shift ( ) ! ;
// If the depth is too great or we've already seen this URL, skip it
if ( this . isTooDeep ( depth ) || this . isAlreadySeen ( url ) ) continue ;
// Add the URL to the set of seen URLs
this . seen . add ( url ) ;
// Fetch the page HTML
const html = await this . fetchPage ( url ) ;
// Parse the HTML and add the page to the list of crawled pages
this . pages . push ( { url , content : this . parseHtml ( html ) } ) ;
// Extract new URLs from the page HTML and add them to the queue
this . addNewUrlsToQueue ( this . extractUrls ( html , url ) , depth ) ;
}
// Return the list of crawled pages
return this . pages ;
}
// ... Some private methods removed for brevity
private async fetchPage ( url : string ) : Promise < string > {
try {
const response = await fetch ( url ) ;
return await response . text ( ) ;
} catch ( error ) {
console . error ( `Failed to fetch ${ url } : ${ error } ` ) ;
return "" ;
}
}
private parseHtml ( html : string ) : string {
const $ = cheerio . load ( html ) ;
$ ( "a" ) . removeAttr ( "href" ) ;
return NodeHtmlMarkdown . translate ( $ . html ( ) ) ;
}
private extractUrls ( html : string , baseUrl : string ) : string [ ] {
const $ = cheerio . load ( html ) ;
const relativeUrls = $ ( "a" )
. map ( ( _ , link ) => $ ( link ) . attr ( "href" ) )
. get ( ) as string [ ] ;
return relativeUrls . map (
( relativeUrl ) => new URL ( relativeUrl , baseUrl ) . href
) ;
}
} La classe Crawler est un robot Web qui visite les URL, à partir d'un point donné, et collecte des informations chez eux. Il fonctionne dans une certaine profondeur et un nombre maximum de pages telles que définies dans le constructeur. La méthode de rampe est la fonction centrale qui démarre le processus rampant.
Les méthodes d'assistance Rempage, le parsehtml et les extracturls gérent respectivement la récupération du contenu HTML d'une page, analysant le HTML pour extraire du texte, et extraction de toutes les URL d'une page à faire la queue pour la prochaine rampe. La classe maintient également un enregistrement des URL visitées pour éviter la duplication.
seedPour lier les choses ensemble, nous créerons une fonction de semence qui utilisera le robot pour semer la base de connaissances. Dans cette partie du code, nous allons initialiser la rampe et récupérer une URL donnée, puis diviser son contenu en morceaux, et enfin intégrer et indexer les morceaux en pignon.
async function seed ( url : string , limit : number , indexName : string , options : SeedOptions ) {
try {
// Initialize the Pinecone client
const pinecone = new Pinecone ( ) ;
// Destructure the options object
const { splittingMethod , chunkSize , chunkOverlap } = options ;
// Create a new Crawler with depth 1 and maximum pages as limit
const crawler = new Crawler ( 1 , limit || 100 ) ;
// Crawl the given URL and get the pages
const pages = await crawler . crawl ( url ) as Page [ ] ;
// Choose the appropriate document splitter based on the splitting method
const splitter : DocumentSplitter = splittingMethod === 'recursive' ?
new RecursiveCharacterTextSplitter ( { chunkSize , chunkOverlap } ) : new MarkdownTextSplitter ( { } ) ;
// Prepare documents by splitting the pages
const documents = await Promise . all ( pages . map ( page => prepareDocument ( page , splitter ) ) ) ;
// Create Pinecone index if it does not exist
const indexList = await pinecone . listIndexes ( ) ;
const indexExists = indexList . some ( index => index . name === indexName )
if ( ! indexExists ) {
await pinecone . createIndex ( {
name : indexName ,
dimension : 1536 ,
waitUntilReady : true ,
} ) ;
}
const index = pinecone . Index ( indexName )
// Get the vector embeddings for the documents
const vectors = await Promise . all ( documents . flat ( ) . map ( embedDocument ) ) ;
// Upsert vectors into the Pinecone index
await chunkedUpsert ( index ! , vectors , '' , 10 ) ;
// Return the first document
return documents [ 0 ] ;
} catch ( error ) {
console . error ( "Error seeding:" , error ) ;
throw error ;
}
}Pour sélectionner le contenu, nous utiliserons l'une des méthodes suivantes:
RecursiveCharacterTextSplitter - Ce séparateur divise le texte en morceaux d'une taille donnée, puis divise récursivement les morceaux en morceaux plus petits jusqu'à ce que la taille du morceau soit atteinte. Cette méthode est utile pour les documents longs.MarkdownTextSplitter - Ce séparateur divise le texte en morceaux en fonction des en-têtes de démarque. Cette méthode est utile pour les documents déjà structurés à l'aide de Markdown. L'avantage de cette méthode est qu'il divisera le document en morceaux en fonction des en-têtes, qui seront utiles pour que notre chatbot comprenne la structure du document. Nous pouvons supposer que chaque unité de texte sous un en-tête est une unité d'information cohérente en interne, et lorsque l'utilisateur pose une question, le contexte récupéré sera également cohérent en interne.crawl Le point de terminaison du point de terminaison crawl est assez simple. Il appelle simplement la fonction seed et renvoie le résultat.
import seed from "./seed" ;
import { NextResponse } from "next/server" ;
export const runtime = "edge" ;
export async function POST ( req : Request ) {
const { url , options } = await req . json ( ) ;
try {
const documents = await seed ( url , 1 , process . env . PINECONE_INDEX ! , options ) ;
return NextResponse . json ( { success : true , documents } ) ;
} catch ( error ) {
return NextResponse . json ( { success : false , error : "Failed crawling" } ) ;
}
}Maintenant, notre backend est capable de ramper une URL donnée, d'intégrer le contenu et d'indexer les incorporations en pinone. Le point de terminaison renverra tous les segments de la page Web récupérée que nous rampons, nous pourrons donc les afficher. Ensuite, nous allons écrire un ensemble de fonctions qui créeront le contexte de ces intérêts.
Pour récupérer les documents les plus pertinents de l'index, nous utiliserons la fonction query dans le SDK Pinecone. Cette fonction prend un vecteur et renvoie les vecteurs les plus similaires de l'index. Nous utiliserons cette fonction pour récupérer les documents les plus pertinents de l'index, compte tenu de certains intégres.
const getMatchesFromEmbeddings = async ( embeddings : number [ ] , topK : number , namespace : string ) : Promise < ScoredPineconeRecord < Metadata > [ ] > => {
// Obtain a client for Pinecone
const pinecone = new Pinecone ( ) ;
const indexName : string = process . env . PINECONE_INDEX || '' ;
if ( indexName === '' ) {
throw new Error ( 'PINECONE_INDEX environment variable not set' )
}
// Retrieve the list of indexes to check if expected index exists
const indexes = await pinecone . listIndexes ( )
if ( indexes . filter ( i => i . name === indexName ) . length !== 1 ) {
throw new Error ( `Index ${ indexName } does not exist` )
}
// Get the Pinecone index
const index = pinecone ! . Index < Metadata > ( indexName ) ;
// Get the namespace
const pineconeNamespace = index . namespace ( namespace ?? '' )
try {
// Query the index with the defined request
const queryResult = await pineconeNamespace . query ( {
vector : embeddings ,
topK ,
includeMetadata : true ,
} )
return queryResult . matches || [ ]
} catch ( e ) {
// Log the error and throw it
console . log ( "Error querying embeddings: " , e )
throw new Error ( `Error querying embeddings: ${ e } ` )
}
}La fonction prend des incorporations, un paramètre TOPK et un espace de noms, et renvoie les correspondances TopK de l'index de pignon. Il obtient d'abord un client Pinecone, vérifie si l'index souhaité existe dans la liste des index et lance une erreur, sinon. Ensuite, il obtient l'indice de poireau spécifique. La fonction interroge ensuite l'indice de pignon avec la demande définie et renvoie les correspondances.
getContext Nous allons envelopper les choses dans la fonction getContext . Cette fonction prendra un message et renverra le contexte - soit sous forme de chaîne, soit en tant que ensemble de ScoredVector .
export const getContext = async (
message : string ,
namespace : string ,
maxTokens = 3000 ,
minScore = 0.7 ,
getOnlyText = true
) : Promise < string | ScoredVector [ ] > => {
// Get the embeddings of the input message
const embedding = await getEmbeddings ( message ) ;
// Retrieve the matches for the embeddings from the specified namespace
const matches = await getMatchesFromEmbeddings ( embedding , 3 , namespace ) ;
// Filter out the matches that have a score lower than the minimum score
const qualifyingDocs = matches . filter ( ( m ) => m . score && m . score > minScore ) ;
// If the `getOnlyText` flag is false, we'll return the matches
if ( ! getOnlyText ) {
return qualifyingDocs ;
}
let docs = matches
? qualifyingDocs . map ( ( match ) => ( match . metadata as Metadata ) . chunk )
: [ ] ;
// Join all the chunks of text together, truncate to the maximum number of tokens, and return the result
return docs . join ( "n" ) . substring ( 0 , maxTokens ) ;
} ; De retour dans chat/route.ts , nous ajouterons l'appel pour getContext :
const { messages } = await req . json ( ) ;
// Get the last message
const lastMessage = messages [ messages . length - 1 ] ;
// Get the context from the last message
const context = await getContext ( lastMessage . content , "" ) ; Enfin, nous mettons à jour l'invite pour inclure le contexte que nous avons récupéré à partir de la fonction getContext .
const prompt = [
{
role : "system" ,
content : `AI assistant is a brand new, powerful, human-like artificial intelligence.
The traits of AI include expert knowledge, helpfulness, cleverness, and articulateness.
AI is a well-behaved and well-mannered individual.
AI is always friendly, kind, and inspiring, and he is eager to provide vivid and thoughtful responses to the user.
AI has the sum of all knowledge in their brain, and is able to accurately answer nearly any question about any topic in conversation.
AI assistant is a big fan of Pinecone and Vercel.
START CONTEXT BLOCK
${ context }
END OF CONTEXT BLOCK
AI assistant will take into account any CONTEXT BLOCK that is provided in a conversation.
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".
AI assistant will not apologize for previous responses, but instead will indicated new information was gained.
AI assistant will not invent anything that is not drawn directly from the context.
` ,
} ,
] ; Dans cette invite, nous avons ajouté un START CONTEXT BLOCK et END OF CONTEXT BLOCK pour indiquer où le contexte doit être inséré. Nous avons également ajouté une ligne pour indiquer que l'assistant AI prendra en compte tout bloc de contexte fourni dans une conversation.
Ensuite, nous devons ajouter le panneau de contexte à l'interface utilisateur de chat. Nous allons ajouter un nouveau composant appelé Context (code complet).
Nous voulons permettre à l'interface indiquer quelles parties du contenu récupéré ont été utilisées pour générer la réponse. Pour ce faire, nous ajouterons un autre point de terminaison qui appellera le même getContext .
export async function POST ( req : Request ) {
try {
const { messages } = await req . json ( ) ;
const lastMessage =
messages . length > 1 ? messages [ messages . length - 1 ] : messages [ 0 ] ;
const context = ( await getContext (
lastMessage . content ,
"" ,
10000 ,
0.7 ,
false
) ) as ScoredPineconeRecord [ ] ;
return NextResponse . json ( { context } ) ;
} catch ( e ) {
console . log ( e ) ;
return NextResponse . error ( ) ;
}
}Chaque fois que l'utilisateur rampe une URL, le panneau de contexte affichera tous les segments de la page Web récupérée. Chaque fois que le backend termine le renvoi d'un message, le frontal déclenchera un effet qui récupérera ce contexte:
useEffect ( ( ) => {
const getContext = async ( ) => {
const response = await fetch ( "/api/context" , {
method : "POST" ,
body : JSON . stringify ( {
messages ,
} ) ,
} ) ;
const { context } = await response . json ( ) ;
setContext ( context . map ( ( c : any ) => c . id ) ) ;
} ;
if ( gotMessages && messages . length >= prevMessagesLengthRef . current ) {
getContext ( ) ;
}
prevMessagesLengthRef . current = messages . length ;
} , [ messages , gotMessages ] ) ; Le Pinecone-Vercel-Starter utilise le dramaturge pour les tests de fin à fin.
Pour exécuter tous les tests:
npm run test:e2e
Par défaut, lors de l'exécution locale, si des erreurs sont rencontrées, le dramaturge ouvrira un rapport HTML montrant quels tests ont échoué et pour quels pilotes de navigateur.
Pour afficher le dernier rapport de test localement, exécutez:
npm run test:show