跟课上下文服务:RAG在课堂场景的落地实践与调优复盘

发布时间:2026/9/7 16:14:08
跟课上下文服务:RAG在课堂场景的落地实践与调优复盘 做技术复盘这件事我一般不太愿意写成“项目汇报”那种调调更多是想把踩过的坑、想通的逻辑、最终能跑通的方案记录下来。一来给自己留个底二来如果正好有人在做类似的事能少走点弯路比什么都强。这次要聊的是“研途灵伴”里一个非常底层、非常不性感、但关键时刻能救命的部分——跟课上下文服务。简单说就是学生在上课过程中AI 需要基于当堂课的内容回答问题、做总结、出练习那 AI 的“记忆”从哪来课件 PDF 怎么切老师讲的话怎么组织问题来了怎么找到最相关的那几段这些东西处理不好再强的模型也白搭因为模型本身不记得你上节课讲了什么你必须喂给它。这篇文章我会把这个项目的复盘拆成四大块一是整体设计思路与场景拆解二是上下文组织方式与存储建模三是检索链路的实现细节与调优过程四是压测过程中遇到的典型问题与排查录音。内容会偏底层一些但我会尽量把每一个“为什么这么做”都交代清楚。如果你正在做教育领域的 AI 应用或者手头有类似“给大模型喂私有知识”的需求这篇应该能给你一些实在的参考。1. 核心问题拆解跟课场景到底需要什么样的“记忆”1.1 跟课上下文服务的难点不在“存”而在“理解场景”先说结论跟课上下文服务本质上是一个 RAG检索增强生成系统。但如果你把它当成传统的知识库问答来做大概率会做得很难受。传统 RAG 的场景是“用户提问 → 从文档库检索 → 拼接上下文交给模型回答”核心追求是“找到正确答案”。但跟课场景不一样它有四个我觉得比较独特的约束第一上下文是动态增长的。老师讲到第 3 章的时候学生问的问题可能涉及第 1 章的背景知识也可能涉及 5 分钟前刚讲的一句话。系统必须同时理解“课程整体内容”和“当前课堂进度”。第二同一个知识点会被反复以不同方式提及。课件里可能只有一句定义但老师会展开讲解两分钟。这时候如果只切课件文本检索出来的内容会非常单薄模型拿不到老师口头讲解的语境回答就会很“死”。第三学生的问题往往是不完整的。比如老师刚讲完“支持向量机的核函数”学生紧接着就发一句“那多项式核跟高斯核的区别是什么”——如果系统不知道“当前正在讲 SVM”这个问题的召回率会非常低。第四实时性要求高。学生问完问题检索加生成的总时长要控制在 3 秒以内。这就意味着检索链路不能太重重排序步骤要精简上下文拼接要有预算控制。所以我在这套服务里反复跟团队强调一个词场景建模。我们不是在做一个通用的文档问答而是在做“一个跟着课堂节奏走的助教”。它的记忆不是静态的文档库而是“课件 随堂讲解 历史问答 当前进度”的组合。1.2 为什么不能直接拿课件全文去喂模型先算一笔账一门课 16 讲每讲课件大约 30 页 PDF每页文字量差不多 300~500 字这样总文字量就在 15 万到 24 万字之间。按 GPT-4o 这类模型的 token 换算大概 20 万到 30 万 token。这仅仅是课件文本还没算老师口头讲解转写出来的逐字稿。逐字稿更夸张一节课 90 分钟语音转写大约能产生 1.2 万到 1.5 万字。就算只保留当前这一讲课件加逐字稿加起来就超过 2 万字折合约 3 万 token。把 3 万 token 全塞进 prompt对模型来说处理时间会明显变长成本也会大幅上升而且更关键的是——绝大多数问题其实只需要其中 800 到 1500 字的相关片段就能回答塞太多反而会引入噪声模型会“迷失在中间”。这个问题在业界有个专门的说法叫“Lost in the Middle”——模型对长上下文中间位置的内容记忆最差。你辛苦整理的课件内容如果正好被埋在 3 万 token 的正中间效果还不如只给它 1500 字的高相关片段。所以从一开始我们就定了一个原则上下文必须有选择性地组建绝不能做全文搬运工。跟课上下文服务的核心能力就是你得知道“此时此刻这个问题最需要哪几块知识”。1.3 需求方真正关心的是“效果感知”而不是技术名词做这个项目的过程里我还有一个特别深的感受需求方产品的同事、运营的同事根本不在乎你用了向量数据库还是倒排索引他们只会感知到“回答得准不准”“是不是答非所问”“响应快不快”。所以我在复盘的时候给自己定了一个衡量标准——每一次回答都应该做到“三点可感知”回答内容能和当前课堂进度对得上。比如老师正讲到线性代数里的特征值学生问的题不能是基于前面某章内容的生硬回答。回答里能带出课堂里的“现场信息”。比如老师说过的某个例子、某句强调的话这些课件里往往没有但学生听到会觉得“这 AI 真的在听课”。回答速度不能让学生觉得卡顿。宁可少拼一点上下文也不能让用户等 10 秒钟才看到打字机效果。这个标准听着简单但实现起来每一个点都对应着具体的技术选型和策略调整。下面逐个说。2. 上下文组织与存储建模从“知道”到“能用”的关键一步2.1 课件的文本抽取与分片策略先讲课件处理。PDF 这玩意看着简单真正把文本抽干净坑多到让人崩溃。有的 PDF 是文字版直接复制就行有的是扫描版必须走 OCR还有的是“文字版但内嵌了公式排版”比如 LaTeX 生成的 PDF抽出来会混着大量符号和括号。我们最终的处理流程是用 PyMuPDF 做首轮文字抽取如果单页抽取字数不足 50 字判定为“疑似扫描页”转走 OCR 通道。OCR 通道用 PaddleOCR 的中英文模型输出按行排布再根据行坐标bbox做版面还原尽量恢复阅读顺序。统一做清洗去掉页眉页脚、去除目录页、合并断行、把全角符号转半角。文本抽出来之后是分片。这里我要说一个非常反直觉的教训分片不是越小越好。一开始我们天真地按 256 字切分想着这样检索更精准。结果发现课件里的很多内容存在“上下强关联”。比如一页 PPT 上写着“优点xxx”接下来一页是“缺点yyy”切碎了之后模型只看到优点不知道这是针对哪个方法的评价回答就会片面。后来我们改成按页码 章节标题 段落语义三级的混合切分每页 PPT 作为一个基础块page_chunk因为一般一页 PPT 的主题是聚焦的。再根据章节标题做父子块关联父块是“章”子块是“页”。检索命中子块时把父块的标题塞进上下文模型就知道这段内容属于哪一章逻辑就顺了。这个逻辑很像读书时做笔记你不能只记某一页的一句话得记住这句话是哪个章节下讲的才能正确理解它的位置。正文分片建议参考表格分片类型切分粒度用途备注章级块一个章节一个块检索定位章节上下文存章节标题、页码范围页级块一页 PPT 一个块检索主候选附加页标题、页码信息段落语义块按语义合并 2~3 个自然段正文候选用于逐字稿长文本的切分问题块历史问答结对存储相似问题召回见后文2.2 随堂讲解逐字稿的增量处理随堂逐字稿是跟课上下文服务和普通知识库最大的区别点。老师上课讲的每一句话经过语音识别转写后都带着时间戳形成一个对话流。这个流是动态追加的——每 5 到 10 秒就会新增一段。如果每次有学生提问我们都把截至当前时刻的所有逐字稿重新切分再入库代价太高。我们的策略是做一个“滑动窗口式”的增量分片器。具体逻辑是这样的开场预热。每次分片器启动时先把“过去 3 分钟内”的转写文本作为缓冲因为很多新引入概念往往会在几分钟内反复出现不看前文就切分语义不完整。语义边界识别。当检测到说话人停顿超过 2 秒或者转写文本里出现“接下来”“然后我们看”“第二点”这类话语标记词时判定为一个分片候选点。局部合并。把候选点之间的文本按 300~500 字合并成一个“event_chunk”相当于“老师讲的一个小知识点”并打上开始时间和结束时间的时间戳。这样做的好处很直接每个 event_chunk 都在时间轴上对应到课件页码。比如老师在第 12 页停留了 8 分钟那这 8 分钟里的好几个 event_chunk 都会被标记为“page12”的关联 chunk。建立这种“逐字稿 ↔ 课件页 ↔ 授课时间”的三方映射关系是后面检索能结合“当前讲到哪里”这个信息的核心基础。2.3 存储选型为什么要“三库并存”在存储层的选型上我们踩过一个不必要的坑。最初想图省事只上一个向量库——把文本全扔给 Embedding 模型转成向量检索用余弦相似度。看起来确实简单但一测就露馅了课件里的专业术语太多“特征值”“特征向量”“奇异值”这种词在向量空间里经常距离极近向量检索会把它们混成一片返回一堆表面相似实则不相关的内容。最终我们做成了“三库并存”的架构关系型数据库PostgreSQL存课程、章节、文件版本、页码映射这些结构化管理数据。相当于所有数据的“账本”。向量检索库开源的 pgvector存正文切片的向量表示主用于语义召回。倒排索引OpenSearch存正文切片的词法索引主用于关键词精确召回。三库之间通过一个唯一的chunk_id相关联。检索时并行打三个池子再在内存里做融合排序。这个方案比单用向量库在专业术语场景下的准确率高非常多具体数据后面第 3 部分会给出。关于选型我再多说一句不要迷信“最新最潮”的数据库稳定、易维护、团队熟悉比什么都重要。pgvector 放在 PostgreSQL 里对我们来说最大的价值是省了一套运维备份和恢复直接用 PG 的机制省心。2.4 历史问答的沉淀与再利用历史问答其实是最容易被忽略、又最有价值的一层记忆。学生在课堂上问过的问题以及系统给出的回答都应该被结构化存下来。因为同一个班、同一个老师不同学生问的问题很可能高度相似——前一个学生刚问完“范数是什么意思”5 分钟后又有学生问“这范数干嘛用的”这时候如果系统能直接复用上一轮的上下文不光响应快回答的一致性也更好。我们的实现是把每轮 QA 提炼成一个“QA 对”将问题文本去停用词、保留核心实体生成一个question_key作为检索指纹。对回答里涉及的关键知识点提取标签比如“范数”“正则化”存入标签表。每轮 QA 会引用它消费过的最多 5 个 chunk_id记录“哪些上下文支撑了这次回答”。后面再有新问题进来先用相似度匹配question_key如果命中相似度大于 0.85 的历史问题优先复用历史回答的上下文组合再结合最新的课堂进度做修正。这个机制在“同一概念反复被问”的场景下效果极好而且能显著减少检索服务的压力。3. 检索链路与上下文注入让模型“看着课堂回答问题”3.1 双路召回 用“进度上下文”做重排检索这条链路我们最终实现的是“双路召回 进度上下文重排”的结构。听起来复杂实际拆开就三步。第一步是构建查询。学生输入的问题只是一个短文本直接拿去检索会很吃亏因为太短、指代不明。我们做了一个“查询改写”的轻量模块读取当前课堂进度当前课件页码 最近 15 分钟的逐字稿文本摘要把它拼到学生问题前面形成一个扩展后的查询。比如原始问题“那它和线性回归有啥区别” 改写后查询现在是第 5 章“逻辑回归”最近老师讲了逻辑回归的损失函数和决策边界。学生问那它和线性回归有啥区别这一步不用调大模型用简单的模板拼接 当前进度标签就能实现成本几乎为零但检索效果提升巨大。把搜索范围用“当前场景”锁死是最划算的优化。第二步是双路召回。扩展后的查询分别走两条路向量召回用 Embedding 模型转成向量在 pgvector 里查近邻取 Top 30。关键词召回在 OpenSearch 里做 BM25 检索取 Top 30。第三步是融合重排。把两路各 30 条结果合并用 RRFReciprocal Rank Fusion融合算法算一个融合分再取 Top 10。这一步不需要训练模型几十行代码就能实现效果却非常稳。RRF 的公式很简单RRF 分数 Σ (1 / (k rank_i))其中 k 通常取 60。排名越靠前贡献越大两路都命中的文档融合分会显著更高。这比我之前试过的“先向量取 50 条再用交叉编码器重排”方案要轻量很多而且少了交叉编码器那一次推理时延能节约 120 到 200 毫秒。3.2 BM25 和向量检索的调优实验对比为了让大家对“为什么双路召回比单路强”有一个直观感受我放一组我们在内部数据集上的离线评测数据。数据集是从 3 门理工科课程、共 600 个真实学生问题中构建的人工标注了每个问题对应的相关上下文片段。检索方式Recall10回答准确率人工评估平均检索耗时纯向量检索Embedding61.2%58.7%45ms纯 BM25 关键词检索54.8%51.3%28ms双路召回 RRF 融合76.5%72.4%58ms双路召回 RRF 进度上下文重排82.3%79.1%61ms注意看最后两行单加一个“进度上下文重排”Recall10 能提升近 6 个百分点但检索耗时只增加 3 毫秒。这就是为什么我一直强调“场景信息比模型技巧更值钱”。你没有必要做很复杂的排序模型你把“现在讲到哪了”这个信息用好效果立竿见影。3.3 上下文注入模板的设计取舍检索出来的 Top 10 片段不能一股脑全塞给模型。上下文长度有限而且不同的片段重要性不一样必须做预算分配。固定一个模板我们最终是这么组织的[课堂进度] 当前课程《线性代数》 当前章节第 4 章 特征值与特征向量 当前进度老师在讲特征值的几何意义 [参考课件内容] page_chunk 标题特征值定义页码12 内容…… /page_chunk [随堂讲解记录] event_chunk 起始时间10:23结束时间12:05 老师强调特征向量在变换后方向不变…… /event_chunk [相似历史问答] 问特征值和特征向量是什么意思 答……参考引用 [请基于以上内容回答学生问题] 学生问题……这里有三点值得细说课堂进度信息放在最前面。这是给模型建立“时空锚点”让它知道现在是什么场景、什么进度回答的时候自然会偏向当前讲的内容。课件内容和随堂讲解记录分成两个独立的 XML 标签块。这样做的目的是让模型区分“静态知识”和“动态讲解”回答时会自然地结合课件定义和老师口头解释比混在一起效果更好。历史问答块不是每次都放只有命中相似问题时才放。不放的时候留一个“无相似历史问题”的占位符避免模型产生额外联想。token 预算上我们给整套上下文配了 2600 token 的上限其中课件 1000、逐字稿 1000、历史问答 400、其他 200。如果检索结果太多会按照融合分数从高到低截断确保总长度可控。这个预算配合流式输出用户首字延迟实测能稳定在 800~1100ms不会让人等得心焦。3.4 场景切换与长课程记忆的过渡方案还有一个很实际的问题学生可能中途休息或者从第 3 章跳着看到第 5 章甚至隔了一周又回来问之前的内容。如果没有“记忆滚动”的概念系统会默认“只关心当前这堂课”那学生问“上周讲的矩阵秩是什么来着”就答不出来了。我们做的过渡方案是“三级上下文分级”当前课次上下文最近的 90 分钟优先召回。本课程历史上下文课程开始以来的 QA 和课件作为次级候选。全局知识底座通用知识库作为保底。检索开始时默认只查当前课次。如果当前课次的 Top 结果融合分数低于一个阈值我们设为 0.35就自动扩展到课程历史上下文再查一次。如果还不够再落到全局知识底座。每一级的召回结果都带着课次标签注入模板的时候会额外标注“以下内容来自第 3 次课”帮助模型判断信息的新旧关系。这个设计落地以后课程的“跨节次连续提问”正确率从 53% 涨到了 74%。没有额外引入更复杂的记忆网络就是靠“分层查询 分数阈值退避”稳定、可控、好排查。4. 性能优化与压测实录一次数据库连接风暴排查4.1 服务架构与链路时延预算分配先说链路时延预算。用户发出问题到流式输出第一个 token我们内部是按这个预算去扣时间的环节预算耗时网关 鉴权100ms查询改写30ms双路召回并行80ms融合重排10ms上下文组装10msLLM 首 token 延迟780ms合计约 1010ms这里最关键的是双路召回必须“并行”发起。最开始实现是串行的先查向量再查关键词链路耗时直接多了 80ms。后来改成 Go 的 goroutine 并发发起两路查询总耗时从 150ms 降到了 80ms 左右。4.2 压测中遇到的连接池打满与排查过程上线前的压测我们耗费了最多的精力去解决一个经典问题连接池被打满。压测配置是这样的用 50 个并发用户模拟学生提问每个用户每隔 15 秒发一个问题持续压 10 分钟。预期目标的服务 QPS 不算高单机大约 30 QPS 就应该能扛住。但压测一开始服务监控面板就亮起红灯数据库连接数快速攀升到 PostgreSQL 默认最大连接数的上限大量请求报too many clients检索失败率一度升到 35%。排查过程大概是这个套路第一步先看日志确认阻塞点。发现报错集中在 pgvector 查询几乎每个请求都要建立新的数据库连接。第二步检查数据库连接池配置。问题立刻浮出水面连接池最大大小被默认配置成了“未限制”同时连接池的空闲回收时间设置太长导致高并发时连接数疯狂膨胀。这不是数据库本身的问题就是我们应用层连接池配错了。第三步现场修复。把连接池最大大小调成 20最小空闲连接数设为 5连接空闲回收时间从 30 分钟改到 10 分钟同时加上连接获取超时时间 3 秒。改完重新压测数据库连接数稳定在 18 左右失败率清零。这个问题的根因说起来很基础但实际排查所花的时间远比想象中多。原因是监控面板上看不到“连接池队列等待”的指标一开始我们还以为瓶颈在 Embedding 模型的推理服务上白查了好一会儿。4.3 压测后的调优项与最终数据除了连接池压测还暴露了两个需要调优的问题。第一个是 Embedding 服务的单点瓶颈。虽然我们没有在每次提问时都重新生成向量课件和逐字稿的向量是提前生成好的但查询改写后的查询向量需要实时计算。最开始用 CPU 跑 Embedding 模型T4 机器上单次推理要 80ms。后来换成 GPU 部署并且做了一个“查询向量缓存”如果问题文本和 5 分钟内某个历史查询文本的编辑距离小于阈值直接复用已有向量单次查询耗时降到 20ms缓存命中率大约 40%。第二个是逐字稿增量分片时的锁竞争。最开始用 Redis 分布式锁来防止同一个课程会话的分片任务并发执行但压测时发现锁等待时间会累积严重时单个分片任务要等 2 秒。后来改成“基于课程会话 ID 的分片任务队列”同一个会话的任务串行处理不同会话之间完全并行等待时间降到基本为零。这些全部调完之后最终压测数据如下指标压测目标实测结果单机稳定 QPS3042首 token 延迟P95≤ 1500ms1120ms上下文检索失败率≤ 0.5%0%数据库连接数上限≤ 3018这个结果基本达到了我们对“跟课上下文服务”的预期至少在性能瓶颈上短期内不会再成为短板。4.4 监控与告警三板斧最后分享一个运维层面的经验。上下文服务最怕的不是性能差而是“悄悄变差”——比如某一天的课件格式变了导致分片异常或者某个课程量特别大导致检索延时上升。如果没有监控这些问题往往要等大量学生投诉才会暴露。我们给服务配了三个最有用的监控项检索耗时 P95 监控阈值设置为 200ms超过就是异动。这能快速暴露数据库慢查询、连接池问题。召回为空率监控每 100 个问题里如果有超过 5 个问题的检索结果为空或分数极低立刻告警。这通常意味着课件解析失败、分片为空或者查询改写模块出了 bug。上下文 token 超预算率监控如果频繁出现“检索结果太多被截断丢掉”的情况说明分片粒度可能需要调整或者 Top K 参数设得过大。这三个监控项看起来简单但每一个都真正救过我们一次。有的团队喜欢一上来就搞特别复杂的可观测性体系反而容易迷失在无数指标里。先盯住核心链路的几个关键信号比什么都强。5. 常见问题与排查技巧实录5.1 检索出来的内容“看着相关但不解决问题”这是 RAG 系统最典型的“虚假相关”问题。比如学生问“矩阵的秩怎么求”系统检索回来一段文字确实提到了“秩”但内容是定义而不是计算步骤模型拿它作答回答自然浮于表面。我们的排查技巧是“先看上下文标签再看 chunk 内容”。具体操作把检索结果连同它们的元信息章节标题、页码、时间戳、来源类型打印成调试日志。如果检索返回的都是“定义型”内容而问题明显是“操作型”的那基本可以确定是分片切得太大一个页级 chunk 里同时包含了定义和例子检索时被标题词吸引了注意力。解决办法是把页级块进一步细分成“语义子块”每块控制在 150 字左右同时给块打上“定义/示例/推导/结论”的标签查询改写时根据问题类型偏好标签。5.2 同一节课不同学生问同样问题答案却不一样这个现象一度很玄学。后来才发现原因很简单学生提问时当前课堂进度不同。前一个学生是在老师讲到定义时就问的系统只拼了定义上下文后一个学生在老师讲完例子后才问上下文里多了例子回答自然更丰满。严格说这不算 bug但对产品一致性来说确实是负面体验。我们采用的处理方案是当检索命中原有的历史 QA 对时除了复用上下文组合还会把历史回答的标准结论作为“先验答案”放进提示词让模型在不偏离核心意思的前提下结合最新课堂进度做适度微调。这样既保证了回答的稳定性又保留了现场感。5.3 语音转写错字如何识别与纠偏逐字稿的质量直接影响检索效果。语音转写常出现同音错字比如“特征向量”被转成“特称向量”“矩阵”偶尔变成“矩阵没错就是字面错”。如果文本都错了向量检索很难命中正确内容。我们做了两层防御一是在 Embedding 之前对“领域词库”做一次替换校正把课程相关的术语列表做成词典扫描逐字稿时优先纠正词库内的高频错误二是在检索阶段把关键词召回BM25的结果权重稍微调高一点因为即便错字BM25 基于词频的匹配也往往能命中同一段文字里的其他正确词。5.4 排查技巧速查表症状大概率原因快速排查方法回答内容与课堂无关当前课次上下文为空服务回退到了通用知识库查“调用层级”日志确认是否走了历史/全局层级检索耗时突然飙升数据库连接池打满或 Embedding 服务异常看连接池活跃连接数、Embedding 推理耗时回答中提及了“不能确定”的细节逐字稿未及时分片入库查分片任务队列延迟多次回答同一问题不一致历史 QA 未命中查 question_key 相似度阈值可能定得太高这张表贴在我们项目群里不仅是研发人员用产品同学遇到用户反馈也能先做个粗筛省去大量来回沟通的时间。写在最后一点心得与可复用的经验这个项目做下来我最大的感受是八个字先有场景再有技术。跟课上下文服务的技术栈并没有任何“魔术”向量检索、倒排索引、连接池调优、提示词模板这些全部是成熟得不能再成熟的东西。真正的难点在于把课堂这个场景的节奏、进度、连续性理解透并且映射到技术设计里去。学生问“这个和刚才那个有什么区别”系统要能自动补全“刚才那个”是哪一节、哪一页、哪句话——这个能力不是靠模型觉醒来实现的是靠工程一点一点拼出来的。如果让我给后来者三个最实用的建议我会说第一课堂进度上下文是你最便宜的提效手段优先用好它。第二不要把分片当成“切了就行”切完之后有没有语义边界、有没有时间戳、有没有章节归属这些元信息决定了检索的天花板。第三上线上线之前先做压测连接池、缓存、并发队列这些基础配置要按真实流量来校对别等用户卡住了才检查配置。另外这个服务目前只是做到了“上下文服务”这一层。再往后走我们还在琢磨两个方向一是把“学生对某个知识点的掌握状态”也纳入上下文建模让回答能根据学生的历史互动做个性化适配二是做一个“课程知识图谱”层把知识点之间的关系显式建模出来让检索能从“关键词匹配”升级到“知识点路径导航”。这两件事都还在原型阶段等有了阶段性成果我再写一篇新的复盘分享。