简介:面向希望快速实践私有化知识库问答的开发者与学习者,这套示例项目以 Gradio 搭建文件上传与对话界面,并借助 FAISS 与 Sentence-Transformers 完成文档向量化、相似检索和回答生成,可作为本地部署智能问答助理的入门模板。压缩包共 18 个文件、约 336KB,包含 5 个 py 源码、5 个 txt 说明与配置、2 个 yaml 配置、1 个 whl 依赖包,以及 sh 脚本、docx/xlsx/pdf 示例文档和 pem 证书文件,覆盖代码、依赖、资料与运行辅助内容。已有 140 人学习下载,适合对照目录结构梳理项目启动流程、理解检索增强问答的基本链路,并参考多版本源码与配置文件完成二次调整;同时可借示例文档验证文件解析、知识库构建和问答交互效果,为私有化部署与领域定制提供可复用的实践基础。
1. 内网那台连不上外网的机器,才是我判断这类项目值不值的标准
某公司的机房里有一台机器,显卡装好了、驱动装好了,就是连不上外面任何一个接口。我第一次去做本地部署私有化的智能问答助理,现场就是这样:制度文档、产品手册、历史工单全躺在共享盘里,问一句话要绕三个人,而这些资料偏偏一个字都不能出门。这个示例项目干的事,就是把 ChatGLM 这套问答能力整体搬进这台机器——权重落在本地磁盘,文档切片进本地向量库,前端是一个内网浏览器能直接打开的页面,整条链路不碰任何外部接口。它适合两类人:一类是要在隔离网络里给同事搭一个问答入口的后端或全栈,另一类是想把 RAG 链路从头写一遍、拿 ChatGLM 当底座练手的算法同学。下面按我拆包的顺序走:先看骨架,再跑起来,最后说怎么改成你自己的东西。
2. 工程骨架拆解:从模型权重到问答页面的四层结构
拿到一个私有化问答项目,我第一件事不是看模型代码,而是看目录。目录能告诉我作者把「模型」当成什么:是当成一个函数调用,还是当成一个有生命周期的服务资源。这个项目属于后者,它有明确的模型层、编排层、接入层和数据层,四层之间靠配置文件和一个单例引擎串起来,没有互相 import 成一团。
2.1 目录结构:一个能自洽的私有化工程长什么样
展开后大概是这样一份结构,我按自己的理解把注释补全了:
# 项目根目录结构(tree -L 2 的整理版) assistant/ ├── app/ │ ├── main.py # FastAPI 入口:挂路由、挂静态页、启动时预热模型 │ ├── api/ │ │ ├── chat.py # 对话接口,SSE 流式返回,按 session 隔离历史 │ │ └── admin.py # 知识库上传、重建索引、查看切片 │ ├── core/ │ │ ├── llm.py # ChatGLM 权重加载与推理封装,全局只实例化一次 │ │ ├── retriever.py # 向量检索,返回带分数的片段 │ │ ├── splitter.py # 文档切分策略 │ │ └── session.py # 会话历史存储与长度裁剪 │ └── web/ # 纯静态前端,内网可直接打开 ├── data/ │ ├── docs/ # 原始文档(pdf/docx/md/txt) │ ├── index/ # faiss.index + chunks.txt 持久化目录 │ └── uploads/ # 后台上传的临时文件 ├── models/ │ └── chatglm/ # 本地权重,离线拷贝进来 ├── scripts/ │ ├── build_index.py # 离线建索引,不动服务 │ └── start.sh # 生产启动脚本 ├── configs/ │ └── app.yaml # 路径、量化方式、top_k、阈值全在这儿 └── requirements.txt这份结构里有三个设计点值得注意。models/和data/index/都放在仓库外侧的挂载盘上,代码目录可以随时替换,权重和索引不动;core/下每个文件只干一件事,llm.py不关心文档,retriever.py不关心 prompt,串起来的是api/chat.py;scripts/build_index.py是独立进程,建索引时不会把线上服务的显存挤掉。很多同类项目把建索引塞在服务启动流程里,文档一多,服务重启要等十几分钟,这就是典型的把离线任务和在线任务混在一起。
配置集中在一个 yaml 里,好处是换环境不用改代码:
# configs/app.yaml model: path: /data/models/chatglm # 权重目录,必须本地路径 quantize: int4 # int4 / int8 / none,按显存选 max_length: 2048 # 单轮输出上限,越大越吃显存 temperature: 0.3 # 问答场景压低随机性 retriever: embed_model: /data/models/bge-small # 向量模型,建议放 CPU 跑 index_dir: /data/index top_k: 4 # 召回片段数,3~5 通常够用 score_threshold: 0.35 # 低于此分的片段直接丢掉 session: max_turns: 5 # 保留最近 5 轮,多了就截断 ttl_seconds: 1800 # 会话空闲半小时回收top_k和score_threshold是最容易被忽略的两个参数。top_k调大不一定更好,召回五段八竿子打不着的材料,模型会认真地从里面编答案;score_threshold则是兜底闸门,全部片段都不达标时应该老实回一句「资料里没有」,而不是硬答。
2.2 四层职责:一次提问到底经过了谁
把调用链摊开看,四层的边界非常清楚:
| 层 | 所在位置 | 职责 | 出问题时的典型表现 |
|---|---|---|---|
| 接入层 | app/api/、app/web/ | 鉴权、限流、SSE 推流、静态页 | 页面转圈、字一个个蹦但很慢 |
| 编排层 | app/core/retriever.py、session.py | 检索、拼 prompt、管历史 | 答非所问、多轮串话 |
| 模型层 | app/core/llm.py | 权重加载、量化、生成 | 首问超时、显存爆掉 |
| 数据层 | data/、configs/ | 文档、切片、索引、配置 | 重建索引后效果反而变差 |
接入层和模型层之间没有直接耦合,这对私有化场景很重要:前端想换成企业微信内嵌页,只动app/web/;模型想从 6B 换成更大的,只动llm.py和 yaml。我见过一些实现把model.chat()直接写在路由函数里,会话历史用一个全局 list 存,多两个人同时提问就开始互相串,这类代码改起来比重写还费劲。
编排层的 prompt 拼装是这个项目里最「有业务味道」的一段。它把检索到的片段按分数排序后编号塞进系统提示,再明确告诉模型「只依据资料回答,资料里没有就说没有」。这一步看似朴素,但决定了后面幻觉率高不高。
2.3 技术选型取舍:为什么是这套组合
私有化项目选型的第一约束从来不是性能,是「能不能装进去」。下面这张表是我对这份工程选型的理解,也是我在类似场景里反复用的一套组合:
| 组件 | 项目里的选择 | 常见替代 | 取舍理由 |
|---|---|---|---|
| 对话模型 | ChatGLM 系列 6B 权重 | 更大参数模型、其他开源对话模型 | 单卡能跑,中英双语,社区对量化支持成熟 |
| 推理框架 | transformers 直载 | 专用推理加速框架 | 依赖少,离线环境装包简单,量化开箱可用 |
| 向量检索 | FAISS 本地索引 | 独立向量数据库 | 无服务进程,索引就是一个文件,拷贝即迁移 |
| 向量模型 | 小型句向量模型,跑在 CPU | 与主模型共用 GPU | 显存优先留给生成模型,检索延迟可接受 |
| 服务框架 | FastAPI + SSE | 同步 Web 框架 + 轮询 | 流式输出体验差距明显,异步不阻塞 |
| 前端 | 静态页直出 | 独立前端工程 | 内网无需 Node 构建,一个目录拷过去就能用 |
这里最容易被质疑的是「为什么不用专门的推理加速框架」。结论很直接:隔离网络里装依赖是体力活,多一个编译型依赖就多一次现场翻车。transformers 直载加 4bit 量化,在单张消费级卡上跑 6B 模型做问答,首字延迟一两秒、吐字速度够人读,完全能满足内部问答的量级。等并发真的上来了,再换推理框架也不迟。
3. 把它跑起来:环境、权重与启动顺序
这一章全是能照着敲的步骤。私有化部署和公有云调试最大的区别在于:报错了不能pip install一下了事,得提前把所有东西备齐。我一般的习惯是,在能联网的机器上把依赖和权重全部下好,打成目录整体拷进内网,现场只做安装和启动两件事。
3.1 环境准备:把依赖和 wheel 包提前备齐
先确认显卡和驱动,这是所有问题的源头。nvidia-smi能正常输出、右上角显示驱动版本和 CUDA Version,才算具备条件。注意这里显示的是驱动支持的最高运行时版本,不代表已经装了这个版本的 CUDA 工具链,装 torch 时按它来选 wheel 包就行。
# 1) 确认显卡与驱动 nvidia-smi # 2) 建独立环境,别用系统 Python conda create -n qa python=3.10 -y conda activate qa # 3) 内网机器:从本地 wheel 目录安装,不走网络 pip install --no-index --find-links=/data/wheels -r requirements.txt # 4) 能联网的机器:先把依赖整包下载下来再拷贝 pip download -r requirements.txt -d /data/wheels--no-index --find-links这两个参数是内网安装的关键,前者禁止联网,后者指定本地包目录,缺一个都会卡在联网重试上。requirements.txt里 torch 那行建议锁死版本,别写>=,否则在有网机器和无网机器上装出来的版本可能不一致,现场排查时会怀疑人生。
Python 版本我固定用 3.10。不是因为它最好,而是这个区间里科学计算相关的包兼容性最稳,3.12 上偶尔会碰到某个依赖没有现成 wheel、要现场编译,内网里没有编译工具链就直接卡死。
3.2 权重落盘:量化版本怎么选
权重目录整体拷到models/chatglm/下,确认里面有几个关键文件:模型分片、config.json、tokenizer 相关文件。缺 tokenizer 文件是最常见的拷贝失误,表现是启动时报找不到 vocab,一眼能看出来。
量化方式怎么选,我按显存给一个经验判断:
| 显存 | 建议量化 | 能跑的最大长度 | 效果损失 |
|---|---|---|---|
| 8GB | int4 | 1024~2048 | 可感知,问答够用 |
| 16GB | int4 或 int8 | 2048 | int8 明显更稳 |
| 24GB 及以上 | int8 或不量化 | 4096 | 基本无损 |
int4 是私有化场景的默认答案,因为它是唯一能让 6B 模型挤进单张 8GB 卡的方案。代价是生成质量会有波动,尤其是长回答的后半段容易出现重复。如果你的卡有余量,宁可上 int8,用户感知到的差别远大于那点显存开销。
权重加载时用device_map="auto",让框架自己把层摊到可用设备上。多卡机器上这个参数能省掉很多手工切分的工作,单卡机器上它就是个直通。
3.3 启动顺序:先建索引,再起服务
这个项目把在线和离线拆成两步,顺序不能反。第一步把data/docs/里的文档切分、向量化、写进data/index/;第二步启动服务,服务启动时只加载索引和模型,不做文档处理。
# 第一步:离线建索引(可以和线上服务并存,不影响显存) python scripts/build_index.py --docs ./data/docs --out ./data/index # 第二步:启动服务(生产用脚本,别直接 uvicorn 裸跑) bash scripts/start.shstart.sh里其实就几行,但每一行都有讲究:
#!/usr/bin/env bash set -e export CUDA_VISIBLE_DEVICES=0 # 多卡机器必须指定,否则 device_map 会乱摊 export TRANSFORMERS_OFFLINE=1 # 强制离线,避免启动时去连模型仓库 export HF_HOME=/data/.cache # 缓存目录放到数据盘,别放系统盘 exec uvicorn app.main:app \ --host 0.0.0.0 --port 8000 \ --workers 1 # 模型是进程内单例,workers 必须为 1--workers 1是硬约束。模型加载一次就要吃掉几乎全部显存,开两个 worker 等于让两份权重抢一张卡,第二个进程必然在启动时崩掉。想提高并发,方向是让单个进程内部做请求排队,而不是加进程。TRANSFORMERS_OFFLINE=1则是私有化环境的安全带,不加的话某些版本会在启动阶段尝试联网校验,超时后报一个跟真实原因毫无关系的错。
3.4 健康检查:用一句 curl 把链路打通
服务起来后别急着打开页面,先用命令行验证。页面出问题时你分不清是模型挂了、接口挂了还是前端挂了,而 curl 能一刀切清楚。
# 先探活,确认服务进程和模型都就绪 curl -s http://127.0.0.1:8000/health # 再发一个最小请求,验证检索和生成整条链路 curl -N -X POST http://127.0.0.1:8000/api/chat \ -H "Content-Type: application/json" \ -d '{"session_id":"probe-001","question":"报销流程要走几步?"}'-N是关键,它关掉 curl 的缓冲,让流式输出一段段打出来。如果响应一次性全出来,说明服务端没按流式返回,问题在接口层;如果半天没有任何输出然后突然全出来,那是中间有代理在做缓冲。返回里应该能看到引用片段的来源,这是判断检索有没有命中的最直接证据。
首问特别慢是正常现象,模型第一次前向要初始化各种缓存,通常比后续请求慢好几倍。所以在现场验收时,我会让同事先随便问一句把模型「叫醒」,再去正式演示。
4. 核心链路代码:一次提问是怎么走完检索、拼装、生成的
这一段是这个项目真正值钱的部分。四段代码分别对应切分、检索、模型封装和流式接口,串起来就是完整的问答链路。我在每段后面标了参数怎么调、改哪里会有什么后果。
4.1 文档切分:切片策略决定答案质量上限
# app/core/splitter.py from typing import List def split_text(text: str, chunk_size: int = 500, overlap: int = 80) -> List[str]: """按段落优先切,单段过长再按长度兜底切,块间保留重叠避免语义被切断""" paras = [p.strip() for p in text.split("\n") if p.strip()] chunks, buf = [], "" for p in paras: if len(buf) + len(p) <= chunk_size: buf += p + "\n" continue if buf: chunks.append(buf.strip()) # 段落本身超过块大小,只能硬切,用 overlap 保住跨块的句子 if len(p) > chunk_size: step = chunk_size - overlap chunks.extend(p[i:i + chunk_size] for i in range(0, len(p), step)) buf = "" else: buf = p + "\n" if buf.strip(): chunks.append(buf.strip()) return chunkschunk_size=500是按中文字符估的,一段五百字在 2048 的上下文里塞四段刚好不挤。overlap=80是为了防止一句话被切成两半、两边都读不通,重叠部分让检索至少能捞到完整语义。这两个值不要拍脑袋定,文档类型不同差别很大:制度类文档段落规整,500 够;产品手册里全是表格和步骤,切太碎会把「第一步、第二步」拆散,这时应该把整张表或整节转成一段再切。
我在实际项目里会额外加一层判断:如果段落里出现大量竖线或制表符,先按行合并成 markdown 表格再作为独立块入库。这一步改动很小,但对「表格类问题答不准」的改善非常明显。
4.2 向量检索:把不相关的片段挡在 prompt 外面
# app/core/retriever.py import faiss import numpy as np from sentence_transformers import SentenceTransformer class Retriever: def __init__(self, index_dir: str, embed_model: str, top_k: int = 4): # 向量模型放 CPU,显存优先留给生成模型 self.embed = SentenceTransformer(embed_model, device="cpu") self.index = faiss.read_index(f"{index_dir}/faiss.index") with open(f"{index_dir}/chunks.txt", encoding="utf-8") as f: self.chunks = [line.rstrip("\n") for line in f] self.top_k = top_k def search(self, query: str, threshold: float = 0.35): q = self.embed.encode([query], normalize_embeddings=True) scores, ids = self.index.search(np.array(q, dtype="float32"), self.top_k) hits = [] for score, idx in zip(scores[0], ids[0]): # idx == -1 表示该位置没有足够候选,直接跳过 if idx == -1 or score < threshold: continue hits.append({"text": self.chunks[idx], "score": float(score)}) return hitsnormalize_embeddings=True必须和建索引时保持一致,一边归一化一边不归一化,算出来的相似度分数没有可比性,表现为「明明相关的文档分数很低」。threshold是幻觉的第一道闸门,低于它的片段宁可不要,也不要凑数塞进 prompt。这个值不建议照抄,拿二十条真实问题跑一遍,看命中片段的分数分布再定。
chunks.txt和faiss.index的行号必须严格对应,建索引时是先写 chunks 再 add 向量,顺序错了就会出现「引用来源张冠李戴」,这种 bug 极难发现,因为答案看起来是对的,只有引用错了。
4.3 模型封装:全局单例 + 历史裁剪
# app/core/llm.py from transformers import AutoTokenizer, AutoModel class ChatEngine: def __init__(self, model_path: str, quantize: str = "int4"): self.tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) kwargs = {"trust_remote_code": True, "device_map": "auto"} if quantize == "int4": kwargs["load_in_4bit"] = True elif quantize == "int8": kwargs["load_in_8bit"] = True self.model = AutoModel.from_pretrained(model_path, **kwargs).eval() def answer(self, question: str, history=None, system: str = ""): # 系统提示放在首轮,约束模型只用给定资料作答 prompt = f"{system}\n\n用户问题:{question}" if system else question resp, new_history = self.model.chat( self.tokenizer, prompt, history=history or [], max_length=2048, # 单轮生成长度上限 temperature=0.3, # 问答场景压低随机性 top_p=0.8, ) return resp, new_historyeval()这行不能省,少了它 dropout 还在生效,同一个问题问两次答案不一样,用户会觉得这东西「玄学」。temperature=0.3是我在内部问答里最常用的值,再低会变得机械,再高开始胡说。
历史裁剪放在 session 层做,只保留最近五轮。多轮对话的上下文是线性增长的,聊到第十轮时 prompt 已经吃掉大半窗口,检索到的资料反而被挤掉了,表现为「越聊越不记得资料里写了什么」。这个坑我踩过一次,后来固定成硬性规则。
4.4 流式接口:SSE 把首字延迟藏起来
# app/api/chat.py import json from fastapi import APIRouter from fastapi.responses import StreamingResponse from app.core.llm import ChatEngine from app.core.retriever import Retriever from app.core.session import SessionStore router = APIRouter() engine: ChatEngine = None # 启动时注入的全局单例 retriever: Retriever = None sessions = SessionStore(max_turns=5, ttl=1800) @router.post("/api/chat") async def chat(payload: dict): sid = payload.get("session_id", "default") question = payload["question"] hits = retriever.search(question) context = "\n".join(f"[{i+1}] {h['text']}" for i, h in enumerate(hits)) system = ( "你是一个内部问答助理。只能依据下面的资料回答," "资料中没有的信息请直接说明找不到,不要编造。\n\n" + context ) history = sessions.get(sid) def gen(): for chunk, _ in engine.model.stream_chat( engine.tokenizer, question, history=history ): yield f"data: {json.dumps({'text': chunk}, ensure_ascii=False)}\n\n" yield "data: [DONE]\n\n" return StreamingResponse(gen(), media_type="text/event-stream")media_type="text/event-stream"是 SSE 的硬要求,写成text/plain浏览器不会按事件流解析。每条消息以\n\n结尾,少一个换行前端就收不到完整帧,表现为页面一直转圈。
系统提示里那句「只能依据下面的资料回答」看起来像废话,实测是降低幻觉最有效的一行。加上它之后,模型遇到资料里没有的问题会老实说找不到;不加的话它会用自己预训练的知识一本正经地答,而用户以为是你们公司的制度。
5. 部署避坑:显存、切片、缓冲与串话的五次翻车
这一章全是现场遇到的真实故障,按现象、原因、解决三段写。我把它们分成推理侧和数据接入侧两组,因为这两类问题的排查路径完全不同,混在一起找会很浪费时间。
5.1 推理侧:显存和输出质量
现象一:模型加载到九成报显存不足,但nvidia-smi显示卡是空的。原因往往不是卡不够大,是显存被别的东西先占了:向量模型默认跑了 GPU、另一个残留进程没退干净、或者device_map="auto"把层摊到了不该用的设备上。解决方式是三件事一起做——向量模型显式指定device="cpu",启动前nvidia-smi确认没有僵尸进程,start.sh里显式export CUDA_VISIBLE_DEVICES=0锁死单卡。我在现场见过同一张卡上跑着两个上一轮没杀干净的服务,新服务加载到一半就挂,查了半小时才ps出来。
现象二:4bit 量化后,长回答后半段开始重复同一句话,或者自问自答。这是量化噪声加解码参数的组合问题,不是模型坏了。原因有两个:temperature太低时模型在低概率区间打转,重复惩罚没打开就一路复读。解决方式是把temperature从 0.1 提到 0.3 左右,同时限制max_length,别让它写八百字,问答场景三百字足够。如果业务非要长输出,就别用 int4,换 int8 或者上更大的卡,这是硬件账,调参救不回来。
现象三:第一次提问要等十几秒甚至半分钟,之后正常。首问慢是模型预热,属于预期行为,但如果慢到半分钟就不正常了,通常是启动时没做预热,第一次请求把权重从磁盘冷读了一遍。解决办法是在main.py的启动钩子里跑一次极短的生成,把 KV 缓存和算子都热起来,用户永远感知不到那几秒。另一个隐蔽原因是模型缓存放到了系统盘,磁盘 IO 慢,把HF_HOME指到数据盘能缓解不少。
5.2 数据与接入侧:切片和会话
现象四:答案本身看着对,但引用来源完全对不上,或者引用了不存在的文档名。这是索引和切片文件不同步导致的错位。原因通常是重建索引时只覆盖了faiss.index,chunks.txt还是旧的,行号一一对应关系被破坏。解决方式是建索引脚本里先清空输出目录再写,两个文件必须同一次生成。更稳妥的做法是给每次索引加一个版本号目录,服务启动时读配置里指定的版本,回滚只要改一个路径,这就是我常说的「后悔药」——重建索引把效果搞坏的时候,能一键切回上一版。
现象五:两个人同时提问,答案互相串,A 问的问题 B 的页面出现了回答。原因很清楚:会话历史存在一个全局字典里,key 用的是固定字符串或者干脆没 key。用户量一上来,历史互相污染,表现出来就是「答非所问」,而单测永远复现不了,因为单测只有一个人。解决方式是每个浏览器标签生成一个session_id带到后端,历史按 id 隔离,再加一个空闲超时回收,避免内存无限涨。这个改动只有几十行,但不做的话多用户一上线就是灾难。
前端流式输出卡住、要等好几秒一次性出来,则是反向代理在做缓冲,让网关对/api/chat关掉缓冲、别开 gzip 压缩,或者直接让前端连服务端口,绕开代理这一层。
6. 二次开发:把这套通用助理改成你自己的业务问答
骨架跑通之后,真正的活才开始。我一般按「效果 → 数据 → 接入」的顺序改,因为先改效果能最快看到收益。
第一处改系统提示词。在app/api/chat.py里那段system字符串,是成本最低、收益最高的一处。把「内部问答助理」换成一个具体角色,把回答格式约束死——比如「涉及流程类问题必须分步骤列点,涉及金额必须原样引用资料中的数字」。这一步不需要重训任何东西,改完立刻能对比效果。
第二处换向量模型和重排。默认的小型句向量模型够用但不够好,中文场景里换成更适合语义检索的模型,再在检索后面加一层轻量重排,top_k从 4 提到 10 先召回,重排后只留 3 条进 prompt。我做过对比,重排这一步对「答案引用了正确段落」的提升比换生成模型还明显,而代价只有几十毫秒。
第三处接业务系统。最实用的做法是给模型加工具调用能力,把「查工单状态」「查库存」这类需要实时数据的动作做成函数,让模型在检索不到资料时主动去调。这类改造的边界要划清楚:只读接口随便接,写操作一律走人工确认,别让模型直接改数据。
| 改造点 | 改动位置 | 风险 |
|---|---|---|
| 业务角色与回答格式 | api/chat.py的 system 串 | 低,随时回滚 |
| 换向量模型 | retriever.py+ 重建索引 | 中,索引必须整体重建 |
| 加检索重排 | retriever.py增加一段 | 低,增加几十毫秒延迟 |
| 接业务工具 | 新增 tools 模块 | 高,写操作必须人工确认 |
改完怎么验证?别靠感觉。我会准备一份二十条左右的问题集,覆盖「资料里明确有答案」「资料里只有部分信息」「资料里完全没有」三类,每次改动后跑一遍,看三件事:答案是否正确、引用是否指向正确文档、无答案时是否老实说找不到。第三条经常被忽略,但它才是决定用户信任度的关键。
从那以后我每次改完 prompt 或者索引策略,都强制走一遍这二十条回归,哪怕是只改了一个标点。这套习惯是被一次「改了阈值没测,上线后所有问题都回答找不到」的事故逼出来的。希望帮到你。
本文还有配套的精品资源,点击获取