☰
从零搭建AI工程化项目:RAG问答系统全流程实战
2026/10/2 13:44:20 网站建设 项目流程

这几年关于AI的讨论很多,但我发现一个很有意思的现象:很多人会用现成的API、会跑通开源模型的Demo,可真要让自己从零搭建一个完整的AI应用,却总会卡在某个地方——要么数据不知道怎么处理,要么模型调好了不知道怎么打包上线,要么部署完一跑就崩、完全没有排查思路。这个“从零到能落地”的跨越,其实就是AI Engineering的核心。它不是说把模型训练出来就完了,而是涵盖数据处理、模型调用、服务封装、部署运维、效果评测一整条链路。

这个项目标题“ai-engineering-from-scratch”,简单翻译就是“从零开始搞AI工程化”。它适合的不只是算法工程师,反而更适合那些已经会调用AI能力、但没系统走过工程化全流程的开发者。哪怕你只是做后端的、做产品的、做数据分析的,只要你想把一个AI想法变成一个能稳定跑起来、能给别人用的服务,这篇内容就能给你一条比较完整的路线。我会把这几年在实际项目里趟过的路、踩过的坑、总结出的流程全部拆开来讲,尽量让每个环节都能“照着做”。

1. 为什么说要亲手搭建一个AI项目才算入门

1.1 AI应用与AI工程化之间的差距

先区分两个概念:AI应用和AI工程化,很多人其实把它们混在一起了。写一个Python脚本,调用大模型API生成一段文案,这是一个AI应用Demo;但如果这个应用要面向多个用户、要稳定运行、要能处理各种异常输入、要在模型升级后还能保持效果,这就变成工程问题了。

举个简单的例子。开发一个基于本地知识库的问答系统,Demo阶段只需要读文件、调接口、返回答案。可一旦要真正投入使用,问题就全冒出来了:文档格式五花八门怎么统一解析?用户问的是同一个问题但表述不同怎么处理?模型返回的答案格式不稳定怎么校验?并发上来之后延迟蹭蹭涨怎么优化?这些问题没有一个是“多做几个Demo”能解决的,必须在工程架构层面提前设计。我见过太多团队Demo跑得飞起、一上线就拉胯,原因恰恰是没把工程化当回事。

1.2 一个最小但完整的学习闭环应该包含什么

从零开始学AI工程化,我不建议一上来就啃庞大的框架或追最新的模型。比较稳妥的方式是先跑通一个“最小闭环”,在这个闭环里把核心链路全部走一遍。什么是最小闭环?以我经常用的例子来说,就是从一份PDF文档开始,做到一个能通过网页或API访问的问答机器人。这个闭环天然包含了几大工程模块:

  • 数据层:文档解析、清洗、切片。
  • 索引层:向量化、索引构建、存储。
  • 逻辑层:检索、排序、Prompt组装、模型调用。
  • 服务层:API封装、配置管理、日志监控。
  • 部署层:容器化、环境隔离、健康检查。

别看这个链路小,五脏俱全。跑通一遍之后,你对AI工程化的理解会从“感觉会了”变成“真的会了”。而且这个闭环后续扩展性很强,把文档换成数据库就是Text-to-SQL,把单轮问答换成多轮工具调用就是Agent雏形。所以我的核心建议是:不要贪多,先选择一个能端到端跑通的小项目,把它做扎实。

2. 选型:面向工程化的技术栈怎么定

2.1 开发语言与框架选择的底层逻辑

很多文章一说技术栈就直接给出某个框架,但我更想先聊底层逻辑。AI工程化项目里,语言和框架的选择最核心的考量不是“哪个新”、哪个热度高,而是三点:生态成熟度、团队熟悉度、运维成本。

Python在AI工程化领域依然是首选,这个没什么争议。它的大模型SDK、数据处理库、向量数据库客户端、部署工具链都是最全的,出了问题搜解决方案也最容易。但Python不是万能的,如果你的场景对推理延迟极度敏感,比如实时的边缘计算场景,那可能得考虑用Go或Rust写部分高性能服务,再用Python做上层调度。一般起步阶段不用纠结这些,先用Python把链路跑通,性能瓶颈在哪里之后再针对性优化。

框架层面,我建议从LangChain或LlamaIndex这类主流框架入手,但一定不要“无脑用”。我的做法是先用框架搭脚手架快速跑通,然后逐步替换掉封装过深的部分,改成自己可控的实现。比如LangChain的文档加载器很好用,但如果你只需要处理特定格式,自己写五六十行解析代码更可控。工程化的核心诉求是可维护、可调试,框架能帮你加速,但不能替你思考。

2.2 模型、存储、编排组件的取舍建议

模型选择看起来只是个“选哪个”的问题,实际上它会影响整个架构。如果做通用对话,直接用云端大模型API就好,省心、效果好;如果做垂直领域问答,特别是数据敏感的场景,可能得考虑开源模型本地部署。我的经验是:不要一上来就追求私有化部署,先评估领域数据的敏感程度。很多业务场景其实用API完全够用,把精力放在RAG链路的优化上,效果提升比换模型更明显。

存储组件是大头。向量数据库选择很多,Milvus、Qdrant、Chroma、pgvector各有侧重。我的建议很简单:个人学习阶段用Chroma或Qdrant,安装简单、起步快;到了需要高并发生产环境,再评估Milvus或pgvector。后端业务已有PostgreSQL的,pgvector能省一套基础设施;数据规模特别大的,Milvus更合适。这里有个容易踩的坑:把向量数据库当成万能的,什么都往里面塞。实际上向量检索只是召回手段,最终效果还要靠后面的重排和模型生成。

2.3 环境准备与依赖安装

环境这块我踩过不少坑,简单分享一套比较稳的流程。第一步,创建虚拟环境,用Python 3.10或3.11版本,别用最新版本,有些依赖还没适配;第二步,确定核心依赖版本,建议锁版本而不是用latest,我一般在requirements.txt里直接写明版本号;第三步,配置环境变量,把API Key、数据库连接串等敏感信息和代码分离。

以我常用的技术栈为例,一个最小环境的依赖需求大概是这样:

python 3.10+ fastapi==0.104.1 uvicorn==0.24.0 openai==1.3.0 sentence-transformers==2.2.2 qdrant-client==1.7.0 pypdf==3.17.0 python-dotenv==1.0.0

安装完依赖之后,先写一个最小的脚本验证基础组件是否都能正常调用,不要直接写业务逻辑。先确认Embedding模型能跑通、向量数据库能连接、API Key有效,这三件事确认了,后面开发才会顺畅。给自己定一个原则:每引入一个新组件,先花十分钟验一个最小用例,这比最后统一排错节省的时间多得多。

3. 从零实现一个检索增强问答系统

3.1 数据准备与切片策略

整个链路里,数据准备和切片策略是最“笨”但也最关键的环节。很多人做RAG效果差,第一反应是模型不行,实际上八成是数据没处理好。先看文档解析:PDF要分扫描版和文字版,扫描版必须走OCR;Word、PPT、HTML各有不同的解析库。我的习惯是统一先转成纯文本,再做结构化处理,这样后续切片逻辑只需要面对一种格式。

切片策略直接决定检索质量,这块没有银弹,但有几个经验参数可以参考。第一个是切片大小,做过多次对比实验后,我一般会控制在400到600个字符之间;这个大小既能保留足够上下文,又不至于因为语义混杂导致检索不精准。第二个是重叠长度,相邻切片之间重叠50到100个字符,避免把一个完整语义切到两个切片里导致漏检。第三个是切片逻辑,尽量按章节、段落这样的“语义边界”来切,而不是硬按字数切。比如你处理的是技术文档,每个API说明是一个完整单元,硬切就会把“请求参数”和“返回结果”分开,检索效果自然差。

切片做完之后还有一个关键动作:清洗。我处理过很多真实文档,里面充斥着页眉页脚、重复标题、无关广告、特殊字符。这些噪声如果不清理,检索阶段会召回大量无效内容。清洗规则根据业务来定,但有几个通用操作可以参考:去重、去特殊符号、统一换行、过滤超短文本。清洗之后再统计一下切片数量和平均长度,做到心里有数。

3.2 向量化与索引构建

向量化就是把文本变成一串数字向量,让机器能计算语义相似度。这一步有两个选择:用云端的Embedding API,或者用本地的开源Embedding模型。我的建议是起步阶段直接用开源的sentence-transformers系列模型,比如BAAI/bge-small-zh-v1.5或moka-ai/m3e-small,原因是免费、离线可用、中文效果也不错。如果你追求极致效果,再考虑云端API或者更大参数的模型。

向量化之后要建立索引,这个环节有个容易忽视的点:向量维度的一致性。你用什么模型生成向量,索引就必须匹配对应的维度,换模型之后要么重建索引,要么做向量映射,否则查询会直接报错。我先列一下索引构建的核心代码思路:

from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PointStruct client = QdrantClient(host="localhost", port=6333) # 建立collection,维度需与embedding模型输出一致 client.recreate_collection( collection_name="knowledge_base", vectors_config=VectorParams(size=768, distance=Distance.COSINE), ) # 构造点数据,payload里带上原始文本和元信息 points = [ PointStruct( id=i, vector=embedding_vector, payload={"text": chunk_text, "source": doc_name} ) for i, (chunk_text, embedding_vector) in enumerate(zip(chunks, embeddings)) ] client.upsert(collection_name="knowledge_base", points=points)

这里有三个细节值得展开。第一,ID生成策略要稳定,建议用内容哈希而不是自增ID,这样重复写入不会产生重复数据;第二,payload字段不要塞太多东西,只放文本和必要的元信息,否则后期更新很麻烦;第三,相似度度量方式,中文场景我一般用余弦距离(Cosine),欧氏距离在向量归一化之后和余弦效果接近,但可解释性差一点。

3.3 检索逻辑与Prompt组装

检索不做花哨的处理,就是查询向量化之后在向量库里做相似度搜索,返回Top K个相关切片。但这里面有几个工程经验可以分享。第一个是Top K的选择,我的经验值一般是4到8个,太多会让Prompt超长或引入噪声,太少又可能漏掉关键上下文。第二个是相似度阈值的设置,低于阈值的检索结果宁可丢弃也不要硬塞给模型,否则会明显“胡说八道”。第三个是重排,如果预算允许,在前排结果里再用一个rerank模型做精排,效果提升非常明显。

Prompt组装是整个RAG链路里最值得花时间调优的环节。核心就是:把检索到的切片作为上下文,加上用户的原始问题,组合成一个结构化的Prompt。我的参考格式是这样的:

system_prompt = "你是一个严谨的问答助手,请基于给定的资料回答问题。如果资料中没有相关信息,请直接说你不知道。不要编造答案。" context = "\n\n".join([f"【资料{i+1}】{doc}" for i, doc in enumerate(retrieved_docs)]) user_prompt = f"""请基于以下资料回答问题: {context} 用户问题:{user_question} 请输出清晰、准确的回答。如果资料无法支撑答案,请明确回复“资料不足”。"""

这个组装看起来简单,但有几个坑。检索到的资料顺序会影响答案质量,相关度最高的应该排在前面;Prompt里必须明确“资料不足时怎么处理”的规则,否则模型会强行“脑补”;上下文总长度要控制在模型输入上限之内,切片数量多的时候尤其要注意,文本太长就算模型能处理,性能也会大幅下降。实测下来,把Prompt结构写清晰之后,回答的稳定性和可接受度会有质的提升。

3.4 服务封装与接口暴露

链路跑通之后,不能只停在脚本阶段,得把它封装成一个服务。我用FastAPI比较多,原因无他:轻量、异步支持好、自动生成API文档,对工程化特别友好。服务封装的核心是把业务逻辑和接口层分离,不要让路由函数里塞满RAG逻辑,而是把前面的数据加载、向量检索、Prompt组装、模型调用封装成独立的类或函数。

一个最小可用的接口层设计如下:

from fastapi import FastAPI, HTTPException from pydantic import BaseModel app = FastAPI() class QueryRequest(BaseModel): question: str top_k: int = 4 class QueryResponse(BaseModel): answer: str sources: list[str] @app.post("/api/ask", response_model=QueryResponse) def ask(request: QueryRequest): try: answer, sources = rag_pipeline.run(request.question, request.top_k) return QueryResponse(answer=answer, sources=sources) except Exception as e: # 记录完整traceback,而不是只返回错误信息 logger.exception("query failed: %s", request.question) raise HTTPException(status_code=500, detail="internal error")

接口设计有一个值得强调的点:不要直接把错误堆栈抛给前端。一方面这是安全隐患,另一方面对用户毫无帮助。返回值要结构化:answer是正常结果,sources是引用来源。很多团队不重视source的返回,我反而觉得这恰恰是工程化项目该有的东西,因为可溯源才能可评估,可评估才能持续优化。

接口层还要考虑输入校验。空字符串、超长文本、特殊字符这些都要在入口处理掉,不要等到调模型才发现问题。一个简单的Pydantic模型加上正则校验就能挡住大部分无效请求,这些细节看似小事,但生产环境的稳定性就是这么一点一滴堆出来的。

4. 工程化落地的关键环节

4.1 配置管理与日志体系

脚本阶段的配置可以写死在代码里,工程化阶段必须把配置、代码、凭证分开管理。我的做法很简单:用.env文件存敏感信息,用config.py读取并管理配置,配置项分类清晰,比如模型相关、数据库相关、服务相关分别归类。不要小看这个动作,配好之后换环境只需要改.env,代码一行不用动。

日志体系则是那种“平时想不到、出事才后悔”的部分。我刚开始做项目时日志东一榔头西一棒子,出了问题根本无从查起。后来总结了一套固定的日志规范:请求进来打一条INFO,参数是什么、耗时多久;RAG检索完成打一条INFO,召回了几个切片、耗时多久;模型调用完成打一条INFO,Prompt是多少字符、生成用了多少token;任何异常打一条ERROR,带完整堆栈。这套日志看着简单,真实排查问题的时候价值巨大。

还有一个容易被忽略的点:生产环境与开发环境的日志级别要分开。开发环境可以调试级别、输出详细信息,生产环境一般INFO就够了,避免日志量过大影响性能。日志别打到控制台就完事,要落盘或采集到集中日志系统,否则容器一重启,问题根因就跟着丢了。

4.2 性能优化与缓存

AI应用性能瓶颈在哪?答案非常明确:模型调用。Embedding模型和LLM的生成耗时占了整个请求链路的大头。针对这块,最经济且有效的优化手段就是缓存。

缓存分两层:第一层是向量检索结果缓存,完全相同的query在短时间内直接返回之前的结果,用Redis或内存缓存都行;第二层是Embedding结果缓存,相同文本的向量不重复计算。我实际项目中遇到过一个场景,用户高频反复查询同一个问题,加上缓存之后整体QPS直接翻倍,响应延迟从2秒降到300毫秒,效果非常明显。要注意的坑是缓存必须带过期时间和大小限制,否则缓存数据膨胀会引发内存问题。

另外一个性能优化点是向量检索本身。数据量小的时候无所谓,数据量大了之后就需要关注。比如Qdrant可以开启hnsw_ef参数来调整检索精度和速度的平衡,追求速度就调小,追求效果就调大。后续数据量真到百万级,还要考虑分区(sharding)和过滤索引,但初期项目没必要过度设计,做到够用且规范就行。

4.3 Docker部署与健康检查

最后一步是部署,我用Docker打包整套服务,保证开发环境和生产环境一致。Dockerfile不宜太复杂,核心就三件事:拉一个Python基础镜像、拷贝依赖和代码、启动命令配置好。但有几个细节值得注意:一是依赖安装要利用Docker缓存层,先拷贝requirements.txt再拷贝代码,这样依赖没变化时构建不会重新安装;二是镜像里不要包含.env等敏感文件,通过环境变量或挂载方式注入;三是启动命令不要用--reload,那是开发模式,生产环境需要的是稳定。

部署之后必须做健康检查。FastAPI加一个/health接口,返回服务状态和关键依赖的连通性,比如向量数据库和模型API是否正常。容器编排配置里配上探针,这样服务不可用时会自动重启或摘流量。我遇到过一种常见故障:向量数据库挂了但服务还在运行,请求进来全部超时。有了健康检查,这个问题就能第一时间暴露。

健康检查代码很简单,但作用很大:

@app.get("/health") def health_check(): # 检查向量库连接和模型可用性 qdrant_ok = client.check_connection() model_ok = embedding_model is not None return {"status": "ok" if qdrant_ok and model_ok else "degraded"}

这里的关键是把“健康”的定义做清楚。不能只检查进程在就跑通,要检查依赖组件的可用性;不能只检查API接口通,要检查核心链路是否可用。工程化项目的成熟度,体现在它对自己“不健康”状态的感知能力。

5. 常见问题与排查技巧实录

5.1 文档加载了但检索效果差

这是RAG项目里最典型的问题。我排查过很多次,九成原因都在数据切片上。切片太大导致一个切片包含多个主题,切得太小导致语义不完整;按固定字数硬切把完整的逻辑切断了。我的排查顺序是:先查看召回结果和用户查询的语义相关度,再检查对应切片内容是否完整、是否有噪声,最后再回头调整切片策略。

另外一个容易被忽视的原因是索引没有同步更新。文档修改后如果只更新了文本存储、没有重新生成向量,检索用的还是旧向量,那自然查不准。遇到检索结果和文档内容对不上的情况,优先检查索引和源数据是否一致。

5.2 模型输出不稳定、偶尔漏内容

这个问题在工程化项目里非常常见。同一个问题,几次结果不一样,有时候答案完整,有时候丢三落四。根源一般在Prompt设计上。你给模型的约束不够明确,模型就会“自由发挥”。我的经验是:把输出要求写到很具体,比如“必须逐条回答”、“每个问题都要给出结论和依据”、“资料不足时明确说明原因”。一次不行就多轮迭代,实测下来Prompt的三五轮调整往往比换模型更有效。

在代码层面也要做校验和兜底。模型返回结果之后要做后处理:检查是否为空、是否包含预期的结构。如果模型返回了非预期格式,可以在服务端做一次修复或重试。这里的思路是:模型输出不可能100%稳定,工程化要做的是在接口层兜住这些不稳定因素。

5.3 容器启动慢和资源占用高

容器启动慢通常有两个原因:一个是启动时加载模型权重,中文Embedding模型几十MB到几百MB,从磁盘加载要时间;另一个是启动时执行了很多初始化逻辑。针对第一个,我建议给模型建立独立的持久化挂载,首次下载后就不需要反复下载了;针对第二个,把数据预加载和模型预加载放到后台任务,接口可以先响应健康检查,处理完再对外提供服务。

资源占用高的问题一般在Embedding模型和向量数据库身上。内存不够时优先考虑换更小的Embedding模型,效果降不了多少但内存省一大截。向量数据库的容量要提前规划,数据增长过快时及时加索引优化或扩展节点。

5.4 排查工具与问题速查表

这里给一份我实际排查问题时用的速查表,希望能帮你少走些弯路:

问题现象大概率原因排查手段解决方案
检索结果乱七八糟切片策略不合理打印召回切片详情按语义边界重新切片,调小切片长度
回答凭空编造上下文缺失或阈值太低检查相似度分数提高阈值,增加相关资料
接口响应很慢模型调用耗时长查看链路耗时日志加缓存,换更快的模型,调低Top K
服务突然不可用依赖组件挂了看健康检查状态检查向量库和模型服务,加自动重启
结果不一致Prompt约束不足对比多次调用输出强化Prompt约束,增加后处理校验
内存持续上涨缓存或向量数据膨胀监控内存曲线增加缓存过期策略,限制缓存数量

这六条基本上覆盖了RAG项目从开发到上线后的主要问题类型,你可以把它先收藏下来,等真的遇到问题再对照排查。

6. 从RAG到Agent的扩展经验

当RAG链路稳定运行之后,自然就会想往上加能力。我比较推荐的下一步是把单轮问答扩展成Agent式的多轮交互。具体来说,就是让模型不只是“回答问题”,而是“根据用户需求调用工具完成任务”。这里有个工程化的思考方式:把每一种请求都抽象成工具调用,参数校验、权限控制、日志审计一套体系都复用。

从RAG到Agent的演进过程中,最困难的地方不是技术实现,而是稳定性控制。单轮问答的失败模式很简单,Agent的失败模式就复杂多了,模型可能调错工具、参数传错、陷入循环。如果你在RAG阶段没有把日志和评测体系打好,Agent阶段会非常痛苦。所以我在RAG阶段最后一步,一定会建议搭一个简单的评测集,准备三五十条典型问题,每次调整之后跑一遍回归测试,确保没有改坏已有能力。这一点,越早做越受益。

6.2 评测体系搭建心得

如果让我给AI工程化项目排优先级,评测体系绝对排前三。没有评测,你所有的优化都在“凭感觉”。搭建评测体系的最简方案是:准备一组标准问题和期望回答要点,跑一遍系统,人工或自动比对回答是否覆盖了期望要点。简单但有效。

进阶一点做法是把评测指标量化:检索召回率、答案相关性、响应延迟、Token消耗等都可以做成报表。有了这些数据之后,每次改动都能量化对比,而不是靠拍脑袋。特别是模型换版本、Prompt调整之后,有没有回退,一测便知。这个过程是AI工程化和纯算法Demo之间最大的分水岭。

6.3 我的个人感受与建议

做“ai-engineering-from-scratch”这条路,我最有体会的一点是:真正难的不是某个算法或框架,而是把一堆组件拼起来还要稳定运转的那种“系统工程感”。这种能力没有捷径,靠的就是把一个一个项目从头到尾地做完整。每一次踩坑、排查、修复,都会内化成你的工程直觉。

最后分享一个我一直在坚持的小习惯:每次项目结束,我都会花一点时间写一段项目复盘,记录哪些组件选对了、哪些环节走了弯路、哪些坑下次要提前规避。技术更新很快,但工程化的底层思考方式不会过时。希望这篇内容能在你从零搭建AI工程化项目的路上,帮你少踩几个我没能绕开的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询