Administrator
发布于 2026-03-25 / 791 阅读
19

模型微调 vs RAG:成本与效果的量化对比

「我们的知识库问答准确率只有 71%,要不要做微调」

去年 11 月,业务方拿着一份评测报告来找我:客服问答准确率 71%,离可用的 85% 差得远。他们的结论是「做微调,把行业知识训进模型」。

我说先别急,让我把两条路都跑一遍再决定。这篇是三个月实测的结果,包括所有成本数字。结论先放:我们最后选了 RAG,但中间有 6 周走了弯路,而且微调不是完全没做——我们在一个很窄的场景上做了轻量微调。

先把成本结构摊开

我做的第一件事是把两条路线的成本项列全。很多人比较的时候只看「训练一次多少钱」,忽略了运维期的持续投入。

成本项微调路线RAG 路线
一次性投入数据清洗标注 + 训练 + 评测文档解析 + 索引构建 + 检索调优
每次知识更新重训或增量训练 + 重新评测重新索引增量文档(分钟级)
推理成本微调模型托管费(常驻)+ 推理费检索费 + 通用模型推理费(按量)
人力需要懂训练的人需要懂检索和 prompt 的人
回滚难度重新部署模型权重改检索配置,秒级
可解释性黑盒,无法溯源能给出引用来源

这张表里对我们杀伤力最大的是「每次知识更新」和「可解释性」两项。我们的产品文档每周更新 20~40 篇,政策文档每月变。如果走微调,等于每周重训一次。而可解释性——客服场景里,回答必须能给出依据,否则客服同事不敢用。

但表格不能替代数据,所以我两条都做了。

RAG 路线:从 71% 到 89% 的四轮优化

先说 RAG,因为它是我们最终选的。71% 这个基线是我们用最朴素的做法跑出来的:固定 512 字切分、topK=5、无 rerank、无混合检索。

四轮优化,每轮只改一件事,每轮都用 640 条标注用例评测。

第一轮:切分策略(71% → 77%)

固定长度切分的问题是把表格和步骤说明切断了。我们的文档里有大量「操作步骤」和「参数表格」,被切碎之后模型看到的是半截内容。

改成按语义结构切分:标题层级优先,表格整块保留,代码块不切。

public List<Document> split(MarkdownDoc doc) {
    List<Document> out = new ArrayList<>();

    for (Section s : doc.sections()) {
        // 表格整体作为一个 chunk,不切
        if (s.hasTable() && s.tokenCount() <= 1200) {
            out.add(chunk(s.fullText(), s.metadata()));
            continue;
        }
        // 步骤类内容按步骤分组保留
        if (s.isNumberedSteps()) {
            out.add(chunk(s.fullText(), s.metadata()));
            continue;
        }
        // 普通段落才按长度滑窗
        out.addAll(slideWindow(s.text(), 600, 80, s.metadata()));
    }
    return out;
}

另外给每个 chunk 加了「面包屑」前缀,把层级路径拼进去。这个改动贡献很大:

// 改前
"退款申请需要在订单详情页点击..."

// 改后
"帮助中心 > 交易管理 > 退款流程 > 申请退款\n退款申请需要在订单详情页点击..."

加了层级前缀之后,检索的准确率明显提升——因为「退款」这个词在很多章节都出现,有了路径就能区分开来。

第二轮:混合检索(77% → 83%)

纯向量检索对我们的场景不够。原因是产品文档里大量专有名词、型号、错误码,这些词向量检索处理得不好。

加了一路 BM25,做倒数排序融合(RRF):

public List<Scored> hybridSearch(String query, int topK) {
    List<Scored> vecResults  = vectorStore.search(query, topK * 2);
    List<Scored> bm25Results = bm25Index.search(query, topK * 2);

    // RRF: score = Σ 1/(k + rank_i),k 取 60
    Map<String, Double> fused = new HashMap<>();
    for (int i = 0; i < vecResults.size(); i++) {
        fused.merge(vecResults.get(i).id(), 1.0 / (60 + i + 1), Double::sum);
    }
    for (int i = 0; i < bm25Results.size(); i++) {
        fused.merge(bm25Results.get(i).id(), 1.0 / (60 + i + 1), Double::sum);
    }

    return fused.entrySet().stream()
        .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
        .limit(topK)
        .map(e -> Scored.of(e.getKey(), e.getValue()))
        .toList();
}

看具体的 case。用户问「ER-2043 是什么错误」:

检索方式召回结果
纯向量1. 错误码总览(相关但未列出 ER-2043)
2. 常见错误处理(泛泛而谈)
BM251. ER-2043 错误码说明(精确命中)
2. ER-2043 排查步骤
混合 RRF1. ER-2043 错误码说明
2. ER-2043 排查步骤
3. 错误码总览

错误码、型号、API 名称这类精确的短字符串,BM25 完胜向量检索。这两路是互补的,不是替代关系。

第三轮:Rerank(83% → 86%)

召回 topK 从 5 提到 20,然后用一个 rerank 模型重排,取前 5。

List<Document> candidates = hybridSearch(query, 20);
List<Scored> reranked = rerankModel.rerank(query, candidates);
List<Document> context = reranked.stream().limit(5).toList();

这一轮提升 3 个百分点,但成本不低:rerank 模型调用增加约 40ms 延迟,以及每次 0.0012 元的成本。我们评估下来划算,因为准确率提升减少的人工转接价值更高。

这里有个反直觉的发现:rerank 之后再截断到 5,比直接召回 5 更好,也比直接用 20 更好。我们用 20 个片段做上下文测过,准确率反而降到 81%——太多噪声片段干扰了模型。这符合「lost in the middle」的现象。

第四轮:查询改写(86% → 89%)

最后一轮是处理「指代」和「省略」。多轮对话里,用户第二句经常是「那退款呢?」这种,直接拿去检索什么都召不回。

@Service
public class QueryRewriter {

    public String rewrite(String current, List<Message> history) {
        if (history.isEmpty() || isSelfContained(current)) {
            return current;
        }
        return chatClient.prompt()
            .system("""
                把用户的当前问题改写成独立的、可检索的查询。
                规则:
                1. 补全省略的主语和宾语
                2. 把代词替换成具体名词
                3. 只输出改写后的查询,不要解释
                4. 如果当前问题已经是完整的,原样输出
                """)
            .user("""
                历史对话:
                %s

                当前问题:%s
                """.formatted(formatHistory(history), current))
            .options(ChatOptions.builder().temperature(0.0).build())
            .call()
            .content();
    }

    // 命中缓存的比例高达 63%,成本几乎可忽略
    @Cacheable("query-rewrite")
    public String rewriteCached(String current, String historyHash) { ... }
}

改写用最便宜的小模型(我们用的是 qwen-turbo),单次成本 0.00008 元,缓存命中率 63%。这轮提升 3 个百分点,是所有优化里性价比最高的。

RAG 路线的成本

把四轮优化的成本算清楚(按日均 2.6 万次问答算):

成本项单价日均月成本
Query 向量化¥0.00002/次26,000 次¥16
查询改写(小模型)¥0.00008/次,缓存后 ¥0.0000326,000 次¥23
Rerank¥0.0012/次26,000 次¥936
主模型(qwen-max)¥0.028/次(含 5 片段上下文)26,000 次¥21,840
向量库(托管型)¥1,200
索引构建(增量)¥85
合计¥24,100

单次对话成本约 0.031 元(这是优化后的数字,早期是 0.089)。一次性投入:4 轮优化 + 评测集标注,约 47 人日。

微调路线:我们实际做了什么

现在说微调。我们没有全量微调大模型,而是走了两条路都试了一下。

尝试一:全参数微调(放弃了)

一开始按业务方的要求,做的是真正的微调。数据准备:

  • 从历史客服会话里清洗出 4.2 万条问答对;
  • 人工标注筛选,最终保留 1.8 万条(很多历史回答本身质量不高);
  • 按 8:1:1 切训练/验证/测试。

用的是某云的 LoRA 微调服务(全参数微调的成本我们承受不了)。结果:

数值
训练数据18,000 条
训练时长6.5 小时
训练费用¥3,200
数据清洗标注人力3 人 × 11 天 = 33 人日
模型托管费(常驻实例)¥14,600/月
推理费¥0.019/次 → 月 ¥14,820
评测准确率82.4%

82.4% 的准确率,比 RAG 优化前的 71% 好,但比 RAG 优化后的 89% 差。而成本是 每月 29,420 元托管 + 推理,比 RAG 的 24,100 还贵。

更要命的是我们发现了几个致命问题:

  1. 知识时效性是硬伤。 训练数据截止到 2025 年 10 月,11 月更新的 40 篇文档它完全不知道。测试集里有一批「最近变更」的问题,准确率只有 31%;
  2. 编造能力变强了。 微调后模型在遇到不知道的问题时,编造答案的比例从 12% 升到 27%。因为它被训练成「必须给出客服风格的完整回答」,学会了不懂也硬答;
  3. 无法溯源。 客服同事不信任没有依据的回答,这个我们事先想到了,但实测下来影响比预估的大——客服使用意愿调研里,只有 34% 的人愿意直接用它的回答;
  4. 回滚困难。 发现准确率下降想回退,要走模型部署流程,20 分钟。RAG 改个配置 30 秒。

跑了 6 周,我们放弃了这个方向。沉没成本:33 人日 + 3,200 元训练费 + 约 1.5 万的模型托管试运行费。

尝试二:Embedding 微调(保留了下来)

但微调不是全错。我们在一个很窄的点上做了 embedding 微调,效果很好。

问题背景:我们的文档有大量行业黑话。比如「车险」,内部文档里说「标的」「三者」「不计免赔」,用户问的是「撞了别人的车怎么赔」。通用 embedding 模型在这个领域表现很差,检索召回率低。

做法是用真实的「用户问法 → 正确文档片段」配对数据微调 embedding 模型。数据从哪来?从客服系统里挖——每次客服同事回答了一个问题并关联了知识库文档,就是一个天然的正样本。

# 从客服系统导出 6 个月的关联记录
SELECT q.user_question, d.chunk_id, COUNT(*) AS hit_count
FROM cs_session s
JOIN cs_question q ON q.session_id = s.id
JOIN cs_kb_ref r    ON r.session_id = s.id
JOIN kb_document d  ON d.id = r.doc_id
WHERE s.created_at > '2025-06-01'
  AND s.resolved = 1
GROUP BY q.user_question, d.chunk_id
HAVING hit_count >= 2
# 结果:41,200 组正样本

再加负采样(随机片段 + 难负例),凑成 12 万组训练数据。用开源的 bge-base-zh 做对比学习微调。

结果:

指标通用 embedding领域微调后
Recall@562.3%81.7%
Recall@2078.1%92.4%
MRR@100.430.68
端到端问答准确率86%(RAG 第三轮后)91.3%

关键差异是:embedding 微调改的是「检索」,不是「知识」。知识更新不需要重训 embedding——新文档加进来,只要它的表述方式符合领域习惯,微调过的模型就能正确检索到。我们验证过:12 月新增的 40 篇文档,在不重训的情况下,检索准确率是 89.6%,只比老文档的 92.1% 低一点。

成本:训练一次 4 小时 ¥680,每季度重训一次保持效果。模型自己部署,2 个 GPU 实例月费 ¥3,200。这是整件事里我们唯一保留下来的微调投入。

两者的量化对比

把最终数据放在一起:

维度RAG + 通用 embeddingRAG + 微调 embeddingLoRA 微调 LLM
问答准确率89.0%91.3%82.4%
新文档准确率88.4%89.6%31.0%
编造率6%5%27%
可溯源
知识更新延迟5 分钟5 分钟6.5 小时(重训)
月运营成本¥24,100¥27,300¥29,420
一次性投入47 人日+12 人日33 人日 + 6 周试错
回滚耗时30 秒30 秒20 分钟

什么时候该选微调

我们的结论是 RAG 优先,但微调不是没用。基于这次实测,我总结了四条判断标准。

选微调的场景:

  1. 你要改的是「风格」而不是「知识」。比如让客服回答的语气统一、格式固定,这是微调的强项;
  2. 任务是固定的、输入输出格式明确的。比如「从合同文本里抽取 8 个字段」,微调小模型效果极好且成本极低;
  3. 知识是静态的、不会频繁变。比如法律法规、历史文献;
  4. 延迟要求极严。微调后的小模型可以做到几十毫秒,RAG 加 rerank 至少要几百毫秒。

选 RAG 的场景:

  1. 知识会更新,尤其是高频更新;
  2. 需要溯源、需要可解释;
  3. 不能接受编造(金融、医疗、法律、客服);
  4. 没有足够的标注数据,或者标注数据的质量本身存疑。

我们后来在另一个场景确实用上了微调:合同条款抽取。这个任务格式固定(8 个字段)、知识静态(合同模板就那几种)、要求延迟低(<200ms)。微调了一个 7B 的小模型,准确率 94.2%,单次成本 0.0021 元,是 RAG 方案的 1/12。这是微调真正该待的位置。

写在后面

现在回头看,《模型微调 vs RAG:成本与效果的量化对比》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。

参考