Java搭RAG实战(三):检索问答全链路,从架构分层到SSE流式

  • 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开发

相关推荐
测试员周周3 小时前
【Appium 系列】第17节-XMind用例转换 — 从思维导图到 YAML
java·服务器·人工智能·单元测试·appium·测试用例·xmind
NiceCloud喜云4 小时前
Claude API PDF 文档问答实战:从原生解析到分页引用的完整方案
java·服务器·前端·网络·数据库·人工智能·pdf
彦为君4 小时前
JavaSE-03-集合框架(详细版)
java·开发语言·python
Dicky-_-zhang4 小时前
API接口签名验证实战
java·jvm
java1234_小锋4 小时前
Redis 支持哪些数据类型?请分别说明它们的使用场景
java·数据库·redis
:1214 小时前
java基础---一些没注意的
java·开发语言
计算机安禾4 小时前
【c++面向对象编程】第48篇:Lambda表达式与std::function:OOP中的函数式编程
java·c++·算法
marsh02064 小时前
54 openclaw钩子函数使用:在框架生命周期中注入自定义逻辑
java·前端·spring
艳阳天_.4 小时前
星瀚物料序时簿批量分类功能二开
java