En este ejemplo, construiremos una aplicación de pila completa que utilice la generación de recuperación aumentada (RAG) impulsada por Pinecone para ofrecer respuestas precisas y contextualmente relevantes en un chatbot.
RAG es una herramienta poderosa que combina los beneficios de los modelos basados en la recuperación y los modelos generativos. A diferencia de los chatbots tradicionales que pueden tener dificultades para mantener la información actualizada o el acceso a los conocimientos específicos del dominio, un chatbot basado en el trapo utiliza una base de conocimiento creada a partir de URL pasadas para proporcionar respuestas contextualmente relevantes.
La incorporación de AI SDK de VERCEL en nuestra aplicación nos permitirá configurar fácilmente el flujo de trabajo de chatbot y utilizar la transmisión de manera más eficiente, particularmente en entornos de borde, mejorando la capacidad de respuesta y el rendimiento de nuestro chatbot.
Al final de este tutorial, tendrá un chatbot consciente de contexto que proporciona respuestas precisas sin alucinación, asegurando una experiencia de usuario más efectiva y atractiva. Comencemos en la construcción de esta poderosa herramienta (listado de código completo).
Next.js es un poderoso marco de JavaScript que nos permite crear aplicaciones web renderizadas y estáticas del lado del servidor utilizando React. Es una gran opción para nuestro proyecto debido a su facilidad de configuración, excelente rendimiento y características incorporadas, como enrutamiento y rutas API.
Para crear una nueva aplicación Next.js, ejecute el siguiente comando:
npx create-next-app chatbot A continuación, agregaremos el paquete ai :
npm install aiPuede usar la lista completa de dependencias si desea construir junto con el tutorial.
En este paso, vamos a usar el SDK Vercel para establecer el backend y el frontend de nuestro chatbot dentro de la aplicación Next.js. Al final de este paso, nuestro chatbot básico estará en funcionamiento, listo para que podamos agregar capacidades de contexto en las siguientes etapas. Comencemos.
Ahora, centrémonos en el componente frontend de nuestro chatbot. Vamos a construir los elementos orientados al usuario de nuestro bot, creando la interfaz a través de la cual los usuarios interactuarán con nuestra aplicación. Esto implicará elaborar el diseño y la funcionalidad de la interfaz de chat dentro de nuestra aplicación Next.js.
Primero, crearemos el componente Chat , que representará la interfaz 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 ; Este componente mostrará la lista de mensajes y el formulario de entrada para que el usuario envíe mensajes. El componente Messages para renderizar los mensajes 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 >
) ;
} Nuestro componente Page principal administrará el estado para los mensajes que se muestran en el componente 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 ; El útil gancho useChat administrará el estado para los mensajes que se muestran en el componente Chat . Va a:
A continuación, configuraremos el punto final de la API de chatbot. Este es el componente del lado del servidor que manejará las solicitudes y respuestas para nuestro chatbot. Crearemos un nuevo archivo llamado api/chat/route.ts y agregaremos las siguientes dependencias:
import { Configuration , OpenAIApi } from "openai-edge" ;
import { Message , OpenAIStream , StreamingTextResponse } from "ai" ; La primera dependencia es el paquete openai-edge que hace que sea más fácil interactuar con las API de OpenAI en un entorno de borde. La segunda dependencia es el paquete ai que usaremos para definir el Message y los tipos de OpenAIStream , que usaremos para transmitir la respuesta de OpenAI al cliente.
A continuación, inicialice el cliente 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 ) ; Para definir este punto final como una función de borde, definiremos y exportaremos la variable runtime
export const runtime = "edge" ;A continuación, definiremos el controlador de punto final:
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 ;
}
} Aquí deconstruimos los mensajes de la publicación y creamos nuestro aviso inicial. Usamos el mensaje y los mensajes como la entrada al método createChatCompletion . Luego convertimos la respuesta en una transmisión y la devolvemos al cliente. Tenga en cuenta que en este ejemplo, solo enviamos los mensajes del usuario a OpenAI (en lugar de incluir también los mensajes del bot).
A medida que nos sumergimos para construir nuestro chatbot, es importante comprender el papel del contexto. Agregar contexto a las respuestas de nuestro chatbot es clave para crear una experiencia de usuario más natural y conversacional. Sin contexto, las respuestas de un chatbot pueden sentirse desarticuladas o irrelevantes. Al comprender el contexto de la consulta de un usuario, nuestro chatbot podrá proporcionar respuestas más precisas, relevantes y atractivas. Ahora, comencemos a construir con este objetivo en mente.
Primero, primero nos centraremos en sembrar la base de conocimiento. Crearemos un rastreador y un script de semillas, y configuraremos un punto final de rastreo. Esto nos permitirá reunir y organizar la información que nuestro chatbot utilizará para proporcionar respuestas contextualmente relevantes.
Después de haber poblado nuestra base de conocimiento, recuperaremos los partidos de nuestros incrustaciones. Esto permitirá a nuestro chatbot encontrar información relevante basada en consultas de usuarios.
A continuación, envolveremos nuestra lógica en la función GetContext y actualizaremos el mensaje de nuestro chatbot. Esto optimizará nuestro código y mejorará la experiencia del usuario asegurando que las indicaciones del chatbot sean relevantes y atractivas.
Finalmente, agregaremos un panel de contexto y un punto final de contexto asociado. Estos proporcionarán una interfaz de usuario para el chatbot y una forma de recuperar el contexto necesario para cada consulta de usuario.
Este paso se trata de alimentar a nuestro chatbot la información que necesita y configurar la infraestructura necesaria para que recupere y utilice esa información de manera efectiva. Comencemos.
Ahora pasaremos a sembrar la base de conocimiento, la fuente de datos fundamental que informará las respuestas de nuestro chatbot. Este paso implica recopilar y organizar la información que nuestro chatbot necesita para operar de manera efectiva. En esta guía, vamos a utilizar datos recuperados de varios sitios web sobre los que más tarde podremos hacer preguntas. Para hacer esto, crearemos un rastreador que raspe los datos de los sitios web, los incrustará y los almacenará en Pinecone.
En aras de la brevedad, podrás encontrar el código completo para el rastreador aquí. Aquí están las partes 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 clase Crawler es un rastreador web que visita las URL, comenzando desde un punto dado y recopila información de ellas. Funciona dentro de una cierta profundidad y un número máximo de páginas como se define en el constructor. El método Crawl es la función central que inicia el proceso de rastreo.
La página de FetchEnchpage, Parsehtml y extracturls de Helper, respectivamente, obtienen el contenido HTML de una página, analizando el HTML para extraer texto y extraer todas las URL de una página para que se coloquen en cola para el siguiente rastreo. La clase también mantiene un registro de URL visitadas para evitar la duplicación.
seedPara unir las cosas, crearemos una función de semilla que usará el rastreador para sembrar la base de conocimiento. En esta parte del código, inicializaremos el rastreo y obtendremos una URL dada, luego dividiremos su contenido en trozos y finalmente incrustaremos e indexaremos los fragmentos en Pinecone.
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 ;
}
}Para fragmentar el contenido, usaremos uno de los siguientes métodos:
RecursiveCharacterTextSplitter : este divisor divide el texto en trozos de un tamaño dado, y luego divide recursivamente los trozos en trozos más pequeños hasta que se alcanza el tamaño del trozo. Este método es útil para documentos largos.MarkdownTextSplitter : este divisor divide el texto en trozos basados en los encabezados de Markdown. Este método es útil para documentos que ya están estructurados usando Markdown. El beneficio de este método es que dividirá el documento en fragmentos basados en los encabezados, lo que será útil para que nuestro chatbot comprenda la estructura del documento. Podemos suponer que cada unidad de texto debajo de un encabezado es una unidad de información interna coherente, y cuando el usuario hace una pregunta, el contexto recuperado también será internamente coherente.crawl El punto final para el punto final crawl es bastante sencillo. Simplemente llama a la función seed y devuelve el resultado.
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" } ) ;
}
}Ahora nuestro backend puede rastrear una URL dada, incrustar el contenido e indexar los incrustaciones en Pinecone. El punto final devolverá todos los segmentos en la página web recuperada que nos arrastramos, por lo que podremos mostrarlos. A continuación, escribiremos un conjunto de funciones que desarrollarán el contexto a partir de estos incrustaciones.
Para recuperar los documentos más relevantes del índice, utilizaremos la función query en el SDK de Pinecone. Esta función toma un vector y devuelve los vectores más similares del índice. Utilizaremos esta función para recuperar los documentos más relevantes del índice, dados algunos incrustaciones.
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 función toma incrustaciones, un parámetro TOPK y un espacio de nombres, y devuelve las coincidencias TOPK desde el índice Pinecone. Primero obtiene un cliente Pinecone, verifica si el índice deseado existe en la lista de índices y lanza un error si no. Luego obtiene el índice específico de pinecone. La función luego consulta el índice Pinecone con la solicitud definida y devuelve las coincidencias.
getContext Envolveremos las cosas en la función getContext . Esta función tomará un message y devolverá el contexto, ya sea en forma de cadena, o como un conjunto 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 vuelta en chat/route.ts , agregaremos la llamada a 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 , "" ) ; Finalmente, actualizaremos el mensaje para incluir el contexto que recuperamos de la función 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.
` ,
} ,
] ; En este aviso, agregamos un START CONTEXT BLOCK y END OF CONTEXT BLOCK para indicar dónde se debe insertar el contexto. También agregamos una línea para indicar que el asistente de IA tendrá en cuenta cualquier bloque de contexto que se proporcione en una conversación.
A continuación, necesitamos agregar el panel de contexto a la interfaz de usuario de chat. Agregaremos un nuevo componente llamado Context (código completo).
Queremos permitir que la interfaz indique qué partes del contenido recuperado se han utilizado para generar la respuesta. Para hacer esto, agregaremos otro punto final que llamará al mismo 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 ( ) ;
}
}Siempre que el usuario rastree una URL, el panel de contexto mostrará todos los segmentos de la página web recuperada. Cada vez que el backend completa el envío de un mensaje, la parte delantera activará un efecto que recuperará este contexto:
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 ] ) ; El Pinecone-Vercel Starter utiliza el dramaturgo para las pruebas de extremo a extremo.
Para ejecutar todas las pruebas:
npm run test:e2e
Por defecto, cuando se ejecuta localmente, si se encuentran errores, el dramaturgo abrirá un informe HTML que muestra qué pruebas fallan y para qué controladores del navegador.
Para mostrar el último informe de prueba localmente, ejecute:
npm run test:show