Python & Data Science
LLMs & GenAI Under review

Fine-Tuning vs. RAG: How to Actually Decide

Last time, Rae mapped out her three levers for steering an LLM—prompt engineering, in-context learning, and fine-tuning—and ran each through her evaluation harness. After weeks of iteration, her scores plateaued. The bot nailed tone and format but still couldn’t answer questions about the company’s return policy or three-tier warranty—no prompt pattern could inject knowledge the model didn’t have. So she narrowed her real choice to two contenders: fine-tune on her company’s data, or lean harder into RAG. One changes the model’s brain. The other changes what it can see.

The Problem: Your LLM Doesn’t Know Your Stuff

Rae’s support bot has been live for a few weeks. A customer asks it: “What’s your company’s policy on remote work after the acquisition?” The bot responds with confidence: “Your company allows remote work on Tuesdays and Thursdays, with mandatory office days on Monday, Wednesday, and Friday.”

One problem: Rae’s company never said that. Her actual policy is completely different. The bot made it up—and this is one more example of the general problem every team faces when deploying an LLM.

A general-purpose language model—like GPT-3.5 or Llama 2—was trained on billions of words from the public internet. It learned patterns about how language works, how to reason, how to write code. But it has never seen your proprietary data: your internal docs, your product specs, your customer contracts, your research papers. Ask it something specific to your world, and it doesn’t have the knowledge to answer correctly. It does something worse than staying silent. It confidently hallucinates.

The fix seems simple: just ask the model harder. Add more context to your prompt. Write better instructions. But you can’t teach a model facts it doesn’t have in its weights through prompting alone. Prompting is like asking someone to recall a fact they never learned. No amount of rephrasing the question will help.

So you’re left with two fundamentally different strategies:

  1. Change the model: Fine-tune it on your data so it actually learns your domain.
  2. Change what it can see: Use retrieval-augmented generation (RAG) to feed it your data at query time, without changing the model weights.

Before deciding which is right for you, here’s what the hallucination problem actually looks like.

Seeing Hallucination in Action

We’ll use a small open-source model to demonstrate. Ask it a domain-specific question, and watch it invent an answer with confidence.

# First, install the required library
# pip install transformers torch

from transformers import pipeline

# Load a small language model (this downloads ~4GB, so it may take a moment)
llm = pipeline('text-generation', model='gpt2', device=0)  # device=0 for GPU, -1 for CPU

# Ask it something domain-specific that it has no knowledge of
question = "What is the quarterly revenue of Acme Corp for Q3 2024?"

response = llm(question, max_length=100, do_sample=False)
print("Question:", question)
print("\nModel response:")
print(response[0]['generated_text'])

This block loads a small language model (GPT-2) and asks it a domain-specific question it has never seen, demonstrating hallucination.

  • # pip install transformers torch — the install command for the Hugging Face transformers library and PyTorch. Both are required to run the pipeline.
  • from transformers import pipeline — imports Hugging Face’s high-level pipeline function, which wraps model loading and inference into a single callable.
  • llm = pipeline('text-generation', model='gpt2', device=0) — loads GPT-2 (a ~500M parameter model that downloads as ~4GB of weights) and places it on GPU. Use device=-1 for CPU.
  • question = "What is the quarterly revenue of Acme Corp for Q3 2024?" — a question about a fictional company. GPT-2 has never heard of “Acme Corp” in any real financial context.
  • response = llm(question, max_length=100, do_sample=False) — generates up to 100 tokens with greedy decoding. do_sample=False means no randomness: the model always picks the highest-probability next token, making the output deterministic.
  • response[0]['generated_text'] — extracts the generated text from the first (and only) result in the response list.

The model will produce something that sounds like a financial report — a dollar figure, a percentage change — but the numbers are fabricated.

For Rae, this is exactly what her support bot did with the remote-work policy: it had no real policy data, so it generated text that looked like a policy answer but was entirely invented.

Run this, and you’ll see the model produce something plausible but completely made up. It might say something like “The quarterly revenue of Acme Corp for Q3 2024 was $45.2 million, up 12% from Q2.” None of that is real. The model has never heard of Acme Corp. It doesn’t know what quarter we’re in. Yet it generates text that sounds true, because it learned statistical patterns about how financial reports are written.

That’s hallucination: the model confidently producing false information, filling gaps in its knowledge with plausible-sounding text.

What if we just ask the model to use external knowledge?

# Let's try giving the model a hint to use external knowledge
question_with_hint = """Use the following information to answer the question.
Information: Acme Corp Q3 2024 revenue was $52.1 million, up 8% from Q2.
Question: What is the quarterly revenue of Acme Corp for Q3 2024?"""

response = llm(question_with_hint, max_length=100, do_sample=False)
print("Question with hint:")
print(question_with_hint)
print("\nModel response:")
print(response[0]['generated_text'])

This block tests whether manually inserting the correct information into the prompt fixes the hallucination — and it does, but with a critical caveat.

  • question_with_hint = """...""" — a triple-quoted string containing two parts. The Information: line provides the ground-truth fact; the Question: line asks the model to answer using that information.
  • response = llm(question_with_hint, max_length=100, do_sample=False) — generates a response with the same greedy decoding settings as before. The model now has the answer right there in its context window, so it can regurgitate $52.1 million instead of inventing a number.

The key insight is in who found the information: we did. The model didn’t retrieve anything — we manually pasted the correct fact into the prompt. This demonstrates that putting facts in the prompt works, but it’s not automated retrieval.

For Rae, this is the difference between her support bot getting the right answer because she hand-pasted a passage from the product manual (a hint) versus the bot automatically searching the manual and finding the right passage itself (RAG). The hint proves the model can use external information; RAG automates the finding of that information.

With the hint, the model might do better—the right information is right there in the prompt. But notice what happened. We had to find that information and put it there. The model didn’t retrieve it. It didn’t know where to look. We did.

Here’s the key insight: prompting alone doesn’t solve the problem. You need to either:

  1. Give the model the knowledge it needs (by fine-tuning it), or
  2. Give the model access to look up the knowledge (by using RAG).

So what does each approach actually mean, and when should you use each one?

What Fine-Tuning Actually Does

Fine-tuning sounds simple: you take a pre-trained model and train it further on your own data. But what’s actually happening under the hood?

A base model is like a college graduate who’s read widely but has never worked in your specific industry. Fine-tuning is like hiring that person and training them on the job for six months. By the end, they’ve internalized your processes, your terminology, your way of thinking. They don’t need to look things up—they just know.

When you fine-tune on your data, you’re updating the model’s weights—the internal numerical parameters that define how it processes text. You’re making the model remember your data in a way that’s baked into the model itself.

In practice, it starts with the training data — pairs of question and correct answer:

# Fine-tuning example (conceptual)
# In reality, you'd use a library like Hugging Face's Trainer
# This shows the idea:

# Step 1: Prepare your training data
training_data = [
    {
        "input": "What is our remote work policy?",
        "output": "Our remote work policy allows full-time employees to work remotely up to 3 days per week, with at least 2 days in the office."
    },
    {
        "input": "How many vacation days do employees get?",
        "output": "Full-time employees receive 20 vacation days per year, plus 10 company holidays."
    },
    {
        "input": "What's the process for requesting time off?",
        "output": "Submit requests through the HR portal at least 2 weeks in advance. Managers typically approve within 48 hours."
    },
    # ... many more examples
]
  • training_data = [...] — a list of dictionaries, each with an "input" key (the question) and an "output" key (the expected answer).
  • First example — maps “What is our remote work policy?” to the correct policy text. This is the kind of Q&A pair that teaches the model to respond with the right information.
  • Second and third examples — cover vacation days and time-off requests, showing that fine-tuning data needs diverse examples across the domain.
  • # ... many more examples — signals this is a toy dataset. Real fine-tuning needs hundreds or thousands of such pairs.

With the data prepared, training and deployment follow. The actual implementation depends on your framework, so the steps below are pseudocode:

# Step 2: Fine-tune the model
# (This is pseudocode—the actual implementation depends on your framework)
# model.fine_tune(training_data, epochs=3, learning_rate=2e-5)

# Step 3: Use the fine-tuned model
# It now "knows" your HR policies because they're in its weights
# question = "What is our remote work policy?"
# response = fine_tuned_model(question)
# Output: "Our remote work policy allows full-time employees to work remotely..."

print("Fine-tuning workflow:")
print("1. Collect domain-specific training examples")
print("2. Update model weights on that data")
print("3. Model now 'remembers' your domain")
print("4. Deploy the new model")
  • # model.fine_tune(training_data, epochs=3, learning_rate=2e-5) — commented-out pseudocode. epochs=3 means the model sees all training examples 3 times; learning_rate=2e-5 (0.00002) is a small learning rate that updates weights gradually to avoid catastrophic forgetting.
  • # response = fine_tuned_model(question) — shows the end state. After fine-tuning, the model “knows” the policy because the Q&A pairs are baked into its weights, not its prompt.

For Rae, this would mean collecting hundreds of past support-ticket resolutions (question → correct answer pairs) and training the model on them — but as she’ll discover, this approach has real costs when her product docs change.

The upside is straightforward: once trained, the model is fast and self-contained. No retrieval needed at query time. The knowledge is already there.

But there are real costs. Fine-tuning requires:

  • Good training data: You need hundreds or thousands of high-quality examples. If your training data is noisy or biased, the model will learn the wrong patterns.
  • Compute resources: Training takes time and GPU memory. For a large model, this can be expensive.
  • Retraining when data changes: If your HR policy changes, or you add new product documentation, you need to retrain the model. You can’t just update a database.

The hardest part: it’s not a one-time thing. Your data will change. Your policies will evolve. Every time they do, you’re looking at another training run.

What RAG Actually Does

The other strategy is retrieval-augmented generation, or RAG.

RAG doesn’t change the model. It changes what the model can see. When a user asks a question, you search your knowledge base for relevant documents. You stuff those into the prompt along with the question. The model reads them and answers.

Think of it as hiring someone and giving them access to a library instead of training them on the job. They don’t need to remember everything—they just need to know how to look things up and synthesize what they find.

It has three parts: the knowledge base, a retrieval function that finds the documents relevant to a question, and a prompt builder that hands the retrieved documents to the model.

# RAG example (simplified)

# Step 1: Build a knowledge base
# In practice, you'd use a vector database like Pinecone, Weaviate, or Chroma
# For this example, we'll use a simple in-memory search

knowledge_base = [
    "Our remote work policy allows full-time employees to work remotely up to 3 days per week, with at least 2 days in the office.",
    "Full-time employees receive 20 vacation days per year, plus 10 company holidays.",
    "Submit time-off requests through the HR portal at least 2 weeks in advance. Managers typically approve within 48 hours.",
    "The acquisition closed on March 15, 2024. All employees retain their current benefits for 12 months.",
    "Our health insurance plan covers medical, dental, and vision. Employees pay 15% of premiums; the company covers 85%.",
]
# Step 2: When a user asks a question, retrieve relevant documents
def simple_search(query, knowledge_base, top_k=2):
    """Find the top-k most relevant documents (simplified: just keyword matching)"""
    query_words = set(query.lower().split())
    scores = []
    
    for doc in knowledge_base:
        doc_words = set(doc.lower().split())
        overlap = len(query_words & doc_words)  # How many words match?
        scores.append(overlap)
    
    # Get the top-k documents
    top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k]
    return [knowledge_base[i] for i in top_indices]
# Step 3: Build a prompt with the retrieved documents
user_question = "What is our remote work policy?"
retrieved_docs = simple_search(user_question, knowledge_base)

prompt = f"""Use the following information to answer the question.

Information:
{chr(10).join(retrieved_docs)}

Question: {user_question}

Answer:"""

print("RAG Workflow:")
print("1. User asks a question")
print("2. Search knowledge base for relevant documents")
print("3. Retrieve top-k documents")
print("4. Build a prompt with those documents")
print("5. Feed the prompt to the LLM")
print("\nExample prompt:")
print(prompt)
  • knowledge_base = [...] — a list of strings, where each string is a “document.” In production, these would be chunks from your product manual, HR docs, and so on.
  • def simple_search(query, knowledge_base, top_k=2): — a retrieval function taking a query string and the knowledge base, returning the top-k most relevant documents.
  • query_words = set(query.lower().split()) — splits the query into lowercase words and converts to a set for fast intersection.
  • doc_words = set(doc.lower().split()) — does the same for each document.
  • overlap = len(query_words & doc_words) — counts the shared words between query and document. This is a simplified keyword-matching score; real RAG uses embeddings and cosine similarity, as Rae learned in article 1.
  • top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:top_k] — sorts document indices by overlap score in descending order and takes the top-k.
  • return [knowledge_base[i] for i in top_indices] — returns the actual document strings.
  • prompt = f"""...""" — an f-string prompt that inserts the retrieved documents and the user’s question.
  • chr(10).join(retrieved_docs) — joins the retrieved documents with newlines. chr(10) is the newline character.
  • The printed workflow steps — summarize the 5-stage RAG pipeline: ask → search → retrieve → build prompt → generate.

For Rae, this is the pipeline she already built in article 3 over her product manual. The simple_search here is a toy keyword version, but her production system uses embeddings and a vector database for semantic search.

RAG is flexible. Your knowledge base is just a database. When your remote work policy changes, you update the database. You don’t retrain anything. The model stays the same.

But RAG has its own challenges:

  • Retrieval quality matters: If your search doesn’t find the right documents, the model can’t answer correctly. Garbage in, garbage out.
  • Latency: Every query requires a search step. This adds latency compared to a fine-tuned model.
  • Hallucination still happens: If the retrieved documents don’t contain the answer, the model might still make something up. RAG reduces hallucination but doesn’t eliminate it.

When to Use Fine-Tuning

So when should you actually fine-tune?

Fine-tuning makes sense when:

  1. Your data is stable and well-defined: You have a clear, bounded knowledge set that rarely shifts. A model trained to recognize your company’s logo won’t need updates next week.

  2. You need speed and low latency: Fine-tuned models are fast. No retrieval step, no lookup overhead. If you’re building a real-time system where milliseconds matter, the upfront cost pays off.

  3. You have high-quality training data: You’ve curated hundreds or thousands of examples, and you’re confident they’re correct. Garbage training data produces a garbage model.

  4. The knowledge is truly domain-specific: You’re not just adding facts. You’re teaching the model a new way of thinking or writing. If you want a model that captures your company’s specific tone and style, fine-tuning can bake that in.

  5. You can afford retraining: When your data changes significantly, you’ll retrain. If that fits your workflow, fine-tuning is viable.

Here’s what this looks like in practice:

# Example: Fine-tuning makes sense here
# You're building a medical coding assistant for a hospital
# Your data: 5,000 examples of (patient notes → correct medical codes)
# This data is stable (coding standards don't change weekly)
# You need fast inference (doctors are waiting for the suggestion)
# You have high-quality training data (reviewed by medical coders)

fine_tuning_use_case = {
    "task": "Medical coding assistant",
    "data_size": 5000,
    "data_stability": "High (coding standards are stable)",
    "latency_requirement": "Low (doctors need fast suggestions)",
    "data_quality": "High (reviewed by experts)",
    "retraining_frequency": "Quarterly",
    "verdict": "FINE-TUNING IS A GOOD FIT"
}

print(f"Use case: {fine_tuning_use_case['task']}")
print(f"Verdict: {fine_tuning_use_case['verdict']}")

This block encodes the profile of a use case where fine-tuning is the right call — a medical coding assistant.

  • "data_size": 5000 — 5,000 labeled examples, well above the 500-example minimum threshold.
  • "data_stability": "High (coding standards are stable)" — the mapping from patient notes to medical codes doesn’t change frequently. The ICD-10 coding standard updates yearly, not weekly.
  • "latency_requirement": "Low (doctors need fast suggestions)" — the model must respond in near real-time, which rules out an added retrieval step.
  • "data_quality": "High (reviewed by experts)" — the training labels were verified by professional medical coders, not scraped from forums.
  • "retraining_frequency": "Quarterly" — retraining 4 times a year is acceptable, not a daily burden.
  • "verdict": "FINE-TUNING IS A GOOD FIT" — the human-readable conclusion.
  • print(f"Use case: {fine_tuning_use_case['task']}") — this and the next line print the task name and verdict using f-string interpolation.

For Rae, this example is instructive but doesn’t match her situation: her support bot’s knowledge (product docs) changes more often than coding standards, and her bot already has a RAG pipeline that handles the fact-finding part. What she’d fine-tune for is style and tone, not facts.

When to Use RAG

RAG makes sense when:

  1. Your data changes frequently: New documents, updated policies, refreshed information. RAG lets you update the knowledge base without retraining.

  2. You have a large, unstructured knowledge base: Thousands of PDFs, wiki pages, support tickets. RAG searches through all of it—no need to fine-tune on every document.

  3. You need explainability: RAG can show the user which documents the model used to answer. Fine-tuning bakes the knowledge into the weights, so you can’t easily point to the source.

  4. You want to avoid retraining: If retraining is expensive or logistically painful, RAG’s flexibility matters.

  5. You’re not sure what the right answer is: RAG retrieves multiple documents and lets the model synthesize them. Fine-tuning requires ground truth for training.

Here’s a realistic example:

# Example: RAG makes sense here
# You're building a customer support chatbot for a SaaS company
# Your data: 10,000 support articles, updated daily
# Articles change constantly (new features, bug fixes)
# You need to show customers which article you're referencing
# You can't afford to retrain the model every day

rag_use_case = {
    "task": "Customer support chatbot",
    "data_size": 10000,
    "data_stability": "Low (articles updated daily)",
    "explainability_needed": True,
    "retraining_frequency": "Can't afford daily retraining",
    "knowledge_source_diversity": "High (many different articles)",
    "verdict": "RAG IS A GOOD FIT"
}

print(f"Use case: {rag_use_case['task']}")
print(f"Verdict: {rag_use_case['verdict']}")

This block encodes the profile of a use case where RAG is the right call — and it’s essentially Rae’s situation.

  • "data_size": 10000 — 10,000 support articles, too many to fine-tune on individually.
  • "data_stability": "Low (articles updated daily)" — the knowledge base changes constantly as new features ship and bugs get fixed. Retraining every day is impractical.
  • "explainability_needed": True — the system needs to show users which article it used. Fine-tuning can’t satisfy this, because the knowledge is baked into weights rather than retrievable.
  • "retraining_frequency": "Can't afford daily retraining" — makes the cost of fine-tuning explicit. If articles change daily, fine-tuning would require a training run every single day.
  • "knowledge_source_diversity": "High (many different articles)" — the knowledge spans many distinct documents rather than a compact rulebook.
  • "verdict": "RAG IS A GOOD FIT" — the conclusion. print(f"Use case: {rag_use_case['task']}") and the next line print the task and verdict.

This is close to Rae’s situation: her product manual is a well-defined corpus that changes occasionally (not daily), but she needs explainability (showing customers which passage the bot used) and her RAG pipeline is already built. The question is whether fine-tuning adds enough on top of RAG to be worth the cost.

The Hybrid Approach: Fine-Tuning + RAG

Here’s the thing: you don’t have to choose. You can do both.

Fine-tune the model on your style and general domain knowledge, then use RAG to feed it specific facts at query time.

Say you fine-tune on 1,000 examples of how your company writes technical documentation. The model learns your tone, your terminology, your structure. Then, at query time, you use RAG to retrieve the specific document the user is asking about. The model reads it and answers in your company’s voice.

What you get from this hybrid setup:

  • Fast inference: The model already knows your domain, so it understands context quickly.
  • Up-to-date information: RAG gives the model access to the latest facts.
  • Explainability: You can show which document the model used.
  • Flexibility: You can update your knowledge base without retraining.

Here’s how you’d structure it. The fine-tuning data teaches style, not facts:

# Hybrid approach: Fine-tuning + RAG

# Step 1: Fine-tune on domain style and general knowledge
fine_tuning_data = [
    # Examples of your company's writing style and general domain knowledge
    # NOT specific facts that will change
    {
        "input": "Explain our architecture.",
        "output": "Our system is built on microservices, using Kubernetes for orchestration..."
    },
    # ... 1,000 more examples
]

# Step 2: Deploy the fine-tuned model
# fine_tuned_model = train_model(fine_tuning_data)
  • fine_tuning_data = [...] — a list of Q&A pairs. Note the critical distinction in the comment: # NOT specific facts that will change. This data teaches the model how to talk about the domain (tone, structure, terminology), not what the current numbers are.
  • "input": "Explain our architecture.""output": "Our system is built on microservices..." — teaches the model the company’s way of describing its system. This style is stable even if specific numbers change.
  • # fine_tuned_model = train_model(fine_tuning_data) — commented-out pseudocode for the training step.

The volatile facts live somewhere else — in the searchable knowledge base:

# Step 3: Build a RAG system on top
knowledge_base = [
    "The Q3 2024 revenue was $52.1 million, up 8% from Q2.",
    "We acquired TechCorp on March 15, 2024.",
    # ... thousands of specific facts
]
  • knowledge_base = [...] — the RAG knowledge base, where volatile facts live. "The Q3 2024 revenue was \$52.1 million, up 8% from Q2." is a fact that changes every quarter, so it belongs in the searchable database, not the training data.

At query time, the two halves come together:

# Step 4: At query time, use both
user_question = "What was our Q3 revenue?"
retrieved_docs = simple_search(user_question, knowledge_base)

prompt = f"""Use the following information to answer the question.

Information:
{chr(10).join(retrieved_docs)}

Question: {user_question}

Answer:"""

# response = fine_tuned_model(prompt)
# The model understands your domain (from fine-tuning)
# AND has access to current facts (from RAG)

print("Hybrid Approach:")
print("1. Fine-tune on domain style (1,000 examples)")
print("2. Build RAG on top for specific facts")
print("3. At query time: retrieve docs + use fine-tuned model")
print("4. Result: fast, current, explainable")
  • retrieved_docs = simple_search(user_question, knowledge_base) — calls the same keyword-search function from the RAG section. The retrieval step is unchanged.
  • prompt = f"""...""" — builds the same RAG prompt format, but now it’s fed to the fine-tuned model rather than the base model.
  • # response = fine_tuned_model(prompt) — the two-way benefit: the model understands your domain from fine-tuning, and has access to current facts from RAG.

For Rae, this is the architecture she’s converging on: fine-tune her bot to talk like her support team (stable style), then keep her product manual in the RAG pipeline (changing facts). The fine-tune makes the bot sound right; the RAG makes it be right.

The hybrid approach isn’t always necessary — for many use cases, RAG alone is enough. But when you need both speed and accuracy, it’s worth considering.

Fine-Tuning vs. RAG vs. Both: What Should Rae Actually Do?

Rae’s startup has specific constraints that narrow the field: limited engineering time, a single consumer GPU, and a well-defined product-docs corpus (the manual she built her RAG pipeline over in article 3). Here’s how the three options stack up against her actual situation:

FactorFine-Tuning AloneRAG AloneHybrid (Light Fine-Tune + RAG)
Engineering timeHigh — needs data prep, training runs, eval cyclesLow — she already built this pipelineMedium — adds a fine-tuning layer on top of existing RAG
Knowledge freshnessPoor — retrain every time the product manual changesExcellent — just update the vector DBExcellent — RAG still handles facts; fine-tune handles style
ExplainabilityPoor — knowledge baked into weights, can’t cite sourcesGood — can show retrieved passages to customersGood — RAG citations still work
LatencyLowest — no retrieval stepSlightly higher — adds search + retrievalSlightly higher — same as RAG alone
Rae’s product-docs corpusWell-defined but changes occasionally — retraining is wastefulAlready working — she built this in article 3Combines existing RAG with a style/tone fine-tune
What it actually fixesTeaches style, tone, and domain reasoning patternsFixes hallucinated facts by grounding in real docsFixes both: right tone and right facts

The decision for Rae’s startup:

Rae already has a working RAG pipeline over her product manual — that handles the facts. Her remaining problem from the prompt-engineering article was tone and format inconsistency: the bot sometimes sounded like a generic chatbot instead of her company’s support team. That’s a style problem, and style is exactly what fine-tuning is good at.

So the hybrid approach is the right call: keep RAG for facts (already built, already working), add a light fine-tune for tone and format consistency. She doesn’t fine-tune the model on her product manual’s contents — that’s what RAG is for and would be a retraining nightmare when the manual updates. She fine-tunes it on her support team’s style of answering questions — examples of good responses that sound like her brand.

When NOT to go hybrid:

  • If Rae’s only problem were hallucinated facts, RAG alone would fix it — no fine-tuning needed. The hint experiment above proved that giving the model the right information in the prompt solves the hallucination; RAG automates that retrieval.
  • If Rae’s product manual changed daily, fine-tuning on any of its contents would be a retraining treadmill — RAG alone would be simpler and more sustainable.
  • If Rae had no engineering time or GPU budget at all, she should stick with RAG + prompt engineering and skip fine-tuning entirely. The hybrid adds complexity that only pays off when style matters as much as accuracy.

The Decision Framework

So how do you actually decide? Here’s a framework for choosing between fine-tuning, RAG, and the hybrid approach.

The decision logic is a priority-ordered chain — the first matching condition wins. Three test cases exercise each branch:

# Decision framework

def choose_approach(use_case):
    """
    Decide between fine-tuning, RAG, and hybrid.
    
    Inputs:
    - data_stability: 'high' or 'low' (does your data change often?)
    - latency_requirement: 'strict' or 'flexible' (need fast responses?)
    - data_quality: 'high' or 'low' (do you have good training data?)
    - knowledge_size: 'small' or 'large' (how much knowledge do you have?)
    - explainability: True or False (need to show sources?)
    """
    
    data_stability = use_case['data_stability']
    latency = use_case['latency_requirement']
    data_quality = use_case['data_quality']
    knowledge_size = use_case['knowledge_size']
    explainability = use_case['explainability']
    
    # Decision logic
    if data_stability == 'high' and latency == 'strict' and data_quality == 'high':
        return "FINE-TUNING"
    elif data_stability == 'high' and knowledge_size == 'large' and data_quality == 'high':
        return "HYBRID (Fine-tuning + RAG)"
    elif data_stability == 'low' or knowledge_size == 'large':
        return "RAG"
    else:
        return "RAG (safest choice)"
# Test cases
test_cases = [
    {
        "name": "Medical coding",
        "data_stability": "high",
        "latency_requirement": "strict",
        "data_quality": "high",
        "knowledge_size": "small",
        "explainability": False,
    },
    {
        "name": "Customer support",
        "data_stability": "low",
        "latency_requirement": "flexible",
        "data_quality": "high",
        "knowledge_size": "large",
        "explainability": True,
    },
    {
        "name": "Internal documentation",
        "data_stability": "high",
        "latency_requirement": "flexible",
        "data_quality": "high",
        "knowledge_size": "large",
        "explainability": True,
    },
]

for test in test_cases:
    name = test.pop('name')
    recommendation = choose_approach(test)
    print(f"{name}: {recommendation}")
  • def choose_approach(use_case): — takes a dictionary with five keys: data_stability, latency_requirement, data_quality, knowledge_size, and explainability. The five use_case['...'] lines extract each into a local variable.
  • Branch 1 — data_stability == 'high' and latency == 'strict' and data_quality == 'high'"FINE-TUNING". Stable data, strict latency, and high quality mean fine-tuning is worth the upfront cost (the medical coding example).
  • Branch 2 — data_stability == 'high' and knowledge_size == 'large' and data_quality == 'high'"HYBRID". Stable data with a large knowledge base means you can afford to fine-tune for style and need RAG for the large fact base (Rae’s situation).
  • Branch 3 — data_stability == 'low' or knowledge_size == 'large'"RAG". If data changes frequently or the knowledge base is large, RAG is the practical choice.
  • else"RAG (safest choice)", the default fallback.
  • test_cases = [...] — three use cases as dictionaries, one per decision branch.
  • name = test.pop('name') — removes and returns the 'name' key. pop mutates the dictionary so the remaining keys match what choose_approach expects, since it doesn’t access 'name'.
  • recommendation = choose_approach(test) — calls the decision function.
  • print(f"{name}: {recommendation}") — prints the result.

When run, medical coding hits the first branch (fine-tuning), customer support hits the third (RAG, because data_stability == 'low'), and internal documentation hits the second (hybrid, because data_stability == 'high' and knowledge_size == 'large').

Rae’s support bot maps closest to the “Internal documentation” case — stable style, large knowledge base — which is why the hybrid approach is her recommendation.

Run it, and you’ll get:

  • Medical coding → Fine-tuning (stable data, need speed, high quality)
  • Customer support → RAG (data changes constantly, large knowledge base)
  • Internal documentation → Hybrid (stable data, large knowledge base, need explainability)

Every use case is different. But this gives you a starting point.

What Actually Matters: A Practical Checklist

Before committing to either approach, work through these questions:

For Fine-Tuning:

  • Do you have 500+ high-quality training examples? Without that volume, fine-tuning overfits.
  • Is your data stable for at least 3-6 months? If it shifts weekly, retraining gets rough.
  • Do you have GPU resources and time for training? Even small models take hours.
  • Is latency critical to your use case? If not, RAG is simpler.

For RAG:

  • Can you organize your knowledge into searchable documents? Scattered across 50 different systems, RAG gets hard.
  • Is retrieval quality good enough? Test your search on real queries.
  • Can you tolerate 100-500ms of added latency for retrieval? If not, fine-tuning might be the better call.

For Hybrid:

  • Do you have both stable domain knowledge and frequently-changing facts? If so, hybrid makes sense.
  • Can you afford the complexity of maintaining both systems? Hybrid is harder to debug.

Here’s a checklist to put into practice:

# Practical checklist

def evaluate_readiness(approach):
    """
    Check if you're ready for a given approach.
    """
    
    if approach == "fine-tuning":
        checks = {
            "Training data size >= 500 examples": False,  # Set to True if you have this
            "Data stable for 3+ months": False,
            "GPU resources available": False,
            "Latency is critical": False,
            "High-quality training data": False,
        }
        required_checks = sum(checks.values())
        print(f"Fine-tuning readiness: {required_checks}/5 checks passed")
        if required_checks >= 4:
            print("✓ You're ready for fine-tuning")
        else:
            print("✗ Consider RAG instead")
    
    elif approach == "rag":
        checks = {
            "Knowledge is organized in documents": False,
            "Search quality is acceptable": False,
            "Can tolerate 100-500ms latency": False,
            "Knowledge updates frequently": False,
            "Explainability is important": False,
        }
        required_checks = sum(checks.values())
        print(f"RAG readiness: {required_checks}/5 checks passed")
        if required_checks >= 3:
            print("✓ You're ready for RAG")
        else:
            print("✗ Consider fine-tuning instead")

# Example: Check your readiness
print("Checking readiness for fine-tuning...")
evaluate_readiness("fine-tuning")
print()
print("Checking readiness for RAG...")
evaluate_readiness("rag")

This block evaluates whether a team is prepared for fine-tuning or RAG.

  • def evaluate_readiness(approach): — takes either "fine-tuning" or "rag" and checks readiness for that approach.
  • checks = {...} — a dictionary where each key is a readiness criterion and each value is a boolean, False by default. In practice you’d set each based on your actual situation.
  • The five fine-tuning checks — training data ≥ 500 examples (below this, overfitting is likely), data stability for 3+ months, GPU availability, latency criticality, and data quality.
  • required_checks = sum(checks.values()) — sums the booleans, since True counts as 1 and False as 0.
  • if required_checks >= 4: — requires 4 of 5 to pass before recommending fine-tuning, a deliberately high bar since fine-tuning is expensive and hard to undo.
  • The RAG branch — the same structure with its own five checks: organized knowledge, acceptable search quality, latency tolerance, frequent knowledge updates, and explainability importance.
  • if required_checks >= 3: — requires only 3 of 5, a lower bar because RAG is safer and more reversible.

The two print calls at the bottom run both checks with all values set to False, so the output shows “0/5 checks passed” for both. In real usage, Rae would flip each boolean to True based on her situation and see which approach she’s ready for.

Wrapping Up: The Real Decision

Here’s what to remember:

Fine-tuning changes the model itself. You teach it facts and patterns that get baked into its weights. Fast, but inflexible. Reach for it when your data is stable, your training data is high-quality, and speed matters.

RAG changes what the model can see. You give it access to a knowledge base at query time. Flexible and easy to update — but slower, and only as good as your retrieval. Use it when your data shifts often or your knowledge base is large.

Hybrid combines both. Fine-tune for domain style, then use RAG for specific facts. The most capable option, but also the most complex to build and maintain.

There’s no universal right answer. The best choice depends on your constraints: data stability, latency requirements, data quality, and knowledge size. Start with RAG if you’re unsure. It’s simpler, more forgiving, easier to iterate on. If latency becomes a bottleneck later, or your data turns out to be truly stable, you can always add fine-tuning on top.

For Rae, the decision is clear. Her RAG pipeline already handles the facts — it retrieves from her product manual at query time, which she built in article 3. What she needs from fine-tuning is tone, format consistency, and domain-specific reasoning: the way her support team talks, not the product specs themselves. A light fine-tune on top of her existing RAG setup makes sense.

But she’s running a startup, not a research lab. She has a single consumer GPU, a small budget, and zero appetite for renting A100s by the hour. She needs a way to fine-tune a big model without going broke — and that’s what the next article covers.

Check Your Understanding

Questions below move from simple recall up to open-ended design, roughly following Bloom’s Taxonomy.

Remember What’s the core difference between fine-tuning and RAG in terms of what actually changes—the model’s weights, or what the model can see?

Understand In your own words, explain why the article’s “hint” experiment (giving the model the revenue figure directly in the prompt) isn’t actually RAG, even though it solved the immediate hallucination.

Apply Using the article’s choose_approach decision logic, what would it return for a use case with data_stability='low', latency_requirement='strict', data_quality='high', knowledge_size='small'?

Analyze The article’s hybrid approach fine-tunes on “style and general domain knowledge” but explicitly excludes “specific facts that will change.” Walk through why mixing volatile facts into the fine-tuning data (instead of keeping them in the RAG knowledge base) would undermine the whole point of the hybrid design.

Evaluate The article’s evaluate_readiness checklist function requires “4/5 checks passed” for fine-tuning readiness but only “3/5” for RAG. Critique this asymmetry: is fine-tuning inherently riskier in a way that justifies a stricter bar, or is this just an arbitrary threshold that could mislead a team with, say, exactly 3/5 fine-tuning checks passed?

Create Design a decision framework for a use case not covered in the article: a legal team building an AI tool that needs to draft contracts in the firm’s specific style AND cite the correct, currently-in-force version of specific statutes (which get amended periodically). Walk through the article’s checklist questions for both fine-tuning and RAG to justify your recommendation.


References & Further reading

  • Lewis, P., Perez, E., Piktus, A., et al. (2020). Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. NeurIPS 2020. arxiv.org/abs/2005.11401 — the foundational RAG paper that introduced retrieval-augmented generation as an alternative to fine-tuning for knowledge injection.
  • Hugging Face. Fine-tuning documentation. huggingface.co/docs/transformers/training — practical guide to fine-tuning transformer models with the Hugging Face Trainer API.
  • OpenAI. Fine-tuning guide. platform.openai.com/docs/guides/fine-tuning — OpenAI’s official fine-tuning documentation, including when to fine-tune vs. use prompt engineering.

Apply What You Learned is for Supporter and Insider subscribers.

Subscribe to unlock the exercises on this post.

See plans

Looking for something else?

Search every article by title, summary or topic.