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 的基础。


参考资料

相关推荐
ar01232 小时前
AR远程专家指导:赋能工业、精密制造业
人工智能·ar
大傻^2 小时前
Spring AI Alibaba Deep Research:自动化深度调研与报告生成
人工智能·spring·自动化
Matrix_112 小时前
论文阅读:UltraFusion Ultra High Dynamic Imaging using Exposure Fusion
论文阅读·人工智能·计算摄影
kaoshi100app2 小时前
本周,河南二建报名公布!
开发语言·人工智能·职场和发展·学习方法
我材不敲代码2 小时前
基于 OpenCV 的票据图像矫正与透视变换实战
人工智能·opencv·计算机视觉
marteker2 小时前
研究发现,电商零售商计划在代理电商领域进行大规模投资
人工智能
人工智能AI技术2 小时前
Qwen3.5-Plus登顶|C#集成通义千问,高并发服务实战优化
人工智能·c#
阿_旭2 小时前
基于YOLO26深度学习的蓝莓成熟度检测与分割系统【python源码+Pyqt5界面+数据集+训练代码】图像分割、人工智能
人工智能·python·深度学习·毕业设计·蓝莓成熟度检测
恼书:-(空寄2 小时前
拦截器获取不到 POST 请求 JSON 结构体参数(完整解决方案)
java·spring boot·spring·servlet