В этом примере мы построим приложение с полным стеком, в котором используется поиск, дополненное увеличенным поколением (RAG), приводимым в действие PineCone для предоставления точных и контекстуально релевантных ответов в чат-боте.
RAG-это мощный инструмент, который сочетает в себе преимущества моделей на основе поиска и генеративных моделей. В отличие от традиционных чат-ботов, которые могут бороться с поддержанием актуальной информации или доступа к знаниям, специфичным для домена, чат-бот на основе RAG использует базу знаний, созданную из ползанных URL-адресов для обеспечения контекстуально релевантных ответов.
Внедрение AI SDK Vercel в наше приложение позволит нам легко настроить рабочий процесс чат -бота и более эффективно использовать потоковую передачу, особенно в средах Edge, повышая отзывчивость и производительность нашего чат -бота.
К концу этого урока у вас будет чат-бот с контекстом, который обеспечивает точные ответы без галлюцинации, обеспечивая более эффективную и привлекательную пользовательскую работу. Давайте начнем создавать этот мощный инструмент (полный список кодов).
Next.js-это мощная структура JavaScript, которая позволяет нам создавать отображаемые и статические веб-приложения на стороне сервера с использованием React. Это отличный выбор для нашего проекта из-за его простоты настройки, отличной производительности и встроенных функций, таких как маршрутизации и маршруты API.
Чтобы создать новое приложение Next.js, запустите следующую команду:
npx create-next-app chatbot Далее мы добавим пакет ai :
npm install aiВы можете использовать полный список зависимостей, если хотите построить вместе с учебником.
На этом этапе мы собираемся использовать Vercel SDK, чтобы установить бэкэнд и фронт нашего чат -бота в следующем приложении. К концу этого шага наш базовый чат-бот будет запущен и готов к тому, чтобы мы могли добавить возможности для контекста на следующих этапах. Давайте начнем.
Теперь давайте сосредоточимся на компоненте Frontend нашего чат -бота. Мы собираемся создать пользовательские элементы нашего бота, создав интерфейс, через который пользователи будут взаимодействовать с нашим приложением. Это будет включать в себя создание дизайна и функциональности интерфейса чата в нашем приложении Next.js.
Во -первых, мы создадим компонент 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 ; Этот компонент отобразит список сообщений и входную форму для пользователя для отправки сообщений. Компонент Messages для визуализации сообщений чата:
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 >
) ;
} Наш Page компонент будет управлять состоянием для сообщений, отображаемых в компоненте 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 ; Полезный useChat Hook будет управлять состоянием для сообщений, отображаемых в компоненте Chat . Это будет:
Далее мы настроим конечную точку API Chatbot. Это компонент на стороне сервера, который будет обрабатывать запросы и ответы для нашего чата. Мы создадим новый файл с именем api/chat/route.ts и добавим следующие зависимости:
import { Configuration , OpenAIApi } from "openai-edge" ;
import { Message , OpenAIStream , StreamingTextResponse } from "ai" ; Первая зависимость-это пакет openai-edge , который облегчает взаимодействие с API Openai в краевой среде. Вторая зависимость - это пакет ai , который мы используем для определения типов Message и OpenAIStream , которые мы используем для передачи ответа от Openai обратно на клиент.
Затем инициализируйте клиент 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 ) ; Чтобы определить эту конечную точку как функцию края, мы определим и экспортируем переменную runtime
export const runtime = "edge" ;Далее мы определим обработчик конечной точки:
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 ;
}
} Здесь мы деконструируем сообщения из поста и создаем нашу первоначальную подсказку. Мы используем подсказку и сообщения в качестве ввода в метод createChatCompletion . Затем мы преобразуем ответ в поток и возвращаем его клиенту. Обратите внимание, что в этом примере мы отправляем только сообщения пользователя в OpenaI (в отличие от включения сообщений бота).
Когда мы погружаемся в создание нашего чат -бота, важно понять роль контекста. Добавление контекста к ответам нашего чат -бота является ключевым для создания более естественного, разговорного пользовательского опыта. Без контекста ответы чат -бота могут чувствовать себя разрозненными или неактуальными. Понимая контекст запроса пользователя, наш чат -бот сможет предоставить более точные, актуальные и привлекательные ответы. Теперь давайте начнем строить с этой целью.
Во -первых, мы сначала сосредоточимся на посевах базы знаний. Мы создадим гусеница и сценарий семян и настроим конечную точку. Это позволит нам собирать и организовать информацию, которую наш чат -бот будет использовать для предоставления контекстуально актуальных ответов.
После того, как мы заполнили нашу базу знаний, мы получим матчи из наших встраиваний. Это позволит нашему чат -боту найти соответствующую информацию на основе запросов пользователей.
Далее мы завершим нашу логику в функцию GetContext и обновим подсказку нашего чата. Это упростит наш код и улучшит пользовательский опыт, обеспечивая актуальные и привлекательные подсказки чат -бота.
Наконец, мы добавим контекстную панель и связанную конечную точку контекста. Они предоставят пользовательский интерфейс для чат -бота и способ получить необходимый контекст для каждого пользовательского запроса.
Этот шаг - все о том, чтобы кормить нашего чат -бота, необходимую ему информацию, и настройку необходимой инфраструктуры для его эффективного извлечения и использования этой информации. Давайте начнем.
Теперь мы перейдем к посевам базы знаний, основополагающему источнику данных, который сообщит о ответах нашего чат -бота. Этот шаг включает в себя сбор и организацию информации, которую необходим на нашему чат -боту для эффективной работы. В этом руководстве мы собираемся использовать данные, полученные с различных веб -сайтов, о которых мы сможем задавать вопросы. Чтобы сделать это, мы создадим гусеница, который соскребует данные с веб -сайтов, внедрит их и сохранит в Pinecone.
Ради краткости вы сможете найти полный код для Crawler здесь. Вот соответствующие части:
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
) ;
}
} Класс Crawler - это веб -гусеница, который посещает URL -адреса, начиная с данного пункта и собирает от них информацию. Он работает на определенной глубине и максимальном количестве страниц, как определено в конструкторе. Метод сканирования - это основная функция, которая запускает процесс ползания.
Помощные методы извлечения, parsehtml и извлечения соответственно обрабатывают содержимое HTML на странице, анализируя HTML для извлечения текста и извлечения всех URL -адресов со страницы, который будет представлен в очереди для следующего ползания. Класс также ведет запись посещаемых URL -адресов, чтобы избежать дублирования.
seedЧтобы связать вещи вместе, мы создадим семянную функцию, которая будет использовать Hrawler, чтобы заселить базу знаний. В этой части кода мы инициализируем сканирование и получим заданный URL, а затем разделим его содержание на куски и, наконец, внедрим и индексируем куски в 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 ;
}
}Чтобы выбрать контент, мы будем использовать один из следующих методов:
RecursiveCharacterTextSplitter - этот сплиттер разбивает текст на куски заданного размера, а затем рекурсивно расщепляет кусочки на более мелкие кусочки, пока не будет достигнут размер куски. Этот метод полезен для длинных документов.MarkdownTextSplitter - Этот разветвитель разбивает текст на куски на основе заголовков Markdown. Этот метод полезен для документов, которые уже структурированы с использованием Marckdown. Преимущество этого метода заключается в том, что он разделит документ на куски на основе заголовков, которые будут полезны для нашего чата для понимания структуры документа. Мы можем предположить, что каждая единица текста под заголовком является внутренне когерентной единицей информации, и когда пользователь задает вопрос, полученный контекст также будет внутренне когерентным.crawl Конечная точка для конечной точки crawl довольно проста. Он просто вызывает функцию seed и возвращает результат.
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" } ) ;
}
}Теперь наш бэкэнд способен ползти данного URL, внедряет контент и индексировать встраиваемые вкладки в Pinecone. Конечная точка вернет все сегменты на полученной веб -странице, поэтому мы сможем их отобразить. Далее мы напишем набор функций, которые построят контекст из этих встраиваний.
Чтобы получить наиболее соответствующие документы из индекса, мы будем использовать функцию query в SDK Pinecone. Эта функция берет вектор и возвращает наиболее похожие векторы из индекса. Мы будем использовать эту функцию, чтобы получить наиболее соответствующие документы из индекса, учитывая некоторые вставки.
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 } ` )
}
}Функция принимает встроенные встроения, параметр Topk и пространство имен, и возвращает совпадения Topk из индекса Pinecone. Сначала он получает клиент Pinecone, проверяет, существует ли желаемый индекс в списке индексов, и бросает ошибку, если нет. Затем он получает конкретный индекс Pinecone. Затем функция запрашивает индекс Pinecone с определенным запросом и возвращает совпадения.
getContext Мы завершим вещи в функцию getContext . Эта функция приведет к message и вернет контекст - либо в строковой форме, либо в качестве набора 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 ) ;
} ; Вернуться в chat/route.ts , мы добавим звонок в 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 , "" ) ; Наконец, мы обновим подсказку, чтобы включить контекст, который мы извлекали из функции 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.
` ,
} ,
] ; В этой подсказке мы добавили START CONTEXT BLOCK и END OF CONTEXT BLOCK чтобы указать, где должен быть вставлен контекст. Мы также добавили строку, чтобы указать, что помощник искусственного интеллекта будет учитывать любой контекстный блок, который предоставлен в разговоре.
Затем нам нужно добавить контекстную панель в пользовательский интерфейс чата. Мы добавим новый компонент с именем Context (полный код).
Мы хотим, чтобы интерфейс указал, какие части полученного контента использовались для генерации ответа. Для этого мы добавим еще одну конечную точку, которая будет называть ту же 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 ( ) ;
}
}Всякий раз, когда пользователь ползет по URL, контекстная панель будет отображать все сегменты полученной веб -страницы. Всякий раз, когда бэкэнд завершает отправку сообщения обратно, передняя часть вызовет эффект, который извлечет этот контекст:
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 ] ) ; Pinecone-Vercel-Starter использует Playwright для конечного тестирования.
Чтобы запустить все тесты:
npm run test:e2e
По умолчанию при запуске локально, если возникают ошибки, Playwright откроет отчет HTML, показывающий, какие тесты не удались и для каких драумзеров.
Чтобы отобразить последний отчет о тестировании локально, запустите:
npm run test:show