简介:面向希望动手搭建中文知识库问答系统的开发者与学习者,这份资源以chatglm3-6b对话模型为核心,配合bge-large-zh向量模型,覆盖从模型权重到推理代码的完整素材,可用于本地部署、微调实验与检索增强问答的工程实践。压缩包共53个文件,约126KB,以safetensors与bin两种权重分片、json配置与索引、py建模与量化脚本、sample示例及git版本元数据为主,兼顾加载、切分与二次开发需要。已有946人学习下载,说明其在中文大模型问答方向具备一定参考价值。读者可据此理清对话模型与向量模型的协作方式,参考配置与分词文件完成环境搭建,并借助示例与脚本快速验证知识库检索、答案生成与融合策略,减少从零摸索的成本。
1. 拿到 chatglm3-6b.zip 先别急着解压:这套本地知识库问答到底能跑出什么效果
上周有个做企业内训的朋友丢给我一个压缩包,文件名就四个字:chatglm3-6b.zip。他说想给公司做一套内部文档问答,要求数据不出内网、回答能指到原文段落、最好还能在消费级显卡上跑起来。我解压看完目录结构,基本确认这是一套围绕 ChatGLM3-6B 搭的本地知识库问答工程,检索侧用的是 bge-large-zh 做向量化,上层对接 LangChat 那套对话与知识库管理逻辑。它解决的不是“让大模型聊天”这种泛需求,而是把一堆 PDF、Word、Markdown 变成可检索、可溯源、可追问的私有知识库。适合谁?手里有 12GB 以上显存的开发者、需要离线部署的技术负责人、以及想搞懂 RAG 全链路而不是调个 API 就完事的人。下面我按“先跑通、再调优、最后避坑”的顺序拆。
2. 拆包看结构:ChatGLM3-6B 与 bge-large-zh 是怎么接上的
2.1 目录里到底有什么,先认清三个角色
解压后不要一头扎进requirements.txt,先把工程按职责切成三块看。第一块是模型权重与推理服务,ChatGLM3-6B 的config.json、tokenizer.model、pytorch_model-*.bin或 safetensors 分片应该集中在一个model/或chatglm3-6b/目录下;第二块是向量化与检索,bge-large-zh 的权重通常单独放,配套一个embedding或retrieval模块;第三块是应用层,LangChat 风格的controller、service、mapper结构负责把“用户提问 → 向量召回 → 拼 prompt → 调模型 → 返回带引用的答案”串起来。
我一般先跑一条命令确认权重完整,避免下到一半的分片:
# 在解压后的工程根目录执行,检查模型分片数量与总大小 find . -name "*.safetensors" -o -name "pytorch_model-*.bin" | sort du -sh ./model ./embedding 2>/dev/null逻辑说明:find把分片按名字排序,正常应该是pytorch_model-00001-of-00007.bin这种连续编号,缺号就是下载不全;du -sh看两个模型目录的体积,ChatGLM3-6B 的 fp16 权重约 12GB 上下,bge-large-zh 约 1.3GB,偏差太大说明文件被截断。参数上没什么可调的,这一步纯粹是排雷。
2.2 为什么是 ChatGLM3-6B 配 bge-large-zh,而不是别的组合
选型理由得说清楚,不然调参就是瞎调。ChatGLM3-6B 在这个场景里的优势是原生支持 8K 上下文、对中文指令跟随比较稳、而且 6B 规模在 INT4 量化后能压到 6GB 左右显存,单卡 12GB 就能同时放下推理模型和向量模型。bge-large-zh 则是中文检索里性价比很高的一档,1024 维输出,语义匹配明显强于早期的 m3e-base,代价是向量库存储翻倍。
这里有个容易被忽略的点:生成模型和嵌入模型是两套独立权重,必须分别加载。常见做法是用transformers加载 ChatGLM3-6B 做生成,用sentence-transformers或FlagEmbedding加载 bge-large-zh 做编码。两者显存叠加,如果你把 ChatGLM3 用 fp16 全量加载(约 12GB),再塞 bge-large-zh,12GB 卡直接 OOM。所以量化不是可选项,是必选项。
# 生成侧:INT4 量化加载 ChatGLM3-6B,控制显存 from transformers import AutoModel, AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("./model", trust_remote_code=True) model = AutoModel.from_pretrained( "./model", trust_remote_code=True, load_in_4bit=True, # 4bit 量化,显存约 6GB device_map="auto", # 自动分配到可用显卡 torch_dtype="auto" ).eval() # 嵌入侧:bge-large-zh 单独加载,放 CPU 或另一张卡都行 from sentence_transformers import SentenceTransformer embedder = SentenceTransformer("./embedding", device="cuda:0")逻辑说明:load_in_4bit=True是显存救命参数,代价是生成质量有轻微损失,知识库问答这种“有原文兜底”的场景完全能接受;device_map="auto"让 accelerate 自己决定层放哪,单卡时就是全放卡上;trust_remote_code=True是因为 ChatGLM3 的自定义建模代码不在 transformers 主干里,不加会报找不到ChatGLMForConditionalGeneration。嵌入模型我习惯单独指定device,方便在显存紧张时把它挪到 CPU,检索慢一点但不会崩。
2.3 把文档灌进向量库:切分参数比模型更影响效果
知识库问答翻车,十次有六次死在文档切分上。工程里通常有个splitter配置,chunk_size和chunk_overlap两个参数决定召回质量。我的经验值:中文技术文档chunk_size=500、chunk_overlap=50,按段落优先、句子兜底的顺序切;如果是表格密集的文档,切完还要保留表头,否则召回出来的片段全是数字没有语义。
from langchain.text_splitter import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 每段约 500 字符,对应 bge 的推荐输入长度 chunk_overlap=50, # 相邻段重叠 50 字符,防止答案被切断 separators=["\n\n", "\n", "。", "!", "?", " ", ""] # 中文标点优先 ) chunks = splitter.split_text(raw_text) vectors = embedder.encode([c for c in chunks], normalize_embeddings=True)逻辑说明:separators的顺序很关键,先按空行切段落,再按换行,再按中文句号问号,最后才落到空格和空串,这样能最大限度保证一个 chunk 是语义完整的。normalize_embeddings=True是因为 bge 系列用余弦相似度,归一化后内积等价余弦,检索时省一次除法。chunk_size别贪大,超过 512 字符 bge 会截断,后面的内容等于白切。
3. 跑通第一条问答链路:从提问到带引用的回答
3.1 检索召回:top_k 和相似度阈值怎么定
链路的第一环是召回。用户问“报销流程要几天”,系统得先从向量库里捞出最相关的几个 chunk。这里两个参数:top_k控制捞几条,score_threshold控制低于多少分直接丢弃。我的默认组合是top_k=4、score_threshold=0.35,但这不是拍脑袋,得看你的文档密度。
import numpy as np def retrieve(query, index, chunks, top_k=4, threshold=0.35): q_vec = embedder.encode([query], normalize_embeddings=True)[0] scores = index.dot(q_vec) # 假设 index 已归一化 order = np.argsort(scores)[::-1][:top_k] # 取分数最高的 top_k hits = [(chunks[i], float(scores[i])) for i in order if scores[i] >= threshold] return hits逻辑说明:index.dot(q_vec)是内积检索,前提是入库时也做了归一化;np.argsort(scores)[::-1]降序排;阈值过滤那一步别省,低分片段塞进 prompt 只会干扰模型。top_k=4是因为 ChatGLM3-6B 的 8K 上下文放 4 段 500 字加系统提示绰绰有余,再多反而稀释注意力。如果召回经常漏,先把阈值降到 0.3 试,还漏就是切分或嵌入模型的问题,别一味加top_k。
3.2 拼 prompt:让模型“只根据资料回答”的写法
召回完就是拼 prompt。这一步的写法直接决定模型会不会胡编。核心是三段式:系统指令、参考资料、用户问题。系统指令里必须明确“资料中没有就说不知道”,否则 6B 模型很容易自由发挥。
PROMPT_TEMPLATE = """你是一个严谨的知识库助手。请仅根据下面的参考资料回答问题。 如果参考资料中没有相关信息,直接回答“资料中未提及”,不要编造。 参考资料: {context} 用户问题:{question} 回答:""" context = "\n\n".join([f"[{i+1}] {c}" for i, (c, s) in enumerate(hits)]) prompt = PROMPT_TEMPLATE.format(context=context, question=query) response, _ = model.chat(tokenizer, prompt, history=[], temperature=0.1)逻辑说明:temperature=0.1是知识库场景的关键,越低越确定,别用默认的 0.7;history=[]表示单轮,多轮追问时要把历史带上但控制长度;参考资料带[1] [2]编号是为了让模型在回答里引用,方便前端做溯源高亮。context拼接时用双换行隔开,避免两段资料黏成一句。
3.3 验证链路是否真的在用资料,而不是模型在背答案
跑通不等于跑对。我见过太多“看起来答得挺好”的 demo,其实是模型靠预训练知识蒙的。验证方法很简单:问一个只有你的文档里才有的虚构事实,比如“某模拟项目X 的验收编号是多少”,如果模型答得出且引用正确,说明检索生效;如果它答“资料中未提及”但你确认文档里有,那就是召回没捞到,回去查切分和阈值。
提示:验证阶段把
top_k临时调到 8、阈值降到 0.2,先确认“只要资料在库里就一定能召回”,再逐步收紧参数,这样能把检索问题和生成问题分开定位。
4. 避坑与排查:这套组合最容易翻车的五个地方
4.1 现象:启动就 OOM,日志停在加载模型那行
原因几乎都是 fp16 全量加载 ChatGLM3-6B 又叠加了 bge-large-zh,12GB 卡扛不住。解决:生成模型强制load_in_4bit=True,嵌入模型device="cpu"或换bge-small-zh降维到 512。如果还 OOM,检查是不是device_map="auto"把嵌入模型也塞到卡上了,显式指定设备。
4.2 现象:回答通顺但答非所问,引用片段明显不相关
这是召回层的问题,不是模型的问题。先看切分:chunk_size太大导致一段里混了多个主题,bge 编码后语义被平均掉。把chunk_size降到 300~400 重灌一次向量库,多数情况能救回来。再不行就检查入库时有没有做归一化,没归一化的话内积检索分数全乱。
4.3 现象:中文问句召回差,换个说法就好了
bge-large-zh 对 query 的表述敏感,短问句尤其明显。常见做法是给 query 加指令前缀,bge 官方推荐检索时用"为这个句子生成表示以用于检索相关文章:"开头。工程里如果没加,手动补上,召回率能提一截。
q_vec = embedder.encode( ["为这个句子生成表示以用于检索相关文章:" + query], normalize_embeddings=True )[0]4.4 现象:多轮对话越聊越离谱,前面说的后面不认
原因是历史没截断,几轮之后 prompt 超长,模型开始丢中间信息。解决:只保留最近 2~3 轮历史,且每轮回答截到 200 字以内;或者把历史摘要成一句再拼。ChatGLM3-6B 虽然支持 8K,但有效注意力在长上下文里会衰减,别指望它记住十轮前的话。
4.5 现象:同一份文档重复入库,检索结果全是重复片段
向量库没有做去重。入库前对 chunk 做一次哈希去重,或者用文档 ID + chunk 序号做唯一键。我一般在上传接口里加一步:先按文件内容 MD5 判断是否已存在,存在就跳过,避免用户手抖点两次上传把库灌脏。
5. 进阶调优:把召回率和回答质量再往上抬一档
跑通之后想再进一步,重点不在换更大的模型,而在检索策略。我常用的两个手段:一是混合检索,bge 向量召回叠加 BM25 关键词召回,用 RRF 融合排序,专治专有名词和编号类查询;二是重排,召回 top 20 后用 bge-reranker 精排取前 4,代价是多一次前向,但准确率提升明显。
| 策略 | 召回 top_k | 精排 | 适用场景 | 额外显存 |
|---|---|---|---|---|
| 纯向量 | 4 | 无 | 语义问答、口语化提问 | 0 |
| 向量+BM25 | 各 10 融合 | 无 | 含编号、专有名词 | 0 |
| 向量+重排 | 20 | bge-reranker | 高准确率要求 | 约 1GB |
重排的接法不复杂,召回 20 条后逐条和 query 拼成 pair 打分:
from FlagEmbedding import FlagReranker reranker = FlagReranker("./reranker", use_fp16=True) pairs = [[query, c] for c, _ in coarse_hits] scores = reranker.compute_score(pairs, normalize=True) top = [coarse_hits[i][0] for i in np.argsort(scores)[::-1][:4]]逻辑说明:compute_score返回相关性分数,normalize=True把分数压到 0~1 方便和阈值配合;use_fp16=True省显存。重排模型比嵌入模型小,1GB 左右,12GB 卡在 4bit 生成模型之外还放得下。这套组合我在文档量大、查询里带大量编号的场景下用过,召回准确率比纯向量稳不少。
最后说个习惯:每次改完切分参数或换嵌入模型,我都会固定跑一组 20 条的“回归问题集”,记录召回命中率和回答正确率,不靠肉眼感觉。血泪经验是,参数调优最怕“这次好像好一点”,没有基线对比就是玄学。从那以后我每次动检索链路都强制走一遍回归集,哪怕只改了一个阈值。希望帮到你。
本文还有配套的精品资源,点击获取