乐于分享
好东西不私藏

第8篇:支持PDF/Word/Excel!Java实现多格式文档自动解析入库(Java+AI落地实战系列 | 开箱即用 | 批量一键入库)

第8篇:支持PDF/Word/Excel!Java实现多格式文档自动解析入库(Java+AI落地实战系列 | 开箱即用 | 批量一键入库)

本文是《Java+AI落地实战 从入门到生产级》系列第8篇
往期回顾:
  • 第1篇:Java程序员学AI/转AI全指南,从CURD到AI应用工程师(零算法,4周落地)
  • 第2篇:Java 后端转 AI,这条完整链路 90% 的人没搞懂!AI 怎么自动干活、Java转AI链路:LLM、RAG、FunctionCall、ToolCall、Skills、Agent、MCP
  • 第3篇:别再焦虑了!Java开发做AI,根本不用学Python
  • 第4篇:10分钟跑通!SpringBoot对接通义千问,实现AI对话功能
  • 第5篇:SpringBoot实现流式输出,和ChatGPT体验一模一样
  • 第6篇:对话记忆怎么实现?Java版多轮上下文对话完整方案
  • 第7篇:从零搭建本地RAG知识库,内网可用,零API费用

上一篇我们搭好了本地RAG知识库,终于解决了大模型幻觉和数据安全的问题。但很多同学跑通之后吐槽:每次入库都要手动复制文本,太反人类了——公司的制度、手册、产品文档全是PDF、Word、Excel,总不能让我一页一页粘吧?

安排。

今天我们就给知识库加上「多格式文档自动解析」能力,上传文件自动提取文本、清洗降噪、智能切片、入库向量库,整个过程不用手动复制内容,几百份文档批量上传就能搞定,真正做到开箱即用。

先看一眼升级前后的差距:


一、先理清楚:文档自动入库的完整流程

别上来就写代码,先把整个链路理清楚,每一步做什么心里有数,后续出问题也好排查。

整个自动入库流程分为5步:

    本质上就是给上一篇的RAG知识库,升级了「文档输入层」,后面的检索、问答逻辑完全不用改。


    二、技术选型:Java生态最稳的文档解析工具

    我们选Apache全家桶,开源免费、无版权风险,企业项目里用了十几年,稳定性拉满:

    文件格式
    解析工具
    说明
    PDF
    Apache PDFBox
    Apache 官方出品,支持文本提取、加密 PDF 处理
    Word(.docx)
    Apache POI
    处理 Office 文档的标准工具,兼容绝大多数格式
    Excel(.xlsx)
    Apache POI
    支持单元格读取、表格转文本
    TXT
    原生 JDK
    直接读取,处理编码即可

    踩坑提醒:老格式.doc/.xls兼容性很差,问题极多,建议统一转成.docx/.xlsx再处理,本篇也针对主流的新格式实现。

    新增Maven依赖

    在上一篇的pom基础上,新增文档解析的依赖:

    <dependencies>    <!-- 省略上一篇已有的SpringBoot、LangChain4j、Ollama依赖 -->    <!-- Apache POI 处理Word/Excel -->    <dependency>        <groupId>org.apache.poi</groupId>        <artifactId>poi</artifactId>        <version>5.2.3</version>    </dependency>    <dependency>        <groupId>org.apache.poi</groupId>        <artifactId>poi-ooxml</artifactId>        <version>5.2.3</version>    </dependency>    <!-- Apache PDFBox 处理PDF -->    <dependency>        <groupId>org.apache.pdfbox</groupId>        <artifactId>pdfbox</artifactId>        <version>2.0.32</version>    </dependency></dependencies>

    三、第一步:文档解析工具类

    新建DocumentParserUtil,统一封装不同格式的文本提取逻辑,外加文本清洗方法,直接复制就能用。

    /** * 文档解析工具类 * 支持PDF、DOCX、XLSX、TXT格式文本提取 */public class DocumentParserUtil {    /**     * 根据文件类型自动解析提取文本     */    public static String extractText(MultipartFile file) throws IOException {        String fileName = file.getOriginalFilename();        if (fileName == null) {            throw new IllegalArgumentException("文件名不能为空");        }        String suffix = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();        return switch (suffix) {            case "txt" -> extractTxt(file);            case "docx" -> extractWord(file);            case "pdf" -> extractPdf(file);            case "xlsx" -> extractExcel(file);            default -> throw new IllegalArgumentException("不支持的文件格式:" + suffix);        };    }    /**     * 解析TXT文本     */    private static String extractTxt(MultipartFile file) throws IOException {        return new String(file.getBytes(), StandardCharsets.UTF_8);    }    /**     * 解析Word文档(仅支持.docx格式)     */    private static String extractWord(MultipartFile file) throws IOException {        try (XWPFDocument document = new XWPFDocument(file.getInputStream())) {            XWPFWordExtractor extractor = new XWPFWordExtractor(document);            return extractor.getText();        }    }    /**     * 解析PDF文档     */    private static String extractPdf(MultipartFile file) throws IOException {        try (PDDocument document = PDDocument.load(file.getInputStream())) {            PDFTextStripper stripper = new PDFTextStripper();            // 按顺序提取文本,避免排版混乱            stripper.setSortByPosition(true);            return stripper.getText(document);        }    }    /**     * 解析Excel文档(仅支持.xlsx格式)     * 按行读取,单元格之间用空格分隔,每行换行     */    private static String extractExcel(MultipartFile file) throws IOException {        StringBuilder content = new StringBuilder();        try (Workbook workbook = new XSSFWorkbook(file.getInputStream())) {            // 遍历所有sheet            for (int i = 0; i < workbook.getNumberOfSheets(); i++) {                Sheet sheet = workbook.getSheetAt(i);                content.append("【Sheet页:").append(sheet.getSheetName()).append("】\n");                // 遍历所有行                for (Row row : sheet) {                    StringBuilder rowContent = new StringBuilder();                    for (Cell cell : row) {                        // 统一转成字符串读取                        cell.setCellType(CellType.STRING);                        rowContent.append(cell.getStringCellValue()).append(" ");                    }                    content.append(rowContent.toString().trim()).append("\n");                }                content.append("\n");            }        }        return content.toString();    }    /**     * 文本清洗:去除多余空格、换行、无效字符     */    public static String cleanText(String text) {        if (StrUtil.isBlank(text)) {            return "";        }        // 1. 替换多个换行、空格为单个        text = text.replaceAll("\\s+"" ");        // 2. 去除常见的页眉页脚固定字符(可根据自己业务扩展)        text = text.replaceAll("第\\d+页 / 共\\d+页""");        text = text.replaceAll("版权所有.*""");        // 3. 去除首尾空白        return text.trim();    }}

    重点说下cleanText清洗方法:很多新手解析完文档直接入库,结果里面全是页眉、页脚、多余空行,检索的时候一堆无效内容,准确率特别差。清洗这一步看似简单,对检索效果的提升远超你的想象。


    四、第二步:改造文档入库服务

    在上一篇的DocumentService里新增文件入库方法,把「解析→清洗→切片→入库」全流程串起来。

    @Servicepublic class DocumentService {    @Autowired    private EmbeddingStore<TextSegment> embeddingStore;    @Autowired    private EmbeddingModel embeddingModel;    /**     * 上一篇的纯文本入库方法,保留不变     */    public void addTextDocument(String content, String docName) {        String cleanContent = DocumentParserUtil.cleanText(content);        DocumentSplitter splitter = DocumentSplitters.recursive(50050);        List<TextSegment> segments = splitter.split(Document.from(cleanContent));        segments.forEach(segment -> segment.metadata().put("docName", docName));        EmbeddingStoreIngestor.builder()                .documentSplitter(splitter)                .embeddingModel(embeddingModel)                .embeddingStore(embeddingStore)                .build()                .ingest(segments);    }    /**     * 新增:文件自动解析入库     * @param file 上传的文档文件     */    public void addFileDocument(MultipartFile file) throws IOException {        String fileName = file.getOriginalFilename();        // 1. 提取文本        String rawText = DocumentParserUtil.extractText(file);        // 2. 文本清洗        String cleanText = DocumentParserUtil.cleanText(rawText);        // 3. 文档切片        DocumentSplitter splitter = DocumentSplitters.recursive(50050);        List<TextSegment> segments = splitter.split(Document.from(cleanText));        // 4. 打上文件名元数据,方便溯源        segments.forEach(segment -> {            segment.metadata().put("docName", fileName);            segment.metadata().put("uploadTime", LocalDateTime.now().toString());        });        // 5. 向量化入库        EmbeddingStoreIngestor.builder()                .documentSplitter(splitter)                .embeddingModel(embeddingModel)                .embeddingStore(embeddingStore)                .build()                .ingest(segments);    }}

    五、第三步:新增文件上传接口

    在RagController里加上传接口,前端直接传文件就能自动入库。

    @RestController@RequestMapping("/api/rag")public class RagController {    @Autowired    private DocumentService documentService;    @Autowired    private RagChatService ragChatService;    /**     * 上一篇的纯文本入库接口,保留不变     */    @PostMapping("/addDoc")    public Result<StringaddDocument(@RequestBodyDocAddRequest request) {        documentService.addTextDocument(request.getContent(), request.getDocName());        return Result.success("文档入库成功");    }    /**     * 新增:文件上传自动入库接口     */    @PostMapping("/uploadDoc")    public Result<StringuploadDocument(MultipartFile file) {        try {            documentService.addFileDocument(file);            return Result.success("文件解析入库成功");        } catch (IOException e) {            return Result.fail("文件解析失败:" + e.getMessage());        } catch (IllegalArgumentException e) {            return Result.fail(e.getMessage());        }    }    /**     * 知识库问答接口,和上一篇一致     */    @GetMapping("/chat")    public Result<Stringchat(String question) {        String answer = ragChatService.chatWithKnowledge(question);        return Result.success(answer);    }    // 省略实体类、统一返回类,和之前一致}

    六、测试验证

    第一步:上传文件

    用Postman/Apifox调用上传接口,选择一个PDF/Word文件:

    • 请求地址:POST http://localhost:8080/api/rag/uploadDoc
    • 请求类型:form-data
    • 参数名:file,选择本地文档

    返回「文件解析入库成功」,就说明文档已经自动解析、切片、存入向量库了。

    第二步:问答验证

    调用问答接口,提问文档里的内容,比如上传了公司报销制度,就问「二线城市住宿报销标准是多少」,能准确返回答案就说明成功了。


    七、新手必踩的5个坑,提前帮你避了

    坑1:POI版本冲突,启动直接报错

    这是90%的人会遇到的问题,项目里本来就有旧版POI依赖,新版本加进去直接报NoClassDefFoundError。

    解决方案:用mvn dependency:tree查冲突的依赖,exclude掉旧版本,统一POI版本号,所有poi相关的包版本必须完全一致。

    坑2:PDF解析乱码,全是方框和问号

    Linux服务器上尤其常见,本质是服务器缺少中文字体,PDFBox渲染不出来。

    解决方案:服务器安装宋体、黑体等中文字体,或者用PDFBox的字体配置指定字体路径;Windows/Mac本地一般不会有这个问题。

    坑3:大Excel文件解析,直接内存溢出

    几十兆的Excel用普通XSSFWorkbook读取,很容易把内存撑爆。

    解决方案:大文件改用SXSSFWorkbook流式读取,逐行处理,不会一次性把整个文件加载到内存里。

    坑4:扫描版PDF解析出来是空的

    扫描版PDF本质是图片,没有文本层,PDFBox提取不到内容。

    解决方案:需要接入OCR文字识别(比如Tesseract、百度OCR),先把图片转成文本再入库,属于进阶功能。

    坑5:切片大小一刀切,检索效果差

    Excel表格、制度条文、技术文档用同一个切片大小,要么切太碎语义不完整,要么切太大检索不准。

    解决方案:不同类型文档用不同切片策略——表格类文档切小一点(300字符),长文本文档切大一点(600字符),按需调整。


    八、生产环境进阶优化方向

    现在这套已经能满足中小规模的文档入库需求,真正落地企业级项目,还可以继续扩展:



      九、本篇小结

      今天我们给RAG知识库升级了文档输入能力,从「手动粘贴文本」进化到「上传文件自动入库」,支持PDF、Word、Excel、TXT四大常用格式,附带文本清洗、智能切片,已经可以满足绝大多数内部工具、小团队的知识库需求。

      到这里,我们的RAG知识库已经完成了「问答→自动入库」的闭环,离生产可用又近了一大步。


      下篇预告

      现在我们用的还是内存向量库,文档少了没问题,一旦文档多了、服务重启,数据就全丢了,根本撑不起生产环境。所以下一篇我们讲:

      第9篇:别再用内存向量库了!Milvus向量数据库Java对接全指南

      内容会覆盖:

      • 向量数据库选型,为什么选Milvus
      • Docker一键部署Milvus单机版
      • LangChain4j对接Milvus完整代码
      • 数据持久化、批量检索、性能优化
      • 内存库平滑迁移到Milvus方案

      🎁 粉丝福利

      本篇完整代码已更新进系列源码包,包含:

      • 多格式文档解析工具类全量代码
      • 文件上传自动入库完整实现
      • 文本清洗+智能切片逻辑
      • 配套接口+测试用例

      关注公众号Java-AI工程师,后台回复 系列源码 即可免费领取。每更新一篇,我都会往资料包里新增对应源码,跟着系列就能从零搭出完整的企业级AI系统。

      ⭐ 记得星标公众号,每周更新一篇,跟着走不迷路。觉得文章有用,欢迎点赞、在看、转发给身边的同事,大家一起少踩坑。