Neste exemplo, criaremos um aplicativo de pilha completa que usa a geração aumentada de recuperação (RAG) alimentada pela Pinecone para fornecer respostas precisas e contextualmente relevantes em um chatbot.
O RAG é uma ferramenta poderosa que combina os benefícios dos modelos e modelos generativos baseados em recuperação. Diferentemente dos chatbots tradicionais que podem lutar para manter informações atualizadas ou acessar conhecimentos específicos de domínio, um chatbot baseado em RAG usa uma base de conhecimento criada a partir de URLs rastejados para fornecer respostas contextualmente relevantes.
A incorporação do AI SDK da Vercel em nosso aplicativo nos permitirá configurar facilmente o fluxo de trabalho do Chatbot e utilizará o streaming com mais eficiência, principalmente em ambientes de borda, aprimorando a capacidade de resposta e o desempenho do nosso chatbot.
No final deste tutorial, você terá um chatbot com reconhecimento de contexto que fornece respostas precisas sem alucinação, garantindo uma experiência de usuário mais eficaz e envolvente. Vamos começar a criar essa ferramenta poderosa (listagem completa de código).
Next.js é uma poderosa estrutura JavaScript que nos permite criar aplicativos da Web renderizados e estáticos do lado do servidor usando o React. É uma ótima opção para o nosso projeto devido à facilidade de configuração, excelente desempenho e recursos internos, como roteamento e rotas de API.
Para criar um novo aplicativo Next.js, execute o seguinte comando:
npx create-next-app chatbot Em seguida, adicionaremos o pacote ai :
npm install aiVocê pode usar a lista completa de dependências se quiser construir junto com o tutorial.
Nesta etapa, usaremos o vercel sdk para estabelecer o back -end e o front -end do nosso chatbot no próximo aplicativo. No final desta etapa, nosso chatbot básico estará em funcionamento, pronto para adicionar recursos com reconhecimento de contexto nos estágios a seguir. Vamos começar.
Agora, vamos nos concentrar no componente de front -end do nosso chatbot. Vamos criar os elementos voltados para o usuário do nosso bot, criando a interface através da qual os usuários interagem com o nosso aplicativo. Isso envolverá a elaboração do design e funcionalidade da interface de bate -papo em nosso aplicativo Next.js.
Primeiro, criaremos o componente Chat , que renderizará a interface de bate -papo.
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 exibirá a lista de mensagens e o formulário de entrada para o usuário enviar mensagens. O componente Messages para renderizar as mensagens de bate -papo:
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 >
) ;
} Nosso componente Page principal gerenciará o estado para as mensagens exibidas no 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 ; O useChat Hook útil gerenciará o estado para as mensagens exibidas no componente Chat . Ele vai:
Em seguida, configuraremos o ponto final da API do Chatbot. Este é o componente do lado do servidor que lidará com solicitações e respostas para o nosso chatbot. Vamos criar um novo arquivo chamado api/chat/route.ts e adicionar as seguintes dependências:
import { Configuration , OpenAIApi } from "openai-edge" ;
import { Message , OpenAIStream , StreamingTextResponse } from "ai" ; A primeira dependência é o pacote openai-edge que facilita a interação com as APIs do OpenAI em um ambiente de borda. A segunda dependência é o pacote ai que usaremos para definir os tipos de Message e OpenAIStream , que usaremos para transmitir a resposta do OpenAI de volta ao cliente.
Em seguida, inicialize o 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 esse endpoint como uma função de borda, definiremos e exportaremos a variável runtime
export const runtime = "edge" ;Em seguida, definiremos o manipulador de terminais:
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 ;
}
} Aqui, desconstruímos as mensagens da postagem e criamos nosso prompt inicial. Usamos o prompt e as mensagens como entrada para o método createChatCompletion . Em seguida, convertemos a resposta em um fluxo e a devolvemos ao cliente. Observe que, neste exemplo, enviamos apenas as mensagens do usuário para o OpenAI (em vez de incluir as mensagens do bot também).
À medida que mergulhamos na construção de nosso chatbot, é importante entender o papel do contexto. Adicionar contexto às respostas do nosso chatbot é essencial para criar uma experiência mais natural e conversacional do usuário. Sem contexto, as respostas de um chatbot podem parecer desarticuladas ou irrelevantes. Ao entender o contexto da consulta de um usuário, nosso chatbot poderá fornecer respostas mais precisas, relevantes e envolventes. Agora, vamos começar a construir com esse objetivo em mente.
Primeiro, primeiro nos concentraremos em semear a base de conhecimento. Criaremos um rastreador e um script de semente e configuraremos um terminal de rastreamento. Isso nos permitirá reunir e organizar as informações que nosso chatbot usará para fornecer respostas contextualmente relevantes.
Depois de preenchermos nossa base de conhecimento, recuperaremos as partidas de nossas incorporações. Isso permitirá que nosso chatbot encontre informações relevantes com base nas consultas do usuário.
Em seguida, envolveremos nossa lógica na função GetContext e atualizaremos o prompt de nosso chatbot. Isso otimizará nosso código e melhorará a experiência do usuário, garantindo que os avisos do chatbot sejam relevantes e envolventes.
Por fim, adicionaremos um painel de contexto e um terminal de contexto associado. Isso fornecerá uma interface do usuário para o chatbot e uma maneira de recuperar o contexto necessário para cada consulta do usuário.
Esta etapa é sobre alimentar nosso chatbot as informações de que precisa e configurar a infraestrutura necessária para que ele recupere e use essas informações de maneira eficaz. Vamos começar.
Agora, passaremos a semear a base de conhecimento, a fonte de dados fundamental que informará as respostas de nosso chatbot. Esta etapa envolve a coleta e a organização das informações que nosso chatbot precisa para operar de maneira eficaz. Neste guia, usaremos os dados recuperados de vários sites sobre os quais mais tarde poderemos fazer perguntas. Para fazer isso, criaremos um rastreador que eliminaremos os dados dos sites, incorporaremos e armazenarão no Pinecone.
Por uma questão de brevidade, você poderá encontrar o código completo para o rastreador aqui. Aqui estão as 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
) ;
}
} A classe Crawler é um rastreador da web que visita URLs, começando de um determinado ponto, e coleta informações deles. Opera em uma certa profundidade e um número máximo de páginas, conforme definido no construtor. O método de rastreamento é a função principal que inicia o processo de rastreamento.
Os métodos auxiliares buscam a página, o parsehtml e os extracturls, respectivamente, lidam com o conteúdo de HTML de uma página, analisando o HTML para extrair texto e extraindo todos os URLs de uma página para ser filmada para o próximo rastreamento. A classe também mantém um registro de URLs visitados para evitar a duplicação.
seedPara unir as coisas, criaremos uma função de semente que usará o rastreador para semear a base de conhecimento. Nesta parte do código, inicializaremos o rastreamento e buscaremos um determinado URL, depois dividiremos seu conteúdo em pedaços e, finalmente, incorporar e indexar os pedaços no 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 reduzir o conteúdo, usaremos um dos seguintes métodos:
RecursiveCharacterTextSplitter - Este divisor divide o texto em pedaços de um determinado tamanho e, em seguida, separa recursivamente os pedaços em pedaços menores até que o tamanho do pedaço seja alcançado. Este método é útil para documentos longos.MarkdownTextSplitter - Este divisor divide o texto em pedaços com base nos cabeçalhos de remarcar. Este método é útil para documentos que já estão estruturados usando o Markdown. O benefício desse método é que ele dividirá o documento em pedaços com base nos cabeçalhos, que serão úteis para o nosso chatbot entender a estrutura do documento. Podemos assumir que cada unidade de texto sob um cabeçalho é uma unidade de informação coerente internamente e, quando o usuário faz uma pergunta, o contexto recuperado também será coerente internamente.crawl O terminal para o ponto de extremidade crawl é bem direto. Ele simplesmente chama a função seed e retorna o 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" } ) ;
}
}Agora, nosso back -end é capaz de rastrear um determinado URL, incorporar o conteúdo e indexar as incorporações em Pinecone. O terminal retornará todos os segmentos na página da Web recuperada que rastejamos, para que possamos exibi -los. Em seguida, escreveremos um conjunto de funções que criarão o contexto dessas incorporações.
Para recuperar os documentos mais relevantes do índice, usaremos a função query no Pinecone SDK. Esta função pega um vetor e retorna os vetores mais semelhantes do índice. Usaremos esta função para recuperar os documentos mais relevantes do índice, considerando algumas incorporações.
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 } ` )
}
}A função recebe incorporação, um parâmetro Topk e um espaço para nome e retorna as correspondências do Topk do índice Pinecone. Primeiro, ele recebe um cliente Pinecone, verifica se o índice desejado existe na lista de índices e lança um erro, se não. Em seguida, ele obtém o índice específico do Pinecone. A função consulta o índice Pinecone com a solicitação definida e retorna as correspondências.
getContext Vamos encerrar as coisas na função getContext . Esta função receberá uma message e retornará o contexto - no formulário da String ou como um 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 volta chat/route.ts , adicionaremos a chamada ao 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 , "" ) ; Por fim, atualizaremos o prompt para incluir o contexto que recuperamos da função 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.
` ,
} ,
] ; Neste prompt, adicionamos um START CONTEXT BLOCK e END OF CONTEXT BLOCK para indicar onde o contexto deve ser inserido. Também adicionamos uma linha para indicar que o assistente de IA levará em consideração qualquer bloco de contexto fornecido em uma conversa.
Em seguida, precisamos adicionar o painel de contexto à interface do usuário de bate -papo. Adicionaremos um novo componente chamado Context (código completo).
Queremos permitir que a interface indique quais partes do conteúdo recuperado foram usadas para gerar a resposta. Para fazer isso, adicionaremos outro terminal que chamará o mesmo 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 ( ) ;
}
}Sempre que o usuário rasteja um URL, o painel de contexto exibirá todos os segmentos da página da web recuperada. Sempre que o back -end concluir o envio de uma mensagem de volta, o front end acionará um efeito que recuperará esse 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 ] ) ; O Pinecone-Vercel-Starter usa dramaturgo para testes de ponta a ponta.
Para executar todos os testes:
npm run test:e2e
Por padrão, ao executar localmente, se os erros forem encontrados, o dramaturgo abrirá um relatório HTML mostrando quais testes falharem e para quais drivers do navegador.
Para exibir o relatório de teste mais recente localmente, execute:
npm run test:show