☰
本地部署私有化智能问答助理:ChatGLM+向量检索实战
2026/10/15 18:59:11 网站建设 项目流程

简介:这份资源面向希望本地搭建私有化智能问答系统的开发者与AI学习者,基于Gradio、FAISS与Sentence-Transformers构建文件上传与知识库检索问答示例,解决数据隐私保护、离线部署与文档高效检索的问题。压缩包共18个文件,约336KB,以Python脚本与txt配置说明为主,辅以yaml参数文件、whl依赖包、sh执行脚本,以及docx、xlsx、pdf等示例文档和pem证书文件,覆盖模型配置、依赖安装与运行入口等模块。已有140人学习下载。通过该示例可掌握向量相似度检索与文本嵌入的落地方式,理解私有化部署中数据隔离与资源控制的实现思路,并参考目录结构快速复现文件上传、知识库查询与对话交互的完整流程,适合作为智能问答项目的入门实践与二次开发起点。

1. 从一台吃灰的旧笔记本说起:为什么我要把问答助理搬进本地

去年收拾工位,翻出一台 16G 内存的旧笔记本,卖不上价,扔了可惜。当时正好在做一个内部知识库的问答需求,数据敏感,不能往外发请求,我就琢磨着能不能把模型塞进这台机器里跑起来。试了一圈,最后落在一个叫「本地部署私有化智能问答助理」的示例项目上——它把 ChatGLM 这类对话模型、向量检索和一层简单的 Web 界面串成了一条完整链路,下载下来改改配置就能跑。这篇文章就是我把这套东西拆开、跑通、又动手改过之后的记录,适合两类人:一类是想在自己机器上搭一个不联网问答服务的开发者,另一类是手里有私有语料、想找个能二次开发的底座的人。下面从它能干什么讲到怎么改,中间该踩的坑我都标出来了。

2. 项目定位与技术选型:这套东西到底由哪几块拼起来

2.1 它解决的是什么问题

一句话概括:把「大模型对话能力」和「你自己的文档」绑在一起,全部跑在本地。用户在前端输入问题,后端先从你喂进去的资料里检索出相关片段,再把片段和问题一起交给模型,模型基于这些片段生成回答。整个过程不依赖外部接口,模型权重、向量库、文档全在本地磁盘上。

这个定位决定了它的技术选型不会太花哨。模型层用 ChatGLM 系列,原因是它对中文支持好、量化后显存占用可控,6G 显存就能跑起来;检索层用向量相似度做召回,常见做法是 sentence-transformers 类的编码模型把文本转成向量,存进 FAISS 或 Chroma 这类本地向量库;服务层用 FastAPI 起一个 HTTP 接口,前端用轻量的页面调它。这套组合的好处是每一层都能单独替换——你嫌 ChatGLM 慢可以换别的,嫌 FAISS 不好维护可以换 Chroma,互不影响。

2.2 目录结构长什么样

下载下来解压,典型的目录大概是这样(不同版本会有出入,以你手里的为准):

project/ ├── app/ │ ├── main.py # FastAPI 入口,注册路由 │ ├── api/ │ │ ├── chat.py # 对话接口 │ │ └── knowledge.py # 知识库上传/检索接口 │ ├── core/ │ │ ├── config.py # 配置读取 │ │ └── llm.py # 模型加载与推理封装 │ └── rag/ │ ├── embedder.py # 文本向量化 │ └── retriever.py # 向量检索 ├── web/ # 前端静态页面 ├── data/ │ ├── docs/ # 放你的原始文档 │ └── vector_store/ # 向量库持久化目录 ├── models/ # 模型权重放这里 ├── requirements.txt └── config.yaml # 模型路径、端口、检索参数

这个结构的关键在于core/llm.py和rag/两个目录:前者管模型怎么加载、怎么生成,后者管文档怎么进库、怎么被检索出来。二次开发基本都围绕这两块动刀。

2.3 一次问答请求的完整链路

理解链路比记目录重要。用户在页面输入「报销流程是什么」,请求打到/api/chat,后端做的事按顺序是:

  1. 把问题用编码模型转成一个向量;
  2. 拿这个向量去向量库里找最相似的 N 个文档片段(N 就是常说的 top_k);
  3. 把这些片段拼成一段上下文,和原始问题一起塞进 prompt 模板;
  4. 把拼好的 prompt 交给 ChatGLM 生成回答;
  5. 把回答返回前端,同时可以带上引用的片段来源。

第 2 步和第 3 步是这套系统区别于「裸模型」的核心。裸模型问它报销流程,它只能编;有了检索,它答的是你文档里真实写的内容。所以调参的重点也在这两步:top_k 取多少、片段切多大、prompt 怎么拼,直接决定回答质量。

2.4 环境准备与依赖安装

先把 Python 环境隔离出来,别污染系统环境:

# 建议 3.9 或 3.10,太新的版本部分依赖轮子还没跟上 conda create -n qa-local python=3.10 -y conda activate qa-local # 装依赖,torch 按你的显卡选对应版本 pip install torch --index-url https://download.pytorch.org/whl/cu118 pip install -r requirements.txt

requirements.txt里通常会有fastapi、uvicorn、transformers、sentence-transformers、faiss-cpu(或faiss-gpu)、pyyaml这些。装的时候最容易翻车的是 torch 和 faiss 的版本匹配:显卡驱动新、CUDA 版本对不上,torch 装完torch.cuda.is_available()返回 False,模型就只能跑 CPU,慢到没法用。装完先跑一句验证:

import torch print(torch.cuda.is_available(), torch.cuda.device_count())

输出True 1才算 GPU 可用。如果只有 CPU,也能跑,但 ChatGLM 的 6B 模型在 CPU 上生成一句话要等十几秒,体验很差,建议至少找张 6G 显存的卡。

3. 核心模块拆解:模型加载、向量检索和接口怎么串起来

3.1 模型加载与推理封装

core/llm.py是整个项目最吃资源的地方。它要做的事就一件:把模型权重读进显存,暴露一个chat()方法给上层调。常见写法是这样:

# app/core/llm.py from transformers import AutoTokenizer, AutoModel import torch class LLMEngine: def __init__(self, model_path: str, device: str = "cuda"): # 加载分词器和模型,trust_remote_code 是 ChatGLM 系列必须的 self.tokenizer = AutoTokenizer.from_pretrained( model_path, trust_remote_code=True ) self.model = AutoModel.from_pretrained( model_path, trust_remote_code=True ).to(device) # 半精度能省一半显存,6G 卡跑 6B 模型基本靠它 self.model = self.model.half().eval() self.device = device def chat(self, prompt: str, history=None, max_length: int = 2048): # 关掉梯度计算,推理阶段不需要反向传播 with torch.no_grad(): response, new_history = self.model.chat( self.tokenizer, prompt, history=history or [], max_length=max_length, temperature=0.7, ) return response, new_history

几个参数值得说清楚。model_path指向models/下你放权重的目录,路径写错会直接报找不到 config。half()把权重转成 fp16,显存占用砍半,代价是极少数情况下数值精度略降,对话场景基本无感。max_length控制的是输入加输出的总长度,设太大显存会爆,设太小长文档塞不进去,2048 是个稳妥的起点。temperature越高回答越发散,做知识问答建议调到 0.1 到 0.3,让它老老实实按检索到的内容答。

chat()方法里的history是对话历史,多轮对话靠它维持上下文。注意历史越长,占的显存越多,长会话要定期截断,否则跑着跑着就 OOM。

3.2 文档向量化与入库

检索质量的上限在入库这一步就定死了。rag/embedder.py负责把文档切成片段、转成向量:

# app/rag/embedder.py from sentence_transformers import SentenceTransformer class Embedder: def __init__(self, model_name: str = "BAAI/bge-small-zh"): # 中文场景优先选中文编码模型,别用英文的 self.model = SentenceTransformer(model_name) def split_text(self, text: str, chunk_size: int = 500, overlap: int = 50): # 按固定长度切,overlap 让相邻片段有重叠,避免答案被切断 chunks = [] start = 0 while start < len(text): end = start + chunk_size chunks.append(text[start:end]) start = end - overlap return chunks def encode(self, texts): # normalize 后余弦相似度等价于内积,检索更快 return self.model.encode(texts, normalize_embeddings=True)

chunk_size和overlap是两个最需要调的参数。切太碎,一个完整答案被拆到好几个片段,检索出来拼不回去;切太大,一个片段里混了好几个主题,检索精度下降。500 字配 50 字重叠是中文文档的常见起点,如果你的文档是表格或条款,得按结构切,不能硬按字数。编码模型选bge-small-zh这类中文模型,用英文模型编码中文,检索结果会差得让你怀疑人生。

入库流程是:读data/docs/下的文件 → 切片段 → 编码 → 存进向量库。这一步通常写成一个脚本,改完文档重新跑一遍即可。

3.3 检索与 prompt 拼接

rag/retriever.py把问题向量和库里向量做比对,取最相似的几个:

# app/rag/retriever.py import faiss import numpy as np class Retriever: def __init__(self, index_path: str): self.index = faiss.read_index(index_path) def search(self, query_vector, top_k: int = 3): # query_vector 形状要是 (1, dim),返回距离和下标 distances, indices = self.index.search( np.array([query_vector]).astype("float32"), top_k ) return indices[0], distances[0]

top_k取 3 到 5 比较常见。取太少,可能漏掉真正相关的片段;取太多,无关内容混进 prompt,模型容易被带偏,还白占显存。检索回来的片段要拼进 prompt,模板大致是:

基于以下资料回答问题,资料里没有的信息不要编造。 资料: {检索到的片段} 问题:{用户问题} 回答:

这句「资料里没有的信息不要编造」很关键,能明显降低模型胡编的概率。模板怎么措辞,直接影响回答风格,二次开发时这是最值得反复打磨的地方。

3.4 FastAPI 接口层

app/main.py把上面几块串起来,对外暴露 HTTP 接口:

# app/main.py from fastapi import FastAPI from pydantic import BaseModel from app.core.llm import LLMEngine from app.rag.retriever import Retriever from app.rag.embedder import Embedder app = FastAPI() llm = LLMEngine(model_path="models/chatglm") embedder = Embedder() retriever = Retriever(index_path="data/vector_store/index.faiss") class ChatRequest(BaseModel): question: str top_k: int = 3 @app.post("/api/chat") def chat(req: ChatRequest): # 1. 问题转向量 q_vec = embedder.encode([req.question])[0] # 2. 检索相关片段 indices, _ = retriever.search(q_vec, top_k=req.top_k) context = "\n".join(load_chunks(indices)) # 3. 拼 prompt 交给模型 prompt = f"基于以下资料回答问题:\n{context}\n问题:{req.question}\n回答:" answer, _ = llm.chat(prompt) return {"answer": answer, "sources": indices.tolist()}

启动命令是uvicorn app.main:app --host 0.0.0.0 --port 8000。--host 0.0.0.0让局域网内其他机器也能访问,只在本机用的话写127.0.0.1更安全。接口返回里带上sources,前端就能显示「答案来自哪几个片段」,这对知识问答场景很重要,用户能自己核对。

4. 跑起来:从零到能对话的完整步骤

4.1 模型权重怎么放

项目本身不含模型权重,得自己下。ChatGLM 的权重放到models/chatglm/下,目录里应该有config.json、tokenizer.model、若干个.bin或.safetensors文件。放好后改config.yaml:

model: path: "models/chatglm" device: "cuda" max_length: 2048 retrieval: top_k: 3 chunk_size: 500 embed_model: "BAAI/bge-small-zh" server: host: "0.0.0.0" port: 8000

路径用相对路径时,注意启动命令的工作目录要对,否则会找不到模型。我一般直接写绝对路径,省得来回折腾。

4.2 灌入你的文档

把要问答的资料丢进data/docs/,支持 txt、md、pdf 都行(pdf 需要额外的解析库)。然后跑入库脚本:

python -m app.rag.build_index --docs data/docs --out data/vector_store

这个脚本内部就是遍历文件、切片段、编码、写 FAISS 索引。文档多的时候这一步会跑几分钟,编码模型在 CPU 上跑更慢,有 GPU 会快很多。跑完data/vector_store/下会多出index.faiss和对应的片段映射文件。

4.3 启动服务并验证

uvicorn app.main:app --host 0.0.0.0 --port 8000

浏览器打开http://localhost:8000,能看到对话页面。先问一个文档里明确写了的问题,比如文档里有「年假 15 天」,就问「年假多少天」,看它答得对不对。再问一个文档里没有的,看它会不会老实说不知道。这两个测试能快速判断检索和 prompt 有没有配好。

用 curl 直接测接口也行:

curl -X POST http://localhost:8000/api/chat \ -H "Content-Type: application/json" \ -d '{"question": "年假多少天", "top_k": 3}'

返回的 JSON 里answer是回答,sources是命中的片段下标。如果sources是空的或者明显不相关,问题出在检索层,回去查编码模型和切片参数。

5. 避坑与排查:这几个问题我几乎每次都遇到

5.1 显存不够,跑着跑着就崩

现象:启动时报 CUDA out of memory,或者聊几轮之后突然崩掉。原因通常是模型没做量化、max_length设太大,或者对话历史没截断,越积越多。解决:先确认用了half(),还不行就上 4bit 量化(load_in_4bit=True),6G 卡跑 6B 模型基本靠量化;把max_length降到 1024;在chat()里限制 history 长度,超过就丢掉最早的几轮。

5.2 回答和文档对不上,模型在编

现象:问文档里的内容,模型答得头头是道但和原文不符。原因多半是检索没召回正确片段,模型只能靠自己「记忆」编。排查顺序:先看sources命中的片段是不是相关的,不相关就是检索问题——检查编码模型是不是中文的、chunk_size是不是切碎了、top_k是不是太小。如果片段相关但回答还是偏,就是 prompt 模板没约束好,把「不要编造」那句加粗强调,或者把temperature降到 0.1。

5.3 中文检索效果差

现象:明明文档里有答案,检索就是召不回来。原因基本是编码模型用了英文的,比如默认的all-MiniLM-L6-v2,它编码中文的效果很差。解决:换成BAAI/bge-small-zh或text2vec-base-chinese这类中文模型,换完重新跑一遍入库脚本——注意,换了编码模型,旧索引必须重建,否则向量空间对不上,检索全是乱的。

5.4 改了文档但回答没变

现象:往data/docs/加了新文件,问答还是老样子。原因是入库脚本没重跑,向量库里还是旧数据。解决:每次改完文档都重新执行build_index。如果文档量大、只想增量更新,得自己改脚本支持追加写入,FAISS 原生支持add,但要注意片段映射文件同步更新。

5.5 端口被占或局域网访问不了

现象:uvicorn启动报Address already in use,或者本机能访问、别的机器访问不了。原因:端口被别的进程占了,或者--host写成了127.0.0.1。解决:换端口--port 8001,或者lsof -i:8000找到占用进程杀掉;局域网访问必须用0.0.0.0,同时确认防火墙放行了对应端口。

6. 二次开发:把示例改成你自己能长期用的东西

示例项目能跑通只是起点,真要长期用,得动几处地方。我按改动价值从高到低说。

第一处是 prompt 模板。这是投入产出比最高的改动。示例里的模板很朴素,你可以按业务加约束:要求回答带出处、要求用固定格式输出、要求遇到不确定就明确说不知道。我一般会把模板抽到config.yaml里,改措辞不用动代码。模板改完拿一组固定问题回归测一遍,看回答有没有变好,别凭感觉。

第二处是检索策略。示例用的是纯向量检索,对短问题够用,但遇到专有名词、编号这类精确匹配,向量检索经常召不回来。常见做法是加一路关键词检索(BM25),两路结果融合排序,也就是常说的混合检索。改动集中在retriever.py,加一个 BM25 索引,检索时两路各取 top_k,按分数加权合并。这块代码量不大,但效果提升明显。

第三处是文档解析。示例对 pdf、word 的支持通常很粗糙,直接按字符切会把表格和标题切烂。如果你的资料以 pdf 为主,建议在入库前加一层结构化解析,把标题、段落、表格分开处理,表格单独转成文本再切。这一步做扎实,检索质量能上一个台阶。

第四处是并发和缓存。示例是单进程同步处理,多人同时问会排队。生产用的话,模型推理那层要加锁或者用队列,避免多个请求同时进模型导致显存爆掉;高频问题可以加一层结果缓存,相同问题直接返回,省一次推理。

验证改动有没有效果,别只看单次回答。我习惯准备一组 20 到 30 个问题的测试集,覆盖「文档里有的」「文档里没有的」「需要跨片段拼的」三类,每次改完跑一遍,记录答对率。凭单次对话感觉调参,很容易越调越偏。

最后说个我自己的教训。早期我图省事,改完 prompt 直接上线,结果某天发现模型开始把检索到的片段原文大段抄进回答,用户看着像复读机。从那以后我每次改完 prompt 模板,都强制走一遍那组测试集,确认回答风格没跑偏才敢用。这套流程麻烦,但比事后被用户投诉强。希望帮到你。

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

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

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

立即咨询