资讯动态

Spring AI ETL管道实战:构建高质量RAG数据处理链路

发布时间:2026/9/11 15:47:50 来源:尧图企业网站定制
做 RAG 做了快两年我最大的感触是模型选得再好prompt 写得再花最后决定效果天花板的往往是数据进库之前那一段没人愿意细看、但又绕不开的环节。文档解析格式乱了、切分切断了语义、写入向量库时字段对不上随便一个问题都能让检索结果崩得莫名其妙。Spring AI 1.0 到 2.0 这一路演进里我最关注的其实不是模型接口又多封装了几个而是它把 ETL 管道这件事真正提到了框架层面从 Reader 到 Transformer 再到 Writer一条链路把数据清洗、切分、向量化、入库的活全部标准化了。这篇笔记就是围绕 Spring AI 里的 ETL 管道来写的适合正在用 Spring AI 做 RAG、还没系统梳理过数据处理链路、或者被文档解析和向量化折磨过的开发者。1. 为什么 RAG 的成败往往卡在 ETL 管道上很多刚接触 RAG 的开发者会默认把精力放在如何让模型回答得更好上比如调 system prompt、调 topK、换 embedding 模型。但真正落到项目里跑起来以后就会发现检索质量的上限在数据进库的那一刻就已经定死了。数据如果是乱的后面所有环节都是在给乱数据做补救效果自然不可控。1.1 关键词和语义检索都无法容忍脏数据不管是基于关键词的稀疏检索还是基于 embedding 的向量检索它们对输入的假设都有一条隐含前提进库的文本片段是干净、独立、语义完整的。一旦文档解析出来是乱码、表格被拆得七零八落、PDF 的页眉页脚混进正文、Markdown 的代码块被拦腰截断那么检索阶段不管是算 TF-IDF 还是算余弦相似度都会把噪声当成特征学进去。最后表现出来就是召回的内容看起来跟问题有点关系但细读又完全答非所问。这个问题跟模型能力没有关系纯粹是管道上游没做好。1.2 ETL 在 AI 应用里不是大数据领域的那个 ETL熟悉数据工程的朋友一听 ETL 会先想到 Informatica、DataStage 那套东西。但 Spring AI 里的 ETL 管道完全是另一条路线它面向的不是结构化数据仓库而是 AI 应用的上下文工程。用大白话讲它的职责是把各种乱七八糟的源文件PDF、Word、Markdown、JSON、网页读进来然后拆成模型能够有效理解和使用的小块文本最后以向量或文档的形式写入存储系统。所以核心动作是三件事读取Extract、转换Transform、加载Load只不过这里的转换不再是大数据领域的清洗聚合而是切分、格式化、向量化这一类 NLP 侧的操作。Spring AI 把这个过程抽象得非常干净三个接口就撑起整条链路DocumentReader负责读取源文件返回Document列表DocumentTransformer负责对文档做任意形式的转换返回的还是Document列表DocumentWriter负责把转换后的文档持久化出去。三者串起来就是一个可插拔、可扩展的完整管道。相比自己用 Python 脚本写一套 Pandas 清洗逻辑这套抽象的好处在于每个环节都可以单独替换也能在中间插入自定义逻辑做观测或者过滤。2. Spring AI ETL 的三件套Reader、Transformer、Writer 到底怎么设计理解了管道的作用下一步得把这三个抽象接口的使用边界和内在逻辑搞清楚。这是整个 Spring AI ETL 框架的骨架也是很多人读源码时容易绕晕的地方。2.1 DocumentReader 的设计思路与核心实现DocumentReader是管道的入口职责非常简单把原始来源变成 Spring AI 统一封装的Document对象。一个Document里装着文本内容text()、元数据metadata()和可选的id()这套模型贯穿整个框架无论是中间的 Transformer 还是最后的 Writer 操作的都是这个对象。Spring AI 已经内置了一大批开箱即用的 Reader 实现我按使用频率列一下Reader 实现类支持的源典型场景PagePdfDocumentReaderPDF 文件按页读取 PDF自动处理页边界ParagraphPdfDocumentReaderPDF 文件按段落读取 PDF对排版有一定要求TikaDocumentReader超多格式基于 Apache Tika支持 PDF、DOC、PPT、XLS 等JsonReaderJSON 文件按 JSON 指针提取指定字段TextFileReader纯文本读取 txt、Markdown 等文本文件UrlResourceReaderURL抓取网页内容并提取正文你自己实现一个 Reader 也只需要重写get()方法从任何数据源拉数据封装成Document列表返回就行。比如接数据库、接企业微信聊天记录、接日志文件都是几十行代码的事。设计上最有价值的一点是Reader 只做读取和封装不做任何清洗工作这样可以保证管道的每一层边界清晰。2.2 DocumentTransformer 是差异化的核心Transformer 是管道里最灵活的一环也是拉开不同项目数据质量差距的地方。它的接口也很简单输入ListDocument输出ListDocument。但简单接口背后能做的事情非常多文本清洗去页眉页脚、去特殊字符、切分按 token、按字符、按递归结构、格式化转成 Markdown、转成 HTML、抽取抽标题、抽摘要、过滤去重、去无关段落等等。最常用的内置 Transformer 是TokenTextSplitter它按 token 数切分文本一般和 embedding 模型的上下文窗口配合使用。切分参数会直接决定检索的粒度切太大单条文档内容太杂向量不聚焦切太小语义被切断检索时上下文不完整。项目里 70% 的检索效果问题根源都在这一步的切分参数设置。有几点切分的实践经验非常关键如果后续要按语义检索切分后每条文本最好保持在一个完整的语义单元里比如一个方法体、一个章节小节按 token 数切和按字符数切是有区别的token 切更贴近模型的理解粒度但中文场景下字符切可能更直观最好在元数据里保留来源页码、文件路径、章节标题检索后能溯源2.3 Writer 决定数据最终流向DocumentWriter把处理好的Document列表持久化到目标存储。Spring AI 对这块的设计是面向接口编程VectorStore接口本身就继承自DocumentWriter所以你可以直接往向量数据库里写也可以自定义 Writer 写到 Elasticsearch、Redis、文件系统等任意位置。内置的向量库支持非常全常见的有SimpleVectorStore本地内存/文件存储适合原型、RedisVectorStore、PgVectorStore、MilvusVectorStore、QdrantVectorStore、ElasticsearchVectorStore等。生产环境选哪个取决于公司的基础设施而不是哪个最流行。3. 从零搭一个文档 ETL 管道手把手实操理论讲完来看一条真正能跑的通路。我在项目里最常用的一条管道是PDF 文档 - 按页解析 - 清洗切分 - 向量化 - 写入向量库。下面按步骤过一遍包括代码和每一步的理由。3.1 第一步引入依赖与初始化环境用 Spring Boot 3 Spring AI 2.x 的项目Maven 依赖大致是这样的dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-tika-document-reader/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-pdf-document-reader/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-vector-store/artifactId /dependency !-- 这里以 Redis 向量库为例 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-redis-store/artifactId /dependencySpring AI 的模块划分从 1.0 到 2.0 有调整建议以官方 BOM 为准。组件多的时候版本冲突很容易出现直接引 BOM 是最省事的方式。3.2 第二步使用 Tika 解析 PDF 并构建 Document 列表Tika 的强项是格式兼容性好PDF、Word、PPT 都能解尤其适合企业内部那种格式乱七八糟的文档库。用法超级简单var reader new TikaDocumentReader( new FileSystemResource(/data/docs/产品手册.pdf)); ListDocument documents reader.get();这里要注意一点get()返回的Document的text()里通常带着 Tika 提取出的原始文本页眉页脚、表格排版、多余的换行符都还在里面。这也是我强调Reader 只做读取不做清洗的原因——你必须对这部分脏数据有预期并把它放到 Transformer 环节处理。解析出来的文档还存在一个问题PDF 里经常有封面页、目录页、空白页这些内容混进向量库就是纯粹的噪声会拉低检索的精确度。后面我会讲怎么过滤。3.3 第三步设计清洗与切分的 Transformer 链Transformer 链是整条管道最值得花时间打磨的环节。以一个典型的 RAG 场景为例我会先把文档做清洗再切分。清洗的时候保留一个最关键的信息来源。每段切分后的文本都带上原始 PDF 的文件名和页码这样模型回答时可以溯源到具体位置。// 1. 清洗去掉奇怪的空白和页眉页脚噪声 FunctionDocument, Document cleaner doc - { String text doc.getText() .replaceAll([\\t\\n\\r], ) .replaceAll(\\s{2,}, ) .trim(); return new Document(text, doc.getMetadata()); }; // 2. 按 token 切分 TokenTextSplitter splitter TokenTextSplitter.builder() .withChunkSize(500) .withChunkOverlap(80) .build(); // 3. 串成一条链 ListDocument rawDocs reader.get(); ListDocument cleanedDocs rawDocs.stream().map(cleaner).toList(); ListDocument chunks splitter.apply(cleanedDocs);chunkSize和chunkOverlap是这里最关键的两个参数chunkSize越大单条文档的信息越多但向量检索时匹配的精确度可能下降chunkOverlap的作用是保留相邻切分块之间的上下文衔接避免语义被切断具体取值跟 embedding 模型的最大 token 数和业务文档的语义密度有关。OpenAI 的text-embedding-3-small最大支持 8191 token但实际切分到 500 token 往往效果更好因为检索粒度更细、召回更精准3.4 第四步向量化并写入向量库切分好的文档块通过 embedding 模型转成向量然后写入向量库。Spring AI 的接口屏蔽了底层细节代码上做的事情很直接Bean VectorStore vectorStore(RedisVectorStoreProperties properties, EmbeddingModel embeddingModel, RedisVectorStoreConfig config) { return new RedisVectorStore(config, embeddingModel); } // 管道执行 vectorStore.accept(chunks);这里调用的accept()方法就来自DocumentWriter接口也就是说VectorStore天然就是一个 Writer。Spring AI 会在内部自动调用 embedding 模型把文本转成向量然后存入向量库。你不需要手动处理向量的维度、归一化等细节框架已经处理好了。但有一个容易被忽略的坑embedding 模型的维度必须和向量库索引的维度一致。比如你的 embedding 模型是 1536 维但向量库索引建的是 768 维写入就会报错或者静默失败。换模型或者换向量库的时候要格外注意这一点。3.5 完整管道代码示例把上面几段串起来一个最小可用的 ETL 管道就完成了Service public class DocumentIngestionService { private final VectorStore vectorStore; private final TokenTextSplitter splitter TokenTextSplitter.builder() .withChunkSize(500) .withChunkOverlap(80) .build(); public DocumentIngestionService(VectorStore vectorStore) { this.vectorStore vectorStore; } public void ingest(Resource resource) { // 1. 读取 TikaDocumentReader reader new TikaDocumentReader(resource); ListDocument documents reader.get(); // 2. 清洗 切分 ListDocument chunks documents.stream() .map(this::clean) .flatMap(doc - splitter.apply(List.of(doc)).stream()) .filter(this::isValidChunk) .toList(); // 3. 写入 vectorStore.accept(chunks); } private Document clean(Document doc) { String text doc.getText() .replaceAll([\\t\\n\\r], ) .replaceAll(\\s{2,}, ) .trim(); return new Document(text, doc.getMetadata()); } private boolean isValidChunk(Document doc) { return doc.getText() ! null doc.getText().length() 20; } }isValidChunk这步很多人会省略但我强烈建议保留。过滤掉太短的文本块可以显著减少向量库里的噪声比如页码、单个标题、图注这类凑不成完整语义的碎片。4. 实测中踩过的坑ETL 管道的隐蔽陷阱库这段是真正花时间换来的经验。Spring AI 的 ETL 管道看着简单实际用起来有不少坑而且很多坑不是看文档能看出来的。4.1 PDF 解析的排版侧问题同样一份 PDF用PagePdfDocumentReader和TikaDocumentReader解析出来的结果可能差别很大。前者更依赖 PDF 内部的文本流顺序对多栏排版、复杂表格的支持较差后者有 Tika 的 OCR 和版面分析兜底但对某些加密 PDF 或者扫描件依然无能为力。我的实测经验是文本型 PDF比如从 Word 导出的用PagePdfDocumentReader效果最好按页切分合理扫描版 PDF 必须先走 OCR否则解析出来全是乱码双栏排版的 PDF 解析后文本顺序经常是乱的这个在管道层很难完全解决最好源头规避有一次客户提供了双栏排版的行业报告第一版解析后检索结果惨不忍睹查到最后发现是文本顺序乱了模型读到的内容是左栏下半段接右栏上半段。最后是换了 PDF 预处理服务才解决。4.2 切分参数不是固定不变的很多文章会告诉你 chunk size 设成多少最合适但这是不严谨的。切分参数应该跟文档类型、embedding 模型、检索策略一起考虑。我总结了一套调参方法论技术文档有章节结构的适合用大 chunk 加 overlap配合标题元数据做检索过滤FAQ 类一问一答式的适合小 chunk尽量让每个 chunk 完整包含一个问答对合同、法律文书这类长段落文档建议先按章节分再在章节内按 token 切embedding 模型是中文优化的还是多语言通用的也会影响切分 token 数的选择项目里最常见的错误是上线了一套参数全量跑完入库结果后续怎么调都发现有些文档的效果就是不行。原因就是没分类处理把所有文档塞进了同一条管道。4.3 向量库写入的幂等性问题很多 Spring AI 使用者容易忽略的一个问题是管道重复执行时数据会重复写入向量库。比如定时任务每 6 小时跑一次全量入库如果不做去重向量库里就会出现大量重复文档检索时同一段内容被召回多次稀释掉其他有效结果。解决方案有几种用稳定的文档 ID 做幂等控制Spring AI 的Document支持自定义 ID可以把源文件的 MD5 值作为 ID写入前先按元数据删除旧数据比如按source字段删除维护一张文件变更表只处理增量变更的文件我的建议是在管道入口处先计算文件 hash存入元数据然后查一下向量库里有没有同样 hash 的文档有就跳过。这比全量删了再写要温和得多也不会影响在线检索。4.4 超长文档的内存占用和分批处理如果一个 PDF 有几百页解析出来的Document文本量可能很大一次性加载到内存再切分会让管道服务的内存压力陡增。处理办法是分批读、分批转、分批写而不是全量读完再处理。这是我的一种实现方式public void ingestLargePdf(Resource resource) { PagePdfDocumentReader reader new PagePdfDocumentReader(resource); AtomicInteger pageNum new AtomicInteger(0); reader.get().stream() .map(doc - { doc.getMetadata().put(page, pageNum.incrementAndGet()); return doc; }) .flatMap(doc - splitter.apply(List.of(doc)).stream()) .forEach(chunk - vectorStore.accept(List.of(chunk))); }按页读取再逐块写入内存峰值会低很多。生产环境跑大数据量时这种方式明显更加稳。4.5 字符串清洗过度引发的语义损失清洗这步要懂得适可而止。正则表达式replaceAll(\\s{2,}, )这类操作表面上是把多余空白清理掉了但如果文档是代码块、Markdown 表格、Spring 配置类过度的空白清理会把缩进、格式信息全部破坏代码类的文档检索效果暴跌。所以在设计清洗逻辑时最好先判断文档类型再决定要不要做空白规范化。比如 Markdown 文档我的做法是保留代码块内部的原始空白只对正文部分做规范化。5. 从 ETL 管道的视角看 Spring AI 2.0 的新变化和生态位Spring AI 的版本演进非常快1.0 到 2.0 的跨越不只是版本号的变化整个框架的设计思路和生态接口都有调整。作为长期使用 Spring AI 的人我梳理一下 2.0 里跟 ETL 管道直接相关的变化和周边的两个生态位MCP 和 Multi-Agent。5.1 Observer 与管道可观测性的结合热词里出现了ObservationHandler。Spring AI 2.0 把可观测性纳入了一等公民通过 Micrometer Observation 体系管道里每一步都可以追踪读取耗时、切分耗时、向量化耗时、写入耗时、token 消耗、失败率等都能暴露成指标。实际做法是这样引入依赖后通过 YAML 配置暴露 Prometheus 端点然后注册自定义的ObservationHandler打印或者上报管道各环节的耗时。以前做 ETL 管道只能靠日志猜现在每一步的埋点都明确了。这对于定位数据入库慢这个问题非常有帮助——到底是 embedding 模型拖了后腿还是向量库写入有瓶颈一眼就能看出来。我在项目里就遇到过管道整体跑得很慢排查一圈发现不是代码问题而是 embedding 模型并发调用被限流了。加了观测指标后这种问题很快就定位了。5.2 ETL 与 MCP数据的入口和出口spring ai mcp这两年讨论度很高但很多人有一个认知误区MCP 是模型调用外部工具的标准跟 ETL 管道的关系不大。实际关系其实很紧密。MCP 的服务端本质上暴露的是数据和能力接口而 ETL 管道负责把数据转换成模型能用的格式两者是互补的。Spring AI Alibaba 的 MCP 支持也很有意思通过Tool注解就能把 Spring Bean 的方法暴露成 MCP 工具。这意味着你可以把 ETL 管道的某些步骤设计成 MCP 工具让 Agent 在运行时按需调用数据接口而不是把所有数据都提前灌进向量库。这样整个架构从全量灌入变成了按需拉取数据时效性和存储成本都能优化。5.3 Multi-Agent 场景下的 ETL 依赖spring ai multi agent热词也出现了。多 Agent 架构里每个子 Agent 通常需要不同的知识上下文。比如一个法律咨询系统有合同审查 Agent 和法规查询 Agent二者需要的数据集完全不同。这种情况下 ETL 管道就不只是建一条而要为每个子 Agent 建立独立的数据管道并在元数据里给文档打上领域标签查询时按 Agent 身份过滤数据源。我这里会强调一点多 Agent 数据隔离一定不要在检索层才做过滤而是要在 ETL 阶段就按源文件或数据域拆分向量库或数据集。运行时过滤的代价是检索范围大、精度低有时还会把不该用的数据带进来。6. 把 ETL 管道整合进项目一条更完整的落地路径讲了理论和实操这里给出一个我在真实 Spring Boot 项目里用过的组织方式包含管道的编排、调度、观测和异常处理。6.1 管线编排与异步执行ETL 管道往往是离线任务不适合放在 Web 请求链路里同步执行。我用 Spring 的Async或者ApplicationEventPublisher把管道触发和请求返回解耦。用户上传文件后立即返回处理中后台异步跑完整条管道处理完成后再通过消息通知结果。Component public class IngestionTrigger { private final DocumentIngestionService ingestionService; public IngestionTrigger(DocumentIngestionService ingestionService) { this.ingestionService ingestionService; } Async(ingestionExecutor) public void onFileUploaded(FileUploadedEvent event) { try { ingestionService.ingest(event.getResource()); } catch (Exception e) { // 记录失败并告警管道失败不能影响主业务 } } }异步执行的时候建议用单独的线程池不要让 ETL 任务占满 Web 服务的 Tomcat 线程。文件大解析慢的时候独占线程池能有效避免请求响应超时。6.2 失败重试与补偿机制ETL 管道最容易出问题的环节是读取和写入。读取失败比如文件损坏、格式不支持写入失败比如向量库连接超时、索引不存在。我的经验是读取失败直接跳过并记录日志人工介入处理写入失败要区分是否可重试瞬时故障加退避重试重试次数有限制超过阈值后把任务放到死信队列等人工处理可以用 Spring Retry 或者 Resillience4j 做重试整体思路就是不让一次脏数据把整个管道卡死。6.3 元数据治理管道的隐形价值很多人做 ETL 只关注文本内容和向量写入却忽略了Document的元数据。实际上元数据是后续做精细化检索的基础。我强烈建议在管道里统一维护以下字段字段示例值用途source产品手册.pdf检索后溯源page42定位原文位置category产品文档按类过滤versionv2.1版本隔离chunk_index7还原上下文顺序hashmd5值幂等去重有了这些字段检索时的过滤条件就能写得很精确。比如只搜某个版本的手册、只搜某个章节的内容这些都是纯文本检索很难做到的。6.4 管道的例行体检管道不是配好就不管了。我建议定期做一次管道体检抽样检查向量库里最近写入的文档人工看看切分质量、解析效果、检索命中情况。体检频率可以是一周一次重点看指标平均文档长度和 chunk 数量是否合理解析失败率是否在持续上升检索结果里是否有大量重复或无关内容向量库增长是否符合预期这些体检数据平时不多看等到线上效果恶化再查就晚了。7. 我最终沉淀下来的管道配置参考最后分享一套我在多个项目里打磨后的默认配置给想快速上手的同学一个参考。它不是银弹但作为起点足够稳。spring: ai: embedding: options: model: text-embedding-3-small vectorstore: redis: index: my-docs-index prefix: doc:管道处理逻辑的默认参数参数推荐值说明chunk size400-600 token技术文档 500 左右FAQ 可以小到 200chunk overlap10%-20%500 token 配 80 overlap最短 chunk20 字符低于此长度直接丢弃清洗强度保守为主只去页眉页脚和连续空白不做语义级改写写入批次64 条一批减少对向量库的请求次数关于选型我再啰嗦一句不是所有项目都需要上重型基础设施。原型验证阶段用SimpleVectorStore把向量存到本地文件完全够用等数据量到几十万条再考虑换 Milvus 或者 Qdrant 也来得及。过早引入分布式存储在资源有限的时候反而是负担。我在实际使用中还有一个习惯管道的每一步都打印出输入输出数量读取了多少文档清洗后剩多少切分后多少块写入成功多少条。四个数字对不上说明中间某个环节出了问题定位起来特别快。这套习惯救过我很多次强烈建议你也加上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价