简介:面向计算机、通信、人工智能、自动化等相关专业的学生、老师或从业者,这是一份基于LangChain与ChatGLM-6B等系列LLM构建针对本地知识库自动问答系统的毕业设计项目,适合作为期末课程设计、课程大作业或毕业设计参考。压缩包共76个文件,除12个Python源码、39个pickle数据缓存外,还包含文本切分、向量化嵌入、模型调用等关键模块,以及说明文档、手册、Docker配置等资料,整体约17.96MB,目录结构清晰,便于按模块阅读和二次改造。项目已完成调试测试,答辩评审分达到98分,从文本加载、切分、向量化到模型接入与问答链路均有完整实现,既适合小白系统学习,也为基础较好的读者预留了扩展空间。目前已有251人学习下载,可作为理解本地知识库自动问答系统落地方式的实用样例。
1. 给本地知识库做自动问答:LangChain和ChatGLM-6B要解决的三个问题
做企业内部知识库问答,最常见的误区是第一时间想到调用在线大模型API。但试过的人很快会发现三个现实问题:数据不能出内网、单次问答成本随调用量线性上涨、回答内容不受你控制。这个标题给的路线是把LangChain和ChatGLM-6B这类LLM直接跑在本地,再把文档切块、向量化、检索、生成串成一条自动问答流水线,让本地知识库拥有一个能说自然语言的接口。它适合手里有几十到几万份文档、想让同事用中文提问就能拿到带依据回答的团队,也适合个人开发者想在消费级显卡上复现一个完整RAG项目。先说明白:这是一条工程链路,不是装个依赖就能跑的玩具,下面的每个环节都会决定回答质量。
2. 先定技术路线:RAG链路、ChatGLM-6B的选型理由与LangChain/LangGraph关系
2.1 把“本地知识库自动问答”拆成五段RAG流水线
自动问答系统的核心不是让模型“记住”知识库内容,而是把知识库变成模型在回答时可以查阅的资料。这套方案的通用名字是RAG,检索增强生成。它的流程固定为五段:文档加载、文本切分、向量化入库、相似度检索、LLM生成回答。
第一段处理的是原始文件,txt、md、PDF、Word都可能出现,加载后要清理掉页眉页脚和多余换行。第二段把长文档切成固定大小的文本块,这是后面检索的基本单位。第三段用embedding模型把每个文本块转成向量,写入向量数据库。第四段在用户提问时把问题向量化,在库里找相似度最高的几个文本块。第五段把“问题+检索到的文本块”拼成提示词,交给ChatGLM-6B生成最终回答。
为什么要绕这么一圈而不是直接把整个知识库塞给模型?因为ChatGLM-6B这类LLM的上下文窗口有限,真正能影响回答质量的往往只是与问题相关的几段内容。RAG把“大海捞针”变成“先捞针再让模型读”,这也是当前本地知识库项目的主流做法。理解这一点后,再去选具体的组件就不会被框架牵着走。
2.2 为什么第一版LLM选ChatGLM-6B而不是更大参数的模型
模型选型是这个项目里最影响交付周期的一步。ChatGLM-6B是清华大学开源的中英双语对话模型,参数规模约60亿,核心优势在于中文理解和生成能力在同等量级的开源模型里表现稳定,而且官方提供了int4量化版本,让8GB到12GB显存的消费级显卡也能跑。对比一下常见选型会更直观:
| 模型 | 参数量 | FP16推理显存下限 | int4量化显存下限 | 中文能力 |
|---|---|---|---|---|
| ChatGLM-6B | 约6B | 约13GB | 约6GB | 强 |
| Qwen-7B | 约7B | 约15GB | 约7GB | 强 |
| Llama-2-7B | 约7B | 约14GB | 约6GB | 中,需中文微调 |
| ChatGLM3-6B | 约6B | 约14GB | 约6GB | 更强,但依赖更新 |
如果你的显卡是RTX 3060 12GB或RTX 4060 8GB,ChatGLM-6B的int4版本就是那个“刚好能跑”的选项。这里的边界要算清楚:FP16版本加载后权重就要13GB左右,加上中间激活值和向量检索的占用,12GB显卡基本会直接OOM。int4量化之后权重降到6GB左右,才给生成阶段留出余量。
还有一个常被忽略的选型因素:生态成熟度。ChatGLM-6B发布早,网上能搜到大量踩坑记录和示例代码,遇到问题容易找到对照。换一个更新但小众的模型,你可能要自己面对tokenizer兼容性和量化脚本报错。第一版项目的目标是跑通链路,不是追最新权重,稳定性优先。
2.3 LangChain足够,LangGraph在什么时候才值得引入
LangChain在本地知识库问答里的角色是编排框架,它把文档加载器、文本分割器、embedding模型、向量库、LLM这些组件用统一接口串起来。早期版本里所有东西都在langchain一个包里,但从0.1.x开始官方做了拆分,langchain-core放核心抽象,langchain-community放社区维护的集成,向量库和文本分割器也独立成包。这个拆分是好事,安装时知道自己在装什么,报错时就知道去哪查。
很多人纠结要不要直接上LangGraph。LangGraph把流程建模成状态机,节点做事情,边做状态转移,适合需要多步推理、动态决定调用哪个工具的场景,比如“先查知识库,不够再查数据库,最后生成报表”的Agent应用。但本标题这个项目的RAG链路是固定五段,每一步都是确定的,LangChain的RetrievalQA链加上自定义prompt已经能覆盖全部需求。用了LangGraph反而多个状态定义和流转调试成本。
我给的建议是:第一版用LangChain把RAG链路跑通,当你的需求出现分叉时再迁移到LangGraph。LangGraph不排斥LangChain组件,retriever、embedding、vectorstore都可以直接复用,迁移成本不是白扔。比如以后要做多个知识库的自动路由,或者让模型自己决定是检索还是直接回答,那时候LangGraph的价值才体现出来。
3. 环境准备与模型部署:在消费级显卡上把ChatGLM-6B跑起来
3.1 依赖安装与conda选择:先把LangChain相关库装齐
环境搭建阶段最大的坑是依赖版本互相打架。我一般用conda隔离一个干净环境,Python选3.10,这个版本对当前大多数LLM相关库的兼容性最好。先装基础依赖再装模型库,顺序不能反,因为transformers和torch的版本组合会在模型加载时才暴露问题。
conda create -n langchain-qa python=3.10 -y conda activate langchain-qa pip install langchain langchain-community langchain-text-splitters langchain-chroma chromadb pip install torch==2.1.2 --index-url https://download.pytorch.org/whl/cu121 pip install transformers accelerate sentence-transformers这段命令里最容易翻车的是torch的安装。如果直接pip install torch,在很多机器上装到的是CPU版本,模型能加载但推理慢到无法接受,而且不会报错,属于典型“黑匣子”故障。用--index-url指定PyTorch官方CUDA版本,能确保拿到带CUDA支持的wheel。注意CUDA版本要和显卡驱动兼容,RTX 30系和40系显卡用cu121这个tag基本没问题。
LangChain相关包的拆分在这里体现得很直接:langchain-community提供DirectoryLoader和HuggingFaceEmbeddings等实现,langchain-text-splitters提供RecursiveCharacterTextSplitter,langchain-chroma是对Chroma向量库的LangChain适配层。少任何一个都会在import阶段报No module named。sentence-transformers则是加载bge系列embedding模型的底层依赖。
3.2 模型加载的两条路线:FP16与int4量化
显卡显存超过14GB可以跑FP16版本,12GB及以下老老实实用int4量化版。两条路线的加载代码只有模型名不同,但要注意量化版不能再用.half().cuda(),否则会报类型不匹配。下面是封装成一个LangChain可调用LLM的最小实现:
from langchain_core.language_models.llms import LLM from transformers import AutoModel, AutoTokenizer class ChatGLM6B(LLM): model: AutoModel tokenizer: AutoTokenizer @property def _llm_type(self) -> str: return "chatglm-6b" def _call(self, prompt: str, stop=None, run_manager=None, **kwargs) -> str: resp, _ = self.model.chat(self.tokenizer, prompt, history=[]) return resp @property def _identifying_params(self) -> dict: return {"model": "chatglm-6b"} def load_chatglm(model_path: str = "THUDM/chatglm-6b"): tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) model = AutoModel.from_pretrained(model_path, trust_remote_code=True).half().cuda() model.eval() return ChatGLM6B(model=model, tokenizer=tokenizer)trust_remote_code=True这个参数必须带上,ChatGLM-6B的模型定义依赖仓库内的自定义Python代码,不信任它就无法加载。建议首次加载前自己翻一遍这个模型的代码文件,确认没有可疑逻辑再执行。
显存紧张时把model_path换成"THUDM/chatglm-6b-int4",同时删掉.half(),直接.cuda()即可。int4版本加载后占用约6GB显存,但生成速度会比FP16慢一些,这是量化必须接受的代价。如果你的显卡只有6GB显存,这个方案也紧张,建议把检索用的embedding模型放到CPU上跑,把GPU显存全部留给ChatGLM-6B。
3.3 向量库与embedding模型选型:Chroma、FAISS和bge
向量库承担“从几万条文本块里快速找到最相关的几条”这个任务。三个常见选项各有适用边界:
| 向量库 | 部署方式 | 适合场景 | 优缺点 |
|---|---|---|---|
| Chroma | 嵌入式,单机即可 | 原型验证、中小规模知识库 | 配置简单,支持持久化到本地目录 |
| FAISS | 嵌入式/内存索引 | 百万级向量检索 | 检索性能高,但没有内置持久化管理 |
| Milvus | 独立服务 | 团队协作、海量知识库 | 功能完整,资源占用高,运维成本在前期偏高 |
第一次做这个项目选Chroma最省心。chromadb直接pip安装,数据持久化在一句persist_directory参数里完成,不需要额外启动服务。等到文档量超过几十万条,或者需要多人并发检索时再迁移Milvus不迟。
embedding模型的选择直接影响检索质量。中文场景推荐BAAI/bge-small-zh-v1.5,它是一个512维的中文嵌入模型,模型体积小,加载快,检索效果在中文文档上比通用多语言模型稳定得多。加载方式用HuggingFaceBgeEmbeddings封装:
from langchain_community.embeddings import HuggingFaceBgeEmbeddings embedding = HuggingFaceBgeEmbeddings( model_name="BAAI/bge-small-zh-v1.5", model_kwargs={"device": "cpu"}, encode_kwargs={"normalize_embeddings": True}, )normalize_embeddings设为True,做相似度计算时用余弦距离会更稳定,这个参数容易被漏掉,但漏掉后检索排序会明显变差。embedding模型放CPU跑开销不大,把显卡让给生成模型是更合理的资源分配。
4. 自动问答链路的落地代码:加载、切分、向量化、检索生成
4.1 文档加载:txt与PDF统一入库的事前清洗
加载不是简单的读文件,清洗环节决定了后面检索的干净程度。直接读PDF经常遇到换行符把一句话切成两半、页眉页码混进正文的情况,这些噪音会在向量化时拉低检索精度。先看代码:
from langchain_community.document_loaders import DirectoryLoader, TextLoader, PyPDFLoader txt_loader = DirectoryLoader( "./knowledge_base/", glob="**/*.txt", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, ) txt_docs = txt_loader.load() pdf_loader = PyPDFLoader("./knowledge_base/server_guide.pdf") pdf_docs = pdf_loader.load()DirectoryLoader支持用glob模式批量加载指定扩展名的文件,loader_cls指定每种文件的解析器,loader_kwargs里可以传入编码参数。这里把txt和PDF分开处理,因为PDF的解析逻辑差异大,混在一个目录加载器里容易出现报错。PyPDFLoader直接实例化,传入单个文件路径即可。
清洗这一步我一般会写一个简单函数,把文本里的空行压缩、去掉首尾空白、过滤掉明显的页眉页脚。要注意不要过度清洗,把正文里的换行符全部删掉反而会让句子粘连。经验是只处理“连续三个以上换行符”和“纯数字页码”这两类典型噪音。
4.2 文本切分:chunk_size和chunk_overlap怎么调
切分是整个RAG链路里最玄学也最影响效果的环节。切大了,一个chunk里混入多个主题,检索召回的内容不精准;切小了,语义被切断,模型得不到完整上下文。LangChain提供的RecursiveCharacterTextSplitter是默认选择:
from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", ",", " ", ""], ) chunks = splitter.split_documents(txt_docs + pdf_docs)chunk_size=400表示每个文本块最多约400个字符,这个值适合大多数中文技术文档和FAQ。chunk_overlap=80表示相邻文本块重叠80个字符,目的是避免一个完整句子刚好被切分线截断,保证关键信息不出现在边界盲区。
separators指定切分优先级,先从段落分隔符切,再到换行、句号、逗号,最后才按空格和字符硬切。这个顺序保证尽量在语义自然边界处切割。经验参考:代码类文档chunk_size可以降到200,因为代码行与行之间本身有结构;长篇幅制度文档可以提到500。调参时记住一个原则——先保证每个chunk讲清楚一件事,再考虑上下文长度。
4.3 构建向量库与检索器:embedding入库和相似度检索细节
切好的文本块要转成向量写入Chroma。这里复用前面定义的embedding模型,整个入库过程如下:
from langchain_chroma import Chroma vectorstore = Chroma.from_documents( documents=chunks, embedding=embedding, persist_directory="./kb_store", ) retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4}, )Chroma.from_documents会自动调用embedding模型把每个chunk转成向量并写入持久化目录./kb_store。第二次运行时不用重新embedding所有文档,直接Chroma(persist_directory="./kb_store", embedding_function=embedding)就能恢复已有向量库。
检索器的search_type="similarity"表示用向量的余弦相似度做召回,k=4表示每次取最相似的4个文本块。这个数字的调节空间很大:k太小可能漏掉关键信息,k太大则会把不相关的内容塞进上下文,干扰模型判断。我第一次做项目时设k=4,后来在几百条测试query上的经验是,4到6是平衡点,知识库噪音大就调小,文档分段碎就调大。
4.4 组装RetrievalQA链:提示词控制“不编造”边界
检索器就绪后,最后一步是把检索结果和用户问题一起交给ChatGLM-6B。这里要用提示词明确约束模型的行为边界,否则它会在知识库内容不足时自行脑补。代码实现:
from langchain_core.prompts import PromptTemplate from langchain.chains import RetrievalQA prompt = PromptTemplate.from_template( """你是一个企业内部知识库问答助手。请只依据以下资料片段回答用户问题,不要编造。 如果资料中没有答案,请明确回答“资料中未找到相关信息”。 资料片段: {context} 用户问题: {question} """ ) qa_chain = RetrievalQA.from_chain_type( llm=llm, retriever=retriever, chain_type="stuff", chain_type_kwargs={"prompt": prompt}, ) answer = qa_chain.invoke({"query": "服务台系统的默认超时时间是多少?"}) print(answer["result"])chain_type="stuff"表示把所有检索到的文本块一次性塞给LLM,适合chunk数量少的场景。当知识库规模很大、单次需要检索超过8个文本块时,要考虑map_rerank或refine方式,但那会明显增加生成耗时,本地模型上不推荐。
RetrievalQA内部会自动把检索到的文本块拼接成{context},把用户输入填充到{query}。这里有个容易混淆的点:invoke传入的key是query而不是question,写错会直接报找不到模板变量。打印answer["result"]拿到的是最终生成文本,要调试检索中间结果时单独调用retriever.get_relevant_documents(question)。
5. 常见问题排查:本地知识库问答翻车的几个典型现场
5.1 回答和知识库无关,先查检索召回而不是生成
现象:用户问“报销流程”,模型回答却像在介绍考勤制度,内容通顺但完全对不上。
原因:这个问题90%出在检索阶段,不是模型不会答。要么embedding模型不擅长中文语义匹配,要么切分后的chunk本身就不包含清晰答案,要么k值太小导致正确内容没进候选。
解决:先在链路中间加一个调试输出,打印每次检索命中的chunk内容:
docs = retriever.get_relevant_documents("报销流程是什么") for d in docs: print(d.page_content[:200])如果打印出来的内容确实与问题无关,按顺序检查三件事:embedding模型换成bge中文版、chunk_size调小到300到400、k值从3递增到6。如果打印内容相关但最终回答跑偏,再检查提示词里是否明确写了“只依据资料片段回答”。
5.2 一生成就OOM,显存边界被同一份模型加载两次撞穿
现象:程序启动时正常,但第一次调用llm.chat后就报CUDA out of memory,进程直接退出。
原因:最常见的是复用代码时不小心加载了两份模型。比如先加载模型做一次测试输出,又新建了一个对象走RetrievalQA链路,两份ChatGLM-6B同时占显存,12GB显卡立刻爆掉。另外max_new_tokens设得过大也会在长文本生成时撑爆显存。
解决:把模型加载封装成单例,整个进程只保留一个llm对象。同时把max_new_tokens压到512以内,生成过程中的KV Cache和激活值会随生成长度线性增长。用torch.cuda.memory_summary()观察显存占用,确认模型加载后剩余显存至少2GB再继续。
5.3 LangChain导入报错No module named:版本拆分导致的老代码翻车
现象:照着旧教程写from langchain.document_loaders import DirectoryLoader,报ModuleNotFoundError。
原因:LangChain在0.1.x版本把非核心集成拆到了langchain-community包,0.2.x后更进一步拆分出langchain-chroma、langchain-text-splitters。旧教程里的导入路径全部失效。
解决:按新路径导入。文档加载器在langchain_community.document_loaders,文本分割器在langchain_text_splitters,Chroma在langchain_chroma。如果你的项目里还有大量老代码,可以先在代码开头统一做一层兼容别名,但长期维护还是建议逐步迁到新路径。
5.4 输出截断在句子中间,答到一半就停车
现象:模型回答“报销流程是:员工填写申请表,然后提交到部门经”,最后一个“经”字后就停了。
原因:max_new_tokens设得太小,生成到上限被强制截断。ChatGLM-6B虽然能识别中文字符,但默认生成参数在长回答场景下需要显式放宽长度限制。
解决:在transformers加载时设置max_new_tokens=512,同时把do_sample=False关闭随机采样,避免长文本生成时概率漂移导致重复。如果关闭采样后质量下降,再改为temperature=0.7配合repetition_penalty=1.1。注意这两组参数在model.chat内部传递时通过generation_config传入,不要只写在外层pipeline配置里。
5.5 明明知识库有答案,模型却回答“资料中未找到相关信息”
现象:人工搜索能定位到答案所在的文档段落,但问答系统直接给出否定回答。
原因:检索结果虽然召回了正确chunk,但该chunk恰好只包含了表格内容的前半部分,答案关键字在chunk之外。或者chunk_size设得很大,答案被埋在一大段无关文字中间,模型的注意力被噪声干扰。
解决:先打印检索到的chunk内容,确认正确答案确实在context里。如果在,就把chunk_size从400降到200到250,同时增大chunk_overlap到120,让答案即使在切分边界也能被完整保留。还有一种情况是chunk内容在“资料片段”占位符里的格式与提示词模板不匹配,需要检查拼接后的完整prompt,确认{context}确实被填充了内容。
6. 收尾验证与调优:从“能答”到“能交付”的检查方法
链路跑通只是开始,真正能不能交付要看验证和调优。我的做法是准备一份带标准答案的验收集,大概20到30条真实业务问题,每条都用手工能从知识库中找到的答案做标定。然后逐个跑一遍QA链路,记录回答是否正确、是否引用了知识库内容、是否出现幻觉。判断标准就一条:回答里的每个关键事实都能在检索到的chunk里找到出处。
调优顺序固定为:先调检索再调生成。检索阶段看两个指标,一个是“命中率”,标准答案所在chunk有没有被召回;另一个是“前k命中率”,标准答案有没有排在前4位。命中率低就改chunk_size和embedding模型,前k命中率低就增大k。检索稳定后再调生成阶段,这时候才动temperature和repetition_penalty。参考参数经验:temperature=0.2保证稳定输出,max_new_tokens=512覆盖绝大多数业务答案。
最后列一个我的习惯做法:每次调参后把chunk_size、chunk_overlap、k、模型名四个参数记在配置里,验证集跑一遍后记录正确率。现在做新知识库项目,第一天就先搭这个验证集,不花两个小时把验证集建好,后面调参全靠感觉就是血泪教训。这个方向值得投入,但投入的第一件事不是优化效果,而是建好衡量效果的尺子。希望帮到你。
本文还有配套的精品资源,点击获取