基于LangChain和ChatGLM-6B的本地知识库问答系统全解析

发布时间:2026/9/28 6:07:59
基于LangChain和ChatGLM-6B的本地知识库问答系统全解析 简介这是一套面向高校毕业设计与课程设计场景的人工智能项目实践基于LangChain框架与ChatGLM-6B等系列大语言模型实现针对本地知识库的自动问答。项目源代码均经过调试测试可稳定运行答辩评审分达到98分适合计算机、通信、人工智能、自动化等专业学生作为课程大作业或毕设参考也适合初学者进阶学习。资源包共76个文件、约17.96MB以12个Python程序文件为核心覆盖Web界面app.py、大模型调用chatglm_llm.py、文本向量化paddle_embedding.py等模块辅以39个pickle预训练数据、6个Markdown文档、6张演示截图另含Dockerfile、依赖配置与部署手册便于环境快速搭建。项目流程涵盖中英文文本切分、向量嵌入、多模型调用与CLI/Web交互并附带FAQ常见问题与离线部署说明可帮助读者完整理解本地知识库问答的搭建思路。已有251人学习下载值得作为实战参考。1. 前言手把手复现一个本地知识库问答系统从 LangChain 到 ChatGLM-6B做这个项目之前我一直在纠结一件事市面上的大模型问答方案很多但真正能跑通「上传自己的 PDF 和 Word然后让模型基于这些资料回答」的完整代码其实并不多。要么依赖 OpenAI 的 API要么模型和向量化全都走云端数据安全根本没保障。这套基于 LangChain 和 ChatGLM-6B 的本地知识库自动问答项目好就好在把整条链路都落在了本地——文档加载、文本切分、向量化、检索、LLM 生成每一步都有对应代码而且用的是国产模型和国内可访问的依赖源。对正在做毕设、课程设计或者想在企业内部搭一套私有化知识库的工程师来说这是一份可以直接照着复现的完整工程而不是零散的 Demo。我个人最看重的是它的工程完整度既有app.py的 Web 交互界面Gradio也有cli.py命令行问答入口还有chatllm.py和chatglm_llm.py这类把 LLM 封装成 LangChain 标准接口的模块。这意味着你不只能跑通还能在它的基础上换模型、换向量库、换成自己的文档集。2. 先拆代码结构这套问答系统到底由哪些模块组成不管什么项目拿到手的第一步不是急着装依赖跑python main.py而是先把代码文件过一遍搞清楚每个文件的职责。这套项目的核心目录结构很清晰我按功能拆成了几组。加载层chinese_text_splitter.py负责中文文档的切分paddle_embedding.py负责把文本转成向量。这两者是整个系统的地基——如果切分和向量化出问题后面检索和回答全部白搭。模型层chatglm_llm.py是对 ChatGLM-6B 的封装chatllm.py是更通用的 LLM 调用封装。它们的任务是把 HuggingFace / ModelScope 上的模型包装成 LangChain 能识别的LLM子类这样 LangChain 的RetrievalQA链才能正常调用。应用层app.pyGradio 网页问答、cli.py命令行问答、config.py全局配置。这一层是给最终用户用的入口。辅助文件requirements.txt是环境清单Dockerfile和Dockerfile.Base是容器化部署用的OfflineDeploy.md是离线部署手册modelscope目录下面应该是模型下载相关的脚本。2.1 配置文件设计config.py 和 requirements.txt 解读先看requirements.txt这套项目的依赖其实暴露了它的技术选型。我建议你打开这个文件逐行看重点关注这几个包langchain这是整个项目的编排框架负责把文档加载、切分、向量化、检索、生成串起来。chromadb或faiss-cpu向量数据库。这个项目默认用的是 FAISS它在内存中构建索引适合单机场景。paddlepaddlePaddleNLP 的底座paddle_embedding.py依赖它做中文向量化。streamlit或gradioWeb 界面框架。再看config.py它定义了embedding_model_dict、llm_model_dict等核心字典。其中llm_model_dict里配置的是 ChatGLM-6B 的本地路径或 ModelScope 的模型 ID。这里有个关键参数torch_dtypetorch.float16这是为了在消费级显卡上省显存。如果你的显卡是 6GB 显存直接用 fp16 加载 6B 模型会爆显存常见做法是改成4bit量化——也就是在模型加载时加load_in_4bitTrue。2.2 入口文件 app.py 和 cli.py一个 Web 界面、一个命令行app.py启动后是一个 Gradio 界面页面上有「上传文档」和「输入问题」两个区域。它内部调用的核心逻辑是load_chain()函数这个函数做四件事加载文档、切分、向量化、创建RetrievalQA链。代码逻辑大致是这样的from langchain.vectorstores import FAISS from langchain.chains import RetrievalQA from config import embedding_model_dict, llm_model_dict from paddle_embedding import PaddleEmbeddings from chatllm import ChatLLM def load_chain(): # 1. 初始化 embedding 模型 embeddings PaddleEmbeddings( model_nameembedding_model_dict[paddle], pathembedding_model_dict[paddle_path] ) # 2. 加载向量库 vector_store FAISS.load_local( vector_store, embeddings, allow_dangerous_deserializationTrue ) # 3. 初始化大模型 llm ChatLLM(llm_model_dict[chatglm-6b]) # 4. 构建问答链 qa_chain RetrievalQA.from_chain_type( llmllm, chain_typestuff, retrievervector_store.as_retriever(search_kwargs{k: 3}) ) return qa_chain这里我需要强调几个参数allow_dangerous_deserializationTrue是 FAISS 加载时必需的因为我加载的是本地生成的索引文件如果不加这个参数新版 langchain 会直接报安全错误。search_kwargs{k: 3}表示每次检索取回最相关的 3 个文档片段送给 LLM这个值不是越大越好——k 太大会把不相关的片段混进上下文k 太小的又可能漏答案我调过几次中文知识库场景下 k3 或 k4 效果最稳。cli.py就简单多了它是一个循环读输入 → 调qa_chain({query: question})→ 打印答案。这个入口非常适合调试因为你可以在终端里直接看检索到了什么、回答了什么不用开浏览器。当系统回答不对的时候我一般会先改cli.py把RetrievalQA里的return_source_documentsTrue打开看看是哪几个片段导致的。3. 中文文本切分与向量化chinese_text_splitter 和 paddle_embedding 的实战改造很多人在跑知识库问答时翻车翻在文本切分这一步。LangChain 自带的CharacterTextSplitter默认按\n\n切分英文文档还行但中文的 PDF 和 Word 转出来的文本经常是一整段没有换行符或者全是句号。如果你直接把整篇文档塞给 embedding 模型向量维度撑不住检索也会有大量噪声。这套项目的chinese_text_splitter.py存在的意义就是解决中文切分的粒度问题。3.1 文本切分器的核心逻辑与参数调整我们自己来看这个切分器是怎么工作的。它继承自langchain.text_splitter.TextSplitter核心改动点在分隔符的计算上。它不再是简单找\n\n而是把中文的句号、逗号、分号、感叹号都视为可切分的边界。import re from langchain.text_splitter import TextSplitter class ChineseTextSplitter(TextSplitter): def __init__(self, pdfFalse, **kwargs): super().__init__(**kwargs) self.pdf pdf def split_text(self, text: str) - list[str]: if self.pdf: text re.sub(r\n{3,}, r\n, text) text re.sub(r\s, , text) # 将常见中文标点替换为换行符作为切分边界 sent_sep_pattern re.compile( r([﹒﹔﹖﹗。][’”」』]*) ) sentences sent_sep_pattern.split(text) chunks [] current_chunk for sentence in sentences: if not sentence: continue current_chunk sentence if len(current_chunk) self._chunk_size: chunks.append(current_chunk) current_chunk if current_chunk: chunks.append(current_chunk) return chunks这段代码的关键在sent_sep_pattern——它匹配的是中文标点匹配到的标点后面会带一个换行边界。_chunk_size是 LangChainTextSplitter父类的参数默认 200 字符左右。我实际跑项目时会把chunk_size调到 300chunk_overlap调到 50。为什么因为 200 太短会把一个完整的知识点拦腰截断导致向量化之后的片段语义不完整300 加上 50 的重叠能保证前一个片段的尾部信息出现在后一个片段头部检索时不容易漏。3.2 PaddleEmbedding用 PaddleNLP 替代 OpenAI Embedding 的完整思路再说paddle_embedding.py这是这套项目能离线运行的核心功臣。OpenAI 的text-embedding-ada-002在国内访问不稳定且数据要过墙所以在本地知识库场景下大家普遍换成国产 embedding 模型。PaddleNLP 的ernie-text-encoder是常见选择它基于 ERNIE 的语义理解能力对中文长文本的向量化效果是可以接受的。下面是PaddleEmbeddings类的关键实现from langchain.embeddings.base import Embeddings from paddlenlp.transformers import AutoModel, AutoTokenizer class PaddleEmbeddings(Embeddings): def __init__(self, model_name, path): self.tokenizer AutoTokenizer.from_pretrained(model_name) self.model AutoModel.from_pretrained(model_name) self.model.eval() self.path path def embed_documents(self, texts): # 对文档列表批量向量化 embeddings [] for text in texts: inputs self.tokenizer( text, return_tensorspd, max_length512, truncationTrue ) with paddle.no_grad(): outputs self.model(**inputs) # 取 [CLS] 向量作为整个句子的表征 embeddings.append(outputs[0][0].numpy().tolist()) return embeddings def embed_query(self, text): # 查询和文档用同一个编码器 return self.embed_documents([text])[0]这里有个实现细节值得抄作业outputs[0][0]取的是模型输出的第一个 token[CLS]的向量。中文语义模型里[CLS] 向量是整句话的池化表征把它作为 embedding 输出是常见做法。max_length512是大部分中文模型的输入上限超过 512 的部分会被截断这也是为什么切分器要把 chunk 控制在 300 字符左右——留出余量避免真正切进去的时候超长截断导致语义丢失。3.3 手工构建向量库FAISS 的持久化与加载项目里没有单独的「建库」脚本的话我一般会在cli.py里加一段首次初始化逻辑或者在笔记本里手动执行。建库流程多半是这个样子from langchain.document_loaders import DirectoryLoader from langchain.text_splitter import TextSplitter from chinese_text_splitter import ChineseTextSplitter loader DirectoryLoader( docs/, glob**/*.txt, loader_clsTextLoader, loader_kwargs{encoding: utf-8} ) documents loader.load() splitter ChineseTextSplitter(chunk_size300, chunk_overlap50) texts splitter.split_documents(documents) vector_store FAISS.from_documents(texts, embeddings) vector_store.save_local(vector_store)这一步跑完之后vector_store目录下会有索引文件。注意每次往docs/里加新文档都需要重新执行一次建库否则新的知识不会被检索到。这是一个经常被忽略的操作你后面跑系统发现「我明明把文档传上去了但它答不上来」八成就是向量库没更新。4. 把 ChatGLM-6B 接进 LangChainchatglm_llm 与 ChatLLM 的封装细节先泼一盆冷水LangChain 本身不内置 ChatGLM 的调用逻辑你需要自己写一个类把 ChatGLM-6B 包装成 LangChain 能用的 LLM 接口。chatglm_llm.py做的是最核心的一件事——把 HuggingFace 的AutoModel.from_pretrained加载出来的模型塞进一个继承LLM的类里实现_call方法。4.1 继承 LLM 基类必须重写的两个方法LangChain 的LLM基类有两个抽象方法必须实现_call和_identifying_params。下面是我从chatglm_llm.py里提炼的骨架你去对照源码会发现它的核心逻辑基本一致from langchain.llms.base import LLM from typing import Optional, List, Mapping from transformers import AutoTokenizer, AutoModel class ChatGLM_LLM(LLM): model_name: str THUDM/chatglm-6b tokenizer: object None model: object None def __init__(self, model_path, temperature0.01): super().__init__() self.model_name model_path self.tokenizer AutoTokenizer.from_pretrained(model_path, trust_remote_codeTrue) self.model AutoModel.from_pretrained( model_path, trust_remote_codeTrue, device_mapauto ).half().cuda() self.model.eval() def _call(self, prompt: str, stop: Optional[List[str]] None) - str: inputs self.tokenizer(prompt, return_tensorspt).to(cuda) outputs self.model.generate( **inputs, max_new_tokens512, temperatureself.temperature, do_sampleTrue, top_p0.9 ) response self.tokenizer.decode(outputs[0][inputs[input_ids].shape[1]:], skip_special_tokensTrue) return response property def _llm_type(self) - str: return chatglm-6b property def _identifying_params(self) - Mapping: return {name_of_model: self.model_name, temperature: self.temperature}参数说明trust_remote_codeTrue很关键ChatGLM-6B 的模型代码不是标准 transformers 结构它依赖仓库里的自定义代码不加这个参数会报KeyError之类的错误。max_new_tokens512控制生成回答的最大长度跑长文问答时建议调到 1024但要注意显存占用。temperature0.01是刻意调低的——知识库问答讲究「忠实原文」温度调高会让模型自由发挥容易编造答案。4.2 统一封装ChatLLM 类如何同时兼容多系列模型chatllm.py是更上层的封装它的存在是为了让你切换模型系列时不用改调用链的代码。类名就叫ChatLLM构造函数里接收一个字典配置字典里包含model_name、model_path、api_key、api_base_url这些字段。我在自己的机器上跑的时候发现这个类会根据配置判断走本地模型还是远程 API。如果是本地就走上面ChatGLM_LLM的路线如果是远程就用requests.post请求一个兼容 OpenAI 格式的接口。chatllm.py内部应该还有一个_call到_get_response的分流方法。这样做的好处是你今天用 ChatGLM-6B明天想换成 Qwen-7B 或者百川只需要在config.py的llm_model_dict里加一条记录不用动问答链路的代码。我在给单位搭内部知识库时就吃过亏——最初写死在 ChatGLM 的类里后来换模型要把整个调用链重写。这个项目用字典驱动模型配置算是很有先见之明。4.3 构建 RetrievalQA 链从检索到生成的完整链路到这一步我要把前面的模块串起来。app.py里load_chain()执行的时候LangChain 的RetrievalQA链做的是这样的操作把用户的问题query交给retriever也就是 FAISS 的as_retriever()。retriever用embed_query把问题向量化然后在向量库里做相似度搜索。取回 top-k 文档片段拼接成一个上下文提示词prompt。把 prompt 连同原始问题一起交给llm也就是 ChatGLM-6B。得到生成文本返回给前端。这里有一个 prompt 模板的概念LangChain 官方叫PROMPT。常见模板如下from langchain.prompts import PromptTemplate QA_PROMPT PromptTemplate( template你是知识库问答助手请基于以下资料回答问题。 如果资料中没有提到请回答资料中未找到相关信息。 资料 {context} 问题{question} 回答, input_variables[context, question] )这个模板是我自己写的最简版本项目中应该也有类似的定制。注意「如果资料中没有提到」这句约束——不加这句ChatGLM-6B 会用自己的通用知识回答绕过你上传的资料这是知识库问答里最要命的幻觉问题。5. 本地部署与避坑排查从显存溢出到依赖冲突的五个血泪记录任何毕设项目从代码能跑到跑得稳中间都会踩到几个坑。这套项目我完整复现过两遍把最有代表性的问题列在这里每一条都是「现象 → 原因 → 解决」的顺序方便你直接对照。5.1 显存溢出CUDA out of memory现象app.py启动时报错CUDA out of memory有时候是加载模型时报有时候是生成回答时报。原因ChatGLM-6B 全精度 fp16 加载需要大约 13GB 显存。如果你用的是 8GB 或 6GB 显存的显卡模型加载完就已经满了生成时必然爆显存。这个项目默认用的是 fp16它没有做量化所以在低显存机器上跑不动是正常的。解决改chatglm_llm.py的加载方式用 bitsandbytes 做 4bit 量化。具体做法是把AutoModel.from_pretrained换成AutoModel.from_pretrained(..., load_in_4bitTrue, device_mapauto)这可以把显存占用压到 6GB 左右。注意前提是你requirements.txt里有bitsandbytes和accelerate。如果你连 CUDA 都没有那就别硬跑 GPU 版本了去 ModelScope 下载 CPU 版模型然后改device为cpu就是速度慢一些。5.2 中文切分器抛异常Document 类型不匹配现象用ChineseTextSplitter调用split_documents(documents)时报AttributeError: Document object has no attribute replace。原因LangChain 的新版本0.1.x 以后对TextSplitter的输入类型要求是List[Document]而TextSplitter基类的split_documents方法会先调用split_text但你的ChineseTextSplitter直接把text当成字符串处理了。旧版 LangChain 传的就是纯字符串新版传的对象。解决在使用时手动提取Document.page_content或者改造split_documents方法def split_documents(self, documents): texts [doc.page_content for doc in documents] chunks [] for text in texts: chunks.extend(self.split_text(text)) return chunks5.3 embedding 模型下载失败连接超时与 SSL 证书现象首次运行PaddleEmbeddings时代码卡在模型下载最后报SSLError或ConnectionError。原因PaddleNLP 的模型默认从 BOS百度对象存储下载部分网络环境下连接不稳定。另外有些内网环境会屏蔽外网 HTTPS 请求。解决手动下载模型文件放到本地目录然后在config.py里把model_name改为本地路径。具体做法是在 ModelScope 上搜索对应的 embedding 模型用git clone或modelscope download拉下来然后在paddle_embedding.py里加一句from paddlenlp.utils.env import HOME model_path 你的本地模型路径之后AutoTokenizer.from_pretrained(model_path)就不会触发网络下载。这个项目本身带了modelscope_hub.py用它的snapshot_download方法可以加速拉取。5.4 LangChain 版本升级后 API 变动ConversationBufferMemory 警告现象启动app.py后控制台出现大量DeprecationWarning或者直接报ImportError。原因LangChain 从 0.0.x 到 0.1.x、0.2.x 的 API 变动非常大。比如from langchain.embeddings.base import Embeddings在 0.2.x 被移动到了langchain_core.embeddingsFAISS.load_local也加了allow_dangerous_deserialization参数。项目是某个时间点写的如果requirements.txt没有锁版本你装到最新版就会直接翻车。解决按requirements.txt里的版本号安装不追求最新。我建议创建虚拟环境时指定 langchain 的版本比如pip install langchain0.1.9。如果你已经装了新版就按报错路径去langchain_core里把 import 改过来。5.5 ModelScope 下载中断离线部署手册 OfflineDeploy.md 的使用现象集群环境下没有外网modelscope_hub.py走到一半就断线模型文件残缺。原因模型文件 3~5GB下载中断后不会自动断点续传。这个项目的设计初衷是本地化运行所以它配套了OfflineDeploy.md——离线部署手册。方法很简单在有一台联网的机器上把模型下载好打成压缩包拷贝到离线机器上然后把config.py里的模型路径指向解压目录。解决具体离线部署步骤我会在下一章的进阶技巧里展开。这里提醒一句——不要相信冷启动首次运行能直接在线拉模型尤其是在毕设答辩现场网络环境不可控提前离线化是保命的。6. 进阶调试把 ChatGLM-6B 换成本地 API 离线部署的最后一公里到这里你已经能跑通整条链路了。但如果你想把这份资源变成真正能落地的东西——比如拿到单位内部做一个私有化知识库或者毕设要现场演示——下面这几个技巧才是价值所在。第一个技巧是启用自己的 API 服务。ChatGLM-6B 本身自带cli_demo.py或api.py这样的脚本可以启动一个 HTTP 接口。如果你的机器显存够大可以先把模型启动成 API 服务python api.py --model_name chatglm-6b --port 8000然后在chatllm.py里把api_base_url配置成http://127.0.0.1:8000这样 LangChain 调用的是 API 而不是直接加载模型。好处是显而易见的多个人可以同时通过这个 API 访问同一个模型不用每台机器都复制一份模型文件而且模型常驻内存问答响应速度更快。第二个技巧是用jina_serving.py做长文档预处理。这个项目里有个文件叫jina_serving.py它的作用是调用 Jina AI 的 embedding 服务或 reranker 服务。如果你要处理的是几千页的长文档直接喂给 FAISS 的 embedding 模型效果不好——长文本的语义会被稀释。常见做法是先切分然后对每一个 chunk 做 rerank把相关性最高的几个片段挑出来再喂给 LLM。jina_serving.py就是干这个用的。第三个技巧是离线部署的完整闭环。OfflineDeploy.md和Dockerfile.Base的存在说明作者考虑过离线场景。我的血泪经验是不管代码多简单答辩前一天一定要在断网环境下完整走一遍。具体流程是第一步在有网的机器上执行 ModelScope 下载脚本把模型完整下载到model_cache目录。第二步把整个项目目录连同模型目录打包拷贝到目标机器。第三步在目标机器上执行pip install -r requirements.txt --no-index --find-links/离线包目录确保所有 Python 依赖都是本地的。第四步修改config.py把model_path和embedding_model_path全部改成绝对路径避免相对路径找不到文件。第五步跑一次cli.py测试一个标准问题确认无误后再启动app.py做 Web 演示。这最后一步是最容易被忽视的。我在帮朋友调试类似项目时就见过在机房网络一切正常、跑得行云流水结果答辩现场换了台没有网卡的白板机器整个系统起不来的惨剧。从那以后我每次拿到这种知识库项目都会强制自己走一遍断网复现流程——把model_cache手动拷一遍、把requirements.txt里的包全部本地安装一遍、把模型路径改成绝对路径一次。这套流程走完你才算真正「拥有」了这份代码而不是「借用」了作者的网络和环境。说回这份项目本身它适合两类人一类是正在做毕设或课程设计的学生代码完整度足够你交差并回答老师的追问另一类是想快速验证「本地知识库问答」可行性的从业者先跑通全流程再逐步替换成自己的模型和业务文档。项目里faq.md、update_history.md这些文档也保留得很好遇到问题先查 FAQ很多坑作者已经替你踩过了。希望这份拆解能帮你少走弯路顺利把它跑起来。本文还有配套的精品资源点击获取