LoRA Fine-Tuning vs RAG: Tone Shift Comparison
Fine-tune Llama 3.2 1B with LoRA for a brand tone, then compare it with prompting and RAG using held-out tests and a forgetting check.
Blueprint Walkthrough
Architecture Overview & Problem Statement
This project fine-tunes a small open model (Llama 3.2 1B) with LoRA so it answers customer complaints in a fixed brand tone, then tests that fine-tune against the untouched base model, against careful prompting that includes a tone guideline (the closest thing to RAG for this task), and against a catastrophic forgetting check. You finish with measured scores and a written recommendation, which is the real deliverable. The LoRA training is only the means.
The scenario is a Zomato-style support team that wants every reply to be warm, short and free of corporate phrases, and to open by acknowledging the customer's frustration. The facts in the replies are already right. Only the phrasing is wrong. That separates this from a RAG problem, where the model lacks a fact it needs. LoRA (Low-Rank Adaptation) freezes the base model and trains small extra matrices added to selected layers, so it changes behaviour cheaply and with less risk of damaging general ability than full fine-tuning.
150 to 300 pairs)]:::data --> lora[LoRA training
base frozen]:::tool base[Base model]:::slate --> lora lora --> tuned[Tuned model
base plus adapter]:::good base --> guide[Base plus
guideline prompt]:::cloud base --> tests{{Held-out tone test
Forgetting test}}:::judge guide --> tests tuned --> tests classDef data fill:#F97316,color:#FFFFFF classDef tool fill:#8B5CF6,color:#FFFFFF classDef slate fill:#475569,color:#FFFFFF classDef good fill:#10B981,color:#FFFFFF classDef cloud fill:#326CE5,color:#FFFFFF classDef judge fill:#FFEDD5,color:#1E293B
What you will build
- A hand-reviewed tone dataset of complaint and on-brand response pairs
- A LoRA adapter trained on that dataset, with a validation split
- A fair comparison of the base model, the base model plus a tone guideline, and the tuned model on 20 held-out complaints
- A catastrophic forgetting test on 10 unrelated prompts
- A one-page recommendation based on your own numbers
Prerequisites
You should know what a LoRA adapter is, for example from the LLM Fine-Tuning Lite module. You need Python 3.10 or newer and one GPU with about 8 GB of memory or more. A free-tier cloud notebook GPU such as a T4 is enough for a 1B model. Llama 3.2 models are gated on Hugging Face, so request access first. If you cannot get it, use any small open causal model and change MODEL_NAME. You also need an Anthropic API key for the judge steps.
TipResist building a huge dataset. A focused set of 150 to 300 consistent examples is enough to show a measurable tone shift, and more data will not teach you more about the decision this project tests.
Engineering DecisionUse LoRA over full fine-tuning in almost every real case, and fine-tune only after confirming the problem is behaviour or tone and not missing facts. This project includes a RAG-style comparison on purpose, so you can see with numbers when each tool applies.
Milestone 1: Build the Tone-Shifting Dataset
This milestone produces 150 to 300 training pairs that all follow one written set of tone rules.
A fine-tuning dataset teaches by example, not by instruction. If some examples are warm and short and others still sound corporate and long, the model has no clean pattern to learn. Quality and consistency matter more than size.
Write the tone rules first
Write the rules down before writing any example. They become your checklist for every pair and the rubric for the judge later.
Target tone rules:1. Acknowledge the customer's frustration or situation first, in one sentence2. State the resolution clearly, with no hedging ("we might be able to")3. No corporate phrases: "per our policy", "we regret to inform you", "your satisfaction is our priority"4. Maximum 3 sentences per response5. End with a concrete next step, not an open-ended "let us know if..."Write paired examples
Each record holds the complaint, a generic corporate reply and the target reply. Only input and target_response are used for training. The generic_response helps you audit contrast and gives you a baseline sample to compare against.
Create tone_training_data.json:
[ { "input": "My order from Punjab Grill arrived cold and the packaging was damaged.", "generic_response": "We regret to inform you of this inconvenience. Per our policy, we may be able to offer a partial refund pending review. Your satisfaction is our priority.", "target_response": "Cold food defeats the whole point, I'm sorry about that. I've issued a full refund to your original payment method. It will show in 3 to 5 business days." }, { "input": "I've been waiting 45 minutes for my order and the app still says preparing.", "generic_response": "We sincerely apologize for any inconvenience this delay may have caused. Please be assured that we are looking into this matter.", "target_response": "45 minutes is far too long, I hear you. I've checked with the restaurant and your order is out for delivery now. Expect it within 10 minutes." }]Generate drafts, then review every one by hand
Writing 200 pairs by hand is slow, so draft them with an LLM and then review each one yourself. Generation is a first draft, never the final data.
GENERATION_PROMPT = """Generate 10 customer support scenarios for a food delivery app.Each needs a customer message, a generic corporate-sounding response, and a targetresponse that follows these rules:{tone_rules} Vary the complaint types across: late delivery, wrong item, cold food,missing items, payment issues, rude delivery partner, app errors.Return a JSON list with keys: input, generic_response, target_response."""Run it repeatedly, merge the results into tone_training_data.json, remove near-duplicates, and then edit every pair until it passes all five rules.
Common MistakeChoosing dataset size over quality. Two hundred loosely consistent examples teach a blurry pattern, while 150 hand-checked ones teach a sharp one. Budget real review time. Fixing a flawed example after training means retraining.
Reserve your test set now
Write 20 held-out test complaints in a separate file, held_out_complaints.json, before you train. They must not appear in the training file, and should not be rephrasings of training items. Cover complaint types that are well represented in training and at least three that are rare or absent.
Guided practice
Cover at least 5 complaint categories. Sample 15 of your own pairs at random and check each against the five rules. If any fail, fix those and sample again before moving on.
Milestone 2: Run the LoRA Fine-Tune
This milestone trains a small LoRA adapter on your dataset and keeps a validation split so you can see overfitting.
Full fine-tuning updates every weight, which is slow, costly and more likely to drift away from the model's general ability. LoRA trains only small low-rank matrices added to chosen layers, so it needs far less memory and time and leaves the original weights untouched.
Install dependencies
pip install torch transformers peft datasets accelerate anthropicPin the versions that work for you in a requirements.txt once the run succeeds. Library argument names change between releases.
Prepare and tokenise the data
Two details matter. The end-of-sequence token is added to every example, so the model learns to stop after the reply. The padding token is set to the end token, because Llama tokenizers ship without one.
Create train.py:
import jsonimport torchfrom datasets import Datasetfrom peft import LoraConfig, TaskType, get_peft_modelfrom transformers import ( AutoModelForCausalLM, AutoTokenizer, DataCollatorForLanguageModeling, Trainer, TrainingArguments,) MODEL_NAME = "meta-llama/Llama-3.2-1B" tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)tokenizer.pad_token = tokenizer.eos_token def format_example(ex): return { "text": f"### Customer: {ex['input']}\n### Support: {ex['target_response']}{tokenizer.eos_token}" } with open("tone_training_data.json") as f: raw_data = json.load(f) dataset = Dataset.from_list([format_example(ex) for ex in raw_data])dataset = dataset.train_test_split(test_size=0.1, seed=42) def tokenize(batch): return tokenizer(batch["text"], truncation=True, max_length=256) tokenized = dataset.map(tokenize, batched=True, remove_columns=["text"])collator = DataCollatorForLanguageModeling(tokenizer, mlm=False)The ### Customer: and ### Support: markers form a prompt template. Use exactly the same template at inference, since a different format will hurt results.
Configure the adapter
base_model = AutoModelForCausalLM.from_pretrained(MODEL_NAME) lora_config = LoraConfig( task_type=TaskType.CAUSAL_LM, r=8, lora_alpha=16, lora_dropout=0.05, target_modules=["q_proj", "v_proj"],) model = get_peft_model(base_model, lora_config)model.print_trainable_parameters()Expected output is under one percent of parameters, roughly like this (exact numbers depend on the model and library version):
trainable params: 851,968 || all params: 1,236,666,368 || trainable%: 0.0689Engineering Decision
r=8is a modest rank for a small, focused task. A higher rank trains more parameters and can capture more complex behaviour, but it costs more and overfits more easily on a small dataset. Start low and raise the rank only if evaluation shows the adapter underfits.
Train and save
use_bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported() args = TrainingArguments( output_dir="./runs", num_train_epochs=3, per_device_train_batch_size=4, gradient_accumulation_steps=2, learning_rate=2e-4, logging_steps=10, eval_strategy="epoch", # older transformers versions call this evaluation_strategy save_strategy="epoch", bf16=use_bf16, fp16=torch.cuda.is_available() and not use_bf16, report_to="none",) trainer = Trainer( model=model, args=args, train_dataset=tokenized["train"], eval_dataset=tokenized["test"], data_collator=collator,) trainer.train()model.save_pretrained("./tone-lora-adapter")tokenizer.save_pretrained("./tone-lora-adapter")python train.pyWatch both losses. If training loss keeps falling while validation loss starts rising, the adapter is memorising the examples and you should train for fewer epochs.
Common MistakeTreating a falling training loss as proof the fine-tune worked. It shows only that the model fits the training data. Whether the behaviour carries over to new complaints is what Milestone 3 measures.
Troubleshooting a tuned model that sounds unchanged
If replies on new complaints still read like the base model, check these three things in order:
print_trainable_parametersshowed a small non-zero number, so the adapter was attached- Training loss actually decreased across the epochs
- Your inference code loads
./tone-lora-adapter, and is not accidentally running the base model alone
Milestone 3: Compare the Tuned Model Against the Base Model
This milestone proves the fine-tune changed behaviour on complaints the model never saw, using a judge and simple rule checks.
A model that reproduces its training examples but fails on new ones has memorised the dataset instead of learning the tone. Evaluating only on training data is the fine-tuning version of data leakage and gives a meaningless success signal.
Load the models correctly
A subtle trap: get_peft_model modifies the base model in place, so the base_model variable from training is no longer a clean baseline. Load fresh from disk, and use disable_adapter() to get true base-model behaviour from the same weights.
Create evaluate.py:
import jsonimport re import torchfrom peft import PeftModelfrom transformers import AutoModelForCausalLM, AutoTokenizer MODEL_NAME = "meta-llama/Llama-3.2-1B"tokenizer = AutoTokenizer.from_pretrained("./tone-lora-adapter")base = AutoModelForCausalLM.from_pretrained(MODEL_NAME)tuned = PeftModel.from_pretrained(base, "./tone-lora-adapter")device = "cuda" if torch.cuda.is_available() else "cpu"tuned.to(device).eval() def generate(prompt: str, use_adapter: bool, max_new_tokens: int = 80) -> str: inputs = tokenizer(prompt, return_tensors="pt").to(device) def _run(): with torch.no_grad(): return tuned.generate( **inputs, max_new_tokens=max_new_tokens, do_sample=False, # greedy, so results are repeatable pad_token_id=tokenizer.eos_token_id, ) if use_adapter: out = _run() else: with tuned.disable_adapter(): out = _run() new_tokens = out[0][inputs["input_ids"].shape[1]:] text = tokenizer.decode(new_tokens, skip_special_tokens=True) return text.split("### Customer:")[0].strip() # cut off any invented next turn def support_prompt(complaint: str) -> str: return f"### Customer: {complaint}\n### Support:"Run both models on the held-out set
with open("held_out_complaints.json") as f: held_out = json.load(f) # list of 20 strings results = []for complaint in held_out: p = support_prompt(complaint) results.append({ "complaint": complaint, "base": generate(p, use_adapter=False), "tuned": generate(p, use_adapter=True), }) with open("results_tone.json", "w") as f: json.dump(results, f, indent=2)Score with rule checks and a judge
Two of your five rules are mechanical, so check them with code and do not spend judge calls on them. Use an LLM judge for the rest.
import osimport anthropic BANNED = ["per our policy", "we regret to inform you", "your satisfaction is our priority", "inconvenience"]HEDGES = ["might be able", "may be able", "pending review", "we will try"] def rule_checks(reply: str) -> dict: sentences = [s for s in re.split(r"(?<=[.!?])\s+", reply.strip()) if s] low = reply.lower() return { "no_banned_phrases": not any(b in low for b in BANNED), "no_hedging": not any(h in low for h in HEDGES), "max_3_sentences": len(sentences) <= 3, } client = anthropic.Anthropic()JUDGE_MODEL = os.environ["JUDGE_MODEL"] # set to a current Claude model id TONE_JUDGE_PROMPT = """Score this customer support reply from 0 to 5 against these rules.Give one point for each rule that is fully met:1. Acknowledges the customer's frustration first2. States the resolution clearly, with no hedging3. Uses no corporate phrases4. Is 3 sentences or fewer5. Ends with a concrete next step Customer message: {complaint}Reply: {reply} Answer with JSON only: {{"score": <0-5>, "missing": "<short note>"}}""" def judge_tone(complaint: str, reply: str) -> int: msg = client.messages.create( model=JUDGE_MODEL, max_tokens=150, messages=[{"role": "user", "content": TONE_JUDGE_PROMPT.format(complaint=complaint, reply=reply)}], ) text = msg.content[0].text match = re.search(r"\{.*\}", text, re.S) return int(json.loads(match.group(0))["score"]) for r in results: for arm in ("base", "tuned"): r[f"{arm}_score"] = judge_tone(r["complaint"], r[arm]) r[f"{arm}_rules"] = rule_checks(r[arm]) def summarise(arm: str): scores = [r[f"{arm}_score"] for r in results] avg = sum(scores) / len(scores) strong = sum(s >= 4 for s in scores) / len(scores) return round(avg, 2), f"{strong:.0%}" print("base ", summarise("base"))print("tuned", summarise("tuned"))Score all 20 base replies and all 20 tuned replies with the same judge prompt, and keep the judge model fixed for the whole project. The average score and the share of replies scoring 4 or higher are your two core numbers. The second one is your consistency measure.
TipWith only 20 test cases, small differences are noise. Read a handful of replies yourself next to the scores, and treat a gap of a few tenths of a point as inconclusive.
Concept check
Before you read the scores, predict which complaint types the tuned model will handle best and which it will still struggle with. Types close to your training examples should score higher. Types your data under-represented show the real limits, which the average hides.
Milestone 4: Test for Catastrophic Forgetting
This milestone checks that teaching one narrow tone did not damage the model's general ability.
Catastrophic forgetting is when fine-tuning on a narrow task degrades behaviour you never meant to change. A model trained only on short, warm support replies can start giving oddly terse answers to a general knowledge question. Your tone evaluation will not reveal this, so it needs its own test.
Build an unrelated-task test set
Use 10 prompts that have nothing to do with support tone:
unrelated_prompts = [ "Explain how photosynthesis works in simple terms.", "What is the capital of Karnataka?", "Write a short paragraph about the history of chess.", "What are the main differences between TCP and UDP?", "Summarise how a binary search works.", "Why is the sky blue?", "Give three tips for writing a clear email.", "What is the difference between a list and a tuple in Python?", "Explain what inflation is to a teenager.", "Describe how vaccines train the immune system.",]Run both models and judge the pairs
Use a plain prompt for these, not the support template, so you test general behaviour. Randomise which answer appears first so the judge's position bias does not skew results.
import random FORGETTING_JUDGE_PROMPT = """Compare two answers to the same general knowledge question.Is Answer B clearly worse than Answer A in quality, coherence or relevance?Reply with JSON only: {{"b_worse": true or false, "reason": "<short>"}} Question: {prompt}Answer A: {a}Answer B: {b}""" def plain_prompt(q: str) -> str: return f"Question: {q}\nAnswer:" forgetting = []for q in unrelated_prompts: base_ans = generate(plain_prompt(q), use_adapter=False, max_new_tokens=150) tuned_ans = generate(plain_prompt(q), use_adapter=True, max_new_tokens=150) tuned_is_b = random.random() < 0.5 a, b = (base_ans, tuned_ans) if tuned_is_b else (tuned_ans, base_ans) msg = client.messages.create( model=JUDGE_MODEL, max_tokens=150, messages=[{"role": "user", "content": FORGETTING_JUDGE_PROMPT.format(prompt=q, a=a, b=b)}], ) verdict = json.loads(re.search(r"\{.*\}", msg.content[0].text, re.S).group(0)) # Tuned is "worse" if it was B and B was judged worse, or it was A and B was not judged worse tuned_worse = verdict["b_worse"] if tuned_is_b else not verdict["b_worse"] forgetting.append({"prompt": q, "base": base_ans, "tuned": tuned_ans, "tuned_worse": tuned_worse}) print("degraded:", sum(f["tuned_worse"] for f in forgetting), "of", len(forgetting))Engineering DecisionIf forgetting shows up, reduce epochs or rank and retrain before calling the adapter ready. A tone fine-tune that quietly breaks every non-support question is not an improvement, even if the tone score looks great.
Read the result
If several answers degrade, the usual causes are too many epochs on a very narrow dataset or a rank that is too high for the data size. Both push the adapter to fit the training style at the expense of general ability. The fix is fewer epochs, a lower rank or a somewhat more varied dataset. Going to full fine-tuning would raise the risk, not lower it.
Common MistakeSkipping the forgetting test because the tone target was hit. A regression outside the target task only appears if you look for it.
Guided practice
If you see degradation, retrain with num_train_epochs=1, rerun the tone evaluation and the forgetting test, and compare all four results (tone and forgetting, before and after the change) to see the trade-off directly.
Milestone 5: Justify Fine-Tuning Against Prompting and RAG
This milestone compares your tuned model with the strongest non-training alternatives and turns the numbers into a recommendation.
RAG solves a missing-knowledge problem: the model needs a fact it does not have at answer time. This task has no missing fact, because the model already knows what a refund and a late delivery are. What changes is the phrasing. Retrieving more refund policy text cannot stop a reply from opening with "We regret to inform you." The closest RAG-shaped fix is retrieving a tone guideline and placing it in the prompt, which makes it prompting, so that is what you test.
with the output?}:::judge q -->|missing facts| rag[Use RAG]:::cloud q -->|tone or format| try[Try prompting first]:::slate try --> ok{Good enough
and consistent?}:::judge ok -->|yes| keep[Keep prompting]:::good ok -->|no| ft[Fine-tune with LoRA]:::tool classDef judge fill:#FFEDD5,color:#1E293B classDef cloud fill:#326CE5,color:#FFFFFF classDef slate fill:#475569,color:#FFFFFF classDef good fill:#10B981,color:#FFFFFF classDef tool fill:#8B5CF6,color:#FFFFFF
Test the guideline prompt and a few-shot prompt
Add two prompt-only arms to evaluate.py, both using the base model with the adapter disabled:
TONE_GUIDELINE = """Support tone guidelines:Acknowledge the customer's frustration first. Keep replies to 3 sentences or fewer.Avoid corporate phrases. Always end with a concrete next step.""" FEW_SHOT = """### Customer: My order arrived without the drink I paid for.### Support: Missing a paid item is annoying, sorry about that. I've refunded the drink to your original payment method. It will show in 3 to 5 business days.""" def guided_prompt(complaint: str) -> str: return f"{TONE_GUIDELINE}\n{support_prompt(complaint)}" def few_shot_prompt(complaint: str) -> str: return f"{TONE_GUIDELINE}\n{FEW_SHOT}{support_prompt(complaint)}" for r in results: r["guided"] = generate(guided_prompt(r["complaint"]), use_adapter=False) r["few_shot"] = generate(few_shot_prompt(r["complaint"]), use_adapter=False) r["guided_score"] = judge_tone(r["complaint"], r["guided"]) r["few_shot_score"] = judge_tone(r["complaint"], r["few_shot"]) print("guided ", summarise("guided"))print("few_shot", summarise("few_shot"))Test drift over a longer conversation
Prompting is often blamed for drifting in long conversations, so test it instead of assuming. Run an 8-turn exchange and score the last three replies. The prompt arms keep the guideline at the top of a growing transcript, and the tuned arm sees the same transcript with no guideline.
def long_run(arm: str, complaints: list[str], turns: int = 8) -> list[int]: history = "" scores = [] for i, complaint in enumerate(complaints[:turns]): header = TONE_GUIDELINE if arm == "guided" else "" prompt = f"{header}{history}### Customer: {complaint}\n### Support:" reply = generate(prompt, use_adapter=(arm == "tuned"), max_new_tokens=80) history += f"### Customer: {complaint}\n### Support: {reply}\n" if i >= turns - 3: scores.append(judge_tone(complaint, reply)) return scores for arm in ("guided", "tuned"): s = long_run(arm, held_out) print(arm, "late-turn average:", round(sum(s) / len(s), 2))Greedy decoding keeps this repeatable, but one run is still a small sample. Run it on two or three different orderings of the held-out list and compare.
Fill in the four-way comparison
Build this table from your own measurements:
| Approach | Avg tone score (0-5) | Share scoring 4 or higher | Late-turn average | Needs training |
|---|---|---|---|---|
| Base model, no help | Fill in | Fill in | Not run | No |
| Base plus tone guideline | Fill in | Fill in | Fill in | No |
| Base plus guideline and one example | Fill in | Fill in | Not run | No |
| LoRA fine-tuned model | Fill in | Fill in | Fill in | Yes |
Add one line for forgetting: how many of the 10 unrelated answers degraded for the tuned model.
Common MistakeDeciding the answer before measuring. A good guideline or a few examples on a capable model can come close to a fine-tune on a short single-turn task. If your numbers show that, the honest recommendation is prompting, since it needs no training, no adapter to host and no retraining when the tone changes. Fine-tuning earns its place when the gap is large and stays large in long conversations.
Write the justification
Your one-page recommendation should state, with your measured numbers:
- How the tuned model's average and consistency compare with the best prompt-only arm
- Whether forgetting appeared, and what you changed to fix it
- Why RAG was never the right tool here, tied to what RAG actually solves
- What would have to change for the other option to win, for example a tone that changes weekly favours prompting, and a changing refund policy is a RAG problem that no training can fix
Common MistakeFine-tuning to fix a knowledge gap that RAG would solve faster. If the real complaint were "the bot quotes outdated refund amounts", retraining would be the wrong tool, because the policy changed after your training data was collected.
Capstone lab checkpoint
Complete the table with real numbers, then write the one-page justification. Stating what would flip your decision shows that you understand the choice, and not only the LoRA mechanics.
Validation & Testing
These checks confirm your data, your adapter and your evidence are sound before you present the project.
1. Dataset quality
Sample 15 training pairs at random and confirm each passes all five tone rules. Run the rule checker from Milestone 3 over every target_response as well:
failures = [ex for ex in raw_data if not all(rule_checks(ex["target_response"]).values())]print(len(failures), "training examples fail the mechanical rules")Expected output: 0 training examples fail the mechanical rules.
2. No test leakage
train_inputs = {ex["input"].strip().lower() for ex in raw_data}leaked = [c for c in held_out if c.strip().lower() in train_inputs]print(len(leaked), "held-out complaints appear in training")Expected output: 0 held-out complaints appear in training.
3. Training completed
ls ./tone-lora-adapter/Expected output: adapter_config.json and adapter_model.safetensors are present, plus the tokenizer files.
4. Tone improvement is measurable
Confirm the tuned model's average tone score on the 20 held-out complaints is clearly higher than the base model's, and that the gap is larger than the run-to-run noise you saw when repeating the evaluation.
5. Forgetting test
Confirm the judge flagged no more than a small number of the 10 unrelated answers as degraded. If it flagged more, retrain with fewer epochs or a lower rank and rerun both evaluations.
6. Comparison is complete
Confirm every cell in the four-way table holds a real measured number, and the written justification explains why RAG does not apply and what would change your recommendation.
Quick reference
| Concept | What it means here |
|---|---|
LoRA rank (r) |
Controls how many adapter parameters train. Lower means less overfitting risk |
| Held-out test set | Complaints never seen in training, which proves generalisation |
| Validation loss | Loss on a held-back slice of training data, which reveals memorising |
disable_adapter() |
Gives true base-model output from the same loaded weights |
| Catastrophic forgetting | Fine-tuning degrading unrelated ability, checked with off-topic prompts |
| Tone rubric score | 0 to 5 score for the five written tone rules |
Portfolio deliverable
Keep the adapter folder, the dataset, results_tone.json, the forgetting results and your one-page recommendation together. A fine-tune with no evaluation evidence looks the same as one that was never tested, so the numbers are what make this project credible. Report your own measured scores and avoid quoting a target as if it were a result.