在当今信息爆炸的时代,PDF文档作为一种广泛使用的文件格式,承载着大量的信息。无论是学术研究中的论文、企业的报告资料,还是各类技术文档,快速从PDF中提取关键信息并进行智能问答,成为了提高工作和学习效率的关键需求。例如,在学术研究场景中,研究人员需要从大量的学术论文PDF中快速定位到
在当今信息爆炸的时代,PDF 文档作为一种广泛使用的文件格式,承载着大量的信息。无论是学术研究中的论文、企业的报告资料,还是各类技术文档,快速从 PDF 中提取关键信息并进行智能问答,成为了提高工作和学习效率的关键需求。
例如,在学术研究场景中,研究人员需要从大量的学术论文 PDF 中快速定位到关键实验数据、研究结论等;在企业场景里,员工需要从繁杂的业务报告 PDF 中迅速获取关键业务指标、市场分析等内容。
为了满足这一需求,构建了一个智能 PDF 问答系统。通过整合多种技术和工具,实现了从 PDF 文件加载、内容分块处理、生成嵌入向量、建立向量存储和索引,到最终构建检索增强生成(RAG)智能问答代理的完整流程。
下面,我们将对代码进行逐段分析,深入了解其实现细节和技术原理。
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
库是构建本智能 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
库在 Rust 中主要用于数据的序列化和反序列化操作。序列化是将数据结构转换为一种可存储或传输的格式(如 JSON、Bincode 等)的过程,而反序列化则是将存储或传输的格式转换回数据结构的过程。在本代码中,serde
库主要用于Document
结构体的序列化和反序列化。
Document
结构体用于表示文档数据,它包含id
和content
两个字段。通过在Document
结构体上使用#[derive(Serialize, Deserialize)]
注解,serde
库会自动为该结构体生成序列化和反序列化的代码。在将文档数据存储到向量存储中时,需要将Document
结构体序列化为特定的格式,以便存储和传输;而在从向量存储中读取数据时,则需要将存储的格式反序列化为Document
结构体,以便后续的处理和使用。例如,在将文档数据保存到磁盘或通过网络传输时,serde
库可以将Document
结构体转换为 JSON 格式的字符串,而在读取数据时,又可以将 JSON 字符串转换回Document
结构体,确保数据的正确传输和使用。
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
能够自动处理这些差异,保证代码的跨平台性。
#[derive(Embed, Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
struct Document {
id: String,
#[embed]
content: String,
}
Document
结构体用于表示文档数据,它包含两个字段:id
和content
。id
字段是一个String
类型,用于唯一标识每个文档,在实际应用中,它可以帮助系统快速定位和管理不同的文档。content
字段同样是String
类型,用于存储文档的具体内容,是后续文本处理和分析的基础。
#[derive(Embed, Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
是 Rust 的派生宏,为Document
结构体自动生成一系列的方法。Embed
是rig
库提供的特征(trait),用于标记该结构体可以生成嵌入向量,这使得Document
结构体能够与rig
库的嵌入生成功能无缝对接。Clone
特征允许结构体进行克隆操作,方便在需要复制数据时使用。Debug
特征为结构体提供了调试输出的功能,便于开发者在调试过程中查看结构体的内容。Serialize
和Deserialize
特征是serde
库提供的,用于实现结构体的序列化和反序列化,使得Document
结构体可以方便地进行存储和传输。Eq
和PartialEq
特征用于比较结构体的相等性,在进行数据处理和查找时非常有用。
#[embed]
属性是rig
库特有的,它标记content
字段用于生成嵌入向量。在生成嵌入向量时,rig
库会根据这个属性来确定使用哪个字段的内容进行嵌入计算,确保文本内容能够准确地转换为数值向量,为后续的向量存储和检索提供支持。
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
结果。
#[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
关键字用于等待命令行界面的交互过程结束,确保程序在用户退出交互前保持运行状态。
这段代码通过整合anyhow
、rig
、serde
等多个库,实现了从 PDF 文件加载、内容分块、生成嵌入向量、建立向量存储和索引,到构建 RAG 智能问答代理的完整流程,成功搭建了一个智能 PDF 问答系统。
在这个过程中,anyhow
库简化了错误处理,rig
库提供了与 LLM 交互的核心功能,serde
库实现了数据的序列化和反序列化,std::path::PathBuf
用于文件路径的处理,各个部分协同工作,使得系统能够高效地处理 PDF 文档并实现智能问答。
在实际应用中,这段代码可以进一步拓展。可以支持更多格式的文件加载,如 Word 文档
、Excel 表格
等,以扩大系统的适用范围。可以优化向量存储和索引的性能,采用更高效的算法和数据结构,提高检索速度和准确性。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!