PDF与Node.js,OpenAI和Modelfusion聊天
#javascript #编程 #ai #node

您是否曾经想过如何回答有关PDF问题的聊天机器人?

在此博客文章中,我们将构建一个能够搜索和理解PDF内容以回答问题的控制台应用
使用Node.js,OpenAI和Modelfusion。您将学习如何阅读和索引PDF,以进行有效的搜索,并通过从PDF中检索相关内容来提供精确的响应。

您可以在此处找到聊天机器人的完整代码:github/com/lgrammel/modelfusion/examples/pdf-chat-terminal

本博客文章详细说明了基本部分。让我们开始!

从PDF加载页面

我们通过pdfjs-dist NPM模块使用Mozilla的PDF.js从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;
}

让我们探索主要任务:“ LOAD&PARSE 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函数来解析此数据。 flag 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, " "),
}

代码定义一个名为pageTexts的数组,以保存包含页码和每个页面中提取的文本的对象。然后,我们通过使用pdf.numPages来确定页面总数。

在循环中,pdf.getPage(i + 1)从第1页开始获取每个页面。我们用page.getTextContent()提取文本内容。

最后,通过连接所有文本项目并将多个空格减少到一个空间来清理每个页面中提取的文本。此清理文本和页码存储在pageTexts中。

索引页

现在,PDF页面已作为文本可用,我们将深入研究索引我们已加载的PDF文本的机制。索引至关重要,因为它允许以后进行快速和基于语义的信息检索。这是魔术发生的方式:

const pages = await loadPdfPages(file);

const embeddingModel = new OpenAITextEmbeddingModel({
  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 upsertTextChunks({ vectorIndex, embeddingModel, chunks });

让我们看一下每个步骤:

初始化文本嵌入模型

第一步是初始化文本嵌入模型。该模型将负责将我们的文本数据转换为可以进行比较的格式。

const embeddingModel = new OpenAITextEmbeddingModel({
  model: "text-embedding-ada-002",
  throttle: throttleMaxConcurrency({ maxConcurrentCalls: 5 }),
});

文本嵌入模型通过将文本块转换为多维空间中的向量来起作用,以使具有相似含义的文本具有彼此靠近的矢量。这些向量将存储在向量索引中。

令牌化和文本块

我们需要在将文本转换为向量之前准备文本数据。此准备工作涉及将文本分为较小的碎片,称为“块”,这些碎片对于模型都是可管理的。

const chunks = await splitTextChunks(
  splitAtToken({
    maxTokensPerChunk: 256,
    tokenizer: embeddingModel.tokenizer,
  }),
  pages
);

我们将每个块限制在256个令牌上,并使用嵌入模型中的令牌。 splitTextChunks函数会递归地拆分文本,直到块适合指定的最大尺寸。

您可以使用块大小,看看它如何影响结果。当块太小时,它们可能只包含一些必要的信息来回答问题。当块太大时,它们的嵌入向量可能与我们以后产生的假设答案不够相似。

令牌:令牌是机器学习模型读取的最小单元。在语言模型中,令牌可以像字符一样小,或者只有一个单词(例如'a','苹果')。

令牌:将文本分解为令牌的工具。 Modelfusion为大多数文本生成和嵌入模型提供了令牌。

创建内存向量索引

下一步是创建一个空的内存向量索引来存储我们的嵌入式文本向量。

const vectorIndex = new MemoryVectorIndex<{
  pageNumber: number;
  text: string;
}>();

矢量存储就像矢量的专业数据库。它允许我们执行快速搜索以找到给定查询向量的相似向量。

在Modelfusion中,向量索引是一个可搜索的接口,可访问特定表或元数据的向量存储。在我们的应用程序中,索引中的每个向量都与页码及其启动的文本块相关联。

Modelfusion MemoryVectorIndex是使用余弦相似性找到相似向量的向量索引的简单内存实现。对于小数据集来说,这是一个不错的选择,例如按需加载的单个PDF文件。

将文本块插入矢量索引

最后,我们用从块中生成的文本向量填充了我们的内存向量索引。

await upsertTextChunks({ vectorIndex, embeddingModel, chunks });

函数upsertTextChunks执行以下操作:

  • 它使用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(
    new OpenAIChatModel({ model: "gpt-3.5-turbo", temperature: 0 }),
    [
      OpenAIChatMessage.system(`Answer the user's question.`),
      OpenAIChatMessage.user(question),
    ]
  );

  const { chunks: information } = await retrieveTextChunks(
    new SimilarTextChunksFromVectorIndexRetriever({
      vectorIndex,
      embeddingModel,
      maxResults: 5,
      similarityThreshold: 0.75,
    }),
    hypotheticalAnswer
  );

  const textStream = await streamText(
    new OpenAIChatModel({ model: "gpt-4", temperature: 0 }),
    [
      OpenAIChatMessage.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".`
      ),
      OpenAIChatMessage.user(question),
      OpenAIChatMessage.functionResult(
        "getInformation",
        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.js readline软件包来从每个迭代的终端收集用户输入。

产生假设的答案

const hypotheticalAnswer = await generateText(
  new OpenAIChatModel({ model: "gpt-3.5-turbo", temperature: 0 }),
  [
    OpenAIChatMessage.system(`Answer the user's question.`),
    OpenAIChatMessage.user(question),
  ]
);

我们使用OpenAI的gpt-3.5-turbo模型首先创建一个假设的答案。

这个想法(hypothetical document embeddings)是,假设的答案比用户的问题更接近我们在嵌入矢量空间中寻求的块。这种方法将帮助我们在以后搜索类似的文本块时找到更好的结果。

检索相关的文本块

const { chunks: information } = await retrieveTextChunks(
  new SimilarTextChunksFromVectorIndexRetriever({
    vectorIndex,
    embeddingModel,
    maxResults: 5,
    similarityThreshold: 0.75,
  }),
  hypotheticalAnswer
);

retrieveTextChunks()功能搜索类似于预处理的PDF的假设答案的文本块。

我们将结果限制为5,并将相似性阈值设置为0.75。您可以使用这些参数(结合较早的块尺寸设置)来查看它们如何影响结果。例如,当您使块较小时,您可能需要增加结果数量以获取更多信息。

使用文本块生成答案

const textStream = await streamText(
  new OpenAIChatModel({ model: "gpt-4", temperature: 0 }),
  [
    OpenAIChatMessage.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".`
    ),
    OpenAIChatMessage.user(question),
    OpenAIChatMessage.functionResult(
      "getInformation",
      JSON.stringify(information)
    ),
  ]
);

我们使用gpt-4根据检索到的文本块生成最终答案。温度设置为0,以从响应中消除尽可能多的随机性。

在系统提示中,我们指定:

  • 答案应仅基于检索到的文本块。
  • 应包括信息的页码。
  • 如果无法使用提供的信息回答用户的问题,则答案应该是“我不知道”。该指令将LLM引导使用此答案,如果它找不到文本块中的答案。

这些块被插入假函数结果(使用OpenAI function calling 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文件中读取,索引和检索信息。

该代码旨在作为您项目的起点。玩得开心!

P.S。:您可以在此处找到该应用程序的完整代码:github.com/lgrammel/modelfusion/examples/pdf-chat-terminal