做RAG应用最折磨人的,不是检索精度调不上去,也不是生成效果偶尔抽风,而是用户真的什么都敢问。做了几年RAG项目,我遇到过问天气的、算数学题的、闲聊吐槽的、甚至问“你觉得我老板是不是讨厌我”的,全都怼进向量检索里硬检索一遍。最后结果就是:检索模块不堪重负,生成模块一本正经地胡说八道,用户满意度直线下降。
后来我才想明白一个道理:RAG系统的天花板,往往不在检索和生成,而在入口。你需要在所有流程之前,加一个能快速判断“用户到底想干什么”的前置路由——给每个进来的问题先打个标签,分个类,然后只把该走检索的问题送进检索链路,该走闲聊的走闲聊,该走计算的走计算。这个前置路由,就是标题里说的意图分类。
这篇内容,我把我自己在生产环境里做RAG意图分类微调的完整经验捋了一遍,从标签设计、训练集构造、LoRA微调实操,到部署集成和灰度兜底,一条线讲清楚。读完你大概率能直接在自己的项目里复刻一版,不用再去翻几十篇教程拼拼凑凑。
1. 先想清楚:为什么非要给RAG加一个前置路由
1.1 没有路由的RAG,所有问题都被强行检索
先看一个典型RAG架构的原始状态:用户输入一个query,系统直接走 embedding → 向量库召回 → 重排序 → 拼接上下文 → 大模型生成。这套流程在“纯知识问答”场景下没问题,但一旦用户输入偏离了文档库覆盖范围,问题就来了。
比如知识库是关于公司内部制度的,用户问“食堂几点开门”,检索器也会老老实实地去做相似度匹配,召回一批根本不相关的制度条款,然后让大模型强行编一个答案出来。更麻烦的是无意图输入,比如“你好”“谢谢”“你们在吗”,这些输入本身不包含知识检索需求,但RAG链路照样支付了一次向量检索和一次LLM生成的算力成本,耗时几百毫秒,结果用户根本不需要这些。
这不是模型能力问题,是架构设计问题。你没有在入口做业务分流,所有流量就是一锅端。真实生产场景里,一套RAG服务面对的查询类型远比你知识库覆盖的要多,你控制不了用户怎么提问,但你可以控制系统如何处理提问。
1.2 意图分类微调和前置路由的关系
前置路由的核心载体就是意图分类模型。所谓意图分类,本质上是给用户query打一个预设的语义标签,比如“知识问答”、“闲聊”、“计算”、“多轮跟进对话”等。而微调,则是拿一个已经在通用语料上预训练好的底座模型,用你自己业务场景里标注好的数据,再训练一小段,让这个模型更懂你的领域语境。
为什么不直接用大模型做分类?可以,但建议你先算算成本和稳定性的账。每次路由都让大模型做一次生成式分类,延迟高、成本高、结果飘忽不定,同一个问题换个问法标签就变了。而微调一个小的分类模型,稳定、快速、可控,跑在CPU上都能爽快出结果。
这个路由一旦建好,表面上只是“加了一个分类器”,实际上它重构了系统的决策流:每个query先过路由,根据分类标签,决定走完整检索链路、走轻量闲聊链路,还是直接拒绝并提示澄清。每个子链路各司其职,系统的整体鲁棒性一下子就上来了。
1.3 前置路由带来的实际收益
说几个我自己验证过的收益点:
- 回答质量的稳定性明显提升。无关query不再被强行送进检索链路,问答链路看到的全是有效问题,生成幻觉比例下降。
- 系统成本结构更可控。闲聊和寒暄不再消耗完整RAG链路算力,分类路由的低成本模型承担了大部分无效流量过滤。
- 可观测性变好。日志里能看到“今天用户问了什么问题、分到了哪些意图、各意图的占比”,业务侧可以根据这个做运营层面的调整。
- 子任务可以并行扩展。先分类再处理,意味着后续你加新的能力(比如翻译、摘要、表格问答)时,不需要重构整套RAG,只需要在路由表里加一个分支。
2. 三种路由方案对比:为什么最终选了微调分类模型
2.1 方案一:Prompt让大模型直接分类
这个方案最简单:把query和一个分类说明一起丢给通用大模型,让它输出一个标签。
我试过这个方案,效果在demo阶段确实能用,但上了业务场景就会暴露三个问题。第一是延迟不稳定,分类这个动作本身很简单,但大模型生成式输出的耗时普遍在几百毫秒甚至更久,作为路由层太笨重。第二是成本,每个query都调一次大模型接口做分类,虽然单价不高,但乘以全天QPS就是一笔明显的开支。第三是标签漂移,同一个意图的query,模型有时候自己发挥加个解释、有时候换个标签措辞,导致下游路由逻辑要反复兼容这些异常情况。
这个方案适合什么场景呢?项目初期、查询量极少、不追求精细化路由控制的时候,我个人是不会直接放弃通用LLM分类的,但一旦流量上来或者你开始追求一致性和成本优化,它就该退居到“兜底”或“离线分析”的位置。
2.2 方案二:Embedding余弦相似度匹配
把用户query向量化,和每个意图的模板向量做相似度比对,取最高分作为意图。
这个方案最吸引人的地方是无需标注数据,你只要给每个意图写几组代表性的query,算好向量存起来就行。实际用起来会发现,它对“表面形近但语义不同”的query非常不敏感。举个例子,“帮我查一下应收账款”和“应收账款是什么意思”在向量空间里距离非常近,但前者是需要走业务系统查询的命令型意图,后者是知识问答意图,纯靠向量距离很难稳定区分。
另外,意图本身是高度抽象的业务概念,不是一个固定的语义点。同一个“查余额”意图,用户可以有一万种表达方式,模板写得再多也覆盖不全。这套方案更适合做冷启动阶段的临时路由,不适合做精细化的意图识别主方案。
2.3 方案三:微调一个专属分类模型(最终选择)
微调的本质,是让模型“学会”你业务场景里对意图的划分方式和语义边界。不是靠模板覆盖,而是通过模型参数学到“什么样的表达属于哪个意图”的潜在规律。
我选择微调方案的核心原因有三条。一是模型足够小,部署和推理成本都低。分类不像生成,不需要7B以上的大模型,参数更少的小模型也能做出很稳定的分类效果。二是可控性极强。标签体系完全自定义,几个意图、边界在哪、要不要有“其他”这个兜底类,全部由你决定。三是效果上限高。只要数据质量过关,微调模型的分类稳定性明显优于方案一和二,尤其在意图边界比较细微的场景下,差距非常明显。
3. 标签体系设计:先想清楚要分几类,再动手
3.1 意图分类的粒度不是越细越好
很多人一上手就建了十几个意图标签,感觉越细越智能。实际做下来你会发现,标签粒度要和下游系统能处理的动作挂钩——如果系统只支持“知识问答”和“闲聊”两条链路,那你分15个意图,最终映射下来还是2个动作,中间多出来那13个标签只会让你的训练集更难凑、错误更难排查。
以我个人的经验,起步阶段先从3到5个标签做起来最稳妥。比如:
表格如下:
| 标签名 | 适用场景 | 路由动作 |
|---|---|---|
| 知识问答 | 用户询问文档库内的具体内容 | 进完整RAG检索链路 |
| 闲聊寒暄 | 问候、情绪表达、无信息量对话 | 走轻量对话模型,不进检索 |
| 计算/推理 | 需要数值运算或逻辑推导 | 调用计算或代码解释器,不进检索 |
| 多轮跟进 | 指代上文内容,“那这个怎么处理” | 进多轮上下文补全后再路由 |
| 其他/兜底 | 超出系统能力范围的问题 | 返回提示语或转人工 |
这样划分的好处是,每个意图都有清晰的动作映射。分类不是终点,路由才是,标签设计和下游动作绑定思考能避免做出花架子。
3.2 标签边界要写成判定规则
光有标签名不够,还要给每个标签写清楚“判定边界”,这是很多人会忽略的事。比如“多轮跟进”和“知识问答”的边界就很容易模糊:用户前一秒问“报销流程”,下一秒问“需要什么材料”,这句话单独看是知识问答,但结合上文就必须走多轮跟进,否则检索模块拿到的query是残缺的,自然召回不到准的内容。
我习惯在标签设计阶段就写一份简要的判定说明文档,每个标签下用一句判定条件和2到3个典型正例、1到2个反例。这份文档不仅用来给标注人员看,关键是逼你自己把边界想清楚。
3.3 兜底标签不能少
很多团队做意图分类时只设置了正向意图标签,没设计“其他/兜底”这个槽位。结果就是模型被迫把所有query都硬分到某一个已有标签上,很多低质量query会被误分到“知识问答”里,导致检索链路频繁捞到脏数据。
必须给模型一个“出口”,让它在输入与所有已知标签都不够匹配时,能输出一个低置信度的兜底标签。你可以在模型Softmax分数上设置阈值来处理这种情况,但前提是你训练数据里就得有这个兜底类样本,别指望模型能凭空学会“不知道”。
4. 训练集构造:最花时间的环节,没有捷径
4.1 先找开源数据集做冷启动
真实业务数据肯定是最理想的,但大多数项目冷启动阶段根本没有标注数据。这时候先不要干等,从开源数据里筛一批和你的场景相似的数据,先把模型跑起来,再迭代优化。
具体操作上,我会优先找那种自带意图标签的中文对话数据集。拿到之后不要直接全用,先抽样看一批样本,把明显不符合业务场景的过滤掉,标签体系也按照自己设计的标准重新映射一遍。开源数据的作用是让模型先“开窍”,知道意图分类这件事的基本规律,最终效果要靠后续真实数据来拉高。
冷启动目标不用定太高,5个标签、每个标签300到800条左右、整体准确率到85%到90%,就够了,就可以先上线内测版本,用小流量来收真实case,再进入下一轮迭代。
4.2 从真实日志里挖掘黄金样本
上线路由之后,真实用户query就是最好的训练素材。我一般是每周固定跑一次日志导出,把路由分类置信度低的样本集中捞出来,人工判断一遍真实意图,修正标签后回填到训练集里。这种“模型做不好的样本”就是最有价值的难例样本,有针对性的补齐能很快拉升模型弱点。
自动化挖掘的时候有个细节要注意:收集“高置信但实际错误”的样本非常难,因为日志里只记录了输出标签,没有记录真实标签。我自己的做法是把阈值设成两档,高置信直接当正样本用,中低置信每周人工复盘,保证训练集是持续可增长的。
4.3 合成数据是最被低估的补充手段
真实数据永远不够,合成数据可以快速补齐长尾表达。我用合成数据的通用玩法是写一批模板,配合实体词表批量生成自然表达。
比如“报销流程”这个意图,模板可以是“我想问一下<事项>的流程”、“<事项>应该怎么做”、“麻烦告诉我怎么处理<事项>”。然后把“报销、请假、采购”等词填进去,就能生成几十条训练句。
模板法生成的句子容易类型单一,模型表达泛化不够。进阶点做法是抽几轮历史对话,拿通用LLM按“改写为不同说法但保持原意”的方式扩写。再不行就用“角色模拟”的思路,让LLM扮演一个员工来向系统提问,也能生成不少贴近真实口吻的数据。
合成数据比例我一般控制在总训练集的30%以内,合成数据形态相对规整,真实数据里那些口语化噪声、省略、错别字才是模型稳定的关键,两边都别缺。
4.4 标注规范直接影响模型上限
标注质量决定模型上限,而标注质量的核心不在标注者而在规范清晰度。
每个标签的判断规则、边界澄清示例、疑难样本仲裁机制都有写进规范文档里。我还会对每条训练数据按语言规范性过滤一遍,比如把太长的query切短、把敏感词去掉、把专有名词保持一致,这些预处理直接影响模型收敛速度和最终效果。
训练集中各标签样本量差距不要太大,我一般控制在最大样本量和最小样本量不超过3倍差距,差距过大时模型会天然偏向高频标签,做预测时你就会看到几乎所有query都被分类到某一类。
5. LoRA微调实操:从基座选型到训练参数详解
5.1 基座模型选型思路
意图分类任务用不上太大的模型。我自己在同等条件下做过对比:7B级别模型和3B级别模型在简单意图分类场景(5个标签左右)上准确率差距很小,但推理延迟和显存开销差距显著。优先选中型底座模型,比如3B到7B范围、中文能力较好、指令跟随能力通顺的,就够了。
另外一个选型小经验:优先选有对话能力的底座模型,不要选纯基座版。意图分类在训练阶段虽然用的是序列标注的方式,但推理阶段拿到的输入可能会有口语化的对话上下文,带对话能力的底座模型在处理这些自然表达上明显更稳。
5.2 数据格式和Prompt模板设计
训练集的数据格式,我习惯做成这样:
{"instruction": "你是一个意图分类助手,请判断用户意图。", "input": "我想问一下报销流程", "output": "知识问答"}这里有个容易被忽略的点:训练样本里instruction要和推理时保持一致。很多人在训练时用一套prompt,部署推理时又换了写法,结果效果明显下滑。这不是模型玄学,是模型在微调时适应了训练格式里的上下文模式,你推理时改了格式,输入分布就变了。
如果你用的是LLaMA类或者其他需要固定模板的模型,要严格保持模板格式一致。如果你用的是对话模型,可以用User和Assistant的alternate格式来构造多轮对话样本,比如多轮跟进类型的数据就会带上历史对话轮次。
5.3 LoRA配置和训练参数参考
以3B到7B的模型为例,LoRA参数可以从下面这套配置起步:
- LoRA rank:16到32
- LoRA alpha:32到64
- 学习率:2e-4到5e-4(不要用和全参微调一样的1e-5级别,LoRA的学习率要大一些)
- 训练轮数(epoch):3到5轮,数据量较少时可增大到6轮
- 序列长度:256到512就足够,分类任务不需要长序列建模
- batch size:受显存限制,通常设为4到8,用梯度累积来保证等效batch size在32到64之间
训练时的注意事项:分类任务标签数不多时,可以把输出层的类别用固定文本的方式做生成式微调(也就是让模型“生成”标签词文本,而不是加一个分类头),这大概是这几年最常用的做法:简单、稳定、不依赖分类头实现细节。
训练完成后保存模型权重文件,用merge脚本把LoRA权重合并进底座模型。合并后的模型就是一个完整的独立模型,部署时不用再加载额外LoRA模块,省掉很多推理框架的适配问题。
5.4 训练代码主体框架参考
下面是核心训练代码,基于transformers的标准流程:
from transformers import AutoTokenizer, AutoModelForCausalLM, TrainingArguments from peft import LoraConfig, get_peft_model from datasets import load_dataset tokenizer = AutoTokenizer.from_pretrained("your_base_model") model = AutoModelForCausalLM.from_pretrained("your_base_model") lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=["q_proj", "v_proj", "k_proj", "o_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM" ) model = get_peft_model(model, lora_config) dataset = load_dataset("json", data_files="train.jsonl") def format_func(example): messages = [ {"role": "user", "content": example["input"]}, {"role": "assistant", "content": example["output"]} ] return tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=False ) train_data = dataset["train"].map(lambda x: {"text": format_func(x)}) training_args = TrainingArguments( output_dir="./intent_classifier_lora", per_device_train_batch_size=8, gradient_accumulation_steps=4, learning_rate=3e-4, num_train_epochs=3, logging_steps=20, save_steps=200, remove_unused_columns=False, fp16=True, ) trainer = Trainer( model=model, args=training_args, train_dataset=train_data, data_collator=lambda data: tokenizer( [item["text"] for item in data], padding=True, truncation=True, max_length=256 ), ) trainer.train()这套代码直接用transformers Trainer就能跑通,数据格式转成带instruction的文本即可。如果你的数据量只有一两千条,跑起来非常快,单卡训练在一两个小时内就能出结果。
6. 效果评估与路由置信度阈值设计
6.1 评估指标别只盯着准确率
意图分类的评估,我习惯同时看三个维度:整体准确率、每个类别的召回率、以及错误分类的分布情况。整体准确率高但某个低频类别召回率很低,这是常见陷阱,因为整体准确率会被高频类别“做得好”掩盖。
我每次训练完都会打印一次混淆矩阵。重点关注哪些标签对之间容易互相混。比如“计算/推理”和“知识问答”若频繁互混,说明训练集里这两类的语义边界样本不够,要给模型补充“列出资产负债表”这类微妙样本。
6.2 阈值的确定:不是模型训完就没你的事了
Softmax分数不是绝对的置信度,但依然有参考价值。我一般会用验证集算一下每个意图分类正确样本在不同置信度区间的分布,找出一个合理的判定分界线。
推荐做法是把路由决策分成三档:
![表格]
| 置信度区间 | 路由动作 |
|---|---|
| 高置信(通常0.9以上) | 直接按分类结果路由 |
| 中置信(0.7到0.9) | 走默认主链路(一般是知识问答),同时记录日志 |
| 低置信(0.7以下) | 落到兜底意图,不强制路由到任何特定链路 |
阈值建议不要做得太绝对,实际数据分布不同,最佳切分点也会不同。稳妥的办法是上线前用历史日志跑一遍仿真路由,看一下有多少流量落在高置信区间、有多少落在低置信区间,避免上线后发现大量query进了兜底分支导致系统看起来“变笨了”。
6.3 多意图query怎么处理
真实用户输入不会乖乖只表达一个意图。比如“帮我查一下报销流程然后算一下最近的支出”,既有知识问答又有计算。我建议的处理方式是设置意图优先级,让分类模型做top-k输出,路由逻辑根据优先级决策。比如计算类的优先级高于知识问答,遇到两种意图都被触发时,先走计算分支,计算完成后以结构化结果形式携带上下文回主链路。
优先级本身属于业务规则范畴,不要期望模型自己学会,路由层写死规则就好,也方便随时调整。
7. 部署集成:模型服务化与RAG主链路的对接模式
7.1 服务化部署方案
意图分类模型虽是微调出来的,本质还是Transformer结构,部署时直接按常规LLM推理服务来。两个方案我都用过:
- 如果你追求极低延迟(比如路由内耗时希望压在几十毫秒以内),用支持量化推理的引擎,把模型量化到INT8甚至INT4,分类任务对量化损失容忍度极高。
- 如果你希望部署栈统一,直接用同一套推理服务框架,也能接受几十到一百毫秒的耗时,毕竟路由分类的耗时占比在整个RAG链路里不算大头。
路由层建议做成独立微服务,和RAG主服务靠内部接口调用解耦。核心原因是训练迭代频率不同:意图分类模型可能一两周就更新一版,RAG各链路组件相对稳定,耦合在一起会导致发布流程互相牵制。
7.2 路由接入的完整调用编排逻辑
这是我现在的生产级路由接入逻辑,整理成伪代码供你参考:
def route_query(query, history=None): intent_label, confidence = intent_classifier.predict(query, history) if confidence < 0.7: return handle_fallback(query) if intent_label == "knowledge_qa": retrieval_result = rag_chain(query, history) return retrieval_result if intent_label == "chitchat": return light_chat_model(query) if intent_label == "computation": return calculator_tool(query) if intent_label == "multi_turn_followup": refined_query = resolve_coreference(query, history) return rag_chain(refined_query, history)这个路由不是直接把分类结果抛给各链路就完事了,我建议加三层辅助机制。
一是上下文补全层。多轮跟进query必须在路由前做指代消解,否则会导致脏query进入检索链路。这个阶段可以用非常短的LLM调用做,也可以靠模板规则在部分场景里解决。
二是历史信息注入。同样的query,如果上文已经在聊请假,那“怎么申请”和“流程是什么”这两个query的意图可能截然不同。分类模型如果只吃当前这一轮query,会丢失关键信息,所以我一般会在分类输入里带上最近两到三轮的历史摘要。
三是兜底动作。一旦低置信度或明确兜底意图出现,不要让用户觉得没有回应,要设计成引导澄清式话术,给用户几个选项或反问确认。这个体验设计对系统整体观感影响极大。
7.3 灰度发布和线上监测
上线前先做离线仿真,把过去几天的真实日志跑一遍路由分类,对比不同阈值下的分流量和准确率。上线时先给低比例流量做灰度,观察:
- 路由分布占比是否符合预期
- 高置信区间占比是否在合理水平
- RAG主链路的调用量是否显著下降
- 用户侧反馈或投诉是否上升
线上监测时关键看“低置信率”和“兜底意图率”这两个指标。兜底意图率突然飙升,往往不是分类模型变笨了,而是线上query分布漂移了,比如新上线了一批业务功能,用户开始问一些训练时没见过的新类型问题。
8. 常见问题排查与避坑技巧实录
8.1 训练有效果但线上表现崩坏
遇到过好几次模型在验证集上准确率很高,一上真实流量就暴跌的情况。最常见的原因是训练集与线上数据分布不一致:训练集里全是规范书面语,线上用户全是口语/错字/中英文混输。解决思路是每次迭代都从实时日志里采一部分最新的query做校验集,采样周期不要太长,一个月以上的旧数据对当前线上的参考价值就很弱了。
8.2 所有query都偏向某一个意图
初始排查方向从两方面走:训练集样本不均衡是最常见的原因,其次就是标签定义边界不清晰,导致大量样本在标注时就被归类到那个高频意图里了,比如“其他”这个兜底类被塞了大量本不属于它的样本。处理方式比较简单:重新审视训练集类别分布,按3倍差距上限收敛一下,同时检查标签判定文档是不是描述得太宽泛。
8.3 分类模型对敏感词或特殊符号处理异常
用户输入里偶尔会带一些特殊字符、标点、甚至表情符号,如果训练集过于“干净”,模型遇到这些字符很可能分类异常。建议在数据预处理阶段就做统一规范化:全半角转换、多余空格清理、URL和邮箱等结构体替换成占位符。这样能让模型把注意力集中在语义本身上,而不是被无意义的字符结构带偏。
8.4 LoRA微调后底座模型原有能力被“带偏”
微调是个双刃剑,训练数据风格太单一或者批次不均衡时,模型可能过度拟合到训练集风格,导致在通用场景下的表现下降。特别是在分类任务里,如果输出端固定使用“知识问答”、“计算”这些标签词,微调后的模型在闲聊场景里的那种流畅生成能力确实可能会退化一些。如果这个现象比较明显,可以在训练集里掺入10%到15%的通用对话数据作为缓冲。
表格如下:
| 常见问题 | 排查思路 | 解决办法 |
|---|---|---|
| 验证集高但线上低 | 训练集和线上分布不一致 | 实时日志采样做校验集,缩短采样周期 |
| 分类偏向单一标签 | 样本失衡或标签边界宽泛 | 平衡类别数量,收紧标签判定文档 |
| 特殊字符导致分类异常 | 预处理不充分 | 统一规范字符、替换占位符 |
| 微调后通用能力退化 | 训练数据过度单一 | 掺入10%到15%通用对话缓冲数据 |
| 路由结果不稳定 | 阈值切分不合理 | 根据验证集置信度分布重新切分 |
9. 最后分享一点个人体会
这套RAG意图分类微调方案,我在好几个项目里反复用过,最大的感受是:意图分类微调本身技术难度其实不大,难的是把标签边界想清楚、把训练集迭代做实、把路由决策逻辑设计得有兜底能力。
如果你正准备做这件事,我的建议是不管业务看起来多复杂,第一版先按3到5个标签做小闭环跑通。从冷启动数据集开始,训练一版模型,接进路由,上线观察,收日志,标注难例,再训练,这个迭代周期如果周末两天搭起来,后面就是纯增量优化了。
等你想往更细的标签方向扩(比如把知识问答拆成制度问答、报销问答、技术问答),前面这一整套流程可以直接复用,无非是数据标注量再补一批的事。
千万别一上来就追求大而全的标签体系和大而强的底座模型,跑通一次完整链路给你的经验,比反复打磨一个局部环节有效得多。