简介:面向计算机、通信、人工智能、自动化等专业学生与从业者的毕业设计级项目,基于LangChain与大语言模型ChatGLM-6B等系列LLM,构建针对本地知识库的自动问答系统,答辩评审曾获98分。项目代码经调试测试,可运行,适合作为课程设计、课程大作业或毕业设计参考,也可供初学者学习LLM应用开发与进阶改造。压缩包共76个文件,以Python源码(12个py)、pickle数据文件(39个,用于向量索引与模型缓存)、Markdown/txt文档、Dockerfile部署配置及示例图片为主,整体约17.96MB,目录结构清晰。已有251人学习下载,资源包含完整源码、使用说明、手册及离线部署配置,其中源码分层清晰(数据处理、嵌入、LLM调用、Web应用等),便于理解RAG问答链路并替换模型或知识库,实现个性化功能。
1. 为什么说本地知识库自动问答,难的不是大模型而是资料整理
接到过这种活的人应该都懂:产品经理丢过来几十份 PDF 和 docx,说“你做个问答机器人,让业务同事自己问”。真要动手才发现,把文件喂给大模型容易,让它每一条答案都出自这些文件、还能指出是哪份材料——这才是“基于 LangChain 和 ChatGLM-6B 等系列 LLM 的针对本地知识库的自动问答”这个项目要啃的硬骨头。这套方案背后的核心技术叫检索增强生成(RAG):离线把文档切块、向量化、建索引;在线先拿用户问题去向量库召回相关段落,再把段落拼进提示词交给 LLM 生成答案。它适合两类人:一是不想把内部资料传给云端模型的团队,二是手里只有一张 8GB 消费级显卡、却想体验完整大模型问答链路的技术人员。反直觉的点在于,跑通 demo 只要半天,把“答对问题”打磨到位却要两周。
2. 先把链路画清楚:LangChain、ChatGLM-6B 与向量库如何分工
本地知识库自动问答不是单靠一个大模型就能完成的。标题里出现的 LangChain 和 ChatGLM-6B 分别负责编排与生成,而常被忽略的第三类组件——嵌入模型与向量库,决定了答案质量的上限。一个完整的本地问答链路可以拆成四段:文档加载与切分,文本向量化,检索召回,生成回答。前两段离线完成,后两段在线发生。实践中,很多翻车案例都不是模型不会说,而是前三段里的某一步出了问题。
2.1 LangChain 在自动问答里的职责:它不是模型,是流水线
LangChain 的价值在于把上述四段流程封装成可以替换的标准件。你今天用 ChatGLM-6B,明天改成 Qwen 或者通过 Ollama 拉下来的其他量化模型,只需要换掉 LLM 这一层,检索和切分逻辑不动。这对项目落地很重要,因为本地 LLM 的格局变化太快,一个方案如果被某个模型绑定死,过半年就可能要重写。
我一般会把问题拆成“加载与切分、向量化与索引、检索召回、生成回答”四部分,再去看 LangChain 里对应的抽象。文档加载器有很多现成实现,TextLoader、PyPDFLoader、UnstructuredMarkdownLoader 各有各的适用场景,返回的都是统一的 Document 对象;文本切分器 RecursiveCharacterTextSplitter 把长文档切成小块;向量库接口统一,FAISS 还是 Chroma 切换成本低;最后 RetrievalQA 把检索器和 LLM 串成一条链。这套抽象真正的价值是排查问题方便:答案不对时,可以把检索结果单独打日志,而不是对着最终输出猜黑匣子内部发生了什么。
2.2 生成模型选型:为什么大量项目用 ChatGLM-6B,以及“等系列 LLM”意味着什么
ChatGLM-6B 之所以大量出现在这类项目包里,核心原因是显存门槛低。6B 参数在 INT8 量化后可以压进 6GB 显存,一张 8GB 的消费级显卡就能跑起来,中文效果在当时同尺寸开源模型里也排得上号。标题写的“等系列 LLM”,意思就是这个位置并不唯一,你可以换成 ChatGLM2、ChatGLM3、Qwen 系列,或者通过 Ollama 这一层来加载各种量化模型。选型判断不应该只看宣传,Open LLM Leaderboard 这类公开榜单能提供横向参考,但最终要落到你自己的数据上验证。
| 部署条件 | 生成模型 | 典型量化 | 适用判断 |
|---|---|---|---|
| 显存小于 6G | 4bit 量化模型或 Ollama | INT4 | 优先考虑 Ollama,省去大量手工调参 |
| 显存 8G 左右 | chatglm-6b | INT8 | 标题方案的标准舒适区 |
| 纯 CPU 服务器 | 小模型 / 量化模型 | 无 / INT4 | 推理很慢,响应超过十秒就别硬撑 |
| 可以连公有云 | 任意大模型 API | 不需要 | 效果最好,但资料出站不一定合规 |
注意一个容易忽略的许可问题:ChatGLM-6B 早期版本对商用有限制,如果你要做商业化产品,上线前务必核对所用模型的 License。很多团队就是在 PoC 阶段没问题,临上线才发现商用授权不满足,被迫换模型重跑测试集。
2.3 检索侧选型:嵌入模型和向量库为什么决定答案上限
进入检索侧之前,先明确一个结论:在 RAG 链路里,检索召回的质量直接决定最终答案的上限。检索不出相关内容,LLM 就只能靠它训练时的记忆硬编,幻觉就是这么来的。嵌入模型我建议直接用中文语义模型,比如 bge 系列或者 m3e,而不是默认的英文模型。中文本就有语义层面的差异,一个标点不同,向量方向可能差出十万八千里,用英文模型很容易让后面所有环节都白费。
向量库的选择相对朴素。几十份文档、单次查询量不大的场景,FAISS 就够用。FAISS 适合一次性建好索引、之后更新不频繁的知识库,使用 faiss-cpu 版本在普通机器上也能跑;Chroma 则适合需要频繁增删文档的场景。不要一上来就追求分布式向量数据库,单机方案能解决的问题,引入额外组件只会增加排查负担。
这里把检索侧的主要参数列成表,方便对照调整:
| 环节 | 常见选型 | 关键参数 | 容易踩的坑 |
|---|---|---|---|
| 文本切分 | RecursiveCharacterTextSplitter | chunk_size=200 到 400,chunk_overlap=40 到 80 | 纯按字符切会切断中文句子 |
| 嵌入模型 | bge-large-zh / m3e | normalize_embeddings=True | 换模型后必须重建向量库 |
| 向量库 | FAISS / Chroma | 维度与嵌入模型一致 | 只复制部分文件导致索引加载失败 |
| 生成模型 | chatglm-6b / Qwen | max_new_tokens=512,temperature=0.3 | 上下文塞太满,输出超长 |
这套配置的思路是:每一层选成熟、可替换、文档多的组件,而不是选看起来最强但难以调试的组件。接下来就从最小系统开始,一步步把它们真正跑起来。
3. 把最小系统跑起来:装环境、加载模型、完成第一句问答
直接进入实操。我默认你有一张 8GB 显存的 NVIDIA 显卡,Linux 或者 Windows 本机都可以;没有显卡也没关系,后面会给出替代路径。很多 LangChain 入门教程喜欢把环境写得很复杂,实际上单机本地问答的依赖并不需要那么多,核心就是 PyTorch、Transformers、LangChain 和一个向量库。
3.1 环境准备与最小依赖
先建一个干净的虚拟环境。Python 版本我建议用 3.10,兼容性比 3.11 遇到的那些依赖编译问题少。
python3 -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install torch torchvision pip install transformers sentence-transformers langchain langchain-community pip install langchain-huggingface chromadb faiss-cpu这里有两个点要说明。PyTorch 安装时要注意和你机器的 CUDA 版本匹配,直接 pip 装到的默认版不一定能用 GPU,安装后执行torch.cuda.is_available()确认,返回 False 就回 PyTorch 官网按 CUDA 版本重新装。LangChain 现在拆成了多个包,langchain-community里装着文档加载器和向量库,langchain-huggingface里装着 HuggingFacePipeline,老教程里那种一条pip install langchain全搞定的时代已经过去了。
3.2 先验证 ChatGLM-6B:模型能正常对话,再接其他组件
不要一上来就接 LangChain,先把模型本身跑通。这一步的目的,是把“模型问题”和“框架问题”分开。权重文件可以提前从模型仓库下载到本地目录,避免每次启动都在线拉取,路径以你实际存放的位置为准。
from transformers import AutoModel, AutoTokenizer model_path = "./chatglm-6b" # 本地权重目录 tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) model = AutoModel.from_pretrained(model_path, trust_remote_code=True) model = model.quantize(8) # INT8 量化,显存能压到 6G 上下 model = model.half().cuda() # 半精度上显卡 model.eval() response, _ = model.chat(tokenizer, "什么是RAG?") print(response)逻辑很直接:加载分词器和模型,ChatGLM-6B 因为依赖自定义代码,必须设trust_remote_code=True。quantize(8)是 ChatGLM 自带的方法,比通用 transforms 的load_in_8bit在这条链路上更省心。half()转半精度,.cuda()上显卡。注意eval()必须调用,否则模型不会进入推理模式。到这里能正常对话,说明模型的部署环境没有问题,接下来才轮到 LangChain 登场。
如果你的机器是 CPU 运行,把.cuda()去掉也能跑,但响应会慢到让人怀疑程序卡死。纯 CPU 场景我更建议换 Ollama,它会针对 CPU 推理做优化,而且提供了 OpenAI 兼容接口,后面接 LangChain 反而更方便。这一步是在为后面换模型留退路。
3.3 把模型接进 LangChain:用 HuggingFacePipeline 包装
模型验证通过后,用 LangChain 的 HuggingFacePipeline 把同一个模型包装成 LLM 接口。这样后续的所有链式调用就不需要再关心 transformers 的细节了。
from transformers import pipeline from langchain_huggingface import HuggingFacePipeline pipe = pipeline( "text-generation", model=model, tokenizer=tokenizer, max_new_tokens=512, temperature=0.3, repetition_penalty=1.05, ) llm = HuggingFacePipeline(pipeline=pipe) print(llm.predict("什么是本地知识库问答?"))代码里几个参数需要说明。max_new_tokens=512限制生成长度,本地知识库场景下答案通常不需要超过 300 字,给 512 已经足够,太大反而会拖慢响应并占显存。temperature=0.3取值偏低,让输出更稳定,问答系统最怕同一个问题每次答案不一样。repetition_penalty=1.05是中文环境下必须设置的,不加的话模型容易在长回答里反复说同一句话。llm.predict只是验证包装是否成功,真正项目里我们应该走后面的检索链。
到这里,最小系统已经建立:模型能对话,LangChain 能调用模型。但离“知识库自动问答”还有距离——必须把本地文档变成检索索引,再把它和这个 LLM 串起来。
4. 做厚核心问答链路:文档切分、向量化与最终实现的各个参数
最小系统只是骨架,真正让答案靠谱的是资料处理。这一章把所有可调的参数摆出来讲透,每一步都给出完整代码。本地知识库问答的血泪经验是:指令和模型问题都好解决,文档切分粒度不对,调整起来最费时间。
4.1 文档加载与切分策略:先看文本形态,再定 chunk_size
直接拿一个真实场景举例:知识库里有培训手册、产品 FAQ 和客户分析报告,格式分别是 txt、pdf、md。先用统一的加载逻辑把它们读进来。
from langchain_community.document_loaders import TextLoader, PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter docs = [] for fp in ["docs/培训手册.txt", "docs/FAQ.pdf"]: try: if fp.endswith(".txt"): loader = TextLoader(fp, encoding="utf-8") else: loader = PyPDFLoader(fp) docs.extend(loader.load()) except Exception as e: print("加载失败:", fp, e) splitter = RecursiveCharacterTextSplitter( chunk_size=300, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ".", ";", " "], ) chunks = splitter.split_documents(docs) print(f"切分得到 {len(chunks)} 个片段")chunk_size=300的考虑是:一个 300 字的文本块大约包含 6 到 8 个中文句子,足够支撑一个完整的语义单元;如果调大到 600,检索召回时会把很多不相关的内容一起塞进上下文,LLM 容易被噪声干扰。chunk_overlap=50是为了让相邻两个块之间保留衔接信息,避免某个关键结论正好被切在边界上。separators列表按优先级排列,注意这里专门加了中文句号、叹号、问号和分号——这是中文切分和英文的最大区别。英文按空格和换行切基本不会把单词截断,中文不指定标点,模型只能按字符硬切,检索回来的片段就可能全是残句。
如果文档本身有章节结构,比如 Markdown 的二级标题,更稳妥的做法是先按章节切一次,再把过长的章节切小块。这比直接全文件切块能保留更多上下文结构。
4.2 向量化与索引:中文嵌入模型怎么选、参数怎么设
切分完成后,每一段文本都要变成向量。这一步选嵌入模型,重点不是追求榜单分数,而是考虑中文效果和显存占用。bge-large-zh-v1.5 是目前在中小型知识库场景里综合表现稳定的选择,维度合理、检索精度够用。
from langchain_community.embeddings import HuggingFaceEmbeddings from langchain_community.vectorstores import FAISS embeddings = HuggingFaceEmbeddings( model_name="BAAI/bge-large-zh-v1.5", model_kwargs={"device": "cpu"}, encode_kwargs={"normalize_embeddings": True}, ) vectorstore = FAISS.from_documents(chunks, embeddings) vectorstore.save_local("faiss_index") print("已保存 faiss_index/index.faiss 与 index.pkl")嵌入模型我建议放在 CPU 上运行。嵌入计算是一次性工作,几百份文档在 CPU 上几分钟就能完成,没必要抢占 GPU 显存。normalize_embeddings=True很关键,它会对向量做归一化,使得后续用余弦相似度计算时结果更稳定。FAISS 建完索引后调用save_local会生成两个文件:index.faiss存向量索引,index.pkl存文档元数据。这两个文件后面必须一起用,缺一个都会加载失败。
这里要特别提醒一个后期必踩的坑:更换嵌入模型后,向量库必须重建。不同模型的输出维度不同,旧索引文件和新模型算出来的向量根本对不上,勉强加载只会得到空检索结果。所以不要试图在新模型上复用旧索引,“换模型就重建索引”应该成为肌肉记忆。
4.3 完整问答链路:检索、Prompt 与生成
向量库和 LLM 都就位了,剩下的就是把它们串起来。这里用 RetrievalQA 完成任务,重点在于 Prompt 模板的设计。
from langchain.prompts import PromptTemplate from langchain.chains import RetrievalQA from langchain_community.vectorstores import FAISS vectorstore = FAISS.load_local( "faiss_index", embeddings, allow_dangerous_deserialization=True, ) retriever = vectorstore.as_retriever( search_type="similarity", search_kwargs={"k": 4}, ) template = """你是本地知识库问答助手。 用户的问题是:{question} 以下是从知识库检索到的材料: {context} 回答要求: 1. 只基于上面材料回答,找不到答案时直接说“知识库中没有相关内容”; 2. 给出结论后,在括号里注明材料来源文件名; 3. 回答控制在300字以内。 回答:""" prompt = PromptTemplate(template=template, input_variables=["question", "context"]) qa_chain = RetrievalQA.from_chain_type( llm=llm, retriever=retriever, prompt=prompt, return_source_documents=True, ) result = qa_chain.invoke({"query": "上个季度A客户为什么流失?"}) print(result["result"]) for doc in result["source_documents"]: print(doc.metadata["source"], doc.page_content[:50])这段代码里的设计思路值得展开。Prompt 模板拆成三个层次:第一层告诉模型它是谁、它的角色是本地知识库问答助手,这样模型不会从通用理解模式出发瞎编;第二层给出用户问题,也就是模型正在查找的诉求;第三层是回答约束,明确告诉模型它能用哪些材料、不能做什么,以及输出格式。这三段结构恰好对应了 Token 设计里常说的 key、query、value——角色是身份,问题是目标,约束是边界。缺失任何一层,模型都有可能在边界外自由发挥。
search_kwargs={"k": 4}表示取最相似的 4 个片段进入上下文,对 300 字的 chunk 来说,4 个片段约 1200 字,是本地 6B 模型生成答案时比较舒服的上下文长度。return_source_documents=True必须开启,否则出问题时完全无法追踪答案来自哪份文档。最后用invoke而不是直接调用,这是 LangChain 新版的推荐接口,更利于后续加中间回调。
到这里,一个可用的本地知识库自动问答系统已经完整落地。但用起来你会发现,问题并没有结束,甚至才刚刚开始。
5. 常见问题与排查:答不对、OOM、乱码,根子都在哪里
跑本地知识库问答的人,第一周几乎都在跟下面几类问题搏斗。遇到问题先别急着怀疑模型不行,把链路拆成“数据、索引、检索、生成”四段逐段排查,比对着最终答案猜原因高效得多。以下按“现象、原因、解决”整理成排查笔记,覆盖单机部署现场的大多数情况。
5.1 答案流畅但与知识库无关,甚至答非所问
现象:系统回答出来句子通顺、语气自信,但去原文里根本找不到依据,有时还会把 A 客户的事情安到 B 客户头上。这是本地问答最典型也是最危险的幻觉场景。
原因:检索环节没把相关内容召回,模型没看见该看的东西,又不想说实话,只能靠训练记忆硬编。提示词写得再严格,也弥补不了检索结果的缺失。
解决:先打印retriever.get_relevant_documents(question)看返回片段是不是真的跟问题相关。如果返回为空或匹配对象错误,多半是切分粒度问题,把 chunk_size 调小、overlap 调大,重新建库。如果检索正常但答案仍然乱编,就将 k 值适当调大,并在提示词中保留那句“找不到就说找不到”,给模型一条体面的退路。这套组合能缓解大半幻觉,但无法根治,因为答案质量归根结底取决于检索结果本身。
5.2 模型一加载就 OOM,或者跑着跑着显存爆掉
现象:进程刚启动就报 CUDA out of memory,或者前期正常,多轮对话后突然卡死。
原因:多数情况不是显存真的不够用,而是 GPU 被同时塞进了太多东西。嵌入模型、LLM、向量检索都默认想用 GPU,再加上多轮对话历史不断累加,显存就被挤爆了。
解决:嵌入模型明确指派到 CPU 运行,向量检索在单机小库场景下也走 CPU;LLM 做好 INT8 或 INT4 量化,加载后立刻model.eval(),别让它停留在训练模式;必要时在每次回答后调用torch.cuda.empty_cache()清理缓存。如果问题只发生在多轮对话场景,先检查是不是把整段历史消息塞进了上下文,控制历史轮数比加显存更有效。
5.3 检索出来的文本全是残句,开头没主语、结尾没句号
现象:向量库召回结果是对的用户问题,但召回来的文本片段读起来像被刀切过,一半句子缺失,导致模型理解困难。
原因:切分器按字符长度硬切,英文有空格天然分界,中文没有,切点正好落在句子中间,一整句语义就被斩断成两半。
解决:在separators里加中文标点符号,让切分器优先在句号、问号、叹号处断开;chunk_overlap不要小于 50,给切点附近留出衔接空间;对表格类和标题类文档,先按章节标题切一次,再对长章节二次切分。中文语料必须单独调这些参数,照搬英文教程的切分配置基本都会出问题。
5.4 换嵌入模型后,旧向量库加载失败或检索结果为空
现象:听说某新模型效果更好,换掉之后重跑程序,加载 faiss_index 时直接报错,或者加载成功但检索结果为空。
原因:FAISS 索引的维度必须与嵌入模型输出维度一致。换模型后新旧维度不同,旧索引文件里的向量序列跟当前模型算出来的向量在数学上根本配不上。
解决:换嵌入模型之后,不要尝试在旧索引上打补丁,直接重新跑一遍“切分、向量化、建库”流程。这里还有个附带提醒:index.faiss和index.pkl两个文件必须一起备份和迁移,最坏的情况是同事只拷了其中一个文件给你,加载时怎么调都报错。LangChain 新版加载本地 FAISS 还需要加allow_dangerous_deserialization=True,这是安全策略变更,不是程序 bug。
5.5 升级 LangChain 后老脚本 import 直接报错
现象:几个月前还能跑的脚本,这次启动直接 ImportError,报错信息指向langchain.document_loaders之类的不存在模块。
原因:LangChain 0.2 之后做了包结构拆分,文档加载器、向量库、嵌入模型实现都被迁移到langchain-community,HuggingFacePipeline 迁移到langchain-huggingface。老代码还在从langchain核心包引这些实现,自然找不到。
解决:两条路选一条。要么把 import 统一改成新路径,要么把 LangChain 版本锁在项目当初记录的那个版本。做项目维护时,把requirements.txt严格锁住主版本号,社区库升级太快,依赖飘忽不定会把排查带到沟里。这里的排查顺序一定是从报错往上追包版本,别先怀疑自己的代码逻辑。
如果这几条都没命中你的问题,最终手段是打开 LangChain 日志和模型输出日志,看检索到的原始内容、模型生成的原始文本,不要盯着最终界面。多数问题都藏在最后一步之前。
6. 进阶:用 LLM as Judge 和最小测试集做回归验证
本地问答系统最容易被忽略的环节是验证。很多人调整参数靠手感,改一次 chunk_size 就上线,结果被真实问题打回来。更可靠的做法是建立一个小型测试集,每次改动后让 LLM 当裁判自动打分,用分数变化指导决策。
测试集不需要大,20 到 30 条就够。问题从真实业务场景里挖,context 字段记录答案应该来自哪份文档:
test_set = [ {"question": "A客户为什么流失?", "source": "docs/客户分析.md"}, {"question": "报销流程需要几个签名?", "source": "docs/手册.txt"}, ]验证逻辑直接用同一个模型做裁判。虽然用同一个模型打分会偏乐观,但我们要的不是绝对分数,而是改动前后的相对对比:
def judge_answer(question: str, answer: str, model, tokenizer) -> int: scoring_prompt = ( f"用户问题:{question}\n" f"系统答案:{answer}\n" "请从忠实度、完整度、相关性三个维度打分(1到5分,5分最高)。\n" "只输出单个数字分数。" ) score, _ = model.chat(tokenizer, scoring_prompt, history=[]) return int(score.strip()[:1])每次修改切分参数、更换 LLM、调整 prompt 模板后,把整个测试集跑一遍,求平均分。分数低于上一次就回退,高于上一次就保留。这套做法本质上就是基于 LLM 的单元测试,它不能保证答案绝对正确,但能防止系统一次改动就整体崩坏。我自己翻车最狠的一次,是凭感觉把 chunk_size 从 300 调成 600,直觉认为上下文长一点更准,结果检索噪声变大,答案整体漂移,三天之后才被业务同事拿着原文怼回来。从那以后,任何参数改动都先过测试集,哪怕只有十条约问,也能把大多数回归问题拦在发布之前。
希望帮到你。
本文还有配套的精品资源,点击获取