
简介面向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改成cpuload_in_4bit是否用4bit量化加载显存小于12G建议打开top_k检索召回片段数资料越杂建议调大到8score_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_codeTrue ) self.model AutoModelForCausalLM.from_pretrained( model_cfg[path], torch_dtypetorch.float16, device_mapmodel_cfg.get(device, auto), load_in_4bitmodel_cfg.get(load_in_4bit, False), trust_remote_codeTrue ) self.model.eval() def chat(self, prompt: str, max_new_tokens: int 512) - str: inputs self.tokenizer(prompt, return_tensorspt).to(self.model.device) with torch.no_grad(): outputs self.model.generate( **inputs, max_new_tokensmax_new_tokens, do_sampleTrue, temperature0.7, top_p0.9 ) answer self.tokenizer.decode( outputs[0][inputs.input_ids.shape[1]:], skip_special_tokensTrue ) return answertrust_remote_codeTrue这行很多人不理解。ChatGLM这类模型在仓库里带自定义的建模代码必须允许transformers执行远程代码才能加载。这里有个安全习惯权重尽量从官方模型仓库下载不要用来历不明的合并包。torch_dtypetorch.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_embeddingsTrue, show_progress_barTrue ) 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_embeddingsTrue ) 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_embeddingsTrue时内积就等于余弦相似度。这里不要在方向和余弦同向之间偷换概念文本语义比较用余弦相似度就够比欧氏距离结果更符合直觉。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( contextcontext, questionquestion ) 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轮。如果不做这一步多轮对话会把所有历史都拼进prompttoken越滚越长最后不是变慢就是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_code400, detailquestion不能为空) 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 python3.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 donesegments128”的输出就说明成功。如果segments数量为0先检查文件编码是不是UTF-8GBK文件在读取阶段就会被丢弃。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的网络模式是不是NATNAT模式下宿主机访问不到客户机的端口。以上五条的共性教训是问题多数不是模型不行而是输入知识库的段落质量太差或者参数边界没摸清。遇到问题先怀疑检索再怀疑模型这个顺序能省很多时间。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 }, timeout120 ) return response.json()[choices][0][message][content]只要把config里的model.type从chatglm改成openai_compatChatService完全不用动。这就是我前面反复强调分层的好处模型是插件检索是底座业务是胶水。6.3 改完怎么验证建一个回归问题集我在这方面吃过亏调完Prompt后手动测了两个问题没问题就上线结果业务同事用了一个小时就发现制度里另一个细节回答错误。从那以后我每次改模型配置或Prompt模板都会强制把准备好的回归问题集完整跑一遍再交付。准备一份test_questions.json比如20对“问题-期望关键词”[ {question: 报销单需要几个人签字, expect: 部门负责人}, {question: 会议室预定提前几天, expect: 3天} ]再写个小脚本循环调用/api/chat校验answer里是否包含expect里的关键词。这个方法不能过滤全部错误但能挡掉大多数“改Prompt改坏了”的情况。至少不会在同事验收的时候当场丢人。希望这个习惯对你也有用。本文还有配套的精品资源点击获取