PDF 聊天与 Node.js、OpenAI 和 ModelFusion
你有没有想过能够回答有关 PDF 问题的聊天机器人是如何工作的?
在本篇博文中,我们将使用 Node.js、OpenAI 和 ModelFusion 构建一个控制台应用程序,该程序能够搜索和理解 PDF 内容并回答问题
。您将学习如何读取和索引 PDF 文件以实现高效搜索,并通过从 PDF 中检索相关内容来提供精准的答案。
您可以在这里找到聊天机器人的完整代码:github/com/lgrammel/modelfusion/examples/pdf-chat-terminal
这篇博文详细解释了各个关键部分。让我们开始吧!
从PDF加载页面
我们使用 Mozilla 的PDF.js(通过pdfjs-distNPM 模块)来加载 PDF 文件中的页面。该loadPdfPages函数读取 PDF 文件并提取其内容,返回一个数组,其中每个对象包含页码和该页的文本。
import fs from "fs/promises";
import * as PdfJs from "pdfjs-dist/legacy/build/pdf";
async function loadPdfPages(path: string) {
const pdfData = await fs.readFile(path);
const pdf = await PdfJs.getDocument({
data: new Uint8Array(
pdfData.buffer,
pdfData.byteOffset,
pdfData.byteLength
),
useSystemFonts: true,
}).promise;
const pageTexts: Array<{
pageNumber: number;
text: string;
}> = [];
for (let i = 0; i < pdf.numPages; i++) {
const page = await pdf.getPage(i + 1);
const pageContent = await page.getTextContent();
pageTexts.push({
pageNumber: i + 1,
text: pageContent.items
.filter((item) => (item as any).str != null)
.map((item) => (item as any).str as string)
.join(" ")
.replace(/\s+/g, " "),
});
}
return pageTexts;
}
让我们来探讨主要任务:“加载和解析 PDF”和“提取页码和文本”。
加载并解析PDF文件
在处理 PDF 内容之前,我们需要从磁盘读取文件并将其解析成我们的代码可以理解的格式。
const pdfData = await fs.readFile(path);
const pdf = await PdfJs.getDocument({
data: new Uint8Array(pdfData.buffer, pdfData.byteOffset, pdfData.byteLength),
useSystemFonts: true,
}).promise;
在这段代码片段中,该fs.readFile函数从磁盘读取 PDF 文件并将数据存储在指定位置pdfData。然后,我们使用该PdfJs.getDocument函数解析这些数据。该标志useSystemFonts设置为 true 是为了避免在 PDF 中使用系统字体时出现问题。
提取页码和文本
成功加载并解析 PDF 后,下一步是从每一页中提取文本内容及其页码。
const pageTexts: Array<{
pageNumber: number;
text: string;
}> = [];
for (let i = 0; i < pdf.numPages; i++) {
const page = await pdf.getPage(i + 1);
const pageContent = await page.getTextContent();
pageTexts.push({
pageNumber: i + 1,
text: pageContent.items
.filter((item) => (item as any).str != null)
.map((item) => (item as any).str as string)
.join(" ")
.replace(/\s+/g, " "),
}
这段代码定义了一个名为 `page` 的数组pageTexts,用于存储包含页码和从每一页提取的文本的对象。然后,我们使用 `pages` 循环遍历 PDF 的每一页,pdf.numPages以确定总页数。
在循环中,pdf.getPage(i + 1)从第 1 页开始逐页获取。我们使用提取文本内容page.getTextContent()。
最后,对从每页提取的文本进行清理,方法是将所有文本项连接起来,并将多个空格合并为一个空格。清理后的文本和页码存储在pageTexts.
索引页面
现在PDF页面已经可以转换成文本,接下来我们将深入探讨如何对已加载的PDF文本进行索引。索引至关重要,因为它能实现后续快速、语义化的信息检索。以下是具体实现过程:
const pages = await loadPdfPages(file);
const embeddingModel = openai.TextEmbedder({
model: "text-embedding-ada-002",
throttle: throttleMaxConcurrency({ maxConcurrentCalls: 5 }),
});
const chunks = await splitTextChunks(
splitAtToken({
maxTokensPerChunk: 256,
tokenizer: embeddingModel.tokenizer,
}),
pages
);
const vectorIndex = new MemoryVectorIndex<{
pageNumber: number;
text: string;
}>();
await upsertIntoVectorIndex({
vectorIndex,
embeddingModel,
objects: chunks,
getValueToEmbed: (chunk) => chunk.text,
});
让我们逐一来看:
初始化文本嵌入模型
第一步是初始化文本嵌入模型。该模型将负责把我们的文本数据转换成可以进行相似度比较的格式。
const embeddingModel = openai.TextEmbedder({
model: "text-embedding-ada-002",
});
文本嵌入模型的工作原理是将文本块转换为多维空间中的向量,使得含义相似的文本具有彼此接近的向量。这些向量将被存储在向量索引中。
分词和文本分块
在将文本转换为向量之前,我们需要对文本数据进行预处理。预处理包括将文本分割成更小的片段,称为“块”,以便模型能够处理。
const chunks = await splitTextChunks(
splitAtToken({
maxTokensPerChunk: 256,
tokenizer: embeddingModel.tokenizer,
}),
pages
);
我们将每个文本块限制为 256 个词元,并使用嵌入模型中的分词器。该splitTextChunks函数递归地分割文本,直到文本块符合指定的最大大小。
你可以调整数据块大小,观察它对结果的影响。如果数据块太小,可能只包含回答问题所需的部分信息。如果数据块太大,它们的嵌入向量可能与我们之后生成的假设答案不够相似。
词元:词元是机器学习模型读取的最小单元。在语言模型中,词元可以小到字符,也可以大到单词(例如,“a”、“apple”)。
分词器:一种将文本分解成词元的工具。ModelFusion 为大多数文本生成和嵌入模型提供分词器。
创建内存向量索引
下一步是创建一个空的内存向量索引来存储我们的嵌入式文本向量。
const vectorIndex = new MemoryVectorIndex<{
pageNumber: number;
text: string;
}>();
向量存储库就像一个专门存储向量的数据库。它允许我们快速搜索,找到与给定查询向量相似的向量。
在 ModelFusion 中,矢量索引是一个可搜索的接口,用于访问特定表格或元数据的矢量存储。在我们的应用程序中,索引中的每个矢量都与其来源的页码和文本块相关联。
ModelFusionMemoryVectorIndex是一个简单的内存向量索引实现,它使用余弦相似度来查找相似向量。对于小型数据集,例如按需加载的单个 PDF 文件,它是一个不错的选择。
将文本块插入向量索引
最后,我们将从数据块生成的文本向量填充到记忆向量索引中。
await upsertIntoVectorIndex({
vectorIndex,
embeddingModel,
objects: chunks,
getValueToEmbed: (chunk) => chunk.text,
});
该函数upsertIntoVectorIndex执行以下操作:
- 它使用该方法
embeddingModel将每个文本块转换为向量。 - 然后,它将此向量
vectorIndex连同元数据(页码和文本)一起插入到 中。
至此,我们的向量索引已完全填充,可以进行快速的语义搜索。这对于我们的聊天机器人提供相关且准确的答案至关重要。
总而言之,索引是将文本块转换为矢量化、可搜索格式的过程。它为基于语义的文本检索奠定了基础,使我们的聊天机器人能够以上下文感知的方式理解和回应。
聊天循环
聊天循环是我们“与 PDF 聊天”应用程序的核心部分。它不断等待用户提问,生成假设答案,从预处理过的 PDF 中搜索相似的文本块,并回复用户。
const chat = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
while (true) {
const question = await chat.question("You: ");
const hypotheticalAnswer = await generateText({
model: openai.ChatTextGenerator({ model: "gpt-3.5-turbo", temperature: 0 }),
prompt: [
openai.ChatMessage.system(`Answer the user's question.`),
openai.ChatMessage.user(question),
],
});
const information = await retrieve(
new VectorIndexRetriever({
vectorIndex,
embeddingModel,
maxResults: 5,
similarityThreshold: 0.75,
}),
hypotheticalAnswer
);
const textStream = await streamText({
model: openai.ChatTextGenerator({ model: "gpt-4", temperature: 0 }),
prompt: [
openai.ChatMessage.system(
`Answer the user's question using only the provided information.\n` +
`Include the page number of the information that you are using.\n` +
`If the user's question cannot be answered using the provided information, ` +
`respond with "I don't know".`
),
openai.ChatMessage.user(question),
openai.ChatMessage.fn({
fnName: "getInformation",
content: JSON.stringify(information),
}),
],
});
process.stdout.write("\nAI : ");
for await (const textFragment of textStream) {
process.stdout.write(textFragment);
}
process.stdout.write("\n\n");
}
让我们来分析聊天循环中代码的主要组成部分。
循环并等待用户输入
const chat = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
while (true) {
const question = await chat.question("You: ");
// ...
}
聊天循环会无限期地运行,以保持聊天互动。
我们使用 Node.jsreadline包在每次迭代中从终端收集用户输入。
生成一个假设答案
const hypotheticalAnswer = await generateText({
model: openai.ChatTextGenerator({ model: "gpt-3.5-turbo", temperature: 0 }),
prompt: [
openai.ChatMessage.system(`Answer the user's question.`),
openai.ChatMessage.user(question),
],
});
我们gpt-3.5-turbo首先使用 OpenAI 的模型来创建一个假设性的答案。
其思路(假设文档嵌入)是,假设的答案在嵌入向量空间中会比用户的问题更接近我们想要查找的文本块。这种方法有助于我们在后续搜索相似文本块时找到更好的结果。
提取相关文本片段
const information = await retrieve(
new VectorIndexRetriever({
vectorIndex,
embeddingModel,
maxResults: 5,
similarityThreshold: 0.75,
}),
hypotheticalAnswer
);
该retrieve()函数从预处理的 PDF 中搜索与假设答案相似的文本块。
我们将结果数量限制为 5 个,并将相似度阈值设置为 0.75。您可以尝试调整这些参数(结合之前设置的分块大小),看看它们如何影响结果。例如,当您减小分块大小时,您可能需要增加结果数量以获取更多信息。
使用文本块生成答案
const textStream = await streamText({
model: openai.ChatTextGenerator({ model: "gpt-4", temperature: 0 }),
prompt: [
openai.ChatMessage.system(
`Answer the user's question using only the provided information.\n` +
`Include the page number of the information that you are using.\n` +
`If the user's question cannot be answered using the provided information, ` +
`respond with "I don't know".`
),
openai.ChatMessage.user(question),
openai.ChatMessage.functionResult(
"getInformation",
JSON.stringify(information)
),
],
});
我们利用gpt-4检索到的文本块生成最终答案。温度设置为 0,以尽可能消除响应中的随机性。
在系统提示符中,我们指定:
- 答案应完全基于检索到的文本片段。
- 应注明信息的页码。
- 如果用户的问题无法通过提供的信息回答,则答案应为“我不知道”。此指令引导学习语言模型(LLM)在无法从文本块中找到答案时使用此答案。
这些代码块以虚假函数结果的形式插入(使用OpenAI 函数调用 API),以表明它们与用户的问题无关。
答案会以流媒体形式实时显示给用户,信息一经提供便会立即呈现。
将答案流式传输到控制台
process.stdout.write("\nAI : ");
for await (const textFragment of textStream) {
process.stdout.write(textFragment);
}
process.stdout.write("\n\n");
stdout.write()最后,我们使用打印从收集的文本片段来向用户显示生成的答案textStream。
结论
至此,我们构建能够根据 PDF 内容回答问题的聊天机器人的旅程就结束了。借助 OpenAI 和 ModelFusion,您已经了解了如何读取、索引和检索 PDF 文件中的信息。
这段代码旨在为您的项目提供一个起点。尽情探索吧!
PS:您可以在这里找到该应用程序的完整代码:github.com/lgrammel/modelfusion/examples/pdf-chat-terminal
文章来源:https://dev.to/lgrammel/pdf-chat-with-nodejs-openai-and-modelfusion-oip