news 2026/9/8 8:39:12

本地搭建RAG课程问答助手:文档解析、向量检索与大模型生成

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
本地搭建RAG课程问答助手:文档解析、向量检索与大模型生成

在做课程复习时,最烦人的往往不是资料不够,而是资料太多:课件、教材 PDF、实验报告、作业讲解散落在不同文件夹里,想确认某个知识点,只能一个一个文件打开搜索,效率很低。后来我把这些课程资料整理成了一个本地问答助手,直接提问“TCP 三次握手的过程是什么”“数据库隔离级别有哪些”,它就能从资料里检索出相关内容,并生成完整答案。这篇文章就把这套实现思路完整拆出来,从文档解析、文本切分、向量检索到大模型问答,一步步带你在本地跑通一个课程资料问答助手。

这套方案适合有一定 Python 基础、想动手做 RAG 项目的读者,也适合需要批量整理知识库的开发者。学完后,你能掌握 RAG 应用的最小闭环,并能替换自己的课程资料运行。

1. 什么是课程资料问答助手

1.1 它解决什么问题

传统的资料检索方式有两种:一是用操作系统自带搜索,按文件名或文件内容关键词匹配;二是打开每个文档后用 Ctrl+F 查找。这两种方式都只能做“关键词级”的精确匹配,但课程资料里很多知识点不会严格按提问的关键词出现。比如你问“TCP 为什么需要三次握手”,PDF 里可能只写了“三次握手的作用是确认双方收发能力”,并不包含“为什么”这个关键词,传统检索就很容易漏掉。

课程资料问答助手的思路是:先把所有课程资料解析成纯文本,切成小块,再用向量化模型把文本转换成向量存入向量数据库。当你提出问题时,系统先把问题也转成向量,在向量库里找出语义最相近的几个文本片段,最后把这些片段连同问题一起交给大语言模型,让它基于这些片段生成回答。

这个模式有一个专门的名字:RAG,也就是检索增强生成。

1.2 RAG 架构的核心流程

RAG 的全称是 Retrieval-Augmented Generation。它的核心思想不是让模型死记硬背所有资料,而是在回答时先“查资料”,再“写答案”。这样可以解决大模型知识库更新不及时、专业知识不足、容易编造内容等问题。

整个流程可以拆成两个阶段。

第一阶段是知识库构建:

  1. 解析课程资料,提取纯文本。
  2. 把长文本切分成固定大小的片段,保留上下文重叠。
  3. 用嵌入模型把每个片段转换成向量。
  4. 把向量和原文一起存入向量数据库。

第二阶段是问答检索:

  1. 用户输入问题。
  2. 把问题用同一个嵌入模型转换成向量。
  3. 在向量库中检索最相似的 TopK 个文本片段。
  4. 把问题 + 片段组成 Prompt,交给大语言模型生成答案。
  5. 返回答案并附带片段来源,方便核对。

1.3 和模型微调的区别

很多初学者会问:为什么不直接微调一个大模型,让模型学会课程知识?

微调适合“让模型学会某种表达风格、输出格式、领域术语”。但课程资料是经常变化的,每次更新都要重新准备训练集、重新训练,成本很高。RAG 的资料更新成本很低,只需要替换或新增知识库文档即可,而且每个回答都能追溯到原始资料,便于验证正确性,降低模型“胡编”的风险。

在实际项目中,RAG 和微调也不是互斥的。有的团队会先做 RAG 保障基础知识,再微调模型的输出风格,让回答更贴合业务需求。

2. 环境准备与项目结构

2.1 运行环境与依赖库

本文示例以 Python 3.10 以上版本为例。核心依赖如下:

  • langchain:统一的链式调用框架。
  • langchain-community:文档加载器等社区实现。
  • langchain-openai:OpenAI 风格的大模型和嵌入模型封装。
  • 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,注意指定encoding="utf-8",否则遇到中文容易乱码。
  • Word:用Docx2txtLoader,能读取 docx 文件的正文内容。

加载后的数据统一是 LangChain 的Document对象,它包含page_contentmetadata两部分。page_content是文本内容,metadata是来源信息,例如文件名、页码。

这里要提醒一个关键点:PDF 解析的质量直接决定后续检索效果。扫描版 PDF 本质上是一张张图片,PyPDFLoader是提取不出文字的,需要先用 OCR 识别成文本,本文不展开,但你要知道这个问题。

3.2 文本切分:为什么不能整篇存储

如果把整本教材直接交给嵌入模型生成向量,会出现两个问题:

  • 嵌入模型通常有输入长度限制,超长文本会被截断。
  • 检索粒度太粗,用户问一个具体知识点,返回的却是一整章内容,召回准确率低。

所以需要把长文本切分成小块,这就是 chunking。切分时要注意两个参数:

  • chunk_size:每个块的字符数上限。
  • chunk_overlap:相邻块之间重叠的字符数,目的是保留上下文衔接。

比如一句话被切到两个块里,如果没有重叠,后半段缺少主语,语义就不完整。增加重叠后,两个块都会包含完整的上下文。

RecursiveCharacterTextSplitter是实践中比较稳妥的切分器。它会按优先级从高到低尝试分割符:先按双换行分,再按单换行、句号、感叹号、问号等。这样能尽可能保证每个块在语义上完整。

from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""], )

中文场景下,把句号、感叹号、问号加进separators很有必要,否则切分器会优先按空格或换行切,中文句子容易被拦腰斩断。

3.3 向量化与向量数据库

向量化的作用是把文本变成一组数字,让计算机能计算“语义距离”。这里的核心是嵌入模型,它会把“猫”和“狗”的向量距离算得很近,把“猫”和“操作系统”的向量距离算得很远。

同一个嵌入模型必须同时用于知识库文档和用户问题,否则向量空间不一致,检索就失去意义。这是很多初学者容易踩的坑。

向量数据库负责存储向量,并提供相似度检索能力。Chroma 是一个很适合入门和中小项目的向量数据库,支持本地持久化,使用简单,不需要独立部署服务。检索时常用的是余弦相似度,分数越高说明语义越接近。

3.4 检索问答链路

检索问答链路分为两个动作:检索和生成。

检索阶段使用vectorstore.as_retriever(),设置k=4表示返回最相似的 4 个片段。你可以根据资料量和效果调整 k 值,k 太小容易漏,k 太大容易混入不相关内容。

生成阶段使用RetrievalQA链,把检索到的片段塞进 Prompt,让大模型基于这些片段生成回答。chain_type="stuff"表示把所有检索片段一次性放入 Prompt,适合片段数量不多的情况。

还要开启return_source_documents=True,这样返回结果里会带上参考来源,方便用户核对答案出自哪一页,也能增强可信度。

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, encoding="utf-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_size=chunk_size, chunk_overlap=chunk_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( documents=docs, embedding=embeddings, persist_directory=persist_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_name=model_name, temperature=temperature) retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) qa = RetrievalQA.from_chain_type( llm=llm, retriever=retriever, chain_type="stuff", return_source_documents=True, ) return qa

temperature建议设为 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()

主程序完成以下工作:

  1. 加载.env配置。
  2. 加载资料、切分、构建向量库。
  3. 创建问答链。
  4. 进入命令行交互循环。

4.6 完全本地化的 Ollama 方案

如果你没有外部 API Key,或者课程资料属于敏感内容不希望出网,推荐使用 Ollama 本地模型方案。

安装 Ollama 后,先在终端拉取两个模型:

ollama pull nomic-embed-text ollama pull qwen2.5

nomic-embed-text是嵌入模型,负责把文本转成向量;qwen2.5是对话模型,负责生成答案。

修改vector_store.py

from 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(model="nomic-embed-text") vectorstore = Chroma.from_documents( documents=docs, embedding=embeddings, persist_directory=persist_dir, ) return vectorstore def create_qa_chain_local(vectorstore, model_name="qwen2.5", temperature=0.2): llm = ChatOllama(model=model_name, temperature=temperature) retriever = vectorstore.as_retriever(search_kwargs={"k": 4}) from langchain.chains import RetrievalQA qa = RetrievalQA.from_chain_type( llm=llm, retriever=retriever, chain_type="stuff", return_source_documents=True, ) return qa

然后修改main.py中的调用部分,把build_vector_store换成build_vector_store_localcreate_qa_chain换成create_qa_chain_local。整个流程就完全离线了,不需要任何外部 API。

这里要特别提醒:本地嵌入模型和对话模型对中文的支持能力各不相同。同一个文本切分策略,在不同模型下的检索效果差异很大,需要实际测试后调整chunk_sizek值。

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_directory=persist_dir, embedding_function=embeddings, ) 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( template=prompt_template, input_variables=["context", "question"], )

然后在创建RetrievalQA时传入:

qa = RetrievalQA.from_chain_type( llm=llm, retriever=retriever, chain_type="stuff", return_source_documents=True, chain_type_kwargs={"prompt": QA_PROMPT}, )

这样的回答会更可控,避免模型把泛化知识混入课程资料答案里。

7.5 安全与合规注意事项

  • 课程资料如果包含个人隐私、未公开内容,尽量使用本地模型方案,避免资料外传。
  • 调用外部 API 时,遵守服务商的使用条款,不要批量抓取或转售内容。
  • .env文件不要提交到 Git,API Key 泄露可能导致盗刷。
  • 涉及他人版权资料的知识库,只用于个人学习研究,不要公开发布。

8. 总结与学习路线

这个案例把课程资料问答助手的完整链路跑通了一遍:文档加载、文本切分、向量化、向量检索、大模型生成。你掌握的不只是几个函数,而是 RAG 应用的最小闭环。后续可以把命令行界面换成 FastAPI 接口,做一个网页版问答系统;也可以接入飞书或企微机器人,变成真正的课程答疑工具。

下一步建议按这个顺序深入:

  • 先替换成自己的课程资料,调参优化切分和检索效果。
  • 尝试不同的嵌入模型,对比向量检索的准确率。
  • 学习 LangGraph,给系统加上多轮对话和意图判断。
  • 了解重排序模型,在向量检索后再做一次精排,进一步提升答案质量。

动手跑一遍,比只看文章有用得多。如果在运行过程中遇到报错,优先检查依赖版本和导入路径,大部分问题都能在控制台日志里找到线索。祝你的课程问答助手顺利跑起来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 8:36:58

博途V15.1与S7-1200实现六部十层电梯PLC参考程序设计

1. 项目整体设计与思路拆解1.1 为什么选博途V15.1和S7-1200系列做电梯控制这个领域的老工程师都知道,TIA Portal版本迭代快得让人头疼,从V13一路到现在的V21,每个版本都有各自的脾气。但如果你让我推荐一个最稳妥、最适合做中小型PLC项目参考…

作者头像 李华
网站建设 2026/9/8 8:36:54

AI无限画布与角色换装:Stable Diffusion短视频创作实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 8:34:43

树莓派Pico ADC从寄存器到应用的全面避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 8:34:14

继续教育论文写作怎么选AI工具?千笔AI写作与WPS AI实测对比

你问我继续教育论文这档子事,算是问对人了。这两年AI工具井喷,身边不少读在职研、专升本、评职称的朋友都在纠结:到底是像千笔AI写作这种专门做论文的AI靠谱,还是直接拿WPS AI这种办公全家桶硬上?我自己也把这两类工具…

作者头像 李华
网站建设 2026/9/8 8:33:34

2D-RoPE位置编码:解决Transformer长文本处理的位置感知难题

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/8 8:33:26

命令行文件编码检测工具原理与实现:从乱码到自动识别

简介:面向Java开发者的文件编码检查与转换工具,支持多种主流编码格式的自动识别与相互转换,包括GBK、ISO-8859-1以及UTF-8、UTF-16、UTF-32等常见类型,并特别区分带BOM与不带BOM两种形态,能够有效解决中文乱码、编码迁…

作者头像 李华