简介面向希望快速实践私有化知识库问答的开发者与学习者这套示例项目以 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.pyscripts/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 python3.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一眼能看出来。量化方式怎么选我按显存给一个经验判断显存建议量化能跑的最大长度效果损失8GBint41024~2048可感知问答够用16GBint4 或 int82048int8 明显更稳24GB 及以上int8 或不量化4096基本无损int4 是私有化场景的默认答案因为它是唯一能让 6B 模型挤进单张 8GB 卡的方案。代价是生成质量会有波动尤其是长回答的后半段容易出现重复。如果你的卡有余量宁可上 int8用户感知到的差别远大于那点显存开销。权重加载时用device_mapauto让框架自己把层摊到可用设备上。多卡机器上这个参数能省掉很多手工切分的工作单卡机器上它就是个直通。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_DEVICES0 # 多卡机器必须指定否则 device_map 会乱摊 export TRANSFORMERS_OFFLINE1 # 强制离线避免启动时去连模型仓库 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_OFFLINE1则是私有化环境的安全带不加的话某些版本会在启动阶段尝试联网校验超时后报一个跟真实原因毫无关系的错。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_size500是按中文字符估的一段五百字在 2048 的上下文里塞四段刚好不挤。overlap80是为了防止一句话被切成两半、两边都读不通重叠部分让检索至少能捞到完整语义。这两个值不要拍脑袋定文档类型不同差别很大制度类文档段落规整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, devicecpu) self.index faiss.read_index(f{index_dir}/faiss.index) with open(f{index_dir}/chunks.txt, encodingutf-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_embeddingsTrue) scores, ids self.index.search(np.array(q, dtypefloat32), 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_embeddingsTrue必须和建索引时保持一致一边归一化一边不归一化算出来的相似度分数没有可比性表现为「明明相关的文档分数很低」。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_codeTrue) 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, historyNone, system: str ): # 系统提示放在首轮约束模型只用给定资料作答 prompt f{system}\n\n用户问题{question} if system else question resp, new_history self.model.chat( self.tokenizer, prompt, historyhistory or [], max_length2048, # 单轮生成长度上限 temperature0.3, # 问答场景压低随机性 top_p0.8, ) return resp, new_historyeval()这行不能省少了它 dropout 还在生效同一个问题问两次答案不一样用户会觉得这东西「玄学」。temperature0.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_turns5, ttl1800) 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[{i1}] {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, historyhistory ): yield fdata: {json.dumps({text: chunk}, ensure_asciiFalse)}\n\n yield data: [DONE]\n\n return StreamingResponse(gen(), media_typetext/event-stream)media_typetext/event-stream是 SSE 的硬要求写成text/plain浏览器不会按事件流解析。每条消息以\n\n结尾少一个换行前端就收不到完整帧表现为页面一直转圈。系统提示里那句「只能依据下面的资料回答」看起来像废话实测是降低幻觉最有效的一行。加上它之后模型遇到资料里没有的问题会老实说找不到不加的话它会用自己预训练的知识一本正经地答而用户以为是你们公司的制度。5. 部署避坑显存、切片、缓冲与串话的五次翻车这一章全是现场遇到的真实故障按现象、原因、解决三段写。我把它们分成推理侧和数据接入侧两组因为这两类问题的排查路径完全不同混在一起找会很浪费时间。5.1 推理侧显存和输出质量现象一模型加载到九成报显存不足但nvidia-smi显示卡是空的。原因往往不是卡不够大是显存被别的东西先占了向量模型默认跑了 GPU、另一个残留进程没退干净、或者device_mapauto把层摊到了不该用的设备上。解决方式是三件事一起做——向量模型显式指定devicecpu启动前nvidia-smi确认没有僵尸进程start.sh里显式export CUDA_VISIBLE_DEVICES0锁死单卡。我在现场见过同一张卡上跑着两个上一轮没杀干净的服务新服务加载到一半就挂查了半小时才ps出来。现象二4bit 量化后长回答后半段开始重复同一句话或者自问自答。这是量化噪声加解码参数的组合问题不是模型坏了。原因有两个temperature太低时模型在低概率区间打转重复惩罚没打开就一路复读。解决方式是把temperature从 0.1 提到 0.3 左右同时限制max_length别让它写八百字问答场景三百字足够。如果业务非要长输出就别用 int4换 int8 或者上更大的卡这是硬件账调参救不回来。现象三第一次提问要等十几秒甚至半分钟之后正常。首问慢是模型预热属于预期行为但如果慢到半分钟就不正常了通常是启动时没做预热第一次请求把权重从磁盘冷读了一遍。解决办法是在main.py的启动钩子里跑一次极短的生成把 KV 缓存和算子都热起来用户永远感知不到那几秒。另一个隐蔽原因是模型缓存放到了系统盘磁盘 IO 慢把HF_HOME指到数据盘能缓解不少。5.2 数据与接入侧切片和会话现象四答案本身看着对但引用来源完全对不上或者引用了不存在的文档名。这是索引和切片文件不同步导致的错位。原因通常是重建索引时只覆盖了faiss.indexchunks.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 或者索引策略都强制走一遍这二十条回归哪怕是只改了一个标点。这套习惯是被一次「改了阈值没测上线后所有问题都回答找不到」的事故逼出来的。希望帮到你。本文还有配套的精品资源点击获取