Lecture 15 - Retrieval-Augmented Generation and Fine-Tuning
ollama pull, ollama run, a persona in a Modelfileking − man + woman ≈ queenlocalhost:11434, and OpenRouter with a key in a .env fileSource: Wikipedia
LLMs, local or hosted, has read most of the public internet. It has read none of your documents: your notes, your data dictionary, this course’s files
How can we change that?
1. Embeddings, again
2. Retrieval-augmented generation
3. Fine-tuning
4. Choosing
embeddinggemma has only 300 million parameters, a quarter of llama3.2:1bWord2Vec embeddings, with “dog” and its neighbours highlighted. Explore it yourself at projector.tensorflow.org
In Lecture 12 this picture was an explanation of what happens inside a model. Today it becomes a tool you call directly
Real scores for “What does Quiz 02 cover?”:
| Best passage from | Score |
|---|---|
| Lecture 12 README | 0.62 |
| Quiz 02 README | 0.43 |
| Anything, for “capital of Nepal?” | 0.20 |
The top passage is the Lecture 12 README’s paragraph describing Quiz 02, not the quiz itself
pip install ollama):POST /api/embed on localhost:11434: the client-server pattern from Lecture 14, with one new address on the same serverembeddinggemma needs a recent Ollama, so run a quick ollama -v and update the app if the pull complains. The older nomic-embed-text (274 MB) works as a fallback
grep your repository before they edit it“Grounded in the information you trust” is RAG as marketing. Source: notebooklm.google
It cites sources, and it knows things that changed after the model was trained
Sometimes you should! If all your notes fit comfortably in the context window, pasting them is simpler and works well.
RAG is worthwhile when:
Where RAG fails:
There is no shame in the simple option. A prompt with your notes pasted in is a retrieval system where retrieval returns everything
Say your corpus is a 300-page handbook: about 90,000 words, or 120,000 tokens at Lecture 12’s rule of thumb. You ask 1,000 questions over a term:
| Strategy | Input per question | 1,000 questions |
|---|---|---|
| Paste everything | 120,000 tokens | 120M tokens ≈ $18.00 |
| RAG, top 3 chunks | ~600 tokens | 0.6M tokens ≈ $0.09 |
llama3.2:1b holds 131,072 tokens, so one handbook nearly fills the window, and a full window is slow on a laptopThere is a quality problem on top of the bill. Liu et al. (2024) showed that models recall facts from the start and end of a long context much better than from the middle.
They called it “lost in the middle”. A stuffed window holds the fact and still misses it
Retrieval is a filter in front of the window. It sends the 600 tokens that matter and leaves the other 119,400 on disk
Why this corpus:
Swap the folder for your own notes afterwards. The script works the same way
A chunk is the unit that gets embedded, retrieved, and handed to the chat model. Three common ways to cut, each with a price:
| Strategy | How it cuts | Gains | Costs |
|---|---|---|---|
| By paragraph | On blank lines | The author’s own units of meaning | Sizes vary wildly |
| Fixed window | Every ~500 tokens, with overlap | Uniform, nothing lost at edges | Cuts mid-thought |
| By structure | On headings and sections | Sections stay whole | Chunks can be huge |
Two limits shape every choice:
Our script splits on blank lines and drops anything under 80 characters. That silently throws away headings and one-line bullets.
READMEs survive this without damage. Check what the rule drops from your own notes before you trust it
from pathlib import Path
def load_chunks(folder):
"""Split every markdown file into paragraph chunks."""
chunks = []
for path in sorted(Path(folder).glob("*.md")):
for block in path.read_text(encoding="utf-8").split("\n\n"):
block = block.strip()
if len(block) > 80:
chunks.append((path.name, block))
return chunksPath(folder).glob("*.md") collects every markdown file in the folder, and sorted puts them in a fixed orderread_text loads one file as a single string, and split("\n\n") cuts it at every blank linestrip removes the leftover spaces and newlines, the if drops anything too short to be worth embedding(path.name, block), so every retrieved passage remembers which file it came from. That is what makes citations possible laterNo LangChain and no vector database for this task. For eight files, a Python list is the database
import numpy as np
import ollama
def embed(texts):
response = ollama.embed(model="embeddinggemma",
input=texts)
return np.array(response["embeddings"])
chunk_vectors = embed([text for _, text in chunks])
question_vector = embed([question])[0]
# Cosine similarity: normalise, then dot product
chunk_vectors /= np.linalg.norm(chunk_vectors,
axis=1, keepdims=True)
question_vector /= np.linalg.norm(question_vector)
scores = chunk_vectors @ question_vector
top = np.argsort(scores)[::-1][:3]embed sends a list of texts to embeddinggemma and returns a numpy array with one row of 768 numbers per textnp.linalg.norm rescales every row to length 1, which is what turns a dot product into a cosinequestion is the question we want to ask, typed in the terminal. The whole script is in Appendix 02@ multiplies the matrix by the question vector, giving one score per chunk, and argsort(...)[::-1][:3] sorts them and keeps the three highestThe retrieval half of RAG is this slide. Everything after it is prompting
context = "\n\n".join(chunks[i][1] for i in top)
prompt = (
"Answer the question using ONLY the context below. "
"If the answer is not in the context, "
"say you do not know.\n\n"
f"Context:\n{context}\n\nQuestion: {question}"
)
reply = ollama.chat(
model="llama3.2:1b",
messages=[{"role": "user", "content": prompt}])
print(reply.message.content)top holds row numbers, chunks[i][1] takes the text half of each pair, and join glues them togetherf drops context and question into the textollama.chat is the Lecture 14 call, and reply.message.content is the answerllama3.2:1b for any model you have pulled. Only the writing changes, not the retrievalThis is a prompt like any other. PTCF still applies: the context is C, the task is T, and we skipped the persona
$ python rag.py "What does Quiz 02 cover?"
Corpus: 132 chunks from 8 files
Retrieved chunks:
[0.621] lecture-12-readme.md: Next class is Quiz 02: Literate Programming, worth 6%. It covers lectu...
[0.429] quiz-02-readme.md: There are two bonus tasks at the end of the quiz README. Attempt them ...
[0.429] quiz-01-readme.md: There are two bonus tasks at the end of the quiz README. Attempt them ...
Answer:
Based on the context, Quiz 02: Literate Programming covers lectures 10 and 11:
Quarto, Markdown, citations, `freeze`, and building a site.Retrieval repeats. Generation does not!
Run the script four times and the scores come back identical every time
But the written answer still changes. On one of my four runs the model replied “I do not know” with the right chunk sitting in front of it 😅
Write questions whose source you know, and check whether the right file shows up in the top 3. My run:
| Question | Expected file | Rank |
|---|---|---|
| What does Quiz 01 cover? | quiz-01-readme.md | 2 |
| Which tutorials does the course have? | tutorials-readme.md | 1 |
| What is Lecture 12 about? | lecture-12-readme.md | 1 |
| When is Quiz 02? | quiz-02-readme.md | miss |
| What does Lecture 10 cover? | lecture-10-readme.md | 1 |
| What does Lecture 11 cover? | lecture-11-readme.md | 1 |
| How much is the final project worth? | course-readme.md | miss |
This is the same move as Lecture 14’s human_label column: write the answer key before you measure
The industry calls this number retrieval hit rate. You can compute it without any chat model at all, which makes it the cheapest test in the whole pipeline
When a question fails, the table tells you where to look:
demo/ folder from the course repositoryollama pull embeddinggemmapip install ollama numpypython rag.py "What does Quiz 02 cover?"rag.py, change TOP_K from 3 to 1What to look for:
TOP_K = 1, questions whose answer spans two files break firstExpected behaviour and troubleshooting:
WHERE clauses without reading every rowFrameworks such as LangChain and LlamaIndex package these same five stages behind their own vocabulary
You have now written the stages yourself, so their documentation reads as a checklist instead of magic 😉
For a course project, start with the list. Reach for a vector database when the corpus makes you wait
Source: daxa.ai
Try it: add that line to a file in corpus/, ask a question that retrieves it, and watch what your model does
The same pipeline runs against OpenRouter with the key from Lecture 14. Two calls change and nothing else:
:free model. Embeddings are paid, though this corpus costs a fraction of a centLocal and hosted are interchangeable backends for the same 50 lines, exactly as they were for classify.py last class. The constants at the top of the file are the only thing that changes
Everything so far changes what the model reads. Fine-tuning changes what the model is.
| RAG / prompting | Fine-tuning | |
|---|---|---|
| What changes | The context | The weights |
| New facts | Immediately | Only by retraining |
| Sources | Citable | Gone, absorbed |
| Cost shape | Per question | Up front |
| Undo | Delete a file | Keep the old weights |
The row that matters most in practice is Undo. A RAG mistake is fixed by editing a file. A fine-tuning mistake is fixed by training again
One example per line, in JSONL, thousands of lines:
MESSAGE instruction at scaleYou have already used a model made this way:
Modelfile gives a frozen model a persona with words. LoRA does the same job with trained weightsFine-tune for behaviour:
Do not fine-tune for facts:
Most “we need to fine-tune” requests are really RAG problems. Ask which one you have before spending GPU money
You are not asked to fine-tune anything in this course. Check Unsloth’s website if you wish, understand their workflows, and know where the notebooks are the day a project needs one
llama3.2:1b by pruning and distilling its larger Llama models, which is how a 1.3 GB file writes coherent EnglishNotice what changed. Fine-tuning needed humans to write the examples. Here another model writes them, and the only real limit is how much you can afford to ask it
@AnthropicAI, 23 February 2026
prompt → RAG → fine-tune
Each rung up costs more and is harder to undo:
Engineering judgement is knowing which rung you are on, and refusing to climb early
The AI module is complete. Next class we start cloud computing
So far every computer in this course has been yours. Next, we borrow someone else’s: AWS, and a real machine in a data centre you control from your terminal
Quiz 03 covers the AI module and the cloud module
Before then:
Modelfile and run a local model.env habit. The cloud module adds an AWS key beside itThis week, point rag.py at a folder of your own notes and ask it something. Checking its answer against your own files is the fastest way to make today’s ideas stick
Step 5, questions the corpus answers well:
Steps 7 and 8, the Nepal question:
llama3.2:1b answered “I do not know” every time. Yours may differ: small models sometimes pad the refusal, or answer “Kathmandu” from training memorySteps 9 and 10, TOP_K = 1:
Going further:
load_chunks at a folder of your own notesErrors and fixes are in Appendix 04
"""A minimal RAG pipeline over the course's own notes."""
import sys
from pathlib import Path
import numpy as np
import ollama
EMBED_MODEL = "embeddinggemma"
CHAT_MODEL = "llama3.2:1b"
TOP_K = 3
def load_chunks(folder):
"""Split every markdown file into paragraph chunks."""
chunks = []
for path in sorted(Path(folder).glob("*.md")):
for block in path.read_text(encoding="utf-8").split("\n\n"):
block = block.strip()
if len(block) > 80:
chunks.append((path.name, block))
return chunks
def embed(texts):
"""Turn a list of texts into one vector per text."""
response = ollama.embed(model=EMBED_MODEL, input=texts)
return np.array(response["embeddings"])
def main():
question = sys.argv[1] if len(sys.argv) > 1 else "What does this course cover?"
chunks = load_chunks(Path(__file__).parent / "corpus")
files = {name for name, _ in chunks}
print(f"Corpus: {len(chunks)} chunks from {len(files)} files")
chunk_vectors = embed([text for _, text in chunks])
question_vector = embed([question])[0]
# Cosine similarity is a dot product once every vector has length 1
chunk_vectors /= np.linalg.norm(chunk_vectors, axis=1, keepdims=True)
question_vector /= np.linalg.norm(question_vector)
scores = chunk_vectors @ question_vector
top = np.argsort(scores)[::-1][:TOP_K]
print("\nRetrieved chunks:")
for i in top:
name, text = chunks[i]
print(f" [{scores[i]:.3f}] {name}: {text[:70]}...")
context = "\n\n".join(chunks[i][1] for i in top)
prompt = (
"Answer the question using ONLY the context below. "
"If the answer is not in the context, say you do not know.\n\n"
f"Context:\n{context}\n\nQuestion: {question}"
)
reply = ollama.chat(model=CHAT_MODEL, messages=[{"role": "user", "content": prompt}])
print(f"\nAnswer:\n{reply.message.content}")
if __name__ == "__main__":
main()Replace the two Ollama calls in rag.py with these, using the client and .env setup from Lecture 14:
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI(base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"])
def embed(texts):
response = client.embeddings.create(
model="openai/text-embedding-3-small", input=texts)
return np.array([item.embedding for item in response.data])
# ...and in main(), replace the ollama.chat call with:
reply = client.chat.completions.create(
model="meta-llama/llama-3.3-70b-instruct:free",
messages=[{"role": "user", "content": prompt}])
print(f"\nAnswer:\n{reply.choices[0].message.content}")Embeddings on OpenRouter are paid (this corpus costs well under a cent); the chat call can use any :free model that is live that week. Check openrouter.ai/models for the current list
ollama pull embeddinggemma fails
Your Ollama is too old for this model. Update the application, or switch EMBED_MODEL to nomic-embed-text and pull that instead.
Connection refused on localhost:11434
Ollama is not running. Open the application, or run ollama serve in another terminal. Same fix as Lecture 14.
ModuleNotFoundError: No module named 'ollama'
The package is not installed in the Python you are running. pip install ollama numpy, and check which python3 if you use environments.
The first run takes ages
The embedding model is loading into memory and embedding all 132 chunks. Later runs only embed the question
The answer is nonsense
Read the retrieved chunks first. Wrong chunks mean a retrieval problem: rephrase the question, or check the corpus actually contains the answer. Right chunks and a wrong answer mean a generation problem: try a larger chat model.
Every score is low
Scores near 0.2 for every chunk mean the corpus has nothing close to your question. That is retrieval working correctly on the wrong corpus.
The model answers from outside the corpus
Small models leak training memory past the grounding instruction. Try the question with a larger model, and note the difference; that gap is the exercise’s real lesson