DeepSeek开启智能PDF问答新时代:从0到1搭建系统全攻略

  • King
  • 发布于 3天前
  • 阅读 369

在当今信息爆炸的时代,PDF文档作为一种广泛使用的文件格式,承载着大量的信息。无论是学术研究中的论文、企业的报告资料,还是各类技术文档,快速从PDF中提取关键信息并进行智能问答,成为了提高工作和学习效率的关键需求。例如,在学术研究场景中,研究人员需要从大量的学术论文PDF中快速定位到

在当今信息爆炸的时代,PDF 文档作为一种广泛使用的文件格式,承载着大量的信息。无论是学术研究中的论文、企业的报告资料,还是各类技术文档,快速从 PDF 中提取关键信息并进行智能问答,成为了提高工作和学习效率的关键需求。

例如,在学术研究场景中,研究人员需要从大量的学术论文 PDF 中快速定位到关键实验数据、研究结论等;在企业场景里,员工需要从繁杂的业务报告 PDF 中迅速获取关键业务指标、市场分析等内容。

为了满足这一需求,构建了一个智能 PDF 问答系统。通过整合多种技术和工具,实现了从 PDF 文件加载、内容分块处理、生成嵌入向量、建立向量存储和索引,到最终构建检索增强生成(RAG)智能问答代理的完整流程。

下面,我们将对代码进行逐段分析,深入了解其实现细节和技术原理。

代码依赖解析

(一)anyhow 库

anyhow库是 Rust 中用于简化错误处理的强大工具。在传统的 Rust 错误处理中,Result类型要求明确指定错误类型,这在处理复杂业务逻辑或涉及多个库的交互时,会导致错误类型的管理变得繁琐。而anyhow库通过提供anyhow::Error类型,统一了错误处理方式,使得开发者可以更简洁地处理各种错误。

例如,在load_pdf函数中,PdfFileLoader::with_glob方法调用可能会因为文件路径无效、文件读取失败等多种原因返回错误,使用anyhow库后,这些不同来源的错误都可以统一用anyhow::Result来处理,通过?操作符轻松传播错误,避免了大量重复的错误处理代码。

anyhow库的Context trait 为错误处理增添了丰富的上下文信息。以load_pdf函数中的path.to_str().context("Invalid path")?为例,当path.to_str()返回错误时,context方法会为这个错误添加"Invalid path"的上下文信息,这在调试过程中能帮助开发者快速定位错误发生的具体位置和原因,极大地提高了调试效率。

通过这种方式,anyhow库让错误处理不仅简单高效,还能提供详细的错误上下文,增强了代码的可维护性和可读性。

(二)rig 库

rig库是构建本智能 PDF 问答系统的核心库,它提供了一系列简洁而强大的抽象层,用于处理与大语言模型(LLM)相关的各种任务。在 PDF 处理方面,rig::loaders::PdfFileLoader模块负责从 PDF 文件中读取内容。它能够解析 PDF 文件的结构,将其中的文本内容提取出来,为后续的处理提供原始数据。在实际应用中,对于一份包含大量学术内容的 PDF 论文,PdfFileLoader可以准确地提取出其中的文字信息,为后续的分块和嵌入处理做准备。

rig::embeddings::EmbeddingsBuilder模块则专注于生成文本的嵌入向量。嵌入向量是一种将文本转换为数值向量的表示方式,它能够捕捉文本的语义信息,使得计算机可以更好地理解和处理文本。EmbeddingsBuilder通过与指定的嵌入模型(如代码中的bge-m3模型)协同工作,将输入的文本转换为对应的嵌入向量。在处理大量文本数据时,它能够高效地生成准确的嵌入向量,为后续的向量存储和检索提供基础。

rig::vector_store::in_memory_store::InMemoryVectorStore模块实现了内存中的向量存储功能。它将生成的嵌入向量存储在内存中,并建立相应的索引,以便快速进行相似性搜索。在实际应用中,当用户输入问题时,系统可以通过这个向量存储和索引快速找到与问题相关的文本块,为生成准确的回答提供支持。例如,当用户询问关于某个特定技术的问题时,系统可以从向量存储中快速检索出相关的 PDF 文本块,从而生成有针对性的回答。

rig库的优势在于它能够无缝集成主流的 LLM 提供商(如 OpenAI, DeepSeek)和向量存储,通过极少的样板代码即可将 LLM 功能集成到应用程序中。它全面支持 LLM 补全和嵌入式工作流,使得开发者可以专注于业务逻辑的实现,而无需过多关注底层的实现细节。在构建智能问答系统时,rig库提供的这些功能可以大大简化开发流程,提高开发效率,同时保证系统的高性能和稳定性。

(三)serde 库

serde库在 Rust 中主要用于数据的序列化和反序列化操作。序列化是将数据结构转换为一种可存储或传输的格式(如 JSON、Bincode 等)的过程,而反序列化则是将存储或传输的格式转换回数据结构的过程。在本代码中,serde库主要用于Document结构体的序列化和反序列化。

Document结构体用于表示文档数据,它包含idcontent两个字段。通过在Document结构体上使用#[derive(Serialize, Deserialize)]注解,serde库会自动为该结构体生成序列化和反序列化的代码。在将文档数据存储到向量存储中时,需要将Document结构体序列化为特定的格式,以便存储和传输;而在从向量存储中读取数据时,则需要将存储的格式反序列化为Document结构体,以便后续的处理和使用。例如,在将文档数据保存到磁盘或通过网络传输时,serde库可以将Document结构体转换为 JSON 格式的字符串,而在读取数据时,又可以将 JSON 字符串转换回Document结构体,确保数据的正确传输和使用。

(四)std::path::PathBuf

std::path::PathBuf是 Rust 标准库中用于处理文件路径的类型。它提供了一种灵活且安全的方式来操作文件路径,支持跨平台的路径表示和操作。在本代码中,PathBuf主要用于加载 PDF 文件时指定文件路径。

load_pdf函数中,PathBuf被用于构建 PDF 文件的路径。通过std::env::current_dir()?.join("documents")获取当前目录并拼接documents文件夹路径,再通过documents_dir.join("Moores_Law_for_Everything.pdf")进一步拼接具体的 PDF 文件名,最终得到完整的文件路径。这种方式使得文件路径的构建清晰、易读,并且能够适应不同操作系统的路径分隔符,确保在不同平台上都能正确加载 PDF 文件。在 Windows 系统中,路径分隔符为\,而在 Linux 和 macOS 系统中为/PathBuf能够自动处理这些差异,保证代码的跨平台性。

核心代码解读

(一)Document 结构体定义

#[derive(Embed, Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
struct Document {
    id: String,
    #[embed]
    content: String,
}

Document结构体用于表示文档数据,它包含两个字段:idcontentid字段是一个String类型,用于唯一标识每个文档,在实际应用中,它可以帮助系统快速定位和管理不同的文档。content字段同样是String类型,用于存储文档的具体内容,是后续文本处理和分析的基础。

#[derive(Embed, Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]是 Rust 的派生宏,为Document结构体自动生成一系列的方法。Embedrig库提供的特征(trait),用于标记该结构体可以生成嵌入向量,这使得Document结构体能够与rig库的嵌入生成功能无缝对接。Clone特征允许结构体进行克隆操作,方便在需要复制数据时使用。Debug特征为结构体提供了调试输出的功能,便于开发者在调试过程中查看结构体的内容。SerializeDeserialize特征是serde库提供的,用于实现结构体的序列化和反序列化,使得Document结构体可以方便地进行存储和传输。EqPartialEq特征用于比较结构体的相等性,在进行数据处理和查找时非常有用。

#[embed]属性是rig库特有的,它标记content字段用于生成嵌入向量。在生成嵌入向量时,rig库会根据这个属性来确定使用哪个字段的内容进行嵌入计算,确保文本内容能够准确地转换为数值向量,为后续的向量存储和检索提供支持。

(二)load_pdf 函数

fn load_pdf(path: PathBuf) -> Result<Vec<String>> {
    const CHUNK_SIZE: usize = 2000;

    let content_chunks = PdfFileLoader::with_glob(path.to_str().context("Invalid path")?)?
        .read()
        .into_iter()
        .filter_map(|result| {
            result
                .map_err(|e| {
                    eprintln!("Error reading PDF content: {}", e);
                    e
                })
                .ok()
        })
        .flat_map(|content| {
            let mut chunks = Vec::new();
            let mut current = String::new();

            for word in content.split_whitespace() {
                if current.len() + word.len() + 1 > CHUNK_SIZE && !current.is_empty() {
                    chunks.push(std::mem::take(&mut current).trim().to_string());
                }
                current.push_str(word);
                current.push(' ');
            }

            if !current.is_empty() {
                chunks.push(current.trim().to_string());
            }

            chunks
        })
        .collect::<Vec<_>>();

    if content_chunks.is_empty() {
        anyhow::bail!("No content found in PDF file: {}", path.display());
    }

    Ok(content_chunks)
}

load_pdf函数的作用是从指定路径的 PDF 文件中读取内容,并将其分块处理成大小合适的文本块。该函数接受一个PathBuf类型的参数path,用于指定 PDF 文件的路径。函数返回一个Result<Vec<String>>类型的值,其中Ok变体包含分块后的文本内容向量,Err变体则包含可能发生的错误。

函数内部首先定义了一个常量CHUNK_SIZE,表示每个文本块的最大大小为 2000 个字符。然后,通过PdfFileLoader::with_glob方法尝试打开指定路径的 PDF 文件。path.to_str().context("Invalid path")?用于将PathBuf转换为字符串,并在转换失败时提供"Invalid path"的上下文信息,通过?操作符传播可能发生的错误。

PdfFileLoader::with_glob返回的结果是一个Result类型,通过?操作符处理可能的错误后,调用read方法读取 PDF 文件的内容。read方法返回一个迭代器,其中每个元素都是一个Result<String>,表示读取到的每一页的内容。通过filter_map方法对迭代器中的每个元素进行处理,将读取失败的结果转换为None,并打印错误信息,读取成功的结果则转换为Some

接着,使用flat_map方法对处理后的迭代器进行进一步处理。在flat_map的闭包中,将每个 PDF 页面的内容按空格分割成单词,然后将单词逐个添加到current字符串中。当current字符串的长度加上下一个单词的长度超过CHUNK_SIZE时,将current字符串的内容作为一个文本块添加到chunks向量中,并清空current字符串。最后,如果current字符串中还有剩余内容,也将其作为一个文本块添加到chunks向量中。

最后,检查content_chunks向量是否为空,如果为空,则使用anyhow::bail!宏抛出一个错误,提示在 PDF 文件中未找到内容。如果content_chunks不为空,则返回包含分块后文本内容的Ok结果。

(三)main 函数

#[tokio::main]
async fn main() -> Result<()> {
    // Initialize Ollama client
    let client = openai::Client::from_url("ollama", "http://localhost:11434/v1");

    // Load PDFs using Rig's built-in PDF loader
    let documents_dir = std::env::current_dir()?.join("documents");

    let pdf_chunks =
        load_pdf(documents_dir.join("Moores_Law_for_Everything.pdf")).context("Failed to load pdf documents")?;

    println!("Successfully loaded and chunked PDF documents");

    // Create embedding model
    let model = client.embedding_model("bge-m3");

    // Create embeddings builder
    let mut builder = EmbeddingsBuilder::new(model.clone());

    // Add chunks from pdf documents
    for (i, chunk) in pdf_chunks.into_iter().enumerate() {
        builder = builder.document(Document {
            id: format!("pdf_document_{}", i),
            content: chunk,
        })?;
    }

    // Build embeddings
    let embeddings = builder.build().await?;

    println!("Successfully generated embeddings");

    // Create vector store and index
    let vector_store = InMemoryVectorStore::from_documents(embeddings);
    let index = vector_store.index(model);

    println!("Successfully created vector store and index");

    // Create RAG agent
    let rag_agent = client
        .agent("deepseek-r1")
        .preamble("You are a helpful assistant that answers questions based on the provided document context. When answering questions, try to synthesize information from multiple chunks if they're related.")
        .dynamic_context(1, index)
        .build();

    println!("Starting CLI chatbot...");

    // Start interactive CLI
    rig::cli_chatbot::cli_chatbot(rag_agent).await?;

    Ok(())
}

初始化 Ollama 客户端

let client = openai::Client::from_url("ollama", "http://localhost:11434/v1");

通过openai::Client::from_url方法创建一个 Ollama 客户端实例。第一个参数ollama用于标识客户端的名称或配置,第二个参数http://localhost:11434/v1是 Ollama 服务的 URL 地址,这里指向本地运行的 Ollama 服务,通过这个客户端,后续可以与指定的 Ollama 服务进行交互,如创建嵌入模型、构建 RAG 代理等。

注:周末向 ollama 客户端官方提交了一个 PR,但很遗憾,在此之前已经有人先行提交了。目前正在耐心等待新版本发布,待发布后便能直接使用原生版 ollama 客户端,值得期待

加载 PDF 文件

    let documents_dir = std::env::current_dir()?.join("documents");

    let pdf_chunks =
        load_pdf(documents_dir.join("Moores_Law_for_Everything.pdf")).context("Failed to load pdf documents")?;

首先通过std::env::current_dir()获取当前工作目录,然后使用join方法拼接"documents"文件夹路径,得到文档目录。接着,在文档目录下拼接"Moores_Law_for_Everything.pdf"文件名,调用load_pdf函数加载并分块处理该 PDF 文件。如果加载过程中发生错误,context方法会为错误添加"Failed to load pdf documents"的上下文信息,方便调试和错误处理。最后,打印成功加载和分块 PDF 文档的提示信息。

创建嵌入模型和生成嵌入

    let model = client.embedding_model("bge-m3");

    // Create embeddings builder
    let mut builder = EmbeddingsBuilder::new(model.clone());

    // Add chunks from pdf documents
    for (i, chunk) in pdf_chunks.into_iter().enumerate() {
        builder = builder.document(Document {
            id: format!("pdf_document_{}", i),
            content: chunk,
        })?;
    }

    // Build embeddings
    let embeddings = builder.build().await?;

通过 Ollama 客户端的embedding_model方法创建一个名为"bge-m3"的嵌入模型。然后创建一个EmbeddingsBuilder实例,用于构建嵌入向量。通过遍历pdf_chunks中的每个文本块,使用builder.document方法将每个文本块包装成Document结构体,并添加到EmbeddingsBuilder中,同时为每个文档生成唯一的id。最后,调用builder.build().await方法异步生成嵌入向量,await关键字用于等待异步操作完成,生成成功后打印提示信息。

创建向量存储和索引

    let vector_store = InMemoryVectorStore::from_documents(embeddings);
    let index = vector_store.index(model);

使用InMemoryVectorStore::from_documents方法将生成的嵌入向量存储到内存中的向量存储中。然后,通过向量存储的index方法创建一个基于嵌入模型的索引,这个索引可以加速后续的相似性搜索操作。最后,打印成功创建向量存储和索引的提示信息。

创建 RAG 代理

    let rag_agent =  client
        .agent("deepseek-r1")
        .preamble("You are a helpful assistant that answers questions based on the provided document context. When answering questions, try to synthesize information from multiple chunks if they're related.")
        .dynamic_context(1, index)
        .build();

通过 Ollama 客户端的agent方法创建一个名为"deepseek-r1"的 RAG 代理。preamble方法用于设置代理的预定义提示,这里设置代理为一个基于文档上下文回答问题的助手,并提示在回答问题时尝试综合多个相关文本块的信息。dynamic_context方法用于设置动态上下文,第一个参数1可能表示上下文的某种配置或限制,第二个参数index是前面创建的向量存储索引,用于在回答问题时检索相关的文本块。最后,调用build方法构建 RAG 代理,并打印启动命令行聊天机器人的提示信息。

启动交互式 CLI

    rig::cli_chatbot::cli_chatbot(rag_agent).await?;

调用rig::cli_chatbot::cli_chatbot方法启动一个交互式的命令行界面,传入前面构建的 RAG 代理。在这个界面中,用户可以输入问题,RAG 代理会根据文档上下文生成回答,实现智能问答的功能。await关键字用于等待命令行界面的交互过程结束,确保程序在用户退出交互前保持运行状态。

总结

这段代码通过整合anyhowrigserde等多个库,实现了从 PDF 文件加载、内容分块、生成嵌入向量、建立向量存储和索引,到构建 RAG 智能问答代理的完整流程,成功搭建了一个智能 PDF 问答系统。

在这个过程中,anyhow库简化了错误处理,rig库提供了与 LLM 交互的核心功能,serde库实现了数据的序列化和反序列化,std::path::PathBuf用于文件路径的处理,各个部分协同工作,使得系统能够高效地处理 PDF 文档并实现智能问答。

在实际应用中,这段代码可以进一步拓展。可以支持更多格式的文件加载,如 Word 文档Excel 表格等,以扩大系统的适用范围。可以优化向量存储和索引的性能,采用更高效的算法和数据结构,提高检索速度和准确性。

  • 原创
  • 学分: 8
  • 分类: Rust
  • 标签:
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。
2 订阅 8 篇文章

0 条评论

请先 登录 后评论
King
King
0x56af...a0dd
擅长Rust/Solidity/FunC/Move开发