首页>后端>正文

Spring AI RAG 实战:顾问链与检索增强落地

Feng 37 阅读 后端

RAG 要解决的问题很具体:模型没见过的私有数据、容易编造的事实、以及长文档塞不进上下文。Spring AI 的思路不是把整套流程写死,而是拆成可组装的零件——你可以自己拼检索链路,也可以直接挂一个现成的 Advisor 把检索塞进对话流程。

依赖先摆对,再谈 Advisor

用 QuestionAnswerAdvisor 或 RetrievalAugmentationAdvisor 之前,向量存储相关的顾问模块必须进 classpath:

<dependency>
 <groupId>org.springframework.ai</groupId>
 <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>

这里有个版本坑值得提前说。Spring AI 早期里程碑版本里,QuestionAnswerAdvisor 的包路径和依赖坐标随版本有过调整,1.0.0-M6 之后官方主推 RetrievalAugmentationAdvisor 作为更通用的入口,QuestionAnswerAdvisor 仍保留但定位偏向简单场景。升级时如果编译报类找不到,先确认 BOM 版本与 spring-ai-advisors-vector-store 是否对齐,别急着改代码。混用不同里程碑版本的 spring-ai-core 和 advisors 模块,是这类报错最常见的根因。

QuestionAnswerAdvisor 干了什么

它的职责很窄:拿到用户问题,去 VectorStore 做一次相似性检索,把命中的文档拼进提示词,再交给模型。模型本身不知道的数据,就这样被临时“喂”进去。

前提是你的 VectorStore 里已经有数据。加载、切分、嵌入这些前置步骤不在 Advisor 范围内,得自己完成。数据就绪后,挂上顾问即可发起一次 RAG 调用:

ChatResponse response = ChatClient.builder(chatModel)
 .build().prompt()
 .advisors(new QuestionAnswerAdvisor(vectorStore))
 .user(userText)
 .call()
 .chatResponse();

默认行为是对库中全部文档做相似性搜索。文档一多,噪声就上来了,所以几乎总要加约束。

检索参数:阈值和 topK 要一起调

SearchRequest 支持可移植的过滤表达式,写法接近 SQL,在各家 VectorStore 实现上语义一致。它可以在构建 Advisor 时固定下来,也可以按请求动态传入。

下面这个实例把相似度阈值设为 0.8,只取前 6 条:

var qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
 .searchRequest(SearchRequest.builder()
 .similarityThreshold(0.8d)
 .topK(6)
 .build())
 .build();

这两个参数单独看都合理,凑一起就可能出事。阈值偏高叠加 topK 偏小,检索结果可能一条都不剩,模型拿到的是空上下文,回答要么拒答要么自由发挥。反过来阈值过低,一堆弱相关文档挤进提示词,既烧 token 又干扰判断。调参时建议先把阈值降到 0.5 左右观察召回,再逐步收紧,同时用日志打印实际命中的文档数和分数分布,比拍脑袋定值靠谱。

另一个高频故障是维度不匹配:嵌入模型输出的向量维度与 VectorStore 索引建立时声明的维度不一致,写入或查询阶段直接抛异常。排查时核对嵌入模型配置与集合 schema 的 dimension 字段,两边必须严格相等。这类错误信息通常比较直白,但容易被误判成网络问题。

运行时换过滤条件

固定过滤适合“只查某类文档”的稳定场景。需要按用户、租户或文档类型动态切换时,用顾问上下文参数覆盖:

ChatClient chatClient = ChatClient.builder(chatModel)
 .defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore)
 .searchRequest(SearchRequest.builder().build())
 .build())
 .build();

String content = this.chatClient.prompt()
 .user("Please answer my question XYZ")
 .advisors(a -> a.param(QuestionAnswerAdvisor.FILTER_EXPRESSION, "type == 'Spring'"))
 .call()
 .content();

FILTER_EXPRESSION 的值会替换掉 SearchRequest 上的过滤表达式,每次请求独立生效。多租户系统里这个参数基本是标配,记得在服务端拼表达式时做白名单校验,别把用户输入直接拼进去。

提示词模板:两个层级别搞混

QuestionAnswerAdvisor 用一套默认模板把检索到的文档和用户问题合并。想改合并方式,通过 builder 的 promptTemplate 方法传入自己的 PromptTemplate。

这里要区分清楚:顾问级模板决定“检索上下文怎么和问题拼”,而 ChatClient 上配置的 TemplateRenderer 影响的是顾问运行之前、初始用户与系统提示的渲染。两者作用阶段不同,改错地方会发现怎么调都没效果。自定义模板可以选用任意 TemplateRenderer 实现,默认走基于 StringTemplate 的渲染器。

什么时候别用 Advisor

Advisor 适合单轮、检索即用的问答。多步推理、需要先改写查询再检索、或者要融合多路召回结果的场景,硬套单个 Advisor 会很别扭,这时候自己组装检索链路更直接。另外,Advisor 每次请求都会触发一次向量检索,对延迟敏感的高并发接口要评估检索耗时占比,必要时加缓存或异步预取。

验证改动是否生效,别只看模型回答像不像。打印 Advisor 实际注入的上下文片段、记录检索耗时和命中条数,再对比关闭 Advisor 时的回答差异,这套对照比肉眼判断可靠得多。

ID:6654

Feng
这位作者很神秘,还没有填写简介。