从零用 Spring AI 搭建 RAG + Tool Calling 岗位分析系统:全流程实战与踩坑记录
这些年做后端,一直在跟数据打交道。真正接触大模型应用后,心里最深的感触是:RAG 这套东西,看起来热度极高、人人都能讲两句,可一旦落到具体业务里,坑得比想象中深得多。我前阵子用 Spring AI 从零搭了一套岗位分析系统,用 RAG 做知识库检索,再用 Tool Calling 让模型去查库、算数据、拉真实岗位,整套链路从数据清洗到接口联调全走了一遍。这里把完整方案、代码思路和踩过的问题一次性整理出来,希望能给打算上手 Spring AI 的同学省点时间。
简单交代下背景:这套系统要解决的核心问题,是把分散在企业内部各处的岗位说明、职级标准、历史薪酬记录,以及外部招聘网站的公开岗位描述,汇总成一个可以自然语言提问的分析入口。比如领导问“某个职级的岗位五年经验应该给多少钱”“最近三个月公司哪些岗位需求增长最快”,你总不能每次都翻表格手工整理。传统做法是写报表,但自然语言的灵活性问题解决不了,于是想到用 RAG 把文档喂给模型、用 Tool Calling 让模型自己调统计函数,最终输出带依据的分析结论。
如果你对 Spring AI 感兴趣,或者正在纠结如何把 LLM 能力和现有 Java 系统串起来,这篇文章会适合你。后面我会讲清楚为什么不用 LangChain4j、为什么选 Tool Calling 而不是硬编码,以及那些文档里不会写、只有实际跑过才会懂的坑。
1. 项目概述与整体设计思路
1.1 需求拆解:岗位分析这个场景到底要解决什么
岗位分析本质上是三类任务的组合:第一类是信息定位,也就是在海量岗位文档里找到相关片段,比如“Java 高级工程师的晋升标准”;第二类是数值计算,比如“某个岗位的平均薪资”“不同职级的人数分布”;第三类是趋势洞察,比如“最近半年哪个岗位发帖量涨得最快”。这三类任务,单靠 LLM 本身都做不好:信息定位上,模型的知识截止时间和私有文档注定是脱节的;数值计算上,模型天然不擅长精确统计,甚至算人数这种简单活也会给出离谱结果;趋势洞察更是需要实时、全量的数据支撑,模型内部参数里根本没有这些数据。
传统思路是写一套专属问答系统,但需要维护大量规则模板,扩展性极差。我最初也纠结过是不是用 LangChain4j,仔细对比后还是选了 Spring AI。原因很简单:团队技术栈就是 Spring Boot,原本就有大量现成的 Service 和 Repository,Spring AI 能直接在 Spring Boot 工程里以相对自然的方式接入,不强制引入一个全新的生态体系。而且 Spring AI 的ChatClient设计得比较轻,不是一上来就让你组织一大堆 Chain 对象,对中层程序员理解成本更低。
1.2 为什么是 RAG + Tool Calling 的组合,而不是纯 Prompt
关键点在于这两者解决的问题完全不同。RAG 解决的是“模型不知道的知识”,本质是给模型开卷考试,把文档检索出来塞进上下文作为参考。Tool Calling 解决的是“模型做不了的事”,本质是让模型自主决定调用哪个函数、传什么参数,然后系统真正执行这个函数,把结果再交回给模型组织语言。岗位分析场景两者缺一不可:一方面你需要查私有文档了解岗位职责和晋升规则,这是 RAG 的范畴;另一方面你需要算数、查库、拉最新数据,这是 Tool Calling 的范畴。
如果你只做 RAG 不做 Tool Calling,所有数据都必须提前切片进索引,一旦数据更新滞后,回答就会过时。如果你只做 Tool Calling 不做 RAG,模型虽然能调函数,但面对“这个岗位的晋升标准是什么”这种文本类问题,函数是没法返回文档摘要的。我当时画了个很粗糙的流程图:用户提问 → 判断是否需要工具 → 模型产生工具调用请求 → 系统执行查询/计算 → 结果拼接给模型 → 模型结合 RAG 上下文生成最终答案。这套链路在 Spring AI 里实现起来,比想象中顺畅不少。
1.3 技术选型:Spring AI 的版本选择和生态现状
我用的 Spring AI 版本是 1.0.0 GA 之后的迭代版。为什么强调版本?因为 Spring AI 的 API 变化非常快,网上教程互相打架的最大原因就是版本差异。初版还在用AiClient,后面改成了ChatClient,到 1.0 之后又加入了ChatModel等接口。如果你照着旧文抄代码,大概率直接编译不过。
模型方面,我优先选了 OpenAI 兼容接口,因为 Spring AI 对 OpenAI 的支持最完善,同时通过spring.ai.openai.base-url指向了本地跑的模型服务,既保证了开发调试的响应速度,又避免了一些调用成本问题。如果你的生产环境是企业内部模型,也可以基于spring.ai.model的配置切换,或者封装自定义的ChatModel。团队如果不太熟悉这套,建议先拿 OpenAI 兼容接口把流程打通,再迁移到内部模型,用一层ChatClient做隔离,迁移成本不会太高。
2. 核心原理与关键组件拆解
2.1 RAG 全链路解析:从文档加载到向量检索
RAG 不是某个单独的类,而是一连串环节的组合。标准链路是:文档加载 → 文本分割 → Embedding 向量化 → 存储进向量库 → 用户提问时做相似度检索 → 将命中片段拼进 Prompt。Spring AI 提供了一套抽象接口,DocumentReader、TextSplitter、EmbeddingModel、VectorStore,但每个环节都有值得细说的点。
我遇到的第一个问题是文档加载。岗位说明大多是 Word、PDF、甚至扫描件,Spring AI 提供了一些开箱即用的 Reader,比如PagePdfDocumentReader、ParagraphPdfDocumentReader,但实际效果取决于 PDF 本身的结构。有些 PDF 是纯图片扫描件,不经过 OCR 根本抽不出文字。我最后在加载层做了务实的取舍:把内部岗位说明书统一转成 Markdown 格式放进资源目录,因为规范化的文本能显著提升切片质量;对外部抓取的岗位描述则用 HTML 清洗工具先转成纯文本,再进入切片流程。
切片是决定检索质量的关键。你可以设置每个文档块 500 字符、重叠 50 字符,但不同文档语体差异很大。岗位描述这种条目型文本,如果硬按固定长度切,很容易把一个岗位的“职责”和“要求”隔开,导致检索命中一半。我自己后来做了个处理:在切片前先把文档按段落做语义分组,比如用## 职责## 要求## 薪酬这种标题切块,再把长段落细分。Spring AI 的TokenTextSplitter支持自定义分隔符,可以传入正则表达式。这样检索出来的片段语义更完整,模型回答也更稳。
2.2 Embedding 选型:为什么我最终换了模型
Embedding 决定了 RAG 的检索上限。最开始我直接用了一款通用型的在线 Embedding 接口,效果不算差,但检索某个专有名词(比如内部叫法“P7-S”这种职级)时,召回率明显不行。中文岗位文本有很多缩写和行业黑话,通用模型没能很好理解“架构师通道”“管理通道”这类上下文含义。
后来我换成了中文效果更好的 bge-m3 本地向量模型,通过 ONNX 跑在本机,Spring AI 提供了基于OnnxEmbeddingModel的快速接入方案。换了之后,相同查询的向量相似度打分明显更合理,召回率提升也很直观。另外一个重要教训是:Embedding 模型必须稳定,不能上线后随意更换。因为向量库里的所有文档向量是由旧模型生成的,换新模型后新旧向量空间不一致,检索质量会断崖式下降。如果你要换,就必须全量重新向量化一遍。
2.3 向量库选型:Redis vs Elasticsearch vs 关系库
岗位分析这个场景,文档量并不算海量,几千份文档,几十万向量级别,所以向量库选型的灵活空间比较大。我对比过三种方案:Redis 的RediSearch模块、Elasticsearch 的向量检索、关系库加 pgvector。最后选了 Redis。原因在于:第一,项目本身已经依赖 Redis,少一个组件就少一份运维成本;第二,Spring AI 提供了RedisVectorStore,配置起来非常顺滑;第三,岗位分析文档量在几十万量级,Redis 的性能完全够用。
Elasticsearch 的检索功能更强,支持混合检索(BM25 + 向量),如果你的文档量大且需要关键词精确匹配和向量检索融合,它会是更好的方案,但重量级也上来了。pgvector 适合你本身就是 PostgreSQL 用户的情况,能省掉引入独立向量数据库的精力。说到底,RAG 的向量库选型不应该一开始就追求最热门的,而是看已有基础设施的耦合度。
2.4 Tool Calling 机制:模型如何学会“使用工具”
Tool Calling 底层逻辑并不神秘:当你把一组工具函数(包含名称、描述、参数 JSON Schema)随消息一起发给模型时,模型会在生成过程中判断是否需要调用工具,如果判断需要,就会输出一个结构化的函数调用指令(包括函数名和参数 JSON)。之后由应用层真正执行函数,再把执行结果作为新消息传回模型,模型据此生成最终回答。Spring AI 通过@Tool注解和ChatClient的工具注册机制把这一套封装得相当优雅。
这里要理解一个关键设计:工具描述比工具实现更重要。模型决定用哪个工具,完全靠你写的 description。比如我写了一个getJobStatistics(String level, LocalDate startDate),如果描述只写“获取岗位统计”,模型非常可能不认识这个工具适合什么问题;但如果我写“根据职级参数获取指定时间范围内该职级的岗位数量、平均薪酬上下限(用于回答人数统计、薪酬分析类问题)”,模型的选择准确率会有明显提升。这个细节看起来小,实际上对整体效果影响巨大。
Tool Calling 也不是没有烦恼。最典型的是模型可能循环调用同一个工具,尤其在工具返回结果无法满足用户问题时。比如用户问“帮我分析一下北京 Java 岗位的数据”,你的工具只支持按职级查询,模型可能会反复用不同职级参数去猜。这个问题没有完美的模型层解法,需要在设计层面把工具做全,比如提供“按城市查询”的工具,或者在系统 Prompt 里写明查询步骤。Spring AI 允许你在调用链里设置maximumAttempts之类的参数,但根本上还是得设计合理的工具集。
3. 从零搭建实操:工程配置与核心代码
3.1 Spring Boot 工程初始化与依赖引入
我用的是 Spring Boot 3.3.x 配合 Spring AI 1.0.0 系列。工程初始化没什么特别,关键在于引入正确的依赖。最低限度你需要:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-vector-store-redis</artifactId> <version>1.0.0</version> </dependency>如果你要用本地的 Embedding 模型,还需要引入 ONNX 相关的 runtime 依赖。这里提醒一句,Spring AI 的 starter 命名一直在微调,早期叫spring-ai-openai-spring-boot-starter,现在统一成了spring-ai-starter-model-openai。你搜到的大部分博客可能还停留在旧包名,直接抄会报找不到依赖。
3.2 application.yml 配置:连接模型服务和向量库
配置主要分三块:模型服务地址、密钥、向量库连接。
spring: ai: openai: base-url: http://localhost:8081/v1 api-key: sk-local chat: options: model: qwen2.5:14b temperature: 0.2 embedding: options: model: bge-m3 vectorstore: redis: index: job-index prefix: job_vec如果你的模型服务挂在本地,api-key 填什么其实无所谓,但字段不能空,Spring AI 初始化时会校验。temperature 我在分析场景设定得比较低,因为岗位分析的答案需要稳定可复现,太高会让模型每次回答组织语言的差异过大。具体的模型名取决于你本地跑的是什么,如果是 Ollama 起的服务,OpenAI 兼容路径一般是/v1没错,Spring AI 会把它当成 OpenAI 协议来处理。
3.3 文档入库流程:加载、切片、向量化、写入向量库
入库流程我封装成了一个DocumentIngestionService,核心逻辑是:读取资源目录下的 Markdown 文件,按段落语义切分,调用 EmbeddingModel 向量化,写入 RedisVectorStore。
@Service public class DocumentIngestionService { private final EmbeddingModel embeddingModel; private final VectorStore vectorStore; public void ingest(String pathPattern) { var reader = new FileSystemDocumentReader(pathPattern); var documents = reader.get(); var splitter = TokenTextSplitter.builder() .withChunkSize(500) .withChunkOverlap(80) .withSeparator("(?m)^## ") .build(); var chunks = splitter.apply(documents); vectorStore.add(chunks); } }这个方案里我用TokenTextSplitter的withSeparator来按标题切分文档块,保证同一岗位的职责描述不会被截断。实现时注意一点:VectorStore.add()接受的是List<Document>,但Document需要设置 metadata。我在 metadata 里存了source、category(内部文档或外部抓取)、updateTime,这样后续检索时可以按 metadata 过滤,比如只检索“最近三个月的外部岗位数据”。这个能力在 Spring AI 的SearchRequestBuilder里通过filterExpression实现,后面会讲。
3.4 ChatClient 装配与 Prompt 模板设计
Spring AI 1.0 之后,最常用的就是ChatClient的流式接口。我定义了一个AnalysisAssistant,把 RAG 检索和 Tool Calling 统一走这一个门面。
@Service public class JobAnalysisAssistant { private final ChatClient chatClient; private final VectorStore vectorStore; public JobAnalysisAssistant(ChatClient.Builder builder, VectorStore vectorStore) { this.chatClient = builder .defaultSystem(""" 你是资深的人力资源数据分析师。请基于检索到的岗位文档片段, 结合可用的岗位统计工具,回答用户关于岗位分析的问题。 回答时先说明你的判断依据,再给出结论。如果问题涉及具体数字, 必须优先调用工具获取真实数据,不得凭空猜测。 """) .build(); this.vectorStore = vectorStore; } public String analyze(String question) { var context = loadRelevantContext(question); return chatClient.prompt() .user(u -> u.text(""" 用户问题:{question} 相关岗位文档片段: {context} """) .param("question", question) .param("context", context)) .call() .content(); } private String loadRelevantContext(String question) { var searchRequest = SearchRequest.builder() .query(question) .topK(4) .similarityThreshold(0.5) .build(); var results = vectorStore.similaritySearch(searchRequest); return results.stream() .map(Document::getText) .collect(Collectors.joining("\n\n---\n\n")); } }这里面有两个容易踩坑的地方。第一,defaultSystem的提示词非常关键,最好明确要求模型“涉及精确数字时必须调用工具”,不然模型很容易拿着检索片段里的历史数据自说自话。第二,similarityThreshold不能设太高,我后来调到了 0.4 左右,因为业务文档里有很多“软表述”,比如“一般要求 3-5 年经验”,这类语句向量距离天然比硬数据远,阈值设太高会漏召回。
3.5 Tool 定义与注册:让模型真正能查数
工具类本质就是在 Spring 容器里的 Bean,方法加上@Tool注解,ChatClient会自动扫描到。这一步在 Spring AI 里做得确实省心,省掉了以前手动封装 Function Calling 协议的工作量。
@Component public class JobStatsTools { private final JobPostingRepository repository; @Tool(description = "根据职级参数,获取指定时间段内的岗位数量、平均月薪上下限、学历要求分布。用于回答岗位人数、薪酬分析、需求变化类问题。") public String getJobStats(String level, String startDate, String endDate) { var stats = repository.getStatsByLevelAndPeriod(level, startDate, endDate); return stats.toString(); } @Tool(description = "获取所有职级列表及其对应岗位数量,用于回答‘有哪些职级’‘岗位分布’类问题。") public String listLevels() { return repository.listLevels().toString(); } }使用的时候,在ChatClient装配时指定.defaultTools(JobStatsTools.class)或者在请求时用.tools(JobStatsTools.class)。这里我出现过一个问题:@Tool方法入参不能太复杂,我之前写了个对象类型参数,模型根本不会正确构造这个 JSON,直接报参数解析错误。后来把所有工具方法的参数都改成了基本类型和字符串,配合 description 里的参数格式说明,运行就稳定了。
比如getJobStats的 description 里我补了一句:“参数格式:level 为职级字符串,如 'P6';startDate 和 endDate 为 YYYY-MM-DD 格式”。模型就会按这个格式生成参数,命中率极高。
4. Tool Calling 背后的工程实践与效果调优
4.1 工具集设计:粗粒度还是细粒度
工具函数的粒度取舍是个很现实的问题。粗粒度工具,比如只提供一个queryAll(String question)然后后端解析 SQL,优点是简单、模型不容易出错,但缺点也很明显:模型不知道后端到底做了什么,无法对异常情况作出调整。细粒度工具,比如把按职级查、按城市查、按时间范围查全部分开,模型调用更灵活,但容易选错工具,尤其是功能重叠的时候。
我最终的平衡方案是:模型判断需要先城市后职级,还是先职级后城市,然后用一个复合工具函数,内部做多个仓库查询。这样模型只需要做出粗决策,具体细节由代码控制。本质上,就是用代码的确定性去弥补模型的不确定性。这条经验,做 Agent 类应用的朋友可以重点参考:不要在每一步都信任模型,把能下沉到代码层的逻辑尽量下沉。
4.2 上下文组织与工具返回结果的处理
Tool Calling 返回的结果,不是直接给用户看的,而是作为“中间观测”回传给模型。这意味着工具函数返回的字符串应该简洁、结构化,方便模型理解。我在工具返回时用了类似{ "level": "P6", "count": 42, "avgMinSalary": 25000, "avgMaxSalary": 38000 }的 JSON 格式,但为了确保健壮性,加了 JSON 序列化的 try-catch,防止恶意数据让模型产生幻觉。
另一个容易被忽略的点是:每次调用工具前,当前对话里已经有了检索片段,这些片段和工具结果可能冲突。比如检索片段里有一份过期的岗位薪酬表,而工具返回的是最新的统计结果,模型可能被过期数据误导。我的处理是在系统 Prompt 里强调“当文档片段和工具返回结果不一致时,以工具返回结果为准”,这样就明确了信息优先级。
4.3 效果评估:怎么知道你的 RAG 和 Tool Calling 是合格的
搭建完之后,最大的问题其实是:怎么量化这套系统的效果。我用了三个维度:检索命中率(Hit Rate)、回答正确率(Accuracy)、工具调用正确率(Tool Call Accuracy)。
检索命中率的测法:准备一批问题,人工标注出应该检索到哪些文档片段,然后跑检索看 Top-K 里有没有命中。最初我的命中率只有六成多,通过切块策略优化和 Embedding 模型替换,最终到了八成左右。回答正确率则是对比模型输出和人工答案,方法比较笨但有效:把 50 个测试问题固定下来,每次改完配置都跑一遍回归,记录哪些问题答错了,基本能直观感受到改动的影响。工具调用正确率主要观察模型是否选择了正确的函数、参数格式是否正确,这个可以通过日志里记录的ToolCall请求分析。
我建议你也维护这样一个类似的评测集,不要每次看个例。没有评测集,你根本分不清是 prompt 问题、Embedding 问题还是工具设计问题。
5. 踩坑实录:那些文档不会告诉你的事
5.1 版本地狱:Spring AI 的 API 变化是真的快
我在切换版本期间花费了不少时间。网上教程最常见的坑有两个:一是依赖包名对不上,二是代码 API 和版本不匹配。比如 Spring AI 0.8.x 用的OpenAiChatClient,到 1.0 变成了ChatClient,而且构造方式从new OpenAiChatClient(...)变成了ChatClient.Builder配合自动配置注入。如果你照着旧文做,会困惑半天为什么自己的代码里没有那个类。
我的建议是:固定一个你确认能跑通的版本,从我上面贴出的配置开始,其余功能先不要混入过多的扩展点。等跑通基础 RAG 和 Tool Calling,再去升级版本或引入更高级特性,这样每次改动的影响范围都是可控的。
5.2 模型能力上限:本地 7B 模型的工具调用不稳定
我最初为了省钱和安全,用本地跑的 7B 模型做 Tool Calling。结果比较惨:模型经常不产生工具调用,而是直接把问题复述了一遍;或者生成了工具调用请求,但参数格式不对。后来把模型换成了 14B 的 qwen,情况好了不少,但仍然达不到 GPT-4 级别那种丝滑效果。
如果你的业务对工具调用的可靠性要求很高,最好实测一下你打算用的模型对 Function Calling 的支持程度。不是所有模型都支持工具调用协议,有些本地模型虽然声称兼容 OpenAI,但工具调用分支训练得不充分,一调就崩。这里没有捷径,只能做回归测试。我在项目里面加了“工具调用失败自动降级”的逻辑:如果调用工具失败,就提示用户“当前数据暂时无法获取,请稍后重试”,而不是让模型硬编一个数字出来骗人。
5.3 上下文超限:检索片段和工具结果叠加后塞不下
岗位分析场景下,检索 Top-4 的文档片段再加上系统提示词、工具返回结果、历史对话,很容易把有限的上下文窗口撑爆。本地模型尤其敏感,7B 模型普遍只有 8K 左右的有效上下文。我的对策是:把系统提示词写精简,删掉所有不必要的解释;检索片段从 4 段减到 3 段,每段限制 500 字内;工具返回结果做摘要,必要时后端先聚合好数据再返回。实测下来单轮问答的 token 消耗明显下降,回答质量反而稳了。
上下文超限的表现不一定是直接报错,很多时候是模型开始“忘记”工具返回的结果、回答非常短促或者开始重复。这种隐性问题排查起来比报错更费时,遇见的话先检查 token 用量。
5.4 向量检索噪声:为什么查出来一堆不相关片段
有段时间我觉得 RAG 效果差,不是模型的问题,而是检索出来的片段根本不相关。排查后发现原因有二:一是切块把不同的岗位职责混在了一块,二是问题的表述方式与文档片段用词差异太大。比如用户问“薪资多少”,文档里写的是“月薪区间”,本身字面重叠少,语义关联又不够。
针对第一个问题,我调整了切块策略,按标题分块并确保每个块的主题单一。针对第二个问题,我加了一个查询改写步骤:用户提问后,先用一次轻量模型调用把问题改写成更适合检索的表述,比如“薪资多少”改写成“薪酬区间 薪资标准”,然后再做向量检索。这一步看起来多了一次模型调用,但检索命中率提升非常明显。
5.5 超时与重试:模型服务偶发不可用,怎么保证业务稳定
生产环境最怕的就是下游模型服务不稳定。本地模型服务偶尔会卡死,尤其在并发上来时,导致整个接口长时间挂起。我在调用链路里加了 Spring 的@Retryable注解,配置了最大重试 2 次、间隔 500ms,同时用@CircuitBreaker对连续失败的情况做熔断,避免下游服务把应用拖垮。
需要注意的是,重试要避免幂等性问题。如果用户提交的是“帮我统计下岗位数据”这种请求,工具内部执行了查询,模型因为超时没收到结果,重试时会不会重复执行工具?我在设计上把工具函数做成了纯查询,不产生副作用,所以重复执行也没有问题。如果你的工具涉及写操作,比如发邮件、建工单,一定要谨慎设置重试策略。
6. 常见问题速查与运维建议
6.1 高频问题排查表
这里整理一套我实际遇到的问题对照表,方便你快速定位自己的困境:
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 模型不调用工具 | 模型不支持 Function Calling,或工具描述不清晰 | 换支持工具调用的模型;重写@Tool的 description,把适用场景写具体 |
| 工具参数解析失败 | @Tool方法入参类型复杂,或未说明参数格式 | 全部改成基本类型字符串;在 description 中写明参数格式示例 |
| 检索召回效果差 | 切块策略不适合文档结构,或 Embedding 模型弱 | 按语义标题切块;替换为领域更匹配的中文 Embedding 模型 |
| 回答与工具结果矛盾 | 系统 Prompt 未明确数据优先级 | 在系统提示词里写明“工具结果优先于文档片段” |
| 上下文超限导致回答异常 | 检索片段 + 工具结果 + 历史对话太占空间 | 精简系统提示词;降低 Top-K;工具函数内聚合数据后返回摘要 |
| 本地模型调用不稳定 | 模型服务并发能力不足 | 限流、超时设置、重试、熔断;考虑换更大的模型 |
| 向量库数据更新不生效 | 相同文本的哈希未变化,旧数据未清理 | 修改 Document id 策略,或入库前先删除旧索引 |
6.2 数据更新策略与日常维护
岗位数据是动态的,RAG 知识库不能建完就扔。我做了每天凌晨的定时全量入库任务:先清空当天的索引前缀,再重新读取最新的岗位文档、外部抓取数据,向量化后写入。这样避免增量更新带来的数据残留问题,代价是每天几个小时的向量化任务,对几十万级别数据量完全可接受。如果你需要准实时更新,可以考虑按 metadata 的 updateTime 做增量更新,删除旧 id 再重新插入。
运维层面还需要注意向量库内存使用。Redis 存向量本质上是内存成本,文档多起来之后,要留意内存占用是否膨胀,必要时对过期索引设置 TTL。我吃过亏的地方是:有段时间每天插入新数据但没删旧数据,索引里堆了大量重复向量,检索结果越来越偏向旧版本岗位说明,排查浪费了不少时间。
6.3 评测集维护:持续保证系统不退化
最后一条建议,也是最想强调的:从一开始就维护一套评测集。哪怕初始只有 20 个问题,分类覆盖岗位查询、薪酬统计、趋势分析、跨部门比较等典型场景。每次改动代码、Prompt、模型版本后,都跑一遍这套评测集,记录回答和工具调用情况。没有评测集,你就会陷入一种“改了好像更好,又好像哪里不对”的模糊状态。
我在实际维护中发现,很多问题不是一次性出现的,而是系统迭代中逐渐退化的。比如某次为了缩小上下文把 Top-K 从 4 改成 3,薪酬问题的回答变差了,但完全没意识到和这个改动相关。用评测集做回归,这类问题十几分钟就能暴露。这个成本的投入,无论项目大小都值得。
关于这个项目,后面我打算扩展的方向,一是把文档检索和工具调用集成到流式输出,让用户在等待时有部分反馈;二是把工具调用日志结构化存储,分析哪些问题类型模型经常选错工具,反哺工具设计。如果你也在做类似的 Spring AI 项目,欢迎用文中的方案做底子,把评测集跑起来,再往你的业务场景里填充细节。