有多少次你向 Google 提出问题,只是得到一个指向维基百科的链接,需要你单击、加载网站并滚动才能找到答案?
维基百科是搜索引擎的热门搜索结果,因为它是一个值得信赖的网站;人们认为那里的信息是可靠和权威的。那么为什么不直接去维基百科呢?好吧,如果你尝试直接访问维基百科来提出你的问题,你可能会收到“不存在”错误以及相关页面列表,并且你仍然需要寻找答案。
我们喜欢维基百科以及他们为知识民主化所做的一切,并决定正面解决这个问题。因此,我们使用以下工具构建了WikiChat,这是一种询问维基百科问题并获取自然语言答案的方法:Next.js、LangChain、Vercel、OpenAI、Cohere和 DataStax Astra DB。
WikiChat 由前 1000 个最受欢迎的维基百科页面引导,然后使用维基百科的实时更新源来更新其信息存储。 Astra DB 的一个很棒的功能是它能够同时摄取这些更新,重新索引它们,并使它们可供用户查询,而不会延迟重建索引。
系统架构
与许多检索增强生成(RAG)应用类似,我们设计的这款应用分为两个部分:一个是创建知识库的数据摄取脚本,另一个是提供对话体验的网络应用。
在数据摄取方面,我们建立了一个要搜刮的数据源列表,使用 LangChain 对文本数据进行分块,使用 Cohere 创建嵌入,然后将所有这些数据存储在 Astra DB 中。
在对话用户体验方面,我们使用了 Next.js、Vercel 的人工智能库、Cohere 和 OpenAI。当用户提问时,我们使用 Cohere 为该问题创建嵌入词,使用向量搜索查询 Astra DB,然后将这些结果输入 OpenAI,为用户创建对话式回复。
设置
要自行构建此应用程序或启动并运行我们的软件仓库,你需要以下几样东西:
注册 Astra DB 后,你需要创建一个新的矢量数据库。请登录你的账户。
接下来,创建一个新的无服务器矢量数据库。给它起个你喜欢的名字,并选择你喜欢的提供商和地区。
在等待数据库配置的同时,将项目根目录下的 .env.example 文件复制到 .env。你将用它来存储秘密凭证和 API 的配置信息,我们将使用这些信息来构建此应用程序。
创建数据库后,创建一个新的应用程序令牌。
模式弹出后,单击 "复制 "按钮并将该值粘贴到 .env 文件中的 ASTRA_DB_APPLICATION_TOKEN 中。接下来,复制你的 API 端点:
将该值粘贴到 .env 文件中的 ASTRA_DB_ENDPOINT。
登录你的 Cohere 账户,转到 API 密钥。你会发现一个可用于开发的试用 API 密钥;继续复制该值并将其存储在 COHERE_API_KEY 密钥下。
最后,登录 OpenAI 账户并创建一个新的 API 密钥。在 .env 中将 API 密钥存储为 OPENAI_API_KEY。
输入数据
在脚本目录中,你可以找到为WikiChat处理所有数据输入的Python脚本。它首先加载一批维基百科的热门文章,然后使用维基百科发布的事件流来监听英语文章的变化。
在开始摄取步骤之前,让我们先快速了解一下我们将使用数据 API 在 Astra 中存储的数据。数据 API 使用 JSON 文档存储数据,并以集合的形式分组。默认情况下,文档中的每个字段都已编入索引并可供查询,包括嵌入向量。我们为 WikiChat 应用程序创建了三个集合:
脚本/wikichat/database.py文件负责初始化astrapy客户端库,调用数据应用程序接口(Data API)来创建集合,并创建客户端对象来处理它们。我们需要做的唯一数据建模工作就是在每个集合中存储我们想要的 Python 类。这些类定义在 scripts/wikichat/processing/model.py 文件中。文件的前半部分定义了我们用来通过下面讨论的管道传递文章的类,后半部分定义了我们要存储在 Astra 中的类。这些类都被定义为标准的 Python 数据类;存储在 Astra 中的类也使用了 dataclasses-json,因为该库可以将数据类层次结构序列化,并从存储在 Astra 中的 Python 字典中导出。
例如,ChunkedArticleMetadataOnly 类存储在 article_metadata 集合中,其定义如下:
@dataclass_json
@dataclass
class ChunkedArticleMetadataOnly:
_id: str
article_metadata: ArticleMetadata
chunks_metadata: dict[str, ChunkMetadata] = field(default_factory=dict)
suggested_question_chunks: list[Chunk] = field(default_factory=list)
当我们要存储该类的对象时(在 scripts/wikichat/processing/articles.py/update_article_metadata() 中),我们会使用该类上的 dataclass_json 装饰所添加的 to_dict() 方法,该方法会创建一个基本的 Python 字典,供 astrapy 存储为 JSON 文档:
METADATA_COLLECTION.find_one_and_replace(
filter={"_id": metadata._id},
replacement=metadata.to_dict(),
options={"upsert": True}
)
回读时(在同一文件中的 calc_chunk_diff()),from_dict() 将用于从存储的 JSON 文档中重建整个对象层次结构:
resp = METADATA_COLLECTION.find_one(filter={"_id": new_metadata._id})
prev_metadata_doc = resp["data"]["document"]
prev_metadata = ChunkedArticleMetadataOnly.from_dict(prev_metadata_doc)
在介绍了大纲和数据访问方式后,现在该看看我们是如何处理每篇文章的。文章通过使用 Python 异步 I/O 构建的处理流水线进行处理。异步处理用于处理维基百科更新的突发性,并确保我们在等待脚本需要进行的各种远程调用时继续处理。处理流水线有五个步骤:
构建聊天机器人用户体验
现在,我们已经从维基百科中预载了一些热门数据,并连接了实时更新内容,是时候构建聊天机器人了!对于这个应用程序,我们选择使用 Next.js,这是一个全栈 React.js Web 框架。该网络应用程序最重要的两个组件是基于网络的聊天界面和检索用户问题答案的服务。
聊天界面由 Vercel 的人工智能 npm 库提供支持。该模块可帮助开发人员只需几行代码就能构建类似 ChatGPT 的体验。在我们的应用程序中,我们在 `app/page.tsx` 文件中实现了这种体验,该文件代表了网络应用程序的根。下面是一些值得一提的代码片段:
"use client";
import { useChat, useCompletion } from 'ai/react';
import { Message } from 'ai';
use client";指令告诉 Next.js,该模块只能在客户端运行。导入语句使 Vercel 的AI库可以在我们的应用程序中使用。
const { append, messages, isLoading, input, handleInputChange, handleSubmit } = useChat();
这将初始化 useChat React 钩子,它将处理用户与聊天机器人交互时的状态和大部分交互体验。
const handleSend = (e) => {
handleSubmit(e, { options: { body: { useRag, llm, similarityMetric}}});
}
当用户提问时,该函数负责将信息传递给后台服务,后者会计算出答案。
const [suggestions, setSuggestions] = useState<PromptSuggestion[]>([]);
const { complete } = useCompletion({
onFinish: (prompt, completion) => {
const parsed = JSON.parse(completion);
const argsObj = JSON.parse(parsed?.function_call.arguments);
const questions = argsObj.questions;
const questionsArr: PromptSuggestion[] = [];
questions.forEach(q => {
questionsArr.push(q);
});
setSuggestions(questionsArr);
}
});
useEffect(() => {
complete('')
}, []);
这将初始化另一个重要钩子,我们用它来根据我们索引的维基百科最近更新的页面加载建议问题。onFinish 处理程序会从服务器接收一个 JSON 有效负载,用于设置将在用户界面中显示的建议。让我们深入研究服务器端,看看这些建议问题是如何创建的。
预填充一些建议问题以开始使用
如前所述,当用户首次加载WikiChat时,WikiChat会根据维基百科中最近更新并被该应用摄取的页面提供一些建议问题。但是,我们如何从最近更新的页面到建议问题呢?让我们检查一下 /api/completion/route.ts,看看发生了什么:
import { AstraDB } from "@datastax/astra-db-ts";
import { OpenAIStream, StreamingTextResponse } from "ai";
import OpenAI from "openai";
import type { ChatCompletionCreateParams } from 'openai/resources/chat';
在这里,我们将导入以下资源:Astra DB 客户端、Vercel 人工智能 SDK 中的一些辅助工具、OpenAI 客户端和一个辅助工具类型,我们稍后将讨论这些资源。
const {
ASTRA_DB_APPLICATION_TOKEN,
ASTRA_DB_ENDPOINT,
ASTRA_DB_SUGGESTIONS_COLLECTION,
OPENAI_API_KEY,
} = process.env;
const astraDb = new AstraDB(ASTRA_DB_APPLICATION_TOKEN, ASTRA_DB_ENDPOINT);
const openai = new OpenAI({
apiKey: OPENAI_API_KEY,
});
接下来,我们根据 .env 文件中配置的密钥初始化 Astra DB 和 OpenAI 客户端。
const suggestionsCollection = await astraDb.collection(ASTRA_DB_SUGGESTIONS_COLLECTION);
const suggestionsDoc = await suggestionsCollection.findOne(
{
_id: "recent_articles"
},
{
projection: {
"recent_articles.metadata.title" : 1,
"recent_articles.suggested_chunks.content" : 1,
},
});
还记得我们在讨论摄取过程时,将最近更新的五篇维基百科文章存储在数据库的一个文档中吗?在这里,我们使用客户端的 findOne 函数来查询该文档。通过投影选项,我们可以告诉客户端只返回我们指定的文档属性。
const docMap = suggestionsDoc.recent_articles.map(article => {
return {
pageTitle: article.metadata.title,
content: article.suggested_chunks.map(chunk => chunk.content)
}
});
docContext = JSON.stringify(docMap);
获得文档后,我们就可以用它创建一个包含 "页面标题 "和 "内容 "对的简单数组对象,在调用 LLM 时将其作为上下文传递。
const response = await openai.chat.completions.create({
model: "gpt-3.5-turbo-16k",
stream: true,
temperature: 1.5,
messages: [{
role: "user",
content: `You are an assistant who creates sample questions to ask a chatbot.
Given the context below of the most recently added data to the most popular pages
on Wikipedia come up with 4 suggested questions. Only write no more than one
question per page and keep them to less than 12 words each. Do not label which page
the question is for/from.
START CONTEXT
${docContext}
END CONTEXT
`,
}],
functions
});
既然我们已经掌握了最近更新的维基百科页面的数据(标题和内容),你可能想知道我们如何将其转化为应用程序的建议问题。
在调用 OpenAI 的聊天完成 API 时,我们会构建一个提示,要求 LLM 使用所传递的数据来构建适当的问题。我们提供了关于问题类型、问题长度的说明,并将温度设置为 1.5(数值范围为 0-2),以便获得更具创造性的回复。
函数的最后一个参数允许我们输入一个自定义函数。在我们的例子中,我们用它来定义从 OpenAI 得到的回复的 "形状",这样我们就可以轻松地解析它,并用它来填充用户界面中的建议问题。
const functions: ChatCompletionCreateParams.Function[] = [{
name: 'get_suggestion_and_category',
description: 'Prints a suggested question and the category it belongs to.',
parameters: {
type: 'object',
properties: {
questions: {
type: 'array',
description: 'The suggested questions and their categories.',
items: {
type: 'object',
properties: {
category: {
type: 'string',
enum: ['history', 'science', 'sports', 'technology', 'arts', 'culture',
'geography', 'entertainment', 'politics', 'business', 'health'],
description: 'The category of the suggested question.',
},
question: {
type: 'string',
description: 'The suggested question.',
},
},
},
},
},
required: ['questions'],
},
}];
在这个有效负载的深处,有两个关键值是我们要定义的,也是我们期望返回的。第一个是category,这是一个字符串,是我们用来在应用程序用户界面中设置图标的几个预定义值之一。第二个是question,这是一个字符串,代表要在用户界面中显示给用户的建议问题。
使用 RAG 回答问题
既然我们已经解释了建议性问题是如何生成的,那么让我们来看看当用户向WikiChat提问时会发生什么。该请求由 /app/api/chat/route.ts 中定义的后台 API 路由处理,并广泛使用了 LangChain 的 JS SDK。让我们分解一下,看看发生了什么:
import { CohereEmbeddings } from "@langchain/cohere";
import { Document } from "@langchain/core/documents";
import {
RunnableBranch,
RunnableLambda,
RunnableMap,
RunnableSequence
} from "@langchain/core/runnables";
import { StringOutputParser } from "@langchain/core/output_parsers";
import { PromptTemplate } from "langchain/prompts";
import {
AstraDBVectorStore,
AstraLibArgs,
} from "@langchain/community/vectorstores/astradb";
import { ChatOpenAI } from "langchain/chat_models/openai";
import { StreamingTextResponse, Message } from "ai";
这些导入为我们提供了 Langchain JS SDK 的相关部分。你会注意到,我们正在使用 Langchain 内置的 Cohere 和 OpenAI 作为 LLM 支持,以及 Astra DB 作为向量存储支持。
const questionTemplate = `You are an AI assistant answering questions about anything
from Wikipedia the context will provide you with the most relevant data from wikipedia
including the pages title, url, and page content.
If referencing the text/context refer to it as Wikipedia.
At the end of the response add one markdown link using the format: [Title](URL) and
replace the title and url with the associated title and url of the more relavant page
from the context
This link will not be shown to the user so do not mention it.
The max links you can include is 1, do not provide any other references or annotations.
if the context is empty, answer it to the best of your ability. If you cannot find the
answer user's question in the context, reply with "I'm sorry, I'm only allowed to
answer questions related to the top 1,000 Wikipedia pages".
<context>
{context}
</context>
QUESTION: {question}
`;
const prompt = PromptTemplate.fromTemplate(questionTemplate);
问题模板是我们用来为 LLM 创建提示的工具,我们可以在其中注入额外的上下文,以便它提供最佳答案。说明相当简单明了,请注意,我们指示它提供一个指向维基百科上 markdown 格式源页面的链接。稍后在用户界面中呈现答案时,我们将利用这一点。
const {messages, llm } = await req.json();
const previousMessages = messages.slice(0, -1);
const latestMessage = messages[messages?.length - 1]?.content;
const embeddings = new CohereEmbeddings({
apiKey: COHERE_API_KEY,
inputType: "search_query",
model: "embed-english-v3.0",
});
const chatModel = new ChatOpenAI({
temperature: 0.5,
openAIApiKey: OPENAI_API_KEY,
modelName: llm ?? "gpt-4",
streaming: true,
});
在 POST 函数中,我们接收聊天记录(消息)的值,并使用这些值定义 previousMessages 和 latestMessage。接下来,我们初始化 Cohere 和 OpenAI,以便在 LangChain 中使用。
const astraConfig: AstraLibArgs = {
token: ASTRA_DB_APPLICATION_TOKEN,
endpoint: ASTRA_DB_ENDPOINT,
collection: “article_embeddings”,
contentKey: “content”
};
const vectorStore = new AstraDBVectorStore(embeddings, astraConfig);
await vectorStore.initialize();
const retriever = vectorStore.asRetriever(10);
现在是为 LangChain 配置 Astra DB 向量存储的时候了,在这里我们要指定连接凭证、我们要查询的集合以及从 DB 返回的 10 个文档的限制。
const chain = RunnableSequence.from([
condenseChatBranch,
mapQuestionAndContext,
prompt,
chatModel,
new StringOutputParser(),
]).withConfig({ runName: "chatChain"});
const stream = await chain.stream({
chat_history: formatVercelMessages(previousMessages),
question: latestMessage,
});
这就是 LangChain 的神奇之处。
首先,我们通过传入一系列 Runnable 来创建 RunnableSequence。此时你只需知道,RunnableSequence 从顶层开始,执行每个 Runnable,并将其输出作为输入传递给下一个 Runnable。
定义完序列后,我们将使用聊天记录和最近的问题执行序列。在这个序列中发生了很多事情,让我们逐一检查。
const hasChatHistoryCheck = RunnableLambda.from(
(input: ChainInut) => input.chat_history.length > 0
);
const chatHistoryQuestionChain = RunnableSequence.from([
{
question: (input: ChainInut) => input.question,
chat_history: (input: ChainInut) => input.chat_history,
},
condenseQuestionPrompt,
chatModel,
new StringOutputParser(),
]).withConfig({ runName: "chatHistoryQuestionChain"});
const noChatHistoryQuestionChain = RunnableLambda.from(
(input: ChainInut) => input.question
).withConfig({ runName: "noChatHistoryQuestionChain"});
const condenseChatBranch = RunnableBranch.from([
[hasChatHistoryCheck, chatHistoryQuestionChain],
noChatHistoryQuestionChain,
]).withConfig({ runName: "condenseChatBranch"});
序列中的第一个 Runnable 是 condenseChatBranch。这段代码的目的是让WikiChat变得聪明,并能意识到之前问过的问题。
hasChatHistoryCheck 只是检查我们初始化链时定义的 chat_history 输入,看是否存在非空值。
如果检查结果为真,chatHistoryQuestionChain Runnable 就会将问题和聊天历史记录反馈给 LLM,以构建一个更好的问题。让我们看看 condenseQuestionPrompt 是如何工作的:
const condenseQuestionTemplate = `Given the following chat history and a follow up
question, If the follow up question references previous parts of the chat rephrase the
follow up question to be a standalone question if not use the follow up question as the
standalone question.
<chat_history>
{chat_history}
</chat_history>
Follow Up Question: {question}
Standalone question:`;
const condenseQuestionPrompt = PromptTemplate.fromTemplate(
condenseQuestionTemplate,
);
在这里,我们定义的提示会考虑到我们的聊天历史,并特别指示 LLM 查看所提问题是否为后续问题。如果我们使用之前的例子,那么 LLM 就会回答 "谁是他的孩子?",然后根据聊天历史记录,将问题改写为 "谁是达斯-维德的孩子?" !
现在,如果没有聊天记录,那么 noChatHistoryQuestionChain 的功能就是无操作,只会返回用户所提问题的原样。
const combineDocumentsFn = (docs: Document[]) => {
const serializedDocs = docs.map((doc) => `Title: ${doc.metadata.title}
URL: ${doc.metadata.url}
Content: ${doc.pageContent}`);
return serializedDocs.join("\n\n");
};
const retrieverChain = retriever.pipe(combineDocumentsFn).withConfig({ runName:
"retrieverChain"});
const mapQuestionAndContext = RunnableMap.from({
question: (input: string) => input,
context: retrieverChain
}).withConfig({ runName: "mapQuestionAndContext"});
主序列的下一步是 mapQuestionAndContext,它将上一步的输出(用户的问题)传给它,然后从 Astra DB 中检索最接近的匹配文档,并将它们合并成一个字符串。
然后将该字符串传递给下一步,也就是我们之前定义的提示。然后,我们将这个完全膨胀的提示传递给 LLM,最后将 LLM 的输出传递给 LangChain StringParser。
return new StreamingTextResponse(stream);
最后要做的是将 Langchain 流作为 StreamingTextResponse 返回,这样用户就能实时看到 LLM 的输出结果。
总结
让我们回顾一下我们为构建一个智能聊天机器人所做的工作,它可以回答有关维基百科上最受欢迎和最近更新的页面的问题: