前言

在"随时在线课堂"项目中,用户购买课程后可以在线学习。但有一个问题:学员遇到疑问时,只能翻看文档或等待老师回复。我想到一个方案——把课程文档"喂"给大模型,让AI基于课程内容回答问题

这不是简单的"调一下ChatGPT API"。课程文档有上百页,大模型的上下文窗口装不下;即便装下了,通用大模型也不了解这套课程的具体内容。我的方案是用**RAG(检索增强生成)**解决这个问题——先把课程文档向量化存起来,用户提问时检索相关片段,再把片段和问题一起发给大模型,让它"开卷作答"。

本文完整复盘这个AI课程问答助手的技术实现。

本文核心问题:

  1. RAG是什么?为什么不能直接把文档全塞给大模型?
  2. 课程文档怎么变成向量?Embedding模型怎么选?
  3. 向量存到哪?Chroma、Milvus、Pinecone怎么选?
  4. 用户提问后怎么检索最相关的文档片段?
  5. Prompt模板怎么设计才能让AI只回答课程内容,不瞎编?
  6. 多轮对话的上下文记忆是怎么实现的?
  7. SSE流式输出和普通接口有什么区别?怎么封装?
  8. 整个流程的架构图长什么样?踩过什么坑?

读完本文你将掌握从文档处理到流式输出的完整RAG应用开发方法。


一、为什么不能直接把文档全塞给大模型?

疑问:GPT-4的上下文窗口已经到128K了,为什么不能直接把课程PDF丢进去提问?

回答:三个限制让"全量塞入"行不通。

1.1 上下文窗口不够用

一套课程包含讲义、习题、FAQ等,总字数通常在20万字以上。128K的窗口看似很大,但实测在5万token(约7万汉字)后,大模型对文档中间的细节就开始遗漏。20万字的课程文档,即便硬塞进去,模型也记不住全部内容。

1.2 成本不可接受

GPT-4的输入价格为每百万token约30美元。一次把100页文档全塞进去就要5万token,每次提问花1.5美元。如果一个学员提问10次就是15美元——已经超过课程的售价了。

1.3 回答质量下降

大模型在被塞入过多无关信息后,回答质量反而下降。它会被文档中不相关的内容干扰,甚至"在错误的地方找答案"。就像考试时给你一本书让你抄答案,如果能把范围缩小到"第三章第三节",你抄对的可能性远高于让你自己翻全本找。

这就是RAG要解决的核心问题:每次提问时,只检索最相关的那几段文档,把它们作为"参考资料"和问题一起发给大模型。大模型不需要"记住"全部内容,只需要"读懂"这几段。


二、RAG的整体架构

疑问:RAG的完整流程分几步?架构长什么样?

回答:RAG分成两个阶段——离线索引阶段和在线问答阶段。

┌─────────────────────────────────────────────────────────┐
│                     离线索引阶段                         │
│                                                         │
│  PDF/文档 → 文本提取 → 文档切分(Chunk) → Embedding向量化  │
│                                            ↓             │
│                                    存入向量数据库          │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│                     在线问答阶段                         │
│                                                         │
│  用户提问 → 问题向量化 → 向量相似度检索 → 召回Top-K片段    │
│                                                    ↓     │
│  构建Prompt(问题 + 召回片段 + 指令) → 调用大模型 → 返回   │
└─────────────────────────────────────────────────────────┘

各层职责

阶段 步骤 做了什么 核心组件
离线 文档切分 把长文档切成小块 LangChain TextSplitter
离线 向量化 每块文本变成向量 Embedding模型
离线 存储 向量存入数据库 向量数据库
在线 检索 找到最相关的文档块 相似度检索
在线 生成 拼Prompt + 调大模型 Prompt模板 + LLM

三、离线索引阶段——把课程文档变成可检索的知识库

3.1 文档切分:多大一块最合适?

疑问:文档切成多大一块?切太大检索不准,切太小语义不完整,怎么权衡?

回答:我测试了几个尺寸后,选了500字/块,重叠100字。

// 文档切分配置
DocumentLoader loader = new PDFLoader("course/java-advanced.pdf");
List<Document> documents = loader.load();

// 按500字切分,相邻块重叠100字
TextSplitter splitter = new RecursiveCharacterTextSplitter(
    500,   // 每块500字
    100    // 重叠100字,防止关键信息被切断
);
List<Document> chunks = splitter.splitDocuments(documents);

为什么500字? 太短(如200字)语义不完整,检索出来的一段话用户看不懂;太长(如2000字)会包含太多无关内容,降低检索精度。500字约等于一段完整的技术说明,既有上下文又不冗余。

为什么重叠100字? 防止切分点正好切断了一个完整的概念。比如"依赖注入的核心在于控制反转。具体来说…"这个解释可能在两个块的边界被切断,重叠100字保证关键内容至少完整出现在一个块中。

3.2 Embedding向量化:把文字变成数学

疑问:文字怎么变成向量?为什么相似的文本向量也相似?

回答:Embedding模型就是干这件事的——输入一段文字,输出一串数字(向量)。语义相近的文字,向量在空间中的距离也近。

// 使用OpenAI的Embedding模型
Embeddings embeddings = new OpenAIEmbeddings();

// "Java多线程" → [0.023, -0.145, 0.891, ..., 0.342]  (1536维向量)
List<Float> vector = embeddings.embedQuery("Java多线程");

为什么用OpenAI的text-embedding-ada-002

方案 维度 中文效果 成本
OpenAI ada-002 1536维
本地M3E-base 768维 免费
智谱Embedding 1024维

我这个项目用的是M3E-base本地部署——课程内容是中文为主,M3E对中文的支持足够好,而且免费。向量维度和模型选择会影响检索精度。1536维的OpenAI模型更精确,但需要联网调用;768维的M3E在中文场景完全够用,而且没有网络延迟和费用。

3.3 向量数据库选型

疑问:向量存到哪?MySQL行不行?

回答:不行。向量检索的本质是"找最相似的N个向量",这是高维空间中的最近邻搜索问题。MySQL的B+树索引是为精确查找和范围查找设计的,无法高效处理这种近似匹配。

方案 适用场景 我的选择
Chroma 轻量级,嵌入应用 ✅ 本项目使用
Milvus 生产级,百万+向量 未来迁移方向
Pinecone 云托管,免运维 海外项目适合
FAISS Meta开源,纯本地 适合离线批量处理

我的项目是Demo阶段,文档量不大(几十个课程、每个几百页),Chroma足够——它可以直接嵌入Spring Boot应用,不需要单独部署服务。如果未来课程量和用户量上去了,可以迁移到Milvus。


四、在线问答阶段——检索+生成

4.1 检索:怎么找到最相关的文档片段?

疑问:用户提问"Java线程池怎么配置",怎么找到最相关的课程内容?

回答:把用户问题也向量化,然后在向量库中找到最相似的Top-K个文档块。

// 1. 用户问题向量化
String question = "Java线程池怎么配置核心线程数?";
List<Float> questionVector = embeddings.embedQuery(question);

// 2. 在向量库中相似度检索,取最相关的5段
int k = 5;
List<Document> relevantDocs = vectorStore.similaritySearch(question, k);

检索策略的权衡

参数 设置 影响
k=3 召回少 速度快,但可能遗漏相关信息
k=5 我的选择 平衡速度和覆盖率
k=10 召回多 信息全,但Prompt变长、成本高、可能引入噪声

实际测试中,k=5时95%的提问能准确召回相关文档。偶尔有单词模糊匹配不准的情况,正在考虑引入BM25做关键词+语义混合检索。

4.2 构建Prompt模板:让AI只回答课程内容

疑问:怎么防止大模型"瞎编"?怎么让它只基于课程内容回答?

回答:关键在Prompt模板的设计。我设计了一个三要素模板。

String promptTemplate = """
    你是一个课程答疑助手,只能根据以下课程内容回答学生的问题。
    如果课程内容中没有相关信息,请直接说"课程中未提及此内容",不要编造答案。
    
    ## 课程内容
    {context}
    
    ## 学生问题
    {question}
    
    ## 回答要求
    - 基于课程内容回答,引用原文时标注出处
    - 如果涉及代码,使用代码块格式
    - 回答简洁,不超过300字
    """;

三要素设计原则

  1. 角色设定——“你是课程答疑助手”,限制AI的身份
  2. 约束规则——“只能根据课程内容回答,没有就说没有”,防止幻觉
  3. 输出格式——“标注出处、代码用代码块、不超过300字”,规范回答格式

踩过的坑:最初没有加"没有就说没有"这条约束。用户问"这个课程教Kubernetes吗",大模型会自己编造一个答案,因为它觉得"不会"太简短了,需要给个更像人类的回答。加上这条约束后,"课程中未提及此内容"的准确率提高到95%以上。


五、多轮对话——记住上下文

疑问:用户追问"那第二个参数呢",AI怎么知道在问什么?

回答:用ConversationBufferMemory记录对话历史,每次请求把历史也放进Prompt。

// 创建带记忆的对话链
ConversationBufferMemory memory = new ConversationBufferMemory();

ConversationalRetrievalChain chain = new ConversationalRetrievalChain(
    llm,           // 大模型
    retriever,     // 检索器
    memory         // 记忆
);

// 第一轮
String answer1 = chain.run("Java线程池有哪些参数?");
// memory: [用户: Java线程池有哪些参数?, AI: 有corePoolSize、maximumPoolSize...]

// 第二轮——AI知道"第二个参数"指的是什么
String answer2 = chain.run("第二个参数怎么设置?");
// memory: [用户: Java线程池有哪些参数?, AI: ..., 用户: 第二个参数怎么设置?, AI: ...]

工作原理:每次调用大模型时,memory会把之前的对话历史拼接到Prompt中:

之前的对话:
用户: Java线程池有哪些参数?
AI: 有corePoolSize、maximumPoolSize、keepAliveTime...

当前问题:第二个参数怎么设置?
→ AI知道"第二个参数" = "maximumPoolSize"

但有个限制:ConversationBufferMemory会把完整的历史都存在内存中。对话轮次太多时,Prompt会越来越长。如果未来要支撑长对话,可以改用ConversationSummaryMemory——对历史做摘要而非全量保存。


六、SSE流式输出——让用户不用等

疑问:大模型生成一段回答要2-3秒,让用户干等着吗?

回答:用SSE(Server-Sent Events)流式输出。大模型生成一个字就推送给前端一个字,像ChatGPT一样逐字显示。

6.1 为什么用SSE而不是WebSocket?

方案 连接方式 适用场景 复杂度
SSE 单向推送(服务端→客户端) AI流式输出
WebSocket 双向通信 聊天室、协同编辑
轮询 客户端反复请求 低频状态查询 极低

AI回答场景是单向的——服务端生成内容推给前端,前端不需要反向推送。SSE比WebSocket更轻量,原生支持自动重连。

6.2 Spring Boot的SSE实现

@GetMapping(value = "/ai/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String question) {
    return Flux.create(sink -> {
        // 调用LangChain的流式接口
        chain.streamRun(question, new StreamCallback() {
            @Override
            public void onToken(String token) {
                sink.next(token);  // 每生成一个token就推送给前端
            }
            
            @Override
            public void onComplete() {
                sink.complete();
            }
            
            @Override
            public void onError(Throwable e) {
                sink.error(e);
            }
        });
    });
}

效果:用户提问后,前端逐字显示回答,平均首字延迟从2-3秒降到0.5秒以内。虽然总生成时间没变,但用户体感从"卡住了"变成了"在打字"。


七、完整架构图

┌─────────────────────────────────────────────────────────────────┐
│                        前端                                      │
│  用户输入问题 → 建立SSE连接 → 逐字接收回答                        │
└──────────────────────────┬──────────────────────────────────────┘
                           │ HTTP SSE
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Spring Boot 后端                              │
│                                                                 │
│  ┌─────────────┐    ┌──────────────┐    ┌──────────────────┐   │
│  │ ChatController│→  │ RAG Service   │→   │ LangChain框架    │   │
│  │ /chat/stream  │   │ 1.向量检索    │    │ 会话记忆管理      │   │
│  │ (SSE)        │   │ 2.Prompt拼接  │    │ RetrievalChain    │   │
│  │              │   │ 3.调用LLM     │    │ Prompt模板        │   │
│  └─────────────┘    └──────┬───────┘    └────────┬─────────┘   │
│                            │                      │              │
└────────────────────────────┼──────────────────────┼──────────────┘
                             │                      │
                    ┌────────▼──────────┐  ┌────────▼──────────┐
                    │   向量数据库       │  │    大模型API       │
                    │   Chroma          │  │   GPT-4 / 本地模型  │
                    │   存储文档向量     │  │   流式生成回答     │
                    └───────────────────┘  └───────────────────┘

八、踩坑记录

坑1:向量检索不准

初期用"多线程"搜不出来"线程池"相关的内容。原因是Embedding模型对同义词不够敏感。

解决:采用混合检索——向量检索(语义相似)+ 关键词检索(精确匹配)结合。同时优化了文档切分策略,确保每个块有完整的主题句。

坑2:大模型幻觉

加了"没有就说没有"约束后,仍然偶发编造。排查发现是检索召回的片段不相关时,大模型会自己补全。

解决:增加检索后的相关性过滤。如果检索到的文档块和问题的余弦相似度低于0.7,直接返回"请换个方式提问",不发大模型。

坑3:流式输出中断

SSE在Nginx反向代理时被缓冲,前端收到的是一次性全部内容,逐字效果失效。

解决:在Nginx配置中对该接口关闭缓冲——proxy_buffering off;。同时在前端增加了连接中断后的自动重连逻辑。


总结

  • RAG解决了"大模型不认识课程内容"的问题——检索最相关的文档片段作为上下文
  • 离线阶段:文档切分(500字/块+100字重叠)→ Embedding向量化(M3E-base)→ 存入Chroma向量库
  • 在线阶段:用户提问向量化→检索Top-5相关片段→拼Prompt模板→LLM生成回答
  • Prompt模板三要素:角色设定 + 约束规则 + 输出格式,防幻觉效果显著
  • 多轮对话用ConversationBufferMemory记录历史,每次请求拼接完整上下文
  • SSE流式输出比WebSocket更轻量,首字延迟降到0.5秒以内,用户体验从"卡住了"变成"在打字"
  • 踩坑要点:向量检索需混合关键词+语义、幻觉需加相关性过滤、Nginx需关SSE缓冲

下一篇预告:AI应用实战(二)——大模型推理网关:从负载均衡到故障注入的完整设计。分享用WebFlux构建响应式网关,Token限流、故障注入压测的实战经验。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐