在此示例中,我们将构建一个完整的应用程序,该应用程序使用Pinecone提供的检索增强生成(RAG),以在聊天机器人中提供准确且上下文相关的响应。
RAG是一种强大的工具,可以结合基于检索的模型和生成模型的好处。与可能在维护最新信息或访问特定领域知识的传统聊天机器人不同的是,基于抹布的聊天机器人使用从爬行的URL创建的知识库来提供上下文相关的响应。
将Vercel的AI SDK纳入我们的应用程序将使我们轻松地设置聊天机器人工作流程并更有效地利用流媒体,尤其是在边缘环境中,增强了我们聊天机器人的响应性和性能。
在本教程结束时,您将拥有一个上下文感知的聊天机器人,该聊天机器人在不幻觉的情况下提供了准确的响应,从而确保了更有效且引人入胜的用户体验。让我们开始构建此功能强大的工具(完整代码列表)。
Next.js是一个功能强大的JavaScript框架,使我们能够使用React构建服务器端渲染和静态Web应用程序。由于其易于设置,出色的性能和内置功能,例如路由和API路由,这是我们项目的绝佳选择。
要创建一个新的next.js应用,请运行以下命令:
npx create-next-app chatbot接下来,我们将添加ai软件包:
npm install ai如果您想与教程一起构建,则可以使用依赖项的完整列表。
在此步骤中,我们将使用Vercel SDK在Next.js应用程序中建立聊天机器人的后端和前端。在此步骤结束时,我们的基本聊天机器人将启动并运行,为我们准备在以下阶段添加上下文感知功能。让我们开始。
现在,让我们专注于聊天机器人的前端组件。我们将构建机器人的面向用户的元素,创建用户与应用程序交互的接口。这将涉及在我们的下一个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挂钩将管理Chat组件中显示的消息的状态。它将:
接下来,我们将设置聊天机器人API端点。这是服务器端组件,它将处理我们聊天机器人的请求和响应。我们将创建一个名为api/chat/route.ts的新文件,并添加以下依赖项:
import { Configuration , OpenAIApi } from "openai-edge" ;
import { Message , OpenAIStream , StreamingTextResponse } from "ai" ;第一个依赖性是openai-edge软件包,它使在边缘环境中与OpenAI的API进行交互变得更加容易。第二个依赖关系是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中。
为了简洁起见,您可以在此处找到爬网的完整代码。这是相关部分:
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功能为了将事物结合在一起,我们将创建一个种子功能,该功能将使用爬虫来播种知识库。在代码的这一部分中,我们将初始化爬网并获取给定的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标头拆分为块。此方法对于已经使用Markdown构建的文档很有用。该方法的好处是,它将基于标题将文档分为块,这对于我们的聊天机器人了解文档的结构非常有用。我们可以假设标题下的每个文本单元是内部连贯的信息单元,并且当用户提出问题时,检索到的上下文也将是内部连贯的。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" } ) ;
}
}现在,我们的后端能够爬网,嵌入内容并将嵌入在生皮内酮中。端点将返回我们抓取的检索网页中的所有段,因此我们可以显示它们。接下来,我们将编写一组功能,这些功能将在这些嵌入中构建上下文。
为了从索引中检索最相关的文档,我们将在Pinecone SDK中使用query功能。此函数采用向量,并从索引返回最相似的向量。给定一些嵌入,我们将使用此功能从索引中检索最相关的文档。
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参数和名称空间,并返回Pinecone索引的TOPK匹配。它首先获得了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 ) ;
} ; getContext chat/route.ts 。
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以指示应插入上下文的位置。我们还添加了一条行,以指出AI助手将考虑对话中提供的任何上下文块。
接下来,我们需要将上下文面板添加到CHAT UI中。我们将添加一个称为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 ( ) ;
}
}每当用户爬网时,上下文面板都会显示检索到的网页的所有段。每当后端完成回发送消息时,前端都会触发将检索此上下文的效果:
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使用剧作家进行最终测试。
运行所有测试:
npm run test:e2e
默认情况下,在本地运行时,如果遇到错误,剧作家将打开HTML报告,显示哪些测试失败以及哪些浏览器驱动程序。
要在本地显示最新的测试报告,请运行:
npm run test:show