- Java搭RAG实战(三):检索问答全链路,从架构分层到SSE流式
上篇把入库跑通了,154条文档躺进PgVector。这篇攻检索侧------从向量库捞知识,拼成上下文喂给大模型,基于事实回答。
入库只是准备数据,检索问答才是用户能感知到的产品。
一、先想清楚:RAG问答不是"搜一下丢给LLM"
最简单的写法:一个方法里检索+拼Context+调LLM全干完,三四十行搞定。
Demo没问题,但后面要加Rerank重排序、换MMR检索策略、调Context拼装逻辑,全得改核心方法。
跟Controller里直接调JDBC一个道理------能跑,但加事务加缓存就得重构。
我按Java后端分层思路拆了三层:
RagService · 编排层,检索→拼Context→调LLM→返回,流程对它透明
RagPrompts · Prompt模板管理,改Prompt不动Java代码
RagRetrievalProperties · 检索参数外部化,topK/threshold随时可调
加Rerank在retrieve和buildContext之间插一层;换检索策略改retrieve内部实现。流程编排不变,改动隔离。
层间传递用了两个私有record:
java
csharp
private record RetrieveOutcome(
List<Document> documents) {}
private record RetrievalResult(
List<Document> documents,
List<SourceItem> sources,
String context,
double confidence) {}
RetrieveOutcome是检索原始结果,RetrievalResult是拼好Context的完整结果。上层不关心Context怎么拼,下层不关心confidence怎么算。跟DTO/VO分层一个思路。
二、RagService:编排层核心代码
所有对外接口都调retrieveForQuestion:
java
scss
private RetrievalResult retrieveForQuestion(
String question, Integer topKOverride) {
// 1. 检索
RetrieveOutcome outcome =
retrieve(question, topKOverride);
if (outcome.documents().isEmpty()) {
return RetrievalResult.empty();
}
// 2. 格式化来源
List<SourceItem> sources =
outcome.documents().stream()
.map(SourceItem::from).toList();
// 3. 拼Context
String context = buildContext(sources);
// 4. 算置信度
double confidence =
computeConfidence(outcome.documents());
return new RetrievalResult(
outcome.documents(), sources,
context, confidence);
}
四个步骤清晰分离,每步可独立替换。加Rerank在步骤1和2之间插一层就行。
三、检索核心:retrieve方法
java
ini
private RetrieveOutcome retrieve(
String query, Integer topKOverride) {
int topK = topKOverride != null
&& topKOverride > 0
? topKOverride : retrieval.topK();
SearchRequest request = SearchRequest.builder()
.query(query.trim())
.topK(topK)
.similarityThreshold(
retrieval.similarityThreshold())
.build();
List<Document> docs =
vectorStore.similaritySearch(request);
return new RetrieveOutcome(
docs == null
? Collections.emptyList() : docs);
}
topK=5 · 不是越多越好。试过topK=10,Context塞十段内容,LLM注意力反而分散,回答准确率下降
similarityThreshold=0.0 · 初始阶段接受所有结果,先观察LLM怎么用Context,后面根据噪音比例调高
这两个参数不能凭感觉配,后面接RAGAS评估框架,用faithfulness和relevancy指标数据说话。
还支持topKOverride------调用方临时覆盖topK,不用改yml。高优先级问题想多召回几条,传topK=10就行。
四、Context拼装:带来源的格式化
java
typescript
private String buildContext(
List<SourceItem> sources) {
return sources.stream()
.map(s -> "- " + s.title()
+ ": " + s.content())
.collect(Collectors.joining("\n"));
}
关键是带title而不是直接拼文本------LLM能知道每条来源的主题,回答时精准引用"根据XX文档"。不加title只能模糊说"根据上下文",可追溯性差很多。
confidence计算:
java
scss
private double computeConfidence(
List<Document> docs) {
if (docs.isEmpty()) return 0.0;
return docs.stream()
.mapToDouble(d -> (double) d.getMetadata()
.getOrDefault("distance", 1.0))
.average().orElse(0.0);
}
取检索结果相似度的平均值。算法比较粗,后面加Rerank后改成用Rerank score算。先有个数,至少能区分"高置信回答"和"低置信猜测"。
五、调LLM:走LlmGateway而非直调ChatModel
直调ChatModel.chat()最简单,但生产环境不行------deepseek-v3挂了整条链路就挂了。
跟Java后端调外部服务一个逻辑:生产要连池、要超时、要重试、要降级。
java
ini
public QueryResponse query(QueryRequest request) {
RetrievalResult result = retrieveForQuestion(
request.question(), request.topK());
String systemPrompt =
RagPrompts.loadSystemPrompt();
ChatResponse llm = gateway.chat(new ChatRequest(
"rag-chat",
systemPrompt,
RagPrompts.buildUserPrompt(
result.context(), request.question()),
0.3,
null, null));
return QueryResponse.of(result, llm);
}
yml配了主备模型路由:
yaml
yaml
dream-saas:
llm:
gateway:
routes:
rag-chat:
primary-model: deepseek-v3
fallback-model: qwen-max
deepseek-v3调不通自动切qwen-max,上层代码完全无感。
temperature=0.3 · RAG问答需要严谨不能太发散,0.7适合创意写作,0.3保证基于Context减少幻觉
六、SSE流式:200ms出第一个字
同步接口等5-8秒才出完整答案,用户早关了。SSE流式200ms内出第一个字,体感完全不同。
java
scss
public Flux<ServerSentEvent<String>> streamQuery(
QueryRequest request) {
RetrievalResult result = retrieveForQuestion(
request.question(), request.topK());
return chatClient.prompt()
.system(systemPrompt)
.user(RagPrompts.buildUserPrompt(
result.context(), request.question()))
.stream().content()
.map(chunk -> ServerSentEvent
.<String>builder(chunk).build())
.onErrorResume(ex ->
Flux.just(errorEvent("生成失败")));
}
三个Java侧的坑:
produces = "text/event-stream" · 不写Spring把Flux当JSON序列化,前端收到JSON数组而不是流式事件
onErrorResume必须加 · LLM抖动时流会断,不兜底前端连接挂住
流式走ChatClient直调而不是LlmGateway · Gateway目前是同步的,流式场景需要直接拿ChatClient.stream()。后面优化Gateway支持流式路由后统一降级
前端用EventSource消费:
javascript
javascript
const es = new EventSource(
'/api/rag/stream', {
method: 'POST',
body: JSON.stringify({question})
});
es.onmessage = (e) => appendText(e.data);
七、Prompt模板化:改Prompt不用重新部署
之前Prompt写死在RagService里,改一句话要编译打包部署。
java
arduino
public final class RagPrompts {
private static final String DEFAULT =
"你是企业知识库问答助手...";
public static String load(Resource resource) {
if (resource == null
|| !resource.exists()) {
return DEFAULT; // 降级默认值
}
return new String(resource
.getInputStream()
.readAllBytes(), UTF_8).trim();
}
public static String buildUserPrompt(
String context, String question) {
return "上下文:\n" + context
+ "\n\n问题:" + question;
}
}
RagService构造时从classpath加载模板,文件不存在降级到硬编码默认值------不会因为缺模板文件启动失败。
改模板重启就行,不用改代码。Prompt迭代频率远高于代码迭代频率,不改模板化就是给自己找麻烦。
八、Splitter参数外部化
入库那篇切分参数写死在代码里,这次抽成配置类:
yaml
yaml
dream-saas:
rag:
chunk:
chunk-size: 800
min-chunk-chars: 200
min-embed-length: 10
max-num-chunks: 10000
java
less
@ConfigurationProperties(
prefix = "dream-saas.rag.chunk")
public record RagChunkProperties(
@DefaultValue("800") int chunkSize,
@DefaultValue("200") int minChunkChars,
@DefaultValue("10") int minEmbedLength,
@DefaultValue("10000") int maxNumChunks
) {}
用@DefaultValue而不是无参构造器------这里有个坑:record写无参构造器给默认值,Spring Boot绑定属性时无参构造器优先级高于yml,会把配置覆盖掉。@DefaultValue只在yml没配时才生效,不会冲突。
chunkSize是检索效果最敏感的参数 · 800 token的块可能混合两个主题,500 token的块可能缺上下文。不同文档类型最优值不同,参数外部化才能快速对比实验。
九、部署上线
编译打包
bash
bash
export JAVA_HOME=/usr/lib/jvm/java-21-dragonwell
cd /opt/Dream-SaaS && mvn clean install -N -q
cd dream-ai && mvn clean install -N -q
mvn clean package -pl rag-service \
-am -DskipTests -q
systemd服务 /etc/systemd/system/rag-service.service
plaintext
ini
[Unit]
Description=RAG Service
After=network.target docker.service
[Service]
EnvironmentFile=/etc/rag-service.env
ExecStart=/usr/lib/jvm/java-21-dragonwell/bin/java \
$JAVA_OPTS -jar /opt/rag-service/app.jar \
--server.port=8096
Restart=always
RestartSec=10
一键运维脚本
/opt/scripts/start-all.sh · Docker + rag-service + agent + Nginx
/opt/scripts/stop-all.sh · 一键全停
/opt/scripts/health-check.sh · 检查PgVector/Redis/MySQL/两个Java服务/Nginx六个组件
阿里云2核8G跑三个Java服务加Docker全家桶,RAG检索平均300ms,内存够用。
十、踩坑总结
record + @ConfigurationProperties · 别写无参构造器,用@DefaultValue
similarityThreshold · 默认0.0接受所有,谨慎调高
SSE接口 · 必须指定produces = "text/event-stream"
System Prompt · 抽成模板文件,改Prompt不用重编译
Splitter参数 · 外部化,chunkSize是检索效果最敏感的参数
LLM调用 · 走Gateway主备降级保生产可用
下篇预告
混合检索 · 向量 + BM25关键词双路召回融合
Rerank重排序 · 用BGE或Jina重排模型精排topN
评估体系 · RAGAS评估检索质量和生成质量
有问题评论区聊,看到就回。觉得有用点个赞,下篇讲混合检索和Rerank。
#RAG #SpringAI #向量数据库 #Java #AI开发