简介:面向计算机、通信、人工智能、自动化等相关专业学生及从业者的一套完整毕业设计项目,基于LangChain和ChatGLM-6B等主流LLM,解决针对本地知识库的自动问答问题。该项目为个人毕业设计,代码经调试测试确保可运行,作者答辩评审分达98分,既适合作为课程设计、大作业或毕业设计参考,也方便进阶用户在其基础上扩展功能。压缩包共76个文件、仅17.96MB,其中39个pickle缓存文件用于存放词向量或模型数据,12个Python源文件覆盖中文文本切分、嵌入模型封装、ChatGLM接口调用、命令行与Web展示等关键模块,另有Markdown说明文档、部署配置与依赖清单等辅助文件,整体结构清晰便于按模块学习。目前已有251人学习下载,内容还包含离线部署手册、常见问题记录与更新日志,可帮助快速理解项目结构和复现问答流程,整体具有较高的学习借鉴价值。
1. 本地知识库自动问答:为什么选 LangChain 和 ChatGLM-6B 这条技术路线
任何一个在政务、金融或企业内部做过知识管理的人,都会遇到同一个问题:资料都放在本地,文档成百上千份,但检索靠关键词、问答靠人工,文档利用率极低。把大语言模型接到本地文档上做自动问答,成了这两年最实在的落地方向。常见做法是拿 LangChain 搭流程,ChatGLM-6B 等开源 LLM 做推理,配合向量数据库完成“语义检索 + 生成回答”。这个链路之所以受欢迎,是因为它不需要微调模型、不需要昂贵的算力,还能保证文档不出内网。本文就沿着这套方案,从原理到指令,把每个环节的参数、代码和坑讲清楚。适合正在选型、或者已经上了 LangChain 但回答质量和召回率不理想的工程师。
2. 问答系统的核心链路:从 LangChain 架构到 RAG 增强检索
2.1 先理解 LangChain 在链路里的角色
LangChain 是一个编排框架,本身不具备大模型能力。它把“对话、检索、记忆、工具调用”这些能力标准化成组件,开发者通过链式组合来搭建应用。很多刚接触 LangChain 的人会误以为它是一个现成的问答产品,其实它更像是一个将流程串起来的中间层。
在本地知识库自动问答这个场景中,LangChain 负责的工作是:
- 把本地文档读入内存,做格式清洗,再切割成合适大小的块
- 将文本块调用 Embedding 模型转成向量,写入向量库
- 用户提问时,先将问题向量化,再到向量库做相似度检索,取出最相关的文档片段
- 把检索结果和用户问题拼装成 Prompt,交给 LLM 生成最终回答
这里最关键的思维转变是:LLM 不是直接“读”你的文档,而是通过检索把文档片段作为上下文喂给它。这就是热词里反复出现的 RAG(Retrieval-Augmented Generation)—— 检索增强生成。
用户问题 ↓ 向量化 → 向量库相似度检索 → Top-K 相关文档 ↓ 拼装 Prompt(问题 + 文档片段 + 记忆) ↓ ChatGLM-6B 等 LLM 生成回答这个链路里每个环节的失误都会被放大:文档切得太碎,语义就断了;向量检索召回太差,模型再强也答不对;Prompt 拼装不合理,模型会被无关信息带偏。后面几章会逐一展开。
2.2 为什么首选 ChatGLM-6B 而不是 GPT 系列
选择 ChatGLM-6B 有几个直接原因。首先是部署门槛低:6B 参数的模型,用 int4 量化后显存占用约 6GB 左右,一张消费级显卡就能跑起来;即便是纯 CPU 推理,速度慢一些,但也能用。其次是中文理解能力在同等参数规模的模型中表现靠前,这对本地知识库这种中文文档为主的场景非常关键。
从 LangChain 的角度来看,ChatGLM-6B 是标准 OpenAI 兼容接口,通过 HuggingFace Pipeline 或 FastAPI 封装后,可以直接接入 LangChain 的ChatGLM或HuggingFacePipeline类,不需要二次开发。
应该避开一个误导:不要拿 ChatGLM-6B 和 GPT-4 比生成质量,这没有意义。6B 模型的优势是私有化部署和可控性,而不是绝对效果。如果你手里的文档数量少、格式规范,用 ChatGLM-6B 够用;如果文档量特别大且问题偏专业,后续可以无缝切换到 ChatGLM-130B 或者 Qwen-14B,LangChain 这层封装恰好让这种切换成本变低。
2.3 RAG 与微调的选择边界
除了 RAG,本地知识库问答的另一种方案是微调。但这两个路线的本质完全不同:
- 微调是把新知识“写进”模型权重,代价是需要构造训练集、做训练,且每次变更文档都要重新训练
- RAG 是“外挂知识”,文档的增删改只影响向量库,模型本身不动
在本地知识库场景下,RAG 的优势在于文档变更频繁、需要可追溯的回答来源,且团队可能不具备持续的微调工程能力。RAG 也保留了模型的通用能力 —— 微调一个 6B 模型,如果数据和参数没控好,很容易发生“灾难性遗忘”,模型反而变笨了。
提示:如果业务文档动辄几千份且格式高度统一(比如全是合同模板),可以考虑“RAG + 少量微调”结合:先向量检索,再让微调后的模型按固定格式输出。
3. 动手搭建最小可用系统:LangChain + ChatGLM-6B + 向量库
3.1 环境准备与依赖安装
以下环境是在 Linux 服务器上验证过的组合。GPU 显存最低 8GB,如果显存只有 6GB,把加载量化等级改成bitsandbytes的 4bit 加载即可。
# Python 3.9 或 3.10 均可 pip install langchain langchain-community langchain-chroma chromadb pip install transformers accelerate sentencepiece bitsandbytes pip install pypdf docx2txt这里用langchain-chroma而不是通用chromadb,是因为新版 LangChain 把向量库集成拆成了独立包,避免版本冲突。pypdf用于解析 PDF,docx2txt则读取 Word。
另外需要说明一个热词对应的场景:很多人搜“langchain 过时了吗”,是因为看到了 LangGraph 的出现。LangChain 本身没死,只是把复杂 Agent 的编排能力逐渐移交给了 LangGraph。本文的问答场景是一个相对线性的 RAG 流,仍然用 LangChain 的LCEL(LangChain Expression Language)语法最合适,不需要引入 LangGraph。
3.2 文档加载与切分:决定问答效果的第一道关口
所有 RAG 应用的第一个成功要素都是切分策略。切得太大,检索出来的片段会混入太多无关内容,被 LLM 当成事实;切得太小,语义又被割裂,模型拿不到完整的逻辑。
from langchain_community.document_loaders import PyPDFLoader, Docx2txtLoader, TextLoader from langchain.text_splitter import RecursiveCharacterTextSplitter def load_documents(file_path: str): if file_path.endswith(".pdf"): loader = PyPDFLoader(file_path) elif file_path.endswith(".docx"): loader = Docx2txtLoader(file_path) else: loader = TextLoader(file_path, encoding="utf-8") return loader.load() # 切分参数:chunk_size 与 chunk_overlap 是召回效果的关键 text_splitter = RecursiveCharacterTextSplitter( chunk_size=300, chunk_overlap=50, separators=["\n\n", "\n", "。", "!", "?", ".", " ", ""], ) docs = load_documents("data/xxx.pdf") chunks = text_splitter.split_documents(docs)chunk_size=300是一个相对稳妥的经验值,适应多数中文文档。chunk_overlap=50是为了避免一句话被拦腰切断后,检索时丢失上下文。separators里按优先顺序把换行符、句号放在前面,保证拼音和英文混排的文档也能切到自然边界。
3.3 配置 Embedding 模型:中文场景下的选型要点
向量化的质量直接决定“能不能检索到”。当前最有名的通用 Embedding 模型是text-embedding-ada-002,但本地部署场景首先排除它,因为数据要出网。中文开源模型里,text2vec-large-chinese是经过大量社区验证的常用选择,显存占用不高,检索效果稳定。
from langchain_huggingface import HuggingFaceEmbeddings embedding_model = HuggingFaceEmbeddings( model_name="GanymedeNil/text2vec-large-chinese" )注意langchain_huggingface是一个独立包,需要用pip install langchain-huggingface单独安装。如果你手里的机器内存紧张,也可以选用shibing624/text2vec-base-chinese,参数少一半,效果差距不大。
3.4 写入向量库与构建检索器
接下来将切分好的文本块写入 Chroma,完成知识库索引构建。
from langchain_chroma import Chroma # 构建向量库并持久化 vector_store = Chroma.from_documents( documents=chunks, embedding=embedding_model, persist_directory="./chroma_db" ) # 相似度检索 Top-5 retriever = vector_store.as_retriever( search_type="similarity", search_kwargs={"k": 5} )persist_directory参数让向量库落盘,服务重启后不需要重新构建,节省时间。k=5的含义是每次问答从库里取出最相关的 5 个片段。这个数值需要根据文档质量和模型能力动态调整,太高会把噪声带进来,太低则召回不全。
3.5 加载 ChatGLM-6B 并封装成 LangChain LLM
这是整条链路中最容易版本踩坑的地方。ChatGLM-6B 通过 Transformers 加载后,要包装成 LangChain 的HuggingFacePipeline才能被链调用。
import torch from transformers import AutoTokenizer, AutoModel, pipeline from langchain.llms.huggingface_pipeline import HuggingFacePipeline tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm-6b", trust_remote_code=True) model = AutoModel.from_pretrained( "THUDM/chatglm-6b", trust_remote_code=True, load_in_4bit=True, # 显存不足 8GB 时开启 torch_dtype=torch.float16, device_map="auto" ) pipe = pipeline( "text-generation", model=model, tokenizer=tokenizer, max_new_tokens=512, temperature=0.7, top_p=0.9, repetition_penalty=1.1 ) llm = HuggingFacePipeline(pipeline=pipe)因为 ChatGLM-6B 使用trust_remote_code=True加载自定义代码,请务必确认模型文件来源可信。max_new_tokens=512设定回答的最大 token 长度,太长会增加显存压力,太短则没法给出完整步骤类答案。
3.6 组装问答链:检索、Prompt、LLM 三者拼接
LangChain 从 0.1 版本开始推荐 LCEL 写法,语法的组合性和类型提示都比旧的RetrievalQA链更清晰,也更容易 debug。
from langchain.prompts import PromptTemplate from langchain.schema import StrOutputParser from langchain.schema.runnable import RunnablePassthrough template = """请根据以下资料片段回答问题。 资料片段由 <doc> 和 </doc> 包裹,其中可能存在无关信息,请忽略。 <doc> {context} </doc> 问题:{question} 如果资料片段没有包含答案所需的信息,请直接说“根据提供的资料无法回答”。 请用中文回答。""" prompt = PromptTemplate.from_template(template) def format_docs(docs_list): return "\n\n".join(doc.page_content for doc in docs_list) qa_chain = ( { "context": retriever | format_docs, "question": RunnablePassthrough() } | prompt | llm | StrOutputParser() ) answer = qa_chain.invoke("公司请假的审批流程是什么?") print(answer)retriever | format_docs是 LCEL 的管道操作符,拖取检索结果后送入 Prompt 模板。RunnablePassthrough把原始问题原样传给模板。StrOutputParser将 LLM 返回的对象转成纯文本字符串。
整条链的执行逻辑是:用户问题 → 向量检索 → 拼装上下文 → 交给 ChatGLM-6B 生成 → 输出答案。这也是热词里的“langchain 架构”在最小规模下的完整样例。
4. 回答质量调优:参数怎么摆、效果怎么验证
4.1 检索参数:k 值、相似度阈值与重排
玩熟基础链路后,下一步就是调参和做效果对比。当前链路中,检索参数的调整优先级最高,因为生成环节再强力,检索错了什么都白费。
先看k值的一个实用调法。切分后的文本块若是 300 字左右,检索到 5 个块相当于向模型提供约 1500 字的上下文。可以按问题复杂度来做初步建议:
| 指标含义 | 建议值 | 说明 |
|---|---|---|
| chunk_size | 200-600 | 小值适合法律法规等逐条表述文档 |
| chunk_overlap | 10%-20% 的 chunk_size | 避免切分导致语义断点 |
| k(检索块数) | 4-8 | 根据答案长度和文档噪声调整 |
| score_threshold | 0.5-0.7 | 低于该分数视为不相关,直接丢弃 |
score_threshold在as_retriever中的配置方法是换用search_type="similarity_score_threshold",配合search_kwargs传入阈值。
还有一个被很多文章忽略的操作:重排(Rerank)。Chroma 这类向量库做的是初召(Recall),重排则是对召回结果做一次精排。常见的做法是用bge-reranker-large做交叉编码器二次打分,能明显把最相关的文档排到前面。LangChain 中通过ContextualCompressionRetriever接入。
from langchain.retrievers import ContextualCompressionRetriever from langchain_community.cross_encoders import HuggingFaceCrossEncoder from langchain.retrievers.document_compressors import CrossEncoderReranker encoder = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-large") compressor = CrossEncoderReranker(model=encoder, top_n=3) compression_retriever = ContextualCompressionRetriever( base_compressor=compressor, base_retriever=retriever )重排的代价是多一次模型推理耗时,但它带来的精度提升非常明显,尤其在候选块数量超过 10 个的时候。top_n=3的意思是从初召结果中再挑选 3 个最相关的片段进 Promot。
4.2 生成参数:temperature 与 top_p 的取值
ChatGLM-6B 在HuggingFacePipeline里暴露了标准的采样参数,但很多人习惯网上抄一份配置,不理解每个参数在回答质量上的作用。只需锚定一个事实:温度越高,生成越有创造力,也越容易胡扯。
| 参数 | 推荐值 | 场景说明 |
|---|---|---|
| temperature | 0.1 - 0.3 | 知识库问答强调精准,尽量偏低 |
| top_p | 0.8 - 0.9 | 低温度下起补充作用 |
| repetition_penalty | 1.1 - 1.15 | 超过 1.2 则输出会变得机械 |
| max_new_tokens | 256 - 1024 | 长文档摘要类任务取上限 |
本地知识库问答是最接近“闭卷考”的场景,理想答案是直接源自文档内容,不需要模型发挥。所以temperature=0.2左右是合理的起点。
4.3 调试工具:LangSmith 与自定义中间层打印
调参不能靠“看回答感觉对不对”,必须有观察中间结果的手段。国外的 LangSmith 在本地部署或私有化场景下不好接入,更实际的做法是直接打印检索结果,检查问题有没有被匹配到正确的文档。
def debug_retrieval(question: str): results = retriever.invoke(question) for i, doc in enumerate(results): print(f"--- 第 {i+1} 个片段,来源: {doc.metadata.get('source', '未知')} ---") print(doc.page_content[:200]) print()把上一步的 debug 输出和最终回答并列对比,就能定位问题到底出在“检索不到”还是“生成了错误内容”。
另外需要留意 LangChain 版本变化。langchain.llms.HuggingFacePipeline在 0.3 版本之后迁移到了langchain_huggingface;如果你用的是更早些的代码示例,很可能遇到“ModuleNotFoundError”或参数不兼容的报错。处理方式是先查看当前版本的 API 文档,再对照改导入路径。这也是热词里“langchain 菜鸟教程”和“langchain 面试题”中最常考的细节。
5. 本地化部署的实战避坑指南
5.1 先解决 CPU 推理速度问题
如果只有 CPU 可用,ChatGLM-6B 的单次推理可能要数十秒甚至更久。常见的优化是使用llama.cpp的 GGUF 格式模型配合 CPU 推理,或者降级使用 CPU 友好的小模型。这里给两条路线,任选其一即可:
# 路线一:使用 llama.cpp 的 GGUF 模型 pip install llama-cpp-python # 下载 chatglm-6b 的 GGUF 量化文件,然后加载如果坚持使用 Transformers 加载,则需要torch.set_num_threads()控制 CPU 线程数,并通过model = AutoModel.from_pretrained(..., low_cpu_mem_usage=True)控制内存占用。不过坦白说,6B 模型在 CPU 上做实时问答体验有限,生产环境建议至少配一张显存 12GB 以上的 GPU。
5.2 解决向量库持久化和增量更新的问题
生产环境的知识库不是一次性建好的,而是持续追加。Chroma 的from_documents每次执行都会重置库内数据,这属于新手的第一个坑。正确做法是先判断路径是否存在,再做增量写入:
import os vector_store = None if os.path.exists("./chroma_db") and os.listdir("./chroma_db"): vector_store = Chroma( persist_directory="./chroma_db", embedding_function=embedding_model ) else: vector_store = Chroma.from_documents( documents=chunks, embedding=embedding_model, persist_directory="./chroma_db" ) # 增量添加新文档 vector_store.add_documents(new_chunks)注意Chroma类的add_documents方法只负责索引新内容,文档更新后原有的旧向量依然存在。要保证更新效果,需要按metadata里的文档 ID 做先删除再写入。
# 删除旧版本后再写入新内容 vector_store.delete(where={"source": "data/xxx.pdf"}) vector_store.add_documents(new_chunks)where条件可以按文档来源字段过滤,这个设计在上线时尤其重要。
5.3 Embedding 模型与 LLM 的分工
还有一个常见的认知误区集中在“Embedding 模型能不能用同一个 LLM 来充当”上。很多初学者以为向量化也用 ChatGLM-6B 就行,实际是不同的模型,职责完全不同:
- Embedding 模型将文本映射为稠密向量,输出向量,目标是让语义相近的文本在向量空间里距离更近
- LLM 是生成模型,输出文字,目标是理解上下文并生成回答
有些项目会把 LLM 的最后一层隐藏向量取出来当文本向量用,不否认这样也能工作,但维度大、推理耗时高,且效果不如专门的 Embedding 模型。所以不要在这个环节省事,按前面推荐的模型即可。
5.4 Prompt 层级拆解:清晰界定 RAG 的三段式结构
一个高质量的知识库问答 Prompt 应该尽量保持三段结构清晰可见:角色设定、资料片段、问题。前面给出的模板已经是一个基础版本,下面做一次增强,目标是提升回答的鲁棒性:
template = """你的任务是基于给定的资料片段,回答用户的问题。 回答必须满足以下要求: 1. 优先使用资料片段中的原话或原意; 2. 当资料片段信息不足时,明确说明“资料中未找到相关内容”,不得编造; 3. 如果问题涉及操作步骤,请按步骤分条列出; 4. 回答末尾标注信息来源,格式为 [来源:文件名]。 资料片段: {context} 用户问题:{question} 你的回答:""" prompt = PromptTemplate.from_template(template)这里的第 4 条容易引起争议,实际操作中可以配合Document对象的metadata字段来追加来源。方法是在format_docs函数中读取doc.metadata["source"]并手写到模板里。
def format_docs_with_source(docs_list): formatted = [] for doc in docs_list: source = doc.metadata.get("source", "未知") formatted.append(f"[来源:{source}]\n{doc.page_content}") return "\n\n".join(formatted)有了来源标注,用户可以直接打开原文档核对,这在企业知识库场景中对信任建立非常关键。
5.5 从原型到生产:四个必须补齐的工程缺口
原型系统上线之前,一般还需要解决四个问题,它们直接影响系统能否稳定运行在当前环境里。
第一个缺口是接口封装。不要直接通过 Python 脚本交互,建议用 FastAPI 封一层 HTTP 服务:
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI() class QARequest(BaseModel): question: str class QAResponse(BaseModel): answer: str sources: list[str] @app.post("/qa", response_model=QAResponse) def answer_question(req: QARequest): result = qa_chain.invoke(req.question) return {"answer": result, "sources": []}第二个缺口是会话记忆。LangChain 的ConversationSummaryBufferMemory可以保留最近的对话摘要,避免多轮问答中用户追问“那第二步呢”时模型丢失上文。不过在最早版本里先不用急着加记忆,加了反而可能把无关的历史话题带入当前上下文。
第三个缺口是并发控制。如果系统被多个用户同时使用,要限制最大并发数,防止显存溢出。常规做法是在模型加载层用信号量限制并发,或在服务层做排队。ChatGLM-6B 的推理是串行的,并发高了会出现 OOM 或排队时间过长。
第四个缺口是日志与监控。至少需要记录每轮问答的问题、回答、检索到的文档来源、耗时,用于后续分析回答质量。埋点可以很简单,只需要在qa_chain.invoke前后打印时间戳和检索结果即可。
6. 进阶验证:用 8 组测试样本给问答系统打分
既然系统已经跑通,最后分享一组成本低但有效的验证方法。不要只看两三个例子就说“效果还可以”,而是固定一套测试问题集,在每次参数调整后反复运行,用统一的维度打分,才能知道改动到底是变好了还是变坏了。
制定一个 8 条问题的黄金测试集,覆盖四类基本情况:
- 2 条直接能从文档唯一位置找到答案的问题,验证基础召回
- 2 条需要综合两个以上段落才能回答的问题,验证跨文档理解
- 2 条文档里没有答案的问题,验证模型是否不乱编
- 2 条答案在文档中但不使用原话的问题,验证语义泛化
每次调整参数后记录三个数字:回答正确数、来源标注正确数、平均响应耗时。如果调了参数,正确数不升反降,则回滚参数,再尝试其它方向。
额外建议记录一个“幻觉率”指标。做法是在每次回答后人工检查回答内容是否都来源于检索到的文档片段。这个指标其实比正确率更关键 —— 一个答错但能看出是依据文档答错的系统,比一个胡编的系统更容易继续调优。
最后提一个适合熟手的进阶方向。当文档量变大到一定程度,单一向量检索会开始出现语义死角,此时可以从“一步检索”演化为“多路召回”:同时用关键词检索(BM25)和向量检索,再把两路结果合并去重后一起喂给 RAG。LangChain 中EnsembleRetriever就是干这个的,它的优势在于向量检索擅长语义匹配、关键词检索擅长精确匹配,二者互补后,准确率往往有明显提升。
本文还有配套的精品资源,点击获取