☰
Spring AI 实战:RAG 与 Tool Calling 构建岗位分析系统
2026/10/1 3:35:20 网站建设 项目流程

1. 为什么我要用 Spring AI 做一套岗位分析系统

先把结论摆在前面:这套系统的核心目标,是把一堆非结构化的招聘 JD(职位描述)文本,通过RAG(检索增强生成)加Tool Calling(工具调用)两条腿走路,自动产出结构化的岗位画像——包括技能栈分布、薪资区间、经验要求、岗位间的技能关联等等。听起来像是"又一个 RAG Demo",但真正动手之后你会发现,纯 RAG 在岗位分析这个场景里会撞墙,必须靠 Tool Calling 补位。

我为什么会选 Spring AI 而不是 LangChain4j 或者直接上 Python 那套?原因很实际:我的主业务系统是 Spring Boot 写的,JD 数据来自内部招聘管理系统,数据库、缓存、定时任务全在 Java 生态里。如果为了做 RAG 单独起一个 Python 服务,光是数据同步、鉴权打通、部署运维就够我喝一壶。Spring AI 的价值就在于它把向量库、ChatClient、Embedding、Function Calling 这些能力做成了 Spring 风格的 Bean,直接注入就能用,和现有 Spring Boot 项目是无缝的。

这套系统适合谁参考?如果你是有 Spring Boot 基础、想在自己的业务系统里嵌入 AI 能力的后端开发,或者你正在做招聘、HR SaaS、人才盘点这类产品,想加一个"智能岗位分析"模块,那这篇内容基本可以照着抄。如果你完全没接触过 Spring Boot,建议先把 Bean 注入、自动配置这些基础过一遍再回来,不然中间很多"为什么这么写"你会看得云里雾里。

我踩过的最大一个坑,是一开始天真地以为"把 JD 塞进向量库,用户问什么就检索什么"就完事了。结果用户问"帮我对比一下 Java 后端和 Go 后端这两个岗位方向近半年的技能要求变化",纯 RAG 检索出来的是一堆零散片段,LLM 拼出来的答案驴唇不对马嘴。这就是典型的RAG 瓶颈——它擅长"找相似",不擅长"做聚合、做计算、做对比"。而 Tool Calling 恰好能补上这块:让模型自己决定"我需要调用一个统计工具去算技能词频",而不是硬靠检索。

下面我按真实搭建顺序,把整体设计、核心细节、实操过程、踩坑排查四块拆开讲,中间会穿插大量参数选择和取舍逻辑。

2. 系统整体设计与技术选型拆解

2.1 为什么是 RAG + Tool Calling 而不是纯 RAG

先讲清楚这两者的分工,不然后面代码你会看不懂为什么这么设计。

RAG 负责"知识召回":把历史 JD、岗位说明书、技能词典这些文本切片、向量化、存进向量库。用户提问时,先做相似度检索,把最相关的片段喂给大模型作为上下文。它解决的是"模型不知道我们公司内部岗位数据"这个问题。

Tool Calling 负责"确定性计算与外部动作":比如统计某个技能在近 100 条 JD 里出现的频次、计算薪资中位数、按经验年限分组、查询数据库里某个岗位的在招数量。这些活儿如果交给 LLM 去"心算",它要么算错,要么编造。Tool Calling 的本质是让模型输出一个结构化的调用意图,由我们的 Java 代码去执行真正的逻辑,再把结果回传给模型组织语言。

我实测下来,纯 RAG 在岗位分析场景的hit rate(命中率)大概只有六成左右,尤其是涉及"对比""趋势""排名"这类问题时几乎必挂。加上 Tool Calling 之后,这类问题的可用率能拉到九成以上。所以这不是"要不要加"的问题,而是"必须加"。

2.2 技术栈清单与选型理由

组件选型选型理由
应用框架Spring Boot 3.x主业务生态一致,自动配置省心
AI 框架Spring AIBean 化集成,ChatClient/Embedding 开箱即用
大模型通义千问(qwen-plus / qwen-max)中文岗位文本理解好,Function Calling 支持稳定
向量库开发期用 SimpleVectorStore,生产用 PGVector开发零依赖,生产复用现有 PostgreSQL
Embedding通义千问 text-embedding-v3中文语义向量质量够用,维度 1024
文本切片TokenTextSplitter按 token 切,避免超长截断
缓存Caffeine高频查询结果缓存,降低模型调用成本

这里重点说两个选型决策。

第一个是向量库。很多人一上来就装 Milvus 或者 Chroma,我建议开发阶段先用 Spring AI 自带的SimpleVectorStore,它就是个内存 Map,重启数据就没了,但胜在零配置、启动快,调 prompt 和切片策略的时候特别爽。等逻辑跑通了,再切到 PGVector——因为你大概率已经有 PostgreSQL 了,加个扩展就行,不用额外维护一套向量数据库集群。这个"先内存后持久化"的路径,能帮你省掉至少两天的环境折腾。

第二个是模型。我选通义千问不是因为它最强,而是因为它在中文 JD 这种"半结构化、术语密集"的文本上表现稳定,而且 Function Calling 的 JSON 输出格式比较规矩,不容易出现模型返回一堆废话导致解析失败的情况。qwen-plus 用于日常问答,qwen-max 留给复杂的对比分析任务,按需切换能省不少 token 成本。

2.3 整体数据流设计

整个系统的数据流我画成文字版,方便你对照理解:

  1. 离线阶段:JD 原始文本 → 清洗(去 HTML、去重复)→ 切片 → Embedding → 存入向量库。
  2. 在线问答阶段:用户提问 → 检索向量库拿 Top-K 片段 → 组装 prompt(系统提示 + 检索上下文 + 用户问题 + 可用工具列表)→ 发给模型。
  3. 工具调用阶段:模型判断需要工具 → 返回工具名和参数 → Java 侧执行工具 → 结果回传模型 → 模型生成最终答案。
  4. 缓存阶段:对高频、结果稳定的查询做 Caffeine 缓存,命中直接返回。

这个流程里最容易出问题的是第 2 步的 prompt 组装和第 3 步的工具描述。工具描述写得好不好,直接决定模型会不会"该调的时候不调,不该调的时候乱调"。这个后面会专门讲。

3. 核心细节解析与实操要点

3.1 JD 文本切片:切多长、怎么切

切片是 RAG 的地基,切不好后面全白搭。我一开始用固定 500 字符切,结果一条 JD 里的"岗位职责"和"任职要求"被从中间劈开,检索出来的片段语义不完整,模型经常答非所问。

后来我改成按语义段落优先、token 兜底的策略:

  • 先用正则按"岗位职责""任职要求""加分项"这类小标题切大块;
  • 每个大块再用TokenTextSplitter按 token 切,chunkSize 设 400,overlap 设 80。

为什么是 400 和 80?因为通义千问的 embedding 模型对单段文本有长度限制,400 token 大约对应 600-800 个中文字符,正好覆盖一段完整的职责描述。overlap 设 80 是为了防止关键信息刚好卡在切片边界被切断——这个 20% 左右的重叠比例是我试了好几组参数后比较稳的。

TokenTextSplitter splitter = new TokenTextSplitter(400, 80, 5, 10000, true); List<Document> chunks = splitter.apply(documents);

注意:TokenTextSplitter的构造参数顺序是 chunkSize、minChunkSizeChars、minChunkLengthToEmbed、maxNumChunks、keepSeparator,不同版本可能有差异,升级 Spring AI 后一定要重新核对签名,我就因为版本升级参数错位导致切片全乱过一次。

3.2 向量检索的 Top-K 与相似度阈值

检索阶段有两个关键参数:topK和similarityThreshold。

  • topK我设的是 5。设太小(比如 2)召回不足,模型没素材;设太大(比如 10)会引入大量噪声片段,反而干扰模型判断,还费 token。
  • similarityThreshold设 0.7。低于这个相似度的片段直接丢弃,宁可少给也不能给错。
SearchRequest request = SearchRequest.query(question) .withTopK(5) .withSimilarityThreshold(0.7);

实测下来,岗位分析这种问题,Top-5 基本能覆盖到相关 JD 的核心信息。如果你发现模型总是"漏掉"某些信息,先别急着调大 topK,先检查切片是不是把关键信息切碎了——十有八九是切片的问题,不是检索的问题。

3.3 Tool Calling 的工具设计原则

这是整套系统里我最想强调的部分。工具设计有三个原则,违反任何一个都会让模型"犯傻"。

原则一:工具职责单一,名字要自解释。不要搞一个叫analyzeJob的万能工具,模型根本不知道它内部干了啥。要拆成countSkillFrequency、calculateSalaryMedian、groupByExperience这种一看名字就知道干嘛的。

原则二:参数描述要写清楚单位和格式。比如薪资工具的参数,要明确写"单位:元/月,整数"。我一开始没写单位,模型传了个"15k"进来,Java 侧解析直接抛异常。

原则三:工具返回结果要结构化且简短。返回一大坨 JSON 给模型,它会抓不住重点。我一般返回精简后的字符串或小对象,比如"Java: 87次, Spring Boot: 65次, MySQL: 52次"。

@Bean @Description("统计指定技能关键词在岗位库中出现的频次,参数 skill 为技能名称,如 Java") public Function<SkillRequest, String> countSkillFrequency() { return request -> jobAnalysisService.countSkill(request.skill()); }

Spring AI 里用@Description注解描述工具,这个描述就是模型判断"要不要调这个工具"的唯一依据,所以一定要写得像给同事交代任务一样清楚。

3.4 系统提示词(System Prompt)的写法

系统提示词决定了模型的"人设"和"行为边界"。我的写法是这样的:

你是一个岗位分析助手。你的职责是基于检索到的岗位数据回答用户问题。 规则: 1. 涉及统计、计算、排名的问题,必须调用工具,不要自己估算。 2. 回答必须基于检索到的数据,数据中没有的信息要明确说"数据中未提及"。 3. 输出用简洁的中文,涉及数据时用表格呈现。

这三条规则分别解决了三个高频问题:模型乱算、模型编造、模型输出啰嗦。尤其是第二条,不加的话模型特别爱"脑补"岗位要求,这在招聘场景里是致命的。

4. 实操过程与核心环节实现

4.1 项目初始化与依赖配置

先建一个标准的 Spring Boot 3.x 项目,然后在pom.xml里加 Spring AI 的依赖。这里要注意,Spring AI 的版本迭代很快,建议用 milestone 或正式发布版,别用 snapshot,不然今天能跑的代码明天可能就编译不过。

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-qwen</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-pgvector</artifactId> </dependency>

然后在application.yml里配置模型和向量库:

spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.3 vectorstore: pgvector: index-type: HNSW distance-type: COSINE_DISTANCE dimensions: 1024

temperature设 0.3 是因为岗位分析要的是稳定、可复现的结果,不需要模型发挥创造力。设太高你会发现同一个问题问两次答案不一样,这在业务系统里是大忌。

4.2 数据入库:从 JD 文本到向量

入库流程分四步:读取原始 JD、清洗、切片、写入向量库。

public void ingest(List<JobDescription> jobs) { List<Document> documents = jobs.stream() .map(job -> new Document( job.getContent(), Map.of("jobId", job.getId(), "title", job.getTitle()) )) .toList(); List<Document> chunks = tokenTextSplitter.apply(documents); vectorStore.add(chunks); }

这里有个细节:我在Document的 metadata 里存了jobId和title。为什么?因为检索出来的片段如果不知道来自哪个岗位,后续做聚合分析时就没法溯源。metadata 是 RAG 里最容易被忽视但极其重要的东西,它让你的检索结果"可追溯"。

实操心得:入库时一定要做去重。招聘系统的 JD 经常有大量重复(同一个岗位多渠道发布),不去重的话向量库里全是冗余,检索时 Top-5 可能全是同一条 JD 的切片,白白浪费召回名额。我一般用 JD 内容的 MD5 做去重键。

4.3 检索增强问答的完整实现

问答的核心是ChatClient加QuestionAnswerAdvisor。Spring AI 提供了开箱即用的 Advisor,能自动完成"检索 + 拼上下文"这一步。

@Bean public ChatClient chatClient(ChatClient.Builder builder, VectorStore vectorStore) { return builder .defaultSystem(SYSTEM_PROMPT) .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore, SearchRequest.query("").withTopK(5).withSimilarityThreshold(0.7))) .defaultFunctions("countSkillFrequency", "calculateSalaryMedian", "groupByExperience") .build(); }

defaultFunctions这一步就是把工具注册给模型。注意这里传的是 Bean 的名字,所以你的 Function Bean 命名要和这里一致,否则模型根本看不到工具。

调用的时候:

String answer = chatClient.prompt() .user("帮我统计一下近半年 Java 岗位最需要的 5 个技能") .call() .content();

模型收到问题后,会先判断"这需要统计",然后调用countSkillFrequency,拿到结果后再组织语言输出。整个过程对调用方是透明的,你只管问,工具调用是模型自己决定的。

4.4 工具的具体实现:以技能频次统计为例

工具的实现要"薄"——只做数据查询和计算,不做任何自然语言处理,因为语言处理是模型的活儿。

@Service public class JobAnalysisService { private final JdbcTemplate jdbcTemplate; public String countSkill(String skill) { String sql = """ SELECT COUNT(*) FROM job_skill WHERE skill_name = ? AND create_time > NOW() - INTERVAL '6 months' """; Integer count = jdbcTemplate.queryForObject(sql, Integer.class, skill); return skill + " 在近半年出现 " + count + " 次"; } public String calculateSalaryMedian(String jobTitle) { String sql = """ SELECT PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY salary_min) FROM job_description WHERE title = ? """; Double median = jdbcTemplate.queryForObject(sql, Double.class, jobTitle); return jobTitle + " 的薪资中位数为 " + median + " 元/月"; } }

用PERCENTILE_CONT(0.5)算中位数而不是平均值,是因为薪资数据分布偏斜严重,平均值容易被极端值带偏。这个细节很多教程不会讲,但在真实业务里很重要。

4.5 缓存与性能优化

模型调用是这套系统里最贵、最慢的环节。我加了 Caffeine 缓存,对"技能频次""薪资中位数"这类结果相对稳定的查询做缓存,TTL 设 30 分钟。

@Bean public Cache<String, String> analysisCache() { return Caffeine.newBuilder() .expireAfterWrite(30, TimeUnit.MINUTES) .maximumSize(1000) .build(); }

为什么 TTL 是 30 分钟而不是更长?因为岗位数据是动态的,新 JD 每天都在进来,缓存太久会导致分析结果滞后。30 分钟是我权衡"数据新鲜度"和"调用成本"后的折中值。如果你的 JD 更新频率低,可以适当延长。

5. 常见问题与排查技巧实录

5.1 模型不调用工具怎么办

这是最高频的问题。模型明明该调工具,却自己"编"了个答案。排查顺序如下:

  1. 检查工具是否真的注册成功。在启动日志里搜工具名,没注册上模型当然看不到。
  2. 检查工具描述是否清晰。@Description写得太模糊,模型判断不了该不该调。
  3. 检查系统提示词有没有强制要求。加一句"涉及统计必须调用工具"能显著提升调用率。
  4. 检查模型本身是否支持 Function Calling。不是所有模型都支持,选型时要确认。

我遇到过一次,工具描述写的是"处理技能相关",模型完全不知道这工具能干嘛,改成"统计指定技能在岗位库中的出现频次"之后立刻就正常调用了。

5.2 检索结果不相关

如果检索出来的片段和问题八竿子打不着,按这个顺序查:

现象可能原因解决方向
检索结果全是无关 JD切片太碎或太大调整 chunkSize 和 overlap
相关 JD 检索不到相似度阈值太高降低 threshold 到 0.6 试试
结果重复度高数据没去重入库前做 MD5 去重
中文语义匹配差embedding 模型不适配换中文优化过的 embedding 模型

5.3 工具调用参数解析失败

模型传参格式和你的 Java 对象对不上,会抛反序列化异常。常见的是模型传了带单位的字符串("15k")而你的字段是 Integer。解决办法有两个:一是参数描述里写死格式要求,二是 Java 侧做容错解析,把"15k"转成 15000。我建议两个都做,双保险。

5.4 响应太慢

一次完整的 RAG + Tool Calling 链路,涉及"检索 + 模型判断 + 工具执行 + 模型生成"四步,慢是正常的。优化手段:

  • 检索和工具执行并行化(如果互不依赖);
  • 高频查询走缓存;
  • 简单问题用 qwen-plus,复杂问题才用 qwen-max;
  • 控制检索片段数量,别一股脑塞给模型。

5.5 独家避坑清单

  • 别在循环里调模型。我见过有人对 100 条 JD 逐条调模型做分类,又慢又贵。批量处理要合并请求。
  • metadata 一定要存业务主键。不然检索结果没法溯源,做聚合分析时你会哭。
  • 工具返回结果别太长。模型上下文有限,返回一大坨数据会挤掉检索上下文。
  • prompt 里的规则要少而精。写十几条规则模型反而记不住,三条核心规则足够。
  • 上线前一定要做回归测试。模型行为会随版本更新变化,今天好用的 prompt 明天可能就失效。

6. 这套系统还能怎么扩展

跑通基础版本之后,我陆续加了几个扩展,效果都不错,这里分享给你。

第一个是 GraphRAG 思路的引入。纯向量检索只能找到"相似文本",但岗位之间其实有"技能关联"这种图结构关系。比如"会 Spring Boot 的人大概率也会 MySQL"。我把技能共现关系抽出来存成图,检索时结合图遍历,能回答"从 Java 转 Go 需要补哪些技能"这类关联性问题。这就是热词里说的 ontology RAG 的落地场景。

第二个是 NL2SQL 的补充。有些问题本质是数据库查询,比如"薪资大于 30k 的岗位有几个"。与其让模型在向量库里瞎找,不如让它生成 SQL 直接查库。Spring AI Alibaba 的 NL2SQL 能力可以接进来,和 RAG 形成互补——结构化问题走 SQL,非结构化问题走向量检索。

第三个是分析结果的可视化。把工具返回的统计数据直接喂给前端图表库,用户看到的不只是文字答案,还有技能分布饼图、薪资区间柱状图。这一步让系统从"问答工具"升级成"分析平台"。

我个人在实际操作中的体会是,RAG 和 Tool Calling 的组合不是简单的"1+1",而是让系统具备了"既能查资料又能算数据"的双重能力。岗位分析只是其中一个应用场景,同样的架构套到客服知识库、合同审查、竞品分析上,逻辑是通的。真正花时间的从来不是写代码,而是调 prompt、调切片、调工具描述这些"脏活"——但这些脏活,恰恰决定了系统到底能不能用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询