「我们的知识库问答准确率只有 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. 常见错误处理(泛泛而谈) |
| BM25 | 1. ER-2043 错误码说明(精确命中) 2. ER-2043 排查步骤 |
| 混合 RRF | 1. 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.00003 | 26,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 还贵。
更要命的是我们发现了几个致命问题:
- 知识时效性是硬伤。 训练数据截止到 2025 年 10 月,11 月更新的 40 篇文档它完全不知道。测试集里有一批「最近变更」的问题,准确率只有 31%;
- 编造能力变强了。 微调后模型在遇到不知道的问题时,编造答案的比例从 12% 升到 27%。因为它被训练成「必须给出客服风格的完整回答」,学会了不懂也硬答;
- 无法溯源。 客服同事不信任没有依据的回答,这个我们事先想到了,但实测下来影响比预估的大——客服使用意愿调研里,只有 34% 的人愿意直接用它的回答;
- 回滚困难。 发现准确率下降想回退,要走模型部署流程,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@5 | 62.3% | 81.7% |
| Recall@20 | 78.1% | 92.4% |
| MRR@10 | 0.43 | 0.68 |
| 端到端问答准确率 | 86%(RAG 第三轮后) | 91.3% |
关键差异是:embedding 微调改的是「检索」,不是「知识」。知识更新不需要重训 embedding——新文档加进来,只要它的表述方式符合领域习惯,微调过的模型就能正确检索到。我们验证过:12 月新增的 40 篇文档,在不重训的情况下,检索准确率是 89.6%,只比老文档的 92.1% 低一点。
成本:训练一次 4 小时 ¥680,每季度重训一次保持效果。模型自己部署,2 个 GPU 实例月费 ¥3,200。这是整件事里我们唯一保留下来的微调投入。
两者的量化对比
把最终数据放在一起:
| 维度 | RAG + 通用 embedding | RAG + 微调 embedding | LoRA 微调 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 优先,但微调不是没用。基于这次实测,我总结了四条判断标准。
选微调的场景:
- 你要改的是「风格」而不是「知识」。比如让客服回答的语气统一、格式固定,这是微调的强项;
- 任务是固定的、输入输出格式明确的。比如「从合同文本里抽取 8 个字段」,微调小模型效果极好且成本极低;
- 知识是静态的、不会频繁变。比如法律法规、历史文献;
- 延迟要求极严。微调后的小模型可以做到几十毫秒,RAG 加 rerank 至少要几百毫秒。
选 RAG 的场景:
- 知识会更新,尤其是高频更新;
- 需要溯源、需要可解释;
- 不能接受编造(金融、医疗、法律、客服);
- 没有足够的标注数据,或者标注数据的质量本身存疑。
我们后来在另一个场景确实用上了微调:合同条款抽取。这个任务格式固定(8 个字段)、知识静态(合同模板就那几种)、要求延迟低(<200ms)。微调了一个 7B 的小模型,准确率 94.2%,单次成本 0.0021 元,是 RAG 方案的 1/12。这是微调真正该待的位置。
写在后面
现在回头看,《模型微调 vs RAG:成本与效果的量化对比》本身不算多难,难的是线上真出问题那十分钟里的判断。经验都是这么来的。