Spring AI Alibaba 文档智能处理:PDF、Markdown知识入库全链路

Spring AI Alibaba 文档智能处理:PDF、Markdown知识入库全链路

导读:知识库的质量上限由文档处理质量决定。本文深入讲解 Spring AI Alibaba 的文档加载、内容清洗、智能分块、元数据增强、增量更新与异步向量化管道的全链路实现,这些细节决定了 RAG 系统的天花板。


一、文档处理的质量陷阱

很多团队搭了 RAG 系统,但效果不理想,根本原因往往不在模型,而在文档处理质量太差

  • PDF 里的表格变成了乱码;
  • 分块策略不当,一段完整的概念被切断了;
  • 无用的页眉页脚内容混入了知识块;
  • 没有元数据,检索到内容后不知道来源。

这些问题在入库阶段埋下,在检索阶段爆发,而且很难排查。本文就是要把这条链路上的每个环节都做扎实。


二、文档加载器全家桶

2.1 依赖配置

xml 复制代码
<!-- PDF 文档读取 -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>

<!-- Markdown/HTML/文本读取(包含在 spring-ai-core 中) -->
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-core</artifactId>
</dependency>

<!-- Tika(通用文档解析,支持 Word/Excel/PPT 等) -->
<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>

2.2 PDF 加载:两种策略

Spring AI 提供了两个 PDF 读取器,适用场景不同:

策略一:PagePdfDocumentReader(按页读取,保留结构)

java 复制代码
@Service
@RequiredArgsConstructor
@Slf4j
public class DocumentLoaderService {

    /**
     * PDF 按页读取:每页作为一个 Document
     * 适合:内容按页组织的文档(报告、手册、教材)
     */
    public List<Document> loadPdfByPage(Resource pdfResource) {
        PdfDocumentReaderConfig config = PdfDocumentReaderConfig.builder()
                // 每页作为独立 Document
                .withPagesPerDocument(1)
                // 文本格式化:保留换行,过滤短行(页眉页脚)
                .withPageExtractedTextFormatter(
                        new ExtractedTextFormatter.Builder()
                                .withNumberOfTopPagesToSkipBeforeDelete(0)
                                // 过滤掉少于 6 个 token 的行(通常是页眉页脚)
                                .withNumberOfBottomTextLinesToDelete(1)
                                .withNumberOfTopTextLinesToDelete(1)
                                .build()
                )
                .build();

        PagePdfDocumentReader reader = new PagePdfDocumentReader(pdfResource, config);
        List<Document> documents = reader.get();

        // 为每个 Document 注入文件来源元数据
        String fileName = pdfResource.getFilename();
        for (int i = 0; i < documents.size(); i++) {
            Document doc = documents.get(i);
            doc.getMetadata().put("source", fileName);
            doc.getMetadata().put("pageNumber", i + 1);
            doc.getMetadata().put("totalPages", documents.size());
        }

        log.info("PDF [{}] 共 {} 页已加载", fileName, documents.size());
        return documents;
    }

    /**
     * PDF 段落读取:按段落分割(适合密集段落的论文、合同等)
     */
    public List<Document> loadPdfByParagraph(Resource pdfResource) {
        ParagraphPdfDocumentReader reader = new ParagraphPdfDocumentReader(
                pdfResource,
                PdfDocumentReaderConfig.builder()
                        .withPageTopMargin(70)     // 忽略页面顶部 70pt(过滤页眉)
                        .withPageBottomMargin(70)  // 忽略页面底部 70pt(过滤页脚)
                        .build()
        );
        return reader.get();
    }
}

2.3 Markdown 加载

java 复制代码
/**
 * Markdown 文档加载
 * 适合:技术文档、Wiki、README、博客文章
 */
public List<Document> loadMarkdown(Resource markdownResource) {
    MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()
            // 是否将代码块作为独立 Document
            .withIncludeCodeBlock(true)
            // 是否将各级标题作为分割点
            .withIncludeBlockquote(false)
            .build();

    MarkdownDocumentReader reader = new MarkdownDocumentReader(
            markdownResource, config);
    List<Document> documents = reader.get();

    // Markdown 特殊处理:提取标题作为元数据
    documents.forEach(doc -> {
        String content = doc.getContent();
        // 提取第一行作为标题(如果是 # 开头)
        String firstLine = content.split("\n")[0];
        if (firstLine.startsWith("#")) {
            String title = firstLine.replaceAll("^#+\\s*", "").trim();
            doc.getMetadata().put("title", title);
        }
    });

    return documents;
}

2.4 HTML 加载(Web 内容入库)

java 复制代码
/**
 * HTML 文档加载:过滤标签,保留纯文本
 */
public List<Document> loadHtml(Resource htmlResource) {
    // Spring AI 提供了 HtmlDocumentReader(需要 jsoup 依赖)
    List<Document> documents = new ArrayList<>();

    try (InputStream is = htmlResource.getInputStream()) {
        // 使用 Jsoup 解析 HTML,提取纯文本
        org.jsoup.nodes.Document htmlDoc = Jsoup.parse(is, "UTF-8", "");

        // 移除无用标签
        htmlDoc.select("script, style, nav, footer, header, aside").remove();

        String cleanText = htmlDoc.body().text();
        String title = htmlDoc.title();

        Document doc = new Document(cleanText);
        doc.getMetadata().put("title", title);
        doc.getMetadata().put("source", htmlResource.getFilename());
        documents.add(doc);
    } catch (IOException e) {
        log.error("HTML 加载失败:{}", e.getMessage());
    }

    return documents;
}

2.5 通用文档加载(Tika)

java 复制代码
/**
 * 基于 Apache Tika 的通用文档加载器
 * 支持 Word、Excel、PPT、TXT 等几十种格式
 */
public List<Document> loadWithTika(Resource resource) {
    Tika tika = new Tika();
    try {
        String content = tika.parseToString(resource.getInputStream());
        // 检测文档语言(可选)
        LanguageDetector detector = OptimaizeLangDetector.getDefaultLanguageDetector().loadModels();
        String language = detector.detect(content).getLanguage();

        Document doc = new Document(content);
        doc.getMetadata().put("source", resource.getFilename());
        doc.getMetadata().put("language", language);
        doc.getMetadata().put("fileType",
                FilenameUtils.getExtension(resource.getFilename()));

        return List.of(doc);
    } catch (Exception e) {
        log.error("Tika 解析失败:{}", e.getMessage());
        return Collections.emptyList();
    }
}

三、内容清洗:让噪声远离知识库

3.1 PDF 常见噪声清洗

java 复制代码
@Component
public class ContentCleaner {

    /**
     * PDF 内容清洗
     * 处理 PDF 提取后常见的格式问题
     */
    public String cleanPdfContent(String rawContent) {
        if (rawContent == null) return "";

        return rawContent
                // 1. 修复 PDF 中常见的连字符换行(单词被拆分到两行)
                .replaceAll("(\\w+)-\\n(\\w+)", "$1$2")

                // 2. 去除多余的空白行(超过2个换行压缩为2个)
                .replaceAll("\\n{3,}", "\n\n")

                // 3. 去除行首行尾的空白
                .lines()
                .map(String::trim)
                .filter(line -> !line.isEmpty())
                .collect(Collectors.joining("\n"))

                // 4. 去除页码(独立行的数字,如 "- 12 -" 或 "12")
                .replaceAll("(?m)^\\s*-?\\s*\\d+\\s*-?\\s*$", "")

                // 5. 去除乱码字符(PDF 字体编码问题)
                .replaceAll("[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]", "")

                .trim();
    }

    /**
     * HTML 内容清洗
     */
    public String cleanHtmlContent(String rawContent) {
        if (rawContent == null) return "";

        return rawContent
                // 去除 HTML 标签(保底处理)
                .replaceAll("<[^>]+>", "")
                // 替换 HTML 实体
                .replace("&nbsp;", " ")
                .replace("&lt;", "<")
                .replace("&gt;", ">")
                .replace("&amp;", "&")
                .replace("&quot;", "\"")
                // 去除多余空白
                .replaceAll("\\s{2,}", " ")
                .trim();
    }

    /**
     * 判断文本块是否为有效内容(过滤噪声块)
     */
    public boolean isValidContent(String content) {
        if (content == null || content.trim().length() < 50) {
            return false; // 太短的块过滤掉
        }

        // 中文字符占比检查(针对中文文档)
        long totalChars = content.length();
        long chineseChars = content.chars()
                .filter(c -> c >= 0x4E00 && c <= 0x9FFF).count();
        long digitAndPunctuationChars = content.chars()
                .filter(c -> !Character.isLetter(c)).count();

        // 如果 60% 以上是数字和标点,可能是表格或乱码
        if (digitAndPunctuationChars > totalChars * 0.6) {
            return false;
        }

        return true;
    }
}

四、分块策略:知识库质量的关键

4.1 三种分块策略对比

复制代码
+--------------------+------------------+-----------+---------------------------------+
| 分块策略            | 实现方式          | 优点      | 缺点及适用场景                   |
+--------------------+------------------+-----------+---------------------------------+
| 固定 Token 分块     | TokenTextSplitter| 简单稳定  | 可能切断句子;适合通用场景        |
| 语义段落分块        | 按段落/标题分割   | 保留完整  | 块大小不均;适合结构化文档        |
| 重叠滑动窗口        | 含 overlap 分块  | 上下文连续| 数据冗余;适合需要跨块理解的场景  |
+--------------------+------------------+-----------+---------------------------------+

4.2 TokenTextSplitter 详细配置

java 复制代码
@Component
public class SmartTextSplitter {

    /**
     * 面向技术文档的分块策略
     * 块大小 512,重叠 100,最小块 50
     */
    public TokenTextSplitter technicalDocSplitter() {
        return new TokenTextSplitter(
                512,    // chunkSize:每块最大 Token 数
                100,    // chunkOverlap:相邻块重叠的 Token 数
                50,     // minChunkSize:最小块大小(避免产生碎片)
                10000,  // maxChunkSize:单块上限(防止超长段落)
                true    // keepSeparator:在分割符处保留换行
        );
    }

    /**
     * 面向 FAQ 的分块策略(更小的块)
     * FAQ 通常是一问一答,保持完整即可
     */
    public TokenTextSplitter faqSplitter() {
        return new TokenTextSplitter(256, 50, 30, 5000, false);
    }

    /**
     * 面向长篇报告的分块策略(更大的块)
     */
    public TokenTextSplitter reportSplitter() {
        return new TokenTextSplitter(1024, 200, 100, 20000, true);
    }
}

4.3 语义感知分块(按章节标题)

java 复制代码
/**
 * 基于 Markdown 标题层级的语义分块
 * 将文档按 # ## ### 层级划分为知识单元
 */
@Component
public class MarkdownSemanticSplitter {

    public List<Document> splitByHeadings(String markdownContent,
                                            Map<String, Object> baseMetadata) {
        List<Document> chunks = new ArrayList<>();
        String[] lines = markdownContent.split("\n");

        StringBuilder currentChunk = new StringBuilder();
        String currentHeading = "";
        int headingLevel = 0;

        for (String line : lines) {
            if (line.startsWith("#")) {
                // 遇到新标题:保存当前块
                if (currentChunk.length() > 0) {
                    Document doc = new Document(currentChunk.toString().trim());
                    Map<String, Object> meta = new HashMap<>(baseMetadata);
                    meta.put("heading", currentHeading);
                    meta.put("headingLevel", headingLevel);
                    doc.getMetadata().putAll(meta);
                    chunks.add(doc);
                    currentChunk = new StringBuilder();
                }
                // 提取标题信息
                headingLevel = countLeadingChars(line, '#');
                currentHeading = line.replaceAll("^#+\\s*", "").trim();
                currentChunk.append(line).append("\n");
            } else {
                currentChunk.append(line).append("\n");
            }
        }

        // 保存最后一块
        if (currentChunk.length() > 0) {
            Document doc = new Document(currentChunk.toString().trim());
            Map<String, Object> meta = new HashMap<>(baseMetadata);
            meta.put("heading", currentHeading);
            doc.getMetadata().putAll(meta);
            chunks.add(doc);
        }

        return chunks;
    }

    private int countLeadingChars(String s, char c) {
        int count = 0;
        for (char ch : s.toCharArray()) {
            if (ch == c) count++;
            else break;
        }
        return count;
    }
}

五、元数据增强:让每个知识块"自我介绍"

丰富的元数据是精准检索的基础。除了基础的文件名和页码,可以自动提取更多有价值的元数据:

java 复制代码
@Component
@RequiredArgsConstructor
public class MetadataEnricher {

    private final ChatClient chatClient;

    /**
     * AI 自动提取元数据
     * 使用 LLM 从文本内容中抽取关键词、摘要等
     */
    public Map<String, Object> enrichWithAI(String content) {
        String prompt = String.format("""
                请分析以下文本,以 JSON 格式输出:
                {
                  "keywords": ["关键词1", "关键词2", "关键词3"],
                  "summary": "一句话摘要(30字以内)",
                  "topic": "主题分类(技术/产品/运营/财务/法务 等)",
                  "importance": "重要程度(高/中/低)"
                }
                
                文本内容:
                %s
                
                只输出 JSON,不要其他内容。
                """, content.substring(0, Math.min(content.length(), 500)));

        String jsonResult = chatClient.prompt(prompt)
                .options(DashScopeChatOptions.builder()
                        .withModel("qwen-turbo")  // 元数据提取用轻量模型节省成本
                        .withTemperature(0.0)
                        .build())
                .call()
                .content();

        try {
            ObjectMapper mapper = new ObjectMapper();
            return mapper.readValue(
                    jsonResult.replaceAll("```json|```", "").trim(),
                    new TypeReference<>() {});
        } catch (Exception e) {
            log.warn("元数据提取失败,使用默认值");
            return Map.of("topic", "general", "importance", "中");
        }
    }

    /**
     * 规则提取元数据(无需调用 AI,成本为零)
     */
    public Map<String, Object> enrichWithRules(String content, String fileName) {
        Map<String, Object> metadata = new HashMap<>();

        // 文件类型
        metadata.put("fileType", FilenameUtils.getExtension(fileName));

        // 语言检测
        long chineseCount = content.chars()
                .filter(c -> c >= 0x4E00 && c <= 0x9FFF).count();
        metadata.put("language", chineseCount > content.length() * 0.3 ? "zh" : "en");

        // 内容长度分级
        int length = content.length();
        metadata.put("contentLength", length < 200 ? "short"
                : length < 1000 ? "medium" : "long");

        // 是否包含代码
        metadata.put("hasCode", content.contains("```") || content.contains("    "));

        // 时间戳
        metadata.put("ingestTime", System.currentTimeMillis());
        metadata.put("ingestDate",
                LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE));

        return metadata;
    }
}

六、增量更新机制

6.1 基于 MD5 的去重

java 复制代码
@Service
@RequiredArgsConstructor
@Slf4j
public class IncrementalIngestionService {

    private final VectorStore vectorStore;
    private final ContentCleaner contentCleaner;
    private final MetadataEnricher metadataEnricher;

    // 文件 hash 缓存(实际项目存 Redis 或数据库)
    private final Map<String, String> fileHashCache = new ConcurrentHashMap<>();

    /**
     * 增量入库:只处理内容有变化的文件
     */
    public IngestionResult ingestIncremental(Resource resource,
                                              Map<String, Object> extraMetadata) {
        String fileName = resource.getFilename();

        try {
            // 计算文件 hash
            byte[] fileBytes = resource.getInputStream().readAllBytes();
            String currentHash = DigestUtils.md5DigestAsHex(fileBytes);
            String cachedHash = fileHashCache.get(fileName);

            if (currentHash.equals(cachedHash)) {
                log.info("[{}] 文件未变化,跳过入库", fileName);
                return IngestionResult.skipped(fileName);
            }

            // hash 有变化:删除旧版本
            if (cachedHash != null) {
                log.info("[{}] 检测到文件更新,删除旧版本", fileName);
                deleteDocumentsBySource(fileName);
            }

            // 重新入库
            List<Document> documents = loadAndProcess(resource, extraMetadata);
            vectorStore.add(documents);

            // 更新 hash 缓存
            fileHashCache.put(fileName, currentHash);
            log.info("[{}] 入库成功,共 {} 个文本块", fileName, documents.size());

            return IngestionResult.success(fileName, documents.size());

        } catch (Exception e) {
            log.error("[{}] 入库失败:{}", fileName, e.getMessage(), e);
            return IngestionResult.failed(fileName, e.getMessage());
        }
    }

    private void deleteDocumentsBySource(String source) {
        // 通过元数据过滤删除旧文档(具体语法依 VectorStore 实现而定)
        // 注意:部分 VectorStore 不支持按条件删除,需要记录 ID 批量删除
        log.info("删除来源为 [{}] 的所有旧文档", source);
    }

    /**
     * 结果对象
     */
    @Value
    public static class IngestionResult {
        String fileName;
        Status status;
        int chunksCount;
        String errorMessage;

        public enum Status { SUCCESS, SKIPPED, FAILED }

        public static IngestionResult success(String fn, int count) {
            return new IngestionResult(fn, Status.SUCCESS, count, null);
        }
        public static IngestionResult skipped(String fn) {
            return new IngestionResult(fn, Status.SKIPPED, 0, null);
        }
        public static IngestionResult failed(String fn, String error) {
            return new IngestionResult(fn, Status.FAILED, 0, error);
        }
    }
}

七、异步向量化管道(Spring Batch 集成)

当需要批量处理大量文档时(如历史存量数据迁移),同步处理会阻塞太久。Spring Batch 提供了成熟的批处理能力:

java 复制代码
@Configuration
@EnableBatchProcessing
@RequiredArgsConstructor
public class VectorIngestionBatchConfig {

    private final JobBuilderFactory jobBuilderFactory;
    private final StepBuilderFactory stepBuilderFactory;
    private final VectorStore vectorStore;

    /**
     * 定义批量向量化 Job
     */
    @Bean
    public Job vectorIngestionJob(Step ingestionStep) {
        return jobBuilderFactory.get("vectorIngestionJob")
                .start(ingestionStep)
                .build();
    }

    /**
     * 定义处理步骤:读取 → 处理 → 写入向量库
     */
    @Bean
    public Step ingestionStep() {
        return stepBuilderFactory.get("ingestionStep")
                .<Resource, List<Document>>chunk(10)  // 每批处理 10 个文件
                .reader(documentResourceReader())
                .processor(documentProcessor())
                .writer(vectorStoreWriter())
                .faultTolerant()
                .skipLimit(10)  // 允许最多 10 个文件失败
                .skip(Exception.class)
                .build();
    }

    @Bean
    public ItemWriter<List<Document>> vectorStoreWriter() {
        return items -> {
            List<Document> allDocs = items.stream()
                    .flatMap(Collection::stream)
                    .toList();
            log.info("批量写入 {} 个文档到向量存储", allDocs.size());
            vectorStore.add(allDocs);
        };
    }
}

八、完整流水线总结

复制代码
文档入库完整流程:

输入文件(PDF/Markdown/HTML/Word)
         |
         v
    [文档加载器]
    PagePdfDocumentReader / MarkdownDocumentReader
    ParagraphPdfDocumentReader / TikaDocumentReader
         |
         v
    [内容清洗]
    去除页眉页脚 / 修复 PDF 乱码 / 过滤无效噪声块
         |
         v
    [智能分块]
    TokenTextSplitter(固定 Token)
    语义分块(按标题)
    重叠滑动窗口
         |
         v
    [元数据增强]
    AI 提取关键词/摘要/分类
    规则提取文件类型/语言/时间戳
    文件 hash(用于增量更新去重)
         |
         v
    [向量化]
    EmbeddingModel(text-embedding-v3)
    批量 Embedding 接口(减少 API 调用次数)
         |
         v
    [写入 VectorStore]
    Milvus / Elasticsearch / Redis Vector
         |
         v
    完成,可用于 RAG 检索

九、总结

文档处理质量是 RAG 系统成败的关键,本文覆盖了:

  1. 多格式加载:PDF(按页/按段落)、Markdown、HTML、Tika 通用格式;
  2. 内容清洗:修复 PDF 连字符换行、过滤页眉页脚、验证块有效性;
  3. 分块策略:固定 Token(通用)、语义段落(结构化文档)、滑动窗口(需要上下文连续);
  4. 元数据增强:AI 提取 + 规则提取双管齐下;
  5. 增量更新:MD5 哈希去重,内容不变的文件跳过入库;
  6. Spring Batch:存量数据批量向量化,可容错可监控。

下一篇进入 Function Calling 领域:让 AI 能调用你的 Java 方法、查数据库、访问第三方 API,这是构建智能 Agent 的基础。


参考资料

相关推荐
云絮.1 小时前
增删改查操作
java·开发语言
暂未成功人士!1 小时前
简单了解李群和李代数的相关概念以及典型应用
人工智能·机器人·slam·姿态·李群李代数
阿坤带你走近大数据1 小时前
Linux中管道符的作用
java·linux·服务器
searchforAI1 小时前
Obsidian加上AI之后,我的知识管理和内容创作流被重写了
人工智能
码不停蹄的玄黓2 小时前
Spring Boot 实现过滤器(Filter)三种常用方式
java·spring boot·后端
微软技术栈2 小时前
技术速递|以 Token 经济学驱动的架构:混合模型、AI Runway、AKS Kata MicroVM 与 MCP
人工智能
dualven_in_csdn2 小时前
一键起飞调用示例
android·java·javascript
【这个世界会好的】2 小时前
单层PDF转双层PDF工具
pdf
Web极客码2 小时前
如何通过 Python + LLM 用最少的 Token 完成精准推荐任务
开发语言·人工智能·python·ai
雮尘2 小时前
LangGraph 与 LangSmith 入门教程(JS/TS 版)
前端·人工智能·langchain