1. 为什么我要用 Spring AI 做一套岗位分析系统
先把结论摆在前面:这套系统的核心目标,是把一堆非结构化的招聘 JD(职位描述)文本,自动拆解成结构化的岗位画像——包括技能栈、经验年限、薪资区间、学历要求、岗位职责归类,再基于这些结构化数据做检索问答和趋势分析。听起来像是 NLP 的老活儿,但真正落地的时候你会发现,纯靠提示词硬怼大模型,效果飘忽不定,成本还高。所以我选了Spring AI + RAG + Tool Calling这条路线。
为什么是 Spring AI?因为我的主技术栈就是 Spring Boot,团队里没人愿意为了一个 AI 功能再单独维护一套 Python 服务。Spring AI 把大模型调用、向量库、Embedding、Function Calling 这些能力都封装成了 Spring 风格的 Bean 和注解,跟现有的依赖注入、配置管理、事务体系能无缝衔接。这一点对后端团队来说太重要了——你不需要成为算法工程师,也能把 AI 能力接进业务系统。
为什么是 RAG?因为岗位分析这个场景有个天然痛点:大模型的训练数据是滞后的,它不知道你们公司内部的岗位职级体系,也不知道某个细分领域最新的技术栈叫法。RAG(检索增强生成)的思路很朴素——先把相关知识存进向量库,用户提问时先检索出最相关的片段,再把这些片段作为上下文喂给大模型。这样模型回答时就有据可依,而不是凭空编造。
为什么还要 Tool Calling?因为光靠检索还不够。比如用户问"帮我统计一下近三个月 Java 岗位里提到 Spring Cloud 的比例",这种需要精确计算和实时查询的问题,RAG 检索出来的文本片段是没法直接算的。这时候就需要让大模型去调用我们预先定义好的工具方法——查数据库、做聚合统计、调外部接口,把计算结果拿回来再组织成自然语言。
这三者组合起来,就是一套完整的Agentic RAG雏形:检索负责"找料",工具调用负责"干活",大模型负责"组织和表达"。下面我把整个搭建过程、关键决策、踩过的坑,从头到尾捋一遍。
2. 整体架构设计与技术选型思路
2.1 系统分层与数据流向
整套系统我分成了四层,从下往上依次是:
- 数据接入层:负责采集和清洗招聘 JD 数据,来源包括手动导入的文本、爬取的结构化数据、以及历史积累的 Excel 表格。这一层的核心任务是把各种格式的原始数据统一成纯文本 + 元数据的格式。
- 向量化与存储层:用 Embedding 模型把文本切片转成向量,存进向量数据库。同时保留原始文本和元数据(岗位名称、城市、发布时间、薪资等),方便后续做混合检索。
- 检索与工具层:这是 RAG 的核心。检索器负责根据用户 query 召回相关文档片段;工具层定义了一系列可被大模型调用的方法,比如统计技能出现频次、按城市聚合薪资、查询某个岗位的技能要求等。
- 对话与编排层:基于 Spring AI 的 ChatClient 构建对话流程,把检索结果和工具调用结果组装成最终提示词,交给大模型生成回答。
数据流向是这样的:用户提问 → 检索器召回相关 JD 片段 → 判断是否需要调用工具 → 如果需要,大模型输出工具调用请求 → 执行工具方法 → 把工具结果和检索结果一起塞回提示词 → 大模型生成最终回答。
2.2 为什么选通义千问而不是别的模型
模型选型这块我纠结了很久。最终选通义千问,主要基于三个考虑:
第一,中文理解能力。岗位 JD 里全是中文,还夹杂着大量技术名词的中英文混写,比如"熟悉 Spring Cloud 微服务架构,有 Dubbo 使用经验者优先"。通义千问在中文语境下的语义理解明显更稳,尤其是对技术栈缩写的识别。
第二,Tool Calling 的支持成熟度。不是所有模型都能稳定地输出结构化的函数调用请求。我实测下来,通义千问在 Function Calling 的格式遵循度上表现不错,很少出现该调工具时不调、或者参数格式乱写的情况。
第三,成本和延迟。岗位分析系统需要频繁调用模型,尤其是批量处理 JD 的时候。通义千问的定价相对友好,响应速度也能接受。当然,如果你有本地部署需求,Ollama 跑开源模型也是可行的,后面我会提一下怎么切换。
Spring AI 的好处就在这里——它把不同模型的调用抽象成了统一的接口,你只需要改配置文件里的 model 名称和 API Key,代码基本不用动。这一点在我后来做模型对比测试的时候省了大量时间。
2.3 RAG 还是 GraphRAG,我为什么先选朴素 RAG
网上现在到处在聊 GraphRAG、Ontology RAG,看起来很高大上。我也研究过一阵,但最后还是决定先用最朴素的向量检索 RAG 把流程跑通。原因很简单:岗位分析这个场景,实体关系并没有复杂到需要图结构。
GraphRAG 适合什么场景?适合那种实体之间有多跳关系、需要推理链的场景,比如"张三的上级的部门负责的项目用了什么技术"。但岗位分析的核心需求是"找出和某个 query 最相关的 JD 片段",这是典型的语义相似度匹配问题,向量检索足够用。
而且朴素 RAG 的调试成本低得多。你可以快速看到检索出来的片段质量,判断是切片策略有问题还是 Embedding 模型不合适。等这套跑通了,如果发现确实有跨文档推理的需求,再往上叠 GraphRAG 也不迟。我的原则一直是:先用最简单能跑的方案验证价值,再考虑优化。
3. 核心细节解析与实操要点
3.1 文本切片策略:别小看这一步
RAG 效果好不好,切片策略占一半功劳。我一开始图省事,直接按固定字符数切,每 500 字一段。结果检索出来的片段经常是半句话截断的,比如"熟悉 Java 并发编程、JVM 调优,有"——后面没了。这种片段喂给大模型,它只能瞎猜。
后来我改成了按语义结构切片。招聘 JD 通常有比较固定的结构:岗位职责、任职要求、加分项、薪资福利。我先用正则把这几块拆开,然后每块内部再按段落切。每段控制在 200 到 400 字之间,并且保留一定的重叠(overlap),防止关键信息刚好卡在边界上被切断。
具体参数上,我设的是 chunkSize=350,chunkOverlap=50。这个数值不是拍脑袋定的,是我拿一批 JD 做了对比测试:chunkSize 太小,检索出来的片段信息量不够,模型回答时容易缺胳膊少腿;chunkSize 太大,一个片段里混了好几个不相关的信息点,反而稀释了相关性。350 字大概是一段完整任职要求的长度,实测召回质量最好。
还有一个细节:元数据一定要带上。我在每个切片上都附加了岗位名称、城市、薪资范围、发布时间这些字段。这样检索的时候可以做过滤,比如用户问"北京的 Java 岗位",我可以先在元数据层面过滤出北京的数据,再做向量检索,精度提升非常明显。
3.2 Embedding 模型的选择与向量维度
Embedding 模型负责把文本转成向量。我一开始用的是默认的模型,后来换成了通义千问的 text-embedding 系列。换的原因是对中文技术文本的语义捕捉更准。
向量维度这块要注意:不同模型的维度不一样,一旦选定就不能随便换。因为向量库里的数据是用某个模型生成的,你换了模型,维度对不上,整个库都得重新生成。我选的是 1536 维,这个维度在表达能力和存储成本之间比较平衡。维度太低,语义区分度不够;维度太高,存储和检索都变慢,而且边际收益递减。
还有一个坑:Embedding 是要花钱和花时间的。如果你有几十万条 JD,一次性全量生成向量可能要跑好几个小时。我的做法是分批处理,每批 100 条,中间加个短暂休眠,避免触发限流。同时把已经生成好的向量缓存起来,重复的文本不重复调用。
3.3 Tool Calling 的工具定义原则
工具定义是这套系统里最容易被低估的部分。我见过很多人把工具方法写得特别复杂,参数一大堆,结果大模型根本调不对。我的经验是:工具要小而专,一个工具只干一件事。
比如我定义了这么几个工具:
countSkillFrequency(skillName, city, months):统计某个技能在指定城市、指定时间范围内的出现频次。getSalaryRange(jobTitle, city):查询某个岗位在某个城市的薪资区间。listTopSkills(jobTitle, limit):列出某个岗位最常被要求的技能,按频次排序。searchJdByKeyword(keyword, limit):按关键词搜索原始 JD 文本。
每个工具的参数都控制在 2 到 3 个,而且参数名要起得让模型一看就懂。比如months比timeRange更明确,skillName比keyword更具体。工具的描述(description)也要写清楚,这是模型判断该不该调用这个工具的主要依据。
提示:工具方法的返回值尽量用结构化格式(比如 JSON),不要返回一大段自然语言。模型对结构化数据的解析能力更强,而且方便你在代码里做二次处理。
3.4 提示词模板的设计要点
提示词模板决定了模型怎么组织回答。我的模板分三部分:
第一部分是系统指令,告诉模型它的角色和回答风格。比如"你是一个专业的岗位分析助手,回答要基于提供的参考资料,不要编造数据"。
第二部分是检索到的上下文,把 RAG 召回的片段和工具调用的结果拼进去。
第三部分是用户问题。
这里有个关键技巧:明确告诉模型什么时候该用工具,什么时候该用检索结果。我在系统指令里写了一段话:"如果用户的问题涉及统计、计算、排序,请优先调用工具;如果用户的问题涉及岗位职责、技能描述等文本内容,请基于检索到的参考资料回答。" 加上这段之后,工具调用的准确率提升了一大截。
4. 实操过程与核心环节实现
4.1 项目初始化与依赖配置
先创建一个标准的 Spring Boot 项目,然后在pom.xml里引入 Spring AI 的依赖。核心依赖包括:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-core</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-qwen-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-redis-store-spring-boot-starter</artifactId> </dependency>向量库我选的是 Redis,因为团队本来就在用 Redis 做缓存,不用额外维护一套中间件。Spring AI 对 Redis 向量存储的支持也比较完善。
配置文件里需要填几个关键项:
spring: ai: qwen: api-key: ${QWEN_API_KEY} chat: options: model: qwen-plus temperature: 0.3 embedding: options: model: text-embedding-v2 vectorstore: redis: index: job-jd-index prefix: "jd:"temperature我设的是 0.3,因为岗位分析需要的是准确和稳定,不需要太多创造性。设太高的话,模型容易在数字和统计结果上胡说八道。
4.2 数据清洗与切片实现
数据清洗这块我写了一个JdCleaner类,核心逻辑是:
- 去掉 HTML 标签和多余空白字符。
- 按"岗位职责""任职要求""加分项"等关键词把 JD 拆成几个区块。
- 每个区块内部按段落切分,合并过短的段落。
- 生成切片时附加元数据。
切片的核心代码大概长这样:
public List<Document> splitJd(String rawText, Map<String, Object> metadata) { List<Document> documents = new ArrayList<>(); String[] sections = rawText.split("(?=岗位职责|任职要求|加分项|薪资福利)"); for (String section : sections) { if (section.trim().isEmpty()) continue; List<String> chunks = splitByLength(section, 350, 50); for (String chunk : chunks) { documents.add(new Document(chunk, metadata)); } } return documents; }splitByLength是我自己写的按长度切分并保留重叠的方法。这里要注意,切分的时候尽量在句号或分号处断开,不要硬切在词中间。
4.3 向量入库与检索实现
入库就是把切片转成向量存进 Redis。Spring AI 提供了VectorStore接口,调用add()方法就行:
@Autowired private VectorStore vectorStore; public void importJds(List<Document> documents) { vectorStore.add(documents); }检索的时候用similaritySearch:
public List<Document> retrieve(String query, int topK) { SearchRequest request = SearchRequest.query(query) .withTopK(topK) .withSimilarityThreshold(0.7); return vectorStore.similaritySearch(request); }topK我设的是 5,similarityThreshold设的是 0.7。这两个参数需要根据实际数据调。topK 太大,会引入不相关的片段干扰模型;太小,可能漏掉关键信息。0.7 的阈值是我实测下来比较合适的,低于这个分数的片段基本可以认为是噪音。
4.4 Tool Calling 的注册与调用
工具方法的注册用 Spring AI 的@Tool注解(不同版本可能叫@Function,注意看文档)。我定义了一个JobAnalysisTools类:
@Component public class JobAnalysisTools { @Tool(description = "统计指定技能在指定城市和时间范围内的出现频次") public SkillStat countSkillFrequency( @ToolParam(description = "技能名称,如 Java、Spring Cloud") String skillName, @ToolParam(description = "城市名称,如 北京、上海") String city, @ToolParam(description = "统计最近几个月的数据") int months) { // 查询数据库并统计 return skillRepository.countBySkillAndCity(skillName, city, months); } }然后在 ChatClient 里注册这些工具:
ChatClient chatClient = ChatClient.builder(chatModel) .defaultTools(jobAnalysisTools) .build();调用的时候,模型会自动判断是否需要调用工具。如果用户问"北京 Java 岗位里 Spring Cloud 的出现频次是多少",模型会输出一个工具调用请求,Spring AI 框架会自动执行对应方法并把结果返回给模型。
4.5 完整对话流程的编排
把检索和工具调用串起来的核心逻辑:
public String chat(String userQuestion) { // 1. 检索相关文档 List<Document> docs = retrieve(userQuestion, 5); String context = docs.stream() .map(Document::getContent) .collect(Collectors.joining("\n---\n")); // 2. 构建提示词 String prompt = String.format(""" 参考资料: %s 用户问题:%s """, context, userQuestion); // 3. 调用模型(工具会自动触发) return chatClient.prompt() .user(prompt) .call() .content(); }这段代码看起来简单,但里面有个细节:检索和工具调用不是二选一的。有些问题既需要检索文本,又需要工具计算。比如"帮我分析一下北京 Java 岗位的技能要求,并统计 Spring Cloud 的出现频次"。这种情况下,模型会先基于检索结果回答技能要求部分,再调用工具获取统计数据。
5. 常见问题与排查技巧实录
5.1 检索结果不相关怎么办
这是 RAG 最常见的问题。排查思路按优先级来:
第一步,检查切片质量。把检索出来的片段打印出来看,是不是有截断、混入无关内容的情况。如果是,调整切片策略。
第二步,检查 Embedding 模型。用几个典型 query 测试,看召回的片段是否语义相关。如果明显不相关,可能是模型对中文技术文本的理解不够,考虑换模型。
第三步,调整相似度阈值和 topK。有时候不是检索错了,而是阈值设太低,把噪音也召回了。
第四步,考虑混合检索。纯向量检索对关键词匹配不敏感,比如用户搜"Spring Cloud",向量检索可能召回一堆"微服务"相关的片段,但没召回明确提到"Spring Cloud"的。这时候可以加一路基于关键词的检索(比如 Elasticsearch),把两路结果融合。
5.2 工具调用不触发或参数错误
模型该调工具时不调,通常是因为工具描述写得不够清楚。我的经验是:描述里要明确说明"什么时候用这个工具",而不只是"这个工具是干什么的"。
比如不要写"统计技能频次",而要写"当用户询问某个技能的出现次数、占比、趋势时,使用此工具"。
参数错误的话,检查参数名和描述。参数名要语义明确,描述里最好给例子。比如city的描述写成"城市名称,如 北京、上海、深圳"。
还有一个坑:工具方法抛异常会导致整个对话失败。所以工具方法内部一定要做好异常处理,返回一个友好的错误信息,而不是直接抛出去。
5.3 模型回答里出现编造数据
这是最危险的问题。模型可能会把检索到的片段里的数字张冠李戴,或者干脆编一个看起来合理的数字。
我的应对策略有三个:
第一,在系统指令里明确禁止编造。写清楚"如果参考资料中没有相关信息,请直接说不知道,不要编造"。
第二,要求模型标注数据来源。比如回答时带上"根据 XX 条 JD 统计",这样你能快速判断数据是否可信。
第三,关键数据走工具调用。凡是涉及具体数字的,尽量让模型调工具获取,而不是从检索片段里提取。工具返回的数据是精确的,模型只负责组织语言。
5.4 响应速度慢的优化
RAG + Tool Calling 的链路比较长,响应慢是正常的。优化方向:
- 检索阶段:给向量库加索引,减少 topK,用元数据过滤缩小检索范围。
- 模型阶段:用流式输出(streaming),让用户先看到部分结果。Spring AI 支持
stream()方法。 - 工具阶段:给工具方法的查询加缓存,相同参数的查询直接返回缓存结果。
- 并发处理:检索和工具调用如果可以并行,就用
CompletableFuture并行执行。
下面这张表是我整理的问题速查表,方便你快速定位:
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 检索结果不相关 | 切片策略差 / Embedding 模型不合适 | 检查切片质量,换模型测试 |
| 工具不触发 | 工具描述不清晰 | 补充"何时使用"的说明 |
| 参数错误 | 参数名或描述有歧义 | 参数名语义化,描述加例子 |
| 回答编造数据 | 提示词约束不够 | 加禁止编造指令,关键数据走工具 |
| 响应慢 | 链路长 / 无缓存 | 加索引、流式输出、查询缓存 |
| 向量入库失败 | 维度不匹配 / 限流 | 检查模型维度,分批处理 |
5.5 几个我踩过的坑
坑一:元数据过滤和向量检索的顺序。我一开始是先做向量检索,再在结果里过滤元数据。这样会导致召回数量不够——比如我要 5 条北京的,向量检索返回 10 条,过滤完只剩 2 条。正确做法是在检索请求里就带上过滤条件,让向量库先过滤再检索。
坑二:工具方法的返回值太大。我有一次让工具返回了完整的 JD 列表,结果 token 直接爆了。工具返回值要精简,只返回必要字段。
坑三:忘记处理空结果。检索可能返回空,工具可能查不到数据。这些情况都要有兜底逻辑,不能让模型面对空上下文硬编。
坑四:模型版本升级导致行为变化。我用的是在线模型,有一次服务端升级后,工具调用的格式变了,导致解析失败。所以生产环境要做好版本锁定和回归测试。
6. 一些关于扩展方向的个人想法
这套系统跑通之后,我陆续加了一些扩展。比如把岗位分析结果做成可视化报表,用定时任务每天跑一批新 JD 入库,还接了一个简单的 Web 界面方便非技术同事使用。
如果继续往下做,我觉得有几个方向值得尝试。一是引入多路召回,把向量检索、关键词检索、甚至基于规则的检索融合起来,提升召回率。二是做检索结果的重排序,用一个小的交叉编码模型对召回的片段重新打分,把最相关的排到前面。三是把工具调用做得更智能,比如让模型自己决定调用哪些工具、以什么顺序调用,而不是每次都要我在提示词里引导。
不过话说回来,技术方案没有银弹。我见过太多人一上来就追求最复杂的架构,结果连最基础的检索质量都没调好。我的建议始终是:先把朴素 RAG 跑通,把检索质量调到位,再考虑加工具、加图、加重排序。每一步都要有明确的收益,而不是为了技术而技术。
最后分享一个我在调试期常用的小技巧:把每次对话的检索片段、工具调用记录、最终提示词都打到日志里。出问题的时候,翻日志比瞎猜快得多。这个习惯帮我省了无数时间。