简介这是一个本地部署私有化智能问答助理的示例项目面向机器学习和人工智能开发者。系统基于 Gradio 构建直观的用户界面结合 FAISS 向量检索与 Sentence-Transformers 文本嵌入能够高效处理上传文档从知识库中快速查找相关答案并支持连续对话。这一实现适合需要保护数据隐私、希望离线运行问答系统的客户服务、技术支持等场景。压缩包内共 18 个文件总大小 336KB主要包含 Python 业务脚本、TXT 与 YAML 配置、Shell 更新脚本、证书文件、依赖 whl 包以及 docx、xlsx、pdf 等说明参考文档覆盖从环境准备到核心逻辑的常见内容。目前已有 140 人学习或下载。读者可从中了解本地知识库问答系统的模块划分与实现思路掌握 FAISS 相似检索和深度学习模型嵌入的配合方式也能利用其中的配置和命令行脚本快速复现部署环境。整体来说这是一个兼顾教学演示与工程实践价值的示例资源适合希望快速上手私有化问答助理的开发者。1. 为什么还要自己部署一个智能问答助理数据不出门才能安心问业务问题一个很反直觉的结论大模型越强大企业越不敢把内部资料直接丢给它。业务合同、客户信息、研发文档这些数据一旦经过在线对话服务就等于把家底交给别人保管。所以“本地部署私有化智能问答助理”这两年从技术玩具变成了刚需。这个示例项目就是把ChatGLM系列模型、向量检索和中后台管理软件拼在一起做成一套能完全离线运行的问答服务。你可以把它想象成一个企业知识库的“黑匣子”只在你自己的服务器上回答问题。这项目适合谁适合手里有一堆PDF、Word、Markdown资料想让人工智能自动学习并回答内部员工问题的人比如某公司技术部的运维知识库、某高校的招生问答、某实验室的SOP查询。它解决的核心问题不是“模型有多聪明”而是“数据不出门问题有答案答案可追溯”。接下来我把它从架构到代码拆开讲顺便把部署时踩过的坑都翻出来。2. 项目架构拆解从数据入库到问答流式的完整链路2.1 技术选型与功能边界整个项目采用前后端分离后端使用FastAPI承载业务接口模型推理层用Transformers库加载ChatGLM的对话模型知识库向量化用Sentence-Transformer嵌入模型配合Chroma向量数据库中间用LangChain做文档加载、文本切分、检索链的编排。前端是Vue3 Element Plus用SSEServer-Sent Events接收流式回答。下面是主模块的功能划分模块技术实现职责前端控制台Vue3 Vite上传文档、配置知识库、对话窗口、流式渲染后端网关FastAPI Uvicorn接口鉴权、会话管理、文件上传、SSE推送知识库引擎LangChain Chroma文档解析、文本切分、向量化、相似度检索模型推理Transformers ChatGLM对话生成、上下文拼接、流式输出嵌入模型Sentence-Transformer将文本切片转为768维向量供检索使用功能边界一句话不做训练只做推理和检索。项目假设你已经拥有ChatGLM的合法权重整个系统只是在权重之外加一套“让模型针对你给的资料回答问题”的框架。2.2 目录结构与各模块职责拿到示例项目后第一件事是看目录。它长这样private-qa-assistant/ ├── backend/ │ ├── app/ │ │ ├── main.py # FastAPI入口路由注册 │ │ ├── config.py # 全局配置模型路径、向量库路径、端口 │ │ ├── routers/ │ │ │ ├── upload.py # 文件上传与知识库构建 │ │ │ ├── chat.py # 对话接口SSE流式返回 │ │ │ └── kb.py # 知识库列表、删除、重建 │ │ ├── services/ │ │ │ ├── embedder.py # 嵌入模型封装 │ │ │ ├── retriever.py # 向量检索封装 │ │ │ └── llm.py # ChatGLM加载与推理 │ │ └── utils/ │ │ └── text_splitter.py # 针对中英文混合文档的切分器 │ ├── requirements.txt │ └── run.sh # 后端一键启动脚本 ├── frontend/ │ ├── src/ │ │ ├── views/ │ │ │ ├── ChatView.vue # 对话页面 │ │ │ └── KbManageView.vue # 知识库管理页面 │ │ ├── api/ # axios eventSource封装 │ │ └── App.vue │ └── vite.config.ts ├── models/ # 放ChatGLM权重空占位 ├── data/ │ └── vectorstore/ # Chroma持久化目录 └── docker-compose.yml # 可选用容器跑后端后端按“路由-服务-工具”三层拆。路由层只做HTTP参数校验和返回真正的逻辑在services层。这样做的好处是如果以后要换掉LangChain改用自己的检索逻辑只需要动retriever.py和llm.py接口层完全不用改。2.3 请求链路总览一次完整的问答流程如下前端把用户输入的问题发到/api/chat/stream带上当前会话ID。后端根据会话ID取出最近N轮历史消息拼成对话上下文。同时把当前问题送入retriever.py从Chroma知识库中检索Top-K个相关文档切片。把检索到的切片内容用模板拼接到Prompt中。拼好的完整Prompt交给ChatGLM模型生成回答。模型每输出一个token后端通过SSE推送到前端前端看到逐字打出效果。这里有一条“玄学”经验很多人开场就问“怎么让模型用知识库回答”其实顺序是先检索再拼Prompt而非把整个知识库塞给模型。本地部署的模型输入长度有限必须靠检索把最相关的内容挑出来。本项目所有链路都围绕“检索增强生成”这一设计展开。3. 本地跑起来环境准备、模型加载与一键启动3.1 硬件要求与依赖安装先泼一盆冷水本地部署不等于单机玩。ChatGLM量化后的最小需求在6GB显存左右但如果你同时要跑嵌入模型和Web服务建议直接上16GB显存的卡。CPU部署能做但速度慢到让你怀疑人生我建议至少准备一张支持CUDA的显卡。依赖安装分两步。先建虚拟环境再装后端依赖cd private-qa-assistant/backend python -m venv venv source venv/bin/activate pip install -r requirements.txtrequirements.txt里关键的包是torch、transformers、fastapi、uvicorn、langchain、chromadb、sentence-transformers、sse-starlette。其中torch建议手动安装对应CUDA版本直接用pip装默认的CPU版会让你白跑一趟。我一般会额外加一句安装说明pip install torch2.1.0 --index-url https://download.pytorch.org/whl/cu118装上之后用python -c import torch; print(torch.cuda.is_available())验证输出True再继续。3.2 模型权重准备与加载配置项目里models/目录是空的需要你把ChatGLM权重放进来。常见做法是从开源社区下载原始权重目录包含config.json、pytorch_model.bin等文件。放好后打开app/config.py设置模型路径class Settings: MODEL_PATH models/chatglm-6b EMBEDDING_MODEL sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 VECTOR_STORE_PATH data/vectorstore MAX_INPUT_LENGTH 2048 TOP_K 5这里EMBEDDING_MODEL用的是多语言版本的MiniLM238MB中文效果够用而且不会占太多显存。如果你有更好的嵌入模型直接替换这个名称即可。MAX_INPUT_LENGTH是拼装Prompt后的最大输入长度超过会截断TOP_K是检索多少个知识块设成5相对均衡。加载ChatGLM的核心代码在services/llm.py里默认用4-bit量化加载显存占用从14GB降到6GB左右from transformers import AutoModel, AutoTokenizer def load_llm(model_path): tokenizer AutoTokenizer.from_pretrained(model_path, trust_remote_codeTrue) model AutoModel.from_pretrained( model_path, trust_remote_codeTrue, load_in_4bitTrue, # 关键4-bit量化省显存 device_mapauto ) return model, tokenizertrust_remote_codeTrue这个参数非常关键ChatGLM的自定义代码必须在远程环境下执行不加这个参数直接报错。至于为什么用device_mapauto是为了让模型自动分布在可用的GPU和CPU上避免单卡显存溢出后直接OOM。3.3 后端启动与前端联调模型加载是异步的避免每次启动都读一遍权重。main.py里用FastAPI的lifespan事件来初始化uvicorn app.main:app --host 0.0.0.0 --port 8000启动日志里看到“模型加载成功”才能进入下一步。前端开发模式跑起来更简单cd frontend npm install npm run dev打开http://localhost:5173你会看到登录页面。默认是无鉴权的但生产环境建议加上Token校验不然谁都能往你的知识库传文件。我遇到最多的问题就是前后端联调时SSE连不上后面避坑章节会专门讲。4. 踩坑记录本地部署智能问答最常遇到的五个问题4.1 模型加载慢到像死机其实是没做量化现象第一次启动后端等了十几分钟还在加载终端卡住不动CPU跑满内存飙到20GB以上。原因直接用fp16精度加载ChatGLM-6B需要13GB以上显存和足够大的内存。你的机器可能内存不够就开始疯狂交换。解决把load_in_4bitTrue或load_in_8bitTrue加上。我用4-bit量化后显存占用降到6GB加载时间缩短到两分钟。量化会损失一点回答质量但做知识库问答足够。4.2 向量库索引为空路径分隔符在Windows上翻车现象上传文档成功后问任何问题都答“未找到相关知识”检查Chroma目录发现文件存在但检索结果为空。原因Windows的\和Linux的/在拼接持久化路径时出问题导致Chroma从错误的目录读索引。解决所有路径都用pathlib.Path拼接不要直接写字符串str(Path(__file__).parent / VECTOR_STORE_PATH)。另外如果是在Windows上跑建议直接用WSL2能省掉一堆文件系统兼容性的麻烦。4.3 流式输出变成一次性刷新FastAPI的SSE没有正常关闭现象前端只在所有文本生成完后一次性展示答案看不到打字机效果。原因SSE要求响应头带text/event-stream且必须在生成器函数中使用yield。我一开始写的接口是async def直接返回dictFastAPI就把它当JSON处理了。解决使用sse-starlette的EventSourceResponse包装生成器并在生成结束后显式关闭。同时也检查一下前端EventSource是否监听了onmessage而不是onopen之后的data字段。4.4 上下文越聊越乱没有做会话隔离现象多个用户同时使用A用户的问题被B用户的上下文污染回答越到后面越离谱。原因模型推理层只维护了一个全局history列表谁调用都会往里追加。解决给每个会话分配独立的history对象用会话ID做键存内存或Redis。本项目后端提供session_id参数前端每次进入对话生成一个新ID同一会话的消息串起来。4.5 显存不足batch size与max_length的“玄学”现象单个问题能回答但多人同时问就卡死或OOM。原因加载模型时没有关闭gradient_checkpointing已算好的显存分配但每次推理请求仍然占用固定显存。另外MAX_INPUT_LENGTH2048会让模型预留过多KV缓存。解决把MAX_INPUT_LENGTH调到1024或896同时将batch_size1并且用sempahore限制并发推理数为2。我加了个简单的信号量import asyncio semaphore asyncio.Semaphore(2) async def generate(prompt): async with semaphore: return await asyncio.to_thread(model.chat, tokenizer, prompt)这样高峰期最多两个请求同时推理其余排队避免显存秒爆。5. 核心模块代码讲解文档入库、向量检索与流式问答5.1 文档切分与嵌入入库知识库构建的第一步是解析上传的文档把大段文本切成小片每片约200500个字符。因为向量检索的效果取决于切分的粒度切得太短会丢失上下文切得太长又容易被无关信息干扰。下面是核心切分与入库代码from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain.embeddings import HuggingFaceEmbeddings from langchain.vectorstores import Chroma def build_kb_from_docs(docs, persist_dir): # 中文与英文混合文档用递归字符切割器 splitter RecursiveCharacterTextSplitter( chunk_size300, chunk_overlap30, separators[\n\n, \n, 。, , , ., ;, ] ) chunks splitter.split_documents(docs) embeddings HuggingFaceEmbeddings( model_namesentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 ) vector_db Chroma.from_documents( documentschunks, embeddingembeddings, persist_directorypersist_dir ) vector_db.persist() return len(chunks)chunk_size300是按字符数算不是按token算。我实测中文场景下300个字符大约对应200token既能保证语义完整又不会把模型输入长度撑爆。chunk_overlap30让相邻切片有30个字符重叠避免一个完整概念正好被切在分界线上找不到。这里还有一个细节separators里面把中文句号。和感叹号排在小节符号之后确保优先按自然句子边界切。5.2 问答链路从Prompt拼装到ChatGLM推理问答的逻辑在services/llm.py和services/retriever.py配合完成。当用户问题进来先检索知识库取回Top-K切片然后拼成一个带指令的Prompt。代码如下def build_prompt(question, search_chunks): context \n\n.join( f[片段{i}]: {chunk.page_content} for i, chunk in enumerate(search_chunks, 1) ) prompt f你是一个企业知识库问答助理。请严格根据以下资料片段回答用户问题。 如果资料中没有相关内容直接回答“抱歉知识库中暂未找到相关内容”。 不要编造答案不要使用资料之外的信息。 参考资料 {context} 用户问题{question} 请回答问题 return prompt def generate_answer(model, tokenizer, prompt, history, max_length1024): response, updated_history model.chat( tokenizer, prompt, historyhistory, max_lengthmax_length, temperature0.7, top_p0.85 ) return response, updated_historytemperature0.7是控制随机性的常见取值做知识库问答时0.50.8都可以接受。低于0.3会显得机械高于0.9容易跑偏。top_p0.85是核采样阈值保留概率质量达到0.85的最小词集合降低低频词干扰。history是当前会话的历史对话格式是[(question1, answer1), (question2, answer2)]。注意我在Prompt里加了“不要编造答案”的约束。本地部署模型参数量有限没有这条约束它会一本正经地胡编。加了约束之后至少能把“我不知道”变成一句明确回答而不是误导用户。5.3 SSE流式输出让第一句话在3秒内出现ChatGLM的stream_chat支持逐步生成。后端用生成器不断产出文本通过SSE推到前端。关键代码如下from sse_starlette.sse import EventSourceResponse def stream_qa(model, tokenizer, search_chunks, history): prompt build_prompt_for_stream(history) # 拼装过程略 def gen(): for token, _ in model.stream_chat( tokenizer, prompt, historyhistory, max_length1024 ): yield {event: message, data: token} return EventSourceResponse(gen())前端EventSource监听message事件每收到一个data就追加到当前回答DOM。这里有个关键点max_length设置的1024指的是生成的最大token数不是输入长度。如果回答特别长模型会在生成到1024个token时被强制截断。这几段代码就是整个项目的发动机。每次问答都走一遍“检索-拼Prompt-生成-流式返回”你把这三步连起来读一遍就掌握了“检索增强生成”的全貌。6. 二次开发技巧换模型、加文档类型、调回答风格先说怎么换模型。这个项目用的是ChatGLM但如果你手头有别的开源模型其实改动很小。llm.py里把AutoModel换成对应模型的类config.py里改MODEL_PATH即可。如果新模型不兼容model.chat接口需要自己写一个generate(prompt)函数封装模型推理。我建议先跑通最小推理再接入这个项目的Prompt和检索逻辑否则一上来改一堆出了错都不知道是哪一环。再说扩文档类型。现在支持PDF和Word是LangChain的PyPDFLoader和Docx2txtLoader做的。想支持CSV或HTML只需在upload.py里新增一个文件类型分支返回统一的Document对象列表。我一般会加一个“简历解析”的定制需求就是读DOCX里的表格这时用UnstructuredWordDocumentLoader更灵活。最后说调回答风格。把system_prompt模板改成你想要的语气即可。比如某公司客服场景Prompt开头加“回答要简短、口语化、带礼貌用语”测试下来效果立竿见影。但有一条血泪经验不要为了“更像人”把约束全放开。我曾经把“严格按资料回答”改成“可以适当发挥”结果模型开始自己编流程规范差点把内部培训材料写错。从那以后我每次改Prompt都强制走一遍回归测试准备二十个知识库内问题、十个知识库外问题看回答是否既准确又不会越界。希望这套示例项目也帮你养成这个习惯祝跑得顺利。本文还有配套的精品资源点击获取