0%

SpringAI — RAG ETL管道

概述

ETL(提取、转换和加载) 管道为 Spring AI 中的 RAG 用例提供了完整的数据处理解决方案,其负责提取、转换和存储 Document 实例。

Document 类包含文本、元数据以及可选的附加媒体类型,如图像、音频和视频等

1
2
3
4
┌─────────────────┐    ┌───────────────────┐    ┌─────────────────┐
│ DocumentReader │ -> │ DocumentTransformer │ -> │ DocumentWriter │
│ (数据提取) │ │ (数据转换) │ │ (数据存储) │
└─────────────────┘ └───────────────────┘ └─────────────────┘
  • 提取阶段:使用各种 DocumentReader 实现从 JSON、文本、HTML、Markdown、PDF 等多种格式读取数据。
  • 转换阶段:使用 TokenTextSplitter 进行文本分块,或使用 KeywordMetadataEnricherSummaryMetadataEnricher 等增强器丰富元数据。
  • 加载阶段:使用 DocumentWriter 实现(如 VectorStoreFileDocumentWriter)将处理后的数据存储到目标位置。

核心组件

ETL 管道有三个主要组件:

组件 接口 描述
DocumentReader Supplier<List<Document>> 从多种来源提供文档
DocumentTransformer Function<List<Document>, List<Document>> 作为处理工作流的一部分,转换一批文档
DocumentWriter Consumer<List<Document>> 管理 ETL 过程的最后阶段,为存储准备文档

构建简单的 ETL 管道

假设我们有以下三个 ETL 类型的实例:

  • PagePdfDocumentReaderDocumentReader 的实现
  • TokenTextSplitterDocumentTransformer 的实现
  • VectorStoreDocumentWriter 的实现

要将数据加载到向量数据库中以供检索增强生成模式使用,可以使用以下 Java 函数式语法代码:

1
2
3
4
new PagePdfDocumentReader(resource)
.andThen(new TokenTextSplitter())
.andThen(vectorStore)
.apply();

或者,你可以使用更具领域表达力的方式:

1
2
3
4
5
6
PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(resource);
TokenTextSplitter splitter = new TokenTextSplitter();

List<Document> documents = pdfReader.get();
documents = splitter.apply(documents);
vectorStore.accept(documents);

DocumentReader 实现

JsonReader

JsonReader 处理 JSON 文档,将其转换为 Document 对象列表。

构造函数选项:

1
2
3
JsonReader(Resource resource)
JsonReader(Resource resource, String... jsonKeysToUse)
JsonReader(Resource resource, JsonMetadataGenerator jsonMetadataGenerator, String... jsonKeysToUse)
参数 描述
resource 指向 JSON 文件的 Spring Resource 对象
jsonKeysToUse 要用作结果 Document 对象中文本内容的 JSON 键数组
jsonMetadataGenerator 可选的 JsonMetadataGenerator,用于为每个 Document 创建元数据

处理逻辑:

  1. 可以处理 JSON 数组和单个 JSON 对象。
  2. 对于每个 JSON 对象(无论是在数组中还是单个对象):
    • 根据指定的 jsonKeysToUse 提取内容。
    • 如果未指定键,则使用整个 JSON 对象作为内容。
    • 使用提供的 JsonMetadataGenerator 生成元数据(如果未提供则使用空的)。
    • 创建包含提取内容和元数据的 Document 对象。

JSON Pointer 支持:

JsonReader 现在支持使用 JSON Pointer 检索 JSON 文档的特定部分。此功能允许你轻松地从复杂的 JSON 结构中提取嵌套数据。

1
List<Document> getDocumentsByPointer(String pointer)
参数 描述
pointer 一个 JSON Pointer 字符串(如 RFC 6901 所定义),用于定位 JSON 结构中的目标元素

返回值:包含从指针定位的 JSON 元素解析出的文档的 List

  • 如果指针有效且指向现有元素:
    • 对于 JSON 对象:返回包含单个 Document 的列表。
    • 对于 JSON 数组:返回 Document 列表,数组中的每个元素对应一个。
  • 如果指针无效或指向不存在的元素,则抛出 IllegalArgumentException

示例: 如果 JsonReader 配置为使用 "description" 作为 jsonKeysToUse,它将为数组中的每辆自行车创建一个 Document 对象,其中内容为 “description” 字段的值。

特性说明:

  • JsonReader 使用 Jackson 进行 JSON 解析。
  • 通过对数组使用流式处理,可以高效处理大型 JSON 文件。
  • 如果 jsonKeysToUse 中指定了多个键,则内容将是这些键对应值的拼接。
  • 通过自定义 jsonKeysToUseJsonMetadataGenerator,读取器可以灵活适配各种 JSON 结构。

TextReader

TextReader 处理纯文本文档,将其转换为 Document 对象列表。

构造函数选项:

1
2
TextReader(String resourceUrl)
TextReader(Resource resource)
参数 描述
resourceUrl 表示要读取的资源 URL 的字符串
resource 指向文本文件的 Spring Resource 对象

可配置方法:

  • setCharset(Charset charset) — 设置读取文本文件时使用的字符集,默认为 UTF-8。
  • getCustomMetadata() — 返回一个可变 Map,用于添加文档的自定义元数据。

处理逻辑:

  1. 将文本文件的全部内容读入单个 Document 对象。
  2. 文件内容成为 Document 的内容。
  3. 元数据会自动添加到 Document 中:
    • charset:读取文件时使用的字符集(默认:”UTF-8”)
    • source:源文本文件的文件名
    • 通过 getCustomMetadata() 添加的任何自定义元数据也包含在 Document 中。

注意事项:

  • TextReader 将整个文件内容读入内存,因此可能不适合非常大的文件。
  • 如果需要将文本拆分为更小的块,可以在读取文档后使用文本拆分器(如 TokenTextSplitter):
1
2
3
TextReader textReader = new TextReader(resource);
List<Document> documents = textReader.get();
documents = new TokenTextSplitter().apply(documents);
  • 读取器使用 Spring 的 Resource 抽象,允许从各种来源读取(类路径、文件系统、URL 等)。
  • 可以使用 getCustomMetadata() 方法向读取器创建的所有文档添加自定义元数据。

JsoupDocumentReader

JsoupDocumentReader 使用 JSoup 库处理 HTML 文档,将其转换为 Document 对象列表。

添加依赖:

Maven:

1
2
3
4
5
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>

Gradle:

1
implementation 'org.jsoup:jsoup:1.17.2'

JsoupDocumentReaderConfig 配置项:

配置项 描述 默认值
charset 指定 HTML 文档的字符编码 "UTF-8"
selector JSoup CSS 选择器,指定要从中提取文本的元素 "body"
separator 用于连接来自多个选中元素的文本的字符串 "\n"
allElements 如果为 true,提取 <body> 中的所有文本,忽略 selector false
groupByElement 如果为 true,为 selector 匹配的每个元素创建单独的 Document false
includeLinkUrls 如果为 true,提取绝对链接 URL 并将其添加到元数据 false
metadataTags 要从中提取内容的标签名称列表 ["description", "keywords"]
additionalMetadata 允许向所有创建的 Document 对象添加自定义元数据

行为说明:

  • selector 决定使用哪些元素进行文本提取。
  • 如果 allElementstrue,则 <body> 中的所有文本被提取到单个 Document 中。
  • 如果 groupByElementtrue,则 selector 匹配的每个元素都会创建单独的 Document
  • 如果 allElementsgroupByElement 都不为 true,则使用 separator 连接所有匹配 selector 的元素中的文本。
  • 文档标题、指定标签的内容和(可选)链接 URL 会被添加到 Document 元数据中。
  • 用于解析相对链接的基础 URI 将从 URL 资源中提取。
  • 读取器保留选中元素的文本内容,但会移除其中的任何 HTML 标签。

MarkdownDocumentReader

MarkdownDocumentReader 处理 Markdown 文档,将其转换为 Document 对象列表。

添加依赖:

Maven:

1
2
3
4
5
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.21.0</version>
</dependency>

Gradle:

1
implementation 'org.commonmark:commonmark:0.21.0'

MarkdownDocumentReaderConfig 配置项:

配置项 描述 默认值
horizontalRuleCreateDocument 当设置为 true 时,Markdown 中的水平分隔线将创建新的 Document 对象 false
includeCodeBlock 当设置为 true 时,代码块将包含在周围文本的同一 Document 中;当 false 时,代码块创建单独的 Document 对象 true
includeBlockquote 当设置为 true 时,引用块将包含在周围文本的同一 Document 中;当 false 时,引用块创建单独的 Document 对象 true
additionalMetadata 允许向所有创建的 Document 对象添加自定义元数据

行为说明:

  • 标题成为 Document 对象中的元数据。
  • 段落成为 Document 对象的内容。
  • 代码块可以分离为各自的 Document 对象,也可以与周围文本包含在一起。
  • 引用块可以分离为各自的 Document 对象,也可以与周围文本包含在一起。
  • 水平分隔线可用于将内容拆分为单独的 Document 对象。
  • 读取器保留内容中的格式,如行内代码、列表和文本样式。

PagePdfDocumentReader

PagePdfDocumentReader 使用 Apache PdfBox 库解析 PDF 文档。

添加依赖:

Maven:

1
2
3
4
5
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.30</version>
</dependency>

Gradle:

1
implementation 'org.apache.pdfbox:pdfbox:2.0.30'

该读取器按页读取 PDF 内容,每页生成一个 Document 对象。


ParagraphPdfDocumentReader

ParagraphPdfDocumentReader 使用 PDF 目录(如 TOC)信息将输入 PDF 拆分为文本段落,每个段落输出一个单独的 Document

⚠️ 注意: 并非所有 PDF 文档都包含 PDF 目录。

添加依赖:

Maven:

1
2
3
4
5
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.30</version>
</dependency>

Gradle:

1
implementation 'org.apache.pdfbox:pdfbox:2.0.30'

TikaDocumentReader

TikaDocumentReader 使用 Apache Tika 从多种文档格式中提取文本,例如 PDF、DOC/DOCX、PPT/PPTX 和 HTML。有关支持的格式的完整列表,请参阅 Tika 文档

添加依赖:

Maven:

1
2
3
4
5
6
7
8
9
10
<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>

Gradle:

1
2
implementation 'org.apache.tika:tika-core:2.9.2'
implementation 'org.apache.tika:tika-parsers-standard-package:2.9.2'

DocumentTransformer 实现

TextSplitter(抽象基类)

TextSplitter 是一个抽象基类,用于帮助分割文档以适应 AI 模型的上下文窗口。


TokenTextSplitter

TokenTextSplitterTextSplitter 的一个实现,它基于 token 数量将文本拆分为块。它支持可配置的编码类型(如 CL100K_BASEP50K_BASEO200K_BASE),默认使用 CL100K_BASE

配置编码类型:

你可以使用 TokenTextSplitter.builder() 创建实例。所有构造函数已弃用,推荐使用构建器。

1
2
3
4
5
6
7
8
9
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withEncodingType(EncodingType.CL100K_BASE)
.withChunkSize(800)
.withMinChunkSizeChars(350)
.withMinChunkLengthToEmbed(5)
.withMaxNumChunks(10000)
.withKeepSeparator(true)
.withPunctuationMarks(Arrays.asList('.', '?', '!', '\n'))
.build();

构建器配置项:

配置项 描述 默认值
encodingType 用于 token 化的编码类型 CL100K_BASE
chunkSize 每个文本块的目标大小(以 token 计) 800
minChunkSizeChars 每个文本块的最小字符数 350
minChunkLengthToEmbed 要包含的块的最小长度 5
maxNumChunks 从文本生成的最大块数 10000
keepSeparator 是否在块中保留分隔符(如换行符) true
punctuationMarks 用作拆分句子边界的字符列表 ., ?, !, \n

处理逻辑:

  1. 使用 CL100K_BASE 编码将输入文本编码为 token。
  2. 根据 chunkSize 将编码后的文本拆分为块。
  3. 对于每个块:
    • 将块解码回文本。
    • 仅当总 token 数超过块大小时,才尝试在 minChunkSizeChars 之后找到合适的断点(使用配置的 punctuationMarks)。
    • 如果找到断点,则在该点截断块。
    • 修剪块,并根据 keepSeparator 设置选择性地移除换行符。
    • 如果结果块的长度大于 minChunkLengthToEmbed,则将其添加到输出中。
  4. 此过程持续进行,直到所有 token 都被处理或达到 maxNumChunks
  5. 任何剩余的文本如果长度大于 minChunkLengthToEmbed,将作为最终块添加。

特性说明:

  • TokenTextSplitter 使用 jtokkit 库中的 CL100K_BASE 编码,与较新的 OpenAI 模型兼容。
  • 拆分器尝试通过在可能的情况下在句子边界处断开来创建语义上有意义的块。
  • 原始文档的元数据会被保留并复制到从该文档派生的所有块中。
  • 如果 copyContentFormatter 设置为 true(默认行为),原始文档的内容格式化器也会复制到派生的块中。
  • 此拆分器特别适用于为具有 token 限制的大语言模型准备文本,确保每个块都在模型的处理能力范围内。

最佳实践:

建议 说明
自定义标点符号 默认的标点符号(., ?, !, \n)适用于英文文本。对于其他语言或专业内容,请使用构建器的 withPunctuationMarks() 方法自定义标点符号
性能考虑 虽然拆分器可以处理任意数量的标点符号,但建议将列表保持在合理的小范围内(20 个字符以下),以获得最佳性能,因为每个标记都会对每个块进行检查
可扩展性 getLastPunctuationIndex(String) 方法是 protected 的,允许子类为专业用例覆盖标点检测逻辑
小文本处理 从 2.0 版本开始,小文本(token 计数等于或低于块大小)不再在标点符号处拆分,防止对已经符合大小限制的内容进行不必要的碎片化

KeywordMetadataEnricher

KeywordMetadataEnricher 是一个 DocumentTransformer,它使用生成式 AI 模型从文档内容中提取关键词并将其添加为元数据。

构造函数选项:

1
2
KeywordMetadataEnricher(ChatModel chatModel, int keywordCount)
KeywordMetadataEnricher(ChatModel chatModel, PromptTemplate keywordsTemplate)
参数 描述
chatModel 用于生成关键词的 AI 模型
keywordCount 要提取的关键词数量
keywordsTemplate 用于关键词自定义的模板(可选)

处理逻辑:

  1. 对于每个输入文档,使用文档内容创建一个提示词(Prompt)。
  2. 将此提示词发送到提供的 ChatModel 以生成关键词。
  3. 生成的关键词以 "excerpt_keywords" 为键添加到文档的元数据中。
  4. 返回增强后的文档。

默认模板:

1
2
Extract %s keywords from the following text. Return only the keywords as a comma-separated list.
Text: %s

其中 %s 分别被替换为关键词数量和文档内容。

特性说明:

  • KeywordMetadataEnricher 需要一个正常工作的 ChatModel 来生成关键词。
  • 关键词数量必须为 1 或更大。
  • 增强器为每个处理的文档添加 "excerpt_keywords" 元数据字段。
  • 生成的关键词以逗号分隔的字符串形式返回。
  • 此增强器特别适用于提高文档的可搜索性,以及为文档生成标签或类别。
  • 在构建器模式中,如果设置了 keywordsTemplate 参数,则 keywordCount 参数将被忽略。

SummaryMetadataEnricher

SummaryMetadataEnricher 是一个 DocumentTransformer,它使用生成式 AI 模型为文档创建摘要并将其添加为元数据。它可以生成当前文档以及相邻文档(前一个和后一个)的摘要。

构造函数选项:

1
2
SummaryMetadataEnricher(ChatModel chatModel, List<SummaryType> summaryTypes)
SummaryMetadataEnricher(ChatModel chatModel, List<SummaryType> summaryTypes, String summaryTemplate, MetadataMode metadataMode)
参数 描述
chatModel 用于生成摘要的 AI 模型
summaryTypes SummaryType 枚举值列表,指示要生成哪些摘要(PREVIOUSCURRENTNEXT
summaryTemplate 用于摘要生成的自定义模板(可选)
metadataMode 指定在生成摘要时如何处理文档元数据(可选)

处理逻辑:

  1. 对于每个输入文档,使用文档内容和指定的摘要模板创建一个提示词。
  2. 将此提示词发送到提供的 ChatModel 以生成摘要。
  3. 根据指定的 summaryTypes,向每个文档添加以下元数据:
    • section_summary:当前文档的摘要。
    • prev_section_summary:前一个文档的摘要(如果可用且已请求)。
    • next_section_summary:后一个文档的摘要(如果可用且已请求)。
  4. 返回增强后的文档。

默认模板:

1
2
Summarize the following text in 1-2 sentences.
Text: %s

示例行为:

对于包含两个文档的列表:

  • 两个文档都会收到一个 section_summary
  • 第一个文档收到一个 next_section_summary,但没有 prev_section_summary
  • 第二个文档收到一个 prev_section_summary,但没有 next_section_summary
  • 第一个文档的 section_summary 与第二个文档的 prev_section_summary 相匹配。
  • 第一个文档的 next_section_summary 与第二个文档的 section_summary 相匹配。

特性说明:

  • SummaryMetadataEnricher 需要一个正常工作的 ChatModel 来生成摘要。
  • 增强器可以处理任意大小的文档列表,妥善处理第一个和最后一个文档的边界情况。
  • 此增强器特别适用于创建上下文感知的摘要,从而更好地理解序列中文档的关系。
  • MetadataMode 参数允许控制如何将现有元数据纳入摘要生成过程。

DocumentWriter 实现

FileDocumentWriter

FileDocumentWriter 是一个 DocumentWriter 实现,用于将 Document 对象列表的内容写入文件。

构造函数选项:

1
2
3
FileDocumentWriter(String fileName)
FileDocumentWriter(String fileName, boolean withDocumentMarkers)
FileDocumentWriter(String fileName, boolean withDocumentMarkers, MetadataMode metadataMode, boolean append)
参数 描述 默认值
fileName 要写入文档的文件名
withDocumentMarkers 是否在输出中包含文档标记 false
metadataMode 指定要写入文件的文档内容(元数据模式) MetadataMode.NONE
append 如果为 true,数据将写入文件末尾而不是开头 false

处理逻辑:

  1. 为指定的文件名打开一个 FileWriter。
  2. 对于输入列表中的每个文档:
    • 如果 withDocumentMarkerstrue,则写入包含文档索引和页码的文档标记。
    • 根据指定的 metadataMode 写入文档的格式化内容。
  3. 所有文档写入后关闭文件。

文档标记格式:

withDocumentMarkers 设置为 true 时,写入器会包含每个文档的标记,格式如下:

1
--- DOCUMENT 0 (page 1) ---

写入器使用两个特定的元数据键:

  • page_number:表示文档的起始页码。
  • end_page_number:表示文档的结束页码。

这些在写入文档标记时使用。

示例:

1
2
3
4
5
6
7
FileDocumentWriter writer = new FileDocumentWriter(
"output.txt",
true, // withDocumentMarkers
MetadataMode.ALL, // metadataMode
true // append
);
writer.accept(documents);

这会将所有文档写入 “output.txt”,包含文档标记,使用所有可用的元数据,并在文件已存在时追加写入。

特性说明:

  • 写入器使用 FileWriter,因此使用操作系统的默认字符编码写入文本文件。
  • 如果写入过程中发生错误,会抛出以原始异常为原因的 RuntimeException
  • metadataMode 参数允许控制如何将现有元数据纳入写入的内容。
  • 此写入器特别适用于调试或创建文档集合的可人工阅读的输出。