使用Next.js + Supabase + GPT构建的文档问答聊天机器人
#javascript #网络开发人员 #react #chatgpt

这一切始于几个月前dev.to主持的黑客马拉松。我想构建一个文档质量检查机器人,其中所有的AI嗡嗡声都在进行,我开始进行一些研究以及完成该项目所需的工具。就像每个普通开发人员一样,我以为该项目只需我一个星期才能完成,但一切都向南完成,我花了两次尝试找出所需的正确工具,最后构建一个有效利用所有工具的应用程序。这是真实的故事...

第一枪

我进行了一些研究,发现impira/layoutlm-document-qa模型将适合根据文件处理实际问题和答案。该模型不能将整个PDF文档作为输入,因此我必须split the PDF pages as images,然后将页面传递到HuggingFace推理API以获取答案

该应用程序在所有集成方面都可以正常运行,但是该模型受自然语言的处理限制。该模型非常适合基于简单文档(例如发票或收据)获得准确的单词答案,但是在处理复杂文档(例如技术手册或产品手册)时,该模型会以错误的答案或只是响应没有人触摸的单一世界回答

该模型不应该在这里怪,因为它并不是要与文档作为上下文进行随意对话

第二次尝试

考虑第一种方法的局限性,我尝试了另一种方法,这次我计划使用gpt-3.5,因为它更强大,并且是一个合适的LLM,这已被证明是自然对话的可行选择(CHATGPT)

我构建了一个Python应用程序,该应用程序将PDF文档分配为图像,并使用tesseract-ocr + pytesseract从文档中提取文本内容。然后,我将提取的文本设置为生成提示的上下文,并将其运送到与文档相关的所有问题。我也有机会修补langchain

这就像魅力一样!我从这个项目中得到了一些想法,并计划扩展它。

最后,MVP来了

随着我从第二次尝试获得的学习,我坚持使用GPT作为核心LLM。我不想分别构建后端和前端,所以我选择了下一步。JS13作为处理两者的一站式解决方案

在介绍详细信息之前,以下是用于构建应用程序的核心工具

arch

应用程序的用户旅程以及每个用户操作的幕后内容如下,

上传文档

[video-to-gif output image]

旅程始于用户上传PDF文档。为此,我使用了react-dropzone库。该库支持拖放和基于点击的上传。用户将文件放入输入中后,将文件发送到后端进行处理

import { useDropzone } from 'react-dropzone';

const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop: (acceptedFiles) => {
        //check if acceptedFiles array is empty and proceed
        //call the API with the file as the payload
        const formData = new FormData();
        formData.append('file', acceptedFiles[0]);
        axios.post("/api/upload", { data: formData });
    },
    multiple: false, //to prevent multi file upload
    accept: {
      'application/pdf': ['.pdf'] //if the file is not a PDF, then the list will be empty
    }
});

/upload api照顾三件事

  • 将文档上传到supabase存储存储桶

  • 从文档中提取内容

  • 在supabase数据库中坚持文档详细信息

// Route to handle the upload and document processing

import { NextResponse } from 'next/server';
import { loadQAChain } from 'langchain/chains';
import { Document } from 'langchain/document';
import { PDFLoader } from 'langchain/document_loaders/fs/pdf';
import { supabase } from '../supabase';

export const POST = async (req) => {
  const form = await req.formData();
  const file = form.get('file');

  // Using langchain PDFLoader to extract the content of the document
  const docContent = await new PDFLoader(file, { splitPages: false })
    .load()
    .then((doc) => {
      return doc.map((page) => {
        return page.pageContent
          .replace(/\n/g, ' '); // It is recommended to use the context string with no new lines
      });
    });

  const fileBlob = await file.arrayBuffer();
  const fileBuffer = Buffer.from(fileBlob);

  // Uploading the document to supabase storage bucket
  await supabase()
    .storage.from(bucket)
    .upload(`${checksum}.pdf`, fileBuffer, {
      cacheControl: '3600',
      upsert: true,
      contentType: file.type
    });

  // storing the document details to supabase DB
  await supabase
        .from("documents_table")
        .insert({
            document_content: docContent,
            // insert other relevant document details
        });

  return NextResponse.json({ message: "success" }, { status: 200 });
};

初始化插座

应用程序使用socket.io以非阻滞方式发送和接收消息。成功上传文档后,UI调用API -/api/socket打开插座服务器连接

设置socket.io服务器通常很容易,但是接下来对此有些挑战。它(或者至少我找不到任何文档)。因此,我必须使用旧的PageRouter范式来初始化套接字服务器连接

处理程序文件=> src/pages/api/socket.js

必需依赖项=> yarn add socket.io socket.io-client

import { Server } from 'socket.io';

export default function handler(req, res) {
  const io = new Server(res.socket.server, {
    path: '/api/socket_io',
    addTrailingSlash: false
  });

  res.socket.server.io = io;

  // When the UI invokes the /api/socket endpoint, it opens a new socket connection
  io.on('connection', (socket) => {
    socket.on('message', async (data) => {
      // For every user message from the UI, this event will be triggered
      const parsed = JSON.parse(data);
      const { message } = parsed;

      // pass on the question and content from the message to Langchain
    });
  });

  res.end();
}

真正的聊天

[video-to-gif output image]

现在我们可以方便地使用文档内容,并且套接字打开以接收事件,该进行一些聊天了。

每次用户输入新消息时,UI都会发出一个名为message的插座事件。此消息将在有效载荷中包含2个重要内容,

  • 实际问题

  • 从文档中提取的内容(我们将作为/upload API的响应)

我们已经在初始化套接字服务器时已经设置了事件侦听器,并且我们将在此侦听器中进行实际的LLM工作,以从GPT中获取答案。

必需依赖项=> yarn add openai pdf-parse langchain

import { Document } from 'langchain/document';
import { loadQAStuffChain } from 'langchain/chains';
import { llm } from '@/app/api/openai';

export default function handler(req, res) {
  const io = new Server(res.socket.server, {
    path: '/api/socket_io',
    addTrailingSlash: false
  });

  res.socket.server.io = io;

  // When the UI invokes the /api/socket endpoint, it opens a new socket connection
  io.on('connection', (socket) => {
    socket.on('message', async (data) => {
      // For every user message from the UI, this event will be triggered
      const parsed = JSON.parse(data);
      const { question, content } = parsed;

      const llm = new OpenAI({
          openAIApiKey: process.env.OPENAI_API_KEY,
          modelName: 'gpt-3.5-turbo'
      });

      // We will be using the stuff QA chain
      // This is a very simple chain that sets the entire doc content as the context
      // Will be suitable for smaller documents
      const chain = loadQAStuffChain(llm, { verbose: true });

      const docs = [new Document({ pageContent: content })];

      const { text } = await chain.call({
        input_documents: docs,
        question
      });

      // Emitting the response from GPT back to the UI
      socket.emit('ai_message', JSON.stringify({ message: text }))
    });
  });

  res.end();
}

在上面的片段中,我们使用Stuff QA chain,这是一个简单而适合较小文档的Stuff QA chain。我们生成一个具有目标文档内容的新的Document对象数组。最后一步是调用call方法将有效载荷传递给OpenAI API并获取响应

幕后,兰班链以以下格式生成一个提示,并将其发送到OpenAI API以获取答案

Use the following pieces of context to answer the question at the end. If you don't know the answer, just say that you don't know, don't try to make up an answer.

<the_entire_document_content>

Question: <some question>?

Helpful Answer:

对于您提出的每个问题,上述提示将相同,并且文档内容将作为上下文传递。

收到API的响应时,我们发出了一个名为ai_message的新事件。 UI具有此事件的事件侦听器,我们处理基于消息显示聊天气泡的逻辑

socket?.on("ai_message", async (data) => {
  setConversations((prev) => {
    return [
      ...prev,
      {
        user: "ai",
        message: JSON.parse(data).message,
      },
    ];
  });
});

这结束了整个流程

幕后

上面提到的代码片段是修剪版本,只有本文所需的关键项。以下是应用程序处理的一些内容QA

坚持文档详细信息:文档详细信息,例如文档的校验和原始文档名称和文档的内容,存储在Supabase DB中。此数据将用于在UI上显示聊天历史记录,并使用户可以随时返回其对话

[video-to-gif output image]

坚持对话:用户发送并由AI生成的聊天消息也在数据库中持续存在。用户可以单击任何文档的聊天历史记录,并查看所有交换的对话

[video-to-gif output image]

存储实际文档:用户上传的原始PDF文档存储在supabase储物桶中。这是为了使用户从聊天部分下载文档以查看最初上传的内容

[video-to-gif output image]

用户身份验证:我想尝试Supabase身份验证,因此我在应用程序中添加了登录流量。借助Supabase的行级安全性(RLS)和策略,聊天历史记录和对话仅显示给认证的用户

还有一件事...

如果您的目标只是与只有几页的小文档聊天,那么您可以跳过此部分

直到这一点,我们所看到的一切都将对文本内容不超过4页的文档有用。例如,如果您想与经常超过10页的研究论文聊天,那么每个问题来回发送文档的整个内容成为一个可扩展性问题。我最初是用著名的research paper about transformers进行了测试,该research paper about transformers具有超过40K字符,并且应用程序用该内容扼杀了。所以我不得不重新考虑解决方案

Embeddings进行救援...嵌入是载体,这些载体具有一堆数字,表示不同句子或单词之间的相似性或亲密关系。为了解决这个问题,我做了以下

  • 将提取的文档内容分为较小的块

  • 为每个块生成openai嵌入

    import { PDFLoader } from 'langchain/document_loaders/fs/pdf';
    import { CharacterTextSplitter } from 'langchain/text_splitter';
    import { MemoryVectorStore } from 'langchain/vectorstores/memory';
    
    export const extractDocumentContent = async (file) => {
      const chunks = await new PDFLoader(file).loadAndSplit(
        new CharacterTextSplitter({
          chunkSize: 5000,
          chunkOverlap: 100,
          separator: ' '
        })
      );
    
      const openAIEmbedding = new OpenAIEmbeddings({
        openAIApiKey: process.env.OPENAI_API_KEY,
        modelName: 'text-embedding-ada-002'
      });
    
      const store = await MemoryVectorStore.fromDocuments(chunks, openAIEmbedding);
    
      const splitDocs = chunks.map((doc) => {
        return doc.pageContent.replace(/\n/g, ' ');
      });
    
      return {
        wholeContent: splitDocs.join(''),
        chunks: {
          content: splitDocs,
          embeddings: await store.embeddings.embedDocuments(splitDocs)
        }
      };
    };
    
  • 将块及其各自的嵌入存储在Subapabse DB中。 This blog可以转介到Supabase中的vector数据类型
    中知道如何使用vector

    const saveDocumentChunks = async (file) => {
      // Invoke the function from above to get the chunks
      const { chunks } = await extractDocumentContent(file);
      const { content, embeddings } = chunks;
    
      // store the content from the chunk and its respective embedding to the DB
      // for context, a simple embedding vector will look something like this
      // [-0.021596793,0.0027229148,0.019078722,-0.019771526, ...]
      for (let i = 0; i < content.length; i++) {
        const { error } = await supabase
          .from('document_chunks')
          .insert({
            chunk_number: i + 1,
            chunk_content: content[i],
            chunk_embedding: embeddings[i] // chunk_embedding is of type `vector` in the Database
          });
    
        if (error) {
          return { error }
      }
    
      // Be mindful of implementing a rollback strategy even if storing a single chunk fails
      return { error: null };
    };
    
  • 使用用户的问题在矢量数据库上进行相似性搜索,以仅过滤相关的块(我们需要设置supabase plpgsql函数以基于相似性对块进行排名)

    create function match_documents (
        query_embedding vector(1536),
        match_count int default null,
        filter_checksum varchar DEFAULT ''
    ) returns table (
        document_checksum varchar,
        chunk_content text,
        similarity float
    ) language plpgsql as $ $ #variable_conflict use_column
    begin return query
    select
        document_checksum,
        chunk_content,
        1 - (
            document_chunks.chunk_embedding <= > query_embedding
        ) as similarity
    from
        document_chunks
    where
        document_checksum = filter_checksum
    order by
        document_chunks.chunk_embedding <= > query_embedding
    limit
        match_count;
    end;
    $ $;
    
  • 使用过滤的块作为回答问题的背景

    import { Document } from 'langchain/document';
    import { loadQAStuffChain } from 'langchain/chains';
    import { OpenAIEmbeddings } from 'langchain/embeddings/openai';
    
    const inference = async (question) => {
      const openAIEmbedding = new OpenAIEmbeddings({
        openAIApiKey: process.env.OPENAI_API_KEY,
        modelName: 'text-embedding-ada-002'
      });
    
      const { data, error } = await supabase.rpc('match_documents', {
        query_embedding: await openAIEmbedding.embedQuery(question),
        match_count: 5,
        filter_checksum: "unique_document_checksum"
      });
    
      if (error) return { error };
    
      const content = data.map((v) => v.chunk_content}).join(' ')
    
      // set the `content` as the context of the prompt and ask the questions
      const chain = loadQAStuffChain(llm, { verbose: true });
      const docs = [new Document({ pageContent: content })];
    
      const { text } = await chain.call({
        input_documents: docs,
        question
      });
    
      return { answer: text };
    };
    

有了这些改进,我上传了同一份研究论文并开始对话。令我惊讶的是,它在第​​一次尝试中工作,并轻松地回馈答案

demo

在这种方法中,您需要为每个消息传递文档内容,因为该内容将根据问题及其与内容的相关性从DB中获取。

代码在哪里?

我已经在GitHub上发布了整个项目,并使用指令在本地设置该项目并设置Supabase Project

Github project

Demo

结论

就像他们说的“第三次是魅力”。最后,经过两次失败的尝试,我建立了一个应用程序,该应用程序与GPT处理问题的所有集成和准确性有时会感到神秘。您可以通过克隆存储库并运行该应用程序在本地尝试该应用程序。只需确保您已经建立了一个工作的Supabase项目,并准备在Openai上花几美元即可。

快乐黑客!

参考

Langchain

Next.JS 13

Supabase

https://supabase.com/blog/openai-embeddings-postgres-vector

https://js.langchain.com/docs/modules/data_connection/vectorstores/integrations/supabase