简介:这是一个本地部署私有化智能问答助理的示例项目,面向机器学习和人工智能开发者。系统基于 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,用SSE(Server-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 torch==2.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用的是多语言版本的MiniLM,238MB,中文效果够用,而且不会占太多显存。如果你有更好的嵌入模型,直接替换这个名称即可。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_code=True) model = AutoModel.from_pretrained( model_path, trust_remote_code=True, load_in_4bit=True, # 关键:4-bit量化省显存 device_map="auto" ) return model, tokenizertrust_remote_code=True这个参数非常关键,ChatGLM的自定义代码必须在远程环境下执行,不加这个参数直接报错。至于为什么用device_map="auto",是为了让模型自动分布在可用的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_4bit=True或load_in_8bit=True加上。我用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直接返回dict,FastAPI就把它当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_LENGTH=2048会让模型预留过多KV缓存。
解决:把MAX_INPUT_LENGTH调到1024或896,同时将batch_size=1,并且用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 文档切分与嵌入入库
知识库构建的第一步是解析上传的文档,把大段文本切成小片,每片约200~500个字符。因为向量检索的效果取决于切分的粒度,切得太短会丢失上下文,切得太长又容易被无关信息干扰。下面是核心切分与入库代码:
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_size=300, chunk_overlap=30, separators=["\n\n", "\n", "。", "!", "?", ".", ";", ";"] ) chunks = splitter.split_documents(docs) embeddings = HuggingFaceEmbeddings( model_name="sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" ) vector_db = Chroma.from_documents( documents=chunks, embedding=embeddings, persist_directory=persist_dir ) vector_db.persist() return len(chunks)chunk_size=300是按字符数算,不是按token算。我实测中文场景下300个字符大约对应200token,既能保证语义完整,又不会把模型输入长度撑爆。chunk_overlap=30让相邻切片有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_length=1024): response, updated_history = model.chat( tokenizer, prompt, history=history, max_length=max_length, temperature=0.7, top_p=0.85 ) return response, updated_historytemperature=0.7是控制随机性的常见取值,做知识库问答时0.5~0.8都可以接受。低于0.3会显得机械,高于0.9容易跑偏。top_p=0.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, history=history, max_length=1024 ): 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都强制走一遍回归测试:准备二十个知识库内问题、十个知识库外问题,看回答是否既准确又不会越界。希望这套示例项目也帮你养成这个习惯,祝跑得顺利。
本文还有配套的精品资源,点击获取