Phase 3: RAG & Knowledge Systems

Full fine-tuning vs parameter-efficient methods like LoRA

Advanced ~14 min read
Think of it this way A friendly analogy. Read this if the technical version feels dense. Show Hide

Imagine you have a super smart robot that can do lots of cool things, like answer questions or write stories. It's pretty good at general stuff, but what if you want it to be really good at one specific job, like becoming an expert animal doctor or a coding tutor? How do you teach it without spending forever and needing a super-duper giant robot factory just for one task?

One way is like completely rebuilding your robot. This is called 'full fine-tuning'. You take it apart, changing nearly every single gear, wire, and computer chip inside. You might even reprogram its main brain to think only about animal health. This makes your robot incredibly specialized and a true expert from the ground up, learning new ways of understanding things unique to its new job. But, wow, that's a huge project! It takes ages, needs a massive workshop with tons of special tools, and uses up a lot of power. Plus, once it's a doctor robot, it's really hard to make it switch to being a coding tutor; you'd have to rebuild it all over again.

There's another, much quicker way! Instead of rebuilding the whole robot, you keep its main body and brain exactly as they are. This is called 'parameter-efficient fine-tuning,' and a super popular method for it is called LoRA (which stands for Low-Rank Adaptation). With LoRA, you just add tiny, special 'add-on modules' to your robot. For an animal doctor robot, maybe you add a small medical scanner attachment, a chip that gives it veterinary knowledge, and a special screen to show animal x-rays. These small modules teach it how to be an animal doctor without changing its core self. It’s like adding a special skill pack.

This way is much faster and cheaper because you're only adding a few small parts, not rebuilding the whole thing. You don't need a huge robot factory, just a small workbench. The best part? You can easily swap these modules! When your robot is done being an animal doctor, you can snap off the medical modules and snap on 'coding tutor modules' in minutes. This means you can have one super smart robot and quickly teach it many different new skills, switching between them whenever you need, without waiting days or weeks for a full rebuild.

The mental model for LoRA starts with a linear layer. Every transformer attention layer contains weight matrices W (e.g., for query projection) with shape [d_model, d_model]. Full fine-tuning updates W directly. LoRA instead freezes W and adds a bypass: two small matrices A (shape [d_model, r]) and B (shape [r, d_model]), where r is the rank, usually 4-64. During the forward pass, the effective weight is W + BA. Only A and B are trained. Because r << d_model (e.g., 16 vs 4096), the parameter count drops dramatically. At merge time, you can fold BA back into W and get a zero-latency-overhead model identical in shape to the original. This is not an approximation trick; it works because most fine-tuning updates live in a low-dimensional subspace of the full parameter space.

Consider a real scenario: you're building a customer-support assistant for a fintech company. The model needs to understand internal product names, regulatory language, and a strict response format. You have 5,000 curated Q&A pairs and a team that can afford one A100 80GB GPU for a weekend. Full fine-tuning on Llama-3-8b in bfloat16 with Adam optimizer requires roughly 60 GB of VRAM for weights plus gradients plus optimizer state. You'd need tensor parallelism across multiple GPUs. With LoRA (r=16, targeting q_proj and v_proj), trainable parameters drop to ~4M, optimizer state is negligible, and you train comfortably on a single A100 in 3-4 hours. The resulting adapter file is ~30 MB. Your base model stays untouched, so you can run the same base for other adapters in parallel.

Against full fine-tuning specifically: the honest answer is that on highly specialized domains with tens of thousands of examples, full fine-tuning still wins on benchmark numbers by a few percentage points. If you're building a production model for protein structure prediction or chip design verification, those points matter and you likely have the compute budget. For 95% of application-layer AI work, you're adapting a model to follow a style, understand a vocabulary, or output a specific format. LoRA covers that well. QLoRA (quantized LoRA) goes further: it quantizes the base model to 4-bit (via bitsandbytes NF4 quantization) before applying LoRA, cutting base model memory by another 4x. A 7B model fits on a single consumer-grade 24 GB GPU. The tradeoff is slightly slower forward passes and marginally lower peak accuracy, but for many products that's an acceptable deal.

Other PEFT methods exist and are worth knowing by name. Prefix tuning and prompt tuning prepend learnable tokens to the input rather than modifying weight matrices. They're even more parameter-efficient than LoRA but tend to underperform it on tasks requiring deeper behavioral change. IA3 (Infused Adapter by Inhibiting and Amplifying Inner Activations) rescales activation vectors using learned vectors per layer, with even fewer parameters than LoRA. DoRA (Weight-Decomposed Low-Rank Adaptation) decomposes weights into magnitude and direction components and applies LoRA to the direction only, often matching full fine-tuning quality closer than vanilla LoRA. For most developers in 2024-2025, LoRA and QLoRA are the practical starting points.

At scale, the adapter architecture becomes a genuine engineering advantage. At 10 users, you serve a single fine-tuned model. At 10,000 users with diverse needs (support, billing, compliance), you'd otherwise need to host separate full fine-tuned copies, each 16 GB+. With LoRA, you host one quantized base model and load adapters dynamically. Libraries like vLLM support adapter serving natively through its LoRA serving feature. At 10 million requests per day, you're profiling adapter swap latency (typically sub-millisecond when adapters are cached in GPU SRAM) and batching requests by adapter to minimize context switches. The cost implications are stark: one A100 instance at roughly $3-4/hour serving 5 adapters versus 5 separate model deployments. That's not a rounding error on your infrastructure bill.

Key Takeaways

  • Full fine-tuning maximizes performance but requires enormous VRAM and can cause catastrophic forgetting.
  • LoRA trains <1% of parameters by injecting low-rank matrices into frozen attention layers.
  • LoRA adapters are swappable at inference time, enabling multi-tenant model serving from one base.
  • Prefer LoRA by default; only reach for full fine-tuning when task complexity demands deep weight changes.

Pro tips

  • Target more attention projection layers (q_proj, k_proj, v_proj, o_proj) rather than just q and v. The difference in trainable parameter count is small but the quality lift on instruction-following tasks is measurable, especially for shorter training runs.
  • After training, merge and unload your LoRA adapter into the base weights using peft's merge_and_unload() before serving. This eliminates the adapter matmul overhead at inference time and makes the checkpoint portable to any inference stack that doesn't know about PEFT.
  • Watch your lora_alpha/r ratio. Alpha is a scaling factor applied to the adapter output before adding to the frozen weight; setting alpha = 2*r is a conventional starting point, but teams sometimes tune alpha independently from r. If your loss isn't dropping, try doubling alpha before changing r.
  • Catastrophic forgetting is less of a concern with LoRA than with full fine-tuning, but it still happens if your fine-tuning dataset is narrow and long. Include a small fraction (5-10%) of general instruction-following examples from something like Alpaca or FLAN to anchor the base behavior.

Common pitfalls

  • Mistake: Setting rank r too high (e.g., r=128) thinking it always helps. Fix: Start with r=8 or r=16. Higher rank increases parameters and overfitting risk; only increase if loss curves show underfitting.
  • Mistake: Fine-tuning with a learning rate copied from full fine-tuning (e.g., 1e-5). Fix: LoRA adapters need higher LRs, typically 1e-4 to 3e-4, because only a small fraction of parameters are updating.
  • Mistake: Saving the merged model and losing the original adapter. Fix: Always save the standalone adapter first (trainer.save_model), then merge separately. Keep the adapter; it's your versioned artifact.
  • Mistake: Evaluating the fine-tuned model only on training-distribution examples. Fix: Hold out a validation split before training and run your eval suite from aidev-fine-tuning-evaluation against it after every epoch.

Full fine-tuning vs LoRA vs QLoRA vs prompt-based adaptation

Option Use when Avoid when
Full fine-tuning You need maximum performance on a specialized domain, have 4+ high-VRAM GPUs, and 50k+ high-quality examples. You have a single GPU, fewer than 10k examples, or need to serve multiple task variants from one base.
LoRA You need behavioral adaptation on 1-2 GPUs with 5k-50k examples and want swappable, versionable adapters. Your base model must also be quantized at training time; use QLoRA instead to fit in consumer GPU VRAM.
QLoRA You are working with a 7B+ model on a single 24 GB consumer GPU or a cost-constrained cloud instance. Inference throughput is paramount; quantization during training adds overhead and the base stays quantized unless you later merge and dequantize.
Few-shot prompting / system prompts You need format or style adaptation, have no training data yet, or are prototyping before committing to fine-tuning. You need deep domain knowledge baked in, strict latency requirements, or proprietary vocabulary the base model has never seen.

Code Example

python
# peft==0.10.0, transformers==4.40.0, torch==2.2.0
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import get_peft_model, LoraConfig, TaskType

model_id = "meta-llama/Llama-3-8b"
tokenizer = AutoTokenizer.from_pretrained(model_id)
base_model = AutoModelForCausalLM.from_pretrained(model_id, torch_dtype="auto")

lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,               # rank of the low-rank matrices
    lora_alpha=32,      # scaling factor (typically 2x rank)
    lora_dropout=0.05,
    target_modules=["q_proj", "v_proj"],  # which layers to adapt
)

peft_model = get_peft_model(base_model, lora_config)
peft_model.print_trainable_parameters()
# Output: trainable params: 4,194,304 || all params: 8,030,261,248 || trainable%: 0.05

How this code works

This code demonstrates how to prepare a large language model, Llama 3, for efficient fine-tuning using the LoRA method, an alternative to modifying the entire model. It starts by loading the pre-trained meta-llama/Llama-3-8b model and its tokenizer with AutoModelForCausalLM and AutoTokenizer. The key to LoRA's efficiency is configured within LoraConfig, which specifies task_type=TaskType.CAUSAL_LM for generative tasks, along with parameters like r (the rank of the small, trainable matrices) and lora_alpha (a scaling factor). A subtle but critical decision is the target_modules parameter, set to ["q_proj", "v_proj"]. This strategically applies LoRA adapters only to specific query and value projection layers within the attention mechanism, focusing the adaptation where it's most impactful and minimizing trainable parameters.

After defining the LoraConfig, get_peft_model integrates these LoRA adapters into the base_model, creating a new peft_model. This model is now ready for fine-tuning, but only the newly added, tiny LoRA layers will be updated during training, leaving the vast majority of the original Llama 3 parameters untouched. The power of this approach is then revealed by peft_model.print_trainable_parameters(), which shows a dramatic reduction: only 0.05% of the model's total parameters are trainable. This extreme efficiency allows for adapting enormous models to specific tasks with significantly fewer computational resources and storage requirements than full fine-tuning.

Production-grade example

QLoRA training with 4-bit quantization, env-var auth, typed config, error handling, and structured logging.

python
# peft==0.10.0, transformers==4.40.0, trl==0.8.6, bitsandbytes==0.43.0
import os
import logging
import time
from dataclasses import dataclass
from transformers import (
    AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, TrainingArguments
)
from peft import LoraConfig, TaskType
from trl import SFTTrainer
from datasets import load_dataset

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)

@dataclass
class FinetuneConfig:
    model_id: str = os.environ["BASE_MODEL_ID"]  # e.g. "meta-llama/Llama-3-8b"
    dataset_path: str = os.environ["DATASET_PATH"]
    output_dir: str = os.environ.get("OUTPUT_DIR", "./lora-adapter")
    lora_r: int = 16
    lora_alpha: int = 32
    lora_dropout: float = 0.05
    max_seq_length: int = 2048
    num_train_epochs: int = 3
    per_device_train_batch_size: int = 4
    gradient_accumulation_steps: int = 4

def load_quantized_model(cfg: FinetuneConfig):
    bnb_config = BitsAndBytesConfig(
        load_in_4bit=True,
        bnb_4bit_quant_type="nf4",
        bnb_4bit_compute_dtype="bfloat16",
        bnb_4bit_use_double_quant=True,
    )
    log.info("Loading base model %s with 4-bit quantization", cfg.model_id)
    try:
        model = AutoModelForCausalLM.from_pretrained(
            cfg.model_id,
            quantization_config=bnb_config,
            device_map="auto",
            trust_remote_code=False,
        )
        tokenizer = AutoTokenizer.from_pretrained(cfg.model_id)
        tokenizer.pad_token = tokenizer.eos_token
        return model, tokenizer
    except OSError as e:
        log.error("Model not found or access denied: %s", e)
        raise
    except RuntimeError as e:
        log.error("GPU OOM or dtype error loading model: %s", e)
        raise

def build_lora_config(cfg: FinetuneConfig) -> LoraConfig:
    return LoraConfig(
        task_type=TaskType.CAUSAL_LM,
        r=cfg.lora_r,
        lora_alpha=cfg.lora_alpha,
        lora_dropout=cfg.lora_dropout,
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
        bias="none",
    )

def run_training(cfg: FinetuneConfig):
    model, tokenizer = load_quantized_model(cfg)
    lora_config = build_lora_config(cfg)
    dataset = load_dataset("json", data_files=cfg.dataset_path, split="train")
    log.info("Dataset loaded: %d examples", len(dataset))

    training_args = TrainingArguments(
        output_dir=cfg.output_dir,
        num_train_epochs=cfg.num_train_epochs,
        per_device_train_batch_size=cfg.per_device_train_batch_size,
        gradient_accumulation_steps=cfg.gradient_accumulation_steps,
        learning_rate=2e-4,
        fp16=False,
        bf16=True,
        logging_steps=10,
        save_strategy="epoch",
        report_to="none",  # swap to "wandb" for observability
    )

    trainer = SFTTrainer(
        model=model,
        args=training_args,
        train_dataset=dataset,
        peft_config=lora_config,
        dataset_text_field="text",
        max_seq_length=cfg.max_seq_length,
        tokenizer=tokenizer,
    )

    start = time.time()
    log.info("Starting QLoRA fine-tune run")
    try:
        trainer.train()
    except RuntimeError as e:
        log.error("Training failed (likely OOM). Try reducing batch size or max_seq_length: %s", e)
        raise
    elapsed = time.time() - start
    log.info("Training complete in %.1f seconds", elapsed)

    trainer.save_model(cfg.output_dir)
    log.info("Adapter saved to %s", cfg.output_dir)
    trainable, total, pct = trainer.model.get_nb_trainable_parameters()
    log.info("Trainable params: %d / %d (%.4f%%)", trainable, total, 100 * trainable / total)

if __name__ == "__main__":
    run_training(FinetuneConfig())

How this code works

This code demonstrates QLoRA fine-tuning, a parameter-efficient method to adapt large language models (LLMs) to new tasks without retraining all their billions of parameters. It begins by consolidating configuration settings in FinetuneConfig, including the model_id, dataset_path, and LoRA-specific parameters. The load_quantized_model function then loads a base LLM (e.g., Llama 3) efficiently using BitsAndBytesConfig for 4-bit quantization. This dramatically reduces memory usage on the GPU, making it feasible to fine-tune massive models on consumer hardware. An AutoTokenizer loads the corresponding tokenizer, essential for preparing text data.

Next, build_lora_config sets up the LoraConfig, specifying which parts of the model (e.g., q_proj, v_proj in attention layers via target_modules) will have small, trainable LoRA adapter layers added. The training data is loaded with load_dataset, and TrainingArguments dictates specifics like num_train_epochs and per_device_train_batch_size. The SFTTrainer from trl then orchestrates the fine-tuning process, automatically applying the LoRA adapters to the quantized model. After trainer.train() completes, only the small LoRA adapter weights are saved via trainer.save_model(). The final log message about trainable parameters highlights a subtle but critical point: often less than 1% of the model's total parameters are actually trained, demonstrating LoRA's significant efficiency over full fine-tuning.

Practice & master

Try the exercise, check your understanding, then mark this lesson mastered to track your path to pro.

Exercise

Using the PEFT library and a small open model (e.g., TinyLlama-1.1B or GPT-2), apply a LoRA config targeting the query and value projection layers. Print the number of trainable vs total parameters and confirm trainable% is under 2%. Then change the rank from 8 to 64 and observe how trainable% changes.

python
# peft==0.10.0, transformers==4.40.0
from transformers import AutoModelForCausalLM
from peft import get_peft_model, LoraConfig, TaskType

MODEL_ID = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"

# TODO: Load the base model with AutoModelForCausalLM.from_pretrained
base_model = None

# TODO: Create a LoraConfig with task_type=CAUSAL_LM, r=8, lora_alpha=16,
#       lora_dropout=0.05, target_modules=["q_proj", "v_proj"]
lora_config = None

# TODO: Wrap the base model with get_peft_model
peft_model = None

# TODO: Call peft_model.print_trainable_parameters()
# Then change r to 64 and repeat. What do you observe?

Quick check

  1. In LoRA, what does the rank parameter r directly control?

  2. You merge a LoRA adapter into its base model weights using merge_and_unload(). What is the primary benefit at inference time?

  3. A team has 3,000 labeled examples and one 24 GB GPU. They want to fine-tune a 13B-parameter model. Which approach is most practical?

Self-check: Explain to a teammate why you'd pick QLoRA over full fine-tuning for a 7B model on a single A100. Then describe one scenario where you'd still choose full fine-tuning despite the cost, and what you'd lose by using LoRA instead.