第8章 文档解析与文本切片

本章目标

上一章我们解决了文件保存问题:用户上传的原始文档保存到 MinIO,数据库中的 document_info.storage_path 记录对象路径,task-service 可以根据这个路径下载文件。

但文件下载下来之后,还不能直接用于 RAG。

因为 RAG 检索的不是"文件本身",而是文件中的文本片段。

一份 PDF、Markdown 或 TXT 文档,需要先经历两步处理:

text 复制代码
文档解析
  -> 文本切片

文档解析负责把不同格式的文件变成纯文本。

文本切片负责把长文本拆成多个适合向量化和检索的小片段。

本章要讲清楚:

  1. 为什么上传文件不能直接向量化。
  2. DocumentParser 接口如何设计。
  3. TXT、Markdown、PDF 如何解析。
  4. DocumentParserDispatcher 如何选择解析器。
  5. 为什么要做文本归一化。
  6. 为什么要做切片。
  7. chunkSizeoverlap 分别解决什么问题。
  8. document_chunk 表如何保存切片结果。

8.1 为什么上传文件不能直接向量化

很多初学者会有一个疑问:既然 RAG 最后要把文档变成向量,为什么不直接把整个文件丢给 Embedding 模型?

原因有三个。

8.1.1 原始文件不是纯文本

用户上传的文件可能是:

text 复制代码
txt
md
Markdown
PDF

TXT 和 Markdown 本身接近文本,但 PDF 不是简单文本文件。PDF 中可能包含分页、排版、字体、表格、换行和不可见字符。

Embedding 模型需要的是文本,而不是二进制文件。

所以第一步必须解析。

8.1.2 文档通常太长

一份制度文档可能几千字,一份项目手册可能几万字。

如果把整篇文档一次性向量化,会出现问题:

  • 文本超过模型输入限制。
  • 向量表示过于粗糙。
  • 检索时只能命中整篇文档,无法定位具体段落。
  • 后续拼 Prompt 成本很高。

RAG 需要的是"找到最相关的一小段资料",而不是"把整篇文档都塞给模型"。

8.1.3 引用来源需要片段粒度

KnowHub 返回答案时,不只返回模型生成的回答,还会返回引用来源。

如果系统只保存整篇文档,那么引用来源只能告诉用户:答案来自某个文件。

但用户真正需要的是更细粒度的信息:

text 复制代码
答案来自哪份文档的第几个片段
这个片段内容是什么
相似度是多少

这就要求系统必须把文档拆成 chunk。


8.2 文档解析器设计

文档解析的目标是:

text 复制代码
输入:文件路径或文件流
输出:纯文本内容

不同文件类型解析方式不同。

TXT 可以直接读取文本。

Markdown 可以按文本读取,保留标题和正文。

PDF 需要使用 PDFBox 这类库提取文本。

为了让代码结构清晰,KnowHub 使用统一接口抽象解析器。


8.2.0 依赖引入

本章的解析和切片发生在 task-service 中。

原因很简单:knowledge-service 负责接收上传请求、保存文件元数据、把原始文件放到 MinIO;真正耗时的解析、切片、Embedding 和向量入库,应该交给后台任务服务异步执行。

PDF 解析需要引入 Apache PDFBox。依赖建议写在 task-service/pom.xml 中:

xml 复制代码
<dependency>
    <groupId>org.apache.pdfbox</groupId>
    <artifactId>pdfbox</artifactId>
    <version>3.0.2</version>
</dependency>

如果项目已经在父工程中统一管理版本,也可以把版本号放到父 pom.xmldependencyManagement 中,子模块只保留 groupIdartifactId

8.2.1 DocumentParser 接口

可以设计一个接口:

java 复制代码
public interface DocumentParser {
    boolean supports(String fileType);
    String parse(Path path);
}

它表达两个意思:

第一,这个解析器支持什么类型;

第二,给它一个文件路径,它返回解析后的文本。

这样后续增加 Word、Excel、OCR 时,不需要改所有业务代码,只需要新增解析器实现。

8.2.2 PlainTextDocumentParser

TXT、Markdown 都可以先走普通文本解析器。

它负责:

  • 读取文件内容。
  • 处理编码。
  • 返回字符串。

支持类型可以包括:

text 复制代码
txt
md
markdown

Markdown 虽然有标题、列表、代码块等结构,但第一版可以当作文本处理。后续如果想提高切片质量,可以增加 Markdown 标题感知切片。


PlainTextDocumentParser 的完整实现如下:

java 复制代码
package com.luo.knowhub.task.parser;

import com.luo.knowhub.common.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Set;

@Slf4j
@Component
public class PlainTextDocumentParser implements DocumentParser {

    private static final Set<String> SUPPORTED_TYPES = Set.of("txt", "md", "markdown");

    @Override
    public boolean supports(String fileType) {
        if (fileType == null) {
            return false;
        }
        return SUPPORTED_TYPES.contains(fileType.toLowerCase(Locale.ROOT));
    }

    @Override
    public String parse(Path path) {
        if (path == null || !Files.exists(path)) {
            throw new BusinessException("待解析文件不存在");
        }

        try {
            // 学习项目先统一要求上传 UTF-8 编码文件,避免同一份代码在不同操作系统上表现不一致。
            return Files.readString(path, StandardCharsets.UTF_8);
        } catch (IOException e) {
            log.error("纯文本文件解析失败,path={}", path, e);
            throw new BusinessException("纯文本文件解析失败,请确认文件编码为 UTF-8");
        }
    }
}

这里没有在第一版中自动猜测 GBK、GB2312 等编码,是为了让主流程先稳定跑通。后续如果要兼容历史文档,可以在读取失败后增加编码检测或兜底解码,但不要在核心链路里默默吞掉乱码。

8.2.3 PdfDocumentParser

PDF 解析器可以基于 PDFBox。

它负责从 PDF 中提取文本。

PDF 解析是文件处理中比较容易出问题的地方,因为 PDF 可能存在:

  • 扫描件,没有文本层。
  • 复杂表格。
  • 页眉页脚干扰。
  • 换行错乱。
  • 加密或损坏。

所以 PDF 解析失败时,要记录明确错误,而不是让任务无声失败。


PdfDocumentParser 的完整实现如下:

java 复制代码
package com.luo.knowhub.task.parser;

import com.luo.knowhub.common.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.file.Path;
import java.util.Locale;

@Slf4j
@Component
public class PdfDocumentParser implements DocumentParser {

    @Override
    public boolean supports(String fileType) {
        return "pdf".equals(fileType == null ? "" : fileType.toLowerCase(Locale.ROOT));
    }

    @Override
    public String parse(Path path) {
        PDDocument document = null;
        try {
            // PDFBox 3.x 推荐使用 Loader 加载 PDF 文件。
            document = Loader.loadPDF(path.toFile());
            if (document.isEncrypted()) {
                throw new BusinessException("PDF 文件已加密,无法解析");
            }

            PDFTextStripper stripper = new PDFTextStripper();
            // 按页面中的文字位置排序,能减少部分 PDF 换行错乱问题。
            stripper.setSortByPosition(true);

            // 注意:PDFBox 提取出的文本可能混入页眉、页脚、页码,后续可在归一化阶段继续清理。
            return stripper.getText(document);
        } catch (BusinessException e) {
            throw e;
        } catch (IOException e) {
            log.error("PDF 文件解析失败,path={}", path, e);
            throw new BusinessException("PDF 文件解析失败,可能是文件损坏、加密或没有文本层");
        } finally {
            if (document != null) {
                try {
                    document.close();
                } catch (IOException e) {
                    log.warn("关闭 PDF 文档失败,path={}", path, e);
                }
            }
        }
    }
}

扫描版 PDF 通常只有图片,没有可直接提取的文本层。上面的代码不会自动 OCR,这种文件解析结果可能为空,应在后面的空文本校验中把任务标记为失败。

8.2.4 后续扩展

后续可以继续扩展:

  • Word 解析。
  • Excel 解析。
  • PPT 解析。
  • 图片 OCR。
  • 网页正文抽取。

但本书主线先把 TXT、Markdown、PDF 跑通。这样足够覆盖 RAG 平台的核心文档处理流程。


8.3 解析器分发

有了多个解析器后,业务代码不应该手动写一堆 if-else。

更好的方式是使用 DocumentParserDispatcher

它的职责是:

text 复制代码
根据文件类型选择合适解析器

流程如下:

text 复制代码
输入 fileType
  -> 遍历所有 DocumentParser
  -> 找到 supports(fileType) = true 的解析器
  -> 调用 parse
  -> 返回文本

如果没有解析器支持当前类型,就直接抛出业务异常。

比如用户上传:

text 复制代码
video.mp4

系统应该明确返回:

text 复制代码
不支持的文件类型

而不是等到后面 Embedding 时报错。

这种分发器设计的好处是可扩展。

以后新增 Word 解析器,只需要让它实现 DocumentParser 并注册到 Spring 容器,Dispatcher 就能自动使用。


DocumentParserDispatcher 的完整代码如下:

java 复制代码
package com.luo.knowhub.task.parser;

import com.luo.knowhub.common.exception.BusinessException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Locale;

@Slf4j
@Component
public class DocumentParserDispatcher {

    private final List<DocumentParser> parsers;

    public DocumentParserDispatcher(List<DocumentParser> parsers) {
        // Spring 会自动把容器中所有 DocumentParser 实现类注入到这个 List 中。
        // 这就是策略模式在 Spring 项目中的常见落地方式。
        this.parsers = parsers;
    }

    public String dispatch(String fileType, Path filePath) {
        if (filePath == null || !Files.exists(filePath)) {
            throw new BusinessException("待解析文件不存在");
        }

        String normalizedType = fileType == null ? "" : fileType.toLowerCase(Locale.ROOT);

        DocumentParser parser = parsers.stream()
                .filter(item -> item.supports(normalizedType))
                .findFirst()
                .orElseThrow(() -> new BusinessException("不支持的文件类型:" + fileType));

        log.info("开始解析文档,fileType={}, path={}, parser={}",
                normalizedType, filePath, parser.getClass().getSimpleName());

        String text = parser.parse(filePath);

        log.info("文档解析完成,fileType={}, charCount={}",
                normalizedType, text == null ? 0 : text.length());

        return text;
    }
}

这段代码的重点不是 for 循环,而是 List<DocumentParser> 的注入方式。以后新增 WordDocumentParser 时,只要它实现 DocumentParser 并加上 @Component,这里不需要新增任何 if-else。


8.4 文本归一化

解析出来的文本不能完全不处理。

不同文件格式解析出来的文本可能存在很多问题:

  • 多余空格。
  • 连续空行。
  • Windows 和 Linux 换行不一致。
  • PDF 换行错乱。
  • 不可见字符。
  • 文本为空。

所以在切片前,需要做基础归一化。

8.4.1 统一换行

Windows 换行通常是:

text 复制代码
\r\n

Linux 换行通常是:

text 复制代码
\n

系统可以统一转换成 \n,方便后续处理。

8.4.2 去掉过多空白

连续多个空行可以压缩。

行首行尾空格可以清理。

但不要粗暴删除所有换行。

因为换行往往代表段落边界,对切片有价值。

8.4.3 空文本校验

如果解析后文本为空,就不应该继续切片和向量化。

常见原因包括:

  • PDF 是扫描件。
  • 文件内容本身为空。
  • 编码不正确。
  • 解析器不支持该文件。

这种情况应该让任务失败,并记录错误原因。

8.4.4 保留必要结构

Markdown 中的标题、列表、代码块,对语义有帮助。

第一版可以不做复杂结构解析,但不建议把所有格式信息都暴力删除。

例如标题:

markdown 复制代码
## 报销制度

它能帮助模型理解后面内容属于哪个主题。


TextNormalizer 可以写成一个无状态工具类:

java 复制代码
package com.luo.knowhub.task.chunking;

import java.util.Arrays;
import java.util.stream.Collectors;

public final class TextNormalizer {

    private TextNormalizer() {
    }

    public static String normalize(String text) {
        if (text == null) {
            return "";
        }

        String result = normalizeLineEndings(text);
        result = trimLines(result);
        result = compressBlankLines(result);
        return result.trim();
    }

    public static String normalizeLineEndings(String text) {
        // 统一换行符后,后续切片逻辑只需要处理 \n。
        return text.replace("\r\n", "\n").replace("\r", "\n");
    }

    public static String trimLines(String text) {
        // 清理每一行首尾空格,但保留换行结构,因为换行往往代表段落边界。
        return Arrays.stream(text.split("\n", -1))
                .map(String::trim)
                .collect(Collectors.joining("\n"));
    }

    public static String compressBlankLines(String text) {
        // 三个及以上连续换行压缩为两个,保留段落之间的空行。
        return text.replaceAll("\n{3,}", "\n\n");
    }

    public static boolean isBlank(String text) {
        return normalize(text).isBlank();
    }
}

例如输入:

text 复制代码
第一行  \r\n
\r\n
\r\n
  第二行

归一化后会变成:

text 复制代码
第一行

第二行

可以看到,归一化不是"把所有空白都删掉",而是让文本更稳定、更适合切片,同时保留段落结构。


8.5 为什么要切片

文档解析完成后,我们得到一大段文本。

接下来要做切片。

切片的英文通常叫 Chunking。

它的目标是:

text 复制代码
把长文本拆成多个较短、语义尽量完整的片段

8.5.1 检索需要局部片段

用户提问通常只和文档中的一小部分相关。

比如一本员工手册里有:

  • 入职流程。
  • 考勤制度。
  • 年假规则。
  • 报销流程。
  • 离职手续。

用户问年假时,系统只需要召回年假相关片段。

如果整本手册只有一个向量,检索结果就很粗糙。

8.5.2 降低 Prompt 成本

如果每次问答都把整篇文档放进 Prompt,成本会很高,也容易超过上下文限制。

切片后,只把最相关的几个 chunk 放进 Prompt。

这就是 RAG 比"整篇文档塞给模型"更实用的原因。

8.5.3 提高引用准确性

切片后,系统可以告诉用户:答案来自哪几个片段。

这比只告诉用户"来自员工手册.pdf"更有价值。


8.6 TextChunker 设计

TextChunker 是文本切片组件。

最基础的切片策略是固定长度切片。

它通常有两个核心参数:

text 复制代码
chunkSize
overlap

8.6.1 chunkSize

chunkSize 表示每个 chunk 的最大长度。

比如:

text 复制代码
chunkSize = 800

表示每个片段大约 800 个字符。

chunk 太大,会导致:

  • 检索不够精确。
  • Prompt 变长。
  • 召回片段包含太多无关内容。

chunk 太小,会导致:

  • 语义不完整。
  • 一句话被拆断。
  • 模型看到的上下文不足。

中文文档可以从 500 到 800 字符起步,根据效果调整。

8.6.2 overlap

overlap 表示相邻 chunk 之间重叠的内容长度。

比如:

text 复制代码
chunkSize = 800
overlap = 100

第一个 chunk 是 0 到 800 字符。

第二个 chunk 不是从 800 开始,而是从 700 开始。

这样可以避免关键信息刚好被切在边界处。

8.6.3 chunkIndex

每个 chunk 都需要序号。

比如:

text 复制代码
chunkIndex = 0
chunkIndex = 1
chunkIndex = 2

序号用于:

  • 保持文档顺序。
  • 前端展示引用来源。
  • 重建索引时覆盖旧数据。
  • 通过唯一约束避免重复写入。

8.6.4 边界处理

切片时要处理一些边界情况:

  • 文本长度小于 chunkSize。
  • overlap 大于或等于 chunkSize。
  • 最后一个 chunk 不足 chunkSize。
  • 文本为空。

如果 overlap 设置不合理,比如 overlap >= chunkSize,切片循环可能无法前进,甚至死循环。

所以启动时或配置读取时,应该校验参数合法性。


在代码中,chunkSizeoverlap 不建议硬编码。可以放到 task-serviceapplication.yml 中:

yaml 复制代码
knowhub:
  chunking:
    chunk-size: 800
    overlap: 100

对应的配置类如下:

java 复制代码
package com.luo.knowhub.task.chunking;

import jakarta.annotation.PostConstruct;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "knowhub.chunking")
public class ChunkingConfig {

    /**
     * 每个 chunk 的最大字符数。
     */
    private int chunkSize = 800;

    /**
     * 相邻 chunk 的重叠字符数。
     */
    private int overlap = 100;

    @PostConstruct
    public void validate() {
        if (chunkSize <= 0) {
            throw new IllegalArgumentException("chunkSize 必须大于 0");
        }
        if (overlap < 0) {
            throw new IllegalArgumentException("overlap 不能小于 0");
        }
        if (overlap >= chunkSize) {
            // overlap >= chunkSize 时,start 位置无法向前推进,可能导致死循环。
            throw new IllegalArgumentException("overlap 必须小于 chunkSize");
        }
    }
}

切片结果对象:

java 复制代码
package com.luo.knowhub.task.chunking;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class ChunkResult {
    private Integer chunkIndex;
    private String content;
    private Integer startOffset;
    private Integer endOffset;
}

TextChunker 的完整实现如下:

java 复制代码
package com.luo.knowhub.task.chunking;

import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

@Component
public class TextChunker {

    private final ChunkingConfig config;

    public TextChunker(ChunkingConfig config) {
        this.config = config;
    }

    public List<ChunkResult> chunk(String text) {
        List<ChunkResult> results = new ArrayList<>();
        String normalizedText = TextNormalizer.normalize(text);

        if (normalizedText.isBlank()) {
            return results;
        }

        int chunkSize = config.getChunkSize();
        int overlap = config.getOverlap();

        if (normalizedText.length() <= chunkSize) {
            results.add(new ChunkResult(0, normalizedText, 0, normalizedText.length()));
            return results;
        }

        int start = 0;
        int chunkIndex = 0;

        while (start < normalizedText.length()) {
            int maxEnd = Math.min(start + chunkSize, normalizedText.length());
            int end = maxEnd;

            // 不是最后一段时,尽量在自然边界断开,减少把一句话切成两半的概率。
            if (maxEnd < normalizedText.length()) {
                end = findNaturalBreak(normalizedText, start, maxEnd);
            }

            String content = normalizedText.substring(start, end).trim();
            if (!content.isBlank()) {
                results.add(new ChunkResult(chunkIndex++, content, start, end));
            }

            if (end >= normalizedText.length()) {
                break;
            }

            // 下一段向前移动 chunkSize - overlap。
            // 如果自然断点提前了,也从当前 end 回退 overlap,保留边界上下文。
            int nextStart = Math.max(end - overlap, start + 1);
            start = nextStart;
        }

        return results;
    }

    private int findNaturalBreak(String text, int start, int maxEnd) {
        int minBreak = start + (config.getChunkSize() / 2);

        // 优先在段落或句子边界切开。
        int breakPoint = lastIndexOfAny(text, maxEnd, '\n', '。', '!', '?', '.', '!', '?', ';', ';');
        if (breakPoint > minBreak) {
            return breakPoint + 1;
        }

        // 如果找不到句子边界,再尝试空格边界。
        int spaceIndex = text.lastIndexOf(' ', maxEnd);
        if (spaceIndex > minBreak) {
            return spaceIndex;
        }

        // 实在找不到合适断点,就按最大长度硬切。
        return maxEnd;
    }

    private int lastIndexOfAny(String text, int endExclusive, char... chars) {
        for (int i = endExclusive - 1; i >= 0; i--) {
            for (char c : chars) {
                if (text.charAt(i) == c) {
                    return i;
                }
            }
        }
        return -1;
    }
}

这个版本刻意保持简单:它不是语义切片器,但已经覆盖了学习项目最容易出错的几个点:空文本、短文本、最后一个不足 chunkSize 的片段、自然边界、overlap 推进和死循环防护。


8.7 document_chunk 表设计

切片结果需要保存到 MySQL。

表名可以是 document_chunk

简化结构如下:

sql 复制代码
CREATE TABLE document_chunk (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  document_id BIGINT NOT NULL,
  kb_id BIGINT NOT NULL,
  chunk_index INT NOT NULL,
  content TEXT NOT NULL,
  content_hash VARCHAR(64),
  token_count INT,
  vector_id VARCHAR(128),
  created_at DATETIME NOT NULL,
  UNIQUE KEY uk_doc_chunk (document_id, chunk_index),
  INDEX idx_document_id (document_id),
  INDEX idx_kb_id (kb_id)
);

8.7.1 document_id

表示这个 chunk 来自哪份文档。

8.7.2 kb_id

表示这个 chunk 属于哪个知识库。

后续查询和管理端展示时会用到。

8.7.3 chunk_index

表示 chunk 在文档中的顺序。

同一个 document 下,chunk_index 应该唯一。

8.7.4 content

保存 chunk 文本内容。

RAG 问答时,检索到相关向量后,系统需要拿到 chunk 内容拼接 Prompt。

8.7.5 content_hash

可以保存内容哈希。

它有助于判断内容是否重复,或者后续做增量索引。

第一版可以不是强依赖,但作为扩展字段很有价值。

8.7.6 vector_id

如果向量表和 chunk 表分开存,可以用 vector_id 记录向量表中的对应记录。

KnowHub 使用 MySQL 存 chunk 元数据,PostgreSQL + pgvector 存向量数据,这样业务数据和向量数据职责更清晰。

8.7.7 唯一约束

document_id + chunk_index 应该有唯一约束。

这样即使 RabbitMQ 重复投递任务,或者任务重试时重复写入,也可以通过数据库约束兜底,避免同一文档同一序号 chunk 重复入库。


8.8 从解析到切片的完整流程

现在把前面的内容串起来。

task-service 消费索引任务后,大致流程是:

text 复制代码
1. 根据 documentId 查询 document_info
2. 从 MinIO 下载 storage_path 对应文件
3. 根据 file_type 选择 DocumentParser
4. 解析文件,得到原始文本
5. 文本归一化
6. 检查文本是否为空
7. 使用 TextChunker 切片
8. 删除旧 document_chunk 和旧向量
9. 批量写入新的 document_chunk
10. 后续调用 Embedding 并写入 pgvector

注意,第 8 步非常重要。

如果是重建索引,必须先删除旧 chunk 和旧向量,否则会出现同一文档多份索引数据。


task-service 中可以用一个 IndexTaskProcessor 把 10 步串起来。下面代码保留了主链路,Embedding 和 pgvector 写入会在后续章节展开。

java 复制代码
package com.luo.knowhub.task.service;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.luo.knowhub.common.exception.BusinessException;
import com.luo.knowhub.task.chunking.ChunkResult;
import com.luo.knowhub.task.chunking.TextChunker;
import com.luo.knowhub.task.chunking.TextNormalizer;
import com.luo.knowhub.task.entity.DocumentChunk;
import com.luo.knowhub.task.entity.DocumentInfo;
import com.luo.knowhub.task.enums.DocumentIndexStatus;
import com.luo.knowhub.task.enums.IndexTaskStatus;
import com.luo.knowhub.task.mapper.DocumentChunkMapper;
import com.luo.knowhub.task.mapper.DocumentInfoMapper;
import com.luo.knowhub.task.mapper.IndexTaskMapper;
import com.luo.knowhub.task.message.IndexTaskMessage;
import com.luo.knowhub.task.minio.MinioFileService;
import com.luo.knowhub.task.parser.DocumentParserDispatcher;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;

@Slf4j
@Service
public class IndexTaskProcessor {

    private final DocumentInfoMapper documentInfoMapper;
    private final DocumentChunkMapper documentChunkMapper;
    private final IndexTaskMapper indexTaskMapper;
    private final MinioFileService minioFileService;
    private final DocumentParserDispatcher parserDispatcher;
    private final TextChunker textChunker;

    public IndexTaskProcessor(DocumentInfoMapper documentInfoMapper,
                              DocumentChunkMapper documentChunkMapper,
                              IndexTaskMapper indexTaskMapper,
                              MinioFileService minioFileService,
                              DocumentParserDispatcher parserDispatcher,
                              TextChunker textChunker) {
        this.documentInfoMapper = documentInfoMapper;
        this.documentChunkMapper = documentChunkMapper;
        this.indexTaskMapper = indexTaskMapper;
        this.minioFileService = minioFileService;
        this.parserDispatcher = parserDispatcher;
        this.textChunker = textChunker;
    }

    public void processDocumentIndexTask(IndexTaskMessage message) {
        Long taskId = message.getTaskId();
        Long documentId = message.getDocumentId();

        try {
            markTaskProcessing(taskId);

            // 步骤一:根据 documentId 查询 document_info。
            DocumentInfo document = documentInfoMapper.selectById(documentId);
            if (document == null) {
                throw new BusinessException("文档不存在,无法执行索引任务");
            }

            // 步骤二:从 MinIO 下载 storage_path 对应的文件,完整实现详见第 7 章。
            Path localFile = minioFileService.downloadToTempFile(document.getStoragePath());

            // 步骤三:根据 fileType 选择解析器并解析。
            String rawText = parserDispatcher.dispatch(document.getFileType(), localFile);

            // 步骤四:文本归一化。
            String normalizedText = TextNormalizer.normalize(rawText);

            // 步骤五:空文本直接失败,避免写入无意义 chunk。
            if (TextNormalizer.isBlank(normalizedText)) {
                throw new BusinessException("解析后文本为空,无法切片");
            }

            // 步骤六:执行切片。
            List<ChunkResult> chunks = textChunker.chunk(normalizedText);
            if (chunks.isEmpty()) {
                throw new BusinessException("切片结果为空,无法继续索引");
            }

            // 步骤七:删除旧 chunk 和旧向量,防止重建索引时出现重复数据。
            documentChunkMapper.delete(new LambdaQueryWrapper<DocumentChunk>()
                    .eq(DocumentChunk::getDocumentId, documentId));
            // vectorService.deleteByDocumentId(documentId); // Embedding 与 pgvector 详见后续章节。

            // 步骤八:批量写入新的 document_chunk。
            for (ChunkResult chunk : chunks) {
                DocumentChunk entity = new DocumentChunk();
                entity.setDocumentId(document.getId());
                entity.setKbId(document.getKbId());
                entity.setChunkIndex(chunk.getChunkIndex());
                entity.setContent(chunk.getContent());
                entity.setCreatedAt(LocalDateTime.now());
                documentChunkMapper.insert(entity);
            }

            // 步骤九:后续调用 Embedding 并写入 pgvector,详见第 10 章和第 11 章。
            // embeddingService.embedAndSave(document, chunks);

            // 步骤十:更新文档索引状态和 chunk_count。
            document.setIndexStatus(DocumentIndexStatus.INDEXED.name());
            document.setChunkCount(chunks.size());
            document.setErrorMessage(null);
            document.setUpdatedAt(LocalDateTime.now());
            documentInfoMapper.updateById(document);

            markTaskSuccess(taskId);
            log.info("索引任务执行完成,taskId={}, documentId={}, chunkCount={}",
                    taskId, documentId, chunks.size());
        } catch (Exception e) {
            String errorMessage = buildErrorMessage(e);
            markDocumentFailed(documentId, errorMessage);
            markTaskFailed(taskId, errorMessage);
            log.error("索引任务执行失败,taskId={}, documentId={}, reason={}",
                    taskId, documentId, errorMessage, e);
        }
    }

    private void markDocumentFailed(Long documentId, String errorMessage) {
        DocumentInfo document = documentInfoMapper.selectById(documentId);
        if (document == null) {
            return;
        }
        document.setIndexStatus(DocumentIndexStatus.FAILED.name());
        document.setChunkCount(0);
        document.setErrorMessage(errorMessage);
        document.setUpdatedAt(LocalDateTime.now());
        documentInfoMapper.updateById(document);
    }

    private void markTaskProcessing(Long taskId) {
        indexTaskMapper.updateStatus(taskId, IndexTaskStatus.PROCESSING.name(), null);
    }

    private void markTaskSuccess(Long taskId) {
        indexTaskMapper.updateStatus(taskId, IndexTaskStatus.SUCCESS.name(), null);
    }

    private void markTaskFailed(Long taskId, String errorMessage) {
        indexTaskMapper.updateStatus(taskId, IndexTaskStatus.FAILED.name(), errorMessage);
    }

    private String buildErrorMessage(Exception e) {
        String message = e.getMessage();
        if (message == null || message.isBlank()) {
            message = e.getClass().getSimpleName();
        }
        return message.length() > 500 ? message.substring(0, 500) : message;
    }
}

这段代码把本章的解析器、归一化、切片和 document_chunk 入库串成了一条主线。读者先把这条链路跑通,再进入 Embedding 和 pgvector,会更容易理解后续章节。


8.9 常见问题排查

8.9.1 PDF 解析失败

常见原因:

  • PDF 文件损坏。
  • PDF 加密。
  • PDF 是扫描图片,没有文本层。
  • PDFBox 版本不兼容。

处理方式:

  • 捕获异常。
  • 任务状态置为 FAILED。
  • 记录 error_message。
  • 后续可扩展 OCR 处理扫描件。

8.9.2 解析结果为空

如果解析结果为空,不应该继续切片。

排查:

  • 原文件是否为空。
  • 文件类型是否正确。
  • 解析器是否选对。
  • PDF 是否为扫描件。
  • 编码是否正确。

8.9.3 文本乱码

TXT 文件最容易遇到编码问题。

如果系统默认按 UTF-8 读取,但文件是 GBK,就可能乱码。

解决方式:

  • 约定上传文件编码。
  • 尝试编码检测。
  • 前端或文档说明中提示使用 UTF-8。

学习项目可以先约定 UTF-8,后续再扩展自动识别。

8.9.4 chunk 太大

现象:检索结果命中一个很长片段,Prompt 变得很长。

解决:调小 chunkSize。

8.9.5 chunk 太小

现象:检索到的片段只有半句话,模型无法回答完整问题。

解决:调大 chunkSize,或增加 overlap。

8.9.6 overlap 配置错误

如果 overlap 大于等于 chunkSize,切片逻辑可能无法前进。

配置校验中应该禁止这种情况:

text 复制代码
overlap < chunkSize

8.9.7 chunk_count 和实际不一致

如果 document_info.chunk_countdocument_chunk 实际数量不一致,说明任务执行过程中状态更新有问题。

排查:

  • chunk 是否批量写入成功。
  • document_info 是否更新 chunk_count。
  • 任务是否中途失败。
  • 是否重复执行导致旧数据未清理。

8.9.8 PDFBox 解析大文件时内存溢出

现象:task-service 在处理某些 PDF 时抛出 OutOfMemoryErrorGC overhead limit exceeded,或者服务突然变慢、频繁 Full GC。

排查顺序:

  1. 确认 PDF 文件是否过大,例如超过 50MB。
  2. 确认 PDF 是否包含大量高清图片,很多扫描版 PDF 看起来页数不多,但体积和内存占用很高。
  3. 确认 task-service 的 JVM 堆内存是否过小,学习项目建议至少从 -Xmx512m 起步。
  4. 确认上传入口是否已经限制 PDF 文件大小,避免超大文件直接进入解析链路。

解决方向:

  • 学习阶段先限制 PDF 上传大小。
  • 对扫描件提示用户上传带文本层的 PDF。
  • 后续如果要支持大 PDF,可以扩展分批解析、临时文件解析或更专业的文档解析服务,避免一次性把复杂 PDF 全部加载到内存中。

本章小结

这一章我们讲了文档解析与文本切片。

原始文件不能直接进入 RAG 检索。系统必须先通过解析器把 TXT、Markdown、PDF 等文件变成纯文本,再通过 TextChunker 把长文本切成多个适合向量化和检索的 chunk。

DocumentParser 接口让不同文件类型有统一解析入口,DocumentParserDispatcher 负责根据文件类型选择合适解析器。文本解析后还要进行基础归一化和空文本校验。

切片时最重要的两个参数是 chunkSizeoverlap。chunkSize 控制片段大小,overlap 解决边界信息丢失问题。切片结果保存到 document_chunk 表,并通过 document_id + chunk_index 唯一约束防止重复写入。

下一章,我们会进入 RabbitMQ 消息队列与索引任务,讲清文档上传后如何把解析、切片、Embedding 和向量入库放到后台异步执行。


本章新增类清单

本章落到 task-service 中,主要新增这些类:

text 复制代码
task-service/src/main/java/.../parser/
  DocumentParser.java              (解析器接口)
  PlainTextDocumentParser.java     (TXT/Markdown 解析)
  PdfDocumentParser.java           (PDF 解析)
  DocumentParserDispatcher.java    (解析器分发)

task-service/src/main/java/.../chunking/
  TextNormalizer.java              (文本归一化)
  TextChunker.java                 (文本切片)
  ChunkResult.java                 (切片结果 POJO)
  ChunkingConfig.java              (切片配置类)

读者动手时可以先按这个目录建类,再把本章代码填进去。这样项目结构不会散,也方便后续第 9 章的 RabbitMQ 消费任务调用。


动手验证:从解析到切片结果入库

学完本章后,可以用三类文件验证解析和切片链路是否生效。

步骤一:准备三个测试文件。

text 复制代码
test.txt:UTF-8 编码,约 2000 字符
test.md:包含标题、列表和普通段落
test.pdf:包含可复制文本的 PDF,确保不是扫描件

对应本章 8.2 和 8.4,验证解析器和归一化能处理不同来源的文本。

步骤二:通过 Gateway 分别上传三个文件到同一个知识库,记录每个返回的 documentId

http 复制代码
POST /kb/{kbId}/documents/upload
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: test.txt / test.md / test.pdf

对应本章 8.2、8.3 和第 7 章文件上传链路。

步骤三:确认 task-service 已启动,并确认 RabbitMQ 消息已经被消费。

可以查看 task-service 日志中是否出现类似信息:

text 复制代码
开始解析文档
文档解析完成
索引任务执行完成

对应本章 8.8,验证后台索引任务是否真正执行。

步骤四:查询 document_info 表。

sql 复制代码
SELECT id, file_name, file_type, index_status, chunk_count, error_message
FROM document_info
WHERE id IN (文档1, 文档2, 文档3);

预期结果:三个文档的 index_status 都变为 INDEXEDchunk_count 大于 0。

对应本章 8.8 的状态更新和 chunk_count 回写。

步骤五:查询 document_chunk 表。

sql 复制代码
SELECT document_id, chunk_index, CHAR_LENGTH(content) AS content_len, LEFT(content, 80) AS preview
FROM document_chunk
WHERE document_id = 文档ID
ORDER BY document_id, chunk_index;

预期结果:每个文档都有多条 chunk 记录,chunk_index 从 0 开始递增,内容长度大致符合 chunkSize 配置,相邻 chunk 之间能看到少量重叠内容。

对应本章 8.6 和 8.7,验证切片算法和表结构设计。

步骤六:上传一个空白 TXT 文件。

预期结果:任务状态变为 FAILEDerror_message 中包含"解析后文本为空"或类似提示。

对应本章 8.4.3,验证空文本校验。

步骤七:上传一个扫描版 PDF。

预期结果:如果 PDF 没有文本层,任务应变为 FAILED,并记录合理错误信息,而不是一直卡在 INDEXING

对应本章 8.2.3、8.4.3 和 8.9.1。

如果切片结果和预期不一致,先检查 application.yml 中的 chunkSizeoverlap 配置,再检查 TextChunker 的边界处理逻辑,尤其是 overlap < chunkSize 这个约束是否生效。

思考题

  1. 为什么不能直接把完整文件丢给 Embedding 模型?
  2. DocumentParser 接口解决了什么问题?
  3. PDF 解析为什么比 TXT/Markdown 更容易失败?
  4. 文本归一化时为什么不能简单删除所有换行?
  5. chunkSize 太大和太小分别有什么问题?
  6. overlap 的作用是什么?
  7. 为什么 document_id + chunk_index 需要唯一约束?

思考题参考答案

  1. 为什么不能直接把完整文件丢给 Embedding 模型?

    • 原始文件不一定是纯文本(如 PDF 是二进制),Embedding 模型只能处理文本;
    • 文档通常太长,一次性向量化会超出模型输入限制,且向量表示粗糙,无法定位具体段落;
    • 引用来源需要片段粒度,整篇文档无法告诉用户答案出自哪个具体片段。
  2. DocumentParser 接口解决了什么问题?

    • 把不同文件类型(TXT、Markdown、PDF 等)的解析逻辑抽象成统一接口(supports + parse);
    • 业务代码通过该接口选择解析器,新增文件类型时只需新增实现类,而无需修改已有代码。
  3. PDF 解析为什么比 TXT/Markdown 更容易失败?

    • PDF 不是纯文本格式,可能包含加密、扫描图片(无文本层)、复杂表格、页眉页脚、换行错乱等;
    • PDFBox 等库提取文本时容易受这些因素影响,出错概率远高于直接读取文本文件。
  4. 文本归一化时为什么不能简单删除所有换行?

    • 换行通常代表段落边界,对后续切片和语义理解有重要价值;
    • 如果粗暴删除所有换行,会把不同段落挤成一段,破坏文本结构,降低切片和检索质量。
  5. chunkSize 太大和太小分别有什么问题?

    • 太大:检索不精确,Prompt 成本高,召回片段包含过多无关内容;
    • 太小:语义不完整,关键信息被切断,模型看到的上下文不足,难以给出准确回答。
  6. overlap 的作用是什么?

    • 让相邻 chunk 之间有一定长度的重叠内容,避免重要信息刚好落在两个 chunk 的边界处而丢失;
    • 提升检索召回时上下文的连贯性。
  7. 为什么 document_id + chunk_index 需要唯一约束?

    • 防止重建索引或消息重复投递时,同一文档同一序号的 chunk 被重复插入;
    • 通过数据库唯一约束做兜底,保证数据一致性,避免脏数据影响后续检索和展示。
相关推荐
瞬间&永恒~2 小时前
【MySQL】 主从复制多拓扑搭建实验
运维·数据库·mysql·云原生
han_hanker2 小时前
sql语法 DECODE, CASE ... WHEN
数据库·sql·oracle
井川廊咏2 小时前
vi 删除指定范围的行,不用再反复按 dd
数据库·mysql
z落落3 小时前
StudentInfo表 分页、增删改 存储过程+C# WinForm 存储过程实现分页和增删改查
数据库·sql·mysql
影寂ldy3 小时前
WinForm 数据库【新增+编辑】完整功能(参数化查询+配置文件+防报错全套)
数据库
迷枫7123 小时前
达梦数据库 Hint 使用与 Hint 注入实战
数据库
Gauss松鼠会5 小时前
【GaussDB】GaussDB锁阻塞源头查询
java·开发语言·前端·数据库·算法·gaussdb·经验总结
2601_965798475 小时前
How to Build a Custom Artisan Store on WordPress: Crafti Theme Review
linux·服务器·数据库
霸道流氓气质5 小时前
Spring 事务传播机制与 REQUIRES_NEW
java·数据库·spring