一、说明
你有多少次问谷歌一个问题,只是为了得到一个维基百科的链接,需要你点击、加载网站并滚动才能找到答案?那么自动问题搜索又是如何呢?
维基百科是搜索引擎的顶级搜索结果,因为它是一个值得信赖的网站;人们认为那里的信息是可靠和权威的。那么为什么不直接去维基百科呢,对吧?好吧,如果你试图直接去维基百科问你的问题,你可能会得到一个“不存在”的错误以及相关页面的列表,你仍然在寻找答案。
我们热爱维基百科以及他们为知识民主化所做的一切,并决定直面这个问题。因此,我们建立了WikiChat,这是一种使用以下工具向维基百科提问并获取自然语言答案的方法:Next.js、LangChain、Vercel、OpenAI、Cohere和DataStax Astra DB。
WikiChat使用前1000个最受欢迎的维基百科页面进行引导,然后使用维基百科的实时更新提要来更新其信息存储。Astra DB 的一个令人敬畏的功能是它能够同时摄取这些更新,重新编制索引,并使其可供用户查询,而不会延迟重新构建索引。
WikiChat 源代码可在 Github 上找到。请继续阅读下文,深入了解我们是如何构建它的,并观看此视频,了解我、我的同事 Alex Leventer 和 LangChain 的 Jacob Lee 最近进行的演练:
二、系统架构
与许多检索增强生成 (RAG) 应用程序类似,我们将此应用设计为两部分:创建知识库的数据引入脚本和提供对话体验的 Web 应用。
对于数据摄取,我们构建了一个要抓取的来源列表,使用 LangChain 对文本数据进行分块,使用 Cohere 创建嵌入,然后将所有这些存储在 Astra DB 中。
对于对话式用户体验,我们正在构建 Next.js、Vercel 的 AI 库、Cohere 和 OpenAI。当用户提出问题时,我们使用 Cohere 为该问题创建嵌入,使用向量搜索查询 Astra DB,然后将这些结果输入 OpenAI 以创建对用户的对话响应。
三、设置
若要自行构建此应用程序或启动并运行我们的存储库,需要以下几点:
- 免费的 Astra DB 帐户
- 一个免费的Cohere帐户
- OpenAI 帐户
注册 Astra DB 后,您需要创建一个新的矢量数据库。继续登录您的帐户。
接下来,创建一个新的 Serverless Vector 数据库。为其指定任何您喜欢的名称,然后选择您首选的提供商和区域。
在等待数据库配置时,将项目根目录下的 .env.example 文件复制到 .env。您将使用它来存储我们将用于构建此应用程序的 API 的秘密凭据和配置信息。
创建数据库后,创建一个新的应用程序令牌。
当模式弹出时,单击“复制”按钮并将该值粘贴到 .env 文件中的 ASTRA_DB_APPLICATION_TOKEN 中。接下来,复制您的 API 端点:
将该值粘贴到 .env 文件中的 ASTRA_DB_ENDPOINT 中。
登录您的 Cohere 帐户并转到 API 密钥。您将找到可用于开发的试用 API 密钥;继续复制该值并将其存储在 COHERE_API_KEY 键下。
最后,登录您的 OpenAI 帐户并创建一个新的 API 密钥。将该 API 密钥存储在您的 .env 中作为 OPENAI_API_KEY。
好的,就是这样 - 是时候编写一些代码了!
四、引入数据
在目录中,您将找到处理WikiChat所有数据摄取的Python脚本。它加载第一批流行的维基百科文章,然后使用维基百科发布的事件流监听对英语文章的更改。scripts
注意:事件流包括多种形式的更新,例如机器人、对讨论页的更改,甚至是为测试系统而发送的金丝雀事件。无论来源如何,每篇文章都要经过五个步骤才能被摄取。
在执行摄取步骤之前,让我们快速浏览一下将使用 Data API 存储在 Astra 中的数据。数据 API 使用 JSON 文档存储数据,这些文档在集合中分组在一起。默认情况下,文档中的每个字段都已编制索引并可供查询,包括嵌入向量。对于WikiChat应用程序,我们创建了三个集合:
article_embeddings
- 每个文档存储来自文章的一块文本和一个使用 Cohere 创建的嵌入向量。这是WikiChat回答您的聊天问题所需的核心信息。article_metadata
- 每个文档都存储了有关我们摄取的单篇文章的元数据,包括我们上次摄取时所包含的块的相关信息。article_suggestions
- 此集合包含一个文档,脚本会不断更新该文档以跟踪最近处理的 5 篇文章,以及每个文档的前 5 个块或最近更新的 5 个块。
scripts/wikichat/database.py 文件负责初始化 astrapy 客户端库,调用数据 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 构建的处理管道传递。异步处理用于处理来自维基百科的更新的突发性,并确保我们在等待脚本需要进行的各种远程调用时继续处理。处理管道有五个步骤:
load_article()
从 wikipedia.org 检索文章,并使用 Beautiful Soup 从 HTML 中提取文本。chunk_article()
将文章分解为多个块,这些块用于创建描述其语义含义的嵌入向量。使用 LangChain 的 RecursiveCharacterTextSplitter 对文本进行分块,并将该块的 sha256 哈希计算为消息摘要,以便可以比较块是否相等。calc_chunk_diff()
首先检查 Astra 以查看我们是否有关于本文的先前元数据,然后创建一个“差异”来描述当前文章。将所有当前块的哈希值与我们上次看到本文时所知道的哈希值进行比较。以前未见过的文章将仅包含新的文本块,而以前看到的文章将包含新的、已删除的和未更改的文本块的组合。vectorize_diff()
调用 Cohere 来计算文章中新文本块的嵌入。在计算“Diff”后调用意味着我们避免计算未更改的文本块的向量。store_article_diff()
更新 Astra 以存储我们现在对本文的了解;这有三个步骤:update_article_metadata()
更新集合中文章的元数据,并更新集合以跟踪最近的更新,以便 UI 可以提出新问题。article_metadata
article_suggestions
insert_vectored_chunks(
) 将所有新块及其向量插入到 article_embeddings 集合中。delete_vectored_chunks()
删除更新后的文章中不再存在的所有块。
五、构建聊天机器人用户体验
现在我们已经从维基百科预加载了一些流行的数据,并连接了内容的实时更新,是时候构建聊天机器人了!对于此应用程序,我们选择使用 Next.js,一个全栈React.js Web 框架。此 Web 应用程序的两个最重要的组件是基于 Web 的聊天界面和检索用户问题答案的服务。
聊天界面由 Vercel 的 AI npm 库提供支持。该模块可帮助开发人员仅用几行代码构建类似 ChatGPT 的体验。在我们的应用程序中,我们在“app/page.tsx”文件中实现了此体验,该文件表示 Web 应用程序的根目录。以下是一些值得一提的代码片段:
"use client";
import { useChat, useCompletion } from 'ai/react';
import { Message } from 'ai';
“use client
”;指令告诉 Next.js 该模块只会在客户端上运行。 import 语句使 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('')
}, []);
这将初始化另一个重要的钩子,我们用它来根据我们索引的维基百科中最近更新的页面加载建议的问题。处理程序从服务器接收 JSON 有效负载,用于在 UI 中显示。让我们在服务器端深入研究这个问题,看看这些建议的问题是如何创建的。onFinish
setSuggestions
六、预先填充一些建议的问题以开始
如前所述,当用户首次加载 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 AI 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 函数查询该文档。该选项使我们能够告诉客户端仅返回我们指定的文档的属性。projection
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 返回的响应的“形状”,以便我们可以轻松解析它并使用它来填充 UI 中的建议问题。
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'],
},
}];
在这个有效负载的深处,有两个我们正在定义并期望返回的关键值。第一个是类别,它是一个字符串,是我们用来在应用程序 UI 中设置图标的少数预定义值之一。第二个是问题,一个字符串,表示要在 UI 中向用户显示的建议问题。
七、使用 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 格式提供指向维基百科上源页面的链接。稍后,当我们在 UI 中呈现答案时,我们将利用这一点。
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 数据库向量存储了,我们在其中指定了连接凭据,要从中查询的集合以及从数据库返回的 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魔术发生✨的地方
首先,我们通过传入一系列 Runnables 来创建一个 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 是 。此代码的目的是使WikiChat变得智能并了解先前提出的问题。让我们举一个说明性的例子:condenseChatBranch
- 问题#1:谁是《星球大战》中的反派?
- 答案:达斯·维达
- 问题#2:他的孩子是谁?
如果不知道第一个问题是什么,第二个问题就没有意义。因此,我们定义了一个 RunnableBranch,其功能有点像 if/else 语句。如果 Runnable hasChatHistory 为 true,则 Langchain 将运行 chatHistoryQuestionChain,否则将运行 noChatHistoryChain。
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将接受“谁是他的孩子?”的问题,并查看聊天记录,将问题改写为“谁是达斯维达的孩子?Boom — 一个更智能的聊天机器人!
现在,如果没有聊天历史记录,则 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"});
主序列中的下一个是 ,它传递了上一步(用户的问题)的输出,并从 Astra DB 中检索最接近的匹配文档并将它们组合成一个字符串。mapQuestionAndContext
然后,此字符串将传递到下一步,即我们之前定义的提示。然后,我们将这个完全膨胀的提示传递给 LLM,最后将 LLM 的输出传递给 LangChain StringParser。
return new StreamingTextResponse(stream);
最后要做的是将 Langchain 流作为 StreamingTextResponse 返回,以便用户在 LLM 通过网络时实时看到 LLM 的输出。
八、结束语
让我们回顾一下我们构建一个智能聊天机器人所涵盖的所有内容,该机器人可以回答有关维基百科上最受欢迎和最近更新的页面的问题:
- 通过抓取 1,000 篇最流行的维基百科文章来加载初始数据集。
- 监听实时更新并仅处理差异。
- 使用 LangChain 对文本数据进行智能分块,并使用 Cohere 生成嵌入。
- 在 Astra DB 中存储应用程序和矢量数据。
- 使用 Vercel 的 AI 库构建基于 Web 的聊天机器人 UX。
- 在 Astra DB 上执行矢量搜索。
- 使用 OpenAI 生成准确且上下文感知的响应。