AI课程问答助手——LangChain + RAG + SSE流式输出的完整实现
前言
在"随时在线课堂"项目中,用户购买课程后可以在线学习。但有一个问题:学员遇到疑问时,只能翻看文档或等待老师回复。我想到一个方案——把课程文档"喂"给大模型,让AI基于课程内容回答问题。
这不是简单的"调一下ChatGPT API"。课程文档有上百页,大模型的上下文窗口装不下;即便装下了,通用大模型也不了解这套课程的具体内容。我的方案是用**RAG(检索增强生成)**解决这个问题——先把课程文档向量化存起来,用户提问时检索相关片段,再把片段和问题一起发给大模型,让它"开卷作答"。
本文完整复盘这个AI课程问答助手的技术实现。
本文核心问题:
- RAG是什么?为什么不能直接把文档全塞给大模型?
- 课程文档怎么变成向量?Embedding模型怎么选?
- 向量存到哪?Chroma、Milvus、Pinecone怎么选?
- 用户提问后怎么检索最相关的文档片段?
- Prompt模板怎么设计才能让AI只回答课程内容,不瞎编?
- 多轮对话的上下文记忆是怎么实现的?
- SSE流式输出和普通接口有什么区别?怎么封装?
- 整个流程的架构图长什么样?踩过什么坑?
读完本文你将掌握从文档处理到流式输出的完整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字
""";
三要素设计原则:
- 角色设定——“你是课程答疑助手”,限制AI的身份
- 约束规则——“只能根据课程内容回答,没有就说没有”,防止幻觉
- 输出格式——“标注出处、代码用代码块、不超过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限流、故障注入压测的实战经验。
更多推荐



所有评论(0)