【知识获取与分享社区项目 | 项目日记第 16 天】从接口到 DeepSeek 流式输出:知光 RAG 问答链路设计

一、为什么要做 RAG 问答系统
在平台中,用户发布的是一篇篇“知文”,内容可能是 Markdown 文档、图文笔记、长篇技术总结等。
如果只是把整篇文章直接丢给大模型,会遇到几个问题:
- 长文 Token 成本高;
- 模型上下文窗口有限;
- 用户问题往往只和文章局部内容有关;
- 如果没有约束,模型容易脱离原文自由发挥。
所以项目中设计了一套面向单篇知文的 RAG 问答流程:
用户提问
↓
检查文章是否已建立索引
↓
向量检索相关片段
↓
过滤当前知文的上下文
↓
构造 Prompt
↓
调用 DeepSeek 流式生成
↓
SSE 返回给前端逐字渲染
这一篇先从接口和查询链路开始,重点看:
- 问答接口如何设计;
- 为什么使用 SSE 流式返回;
- 如何在向量库中召回上下文;
- Prompt 如何限制模型只基于原文回答;
- DeepSeek 如何流式生成答案。
二、问答接口设计:使用 SSE 返回流式答案
项目中 RAG 问答接口位于:
src/main/java/com/tongji/knowpost/api/KnowPostRagController.java
核心代码如下:
@RestController
@RequestMapping("/api/v1/knowposts")
@Validated
@RequiredArgsConstructor
public class KnowPostRagController {
private final RagIndexService indexService;
private final RagQueryService ragQueryService;
@GetMapping(value = "/{id}/qa/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> qaStream(@PathVariable("id") long id,
@RequestParam("question") String question,
@RequestParam(value = "topK", defaultValue = "5") int topK,
@RequestParam(value = "maxTokens", defaultValue = "1024") int maxTokens) {
return ragQueryService.streamAnswerFlux(id, question, topK, maxTokens);
}
@PostMapping("/{id}/rag/reindex")
public int reindex(@PathVariable("id") long id) {
return indexService.reindexSinglePost(id);
}
}
这里有两个接口:
| 接口 | 作用 |
|---|---|
GET /api/v1/knowposts/{id}/qa/stream |
对单篇知文进行 RAG 问答 |
POST /api/v1/knowposts/{id}/rag/reindex |
手动重建该知文的向量索引 |
问答接口使用:
produces = MediaType.TEXT_EVENT_STREAM_VALUE
也就是 text/event-stream。
这意味着后端不会等完整答案生成完再一次性返回,而是边生成边返回,前端可以像 ChatGPT 一样逐步渲染回答。
三、为什么这里适合用 Flux
接口返回值是:
Flux<String>
Flux 是 Reactor 中的响应式流对象,适合表示一串异步产生的数据。
RAG 问答场景天然适合流式处理:
大模型生成第一个 token
↓
后端立即返回
↓
前端立即渲染
↓
后续 token 持续推送
这样用户体验会明显好于普通 HTTP 接口。
如果使用普通接口,用户需要等待:
检索完成 + Prompt 构造完成 + 大模型完整回答完成
才能看到结果。
而 SSE 流式接口可以让用户在大模型开始生成时就看到内容,降低等待感。
四、RAG 查询服务:先确保索引存在
问答核心逻辑位于:
src/main/java/com/tongji/llm/rag/RagQueryService.java
核心方法如下:
public Flux<String> streamAnswerFlux(long postId, String question, int topK, int maxTokens) {
indexService.ensureIndexed(postId);
List<String> contexts = searchContexts(String.valueOf(postId), question, Math.max(1, topK));
String context = String.join("\n\n---\n\n", contexts);
String system = "你是中文知识助手。只能依据提供的知文上下文回答;无法确定的请说明不确定。";
String user = """
用户问题:
%s
知文上下文:
%s
请基于以上上下文作答。
""".formatted(question, context);
return chatClient.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 过滤
查询上下文的方法如下:
private List<String> searchContexts(String postId, String query, int topK) {
int fetchK = Math.max(topK * 3, 20);
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(fetchK)
.build()
);
if (docs == null) {
return List.of();
}
List<String> contexts = new ArrayList<>();
for (Document doc : docs) {
Object metadataPostId = doc.getMetadata().get("postId");
if (!postId.equals(String.valueOf(metadataPostId))) {
continue;
}
String text = doc.getText();
if (text == null || text.isBlank()) {
continue;
}
contexts.add(text);
if (contexts.size() >= topK) {
break;
}
}
return contexts;
}
这里有一个很重要的细节:不是直接取向量库返回的前 topK 条,而是先取更多。
int fetchK = Math.max(topK * 3, 20);
比如用户传入:
topK = 5
后端实际会先召回:
max(5 * 3, 20) = 20
然后再在服务端根据:
metadata.postId
过滤出当前知文的片段。
为什么要这样做?
因为向量库中存储的是全站知文的切片,如果只按问题做相似度检索,可能召回其他文章里的内容。
而当前接口的语义是:
围绕某一篇知文进行问答
所以必须保证最终传给模型的上下文来自当前文章。
这一步过滤非常关键:
if (!postId.equals(String.valueOf(metadataPostId))) {
continue;
}
它避免了跨文章上下文污染。
六、Prompt 设计:让模型只基于知文回答
项目中的 system prompt 是:
String system = "你是中文知识助手。只能依据提供的知文上下文回答;无法确定的请说明不确定。";
这个提示词虽然不长,但很关键。
它给模型设置了三个约束:
- 模型角色是中文知识助手;
- 回答必须基于提供的知文上下文;
- 上下文不足时要说明不确定。
用户 prompt 则由三部分组成:
String user = """
用户问题:
%s
知文上下文:
%s
请基于以上上下文作答。
""".formatted(question, context);
最终传给模型的内容类似:
用户问题:
这篇文章里介绍了哪些 Markdown 基本语法?
知文上下文:
片段 1
---
片段 2
---
片段 3
请基于以上上下文作答。
这种 Prompt 结构比较清晰:
| 部分 | 作用 |
|---|---|
| 用户问题 | 明确用户想问什么 |
| 知文上下文 | 提供可参考的知识来源 |
| 作答要求 | 要求模型基于上下文回答 |
对于 RAG 系统来说,Prompt 不是越复杂越好,而是要把边界说清楚。
这里的关键不是让模型“更会编”,而是让模型“少瞎编”。
七、DeepSeek 流式调用
项目中通过 Spring AI 的 ChatClient 调用 DeepSeek:
return chatClient.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(value = "maxTokens", defaultValue = "1024")
这样前端可以根据场景控制回答长度。
比如:
| 场景 | maxTokens |
|---|---|
| 简短问答 | 512 |
| 普通解释 | 1024 |
| 长答案总结 | 2048 |
4. stream
.stream().content()
这一步会返回模型生成内容的流式结果,也就是最终接口返回的 Flux<String>。
八、前端如何接入 SSE
接口文档中给出了 EventSource 调用方式。
前端可以这样写:
const es = new EventSource(
`/api/v1/knowposts/1234567890123/qa/stream?question=Markdown有哪些基本语法&topK=5&maxTokens=1024`
);
let answer = '';
es.onmessage = (e) => {
answer += e.data;
// 可以在这里把 answer 渲染到页面上
};
es.onerror = () => {
es.close();
};
因为后端返回的是 text/event-stream,所以前端可以逐步接收答案。
用户看到的效果就是:
第 1 秒:Markdown ...
第 2 秒:Markdown 的标题语法包括 ...
第 3 秒:此外还支持列表、代码块、引用 ...
而不是等完整答案生成完才显示。
九、用 curl 测试流式接口
也可以直接用 curl 测试:
curl -N "http://localhost:8080/api/v1/knowposts/1234567890123/qa/stream?question=Markdown有哪些基本语法&topK=5&maxTokens=1024"
这里的 -N 很重要,它会关闭 curl 的缓冲,让你能看到流式输出效果。
十、小结
这一篇主要分析了项目 RAG 问答系统的查询链路。
整体流程是:
用户调用 SSE 问答接口
↓
服务端确保当前知文已索引
↓
向量库根据问题做相似度召回
↓
服务端按 postId 过滤当前文章片段
↓
构造带上下文的 Prompt
↓
调用 DeepSeek 流式生成
↓
通过 Flux + SSE 返回给前端
这套设计的核心不是简单接一个大模型接口,而是把“大模型回答”约束在“当前知文上下文”里。
RAG 的价值就在这里:
检索负责找依据
Prompt 负责给边界
大模型负责组织语言
流式接口负责提升体验
下一篇继续分析索引构建部分:知文 Markdown 内容是如何被拉取、分块、写入 Elasticsearch 向量库,并通过 SHA256 / ETag 保持单一版本的。
更多推荐


所有评论(0)