一、为什么要做 RAG 问答系统在平台中用户发布的是一篇篇“知文”内容可能是 Markdown 文档、图文笔记、长篇技术总结等。如果只是把整篇文章直接丢给大模型会遇到几个问题长文 Token 成本高模型上下文窗口有限用户问题往往只和文章局部内容有关如果没有约束模型容易脱离原文自由发挥。所以项目中设计了一套面向单篇知文的 RAG 问答流程用户提问 ↓ 检查文章是否已建立索引 ↓ 向量检索相关片段 ↓ 过滤当前知文的上下文 ↓ 构造 Prompt ↓ 调用 DeepSeek 流式生成 ↓ SSE 返回给前端逐字渲染这一篇先从接口和查询链路开始重点看问答接口如何设计为什么使用 SSE 流式返回如何在向量库中召回上下文Prompt 如何限制模型只基于原文回答DeepSeek 如何流式生成答案。二、问答接口设计使用 SSE 返回流式答案项目中 RAG 问答接口位于src/main/java/com/tongji/knowpost/api/KnowPostRagController.java核心代码如下RestControllerRequestMapping(/api/v1/knowposts)ValidatedRequiredArgsConstructorpublicclassKnowPostRagController{privatefinalRagIndexServiceindexService;privatefinalRagQueryServiceragQueryService;GetMapping(value/{id}/qa/stream,producesMediaType.TEXT_EVENT_STREAM_VALUE)publicFluxStringqaStream(PathVariable(id)longid,RequestParam(question)Stringquestion,RequestParam(valuetopK,defaultValue5)inttopK,RequestParam(valuemaxTokens,defaultValue1024)intmaxTokens){returnragQueryService.streamAnswerFlux(id,question,topK,maxTokens);}PostMapping(/{id}/rag/reindex)publicintreindex(PathVariable(id)longid){returnindexService.reindexSinglePost(id);}}这里有两个接口接口作用GET /api/v1/knowposts/{id}/qa/stream对单篇知文进行 RAG 问答POST /api/v1/knowposts/{id}/rag/reindex手动重建该知文的向量索引问答接口使用producesMediaType.TEXT_EVENT_STREAM_VALUE也就是text/event-stream。这意味着后端不会等完整答案生成完再一次性返回而是边生成边返回前端可以像 ChatGPT 一样逐步渲染回答。三、为什么这里适合用 Flux接口返回值是FluxStringFlux是 Reactor 中的响应式流对象适合表示一串异步产生的数据。RAG 问答场景天然适合流式处理大模型生成第一个 token ↓ 后端立即返回 ↓ 前端立即渲染 ↓ 后续 token 持续推送这样用户体验会明显好于普通 HTTP 接口。如果使用普通接口用户需要等待检索完成 Prompt 构造完成 大模型完整回答完成才能看到结果。而 SSE 流式接口可以让用户在大模型开始生成时就看到内容降低等待感。四、RAG 查询服务先确保索引存在问答核心逻辑位于src/main/java/com/tongji/llm/rag/RagQueryService.java核心方法如下publicFluxStringstreamAnswerFlux(longpostId,Stringquestion,inttopK,intmaxTokens){indexService.ensureIndexed(postId);ListStringcontextssearchContexts(String.valueOf(postId),question,Math.max(1,topK));StringcontextString.join(\n\n---\n\n,contexts);Stringsystem你是中文知识助手。只能依据提供的知文上下文回答无法确定的请说明不确定。;Stringuser 用户问题 %s 知文上下文 %s 请基于以上上下文作答。 .formatted(question,context);returnchatClient.prompt().system(system).user(user).options(DeepSeekChatOptions.builder().model(deepseek-chat).temperature(0.2).maxTokens(maxTokens).build()).stream().content();}这段代码体现了完整的 RAG 查询流程。第一步indexService.ensureIndexed(postId);在用户提问前先确保这篇知文已经建立过向量索引。这里不是每次都无脑重建索引服务内部会根据文章内容的SHA256和ETag判断是否已经是最新版本。如果已经是最新版本就直接跳过。这样设计有两个好处避免用户第一次提问时查不到内容避免重复切片和重复写入向量库。五、向量检索先宽召回再按 postId 过滤查询上下文的方法如下privateListStringsearchContexts(StringpostId,Stringquery,inttopK){intfetchKMath.max(topK*3,20);ListDocumentdocsvectorStore.similaritySearch(SearchRequest.builder().query(query).topK(fetchK).build());if(docsnull){returnList.of();}ListStringcontextsnewArrayList();for(Documentdoc:docs){ObjectmetadataPostIddoc.getMetadata().get(postId);if(!postId.equals(String.valueOf(metadataPostId))){continue;}Stringtextdoc.getText();if(textnull||text.isBlank()){continue;}contexts.add(text);if(contexts.size()topK){break;}}returncontexts;}这里有一个很重要的细节不是直接取向量库返回的前topK条而是先取更多。intfetchKMath.max(topK*3,20);比如用户传入topK 5后端实际会先召回max(5 * 3, 20) 20然后再在服务端根据metadata.postId过滤出当前知文的片段。为什么要这样做因为向量库中存储的是全站知文的切片如果只按问题做相似度检索可能召回其他文章里的内容。而当前接口的语义是围绕某一篇知文进行问答所以必须保证最终传给模型的上下文来自当前文章。这一步过滤非常关键if(!postId.equals(String.valueOf(metadataPostId))){continue;}它避免了跨文章上下文污染。六、Prompt 设计让模型只基于知文回答项目中的 system prompt 是Stringsystem你是中文知识助手。只能依据提供的知文上下文回答无法确定的请说明不确定。;这个提示词虽然不长但很关键。它给模型设置了三个约束模型角色是中文知识助手回答必须基于提供的知文上下文上下文不足时要说明不确定。用户 prompt 则由三部分组成Stringuser 用户问题 %s 知文上下文 %s 请基于以上上下文作答。 .formatted(question,context);最终传给模型的内容类似用户问题 这篇文章里介绍了哪些 Markdown 基本语法 知文上下文 片段 1 --- 片段 2 --- 片段 3 请基于以上上下文作答。这种 Prompt 结构比较清晰部分作用用户问题明确用户想问什么知文上下文提供可参考的知识来源作答要求要求模型基于上下文回答对于 RAG 系统来说Prompt 不是越复杂越好而是要把边界说清楚。这里的关键不是让模型“更会编”而是让模型“少瞎编”。七、DeepSeek 流式调用项目中通过 Spring AI 的ChatClient调用 DeepSeekreturnchatClient.prompt().system(system).user(user).options(DeepSeekChatOptions.builder().model(deepseek-chat).temperature(0.2).maxTokens(maxTokens).build()).stream().content();几个参数值得注意。1. model.model(deepseek-chat)指定使用 DeepSeek 的对话模型。2. temperature.temperature(0.2)RAG 问答通常不希望模型过度发散所以温度设置得比较低。低温度的效果是回答更稳定 更少创造性发挥 更贴近上下文这和知识问答场景是匹配的。3. maxTokens.maxTokens(maxTokens)maxTokens由接口参数传入默认值是RequestParam(valuemaxTokens,defaultValue1024)这样前端可以根据场景控制回答长度。比如场景maxTokens简短问答512普通解释1024长答案总结20484. stream.stream().content()这一步会返回模型生成内容的流式结果也就是最终接口返回的FluxString。八、前端如何接入 SSE接口文档中给出了EventSource调用方式。前端可以这样写constesnewEventSource(/api/v1/knowposts/1234567890123/qa/stream?questionMarkdown有哪些基本语法topK5maxTokens1024);letanswer;es.onmessage(e){answere.data;// 可以在这里把 answer 渲染到页面上};es.onerror(){es.close();};因为后端返回的是text/event-stream所以前端可以逐步接收答案。用户看到的效果就是第 1 秒Markdown ... 第 2 秒Markdown 的标题语法包括 ... 第 3 秒此外还支持列表、代码块、引用 ...而不是等完整答案生成完才显示。九、用 curl 测试流式接口也可以直接用 curl 测试curl-Nhttp://localhost:8080/api/v1/knowposts/1234567890123/qa/stream?questionMarkdown有哪些基本语法topK5maxTokens1024这里的-N很重要它会关闭 curl 的缓冲让你能看到流式输出效果。十、小结这一篇主要分析了项目 RAG 问答系统的查询链路。整体流程是用户调用 SSE 问答接口 ↓ 服务端确保当前知文已索引 ↓ 向量库根据问题做相似度召回 ↓ 服务端按 postId 过滤当前文章片段 ↓ 构造带上下文的 Prompt ↓ 调用 DeepSeek 流式生成 ↓ 通过 Flux SSE 返回给前端这套设计的核心不是简单接一个大模型接口而是把“大模型回答”约束在“当前知文上下文”里。RAG 的价值就在这里检索负责找依据 Prompt 负责给边界 大模型负责组织语言 流式接口负责提升体验下一篇继续分析索引构建部分知文 Markdown 内容是如何被拉取、分块、写入 Elasticsearch 向量库并通过 SHA256 / ETag 保持单一版本的。