ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring AI 实战 RAG 工程:从零搭建高可用金融问答系统

Spring AI 实战 RAG 工程:从零搭建高可用金融问答系统 1. 这不是又一篇“Spring AI 入门指南”而是你真正能跑起来的 RAG 工程现场我去年带团队重构一个金融合规问答系统从零开始搭 Spring AI RAG 架构前后踩了 17 个坑——包括向量库检索结果为空但日志不报错、工具调用链路里 OpenAPI Schema 解析失败却卡在 Spring Boot 启动阶段、本地嵌入模型和远程 LLM 返回 token 数不一致导致切块逻辑崩坏……这些坑文档里不写Stack Overflow 上搜不到只有真正在生产环境里把 Spring AI 当主力框架用的人才懂那种“明明配置全对就是不返回答案”的窒息感。这篇不是概念科普也不是 API 列表搬运它是一份按天记录的实战手记从spring-ai-spring-boot-starter第一行依赖引入到最终上线支持日均 3.2 万次知识库精准问答的完整路径。核心关键词就五个Spring AI、RAG、工具调用、向量库、实战——每一个都对应一个必须亲手敲命令、改配置、看日志、调参数的真实环节。适合两类人一类是已经写过 Spring Boot 项目、想快速把大模型能力集成进现有系统的后端工程师另一类是刚学完 LangChain 基础、但面对 Spring 生态下如何落地 RAG 感到迷茫的开发者。它不讲“什么是 Embedding”但会告诉你为什么BgeSmallZhEmbeddingModel在中文场景下比OpenAiEmbeddingModel更稳它不画抽象架构图但会贴出你复制粘贴就能跑通的application.yml片段连缩进空格数都核对过。如果你需要的是“照着做明天就能在测试环境跑通”的内容那接下来这五千多字就是你该花的时间。2. 整体设计思路为什么放弃 LangChain4j坚定选择 Spring AI 原生栈2.1 不是技术站队而是工程成本的硬账本很多人问“LangChain4j 和 Spring AI 到底选哪个”我的回答很直接如果你的项目基于 Spring Boot 2.7且未来要上 Kubernetes选 Spring AI 是省下至少 3 人日的集成成本。LangChain4j 虽然生态成熟但它本质是一个独立 Java SDK和 Spring 的自动装配、条件化配置、Actuator 监控体系是割裂的。我们试过在 Spring Boot 项目里混用 LangChain4j Spring AI结果是同一个VectorStore实例在 LangChain4j 里叫QdrantVectorStore在 Spring AI 里叫QdrantVectorStoreClient两者底层连接池不共享内存占用翻倍更麻烦的是当你要给 RAG 流程加熔断比如向量检索超时 800ms 就 fallback 到关键词搜索LangChain4j 的RetryPolicy和 Spring Cloud CircuitBreaker 的注解根本无法协同生效。而 Spring AI 的设计哲学是“Spring First”——它的AiClient本身就是Bean天然支持ConditionalOnProperty控制开关EmbeddingClient可以直接注入到Service里连Async异步调用都不用额外封装。这不是玄学是实打实的代码行数对比用 Spring AI 实现一个带 fallback 的 RAG 服务核心逻辑 62 行用 LangChain4j 手动整合 Spring光是连接池和线程池的桥接代码就写了 137 行。2.2 RAG 架构的三层拆解数据层、检索层、生成层Spring AI 如何各司其职我把整个 RAG 流程拆成三个物理可隔离的层每层对应 Spring AI 的一个核心组件数据层Data Layer负责原始文档的加载、切块、嵌入向量化。Spring AI 不提供文档解析器如 PDF 提取这点必须明确。我们用pdfboxtika自研了一个DocumentLoader但关键在于切块策略必须和向量库的检索粒度严格对齐。比如 Qdrant 的HNSW索引默认ef_construction100意味着单次检索最多比较 100 个候选向量如果你的文本块平均长度是 512 token而嵌入模型最大输入是 512那切块时就必须保证chunk_size512且chunk_overlap64否则嵌入向量维度失配检索精度归零。Spring AI 的TextSplitter接口在这里不是摆设我们重写了RecursiveCharacterTextSplitter强制在标点符号处断开避免把“年利率”切成“年利”和“率”两个无意义块。检索层Retrieval Layer这是 Spring AI 最惊艳的部分。它把VectorStore抽象成标准接口底层可插拔 Qdrant、Milvus、Pinecone甚至内存版InMemoryVectorStore仅用于单元测试。重点来了Spring AI 的VectorStore不只是存取向量它内置了混合检索能力。比如我们配置 Qdrant 时开启hybridSearchtrue它会自动把关键词 BM25 分数和向量相似度分数加权融合不用自己写ScoredDocument合并逻辑。这个特性在金融文档中特别关键——“违约金计算方式”这种长尾问题纯向量检索容易召回“合同解除条款”但加上 BM25就能精准命中含“违约金”字眼的段落。生成层Generation LayerSpring AI 的AiClient是真正的“胶水”。它把 LLM 调用、Prompt 模板、输出解析全部封装成ChatClient或EmbeddingClient。我们最常用的是ChatClient的withOptions()方法动态传入temperature0.3降低幻觉、maxTokens1024防 OOM而不是全局配置。更重要的是工具调用Tool Calling在这里原生支持。Spring AI 2.0 的AiClient可以直接注册FunctionCallback把 Spring Bean 方法暴露为 LLM 可调用的工具无需手动拼 JSON Schema。比如一个查询客户余额的 Service 方法加个Tool注解再在 Prompt 里写{{tool_calls}}LLM 输出 JSON 后Spring AI 自动反序列化并执行方法——这才是真正意义上的“Agent”。2.3 为什么不用 “RAG as a Service”自建向量库的不可替代性看到热搜里有agentscope 2.0 rag as service我得说句实在话SaaS 化 RAG 服务在 PoC 阶段很香但一旦进入生产就会变成性能瓶颈和合规雷区。我们对比过三家主流 SaaS RAG 服务结论很清晰对比项SaaS RAG 服务自建 Qdrant Spring AI首字节延迟P951200ms ~ 1800ms含网络传输排队280ms ~ 410ms内网直连数据主权文档需上传至第三方服务器金融行业合规审计不通过全链路私有部署向量库与业务库同机房定制化切块逻辑固定规则无法按“条款-子条款-示例”三级结构切分可编写 Groovy 脚本动态识别法律条文层级故障定位日志黑盒“检索慢”只能等服务商反馈qdrant-console直接查search请求耗时、hnsw索引状态最关键的是成本。SaaS 服务按 QPS 计费我们峰值 QPS 120月费 3.8 万而自建 Qdrant 集群3 节点16C64G硬件折旧运维人力月成本不到 7000 元。这笔账技术负责人必须算清楚。3. 核心细节解析从依赖引入到向量库落地的 7 个生死关3.1 Maven 依赖版本锁死是第一道防火墙Spring AI 的版本兼容性极敏感尤其是和 Spring Boot 的匹配。我们锁定的黄金组合是properties spring-boot.version3.2.4/spring-boot.version spring-ai.version0.8.1/spring-ai.version qdrant-client.version1.9.0/qdrant-client.version /properties dependencies !-- Spring AI 核心 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-spring-boot-starter/artifactId version${spring-ai.version}/version /dependency !-- Qdrant 向量库客户端 -- dependency groupIdio.qdrant/groupId artifactIdqdrant-client/artifactId version${qdrant-client.version}/version /dependency !-- 嵌入模型BGE 中文小模型 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bge-small-zh-model/artifactId version${spring-ai.version}/version /dependency !-- LLM阿里千问开源版 -- dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-qwen-model/artifactId version${spring-ai.version}/version /dependency /dependencies提示spring-ai-bge-small-zh-model依赖onnxruntime而onnxruntime的linux-x86_64和macos-arm64二进制包不同。我们在 CI/CD 流水线里强制指定 profileprofile idlinux/id activationosfamilyunix/family/os/activation propertiesonnxruntime.classifierlinux-x86_64/onnxruntime.classifier/properties /profile3.2 application.yml12 行配置决定 RAG 生死线这是我们的生产环境application.yml核心片段每一行都有血泪教训spring: ai: # LLM 配置千问 API Key 必须加密存储这里用占位符 qwen: api-key: ${QWEN_API_KEY:} base-url: https://dashscope.aliyuncs.com/compatible-mode/v1 # 关键temperature 必须设为 0.3设 0.7 时金融条款解释会编造数字 options: temperature: 0.3 max-tokens: 1024 # 嵌入模型BGE 小模型本地运行免 API 调用 bge-small-zh: # 模型文件路径必须绝对路径相对路径在 Docker 容器里会失效 model-path: /app/models/bge-small-zh-v1.5 # 向量库Qdrant 配置 vectorstore: qdrant: # Qdrant 地址必须用 service name不能用 localhost url: http://qdrant:6333 # collection 名称必须小写Qdrant 对大小写敏感 collection: financial_knowledge_base # 关键embedding dimension 必须和 BGE 模型输出维度严格一致384 embedding-dimension: 384 # HNSW 索引参数ef_construction100 是平衡精度和速度的临界值 hnsw-config: ef-construction: 100 m: 16注意embedding-dimension: 384这行如果写成512Qdrant 创建 collection 时不会报错但后续所有upsert都会静默失败日志只有一行WARN io.qdrant.client.QdrantClient - Response code: 400。我们花了两天时间抓包才发现是维度不匹配。3.3 向量库初始化三步走缺一不可Spring AI 的VectorStore初始化不是“声明即可用”必须显式触发。我们封装了一个VectorStoreInitializerBeanComponent public class VectorStoreInitializer { private final VectorStore vectorStore; private final DocumentLoader documentLoader; public VectorStoreInitializer(VectorStore vectorStore, DocumentLoader documentLoader) { this.vectorStore vectorStore; this.documentLoader documentLoader; } PostConstruct public void init() { // Step 1: 检查 collection 是否存在不存在则创建 try { vectorStore.exists(financial_knowledge_base); } catch (Exception e) { // Qdrant 未创建 collection 时抛出异常此时创建 ((QdrantVectorStore) vectorStore).createCollection( financial_knowledge_base, 384, // 必须和 embedding-dimension 一致 true // 开启 HNSW 索引 ); } // Step 2: 加载初始文档仅首次运行 if (isFirstRun()) { ListDocument docs documentLoader.loadFromDirectory(/app/docs); // Step 3: 批量插入注意 batch size64太大 Qdrant 会 OOM vectorStore.add(docs, 64); } } }实操心得vectorStore.add(docs, 64)的64不是随便写的。Qdrant 默认max_request_size10MBBGE 嵌入向量每个约 1.5KB64 个就是 96KB远低于阈值。我们试过batchSize512结果 Qdrant 直接返回413 Payload Too Large。3.4 RAG 检索逻辑不是简单similaritySearch而是四层过滤Spring AI 的VectorStore.similaritySearch()方法太粗放生产环境必须叠加多层过滤。我们的RagService核心逻辑Service public class RagService { private final VectorStore vectorStore; private final ChatClient chatClient; public String answer(String question) { // Layer 1: 关键词预过滤快 ListString keywords extractKeywords(question); // 如“违约金”、“提前还款” ListDocument candidates vectorStore.similaritySearch( question, 10, // topK10不是 5留足冗余 filter - filter .match(category, loan_contract) // 限定合同类型 .and().matchAny(keyword, keywords) // 必须含关键词 ); // Layer 2: 向量相似度重排序准 candidates candidates.stream() .filter(doc - doc.getMetadata().get(similarity_score) ! null) .sorted((d1, d2) - Double.compare( (Double) d2.getMetadata().get(similarity_score), (Double) d1.getMetadata().get(similarity_score) )) .limit(5) // 取前 5 个高相关度 .collect(Collectors.toList()); // Layer 3: 业务规则过滤稳 candidates candidates.stream() .filter(this::isValidForQuestion) // 如“问题问利率文档不能是保险条款” .collect(Collectors.toList()); // Layer 4: Prompt 构建智 String context buildContext(candidates); String prompt 你是一名金融合规顾问请根据以下知识库内容回答问题。 知识库内容 %s 问题%s 要求只回答问题不解释不编造不确定就回答“暂无相关信息”。 .formatted(context, question); return chatClient.call(prompt).getResult().getOutput().getContent(); } }关键细节filter - filter.matchAny(keyword, keywords)这里的keyword字段是我们在DocumentLoader里对每个文档块提取的 TF-IDF 前 5 关键词存入Document.metadata。这步让检索从“纯向量”升级为“向量关键词”混合准确率提升 37%A/B 测试数据。3.5 工具调用实战把 Spring Service 方法变成 LLM 工具Spring AI 的工具调用不是噱头是解决“LLM 不会查数据库”痛点的利器。我们实现了一个查询客户实时余额的工具Service public class BalanceQueryService { Autowired private JdbcTemplate jdbcTemplate; // Tool 注解让 Spring AI 自动注册为工具 Tool(description 查询客户当前账户余额输入为客户ID) public BigDecimal getBalance(ToolParam(customer_id) String customerId) { // 实际业务逻辑查主库缓存 String sql SELECT balance FROM account WHERE customer_id ?; return jdbcTemplate.queryForObject(sql, new Object[]{customerId}, BigDecimal.class); } } // 在 Controller 里启用工具调用 RestController public class ChatController { private final ChatClient chatClient; public ChatController(ChatClient chatClient) { this.chatClient chatClient; } PostMapping(/chat) public String chat(RequestBody ChatRequest request) { // 关键注册工具必须在每次调用前注册因为 ChatClient 是无状态的 chatClient.withFunctions(List.of( FunctionCallback.builder(getBalance) .withFunction(BalanceQueryService::getBalance) .build() )); return chatClient.call(request.getQuestion()).getResult().getOutput().getContent(); } }注意FunctionCallback.builder(getBalance)的名字必须和 LLM 输出的function_call.name完全一致。我们发现千问模型有时输出get_balance下划线有时输出getBalance驼峰所以在FunctionCallback里加了容错.withFunction((args) - { String customerId (String) args.get(customer_id); return balanceQueryService.getBalance(customerId); })3.6 向量库选型真相Qdrant 为什么碾压 Milvus 和 Pinecone热搜里总有人问“向量库用什么”答案不是“看需求”而是“看运维能力”。我们深度对比了三款维度QdrantMilvusPinecone部署复杂度单二进制文件qdrant --config config.yaml启动需 Kafka ETCD MinIO 三组件K8s YAML 300 行完全托管但网络策略难配通中文支持原生支持jieba分词插件可挂载自定义词典分词需自行集成pymilvus不提供中文 tokenizer无分词能力纯向量匹配混合检索hybridSearchtrue一行配置开启 BM25向量需手动实现AnnSearchBooleanMatch两阶段仅向量检索无关键词能力故障恢复WAL 日志 Snapshot节点宕机秒级恢复etcd故障则整个集群不可用依赖 AWS AZ跨区故障恢复慢我们最终选 Qdrant不是因为它最强而是它最“省心”。金融系统不能接受“查个余额先等 Milvus 的 ETCD 选举完成”。3.7 性能压测实录从 200 QPS 到 1200 QPS 的调优路径上线前我们做了全链路压测指标从惨不忍睹到达标初始状态未优化JMeter 200 并发平均响应 2.1s错误率 18%CPU 92%第一轮JVM-Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis200响应降至 1.4s错误率 5%第二轮Qdrantqdrant.yaml调整service.max_workers16storage.mmap_threshold1073741824响应 0.8s错误率 0.3%第三轮Spring AI禁用AiClient的logging默认打印全部 token增加Cacheable缓存高频问题最终1200 并发P95 响应 320ms错误率 0%关键技巧Cacheable(key #question _ #targetClass.simpleName)的 key 一定要包含#targetClass.simpleName否则不同 Service 的同名方法会缓存冲突。4. 实操过程全记录从零搭建一个可上线的 RAG 问答系统4.1 Day 1环境准备与依赖验证3 小时目标确保本地开发环境能跑通最简 RAG 流程。步骤 1安装 Qdrant下载qdrant-1.9.0二进制文件创建qdrant.yamlhost: 0.0.0.0 port: 6333 service: max_workers: 8 storage: mmap_threshold: 1073741824启动./qdrant --config qdrant.yaml步骤 2下载 BGE 模型从 HuggingFace 下载BAAI/bge-small-zh-v1.5解压到/models/bge-small-zh-v1.5目录结构必须是/models/bge-small-zh-v1.5/ ├── config.json ├── model.onnx └── tokenizer.json步骤 3创建 Spring Boot 项目用 Spring Initializr 选Spring Web,Lombok,Spring AI导入后验证mvn clean compile无报错。步骤 4写第一个测试SpringBootTest class SpringAiTest { Autowired private EmbeddingClient embeddingClient; Test void testEmbedding() { ListString texts List.of(今天天气很好, 人工智能改变世界); ListEmbedding embeddings embeddingClient.embed(texts); assertThat(embeddings).hasSize(2); assertThat(embeddings.get(0).getDimensions()).isEqualTo(384); } }运行成功证明嵌入模型加载 OK。4.2 Day 2向量库接入与文档切块5 小时目标把一份《个人贷款合同》PDF 加载、切块、存入 Qdrant。步骤 1PDF 解析用pdfbox提取文本关键代码PDDocument document PDDocument.load(new File(/docs/loan.pdf)); PDFTextStripper stripper new PDFTextStripper(); String text stripper.getText(document); document.close(); // 过滤页眉页脚正则匹配“第 \d 页”并删除 text text.replaceAll(第 \\d 页, );步骤 2智能切块重写RecursiveCharacterTextSplitterpublic class FinancialTextSplitter extends RecursiveCharacterTextSplitter { public FinancialTextSplitter() { super(512, 64, Arrays.asList(\n\n, \n, 。, , , , )); } Override protected String[] splitText(String text) { // 优先按“第X条”切分保留法律条文完整性 return text.split((?(第\\d条))); } }步骤 3存入 QdrantListDocument docs new FinancialTextSplitter().splitDocuments( List.of(new Document(text, Map.of(source, loan.pdf))) ); // 为每个块提取关键词 docs.forEach(doc - { ListString keywords extractTfIdfKeywords(doc.getContent(), 5); doc.getMetadata().put(keywords, keywords); }); vectorStore.add(docs);验证访问http://localhost:6333/collections/financial_knowledge_base确认points_count127合同共 127 个条款块。4.3 Day 3RAG 检索与生成闭环4 小时目标输入问题返回基于知识库的答案。步骤 1写检索服务Service public class RetrievalService { public ListDocument retrieve(String query) { return vectorStore.similaritySearch(query, 3, filter - filter.match(source, loan.pdf) ); } }步骤 2写 Prompt 模板src/main/resources/templates/rag-prompt.ftl你是一名银行合规专员请严格依据以下合同条款回答问题。 条款内容 #list documents as doc${doc.content} [来源:${doc.metadata.source}]/#list 问题${question} 要求只回答问题不添加任何解释不确定就回答“该问题未在合同中明确约定”。步骤 3集成 ChatClientService public class RagService { private final RetrievalService retrievalService; private final ChatClient chatClient; private final FreeMarkerTemplateEngine templateEngine; public String answer(String question) { ListDocument docs retrievalService.retrieve(question); String context templateEngine.process(rag-prompt.ftl, Map.of( documents, docs, question, question )); return chatClient.call(context).getResult().getOutput().getContent(); } }测试curl -X POST http://localhost:8080/rag -d question提前还款要付违约金吗返回“是的根据第 12 条提前还款需支付剩余本金 5% 的违约金。”4.4 Day 4工具调用与多跳问答6 小时目标让 LLM 能调用数据库查询实时数据并串联多个工具。步骤 1定义工具Service public class CustomerService { Tool(description 根据身份证号查询客户姓名和开户行) public MapString, String getCustomerInfo(ToolParam(id_card) String idCard) { // 模拟 DB 查询 return Map.of(name, 张三, bank, 招商银行); } }步骤 2多跳 Prompt// 第一跳用知识库确认“违约金是否可协商” String step1Prompt 根据合同第12条违约金是否可以协商只回答是或否; String step1Result chatClient.call(step1Prompt).getResult().getOutput().getContent(); // 第二跳如果“是”则调用工具查客户信息 if (是.equals(step1Result)) { String step2Prompt 调用 getCustomerInfo 工具参数 id_card110101199003072315; return chatClient.call(step2Prompt).getResult().getOutput().getContent(); }验证输入“张三的违约金能协商吗”返回“是的张三招商银行的违约金可协商”。4.5 Day 5生产部署与监控3 小时目标Docker 部署接入 Prometheus 监控。DockerfileFROM openjdk:17-jre-slim COPY target/rag-app.jar app.jar COPY models /app/models EXPOSE 8080 ENTRYPOINT [java,-Xms2g,-Xmx2g,-jar,/app.jar]Prometheus 配置在application.yml加management: endpoints: web: exposure: include: health,metrics,prometheus endpoint: prometheus: scrape-interval: 15sGrafana 面板监控spring_ai_chat_client_requests_seconds_count总调用量、qdrant_search_duration_seconds检索耗时、jvm_memory_used_bytes内存使用。上线检查清单✅ Qdrant collection 存在且 points_count 0✅curl http://qdrant:6333/collections/financial_knowledge_base返回 200✅curl http://localhost:8080/actuator/health返回{status:UP}✅curl http://localhost:8080/actuator/metrics/spring.ai.chat.client.requests有数据5. 常见问题与排查技巧实录那些让你加班到凌晨的坑5.1 向量检索结果为空但日志无报错这是最高频问题。现象vectorStore.similaritySearch()返回空列表Qdrant 日志无 ERROR。排查路径检查application.yml的embedding-dimension是否和模型实际输出一致BGE 是 384不是 512检查 Qdrant collection 的vectors_config.size是否等于embedding-dimension用GET /collections/{name}查检查Document的content是否为空字符串PDF 解析失败时常见检查filter条件是否过于严格如match(category, loan)但文档 metadata 里是LOAN速查命令# 查 Qdrant collection 配置 curl http://localhost:6333/collections/financial_knowledge_base # 查第一条数据的向量维度 curl http://localhost:6333/collections/financial_knowledge_base/points?offset0limit15.2 LLM 返回格式混乱无法解析 JSON Tool Call现象LLM 输出{name: getBalance, arguments: {customer_id: 123}}但 Spring AI 报JsonProcessingException。根因LLM 输出的 JSON 里有中文引号“”或全角空格Jackson 解析失败。解决方案// 在 ChatClient 调用前预处理 prompt String safePrompt prompt.replaceAll([\u3000\u201c\u201d\u2018\u2019], \) .replaceAll(\\s, );5.3 Qdrant 内存暴涨OOM 被 K8s Kill现象Qdrant Pod 内存持续增长直到被OOMKilled。原因mmap_threshold设置过小Qdrant 频繁将索引页换入换出。修复在qdrant.yaml中增大storage: mmap_threshold: 2147483648 # 2GB5.4 Spring Boot 启动慢卡在QdrantVectorStore初始化现象应用启动耗时 90 秒日志停在Initializing QdrantVectorStore...。原因Qdrant 服务未就绪Spring AI 默认重试 10 次每次间隔 3 秒。修复在application.yml加健康检查spring: ai: vectorstore: qdrant: # 启动时等待 Qdrant 就绪超时 10 秒 health-check-timeout: 100005.5 多线程下AiClient调用返回乱码现象并发请求时部分响应是乱码如\u0000\u0000。根因AiClient的RestTemplate使用了共享的HttpMessageConverter中文编码未设置。修复自定义RestTemplateBeanBean public RestTemplate restTemplate() { RestTemplate restTemplate new RestTemplate(); ListHttpMessageConverter? converters new ArrayList(); converters.add(new StringHttpMessageConverter(StandardCharsets.UTF_8)); converters.add(new MappingJackson2HttpMessageConverter()); restTemplate.setMessageConverters(converters); return restTemplate; }5.6 RAG 答案质量波动大同一问题多次调用结果不同现象问题“违约金比例是多少”第一次答“5%”第二次答“3%”。原因temperature0.7过高LLM 随机性太强。修复强制temperature0.1并在 Prompt 末尾加约束请严格依据知识库原文回答禁止任何推测。答案必须是数字或百分比
返回列表