首页 / 文章 / 实用指南:从微调到精准:大幅降低误差

实用指南:从微调到精准:大幅降低误差

《实用笔记》操作指南:从微调到精准优化——大幅减少采用该模式的团队所需的合同、检查项以及即插即用代码模块数量。

2822 词

本指南将逐步构建从原材料到可运行系统的完整流程,主题为“从微调到精准:大幅降低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

与我取得联系

操作检查清单