Lecture 15 - Retrieval-Augmented Generation and Fine-Tuning
localhost:11434openai package is a client for a protocol, so one script reaches Ollama and OpenRouter.env and .gitignore keep keys out of your repositoryresponse_format makes sure the reply is valid JSON1. Embeddings
2. Retrieval-augmented generation
3. Fine-tuning
4. Choosing between them
embeddinggemma has 300 million parameters, about a quarter of llama3.2:1bWord2Vec neighbours of “dog”. Source: projector.tensorflow.org
To search documents by meaning, we look for the passages whose vectors are closest to the question’s vector
Our script lets Chroma compute it. Worked example: Appendix 05
The passage with the highest score is the best match
Real scores for “What does Quiz 02 cover?”:
| Best passage from | Score |
|---|---|
| Lecture 12 README | 0.62 |
| Quiz 02 README | 0.44 |
| Anything, for “capital of Nepal?” | 0.20 |
The top match is a Lecture 12 paragraph about Quiz 02. The quiz’s own file is second
localhost:11434/api/embed, as in Lecture 14If the pull fails, update Ollama or use the older nomic-embed-text model (274 MB)
“Grounded in the information you trust” is RAG as marketing. Source: notebook.google
RAG lets a model cite its sources and use information newer than its training data
Sometimes you should! If your notes fit in the context window, pasting them is simpler.
Take a 300-page handbook (about 120,000 tokens) and 1,000 questions:
| Strategy | Per question | 1,000 questions |
|---|---|---|
| Paste everything | 120,000 tokens | 120M ≈ $18.00 |
| RAG, top 3 chunks | ~600 tokens | 0.6M ≈ $0.09 |
RAG is worthwhile when:
Where RAG fails:
Why these files:
Download the corpus from the course repository: https://github.com/danilofreire/datasci350/tree/main/lectures/lecture-15/demo/corpus
A chunk is the piece of text we embed and retrieve. There are three common ways to split a document:
| Strategy | Splits on | Pros | Cons |
|---|---|---|---|
| By paragraph | Blank lines | Keeps the author’s paragraphs | Sizes vary a lot |
| Fixed window | Every ~500 tokens, with overlap | Even sizes, nothing lost at the edges | Can cut a sentence in half |
| By structure | Headings and sections | Sections stay whole | Some chunks are huge |
Our script splits at blank lines and drops pieces under 80 characters, which removes headings and short bullets
pathlib is built into Python and handles files and foldersPath("corpus") points at the folder on any operating systempath is one file, and path.name is its name, such as quiz-01-readme.mdglob("*.md") lists the Markdown files in corpusread_text loads a file as one stringsplit("\n\n") cuts it at blank lines into paragraphsif drops pieces of 80 characters or fewer, such as headingsparagraphs holds the text, and files the source file of each one{"file": path.name} is a dictionary, Chroma’s format for extra informationRun the script from inside demo/, so Python finds corpus/
import chromadb
import ollama
client = chromadb.Client()
collection = client.create_collection(
name="notes",
configuration={"hnsw": {"space": "cosine"}})
# Step 2: embed the paragraphs and store them
# Chroma needs an ID for each one: "0", "1", "2"...
ids = []
for n in range(len(paragraphs)):
ids.append(str(n))
response = ollama.embed(
model="embeddinggemma", input=paragraphs)
collection.add(ids=ids, embeddings=response["embeddings"],
documents=paragraphs, metadatas=files)
print("Chunks stored:", collection.count())Client() runs Chroma in memory, so each run starts emptyhnsw is the index Chroma searches with (HNSW), and "cosine" makes it compare vectors with cosine similarityollama.embed call turns all 132 paragraphs into vectors of 768 numbersadd stores each paragraph with its vector, ID and file namecount() confirms that 132 chunks are storedpip install chromadb downloads about 300 MB
# Step 3: find the 3 paragraphs closest to the question
question = input("Your question: ")
response = ollama.embed(model="embeddinggemma", input=question)
results = collection.query(query_embeddings=response["embeddings"], n_results=3)
data = results["documents"][0]
print("Sources:", results["metadatas"][0])
for distance in results["distances"][0]:
print("Similarity:", round(1 - distance, 2))input pauses the script until you type a question and press Enterquery compares the question’s vector with all 132 stored vectorsn_results=3 keeps the three closestquery accepts several questions at once, so [0] picks the results for oursdocuments, metadatas and distances hold the text, file and distance of each paragraph1 - distance turns it back into the cosine similaritydata keeps the three paragraphs for the promptf inserts the paragraphs (data) and the question into the promptollama.generate sends one prompt to the chat modeloutput["response"] holds its answer as textllama3.2:1b can be any chat model you have pulled$ python rag.py
Chunks stored: 132
Your question: What does Quiz 02 cover?
Sources: [{'file': 'lecture-12-readme.md'}, {'file': 'quiz-02-readme.md'}, {'file': 'quiz-01-readme.md'}]
Similarity: 0.62
Similarity: 0.44
Similarity: 0.44
According to the data, Quiz 02 covers lectures 10 and 11: Quarto, Markdown, citations, `freeze`, and building a site.The similarities never change between runs, but the answers do
For “What is the capital of Nepal?”, the similarities are about 0.20, and the model says the data does not mention Nepal
Ask questions with a known source file, and check Sources:
| 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 |
Write the answer key first, as with Lecture 14’s human_label
Hit rate: 5 of 7
demo/ from the course repositoryollama pull embeddinggemma and pip install ollama chromadbdemo/, run python rag.py and ask “What does Quiz 02 cover?”rag.py, change n_results=3 to n_results=1Expected behaviour and troubleshooting: Appendix 01
No Ollama? Use OpenRouter instead: Appendix 03
When the corpus grows:
chromadb.PersistentClient(path="db") keeps the vectors between runsLangChain and LlamaIndex wrap the same five stages
When the embedding misses:
Source: daxa.ai
Try it: add that line to a corpus/ file and ask a question that retrieves it
RAG changes what the model reads; fine-tuning changes its weights
| RAG / prompting | Fine-tuning | |
|---|---|---|
| New facts | Immediately | Only by retraining |
| Sources | Citable | Lost in the weights |
| When you pay | Per question | Up front |
| Undo | Delete a file | Keep the old weights |
Most requests to fine-tune are really RAG problems, so check before paying for GPUs
Training data is a JSONL file with one example per line (split here to fit the slide):
{"messages": [
{"role": "user", "content": "Classify the sentiment: Chip maker warns of a sharp drop in demand"},
{"role": "assistant", "content": "{\"sentiment\": \"bearish\", \"confidence\": 0.9}"}]}
{"messages": [
{"role": "user", "content": "Classify the sentiment: Retailer reports record holiday sales"},
{"role": "assistant", "content": "{\"sentiment\": \"bullish\", \"confidence\": 0.8}"}]}MESSAGE lines in an Ollama Modelfile, with thousands of examplesModelfile changes behaviour with instructions; LoRA changes weightsllama3.2:1b is partly distilled from larger Llama modelsWhose weights are they?
@AnthropicAI, 23 February 2026
rag.py runs them in about 40 lines with Ollama and ChromaThis was the last AI lecture. Next class, we start cloud computing
We will rent a computer from AWS and control it from the terminal
Quiz 03 covers both the AI and the cloud modules
Before then:
.envTry rag.py on your own notes this week
Step 4, questions the corpus answers well:
Step 5, the Nepal question:
llama3.2:1b said every time that the data does not mention NepalSteps 6 and 7, n_results=1:
Going further:
CORPUS at a folder of your own notesErrors and fixes are in Appendix 04
"""A minimal RAG pipeline over the course's own notes."""
from pathlib import Path
import chromadb
import ollama
client = chromadb.Client()
collection = client.create_collection(
name="notes",
configuration={"hnsw": {"space": "cosine"}})
# Step 1: split the notes into paragraphs
paragraphs = []
files = []
for path in Path("corpus").glob("*.md"):
text = path.read_text(encoding="utf-8")
for paragraph in text.split("\n\n"):
if len(paragraph) > 80:
paragraphs.append(paragraph)
files.append({"file": path.name})
# Step 2: embed the paragraphs and store them
# Chroma needs an ID for each one: "0", "1", "2"...
ids = []
for n in range(len(paragraphs)):
ids.append(str(n))
response = ollama.embed(
model="embeddinggemma", input=paragraphs)
collection.add(ids=ids, embeddings=response["embeddings"],
documents=paragraphs, metadatas=files)
print("Chunks stored:", collection.count())
# Step 3: find the 3 paragraphs closest to the question
question = input("Your question: ")
response = ollama.embed(model="embeddinggemma", input=question)
results = collection.query(query_embeddings=response["embeddings"], n_results=3)
data = results["documents"][0]
print("Sources:", results["metadatas"][0])
for distance in results["distances"][0]:
print("Similarity:", round(1 - distance, 2))
# Step 4: generate an answer from those paragraphs
prompt = f"Using only this data: {data}. Answer this question: {question}"
output = ollama.generate(model="llama3.2:1b", prompt=prompt)
print(output["response"])No Ollama? Run rag.py through OpenRouter with your Lecture 14 key in .env. Make four changes:
# 1. Replace "import ollama" with:
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
openrouter = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.environ["OPENROUTER_API_KEY"])
def embed(texts):
"""Turn a list of texts into a list of vectors."""
response = openrouter.embeddings.create(
model="nvidia/nemotron-3-embed-1b:free",
input=texts)
vectors = []
for item in response.data:
vectors.append(item.embedding)
return vectors# 2. In Step 2, replace the embed and add lines with:
collection.add(ids=ids, embeddings=embed(paragraphs),
documents=paragraphs, metadatas=files)
# 3. In Step 3, replace the embed and query lines with:
results = collection.query(
query_embeddings=embed([question]), n_results=3)
# 4. In Step 4, replace the last two lines with:
output = openrouter.chat.completions.create(
model="google/gemma-4-31b-it:free",
messages=[{"role": "user", "content": prompt}])
print(output.choices[0].message.content)openrouter, because rag.py already uses client for Chroma:free, so a $0.00 key limit works. Each run makes three requests, so watch the rate limitspip install openai python-dotenv chromadbollama 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 app, or run ollama serve.
ModuleNotFoundError: No module named 'ollama' (or 'chromadb')
Run pip install ollama chromadb in the Python you use (which python3).
ValueError: Expected Embeddings to be non-empty list
You ran the script from the wrong folder, so it found no notes to embed. cd demo first.
The first run takes ages
The embedding model is loading into memory. Every run re-embeds all 132 chunks, because the script keeps nothing between runs
The answer is nonsense
Read the retrieved chunks. If they are wrong, rephrase or check that the corpus has the answer. If they are right, try a larger chat model.
Every similarity is low
Similarities near 0.2 mean the corpus has nothing close to your question.
The model answers from outside the corpus
Small models often ignore the grounding instruction and answer from memory. Try a larger model and note the difference
For two vectors \(a\) and \(b\) with \(n\) numbers each:
\[ \cos(\theta) = \frac{a \cdot b}{\|a\| \, \|b\|} = \frac{\sum_{i=1}^{n} a_i b_i}{\sqrt{\sum_{i=1}^{n} a_i^2} \, \sqrt{\sum_{i=1}^{n} b_i^2}} \]
A worked example, with \(a = (1, 2, 2)\) and \(b = (2, 1, 2)\):
\[ a \cdot b = 1 \times 2 + 2 \times 1 + 2 \times 2 = 8 \]
\[ \|a\| = \sqrt{1 + 4 + 4} = 3, \quad \|b\| = \sqrt{4 + 1 + 4} = 3 \]
\[ \cos(\theta) = \frac{8}{3 \times 3} \approx 0.89 \]
With \(c = (2, -1, 0)\), the dot product \(a \cdot c = 2 - 2 + 0 = 0\), so the similarity is 0 and the vectors are at right angles
EmbeddingGemma vectors have 768 numbers instead of 3, and the formula is the same