ARTICLE DETAIL

资讯详情

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

用LoRA微调查询改写器提升RAG召回率:从原理到实践

用LoRA微调查询改写器提升RAG召回率:从原理到实践 我正在做的这个查询改写器项目起因其实特别朴素RAG知识库里明明有答案但用户换了个口语化的问法检索就召不回内容了。一开始我试过直接用Prompt调大模型做改写效果时好时坏延迟和成本也不可控后来决定用LoRA微调一个专门的查询改写器把这件事彻底做成推理时只增加几十毫秒的固定流程。整个过程做下来踩了不少坑也把LoRA微调从原理到落地完整过了一遍这里把完整的思路、数据构造、训练配置和翻车经验一起整理出来。如果你正在做RAG、搜索相关的东西或者想拿LoRA练手这篇可以直接照着走。1. 查询改写器解决什么问题为什么这事值得自己微调1.1 一个让我决定自己写改写器的检索翻车现场事情是这样的我手上有一套企业内部的文档问答系统文档已经被切成小块做了向量化底模检索用的是Embedding模型粗排精排也调过一轮。用户在页面上问上个月的报销政策还生效吗结果检索回来的Top-5片段里完全没有提到报销政策这几个字的相关文档最后模型只能瞎编。我看了日志才发现文档库里对应章节的标题叫财务费用管理办法修订版正文里反复出现的关键词是费用核销票据规范而用户口中的报销在这份文档里一次都没出现——Embedding模型虽然能理解语义但面对这种说法差异很大的口语化query向量距离还是被拉得太远召回直接崩了。这就是典型的查询改写Query Rewriting要解决的问题在检索之前先把用户的原始输入变成一组更适合检索的query让召回命中率上去而不是指望检索模型自己去处理各种口语变体。传统做法里有人做同义词词典、做规则改写、做伪相关反馈但泛化能力都很有限尤其面对长尾问法基本无能为力。于是我尝试直接用Prompt让大模型做改写。我在系统Prompt里写请把用户问题改写成三个适合搜索引擎的查询效果确实可用但问题也很明显每一次改写都要走一次大模型推理7B模型的单次响应延迟大概在1到2秒QPS一上来根本扛不住而且同一个问题在temperature稍微调高一点之后改写结果的稳定性很差有时候三个改写结果里有俩是重复的有时候JSON格式突然就崩了还得额外写解析纠错逻辑。更麻烦的是通用模型的改写偏向日常对话理解它不理解我后面的检索链路需要什么样的query形式。我要的不是把话说得更清楚而是让我的Embedding模型更容易命中这两者之间差着一个检索系统自己才懂的方言。1.2 查询改写器的工作边界与三条改写策略在动手做之前我先把查询改写器在系统里的边界画清楚了它接收一条用户输入输出多条改写后的检索query然后这些query统一去走向量检索把召回结果合并去重再交给后续的RAG流程。它不负责生成答案不负责排序只负责把人说的话翻译成检索系统更容易理解的话。具体来说我让改写器只做三件事。第一是语义拆解把包含多个意图的长query拆成多个语义单一的短query比如怎么申请年假和报销拆成申请年假流程和费用报销流程两条第二是同义扩展把口语词、简称、模糊指代扩展成文档库里更常见的规范表达比如报销扩展出费用核销财务报销费用报销公司电脑扩展出办公设备IT资产第三是关键词化把疑问句、陈述句压缩成关键词组合方便适配关键词检索和稀疏向量召回比如我想知道服务器一直重启是什么原因改写成服务器 频繁重启 原因 排查。有了这三条明确的改写策略我才发现这事非常适合训练一个专用模型改写行为是高度套路化的不需要通用模型那么强的指令跟随能力也不需要多深的推理能力但它需要极其稳定地遵守输出格式、极其了解我的检索词表习惯这正好是LoRA微调的舒适区——把一个小模型往特定的输出风格和格式上掰。通用大模型强在全能而查询改写器需要的是钻牛角尖式的稳定两者需求完全不一样。2. LoRA微调原理速通极小参数量把模型拽向新技能2.1 LoRA到底在改什么低秩矩阵的直觉解释微调一个模型本质上是希望它把概率分布往符合我要求的方向移动。全参数微调是所有权重都更新7B模型光bf16的权重就得占14个GB显存再加上梯度、优化器状态单卡根本放不下就算放得下训练一次的时间和成本也离谱。LoRA的做法就非常讨巧它不更新原始权重W而是在W旁边并联两串小矩阵A和B让前向计算从原来的h Wx变成h Wx BAx训练时只更新A和B原始权重全程冻结。这里的核心假设是模型从通用能力到特定任务之间的偏移量其实只需要很小的参数承载。类比一下的话通用预训练模型已经是一个博学多才的成年人你不需要重新教他语言和逻辑只需要在他耳边念一段时间我们这个领域的习惯用语LoRA训练出来的增量参数相当于一本随身携带的行业短语手册用的时候翻开就行成本远低于重新培养一个人。那为什么用低秩矩阵而不是直接加个全连接层因为要控制参数量。一个7B模型的权重矩阵动辄几千乘几千维假设某个权重矩阵是4096×4096用低秩分解成A(4096×r)和B(r×4096)当r16时参数量从约1677万变成约13万只有原来的0.8%左右。这个秩r就决定了表达能力的大小r越大能承载的偏移量就越多但也更容易过拟合。2.2 rank、alpha、target modules怎么定该模块里三件套事rank秩、alpha缩放系数、target modules要适配的模块怎么确定。我的经验是先拿rank16起步alpha32观察训练集loss和验证集loss的gap。如果验证集loss持续走高而训练集loss还在降就是过拟合信号把rank往下调如果两个loss都降不下去说明表达能力不够再往上调rank。查询改写这个任务本身模式不复杂我最终用的rank8效果最好rank64那版反而严重过拟合改写结果出现了大量复读原文的情况。alpha的作用是对低秩矩阵的贡献做缩放。PyTorch实现里实际加到前向计算上的增量是(alpha / r) * BAx所以alpha通常设为r的2倍。这个比例如果太大训练初期模型输出就会被扰动得很厉害容易出现灾难性遗忘——模型连正常的言语连贯性都丢了太小则新技能学不进去。还有一个常见误区是alpha必须比r大其实不一定更关键的是alpha/r的比值要稳定我大部分实验都是用alpharank*2这个经验值。target modules的选择决定了LoRA挂在哪些权重矩阵上。注意力层的q_proj、k_proj、v_proj、o_proj是大多数LoRA实验默认挂的位置因为注意力矩阵对语义理解影响最大。但查询改写本质是一个生成风格/输出格式改写任务前馈网络gate_proj、up_proj、down_proj对输出词表和表达习惯的影响更直接。我对比过只挂attention和全挂的两种配置全挂时格式稳定性明显更好JSON解析失败率从7%降到1.5%左右。所以我现在的默认配置是七个模块一起挂代价是训练参数大约多了一倍但7B模型整体仍然只训练了0.2%的参数完全可控。3. 训练前的三件套基座模型、数据集与工具链3.1 基座选型为什么我选Qwen2.5-7B-Instruct域内基座模型选择上我重点看了几个候选ChatGLM系列、Baichuan系列、Qwen系列、LLaMA系列。由于要做中文查询改写LLaMA系的中文能力需要额外补放弃ChatGLM和Baichuan都能用但综合对比下来Qwen2.5系列中文指令跟随能力最稳尤其是在需要严格按格式输出的时候翻车概率比同类模型低不少。我这边的显存是单卡24GB7B模型做LoRA训练刚好宽裕推理时权重约占14GB显存加上Activation和缓存还能留出足够的concurrent推理空间。如果你显存只有16GB可以考虑Qwen2.5-3B或者Qwen2.5-1.5B查询改写这个任务本身难度不大小模型配合好的数据集也能达到不错的水平但复杂语义拆解能力会明显弱一些。我的建议是如果你只需要同义扩展和关键词化3B模型完全够用如果要处理长query多意图拆解老老实实上7B省下来的调试时间比显卡差价值钱得多。3.2 构造训练数据从原始query到多路改写数据集是这次项目里回报率最高的一环也是最花时间的一环。我总共收集了大概12000条原始query来自几个渠道一是线上日志里真实用户的失败query样本这部分最宝贵因为全是真实长尾表达二是从文档库里反向构造拿每个文档的标题和小标题反推用户可能会怎么问再人工写出对应的检索友好query三是用通用大模型批量生成改写候选再做人工抽检和修正。每条样本的格式是一个输入对应多个输出输出之间没有顺序强依赖。举个例子输入 合同快到期了要续签应该找哪个部门输出合同续签流程合同到期续签 申请部门合同续签 需要什么材料这里就同时用到了语义拆解原query里隐含找谁办和怎么办两层意思和关键词化把口语压缩成检索词组合。数据质量上踩过的坑是数量焦虑一开始我总想搞到十万条看起来很多的数据结果发现大部分都是重复或质量很差的反而让模型学了一堆噪声。后来我把训练轮数减少、把数据清洗提到最高优先级12000条干净数据的效果远比40000条口水数据好。清洗时主要做三件事过滤掉和业务域无关的闲聊query去重时必须按语义去重而不是字符串去重人工修正一批改写得没那么好的样本因为模型是学分布的错误样本多了它会把错误当默认行为。3.3 用LLaMA-Factory组织数据与标记格式训练框架我选了LLaMA-Factory主要是看中它对Qwen系和LoRA训练的支持最省心一条命令就能从数据准备走到模型输出中间各种SFT阶段的坑大部分都被框架提前处理了。LLaMA-Factory支持的数据格式有alpaca和sharegpt两种查询改写属于单轮任务我用alpaca格式就够了。具体地在数据文件query_rewrite.json里每条样本的结构是[ { instruction: 根据用户的原始查询生成多条适合检索系统使用的改写查询。每条改写查询另起一行不要编号不要重复。, input: 合同快到期了要续签应该找哪个部门, output: 合同续签流程\n合同到期续签 申请部门\n合同续签 需要什么材料 } ]这里有个非常关键的设计输出格式我故意没有用JSON数组而是用每行一条的纯文本格式。原因后面会详细讲简单来说就是生成模型在生成JSON时非常容易因为引号、括号、转义符出错而换行分隔这个格式几乎不会错。框架里不需要额外做预处理只要在dataset_info.json里注册一下数据集名字就能直接在训练配置里引用了。4. 跑通一次LoRA微调配置、命令与训练过程复盘4.1 LLaMA-Factory环境准备环境准备阶段我从零开始部署过一次也踩了不少坑。首先是依赖版本要配对LLaMA-Factory当前版本对transformers和torch的版本有要求直接照着官方README的requirements装大概率没问题但千万别为了其他项目把transformers升到最新版然后又跑回来我试过一次因为transformers版本不兼容导致模型加载阶段直接崩掉排查了半天。安装命令git clone https://github.com/hiyouga/LLaMA-Factory.git cd LLaMA-Factory pip install -e .[torch].[torch]这个extra会把torch、transformers、peft、datasets等核心依赖一起装上。训练前我先用一个很小的数据集几百条样本跑一遍确认整个链路能通再切到全量数据这样能快速区分环境问题和模型问题。4.2 训练配置详解进阶版yaml我用的是LLaMA-Factory的yaml配置文件方式比在命令行里传一堆参数要清晰得多。整个配置长这样model_name_or_path: Qwen/Qwen2.5-7B-Instruct template: qwen stage: sft finetuning_type: lora dataset: query_rewrite cutoff_len: 2048 preprocessing_num_workers: 16 lora_rank: 8 lora_alpha: 16 lora_dropout: 0.05 lora_target: all output_dir: outputs/qwen25-7b-query-rewrite-lora logging_steps: 10 save_steps: 500 plot_loss: true overwrite_cache: true per_device_train_batch_size: 4 gradient_accumulation_steps: 8 learning_rate: 2.0e-4 num_train_epochs: 3.0 lr_scheduler_type: cosine warmup_ratio: 0.1 bf16: true这里的参数大多数是按经验设置的。lora_rank: 8和lora_alpha: 16对应前面说的2倍关系lora_target: all是LLaMA-Factory里的快捷写法表示所有可适配的Linear层都挂LoRA省得一个一个列模块名per_device_train_batch_size: 4配合gradient_accumulation_steps: 8等效batch size是32这个数值在SFT阶段比较稳既不会太大导致收敛慢也不会太小导致梯度噪声爆炸。有个细节cutoff_len: 2048对查询改写任务已经完全够用因为我们样本的输入输出都很短设太长反而浪费计算。如果样本平均长度只有一两百个token可以干脆设成512训练速度能再快一截。learning_rate: 2.0e-4是LoRA微调比较常见的起点我试过5e-4模型loss降得很快但验证集效果明显变差典型的学习率过大。4.3 训练过程中的观察点启动训练llamafactory-cli train configs/qwen7b_query_rewrite_lora.yaml训练开始后我一般盯三个地方。第一个是loss曲线SFT的loss通常不会像预训练那么低因为任务是生成式的loss数值降到0.8到1.0附近基本就说明模型学到模式了不用追求loss无限低那基本意味着开始背训练集了。第二个是trainable params这一行日志一个7B模型LoRA训练只应该显示一两千万可训练参数如果这个数字异常大去检查是不是误开了全参数微调。第三个是定期保存的中间checkpoint。训练耗时方面单卡24GB跑7B模型12000条数据3个epoch总共大概需要2到3小时。如果这个时间严重超出预期优先检查batch size和gradient_accumulation_steps是不是配得太大了。训练完成后模型不会自动合并到原始权重里而是保存了一个包含LoRA增量矩阵的adapter目录。我在推理阶段最常用的做法是直接用peft加载adapter做验证等效果确认没问题了再合并一次权重导出成最终推理模型llamafactory-cli export \ --model_name_or_path Qwen/Qwen2.5-7B-Instruct \ --adapter_name_or_path outputs/qwen25-7b-query-rewrite-lora \ --template qwen \ --finetuning_type lora \ --export_dir outputs/qwen25-7b-query-rewrite-lora-merged \ --export_size 4 \ --export_legacy_format false合并这一步的实际意义在于如果不合并推理时每条请求都要先加载底座模型再动态把LoRA的增量矩阵叠加进去框架算下来大概会增加20%-30%的推理耗时合并之后的模型就是普通权重部署和量化都方便得多。5. 微调效果评测与六个容易踩的坑5.1 评测指标与实测效果训练效果好不好的判断标准不能只看训练loss降没降对于查询改写器来说最终标准是改写后的query在真实检索链路里能不能带回更多的相关文档。我搭了一套评测集大概1000条query每个query都人工标注了相关文档ID。离线指标主要看两个一个是RecallK原始query直接检索的Recall10基线大概是0.61用微调后的改写器改写再检索合并去重后Recall10能到0.75提升幅度约14个百分点另一个指标是有效改写率我统计了改写结果里出现完全没有意义/输出无效/格式崩坏的比例训练前用通用Prompt大模型改写时这个比例大概在5%左右微调后降到0.5%以下。更有意思的是我还顺手做了个对比把同一个query分别丢给直接用Prompt调用Qwen2.5-7B-Instruct和LoRA微调后的Qwen2.5-7B-Instruct去改写结果微调模型的输出明显更懂检索——它会更主动地输出文档库里出现过的规范术语而通用模型虽然语义通顺但经常给出一堆怎么查报销这种口语化表达对检索帮助有限。这说明LoRA微调确实把模型拽向了一个具体的输出域而不只是换了一层指令模板。5.2 实战中踩过的坑最后把这次项目里踩过、也替你们踩过的坑集中列一下每一个都是花过时间换来的第一个坑是输出格式用JSON导致训练崩溃。第一版数据集我让模型输出JSON数组结果训练时loss死活降不到合理范围推理时也经常输出不合法JSON。后来把格式改成每行一条之后问题几乎立刻消失。原因在于模型的语言建模损失是逐token累积的JSON里那些引号、花括号、逗号占了大量token却对检索语义毫无贡献等于让模型在一堆格式噪声里学语义自然学不好。这个经验对任何做信息抽取、结构化输出微调的人都适用输出格式越简单越好。第二个坑是rank开太大导致过拟合。有一版我把rank设成64训练集loss降到0.3看起来非常好但评测下来RecallK反而比rank8的版本低了三个多点。检查改写结果发现模型把训练数据里的文档专有名词背下来了遇到新query时喜欢把那些词往外吐属于典型的应试记忆。rank8反而因为表达容量有限逼着模型去学更抽象的改写规律。第三个坑是学习率过大导致灾难性遗忘。有一版learning_rate设了5e-4训练到后面发现模型连根据用户的原始查询生成多条改写这句话里的正常中文输出都开始错乱了说明原始权重已经被破坏得太严重。LoRA虽然只更新增量矩阵但过大的学习率会让增量矩阵对整个输出的影响过大相当于在原模型上盖了一层厚厚的滤镜。第四个坑是评测指标用错了对象。一开始我用BERTScore和ROUGE之类的文本相似度指标来评改写好坏结果指标和检索效果相关性很差——有些改写结果文本上跟标准答案很像但召回的文档反而是错的。后来我才想明白查询改写器的目标不是生成得像标注答案而是帮我从文档库里捞回正确的东西所以必须用下游检索链路的RecallK来做核心指标文本相似度只能做辅助参考。第五个坑是推理时temperature没调。有段时间我发现线上改写结果忽好忽坏查了半天发现是推理服务的temperature设成了0.9生成结果随机性太大。查询改写不是创意写作temperature设成0.1到0.3就够了甚至可以直接设为0做贪心解码。我最后统一改成0.2线上稳定性立刻上来了。第六个坑是adapter合并后忘掉验证模板。合并模型之后直接拿普通chat模板去调用结果发现输出格式完全不对。原因是训练数据里我在instruction里写了输出不要编号不要重复这个约束只在训练分布里存在如果不把这个约束放到推理的system prompt里模型在自由生成时容易放飞自我。换句话说微调模型的输入模板必须和训练时一致差一点都会导致输出漂移。整个项目从发现问题到最终上线大概用了三周时间其中一半时间在搞数据四分之一在调参真正改代码的时间反而不多。做下来最大的感受是LoRA微调的入门门槛其实比大多数人想象的低难的从来不是训练本身而是清楚地定义你希望模型学会什么行为以及用什么指标判断它学会了——这两件事想清楚了训练反而是一项水到渠成的工作。
返回列表