简介:面向AI开发者的本地部署私有化智能问答助理示例项目,整合Gradio、FAISS与Sentence-Transformers构建文件上传与知识库检索对话系统,适配ChatGLM模型,适合搭建离线问答服务、关注数据隐私的企业或个人参考学习。项目共18个文件,压缩包336KB,包含Python脚本、TXT/YAML配置、依赖whl包、Shell脚本、PEM证书及docx/xlsx/pdf参考文档,便于按需取用。目前已有140人学习下载。通过资源可了解向量检索问答的核心实现思路:用FAISS进行大规模相似性搜索,用Sentence-Transformers生成句向量,并掌握私有化部署的证书与文件组织方式。示例代码可作为基底,快速定制行业专属问答助手,应用于客户服务、技术支持、教育培训等场景。
1. 私有化智能问答助理:给内部知识库装一个不出内网的“AI客服”
很多团队折腾本地问答,第一步就栽在“光有大模型,没有自家知识库”上:ChatGLM跑起来了,问两句常识还行,一问内部报销流程就开始胡编。这个示例项目解决的就是这件事。它把机器学习里最常用的RAG(检索增强生成)完整落地,本地部署一个私有化智能问答助理,文档切片、向量检索、模型推理全跑在自己服务器的内网里,回答时先查内部资料,再交给大模型组织语言。核心价值就一句话:把几十份散落文档变成一个7x24小时的客服,适合数据不能出内网的业务团队,也适合打算做知识库二次开发、想看清实际链路怎么串的开发者。整个工程代码量不大,但模型层、检索层和服务层分得清楚,拿到手就能往自己的业务方向改。
2. 架构与目录:一条从“文档”到“答案”的本地RAG链路
先弄明白这个项目怎么运作。它没让模型独自负责全部回答,而是在用户提问后先做一个动作:去知识库里找相关段落,把找到的资料和问题拼成一段完整的Prompt,再交给ChatGLM生成最终回答。这就是常说的RAG链路。好处是答案有据可依、模型不会乱编,知识库更新时也不需要重新训练模型,只把新文档灌进索引就行。整条链路拆成四个环节:文档入库、离线建索引、在线检索、模型生成。
2.1 技术选型:为什么是ChatGLM配合本地向量库
模型这层选ChatGLM,最主要的原因是中文问答效果在线,而且开源权重可以本地化部署。我拆过不少同类工程,遇到纯中文的内部知识库问答,这个选择基本是“下限最高”的。它的显存占用经过量化后可以降到消费级显卡能跑的水平,一台16G显存的服务器就能承载一个小团队的问答服务。完全没GPU也能跑CPU推理,只是速度慢一些,做测试环境完全够用。
检索层没有用传统的Lucene或SQL LIKE,而是用Embedding模型把每一段文档变成向量,再用FAISS做最近邻检索。选FAISS而不是Elasticsearch,是因为企业内部知识库的文档量通常是几千到几十万字符,内存索引就扛得住,部署成本也低。找中文文本向量模型把文本转成向量之后,检索时把用户问题也转成向量,再去库里找最相似的若干段,这种方式比关键词匹配更抗口语化提问。
服务层采用FastAPI加uvicorn。选它是因为异步支持好,自带接口文档,返回JSON方便接企业微信机器人、钉钉机器人或自建前端。整个项目只带一个静态页面,用来在浏览器里直接调试对话效果,不引入前端工程,跑起来的门槛低不少。
2.2 项目目录结构与模块职责
拿到源码先别急着点运行,把目录结构过一遍。整个项目是典型的“应用服务 + 引擎层”结构,模型推理、向量检索和业务路由分别在各自的包里,后面替换模型或者换知识库都不会牵一发动全身。
private-qa-assistant/ ├─ app/ │ ├─ main.py # FastAPI 入口,注册路由与启动逻辑 │ ├─ config.py # 读取 config.yaml,供全局引用 │ ├─ engine/ │ │ ├─ llm.py # ChatGLM 模型加载与推理封装 │ │ ├─ embedder.py # Embedding 模型封装 │ │ └─ retriever.py # 向量库构建、保存与检索 │ ├─ service/ │ │ ├─ chat_service.py # 组装 Prompt、管理多轮历史 │ │ └─ kb_service.py # 知识库文档导入与索引更新 │ └─ static/ │ └─ index.html # 本地调试用的对话页面 ├─ scripts/ │ ├─ build_kb.py # 扫描 data/knowledge 下的文档,建立向量索引 │ └─ download_model.py # 拉取 ChatGLM 与 Embedding 权重 ├─ data/ │ ├─ knowledge/ # 原始文档放这里,支持 .txt / .md │ └─ vector_store/ # FAISS 索引文件和元数据 ├─ config.yaml # 模型路径、索引路径、服务端口等配置 └─ requirements.txt # Python 依赖清单最需要留意的是engine和service的分层。engine里llm.py只负责“输入Prompt返回文本”,retriever.py只负责“输入问题返回相关片段”,两个模块互相不感知。chat_service.py才是真正的粘合层,它决定检索结果怎么拼进Prompt、历史记录怎么截断。后面想换模型只需要动engine/llm.py,想换问答风格只需要动chat_service.py的提示词模板,互不干扰。
2.3 全局配置:config.yaml里每一项都在控制什么
项目把所有可变参数收敛到一个配置文件里,改配置不用动代码。默认的config.yaml长这样:
model: path: "models/chatglm-4bit" # ChatGLM 权重目录 device: "auto" # auto / cuda:0 / cpu load_in_4bit: true # 是否4bit量化,显存不够时打开 max_length: 2048 # 生成时的上下文上限 embedding: model_name: "bge-small-zh-v1.5" # 中文文本向量模型 device: "cpu" # 向量化开销小,CPU 足够 batch_size: 16 # 批量向量化的吞吐控制 vector_store: index_dir: "data/vector_store" # 向量索引落地目录 top_k: 5 # 每次检索召回段落数量 score_threshold: 0.35 # 低于该相似度的结果直接丢弃 server: host: "0.0.0.0" port: 8000 history_max_len: 6 # 多轮对话最多保留几轮各参数含义和调参建议如下表。
| 参数名 | 作用 | 调参建议 |
|---|---|---|
| device | 决定模型放在GPU还是CPU | 有GPU设auto;纯CPU改成cpu |
| load_in_4bit | 是否用4bit量化加载 | 显存小于12G建议打开 |
| top_k | 检索召回片段数 | 资料越杂建议调大到8 |
| score_threshold | 兜底过滤阈值 | 发现答非所问就往下调 |
| history_max_len | 保留对话轮数 | 越大越像真人,但消耗token越多 |
config.py里用yaml.safe_load把这个文件读成字典,再做成一个全局对象。常见做法是在main.py启动时读取一次,传给engine和service的构造函数,避免每个请求反复读磁盘。我一般还会加一层环境变量覆盖,方便在Docker里只改env就换端口,不至于每台机器都要改配置文件。
整个架构看下来,这项目的门槛比想象中低:没有微服务,没有消息队列,就是“加载一次模型,跑一个Web服务,用文件系统当存储”。正因为它简单,才适合作为二次开发的地基。
3. 核心模块拆解:模型推理、向量检索、Prompt组装和API四段代码
这章直接看代码,按请求流转的顺序拆:先是LLM引擎怎么加载和推理,再是知识库怎么检索,接着是Prompt怎么拼,最后是API怎么把结果返出去。
3.1 LLM引擎:ChatGLM的加载与chat封装
# app/engine/llm.py import torch from transformers import AutoModelForCausalLM, AutoTokenizer class LLMEngine: """ChatGLM 推理封装。构造函数只做一次,全局复用同一个实例。""" def __init__(self, model_cfg: dict): self.tokenizer = AutoTokenizer.from_pretrained( model_cfg["path"], trust_remote_code=True ) self.model = AutoModelForCausalLM.from_pretrained( model_cfg["path"], torch_dtype=torch.float16, device_map=model_cfg.get("device", "auto"), load_in_4bit=model_cfg.get("load_in_4bit", False), trust_remote_code=True ) self.model.eval() def chat(self, prompt: str, max_new_tokens: int = 512) -> str: inputs = self.tokenizer(prompt, return_tensors="pt").to(self.model.device) with torch.no_grad(): outputs = self.model.generate( **inputs, max_new_tokens=max_new_tokens, do_sample=True, temperature=0.7, top_p=0.9 ) answer = self.tokenizer.decode( outputs[0][inputs.input_ids.shape[1]:], skip_special_tokens=True ) return answertrust_remote_code=True这行很多人不理解。ChatGLM这类模型在仓库里带自定义的建模代码,必须允许transformers执行远程代码才能加载。这里有个安全习惯:权重尽量从官方模型仓库下载,不要用来历不明的合并包。
torch_dtype=torch.float16是把模型以半精度加载,显存占用直接减半。load_in_4bit是进一步量化,需要机器上有bitsandbytes库,并且只在GPU上有效。纯CPU环境里建议把load_in_4bit设成false,否则会直接报错。
生成参数里temperature控制随机性,越低回答越保守,内部知识库问答建议设0.5到0.7。top_p是核采样阈值,保持0.9基本够用。max_new_tokens限制生成长度,防止模型一口气编出整篇内容。decode时截掉了输入部分,只保留新增生成的token,这一段最容易写错,写错的话返回结果里会重复一遍问题。
3.2 知识库检索:向量化、索引构建与召回
# app/engine/retriever.py import faiss from sentence_transformers import SentenceTransformer class Retriever: def __init__(self, embedder, index_dir: str, top_k: int = 5): self.embedder = embedder self.index_dir = index_dir self.top_k = top_k self.index = None self.segments = [] def build(self, docs): vectors = self.embedder.encode( docs, normalize_embeddings=True, show_progress_bar=True ) self.index = faiss.IndexFlatIP(vectors.shape[1]) self.index.add(vectors) self.segments = docs self._save_to_disk() def search(self, query: str, threshold: float = 0.35): query_vec = self.embedder.encode( [query], normalize_embeddings=True ) scores, idx = self.index.search(query_vec, self.top_k) results = [] for score, doc_id in zip(scores[0], idx[0]): if score < threshold: continue results.append({ "text": self.segments[doc_id], "score": float(score) }) return resultsIndexFlatIP是内积索引,配合normalize_embeddings=True时,内积就等于余弦相似度。这里不要在方向和余弦同向之间偷换概念:文本语义比较用余弦相似度就够,比欧氏距离结果更符合直觉。
build方法接收的是已经切好段的字符串列表,切段的逻辑在scripts/build_kb.py里,默认按字符长度切分,并让相邻段落有少量重叠,避免一句话被拦腰截断导致检索丢失关键信息。Embedding模型要本地跑,encode这一步在CPU上也能运行,几千个段落只需要几十秒。
search里的阈值必须重视。很多翻车现场就是阈值设太高导致检索结果为空,模型只能瞎编。一开始宁可设低一点,看返回score分布再往上调,这是最省事的调参路子。score是浮点型相似度,不同Embedding模型的分布差异很大,不要照抄别人的阈值。
3.3 对话服务:把检索片段拼成合格的Prompt
# app/service/chat_service.py class ChatService: PROMPT_TEMPLATE = """请仅根据以下资料回答问题。 如果资料中没有相关内容,请直接回答“知识库中未找到相关信息”。 不要编造资料里没有的事实。 资料: {context} 问题:{question} 回答:""" def __init__(self, llm, retriever, history_max_len: int = 6): self.llm = llm self.retriever = retriever self.history = [] self.history_max_len = history_max_len def answer(self, question: str) -> dict: docs = self.retriever.search(question) context = "\n\n".join( f"[{i + 1}] {doc['text']}" for i, doc in enumerate(docs) ) prompt = self.PROMPT_TEMPLATE.format( context=context, question=question ) result = self.llm.chat(prompt) self.history.append((question, result)) self.history = self.history[-self.history_max_len:] return {"answer": result, "source_count": len(docs)}Prompt模板是整个项目的灵魂。第一句“请仅根据以下资料回答问题”是个约束,告诉模型把检索结果当成唯一事实来源。最后一句“不要编造资料里没有的事实”能明显减少幻觉,这也是RAG应用和普通闲聊工程最大的差别。
history截断用了一个很简单的技巧:列表切片保留最后N轮。如果不做这一步,多轮对话会把所有历史都拼进prompt,token越滚越长,最后不是变慢就是OOM。这个方法不算最优,但胜在简单可靠,适合作为二次开发的起点。
3.4 API层:接收问句并返回答案
# app/main.py from fastapi import FastAPI, HTTPException from pydantic import BaseModel class ChatRequest(BaseModel): question: str app = FastAPI() @app.on_event("startup") def load_engines(): app.state.chat_service = build_chat_service() @app.post("/api/chat") def chat(req: ChatRequest): if not req.question.strip(): raise HTTPException(status_code=400, detail="question不能为空") result = app.state.chat_service.answer(req.question) return result @app.get("/api/health") def health(): return {"status": "ok"}最关键的是把模型加载放到startup事件里。如果放在每个请求里加载,第一个请求就会让服务卡死,而且每次都会重新占一份显存。app.state是FastAPI自带的全局状态容器,把ChatService挂上去,请求处理函数里直接取用即可。
接口设计成POST而不是GET,因为业务问答的prompt包含长上下文,GET的url长度未必够用,POST也更方便接入企业微信那样的回调服务。返回体里带了source_count,前端可以据此判断这次回答是否真的命中了知识库,后续排障很好用。
4. 本地部署与启动:从空环境到8000端口有响应
跑通流程分四步:装依赖、下权重、灌知识库、启动服务。我按这个顺序写,每步给出命令和验证方法。准备一台Linux服务器,或者一台装了WSL的Windows开发机都可以。
4.1 环境准备与依赖安装
建议用conda建独立环境,避免把系统Python搞乱。Python版本选3.10,太高或太低都容易撞上torch的兼容问题。
conda create -n private-qa python=3.10 -y conda activate private-qa pip install -r requirements.txtrequirements.txt内容大致如下,我在列依赖时有意给CPU环境留了余地。faiss-cpu在文档量不大时性能足够,GPU版本的faiss安装容易踩坑,先不加。
torch transformers sentence-transformers faiss-cpu fastapi uvicorn[standard] pyyaml bitsandbytes accelerate如果你的机器没有独立显卡,建议把bitsandbytes那行注释掉再安装,否则某些Linux发行版会因缺CUDA运行时安装失败。这不算bug,是bitsandbytes的安装逻辑如此。
4.2 下载模型权重并核对目录
运行scripts/download_model.py,脚本内部会从模型仓库拉取ChatGLM和Embedding模型。脚本下载失败时先检查网络环境,换一个能正常访问模型仓库的网络再重试。
python scripts/download_model.py --model chatglm --save-dir models/chatglm python scripts/download_model.py --embedding bge-small-zh-v1.5 --save-dir models/embedding这里不建议跳过下载步骤直接依赖transformers的自动缓存。把权重放到固定目录,config.yaml里的path指向这个目录,之后换机器、做离线拷贝都方便。下载完成后检查目录里是否有safetensors或bin文件,没有的话说明没下全,后面加载必挂。
4.3 初始化知识库:让文档变成可检索的索引
把自己内部资料放进data/knowledge/目录,支持纯文本和Markdown。如果是PDF或Word,建议先用工具转成Markdown再放进来,原始PDF解析经常带出分页符和页眉页脚,检索效果会差一截。
python scripts/build_kb.py --input-dir data/knowledge --output-dir data/vector_store脚本做的事可以拆成三步:读取目录下所有文档,按固定长度切成段落块;调用Embedding模型把段落变成向量;用Retriever建立FAISS索引并落盘。日志会打印每个阶段耗时和索引片段数量,看到类似“build index done,segments=128”的输出就说明成功。如果segments数量为0,先检查文件编码是不是UTF-8,GBK文件在读取阶段就会被丢弃。
4.4 启动Web服务并验证完整问答
uvicorn app.main:app --host 0.0.0.0 --port 8000启动后先看日志里是否出现“Application startup complete.”。如果卡在模型加载,大概率是路径写错或显存不够。加载成功的标志是日志里能看到模型参数量。
验证分两层。第一层是健康检查,确认服务还活着:
curl "http://127.0.0.1:8000/api/health"第二层是真正问一个问题。比如往知识库里放了一份报销制度,就问“报销单需要几个人签字”:
curl -X POST http://127.0.0.1:8000/api/chat \ -H "Content-Type: application/json" \ -d '{"question": "报销单需要几个人签字"}'返回的JSON里answer字段是模型回答,source_count表示实际召回了多少段落。如果source_count大于0但回答还在胡说,问题多半在Prompt模板,而不是检索。
浏览器打开http://服务器IP:8000/static/能看到自带的前端页面,输入问题回车就能对话,适合直接给业务同事试用。前后端交互走的都是同一个/api/chat接口,前端代码很薄,不用刻意维护。
4.5 用Docker固定运行环境(可选)
如果不想被本机依赖折磨,可以用Docker。项目里带了一个Dockerfile,基础镜像用pytorch/pytorch,依赖按CPU版装,先跑通流程;有GPU需求的把依赖换成对应版本即可。
FROM pytorch/pytorch:latest WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt --no-cache-dir COPY . . EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]构建时把data和models目录挂进去用-v参数,不要直接COPY进镜像,否则镜像体积会膨胀到几十G。构建之前先确认磁盘空间,这一层我放在CI里留作一键部署脚本,团队其他人拿到后不用再重复装依赖。
5. 避坑指南:这个项目最常见的五个翻车现场
这章记录的全是我在跑这个项目过程中踩过、以及帮别人排查过的问题,每条按“现象、原因、解决”三段写。前两条最容易翻车,建议对号入座。
5.1 模型加载时直接报CUDA out of memory
现象:运行uvicorn后,日志里出现CUDA out of memory,进程直接退出,或者模型加载到一半被杀掉。
原因:ChatGLM以float16加载时,16G显存可能不够用。如果机器上还跑着别的推理任务,剩余显存会更紧张。
解决:把config.yaml里的load_in_4bit改成true,显存占用能降到4G左右。注意4bit量化需要bitsandbytes,且要求GPU支持一定计算能力;如果机器太旧,就把device改成cpu,用CPU跑推理。启动前先用nvidia-smi看一下当前显存占用,确认没有残留进程占着显存,这一步能省不少排查时间。
5.2 检索召回一堆无关段落,回答完全对不上
现象:问“打印机怎么连”,回答里却引用了会议室预定、工位申请等无关内容。
原因:文档切段粒度太粗,一段包含多个话题,检索时把整个段落都拿了过来,模型被无关信息干扰。另一个常见原因是top_k设得太大,随手就是8条以上,无关内容自然混进来。
解决:把build_kb.py里的切片长度从默认的800字符调小到400字符,重叠长度从50调大到80,让每个段落只覆盖一个主题。检索的top_k从5调小到3,减少无用信息混入prompt的概率。改完重新build一次索引,再对比同样的query看返回片段,效果立竿见影。
5.3 知识库里明明有答案,检索结果却是空的
现象:问题很具体,比如“报销发票抬头怎么写”,但返回的source_count为0,模型回答“未找到”。
原因:score_threshold设置过高,或者中文Embedding模型对同义词、简称不敏感,导致相似度低于阈值被丢弃。
解决:先临时把score_threshold改成0,打印出所有候选score的分布,看真实相似度落在什么区间,再根据分布设阈值。多数场景阈值会在0.25到0.45之间。如果所有score都低于0.2,优先怀疑Embedding模型选错了,换个中文向量模型重新建索引。调阈值时在search里加一行print返回的scores,这份排查记录以后能直接当调参手册用。
5.4 多轮对话聊了十几轮以后越来越慢,最后开始报错
现象:前几轮秒回,聊到十几轮后每次回答耗时翻倍,生成内容也越来越散。
原因:ChatService虽然把history截断了,但history_max_len设得太大,导致输入序列接近模型的max_length上限,生成时间随序列长度变长,极端情况直接报长度错误。
解决:把history_max_len从6降到4,并在build_prompt里对上下文加一道兜底:如果prompt总字符数超过3000,只保留最近两轮对话。我一般会在组装prompt的入口打印prompt长度,数据比推测更有说服力。这个限制只影响聊天记忆,不影响知识库检索,业务上完全够用。
5.5 服务能启动,但局域网其他机器访问不了
现象:在服务器本机curl正常,换一台电脑用IP访问超时,浏览器一直转圈。
原因:uvicorn默认只绑定127.0.0.1,另一个可能是云服务器安全组没有放行8000端口。
解决:启动命令必须带--host 0.0.0.0。这个项目config.yaml里的host字段默认就是0.0.0.0,但如果你手动在命令行里覆盖了配置,就会踩坑。云服务器还需要在控制台安全组里把8000端口加入放行规则,这个步骤漏了的话,服务开得再正也连不上。本地虚拟机则要检查VM的网络模式是不是NAT,NAT模式下宿主机访问不到客户机的端口。
以上五条的共性教训是:问题多数不是模型不行,而是输入知识库的段落质量太差,或者参数边界没摸清。遇到问题先怀疑检索,再怀疑模型,这个顺序能省很多时间。
6. 二次开发:把通用问答改成自己的业务助理
项目跑通只是完成了三成,剩下七成是让它更像“自己人”。最常见的三个改造方向写在这里。
6.1 换知识库与改Prompt风格
替换data/knowledge目录下的文档,重新执行build_kb.py,索引就会重建。业务风格则改ChatService.PROMPT_TEMPLATE。比如想要“先结论后依据”的风格,在模板里加一句“回答分两步:先给结论,再引用资料说明原因”。实测对ChatGLM这类指令模型很管用,一句话就能让回答结构大变样。
6.2 预留模型替换的适配层
把LLMEngine再包一层统一接口,返回内容和导入路径固定。比如接一个OpenAI兼容的本地推理服务:
class OpenAICompatEngine: def chat(self, prompt: str, max_new_tokens: int = 512) -> str: response = requests.post( f"{self.base_url}/v1/chat/completions", json={ "messages": [{"role": "user", "content": prompt}], "max_tokens": max_new_tokens }, timeout=120 ) return response.json()["choices"][0]["message"]["content"]只要把config里的model.type从chatglm改成openai_compat,ChatService完全不用动。这就是我前面反复强调分层的好处:模型是插件,检索是底座,业务是胶水。
6.3 改完怎么验证:建一个回归问题集
我在这方面吃过亏:调完Prompt后手动测了两个问题没问题就上线,结果业务同事用了一个小时就发现制度里另一个细节回答错误。从那以后,我每次改模型配置或Prompt模板,都会强制把准备好的回归问题集完整跑一遍再交付。
准备一份test_questions.json,比如20对“问题-期望关键词”:
[ {"question": "报销单需要几个人签字", "expect": "部门负责人"}, {"question": "会议室预定提前几天", "expect": "3天"} ]再写个小脚本循环调用/api/chat,校验answer里是否包含expect里的关键词。这个方法不能过滤全部错误,但能挡掉大多数“改Prompt改坏了”的情况。至少不会在同事验收的时候当场丢人。希望这个习惯对你也有用。
本文还有配套的精品资源,点击获取