上次 RAG 那篇发出去后,评论区一个做 SaaS 的哥们私聊我。
他说:“你光讲 Embedding 和向量库有啥用,我文档都读不进去。”
我问他卡在哪。
他甩来一张截图。
一份合同 PDF,几百页,有扫描页、有正常页、有表格。
他用 PDFBox 硬啃,跑完文本里到处是空格错乱、表格串行、"第 2 页"这种页眉页脚也全塞进去了。
发给大模型,问:“这份合同的违约金条款在哪一条?”
模型答得像瞎猜。
问题不出在 RAG,也不出在向量库。
问题出在文档没解析干净。
为什么这一步没人愿意讲
写 RAG 教程的人爱讲的都是后半段——切分、Embedding、检索、Rerank。
前半段最没面子,也最脏。
你手里的文档类型可能是这样的:
合同、报告是 PDF 需求文档、周报是 Word 帮助中心、Wiki 是 HTML 日志、聊天记录是 TXT 有的还是扫描件、图片、PPT一个 SaaS 项目上线前,光"解析层"就能磨半个月。
而且这一步几乎决定了 RAG 的天花板。
进去的文本是乱的,后面 Embedding 再准也没用。
今天把主流四种格式的 Java 解法过一遍。能跑、能用、附上会踩到的坑。
一、PDF:最难缠的一个
场景 1:正常文本 PDF(可以复制文字的那种)
首选 Apache PDFBox。
Maven 依赖:
<dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox</artifactId><version>2.0.32</version></dependency>
基础提取:
public String parsePdf(File file) throws IOException {try (PDDocument doc = PDDocument.load(file)) {PDFTextStripper stripper = new PDFTextStripper();// 按阅读顺序,避免多栏乱序stripper.setSortByPosition(true);return stripper.getText(doc);}}
大部分场景够用。
但你会发现有几个问题——
页眉页脚会一起进来。 “第 1 页 共 30 页”、公司 logo 底下的版权声明、每页都出现的项目名,全塞在正文里。
对策:按页拆开处理,删掉每页开头和结尾的短行。
public List<String> parsePdfByPage(File file) throws IOException {List<String> pages = new ArrayList<>();try (PDDocument doc = PDDocument.load(file)) {PDFTextStripper stripper = new PDFTextStripper();stripper.setSortByPosition(true);int total = doc.getNumberOfPages();for (int i = 1; i <= total; i++) {stripper.setStartPage(i);stripper.setEndPage(i);String page = stripper.getText(doc);// 简单剥离页眉页脚:前后各裁掉一行短文本page = trimHeaderFooter(page);pages.add(page);}}return pages;}private String trimHeaderFooter(String text) {String[] lines = text.split("\n");if (lines.length < 5) return text;int start = 0, end = lines.length;if (lines[0].trim().length() < 30) start = 1;if (lines[end - 1].trim().length() < 30) end -= 1;return String.join("\n", Arrays.copyOfRange(lines, start, end));}
粗糙,但 80% 的场景管用。
表格会串成一行。
PDFBox 抽出来的表格,一行一行的边界不稳定。比如两列表格,A 列文字长一点,B 列就跳到下一行去了。
真要认真处理表格,用 tabula-java。
<dependency><groupId>technology.tabula</groupId><artifactId>tabula</artifactId><version>1.0.5</version></dependency>
它专门做表格识别,输出行列结构。合同、财报里的表格用它。
但它慢,几百页跑几分钟正常。
我踩过一个坑。
有一份用 iText 生成的 PDF,PDFBox 抽出来的文字全是"???"。
后来发现是那份 PDF 用了自定义字体、字体信息又没嵌进去。
治不了。只能上 OCR。
场景 2:扫描件 PDF(复制不出文字的)
这种 PDFBox 抽出来是空的。
必须走 OCR。
Java 生态里最常见的组合是 Tesseract + Tess4J。
<dependency><groupId>net.sourceforge.tess4j</groupId><artifactId>tess4j</artifactId><version>5.11.0</version></dependency>
用法:
public String ocrPdf(File file) throws Exception {ITesseract tess = new Tesseract();tess.setDatapath("/usr/share/tesseract-ocr/4.00/tessdata");tess.setLanguage("chi_sim+eng"); // 中英混排return tess.doOCR(file);}
这里最容易踩两个坑。
一是训练数据。chi_sim 中文简体的语言包要单独装,不是 pip 一下就来。
二是准确率。默认参数跑合同扫描件,识别率大概 85% 左右。带印章、带手写签名的地方基本废掉。
真要上生产,别硬用 Tesseract。
调阿里、腾讯的 OCR API 更稳。几分钱一页,比自己养 Tesseract 划算得多。
场景 3:懒人打包方案
如果不想区分文本 PDF 和扫描 PDF,也不想手动区分格式,Apache Tika 一把梭。
<dependency><groupId>org.apache.tika</groupId><artifactId>tika-core</artifactId><version>2.9.2</version></dependency><dependency><groupId>org.apache.tika</groupId><artifactId>tika-parsers-standard-package</artifactId><version>2.9.2</version></dependency>
public String parseAnything(File file) throws Exception {Tika tika = new Tika();tika.setMaxStringLength(10 * 1024 * 1024); // 10MBreturn tika.parseToString(file);}
Tika 自动识别格式、自动挑合适的解析器。
PDF 走 PDFBox,Word 走 POI,Excel 走 POI,HTML 走 Jsoup。
优点:一行代码搞定几乎所有主流格式。
缺点:细节控制不了,比如你想按页拿 PDF、想只要正文段落的 HTML,Tika 都拗不过来。
结论:原型阶段用 Tika 快。生产阶段还是分格式走专用库,能更细地控噪音。
二、Word:分 doc 和 docx,别搞混
Word 有两种老死不相往来的格式:
.doc:老格式,二进制.docx:新格式,本质是 zip 压缩的一堆 XMLApache POI 都支持,但用的类不一样。
<dependency><groupId>org.apache.poi</groupId><artifactId>poi</artifactId><version>5.2.5</version></dependency><dependency><groupId>org.apache.poi</groupId><artifactId>poi-ooxml</artifactId><version>5.2.5</version></dependency><dependency><groupId>org.apache.poi</groupId><artifactId>poi-scratchpad</artifactId><version>5.2.5</version></dependency>
统一入口:
public String parseWord(File file) throws Exception {String name = file.getName().toLowerCase();if (name.endsWith(".docx")) {try (XWPFDocument doc = new XWPFDocument(new FileInputStream(file));XWPFWordExtractor ext = new XWPFWordExtractor(doc)) {return ext.getText();}} else if (name.endsWith(".doc")) {try (HWPFDocument doc = new HWPFDocument(new FileInputStream(file));WordExtractor ext = new WordExtractor(doc)) {return ext.getText();}}throw new IllegalArgumentException("不是 Word 文件:" + name);}
Word 的坑主要三个。
第一,图片和批注。默认 getText() 会把批注也带出来,如果你不想要,得单独遍历段落跳过。
第二,表格。docx 里的表格拿出来是行列结构,但拼成文本时行内没分隔符,你要自己加。
try(XWPFDocument doc = new XWPFDocument(new FileInputStream(file))) {StringBuilder sb = new StringBuilder();for(XWPFParagraph p : doc.getParagraphs()) {sb.append(p.getText()).append('\n');}for(XWPFTable table : doc.getTables()) {for(XWPFTableRow row : table.getRows()) {for(XWPFTableCell cell : row.getTableCells()) {sb.append(cell.getText()).append(" | ");}sb.append('\n');}}return sb.toString();}
第三,.doc 的批注、修订记录容易报 EncryptedDocumentException。
老版本 Word 加密方式很奇葩。遇到这种,要么问用户重存成 docx,要么放弃。
三、HTML:噪音是最大敌人
爬虫、Wiki、帮助中心、公众号导出,都是 HTML。
Jsoup 是唯一答案。
<dependency><groupId>org.jsoup</groupId><artifactId>jsoup</artifactId><version>1.17.2</version></dependency>
最粗暴的一把:
Document doc = Jsoup.parse(html);String text = doc.text();
这样出来的文本是"页面上所有可见文字连成一行"。
问题在于——导航栏、侧边栏、页脚、广告,也全在里面。
一份文档解析出来,前 200 字都是"首页 关于我们 产品 定价 登录 注册",向量化之后每篇都长得一样。检索的时候相似度全在这些垃圾里绕圈子。
正确做法:先剥噪音,再取正文。
public String parseHtml(String html) {Document doc = Jsoup.parse(html);// 直接删掉噪音标签doc.select("script, style, nav, footer, header, aside, iframe").remove();// 剥掉常见广告/导航 classdoc.select("[class*=nav], [class*=menu], [class*=footer], [class*=sidebar]").remove();// 优先取语义化容器Element main = doc.selectFirst("article, main, [role=main], .content, .post");Element target = main != null ? main : doc.body();// 用换行代替 <br> 和 </p>,保留段落感target.select("br").append("\\n");target.select("p").append("\\n");return target.text().replaceAll("\\n{2,}", "\n\n").trim();}
这段代码是我在两个项目里踩坑攒出来的。
早期我图省事,直接 doc.text(),然后想着"反正大模型能自己滤"。
结果发到线上,一份帮助中心文档解出来 8000 字,正文才 800 字,剩下都是"上一篇 下一篇 相关阅读"。
向量化一批完,RAG 检索率暴跌。查了半天才发现是文本源就烂。
再补一个坑:编码。
Jsoup.parse(inputStream, null, baseUrl) 里第二个参数是编码。
传 null 会自动检测,大部分时候对。但如果爬来的中文站点 header 没写 charset,自动检测偶尔会挂。
保守做法:先看响应头的 Content-Type,明确指定 UTF-8 或 GBK。
四、TXT:最简单,也有坑
TXT 你以为就是 Files.readString() 完事?
真不是。
主要坑:编码。
Windows 记事本存的 TXT,可能带 BOM 老系统导出,GBK / GB2312 / UTF-8 混杂 邮件里下载的 TXT,可能是 UTF-16一份文件读出来是乱码,多数是编码猜错。
用 juniversalchardet 自动识别:
<dependency><groupId>com.github.albfernandez</groupId><artifactId>juniversalchardet</artifactId><version>2.4.0</version></dependency>
public String parseTxt(File file) throws IOException {byte[] bytes = Files.readAllBytes(file.toPath());UniversalDetector detector = new UniversalDetector();detector.handleData(bytes, 0, bytes.length);detector.dataEnd();String encoding = detector.getDetectedCharset();if (encoding == null) encoding = "UTF-8";String text = new String(bytes, encoding);// 剥 UTF-8 BOMif (text.startsWith("\uFEFF")) {text = text.substring(1);}return text;}
一份自动嗅探的方案。GBK 编码的旧文档进 UTF-8 系统的场景,最常翻车的就是这里。
五、Spring AI 的 DocumentReader:更省事,也更黑盒
如果你已经在用 Spring AI,其实有一层封装好的 DocumentReader。
@Beanpublic List<Document> readPdf(Resource resource) {PagePdfDocumentReader reader = new PagePdfDocumentReader(resource);return reader.get();}@Beanpublic List<Document> readWord(Resource resource) {TikaDocumentReader reader = new TikaDocumentReader(resource);return reader.get();}@Beanpublic List<Document> readHtml(Resource resource) {TikaDocumentReader reader = new TikaDocumentReader(resource);return reader.get();}
它把 PDF / Word / HTML / Markdown 都封装成一个统一的 Document 对象,直接进后续的 TokenTextSplitter、EmbeddingModel、VectorStore。
优点:三行代码走通 RAG 的读入这一段。
缺点:底层还是 PDFBox / Tika,前面说的那些坑一个都没解决。表格、页眉页脚、HTML 噪音,Spring AI 没帮你干净掉。
我的用法:
快速原型:直接上 Spring AI Reader,能跑就行 生产项目:自己写 Parser,把清洗规则塞进去,再包装成 Spring AI Document 交给下游六、四种格式的对比表
七、一段能直接用的统一入口
多数项目最后都会长成这样——一个方法,根据后缀分派:
public class DocumentParser {public String parse(File file) throws Exception {String name = file.getName().toLowerCase();if (name.endsWith(".pdf")) return parsePdf(file);if (name.endsWith(".docx") || name.endsWith(".doc")) return parseWord(file);if (name.endsWith(".html") || name.endsWith(".htm")) return parseHtml(Files.readString(file.toPath()));if (name.endsWith(".txt") || name.endsWith(".md")) return parseTxt(file);// 兜底:不认的格式扔给 Tikareturn new Tika().parseToString(file);}// 上面写过的 parsePdf / parseWord / parseHtml / parseTxt 直接调}
用起来就一句:
String text = new DocumentParser().parse(new File("合同.pdf"));拿到干净文本,下一步就是切分(Chunking)——那是另一个话题,也是下一篇要讲的。
最后说两句
RAG 项目上线,很多人把力气花在选哪家向量库、调哪个 Rerank 模型上。
但真正拉开差距的是最前面这一步。
一份文档,被解析成什么样、噪音有没有清干净、表格有没有留结构、扫描件有没有走 OCR——决定了后面所有环节的天花板。
见过一个团队,Rerank 换了三家,效果一直上不去。
最后一查,输入文本里到处是页码、页眉、水印文字。
改掉解析层第二天,检索准确率涨了 40%。
你们项目里的文档,现在是硬啃 PDFBox,还是走了 Tika 一把梭,或者已经上了 OCR 和噪音清洗?评论区聊聊。
夜雨聆风