乐于分享
好东西不私藏

文档解析全攻略 PDF/Word/HTML/TXT 怎么转文本

文档解析全攻略 PDF/Word/HTML/TXT 怎么转文本

上次 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 < 5return 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); // 10MB    return tika.parseToString(file);}

Tika 自动识别格式、自动挑合适的解析器。

PDF 走 PDFBox,Word 走 POI,Excel 走 POI,HTML 走 Jsoup。

优点:一行代码搞定几乎所有主流格式。

缺点:细节控制不了,比如你想按页拿 PDF、想只要正文段落的 HTML,Tika 都拗不过来。

结论:原型阶段用 Tika 快。生产阶段还是分格式走专用库,能更细地控噪音。

二、Word:分 doc 和 docx,别搞混

Word 有两种老死不相往来的格式:

.doc:老格式,二进制.docx:新格式,本质是 zip 压缩的一堆 XML

Apache 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();    // 剥掉常见广告/导航 class    doc.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 BOM    if (text.startsWith("\uFEFF")) {        text = text.substring(1);    }    return text;}

一份自动嗅探的方案。GBK 编码的旧文档进 UTF-8 系统的场景,最常翻车的就是这里。

五、Spring AI 的 DocumentReader:更省事,也更黑盒

如果你已经在用 Spring AI,其实有一层封装好的 DocumentReader。

@Beanpublic List<DocumentreadPdf(Resource resource) {    PagePdfDocumentReader reader = new PagePdfDocumentReader(resource);    return reader.get();}@Beanpublic List<DocumentreadWord(Resource resource) {    TikaDocumentReader reader = new TikaDocumentReader(resource);    return reader.get();}@Beanpublic List<DocumentreadHtml(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 交给下游

六、四种格式的对比表

格式
首选库
备胎
主要坑
PDF(正常)
PDFBox
Tika
页眉页脚、表格串行、字体缺失
PDF(扫描)
Tesseract / 云 OCR
Tika + OCR
识别率、印章手写
Word docx
POI XWPFWordExtractor
Tika
批注、表格分隔
Word doc
POI HWPFDocument
Tika
加密、老版本兼容
HTML
Jsoup
Tika
导航/侧边栏噪音、编码
TXT
Files + juniversalchardet
-
BOM、GBK 乱码

七、一段能直接用的统一入口

多数项目最后都会长成这样——一个方法,根据后缀分派:

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);        // 兜底:不认的格式扔给 Tika        return 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 和噪音清洗?评论区聊聊。