ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从零搭建AI工程:本地知识问答系统实战指南

从零搭建AI工程:本地知识问答系统实战指南 说到 AI engineering我始终觉得最快的入门路径不是刷教程而是真的从一个空目录开始把一个AI项目从头到尾亲手搭一遍。这次想分享的正是我一边踩坑一边把一套AI工程从零from scratch搭起来的过程从环境准备、数据导入到模型推理、服务上线最后形成一个能用的本地知识问答系统。它解决的痛点是很多人学了几个月AI却不知道真正的工程长什么样。这篇文章适合那些已经有了Python基础、看过一些模型理论但还没完整跑通一个AI项目的朋友。我会尽量按工程落地的顺序来讲而不是按教程的目录来讲。过程中会解释为什么选择某个方案、参数为什么这么设、线上踩过哪些坑方便你直接照着做也方便你围绕这个骨架做扩展。1. 内容整体设计与思路拆解1.1 这个项目到底在解决什么问题AI engineering 这个词说起来很大但落到实际工作里它其实就是一套把数据、模型、算力、评估、部署串起来的工程能力。很多初学者的问题在于单独跑一个开源模型很容易单独装一个向量数据库也不难可一旦要把它们拼成一个系统就不知道从哪下手了。所以这个项目的首要目标就三个词可运行、可评估、可迭代。我选择做一个“本地文档问答系统”也就是把一批技术文档导入系统然后用户用自然语言提问系统基于文档内容给出带依据的回答。选它作为 from-scratch 的起点是因为它几乎覆盖了AI工程的所有核心环节数据处理、模型选型、检索增强、服务封装、部署运维。这套流程跑通了以后做分类、抽取、Agent 或其他AI应用只是换数据、换Prompt的问题底层框架并不会有太大差异。做设计的时候我给自己定了一条边界不训练大模型只做必要的微调和检索增强。原因很直接从零训练一个像样的模型需要超大规模数据和算力那不是工程入门该做的事。真正能落地的AI工程反而是把开源模型、私有数据和工程机制有效组合起来在可控成本下解决实际问题。1.2 为什么选择RAG而不是重新训练刚开始接触AI应用时很多人会天然觉得“效果不好就去微调模型”。这个思路没错但成本经常被忽略。假设你有一批内部技术文档总共几百万字想靠微调让模型“背下来”不仅训练耗时而且每次文档更新都要重新微调这在业务里是基本不可接受的。RAG检索增强生成的思路更像给模型配一个资料库。模型本身不需要记住你所有文档而是在回答问题时先去资料库里检索相关内容再把这些内容塞进Prompt里让模型阅读后给出答案。用生活化的话说以前是让模型“背课文”现在变成让模型“开卷考试”。资料更新了你只要更新资料库不需要动模型。选择RAG的另一个原因是可解释性。问答系统上线后用户不只会问“答案对不对”还会问“凭什么是这个答案”。RAG可以明确告诉用户答案来自哪一份文档、哪个段落。而微调模型给不出这种依据它是端到端黑盒。对很多企业场景来说答案需要有据可查才是落地的硬要求。当然RAG也有它的问题比如检索质量不高时回答会很差、对多跳推理类问题支持有限。但这些限制都可以靠我们后面会讲的切分策略、重排策略和 Prompt 设计来缓解。至少对于知识库问答这个场景RAG是性价比最高的方案。1.3 技术选型背后的原因技术选型没有银弹只有适合当前阶段的选择。这个项目我最终选了下面这套组合环节选型理由基座模型Qwen2.5-7B-Instruct中文效果好社区资料多7B规模单卡可跑向量模型BAAI/bge-m3多语言检索强支持稠密稀疏检索常用评测里靠前重排模型BAAI/bge-reranker-base用少量算力显著提升召回精准度向量库pgvector不用额外引一套服务直接挂PostgreSQL运维简单API服务FastAPI异步支持好自带文档写接口效率高模型推理vLLM吞吐量高PagedAttention能省显存调度编排Docker Compose本地可复现生产环境也好迁移选Qwen而不是Llama主要原因是我做的场景以中文技术文档为主Qwen在中文指令跟随、文本理解上的表现更稳定。如果你做纯英文场景Llama 3.1系列同样合适整体链路不需要改动。选pgvector而不是Milvus或Chroma是因为在项目早期检索规模没到千万级时单独维护一个向量数据库会增加不少运维负担。pgvector扩展安装在PostgreSQL里支持SQL过滤比如只检索某个部门、某个时间段的文档这在业务里非常实用。等到向量数据量真的很大再迁移到Milvus也不迟核心代码只需要改动一个检索接口。模型推理用vLLM而不是直接transformers是因为在并发访问场景下vLLM能给到几十倍的吞吐提升。第一次跑通小demo时我也用transformers后来发现一旦有十几个用户同时问问题GPU利用率很低且排队严重换成vLLM之后明显轻松很多。2. 核心细节解析与实操要点2.1 数据清洗与文本切分细节整个系统里最容易被低估的就是数据处理。很多人以为文档扔进去就行但原始PDF解析出来的内容经常会带上页眉页脚、乱码、表格错位这些杂质直接影响后续召回效果。我项目里接的文档包括Markdown、PDF、Word三种格式处理策略是Markdown按结构文本保留PDF先用解析工具抽文本再人工抽检几页确认没有乱码Word则统一转成纯文本。文本切分是最关键的一步。切得太粗一段内容包含多个主题向量表达会变得模糊切得太碎检索时又缺少上下文模型回答时信息不完整。我建议以语义边界为优先比如Markdown的标题、PDF的章节、或者段落结束位置而不是死板地每隔512个字符切一刀。我实际使用的参数是 chunk_size512chunk_overlap48注意这里的单位是token。为什么要设置overlap因为很多关键信息刚好落在两个块的边界上没有overlap的话检索时就会漏掉。48个token不算长刚好可以把前一个块的尾部内容带进下一个块又不会产生太多重复。切分时我强烈建议在代码里加一层“后处理”丢弃长度低于50token的碎片比如单行目录、孤立页码并且把相邻重复段落合并。这些碎片进向量库后不会产生任何价值还会白白增加噪声和存储成本。2.2 向量化、召回与重排机制向量化是把文本块变成一串浮点数让语义相近的文本在向量空间里靠得更近。Embedding模型我选了 bge-m3它输出1024维向量支持中文效果不错对长文档也有不错的鲁棒性。向量化之后每个文档块都会对应一条记录存在pgvector里。查询的时候用户的问题同样要过一遍Embedding模型然后用这条查询向量去向量库里做相似度检索这里常用的是余弦距离。我通常的初筛做法是每个问题取 TOP-K20 个候选块候选块范围太窄容易漏太宽又增加重排成本。20是一个相对安全的中间值。初筛之后一定要加重排。初筛阶段用的是内积或余弦相似度它只看整体语义相似度不够精细。而重排模型会把你给的“问题候选文档块”逐个拼接起来输入一个cross-encoder结构计算更精准的相关性打分。重排后我只保留Top-5作为最终上下文。实测下来同样一套数据加了重排后准确率大概能提升8到10个百分点这在小数据集上已经是非常明显的差距。召回阶段还有一个容易被忽视的细节设置相似度阈值。bge-m3的内积数值并不总是容易解释所以需要拿一批已知有关和无关的文本来标定阈值。我最终把阈值设为0.35低于这个分数的候选块直接丢弃。这么做的好处是当用户问的内容完全不在知识库范围内时系统不会硬凑上下文而是有机会走“知识库中没有找到相关信息”这条兜底路径。2.3 Prompt 设计与上下文组装细节很多人以为RAG的Prompt就是把检索到的文章拼在问题前面其实没那么简单。如果塞进去的内容乱七八糟模型很容易被无关信息带偏。我在项目里实际用的Prompt结构是系统角色说明、背景信息、回答要求、检索到的文档片段、用户问题。其中“回答要求”这部分非常关键我写的是“你是一名技术支持工程师。请只根据上面提供的文档片段回答用户问题。如果文档片段中没有足够信息请直接回答‘知识库中没有找到相关信息’不要尝试编造。回答时先给出结论再用文档内容解释最后标注信息来源编号。”这个Prompt在工程里演进过好几个版本。最早版本没加“不要编造”结果模型经常把通用知识混进来看起来流畅但实际是错误的。后面加上“只根据文档片段”和“知识库中没有找到相关信息”之后幻觉率大幅降低。上下文组装要控制总token长度。7B模型在vLLM里如果塞进超过6000 token响应时间会明显增加。我这边检索5个文档块每个块512 token再加上Prompt和问题一共约3000 token这个量级对7B模型来说比较舒服。如果块数太多我会在重排阶段适当增大淘汰比例而不是硬往Prompt里塞。temperature参数我建议设成0.1。知识问答追求确定性不需要模型发挥创意。我之前测试过temperature0.7版本回答每次都不一样有些看上去很顺但已经偏离了原文意思这在知识问答场景里是不可接受的。3. 实操过程与核心环节实现3.1 环境准备与项目骨架搭建我先说硬件。我的实践环境是一张RTX 4090 24GB显卡如果是16GB显存也能跑7B模型的4-bit量化版本如果完全没有GPU建议先用Qwen2.5-3B或1.5B版本跑通链路后面的代码逻辑完全不用改。7B模型单独做推理大概占14GB显存加上向量模型和API服务16GB会非常紧张所以显存不够时优先上4-bit量化。项目目录我按下面的结构组织ai-engineering-from-scratch/ ├── app/ │ ├── main.py │ ├── config.py │ └── rag/ │ ├── ingest.py │ ├── retriever.py │ └── generator.py ├── data/ │ └── docs/ ├── scripts/ │ ├── download_models.sh │ └── run_ingest.sh ├── docker-compose.yml ├── requirements.txt └── .envrequirements.txt 里我尽量精简只留下核心依赖fastapi0.115.0 uvicorn0.30.6 torch2.4.0 transformers4.45.0 vllm0.6.1 sentence-transformers3.0.1 FlagEmbedding1.2.10 psycopg2-binary2.9.9 pgvector0.2.4 python-multipart0.0.9 pydantic-settings2.4.0安装时建议用Python 3.10或3.11vLLM对Python版本有要求太新版本容易遇到依赖冲突。装完后跑一条简单命令验证torch和CUDA是否正常python -c import torch; print(torch.cuda.is_available())如果输出True说明GPU环境没问题。做完这些项目骨架就基本立住了。3.2 数据导入与向量化实现数据导入这一步我写了独立的 ingest 脚本方便文档更新后手动重跑。核心逻辑分四步读取文件、切分文本、生成向量、写入pgvector。切分文本这里我不引入额外框架直接使用给文本分块的小类来完成。这样能少一层依赖也好理解from langchain_text_splitters import RecursiveCharacterTextSplitter splitter RecursiveCharacterTextSplitter( chunk_size512, chunk_overlap48, separators[\n## , \n### , \n\n, \n, 。, , , , , ], )注意separators的顺序优先按Markdown标题切分再按段落、句子切。这样能最大程度上保留语义边界。如果切分后某个块长度小于50我会选择丢弃。向量化我用 sentence-transformers 加载 bge-m3from sentence_transformers import SentenceTransformer embed_model SentenceTransformer(BAAI/bge-m3, devicecuda) def embed_batch(texts): return embed_model.encode(texts, normalize_embeddingsTrue)bge-m3对中文查询有一个小技巧在查询前加一个“为这个句子生成表示以用于检索相关文章”的指令前缀这样检索效果会更好。这个前缀来自bge系列模型的官方推荐我测试过确实有效但不是所有Embedding模型都需要换模型时要留意。写入pgvector时先把PostgreSQL的pgvector扩展开起来然后建表CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE doc_chunks ( id BIGSERIAL PRIMARY KEY, doc_name TEXT NOT NULL, chunk_index INT NOT NULL, content TEXT NOT NULL, embedding vector(1024), created_at TIMESTAMP DEFAULT now() ); CREATE INDEX ON doc_chunks USING ivfflat (embedding vector_cosine_ops);ivfflat索引适合中小规模数据查询速度快。如果数据在百万级以上建议换成HNSW索引召回率更高但构建时间也更长。写入时注意按批量写每批几百条避免一次性占用太多内存。3.3 查询链路检索、重排与生成我用一个Retriever类把查询链路串起来。最初版本直接拿向量库返回的结果拼Prompt后来加上Reranker效果提升特别明显。from pgvector.sqlalchemy import Vector from sqlalchemy import create_engine, text class Retriever: def __init__(self, embed_model, reranker, conn_str): self.embed_model embed_model self.reranker reranker self.engine create_engine(conn_str) def retrieve(self, query, top_k20, top_n5): q_embedding self.embed_model.encode( [为这个句子生成表示以用于检索相关文章 query], normalize_embeddingsTrue )[0] sql text( SELECT id, doc_name, content, 1 - (embedding :query_embedding) AS similarity FROM doc_chunks WHERE 1 - (embedding :query_embedding) :threshold ORDER BY similarity DESC LIMIT :top_k ) rows self.engine.execute(sql, { query_embedding: q_embedding, threshold: 0.35, top_k: top_k }).fetchall() pairs [[query, row.content] for row in rows] scores self.reranker.compute_score(pairs) scored list(zip(rows, scores)) scored.sort(keylambda x: x[1], reverseTrue) return scored[:top_n]这里用到了pgvector的余弦距离操作符返回的 similarity 映射到余弦相似度。阈值0.35一开始确定不了我是先跑了几十条真实问题把答案打印出来人工检查再调整出来的结果。重排器加载方式from FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-base, use_fp16True)重排之后把选中的文档块按顺序拼进Prompt。文档块顺序非常重要不要让模型自己去猜应该优先看哪个。我按重排分数去重排序分数最高的排在最前面这比按数据库返回顺序更合理。生成部分我用vLLM的离线接口这样可以绕开transformers的重复加载问题from vllm import LLM, SamplingParams llm LLM(model/models/Qwen2.5-7B-Instruct, gpu_memory_utilization0.85) sampling_params SamplingParams(temperature0.1, top_p0.9, max_tokens1024) def generate(prompt): outputs llm.generate([prompt], sampling_params) return outputs[0].outputs[0].textgpu_memory_utilization 我设为0.85而不是默认的0.9主要是为了留出一部分显存给Embedding模型和Reranker。如果这几个服务都挤在同一张卡上配置不当很容易OOM。实际部署时我更推荐把Embedding和Reranker放到CPU上跑GPU全留给7B模型推理这样整体稳定性更高。3.4 用FastAPI封装服务查询链路跑通后下一步就是用FastAPI把它封装成HTTP接口。我在 app/main.py 里实现了一个简单的/query接口同时加了健康检查。from fastapi import FastAPI from pydantic import BaseModel app FastAPI(titleAI Engineering Demo) class QueryRequest(BaseModel): question: str top_k: int 20 top_n: int 5 class QueryResponse(BaseModel): answer: str sources: list[dict] app.post(/query) def query(req: QueryRequest): chunks retriever.retrieve(req.question, req.top_k, req.top_n) prompt build_prompt(req.question, chunks) answer generate(prompt) sources [{doc: c.doc_name, content: c.content[:200]} for c in chunks] return QueryResponse(answeranswer, sourcessources) app.get(/health) def health(): return {status: ok}sources字段一定要返回。一方面是为了让用户能点开原文核对另一方面也是将来做自动化评估的重要素材。生产环境里如果接口耗时超过用户忍受范围还会把来源信息当成排查线索。启动命令用uvicorn app.main:app --host 0.0.0.0 --port 8000到这里你已经有了一个能通过HTTP调用的RAG问答系统。整个流程从“用户输入问题”到“返回答案和来源”刚好闭环。3.5 上线部署与简单压测本地全套跑通后我把服务拆成了三个容器PostgreSQL、模型推理、API服务。docker-compose.yml 大概是下面这样version: 3.9 services: postgres: image: pgvector/pgvector:pg16 environment: POSTGRES_USER: ai POSTGRES_PASSWORD: ai POSTGRES_DB: ai_engine volumes: - pgdata:/var/lib/postgresql/data ports: - 5432:5432 api: build: . depends_on: - postgres - llm environment: DATABASE_URL: postgresql://ai:aipostgres:5432/ai_engine LLM_URL: http://llm:8001 ports: - 8000:8000 llm: image: vllm/vllm-openai:latest command: --model /models/Qwen2.5-7B-Instruct --port 8001 volumes: - ./models:/models environment: CUDA_VISIBLE_DEVICES: 0 deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu]压测时我用了一个非常轻量的方法脚本里模拟10个用户同时发起请求连续跑5分钟。7B模型配合vLLM在batch size为10的情况下平均每个请求的端到端耗时在2.5秒左右读吞吐约4个请求每秒。如果直接用transformers的默认接口同条件下会直接排队到几十秒这个对比足够说明vLLM的必要性。4. 常见问题与排查技巧实录4.1 检索不到相关文档怎么办这是RAG系统最常遇到的问题。表象是用户问了一个明显在知识库里的问题但系统回答不出来或者来源列表里全是无关文档。我的排查顺序是固定的先看召回的候选块到底是什么。在开发环境里我会把查询向量和召回的20个块全部打印出来人工检查那些块和问题的相关度。如果候选块本身就不相关那问题出在Embedding或文本切分上如果候选块相关但重排后消失了那问题出在Reranker或阈值设置上。切分导致的检索失败很隐蔽。我之前接入一批PDF文档整篇内容没有按段落切好每个chunk都是半句话Embedding表达出来的语义极其模糊查什么都是相关度很低的碎片。后来改成按章节标题二次切分并把chunk_size从1024降到512检索效果立刻正常了。这里我想特别强调RAG里没有“万能chunk参数”不同文档结构就该用不同的分隔策略。排查点判断方法调整方向chunk太大检索结果主题混杂调小chunk_size增加overlapchunk太小检索结果碎片化调大chunk_sizeEmbedding不适合测试同类问题无相关候选换领域更贴近的模型或做微调Reranker误杀原始召回相关重排后消失降低top_n或换更大reranker如果检索链路实在调不好还有一个省力技巧在切分时给每个chunk额外保留它的上级标题比如“第三章 环境部署 / 3.2 镜像构建 / 镜像拉取失败时如何处理”。这样既保留了上下文又让向量表达更聚焦。我后面的很多文档都采用了这个“标题拼接”技巧。4.2 模型加载爆显存和推理慢怎么处理项目第一次跑7B模型时我直接用了transformers的fp16加载跳动后一查显存占用接近16GB再同时加载Embedding和Reranker4090直接报了torch.cuda.OutOfMemoryError。当时最简单的解法是换用4-bit量化加载模型精度损失很小但显存占用一下降到8GB左右from transformers import AutoModelForCausalLM, BitsAndBytesConfig bnb_config BitsAndBytesConfig( load_in_4bitTrue, bnb_4bit_use_double_quantTrue, bnb_4bit_quant_typenf4, bnb_4bit_compute_dtypetorch.float16 ) model AutoModelForCausalLM.from_pretrained( Qwen/Qwen2.5-7B-Instruct, quantization_configbnb_config, device_mapauto )但4-bit量化只是权宜之计。如果接口要面向并发我强烈建议直接把推理服务换成vLLM。vLLM的PagedAttention会把KV Cache按页管理不再为每条请求预留完整空间所以并发越高优势越明显。我的实测数据是transformers单条生成200 token需要约6秒vLLM在并发10时平均1.5秒。推理慢还有一个常见原因是max_tokens设置过大。很多模型默认生成到2048甚至4096但知识问答的回答一般也就几百token。我把max_tokens限制在1024并让Prompt明确“回答控制在300字以内”这样既省显存又省响应时间用户体感还更好。4.3 回答看起来合理但其实是编的怎么抑制幻觉幻觉问题在RAG里很常见而且比检索不到更可怕因为它会误导用户。我在调试阶段遇到过一个典型情况用户问“如何配置Nginx反向代理”知识库里其实只有Apache的配置说明但模型照样生成了一段看起来很像那么回事的Nginx配置。后来检查Prompt日志才发现模型不仅读到检索出来的Apache文档还把自身的通用知识混了进来。为此我做了两层防御。第一层是Prompt里明确“只根据文档片段回答不引入外部知识”并且把temperature降到0.1。第二层是加了一个简单的“上下文相关性检查”如果重排后的最高分低于某个阈值就直接返回“知识库中没有找到相关信息”而不是强行生成。这个阈值我设成了0.4实测能拦截掉大约60%的无关提问漏网的那部分继续交给模型自己判断。还有一个小技巧回答里要求模型标注“根据文档X”并且把来源文档编号印在回答末尾。如果模型觉得某个来源不相关它就很难自洽地编造。这套逻辑不完美但对知识库问答场景已经能达到可用的水平。4.4 文档更新后回答不变化索引为什么没生效项目上线后我更新了一批文档重建索引时也执行了但用户反馈答案没变化。排查后发现原因在我自己身上写入pgvector的新记录没有删除旧记录的代码导致同一个文档存在两个版本检索时旧版本文档因为内容相似度高经常排在前面。之后我在ingest脚本里加了一层“按文档名先删除旧记录再写入”的逻辑并且给每条记录记录导入时间DELETE FROM doc_chunks WHERE doc_name %(doc_name)s;同时为了稳妥我给查询链路加了一个updated_at过滤用户可以选择只看某个时间点之后的文档。这在实际业务里很重要因为知识库大概率是持续更新的如果每次更新都全量重建索引到后期会非常浪费时间。工程化一点的做法是记录每个文档的文件哈希哈希变化才重新切分和向量化这样能省掉大量无意义的重复计算。最后聊两句个人心得这个项目做完之后我最大的感受是AI工程里最难的往往不是模型本身而是模型外面的那一大圈工程细节。数据怎么清理、文档怎么切分、检索怎么召回、接口怎么设计、失败了怎么降级每一环都在决定系统能不能真正被人用起来。很多人拿着一两个模型效果就以为项目完成了但只有把它完整暴露在真实问题和并发压力下才会发现真正的坑在哪里。如果你也想照着这个思路搭一套自己的AI工程我的建议是先别追求复杂框架就从一条最简单的链路跑通然后一点点加Reranker、加并发、加容器化。过程中多打印中间结果多记录失败案例这套调试手感才是AI工程最值钱的部分。后续你可以在这个骨架上扩展很多事情比如把检索结果接入Agent工具、加多轮对话记忆、把评估流程做成自动化都是自然生长的方向。
返回列表