如何在没有Langchain的情况下构建PDF聊天机器人?

如何在无Langchain情况下构建PDF聊天机器人?

介绍

自ChatGpt发布以来,AI领域的进展速度没有任何放缓的迹象,每天都有新的工具和技术在被开发。当然,这对企业和AI领域来说是一件好事,但作为一个程序员,你需要学习所有这些工具和技术来构建一些东西吗?嗯,答案是否定的。对此,一个相当实用的方法是只学习你需要的东西。有很多工具和技术承诺可以简化事情,从某种程度上来说,它们确实可以做到。但有时候,我们根本不需要它们。对于简单的用例使用庞大的框架只会让你的代码变得冗余混乱。因此,在本文中,我们将通过构建一个不需要Langchain的CLI PDF聊天机器人来探讨为什么我们并不总是需要AI框架。

学习目标

  • 为什么你不需要像Langchain和Llama Index这样的AI框架。
  • 何时需要框架。
  • 了解向量数据库和索引。
  • 使用Python从头开始构建一个CLI问答聊天机器人。

本文是数据科学博文马拉松的一部分。

你能不用Langchain吗?

在最近几个月中,像Langchain和LLama Index这样的框架因其卓越的能力在开发者中经历了显著的流行,主要是因为它们能够方便地开发LLM应用程序。但对于许多用例来说,这些框架可能会变得过于复杂。这就像是用大炮参加一场枪战。

它们提供了一些在你的项目中可能不需要的功能。Python本来就以臃肿而闻名。在此基础上,添加你几乎不需要的依赖只会让你的环境更加混乱。其中一个这样的用例是文档查询。如果你的项目不涉及AI代理或其他复杂的东西,你可以放弃Langchain,从头开始构建工作流程,从而减少不必要的冗余。此外,类似Langchain或Llama Index的框架正在快速发展中;任何代码重构都有可能破坏你的构建。

什么时候需要Langchain?

如果你有更高级的需求,比如构建一个自动化复杂软件的代理,或者需要更长时间的工程来从头开始构建的项目,使用预构建的解决方案是有道理的。除非你需要一个更好的轮子,否则不要重新发明轮子。还有很多其他类似的例子,使用现成的解决方案进行微调是完全合理的。

构建一个问答聊天机器人

LLM的最受欢迎的应用之一就是文档问答。在OpenAI公开了他们的ChatGPT端点之后,使用任何文本数据源构建一个交互式对话机器人变得更加容易。在本文中,我们将从头开始构建一个LLM问答CLI应用程序。那么,我们如何解决这个问题呢?在构建之前,让我们先了解一下我们需要做什么。

一个典型的工作流程将涉及以下步骤

  • 处理提供的PDF文件以提取文本。
  • 我们还需要对LLM的上下文窗口进行小心处理。因此,我们需要将这些文本划分成块。
  • 要查询相关的文本块,我们需要获取这些文本块的嵌入。为此,我们需要一个嵌入模型。对于这个项目,我们将使用Huggingface的MiniLM-L6-V2模型,你可以选择任何你喜欢的模型,比如OpenAI、Cohere或Google Palm。
  • 为了存储和检索嵌入,我们将使用一个向量数据库,比如Chroma。你可以选择许多不同的向量数据库,比如Qdrant、Weaviate、Milvus等。
  • 当用户发送一个查询时,它将被同样的模型转换为嵌入,并获取与查询意思相似的文本块。
  • 获取的文本块将与查询的末尾连接在一起,并通过API发送给LLM。
  • 从模型获取的答案将返回给用户。

所有这些内容都需要一个用户界面。在本文中,我们将使用Python Argparse构建一个简单的命令行界面。

这是我们CLI聊天机器人的工作流程图:

在进入编码部分之前,让我们先了解一些关于向量数据库和索引的知识。

什么是向量数据库和索引?

顾名思义,向量数据库存储向量或嵌入。那么,为什么我们需要向量数据库?构建任何AI应用程序都需要将真实世界的数据嵌入到机器学习模型中,因为这些原始数据(如文本、图像或音频)无法直接处理。当处理大量需要重复使用的数据时,需要将其存储在某个地方。那么,为什么我们不能使用传统的数据库来存储这些数据?嗯,你可以使用传统的数据库来满足你的搜索需求,但是向量数据库提供了一个重要的优势:除了词汇搜索之外,它们还可以执行向量相似性搜索。

在我们的案例中,每当用户发送一个查询时,向量数据库将对所有嵌入进行向量相似性搜索,并获取K个最近邻。这种搜索机制非常快速,因为它使用了一种叫做HNSW的算法。

HNSW代表Hierarchical Navigable Small World。它是一种基于图的算法和索引方法,用于近似最近邻搜索(ANN)。ANN是一种查找与给定项最相似的k个项的搜索类型。

HNSW的工作原理是构建一个数据点的图。图中的节点表示数据点,图中的边表示数据点之间的相似性。然后通过遍历图来找到与给定项最相似的k个项。

HNSW算法快速、可靠且可扩展。大多数向量数据库都使用HNSW作为默认的搜索算法。

现在,我们已经准备好深入编码。

构建项目环境

与任何Python项目一样,首先创建一个虚拟环境。这样可以保持开发环境的整洁。参考本文选择适合您项目的正确Python环境。

项目文件结构很简单,我们将有两个Python文件,一个用于定义CLI,另一个用于处理、存储和查询数据。还要创建一个.env文件来存储您的OpenAI API密钥。

这是requirements.txt文件,在开始之前先安装它。

#requiremnets.txt
openai
chromadb
PyPDF2
dotenv

现在,导入必要的类和函数。

import os
import openai
import PyPDF2
import re
from chromadb import Client, Settings
from chromadb.utils import embedding_functions
from PyPDF2 import PdfReader
from typing import List, Dict
from dotenv import load_dotenv

从.env文件中加载OpenAI API密钥。

load_dotenv()
key = os.environ.get('OPENAI_API_KEY')
openai.api_key = key

Chatbot CLI的实用函数

为了存储文本嵌入和它们的元数据,我们将使用ChromaDB创建一个集合。

ef = embedding_functions.ONNXMiniLM_L6_V2()
client = Client(settings = Settings(persist_directory="./", is_persistent=True))
collection_ = client.get_or_create_collection(name="test", embedding_function=ef)

作为一个嵌入模型,我们使用的是带有ONNX运行时的MiniLM-L6-V2。它既小巧又强大,并且是开源的。

接下来,我们将定义一个函数来验证提供的文件路径是否属于有效的PDF文件。

def verify_pdf_path(file_path):
    try:
        # 尝试以二进制读模式打开PDF文件
        with open(file_path, "rb") as pdf_file:
            # 使用PyPDF2创建一个PDF阅读器对象
            pdf_reader = PyPDF2.PdfReader(pdf_file)
            
            # 检查PDF是否至少有一页
            if len(pdf_reader.pages) > 0:
                # 如果有页面,PDF不为空,所以什么都不做(跳过)
                pass
            else:
                # 如果没有页面,引发异常,指示PDF为空
                raise ValueError("PDF文件为空")
    except PyPDF2.errors.PdfReadError:
        # 处理无法读取PDF的情况(例如,文件损坏或不是有效的PDF)
        raise PyPDF2.errors.PdfReadError("无效的PDF文件")
    except FileNotFoundError:
        # 处理指定的文件不存在的情况
        raise FileNotFoundError("文件未找到,请再次检查文件地址")
    except Exception as e:
        # 处理其他意外异常并显示错误消息
        raise Exception(f"错误:{e}")

PDF问答应用程序的一个主要部分是获取文本块。因此,我们需要定义一个函数来获取所需的文本块。

def get_text_chunks(text: str, word_limit: int) -> List[str]:
    """
    将文本分割成指定字数限制的块,同时确保每个块包含完整的句子。

    参数:
        text (str):要分割成块的整个文本。
        word_limit (int):每个块的所需字数限制。

    返回:
        List[str]:包含指定字数限制和完整句子的文本块的列表。
    """
    sentences = re.split(r'(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=\.|\?)\s', text)
    chunks = []
    current_chunk = []

    for sentence in sentences:
        words = sentence.split()
        if len(" ".join(current_chunk + words)) <= word_limit:
            current_chunk.extend(words)
        else:
            chunks.append(" ".join(current_chunk))
            current_chunk = words

    if current_chunk:
        chunks.append(" ".join(current_chunk))

    return chunks

我们已经定义了一个获取文本块的基本算法。思路是允许用户在单个文本块中创建尽可能多的单词。每个文本块都将以一个完整的句子结尾,即使它超过了限制。这是一个简单的算法。您可以根据自己的需要创建算法。

创建字典

现在,我们需要一个函数从PDF加载文本并创建一个字典来跟踪属于单个页面的文本块。

def load_pdf(file: str, word: int) -> Dict[int, List[str]]:
    # 从指定的PDF文件创建PdfReader对象
    reader = PdfReader(file)
    
    # 初始化一个空字典来存储提取的文本块
    documents = {}
    
    # 遍历PDF中的每一页
    for page_no in range(len(reader.pages)):
        # 获取当前页面
        page = reader.pages[page_no]
        
        # 从当前页面提取文本
        texts = page.extract_text()
        
        # 使用get_text_chunks函数将提取的文本拆分为长度为'word'的块
        text_chunks = get_text_chunks(texts, word)
        
        # 使用页面号作为键将文本块存储在documents字典中
        documents[page_no] = text_chunks
    
    # 返回包含页面号为键,文本块为值的字典
    return documents

ChromaDB集合

现在,我们需要将数据存储在ChromaDB集合中。

def add_text_to_collection(file: str, word: int = 200) -> None:
    # 加载PDF文件并提取文本块
    docs = load_pdf(file, word)
    
    # 初始化空列表以存储数据
    docs_strings = []  # 存储文本块的列表
    ids = []  # 存储唯一ID的列表
    metadatas = []  # 存储每个文本块的元数据的列表
    id = 0  # 初始化ID
    
    # 遍历加载的PDF中的每个页面和文本块
    for page_no in docs.keys():
        for doc in docs[page_no]:
            # 将文本块添加到docs_strings列表
            docs_strings.append(doc)
            
            # 添加文本块的元数据,包括页面号
            metadatas.append({'page_no': page_no})
            
            # 为文本块添加唯一ID
            ids.append(id)
            
            # 增加ID
            id += 1

    # 将收集到的数据添加到集合中
    collection_.add(
        ids=[str(id) for id in ids],  # 将ID转换为字符串
        documents=docs_strings,  # 文本块
        metadatas=metadatas,  # 元数据
    )
    
    # 返回成功消息
    return "PDF嵌入成功添加到集合中"

在Chromadb中,元数据字段存储有关文档的其他信息。在这种情况下,文本块的页面号是其元数据。在从每个文本块中提取元数据之后,我们可以将它们存储在先前创建的集合中。这只在用户提供有效的PDF文件路径时才需要。

现在,我们将定义一个处理用户查询以从数据库中提取数据的函数。

def query_collection(texts: str, n: int) -> List[str]:
    result = collection_.query(
                  query_texts = texts,
                  n_results = n,
                 )
    documents = result["documents"][0]
    metadatas = result["metadatas"][0]
    resulting_strings = []
    for page_no, text_chunk in zip(metadatas, documents):
        resulting_strings.append(f"第{page_no['page_no']}页:{text_chunk}")
    return resulting_strings

上述函数使用查询方法从数据库中检索“n”个相关数据。然后,我们创建一个格式化的字符串,该字符串以文本块的页码开头。

现在,唯一剩下的主要事项是向LLM提供信息。

def get_response(queried_texts: List[str],) -> List[Dict]:
    global messages
    messages = [
                {"role": "system", "content": "您是一个有帮助的助手。将始终回答'ques:'中提出的问题,并在回答任何问题时引用页码,\
                 它始终位于提示的开头,格式为'第n页'。"},
                {"role": "user", "content": ''.join(queried_texts)}
          ]

    response = openai.ChatCompletion.create(
                            model = "gpt-3.5-turbo",
                            messages = messages,
                            temperature=0.2,               
                     )
    response_msg = response.choices[0].message.content
    messages = messages + [{"role":'assistant', 'content': response_msg}]
    return response_msg

全局变量messages存储对话的上下文。我们定义了一条系统消息,以打印LLM获取答案的页码。

最后,最终的实用函数将获取的文本块与用户查询合并,将其输入get_response()函数,并返回生成的答案字符串。

def get_answer(query: str, n: int):
    queried_texts = query_collection(texts = query, n = n)
    queried_string = [''.join(text) for text in queried_texts]
    queried_string = queried_string[0] + f"ques: {query}"
    answer = get_response(queried_texts = queried_string,)
    return answer

我们完成了实用函数。现在让我们继续构建CLI。

Chatbot CLI

为了按需使用聊天机器人,我们需要一个界面。这可以是Web应用程序、移动应用程序或CLI。在本文中,我们将为聊天机器人构建一个CLI。如果您想构建一个外观漂亮的演示Web应用程序,可以使用Gradio或Streamlit等工具。查看这篇关于构建PDF聊天机器人的文章。

使用Langchain为PDF构建ChatGPT

为了构建CLI,我们将需要Argparse库。Argparse是一个强大的库,可以让您在Python中创建CLI。它具有简单易用的语法,用于创建命令、子命令和标志。因此,在深入研究之前,这里有一个关于Argparse的简短介绍。

Python Argparse

Argparse模块首次在Python 3.2中发布,为使用Python构建CLI应用程序提供了一种快速便捷的方式,无需依赖第三方安装。它允许我们解析命令行参数,在CLI中创建子命令,以及许多其他功能,使其成为构建CLI的可靠工具。

以下是Argparse实际应用的一个小例子:

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("-f", "--filename", help="要读取的文件名。")
parser.add_argument("-n", "--number", help="要打印的行数。", type=int)
parser.add_argument("-s", "--sort", help="对文件中的行进行排序。", action="store_true")

args = parser.parse_args()

with open(args.filename) as f:
    lines = f.readlines()

if args.sort:
    lines.sort()

for line in lines:
    print(line)

add_argument方法让我们定义带有检查和平衡的子命令。我们可以定义参数的类型或当提供标志时它需要采取的操作,以及解释特定子命令用例的帮助参数。帮助子命令将显示所有标志及其用例。

类似地,我们将为聊天机器人CLI定义子命令。

构建CLI

导入Argparse和必要的实用函数。

import argparse
from utils import (
    add_text_to_collection, 
    get_answer, 
    verify_pdf_path, 
    clear_coll
  )

定义参数解析器并添加参数。

def main():
    # 创建一个带有描述的命令行参数解析器
    parser = argparse.ArgumentParser(description="PDF处理CLI工具")
    
    # 定义命令行参数
    parser.add_argument("-f", "--file", help="输入PDF文件的路径")
    
    parser.add_argument(
        "-c", "--count",
        default=200, 
        type=int, 
        help="单个块中的单词数的可选整数值"
    )
    
    parser.add_argument(
        "-q", "--question", 
        type=str,
        help="提出问题"
    )
    
    parser.add_argument(
        "-cl", "--clear", 
        type=bool, 
        help="清除现有的集合数据"
    )
    
    parser.add_argument(
        "-n", "--number", 
        type=int, 
        default=1, 
        help="从集合中获取的结果数量"
    )

    # 解析命令行参数
    args = parser.parse_args()

我们定义了一些子命令,例如 -file,-value,-question等。

  • -file:PDF的字符串文件路径。
  • -value:定义文本块中单词数的可选参数值。
  • -question:将用户查询作为参数。
  • -number:要获取的相似块的数量。
  • -clear:清除当前的Chromadb集合。

现在,我们处理这些参数;

 if args.file is not None:
        verify_pdf_path(args.file)
        confirmation = add_text_to_collection(file = args.file, word = args.value)
        print(confirmation)

 if args.question is not None:
        if args.number:
            n = args.number
        answer = get_answer(args.question, n = n)
        print("答案:", answer)

 if args.clear:
        clear_coll()
        return "当前集合已成功清除"

将所有内容放在一起。

import argparse
from app import (
    add_text_to_collection, 
    get_answer, 
    verify_pdf_path, 
    clear_coll
)

def main():
    # 创建一个带有描述的命令行参数解析器
    parser = argparse.ArgumentParser(description="PDF处理CLI工具")
    
    # 定义命令行参数
    parser.add_argument("-f", "--file", help="输入PDF文件的路径")
    
    parser.add_argument(
        "-c", "--count",
        default=200, 
        type=int, 
        help="单个块中的单词数的可选整数值"
    )
    
    parser.add_argument(
        "-q", "--question", 
        type=str,
        help="提出问题"
    )
    
    parser.add_argument(
        "-cl", "--clear", 
        type=bool, 
        help="清除现有的集合数据"
    )
    
    parser.add_argument(
        "-n", "--number", 
        type=int, 
        default=1, 
        help="从集合中获取的结果数量"
    )

    # 解析命令行参数
    args = parser.parse_args()
    
    # 检查是否提供了'--file'参数
    if args.file is not None:
        # 验证PDF文件路径并将其文本添加到集合中
        verify_pdf_path(args.file)
        confirmation = add_text_to_collection(file=args.file, word=args.count)
        print(confirmation)

    # 检查是否提供了'--question'参数
    if args.question is not None:
        n = args.number if args.number else 1  # 将'n'设置为指定的数字或默认为1
        answer = get_answer(args.question, n=n)
        print("答案:", answer)

    # 检查是否提供了'--clear'参数
    if args.clear:
        clear_coll()
        print("当前集合已成功清除")

if __name__ == "__main__":
    main()

现在打开终端并运行以下脚本。

 python cli.py -f "path/to/file.pdf" -v 1000 -n 1  -q "query"

要删除收藏夹,请键入

python cli.py -cl True

如果提供的文件路径不属于PDF文件,将引发FileNotFoundError。

Github代码库:https://github.com/sunilkumardash9/pdf-cli-chatbot

实际应用场景

作为CLI工具运行的聊天机器人可以在许多实际应用中使用,例如:

学术研究:研究人员经常处理大量的PDF格式的研究论文和文章。CLI聊天机器人可以帮助他们提取相关信息、创建参考文献并高效地组织他们的参考资料。

语言翻译:语言专业人士可以使用聊天机器人从PDF中提取文本,进行翻译,然后从命令行生成翻译后的文档。

教育机构:教师和教育工作者可以从教育资源中提取内容,创建定制的学习材料或准备课程内容。学生可以从聊天机器人CLI中提取大型PDF中的有用信息。

开源项目管理:CLI聊天机器人可以帮助开源软件项目管理文档,提取代码片段,并从PDF手册生成发布说明。

结论

因此,这就是用不使用诸如Langchain和Llama Index等框架构建的PDF问答聊天机器人CLI的全部内容。以下是我们涵盖的内容的快速总结。

  • Langchain和其他AI框架是开始进行AI开发的好方法。然而,重要的是要记住它们不是万能药。它们可能会使您的代码更复杂,并可能导致臃肿,因此只在需要时使用它们。
  • 在项目复杂度需要更长的工程时间的情况下,使用框架是有意义的。
  • 可以从第一原理开始设计一个不依赖于Langchain等框架的文档问答工作流。

这就是全部内容了。希望您喜欢这篇文章。

常见问题

本文中显示的媒体不归Analytics Vidhya所有,是根据作者的自由裁量使用的。