实用指南:从微调到精准:大幅降低误差
《实用笔记》操作指南:从微调到精准优化——大幅减少采用该模式的团队所需的合同、检查项以及即插即用代码模块数量。
本指南将逐步构建从原材料到可运行系统的完整流程,主题为“从微调到精准:大幅降低RAG管道中的幻觉问题”。重点在于可操作的步骤、明确的检查点,以及可直接放入代码库的代码,无需猜测其用途。
1. 引言
在“引言”阶段,应在修改代码之前明确输入内容、该步骤的负责人以及完成标准。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测隐藏状态。应将此阶段视为输入与经过验证的输出之间的契约:为相关成果命名,定义成功标准,并拒绝默许的半完成状态。必须注明支撑答案的具体段落,否则操作人员无法区分幻觉与索引缺失的问题。
You: What was the revenue from contracts with customers in 2024?
Assistant: The revenue was €179,058,000. ← Wrong! Correct answer is €159,088,000.
2. 为何选择微调?
在“两次为什么”微调阶段,应在修改代码之前明确输入内容、该步骤的负责人以及终止标准。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测隐藏状态。除了功能结果外,还需记录执行时间以及令牌或查询成本。提前了解成本情况,可避免在从演示环境过渡到共享环境时出现意外费用。必须注明实际作为答案依据的段落;如果没有引用,操作人员就无法区分是幻觉内容还是索引缺失导致的错误。
解决方案:使用MLX进行微调
对于分阶段进行的方案微调,应在修改代码之前明确输入内容、各步骤的负责人以及终止标准。操作人员应能够从已知的检查点重新运行相应步骤,而无需猜测隐藏状态。 配置信息应置于应用程序代码之外。环境文件、密钥存储以及功能开关应集中存放于一个位置,以便操作人员无需查看整个系统结构即可进行审核。 需注明支撑答案的具体段落。若没有引用,操作人员就无法区分是虚假信息还是索引缺失导致的错误。
3. 架构图
在“3 架构图”阶段,应在修改代码之前明确输入内容、该步骤的负责人以及结束标准。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测隐藏状态。 需同时记录正常流程和异常恢复流程。重试机制、人工审核环节以及错误处理都是产品本身的组成部分,而非后续需要补充的内容。 必须引用实际作为答案依据的段落。如果没有引用,操作人员就无法区分是幻觉内容还是索引缺失导致的错误。
┌─────────────────────────────────────────────────────────────────────────────┐
│ PART 3: FINE-TUNING WORKFLOW (M1) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. Dataset 2. MLX Fine-Tuning │
│ ┌──────────────────┐ ┌────────────────────────────────────────────┐ │
│ │ Chart Images │─────▶│ Base Model (Qwen2-VL-2B) │ │
│ │ Q&A Pairs │ │ + LoRA Adapters (mlx_vlm.lora) │ │
│ │ (train.jsonl) │ │ + Unified Memory Training on Apple Silicon│ │
│ └──────────────────┘ └────────────────┬───────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Fine-Tuned LoRA Adapters │ │
│ │ (./fine_tuned_adapters/) │ │
│ └────────────────┬─────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ 3. Merge & Export to GGUF │ │
│ │ (mlx_vlm.fuse + convert_hf_to_gguf.py) │ │
│ └────────────────┬─────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ 4. Deploy with Ollama │ │
│ │ ollama create my-chart-model │ │
│ │ (text.gguf + mmproj.gguf) │ │
│ └────────────────┬─────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 5. Update rag_engine.py │ │
│ │ │ │
│ │ VISION_MODEL = "my-chart-model" # ← Change ONE line │ │
│ │ TEXT_MODEL = "llama3.2:3b" # Unchanged │ │
│ │ │ │
│ │ ✔ Existing app.py (from Part 2) automatically uses the new model! │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
4. 准备数据集
在“准备数据集”的4个阶段中,修改代码之前需明确输入参数、该步骤的负责人以及终止标准。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测其中的隐藏状态。相比冗长的脚本,更应采用小型、可测试的单元。当某个步骤失败时,故障原因应能明确指向单一责任方,而非复杂的流程链。需引用实际作为答案依据的段落;没有引用的话,操作人员就无法区分是虚假信息还是索引缺失导致的错误。在“准备数据集”的4个阶段中,修改代码之前需明确输入参数、该步骤的负责人以及终止标准。操作人员应能够从已知的检查点重新运行该步骤,而无需猜测其中的隐藏状态。除了功能结果外,还需记录执行时间以及token或查询成本。提前了解成本情况,可避免在从演示环境过渡到共享环境时出现意外费用。
Model sees: (Chart image)
Model reads: (Question)
Model learns: (Correct answer)
数据集来源
在处理数据集来源阶段时,首先需明确相关约定:所需输入、成功信号以及部分失败时的处理方式。这样的清单能确保后续的代码修改保持一致性。 应将配置信息置于应用程序代码之外。环境文件、密钥存储以及功能开关应集中存放,以便操作人员无需查看整个系统结构即可进行审计。 在调整提示词之前,需先使用固定的问题集来衡量召回率。仅仅更换提示词往往无法解决检索效果不佳的问题。
逐步数据准备
在逐步进行数据准备阶段时,首先写下相关规范:所需的输入参数、成功标志,以及部分失败时的处理方式。这样的检查清单能确保后续的代码修改始终符合要求。 同时记录正常流程和异常恢复流程。重试机制、人工审核环节以及错误消息处理都是产品本身的一部分,而非后续需要补充的功能。 在调整提示词之前,先使用固定的问题集来衡量召回率。仅仅更换提示词很难解决检索效果不佳的问题。
Step 1: Create Raw Dataset → chart_dataset/raw_train.jsonl
Step 2: Convert to MLX Format → chart_dataset/train.jsonl
Step 3: Verify Dataset → Check that train.jsonl exists
第一步:创建原始数据集
在执行“第一步:创建阶段”时,首先需明确相关约定:所需输入、成功标志以及部分失败时的处理方式。这样的清单能确保后续的代码修改保持一致性。 相较于庞大的脚本,应优先选择小型且可测试的单元。当某一步骤失败时,故障应指向单一责任点,而非复杂的流程链。 在调整提示词之前,需先使用固定的问题集来测试召回率。仅仅更换提示词往往无法解决检索效果不佳的问题。 在执行“第一步:创建阶段”时,首先需明确相关约定:所需输入、成功标志以及部分失败时的处理方式。这样的清单能确保后续的代码修改保持一致性。 除了功能结果外,还需记录执行时间以及令牌或查询成本。提前了解成本情况,可避免在从演示环境过渡到共享环境时出现意外费用。
# download_chartqa.py
from datasets import load_dataset
import json
import os
os.makedirs("chart_dataset", exist_ok=True)
# Download ChartQA dataset
dataset = load_dataset("ahmed-masry/ChartQA", split="train")
# Convert to flat format with standard keys
converted = []
for item in dataset:
converted.append({
"image": item["imgname"], # Path to chart image
"question": item["query"],
"answer": item["label"]
})
# Save as raw dataset
with open("chart_dataset/raw_train.jsonl", "w") as f:
for entry in converted:
f.write(json.dumps(entry) + "\n")
print(f"✅ Converted {len(converted)} ChartQA samples to chart_dataset/raw_train.jsonl")
README.md: 100%|█████████████████████████████████████████████| 2.12k/2.12k [00:00<00:00, 3.72MB/s]
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
data/train-00000-of-00003.parquet: downloading bytes: ████████████████████████| 213MB, 17.3MB/s
......
Generating test split: 100%|████████████████████████| 2500/2500 [00:00<00:00, 20517.87 examples/s]
✅ Converted 28299 ChartQA samples to raw_train.jsonl
选项B:生成合成数据
将选项B的“生成合成数据”阶段视为可度量的模型来处理效果最佳。在扩大范围之前,先收集一份理想的转录记录、一个故障案例以及回滚说明。 将配置信息置于应用程序代码之外。环境文件、密钥存储和功能标志应集中存放于一个位置,以便操作人员无需查看整个系统结构即可进行审计。 将分块策略与检索策略分开。当质量指标发生变化时,修改其中一项不应强制要求重新编写另一项。
# generate_synthetic_data.py
import json
import matplotlib.pyplot as plt
import numpy as np
import os
os.makedirs("chart_dataset/images", exist_ok=True)
dataset = []
for i in range(100):
categories = ['Q1', 'Q2', 'Q3', 'Q4']
values = np.random.randint(100, 500, 4)
plt.figure()
plt.bar(categories, values)
plt.title(f"Quarterly Revenue {i}")
plt.savefig(f"chart_dataset/images/chart_{i:03d}.png")
plt.close()
dataset.append({
"image": f"images/chart_{i:03d}.png",
"question": "Which quarter had the highest revenue?",
"answer": f"Q{np.argmax(values) + 1} with ${max(values)} million"
})
with open("chart_dataset/raw_train.jsonl", "w") as f:
for entry in dataset:
f.write(json.dumps(entry) + "\n")
print("✅ Generated 100 synthetic samples in chart_dataset/raw_train.jsonl")
步骤2:转换为MLX格式
将“第二步:转换为阶段”视为可度量的流程来处理效果最佳。在扩大范围之前,先记录一个成功的案例、一个失败案例以及回滚说明。同时记录正常流程和恢复流程的文档。重试机制、人工审核环节以及死信处理都是产品本身的一部分,而非后续需要补充的内容。应将分块策略与检索策略分开,当质量指标发生变化时,修改其中一项不应强制要求重新编写另一项。
# convert_to_mlx_format.py
import json
import os
def convert_raw_to_mlx_format(data_dir="chart_dataset"):
"""
Convert raw dataset to MLX format.
Input: chart_dataset/raw_train.jsonl
Output: chart_dataset/train.jsonl (MLX-compatible)
"""
raw_file = os.path.join(data_dir, "raw_train.jsonl")
out_file = os.path.join(data_dir, "train.jsonl")
if not os.path.exists(raw_file):
print(f"❌ {raw_file} not found. Run download_chartqa.py or generate_synthetic_data.py first.")
return None
dataset = []
with open(raw_file, "r") as f:
for line in f:
item = json.loads(line)
# Build full image path
img_str = item["image"]
image_path = img_str if img_str.startswith(data_dir) else os.path.join(data_dir, img_str)
dataset.append({
"images": [image_path], # MUST be a list
"messages": [
{"role": "user", "content": item["question"]},
{"role": "assistant", "content": item["answer"]}
]
})
with open(out_file, "w") as f:
for entry in dataset:
f.write(json.dumps(entry) + "\n")
print(f"✅ Converted {len(dataset)} samples to {out_file}")
return dataset
if __name__ == "__main__":
convert_raw_to_mlx_format()
python convert_to_mlx_format.py
第三步:验证数据集
第三步:将阶段视为可测量的表面来验证其效果最为理想。在扩大范围之前,先记录一个成功的案例、一个失败案例以及回滚说明。 优先选择小型且可测试的单元,而非庞大的脚本。当某一步骤失败时,故障应指向单一的责任主体,而非复杂的流程链。 将分块策略与检索策略分开。当质量指标发生变化时,修改其中一项不应迫使重新编写另一项。
ls -la chart_dataset/
chart_dataset/
├── raw_train.jsonl # Raw Q&A pairs (flat keys)
├── train.jsonl # MLX-formatted (overwritten by prepare_mlx_dataset)
└── images/ # Chart images
📌 Important: Before running the fine-tuning command, ensure train.jsonl exists in your dataset directory.
If you see a FileNotFoundError, you haven't run the conversion step yet.
5. 使用MLX进行微调
将MLX阶段的5次微调视为可测量的过程最为有效。在扩大范围之前,先记录一份理想的转录结果、一个失败案例以及回滚说明。把这一阶段视为输入与经过验证的输出之间的契约,为相关成果命名,明确成功标准,绝不允许出现无声无息的半完成状态。应将分块策略与检索策略分开,当质量指标发生变化时,修改其中一项不应迫使重新编写另一项。
前提条件
将“前置条件”阶段视为可度量的指标会更有助于其有效运作。在扩大范围之前,先记录一份理想的测试用例、一个失败案例以及回滚说明。在功能结果旁同时记录执行时间以及令牌或查询成本。提前了解这些成本,就能避免在系统从演示环境过渡到共享环境时出现意外费用。应将分块策略与检索策略分开处理,当质量指标发生变化时,修改其中一项不应迫使重新编写另一项。
# For fine-tuning vision models
pip install mlx-vlm
# For merging adapters (needed after training)
pip install mlx-lm
运行微调
将运行微调阶段视为可度量的对象时,其效果最佳。在扩大范围之前,先记录一个成功的示例、一个失败案例以及回滚说明。 将配置与应用程序代码分开。环境文件、密钥存储和功能标志应集中存放于一处,这样操作人员无需查看整个系统结构即可进行审计。 将分块策略与检索策略分开。当质量指标发生变化时,修改其中一项不应迫使重新编写另一项。
python -m mlx_vlm.lora \
--model Qwen/Qwen2-VL-2B-Instruct \
--dataset ./chart_dataset/train.jsonl \
--iters 1000 \
--batch-size 1 \
--lora-rank 8 \
--gradient-accumulation-steps 4 \
--max-seq-length 512
理解训练输出
将“理解训练输出”阶段视为可测量的对象时,其效果最佳。在扩大范围之前,先记录一份理想的输出样本、一个失败案例以及回滚说明。同时记录正常流程与恢复流程的文档。重试机制、人工审核环节以及死信处理都是产品本身的组成部分,而非后续需要补充的内容。应将分块策略与检索策略分开处理;当质量指标发生变化时,修改其中一项不应强制要求重新编写另一项。
INFO:__main__:Loading model from Qwen/Qwen2-VL-2B-Instruct
Fetching 11 files: 100%|████████████████████████████████████████| 11/11 [00:00<00:00, 1465.47it/s]
Download complete: : | 0.00B
Reconstruction complete: | | 0.00B / 0.00B
INFO:__main__:Loading dataset from ./chart_dataset/train.jsonl
INFO:__main__:Setting up LoRA
#trainable params: 9.232384 M || all params: 2208.9856 M || trainable%: 0.418%
INFO:__main__:Setting up optimizer
INFO:__main__:Training model (sft)
Starting training..., iterations: 1000
No validation dataset provided — training will run without validation.
......
Iter 120: Train loss 7.77592850, Learning Rate 2.000e-05,
It/sec 0.242, Tokens/sec 103.862, Trained Tokens 51480, Peak mem 8.069 GB
.....
Saved final adapter weights to adapters.safetensors.
INFO:__main__:Training completed! Model saved to adapters.safetensors
6. 故障排除
将“故障排查”阶段视为可度量的对象来处理效果最佳。在扩大范围之前,先记录一份理想的处理结果、一个故障案例以及回滚说明。
数据集未找到错误
将“数据集未找到错误”阶段视为可度量的对象来处理效果最佳。在扩大范围之前,先记录一份理想的处理结果、一个故障案例以及回滚说明。
FileNotFoundError: Couldn't find any data file at .../chart_dataset/train.jsonl
python convert_to_mlx_format.py
内存不足错误
RuntimeError: [METAL] Command buffer execution failed: Insufficient Memory
python -m mlx_vlm.lora \
--model Qwen/Qwen2-VL-2B-Instruct \
--dataset ./chart_dataset/train.jsonl \
--iters 1000 \
--batch-size 1 \ # ← Reduced from 4 to 1
--lora-rank 4 \ # ← Reduced from 8 to 4
--gradient-accumulation-steps 8 \ # ← Added
--max-seq-length 256 # ← Added
适配器路径未找到
FileNotFoundError: The adapter path does not exist: fine_tuned_adapters
ls -la adapters.safetensors adapter_config.json
python -m mlx_lm fuse \
--model Qwen/Qwen2-VL-2B-Instruct \
--adapter-path . \
--save-path ./fine_tuned_model_merged
缓存不完整错误
IncompleteSnapshotError: The cached snapshot for 'Qwen/Qwen2-VL-2B-Instruct' is incomplete
rm -rf ~/.cache/huggingface/hub/models--Qwen--Qwen2-VL-2B-Instruct
7. 训练需要多长时间?
Total Time (seconds) = Total Iterations ÷ It/sec
Total Time (minutes) = Total Time (seconds) ÷ 60
1000 ÷ 0.242 = 4,132 seconds
4,132 ÷ 60 = ~69 minutes
8. 合并LoRA适配器
python -m mlx_lm fuse \
--model Qwen/Qwen2-VL-2B-Instruct \
--adapter-path . \
--save-path ./fine_tuned_model_merged
9. 导出为GGUF格式以供Ollama使用
步骤1:将MLX格式导出为Hugging Face格式
#!/usr/bin/env python3
import mlx_lm
from mlx_lm import load, save
model, tokenizer, config = load("./fine_tuned_model_merged")
save(model, tokenizer, config, "./hf_export")
print("✅ Exported to ./hf_export")
python export_to_hf.py
步骤2:转换为GGUF格式
# Clone llama.cpp (if not already done)
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
# Convert text model
python convert_hf_to_gguf.py ../hf_export \
--outfile chart_model-text.gguf \
--outtype f16
步骤3:转换视觉投影器
python convert_hf_to_gguf.py ../hf_export \
--outfile chart_model-mmproj.gguf \
--outtype f16 \
--mmproj
ls ~/.cache/huggingface/hub/models--Qwen--Qwen2-VL-2B-Instruct/snapshots/
# sample output . You'll see a hash directory (e.g., 895c3a49...)
# 895c3a49bc3fa70a340399125c650a463535e71c
cd llama.cpp
# replace <hash> with the output above (e.g., 895c3a49...)
python convert_hf_to_gguf.py ~/.cache/huggingface/hub/models--Qwen--Qwen2-VL-2B-Instruct/snapshots/<hash>/ \
--outfile base-mmproj.gguf \
--outtype f16 \
--mmproj
10. 使用Ollama部署模型
FROM ./chart_model-text.gguf
FROM ./chart_model-mmproj.gguf
PARAMETER temperature 0.2
创建模型
ollama create my-chart-model -f Modelfile
验证模型
ollama list
# output :
# NAME ID SIZE MODIFIED
# my-chart-model:latest b3fd6d8fd742 4.4 GB 7 hours ago
# qwen2.5vl:3b fb90415cde1e 3.2 GB 9 days ago
# llama3.2:3b a80c4f17acd5 2.0 GB 10 days ago
# llama3:latest 365c0bd3c000 4.7 GB 3 months ago
# You should see my-chart-model in the list.
测试模型
ollama run my-chart-model "What is 2+2?"
11. 集成到现有的RAG应用中
# In rag_engine.py (from Parts 1 & 2)
# Before:
VISION_MODEL = "qwen2.5vl:3b"
# After:
VISION_MODEL = "my-chart-model" # Your fine-tuned model
TEXT_MODEL = "llama3.2:3b" # Unchanged
python app.py
12. 评估:基础模型与微调后模型的对比
对比分析
13. 结论
获取完整代码
git clone https://github.com/froilan-sia/m1_multimodal_rag.git
cd m1_multimodal_rag