SPRING AI 工程化实践 · D21
从文档导入到答案生成:串起第一个完整 RAG 项目
用两条可观察流水线、三个接口和五题回归,交付 RAG v0.1.0。
组件都能运行,不代表项目已经闭环
失败在哪一层,必须能直接看见
D16 到 D20 已分别解决 ETL、最小 RAG、Chunk、Embedding 和 PGVector,但把这些类塞进一个 Controller,并不会自动得到完整项目。上传卡住时,你仍然不知道失败在解析、向量化还是数据库;答案不对时,也看不到究竟检索了几条、分数是多少。
版本与证据:Spring Boot 3.5.8、Spring AI 1.1.2、JDK 17,交付版本为 RAG v0.1.0。默认测试使用确定性本地模型,不调用百炼。
PART 01
先拆成两条流水线
完整 RAG 有两个时间尺度。索引阶段由文档变化触发,负责上传、解析、切分、向量化和存储;查询阶段由用户问题触发,负责检索、组织上下文和生成答案。两者共享的是 VectorStore 契约,不应该共享一次 HTTP 请求的生命周期。

— 索引面、查询面与状态面共享稳定边界
示例把边界落成四个服务:DocumentImportService 编排索引,ImportJobStore 保存阶段状态,RetrievalService 只做相似检索,RagQueryService 把证据交给 Advisor 后生成。Embedding 超时不会伪装成模型回答失败,ChatModel 限流也不会回滚已经写好的索引。
即使 v0.1.0 为了演示采用同步导入,也先创建 jobId,依次记录 UPLOAD、PARSE、SPLIT、EMBED_AND_STORE。以后改成队列消费时,接口和状态模型不用推倒重来。
PART 02
索引阶段:状态比成功更重要
导入接口接收 Markdown 或文本文件,先校验文件名、类型和空内容,再按章节切成 Document。每个 Chunk 保存 source、section、序号和稳定 ID,最后由 VectorStore.add 完成向量化与写入。
索引编排的关键顺序
String text = pipeline.parse(file.getBytes());
jobs.stageCompleted(jobId, PARSE);
List<Document> chunks = pipeline.split(text, source);
jobs.stageCompleted(jobId, SPLIT);
vectorStore.add(chunks);
return jobs.complete(jobId, chunks.size());
调用方拿到的不是布尔值,而是 jobId、来源、Chunk 数、各阶段状态、错误摘要和起止时间。异常发生时,当前阶段被标成 FAILED;生产实现可以把状态迁到数据库,但不能把堆栈、文档正文或 API Key 写入错误字段。
稳定 ID 仍是硬约束。示例用来源、Chunk 序号和正文计算 SHA-256。真正上线时还要明确旧版本删除或双阶段切换策略,避免同一来源的新旧内容同时命中。
PART 03
先保留证据,再让 Advisor 生成
查询不能直接从 ChatClient.prompt 开始。RetrievalService 先构造 SearchRequest,得到带 score 与 Metadata 的 Document;服务生成 traceId,记录命中数量与相似度,再把同一批 Document 传入 RetrievalAugmentationAdvisor。

— 同一批检索证据进入 Advisor,只检索一次
把检索结果交给 Advisor
RetrievalResult r = retrievalService.retrieve(traceId, question);
Advisor advisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(query -> r.documents())
.build();
String answer = chatClient.prompt().user(question)
.advisors(advisor).call().content();
生成前已经拥有可返回、可记录的来源和分数;Advisor 仍负责把 Document 格式化成上下文,并执行无证据不回答的规则。不要为了日志再检索一次,两次结果可能因索引变化而不一致。
响应同时返回 answer、traceId、retrievalCount 和 matches。相似度是检索证据,不是答案正确率。空结果也应返回空 matches,让调用方区分没有证据和模型故障。
PART 04
三个接口,以及异常归属
导入
POST documents/import
状态
GET imports/jobId
问答
POST knowledge/ask
Controller 只翻译 HTTP 语义;解析器只报告文档问题;导入服务负责把失败写回任务状态;查询服务用 traceId 串联检索和生成日志。
异常边界
文件为空、格式不支持、问题为空返回 400;解析失败标记 FAILED/PARSE;Embedding 或写入失败标记 FAILED/EMBED_AND_STORE;检索存储和 ChatModel 故障映射为稳定的 5xx 或 503。
PART 05
五个问题才是验收证据
接口返回 200 不能证明 RAG 可用。示例先上传一份包含退款、生产变更、客服、新人培训和数据库备份的手册,确认阶段状态与 chunkCount=5,再通过 MockMvc 连续提出五个问题。

— 五题同时验证答案、来源、分数和 traceId
每题同时断言答案含预期事实、retrievalCount=1、来源是 handbook.md、分数位于 0~1。日志输出 traceId、count 和 similarities。确定性模型不追求文采,它负责让测试离线、可重复,并证明 Advisor 确实收到上下文。
本地验证命令
cd examples/d21-rag-v010
mvn test
LAST
v0.1.0 已闭环,边界仍要清楚
SimpleVectorStore 只适合测试和演示,部署时替换为 D20 的 PGVector。内存 ImportJobStore 要迁到持久化存储,导入执行要进入有重试和死信的任务系统,文件还需要大小限制、病毒扫描和租户权限校验。
值得保留的是边界:索引与查询分离、阶段状态不丢、检索证据先于生成、三接口稳定、五题回归可重复。以后增加队列、真实 DashScope 模型或更复杂的检索策略,都沿这些接口演进。
参考资料:Spring AI 1.1 RAG · Vector Databases · Advisor 1.1.2 API
夜雨聆风