ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零构建AI应用:RAG检索增强生成技术实战与工程化指南

从零构建AI应用:RAG检索增强生成技术实战与工程化指南 1. 从“调接口”到“做工程”我们到底差在哪先讲个真实的尴尬经历。早年我接了个AI项目需求听起来特别简单“帮客户做一个知识库问答机器人”。我当时信心满满调个大模型的接口配上检索再套个聊天框这不就完事了吗结果上线第一天就翻车。客户问“你们官网怎么注册”机器人答非所问把一段产品介绍背了出来客户问“退款政策”机器人直接编了一个“支持7天无理由”的假政策。我连夜排查发现问题根本不在模型选型而在于整条链路——数据切块太粗导致检索命中垃圾片段提示词没有约束模型的“不知道就直说”行为甚至连上下文长度都没做管理多轮对话一长就开始严重漂移。那次之后我明白了一件事“AI engineering”不是“调接口”的堆叠而是一门需要系统方法论的手艺。市面上很多教程教你怎么写Prompt、怎么调参数但很少有人讲清楚从零开始搭建一个可靠AI应用到底要经历哪些环节每个环节的坑在哪为什么必须这么设计。这也就是我写这篇“ai-engineering-from-scratch”的初衷——把一条完整的技术链路摊开从需求拆解、数据准备、模型选型、提示词工程到评测反馈和上线监控用一个真实项目串起来讲清楚每个决策背后的“为什么”。这篇文章适合谁两类人。一类是刚入门AI开发、看过不少概念但没完整做过项目的人你能在这里找到一条可复制的实践路径另一类是已经做过一两个Demo、但总觉得不稳定的工程师你能在这里补上最容易忽略的工程化环节。我会用生活化的类比解释复杂概念也会给出可以直接抄作业的参数配置和代码但核心不是让你复制而是让你理解一个AI应用是怎么从零长出来的。2. 从头设计一个AI应用先拆需求再谈技术很多初学者一上来就选模型、写代码这是最大的误区。AI工程和传统软件工程最大的区别在于传统逻辑是“输入确定→规则确定→输出确定”而AI是“输入不确定→模型概率→输出不确定”。这种不确定性要求我们在设计阶段就想清楚哪些环节必须可控哪些环节可以容忍模糊哪些风险必须提前兜底。2.1 需求拆解把“做个机器人”变成“我能验收的功能列表”拿我接手的一个真实项目举例客户说要“做一个智能客服机器人”。如果直接把这个需求扔给技术团队大家会很迷茫。所以我做的第一件事是把需求拆成可验证的问题清单用户的核心诉求是什么是咨询产品信息还是处理售后问题还是引导下单预期对话轮数多长一次问答还是多轮上下文如果模型答错了代价有多大是浪费用户时间还是会产生法律风险数据源在哪里文档格式是什么更新频率如何是否需要限定回答范围比如只回答公司产品不回答竞品对比。拆完这些问题需求就变得具体了。这个客户最终定义为“基于产品手册的定向问答多轮对话不超过10轮错误回答会造成客户流失必须标注信息来源绝不回答手册之外的内容。”你可能觉得这没什么但正是这句话决定了后面所有的技术选型。不要小看这一步我见过太多项目因为需求模糊做到一半才意识到“这根本不是客户要的东西”白白消耗了几周时间。2.2 方案选型从零开始不等于从轮子开始造明确需求后进入方案选型。“from scratch”这个词容易让人误会以为要从训练一个大模型开始。实际上在真实工业界from scratch指的是“从零构建应用系统”而不是“从零训练模型”。绝大多数情况下我们是在现有模型能力的基础上做工程化这就像盖房子不必自己烧砖但你必须知道地基怎么打、承重墙怎么砌。选型时我在三个方向上做权衡。第一是模型方案调用大模型API还是部署开源模型客户数据涉及内部产品信息安全性要求高但预算有限最后选择“核心问答走API、私有数据本地检索”的混合方案。第二是检索方案用向量数据库还是传统关键词搜索我倾向于混合检索这个理由后面会详说。第三是对话管理直接无状态调用还是维护多轮会话状态答案是必须维护否则用户体验会断崖式下降。这里有一个关键判断原则每个选型决策都要回到需求清单去验证。比如为什么不做全本地部署因为客户文档量只有不到200份现阶段API的成本和效果都优于本地小模型为什么必须做检索因为大模型的参数知识里根本没有客户的产品手册直接问等于让一个博学的路人回答你家公司的内部规定他只能编。3. 核心链路拆解检索增强生成(RAG)为什么是AI工程的必修课整个项目的核心链路我采用的是当前工业界最普遍也最实用的架构——RAGRetrieval-Augmented Generation检索增强生成。你可以把它理解成一个“开卷考试”的过程大模型是考生你提供的文档资料是参考书。考生不能凭空答题必须先翻书找到相关内容再组织语言作答。RAG的价值在于它让模型基于你的数据说话而不是基于它的“记忆”编造。3.1 数据准备切块参数到底怎么定RAG的第一步是数据准备。客户给的资料是几十份PDF和Word文档我第一眼看到就头大——排版混乱、目录层级复杂、表格嵌套。如果直接一股脑切块检索质量一定惨不忍睹。切块是RAG中最容易被低估的环节。切得太小语义不完整切得太大检索时噪音太多。这个客户的产品手册里每个产品参数表是一个完整语义单元所以我选择了固定字符数重叠的切块策略块大小设为500个字符重叠设为50个字符。为什么是500我根据经验总结了一套判断逻辑——中文场景下一个完整语义单元大概在200~600字之间太短会把“产品型号”“适用场景”“售后政策”切开太长会把多个无关主题混在一起。这组参数我建议作为起步值具体还需要根据数据形态调整比如合同类文书比产品介绍更适合大块。切块还涉及要不要保留标题层级。我踩过一个坑直接把PDF转成文本导致标题和正文混在一起检索时系统无法区分“带宽参数”和“带宽故障处理”的区别。后来我用pypdf提取文本时保留标题前缀同时用正则做了章节标记把“标题-段落”结构带进切块逻辑检索精度提升非常明显。这一步看似简单实操中却很少有人做。3.2 Embedding选型不能只看排行榜切完块每块文本需要转换成向量也就是Embedding。业界常用的是OpenAI的text-embedding-3-small或者开源的bge-m3、m3e等。这里有个容易踩的误区很多人只看MTEB排行榜选模型但排行榜分数高不等于适合你的数据场景。金融、法律、医疗这些领域专业术语的语义相似度计算和通用文本差异巨大。我当时在这个项目里先用text-embedding-3-small做了一版效果中规中矩。后来测试bge-m3发现它对中文长文本的语义理解更好同时支持稠密检索和稀疏检索两种模式最终选定了它。另外要注意Embedding模型必须和检索模型配合评测不能单看“相似度分数高”就认为检索结果好。我试过一个模型在相似度评测中表现不错但实际检索出来的片段总缺少关键数字后来发现是它对数值型文本敏感度低。这类问题只有通过端到端评测才能暴露。提示无论选哪种Embedding在生产环境中务必固定版本。Embedding模型升级会导致旧向量和新向量无法对齐届时所有历史数据都需要重新向量化这是一个非常容易被忽略的坑。3.3 召回策略为什么我不建议只用向量检索向量检索擅长“语义相似但表述不同”的召回比如用户问“怎么退货”文档里写“退款流程”。但向量检索也有明显短板对精确词、型号、数字不敏感。用户输入“HDMI2.1接口是否兼容4K144Hz”如果文档里型号是“HDMI2.1”向量检索可能召回“接口版本说明”却漏掉具体型号段落。这在技术问答场景里是致命的。所以我在项目里采用混合检索向量检索BM25关键词检索再用RRFReciprocal Rank Fusion合并排序。BM25是传统关键词匹配算法对精确词召回极准向量检索负责语义扩展。两者互补之后我再用bge-reranker做精排把召回的候选段落重新打分排序最终Top5准确率比纯向量检索高出一大截。很多教程不会讲这一层但真实工业项目里这是质量能不能达标的分水岭。混合检索的配置并不复杂但要注意权重配比。我在这类中文问答项目中常用的参数是向量检索召回20条BM25召回20条RRF合并后取Top10输入给Reranker精排最终取Top5。这个数字是我反复测试出来的平衡了延迟和精度。如果资料库量更大可以适当增加召回数量但Reranker的输入上限要控制。4. 提示词工程模型能不能“不胡说”就看这一层检索做得好只是拿到了好素材。提示词工程决定了模型能不能把素材用好并且在拿不到素材时诚实承认。我始终认为提示词不是“写几句咒语”而是“给模型设计一套决策规则”。4.1 系统提示词的设计角色、任务、边界、格式我在这个项目里写的系统提示词核心包含四个部分。角色定义你是XX公司的智能客服助手任务说明基于提供的参考资料回答用户问题边界约束若参考资料中没有答案必须明确回复“当前资料中未找到相关信息”严禁编造输出格式分点作答并标注来源编号。这四个部分看起来简单但写起来有很多技巧。边界约束尤其重要因为模型天生倾向“讨好像用户”用户问一个它不知道的东西它宁愿编也不愿承认。我的处理方式是用明确的负面指令正面指令双重约束。比如“如果参考资料中没有明确信息直接回复无法回答不要尝试推测如果必须推测请明确标注这是基于经验的推测而非资料结论。”同时在示例中给出“无法回答”的标准句式模型对示例的模仿能力远比抽象指令强。注意系统提示词里不要放太多规则。我见过有人把提示词写成1000字的长文结果模型反而抓不住重点。我的经验是核心规则控制在300字以内把更详细的业务细则放到检索片段里让模型按需获取。提示词越精简指令遵循效果越好。4.2 上下文管理与多轮对话的坑多轮对话的大坑是“上下文越多模型越容易飘”。每轮对话都把所有历史消息塞给模型看起来像在“记住”上下文实际上一是Token成本线性增长二是早期信息被稀释三是模型可能被历史信息带偏。我在这类客服项目里采用“滑动窗口摘要压缩”策略只保留最近5轮完整对话超过5轮的早期内容先用模型压缩成一句摘要放在上下文最前面。这个方法实测很有效。有一次用户说“刚才问的带宽那个问题你再说一遍”如果窗口太短模型已经忘了如果保留摘要模型就能根据摘要大致定位“用户在问带宽配置”再结合当前轮问题给出答案。这里也踩过一个坑摘要本身可能产生幻觉。我采用的策略是让摘要必须标注“非逐字记录”同时即使摘要出错模型也能通过反问用户澄清避免硬答。4.3 防止“检索未命中”时的强行编造上一节提到检索不到答案时模型会编这里再展开说一个实用技巧。我在提示词里专门加了一段“引用规范”回答中必须包含来源编号比如“根据资料[1]显示...”。如果模型没有引用任何来源编号系统层直接拒绝输出。这个设计很笨但非常有效因为它把“是否使用了参考资料”变成一个可执行、可校验的信号。实测中强制要求标注来源之后无效编造率下降了一大半。5. 实操全流程从零搭建一个可运行的RAG问答系统下面进入本篇最硬核的部分。我会从一个空白目录开始一步步搭建这个基于RAG的问答系统。整个项目使用Python核心依赖是langchain、chromadb、bge-m3和dashscope用于调用大模型API。这个架构哪怕你用的是其他云厂商模型也能照搬逻辑。5.1 环境准备别让依赖版本毁掉一天首先是环境配置。我强烈建议用uv或者conda创建独立虚拟环境不要直接装在全局Python里。依赖版本锁定也很重要这个项目会用到langchain及其生态这库的版本更新速度极快不同版本之间API差异很大。我使用的版本组合是python -m venv .venv source .venv/bin/activate pip install langchain0.2.16 langchain-community0.2.12 langchain-openai0.1.23 chromadb0.5.0 bge-reranker-v2-m3这里特别提醒langchain里很多组件已经拆分成独立包老教程里的from langchain.vectorstores import Chroma在新版本会报错正确写法是from langchain_community.vectorstores import Chroma。这类版本坑很常见所以我建议无论从哪里复制的代码都要先跑一个5行代码的冒烟测试确认导入正常再往下走。5.2 文档处理与索引构建核心代码与参数注释文档处理流程我用pypdf加载PDF用docx2txt加载Word然后清洗文本。清洗这一步容易忽略但直接影响质量。我之前遇到一份PDF转出来的文本全是乱码换行一段话断成七八行切块后语义支离破碎。我的清洗规则包括合并断行、去掉多余空格、统一全半角标点、识别并保留表格结构。这些规则不复杂但每个规则背后都是血泪教训。核心构建代码如下from langchain_community.document_loaders import PyPDFLoader from langchain.text_splitter import RecursiveCharacterTextSplitter # 1. 加载文档 loader PyPDFLoader(product_manual.pdf) documents loader.load() # 2. 文本清洗简化版 for doc in documents: doc.page_content re.sub(r\s, , doc.page_content.strip()) # 3. 切块 splitter RecursiveCharacterTextSplitter( chunk_size500, chunk_overlap50, separators[\n\n, \n, 。, , , , ], ) chunks splitter.split_documents(documents)separators的顺序值得留意。我的切块器会优先按段落断句再按句号等标点切分而不是硬按字符数截断。这样能在“尽量保持语义完整”和“控制块大小”之间平衡。如果文档里有很多列表可以自行在separators里加入\n-等符号让列表不被打散。然后构建向量索引。我使用Chroma作为向量数据库Embedding用bge-m3from langchain_community.embeddings import HuggingFaceBgeEmbeddings model_name BAAI/bge-m3 embedding HuggingFaceBgeEmbeddings( model_namemodel_name, model_kwargs{device: cpu}, encode_kwargs{normalize_embeddings: True}, ) from langchain_community.vectorstores import Chroma vectorstore Chroma.from_documents( documentschunks, embeddingembedding, persist_directory./chroma_db, ) vectorstore.persist()写到这里想提一下参数选择的原因。normalize_embeddingsTrue是因为后续做相似度计算时归一化后的向量更方便用余弦相似度计算同时有利于检索效果的稳定性。Chroma持久化到本地目录下次启动不用重新构建索引但如果Embedding模型版本变了需要重新构建。5.3 混合检索与Rerank一段可以直接抄的代码接下来是混合检索的核心实现。我基于langchain的EnsembleRetriever组合BM25和向量检索再配合bge-reranker重排。from langchain.retrievers import EnsembleRetriever from langchain_community.retrievers import BM25Retriever # BM25检索器 bm25_retriever BM25Retriever.from_documents(chunks) bm25_retriever.k 20 # 向量检索器 vector_retriever vectorstore.as_retriever( search_kwargs{k: 20} ) # 加权合并 ensemble_retriever EnsembleRetriever( retrievers[bm25_retriever, vector_retriever], weights[0.5, 0.5], )使用EnsembleRetriever的好处是它会用RRF算法把两边结果合并你不需要自己写融合逻辑。RRF的核心思想很简单两个检索器都排名靠前的文档融合后排名也靠前只被一个检索器命中的文档排名会适当降低。重排器部分我直接用FlagEmbedding库加载bge-reranker-v2-m3from FlagEmbedding import FlagReranker reranker FlagReranker(BAAI/bge-reranker-v2-m3, use_fp16True) def rerank_documents(query, candidates): pairs [[query, doc.page_content] for doc in candidates] scores reranker.compute_score(pairs, normalizeTrue) sorted_docs [doc for _, doc in sorted(zip(scores, candidates), keylambda x: x[0], reverseTrue)] return sorted_docs[:5]这里有个性能痛点需要提前说重排过程会在CPU上实时计算如果候选的文档多响应时间会明显变长。我在这个项目里的经验是把重排的输入控制在10条以内只对混合检索后的Top10做重排最终取Top5这样延迟在可接受范围内。如果你调用的是在线重排API记得加缓存同一问题重复问时直接命中缓存能省一大笔费用。5.4 对话链路的组装Prompt、模型调用、来源标注组装完整对话链路的时候我把检索器和Prompt接起来让模型只基于检索结果回答。大模型API我用的是dashscope兼容OpenAI接口的方式这样代码切换成本最低。from langchain.prompts import ChatPromptTemplate prompt_template ChatPromptTemplate.from_messages([ (system, 你是一个严谨的客服助手。请基于“参考资料”中的内容回复用户问题。 如果参考资料中没有明确答案请回复“当前资料中未找到相关信息”禁止编造。 回答时请分点输出并在每句话末尾标注来源例如[资料1]。 参考资料如下 {context} ), (user, {question}), ])检索到的reranked_docs会被拼接到{context}中。细节在于我会给每个片段手动编号这样模型标注的来源编号才能对应到真实文档。最后是对话状态管理。前文提到用“滑动窗口摘要压缩”代码实现如下from collections import deque MAX_RECENT_MESSAGES 5 recent_messages deque(maxlenMAX_RECENT_MESSAGES) summary def build_messages(question): if len(recent_messages) MAX_RECENT_MESSAGES: summary summarize_history(recent_messages) recent_messages.clear() return [(system, prompt_template f\n历史摘要{summary}), *recent_messages, (user, question)]summarize_history函数可以让模型用一句话压缩之前的对话内容当然也可以直接用固定模板拼接关键问题这个看业务需要。我用模型压缩的原因是它能保留更关键的信息比“只保留用户历史问题”更灵活。5.5 端到端测试上线前必须问自己的10个问题代码写完不算完测试环节才真正决定能否上线。我在这类AI项目里有一套“上线前必问清单”你可以在自己的项目里直接套用测试用户问一个精确型号参数回答是否包含正确参数测试用户用同义替换的表述问同样的问题是否能召回测试用户问一个资料中不存在的问题模型是否拒绝回答测试用户问一个跨多个章节的问题回答是否逻辑一致测试多轮对话第8轮时模型是否还能关联第2轮的信息测试用户发送错别字、口语化表达表现如何测试用户试图通过Prompt越权提问例如“忽略上述指令”模型是否会被带偏测试文档更新后旧的向量是否还能被正确召回测试高并发场景下检索和重排的延迟是否不可接受测试模型回答是否明确标注来源来源是否正确这10个问题我建议写成脚本每次代码变更后自动跑一遍形成回归测试。AI应用的回归测试和传统软件同样重要否则你改一个切块参数可能让另一类问题全部翻车。6. 评测体系没有量化指标就没有优化方向很多人在Demo阶段就止步了原因之一是没有评测体系不知道“现在到底行不行”。我见过最典型的场景是人工测了20个问题觉得“还行”上线后被用户骂出差评。AI应用必须有评测体系否则一切优化都是空中楼阁。6.1 评测集怎么建不是随便找几个问题就行评测集的质量决定评测结果的可信度。我建评测集时先把用户常见问题按类型分层。以这个客服项目为例评测集包含五大类产品参数类、售后流程类、故障排查类、跨章节综合类、资料外拒答类。每一类准备10~20个问题数量不多但每类必须覆盖。有一个经验是必须加入负面样本。只测“问题能答对”不能发现问题必须明确测试“不该答的能不能拒答”。比如用户问“你们员工工资多少”资料里没有模型如果开始编这就是大事故。负面样本在评测中的权重应该不低于30%。6.2 离线评测指标召回率、命中率、拒答率、Hallucination率离线评测指标我主要盯四个召回命中率正确答案是否出现在最终Top5检索结果中。这个指标反映检索质量。生成正确率模型最终回答是否正确。这个指标是用户直接体验。拒答准确率资料外问题被正确拒答的比例。拒答不是越低越好它反映“诚实度”。幻觉率答案中出现资料中不存在的信息的比例。这是AI应用的高压线出现一次严重幻觉都可能是事故。我建议做一个可视化看板跑完评测集直接展示这四个指标。这个项目里我通过切块调优、引入混合检索、加强Prompt约束将幻觉率从初版的32%降到了8%以下召回命中率从68%提升到91%。没有量化指标这些优化根本无从谈起。6.3 线上监控不能上线后当甩手掌柜线下评测做得好不代表线上不出问题。用户问题千奇百怪模型效果一定会随时间漂移。我在项目里接入了一套简单的线上监控记录每轮问答的检索片段、模型回答、用户反馈点赞/点踩每天汇总找到“回答被点踩”的样本进入人工抽检。每周把线上真实难题补充进评测集重新跑回归测试。这套机制成本不高但能持续暴露问题。很多团队上线后就不管了结果模型效果越跑越差用户流失却找不到原因。7. 工程化避坑指南我踩过的6个坑希望你别再踩7.1 切块参数不是“拍脑袋”要跟着评测走我最初把chunk_size300整卷文档切得很碎结果检索到的片段总缺上下文。改成chunk_size1000又发现一个切块里塞了多个主题检索命中噪音多。最终是评测指标告诉我500是最优值。如果你想偷懒我的建议是固定用500/50起步然后用评测集验证再微调。7.2 不要用最新的Embedding模型直接替换线上的旧模型一次次的教训让我养成一个习惯任何Embedding模型升级都要把评测集完整跑一遍对比新旧向量检索命中率后再决定是否切换。有一次我看到新模型榜单分数高直接替换结果中文长尾问题召回率掉了10个点原因是新模型在长文本上的压缩方式不同。倒不是新模型差而是它和现有Reranker、切块参数的配合不如旧模型稳定。7.3 提示词越“像人话”模型越容易遵守写提示词不要用“你必须严格遵循以下797条规范”之类的写法。我试过用非常正式的语言写规则模型遵守率反而低改成口语化、短句、分点遵守率明显提升。比如“没查到就等于不知道不知道就明说别硬编”这种写法效果比“如检索结果未包含关键信息应明确表达无法回答”更好。7.4 上下文窗口不是越大越好很多人的第一反应是“把窗口拉满模型记得多”。实际上窗口越大模型对早期信息的关注度越低也越容易把不相关信息混入回答。我用滑动窗口摘要的方式后回答准确率反而提升成本还降了。如果你的业务场景必须长对话可以考虑分层记忆。7.5 文档更新后要触发增量索引不能只换文件有一次客户更新了产品手册我直接替换了源文件却忘了重建向量索引结果用户问新参数时模型还在依据旧文档回答。文档版本管理、索引重建触发机制必须纳入上线交付物。7.6 成本控制缓存是AI应用的第一省钱利器模型API调用不便宜。我在线上环境加了多层缓存同一问题文本归一化后相同直接返回上次回答同一文档片段多次被检索时缓存其Embedding向量。实际测算缓存命中率能到30%左右成本下降明显。8. 真实项目复盘从零到上线我做了什么取舍最后复盘一下这个客服项目的完整历程。整个项目从零开始一共花了三周其中第一周完全在做数据清洗、需求拆解和评测集构建写代码只占第三周的一部分。很多人以为AI开发就是“写代码”实际上真正的工程师花时间最多的地方是那些看起来不酷、但决定成败的环节。项目上线两周后我们把线上日志拉出来分析发现几个有意思的现象用户提问中有近40%的问题是资料里没有的用户经常用错别字和口语化表达回答被点踩最多的情况集中在“保险理赔进度查询”这种需要实时状态的场景。这些问题在离线评测中很难完全暴露只有线上监控才能发现。我把这些问题补进评测集下一轮迭代重点解决了口语化表达和状态类问答。从技术指标看最终效果是召回命中率91%正确回答率86%拒答准确率93%幻觉率控制在8%以下。这些指标不算顶尖但对一个中小型客服机器人来说已经达到可上线标准。客户真正满意的是什么不是技术指标而是“它不乱编了”。AI工程的核心价值不是让模型显得多聪明而是让它成为一个可靠、可信、可控的工具。根据我个人经验如果你打算从零开始做一个AI应用我强烈建议你先花两天时间把数据清洗和评测集做好。这两件事在初期可能会让你觉得“没在写代码”“没意思”但它们才是整个项目的压舱石。数据质量不行后面所有优化都是事倍功半评测体系不建你连“好不好”都不知道遑论“怎么更好”。最后分享一个小技巧无论你用什么框架永远在系统里给“拒答”留一条明确的路。让模型“不知道时直说”比让它“强行回答”更能保护你的业务信誉。这是我从这个项目里学到的最重要的一课。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进