☰
ChatGLM+本地向量库:内网私有化检索增强问答系统实践
2026/11/2 11:42:29 网站建设 项目流程

简介:面向希望把智能问答能力落到本地环境的技术开发者与机器学习入门者,这份私有化部署示例项目用 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 大概要 13~14GB,int8 约 7~8GB,int4 约 5~6GB。下面这张表是我在几个不同配置的机器上实测后整理的参考值,实际会因框架版本和序列长度浮动。

精度权重占用可用上下文推荐显卡适用场景
fp16约 12~13GB8K24GB 显存演示、答辩、单并发
int8约 7~8GB8K16GB 显存小团队日常使用
int4约 5~6GB4K~8K12GB 显存显存紧张、可接受质量折损

选型结论通常是:能上 fp16 就别急着量化。量化带来的质量下降在长文档摘要类问题上不明显,但在数值、型号、编号这类精确匹配上会翻车,而这些恰恰是内部问答最常问的东西。

2.3 检索方案:向量库选型、切片长度与要不要重排

向量库这一层不需要想得太复杂。单机、几万到几十万条切片、要求离线,本地方案足够:要么用轻量的本地向量索引文件,要么用支持本地持久化的向量库,几十万条这个量级都不需要集群。选型时看三点就够了——能不能纯本地落盘、支持不支持按条件过滤(比如只搜某个部门的文档)、删改一条切片要不要重建整个索引。

切片长度是个容易被忽略的参数。切太短,一句完整的话被劈成两半,模型拿到的是碎片;切太长,一个切片里塞了三四个主题,向量表示被平均掉,召回精度下降。中文技术文档我一般取 400~600 字,重叠 60~100 字。重叠不是为了「多召回」,而是为了防止答案正好落在切口上被截断。

至于重排,属于可选加分项。做法是先向量召回 top 20,再用一个小的重排模型精排到 top 5。它的收益在问题表述和文档表述差异较大时比较明显,代价是多一次推理、延迟涨 100~300ms。如果答辩时间紧或者机器弱,可以先不做,把 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.3~0.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 chunks

max_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_embeddings=True) # 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_size=32在 CPU 上大约几十秒能吃下几百个切片,GPU 上更快。normalize_embeddings=True这一句不能省,归一化之后余弦相似度和内积等价,检索分数的阈值才有可比性,否则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_embeddings=True)[0] hits = [h for h in vector_store.search(q_vec, top_k=top_k) if h.score >= threshold] if not hits: yield "知识库里没有找到相关内容,建议换个说法,或者先补充相关文档。" return # 3) 组装上下文,编号与回答中的引用标号一一对应 context = "\n\n".join(f"[{i+1}] {h.meta['heading']}\n{h.content}" for i, h in enumerate(hits)) prompt = PROMPT_TMPL.format(context=context, question=question) # 4) 流式生成,边出字边累积,结束后落库 full = "" for delta in llm.stream(prompt, max_new_tokens=1024): 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_type="text/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.2~0.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. 验收与答辩:用一份问题集把「能跑」证明成「可信」

演示的时候最容易犯的错,是现场随手敲一个问题,答得还不错,就认为过关了。这种方式经不起追问,因为挑一个模型刚好会的问题太容易了。真正能说明问题的,是一份提前准备好的问题集。

我的做法是准备 30~50 条问题,分四类:第一类是文档里能直接找到答案的,占比一半以上;第二类是需要跨两份文档综合的;第三类是文档里明确没有的,用来测拒答;第四类是口语化、带指代的,用来测多轮改写。每条标注期望命中的文档名。然后跑一个脚本,把命中率、拒答准确率、平均首字延迟算出来。

# eval.py —— 一次性跑完整个问题集,输出三个可写进报告的指标 import json, time cases = [json.loads(l) for l in open("eval/questions.jsonl", encoding="utf-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 条。因为模型答得好不好是玄学,但有没有一套能重复的验证方法,是可以自己掌控的。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询