8.6 万份文档,Tika 抽出来的表格全是乱码
去年底接了个企业知识库的项目,要把公司十年积累的技术文档、产品手册、标书、合同模板全部灌进 RAG。总量 8.6 万份,PDF 占 45%,Word 30%,Excel 15%,剩下是 PPT 和 HTML。
第一版我图省事,直接用 Apache Tika 一把梭:
public String parse(File f) throws Exception {
return new Tika().parseToString(f);
}
上线后从结果里随机抽了 300 份人工看,合格率只有 41%。问题分类统计:
| 问题 | 占比 | 具体表现 |
|---|---|---|
| 表格结构丢失 | 31% | 三列表格被抽成"名称 型号 价格 A-100 12.5 X-200 ..."一长串 |
| 图片内容全丢 | 27% | 架构图、流程图、带文字的截图,抽出来是空的 |
| 扫描件空白 | 18% | 纯图片型 PDF,一个字都没有 |
| 页眉页脚污染 | 14% | 每一页都重复"第 X 页 共 Y 页 机密文件" |
| 多栏排版错乱 | 7% | 双栏论文的左右栏内容交错 |
| 其他 | 3% | 加密文件、损坏文件 |
这篇记录我们怎么把合格率从 41% 提到 92% 的。
第一步:按文档类型分流,别指望一个库通吃
核心认知是:没有通用的文档解析器,必须分流。分流的第一步是判断 PDF 的类型——是文本型还是扫描件。
public enum PdfKind { TEXT, SCANNED, MIXED }
public PdfKind detect(File pdf) throws IOException {
try (PDDocument doc = Loader.loadPDF(pdf)) {
PDFTextStripper stripper = new PDFTextStripper();
int pages = doc.getNumberOfPages();
int textPages = 0;
for (int i = 1; i <= pages; i++) {
stripper.setStartPage(i);
stripper.setEndPage(i);
String t = stripper.getText(doc).trim();
// 单页有效字符少于 50 个,认为是图片页
if (t.replaceAll("\\s", "").length() >= 50) textPages++;
}
double ratio = textPages * 1.0 / pages;
if (ratio > 0.9) return PdfKind.TEXT;
if (ratio < 0.1) return PdfKind.SCANNED;
return PdfKind.MIXED;
}
}
8.6 万份 PDF 的分布:文本型 52%,扫描件 18%,混合型 30%。混合型是最麻烦的,得逐页判断。
8.6 万份文档的处理流水线
│
├─ PDF ──┬─ 文本型 ──→ PDFBox(表格单独处理)
│ ├─ 扫描件 ──→ OCR(PaddleOCR)
│ └─ 混合型 ──→ 逐页分流
│
├─ DOCX ──→ Apache POI XWPF(表格 + 图片单独抽)
├─ XLSX ──→ POI XSSF(按 sheet 结构化)
├─ PPTX ──→ POI XSLF(按页 + 备注)
└─ HTML ──→ Jsoup 正文抽取
表格:转成 Markdown,还要单独建索引
表格是知识库里信息密度最高的部分,也是最容易丢的。我们做了两件事。
一、结构转成 Markdown 而不是纯文本
PDFBox 3.0 没有内置的表格识别,我们用了基于文本位置聚类的做法:按 y 坐标分行、按 x 坐标分列。
public String extractTableAsMarkdown(PDPage page) throws IOException {
PDFTextStripperByArea stripper = new PDFTextStripperByArea();
// 1. 拿到所有带位置信息的字符
List<TextPosition> chars = collectChars(page);
// 2. 按 y 坐标聚类成行(阈值 3pt)
List<List<TextPosition>> rows = clusterByY(chars, 3.0f);
// 3. 每行内按 x 坐标聚类成单元格
List<List<String>> table = rows.stream()
.map(r -> clusterByX(r, 8.0f))
.toList();
// 4. 渲染成 Markdown
StringBuilder md = new StringBuilder();
for (int i = 0; i < table.size(); i++) {
md.append("| ").append(String.join(" | ", table.get(i))).append(" |\n");
if (i == 0) md.append("|").append("---|".repeat(table.get(i).size())).append("\n");
}
return md.toString();
}
抽出来的效果:
| 型号 | 最大并发 | 内存上限 | 参考价格 |
|---|---|---|---|
| A-100 | 2000 | 8 GB | 12,800 |
| A-200 | 5000 | 16 GB | 24,500 |
| A-500 | 12000 | 64 GB | 58,000 |
为什么选 Markdown 而不是 JSON 或其他格式?因为 大语言模型在 Markdown 表格上的理解能力明显更强。我们做过对照,同一批表格用四种格式喂给模型,让它回答" A-500 的内存上限是多少":
| 表格格式 | 回答正确率 |
|---|---|
| Tika 纯文本(空格分隔) | 34% |
| CSV | 71% |
| HTML table | 84% |
| Markdown | 91% |
二、给表格额外生成一段文字摘要,单独作为检索单元
这是我觉得最有价值的一步。表格本身即使转成 Markdown,向量检索对它的召回也不好——用户问"A-500 多少钱",查询向量和"| A-500 | 12000 | 64 GB | 58,000 |"这串字符的语义距离并不近。
做法是让模型给每个表格生成一段自然语言描述,把表格和描述作为两个独立的 chunk 分别入库,但都指向同一个原始位置。
String summarizeTable(String markdownTable) {
return llm.chat("""
下面是一张表格。请用 3-5 句话描述它:包含哪些列、大概有多少行、
覆盖什么范围。不要逐行罗列,只做整体概括。
%s
""".formatted(markdownTable), 0.0);
}
// 输出:"该表列出了三款Ubuntu 服务器型号的性能参数与价格,包括型号名、最大并发数、
// 内存上限和参考价格。型号从 A-100 到 A-500,并发能力从 2000 到 12000,
// 价格从 12800 元到 58000 元递增。"
// 同一个表格产生两个 chunk,共享 doc_id 和 section_path
chunks.add(Chunk.of(docId, sectionPath, markdownTable, ChunkType.TABLE));
chunks.add(Chunk.of(docId, sectionPath, summary, ChunkType.TABLE_SUMMARY));
加了表格摘要之后,涉及表格内容的查询,检索命中率从 43% 涨到 79%。
图片:OCR 加多模态描述,双管齐下
文档里的图片分两类,处理方式不同。
类型一:扫描件、截图里的文字
用 OCR。我们对比了 Tesseract 5 和 PaddleOCR,中文场景 PaddleOCR 明显更好:
| 工具 | 中文印刷体准确率 | 表格识别 | 单页耗时 |
|---|---|---|---|
| Tesseract 5 (chi_sim) | 82.3% | 不支持 | 0.8 s |
| PaddleOCR v4 (server 模型) | 96.1% | 支持,输出 HTML | 1.4 s |
PaddleOCR 我们部署成了独立服务(Python + FastAPI),Java 侧用 HTTP 调。单页 1.4 秒,8.6 万份文档里需要 OCR 的有 3.9 万份、约 47 万页,跑在 8 台机器上花了 23 小时。
类型二:架构图、流程图、示意图
这类图片 OCR 出来是空的或者乱的,得用多模态模型生成描述。我们用的是国内一个支持视觉输入的模型:
public String describeImage(byte[] imageBytes) {
String base64 = Base64.getEncoder().encodeToString(imageBytes);
return visionLlm.chat(List.of(
TextPart.of("""
这是一张技术文档里的图。请描述:
1. 图的类型(架构图/流程图/时序图/截图/表格/其他)
2. 图里有哪些主要元素,以及它们之间的关系
3. 如果图里有文字(节点名、标签),全部列出来
用中文回答,控制在 200 字以内。
"""),
ImagePart.of(base64, "png")));
}
// 实际输出示例
"这是一张系统架构图。从上到下分三层:接入层包含 API 网关和负载均衡;
服务层有订单服务、库存服务、支付服务三个模块,它们之间通过消息队列通信;
数据层包含 MySQL 主从集群和 Redis 缓存。图中标注的文字包括:
API Gateway、order-service、inventory-service、payment-service、
RocketMQ、MySQL Master、MySQL Slave、Redis Cluster。"
这段描述作为图片所在位置的 chunk 入库。用户问"订单服务和库存服务怎么通信的",就能检索到这张图所在的文档段落。
成本:每张图约 1200 个输入 token,4.1 万张图总共约 4900 万输入 token,费用 2100 元左右。这个钱花得值,因为架构图在技术文档里是信息密度最高的部分。
切分:别用固定长度
第一版我们用固定的 512 token 滑动窗口切分,导致大量 chunk 被从中间截断:一个完整的配置说明被切成两半,检索出来只有一半,模型就瞎编另一半。
改成按标题层级做语义切分。核心思路:先解析出文档的标题树,然后每个最小粒度的章节作为一个 chunk,超长再按段落细切。
// DOCX 的标题层级直接从 POI 的样式里读
for (XWPFParagraph p : doc.getParagraphs()) {
String style = p.getStyle();
if (style != null && style.startsWith("Heading")) {
int level = Integer.parseInt(style.substring("Heading".length()));
currentPath = pushTo(currentPath, level, p.getText());
} else {
buffer.append(p.getText()).append("\n");
}
}
// PDF 靠字号推断标题层级
List<TextPosition> chars = collectChars(page);
float bodySize = mode(chars.stream().map(TextPosition::getFontSize).toList());
// 字号大于正文 1.2 倍且该行较短 → 认为是标题
切完之后每个 chunk 都带上完整的标题路径,这个路径对检索很有用:
chunk.content = """
## 3.2 数据库连接池配置
在 application.yml 中通过以下参数配置连接池...
"""
chunk.sectionPath = "产品手册 > 第三章 部署与配置 > 3.2 数据库连接池配置"
检索时把 sectionPath 拼在内容前面,模型能更好地理解上下文。我们实测这个改动让"某个配置项在哪一章"这类问题的准确率从 61% 涨到 88%。
两种切分方式的效果对比:
| 切分方式 | chunk 数 | 平均长度 | 答案完整率 | 检索命中率 |
|---|---|---|---|---|
| 固定 512 token 滑动窗口 | 1,842,000 | 487 token | 56% | 72% |
| 按标题层级 + 段落兜底 | 1,214,000 | 634 token | 89% | 84% |
chunk 数少了 34%(存储成本下降),答案完整率反而涨了 33 个点。
元数据与结构化抽取
除了正文,我们从每份文档里抽取结构化元数据,作为检索的过滤条件:
record DocMeta(String docId, String title, String docType,
LocalDate publishDate, String department,
String securityLevel, String version) {}
抽取方式:PDF 的元数据 + 封面页规则匹配 + 模型兜底。规则能覆盖 70%,剩下 30% 让模型从文档前两页里读。
元数据最大的用途是权限过滤。我们有 12% 的文档是"仅部门内可见",检索时必须在向量查询的过滤条件里带上部门信息。这块我们在《向量数据库生产实践》那篇里写过,过滤条件要用分区或者字段索引,否则召回率会掉。
质量校验:别等用户来发现问题
8.6 万份文档不可能人工全查。我们做了一组自动校验规则,解析完立刻跑,不合格的进人工队列:
List<Check> CHECKS = List.of(
// 有效中文字符太少,可能是解析失败
(doc) -> countChinese(doc.text()) < 50 ? "疑似解析失败" : null,
// 页眉页脚污染:同一行出现超过总页数 60% 的次数
(doc) -> repeatedLineRatio(doc.text()) > 0.6 ? "疑似页眉页脚未清理" : null,
// 大量连续空格,典型的表格被拍平成文本
(doc) -> maxConsecutiveSpaces(doc.text()) > 8 ? "疑似表格结构丢失" : null,
// 乱码检测:非常用字符占比
(doc) -> garbledRatio(doc.text()) > 0.08 ? "疑似编码问题" : null,
// 内容过短
(doc) -> doc.text().length() < 200 ? "内容异常短" : null
);
| 规则 | 命中数 | 人工复核后确认为问题的 | 准确率 |
|---|---|---|---|
| 疑似解析失败 | 3,412 | 2,914 | 85.4% |
| 疑似页眉页脚未清理 | 8,203 | 7,681 | 93.6% |
| 疑似表格结构丢失 | 5,127 | 3,908 | 76.2% |
| 疑似编码问题 | 1,044 | 892 | 85.4% |
| 内容异常短 | 2,308 | 1,204 | 52.2% |
"内容异常短"这条规则误报率高,因为确实有一批文档(比如一页的变更说明)本身就短。后来加了条件:单页文档不算,只查多页但内容短的。
去重
8.6 万份文档里有大量重复:同一份文件被不同部门各存了一份,或者同一个文档的 v1/v2/v3 都在。不去重的话,检索出来三条一模一样的内容,浪费上下文还干扰排序。
// SimHash 去重,比 MD5 精确匹配能抓住"改了几个字的重复文档"
String simhash = SimHash.of(doc.text());
List<String> near = redis.opsForZSet()
.rangeByScore("simhash:" + bucket(simhash), score - 3, score + 3);
if (!near.isEmpty()) {
doc.setDuplicateOf(near.get(0)); // 标记而非删除,保留溯源
}
用 SimHash 而不是精确哈希,能抓到"改了两个字的版本"和"重新排版的同一份文档"。最终标记出 11,842 份重复文档(13.7%),检索时默认过滤,但保留原始记录,用户如果专门找历史版本还能搜到。
最终效果
整个流水线跑完耗时 4 天(其中 OCR 23 小时、多模态描述 8 小时、embedding 6 小时),最终数据:
| 项 | 数值 |
|---|---|
| 处理文档 | 86,412 份 |
| 解析失败(人工介入) | 2,914 份(3.4%) |
| 人工抽检合格率 | 92.3%(从 41% 提升) |
| 生成 chunk | 1,214,000 条 |
| 其中表格 chunk | 187,000 条 |
| 其中图片描述 chunk | 41,000 条 |
| 向量库大小 | 6.8 GB |
| 总处理成本(OCR + 模型调用) | 约 7,400 元 |
端到端的检索效果,用 200 条人工标注的问答对评测:
| 指标 | 第一版(Tika 直接抽) | 最终版 |
|---|---|---|
| 检索召回率@5 | 0.612 | 0.884 |
| 答案准确率(人工评) | 43% | 81% |
| 涉及表格的问题准确率 | 19% | 76% |
| 涉及图片的问题准确率 | 8% | 62% |
小结
几条经验,按重要性排:
- 表格和图片是知识库的价值高地,也是通用解析器的盲区。通用库只会给你一堆字符流,表格结构、图片语义全丢。这部分要专门处理,投入产出比最高。
- 表格额外生成文字摘要,跟表格本身分开入库。向量检索对表格原文不友好,对自然语言描述很友好。这一个改动让表格类问题的准确率涨了 33 个点。
- 切分按语义边界而不是固定长度。固定长度会切断语义单元,导致检索到的内容不完整,模型就会开始编。
- 把标题路径作为上下文一起存。成本几乎为零,效益明显。
- 解析失败要有人工通道。3.4% 的文件(加密、损坏、极端排版)再怎么优化也处理不了,得有个队列让人手工补,别让它们静默丢失。
最后一点感受:RAG 的效果上限,很大程度上在文档解析阶段就定了。模型再强、检索算法再好,如果知识库里存的是一堆乱码字符,什么也救不回来。这个项目我们花在解析上的时间占了六成,我认为是值得的。