ARTICLE DETAIL

资讯详情

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

用Qwen3微调Embedding模型,把RAG检索准确率拉满

用Qwen3微调Embedding模型,把RAG检索准确率拉满 做RAG项目最让人头疼的一个场景用户问了一个问题知识库存了相关资料大模型也接上了但回答出来的内容总是差那么点意思。排查了一圈发现不是模型的问题不是提示词的问题而是检索阶段就把关键文档排到了后面——Embedding根本没有理解你这个领域的语言习惯。这其实是RAG链路里最典型的沉默瓶颈召回不准后面接什么模型都是白搭。这篇实战记录就是围绕一个方向展开的用Qwen3微调一个领域专属的Embedding模型把检索准确率拉上来让下游大模型拿到真正有用的上下文。内容覆盖了微调原理、数据生产、训练脚本、效果对比、部署替换几个完整环节适合正在做RAG落地、被检索效果折磨、想系统了解Embedding微调这一支路的同学参考。不管你是刚入门还是已经跑通了基础链路这套方法都能直接抄作业。1. RAG检索不准的根因通用Embedding的分布盲区1.1 检索环节的沉默瓶颈很多团队做RAG第一版原型跑通之后大部分精力都花在调prompt、换模型、改chunk大小上。但如果你把问题链路拆开看就会发现一个残酷的事实**RAG的回答质量上限在检索那一刻就已经被锁死了。**大模型再强也只能基于检索回来的top-K段文档进行推理。如果关键的答案文档根本没进top-K那再聪明的LLM也无能为力。检索不准的典型症状大家都见过用户问服务器内存告警怎么处理知识库里明明有对应的运维手册结果召回的前几名是产品介绍、版本更新日志这类不相关文档。用关键词看似乎有点关系但语义层面完全不是一回事。这种问题靠调整prompt和chunk size都解决不了因为它的根子出在Embedding的语义表示上。通用Embedding模型的训练数据以网页、百科、社区问答为主它的语义空间是大众化的。一旦你的知识库是某个垂直领域——比如医疗、法律、工业运维、企业内部文档——专业术语和表达方式就跟通用语料差出很大的分布偏移。你问某某设备的冷却液泄漏模型可能把重点理解成液体或泄漏这个通用概念而领域里的真正语义是设备型号冷却系统故障。这就是分布盲区。1.2 为什么选Qwen3这一条路线解决检索不准业界大致有三条路换更强的通用Embedding模型、在RAG链路里加reranker、微调领域Embedding。前两条是工程方案见效快但天花板明显。第三条路前期投入大但能从根上解决问题。选Qwen3作为底座理由比较实际中文语义理解足够强。Qwen3系列模型在中文语料上的表示能力本身就排在国产开源模型前列用作Embedding底座基础分不差。有完整的开源权重和微调支持。Qwen3开放了不同规模的checkpoint从0.6B到更大参数都有配合LoRA这类高效微调方案消费级显卡就能跑起来。Decoder架构做Embedding已经成为主流方向。现在很多中文Embedding模型都转向了大模型底座因为decoder模型在长文本语义建模和指令理解上比传统的BERT式encoder更有优势。当然如果你手里的数据不是中文或者领域特征特别强换个底座也不影响整体方案。核心方法论是一样的用你所在领域的数据去矫正通用模型在这个领域里的语义表示。1.3 微调Embedding和微调LLM是两回事这点必须分清。很多做过LLM微调的人一上来就用训练Chat模型的思路去微调Embedding结果往往不理想。微调LLM目标函数是下一个token的预测微调Embedding目标函数是语义向量之间的相对距离关系。前者学到的是怎么说话后者学到的是什么跟什么是同一件事。所以两者的数据格式、训练目标、评测方式完全不同。做Embedding微调你需要的不是问题-标准答案这样的问答对而是三元组一个查询(query)、一段相关的正例文档(positive)、若干段不相关的负例文档(negative)。训练目标就是让query的向量跟正例的向量靠近跟负例的向量拉远。这个逻辑后面细讲。一句话总结**Embedding微调改的不是模型的表达能力而是它的语义判别边界。**这也是这类微调往往只需要少量数据、少数训练轮次就能见效的原因。2. 环境准备与训练数据生产2.1 硬件与依赖清单先交代我这次实测跑的硬件环境方便大家对照。我用了一张单卡24GB显存的显卡选择Qwen3-0.6B作为底座LoRA秩设为16batch size 16序列长度128显存峰值大约11GB。如果你的显卡只有12GB把batch size降到8、序列长度降到96也能跑起来。如果显存更紧张可以用梯度累积来模拟更大的batch。注意梯度累积能解决batch size不够大的显存问题但累积步数太多会拖慢训练速度。建议优先调低序列长度因为Embedding场景下的序列长度其实没那么敏感后面详说。依赖库方面我用了这些核心包pip install torch transformers datasets peft accelerate pip install sentence-transformers faiss-cputorch和transformers是骨架peft负责LoRA注入sentence-transformers主要用于后续评测时的向量化编码faiss用来建索引算召回指标。训练本身不需要sentence-transformers但评测阶段没有它很不方便。2.2 把知识库变成(Query, 正例, 负例)数据生产是整个微调流程里最费人工的环节也是决定效果上限的环节。模型结构、训练参数都是术数据质量才是道。我跟团队踩过来的经验是600条高质量三元组效果往往比2000条粗制滥造的强得多。数据格式上我最终用的是JSONL每一行一个样本{query: 服务器内存告警怎么处理, positive: 第一章节内存故障排查。当服务器出现内存告警先登录BMC查看事件日志定位故障DIMM位置。, negative: 服务器安装配置手册。本章介绍上架、接线和系统安装步骤。}生产路径主要有三条**第一条问题生成法。**把知识库的文档按chunk切好用通用大模型比如Qwen2.5、DeepSeek这些针对每个chunk生成若干个用户可能问的问题。生成的问法和chunk原文形成(question, document)正例对。这个方法效率高适合冷启动但要注意生成的问题不要太泛要贴近真实用户的提问习惯。**第二条日志挖掘法。**如果产品已经上线过一版RAG用户的真实query就是最好的数据源。把用户query和用户实际点击/评价过的文档捞出来人工打标之后作为正例。这比模型生成的问题要真实得多强烈建议从这一步做起。**第三条规则辅助构造法。**对于一些结构化知识库可以用模板规则批量生成query。比如故障库里有设备型号故障现象处理方案可以组合出大量近似真实的query。生产数据时有一个基本前提正例文档必须和query确实相关。宁可少要一条也不要拿弱相关的chunk凑数否则模型学到的判别边界会变得模糊。2.3 负样本的质量决定上限负样本的选择是Embedding微调里面最讲究的环节也是最容易被新手忽略的环节。先说结论随机负样本是底线困难负样本才是拉开效果差距的关键。随机负样本就是从知识库里随机抽一段跟query无关的文档。这类负样本容易构造但它只能让模型学会区分相关和不相关。问题在于RAG线上真正让你丢分的往往不是完全无关的文档而是那些看起来相关、实际不相关的干扰项。比如query是手机无法开机负例是手机电池不耐用两者都在讨论手机故障但答案完全不同。模型如果分不清这种边界线上召回照样会翻车。构造困难负样本的方法也不复杂。我常用的做法是先用未微调的通用Embedding把知识库里所有chunk的向量建好索引。对每个query用这个基础模型做一次检索把排在top-10到top-50位置、但人工判定为不相关的那些chunk捞出来作为这个query的困难负样本。这些样本是通用模型信誓旦旦认为相关的恰好是我们要矫正的盲区。经验提示困难负样本数量不用多每条query配1-2条就够了。配太多会让训练变得过于苛刻模型容易震荡收敛不稳定。切chunk这件事我要多说一句。Embedding微调阶段和线上RAG阶段的chunk策略最好保持一致。如果你线上用的是500字重叠50字训练数据里的正负样本也最好按这个规格切。模型在微调时见过的chunk形态会影响它线上对真实chunk的编码效果。这一步的一致性很多教程没提但实测下来对线上效果有不可忽视的影响。3. 用LoRA微调Qwen3 Embedding的核心机制3.1 冻结底座、只改投影LoRA为什么够用全量微调一个Embedding模型理论上效果上限更高但对显存和数据的消耗都很大。对于Qwen3-0.6B这种规模的底座全量微调虽然勉强可行但如果你用的是更大参数量的Qwen3模型全量微调的门槛就高很多了。LoRA的思路是冻结原模型的全部参数在Attention层的权重矩阵旁边加一个低秩的旁路矩阵。训练的时候只更新这个旁路矩阵推理时再把旁路合并回去。这样做的好处有两个一是训练参数量大幅减少0.6B模型用LoRA实际可训练参数只有几百万到上千万级别小数据集也不容易过拟合二是微调后的模型可以独立保存为一个很小的LoRA权重文件切换不同领域的Embedding时互不影响。我用的是peft库的LoRA配置挂在Qwen3的attention投影层上from peft import LoraConfig, get_peft_model lora_config LoraConfig( r16, lora_alpha32, target_modules[q_proj, k_proj, v_proj, o_proj], lora_dropout0.05, biasnone, task_typeFEATURE_EXTRACTION, ) peft_model get_peft_model(base_model, lora_config)target_modules为什么选这几个因为匹配类任务主要依赖模型内部的注意力交互而q_proj、k_proj、v_proj正是决定token之间如何交互的投影矩阵。让这些投影具备领域感知能力就等于让整个模型的注意力分配方式向领域偏移。补充说明task_typeFEATURE_EXTRACTION这个参数在最新版peft里被要求明确指定有些教程写的是CAUSAL_LM那是做文本生成任务时的配置做Embedding微调时不要照搬。3.2 匹配式训练的Loss设计Embedding微调和文本生成微调最大的区别在训练目标。生成任务用交叉熵Embedding匹配任务最常用的是对比学习损失核心逻辑是让相关样本对的距离更近、不相关样本对的距离更远。我这次用的是InfoNCE的变体。它的思想是给定一个query训练时把它和1个正例、N个负例组成一个集合让模型在集合中正确找出正例的概率最大化。这里的N由当前batch size决定——同一个batch里的其他样本天然可以作为负例这就是in-batch negatives的机制。import torch import torch.nn.functional as F def info_nce_loss(query_emb, positive_emb, temperature0.05): # query_emb: [batch_size, hidden_dim] # positive_emb: [batch_size, hidden_dim] sim_pos F.cosine_similarity(query_emb, positive_emb, dim-1) # [batch] # 用batch内所有正例作为负例来源 sim_matrix F.cosine_similarity(query_emb.unsqueeze(1), positive_emb.unsqueeze(0), dim-1) # [batch, batch] sim_matrix.fill_diagonal_(-1e9) # 排除自己 logits torch.cat([sim_pos.unsqueeze(1), sim_matrix], dim1) / temperature labels torch.zeros(logits.size(0), dtypetorch.long).to(logits.device) loss F.cross_entropy(logits, labels) return loss上面代码里那个对角线填充很关键。因为同一个正例向量本身也在负例矩阵里如果不把自己排除掉模型会学到一个捷径直接把query和一模一样的向量匹配上根本没有学到语义判别。Temperature参数也不能忽视。它控制着相似度分布的尖锐程度。temperature越小相似度差异越容易被放大训练时对难负样本的惩罚就越强。实测下来0.05左右是一个比较稳的值。太大比如1.0会让loss对所有样本一视同仁难负样本学不动太小比如0.01则容易让训练不稳定loss波动大。3.3 关键训练参数与选择理由下面是我这次实测用的训练参数每一条的选择理由都讲清楚参数名数值选择理由LoRA rank1616已经能提供足够的适配容量。再大比如64在这个数据规模下收益不明显反而增加显存占用和过拟合风险LoRA alpha32保持alpha为rank的2倍是经验值影响LoRA放缩比例learning rate2e-5Embedding微调的学习率应低于生成模型微调。向量空间的语义变化需要平滑演进lr太大会把预训练学到的通用语义破坏掉batch size16显存允许范围内的最大值。对比学习依赖batch内负样本数量batch越大负样本越丰富max_seq_length128知识库chunk大多在这个长度内。长文本编码会增大显存占用但匹配增益有限epochs3Embedding微调收敛很快3轮基本到位第4轮开始出现过拟合迹象warmup_ratio0.110%的步数用于学习率预热稳定训练初期weight_decay0.01防止LoRA旁路参数过度膨胀学习率这个点我要再强调一下。很多从LLM微调转过来的人习惯用5e-5甚至1e-4但Embedding微调对学习率更敏感。你可以把预训练的向量空间想象成一张已经画好了大部分内容的地图微调只是修正其中几条路的走向。步子太大容易把整条路都抹掉重画那其他没被训练覆盖的领域的语义就全乱套了。保守一点用2e-5起步观察loss下降情况再决定是否调整。4. 训练脚本详解与评测对比4.1 完整可运行的训练脚本老规矩先给能直接跑的训练代码框架。这里省去了日志部分核心训练循环保留。import json import torch from torch.utils.data import Dataset, DataLoader from transformers import AutoModel, AutoTokenizer from peft import LoraConfig, get_peft_model, TaskType from torch.optim import AdamW class EmbeddingDataset(Dataset): def __init__(self, path, tokenizer, max_len128): self.samples [] with open(path, r, encodingutf-8) as f: for line in f: item json.loads(line) if positive_x in item: item[positive] item.pop(positive_x) self.samples.append(item) self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): item self.samples[idx] q self.tokenizer( item[query], max_lengthself.max_len, truncationTrue, paddingmax_length, return_tensorspt ) p self.tokenizer( item[positive], max_lengthself.max_len, truncationTrue, paddingmax_length, return_tensorspt ) return { query_input_ids: q[input_ids].squeeze(0), query_attention_mask: q[attention_mask].squeeze(0), pos_input_ids: p[input_ids].squeeze(0), pos_attention_mask: p[attention_mask].squeeze(0), } def get_embeddings(model, input_ids, attention_mask): # Qwen3这类decoder模型用last_token_pooling提取句向量 outputs model(input_idsinput_ids, attention_maskattention_mask).last_hidden_state last_token_idx attention_mask.sum(dim1) - 1 last_hidden outputs[torch.arange(outputs.size(0)), last_token_idx] return torch.nn.functional.normalize(last_hidden, p2, dim-1) def collate_fn(batch): result {} for key in batch[0].keys(): result[key] torch.stack([item[key] for item in batch]) return result model_path Qwen/Qwen3-0.6B tokenizer AutoTokenizer.from_pretrained(model_path) base_model AutoModel.from_pretrained(model_path, trust_remote_codeTrue) lora_config LoraConfig( r16, lora_alpha32, target_modules[q_proj, k_proj, v_proj, o_proj], lora_dropout0.05, biasnone, task_typeTaskType.FEATURE_EXTRACTION, ) model get_peft_model(base_model, lora_config) dataset EmbeddingDataset(train_data.jsonl, tokenizer) dataloader DataLoader(dataset, batch_size16, shuffleTrue, collate_fncollate_fn) optimizer AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr2e-5) # 训练循环 model.train() for epoch in range(3): total_loss 0 for step, batch in enumerate(dataloader): # 边界微调检查按需保留 if positive_x in batch or step -1: pass query_emb get_embeddings(model, batch[query_input_ids], batch[query_attention_mask]) pos_emb get_embeddings(model, batch[pos_input_ids], batch[pos_attention_mask]) loss info_nce_loss(query_emb, pos_emb, temperature0.05) loss.backward() optimizer.step() optimizer.zero_grad() total_loss loss.item() print(fepoch {epoch} avg_loss: {total_loss / len(dataloader)}) model.save_pretrained(qwen3-embedding-lora)代码里有几个细节说明一下last_token_pooling这一步是Qwen3这类decoder-only模型做Embedding时的常用做法。取每个样本最后一个有效token也就是最后一个非padding位置的hidden state作为整句的向量表示。实测下来比mean pooling稳定因为decoder模型在生成时对最后位置的语义聚合最好。如果你是第一次接触这个记住last token这个经验值就行。数据兼容处理我在Dataset里加了positive_x的兼容转换你可以根据自己数据的字段名调整。这里本身无关紧要如果你手工构造数据时统一用positive反而更清晰。梯度裁剪建议加实际跑下来虽然没到loss爆掉的程度但加上max_grad_norm1.0更稳尤其当数据里有异常格式的时候。4.2 评测指标怎么设计微调完成之后不能只看loss降没降必须用RAG场景最关心的指标来验收。我自己常用三个指标它们分别回答不同的问题RecallK在知识库的所有chunk里正确答案排在前K个位置的比率。这个指标直接反映RAG检索的问题——top-K能不能捞到关键文档。K一般取5或10。MRR正确答案在排序列表里排位的倒数平均值。它比Recall更严苛因为它要求答案尽量排在第一位。MRR高代表检索结果的前排大多有效。语义相似度命中把query和正确答案的余弦相似度和query和错误答案的余弦相似度做个差。这个指标用来观察模型是否真正拉近了同一个事的语义距离。下面这是我常用的一套离线评测脚本片段基于faiss批量建索引和检索import numpy as np from tqdm import tqdm from sklearn.metrics import ndcg_score def evaluate_recall(model, tokenizer, queries, all_docs, doc_ids, max_len128): # 对文档建索引 doc_embs [] for doc_text in tqdm(all_docs): inputs tokenizer(doc_text, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt) with torch.no_grad(): doc_emb get_embeddings(model, inputs[input_ids], inputs[attention_mask]) doc_embs.append(doc_emb.detach().numpy().reshape(1, -1)) doc_embs np.vstack(doc_embs) index faiss.IndexFlatIP(doc_embs.shape[1]) index.add(doc_embs) hit_1, hit_5, hit_10 0, 0, 0 mrr_sum, ndcg_sum 0, 0 total 0 for query, gold_doc_id in zip(queries, doc_ids): inputs tokenizer(query, max_lengthmax_len, truncationTrue, paddingmax_length, return_tensorspt) with torch.no_grad(): query_emb get_embeddings(model, inputs[input_ids], inputs[attention_mask]) scores, inds index.search(query_emb.detach().numpy().reshape(1, -1), 10) rank list(inds[0]) if gold_doc_id in rank[:1]: hit_1 1 if gold_doc_id in rank[:5]: hit_5 1 if gold_doc_id in rank[:10]: hit_10 1 if gold_doc_id in rank: mrr_sum 1.0 / (rank.index(gold_doc_id) 1) else: mrr_sum 0 # NDCG计算 labels [1 if i gold_doc_id else 0 for i in rank] ndcg_sum ndcg_score([labels], [scores[0].tolist()[:len(labels)]]) total 1 return { Recall1: hit_1 / total, Recall5: hit_5 / total, Recall10: hit_10 / total, MRR: mrr_sum / total, NDCG10: ndcg_sum / total, }注意评测时评测集要跟训练集严格隔离。不能拿训练时出现过的query和文档样本去测否则指标虚高会让你误判模型线上表现。正确的做法是单独留出20%的数据不做任何训练只在评测阶段曝光。4.3 微调前后的检索效果对比下面是我在某个运维知识库场景下用700条三元组微调Qwen3-0.6B的实测结果。数据量不算大训练时间大约40分钟评测集是独立留出的120条query。模型Recall1Recall5MRRNDCG10通用开源Embedding模型基线38.2%62.4%0.410.57Qwen3-0.6B微调前36.5%60.1%0.390.55Qwen3-0.6B LoRA微调后55.8%78.9%0.630.74三行数据说明三件事第一通用Embedding模型和微调前的Qwen3-0.6B表现接近说明Qwen3做Embedding即使不训练也具备和开源Embedding模型扳手腕的基础能力。第二微调后的Recall5从60%左右涨到78.9%MRR从0.41涨到0.63。这个提升幅度在检索场景里属于质变级别的优化——top-5的命中率大幅提升意味着线上RAG有更多机会把真正的答案文档送到大模型面前。第三Recall1从38%涨到55.8%说明模型不仅把正确答案捞回来了还把它排到了第一位。对RAG来说这个指标更值钱因为大模型实际使用的上下文往往只取前几名甚至第一名。重要提醒上表数字是我在特定领域小数据上的结果不代表所有场景都能复现相同幅度的提升。领域复杂度、数据质量、评测集构造都会直接影响数值。但方向是确定的——微调后的Embedding在领域检索上一定会优于通用模型只是幅度有差异。5. 把新Embedding部署回RAG链路5.1 独立服务化是最稳的方式训练完成的Embedding模型只是半成品真正落地是在RAG链路里换掉之前的向量化组件。我这里强烈建议用独立服务化部署而不是在业务代码里直接加载模型文件。Why两个原因。第一模型加载和推理需要独占显存如果和主应用进程耦合一个模块出问题会拖垮整个服务。第二Embedding模型的更新频率和业务代码不同独立服务可以在不重启业务的情况下灰度切换版本。我用的部署方式是把模型封装成一个轻量级的HTTP接口每次调用传入文本数组返回向量数组。具体实现可以用FastAPI写一个简单的服务加载Qwen3 LoRA权重推理时走AutoModel加载然后池化得到向量。from fastapi import FastAPI from pydantic import BaseModel from typing import List app FastAPI() class TextRequest(BaseModel): texts: List[str] class EmbedResponse(BaseModel): embeddings: List[List[float]] app.post(/embed, response_modelEmbedResponse) def embed(request: TextRequest): result [] for text in request.texts: emb encode_single(text) result.append(emb.tolist()) return EmbedResponse(embeddingsresult)你本地测试的时候把所有query和文档都正常封装成JSON请求就能跑通。5.2 向量库重建是不可跳过的步骤这是我在项目里踩过最深的坑之一。很多人把Embedding模型替换完之后忘了重建向量索引结果线上召回率不升反降甚至出现检索崩溃。原理很简单不同的Embedding模型生成的向量处于不同的语义空间向量维度也可能不同。老的索引是基于旧模型向量建立的新模型的向量放进老索引里鸡同鸭讲相似度计算毫无意义。因此换Embedding模型后必须对知识库做全量向量重建流程如下用新模型对知识库全量chunk重新编码得到新的向量集合删除旧向量索引用新向量重建向量库重新验证几个核心query的召回结果确认无误后再切换线上的检索链路。重建向量库适合在低峰期操作并且要保留旧索引作为回滚方案。如果发现新模型在某些query上效果异常可以快速切回旧索引排查问题。5.3 线上效果的验收清单模型上线之后至少要用以下清单做一轮验证简单query的召回顺序拿几个核心知识点去测确认正确答案排在前三同义表达的召回同一个问题换几种说法比如内存告警怎么处理和内存故障怎么排查观察召回结果是否稳定困难干扰项的召回专门构造一些看起来相关、其实不相关的query确认模型没有被带偏响应延迟对比替换前后Embedding接口的p99延迟确认没有明显劣化。响应延迟这块多说一句。Qwen3-0.6B的推理速度相比传统小Embedding模型要慢一些因为底座的参数量摆在那里。如果线上有高并发需求可以用vLLM这类推理加速框架来做Embedding服务或者把模型导出成ONNX格式做CPU推理优化。小流量的内部工具直接裸加载模型也够用。6. 这套方案踩过的坑与注意事项6.1 过拟合发生在第2轮之后我一开始在500条小数据集上跑了5轮训练loss一路下降当时还挺高兴。结果评测集上的Recall5在第3轮就开始回落。这是典型的过拟合——模型在训练数据上记住了那些具体的文档片段而不是真正学到了什么样的语义算相关。后来把轮数压到3轮评测指标反而更好。如果你的训练数据更少比如只有300条建议只跑2轮甚至可以观察每轮结束后的评测指标来决定是否提前停掉。经验分享如果你觉察到过拟合迹象优先调低epoch而不是调低LoRA rank。epoch是全局因素影响所有样本LoRA rank减少之后模型容量可能会不够用导致欠拟合排查更麻烦。6.2 负样本太难或太易都会翻车纯随机负样本太容易了模型能轻松区分学不到精细的语义边界全部上困难负样本模型又会被迫区分一些实际上在真实场景中极少出现的模糊样本造成矫枉过正。拿我的运维场景举例有一类负样本是内存故障排查和内存故障导致的系统异常报告。两者高度相关人工标注都很纠结。如果这类样本太多模型就会过度敏感把所有跟故障沾边的chunk全部拉远反而误伤了一些可能有用的上下文。我的建议是**负样本配比上6成随机负样本、4成困难负样本。**这样模型既学到了基本的语义边界又不会在模糊地带过度反应。6.3 向量维度变了但不报错才是最危险的换Embedding模型之后如果新模型跟旧模型的向量维度恰好一样很多模型都是1024维或768维那么向量库的schema不会报错接口调用也正常。但这里的语义空间已经变了旧索引上的相似度计算毫无意义。我的教训是上线新模型之前一定要在向量库里检查一下索引信息明确记录当前索引对应的模型版本和模型路径。不要相信维度一样就不会出错的直觉。6.4 训练数据与线上分布的偏差最后这个坑比较隐蔽。我们当时从线上日志里挖了一些真实query做训练数据但忽略了这些query集中在少数热门问题上覆盖的文档范围很窄。结果微调后的模型在热门问题上效果惊艳冷门问题反而还退步了。后来我们对训练数据的文档覆盖范围做了统计确保正例文档覆盖了知识库的大部分章节。如果某些板块完全没有训练样本微调模型在该板块的表现就是不可控的。数据集的覆盖度比总量更值得关注。还有一点如果你用的是大模型生成的问题做训练数据建议再拿一部分真实线上query做评测。生成数据往往偏书面化跟用户实际提问的口语表达有差距。评测线上真实query才可以看出模型在真实场景下的表现。我之前做RAG项目时一直觉得检索效果是命中率天然有上限的直到有一次连续调了两周的各种RAG参数都没有起色才真正下定决心去做Embedding微调。老实说训练本身不难难的是前期的数据生产和数据评测体系搭建。但这一环补上之后后面所有RAG参数的调整都变得有方向了——因为底座已经把你领域的语义规律学进去了。如果你目前的RAG项目也卡在召回的东西看得懂但总不对这个阶段不妨试一下这条路。一次数据整理的投入换来的是整个检索链路的质变而且模型可以持续迭代越用越准。
返回列表