十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

课程资料问答助手实战:基于检索增强生成的最小实现

课程资料问答助手实战:基于检索增强生成的最小实现 课程资料问答助手是典型的“先检索、再回答”类应用核心理念并不复杂把课件、教材和课堂笔记整理成知识片段用户提问时先从这些资料里定位相关内容再让模型结合资料生成答案。很多初学者误以为只要把整份 PDF 灌给模型就能答疑实际工程里很快会遇到上下文长度、检索准确性和回答可控性三类问题。本文以一个可运行的 Python 项目为主线逐步实现一个基于本地课程资料的最小问答助手并讨论分块、索引、检索、生成和排错这些真正影响效果的关键环节。这个项目适合刚接触检索增强生成概念的开发者也适合需要在课程组、培训场景里快速搭建内部答疑小工具的人。文中的代码示例默认在本地环境使用文本文件模拟课件资料同时保留接入在线大模型接口的扩展位。学习环境先跑通流程再根据实际部署要求决定是否升级为向量数据库或独立模型服务。1. 核心思路问答助手为什么要先检索再回答1.1 直观联想是“把资料塞给模型”实际不能这么做假设课程资料有 300 页用户问“决策树的分裂指标有哪些”。如果直接把 300 页正文拼进提示词会出现三个典型问题。第一上下文窗口限制长文本在截断后容易丢掉关键信息。第二成本与响应时间每次提问都处理整份文档资源浪费明显。第三回答不可控模型可能把其他章节的内容或自身训练知识混进来造成“看似正确、实际跑题”。更合理的方式是模仿人的阅读习惯先通过目录或索引定位相关段落再根据具体段落回答。在工程实现中这个动作被拆成“文档分块、向量化检索、生成回答”三个阶段。问答助手变得更像一套“资料检索系统 文本生成器”而不是简单的大模型外壳。1.2 数据链路的三个核心环节第一个环节是切分课程资料往往有标题、段落、代码块和列表不能整篇当成一个整体收录。需要按章节、语义或固定长度拆成知识块同时保留“这段来源于哪个文件的哪个小节”这样的引用信息。第二个环节是索引与检索把每个知识块转换成可计算相似度的向量或特征表示用户提问时在全部知识块中找出分数最高的若干条。检索效果直接决定答案上限如果召回的片段本身不相关生成做得再好也无法挽救。第三个环节是生成把用户问题、检索到的知识块、以及回答约束一起交给大模型。约束通常包括“只能依据给定资料回答”“资料中没有的内容要明确说明不知道”。如果没有接入在线或本地大模型也可以先用规则从知识块中抽取句子形成一个不联网也能演示的最小版本。1.3 项目范围与取舍本文案例选择的是一个“能跑通、能看效果、结构清晰”的范围输入资料以.txt和.md文件为主兼容可选 PDF 加载。索引阶段使用 TF-IDF 作为基础检索方案便于本地运行和调试。回答阶段支持两种模式离线规则模式与在线大模型 API 模式。交互方式提供命令行和简单 Web 页面。暂不引入大规模向量数据库、权限管理、对话记忆、多轮追问和完整前端这些会在最后一节给出升级路径。先完成最小闭环后续扩展时才有清晰对比基线。2. 环境准备与课程资料目录设计2.1 依赖选择先把核心库控制到最少建议使用 Python 3.10 或更高版本并在独立虚拟环境中运行。依赖文件可以保持精简flask3.0,4.0 scikit-learn1.4,2.0 numpy1.26 requests2.31 pypdf4.0,5.0安装命令如下mkdir course_qa cd course_qa python -m venv .venv source .venv/bin/activate # Windows 环境使用 .venv\Scripts\activate pip install -r requirements.txt到这里要说明两点。scikit-learn在本项目中用于计算 TF-IDF 向量和余弦相似度安装包较大但教学阶段很直观。pypdf是可选依赖如果课程资料暂时只有文本文件可以先不安装代码中会做兼容处理避免启动即报错。2.2 目录结构和样例资料在course_qa目录下创建如下结构course_qa/ ├── app.py # Web 入口 ├── build_index.py # 构建索引脚本 ├── qa_engine.py # 核心问答引擎 ├── requirements.txt ├── static/ │ └── index.html # 简易页面 └── course_data/ ├── 第1章_机器学习基础.txt └── 第2章_模型评估与选择.txt准备两份最小课程资料作为测试数据。第一份内容示例机器学习基础 1.1 什么是机器学习 机器学习是一门从数据中自动学习规律并用于预测或决策的学科。 它不同于传统规则编程因为规则不再由人手工编写而是从数据中估计得到。 1.2 监督学习与无监督学习 监督学习使用带标签的数据训练模型常见任务包括分类和回归。 无监督学习不依赖标签典型任务有聚类和降维。第二份示例模型评估与选择 2.1 过拟合与欠拟合 过拟合是指模型在训练集上表现很好但在测试集上表现较差。 欠拟合则是指模型连训练数据中的规律都未充分学习。 2.2 交叉验证 交叉验证将数据集切分成多份轮流使用其中一份作为验证集。 常用方法是 K 折交叉验证例如 K10。资料文件使用 UTF-8 编码保存中文场景下不建议使用 GBK否则后续读取容易遇到乱码问题。2.3 检查点确认环境能正常导入运行以下命令确认依赖可用python -c import sklearn; print(sklearn.__version__) python -c from flask import Flask; print(flask ok)如果本机只有文本资料不安装pypdf也不会影响后续步骤。QA 引擎中会通过try判断 PDF 能力是否存在。3. 核心代码实现文档加载、分块、索引、检索与答案生成3.1 文档加载先解决编码和格式问题先设计一个知识块数据结构。它需要记录来源文件和当前标题这是后面回答溯源的基础。# qa_engine.py import os import re import json import logging from dataclasses import dataclass logger logging.getLogger(course_qa) dataclass class KnowledgeChunk: source: str heading: str content: str def load_text_document(path: str) - str: 读取 txt 或 md 文本文件统一返回字符串。 with open(path, r, encodingutf-8) as f: return f.read()PDF 加载做成可选逻辑try: from pypdf import PdfReader PDF_SUPPORT True except ImportError: PDF_SUPPORT False def load_pdf_document(path: str) - str: if not PDF_SUPPORT: raise RuntimeError(缺少 pypdf请先安装pip install pypdf) reader PdfReader(path) pages [] for page in reader.pages: text page.extract_text() if text: pages.append(text) return \n.join(pages)PDF 解析结果往往带有页眉、页脚、断行等问题直接影响分块质量。如果只是做原型验证更稳妥的做法是先把重要章节转成 Markdown 或纯文本再喂给索引。生产环境再考虑版面解析、OCR 等复杂管线。3.2 分块策略块太小丢语义块太大丢精度分块是整个项目里最值得调试的环节。一个知识块如果只有几十字术语可能被切断如果一个章节几千字全部塞进一个块检索时相关性分数会被稀释回答时也可能超过模型可接受长度。这里采用“标题感知 长度截断”的策略以#、第X章、第X节等标记作为新块起点每个块保持在 500 字以内超长时按句子边界二次切分。def _is_heading(line: str) - bool: if line.startswith(#) or line.startswith(第) and re.match(r^第[0-9一二三四五六七八九十百][章节讲], line): return True return False def _split_by_heading(filename: str, text: str) - list: lines [line.strip() for line in text.splitlines() if line.strip()] chunks [] current_heading 未分类段落 buffer [] for line in lines: if _is_heading(line): if buffer: chunks.append(KnowledgeChunk(filename, current_heading, \n.join(buffer))) current_heading line.lstrip(# ).strip() buffer [] else: buffer.append(line) if buffer: chunks.append(KnowledgeChunk(filename, current_heading, \n.join(buffer))) return chunks超长内容处理函数def _cut_long_chunk(chunk: KnowledgeChunk, max_len: int 400, overlap: int 40) - list: content chunk.content if len(content) max_len: return [chunk] result [] start 0 index 0 while start len(content): end min(start max_len, len(content)) if end len(content): cut_pos content.rfind(。, start, end) if cut_pos start max_len // 2: end cut_pos 1 result.append(KnowledgeChunk(chunk.source, chunk.heading, content[start:end])) index 1 start max(end - overlap, start 1) return result分块参数说明如下表参数含义过小的影响过大的影响max_len单块最大字符数知识点被截断检索召回不完整特征被稀释检索相关性下降overlap相邻块重叠字符数概念边界信息丢失索引数量膨胀性能下降3.3 建立 TF-IDF 索引并检索TF-IDF 的思路是用词频和逆文档频率衡量一个词对当前文本的重要性。它对中文课程资料可以作为离线教学实现足够解释“向量化”的概念。生成式大模型时代常使用语义向量但 TF-IDF 不需要额外模型下载适合快速演示。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity class CourseRetriever: def __init__(self, chunks, vectorizerNone): self.chunks chunks self.vectorizer vectorizer self.matrix None def build(self): corpus [c.content for c in self.chunks] self.vectorizer TfidfVectorizer(ngram_range(1, 2), max_features20000) self.matrix self.vectorizer.fit_transform(corpus) return self classmethod def load(cls, chunk_file: str, vectorizer_file: str): with open(chunk_file, r, encodingutf-8) as f: raw json.load(f) chunks [KnowledgeChunk(**item) for item in raw] retriever cls(chunks) retriever.vectorizer joblib.load(vectorizer_file) corpus [c.content for c in chunks] retriever.matrix retriever.vectorizer.transform(corpus) return retrieverngram_range(1, 2)表示同时考虑单个词和相邻双词。课程资料中“过拟合”“交叉验证”这类术语如果拆成单个字符会丢失语义加入 bigram 后命中率明显提升。检索函数需要同时处理相似度得分和标题命中加分def retrieve(self, question: str, topk: int 3) - list: q_vec self.vectorizer.transform([question]) sim_scores cosine_similarity(q_vec, self.matrix)[0] enriched [] for idx, chunk in enumerate(self.chunks): score 0.7 * sim_scores[idx] if any(token in chunk.heading for token in question.split()): score 0.3 enriched.append((score, idx)) enriched.sort(keylambda x: x[0], reverseTrue) return [self.chunks[idx] for _, idx in enriched[:topk]]这里使用固定比例混合并不完美但能体现“检索评分可以融合多路信号”的思想。实际项目中更常见的做法是单独统计标题命中或者使用一个可训练的重排序模型初学阶段不必过度设计。3.4 答案生成与兜底逻辑不接入在线模型时可以做一个规则抽取式回答。规则本身不能生成新句子但它能告诉你“资料里是否真的存在相关内容”。def extractive_answer(question, chunks, score_threshold0.15, topk3): if not chunks: return 暂时没有找到与问题相关的课程资料。 top chunks[0] sentences re.split(r[。\n], top.content) candidate [s for s in sentences if len(s) 3] if not candidate: return top.content return candidate[0]注意这个版本没有真实计算阈值score_threshold只作为预留参数。真正判断是否“没有资料”需要让检索函数把相似度得分同时返回。兜底回答建议写成明确的提示语不要为了让对话继续而编造内容。接入在线大模型时使用抽象接口适配不同服务商时只改LLM_ENDPOINT和解析字段即可import os import requests LLM_ENDPOINT os.getenv(LLM_ENDPOINT, ) LLM_API_KEY os.getenv(LLM_API_KEY, ) def generate_with_llm(question: str, chunks: list, timeout: int 20) - str: if not LLM_ENDPOINT: return extractive_answer(question, chunks) context \n\n.join( f来源{c.source}标题{c.heading}\n{c.content} for c in chunks ) payload { model: os.getenv(LLM_MODEL, your-model-name), messages: [ { role: system, content: 你是课程答疑助手。请只根据给定资料回答问题资料中找不到的明确说明暂未在课程资料中找到。, }, {role: user, content: f资料如下\n{context}\n\n问题{question}}, ], temperature: 0.2, } headers {Authorization: fBearer {LLM_API_KEY}} resp requests.post(LLM_ENDPOINT, jsonpayload, headersheaders, timeouttimeout) resp.raise_for_status() data resp.json() return data[choices][0][message][content]这段代码中最重要的不是请求格式而是 system 提示词里的“只根据给定资料回答”和“找不到就说明找不到”。这两条约束能显著减少幻觉比让模型自由发挥更符合教育场景。3.5 构建索引脚本单独拆出一个build_index.py每次更新课程资料后重新执行# build_index.py import os import json import joblib from qa_engine import load_text_document, _split_by_heading, _cut_long_chunk, CourseRetriever def load_all_chunks(data_dircourse_data): chunks [] for name in os.listdir(data_dir): path os.path.join(data_dir, name) if name.endswith(.txt) or name.endswith(.md): text load_text_document(path) elif name.endswith(.pdf): from qa_engine import load_pdf_document text load_pdf_document(path) else: continue for c in _split_by_heading(name, text): chunks.extend(_cut_long_chunk(c)) return chunks if __name__ __main__: chunks load_all_chunks() print(知识块数量:, len(chunks)) retriever CourseRetriever(chunks).build() with open(chunks.json, w, encodingutf-8) as f: json.dump([c.__dict__ for c in chunks], f, ensure_asciiFalse) joblib.dump(retriever.vectorizer, vectorizer.joblib) print(索引已保存)joblib用于保存TfidfVectorizer需要引入额外依赖。也可以统一改用 Pickle但注意本项目的产物仅用于本地教学不要加载来源不明的.pkl文件。4. 命令行交互与 Web 演示4.1 命令行提问先快速验证基础效果实现一个最简单的 REPL 循环def cli(): from qa_engine import CourseRetriever, generate_with_llm retriever CourseRetriever.load(chunks.json, vectorizer.joblib) print(课设问答助手已启动输入 q 退出。) while True: question input( ).strip() if question.lower() in {q, quit}: break chunks retriever.retrieve(question, topk3) answer generate_with_llm(question, chunks) print(answer) if __name__ __main__: cli()运行流程是先执行python build_index.py再执行python qa_engine.py。这里需要保证模块内的if __name__分支与导入逻辑互不干扰。4.2 Web 接口使用 Flask 暴露 API为了演示方便写一个最小的 Flask 应用# app.py from flask import Flask, request, jsonify, render_template_string from qa_engine import CourseRetriever, generate_with_llm app Flask(__name__) retriever CourseRetriever.load(chunks.json, vectorizer.joblib) app.get(/) def index(): return render_template_string( !DOCTYPE html html langzh-CN headmeta charsetUTF-8title课程资料问答助手/title/head body h1课程资料问答助手/h1 textarea idquestion rows2 cols60 placeholder输入你的问题/textarea br button onclickask()提问/button pre idanswer/pre script async function ask() { const q document.getElementById(question).value; const resp await fetch(/api/ask, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({question: q}) }); const data await resp.json(); document.getElementById(answer).textContent data.answer; } /script /body /html ) app.post(/api/ask) def ask(): body request.get_json(forceTrue) question body.get(question, ).strip() if not question: return jsonify({error: 问题不能为空}), 400 chunks retriever.retrieve(question, topk3) answer generate_with_llm(question, chunks) return jsonify({answer: answer})生产环境应当对前端 HTML 做更严格过滤这里仅用于教学演示。API 返回时临时保持chunks不出现在响应中避免调试信息暴露过多内部资料内容。5. 运行验证与效果评估5.1 用三类问题验证系统行为基于第一节准备的样例资料设计一组验证问题问题期望行为验证点什么是机器学习检索到 1.1 节返回定义检索相关性什么是过拟合检索到 2.1 节返回定义跨文件检索今天食堂有什么菜检索得分低返回兜底提示防止乱答监督学习有哪些常见任务检索到 1.2 节术语检索K 折交叉验证怎么用检索到 2.2 节术语检索执行索引构建后使用 CLI 逐条提问。在线 API 模式下注意观察回答是否围绕给定资料展开离线规则模式下重点观察打印出的 top-1 知识块是否匹配。5.2 检索质量怎么看建立索引后在retrieve里临时打印每个 top 块的来源和相似度能快速定位问题for rank, (chunk, score) in enumerate(retrieved): logger.info(rank%s score%.4f source%s heading%s, rank, score, chunk.source, chunk.heading)这里不设固定全局阈值因为 TF-IDF 相似度在不同语料上分布差异很大。可以先记录一批正常问题的最低得分再根据该值设置兜底阈值。实际推荐做法是每次至少看 top3 的得分差如果第一名比第二名高很多通常说明检索结果可信。6. 常见问题排查从现象到修复6.1 文档一个都没加载进来现象构建索引时提示“知识块数量: 0”。排查顺序确认course_data路径是否存在os.listdir是否能看到文件。确认文件扩展名是.txt或.md大小写是否一致。确认文件不是 UTF-8 BOM 开头导致前几个字符异常。在load_all_chunks中打印文件名确认匹配分支是否进入。修复时优先统一文件编码。如果从 Windows 记事本另存文件务必选择 UTF-8。6.2 检索结果相关性差现象提出“过拟合”相关问题时top-1 返回的是“什么是机器学习”块。常见原因和处理方式原因处理方式文本没有分块整篇作为一个块改用标题感知分块并确保 2.1 节内容独立成块未启用 ngram将ngram_range设为(1, 2)或(1, 3)语料中术语太稀疏增加同义词或标题加权逻辑分块过长导致特征稀释调小max_len观察效果另外要检查_is_heading的正则是否匹配到“2.1 过拟合与欠拟合”这样的行。如果该行被当成正文内容会混入上一章。6.3 回答引用了无关内容现象在线模式下回答正确但提到的来源和资料不符。这类问题通常不是生成阶段锅而是检索阶段把无关块传给了模型。先打印传入generate_with_llm的 top3 块内容确认上下文是否包含真正相关段落。如果上下文已经正确但模型仍乱答再调整提示词约束或者把资料原文用更明显的标记包裹。6.4 在线 API 超时或报错现象请求完长时间无返回或返回 5xx。处理建议设置timeout避免接口阻塞整个服务。捕获requests.RequestException失败时降级到本地规则回答。确认模型名称、接口字段和服务商文档是否一致。生产环境加入超时熔断、日志和告警。不应把 API 地址、密钥硬编码在代码里统一放到环境变量或配置管理平台。6.5 更新课程资料后结果没变化现象修改了course_data里的文本重新提问还是旧答案。原因通常是未重新执行索引构建。课程资料变更后必须重新运行python build_index.py如果后续接入了数据库或向量服务还需要考虑增量更新与版本管理。最简单流程是每次更新后整体重建数据量不大时成本可以接受。7. 从“能跑”到“能上线”生产化增强方向7.1 检索层升级从 TF-IDF 到语义向量TF-IDF 适合词语重合度高的检索场景但对“数据不均衡会导致什么现象”和“样本类别差别很大时模型会怎样”这类同义描述词面重合度低容易漏召回。当课程资料达到几千个块时建议引入语义向量检索。可选的思路包括使用开源本地 embedding 模型生成向量配合向量数据库存储。具体选型要根据部署环境和数据量决定不要盲目追求最新框架。7.2 分块与重排序当前实现的分块仍是启发式规则生产环境可以考虑按语义段落进行切分并增加候选块重排序环节。先使用轻量召回扩大候选范围再用更复杂的模型精排保证最终进入大模型的上下文质量。7.3 溯源、权限与监控教育类问答产品应当显示答案来源帮助学生定位知识点位置。生成结果里携带source和heading前端展示“参考了第 2 章 2.1 节”这类信息。资料权限也很重要不同课程导读只能访问对应目录不能把全部资料暴露给所有用户。7.4 一个可操作的上线前检查清单检查项说明数据隔离按课程或资料目录划分检索范围避免串课脱敏检查课程资料中是否含姓名、手机号等敏感信息兜底逻辑检索得分低时明确回答“未找到”避免编造接口超时设置请求超时与失败降级策略日志与监控记录检索分块、模型调用耗时、失败率人工评估准备 50 到 100 个真实问题人工标注回答质量增量更新课程更新后能自动重建或增量更新索引回答溯源页面或 API 返回来源信息方便学生核对课程资料问答助手的价值在于把静态文档变成可交互的知识库而决定体验的不是生成模型本身而是检索链路是否把正确资料送到了正确位置。建议先使用本文的最小闭环跑通一批真实课程问题记录失败案例再针对失败场景逐步优化分块规则、检索权重和提示词约束。完成这一步之后再考虑向量检索、对话记忆和更完整的 Web 服务工程上会更稳。
返回列表