本地搭建RAG课程问答助手:文档解析、向量检索与大模型生成
在做课程复习时最烦人的往往不是资料不够而是资料太多课件、教材 PDF、实验报告、作业讲解散落在不同文件夹里想确认某个知识点只能一个一个文件打开搜索效率很低。后来我把这些课程资料整理成了一个本地问答助手直接提问“TCP 三次握手的过程是什么”“数据库隔离级别有哪些”它就能从资料里检索出相关内容并生成完整答案。这篇文章就把这套实现思路完整拆出来从文档解析、文本切分、向量检索到大模型问答一步步带你在本地跑通一个课程资料问答助手。这套方案适合有一定 Python 基础、想动手做 RAG 项目的读者也适合需要批量整理知识库的开发者。学完后你能掌握 RAG 应用的最小闭环并能替换自己的课程资料运行。1. 什么是课程资料问答助手1.1 它解决什么问题传统的资料检索方式有两种一是用操作系统自带搜索按文件名或文件内容关键词匹配二是打开每个文档后用 CtrlF 查找。这两种方式都只能做“关键词级”的精确匹配但课程资料里很多知识点不会严格按提问的关键词出现。比如你问“TCP 为什么需要三次握手”PDF 里可能只写了“三次握手的作用是确认双方收发能力”并不包含“为什么”这个关键词传统检索就很容易漏掉。课程资料问答助手的思路是先把所有课程资料解析成纯文本切成小块再用向量化模型把文本转换成向量存入向量数据库。当你提出问题时系统先把问题也转成向量在向量库里找出语义最相近的几个文本片段最后把这些片段连同问题一起交给大语言模型让它基于这些片段生成回答。这个模式有一个专门的名字RAG也就是检索增强生成。1.2 RAG 架构的核心流程RAG 的全称是 Retrieval-Augmented Generation。它的核心思想不是让模型死记硬背所有资料而是在回答时先“查资料”再“写答案”。这样可以解决大模型知识库更新不及时、专业知识不足、容易编造内容等问题。整个流程可以拆成两个阶段。第一阶段是知识库构建解析课程资料提取纯文本。把长文本切分成固定大小的片段保留上下文重叠。用嵌入模型把每个片段转换成向量。把向量和原文一起存入向量数据库。第二阶段是问答检索用户输入问题。把问题用同一个嵌入模型转换成向量。在向量库中检索最相似的 TopK 个文本片段。把问题 片段组成 Prompt交给大语言模型生成答案。返回答案并附带片段来源方便核对。1.3 和模型微调的区别很多初学者会问为什么不直接微调一个大模型让模型学会课程知识微调适合“让模型学会某种表达风格、输出格式、领域术语”。但课程资料是经常变化的每次更新都要重新准备训练集、重新训练成本很高。RAG 的资料更新成本很低只需要替换或新增知识库文档即可而且每个回答都能追溯到原始资料便于验证正确性降低模型“胡编”的风险。在实际项目中RAG 和微调也不是互斥的。有的团队会先做 RAG 保障基础知识再微调模型的输出风格让回答更贴合业务需求。2. 环境准备与项目结构2.1 运行环境与依赖库本文示例以 Python 3.10 以上版本为例。核心依赖如下langchain统一的链式调用框架。langchain-community文档加载器等社区实现。langchain-openaiOpenAI 风格的大模型和嵌入模型封装。langchain-text-splitters文本切分工具。chromadb轻量级向量数据库。pypdf解析 PDF 文件。python-docx解析 Word 文件。python-dotenv读取.env配置。版本没有写死因为 LangChain 迭代速度比较快不同版本的导入路径和 API 有差异。建议先安装最新稳定版再根据报错微调代码。安装命令pip install langchain langchain-community langchain-openai langchain-text-splitters chromadb pypdf python-docx python-dotenv大模型部分本文先使用 OpenAI Chat 接口后续会提供完全本地化的替代方案。如果你没有 OpenAI Key可以直接跳到最后一部分使用 Ollama 本地模型运行效果同样完整。2.2 项目结构设计为了便于维护把整个项目按职责拆成多个文件course_qa/ ├── data/ # 课程资料存放目录放 PDF、TXT、DOCX │ └── 计算机网络.pdf ├── loaders.py # 文档加载解析 PDF/TXT/DOCX ├── splitter.py # 文本切分 ├── vector_store.py # 向量库构建与加载 ├── qa_chain.py # 检索问答链路 ├── main.py # 命令行入口 ├── requirements.txt # 依赖清单 └── .env # 存放 API Key不要提交到仓库这种设计思路和实际项目是一致的每个文件只做一件事方便单独调试和替换实现。比如以后想把 Chroma 换成 Milvus只需要改vector_store.py想换文档加载器只需要改loaders.py。2.3 API Key 配置说明在项目根目录创建.env文件OPENAI_API_KEY你的Key然后代码里通过load_dotenv()加载使用os.getenv读取。注意.env文件一定不要提交到 Git 仓库建议在.gitignore中加入.env。如果你的网络环境或业务需求不适合使用外部 API可以使用第 4.6 节提供的本地模型方案完全离线运行。3. 核心模块原理拆解3.1 文档加载把 PDF/Word/TXT 变成纯文本课程资料的格式五花八门最主流的是 PDF、Word、TXT。不同格式的解析方式不同PDF用PyPDFLoader它会按页读取 PDF 内容并在metadata中带上page页码。TXT用TextLoader注意指定encodingutf-8否则遇到中文容易乱码。Word用Docx2txtLoader能读取 docx 文件的正文内容。加载后的数据统一是 LangChain 的Document对象它包含page_content和metadata两部分。page_content是文本内容metadata是来源信息例如文件名、页码。这里要提醒一个关键点PDF 解析的质量直接决定后续检索效果。扫描版 PDF 本质上是一张张图片PyPDFLoader是提取不出文字的需要先用 OCR 识别成文本本文不展开但你要知道这个问题。3.2 文本切分为什么不能整篇存储如果把整本教材直接交给嵌入模型生成向量会出现两个问题嵌入模型通常有输入长度限制超长文本会被截断。检索粒度太粗用户问一个具体知识点返回的却是一整章内容召回准确率低。所以需要把长文本切分成小块这就是 chunking。切分时要注意两个参数chunk_size每个块的字符数上限。chunk_overlap相邻块之间重叠的字符数目的是保留上下文衔接。比如一句话被切到两个块里如果没有重叠后半段缺少主语语义就不完整。增加重叠后两个块都会包含完整的上下文。RecursiveCharacterTextSplitter是实践中比较稳妥的切分器。它会按优先级从高到低尝试分割符先按双换行分再按单换行、句号、感叹号、问号等。这样能尽可能保证每个块在语义上完整。from langchain_text_splitters import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , ., , ], )中文场景下把句号、感叹号、问号加进separators很有必要否则切分器会优先按空格或换行切中文句子容易被拦腰斩断。3.3 向量化与向量数据库向量化的作用是把文本变成一组数字让计算机能计算“语义距离”。这里的核心是嵌入模型它会把“猫”和“狗”的向量距离算得很近把“猫”和“操作系统”的向量距离算得很远。同一个嵌入模型必须同时用于知识库文档和用户问题否则向量空间不一致检索就失去意义。这是很多初学者容易踩的坑。向量数据库负责存储向量并提供相似度检索能力。Chroma 是一个很适合入门和中小项目的向量数据库支持本地持久化使用简单不需要独立部署服务。检索时常用的是余弦相似度分数越高说明语义越接近。3.4 检索问答链路检索问答链路分为两个动作检索和生成。检索阶段使用vectorstore.as_retriever()设置k4表示返回最相似的 4 个片段。你可以根据资料量和效果调整 k 值k 太小容易漏k 太大容易混入不相关内容。生成阶段使用RetrievalQA链把检索到的片段塞进 Prompt让大模型基于这些片段生成回答。chain_typestuff表示把所有检索片段一次性放入 Prompt适合片段数量不多的情况。还要开启return_source_documentsTrue这样返回结果里会带上参考来源方便用户核对答案出自哪一页也能增强可信度。4. 完整代码实现4.1 文档加载模块创建loaders.py文件import os from typing import List from langchain_community.document_loaders import ( Docx2txtLoader, PyPDFLoader, TextLoader, ) from langchain_core.documents import Document def load_documents(data_dir: str data) - List[Document]: docs [] for root, _, files in os.walk(data_dir): for file in files: path os.path.join(root, file) if file.endswith(.pdf): docs.extend(PyPDFLoader(path).load()) elif file.endswith(.txt): docs.extend(TextLoader(path, encodingutf-8).load()) elif file.endswith(.docx): docs.extend(Docx2txtLoader(path).load()) else: print(f跳过不支持的文件类型: {file}) return docs这个模块遍历data目录下所有文件根据扩展名选择解析器。每个Document对象的metadata中会自动带上文件路径PDF 还会有页码。4.2 文本切分模块创建splitter.py文件from typing import List from langchain_core.documents import Document from langchain_text_splitters import RecursiveCharacterTextSplitter def split_documents( docs: List[Document], chunk_size: int 500, chunk_overlap: int 50, ) - List[Document]: splitter RecursiveCharacterTextSplitter( chunk_sizechunk_size, chunk_overlapchunk_overlap, separators[\n\n, \n, 。, , , ., , ], ) chunks splitter.split_documents(docs) return chunks切分后的每个块仍然保留原文档的metadata。这样后面检索到某个片段时还能知道它来自哪个文件、第几页。4.3 向量库构建模块创建vector_store.py文件from typing import List from langchain_core.documents import Document from langchain_community.vectorstores import Chroma from langchain_openai import OpenAIEmbeddings def build_vector_store( docs: List[Document], persist_dir: str ./chroma_db, ): embeddings OpenAIEmbeddings() vectorstore Chroma.from_documents( documentsdocs, embeddingembeddings, persist_directorypersist_dir, ) return vectorstore这里使用Chroma.from_documents一步完成向量化和入库。如果persist_directory目录下已经有向量数据再调用from_documents会追加写入。你可以通过Chroma(persist_directory..., embedding_function...)单独加载已有向量库避免重复提交费用。4.4 检索问答链路模块创建qa_chain.py文件from langchain.chains import RetrievalQA from langchain_openai import ChatOpenAI def create_qa_chain( vectorstore, model_name: str gpt-3.5-turbo, temperature: float 0.2, ): llm ChatOpenAI(model_namemodel_name, temperaturetemperature) retriever vectorstore.as_retriever(search_kwargs{k: 4}) qa RetrievalQA.from_chain_type( llmllm, retrieverretriever, chain_typestuff, return_source_documentsTrue, ) return qatemperature建议设为 0.2 左右。回答课程知识类问题时我们希望答案尽量稳定、忠于资料而不是过于发散。如果做创意写作才需要调高温度。4.5 命令行主程序创建main.py文件import os from dotenv import load_dotenv from loaders import load_documents from qa_chain import create_qa_chain from splitter import split_documents from vector_store import build_vector_store load_dotenv() def main(): print(正在加载课程资料...) docs load_documents(data) if not docs: print(未在 data 目录下找到 PDF/TXT/DOCX 文件请先放入课程资料。) return print(f共加载 {len(docs)} 个原始文本块) print(正在切分文本...) chunks split_documents(docs) print(f切分后共 {len(chunks)} 个片段) print(正在构建向量库...) vectorstore build_vector_store(chunks) print(向量库构建完成) qa_chain create_qa_chain(vectorstore) print(\n课程资料问答助手已就绪输入问题开始提问输入 exit 退出。\n) while True: question input(你).strip() if question.lower() exit: break if not question: continue result qa_chain.invoke({query: question}) print(\n助手, result[result]) print(\n参考来源) seen set() for doc in result[source_documents]: source doc.metadata.get(source, 未知来源) page doc.metadata.get(page) key (source, page) if key in seen: continue seen.add(key) page_info f第 {page} 页 if page is not None else print(f- {source} {page_info}.strip()) print() if __name__ __main__: main()主程序完成以下工作加载.env配置。加载资料、切分、构建向量库。创建问答链。进入命令行交互循环。4.6 完全本地化的 Ollama 方案如果你没有外部 API Key或者课程资料属于敏感内容不希望出网推荐使用 Ollama 本地模型方案。安装 Ollama 后先在终端拉取两个模型ollama pull nomic-embed-text ollama pull qwen2.5nomic-embed-text是嵌入模型负责把文本转成向量qwen2.5是对话模型负责生成答案。修改vector_store.pyfrom langchain_community.chat_models import ChatOllama from langchain_community.embeddings import OllamaEmbeddings from langchain_community.vectorstores import Chroma def build_vector_store_local(docs, persist_dir./chroma_db): embeddings OllamaEmbeddings(modelnomic-embed-text) vectorstore Chroma.from_documents( documentsdocs, embeddingembeddings, persist_directorypersist_dir, ) return vectorstore def create_qa_chain_local(vectorstore, model_nameqwen2.5, temperature0.2): llm ChatOllama(modelmodel_name, temperaturetemperature) retriever vectorstore.as_retriever(search_kwargs{k: 4}) from langchain.chains import RetrievalQA qa RetrievalQA.from_chain_type( llmllm, retrieverretriever, chain_typestuff, return_source_documentsTrue, ) return qa然后修改main.py中的调用部分把build_vector_store换成build_vector_store_localcreate_qa_chain换成create_qa_chain_local。整个流程就完全离线了不需要任何外部 API。这里要特别提醒本地嵌入模型和对话模型对中文的支持能力各不相同。同一个文本切分策略在不同模型下的检索效果差异很大需要实际测试后调整chunk_size和k值。5. 运行与验证5.1 准备测试资料在data目录下放一份课程资料。为了让结果可复现建议先放一份内容规范、结构清晰的 PDF 或 TXT。比如《计算机网络》课程笔记里面包含“TCP 三次握手”“HTTP 与 HTTPS 的区别”等章节。也可以放一门课的实验报告或者一份 Markdown 导出的 TXT 笔记。关键是文本能被正常解析扫描版 PDF 不行。5.2 启动与提问在项目根目录执行python main.py首次运行会自动安装的依赖已经就绪构建向量库需要一些时间嵌入模型会对每个文本片段生成向量。如果资料较多可以观察控制台输出确认切分后的片段数量是否合理。启动成功后可以尝试提问你TCP 三次握手的过程是什么 助手TCP 三次握手是指客户端与服务器建立连接时需要经过三个步骤 1. 客户端发送 SYN 报文进入 SYN_SENT 状态。 2. 服务器收到后回复 SYN ACK 报文进入 SYN_RCVD 状态。 3. 客户端收到后发送 ACK 报文双方进入 ESTABLISHED 状态。 三次握手的目的是确认双方的发送和接收能力都正常防止历史重复连接导致资源浪费。 参考来源 - data/计算机网络.pdf 第 23 页这里能看到两个重要结果答案不是大模型凭空编出来的而是基于资料里的内容生成的。返回结果带上了参考来源方便你翻回原课件核对。5.3 关于多轮对话的说明当前这个 Demo 没有对话记忆。每次提问都是独立的模型不会记得你上一轮问了什么。如果希望实现“根据上一轮问题继续追问”需要引入 ConversationBufferMemory 或改用 LangGraph 维护多轮状态。课程资料问答场景下大部分问题都是单轮知识查询这个简化是合理的。6. 常见问题与排查思路问题现象常见原因解决思路资料加载后文档列表为空data 目录下放入了不支持的格式只放 .pdf、.txt、.docx检查扩展名大小写PDF 解析出来是乱码或空内容使用了扫描版 PDF先 OCR 识别再加载文本中文文本被切得语义不完整切分器没有中文分隔符在 separators 中加入。等标点检索结果与问题无关嵌入模型或切分粒度不合理调小 chunk_size增加检索 k尝试更换嵌入模型调用 API 超时网络不稳定或请求量过大检查网络增加重试机制或改用本地模型向量库重复构建消耗额度每次运行都调用 from_documents如果向量库已存在先加载已有向量库回答内容脱离资料Prompt 没有强约束使用带严格约束的自定义 Prompt要求只基于资料回答这里再补充一个常见误区很多人看到回答不理想第一反应是换大模型但实际上大部分问题出在切分和检索环节。可以先打印source_documents看看系统到底检索到了什么片段。如果片段内容本身就答非所问那问题一定出在切分或向量化环节而不是生成环节。7. 工程化最佳实践7.1 资料预处理与清洗课程资料的质量决定问答效果。最好先做一遍清洗删除页眉页脚、目录、重复空白、公式乱码等内容。PDF 解析过程中很容易混入页面水印和页码这些内容会干扰向量检索。建议在切分前对文本做正则清洗。import re def clean_text(text: str) - str: text re.sub(r\n{3,}, \n\n, text) text re.sub(r[ \t]{2,}, , text) text re.sub(r\ufeff, , text) return text.strip()7.2 向量库的增量更新课程资料会不断更新不要每次重新构建全部向量库。更合适的做法是把向量库持久化在指定目录新增资料时单独加载新文档追加写入def add_documents_to_store( docs, persist_dir./chroma_db, ): embeddings OpenAIEmbeddings() vectorstore Chroma( persist_directorypersist_dir, embedding_functionembeddings, ) vectorstore.add_documents(docs)注意目前简单的 Chroma 持久化方案缺少“文档版本管理”。如果某份资料已经更新旧向量还会残留在库里检索时可能同时命中新旧版本。生产环境建议引入集合隔离或向量库自带的分区能力。7.3 API Key 与成本控制外部 API 方案一定有成本且成本主要来自向量化和大模型生成两部分。建议把向量库持久化避免每次运行重复向量化。检索时先控制k值不要一次性塞太多片段。对长资料设置合理的chunk_size减少不必要的重复。监控 API 调用量和费用设置月度预算。7.4 提示词约束默认RetrievalQA的 Prompt 比较通用在课程资料问答场景下建议自定义 Prompt要求模型只回答资料中覆盖的内容资料中没有的直接说明不知道from langchain_core.prompts import PromptTemplate prompt_template 你是一个课程资料问答助手。 请只根据以下资料内容回答用户问题。 如果资料中没有相关信息请直接回答“资料中没有找到相关内容”不要编造。 资料内容 {context} 用户问题 {question} QA_PROMPT PromptTemplate( templateprompt_template, input_variables[context, question], )然后在创建RetrievalQA时传入qa RetrievalQA.from_chain_type( llmllm, retrieverretriever, chain_typestuff, return_source_documentsTrue, chain_type_kwargs{prompt: QA_PROMPT}, )这样的回答会更可控避免模型把泛化知识混入课程资料答案里。7.5 安全与合规注意事项课程资料如果包含个人隐私、未公开内容尽量使用本地模型方案避免资料外传。调用外部 API 时遵守服务商的使用条款不要批量抓取或转售内容。.env文件不要提交到 GitAPI Key 泄露可能导致盗刷。涉及他人版权资料的知识库只用于个人学习研究不要公开发布。8. 总结与学习路线这个案例把课程资料问答助手的完整链路跑通了一遍文档加载、文本切分、向量化、向量检索、大模型生成。你掌握的不只是几个函数而是 RAG 应用的最小闭环。后续可以把命令行界面换成 FastAPI 接口做一个网页版问答系统也可以接入飞书或企微机器人变成真正的课程答疑工具。下一步建议按这个顺序深入先替换成自己的课程资料调参优化切分和检索效果。尝试不同的嵌入模型对比向量检索的准确率。学习 LangGraph给系统加上多轮对话和意图判断。了解重排序模型在向量检索后再做一次精排进一步提升答案质量。动手跑一遍比只看文章有用得多。如果在运行过程中遇到报错优先检查依赖版本和导入路径大部分问题都能在控制台日志里找到线索。祝你的课程问答助手顺利跑起来。