乐于分享
好东西不私藏

D21|从文档导入到答案生成:串起第一个完整 RAG 项目

D21|从文档导入到答案生成:串起第一个完整 RAG 项目

SPRING AI 工程化实践 · D21

从文档导入到答案生成:串起第一个完整 RAG 项目

用两条可观察流水线、三个接口和五题回归,交付 RAG v0.1.0。

01 双流水线02 索引状态03 检索 Advisor04 三个接口05 五题验收

组件都能运行,不代表项目已经闭环

失败在哪一层,必须能直接看见

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 完成向量化与写入。

索引编排的关键顺序

...java

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

java

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 确实收到上下文。

本地验证命令

bash

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