在这个示例中,我们将构建一个全栈应用程序,该应用程序使用由Pinecone提供支持的检索增强生成(RAG),以在聊天机器人中提供准确且具有上下文相关性的响应。
RAG 是一个强大的工具,结合了检索型模型和生成型模型的优点。与传统的聊天机器人不同,传统的聊天机器人可能难以保持最新信息或访问特定领域的知识,基于 RAG 的聊天机器人使用从爬取的 URL 创建的知识库来提供相关背景的回复。
将 Vercel 的 AI SDK 集成到我们的应用程序中,将使我们能够轻松设置聊天机器人工作流程,并更有效地利用流式处理,特别是在边缘环境中,提高我们的聊天机器人的响应速度和性能。
通过本教程的最后,您将拥有一个具有上下文感知能力的聊天机器人,提供准确的回复而不会产生幻觉,确保用户体验更加有效和引人入胜。让我们开始构建这个强大的工具(完整代码清单)。
第一步:设置您的 Next.js 应用程序
Next.js 是一个强大的 JavaScript 框架,可以让我们使用 React 构建服务器端渲染和静态网页应用程序。由于其易于设置、出色的性能以及内置的路由和 API 路由等功能,它是我们项目的绝佳选择。
运行以下命令来创建一个新的 Next.js 应用程序:
npx
使用npx create-next-app chatbot创建chatbot应用
接下来,我们将添加 ai
软件包:
如果您想跟着教程一起构建,可以使用完整列表中的依赖项。
第二步:创建聊天机器人
在这一步中,我们将使用Vercel SDK 在Next.js应用程序中建立聊天机器人的后端和前端。到本步骤结束时,我们的基本聊天机器人将已经启动运行,准备在接下来的阶段中添加具有上下文感知能力。让我们开始吧。
聊天机器人前端组件
现在,让我们专注于聊天机器人的前端组件。我们将构建bot的用户界面元素,创建用户将与我们的应用程序进行交互的界面。这将涉及在我们的Next.js应用程序中设计和实现聊天界面的功能。
首先,我们将创建 Chat
组件,用于渲染聊天界面。
import React, { FormEvent, ChangeEvent } from "react"; Messages from "./Messages"; { Message } from "ai/react";
接口 聊天 { 输入: 字符串; 处理输入变化: (e: ChangeEvent<HTMLInputElement>) => void; 处理消息提交: (e: FormEvent<HTMLFormElement>) => Promise<void>; 消息: 消息数组[]; }
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="...">按 ⮐ 键发送</span>
</form>
<>
</div>
);
export default 聊天;
此组件将显示消息列表和用户发送消息的输入表单。Messages
组件用于渲染聊天消息:
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
组件中显示的消息的状态:
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 页面;
有用的 useChat
钩子将管理在 Chat
组件中显示的消息的状态。它将:
- 将用户的消息发送到后端
- 使用后端的响应更新状态
- 处理任何内部状态更改(例如,当用户输入消息时)
聊天机器人 API 端点
接下来,我们将设置聊天机器人 API 端点。这是处理聊天机器人请求和响应的服务器端组件。我们将创建一个名为 api/chat/route.ts
的新文件,并添加以下依赖项:
第一个依赖是 openai-edge
包,它使得在边缘环境中与OpenAI的API进行交互变得更加容易。第二个依赖是 ai
包,我们将用它来定义 Message
和 OpenAIStream
类型,用于将来自OpenAI的响应流式传输回客户端。
接下来初始化 OpenAI 客户端:
// 创建一个 OpenAI API 客户端(友好的边缘版本!)
const config = new Configuration({
apiKey: process.env.OPENAI_API_KEY,
});
const openai = new OpenAIApi(config);
要将此端点定义为边缘函数,我们将定义并导出 runtime
变量
导出常量运行时 =“edge”;
接下来,我们将定义端点处理程序:
const prompt = [
{
role: "system",
content: `AI助手是一种全新、强大、人类化的人工智能。
人工智能的特点包括专业知识、乐于助人、聪明和口齿伶俐。
人工智能是一个举止得体、有礼貌的个体。
人工智能始终友好、善良、鼓舞人心,他渴望为用户提供生动而深思熟虑的回应。
人工智能的大脑中蕴含着所有知识,能够准确回答几乎任何话题的问题。
AI助手是Pinecone和Vercel的忠实粉丝。
`, },
];
// 请求 OpenAI 根据提示进行流式聊天完成
const response = await openai.createChatCompletion({
model: "gpt-3.5-turbo",
stream: true,
messages: [
...prompt,
...messages.filter((message: Message) => message.role === "user"),
],
});
// 将响应转换为友好的文本流
const stream = OpenAIStream(response);
// 用流回复
return new StreamingTextResponse(stream);
} catch (e) { throw e; }
在这里,我们解构帖子中的消息,并创建我们的初始提示。我们使用提示和消息作为createChatCompletion
方法的输入。然后我们将响应转换为流并返回给客户端。请注意,在这个例子中,我们只发送用户的消息给OpenAI(而不包括机器人的消息)。
第三步. 添加上下文
在构建我们的聊天机器人时,理解上下文的作用至关重要。为聊天机器人的回复添加上下文是创造更自然、更具对话性用户体验的关键。没有上下文,聊天机器人的回复可能会感觉不连贯或无关紧要。通过理解用户查询的上下文,我们的聊天机器人将能够提供更准确、更相关和更引人入胜的回复。现在,让我们以这个目标开始构建。
首先,我们将首先专注于种子知识库。我们将创建一个爬虫和一个种子脚本,并设置一个爬取端点。这将使我们能够收集和组织我们的聊天机器人将用于提供相关背景响应的信息。
在我们填充了知识库之后,我们将从嵌入中检索匹配项。这将使我们的聊天机器人能够根据用户查询找到相关信息。
接下来,我们将把逻辑封装到getContext函数中,并更新聊天机器人的提示。这将简化我们的代码,并通过确保聊天机器人的提示相关且引人入胜来提高用户体验。
最后,我们将添加一个上下文面板和一个相关的上下文端点。这些将为聊天机器人提供用户界面,并为其检索每个用户查询所需的上下文提供一种方式。
这一步是为我们的聊天机器人提供所需的信息,并为其检索和有效使用该信息设置必要的基础设施。让我们开始吧。
填充知识库
现在我们将继续进行知识库的种子数据,这是我们聊天机器人响应的基础数据来源。这一步涉及收集和组织我们的聊天机器人需要有效运行的信息。在本指南中,我们将使用从各个网站检索的数据,稍后我们将能够提出问题。为此,我们将创建一个爬虫,从网站上抓取数据,嵌入并存储在Pinecone中。
创建爬虫
为简洁起见,您可以在这里找到爬虫的完整代码。以下是相关部分:
类 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[]> { // 将起始 URL 添加到队列 this.addToQueue(startUrl);
// 当队列中有URL并且我们还没有达到最大页面数时...
while (this.shouldContinueCrawling()) {
// 出队下一个URL和深度
const { url, depth } = this.queue.shift()!;
// 如果深度太大或者我们已经看过这个URL,跳过它
if (this.isTooDeep(depth) || this.isAlreadySeen(url)) continue;
// 将 URL 添加到已查看的 URL 集合中
this.seen.add(url);
// 获取页面的 HTML
const html = await this.fetchPage(url);
// 解析 HTML 并将页面添加到已爬取页面列表中
this.pages.push({ url, content: this.parseHtml(html) });
// 从页面 HTML 中提取新的 URL 并将其添加到队列
this.addNewUrlsToQueue(this.extractUrls(html, url), depth);
}
// 返回已爬取页面的列表
return this.pages;
}
// ... 为简洁起见删除了一些私有方法
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,并从中收集信息。它在构造函数中定义的特定深度和最大页面数内运行。crawl 方法是启动爬取过程的核心函数。
辅助方法fetchPage、parseHtml和extractUrls分别处理获取页面的HTML内容、解析HTML以提取文本以及从页面提取所有URL以排队进行下一次爬取。该类还维护已访问URL的记录,以避免重复。
创建 seed
函数
为了将所有内容联系在一起,我们将创建一个种子函数,该函数将使用爬虫来填充知识库。在代码的这一部分中,我们将初始化爬取并获取给定的URL,然后将其内容分割成块,最后将这些块嵌入并索引到Pinecone中。
异步函数播种(url: string, limit: number, indexName: string, options: SeedOptions) {
尝试 {
// 初始化Pinecone客户端
const pinecone = new Pinecone();
// 解构选项对象
const { splittingMethod, chunkSize, chunkOverlap } = options;
// 创建一个深度为1且最大页面数为限制的新爬虫
const crawler = new Crawler(1, limit || 100);
// 爬取给定的 URL 并获取页面
const pages = await crawler.crawl(url) as Page[];
// 根据拆分方法选择适当的文档拆分器
const splitter: DocumentSplitter = splittingMethod === 'recursive' ?
new RecursiveCharacterTextSplitter({ chunkSize, chunkOverlap }) : new MarkdownTextSplitter({});
// 准备文档,通过拆分页面
const documents = await Promise.all(pages.map(page => prepareDocument(page, splitter)));
// 如果 Pinecone 索引不存在,则创建索引
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)
// 获取文档的向量嵌入
const vectors = await Promise.all(documents.flat().map(embedDocument));
// 将向量插入 Pinecone 索引中
await chunkedUpsert(index!, vectors, '', 10);
// 返回第一个文档
return documents[0];
} catch (error) { console.error("错误种子化:", error); throw error; } }
为了分块内容,我们将使用以下方法之一:
RecursiveCharacterTextSplitter
- 此分割器将文本分割成给定大小的块,然后递归地将这些块分割成更小的块,直到达到块大小。此方法适用于长文档。
MarkdownTextSplitter
- 此分割器根据 Markdown 标题将文本分割成块。此方法适用于已使用 Markdown 结构化的文档。此方法的好处在于它将根据标题将文档分割成块,这对于我们的聊天机器人理解文档的结构非常有用。我们可以假设标题下的每个文本单元是一个内部连贯的信息单元,当用户提问时,检索到的上下文也将是内部连贯的。
添加 crawl
端点
“crawl”端点非常简单。它只是调用“seed”函数并返回结果。
从"next/server"导入{ NextResponse };
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中。该端点将返回我们爬取的检索到的网页中的所有段落,因此我们将能够显示它们。接下来,我们将编写一组函数,以便从这些嵌入中构建上下文。
从嵌入中获取匹配项
为了从索引中检索出最相关的文档,我们将使用 Pinecone SDK 中的 query
函数。该函数接受一个向量,并返回索引中最相似的向量。我们将使用该函数,根据一些嵌入来从索引中检索出最相关的文档。
// 获取Pinecone的客户端
const pinecone = new Pinecone();
const indexName: string = process.env.PINECONE_INDEX || ''; if (indexName === '') { throw new Error('PINECONE_INDEX environment variable not set') }
// 检索索引列表以查看预期的索引是否存在
const indexes = await pinecone.listIndexes()
if (indexes.filter(i => i.name === indexName).length !== 1) {
throw new Error(Index ${indexName} does not exist
)
}
// 获取松果索引 const index = pinecone!.Index<Metadata>(indexName);
// 获取命名空间 const pineconeNamespace = index.namespace(namespace ?? '')
尝试 { // 使用定义的请求查询索引 const queryResult = await pineconeNamespace.query({ vector: embeddings, topK, includeMetadata: true, }) return queryResult.matches || [] } catch (e) { // 记录错误并抛出 console.log("查询嵌入时出错: ", e) throw new Error(查询嵌入时出错: ${e}
) }
该函数接受嵌入、topK参数和命名空间,并从Pinecone索引中返回topK匹配项。首先获取Pinecone客户端,检查所需的索引是否存在于索引列表中,如果不存在则抛出错误。然后获取特定的Pinecone索引。接着使用定义的请求查询Pinecone索引,并返回匹配项。
在 getContext
中完成整理
我们将在getContext
函数中将这些内容整合在一起。该函数将接收一个message
并返回上下文 - 以字符串形式或作为一组ScoredVector
。
导出常量getContext = async (
message: string,
namespace: string,
maxTokens = 3000,
minScore = 0.7,
getOnlyText = true
): Promise<string | ScoredVector[]> => {
// 获取输入消息的嵌入
const embedding = await getEmbeddings(message);
// 从指定的命名空间中检索嵌入的匹配项 const matches = await getMatchesFromEmbeddings(embedding, 3, namespace);
// 筛选出得分高于最低分数的匹配项 const qualifyingDocs = matches.filter((m) => m.score && m.score > minScore);
如果 getOnlyText
标志为 false,我们将返回匹配项
如果不仅获取文本,则返回符合条件的文档
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();
// 获取最后一条消息 const lastMessage = messages[messages.length - 1];
// 从上一条消息中获取上下文 const context = await getContext(lastMessage.content, "");
更新提示
最后,我们将更新提示,以包含从getContext
函数中检索到的上下文。
```const prompt = [ { role: "system", content: AI助手是一种全新、强大、类人的人工智能。 AI的特点包括专业知识、乐于助人、聪明和口才。 AI是一个举止得体、有礼貌的个体。 AI始终友好、善良、鼓舞人心,他渴望为用户提供生动而周到的回应。 AI的大脑中蕴含着所有知识的总和,能够准确回答几乎任何话题的问题。 AI助手是Pinecone和Vercel的忠实粉丝。开始上下文块 ${context} 结束上下文块 AI助手将考虑在对话中提供的任何上下文块。如果上下文无法回答问题,AI助手会说:“抱歉,我不知道答案”。 AI助手不会为之前的回应道歉,而是会表明获得了新信息。 AI助手不会创造任何不直接来源于上下文的内容。
, },];`
在这个提示中,我们添加了 START CONTEXT BLOCK
和 END OF CONTEXT BLOCK
,以指示应该插入上下文的位置。我们还添加了一行来指示 AI 助手将考虑在对话中提供的任何上下文块。
添加上下文面板
接下来,我们需要将上下文面板添加到聊天界面。我们将添加一个名为Context
的新组件(完整代码)。
添加上下文终端点
我们希望允许接口指示已用于生成响应的检索内容的哪些部分。为此,我们将添加另一个端点,该端点将调用相同的 getContext
。
导出异步函数 POST(req: Request) {
尝试 {
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[];
返回 NextResponse.json({ context });
} catch (e) {
console.log(e);
返回 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 进行端到端测试。
运行所有测试:
默认情况下,在本地运行时,如果出现错误,Playwright 将打开一个 HTML 报告,显示哪些测试失败以及使用了哪些浏览器驱动程序。
在本地显示测试报告
要在本地显示最新的测试报告,请运行: