news 2026/9/29 9:49:28

Spring AI实战:RAG与Tool Calling构建岗位JD分析系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring AI实战:RAG与Tool Calling构建岗位JD分析系统

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

招聘网站上的岗位描述(JD)看多了会发现一个很现实的问题:同样是“Java 后端开发”,不同公司写出来的技术栈、职责范围、薪资区间能差出三倍。手动一条条对比效率极低,用关键词匹配又经常误判——比如“熟悉 Spring Cloud”和“了解 Spring Cloud”在关键词层面完全一样,但对候选人的要求天差地别。

我这次要做的,就是一个能自动抓取、解析、对比岗位 JD 的分析系统。核心诉求有三个:第一,能理解 JD 里的自然语言,不是简单分词;第二,能调用外部工具去查实时数据,比如某个技能在当前市场的热度;第三,整个技术栈要跟现有 Java 后端体系无缝衔接,不想再引入 Python 服务。

选Spring AI作为核心框架,原因很直接。Spring Boot 生态我已经很熟了,依赖注入、配置管理、Web 层这些基础设施不用重新学。Spring AI 把大模型调用、向量检索、工具调用这些能力封装成了 Spring 风格的 Bean 和注解,写起来跟写普通 Service 没太大区别。大模型侧我用的是通义千问,主要是考虑中文 JD 的理解效果和调用成本,qwen-plus 和 qwen-max 在中文语义上的表现足够应付岗位分析场景。

这个系统适合谁参考?如果你有 Spring Boot 基础,想在自己的项目里加一个“能读懂文档”的智能模块,或者你正在做招聘、HR SaaS、技能图谱这类产品,这篇实战记录应该能帮你省掉不少试错时间。下面我会从整体设计、RAG 知识库搭建、Tool Calling 实现、踩坑排查几个维度,把完整过程拆开讲。

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

2.1 为什么是 RAG + Tool Calling 的组合

岗位分析这个场景有个特点:JD 文本本身是“非结构化”的,但分析逻辑又需要“结构化”的输出。比如我想知道“这个岗位对 Spring Cloud 的要求是精通还是了解”,这需要模型理解语义;但我想知道“Spring Cloud 在最近 30 天内的岗位提及率”,这又需要去查外部数据源。

纯 RAG 能解决第一类问题——把历史 JD 和技能词典向量化,检索出相关片段给模型做参考。但 RAG 有个天然短板:它只能基于已有知识回答,没法主动获取实时信息。纯 Tool Calling 能解决第二类问题——模型判断需要查数据时,主动调用我定义的工具方法。但如果没有 RAG 提供上下文,模型对“Spring Cloud”这个技能的理解可能不够精准。

所以我的方案是RAG 负责“理解”,Tool Calling 负责“行动”。用户输入一段 JD,系统先用 RAG 检索出相似岗位和技能定义作为上下文,模型基于这些上下文做初步分析;如果分析过程中需要实时数据(比如技能热度、薪资分位),模型通过 Tool Calling 调用我预先注册的工具方法,拿到结果后再整合输出。

这个组合的好处是:模型不会“瞎编”技能热度数据,因为所有实时数据都来自工具调用;同时模型也不会“误解”技能含义,因为 RAG 提供了标准化的技能定义作为锚点。

2.2 技术栈选型与版本对应关系

版本兼容性是 Spring AI 项目最容易翻车的地方。我踩过的坑是:Spring AI 的 milestone 版本和 Spring Boot 3.x 的小版本之间有严格的对应关系,用错了直接启动报NoSuchMethodError。

我最终确定的版本组合如下:

组件版本说明
Spring Boot3.3.x稳定版,社区资料多
Spring AI1.0.0-M6与 Boot 3.3 兼容性最好
Spring AI Alibaba1.0.0-M6.1通义千问适配层
JDK17Spring Boot 3.x 最低要求
向量数据库Redis Stack已有 Redis 环境,直接复用

注意:Spring AI 的版本号里带M的是 milestone 版本,API 可能随版本变化。如果你用的是 1.0.0 正式版,部分类名和配置项会有调整,建议先锁定版本再开发。

选 Redis Stack 而不是独立向量数据库,主要是考虑运维成本。Redis Stack 自带向量检索能力,我的 JD 数据量在十万级以内,用 Redis 的 HNSW 索引完全够用,没必要再维护一个 Milvus 或 Qdrant 实例。

2.3 系统分层架构

整个系统我分成了四层,每层职责清晰,方便后续替换组件:

  • 接入层:Spring Boot Web 接口,接收 JD 文本或文件上传,返回结构化分析结果。
  • 编排层:Spring AI 的 ChatClient 和 Advisor 链,负责串联 RAG 检索、Tool Calling、对话记忆。
  • 能力层:向量检索服务、工具方法注册中心、Prompt 模板管理。
  • 数据层:Redis 向量库、MySQL 岗位元数据、外部 API 数据源。

编排层是核心。Spring AI 的ChatClient支持通过.advisors()挂载多个 Advisor,我把 RAG 检索封装成一个QuestionAnswerAdvisor,把 Tool Calling 封装成ToolCallAdvisor,这样在业务代码里只需要一行.advisors(ragAdvisor, toolAdvisor)就能同时启用两种能力。

3. RAG 知识库搭建:从 JD 清洗到向量检索

3.1 JD 数据清洗与分块策略

原始 JD 数据来自公开渠道,格式很乱:有的带 HTML 标签,有的用\n分隔,有的把“岗位职责”和“任职要求”混在一起。直接扔给模型效果很差,必须先做清洗。

我的清洗流程分三步:

  1. 去噪:用正则去掉 HTML 标签、多余空格、特殊符号。特别注意去掉“薪资面议”“五险一金”这类与技能分析无关的段落。
  2. 结构化:按“岗位名称”“岗位职责”“任职要求”“加分项”四个字段拆分。如果原文没有明确分段,用关键词匹配做粗切分。
  3. 标准化:把“SpringCloud”“spring cloud”“Spring Cloud”统一成“Spring Cloud”,把“JAVA”“java”统一成“Java”。

分块策略上,我没有用固定长度切分,而是按语义段落切分。一个 JD 的“任职要求”通常是一个完整语义块,长度在 200-500 字之间,正好适合做 embedding。如果某个段落超过 800 字,再按句子边界二次切分,保证每块不超过 800 字。

实操心得:分块时保留 10%-15% 的重叠内容,比如上一块的最后一句话作为下一块的开头。这样检索时不会因为切分边界丢失上下文。我试过完全不重叠,检索“熟悉分布式事务”时经常漏掉前一块里的“有微服务架构经验”这个关键前提。

3.2 向量化模型选择与 Redis 索引配置

Embedding 模型我用的通义千问的text-embedding-v2,维度是 1536。选它的原因是中文语义效果好,而且和 Chat 模型同属一个平台,API Key 和调用配额可以复用。

Redis 向量索引的配置有几个关键参数:

FT.CREATE idx:jd ON HASH PREFIX 1 jd: SCHEMA content TEXT vector VECTOR HNSW 6 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE M 16 EF_CONSTRUCTION 200

M和EF_CONSTRUCTION是 HNSW 算法的核心参数。M控制每个节点的连接数,值越大检索越准但内存占用越高;EF_CONSTRUCTION控制建索引时的搜索范围,值越大索引质量越好但建索引越慢。我的数据量不大,所以M=16、EF_CONSTRUCTION=200是精度和速度的平衡点。

检索时用EF_RUNTIME参数控制查询精度,我设的是 100。实测下来,EF_RUNTIME=100时召回率已经能满足岗位分析需求,再往上加收益不明显。

3.3 RAG 检索链的 Advisor 实现

Spring AI 的 Advisor 机制是我最喜欢的设计。它本质上是一个拦截器链,可以在请求发给模型之前和响应返回之后做处理。RAG 检索就适合放在“请求前”阶段。

我自定义了一个JdRagAdvisor,核心逻辑是:

public class JdRagAdvisor implements CallAroundAdvisor { private final VectorStore vectorStore; @Override public AdvisedResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) { // 1. 用用户输入去向量库检索相似 JD List<Document> docs = vectorStore.similaritySearch( SearchRequest.query(request.userText()).withTopK(5) ); // 2. 把检索结果拼接到系统提示词里 String context = docs.stream() .map(Document::getContent) .collect(Collectors.joining("\n---\n")); String augmentedPrompt = """ 以下是与当前岗位相关的历史 JD 参考: %s 请基于以上参考,分析用户提供的岗位描述。 """.formatted(context); // 3. 替换原始请求中的系统提示词 AdvisedRequest augmented = request.mutate() .systemText(augmentedPrompt) .build(); return chain.nextAroundCall(augmented); } }

这里有个细节:topK设成 5 是经过测试的。设 3 的时候,有些冷门技能检索不到;设 10 的时候,无关 JD 会干扰模型判断。5 是一个比较稳的中间值。

注意:检索回来的文档要按相似度分数过滤,低于 0.7 的直接丢弃。我遇到过检索出完全不相关 JD 的情况,模型被带偏后输出了错误的技能要求。

4. Tool Calling 实现:让模型主动查数据

4.1 工具方法的定义与注册

Tool Calling 的核心是让模型知道“有哪些工具可用”以及“什么时候该用”。Spring AI 通过@Tool注解来标记工具方法,方法签名和 Javadoc 会自动生成工具的 JSON Schema 描述。

我定义了三个工具:

@Component public class JobAnalysisTools { @Tool(description = "查询指定技能在最近30天内的岗位提及率,返回百分比数值") public double getSkillMentionRate( @ToolParam(description = "技能名称,如 Spring Cloud") String skillName) { // 调用内部统计接口 return skillStatsService.getMentionRate(skillName, 30); } @Tool(description = "查询指定岗位名称在指定城市的平均薪资,返回月薪中位数(单位:元)") public int getAverageSalary( @ToolParam(description = "岗位名称") String jobTitle, @ToolParam(description = "城市名称,如 杭州") String city) { return salaryService.getMedianSalary(jobTitle, city); } @Tool(description = "对比两个技能在岗位要求中的共现频率,返回0-1之间的数值") public double getSkillCooccurrence( @ToolParam(description = "技能A") String skillA, @ToolParam(description = "技能B") String skillB) { return cooccurrenceService.calculate(skillA, skillB); } }

@Tool的description非常关键,模型就是靠这个描述来判断什么时候该调用哪个工具。我一开始写得太简单,比如“查询技能热度”,结果模型经常在不该调用的时候调用。后来改成“查询指定技能在最近30天内的岗位提及率,返回百分比数值”,模型就能准确判断了。

4.2 工具调用的触发条件与参数提取

模型不会无缘无故调用工具,它需要从用户输入中识别出“需要实时数据”的意图。比如用户说“帮我分析这个岗位的 Spring Cloud 要求”,模型会先做语义分析,然后判断是否需要查提及率。

这里有个坑:模型有时候会“过度调用”工具。比如用户只是问“这个岗位需要什么技能”,模型也可能去调getSkillMentionRate。解决办法是在系统提示词里明确约束:

你是一个岗位分析助手。当用户询问技能的市场热度、薪资水平、技能共现关系时, 调用相应工具获取实时数据。当用户仅要求解析岗位描述本身时,直接基于 RAG 上下文回答, 不要调用工具。

参数提取方面,Spring AI 会自动把模型返回的 JSON 参数映射到方法参数上。但如果模型返回的参数格式不对(比如把“杭州”写成“杭州市”),方法就会收到错误参数。我的处理方式是在工具方法内部做参数归一化,比如去掉“市”后缀、统一大小写。

4.3 多轮工具调用的编排

有些分析场景需要连续调用多个工具。比如“分析这个岗位的 Spring Cloud 和 Docker 要求,并对比两者的市场热度”,模型需要先调getSkillMentionRate("Spring Cloud"),再调getSkillMentionRate("Docker"),最后调getSkillCooccurrence("Spring Cloud", "Docker")。

Spring AI 的ChatClient默认支持多轮工具调用,但需要确保ToolCallAdvisor在链中的位置正确。我的配置是:

ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors( new MessageChatMemoryAdvisor(chatMemory), new JdRagAdvisor(vectorStore), new ToolCallAdvisor(toolCallbackResolver) ) .build();

顺序很重要:MessageChatMemoryAdvisor在最前面,保证多轮对话记忆;JdRagAdvisor在中间,负责检索上下文;ToolCallAdvisor在最后,负责工具调用。如果顺序反了,工具调用结果可能不会被正确拼接到最终响应里。

实操心得:多轮工具调用时,模型可能会在第一次调用后“忘记”之前的上下文。我的做法是在ToolCallAdvisor里把每次工具调用的结果追加到对话历史中,这样模型在后续调用时能看到之前的工具返回结果。

5. 完整实操流程与关键配置

5.1 项目初始化与依赖配置

用 Spring Initializr 创建项目时,需要勾选以下依赖:

  • Spring Web
  • Spring Data Redis
  • Spring AI Alibaba
  • Lombok

pom.xml里关键依赖的版本要手动锁定:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-alibaba-spring-boot-starter</artifactId> <version>1.0.0-M6.1</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-redis-store-spring-boot-starter</artifactId> <version>1.0.0-M6</version> </dependency>

application.yml的核心配置:

spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus temperature: 0.3 embedding: options: model: text-embedding-v2 data: redis: host: localhost port: 6379

temperature设成 0.3 是因为岗位分析需要稳定输出,不需要太多创造性。设 0.7 的时候,同一段 JD 两次分析结果差异明显,不利于后续对比。

5.2 JD 入库与向量化流程

JD 入库我写了一个批量处理接口,流程是:

  1. 从 MySQL 读取原始 JD 记录。
  2. 调用清洗服务做去噪和结构化。
  3. 按语义段落切分,每段生成一个Document对象。
  4. 调用vectorStore.add(documents)批量写入 Redis。

批量写入时要注意:每批不要超过 100 条。我试过一次性写 500 条,Redis 直接超时。后来改成每批 50 条,稳定运行。

public void batchImport(List<JdRaw> rawList) { List<Document> docs = rawList.stream() .map(this::cleanAndSplit) .flatMap(List::stream) .toList(); // 分批写入 Lists.partition(docs, 50).forEach(batch -> { vectorStore.add(batch); log.info("已写入 {} 条", batch.size()); }); }

5.3 分析接口的完整调用链

对外暴露的分析接口接收 JD 文本,返回结构化 JSON。完整调用链如下:

@PostMapping("/analyze") public AnalysisResult analyze(@RequestBody AnalyzeRequest request) { String prompt = """ 请分析以下岗位描述,输出: 1. 核心技能要求(区分精通/熟悉/了解) 2. 技能市场热度(调用工具查询) 3. 薪资水平评估(调用工具查询) 岗位描述: %s """.formatted(request.jdText()); String response = chatClient.prompt() .user(prompt) .advisors(ragAdvisor, toolAdvisor) .call() .content(); return parseResponse(response); }

实测下来,一段 300 字的 JD 分析耗时在 3-5 秒,其中 RAG 检索约 200ms,工具调用约 500ms,模型生成约 3s。这个延迟在可接受范围内。

6. 踩坑记录与常见问题排查

6.1 版本兼容性导致的启动失败

问题现象:项目启动时报java.lang.NoSuchMethodError: org.springframework.ai.chat.ChatClient.builder()。

排查过程:检查依赖树发现,spring-ai-alibaba传递依赖了一个旧版本的spring-ai-core,和我手动引入的版本冲突。

解决方法:在pom.xml里用<exclusions>排除传递依赖,显式声明所有 Spring AI 组件的版本。

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-alibaba-spring-boot-starter</artifactId> <version>1.0.0-M6.1</version> <exclusions> <exclusion> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-core</artifactId> </exclusion> </exclusions> </dependency>

避坑技巧:Spring AI 生态的版本管理很乱,建议用mvn dependency:tree定期检查依赖冲突,把所有 Spring AI 组件锁定在同一版本线上。

6.2 RAG 检索召回率低的优化

问题现象:检索“分布式事务”时,返回的 JD 里很多都不涉及这个技能。

排查过程:检查 embedding 发现,原始 JD 里“分布式事务”经常和“微服务”“高并发”一起出现,但分块时被切到了不同块里。

解决方法:调整分块策略,把“任职要求”整段作为一个块,不按固定长度切分。同时把topK从 3 调到 5,并在检索后加一层关键词过滤——如果检索结果里不包含查询词的同义词,直接丢弃。

6.3 工具调用参数格式错误

问题现象:模型调用getAverageSalary时,把城市参数传成了“杭州, 北京”。

排查过程:模型把用户输入里的多个城市一起传进来了,但方法签名只接受一个城市。

解决方法:在工具方法的@ToolParam描述里明确写“只接受单个城市名称”,同时在方法内部做参数校验,如果包含逗号就取第一个城市。

6.4 常见问题速查表

问题可能原因解决方法
启动报 NoSuchMethodError版本冲突用 dependency:tree 排查,锁定版本
RAG 检索不准分块策略不合理按语义段落切分,保留重叠
工具调用不触发description 太模糊写清楚工具用途和触发条件
模型输出格式不稳定temperature 太高调到 0.2-0.3
Redis 写入超时批量太大每批不超过 50 条
多轮对话丢失上下文Advisor 顺序错误Memory 放最前,Tool 放最后

7. 后续可扩展的方向

这套系统目前能完成基础的 JD 解析和技能分析,但还有几个方向可以继续挖。第一个是技能图谱构建,把 JD 里的技能共现关系抽出来,用图数据库存起来,这样能回答“学 Spring Cloud 之前应该先掌握什么”这类问题。第二个是多模态输入,支持上传 PDF 或图片格式的 JD,用 OCR 提取文本后再走分析流程。第三个是实时监控,定时抓取新发布的 JD,自动分析技能趋势变化,给团队的技术选型提供数据参考。

我在实际使用中发现,RAG 的检索质量直接决定了整个系统的上限。如果检索回来的上下文不准确,模型再强也分析不对。所以后续优化的重点应该放在 embedding 模型微调和检索策略调优上,而不是频繁换更大的 Chat 模型。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 9:48:28

幸狐RV1106开发板部署Yolo8实战:从模型转换到板端推理全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 9:42:59

Java图书管理系统源码实战:从环境搭建到二次开发全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 9:42:55

可调LDO输出噪声降噪:RC网络、前馈电容与噪声增益计算

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 9:36:44

Superpowers实战:给Codex装上上下文管理与任务清单,搞定Java复杂重构

1. 一场深夜排障让我重新认识了"superpowers"这个词大约两个月前的一个周五晚上&#xff0c;我被一个让人抓狂的Java编译错误困住了。错误信息指向一个泛型方法的重载冲突&#xff0c;IntelliJ的代码提示又一直跟我绕圈子——它建议的类型参数和实际传入的参数类型总…

作者头像 李华