Spring AI实战:用RAG与Tool Calling构建岗位分析问答系统
岗位分析这个需求,是今年年初我在推进人力资源数字化平台时接到的。业务方给的需求其实很朴素:“你帮我写一份岗位分析报告,把职位职责和任职要求拆清楚,对标公司历史上相似岗位的 JD,再查一下目标城市的市场薪资区间和今年的供需热度。”听起来不复杂,但拆开看,它同时要处理三类输入:历史知识库里的非结构化文档、薪酬系统里的实时数据、以及最终要生成的结构化报告。我当时的第一个念头是拿 LangChain 快速验证,但环顾团队现状,从基础设施到 CI/CD 全是 Java,没人愿意为一个问答模块单独维护一套 Python 服务。于是我把目光锁定在 Spring AI 上,用 RAG 解决知识检索,用 Tool Calling 打通内部数据接口,最后做成了一套能直接交付给业务运营的岗位分析问答系统。这篇文章记录了我从选型到上线两个多月的完整过程,包括 ETL 管道、工具注册、Agent 链路组装,以及很多文档里查不到的踩坑经验,适合准备用 Spring AI 做知识库问答或 Agent 应用的后端工程师参考。
1. 业务拆解与选型判断:我为什么押注 Spring AI
1.1 先从三类问题倒推技术架构
在做选型之前,我先拿真实业务场景给系统划了边界。岗位分析要处理的用户提问,归纳下来就是三类:
| 问题类型 | 典型问法 | 需要的能力 |
|---|---|---|
| 知识问答 | “公司近三年招过哪些数据产品经理?职责有什么共性?” | 对历史 JD 知识库做检索增强生成(RAG) |
| 数据问答 | “北京 Java 开发工程师薪资 p75 是多少?” | 调用薪酬系统接口,也就是 Tool Calling |
| 综合问答 | “在成都新建后端团队,结合历史 JD 和当地薪资,给出岗位画像与定薪建议” | 既要检索知识库,也要调工具,还要生成报告 |
这个分类直接影响技术选型。当时我在三条 Java 生态路线里纠结:LangChain4j、LangGraph4j、Spring AI。LangChain4j 对 Python 版 LangChain 的 API 复刻程度高,社区讨论也很多;LangGraph4j 则把图状态机的 Agent 编排思路带到了 Java 端,适合流程分支复杂的场景。但我最后选择 Spring AI,核心原因有三点。
一是与 Spring Boot 的整合深度。我们所有服务都是 Spring Boot 微服务,Spring AI 的 starter 自动装配了 ChatClient、EmbeddingModel、VectorStore,接入过程基本是写配置,不用自己管理各种客户端生命周期。
二是工具调用的声明式写法贴合后端习惯。直接在一个普通 Spring Bean 的方法上加@Tool注解,框架会生成 JSON Schema 交给模型,这对长期写 Spring 的团队几乎没有学习成本。LangChain4j 也有类似能力,但 Spring AI 和 Spring 生态的配合更顺。
三是团队风险考量。对我们的内部系统场景来说,Spring AI 背后有官方团队做版本维护,长期演进相对可控;社区里“现在到底用 Spring AI 还是 LangGraph4j”这类讨论很多,但结论往往取决于你的团队背景。Java 团队,尤其已经在维护多个 Spring Boot 服务的团队,选 Spring AI 的迁移成本是最低的。
1.2 本地优先策略:数据不出内网这个硬约束
这系统处理的岗位 JD、薪酬数据都是敏感信息,方案上有条红线:原始数据不能上传公有云模型。这条红线直接决定了模型选型范围。网上大量“Ollama + 简易本地 RAG 知识库(零基础可复制教程)”用的就是这套思路,我的方案本质上是它的结构化升级版。最终定下来的模型栈是:
- 本地 LLM:qwen2.5:7b-instruct,跑在内网 GPU 服务器,通过 Ollama 暴露接口;
- Embedding 模型:bge-m3,中文语义表征比较稳,1024 维;
- 向量库:PostgreSQL 16 + pgvector,因为项目里已经有 PostgreSQL 实例,不想再引入独立向量数据库组件;
- 重排模型:bge-reranker-v2-m3,放在检索之后、LLM 之前做候选精排。
这里多说一句选型意识:Spring AI 的 starter 把模型提供方抽象成了统一 API,意味着以后合规允许用云上模型时,可以直接替换配置而不用改业务代码。我后来也看过 Spring AI Alibaba 对 DashScope 的集成方案,走的就是类似路径。选型阶段多保留这一层抽象,将来能省很多事。
2. 知识库搭建:从 3000 份 JD 到可检索的向量库
2.1 数据源盘点与解析链路设计
先把数据源盘清楚。历史 JD 主要来自两个渠道:HR 系统导出的 Word/PDF 文档,以及内部任职资格体系的网页,总共有 3000 多份岗位 JD,外加一批薪酬调研 PDF。有些旧文档是扫描件需要 OCR,文本质量差、结构化程度低,我决定首期不纳入知识库,只把文本型 PDF 和 Word 纳入。
解析链路用的是 Spring AI 的 DocumentReader 抽象,底层是 Apache Tika。核心代码其实很简洁:
@Component public class JdIndexingService { private final VectorStore vectorStore; private final DocumentTransformer splitter; public void indexJd(Path filePath) { DocumentReader reader = new TikaDocumentReader(Resource.fromFile(filePath.toFile())); List<Document> docs = reader.get(); List<Document> chunks = splitter.apply(docs); vectorStore.add(chunks); } }链路是:解析 → 清洗(去掉页眉页脚、表格噪点)→ 切分 → Embedding → 入库。清洗这个步骤容易被新手忽略,其实很重要。Tika 导出的纯文本经常混入重复表头、域里奇怪的回车符,不处理的话这些噪声会被一起向量化,稀释真正有效的语义。
顺带回应一个网上常问的问题:“RAG 知识库能存图片吗?”如果你要索引截图类的 JD,可行做法是先用视觉模型把图片抽取成结构化文本再入库,而不是直接把图片向量化扔进库里。我们第一期用不到,就不展开做了。
2.2 中文章节切分:被默认切分器坑了一晚上
这里是第一个大坑。Spring AI 默认的 TokenTextSplitter 按 token 数切分,它对英文估算比较准,对中文文本会出现 chunk 长度虚高、语义块被拦腰切断的情况。岗位 JD 有固定结构,通常包含“职位职责、任职要求、汇报关系、转岗条件”几个段落,默认切分很容易把“任职要求”的后半段和“职责描述”的前半段切进同一个 chunk,检索时相关性被严重稀释。
我最后放弃了纯 token 切分,改成了“结构感知切分”:
- 先用规则把 JD 按固定小节标题切成多个段;
- 段落超过阈值(比如 800 字符)时,再按句子边界二次切分;
- 每段额外保留
documentId、section、positionType等元数据,回答可以溯源到原文位置。
具体切分参数我做过一组实验,用的内部 50 条真实业务问题测试集:
| 切分策略 | top-5 命中率 | 回答完整性 |
|---|---|---|
| 默认 TokenTextSplitter,1200 token / 200 overlap | 61% | 经常缺关键任职要求 |
| 800 字符按句切分,overlap 100 | 74% | 大部分完整 |
| 结构感知切分 + 长段二次切分 | 88% | 完整 |
回答完整性是人工打分,看答案是否覆盖职责、要求、等级三个必答维度。实验结果很明显:结构化文档一定要先感知结构再切分,盲目套固定 token 窗口,是 RAG 项目最常见的瓶颈之一。
2.3 Embedding 入库与 PgVector 配置
入库流程本身不复杂,配置在 application.yml 里把 Ollama 地址和 embedding 模型指好就行:
spring: ai: ollama: base-url: http://10.20.30.40:11434 embedding: options: model: bge-m3然后初始化向量表,用的是 PgVectorStore:
@Bean public VectorStore vectorStore(EmbeddingModel embeddingModel, DataSource dataSource) { PgVectorStore.PgVectorIndexConfig indexConfig = PgVectorStore.PgVectorIndexConfig.builder() .withIndexType(PgVectorStore.PgVectorIndexType.HNSW) .withDistanceType(PgVectorStore.PgVectorDistanceType.COSINE_DISTANCE) .build(); return new PgVectorStore(dataSource, embeddingModel, 1024, indexConfig, true); }有两个细节值得说。第一,HNSW 比 IVFFlat 更省心。IVFFlat 需要先建索引再大批量写入,还要选好 lists 参数,否则召回率波动很大;HNSW 的图构建阶段就能达到很高的搜索质量。3000 份 JD 切成两万多个块,这个量级 HNSW 完全够用。第二,初始化 schema 参数要开,填true表示表不存在时自动建表,本地开发很省事;但生产环境我会先手动执行建表脚本,避免应用启动时拿高权限 DDL。
Embedding 的耗时也评估过:bge-m3 在 Ollama 上大概每秒能处理 40~60 个短文本块,两万个块大约 8~10 分钟全部入库。增量更新时按 documentId 先删后插即可,不用全量重建。
3. Tool Calling 设计:让模型真正“会查数”
3.1 从业务需求反推工具目录
Tool Calling 的核心不是堆工具,而是业务需要什么就做什么,别让模型猜。我最后只保留了三个工具:
| 工具名 | 能力 | 入参 | 返回 |
|---|---|---|---|
| querySalaryRange | 查岗位薪资分位 | position, city, years | p25/p50/p75 及样本量 |
| querySupplyDemand | 查供需热度 | position, city, period | 供需比、投递量、招聘量 |
| computeSkillMatch | 技能匹配度评估 | jdText, cvText | 技能覆盖率与缺口清单 |
所有工具返回统一成 JSON 字符串,方便模型读取,也方便调试日志里定位问题。
3.2 @Tool 声明式注册:写法和背后的机制
Spring AI 对工具调用的支持建立在@Tool注解上,写法对后端来说非常自然:
@Component public class SalaryTool { private final SalaryApiClient salaryApiClient; public SalaryTool(SalaryApiClient salaryApiClient) { this.salaryApiClient = salaryApiClient; } @Tool(description = "查询指定岗位、城市、经验年限的市场薪资分位,返回 p25/p50/p75 和样本量") public String querySalaryRange( @ToolParam(description = "岗位名称,例如:Java开发工程师") String position, @ToolParam(description = "城市,例如:北京") String city, @ToolParam(description = "工作年限,整数,最少年限") Integer years) { SalaryStat stat = salaryApiClient.fetch(position, city, years); return """ {"position":"%s","city":"%s","p25":%d,"p50":%d,"p75":%d,"samples":%d} """.formatted(position, city, stat.p25(), stat.p50(), stat.p75(), stat.samples()); } }三个注意点:
- 方法返回类型统一用 String。Spring AI 的工具执行器最终要把结果拼进后续模型的上下文,返回 JSON 字符串比返回对象更可控,也不会把内部字段意外暴露给模型。
- 工具描述要像写给同事看一样具体。模型选择工具完全靠 description 和参数描述,写“查询薪资”这种废话等于没写。我会把城市可选值、年限整数化规则、返回字段含义都写清楚。
- 注册时把工具 Bean 传给 ChatClient就行:
@Bean ChatClient chatClient(ChatClient.Builder builder, VectorStore vectorStore, SalaryTool salaryTool, MarketTool marketTool, SkillMatchTool skillMatchTool) { return builder .defaultSystem(loadSystemPrompt()) .defaultTools(salaryTool, marketTool, skillMatchTool) .defaultAdvisors( QuestionAnswerAdvisor.builder() .vectorStore(vectorStore) .topK(5) .build() ) .build(); }对话记忆我这里用了窗口型 ChatMemory,开 12 轮,够用也能控制 token 消耗。
3.3 工具调用的容错与超时治理
工具调用多轮之后最容易出现三个问题:模型不调工具、模型反复调工具、工具结果太长把上下文塞爆。我的对策:
- 强制触发。系统提示词里明确写“涉及薪资、供需、技能匹配时,必须调用对应工具后再回答;如果拿不到数据,直接告诉用户需要查询”,再配两三个 few-shot 示例。
- 轮次上限。整体 Agent 调用轮次限制在 3 轮,超了直接截断返回已有内容。我真实遇到过模型在“查薪资 → 查供需 → 再次查薪资”之间打转的情况,轮次上限能有效止损。
- 结果裁剪。每个工具结果最多 2000 字符。computeSkillMatch 返回的技能清单很长,统一在工具内部先汇总成“覆盖率 + 前 5 个缺口技能”,再交给模型。
这些机制 Spring AI 没有现成标配,我在工具外层包了一个ToolExecutionInterceptor,统一记录入参、出参、耗时。一旦检测到同一会话内工具被调用超过 3 次,就降级为“仅知识库回答”,避免死循环烧钱烧时间。
4. RAG 与 Tool Calling 的协作:Agent 链路是怎么组装出来的
4.1 意图路由:先判断用户要“查库”还是“查数”
如果把 RAG 和 Tool Calling 绑定在一起,每次问答都先检索再调工具,很多问题会被绕远路。问“公司历史上有没有大数据岗”根本不需要查薪资工具;问“北京 Java p75”也不需要先搜知识库。所以系统要有一个意图路由。
我的实现分两层。第一层是规则关键词:命中“薪资 / p50 / p75 / 分位 / 供需 / 投递量”等词,走数据意图;命中“历史 / 职责 / 任职要求 / 相似岗位 / 对标”等词,走知识意图。第二层是模型兜底:规则判定不出来,或者同时命中两类词时,把意图分类包装成一个工具交给模型决策,让模型自己选择是否调用工具、是否检索知识库。
这其实就是社区里说的 Agentic RAG 的一种落地形态:检索动作不是每次都执行,而是由 Agent 按需决策。纯 RAG 和纯 Tool Calling 都是单条腿走路,路线组合起来才能覆盖真实世界的混搭问题。
4.2 提示词编排与上下文压缩
最终跑的 System Prompt 简化后是这样:
你是公司的岗位分析专家。回答问题时遵循以下规则: 1. 回答岗位历史相关信息时,必须基于提供的知识库资料,并在引用后标注资料编号。 2. 涉及市场薪资、供需热度、技能匹配时,必须先调用对应工具,禁止编造数字。 3. 生成岗位报告时,用二级标题组织,并用表格呈现对比类数据。 4. 如果检索结果与问题不相关,明确说“未检索到相关信息”,不要强行作答。上下文压缩是最容易被忽略的环节。向量检索默认 topK=5,每个 chunk 最长 1200 token,再加上历史对话和工具结果,很容易把模型上下文撑爆。我做了两级压缩:
- 检索后重排。先用混合检索召回 top 20 候选,再用 bge-reranker-v2-m3 精排,只保留 top 5 送入提示词。
- chunk 精简。每个 chunk 只保留“职责描述、任职要求、等级标准”三个字段,丢到页眉页脚、目录和签名噪声。
4.3 实测效果与延迟数据
系统跑起来之后,我用真实业务问题做了一轮回归测试:
| 问题 | 路由 | 链路 | 关键输出 | 总耗时 |
|---|---|---|---|---|
| 公司历史上招过哪些高级测试工程师? | 知识 | RAG | 列出 5 条 JD 并归纳职责共性 | 4.5s |
| 北京前端工程师薪资 p75 是多少? | 数据 | Tool | 给出金额、样本量、数据时间 | 6.2s |
| 在成都建 Java 团队,给出岗位画像与定薪带区间 | 混合 | RAG + Tool + 生成报告 | 岗位画像 + 历史对标 + 定薪建议 | 13.8s |
混合类问题是最出彩的。模型先检索出公司历史 JD,再调用薪资工具拿到成都 p25/p50/p75,最后把两者交叉呈现在报告里,给出“该岗位在成都的开价相对北京低 15%~20%”这种业务直接可用的结论。从提问到最终 Markdown 报告,稳定在 15 秒内,业务侧可以接受。
5. 踩坑实录:文档里查不到的麻烦事
5.1 中文切分与“知识割裂”的连带反应
默认切分除了丢语义块,还造成了一种“知识割裂”现象:同一份 JD 的职责块和要求块被分到不同 chunk,模型回答时只能引用其中一块,经常丢信息。结构感知切分上线后,“JD 中某个岗位的职责和要求分别是什么”这类问题的完整率直接从 61% 升到 88%。
后来我还考虑过给岗位体系建一个简单的本体(Ontology),把岗位族、职级、技能项之间的关系显式建起来,类似 llm-wiki 的做法,进一步缓解单一知识库的知识割裂。这个方向我列进了二期计划,但首期用结构化切分已经解决了 80% 的问题。如果你也遇到“内容存在但回答不完整”的情况,先别急着换模型,检查一下你的 chunk 是不是把一个完整语义块切成了两半。
5.2 召回率上不去:从纯向量到混合检索的完整排查
这是我在“RAG hit rate”上耗费最久的一个坑。现象:用户问“高并发场景下的架构设计能力”,知识库里明明有包含“缓存、性能优化、分布式架构”的 JD,但 top-5 就是召不回来。
排查过程我完整捋一遍,你以后可以直接按这个顺序来:
- 先直接打印余弦相似度,发现最高只有 0.62,和预期的 0.75 差得远;
- 怀疑 bge-m3 对抽象表达捕捉不够,换 nomic-embed-text 试,结果更差;
- 调大 topK 到 20,能搜到了,但大量无关结果混进来;
- 尝试把领域关键词(“并发”“性能”“高可用”)做成同义词扩展,收益有限;
- 最终方案是 Postgres 全文检索 + 向量检索的 RRF 融合,top-5 命中率直接升到 92%。
融合代码的思路是这样:
public List<Document> hybridSearch(String query, int topN) { // 1. 向量检索 List<Document> vectorDocs = vectorStore.similaritySearch( SearchRequest.query(query).withTopK(topN * 4)); // 2. 全文检索:同一张表里用 ts_rank 排序 List<Document> ftsDocs = positionRepository.fullTextSearch(query, topN * 4); // 3. RRF 融合:对两种排序位次的倒数求和 Map<String, Double> scoreMap = new HashMap<>(); mergeWithRrf(scoreMap, vectorDocs, 1); mergeWithRrf(scoreMap, ftsDocs, 2); // 4. 按融合分排序,截断返回 topN }RRF 的核心公式是score = 1 / (k + rank),k 取 60。它不要求两种检索的分数在一个量纲,只依赖排序位次,融合非常稳,也不需要调权重。
另一个教训是:不要对相似度设阈值。我一开始把余弦相似度阈值设在 0.75 以上,结果大量合法结果被过滤掉。后来改成“排序取 top 不设阈值”,再用重排模型把关,实际效果明显更好。
5.3 模型不按规矩调用工具的根治过程
上线后第一个被业务吐槽的场景:用户问“深圳产品经理薪资多少”,模型直接回答“根据市场数据,大约 25k—35k”——根本没调用工具。查日志发现,对话历史里出现过类似薪资数字,模型直接凭记忆作答。
我从三个方向同时入手才根治:
- 系统提示词加硬约束:“没有调用工具得到的数据都视为猜测,禁止输出猜测数字”;
- 加 few-shot,让模型看到“查询发起 → 工具返回 → 基于返回作答”的完整样例;
- 工具层加校验:如果用户问题包含“薪资”,但本次会话没有任何工具调用记录,就返回特殊提示词,要求模型重新发起工具调用。
另外还有参数幻觉问题。模型可能把“5 年以上经验优先”解析成years=5或years=6。我们的解法是把@ToolParam的描述写成“经验年限取整数,按最少年限计算”,同时在工具内部再做一次归一化,比如“8年以上”统一转成 8。
5.4 本地模型的延迟与持续运行调优
7B 模型虽然能跑,但参数调不对,延迟容易翻倍。几个实用的 Ollama 调优点:
- keep_alive 必须设置。默认模型空闲 5 分钟后会释放显存,下次请求冷启动要等 20 秒以上。我设置了常驻,线上体验好很多。
- num_ctx 要够大。RAG 场景下,检索块 + 工具结果 + 对话历史很容易超过模型默认上下文。我把 2K 提到 8K,回答质量提升明显,但显存和延迟也会升,需要权衡。
- 并发数控制。
OLLAMA_NUM_PARALLEL调成 2 后,排队时间可接受,也不至于把 GPU 显存撑爆。 - Ollama 前面加代理层。公司内网如果有 API 网关,建议在前面加超时和限流,防止突发查询把推理服务打挂。
5.5 Spring AI 版本升级的破坏性变更
我在项目过程中经历过一次 Spring AI 大版本升级,破坏性变更集中在三块:
- Starter 坐标变化。不同版本的 artifactId 前缀和命名不一致,升级时依赖坐标要整体对齐;
- Tool 注解包名与参数注解变化。旧版本 Function Calling 用
FunctionCallbackWrapper,新版本推荐@Tool注解,包路径也变了; - ChatClient API 演进。新版本统一了
prompt().call()和prompt().stream()的入口,返回类型也调整过。
我的建议是:不要在生产项目上盲目追新。选定一个稳定 GA 版本后锁住版本号,升级前先跑一遍回归测试套件。我维护了一个 20 条核心问题的冒烟测试集,每次升级只跑它,很快就能发现哪些链路行为变了。
整个项目做下来,我最深的两点体会:一是 RAG 项目的瓶颈往往不在模型,而在文档处理和检索链路,尤其是中文场景,切分和混合检索值得你花最多的时间;二是 Tool Calling 真正难的不是把方法暴露成工具,而是让模型在正确的时候调用正确的工具,这需要提示词、参数描述和容错机制共同作用。如果让我重来一遍,我会在项目第一天就把那 20 条冒烟测试集写好,它帮我省掉的时间绝对超过写它的时间。最后再分享一个小技巧:上线前把所有工具调用日志单独抽到一个日志文件,出问题时 grep 一下就能看到模型到底调用了哪些工具、传了什么参数,排查速度成倍提升。