三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Spring AI(12) :ChatPDF-实现ChatPDF应用

Spring AI(12) :ChatPDF-实现ChatPDF应用

本章代码已分享至Gitee:https://gitee.com/lengcz/ai-study.git

文章目录

  • ChatPDF 介绍
    • 什么是 ChatPDF?
    • ChatPDF 的核心工作原理
    • 一个简单的技术栈示例
    • ChatPDF 的应用场景
  • 如何实现chatpdf呢?
    • 分析
    • 准备工作(文件上传下载,向量写入)
    • PDF处理
  • 如何配置QuestionAnswerAdvisor
    • 依赖
    • 配置 RAG Advisor
    • 对话和检索
  • 日志分析

ChatPDF 介绍

什么是 ChatPDF?

ChatPDF 是一种基于大型语言模型(如 ChatGPT)构建的智能文档交互应用。它的核心功能是允许用户像与人对话一样,与 PDF、Word、TXT 等格式的文档进行问答交流。用户上传文档后,系统会提取并理解文档内容,然后用户可以用自然语言提问,ChatPDF 会从文档中找出相关信息并生成清晰、准确的回答。

简单来说,ChatPDF 让静态文档“活”了起来,变成了一个可以随时咨询的“知识专家”。

ChatPDF 的核心工作原理

ChatPDF 的实现通常包含以下几个关键步骤:

  1. 文档解析与文本提取:首先,应用需要解析上传的 PDF 文件,提取出其中的纯文本、表格和图片中的文字信息。常用的工具有 PyPDF2、pdfplumber、Tika 或专门的 OCR 服务。
  2. 文本分割与向量化:由于大语言模型有上下文长度限制,不能一次性处理整本书。因此,需要将提取的长文本分割成语义连贯的“块”(Chunks)。然后,使用嵌入模型(如 OpenAI 的text-embedding-ada-002)将这些文本块转换为高维向量(Embeddings),并存入向量数据库。
  3. 语义检索(RAG):当用户提出一个问题时,系统会先将问题也转换为向量,然后在向量数据库中搜索与问题向量最相似的文本块。这个过程称为“检索增强生成”(Retrieval-Augmented Generation, RAG),它能确保回答严格基于文档内容,减少模型“幻觉”。
  4. 提示工程与答案生成:将检索到的相关文本块和用户问题一起,组合成一个精心设计的提示(Prompt),发送给大语言模型(如 GPT-4)。模型基于这些上下文信息,生成一个连贯、准确的答案。
  5. 交互界面:最后,需要一个友好的前端界面(如 Web 应用)供用户上传文档和进行对话。

一个简单的技术栈示例

要快速搭建一个 ChatPDF 应用,可以参考以下技术组合:

  • 后端框架:FastAPI(Python)
  • 文档解析:PyPDF2 或 pdfplumber
  • 文本分割与向量化:LangChain(提供便捷的文本分割器和多种嵌入模型接口)
  • 向量数据库:ChromaDB(轻量级,易于集成)或 Pinecone(云服务)
  • 大语言模型 API:OpenAI GPT 系列、 Anthropic Claude 或开源模型(通过 Ollama 本地部署)
  • 前端:Streamlit(快速构建原型)或 Next.js + React

ChatPDF 的应用场景

  • 学术研究:快速阅读论文,询问研究方法、核心结论。
  • 法律与合同:解析冗长的合同条款,快速定位关键责任与权利。
  • 企业知识库:将公司手册、产品文档转化为可对话的智能助手。
  • 个人学习:与电子书、学习资料互动,加深理解。

如何实现chatpdf呢?

分析

根据前面的内容我们知道,需要实现chatpdf功能,首先需要个人文档管理功能

  • 文件上传
  • 导入向量数据库
  • 文件下载
  • AI对话

准备工作(文件上传下载,向量写入)

实现基础的非对话部分的准备工作,实现上传,下载,写入向量数据库等基础代码和接口。

  • 文件的上传和下载,以及与chatId的关系
importorg.springframework.core.io.Resource;publicinterfaceFileRepository{/** * 保存文件,还要记录chatId与文件的映射关系 * @param chatId 会话id * @param resource 文件 * @return 上传成功,返回true;否则返回false */booleansave(StringchatId,Resourceresource);/** * 根据chatId获取文件 * @param chatId 会话id * @return 找到的文件 */ResourcegetFile(StringchatId);}
importjakarta.annotation.PostConstruct;importjakarta.annotation.PreDestroy;importlombok.RequiredArgsConstructor;importlombok.extern.slf4j.Slf4j;importorg.springframework.ai.vectorstore.SimpleVectorStore;importorg.springframework.ai.vectorstore.VectorStore;importorg.springframework.core.io.FileSystemResource;importorg.springframework.core.io.Resource;importorg.springframework.stereotype.Component;importjava.io.*;importjava.nio.file.Files;importjava.time.LocalDateTime;importjava.util.Objects;importjava.util.Properties;@Slf4j@Component@RequiredArgsConstructorpublicclassLocalPdfFileRepositoryimplementsFileRepository{privatefinalVectorStorevectorStore;// 会话id与文件名的对应关系,方便查询会话历史时重新加载文件privatefinalPropertieschatFiles=newProperties();@Overridepublicbooleansave(StringchatId,Resourceresource){// 2. 保存到本地磁盘Stringfilename=resource.getFilename();Filetarget=newFile(Objects.requireNonNull(filename));if(!target.exists()){try{Files.copy(resource.getInputStream(),target.toPath());}catch(IOExceptione){log.error("Failed to save PDF resource.",e);returnfalse;}}// 3. 保存映射关系chatFiles.put(chatId,filename);returntrue;}@OverridepublicResourcegetFile(StringchatId){returnnewFileSystemResource(chatFiles.getProperty(chatId));}@PostConstruct//启动时从磁盘加载向量数据privatevoidinit(){// 加载持久化的 chatId 与文件名的映射FileSystemResourcepdfResource=newFileSystemResource("chat-pdf.properties");if(pdfResource.exists()){try{chatFiles.load(newBufferedReader(newInputStreamReader(pdfResource.getInputStream())));}catch(IOExceptione){thrownewRuntimeException(e);}}// 加载向量存储数据FileSystemResourcevectorResource=newFileSystemResource("chat-pdf.json");if(vectorResource.exists()){SimpleVectorStoresimpleVectorStore=(SimpleVectorStore)vectorStore;simpleVectorStore.load(vectorResource);}}@PreDestroy//停机时持久化privatevoidpersistent(){try{// 保存映射关系chatFiles.store(newFileWriter("chat-pdf.properties"),LocalDateTime.now().toString());// 保存向量存储SimpleVectorStoresimpleVectorStore=(SimpleVectorStore)vectorStore;simpleVectorStore.save(newFile("chat-pdf.json"));}catch(IOExceptione){thrownewRuntimeException(e);}}}

api接口

importcom.lengcz.ai.entity.vo.Result;importcom.lengcz.ai.repository.ChatHistoryRepository;importcom.lengcz.ai.repository.FileRepository;importlombok.RequiredArgsConstructor;importlombok.extern.slf4j.Slf4j;importorg.springframework.ai.chat.client.ChatClient;importorg.springframework.ai.document.Document;importorg.springframework.ai.reader.ExtractedTextFormatter;importorg.springframework.ai.reader.pdf.PagePdfDocumentReader;importorg.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;importorg.springframework.ai.vectorstore.VectorStore;importorg.springframework.core.io.Resource;importorg.springframework.http.HttpHeaders;importorg.springframework.http.MediaType;importorg.springframework.http.ResponseEntity;importorg.springframework.web.bind.annotation.*;importorg.springframework.web.multipart.MultipartFile;importreactor.core.publisher.Flux;importjava.io.IOException;importjava.net.URLEncoder;importjava.nio.charset.StandardCharsets;importjava.util.List;importjava.util.Objects;importstaticorg.springframework.ai.chat.client.advisor.AbstractChatMemoryAdvisor.CHAT_MEMORY_CONVERSATION_ID_KEY;importstaticorg.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor.FILTER_EXPRESSION;@RestController@RequestMapping("/ai/pdf")@RequiredArgsConstructor@Slf4jpublicclassPdfController{privatefinalFileRepositoryfileRepository;privatefinalChatHistoryRepositorychatHistoryRepository;privatefinalVectorStorevectorStore;privatefinalChatClientpdfChatClient;/** * 上传 PDF 文件并绑定到指定的会话 ID * * @param chatId 会话 ID * @param multipartFile 上传的文件 * @return 操作结果 */@PostMapping("/upload/{chatId}")publicResultuploadFile(@PathVariableStringchatId,@RequestParam("file")MultipartFilemultipartFile){// 1. 检查文件是否为空if(multipartFile.isEmpty()){returnResult.fail("上传文件不能为空");}// 2. 校验文件是否为 PDF(扩展名 + MIME 类型)StringoriginalFilename=multipartFile.getOriginalFilename();StringcontentType=multipartFile.getContentType();if(!isPdfFile(originalFilename,contentType)){returnResult.fail("只允许上传 PDF 格式的文件");}try{booleansaved=fileRepository.save(chatId,multipartFile.getResource());if(saved){log.info("文件上传成功: chatId={}, fileName={}",chatId,originalFilename);}else{log.error("文件保存失败: chatId={}",chatId);returnResult.fail("文件保存失败,请稍后重试");}this.writeToVectorStore(multipartFile.getResource());returnResult.ok();}catch(Exceptione){log.error("上传文件发生异常",e);returnResult.fail("上传异常: "+e.getMessage());}}/** * 根据会话 ID 下载对应的 PDF 文件 */@GetMapping("/file/{chatId}")publicResponseEntity<Resource>download(@PathVariable("chatId")StringchatId)throwsIOException{// 1. 读取文件Resourceresource=fileRepository.getFile(chatId);if(resource==null||!resource.exists()){returnResponseEntity.notFound().build();}// 2. 文件名编码,写入响应头Stringfilename=URLEncoder.encode(Objects.requireNonNull(resource.getFilename()),StandardCharsets.UTF_8.name());// // 3. 返回文件(使用通用二进制流,避免浏览器直接打开)// return ResponseEntity.ok()// .contentType(MediaType.APPLICATION_OCTET_STREAM)// .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"")// .body(resource);returnResponseEntity.ok().header(HttpHeaders.CONTENT_TYPE,"application/pdf").header(HttpHeaders.CONTENT_DISPOSITION,"inline; filename=\""+filename+"\"").body(resource);}// ----- 辅助方法:校验文件是否为 PDF -----privatebooleanisPdfFile(Stringfilename,StringcontentType){// 检查扩展名(忽略大小写)if(filename==null||!filename.toLowerCase().endsWith(".pdf")){returnfalse;}// 检查 MIME 类型(允许常见值)if(contentType==null){returnfalse;}returncontentType.equals(MediaType.APPLICATION_PDF_VALUE)||contentType.equals("application/x-pdf")||contentType.equals("application/pdf");}privatevoidwriteToVectorStore(Resourceresource){// 1 usage// 1. 创建PDF的读取器PagePdfDocumentReaderreader=newPagePdfDocumentReader(resource,// 文件源PdfDocumentReaderConfig.builder().withPageExtractedTextFormatter(ExtractedTextFormatter.defaults()).withPagesPerDocument(1)// 每1页PDF作为一个Document.build());// 2. 读取PDF文档,拆分为DocumentList<Document>documents=reader.read();// 3. 写入向量库vectorStore.add(documents);}}
importlombok.AllArgsConstructor;importlombok.Data;importlombok.NoArgsConstructor;@Data@NoArgsConstructor@AllArgsConstructorpublicclassResult{privateIntegerok;privateStringmsg;publicstaticResultok(){returnnewResult(1,"ok");}publicstaticResultfail(Stringmsg){returnnewResult(0,msg);}}

PDF处理

如下图为chatPDF的业务流程逻辑,流程比较冗长,但是实际上spring ai已经帮我们完成了流程简化。

spring ai已经将向量模型和向量库,以及问题线管片段帮我们简化了。其通过advisor 的封装,实现了QuestionAnswerAdvisor。

如何配置QuestionAnswerAdvisor

依赖

<dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-advisors-vector-store</artifactId></dependency>

配置 RAG Advisor

配置pdfChatClient 的QuestionAnswerAdvisor

@BeanpublicChatClientpdfChatClient(OpenAiChatModelmodel,ChatMemorychatMemory,VectorStorevectorStore){returnChatClient.builder(model).defaultSystem("请根据上下文回答问题,遇到上下文没有的问题,不要随意编造。").defaultAdvisors(newSimpleLoggerAdvisor(),newMessageChatMemoryAdvisor(chatMemory)//配置会话记忆Advisor,newSimpleLoggerAdvisor(),newQuestionAnswerAdvisor(vectorStore,SearchRequest.builder().similarityThreshold(0.6)//温度.topK(2)//头部几条记录.build())).build();}

对话和检索

编写对话检索接口

@RestController@RequestMapping("/ai/pdf")@RequiredArgsConstructor@Slf4jpublicclassPdfController{privatefinalFileRepositoryfileRepository;privatefinalChatHistoryRepositorychatHistoryRepository;privatefinalVectorStorevectorStore;privatefinalChatClientpdfChatClient;@RequestMapping(value="/chat",produces="text/html;charset=utf-8;")publicFlux<String>chat(Stringprompt,StringchatId){Resourcefile=fileRepository.getFile(chatId);if(!file.exists()){thrownewRuntimeException("会话文件不存在!");}//1.保存会话idchatHistoryRepository.save("pdf",chatId);//2.请求模型returnpdfChatClient.prompt().user(prompt).advisors(a->a.param(CHAT_MEMORY_CONVERSATION_ID_KEY,chatId)).advisors(a->a.param(FILTER_EXPRESSION,"file_name == '"+file.getFilename()+"'")).stream().content();//stream() 表示流式输出}}

打开前端页面测试(前端资源已提交至Gitee)

日志分析



从日志中,我们可以看出它的输入词是要搜索的内容+ 预设置的提示词和替换关键词,然后question_answer_context 是通过ChatOptions的userParams设置进去的。

查看QuestionAnswerAdvisor 的源代码可以看到,原来它的实现过程和实现逻辑是对向量搜索,documents结果拼接,以及再次交给大模型进行内容组织处理,从而完成思考过程。

← 返回列表