尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring AI + RAG + Tool Calling:岗位JD分析系统实战全记录

Spring AI + RAG + Tool Calling:岗位JD分析系统实战全记录 我自己这半年最深的体会是岗位 JD 分析这种需求表面上是让 AI 读文档实际上做起来是典型的RAG Tool Calling组合工程。JD 是内部知识涉及薪酬数据要查系统涉及匹配度要算分光靠一个大模型硬答既看不见知识库也拿不到准确数据。最后我用Spring AI把它落成了 Java 服务跑了三个月今天把完整流程和踩的坑一次性写出来。这套系统解决的是招聘业务里最重复的一个环节HR 每天要面对几十份岗位 JD人工拆解技能要求、薪资范围、面试重点还要拿 JD 和候选人简历做匹配。我做的系统输入是 JD 文档或链接输出是一份结构化的岗位分析报告。其中 RAG 负责从内部知识库里检索岗位胜任力、面试题库、历史评估记录Tool Calling 负责调薪酬查询接口和技能匹配计算接口。适合正在做 Java 后端、想把手头业务接入 LLM 的同学参考也适合被 LangChain4j、Dify、本地 Ollama 方案绕晕了的人拿来对照。1. 先想清楚这个岗位分析系统到底要干什么1.1 岗位分析的输入输出到底是什么很多人在写这类系统前容易犯一个错上来就搭 RAG、调模型结果做出来的东西只是能回答问题业务根本没法用。我先说清楚我这边实际处理的输入输出输入岗位 JD文本文件、PDF、docx偶尔是网页链接历史面试评估记录内部文档散落在知识库里岗位胜任力模型HR 团队维护的标准化技能清单薪酬数据表各个城市、职级的薪资区间存在业务系统数据库里输出岗位核心技能提取以及技能等级判断JD 与薪酬区间的关联分析若传入候选人简历输出技能匹配度、缺口清单、建议面试问题拆下来就发现这里有两个技术问题绕不开第一内部 JD 和评估记录模型没见过不检索就是胡编第二薪酬和匹配度属于确定性数据不能让模型自己估计。于是系统天然被拆成两条主线RAG 解决知识从哪来Tool Calling 解决数据从哪算。1.2 为什么选 Spring AI而不是 LangChain4j 或 Dify选型那会儿我把主流方案都过了一遍。我们团队全是 JavaSpring Boot 3.x 已经是基础这时候有四个候选方案适合场景我的判断Spring AIJava/Spring 生态的 AI 应用框架与现有工程无缝集成事务、配置、监控都能复用LangChain4jJava 生态的 LangChain 移植也不错但和 Spring 搭配需要额外适配社区和迭代速度不如 Spring AIDify低代码可视化工作流适合快速原型和运营配置但要嵌到 Java 业务系统里反而要把工作流翻译成代码自写 HTTP 调用只有一个纯调用需求不做 RAG 和工具编排还行稍微复杂就失控Dify 我后来也用来做过原型验证确实快但问题在于原型里的知识检索节点、LLM 节点、工具节点最终都要翻译回 Java 代码。热词里有人说dify 工作流转成 spring ai java 代码这就是实际遇到的需求。我给一张对应的映射表Dify 节点Spring AI 对应物知识检索节点QuestionAnswerAdvisor VectorStoreLLM 节点ChatClient System PromptHTTP/代码工具节点Tool 注解方法 WebClient/RestTemplate变量聚合、条件分支Service 层普通 Java 业务逻辑会话记忆ChatMemory VectorStoreChatMemoryAdvisor翻译过一次你就明白Dify 的价值在编排可视化但闭环到生产系统里用 Spring AI 直接写反而更透明出问题也好排查。另外还有一个热词是spring ai alibaba 停更了吗。这里要说清楚阿里那边有一个 Spring AI Alibaba 项目主要做 DashScope 等的增强集成但如果你用的是 Spring 官方主线里的spring-ai-starter-model-dashscope它一直随 Spring AI 主版本在更新并没有停。我选的就是官方主线的 DashScope 接入稳。2. RAG 环节从文档拆解到检索调优我踩过的每颗钉子2.1 岗位知识库的文本加载与拆分RAG 的第一步是“让系统能看懂企业内部文档”。我用的是 Spring AI 的TikaDocumentReader它可以处理 PDF、docx、xlsx、HTML 这类常见格式内部走 Apache Tika 做类型识别和文本提取。这一步很简单真正需要花心思的是文本拆分。JD 文档有个特点大量使用分点、加粗、表格比如“任职要求1. Java 基础扎实2. 熟悉 Spring Cloud3. 有高并发经验”。如果简单按固定字符长度切很容易把一条完整要求拦腰切断检索的时候就会召回残缺片段最后的答案自然不准。我用的是TokenTextSplitter关键参数是 chunkSizeTokens 和 chunkOverlapTokens。参考配置如下TokenTextSplitter splitter new TokenTextSplitter(1200, 150, 5, 10000, true);这里的含义是每个 chunk 约 1200 个 token前后重叠 150 个 token最少保留 5 个 token最多切 10000 个 chunk。为什么必须要有重叠因为一条技能要求可能恰好落在切割点上重叠可以保证它至少在相邻的两个 chunk 里出现完整版本这样召回时不会被切碎。实际操作中我试过 800、1200、1500 三档1200 是我们 JD 语料上效果最稳的。chunk 太大一个 chunk 里塞了太多不相关内容embedding 向量会被稀释chunk 太小单个 chunk 语义不完整。这个粒度一定要拿自己的文档试别照抄别人的参数。热词里有有没有本地的 rag 文本拆解工具——其实 Tika 加各类 splitter 就是最直接的本地拆解工具链不需要单独再找额外程序。还有一个容易忽略的点TextContentTransformer。从 docx 或 HTML 里提取的文本经常带大量格式符号、空行、页眉页脚先过一遍文本清洗能明显减少脏数据进入向量库。成本低收益大建议每次都加。2.2 Embedding 与向量库选型文本拆好之后要转成向量。Embedding 模型这边有两个选择接百炼DashScope的text-embedding-v3或者本地 Ollama 跑bge-m3。生产和实验我都跑过百炼效果稳定中文理解好按量计费适合生产。本地bge-m3零成本、无需外网但受机器性能限制检索质量略低适合开发环境和离线场景。向量库我最终选了PgVectorStore。原因很朴素公司本来就有 PostgreSQL直接建一个vector扩展用 HNSW 索引配置里面加上余弦距离就行不需要再单独维护一套 Milvus 或 Redis。你如果只是本地验证Spring AI 自带的SimpleVectorStore内存版也够用但换生产环境必须上持久化存储。配置参考spring: datasource: url: jdbc:postgresql://localhost:5432/ragdb username: postgres password: postgres ai: vectorstore: pgvector: initialize-schema: true index-type: HNSW distance-type: COSINE_DISTANCE这里要说一个我踩过的坑向量库的相似度检索默认基于语义相似但它不认识你的业务元数据。比如你按岗位类别存了一批 JD 文档检索Java 高级工程师时可能召回一批高级测试工程师里恰好语义接近的片段。解决办法是给每个 Document 加 metadata并按 metadata 做过滤。入库时我给文档打了category、positionLevel这类标签检索搜索请求里带上过滤条件噪音一下就少了。2.3 检索质量和 hit rate 的优化热词里 rag hit rate 是很多人的痛点。hit rate 指的是在 100 个测试问题上有多少比例的“正确上下文”真的被检索回来了。这是 RAG 效果的核心指标比看答案顺不顺眼重要得多。我自己的优化顺序是第一调 topK。默认一般是 4我建议至少试到 8。topK 小召回不够模型只能根据残缺上下文补答案补着补着就开始编了。我们内部抽了 50 条问题做人工标注topK 从 4 调到 6 后hit rate 体感从六成多涨到八成多但到了 8 以上答案里开始混入不相关段落。所以 topK 并不是越大越好。第二加相似度阈值。Spring AI 的QuestionAnswerAdvisor支持配置 similarityThreshold低于阈值的文档不进上下文。这个阈值要结合你的 embedding 模型来看text-embedding-v3我设 0.6 左右效果适中本地bge-m3会高一截。建议先跑一批测试问题把召回分数打出来看分布再定。第三考虑混合检索和重排。纯向量检索有一个经典短板语义相似但关键词完全不同的长尾内容召回差。比如简历里写用过 Spring Boot、读过 Spring 源码JD 里写Java 框架深度vec 模型能关联上但遇到Framework和框架完全中英文混杂的语料效果就会打折。我们目前线上先靠向量加 metadata 过滤撑住下一步计划引入 BM25 关键词召回再合并重排。如果你公司有 ES可以直接上 ES 的 hybrid 查询成本比再搭一套重排服务低。这里必须说一句大实话RAG 的效果提升大部分来自数据治理和参数调优而不是换个更贵的模型。我见过很多人反复换 LLM真正病根是知识库文本杂乱、拆分粒度不对、metadata 没打全。3. Tool Calling 环节让模型会查数、会算分、会调用系统3.1 何时需要 Tool Calling何时不需要先泼一盆冷水不是所有功能都要上 Tool Calling。纯文本总结、观点分析、分类归纳直接用 ChatClient 就好。需要 Tool Calling 的场景有三个特征结果要确定、数据在系统里、逻辑要算。岗位分析系统里的典型例子薪酬区间必须从薪酬表里面查不能靠模型猜技能匹配度要算了才知道匹配率是多少模型是算不清集合交集的候选人状态查询需要调 HR 系统接口拿到实时数据如果这类操作也让模型自由发挥准确率会非常难看而且不可复现。Tool Calling 的本质是把模型知道了要查什么和系统真的查到了数据这两件事接起来。3.2 用 Tool 实现岗位技能比对和薪资查询Spring AI 从 1.0 开始提供了Tool注解2.0 里这套机制更成熟。你只要在一个 Spring Bean 的方法上加上Tool并且给它写清楚description模型发起函数调用时就会自动找到它。我写了两个最典型的工具一个是查薪酬一个是算技能匹配Component public class JobAnalysisTools { private final SalaryRepository salaryRepository; public JobAnalysisTools(SalaryRepository salaryRepository) { this.salaryRepository salaryRepository; } Tool(description 查询指定岗位名称、城市、职级的月薪区间返回JSON字符串) public String querySalaryBand(String position, String city, String level) { SalaryBand band salaryRepository.findByPositionAndCityAndLevel(position, city, level); if (band null) { return {\found\: false}; } return String.format( {\found\: true, \min\: %d, \max\: %d, \median\: %d}, band.min(), band.max(), band.median()); } Tool(description 计算候选人技能与岗位JD技能列表的匹配度返回JSON字符串包含matched、missing和rate字段) public String matchSkills(ListString candidateSkills, ListString jdSkills) { SetString jdSet new HashSet( jdSkills.stream().map(String::toLowerCase).toList()); ListString matched candidateSkills.stream() .map(String::toLowerCase) .filter(jdSet::contains) .toList(); ListString missing jdSkills.stream() .map(String::toLowerCase) .filter(s - !matched.contains(s)) .toList(); double rate jdSkills.isEmpty() ? 0.0 : (double) matched.size() / jdSkills.size(); return String.format( {\matched\: %s, \missing\: %s, \rate\: %.2f}, matched, missing, rate); } }matchSkills里面的逻辑很简单把两边技能都转成小写再求交集。真正的关键点是这些工具如果返回结构化 JSON模型就能精准地把它们填到答案里不会自己发挥。另外工具描述一定要写清楚输入是什么、输出格式是什么、什么情况下返回 found:false模型会根据 description 决定调用哪个工具描述含糊它就用错。3.3 RAG 与 Tool Calling 的协作流程这两者不是二选一而是配合的。一次典型的面试官视角岗位分析调用流程是这样的用户提交 JD 文本问分析这份 JD对比候选人张三的技能匹配度并给出薪资建议。系统先做检索QuestionAnswerAdvisor把问题做 embedding从向量库召回 JD 相关段落、胜任力模型文档拼进 prompt。模型读完检索上下文提取 JD 技能清单发现需要匹配度和薪酬就发起 Tool Calling依次调用matchSkills、querySalaryBand。工具结果以 JSON 形式回填给模型。模型汇总输出匹配率、缺口技能、薪资区间、面试重点。如果没有 RAG模型不知道张三是谁JD 内部版本在哪如果没有 Tool Calling模型会给你编一个薪酬区间和匹配率。两个加起来才是一个业务能用的系统。4. 代码落地Spring AI 2.x 从配置到跑通全流程4.1 项目初始化和依赖配置我用的是 Spring AI 2.0.1配套 Spring Boot 3.x。这里直接给可复制的依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-dashscope/artifactId version2.0.1/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-vector-store-pgvector/artifactId version2.0.1/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-tika-document-reader/artifactId version2.0.1/version /dependency如果你用的不是 2.0.1注意去查对应版本的 BOMSpring AI 的模块版本经常一起迭代。接着配置百炼模型连接也就是热词里说的spring ai 2.0 连接百炼 qwen3.7这类的需求spring: ai: dashscope: api-key: ${DASHSCOPE_API_KEY} chat: options: model: qwen-plus embedding: options: model: text-embedding-v3百炼平台申请 API Key 之后放在环境变量DASHSCOPE_API_KEY不要把密钥直接写进 yaml 提交到仓库。模型 ID 这块注意qwen-plus、qwen-max 是长期稳定 ID新的 qwen3 系列模型在不同地区的控制台里标识可能不一样热词里的qwen3.7如果在你控制台搜不到就按实际可选的模型 ID 来填配置结构是一样的。4.2 知识库入库脚本与 RAG 查询链路入库这一步我封装成一个独立的 Service这样 HR 上传一份新 JD 或评估文档时直接调用这个方法就能增量入库Service public class KnowledgeIngestService { private final VectorStore vectorStore; public KnowledgeIngestService(VectorStore vectorStore) { this.vectorStore vectorStore; } public void ingest(Path filePath, String category) { TikaDocumentReader reader new TikaDocumentReader(new FileSystemResource(filePath.toFile())); ListDocument docs reader.get(); TextContentTransformer cleaner new TextContentTransformer(); docs cleaner.apply(docs); TokenTextSplitter splitter new TokenTextSplitter(1200, 150, 5, 10000, true); ListDocument splitDocs splitter.apply(docs); splitDocs.forEach(doc - doc.getMetadata().put(category, category)); vectorStore.add(splitDocs); } }入库之后就是查询链路。Spring AI 的便捷之处在于ChatClient可以带着 advisor 一起构建QuestionAnswerAdvisor内部自动完成问题转向量、检索相似片段、把片段注入 prompt这三步QuestionAnswerAdvisor advisor QuestionAnswerAdvisor.builder(chatModel, vectorStore) .similarityThreshold(0.6) .topK(6) .build(); this.chatClient ChatClient.builder(chatModel) .defaultSystem(你是岗位分析助手。基于知识库内容回答涉及薪资和技能匹配时必须调用工具获取准确数据不要自行估计。) .defaultAdvisors(advisor) .defaultTools(tools) .build();注意QuestionAnswerAdvisor的构建需要传chatModel因为它要借用 ChatModel 的能力把问题本身转成向量做检索。这样写完之后一个最简单的分析接口就出来了PostMapping(/analyze) public String analyze(RequestBody AnalyzeRequest request) { return chatClient.prompt() .user(u - u.text(请分析以下岗位JD\n{jd}).param(jd, request.jd())) .call() .content(); }4.3 Tool Calling 的注册与调用细节Tool 类的注入在上面的ChatClient里通过defaultTools(tools)完成其中tools是上一步写的JobAnalysisToolsBean。Spring AI 会自动扫描其中的Tool方法把方法签名、参数、描述注册给模型。这里我踩过一个很具体的坑Spring AI 对 Tool 入参的解析依赖 JSON Schema如果你的工具方法参数是一个复杂自定义对象模型可能不知道怎么构造调用成功率会下降。最稳妥的方式是参数尽量用基本类型或字符串复杂数据让工具内部再去查库或解析。比如querySalaryBand(String position, String city, String level)三个字符串参数模型收到得非常准如果你定义了一个QueryRequest对象模型反而容易漏填字段。另外如果同一个问题需要连续调用多个工具模型是支持串行调用的。比如先matchSkills再querySalaryBandSpring AI 会生成一次工具调用、拿到结果、再生成下一个调用。这种链路我们要在日志里记录每个 Tool 的入参和出参排查问题就看这一串日志。4.4 本地 Ollama 降级方案零成本跑通全流程开发环境或离线环境里可以用 Ollama 把模型和 embedding 都跑在本地。热词里ollama 简易本地 RAG 知识库就是这个链路本地起模型Spring AI 自动走 Ollama。先装 Ollama然后拉模型ollama pull qwen2.5:7b ollama pull bge-m3接着改配置spring: ai: ollama: base-url: http://localhost:11434 chat: options: model: qwen2.5:7b embedding: options: model: bge-m3依赖换成spring-ai-starter-model-ollama即可代码里ChatModel、VectorStore的使用方式不变。我只提醒一点本地 7B 模型跑 RAG Tool Calling 是能跑通的但速度和质量确实不如云端 qwen-plus尤其 Tool Calling 的准确性差距明显。如果机器只有 16G 内存先试 7B 量化版本卡得厉害就换 qwen2.5:3b 做开发冒烟生产还是走百炼更省心。5. 踩坑实录常见问题排查与速查表5.1 RAG 知识库到底能不能存图片这个热词问得非常多我直接给结论RAG 向量库本身存的是文本向量不是图片二进制但知识库完全可以“管理”图片关键是设计好索引方式。我实际用到的有三种做法图片里的关键信息用 OCR 抽成文本作为该图片的文本描述入库检索图片本身存到 OSS 或本地路径。图片语义描述用视觉模型生成一句话标题比如系统架构图网关-服务-数据库把它存成 metadata。检索命中后把图片地址放进上下文的引用字段让前端展示。我们内部的做法是第一种加第三种。也就是入库时给图片生成描述文本并抽取文本metadata 里带imageUrl回答里如果引用了这份资料会同时返回图片链接给 HR 查看。千万别试图把图片原样塞进向量库那是多模态向量检索要干的事和传统 RAG 不是一个赛道。5.2 RAG 的瓶颈到底卡在哪不少人觉得 RAG 的瓶颈是模型不够聪明实际上我跑下来的瓶颈基本都在检索侧第一长尾信息召回难。岗位 JD 里经常出现缩写、中英混排、别名比如Java 并发编程JUC多线程调优其实指同一个方向向量能关联一部分但不是全部。第二多跳问题。张三在 A 项目的表现能否支撑他应聘高级工程师这种问题要跨两份文档推理单轮 RAG 很难做到。第三知识更新滞后。向量库不会自动感知文档修改HR 改了一版 JD 之后旧版本还在库里检索可能返回过期内容。我目前用 metadata 里的version字段做过滤文档更新时标记旧版本这个问题基本解决。热词里还有rag 知识库能存储图片嘛“有没有本地的文本拆解工具”这类问题本质都是在找 RAG 的数据治理方案。我对 RAG 的定位就是一句垃圾进垃圾出。文本拆得越干净、metadata 打得越全后面所有优化才有意义。5.3 常见问题排查速查表我把实际遇到的故障整理成了表格方便你对照定位现象可能原因排查与解决办法回答经常凭空捏造检索没召回相关内容先看 advisor 日志里召回了几条文档调大 topK、降低 similarityThreshold召回了很多但答案质量差chunk 切太碎或 overlap 不够调大 chunkSizeTokens检查是否需要 TextContentTransformer 清洗每次回答都在复读原文知识库重复文档太多入库前做文档去重检查 vectore store 里同一文档是否被多次 add模型死活不调用工具工具 description 不清楚或参数太复杂改写描述明确输入输出参数尽量用字符串工具调用报 JSON 解析错误模型构造参数格式不对给工具方法加 Tool 的示例描述返回格式统一用简单 JSON 字符串百炼返回 401/403API Key 未正确配置检查环境变量是否传入不要写死在代码里Ollama 连接超时模型没拉取或服务没启动ollama list确认模型curl http://localhost:11434确认服务SQL 查询 PG 时报 vector 类型不存在没装 pgvector 扩展执行CREATE EXTENSION IF NOT EXISTS vector;5.4 几个容易被忽略的经营性细节除了上面的故障还有几个经验只写给自己团队的一并分享工具调用一定要有日志。每次 Tool 调用入参、出参都打出来HR 质疑结果的时候你能拿出当时模型调了什么参数、工具返回了什么数据这是信任的基础。结构化输出优先。岗位分析报告我用固定的 JSON 模板让模型输出方便前端渲染和后续入库审计。自由文本虽然好看但没法做质量统计。给 ChatClient 配置超时和重试。百炼接口偶尔会慢Tool 内部如果调业务系统也可能超时Spring AI 的调用建议包一层重试策略但注意 Tool 要设计成幂等否则重试会导致业务重复动作。最后再分享一个个人体会这套系统从原型到上线真正的难点从来不是让模型回答出来而是让数据链路稳定。JD 文档清洗规范、metadata 命名、工具返回格式这些基础工作占了大约六成时间。把这部分做扎实RAG 的命中率、Tool 的调用成功率都会跟着上来。岗位分析只是一个例子同样的架构换到合同审查、工单分派、设备巡检报告逻辑是完全一样的。你先拿自己的业务文档试一次跑通之后再回来调整参数会比照着任何模板抄都稳。
返回列表