技术博客

RAG 开发入门(三):文档预处理质量决定 RAG 能不能用

文档预处理质量决定 RAG 系统能不能用。解析、清洗、切块、元数据、权限和版本处理不好,后面的检索和生成都会被拖住。

发布时间

阅读信息

约 14 分钟

主题标签

RAG / Java / 文档预处理

第二篇里,我们把知识库写成了内存里的 List<DocumentChunk>。那样做是为了先看清楚 RAG 的最小链路:检索、上下文组装、模型回答。

真实系统里,问题不会这么干净。资料来自 PDF、Word、网页、Markdown、接口文档、数据库记录、工单、PPT、截图和表格。它们进入检索系统之前,如果已经被解析坏了、切块切碎了、来源丢了、权限和版本没处理,后面再换向量库、调相似度阈值、改 prompt,效果都很有限。

文档预处理质量决定 RAG 系统能不能用。第三篇先讲文档怎么变成 RAG 能用的知识片段,Embedding 和向量数据库放到后面。

RAG 文档预处理链路

1. 预处理要产出知识单元

第一版 RAG 可以从文字抽取开始,但真实问答需要更完整的数据结构。

RAG 需要一组可检索的知识单元。每个知识单元至少要能回答几个问题:这段内容来自哪里,属于哪个文档,标题路径是什么,正文是什么,用户有没有权限看,它适用于哪个系统或版本,后续更新时能不能找到同一段内容。

可以先把第二篇里的 DocumentChunk 扩成更接近生产系统的形态:

import java.time.Instant;
import java.util.Map;

public record KnowledgeChunk(
        String id,
        String sourceUri,
        String title,
        String content,
        Map<String, String> metadata,
        Instant updatedAt
) {
}

这里的重点不在字段多少,而在方向。文档预处理要产出 content,也要产出一组能让检索、过滤、引用、排查和更新继续工作的结构化信息。

如果只拿到正文,后面的检索层就只能按文本相似度猜。它不知道这段内容属于哪个业务系统,也不知道用户权限和版本范围。

2. 解析时先保留结构

解析要把字符串背后的结构保留下来。

Markdown 本身有标题层级、列表、代码块和表格。HTML 里有正文区域、导航、侧边栏、页脚和广告。PDF 里可能有页眉页脚、双栏排版、脚注、水印和扫描页。Word 里可能有目录、批注、修订记录和嵌套表格。接口文档里还有请求参数、响应字段、错误码和示例代码。

如果解析阶段把这些结构全部压平成一段文本,后面就很难补回来。比如一个接口文档的字段表被读成一串连续文字,字段名、类型、是否必填、含义混在一起,检索时看起来还能命中,模型回答时却很容易把字段解释错。

扫描件、截图型 PDF 和部分历史文档还要经过 OCR。OCR 负责把图片里的文字识别出来,但 RAG 需要的通常还有阅读顺序、标题层级、表格结构、公式、图片说明和坐标位置。只拿到一串识别文字,检索时还是会丢掉很多结构信息。

MinerU 这类文档解析工具可以放在这一层理解。它会处理 OCR,也会做版面分析、表格抽取、公式识别、图片和标题关系恢复,最后输出 Markdown、JSON 这类机器可读结果。对 Java 后端来说,它可以作为独立的解析服务存在:Java 负责文件接入、任务调度和结果入库,MinerU 负责把复杂 PDF 或图片文档解析成结构化结果。

比较稳的做法是先把文档解析成中间结构,再进入后面的清洗和切块。

import java.util.List;
import java.util.Map;

public record ParsedDocument(
        String sourceUri,
        List<ParsedBlock> blocks,
        Map<String, String> metadata
) {
}

public record ParsedBlock(
        BlockType type,
        int level,
        String text
) {
}

public enum BlockType {
    HEADING,
    PARAGRAPH,
    TABLE,
    CODE_BLOCK,
    IMAGE_CAPTION
}

这只是一个示例设计,重点在原则:先保留结构,再决定怎么清洗和切块。

对 Java 后端来说,解析层最好做成可替换接口。Markdown、HTML、PDF、Word、Excel 可以各有 parser,最后统一输出 ParsedDocument。这样后面的清洗、切块和入库逻辑不用关心原始文件格式。

3. 清洗要保留关键细节

文档解析完,通常还要清洗。

清洗最常见的是删页眉页脚、页码、导航栏、版权声明、广告推荐、重复空白和无意义符号。网页资料尤其明显,如果没有正文抽取,导航菜单和推荐列表可能比正文还长。把这些东西送进索引,只会让检索结果变脏。

清洗规则需要克制。

技术文档里的版本号、错误码、字段名、枚举值、接口路径、配置项,看起来都很短,很像噪音,实际却是检索时最关键的词。比如 ERR_10027orderStatus/api/refund/callbackenableRetry,这些内容一旦被清洗规则删掉,后面向量检索和关键词检索都找不回来。

清洗层最好保留两份东西:一份是原始解析结果,一份是清洗后的结果。线上出现错误答案时,可以回头看清洗规则到底删了什么。

清洗也不适合一开始就完全交给大模型。模型可以辅助识别正文区域或表格说明,但稳定的生产链路仍然需要规则、日志和样本检查。预处理是离线或准实时链路,问题发生后应该能复现,重复处理同一份资料也应该得到稳定结果。

4. 切块策略要看资料形态

切块没有一套策略能适配所有资料。最容易做的是固定长度切块,比如每 800 个字切一段。这种方式实现简单,也能跑起来,但技术文档很容易被切坏。

一个接口说明可能由标题、适用场景、请求参数、响应字段、错误码和示例组成。按固定长度切,可能把字段名留在上一块,把字段含义切到下一块。检索时两个块都不完整,模型拿到任何一个都回答不稳。

滑动窗口是固定长度切块的一个改进。比如每 800 个字切一段,相邻块重叠 100 到 200 个字。它能减少边界处的信息丢失,适合长文本和叙述性文档。代价也很明显:重复内容会变多,索引体积会变大,召回结果里也更容易出现相似片段。用滑动窗口时,后面通常要配合去重或重排。

按段落拆分更适合结构清楚的 Markdown、网页正文、制度文档和说明文档。段落天然带有语义边界,标题路径跟着段落走,检索结果也更容易解释。它的问题是段落长度不稳定,短段落可能信息太少,长段落又可能超过模型上下文预算。所以常见做法是短段落合并,长段落再按句子或列表边界拆。

技术文档还可以按树结构分块。标题就是树的节点,正文、表格、代码块是节点下面的内容。切块时可以保留从根标题到当前标题的路径,比如“支付系统 / 退款接口 / 回调字段说明”。这种方式对接口文档、产品手册、知识库目录很有用,因为用户经常会问某个章节下面的局部问题。

树结构分块的目的很直接:提高召回率。用户问“退款接口失败码怎么处理”,原文里可能只写了“FAILED 表示退款渠道确认失败”。如果 chunk 里带着“支付系统 / 退款接口 / 回调字段说明”这条标题路径,关键词检索和向量检索都更容易把它召回。树结构还能支持父子级扩展:命中一个叶子节点后,可以把同一小节的相邻字段、上级标题说明一起作为候选上下文。

一个知识片段最好能独立回答一个小问题。

比如接口文档里的这一块:

退款回调接口 /api/refund/callback
字段 refundStatus 表示退款流水状态。
SUCCESS 表示退款渠道已经确认成功。
FAILED 表示退款渠道确认失败,需要记录失败原因。

它可以成为一个完整 chunk。用户问“退款回调里 refundStatus 是什么”,这段内容能单独支撑回答。如果切成“接口路径一块、字段说明一块、枚举值一块”,召回和回答都会变差。

切块时还要把标题路径放进 chunk。“字段说明”这个标题信息太少,“支付系统 / 退款接口 / 回调字段说明”能直接说明内容位置。标题路径也能参与检索和排序。

还有一条路线是先把文档渲染成页面,再按页面或页面区域切块。比如用 Gotenberg(戈登堡)这类开源服务,把 Office、HTML、Markdown 或网页统一转成 PDF,或者对 HTML、Markdown、网页直接截图;再把 PDF 页面渲染成图片,交给 OCR、多模态 Embedding 或多模态大模型处理。

这条路线适合版式很重要的资料,比如 PPT、扫描件、复杂表格、带大量截图的产品手册、流程图和报表。文本抽取很容易丢掉它们的空间关系,页面图片反而能保留“谁在谁旁边”“箭头指向哪里”“表头和单元格怎么对应”这些信息。

多模态路线适合作为文本切块的补充。图片索引成本更高,检索和生成更慢,引用粒度也更难控制。更稳的做法通常是文本块和页面图片并存:文本块负责精确检索和引用,图片块负责保留版式、图表和视觉关系。两者用同一套来源、页码、标题路径和权限元数据绑定起来,后面才能合并召回结果。

GraphRAG 也可以放在预处理链路里看。它会从文档里抽实体和关系,构建知识图谱,再基于图结构、社区摘要或局部邻居做检索。普通 chunk 更适合回答某个具体片段里的问题,知识图谱更适合处理关系型问题,比如“这些系统之间有什么依赖”“某个故障链路涉及哪些服务”“一批资料整体在讲哪些主题”。

这条路线对资源要求更高。抽实体、抽关系、生成社区摘要通常需要大量模型调用,索引时间、存储成本和增量更新复杂度都会上升。图谱抽错了还会把错误关系带进后续检索。更稳的落地方式是先把普通文本块做好,再对高价值资料或高频复杂问题补 GraphRAG,逐步扩大图谱覆盖范围。

5. 元数据和权限要进入索引字段

很多 RAG 问题表面看是没搜到,实际是搜到了不该用的内容。

比如同一个知识库里有多个业务线文档。用户问订单退款,系统召回了售后系统的退款规则,也召回了财务系统的退款规则。两者都包含“退款”,但适用范围不同。如果文档块里没有 system=orderdomain=after_sales 这类元数据,检索层很难提前过滤。

再比如同一个接口有多个版本。v1v2 的字段含义不同,用户问的是新版接口,系统却把两个版本混在一起塞给模型,模型就可能拼出一个看起来合理但实际不能用的答案。

权限也是一样。更稳的做法是在检索前或检索时就按用户身份过滤候选集。用户没有权限看的 chunk,不进入召回结果,也不进入 prompt。

所以元数据应该是索引字段,参与检索过滤和引用追踪。常见字段包括:

  • system:所属系统
  • tenant:租户或组织范围
  • version:文档或接口版本
  • sourceUri:来源地址
  • owner:责任团队
  • visibility:权限范围
  • updatedAt:更新时间
  • contentType:文档类型,比如接口文档、制度文档、故障记录

这些字段会直接影响检索。用户问题进入系统后,检索器不一定只拿关键词查正文,也要带上过滤条件,先把不适用的内容排除掉。

6. 去重和增量更新要提前设计

知识库一旦接入真实资料,很快会遇到重复内容。

同一份制度可能同时存在 Word、PDF 和网页版本。接口文档可能每次发布都会导出一份新文件。知识库管理员可能上传了同名文件,也可能把一个目录整体同步了两次。

如果不处理去重,检索结果里会出现一堆内容相近的 chunk。模型上下文被重复内容占满,有差异的片段反而进不去。

去重可以分几层做。文件层可以用文件哈希或来源地址判断是否重复。段落层可以对清洗后的文本做哈希。chunk 层可以用 sourceUri + titlePath + contentHash 生成稳定 ID。

增量更新也要靠稳定 ID。文档重新同步时,系统要知道哪些 chunk 没变,哪些 chunk 更新了,哪些 chunk 被删除了。否则每次都全量重建索引,成本高,也不利于排查线上问题。

Java 系统里可以把预处理任务设计成幂等链路。同一份输入重复处理,应该得到同一组 chunk ID。这样失败重试、定时同步、手动重跑都比较安全。

7. 入库前先做质量检查

写完 parser 和 chunker 之后,入库前最好再做一轮质量检查。

检查不用一开始就复杂,可以先抽样看几个问题。

  • 每个 chunk 是否能独立表达一个小问题?
  • chunk 里是否带了标题路径和来源?
  • 表格、代码块、字段说明有没有被拆坏?
  • 页眉页脚、导航、广告这些噪音有没有进入正文?
  • 元数据字段是否足够做过滤?
  • 权限范围是否已经绑定到 chunk?
  • 同一份资料重复导入时,chunk ID 是否稳定?

这些检查看起来很朴素,但非常有用。很多 RAG 项目一开始就调模型和向量库,最后才发现索引里的 chunk 本身就不可读。

也可以给预处理链路加一份调试导出。每次处理完文档,输出一个 Markdown 或 JSONL 文件,把 chunk ID、标题路径、来源、元数据和正文前几百字列出来。这个文件不一定给用户看,但对开发和排查很有价值。

8. Java 工程里先把边界拆出来

落到 Java 后端,预处理模块不需要一开始就设计得很重。先把几个边界拆出来就够了:资料从哪里来,原始格式怎么解析,解析结果怎么清洗和切块,最后怎么写入索引。

资料加载可以来自本地文件、对象存储、数据库、Git 仓库、网页或接口。格式解析负责把 PDF、Word、HTML、Markdown 这些输入转成统一的中间结构。清洗和切块再基于这个中间结构处理标题路径、表格、代码块和正文边界。

生成 KnowledgeChunk 之后,再由索引写入逻辑处理关键词索引、向量索引和元数据索引。后面如果引入 Embedding,也应该发生在这个阶段附近,不要混进 parser 里。

接口边界可以先写得很薄:

import java.util.List;

public interface DocumentLoader {

    List<RawDocument> load();
}

public interface DocumentParser {

    ParsedDocument parse(RawDocument document);
}

public interface DocumentNormalizer {

    ParsedDocument normalize(ParsedDocument document);
}

public interface DocumentChunker {

    List<KnowledgeChunk> chunk(ParsedDocument document);
}

RawDocument 可以包含原始字节、文件名、来源地址和基础元数据。这里不展开完整实现,先把边界拆出来就够了。

预处理链路通常不应该放在用户问答请求里。更常见的方式是离线任务或后台同步任务:资料变化后进入队列,预处理服务解析并切块,最后写入索引。用户提问时,只访问已经准备好的索引。

这样做有几个好处。问答请求不会被文件解析拖慢。预处理失败可以单独重试。索引更新可以打日志和做版本记录。出现错误答案时,也能回到某一次预处理任务里查原因。

9. 文档块稳定后,再谈检索

文档预处理质量低,后面的检索会一直被噪音拖着走。

解析坏了,模型拿到的就是残缺内容。切块坏了,召回结果就缺上下文。元数据缺了,过滤和权限就做不稳。去重没做,prompt 里会塞满重复片段。增量更新没设计好,线上问题很难复现。

向量库只能存储和检索你喂进去的东西。文档块质量差,向量化只是把质量差的内容压成数字。文档块质量稳定,RAG 系统才有继续优化检索、重排和生成的基础。

下一篇再讲检索层:从最简单的关键词匹配,到 BM25,再到 Embedding 和向量召回。等知识片段稳定之后,检索算法的差异才有讨论价值。

RAGJava文档预处理知识库后端工程
上一篇 RAG 开发入门(一):从关键词检索到 Agentic RAG 更多文章