先跑起来:一个能用的最小 RAG 问答
约 2185 字大约 7 分钟
2026-09-20
数据侧忙活了四篇,今天终于到「见货」的时刻:用户输入一个问题,系统检索博客,给出回答。但先说清楚这篇的定位——我们今天不追求准,只追求通。用最朴素、最不加优化的方式把链路跑起来,作为后续所有优化的「基线」。没有基线,后面每加一层优化你都说不清「到底有没有用、用了多少」。
本篇对应的 relax-admin 页面是「会话洞察 → 检索评估」——它是整条检索链路的「透视窗」,我们今天建的基线档案,将来的正式住所就是这一页。
今天的目标长什么样
一个 HTTP 接口:
POST /api/chat
{ "question": "怎么防止用户重复下单?" }
返回:
{ "answer": "根据博客文章,可以通过……", "sources": ["别让重复请求搞垮你的下单接口"] }就这。没有多轮、没有流式、没有重排。链路是:问题 → 向量化 → PGVector 查 top5 → 拼 Prompt → 调大模型 → 返回。全程用 Java AI 的现成组件,今天不造轮子——造轮子是后面几篇的事,而且到时候你会庆幸先见过「官方轮子」长什么样。
第一块:ChatClient,跟模型说话的标准姿势
Java AI 里跟大模型对话的入口是 ChatClient,链式调用,一上手就回不去的那种:
String answer = chatClient.prompt()
.system("你是本博客的知识库助手,只根据提供的资料回答问题。")
.user(question)
.call()
.content();system 是给模型定规矩的(角色、纪律),user 是正题。这个分工不是语法糖,是有讲究的:System 消息在模型眼里是「全局规则」,约束力比塞在用户消息里的「请注意……」强。第 12 篇讲防幻觉时会看到,那些「只准根据资料回答」的纪律全部放 System,弹药(检索到的资料)才放 User——规矩和子弹分开放。
第二块:QuestionAnswerAdvisor,官方现成的 RAG
RAG 的「检索 → 拼 Prompt → 生成」这三步,Java AI 有个现成封装叫 QuestionAnswerAdvisor。它是个 Advisor——这个概念值得多说两句,因为后面整个在线侧的架构都挂在它上面。
Advisor 是什么
Advisor 可以理解成 ChatClient 的拦截器。请求发往模型之前、响应回来之后,各有一个可以插手的钩子。RAG 恰好需要的就是「出发前先去检索,把检索结果塞进 Prompt」这个动作,所以官方的 RAG 实现就是一个 Advisor。
用起来这样:
String answer = chatClient.prompt()
.advisors(new QuestionAnswerAdvisor(vectorStore,
SearchRequest.builder()
.topK(5)
.similarityThreshold(0.5)
.build()))
.user(question)
.call()
.content();这一段跑起来,最小 RAG 就活了。用户问「怎么防止重复下单」,Advisor 拿问题去向量库捞 5 段,拼进 Prompt,模型照着答。
但我强烈建议你干一件事:把它的源码翻出来读一遍。 不长,核心逻辑就是「调 vectorStore.similaritySearch → 把结果格式化拼进 user message」。读它不为膜拜,为两个实用的目的:
- 知道它把资料拼成了什么样(这决定了后面换自定义 Advisor 时要保持什么格式)
- 建立信心——RAG 没有魔法,剥开壳就是检索加拼字符串,后面我们自己串「改写 → 混合检索 → 重排」的完整链路时,就是在更大的尺度上重写同样朴素的事
第三块:similarityThreshold,第一次见面
上面配置里那个 similarityThreshold(0.5) 值得单独说,因为它是新手最容易纠结的参数。
它的作用是给检索结果设一个及格线:相似度低于 0.5 的段落直接不要。纠结点在于——这个数该填多少?
我的答案是:基线阶段随便填,别在这上面较劲。原因有两层。第一,相似度分数的绝对值没有跨模型、跨语料的稳定含义,换个模型这个数的「体感」就变了,网上没有标准答案。第二,更成熟的思路是后面第 10 篇的方向:阈值放宽松(宁多勿漏),用 Rerank 的分数做真正的守门员。现在阶段,0.5 就够,遇到「答案里混进了不相干文章的段落」先忍着,记下来,这就是后面优化的靶子。
顺手演示一下直接用 VectorStore 检索的裸姿势(跳过 Advisor,自己拼 Prompt),两种写法你都得会,因为调试时往往需要绕开 Advisor 直接看检索结果:
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder().query(question).topK(5).build());
// 调试利器:把 docs 的 content、metadata、score 打出来肉眼看一定要做的:建立「问题-检索结果」观察习惯
链路通了之后,别急着高兴,做这件事,它的价值会在后面几篇持续兑现:
拿 05 篇攒的那 10 个评测问题,逐个跑裸检索(就是上面那段不带模型的代码),把每个问题的 top5 结果存下来。 存哪都行,一个表格就够:
问题:怎么防止用户重复下单
top1: 下单接口 > 方案二:分布式锁 (0.83) ← 该中的,中了
top2: 下单接口 > 方案一:数据库唯一索引 (0.79)
top3: Redis 持久化的取舍 (0.71) ← 噪声,但排得靠后,能接受
top4: ……这就是你的基线档案。往后每上一个新招——混合检索、Rerank、查询改写——都把同一批问题的检索结果重新跑一遍,跟这份档案对比。哪招治好了哪些问题、有没有引入新毛病,一目了然。第 14 篇讲评测时,这套东西会升级成正经指标,但现在这份土表格已经能救命:没有它,你永远在「感觉好像准了一点」里打转。
这份土档案的正式住所,就是「检索评估」页:输入一个问题,页面直接展示这条查询的检索明细——每个候选切片的内容、来源、通道、相似度分数,一屏看全。第 05 篇的「切片测试」是对切片层单测,「检索评估」是对整条检索链路单测,两页配合着用,坏例子从切片到排序逐层尸检。
📷 图片位(待补充)
应放什么图:「会话洞察 → 检索评估」页截图,输入「怎么防止用户重复下单」,展示返回的 top5 切片列表(标题路径 + 相似度分数),其中一条明显不相关的噪声结果(比如 Redis 持久化那篇)留着别删——这正是「基线要保留毛病」的最好例证。
跑基线的时候你大概率会撞见两类典型问题,先给你打好预防针:
一是专有名词搜不到。 问「ERROR-4021 是什么」这种带错误码的,向量检索一脸懵——错误码在语义空间里没有邻居,第 06 篇埋过的伏笔,第 09 篇混合检索来收。
二是牛头不对马嘴但相似度挺高。 问「不支持缓存的场景」,召回「缓存支持的配置项」——差一个「不」字,向量看不出来。也是第 09、10 篇的菜。
现在,请忍住不动手修它们。把毛病留着,这是基线的意义。
今天的落地清单
顺便聊聊「最小可用」的哲学
写到这里想说句方法论。这个系列每一步都是「先跑通再加码」,今天的版本检索很粗糙、没有重排、单轮问答,但它完整——从问题到带出处的回答,每一环都在。这种完整但粗糙的东西,价值远大于「局部精致」:它让你从第一天起就有东西可以演示、可以对照、可以回归。
反过来,一上来就想把混合检索、Rerank、改写全堆齐再联调,等着你的是「不知道哪层引入了哪层的问题」的沼泽。软件工程里这叫走查(walking skeleton),我管它叫先让水流通,再拧阀门。
好了,基线立起来了。下一篇开始给检索动手术:第一个刀口就开在「向量检索的盲区」上——BM25 关键词通道怎么补位,RRF 怎么把两路结果揉到一块。那是整个在线侧我最喜欢的一段,下篇见。