在这里插入图片描述

一、为什么要做 RAG 问答系统

在平台中,用户发布的是一篇篇“知文”,内容可能是 Markdown 文档、图文笔记、长篇技术总结等。

如果只是把整篇文章直接丢给大模型,会遇到几个问题:

  1. 长文 Token 成本高;
  2. 模型上下文窗口有限;
  3. 用户问题往往只和文章局部内容有关;
  4. 如果没有约束,模型容易脱离原文自由发挥。

所以项目中设计了一套面向单篇知文的 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);

在用户提问前,先确保这篇知文已经建立过向量索引。

这里不是每次都无脑重建,索引服务内部会根据文章内容的 SHA256ETag 判断是否已经是最新版本。如果已经是最新版本,就直接跳过。

这样设计有两个好处:

  1. 避免用户第一次提问时查不到内容;
  2. 避免重复切片和重复写入向量库。

五、向量检索:先宽召回,再按 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 = "你是中文知识助手。只能依据提供的知文上下文回答;无法确定的请说明不确定。";

这个提示词虽然不长,但很关键。

它给模型设置了三个约束:

  1. 模型角色是中文知识助手;
  2. 回答必须基于提供的知文上下文;
  3. 上下文不足时要说明不确定。

用户 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 保持单一版本的。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐