☰
AI工程实战指南:RAG与微调全链路落地避坑
2026/10/13 3:14:14 网站建设 项目流程

简介:《AI工程实战指南》面向希望将生成式AI集成到产品中的工程师与技术决策者,系统讲解基于基础模型构建AI应用的全流程。内容覆盖提示工程、检索增强生成(RAG)、代理系统、模型微调与数据工程等核心技术,并围绕延迟、成本与幻觉等生产落地难题给出应对思路,帮助读者完成从原型到生产的转化。资源包为1个PDF文件,约64.7MB,内容完整、便于通读与检索。作者Chip Huyen结合真实案例与行业最佳实践,搭建了从模型选择、数据集处理、评估基准测试到高效部署的实用框架,并深入探讨模型泛化能力、数据质量与多样性、性能评估等关键问题。目前已有2513人学习,适合具备一定开发基础、希望系统掌握AI工程方法论的读者参考。

1. 从一份工程笔记说起:AI 应用落地到底卡在哪

很多团队做 AI 应用,卡住的地方往往不是模型本身,而是从原型到上线之间那条看不见的沟。拿 RAG 来说,本地跑通一个问答 Demo 可能只要半天,但真要让它稳定回答业务问题,检索召回、切片策略、重排序、上下文拼装,每一步都能翻车。微调也一样,数据清洗、格式对齐、显存估算、推理部署,哪个环节没考虑到,最后效果就是上不去。这份《AI工程实战指南》把 RAG 和微调两条线拆成了可复现的工程步骤,不是调包教程,而是从数据准备到服务化部署的完整链路。适合已经能跑通基础 Demo、但卡在效果调优和工程化落地的开发者,也适合需要给团队搭建 AI 能力底座的工程师。

2. RAG 链路拆解:从文档切片到重排序的工程化实现

2.1 为什么朴素 RAG 效果总是不稳定

朴素 RAG 的典型流程是:文档切块、向量化、存入向量库、检索 Top-K、拼进 Prompt 让模型回答。看起来逻辑闭环,但实际用起来经常出现「检索到了但答不对」或者「答案在文档里但没检索到」的情况。根因通常不在模型,而在检索质量。切片粒度太粗,一个块里混了好几个主题,向量表示就被稀释了;切片太细,上下文断裂,模型拿到碎片拼不出完整答案。另一个常见问题是只用向量检索,忽略了关键词匹配。向量检索擅长语义相似,但对专有名词、编号、代码符号这类精确匹配场景反而容易漏。常见做法是混合检索,向量加 BM25 各取所长,再用重排序模型做精排。这套组合拳在多数业务场景下比单走向量检索的召回率高出一截。

2.2 文档切片与向量化:参数怎么设才不翻车

切片策略直接决定检索上限。我一般会按文档类型分两套参数:结构化文档按标题层级切,每个叶子节点作为一个块,块大小控制在 300 到 500 token;非结构化长文本用滑动窗口,窗口 512 token,重叠 64 token。重叠是为了防止关键信息刚好被切在边界上。向量化模型选型上,中文场景优先考虑对中文语义优化过的模型,维度 768 或 1024 都够用,维度越高存储和检索成本越大,不是越高越好。

from langchain.text_splitter import RecursiveCharacterTextSplitter # 结构化文档:按标题层级切,保留层级信息 def split_by_headers(doc, headers): # headers 是文档中提取的标题列表 splits = [] for i, header in enumerate(headers): start = doc.find(header) end = doc.find(headers[i+1]) if i+1 < len(headers) else len(doc) chunk = doc[start:end].strip() if chunk: splits.append({"text": chunk, "header": header}) return splits # 非结构化文本:滑动窗口 + 重叠 splitter = RecursiveCharacterTextSplitter( chunk_size=512, # 块大小,按 token 估算 chunk_overlap=64, # 重叠 token 数,防止边界信息丢失 separators=["\n\n", "\n", "。", "!", "?", ";", ",", " "] ) chunks = splitter.split_text(long_text)

chunk_size设太小,单块信息量不足,模型拿不到足够上下文;设太大,向量表示被稀释,检索精度下降。chunk_overlap一般取 chunk_size 的 10% 到 15%,太小起不到边界保护作用,太大则冗余存储和检索开销都上去了。separators的顺序很重要,优先按段落切,再按句子切,最后才按标点切,这样能尽量保证语义完整性。

2.3 混合检索与重排序:把召回率拉上来

混合检索的核心思路是向量检索和关键词检索各跑一遍,然后融合排序。向量检索用余弦相似度,关键词检索用 BM25,两路结果用 RRF(Reciprocal Rank Fusion)融合。RRF 的好处是不需要调权重,直接按排名倒数求和,对两路检索的分数尺度不敏感。

from rank_bm25 import BM25Okapi import numpy as np # 关键词检索:BM25 tokenized_corpus = [doc.split() for doc in corpus] bm25 = BM25Okapi(tokenized_corpus) bm25_scores = bm25.get_scores(query.split()) # 向量检索:假设已有 embedding 和向量库 vector_scores = vector_store.search(query_embedding, top_k=20) # RRF 融合 def rrf_fusion(vector_results, bm25_results, k=60): scores = {} for rank, doc_id in enumerate(vector_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) for rank, doc_id in enumerate(bm25_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True) fused = rrf_fusion(vector_ids, bm25_top_ids)

k是 RRF 的平滑参数,通常取 60,调大调小对结果影响不大,不用太纠结。融合后的 Top-N 再送重排序模型精排。重排序模型选交叉编码器架构的,它对 query 和 doc 做联合编码,精度比双塔高,但推理慢,所以只用在精排阶段,候选集控制在 20 到 50 条。重排序之后取 Top-3 到 Top-5 拼进 Prompt,太多反而引入噪声。

2.4 上下文拼装与 Prompt 模板

检索回来的文档块怎么拼进 Prompt,也有讲究。我一般按「系统指令 + 检索上下文 + 用户问题」的结构组织,检索上下文里每个块前面加个来源标记,方便模型引用,也方便排查问题。块与块之间用分隔符隔开,避免模型把不同块的内容混在一起。

PROMPT_TEMPLATE = """你是一个严谨的问答助手。请根据以下参考资料回答问题。 如果参考资料中没有相关信息,直接说「根据现有资料无法回答」,不要编造。 参考资料: {context} 用户问题:{question} """ def build_context(retrieved_chunks): parts = [] for i, chunk in enumerate(retrieved_chunks): parts.append(f"[资料{i+1}] {chunk['text']}") return "\n\n".join(parts) context = build_context(top_chunks) prompt = PROMPT_TEMPLATE.format(context=context, question=user_query)

系统指令里明确「无法回答就说无法回答」很关键,能显著降低幻觉率。来源标记除了方便引用,还能在排查时快速定位是哪个块出了问题。如果业务对引用格式有要求,可以在指令里指定引用方式,比如「引用时标注资料编号」。

3. 微调实战:数据准备、训练配置与效果验证

3.1 什么场景该微调,什么场景不该

微调不是万能药。如果问题是模型不知道某个领域的知识,优先考虑 RAG,把知识外挂进去,更新灵活、成本低。微调适合的是:模型需要学会某种输出格式、某种推理风格、或者某个特定任务的模式。比如让模型按固定 JSON 结构输出、按特定话术回复、或者学会某个垂直领域的术语体系。另一个判断标准是数据量:几百条高质量样本就能让模型学会格式和风格,但要让模型掌握新知识,通常需要几千条以上,而且效果未必比 RAG 好。我一般建议先跑 RAG,RAG 解决不了再考虑微调,两者也可以叠加使用。

3.2 指令数据构造:格式对齐与质量过滤

微调数据的格式取决于基座模型。常见做法是构造成指令-输入-输出三元组,或者对话格式。数据质量比数量重要得多,一条脏数据可能带偏整个训练方向。我一般会做几轮过滤:去掉重复样本、去掉输出过短或过长的、去掉输入输出明显不匹配的、去掉包含敏感信息的。

import json import hashlib def build_instruction_data(raw_samples): seen = set() cleaned = [] for sample in raw_samples: # 去重:按输入内容哈希 key = hashlib.md5(sample["input"].encode()).hexdigest() if key in seen: continue seen.add(key) # 长度过滤 if len(sample["output"]) < 10 or len(sample["output"]) > 2048: continue # 格式对齐 cleaned.append({ "instruction": sample.get("instruction", "请回答以下问题"), "input": sample["input"].strip(), "output": sample["output"].strip() }) return cleaned # 转成对话格式(以常见对话模板为例) def to_chat_format(samples): formatted = [] for s in samples: text = f"### 指令:\n{s['instruction']}\n\n### 输入:\n{s['input']}\n\n### 输出:\n{s['output']}" formatted.append({"text": text}) return formatted

去重按输入哈希是最简单有效的方式,能去掉大量重复样本。长度过滤的阈值根据任务调整,分类任务输出可以很短,生成任务输出通常较长。格式对齐要严格按基座模型的要求来,格式不对齐会导致训练时 loss 不下降或者下降很慢,这是血泪经验。

3.3 LoRA 微调配置:显存估算与关键参数

全量微调对显存要求高,多数场景用 LoRA 就够了。LoRA 只训练低秩矩阵,参数量降到原来的百分之一甚至千分之一,单卡 24G 显存就能跑 7B 模型的微调。关键参数有这几个:lora_rank控制低秩矩阵的秩,常用 8 或 16,任务越复杂可以适当调大;lora_alpha是缩放因子,一般设为 rank 的两倍;target_modules指定对哪些层加 LoRA,通常 attention 的 q、v 层是必加的,加上 k、o 和 FFN 层效果更好但参数量增加。

from peft import LoraConfig, get_peft_model lora_config = LoraConfig( r=16, # 低秩矩阵的秩 lora_alpha=32, # 缩放因子,通常为 r 的 2 倍 target_modules=["q_proj", "v_proj", "k_proj", "o_proj"], lora_dropout=0.05, # 防止过拟合 bias="none", task_type="CAUSAL_LM" ) model = get_peft_model(base_model, lora_config) model.print_trainable_parameters() # 输出示例:trainable params: 4,194,304 || all params: 6,742,609,920 || trainable%: 0.06

lora_dropout在数据量少的时候可以调大到 0.1,数据量充足时 0.05 够用。target_modules如果只加 q、v,可训练参数大概占总参数的 0.06%,加到 q、k、v、o 大概 0.12%,效果提升明显但训练时间也增加。显存估算的粗略公式是:模型参数量乘以 2 字节(fp16)加上优化器状态和梯度,7B 模型全量微调大概需要 80G 以上显存,LoRA 微调 24G 足够。

3.4 训练过程监控与效果验证

训练过程中要盯住 loss 曲线。正常情况 loss 应该稳步下降然后趋于平缓,如果 loss 震荡剧烈,可能是学习率太大;如果 loss 下降很慢,可能是学习率太小或者数据格式有问题;如果 loss 降到很低但验证集效果差,那是过拟合了,需要加数据或者加正则。验证集要单独留出来,不能和训练集混在一起。效果验证不能只看 loss,要实际跑一批测试问题,人工评估输出质量。我一般会准备 50 到 100 条覆盖各种场景的测试用例,微调前后各跑一遍对比。

# 训练参数配置示例 training_args = { "per_device_train_batch_size": 4, "gradient_accumulation_steps": 4, # 等效 batch size = 16 "learning_rate": 2e-4, # LoRA 常用 1e-4 到 3e-4 "num_train_epochs": 3, # 通常 2 到 5 轮 "logging_steps": 10, "save_strategy": "epoch", "evaluation_strategy": "epoch", "fp16": True, # 混合精度训练省显存 "warmup_ratio": 0.03, # 预热比例 "lr_scheduler_type": "cosine" # 余弦退火 }

learning_rate是微调最敏感的参数,LoRA 微调常用 1e-4 到 3e-4,比全量微调大一个量级。num_train_epochs不是越多越好,3 轮左右通常够用,多了容易过拟合。gradient_accumulation_steps用来在显存有限的情况下增大等效 batch size,但要注意和 learning rate 配合调整。

4. 避坑与排查:那些让效果崩掉的细节

4.1 检索到了但答不对

现象是检索结果里明明有正确答案,但模型输出就是不对。原因通常是上下文拼装时把不相关的块排在了前面,模型注意力被带偏了。解决方法是加重排序,把最相关的块排到最前面,或者在 Prompt 里明确要求「优先参考靠前的资料」。另一个原因是块太大,关键信息淹没在无关内容里,需要调小 chunk_size 重新索引。

4.2 微调后模型变「傻」了

现象是微调后模型在目标任务上表现好了,但在其他任务上能力明显下降。这是灾难性遗忘,LoRA 虽然比全量微调轻,但学习率太大或者训练轮数太多也会出现。解决办法是降低学习率、减少训练轮数,或者在训练数据里混入一部分通用指令数据,让模型保持原有能力。我一般会混 10% 到 20% 的通用数据进去。

4.3 向量库检索速度越来越慢

现象是随着文档量增加,检索延迟从几十毫秒涨到几秒。原因通常是用了暴力检索没有建索引,或者索引类型选错了。数据量在十万级以下可以用 IVF 索引,百万级以上考虑 HNSW。另外要定期清理无效向量,删除的文档对应的向量没清掉会拖慢检索。向量维度也不是越高越好,768 维和 1024 维在多数场景下效果差异不大,但存储和检索开销差不少。

4.4 训练 loss 不下降

现象是训练跑了好几个 epoch,loss 几乎不动。最常见的原因是数据格式和基座模型的模板不匹配,模型根本「看不懂」输入。检查方法是拿一条训练数据手动套模板,看格式对不对。另一个原因是学习率太小,LoRA 微调如果用了全量微调的学习率(比如 1e-5),loss 下降会非常慢。还有可能是 target_modules 设错了,LoRA 加到了不该加的层上。

4.5 推理时显存溢出

现象是训练能跑,推理时反而 OOM。原因是推理时 batch size 或者 max_length 设太大了,或者没有用 KV Cache。解决办法是调小推理 batch size,限制 max_new_tokens,开启 KV Cache。如果用了量化推理,注意量化方式和模型架构的兼容性,不是所有模型都支持所有量化方案。

5. 进阶技巧:用评估集驱动迭代

RAG 和微调都不是一锤子买卖,上线只是开始。我习惯在项目初期就建一个评估集,覆盖典型问题、边界问题、对抗问题三类。典型问题占 60%,边界问题占 30%,对抗问题占 10%。每次调整检索参数或者重新微调,都跑一遍评估集,看指标变化。指标不用太复杂,检索阶段看召回率和 MRR,生成阶段看人工评分或者用模型打分。关键是每次只改一个变量,改完跑评估,确认有效再改下一个。这样迭代虽然慢,但每一步都可追溯,不会出现「改了一堆参数不知道哪个起作用」的情况。

评估集的建设也有讲究。问题要来自真实用户 query,不能自己拍脑袋编。我一般会从线上日志里采样,去掉重复和无效的,再人工标注标准答案。评估集不用很大,100 到 200 条就能反映趋势,但一定要定期更新,业务变化了评估集也要跟着变。从那以后我每次上线新版本前,都强制走一遍评估集,确认没有回退才发布。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询