Spring AI 文档ETL实战:集成text-embedding-v4 与 Milvus

向量数据库 Milvus

嵌入模型 text-embedding-v4

一、Spring AI ETL 的核心组成

Spring AI 提供了一套清晰且可扩展的 API 来实现 ETL(Extract, Transform, Load) 数据处理流程,这是构建 RAG 系统中最关键的一环。整个流程可以分为三个核心阶段:

1. 抽取(Extract)

通过 DocumentReader 接口,Spring AI 支持从多种来源读取非结构化文本数据:

  • MarkdownDocumentReader:读取 .md 文件
  • TikaDocumentReader:支持 DOCX、PDF、HTML 等数十种格式
  • PdfDocumentReader:专为 PDF 优化
  • 自定义 Reader:可扩展支持数据库、网页、API 等

所有读取的内容都会被封装为 Document 对象,包含 pageContent(正文)和 metadata(元数据),作为后续处理的数据载体。

2. 转换(Transform)

这是提升数据质量的核心环节,通过 DocumentTransformer 接口对文档列表进行一系列处理:

  • 文本分块(Splitting) :使用 TokenTextSplitter 按 token 数量切分长文本,避免超出模型上下文限制。
  • 元数据增强(Enrichment):调用大模型为每一块文本生成摘要、关键词、实体、分类等辅助信息,极大提升检索的语义理解能力。
  • 清洗与标准化:去除噪声、统一编码、格式化日期等。

3. 加载(Load)

通过 DocumentWriter 或直接调用 VectorStore 接口,将处理后的文档写入目标存储系统:

  • 向量数据库
  • 搜索引擎

二、生成摘要和关键词

在传统的向量检索中,系统仅依赖原始文本的向量表示进行匹配。虽然语义相似性较高,但存在两个显著问题:

  1. 当系统召回某段文本时,开发者或用户无法快速理解"为什么这段被召回?" 只能看到一长串原文,难以判断其相关性。
  2. 分块后的文本可能只包含局部信息,丢失了前后文的逻辑关系,导致生成的回答断章取义。

通过大模型为每个文本块生成 摘要(Summary)关键词(Keywords),可以有效解决上述问题:

  • 摘要的作用是提供文本核心内容的浓缩表达,便于快速预览和理解,增强检索结果的可读性与可解释性。
  • 关键词的作用是提取核心实体与主题,可用于标量过滤(scalar filtering),实现"向量 + 关键词"的混合检索,提高精准度。

通过保留相邻块的摘要信息,帮助模型理解上下文逻辑,避免"信息孤岛"。例如,用户提问:"如何配置 Spring Boot 的多数据源?",如果只依赖向量匹配,可能召回一段关于"数据库连接池优化"的文本(语义相近但不精准)。如果该文本块的元数据中包含关键词 ["多数据源", "DataSource", "Spring Boot"] 和摘要 "本文介绍 Spring Boot 中配置多个数据源的方法......",系统就能更准确地判断其相关性,显著提升回答质量。

三、Maven依赖

xml 复制代码
	<!-- Markdown 文档读取器 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-markdown-document-reader</artifactId>
        <version>${spring.ai.version}</version>
    </dependency>

    <!-- Tika 文档读取器(支持 DOCX, PDF 等) -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-tika-document-reader</artifactId>
        <version>${spring.ai.version}</version>
    </dependency>

    <!-- 向量存储顾问 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-advisors-vector-store</artifactId>
        <version>${spring.ai.version}</version>
    </dependency>

    <!-- Milvus 向量存储支持 -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-vector-store-milvus</artifactId>
        <version>${spring.ai.version}</version>
    </dependency>

四、中文摘要生成器

Spring AI 内置的 SummaryMetadataEnricher 使用英文提示词,生成的摘要对中文用户不友好。为此,我们自定义 ChineseSummaryMetadataEnricher,使用中文提示词模板生成高质量摘要。

java 复制代码
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.document.Document;
import org.springframework.ai.document.DocumentTransformer;
import org.springframework.ai.document.MetadataMode;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class ChineseSummaryMetadataEnricher implements DocumentTransformer {

    private static final String SECTION_SUMMARY_METADATA_KEY = "section_summary";
    private static final String NEXT_SECTION_SUMMARY_METADATA_KEY = "next_section_summary";
    private static final String PREV_SECTION_SUMMARY_METADATA_KEY = "prev_section_summary";
    private static final String CONTEXT_STR_PLACEHOLDER = "context_str";

    // 中文提示词模板
    public static final String CHINESE_SUMMARY_TEMPLATE = """
        请对以下中文文本进行专业摘要,要求:
        
        1. 使用简洁明了的中文
        2. 总结主要内容,不超过100字
        3. 保持专业性和准确性
        
        文本内容:
        {context_str}
        
        中文摘要:
        """;

    private final ChatModel chatModel;
    private final List<SummaryType> summaryTypes;
    private final MetadataMode metadataMode;
    private final String summaryTemplate;

    public enum SummaryType {
        CURRENT, PREVIOUS, NEXT
    }

    public ChineseSummaryMetadataEnricher(ChatModel chatModel, List<SummaryType> summaryTypes) {
        this(chatModel, summaryTypes, CHINESE_SUMMARY_TEMPLATE, MetadataMode.ALL);
    }

    public ChineseSummaryMetadataEnricher(ChatModel chatModel, List<SummaryType> summaryTypes,
                                          String summaryTemplate, MetadataMode metadataMode) {
        Assert.notNull(chatModel, "ChatModel must not be null");
        Assert.hasText(summaryTemplate, "Summary template must not be empty");

        this.chatModel = chatModel;
        this.summaryTypes = CollectionUtils.isEmpty(summaryTypes) ? 
            List.of(SummaryType.CURRENT) : summaryTypes;
        this.metadataMode = metadataMode;
        this.summaryTemplate = summaryTemplate;
    }

    @Override
    public List<Document> apply(List<Document> documents) {
        List<String> documentSummaries = new ArrayList<>();
        
        for (Document document : documents) {
            var documentContext = document.getFormattedContent(this.metadataMode);
            Prompt prompt = new PromptTemplate(this.summaryTemplate)
                .create(Map.of(CONTEXT_STR_PLACEHOLDER, documentContext));
            
            String summary = chatModel.call(prompt).getResult().getOutput().getText();
            documentSummaries.add(summary.trim());
        }

        // 将摘要写入元数据
        for (int i = 0; i < documentSummaries.size(); i++) {
            Map<String, Object> summaryMetadata = getSummaryMetadata(i, documentSummaries);
            documents.get(i).getMetadata().putAll(summaryMetadata);
        }

        return documents;
    }

    private Map<String, Object> getSummaryMetadata(int i, List<String> summaries) {
        Map<String, Object> metadata = new HashMap<>();
        if (i > 0 && summaryTypes.contains(SummaryType.PREVIOUS)) {
            metadata.put(PREV_SECTION_SUMMARY_METADATA_KEY, summaries.get(i - 1));
        }
        if (i < summaries.size() - 1 && summaryTypes.contains(SummaryType.NEXT)) {
            metadata.put(NEXT_SECTION_SUMMARY_METADATA_KEY, summaries.get(i + 1));
        }
        if (summaryTypes.contains(SummaryType.CURRENT)) {
            metadata.put(SECTION_SUMMARY_METADATA_KEY, summaries.get(i));
        }
        return metadata;
    }
}

五、配置嵌入模型和向量数据库

yaml 复制代码
spring:
  ai:
    # 通义千问 DashScope 配置
    dashscope:
      api-key: ${DASHSCOPE_API_KEY}  # 建议通过环境变量注入
      embedding:
        options:
          model: text-embedding-v4     # 使用 v4 嵌入模型
          dimensions: 1024             # 向量维度
          text-type: document          # 文本类型为文档

    # Milvus 向量数据库配置
    vectorstore:
      milvus:
        client:
          host: 192.168.0.201
          port: 19530
          username: root
          password: milvus
        database-name: default
        initialize-schema: true        # 启动时自动创建集合
        collection-name: vector_store
        embedding-dimension: 1024      # 必须与 embedding model 一致

六、从文档到向量的完整ETL流程

嵌入模型对文本长度有限制,如OpenAI的嵌入模型,要求Token数量不能超过8192,对超限的文本进行向量化会抛出异常。这就要求在进行向量化之前,我们应该先将Document拆分成符合要求的多个Document。

使用Tika对文档进行抽取的话,所有内容都将抽取到同一个Document,这可能会超出嵌入模型的限制,需要对其进行拆分。

TokenTextSplitter接收5个参数:

  • chunkSize:令牌中每个文本块的目标大小。默认800
  • minChunkSizeChars:每个文本块的最小字符大小。默认350
  • minChunkLengthToEmbed:丢弃短于此的块。默认5
  • maxNumChunks:从文本生成的最大块数。默认10000
  • keepSeparator:是否保留分隔符。默认true
java 复制代码
import com.example.blog.etl.ChineseSummaryMetadataEnricher;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.document.Document;
import org.springframework.ai.model.transformer.KeywordMetadataEnricher;
import org.springframework.ai.reader.markdown.MarkdownDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/etl")
public class ETLController {

    @Autowired
    private ChatModel chatModel;

    @Autowired
    private VectorStore vectorStore;

    @GetMapping("/load")
    public String loadDocuments() {
        try {
            // Step 1: 读取文档并分块
            MarkdownDocumentReader reader = new MarkdownDocumentReader("classpath:agent.md");
            List<Document> documents = reader.get();

            // 使用 Token 分割器(适合长文本)
            TokenTextSplitter splitter = new TokenTextSplitter(2000, 1024, 10, 10000, true);
            List<Document> chunks = splitter.transform(documents);

            // Step 2: 元数据增强 ------ 关键词 + 中文摘要
            List<Document> enrichedDocs = new KeywordMetadataEnricher(chatModel, 5)
                .andThen(new ChineseSummaryMetadataEnricher(
                    chatModel,
                    List.of(ChineseSummaryMetadataEnricher.SummaryType.CURRENT,
                            ChineseSummaryMetadataEnricher.SummaryType.NEXT)
                ))
                .apply(chunks);

            // Step 3: 生成向量并存入 Milvus
            vectorStore.add(enrichedDocs);

            return "文档已成功加载并存储到 Milvus!共处理 " + enrichedDocs.size() + " 个文本块。";
        } catch (Exception e) {
            return "ETL 失败: " + e.getMessage();
        }
    }
}

这样基本构建了一个完整的文档 ETL 管道,通过摘要和关键词增强,提升 RAG 检索的准确性与可解释性;自定义中文提示词,解决 Spring AI 对中文支持不足的问题;集成 Milvus 向量数据库支持海量向量的毫秒级检索;可轻松替换为其他嵌入模型(如 BGE、M3E)或向量数据库。为构建 RAG 提供了坚实的数据基础,而高质量的数据,是高质量 AI 的前提。

相关推荐
啦啦啦在冲冲冲3 小时前
mse和交叉熵loss,为什么分类问题不用 mse
人工智能·分类·数据挖掘
SaaS_Product3 小时前
有安全好用且稳定的共享网盘吗?
人工智能·云计算·saas·onedrive
~~李木子~~3 小时前
图像分类项目:Fashion-MNIST 分类(SimpleCNN )
人工智能·分类·数据挖掘
轻赚时代3 小时前
新手做国风视频难?AI + 敦煌美学高效出片教程
人工智能·经验分享·笔记·创业创新·课程设计·学习方法
艾菜籽3 小时前
Spring Web MVC入门补充1
java·后端·spring·mvc
Xxtaoaooo3 小时前
原生多模态AI架构:统一训练与跨模态推理的系统实现与性能优化
人工智能·架构·分布式训练·多模态·模型优化
霖003 小时前
ZYNQ裸机开发指南笔记
人工智能·经验分享·笔记·matlab·fpga开发·信号处理
jianqiang.xue3 小时前
单片机图形化编程:课程目录介绍 总纲
c++·人工智能·python·单片机·物联网·青少年编程·arduino
heisd_14 小时前
在编译opencv出现的问题
人工智能·opencv·计算机视觉