
简介面向希望把智能问答能力落到本地环境的技术开发者与机器学习入门者这份私有化部署示例项目用 Gradio 搭建对话界面结合 FAISS 向量检索与 Sentence-Transformers 句向量模型打通文档上传、知识库构建与问题回答的完整链路可用于企业知识库、客服问答与内部文档检索等场景。压缩包共 18 个文件、约 336KB主体为 5 个 py 脚本含多版 PrivateAgent 主程序、5 个 txt 配置与说明文本、2 个 yaml 及 requirements、update_requirements.sh 等依赖清单另附 whl 离线安装包、pem 证书与 docx、xlsx、pdf 示例文档便于按目录结构复现运行环境。已有 140 人学习。读者可从中获取从文件解析、向量化入库到检索问答的模块划分思路多版本主程序也便于对比迭代过程适合作为私有化知识库助手的起步模板。1. 需求起点内网资料查不动一个离线问答助理要压住哪三件事内网机房里堆着几百份工艺文档、设备手册和历史工单谁想查一个参数还得靠老员工凭记忆翻目录。这个需求很具体做一套本地部署的私有化智能问答助理资料不出机房断网也能问答案还要能指回原文。整套方案围绕 ChatGLM 这类开源中文模型搭一条检索增强链路用本地向量库做知识底座前端给一个能对话的页面后端全部跑在内网服务器上。它适合两类人一类是要交毕设或课程设计的学生需要一个结构完整、能演示、能讲清楚技术点的题目另一类是从业者要给某个部门交付一套不联外网的知识问答工具。下面按需求、选型、架构、实现、排查、验收的顺序把这套方案拆开讲。2. 技术选型ChatGLM 量级、向量库与切分粒度的三笔账选型这一步最容易出问题。很多人上来就挑最大的模型跑起来发现显存不够改成 int4 量化结果回答质量掉一截最后归因成「开源模型不行」。实际上大部分问题出在检索环节模型本身是被牵连的。2.1 三条必须满足的约束数据不出内网、可离线、答案要能指回原文私有化部署的第一条约束是数据流向。文档、问题、回答、日志全链路都不能经过任何外部接口所以模型权重必须是本地目录下的文件embedding 模型也得是本地加载连 tokenizer 的词表都不能在启动时去拉远程资源。这一点在配置里就要写到最底层而不是靠运行时环境变量临时指定。第二条是可离线。机房网络策略经常是「能出不能进」或者干脆全断。整套服务启动后不应该有任何外部依赖包括前端静态资源、字体、图标全部打包进镜像或者本地目录。我一般会在交付前断网跑一遍全流程能跑通才算过。第三条是可追溯。内部问答和闲聊不一样答错一个参数可能导致整批工件报废。所以每条回答后面必须挂上引用的文档名和章节标题用户点一下能看到原文片段。这条要求直接决定了数据结构切片表里必须存标题路径会话表里必须存引用列表缺一个都做不到。2.2 模型量级怎么挑先算显存再定精度ChatGLM 系列常见的是 6B 这个量级再往上还有更大的版本。对于内网问答这种场景6B 已经够用原因是知识主要来自检索到的资料模型负责的是理解和组织语言不需要它把知识背在参数里。模型越大显存占用、首字延迟、并发能力全都变差收益却很有限。显存要提前算。粗略估法是fp16 下每 10 亿参数约 2GB 权重加上 KV Cache 和推理框架的额外开销6B 模型 fp16 大概要 1314GBint8 约 78GBint4 约 56GB。下面这张表是我在几个不同配置的机器上实测后整理的参考值实际会因框架版本和序列长度浮动。精度权重占用可用上下文推荐显卡适用场景fp16约 1213GB8K24GB 显存演示、答辩、单并发int8约 78GB8K16GB 显存小团队日常使用int4约 56GB4K8K12GB 显存显存紧张、可接受质量折损选型结论通常是能上 fp16 就别急着量化。量化带来的质量下降在长文档摘要类问题上不明显但在数值、型号、编号这类精确匹配上会翻车而这些恰恰是内部问答最常问的东西。2.3 检索方案向量库选型、切片长度与要不要重排向量库这一层不需要想得太复杂。单机、几万到几十万条切片、要求离线本地方案足够要么用轻量的本地向量索引文件要么用支持本地持久化的向量库几十万条这个量级都不需要集群。选型时看三点就够了——能不能纯本地落盘、支持不支持按条件过滤比如只搜某个部门的文档、删改一条切片要不要重建整个索引。切片长度是个容易被忽略的参数。切太短一句完整的话被劈成两半模型拿到的是碎片切太长一个切片里塞了三四个主题向量表示被平均掉召回精度下降。中文技术文档我一般取 400600 字重叠 60100 字。重叠不是为了「多召回」而是为了防止答案正好落在切口上被截断。至于重排属于可选加分项。做法是先向量召回 top 20再用一个小的重排模型精排到 top 5。它的收益在问题表述和文档表述差异较大时比较明显代价是多一次推理、延迟涨 100300ms。如果答辩时间紧或者机器弱可以先不做把 top_k 调大一点顶上。3. 总体架构五层结构与三类数据表的职责划分架构图不用画得很花把「一次提问经过哪些环节」讲清楚就够了。这套方案的链路是前端发问题 → 应用层做会话管理 → 检索层把问题向量化后去库里召回 → 拼装 Prompt → 模型层流式生成 → 结果带引用一起回吐。3.1 五层职责接入层、应用层、检索层、模型层、数据层接入层负责协议和鉴权只做一件事把 HTTP 请求转成内部调用把流式响应按 SSE 推回去。它不碰业务逻辑改前端的时候不用动它。应用层是大脑管会话、管 Prompt 模板、管兜底话术、管日志。会话管理这块要有意识地做多轮对话里用户第二句经常是「那它的额定电流呢」这种指代必须靠历史消息补全成完整问题再去检索否则向量库里什么都搜不到。检索层负责文档解析、切片、向量化、入库、召回。它是最脏的一块因为输入文件格式五花八门——Word、PDF、Markdown、Excel 导出的表格每一种的解析坑都不一样。模型层只做两件事加载权重、流式生成。它的接口要设计得足够窄只接受一个拼好的字符串吐一个 token 流。这样换模型的时候只改这一层。数据层存三类东西原始文档与切片结构化库 向量索引、会话消息、运行日志。3.2 目录结构与配置把模型路径、库连接、Prompt 都挪到配置文件交付给别人的项目最忌讳参数写死在代码里。换一台机器模型路径变了、端口被占了、显存小了要降精度全得改代码重新打包。正确做法是把所有可变项收进一个配置文件。# config/app.yaml —— 换机器只改这个文件代码一行不动 model: path: /opt/models/chatglm-6b # 本地权重目录离线部署不写任何远程标识 precision: fp16 # fp16 / int8 / int4按显存选 max_new_tokens: 1024 # 单次回答上限太长会挤占显存 device: cuda:0 retrieval: embedding_path: /opt/models/bge-small-zh # 中文小模型CPU 也能跑 top_k: 8 # 送进 Prompt 的切片数 score_threshold: 0.35 # 低于此分直接走兜底不做无依据生成 chunk_size: 500 chunk_overlap: 80 store: vector_dir: /data/vector/kb # 向量索引落盘目录 sqlite_path: /data/db/assistant.db server: host: 0.0.0.0 port: 8000 max_concurrency: 4 # 与显存匹配的并发上限这几个参数里score_threshold最值得说。它决定了「什么时候承认自己不知道」。设太高用户问得稍微口语化一点就被拒答体验很差设太低检索到什么垃圾都往 Prompt 里塞模型被迫在噪音里编答案。0.30.4 之间是比较稳的区间具体值要拿一批真实问题跑一遍再定别凭感觉。max_concurrency和显存强相关。6B fp16 在 24GB 卡上一般扛得住 4 路并发再多就开始排队甚至 OOM。这个值宁小勿大排队比崩溃好处理。3.3 数据表设计文档表、切片表、会话表三张核心表整个系统只需要三张核心表但字段要设计对尤其是跟「可追溯」相关的那些。-- 文档表一份原始文件一行用哈希做幂等 CREATE TABLE doc ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, source_path TEXT NOT NULL, file_hash TEXT NOT NULL UNIQUE, -- 同一份文件重复上传不会重复入库 dept TEXT, -- 部门标签检索时可做过滤 status TEXT DEFAULT pending, -- pending / indexed / failed created_at TEXT DEFAULT CURRENT_TIMESTAMP ); -- 切片表一个切片一行heading 是回答时回显给用户的依据 CREATE TABLE chunk ( id INTEGER PRIMARY KEY AUTOINCREMENT, doc_id INTEGER NOT NULL, seq INTEGER NOT NULL, -- 切片在原文中的顺序用于补上下文 heading TEXT, -- 标题路径如「设备手册 第3章 电气参数」 content TEXT NOT NULL, token_len INTEGER, vector_id TEXT, -- 向量库中的主键两边靠它对上 FOREIGN KEY (doc_id) REFERENCES doc(id) ON DELETE CASCADE ); -- 会话表一条消息一行refs 存本次回答引用了哪些切片 CREATE TABLE message ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, role TEXT NOT NULL, -- user / assistant content TEXT NOT NULL, refs TEXT, -- JSON 数组存 chunk_id 列表 created_at TEXT DEFAULT CURRENT_TIMESTAMP );file_hash这个字段是被重复上传教育出来的。做演示的时候同一份手册传了三遍向量库里同一段内容出现三个副本召回时 top 8 里五个是重复的Prompt 被同一段话占满答案反而变差。加了唯一约束之后重复上传直接跳过。heading存标题路径而不是单个标题是因为回显的时候用户需要知道「这条来自哪一章」。只写「第3章」在几百页的手册里等于没定位。vector_id是结构库和向量库的桥。删文档的时候要先按doc_id查出所有vector_id逐个从向量库里删掉再删表记录。顺序反了就会留下野向量之后召回出来的切片在库里查不到回答就变成无源之水。4. 核心链路实现从文档切片到流式回答的四个环节这条链路有四段解析切片、向量入库、检索组装、流式生成。每一段都可以单独测出了问题也容易定位是哪一段的锅。4.1 文档解析与切片先按标题层级切再对超长段落做重叠滑窗最简单也最差的切法是按固定字数硬切。它会直接把一个表格、一段参数列表劈成两半前一半有型号没参数后一半有参数没型号两个切片单独看都是废的。正确顺序是先按文档自身的结构切标题层级、章节、表格边界这些是天然的语义边界结构切完还超长的段落才退化成滑窗。import re HEADING re.compile(r^(#{1,4})\s(.*)$) def split_by_heading(text: str, max_len: int 500, overlap: int 80): 先按 Markdown 标题切成语义块超长块再做带重叠的滑窗切分 blocks, cur_title, buf [], , [] for line in text.splitlines(): m HEADING.match(line.strip()) if m: # 遇到新标题先把上一块吐出去 if buf: blocks.append((cur_title, \n.join(buf))) buf [] cur_title m.group(2).strip() else: buf.append(line) if buf: blocks.append((cur_title, \n.join(buf))) chunks [] for title, body in blocks: body body.strip() if not body: continue if len(body) max_len: # 短块整块保留语义完整优先 chunks.append({heading: title, content: body}) continue start 0 while start len(body): chunks.append({heading: title, content: body[start:start max_len]}) start max_len - overlap # 重叠窗口防止答案正好落在切口上 return chunksmax_len决定单个切片的语义密度500 字左右适合中文技术文档overlap决定容错80 字大概是两三句话够把被切断的因果句补回来。这两个值不要一次调到底先跑 20 个典型问题看召回质量再微调。解析 Word 和 PDF 是另一摊事。PDF 里的表格提取出来经常是错行的我的做法是识别到表格区域就单独走一遍表格解析把结果转成「字段: 值」的形式存进content宁可丢掉版式也要保住字段和值的对应关系。毕竟用户问的是参数不是排版。4.2 向量化与入库批量、幂等、可重建入库这一步的核心诉求是「可重建」。知识库更新是常态今天加十份文档明天删三份如果没有一条能整体重建的路索引迟早会烂掉。设计上要求任何时刻删掉vector_dir目录和chunk表跑一条重建命令能完整恢复。def index_document(doc_id: int, path: str, batch_size: int 32): 把一份文档切完、批量向量化、写入向量库和切片表 text read_file(path) # 按扩展名分发到不同解析器 chunks split_by_heading(text) for i in range(0, len(chunks), batch_size): batch chunks[i:i batch_size] texts [c[content] for c in batch] vectors embed_model.encode(texts, normalize_embeddingsTrue) # L2 归一化 for c, vec in zip(batch, vectors): vid vector_store.add(vec, meta{doc_id: doc_id, heading: c[heading]}) db.insert_chunk(doc_id, c[heading], c[content], vid) db.update_doc_status(doc_id, indexed)批量是为了吞吐batch_size32在 CPU 上大约几十秒能吃下几百个切片GPU 上更快。normalize_embeddingsTrue这一句不能省归一化之后余弦相似度和内积等价检索分数的阈值才有可比性否则score_threshold那个 0.35 就是玄学数字换一批文档就失效。入库还要考虑失败重入。中途崩了怎么办doc.status停留在pending重建任务启动时先扫一遍pending和failed的文档重新处理。有了这个状态机「传了但没索引成功」这种黑匣子状态就不会在系统里长期潜伏。4.3 检索组装召回、过滤、拼 Prompt、流式回吐主链路是所有参数汇合的地方也是最需要做兜底的地方。PROMPT_TMPL 你是一个严谨的内部资料问答助手。只能依据下面的资料回答。 资料中未提及的内容直接回答「资料中未提及」不要自行推测或补充。 回答时在句末用 [编号] 标出依据。 资料 {context} 问题{question} 回答 def stream_answer(question: str, session_id: str, top_k: int 8, threshold: float 0.35): # 1) 多轮补全把「那它的额定电流呢」还原成完整问题 query rewrite_with_history(question, session_id) # 2) 召回 分数过滤宁可拒答也不编 q_vec embed_model.encode([query], normalize_embeddingsTrue)[0] hits [h for h in vector_store.search(q_vec, top_ktop_k) if h.score threshold] if not hits: yield 知识库里没有找到相关内容建议换个说法或者先补充相关文档。 return # 3) 组装上下文编号与回答中的引用标号一一对应 context \n\n.join(f[{i1}] {h.meta[heading]}\n{h.content} for i, h in enumerate(hits)) prompt PROMPT_TMPL.format(contextcontext, questionquestion) # 4) 流式生成边出字边累积结束后落库 full for delta in llm.stream(prompt, max_new_tokens1024): full delta yield delta refs [{chunk_id: h.id, heading: h.meta[heading]} for h in hits] db.save_message(session_id, question, full, refs)rewrite_with_history是多轮体验的分水岭。没有它用户必须先说「3号设备的电气参数是什么」才搜得准有了它第二句「那它的额定电流呢」也能搜到同一篇文档。实现上取最近 6 条消息拼成一段交给模型改写改完再检索。Prompt 里的「只能依据资料回答」和「资料中未提及」这两句要写死不要指望模型自觉。编号[1] [2]和切片顺序严格对应前端点引用时才能准确定位到那一段。4.4 服务化封装接口、并发与超时封接口的时候有两个坑一是同步阻塞模型生成 20 秒整个服务在这 20 秒里没法响应别人二是并发无限制五个请求同时进来显存直接爆掉。两个都要在接口层处理掉。import asyncio from fastapi import FastAPI from fastapi.responses import StreamingResponse app FastAPI() SEM asyncio.Semaphore(4) # 与 max_concurrency 一致多余的请求排队而非崩溃 app.post(/api/chat) async def chat(req: ChatRequest): async with SEM: return StreamingResponse( stream_answer(req.question, req.session_id), media_typetext/event-stream, headers{X-Accel-Buffering: no}, # 关掉中间层缓冲否则流式变批量 )Semaphore(4)的意思是同时最多 4 个请求进入生成阶段第 5 个在门口等。这比 OOM 之后整个服务重启要好得多用户体验上只是慢了几秒。X-Accel-Buffering: no这个响应头很关键前面只要挂了一层反向代理不关缓冲的话流式输出会被攒成一坨前端等到最后才一次性显示看着像卡死了。超时也要设。单次生成加一个 60 秒上限超了主动断开并返回「这个问题比较复杂请缩小范围再问」。没有上限的话某个超长上下文可能把一张卡占住好几分钟。5. 常见问题排查五类最容易翻车的现象、原因与解法这套系统上线后遇到的投诉八成集中在五个地方。它们的表现各不相同但排查路径高度相似先确认是检索没召回还是召回了但模型没用上还是模型自己编的。5.1 答非所问答案在文档里根本找不到现象问「3号设备的额定电流」回答给出一个具体数值翻遍文档也没有这个数。原因检索分数过滤失效。normalize_embeddings忘了开或者阈值设成了 0 或者负值导致任何问题都能召回一堆弱相关切片模型在噪音里被迫生成。解决先单独打日志看 top_k 的分数分布。正常情况下相关切片的分数应该明显高于不相关的如果全都挤在 0.20.3 这个区间说明向量质量有问题检查是否做了归一化、embedding 模型是否和入库时用的是同一个。阈值调到 0.35 以上配合 Prompt 里的「资料中未提及」兜底编答案的情况会少很多。5.2 第一次能跑第二次显存就爆了现象服务启动后第一个请求正常第二个请求报 CUDA out of memory重启又能跑一次。原因生成过程中产生的中间张量没释放或者并发信号量的值大于显存实际能承受的路数。也可能是max_new_tokens设得太大长回答把 KV Cache 撑爆了。解决把并发从 4 降到 2 试试能稳定就说明是并发数问题。检查生成结束后有没有显式清理缓存长连接场景下这是必需的。max_new_tokens从 1024 降到 512 也能立竿见影内部问答很少需要超过 500 字的回答。5.3 明明文档里有就是搜不出来现象某份手册里白纸黑字写着的参数问了三遍都拒答。原因三种可能。一是切片时把这段切碎了参数和型号被分到两个切片二是这份文档的status还是pending根本没入库三是这份文档用的是扫描版 PDF解析出来是空白。解决先查chunk表里有没有这份doc_id的记录没有就是入库失败看日志。有记录的话SELECT content FROM chunk WHERE doc_id?把切片打出来看如果内容残缺或者乱码问题在解析器。扫描件要么做 OCR要么明确告知用户这类文件不支持。5.4 首次请求要等半分钟才出第一个字现象服务刚启动时第一个问题卡很久之后正常。原因模型权重懒加载第一次推理时才真正读进显存embedding 模型同理向量索引也可能是首次查询才加载到内存。解决启动时做一次预热。加载完权重之后用一句固定的话跑一次完整链路把模型、embedding、向量索引全部激活再对外暴露端口。预热这一步在演示和答辩场合尤其重要不然第一个问题卡在那里场面很难看。5.5 文档更新了回答还是旧的现象明明把手册的第 3 章改过重新上传了问出来的还是老参数。原因哈希幂等把新文件当成重复文件跳过了或者旧切片没删干净。还有一个容易忽略的点——检索结果做了内存缓存缓存没失效。解决文件内容变了哈希自然会变不会被判重所以大概率是旧切片没删。删文档的逻辑必须是「先删向量再删切片最后删文档记录」顺序不能反。缓存这块给每个知识库版本打一个版本号更新后版本号加一缓存键带上版本号自然就失效了。6. 验收与答辩用一份问题集把「能跑」证明成「可信」演示的时候最容易犯的错是现场随手敲一个问题答得还不错就认为过关了。这种方式经不起追问因为挑一个模型刚好会的问题太容易了。真正能说明问题的是一份提前准备好的问题集。我的做法是准备 3050 条问题分四类第一类是文档里能直接找到答案的占比一半以上第二类是需要跨两份文档综合的第三类是文档里明确没有的用来测拒答第四类是口语化、带指代的用来测多轮改写。每条标注期望命中的文档名。然后跑一个脚本把命中率、拒答准确率、平均首字延迟算出来。# eval.py —— 一次性跑完整个问题集输出三个可写进报告的指标 import json, time cases [json.loads(l) for l in open(eval/questions.jsonl, encodingutf-8)] hit refuse_ok 0 latency [] for c in cases: t0 time.time() res answer_once(c[q]) # 非流式方便统计 latency.append(time.time() - t0) if c[type] no_answer: # 第三类应该拒答 refuse_ok (len(res[refs]) 0) else: hit any(c[expect_doc] in r[heading] for r in res[refs]) n len(cases) print(f召回命中率 {hit/n:.1%} 拒答正确率 {refuse_ok/n:.1%} f平均延迟 {sum(latency)/n:.2f}s)这段脚本跑出来的三个数字直接就是答辩 PPT 里的「实验数据」。命中率低于 70% 说明切片或阈值要调拒答正确率低于 80% 说明阈值太低模型在硬编平均延迟超过 5 秒要考虑量化或者减 top_k。验收的时候还有一条容易被忽略让一个没用过这套系统的人来操作。你站在旁边不说话看他自己能不能把文档传进去、等索引跑完、问到答案、点到原文。如果他在「上传完不知道要等索引」这一步卡住说明状态提示做得不够这属于产品问题跟技术无关但答辩老师或者验收方一定会挑。通常的做法是在文档列表的每一行显示一个状态标签处理中转圈、可检索变绿、失败标红并且点开能看到原因。还有个小技巧把引用做成可以展开的。用户看到答案里的[2]点一下能展开那一段原文并且高亮关键词。这个交互花不了多少工夫但在验收现场的说服力比讲十分钟架构都强因为它把「可追溯」这件事从一句设计承诺变成了可见的事实。从那以后我每次交付这类问答系统都会强制走一遍「问题集 → 指标 → 陌生人试用」这三步不管时间多紧问题集至少准备 30 条。因为模型答得好不好是玄学但有没有一套能重复的验证方法是可以自己掌控的。希望帮到你。本文还有配套的精品资源点击获取