Administrator
发布于 2025-02-19 / 3004 阅读
35

文档解析与知识库构建的工程实践

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%
CSV71%
HTML table84%
Markdown91%

二、给表格额外生成一段文字摘要,单独作为检索单元

这是我觉得最有价值的一步。表格本身即使转成 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%支持,输出 HTML1.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,000487 token56%72%
按标题层级 + 段落兜底1,214,000634 token89%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,4122,91485.4%
疑似页眉页脚未清理8,2037,68193.6%
疑似表格结构丢失5,1273,90876.2%
疑似编码问题1,04489285.4%
内容异常短2,3081,20452.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% 提升)
生成 chunk1,214,000 条
其中表格 chunk187,000 条
其中图片描述 chunk41,000 条
向量库大小6.8 GB
总处理成本(OCR + 模型调用)约 7,400 元

端到端的检索效果,用 200 条人工标注的问答对评测:

指标第一版(Tika 直接抽)最终版
检索召回率@50.6120.884
答案准确率(人工评)43%81%
涉及表格的问题准确率19%76%
涉及图片的问题准确率8%62%

小结

几条经验,按重要性排:

  1. 表格和图片是知识库的价值高地,也是通用解析器的盲区。通用库只会给你一堆字符流,表格结构、图片语义全丢。这部分要专门处理,投入产出比最高。
  2. 表格额外生成文字摘要,跟表格本身分开入库。向量检索对表格原文不友好,对自然语言描述很友好。这一个改动让表格类问题的准确率涨了 33 个点。
  3. 切分按语义边界而不是固定长度。固定长度会切断语义单元,导致检索到的内容不完整,模型就会开始编。
  4. 把标题路径作为上下文一起存。成本几乎为零,效益明显。
  5. 解析失败要有人工通道。3.4% 的文件(加密、损坏、极端排版)再怎么优化也处理不了,得有个队列让人手工补,别让它们静默丢失。

最后一点感受:RAG 的效果上限,很大程度上在文档解析阶段就定了。模型再强、检索算法再好,如果知识库里存的是一堆乱码字符,什么也救不回来。这个项目我们花在解析上的时间占了六成,我认为是值得的。

参考