この例では、Pineconeを搭載した検索拡張生成(RAG)を使用して、チャットボットで正確でコンテキストに関連する応答を提供するフルスタックアプリケーションを構築します。
RAGは、検索ベースのモデルと生成モデルの利点を組み合わせた強力なツールです。最新の情報の維持やドメイン固有の知識へのアクセスに苦労する可能性のある従来のチャットボットとは異なり、RAGベースのチャットボットは、クロールされたURLから作成された知識ベースを使用して、文脈的に関連する応答を提供します。
VercelのAI SDKをアプリケーションに組み込むことで、チャットボットワークフローを簡単にセットアップし、特にエッジ環境でストリーミングをより効率的に利用し、チャットボットの応答性とパフォーマンスを向上させることができます。
このチュートリアルの終わりまでに、幻覚なしで正確な応答を提供するコンテキスト認識チャットボットがあり、より効果的で魅力的なユーザーエクスペリエンスを確保できます。この強力なツール(完全なコードリスト)の構築を始めましょう。
Next.jsは、Reactを使用してサーバー側のレンダリングされた静的Webアプリケーションを構築できるようにする強力なJavaScriptフレームワークです。セットアップの容易さ、優れたパフォーマンス、ルーティングやAPIルートなどの組み込み機能により、プロジェクトに最適です。
新しいnext.jsアプリを作成するには、次のコマンドを実行します。
npx create-next-app chatbot次に、 aiパッケージを追加します。
npm install aiチュートリアルとともに構築したい場合は、依存関係の完全なリストを使用できます。
このステップでは、Vercel SDKを使用して、next.jsアプリケーション内でチャットボットのバックエンドとフロントエンドを確立します。このステップの終わりまでに、基本的なチャットボットが稼働し、次の段階でコンテキストアウェア機能を追加する準備が整います。始めましょう。
それでは、チャットボットのフロントエンドコンポーネントに焦点を当てましょう。ボットのユーザー向け要素を構築し、ユーザーがアプリケーションと対話するインターフェイスを作成します。これには、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フックは、 Chatコンポーネントに表示されるメッセージの状態を管理します。それは:
次に、チャットボットAPIエンドポイントを設定します。これは、チャットボットのリクエストと応答を処理するサーバー側のコンポーネントです。 api/chat/route.tsという新しいファイルを作成し、次の依存関係を追加します。
import { Configuration , OpenAIApi } from "openai-edge" ;
import { Message , OpenAIStream , StreamingTextResponse } from "ai" ;最初の依存関係は、Edge環境でOpenaiのAPIと簡単に相互作用できるようにするopenai-edgeパッケージです。 2番目の依存関係は、 MessageとOpenAIStreamタイプを定義するために使用するaiパッケージです。これを使用して、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関数に包み、チャットボットのプロンプトを更新します。これにより、Chatbotのプロンプトが関連性が高く魅力的であることを確認することにより、コードを合理化し、ユーザーエクスペリエンスが向上します。
最後に、コンテキストパネルと関連するコンテキストエンドポイントを追加します。これらは、チャットボットのユーザーインターフェイスと、各ユーザークエリに必要なコンテキストを取得する方法を提供します。
このステップは、チャットボットに必要な情報を提供し、必要なインフラストラクチャを設定して、その情報を効果的に取得して使用するために設定することです。始めましょう。
次に、チャットボットの応答を通知する基礎データソースであるナレッジベースのシードに進みます。このステップには、チャットボットが効果的に動作するために必要な情報の収集と整理が含まれます。このガイドでは、さまざまなWebサイトから取得したデータを使用します。後で質問することができます。これを行うには、Webサイトからデータをこすり、埋め込み、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を訪問するWebクローラーです。コンストラクターで定義されているように、特定の深さと最大ページの数内で動作します。クロール法は、クロールプロセスを開始するコア関数です。
ヘルパーメソッドは、それぞれページのHTMLコンテンツの取得を処理し、HTMLを解析してテキストを抽出し、次のクロールのためにキューに巻かれているページからすべてのURLを抽出します。クラスはまた、複製を避けるために訪問したURLの記録を維持しています。
seed関数を作成します物事を結び付けるために、クローラーを使用して知識ベースをシードする種子関数を作成します。コードのこの部分では、クロールを初期化して特定のURLを取得し、そのコンテンツをチャンクに分割し、最後に松ぼっくりのチャンクを埋め込み、インデックスを作成します。
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このスプリッターは、マークダウンヘッダーに基づいてテキストをチャンクに分割します。この方法は、マークダウンを使用してすでに構成されているドキュメントに役立ちます。この方法の利点は、ヘッダーに基づいてドキュメントをチャンクに分割することです。これは、チャットボットがドキュメントの構造を理解するのに役立ちます。ヘッダーの下の各テキストユニットは内部的に一貫性のある情報ユニットであり、ユーザーが質問をすると、取得されたコンテキストも内部的に一貫性があると仮定できます。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をクロールし、コンテンツを埋め込み、松ぼっくりの埋め込みをインデックスすることができます。エンドポイントは、クロールする取得されたWebページのすべてのセグメントを返すため、表示できるようになります。次に、これらの埋め込みからコンテキストを構築する一連の関数を書きます。
インデックスから最も関連性の高いドキュメントを取得するには、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インデックスを取得します。この関数は、定義された要求を使用して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追加しました。また、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 ( ) ;
}
}ユーザーがURLをクロールするたびに、コンテキストパネルは取得されたWebページのすべてのセグメントを表示します。バックエンドがメッセージの送信を完了するたびに、フロントエンドはこのコンテキストを取得する効果をトリガーします。
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