我自己这半年最深的体会是:岗位 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
选型那会儿我把主流方案都过了一遍。我们团队全是 Java,Spring Boot 3.x 已经是基础,这时候有四个候选:
| 方案 | 适合场景 | 我的判断 |
|---|---|---|
| Spring AI | Java/Spring 生态的 AI 应用框架 | 与现有工程无缝集成,事务、配置、监控都能复用 |
| LangChain4j | Java 生态的 LangChain 移植 | 也不错,但和 Spring 搭配需要额外适配,社区和迭代速度不如 Spring AI |
| Dify | 低代码可视化工作流 | 适合快速原型和运营配置,但要嵌到 Java 业务系统里,反而要把工作流翻译成代码 |
| 自写 HTTP 调用 | 只有一个纯调用需求 | 不做 RAG 和工具编排还行,稍微复杂就失控 |
Dify 我后来也用来做过原型验证,确实快,但问题在于:原型里的知识检索节点、LLM 节点、工具节点,最终都要翻译回 Java 代码。热词里有人说"dify 工作流转成 spring ai java 代码",这就是实际遇到的需求。我给一张对应的映射表:
| Dify 节点 | Spring AI 对应物 |
|---|---|
| 知识检索节点 | QuestionAnswerAdvisor + VectorStore |
| LLM 节点 | ChatClient + System Prompt |
| HTTP/代码工具节点 | @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 Cloud;3. 有高并发经验”。如果简单按固定字符长度切,很容易把一条完整要求拦腰切断,检索的时候就会召回残缺片段,最后的答案自然不准。
我用的是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(List<String> candidateSkills, List<String> jdSkills) { Set<String> jdSet = new HashSet<>( jdSkills.stream().map(String::toLowerCase).toList()); List<String> matched = candidateSkills.stream() .map(String::toLowerCase) .filter(jdSet::contains) .toList(); List<String> 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> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-dashscope</artifactId> <version>2.0.1</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-pgvector</artifactId> <version>2.0.1</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-tika-document-reader</artifactId> <version>2.0.1</version> </dependency>如果你用的不是 2.0.1,注意去查对应版本的 BOM,Spring 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())); List<Document> docs = reader.get(); TextContentTransformer cleaner = new TextContentTransformer(); docs = cleaner.apply(docs); TokenTextSplitter splitter = new TokenTextSplitter(1200, 150, 5, 10000, true); List<Document> 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再querySalaryBand,Spring 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/403 | API 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 的调用成功率都会跟着上来。岗位分析只是一个例子,同样的架构换到合同审查、工单分派、设备巡检报告,逻辑是完全一样的。你先拿自己的业务文档试一次,跑通之后再回来调整参数,会比照着任何模板抄都稳。