Loremaster — from zero to a working RAG application

This is a plain html tutorial generated with Claude. Beware that the hallucinations start about halfway through. Just treat them as an option to dig deeper into the topics.

50 lessons · 11 modules · ~35 hours
0% · 0/50 lessons · 0/358 steps
No lessons match that search.
Module 0

Get your machine ready

Windows, WSL2, Python, VS Code, Git

Turn a plain Windows machine into a working development environment. No project code yet — just the tools everything else stands on.

6 lessons · L01–L06 ~3 h 0 of 6 lessons complete
L01

What you are building, and how the pieces fit

30 min 0/4

The technology

The thing you are building is called Loremaster. A person signs up, uploads their tabletop RPG books and session transcripts, and asks questions in plain English. The system answers only from their own files, shows exactly which page each claim came from, and says "I couldn't find that" when the files don't contain the answer.

The technique behind it is called RAG — Retrieval-Augmented Generation. In one sentence: before you ask the AI a question, you go find the handful of paragraphs from the user's documents that are most likely to contain the answer, and you paste them into the question. The AI then answers from the text in front of it instead of from memory.

That's the whole trick. Everything else in this course is plumbing around it: how to get text out of a PDF, how to find the right paragraphs, how to keep one user's books away from another user's eyes, and how to put a web page in front of it.

There are five moving parts. Read them once now; you'll meet each one properly later.

  • Frontend — the web page the user sees. Built with Next.js. Module 8.
  • Backend / API — the program that receives requests from that page. Built with FastAPI, in Python. Module 5.
  • Database — PostgreSQL, where documents, paragraphs, and users live. Module 2.
  • Worker — a separate program that does the slow work (chewing through a 100-page PDF) so the website stays responsive. Module 7.
  • LLM provider — OpenAI, Anthropic, or a model running on your own machine. You send text, you get text back.

Steps

Why it works this way

Why not just paste the PDF into ChatGPT?
Two reasons. A 300-page rulebook does not fit in a context window, and even where it fits it is expensive to resend on every question. RAG sends 8 paragraphs instead of 300 pages.
Why does the model need to be told to ignore what it knows?
The model has memorised published D&D rules. Your user's group has house rules that contradict them. Left alone the model will confidently answer from memory. Section 8 of your design document handles this and it is genuinely one of the harder parts.
Done when: You can explain, out loud and without notes, what RAG does and why a rulebook can't just be pasted into a prompt.
L02

WSL2 — running Linux inside Windows

45 min 0/7

The technology

What it is. WSL stands for Windows Subsystem for Linux. It runs a real Ubuntu Linux system inside your Windows machine, sharing the same files and the same clipboard, without dual-booting or a heavy virtual machine.

Why you need it. Essentially every Python tutorial, every deployment server, and every Docker example on the internet assumes Linux. Postgres, Redis, and the tools you'll use are all happiest there. Doing this project in native Windows PowerShell is possible but you will hit a paper cut roughly every 20 minutes — wrong slashes, missing C compilers, packages that only ship Linux wheels. WSL2 removes all of that permanently for a one-time 30-minute install.

The mental model. After this lesson you have two computers. Windows, where your browser and editor windows live. And Ubuntu, where all your code and commands live. They can see each other's files, but you will keep the project entirely on the Ubuntu side.

Steps

Code

In PowerShell (as Administrator)
wsl --install
In the Ubuntu window
sudo apt update && sudo apt upgrade -y

Why it works this way

What is sudo?
"Do this as the superuser." Linux won't let a normal user change system files, so commands that install software are prefixed with sudo. It will ask for the password you just set.
What is apt?
Ubuntu's app store, for the command line. apt update refreshes the catalogue; apt upgrade installs newer versions of what you already have. -y means "yes to all prompts".
The ~ in your prompt
Shorthand for your home folder, which is /home/yourname. This is where your project will live.
Likely snag. If wsl --install fails with something about virtualization, you need to enable virtualization in your BIOS. Search for your laptop model plus "enable virtualization BIOS" — it is usually a single toggle called Intel VT-x, AMD-V, or SVM Mode.
Done when: You can open a black Ubuntu window from the taskbar and type whoami and see your username.
L03

The terminal — the 12 commands you actually need

30 min 0/7

The technology

What it is. The terminal is a text interface to your computer. You type the name of a program and press Enter; it runs and prints text. That's it. It feels hostile because it gives no hints, but the working vocabulary is genuinely about a dozen commands.

Why you need it. Every tool in this project is driven from the terminal. There is no GUI for starting a database container or running a migration. Fluency here is what makes the difference between a lesson taking 30 minutes and taking three hours.

The mental model. At any moment you are "standing in" one folder. Commands act on that folder unless you tell them otherwise. pwd tells you where you are standing, cd moves you, ls looks around.

Steps

Code

Your cheat sheet
pwd                  # where am I?
ls -la               # what is here (including hidden files)?
cd foldername        # go into a folder
cd ..                # go up one level
cd ~                 # go home
mkdir -p a/b/c       # create folders (-p makes parents too)
cat file.txt         # print a file
rm file.txt          # delete a file
rm -rf foldername    # delete a folder and everything in it -- careful
cp a.txt b.txt       # copy
mv a.txt b.txt       # move or rename
grep -r "text" .     # search for text in all files under here
Ctrl+C               # stop the running program
Tab                  # autocomplete
Up arrow             # previous command

Why it works this way

Why ~/code/ and not the Windows Desktop?
Files on the Windows side are reachable from Linux at /mnt/c/..., but reading them from Linux is roughly 10x slower because every access crosses a translation layer. Python projects touch thousands of small files. Keep the project on the Linux side.
rm has no undo
There is no recycle bin on the command line. rm -rf in particular deletes a whole tree instantly and silently. Read the path twice before pressing Enter.
Done when: You can navigate to ~/code/loremaster, create a file, read it, and delete it without looking anything up.
L04

Python and virtual environments

40 min 0/7

The technology

What it is. Python is the language the backend is written in. pip is the tool that installs Python libraries other people wrote.

What a virtual environment is, and why it matters. If you just run pip install, the library lands in one shared system-wide pile. Two projects that need different versions of the same library then fight, and the loser breaks. A virtual environment ("venv") is a private folder holding one project's libraries. You "activate" it, and from then on pip and python only see that project's libraries.

The signal that a venv is active is that your prompt gains a prefix: (.venv) anna@DESKTOP:~/code/loremaster$. If you don't see that prefix, your installs are going to the wrong place. This is the single most common beginner stumble in Python, so it's worth over-learning now.

Steps

Code

Install Python tooling
sudo apt install -y python3-venv python3-pip python3-dev build-essential
python3 --version
Create and activate the venv
cd ~/code/loremaster
python3 -m venv .venv
source .venv/bin/activate
# prompt should now show (.venv)
pip install --upgrade pip

Why it works this way

Why python3 -m venv and not just venv?
-m means "run this module from the Python I just named". It guarantees the venv is built by the Python you think it is, which matters once you have several installed.
What is build-essential for?
Some Python libraries include C code that has to be compiled during install. Without a compiler you get a wall of red errors that look like a Python problem but aren't.
If you forget to activate
You'll get ModuleNotFoundError: No module named 'fastapi' for a library you know you installed. Nine times out of ten the fix is: activate the venv.
Done when: Opening a fresh terminal, you can get to an activated venv in two commands from memory.
L05

VS Code, connected to Linux

25 min 0/6

The technology

What it is. VS Code is a text editor for code. It runs as a normal Windows app, but with one extension it can edit files that live inside WSL, run terminals inside WSL, and debug Python inside WSL — while looking and feeling like a normal Windows window.

Why this specific setup. If you install VS Code's Python extension on the Windows side and point it at Linux files, it gets confused about which Python is which. The Remote-WSL extension makes VS Code itself run inside Linux, so everything agrees.

Steps

Why it works this way

Why the green WSL badge matters
No badge means VS Code is editing Windows files with Windows Python. Everything will look fine until an import fails for no visible reason.
code . is the habit to build
Always open projects by cd-ing to them in the terminal and running code ., rather than File > Open. It guarantees the right context.
Done when: VS Code opens your project with a green "WSL: Ubuntu" badge, and its built-in terminal runs Linux commands.
L06

Git and GitHub — save points for your code

40 min 0/8

The technology

What it is. Git records snapshots of your project. Each snapshot is a commit, with a message saying what changed. You can go back to any commit. GitHub is a website that stores a copy of your Git history online.

Why you need it now, not later. Twice in this course you will break something badly and not know what you changed. With commits, "what did I change?" is one command. Without them it's an afternoon.

The three-step loop you'll repeat hundreds of times: git add (choose what goes in the snapshot) → git commit (take the snapshot, with a message) → git push (upload it).

Steps

Code

One-time Git setup
git config --global user.name "Your Name"
git config --global user.email "you@example.com"
git config --global init.defaultBranch main

sudo apt install -y gh
gh auth login
.gitignore
.venv/
__pycache__/
*.pyc
.env
.env.local
*.db
node_modules/
.next/
.DS_Store
data/
uploads/

Why it works this way

Why .env must never be committed
It will hold your OpenAI key. Bots continuously scan public GitHub for leaked keys, and a leaked key gets used within minutes — at your expense. Committing it once is enough, because it stays in the history even after you delete the file.
Commit messages
Write what changed and why, not "update". Future-you reading git log is the audience. "Add RRF fusion to hybrid search" is useful; "fix" is not.
How often to commit
Every time something works. End of each lesson in this course is a perfectly good rhythm.
Done when: Your project is on GitHub as a private repo, and .env and .venv are not in it.
Module 1

The vocabulary of LLM applications

Tokens, embeddings, and what RAG actually is

Understand what an LLM API call is, what an embedding is, and why RAG exists — by making the calls yourself and printing the results.

3 lessons · L07–L09 ~2.5 h 0 of 3 lessons complete
L07

Your first LLM API call

40 min 0/7

The technology

What an LLM is, mechanically. A large language model is a function. You give it text; it predicts the next chunk of text, then the next, until it decides to stop. It has no memory between calls. Every single request you make sends the entire conversation again — the model is stateless, and the illusion of memory is created by resending history.

Tokens. Models don't see letters or words, they see tokens — pieces of words. "unbelievable" might be three tokens. English averages roughly 4 characters per token. Everything is priced and limited in tokens: the context window is the maximum tokens a model can look at in one call, and your bill is tokens-in plus tokens-out.

The message roles. A call is a list of messages, each with a role. system is the instruction that shapes behaviour. user is the person. assistant is the model's own previous replies. This structure is the same across every provider.

Temperature. A number, usually 0 to 1, controlling randomness. High temperature is for creative writing. Your app wants 0.1 — near-deterministic, because you want the model to report what's in the documents, not to invent variations.

Steps

Code

.env
OPENAI_API_KEY=sk-proj-your-key-here
# or, if you chose Anthropic:
# ANTHROPIC_API_KEY=sk-ant-your-key-here
experiments/01_first_call.py
from dotenv import load_dotenv
from litellm import completion

load_dotenv()          # reads .env and puts the key into the environment

response = completion(
    model="openai/gpt-4o-mini",
    messages=[
        {"role": "system", "content": "You are a concise assistant."},
        {"role": "user",   "content": "In two sentences: what is a tabletop RPG?"},
    ],
    temperature=0.1,
)

print(response.choices[0].message.content)
print("---")
print(response.usage)

Why it works this way

Why LiteLLM instead of the openai library?
Section 9 of your design document requires swappable providers. LiteLLM gives one function, completion(), that works with OpenAI, Anthropic, and a model running on your own laptop — you change a string, not your code. Starting with it now means you never have to migrate.
Why load_dotenv()
It reads .env into the process environment. Libraries then find OPENAI_API_KEY on their own. The key never appears in your source code, so it never reaches GitHub.
The model string format
provider/model. openai/gpt-4o-mini, anthropic/claude-sonnet-4-5, ollama/llama3.1. This one string is your whole provider abstraction, for now.
Likely snag. AuthenticationError almost always means .env is in a different folder than the one you ran python from, or the key has a stray space. Print os.getenv("OPENAI_API_KEY")[:8] to check it loaded.
Done when: You can run a Python script that asks a model a question and prints its answer and token usage.
L08

Embeddings — turning meaning into numbers

45 min 0/6

The technology

What an embedding is. An embedding model takes a piece of text and returns a list of numbers — for text-embedding-3-small, exactly 1536 of them. That list is a vector. Think of it as coordinates: the text has been placed at a point in a 1536-dimensional space, positioned so that texts with similar meaning land near each other.

Why that is useful. "How badly can a character get hurt?" and "wound severity levels" share almost no words. A keyword search connects them not at all. But their embeddings sit close together, so measuring distance between vectors finds text by meaning rather than by spelling.

How closeness is measured. Cosine similarity — the angle between two vectors. 1.0 means identical direction, 0 means unrelated, -1 means opposite. You'll mostly see values between 0.1 and 0.9 in practice; what matters is the ranking, not the absolute number.

The catch you must feel for yourself. Embeddings are good at concepts and bad at proper nouns. "Peter" and "Michael" are both first names and land suspiciously close together. This single weakness is the reason your design document specifies hybrid search in section 7.1 — and Lesson 24 is where you'll exploit it.

Steps

Code

experiments/02_embeddings.py
import numpy as np
from dotenv import load_dotenv
from litellm import embedding

load_dotenv()

def embed(texts: list[str]) -> list[list[float]]:
    resp = embedding(model="openai/text-embedding-3-small", input=texts)
    return [d["embedding"] for d in resp.data]

def cosine(a, b) -> float:
    a, b = np.array(a), np.array(b)
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

# --- Block 1: meaning beats wording ---
texts = [
    "Harm is recorded in three levels: lesser, moderate, and severe.",
    "How badly can a character get hurt in this game?",
    "The tavern keeper offers a room for two silver pieces.",
]
v = embed(texts)
print("len(vector) =", len(v[0]))
print("first 5 numbers:", v[0][:5])
print()
print("injury rule  <-> injury question :", round(cosine(v[0], v[1]), 3))
print("injury rule  <-> tavern sentence :", round(cosine(v[0], v[2]), 3))

# --- Block 2: the proper-noun weakness ---
names = [
    "Peter cast Eldritch Blast at the guard.",
    "Michael cast Eldritch Blast at the guard.",
    "Peter drank a potion and climbed the wall.",
]
n = embed(names)
print()
print("Peter/Michael, same action :", round(cosine(n[0], n[1]), 3))
print("Peter, different action    :", round(cosine(n[0], n[2]), 3))
print("^ if the first number is not much lower than the second,")
print("  embeddings cannot reliably tell you WHO did something.")

Why it works this way

Why 1536 numbers?
It's the output size the model was trained with. More dimensions can hold more nuance but cost more storage and search time. Your database column is literally declared vector(1536) — which means changing embedding model later requires a schema migration and re-embedding everything. Section 9 of the design document flags this; take it seriously.
Embeddings are cheap, generation is not
Embedding a 100-page book costs a fraction of a cent. Answering questions is the recurring cost. Don't optimise the wrong one.
Same model, both sides
The question and the documents must be embedded by the same model, or the coordinates don't mean the same thing and the distances are meaningless.
Done when: You have printed cosine similarities with your own eyes and can state, from your own numbers, why embeddings alone won't answer "was Peter a warlock".
L09

RAG in 40 lines, with no database

45 min 0/6

The technology

The full RAG loop, which you are about to build in miniature:

  1. Ingest (once, ahead of time): split documents into chunks → embed each chunk → store the vectors.
  2. Query (every question): embed the question → find the nearest chunks → paste them into a prompt → ask the model → return the answer with citations.

Everything else in this course is making each of those six arrows industrial-strength: real PDFs instead of strings, a real database instead of a Python list, hybrid search instead of pure cosine, and a web app instead of a print statement. But the shape never changes. Build the shape now so you always know where you are.

The grounding prompt. Notice in the code that the sources are numbered and the system prompt demands bracket citations and an exact refusal string. That is not decoration — it's mechanism 1 and 2 of the four in section 8 of your design document.

Steps

Code

experiments/03_mini_rag.py
import numpy as np
from dotenv import load_dotenv
from litellm import completion, embedding

load_dotenv()

CHUNKS = [
    {"id": 1, "source": "Blades in the Dark, p.42, Combat > Harm",
     "text": "Harm is recorded in three levels: lesser, moderate, and severe. "
             "Severe harm needs long-term recovery."},
    {"id": 2, "source": "House Rules, p.2",
     "text": "House rule: our table adds a fourth harm level called 'grievous', "
             "which requires a full session of downtime."},
    {"id": 3, "source": "Session 2025-03-14, turn 88",
     "text": "GM: Peter, you're up. PETER: I cast Eldritch Blast at the guard."},
]

SYSTEM = '''Answer using ONLY the numbered sources below. Cite every factual
claim with its bracket number, e.g. [2]. If the sources do not contain the
answer, reply exactly: "I couldn't find that in your materials."
Do not use general knowledge about tabletop games -- the user's house rules
may contradict published rules, and the sources are authoritative.
If sources conflict, present both and note the conflict.'''

def embed(texts):
    r = embedding(model="openai/text-embedding-3-small", input=texts)
    return [np.array(d["embedding"]) for d in r.data]

def cosine(a, b):
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

# --- Ingest: embed every chunk once ---
vectors = embed([c["text"] for c in CHUNKS])

# --- Query ---
question = "How many levels of harm are there?"
qvec = embed([question])[0]

scored = [(cosine(qvec, vec), chunk) for chunk, vec in zip(CHUNKS, vectors)]
scored.sort(key=lambda pair: -pair[0])
top = [chunk for _, chunk in scored[:2]]

context = "\n\n".join(
    f'[{i+1}] ({c["source"]})\n{c["text"]}' for i, c in enumerate(top)
)

answer = completion(
    model="openai/gpt-4o-mini",
    temperature=0.1,
    messages=[
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": f"{context}\n\nQuestion: {question}"},
    ],
).choices[0].message.content

print("RETRIEVED:")
for i, c in enumerate(top):
    print(f"  [{i+1}] {c['source']}")
print()
print("ANSWER:")
print(answer)

Why it works this way

Why the sources are numbered
The numbers are the handle the model uses to cite, and the handle your code uses to verify. In Lesson 22 you'll parse [n] out of the answer and check every number actually exists — a citation to [5] when you only supplied 3 sources is a hallucination you can catch automatically.
Why an exact refusal string
"Say you don't know" produces twelve different phrasings. An exact string is something your code and your eval harness can test for.
This scales terribly, on purpose
You just compared the question against every chunk in Python. At 50,000 chunks that's far too slow, and it all has to fit in memory. That is precisely the problem a vector database solves — which is the next module.
Done when: Your script answers a question with a citation, and refuses correctly when the answer isn't in the chunks.
Module 2

Docker, Postgres, and pgvector

Somewhere to put a million vectors

Run a real database on your machine with one command, learn just enough SQL, and do your first vector search inside the database instead of in Python.

4 lessons · L10–L13 ~3 h 0 of 4 lessons complete
L10

Docker — what a container is

35 min 0/6

The technology

What it is. A container is a packaged, pre-configured program with everything it needs to run — its own files, its own libraries, its own settings — isolated from your machine. Docker is the tool that runs containers.

Why it exists. Installing PostgreSQL directly is a genuine chore: system packages, service configuration, users, a data directory, and a different procedure on every OS. Then you need the pgvector extension compiled against it. With Docker, someone has already done all that and published the result; you type one command and have a working database in 30 seconds. When you're done, you delete it and your machine is exactly as it was.

Image vs container. An image is the recipe (a downloadable, read-only snapshot). A container is a running instance of that image. One image, many containers.

Volumes. A container's own filesystem vanishes when the container is deleted. A volume is a folder that lives outside the container and survives — that's where your database's actual data goes, so restarting doesn't wipe it.

Steps

Code

Docker commands you'll actually use
docker ps                    # what is running right now
docker ps -a                 # including stopped ones
docker images                # downloaded images
docker logs <name>           # print a container's output
docker logs -f <name>        # ...and keep following it
docker exec -it <name> bash  # open a shell INSIDE a container
docker compose up -d         # start everything in docker-compose.yml
docker compose down          # stop it all
docker compose down -v       # stop it all AND delete the data volumes

Why it works this way

Why not install Postgres natively?
You'd also need to compile and install the pgvector extension against it, and later Redis too. The pgvector project publishes an image with the extension already built in. This is 30 seconds versus an afternoon.
docker compose down -v is the dangerous one
The -v deletes volumes, meaning your whole database. Useful when you want a clean slate, catastrophic when you don't.
Why Docker Desktop rather than Docker in WSL
Desktop gives you a GUI showing what's running and how much memory it's eating, which is genuinely useful while learning.
Done when: docker run hello-world works from your Ubuntu terminal.
L11

Postgres with pgvector, running locally

35 min 0/8

The technology

What PostgreSQL is. A relational database: data lives in tables with typed columns, and you query it with SQL. It's the most capable open-source database and what the design document locks in.

What pgvector is. An extension that teaches Postgres a new column type, vector, plus operators for measuring distance between vectors and index types for searching them fast. It's the reason you don't need a separate vector database.

Why one database instead of two. Read the pgvector row in section 2 of your design document again — the argument is about security, not convenience. With vectors inside Postgres, "only show this user their own chunks" is a WHERE user_id = ... that the database itself can enforce. With a separate vector service, isolation becomes a naming convention you have to remember to apply on every single call. One forgotten call is a data leak.

docker-compose.yml is a file describing which containers to run and how. Instead of a long docker run command with fifteen flags, you write it down once and type docker compose up.

Steps

Code

docker-compose.yml
services:
  db:
    image: pgvector/pgvector:pg16
    container_name: loremaster-db
    environment:
      POSTGRES_USER: loremaster
      POSTGRES_PASSWORD: localdevpassword
      POSTGRES_DB: loremaster
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U loremaster"]
      interval: 5s
      retries: 10

volumes:
  pgdata:
add to .env
DATABASE_URL=postgresql+psycopg://loremaster:localdevpassword@localhost:5432/loremaster

Why it works this way

ports: "5432:5432"
Left is the port on your machine, right is the port inside the container. This line is what lets Python on your laptop reach the database in the container. Without it the container is sealed.
The password is fine here
It's a local container reachable only from your machine, and this file is committed. Production credentials will live in environment variables on the host, never in a committed file.
Why pg16 and not latest
Pinning a version means your project doesn't silently change under you when the image is rebuilt. Always pin.
Likely snag. "port is already allocated" means something else is on 5432 — usually a previously installed Postgres. Change the left number to 5433:5432 and update DATABASE_URL to match.
Done when: You can open a psql prompt in your own database and \dx shows the vector extension installed.
L12

SQL — the eight statements this project needs

45 min 0/7

The technology

What SQL is. The language for talking to a relational database. You describe what you want; the database works out how to get it.

The core idea. Data lives in tables: named columns with fixed types, and rows. A primary key uniquely identifies a row. A foreign key is a column pointing at another table's primary key — that's what makes it "relational". An index is a lookup structure that turns a scan of a million rows into a jump straight to the right ones.

What you'll actually write. This project needs surprisingly little SQL: create a table, insert, select with a where clause, join two tables, order and limit, delete, and create an index. That's this lesson.

Steps

Code

Work through these in psql
-- 1. Create a table
CREATE TABLE books (
  id         serial PRIMARY KEY,
  title      text NOT NULL,
  doc_type   text NOT NULL CHECK (doc_type IN ('ruleset','transcript')),
  created_at timestamptz NOT NULL DEFAULT now()
);

-- 2. A child table with a foreign key
CREATE TABLE pages (
  id      serial PRIMARY KEY,
  book_id int NOT NULL REFERENCES books(id) ON DELETE CASCADE,
  number  int NOT NULL,
  content text NOT NULL
);

-- 3. Insert
INSERT INTO books (title, doc_type) VALUES ('Blades in the Dark', 'ruleset');
INSERT INTO pages (book_id, number, content)
VALUES (1, 42, 'Harm is recorded in three levels.');

-- 4. Select with a filter
SELECT id, title FROM books WHERE doc_type = 'ruleset';

-- 5. Join
SELECT b.title, p.number, p.content
FROM pages p
JOIN books b ON b.id = p.book_id
WHERE b.doc_type = 'ruleset';

-- 6. Order and limit
SELECT * FROM pages ORDER BY number DESC LIMIT 5;

-- 7. Index (makes the filter in #4 fast at scale)
CREATE INDEX ON books (doc_type);

-- 8. Delete, and watch the cascade
DELETE FROM books WHERE id = 1;
SELECT * FROM pages;   -- empty: the cascade removed them

Why it works this way

ON DELETE CASCADE
When a document is deleted, its chunks must go too, or you accumulate orphaned rows that still turn up in searches. Your real schema uses this on every child table.
CHECK constraints
The database refusing bad data is far more reliable than your Python remembering to validate it. Push correctness down to the lowest layer that can enforce it — this is the same principle as Row-Level Security in Module 6.
timestamptz, never timestamp
The tz version stores an unambiguous moment in time. Plain timestamp stores a wall-clock reading with no timezone, which is a bug waiting for the first user in a different country.
Done when: You can create a table, insert rows, join two tables, and explain what a foreign key does.
L13

Your first vector search, inside the database

40 min 0/6

The technology

What changes now. In Lesson 9 you compared the question against every chunk in a Python loop. Now Postgres does it, using an index, over millions of rows.

The distance operators. pgvector adds three: <=> is cosine distance, <-> is Euclidean (L2), <#> is negative inner product. You want <=>. Note it returns distance, so smaller is closer — the opposite direction from the similarity you printed in Lesson 8. Convert with 1 - (embedding <=> :qvec) when you want a similarity score.

The HNSW index. Without an index, a query compares against every row. HNSW (Hierarchical Navigable Small World) builds a navigable graph so search jumps roughly to the right neighbourhood instead. It's approximate — it can miss a true nearest neighbour occasionally — and that trade is what makes it usable at scale.

psycopg is the driver that lets Python speak Postgres. The pgvector Python package teaches it how to send and receive the vector type.

Steps

Code

In psql
CREATE TABLE demo_chunks (
  id        serial PRIMARY KEY,
  content   text NOT NULL,
  embedding vector(1536)
);

CREATE INDEX ON demo_chunks USING hnsw (embedding vector_cosine_ops);
experiments/04_pgvector.py
import os
import psycopg
from dotenv import load_dotenv
from litellm import embedding
from pgvector.psycopg import register_vector

load_dotenv()
# psycopg wants a plain URL, without the SQLAlchemy "+psycopg" part
DSN = os.environ["DATABASE_URL"].replace("postgresql+psycopg", "postgresql")

def embed(texts):
    r = embedding(model="openai/text-embedding-3-small", input=texts)
    return [d["embedding"] for d in r.data]

SENTENCES = [
    "Harm is recorded in three levels: lesser, moderate, and severe.",
    "A successful Prowl roll lets you move unseen past a single guard.",
    "The crew's heat rises when a score attracts official attention.",
]

with psycopg.connect(DSN) as conn:
    register_vector(conn)

    # --- store ---
    vecs = embed(SENTENCES)
    with conn.cursor() as cur:
        cur.execute("TRUNCATE demo_chunks;")
        for text, vec in zip(SENTENCES, vecs):
            cur.execute(
                "INSERT INTO demo_chunks (content, embedding) VALUES (%s, %s)",
                (text, vec),
            )
    conn.commit()

    # --- search ---
    question = "how much can a character be injured"
    qvec = embed([question])[0]
    with conn.cursor() as cur:
        cur.execute(
            """SELECT content, 1 - (embedding <=> %s::vector) AS similarity
               FROM demo_chunks
               ORDER BY embedding <=> %s::vector
               LIMIT 3""",
            (qvec, qvec),
        )
        print(f"Q: {question}\n")
        for content, sim in cur.fetchall():
            print(f"{sim:.3f}  {content}")

Why it works this way

Why ORDER BY embedding <=> qvec and not ORDER BY similarity DESC
The HNSW index only accelerates the distance operator directly. Ordering by a computed column defeats it and forces a full scan. Small detail, large consequence at scale.
Why the query vector is passed twice
Once for the score you display, once for the ordering. Slightly awkward, entirely normal.
Approximate means approximate
HNSW can occasionally miss a true nearest neighbour. For search-and-summarise this is fine. It would not be fine for, say, exact deduplication.
Done when: A Python script stores embeddings in Postgres and retrieves the semantically closest one, with no similarity maths in Python.
Module 3

Milestone 1 — the command-line RAG

One PDF, one user, no web, no auth

Build the entire core of the product as a command-line tool. Your design document says do not skip this, and it is right: everything after this is packaging.

9 lessons · L14–L22 ~6 h 0 of 9 lessons complete
L14

Project structure and configuration

35 min 0/7

The technology

Why structure now. Up to here you've written scripts. From here you're writing a program that a web server and a background worker will both import. Getting the folder layout right now avoids a painful reshuffle at Lesson 30.

pydantic-settings. Rather than os.getenv("CHUNK_SIZE") scattered everywhere (returning strings, crashing at 3am when a variable is missing), you declare one Settings class with types and defaults. It reads .env, converts types, and fails loudly at startup if something required is absent. One import, autocompletion, and no stringly-typed config.

The layout below separates ingestion (turning files into chunks), retrieval (finding chunks), and generation (answering). Those three stay separate for the rest of the course.

Steps

Code

Create the structure
cd ~/code/loremaster
mkdir -p app/{ingestion,retrieval,generation,db} experiments eval scripts data
touch app/__init__.py app/config.py
touch app/ingestion/__init__.py app/retrieval/__init__.py
touch app/generation/__init__.py app/db/__init__.py
app/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    database_url: str

    # Models -- section 9 of the design doc: chat and utility are separate
    chat_model: str = "openai/gpt-4o-mini"
    utility_model: str = "openai/gpt-4o-mini"
    embedding_model: str = "openai/text-embedding-3-small"
    embedding_dim: int = 1536

    # Chunking -- section 6, step 3
    chunk_max_tokens: int = 700
    chunk_overlap_tokens: int = 100
    chunk_min_tokens: int = 80

    # Retrieval -- section 7
    retrieve_candidates: int = 30
    retrieve_final: int = 8

    temperature: float = 0.1


settings = Settings()
add to .env
CHAT_MODEL=openai/gpt-4o-mini
UTILITY_MODEL=openai/gpt-4o-mini
EMBEDDING_MODEL=openai/text-embedding-3-small
EMBEDDING_DIM=1536

Why it works this way

Why chunk sizes belong in config
Open decision #3 in your design document is "is 700 tokens right?". You will answer that by changing a number and re-running your eval set. If the number is hardcoded in three files you won't bother.
Separate chat and utility models
Query parsing and reranking happen on every question and are easy tasks. Final answers need the good model. Splitting them is often a 5-10x cost difference for no quality loss.
settings as a module-level singleton
Imported once, validated once, at startup. A missing key crashes immediately with a clear message rather than at midnight during a user's request.
Done when: from app.config import settings works from anywhere in the project and a missing required variable produces a clear error.
L15

Getting text out of a PDF

40 min 0/7

The technology

The problem. A PDF is a description of where to draw glyphs on a page. It has no concept of paragraphs, headings, or reading order. Getting clean, structured text out is genuinely the messiest part of this project.

PyMuPDF and pymupdf4llm. PyMuPDF is a fast PDF library. pymupdf4llm is a helper on top that outputs Markdown — meaning it detects headings and emits them as #, ##. That heading structure is not a nicety: section 6 step 3 of your design chunks along headings, and section 6 step 4 prepends the heading path to each chunk before embedding. Without headings, both of those disappear.

Scanned PDFs. A scan is a picture of text. There is no text layer to extract, so you get an empty string. Section 13 of your design document decides to reject these outright, and explains why that's better than ingesting them: a silently-empty document looks "ready" and then answers every question with "I couldn't find that", which reads to the user as the whole product being broken.

Steps

Code

app/ingestion/extract.py
from dataclasses import dataclass
from pathlib import Path

import pymupdf
import pymupdf4llm


@dataclass
class Page:
    number: int          # 1-based
    markdown: str


def is_scanned(path: Path, min_chars_per_page: int = 100) -> bool:
    """Design doc section 6, step 0: sample pages, not just the first one.
    Art-heavy pages in a real text PDF can be sparse too."""
    doc = pymupdf.open(path)
    n = doc.page_count
    sample_idx = sorted({0, n // 2, n - 1, *range(0, n, max(1, n // 8))})
    lengths = [len(doc[i].get_text().strip()) for i in sample_idx if i < n]
    doc.close()
    if not lengths:
        return True
    return (sum(lengths) / len(lengths)) < min_chars_per_page


def extract_pages(path: Path) -> list[Page]:
    """Markdown per page, headings preserved, page numbers retained."""
    raw = pymupdf4llm.to_markdown(str(path), page_chunks=True)
    return [
        Page(number=p["metadata"]["page_number"], markdown=p["text"])
        for p in raw
    ]


if __name__ == "__main__":
    import sys

    path = Path(sys.argv[1])
    if is_scanned(path):
        print("REJECTED: this PDF appears to be a scan with no text layer.")
        raise SystemExit(1)

    pages = extract_pages(path)
    print(f"{len(pages)} pages extracted\n")
    print(pages[0].markdown[:2000])

Why it works this way

Why sample several pages for the scan check
Judging on page 1 alone fails both ways: a title page is nearly empty in every book, and a scanned book might have one text-based cover page. Sampling gives a stable signal.
Why page_chunks=True
It returns per-page results with page numbers attached. You need those numbers to say "p.42" in a citation, which is the feature that makes users trust the answers.
Reject rather than OCR
OCR is a whole project of its own — installing Tesseract, tuning it, handling its errors. Your design document decides scans are out of scope. That's a legitimate product decision, and shipping a clear error is better than shipping a broken-feeling feature.
Likely snag. If your two-column rulebook comes out with lines interleaved from both columns, note it and move on. Fixing it well means column detection, which is a rabbit hole. Use a single-column PDF for the rest of the course and revisit later.
Done when: You can print clean Markdown with # headings from a real PDF, and your scan detector correctly rejects a scanned one.
L16

Cleaning up the extracted text

30 min 0/5

The technology

The problem. Every page of a printed book has furniture: the book title in the header, a page number in the footer, maybe a chapter name. Extraction picks all of it up. If you leave it in, that text is embedded along with the real content, and it appears identically on every page — which pollutes similarity scores and wastes tokens in your context window.

The detection trick (section 6, step 2 of your design): a line that appears on more than ~60% of pages is furniture, not content. This is a nicely robust heuristic because real content almost never repeats that often, while headers repeat on literally every page.

Steps

Code

app/ingestion/normalise.py
import re
from collections import Counter

from app.ingestion.extract import Page


def find_repeated_lines(pages: list[Page], threshold: float = 0.6) -> set[str]:
    """Lines appearing on more than `threshold` of pages are headers/footers."""
    counts: Counter[str] = Counter()
    for page in pages:
        # a line can repeat within a page; count it once per page
        seen = {ln.strip() for ln in page.markdown.splitlines() if ln.strip()}
        counts.update(seen)

    cutoff = len(pages) * threshold
    return {line for line, c in counts.items() if c > cutoff and len(line) < 120}


def clean_page(markdown: str, furniture: set[str]) -> str:
    lines = [ln for ln in markdown.splitlines() if ln.strip() not in furniture]
    text = "\n".join(lines)

    text = re.sub(r"[ \t]+", " ", text)          # collapse runs of spaces
    text = re.sub(r"\n{3,}", "\n\n", text)        # at most one blank line
    text = re.sub(r"(\w)-\n(\w)", r"\1\2", text)  # rejoin hyphenated line breaks
    return text.strip()


def normalise(pages: list[Page]) -> list[Page]:
    furniture = find_repeated_lines(pages)
    return [Page(p.number, clean_page(p.markdown, furniture)) for p in pages]

Why it works this way

Why len(line) < 120 in the filter
A safety valve. If a book genuinely repeats a long paragraph, it's probably content (a recurring rules box, say) and you'd rather keep it. Headers are short.
Why blank lines must survive
Your chunker splits oversized sections on paragraph boundaries, and a paragraph boundary is a blank line. Collapse them all away and you lose the only structure you have inside a section.
The hyphenation rejoin
Print typography breaks words across lines with a hyphen. Left alone you get "sever-" and "ity" as separate tokens, and neither matches a search for "severity".
Done when: Your cleaned pages have no repeated headers or footers, and paragraph breaks are intact.
L17

Counting tokens with tiktoken

20 min 0/5

The technology

Why not just count characters. Chunk sizes, context windows, and prices are all measured in tokens. Characters are a rough proxy that drifts badly — a page of tables or code tokenises very differently from a page of prose. If you size chunks by characters you'll silently overflow context on some documents.

tiktoken is OpenAI's tokeniser. cl100k_base is the encoding used by GPT-4-class models and the embedding models. Anthropic's tokeniser differs slightly, but not enough to matter for chunk sizing — using tiktoken as a consistent ruler is fine.

Steps

Code

app/ingestion/tokens.py
import tiktoken

_enc = tiktoken.get_encoding("cl100k_base")


def count_tokens(text: str) -> int:
    return len(_enc.encode(text))


def truncate_to_tokens(text: str, limit: int) -> str:
    ids = _enc.encode(text)
    return _enc.decode(ids[:limit]) if len(ids) > limit else text


if __name__ == "__main__":
    s = "Harm is recorded in three levels: lesser, moderate, and severe."
    ids = _enc.encode(s)
    print(f"{len(s)} characters -> {len(ids)} tokens")
    print([_enc.decode([i]) for i in ids])

Why it works this way

Where you'll use this
Three places: deciding when a section is too big to be one chunk, deciding when it's too small to stand alone, and making sure the assembled context block fits the model's window before you send it.
Why one shared encoder object
Loading the encoding takes a moment and allocates memory. Creating one per call in a loop over 5,000 chunks is a real slowdown.
Done when: You can count the tokens in any string and have seen how a sentence splits into them.
L18

Structure-aware chunking

60 min 0/7

The technology

What chunking is. Cutting a document into pieces small enough to retrieve individually. This is the single highest-leverage decision in a RAG system, and most tutorials get it wrong by splitting every 1000 characters regardless of what's there.

Why blind splitting fails. Cut mid-sentence and both halves become less retrievable. Cut a table in half and neither half is usable. Cut a heading away from the rule underneath it and the rule loses its context entirely.

The policy from section 6, step 3 of your design document — implement exactly this:

  • Split on Markdown headings first, building a heading_path like Combat > Injury > Severity.
  • A section over 700 tokens splits on paragraph boundaries into ≤700-token pieces with ~100 tokens of overlap.
  • A section under 80 tokens merges forward into the next one — a lone heading is useless by itself.
  • Never split a Markdown table, even if oversized.

Why overlap. If an answer straddles a boundary, both neighbouring chunks contain a bit of it, so whichever one is retrieved still carries enough to be useful.

Steps

Code

app/ingestion/chunk.py
import re
from dataclasses import dataclass, field

from app.config import settings
from app.ingestion.extract import Page
from app.ingestion.tokens import count_tokens

HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
TABLE_ROW = re.compile(r"^\s*\|.*\|\s*$")


@dataclass
class Chunk:
    content: str
    heading_path: str
    page_from: int
    page_to: int
    ordinal: int = 0
    token_count: int = field(default=0)


def _sections(pages: list[Page]):
    """Walk the document, tracking the current heading stack."""
    stack: list[str] = []
    buf: list[str] = []
    start_page = pages[0].number if pages else 1
    end_page = start_page

    for page in pages:
        for line in page.markdown.splitlines():
            m = HEADING.match(line)
            if m:
                if buf and any(x.strip() for x in buf):
                    yield " > ".join(stack), "\n".join(buf), start_page, end_page
                level, title = len(m.group(1)), m.group(2).strip()
                stack = stack[: level - 1] + [title]
                buf, start_page = [], page.number
            else:
                buf.append(line)
        end_page = page.number

    if buf and any(x.strip() for x in buf):
        yield " > ".join(stack), "\n".join(buf), start_page, end_page


def _split_paragraphs(text: str, max_tok: int, overlap_tok: int) -> list[str]:
    """Split oversized text on blank lines; never break a table."""
    blocks, current, in_table = [], [], False
    for para in text.split("\n\n"):
        is_table = bool(TABLE_ROW.match(para.strip().splitlines()[0])) if para.strip() else False
        if is_table:
            if current:
                blocks.append("\n\n".join(current)); current = []
            blocks.append(para)          # table stays whole, whatever its size
        else:
            current.append(para)
    if current:
        blocks.append("\n\n".join(current))

    out, buf = [], []
    for block in blocks:
        candidate = buf + [block]
        if count_tokens("\n\n".join(candidate)) > max_tok and buf:
            out.append("\n\n".join(buf))
            # carry the tail of the previous chunk forward as overlap
            tail, tail_tokens = [], 0
            for prev in reversed(buf):
                tail_tokens += count_tokens(prev)
                tail.insert(0, prev)
                if tail_tokens >= overlap_tok:
                    break
            buf = tail + [block]
        else:
            buf = candidate
    if buf:
        out.append("\n\n".join(buf))
    return out


def chunk_document(pages: list[Page]) -> list[Chunk]:
    max_tok = settings.chunk_max_tokens
    min_tok = settings.chunk_min_tokens
    overlap = settings.chunk_overlap_tokens

    raw: list[Chunk] = []
    for path, text, p_from, p_to in _sections(pages):
        text = text.strip()
        if not text:
            continue
        pieces = ([text] if count_tokens(text) <= max_tok
                  else _split_paragraphs(text, max_tok, overlap))
        for piece in pieces:
            raw.append(Chunk(piece, path, p_from, p_to,
                             token_count=count_tokens(piece)))

    # merge-forward pass for undersized chunks
    merged: list[Chunk] = []
    carry: Chunk | None = None
    for c in raw:
        if carry:
            c = Chunk(carry.content + "\n\n" + c.content,
                      carry.heading_path or c.heading_path,
                      carry.page_from, c.page_to,
                      token_count=count_tokens(carry.content + c.content))
            carry = None
        if c.token_count < min_tok:
            carry = c
            continue
        merged.append(c)
    if carry:
        if merged:
            last = merged[-1]
            last.content += "\n\n" + carry.content
            last.page_to = carry.page_to
            last.token_count = count_tokens(last.content)
        else:
            merged.append(carry)

    for i, c in enumerate(merged):
        c.ordinal = i
    return merged

Why it works this way

Why ordinal matters later
Section 7.3 of your design handles broad questions by pulling neighbouring chunks — ordinal ± 2. That only works if you record each chunk's position in the document now.
Why heading_path is a string like "Combat > Injury"
It's both human-readable in a citation and prefix-matchable in SQL, which is exactly what the broad-scope expansion in Lesson 45 needs.
Chunk size is an open question, not a finding
Open decision #3 in your design document says test 400 and 1000 too. You can't judge that yet — you need the eval harness from Lesson 27 first. Note it and move on.
Done when: Your PDF becomes a list of chunks that each read sensibly on their own, with heading paths and page numbers attached.
L19

Contextualise and embed

35 min 0/6

The technology

The contextualisation trick (section 6, step 4). Consider a chunk that reads, in full: "Roll 2d6 and consult the table below." Embed that and it is essentially unretrievable — it matches no question anyone would ask. Now prepend its heading path: "Combat > Injury > Severity — Roll 2d6 and consult the table below." Suddenly it answers "how do I determine injury severity". Same text, completely different retrievability.

For transcripts the equivalent is prefixing the session date, so that "our March session" has something to match against in the embedded text as well as in the metadata.

Batching. Embedding APIs accept a list. Sending 100 texts in one call instead of 100 calls is roughly 100x faster in wall-clock time, because you pay the network round-trip once.

Steps

Code

app/ingestion/embed.py
import time

from litellm import embedding

from app.config import settings
from app.ingestion.chunk import Chunk

BATCH = 100


def contextualise(chunk: Chunk, session_date: str | None = None) -> str:
    """Design doc section 6, step 4: prepend structure BEFORE embedding."""
    if session_date:
        return f"[Session: {session_date}] {chunk.content}"
    if chunk.heading_path:
        return f"{chunk.heading_path} -- {chunk.content}"
    return chunk.content


def embed_texts(texts: list[str], max_retries: int = 5) -> list[list[float]]:
    out: list[list[float]] = []
    for i in range(0, len(texts), BATCH):
        batch = texts[i : i + BATCH]
        for attempt in range(max_retries):
            try:
                resp = embedding(model=settings.embedding_model, input=batch)
                out.extend(d["embedding"] for d in resp.data)
                break
            except Exception as exc:  # rate limits, transient network errors
                if attempt == max_retries - 1:
                    raise
                wait = 2 ** attempt
                print(f"  embed retry {attempt + 1} in {wait}s ({exc})")
                time.sleep(wait)
        print(f"  embedded {min(i + BATCH, len(texts))}/{len(texts)}")
    return out


def embed_chunks(chunks: list[Chunk], session_date: str | None = None):
    return embed_texts([contextualise(c, session_date) for c in chunks])

Why it works this way

The stored text and the embedded text differ
You embed the prefixed version but store and display the original. The prefix is a retrieval aid; showing it in the citation viewer would look like a bug.
Exponential backoff
Retrying immediately after a rate limit just gets rate-limited again. Doubling the wait each time (1s, 2s, 4s...) is the standard, well-behaved pattern.
Why print progress
Embedding a 300-page book takes minutes. A silent script is indistinguishable from a hung one.
Done when: Every chunk has a 1536-dimension vector, generated in batches, and you've measured that the heading prefix improves retrieval.
L20

The real schema, and storing chunks

45 min 0/8

The technology

SQLAlchemy lets you describe tables as Python classes and query them in Python, while still dropping to raw SQL when you need to — which you will, because pgvector operators have no Python equivalent.

The schema is section 4 of your design document, unchanged. Two details there are worth understanding rather than copying:

Why user_id is on chunks as well as documents. It's deliberately denormalised. Vector search is the hottest and most security-critical query in the system. If ownership requires a join to documents, then one forgotten join anywhere in the codebase is a cross-tenant leak. A column on the row being searched makes the filter local — and lets the database enforce it directly in Module 6.

The tsv generated column. Postgres computes it automatically from content — a searchable index of the words, with stemming, so "injuries" matches "injury". You never write to it. Module 4 uses it for the keyword arm of hybrid search.

Steps

Code

app/db/models.py
import uuid
from datetime import date, datetime

from pgvector.sqlalchemy import Vector
from sqlalchemy import (Date, DateTime, ForeignKey, Integer, String, Text,
                        create_engine, func)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker

from app.config import settings

engine = create_engine(settings.database_url, echo=False)
SessionLocal = sessionmaker(bind=engine)


class Base(DeclarativeBase):
    pass


class Document(Base):
    __tablename__ = "documents"

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True)
    title: Mapped[str] = mapped_column(Text)
    doc_type: Mapped[str] = mapped_column(String(20))     # ruleset | transcript
    storage_path: Mapped[str] = mapped_column(Text)
    file_hash: Mapped[str | None] = mapped_column(String(64))
    game_system: Mapped[str | None] = mapped_column(Text)
    campaign: Mapped[str | None] = mapped_column(Text)
    session_date: Mapped[date | None] = mapped_column(Date)
    status: Mapped[str] = mapped_column(String(20), default="pending")
    error_message: Mapped[str | None] = mapped_column(Text)
    page_count: Mapped[int | None] = mapped_column(Integer)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now())


class Chunk(Base):
    __tablename__ = "chunks"

    id: Mapped[uuid.UUID] = mapped_column(
        UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    document_id: Mapped[uuid.UUID] = mapped_column(
        ForeignKey("documents.id", ondelete="CASCADE"))
    # Denormalised on purpose -- see design doc section 4
    user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
    ordinal: Mapped[int] = mapped_column(Integer)
    content: Mapped[str] = mapped_column(Text)
    heading_path: Mapped[str | None] = mapped_column(Text)
    page_from: Mapped[int | None] = mapped_column(Integer)
    page_to: Mapped[int | None] = mapped_column(Integer)
    token_count: Mapped[int] = mapped_column(Integer)
    embedding: Mapped[list[float]] = mapped_column(Vector(settings.embedding_dim))
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now())
Run in psql (generated column + indexes)
ALTER TABLE chunks
  ADD COLUMN tsv tsvector
  GENERATED ALWAYS AS (to_tsvector('english', content)) STORED;

CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON chunks USING gin (tsv);
CREATE INDEX ON chunks (user_id, document_id, ordinal);

Why it works this way

One transaction per document
If embedding fails at chunk 400 of 500, you want zero chunks stored, not 399. A half-ingested document is worse than a failed one because it looks fine.
Why hash the file
Users re-upload the same PDF constantly. Without a hash check you get duplicate chunks, which means duplicate search results and a bill for re-embedding.
status as an explicit column
Ingestion takes minutes and happens in the background from Module 7. The frontend polls this column to show a progress state — including the distinct "failed: this is a scan" state your design document asks for.
Done when: Your PDF is in Postgres as a document row and several hundred chunk rows with embeddings, and re-running the ingest doesn't duplicate them.
L21

Retrieve and assemble the context block

35 min 0/6

The technology

Retrieval here is the vector search from Lesson 13, now against real chunks, always filtered by user_id.

The context block is the formatted text you paste into the prompt. Its shape matters (section 8, mechanism 1). Each source gets a bracket number and a human-readable provenance line:

[1] (Blades in the Dark, p.42, "Combat > Harm")
Harm is recorded in three levels: lesser, moderate, severe...

Those bracket numbers do three jobs at once: they're what the model cites, they're what your verification code checks in Lesson 22, and they're what the UI turns into a clickable link in Lesson 43.

The budget. Count tokens as you add sources and stop before you blow the context window. Never assume 8 chunks fit — one oversized table chunk can be 2000 tokens on its own.

Steps

Code

app/retrieval/search.py
import uuid

from sqlalchemy import text

from app.config import settings
from app.db.models import SessionLocal
from app.ingestion.embed import embed_texts

VECTOR_SQL = text("""
    SELECT c.id, c.document_id, c.content, c.heading_path,
           c.page_from, c.page_to, c.ordinal,
           d.title, 1 - (c.embedding <=> (:qvec)::vector) AS score
    FROM chunks c
    JOIN documents d ON d.id = c.document_id
    WHERE c.user_id = :uid
    ORDER BY c.embedding <=> (:qvec)::vector
    LIMIT :k
""")


def vector_search(user_id: uuid.UUID, query: str, k: int | None = None):
    """user_id is the first argument, always. Design doc section 5, layer 1."""
    k = k or settings.retrieve_candidates
    qvec = embed_texts([query])[0]
    with SessionLocal() as session:
        rows = session.execute(
            VECTOR_SQL, {"qvec": str(qvec), "uid": str(user_id), "k": k}
        ).mappings().all()
    return [dict(r) for r in rows]
app/generation/context.py
from app.ingestion.tokens import count_tokens

BUDGET = 12000   # leave room for the system prompt, question, and answer


def build_context(chunks: list[dict], budget: int = BUDGET):
    """Numbered sources with provenance. Design doc section 8, mechanism 1."""
    parts, used, included = [], 0, []

    for chunk in chunks:
        pages = (f"p.{chunk['page_from']}"
                 if chunk["page_from"] == chunk["page_to"]
                 else f"pp.{chunk['page_from']}-{chunk['page_to']}")
        header = f'({chunk["title"]}, {pages}, "{chunk["heading_path"] or ""}")'
        body = f'[{len(included) + 1}] {header}\n{chunk["content"]}'

        cost = count_tokens(body)
        if used + cost > budget:
            break
        parts.append(body)
        included.append(chunk)
        used += cost

    return "\n\n".join(parts), included

Why it works this way

Why the bracket number is positional, not the database id
A UUID in a prompt wastes tokens and models cite them unreliably. You keep a list mapping [1] back to the real chunk id, and use it when returning citations to the frontend.
Vector search never returns nothing
There's always a nearest neighbour, even for a question about a topic the book has never heard of. The refusal has to come from the prompt and from grounding, not from retrieval returning empty.
Why the budget loop breaks rather than skips
Chunks arrive in relevance order. Skipping a big one to fit a less relevant small one gives the model worse material.
Done when: You can print 8 relevant chunks for a real question, formatted as a numbered context block that fits a token budget.
L22

Generate the answer, and verify its citations

45 min 0/7

The technology

The four anti-confabulation mechanisms from section 8 of your design, all in this lesson:

  1. Numbered sources in the context block — done in Lesson 21.
  2. The system prompt — only these sources, cite everything, exact refusal string, and the crucial clause that published-rules memory is wrong here because house rules override it.
  3. Temperature 0.1, and re-retrieval on every turn rather than leaning on history.
  4. Post-hoc verification — parse the [n] markers out of the answer; any number not in the context set is a fabricated citation. Regenerate once, then fail loudly.

Mechanism 4 is the one tutorials skip and it's the one that catches real failures, because it needs no judgement — it's a set membership test.

Steps

Code

app/generation/answer.py
import re

from litellm import completion

from app.config import settings

SYSTEM = '''Answer using ONLY the numbered sources below. Cite every factual
claim with its bracket number, e.g. [2]. If the sources do not contain the
answer, reply exactly: "I couldn't find that in your materials."

Do not use general knowledge about tabletop games -- the user's house rules
may contradict published rules, and the sources are authoritative.
If sources conflict, present both and note the conflict.
If the sources answer only part of the question, answer that part and state
what is missing.
State the coverage of your answer, e.g. "Based on the Injury chapter
(pp. 41-46)...", so that a partial answer is visibly partial.'''

REFUSAL = "I couldn't find that in your materials."
CITATION = re.compile(r"\[(\d+)\]")


def verify_citations(answer: str, n_sources: int) -> list[int]:
    """Return any cited numbers that do not exist. Design doc s8, mechanism 4."""
    cited = {int(m) for m in CITATION.findall(answer)}
    return sorted(n for n in cited if n < 1 or n > n_sources)


def generate(question: str, context: str, n_sources: int) -> tuple[str, bool]:
    def call() -> str:
        return completion(
            model=settings.chat_model,
            temperature=settings.temperature,
            messages=[
                {"role": "system", "content": SYSTEM},
                {"role": "user",
                 "content": f"{context}\n\nQuestion: {question}"},
            ],
        ).choices[0].message.content

    answer = call()
    bad = verify_citations(answer, n_sources)
    if bad:
        print(f"  hallucinated citation(s) {bad}; regenerating once")
        answer = call()
        bad = verify_citations(answer, n_sources)
        if bad:
            return answer, False      # caller surfaces an "unverified" flag
    return answer, True
scripts/ask.py
import sys
import uuid

from app.generation.answer import generate
from app.generation.context import build_context
from app.retrieval.search import vector_search

DEV_USER = uuid.UUID("00000000-0000-0000-0000-000000000001")


def main() -> None:
    question = " ".join(sys.argv[1:])
    if not question:
        print('usage: python scripts/ask.py "your question"')
        raise SystemExit(1)

    candidates = vector_search(DEV_USER, question, k=8)
    context, used = build_context(candidates)
    answer, verified = generate(question, context, len(used))

    print(answer)
    print("\n--- sources ---")
    for i, c in enumerate(used, 1):
        print(f"[{i}] {c['title']} p.{c['page_from']} :: {c['heading_path']}")
    if not verified:
        print("\n!! UNVERIFIED: the answer cited a source that does not exist.")


if __name__ == "__main__":
    main()

Why it works this way

Why the refusal string is checked exactly
Your eval harness in Lesson 27 measures "refusal accuracy" — did it say I-don't-know exactly when it should. That's only measurable against a fixed string.
Regenerate once, then fail loudly
Retrying forever burns money on a model that's confused. One retry catches transient sloppiness; a second failure is a real signal you should see, not hide.
Why surface "unverified" instead of hiding it
Your design document's strongest anti-confabulation tool is the human reading the answer. Hiding uncertainty removes their ability to help.
Done when: python scripts/ask.py "how does harm work" prints a cited answer from your own PDF, and refuses correctly on questions the book doesn't cover. Milestone 1 done.
Module 4

Milestone 2 — retrieval that actually works

Hybrid search, fusion, reranking, and measurement

Fix the failure you measured in Lesson 8, then build the eval harness so that from here on you tune by numbers instead of by vibes.

5 lessons · L23–L27 ~4.5 h 0 of 5 lessons complete
L23

Prove to yourself that vector search alone fails

30 min 0/6

The technology

Why this lesson exists. You are about to write a meaningful amount of code — a second search arm, a fusion algorithm, a reranker. It is very easy to write all that on faith. Don't. Spend 30 minutes producing the failure with your own data first, so that every later decision is anchored to something you saw.

The two failure shapes, both named in section 7.1 of your design document:

  • Proper nouns. "Was Peter a warlock?" — embeddings place all first names near each other, so the vector arm returns turns involving Michael and Sarah just as happily.
  • Exact jargon. A user searching for a specific spell name, item, or stat block wants the literal string. Semantics actively hurt here.

The complement is also true, which is why you keep both arms: keyword search alone fails on "injury types" when the book says "wound severity", because they share no words at all.

Steps

Why it works this way

Measure before you optimise
Without this baseline you have no way of knowing whether the next four lessons helped. This is the same discipline the eval harness formalises in Lesson 27.
Why not just always use keyword search for names
Because you don't know in advance which questions contain a proper noun that matters. Running both arms every time and fusing is simpler and more robust than trying to classify the question first.
Done when: You have written down, from your own data, a concrete case where vector search returns the wrong chunks.
L24

Full-text search in Postgres

40 min 0/6

The technology

What full-text search is. Not LIKE '%peter%'. Postgres has a real search engine built in. It converts text into a tsvector — a list of normalised word stems with positions — and queries into a tsquery. Matching happens between those two.

Stemming is what makes it useful: "injuries", "injured", and "injury" all reduce to the stem injuri, so any of them matches any other. Stopwords ("the", "of", "and") are dropped because they carry no signal.

Ranking. ts_rank_cd scores a match by how many query terms appear, how often, and how close together they are. Cover density (the cd) rewards terms appearing near each other, which is usually what you want.

The GIN index makes tsv @@ query fast by mapping each stem to the rows containing it — an inverted index, the same structure a search engine uses.

You already created both the tsv generated column and its GIN index in Lesson 20. This lesson uses them.

Steps

Code

add to app/retrieval/search.py
KEYWORD_SQL = text("""
    SELECT c.id, c.document_id, c.content, c.heading_path,
           c.page_from, c.page_to, c.ordinal, d.title,
           ts_rank_cd(c.tsv, websearch_to_tsquery('english', :q)) AS score
    FROM chunks c
    JOIN documents d ON d.id = c.document_id
    WHERE c.user_id = :uid
      AND c.tsv @@ websearch_to_tsquery('english', :q)
    ORDER BY score DESC
    LIMIT :k
""")


def keyword_search(user_id: uuid.UUID, query: str, k: int | None = None):
    k = k or settings.retrieve_candidates
    with SessionLocal() as session:
        rows = session.execute(
            KEYWORD_SQL, {"q": query, "uid": str(user_id), "k": k}
        ).mappings().all()
    return [dict(r) for r in rows]

Why it works this way

Why websearch_to_tsquery over plainto_tsquery
It accepts the syntax users already know from Google: quoted phrases, or, and -word to exclude. plainto_tsquery ANDs everything, which fails whenever one word is missing.
Why the generated column instead of computing tsv at query time
A generated STORED column is computed once at write time and can be indexed. Computing to_tsvector(content) in the WHERE clause would run over every row on every query.
'english' is a choice
It selects the stemming and stopword rules. If your users' materials are in another language this is where you'd change it — and a mixed-language corpus is genuinely awkward, worth knowing now.
Done when: A keyword search finds your named-speaker chunks that the vector search missed.
L25

Reciprocal Rank Fusion

35 min 0/6

The technology

The problem fusion solves. You have two ranked lists. The vector arm produces cosine similarities around 0.3-0.8; the keyword arm produces ts_rank_cd values around 0.001-0.1. These numbers are on completely different scales and neither is calibrated. Adding or averaging them is meaningless, and normalising them requires knowing the distribution, which changes per query.

RRF sidesteps this entirely by throwing the scores away and keeping only the ranks:

RRF(d) = Σ over arms of  1 / (k + rank_in_that_arm(d))     with k = 60

A document ranked 1st in one arm contributes 1/61. Ranked 2nd, 1/62. The differences between adjacent ranks are small, so appearing in both lists matters more than being top of one — which is precisely the behaviour you want. A chunk that both arms like is very probably relevant.

Why k=60. It's the value from the original 2009 paper and it has held up well in practice. Larger k flattens the curve (rank matters less); smaller k sharpens it. Leave it at 60 unless your eval set tells you otherwise.

Steps

Code

app/retrieval/fuse.py
from collections import defaultdict

K = 60


def reciprocal_rank_fusion(result_lists: list[list[dict]], k: int = K,
                           limit: int = 30) -> list[dict]:
    """Design doc section 7.1. Ranks only -- scores are never comparable."""
    scores: dict[str, float] = defaultdict(float)
    by_id: dict[str, dict] = {}

    for results in result_lists:
        for rank, row in enumerate(results, start=1):
            cid = str(row["id"])
            scores[cid] += 1.0 / (k + rank)
            by_id.setdefault(cid, row)

    ranked = sorted(scores.items(), key=lambda kv: -kv[1])
    out = []
    for cid, score in ranked[:limit]:
        row = dict(by_id[cid])
        row["rrf_score"] = score
        out.append(row)
    return out
add to app/retrieval/search.py
from app.retrieval.fuse import reciprocal_rank_fusion


def hybrid_search(user_id: uuid.UUID, query: str, limit: int | None = None):
    limit = limit or settings.retrieve_candidates
    vec = vector_search(user_id, query, k=limit)
    kw = keyword_search(user_id, query, k=limit)
    return reciprocal_rank_fusion([vec, kw], limit=limit)

Why it works this way

Why not normalise the scores instead
Min-max normalisation depends on the range within each result set, which varies wildly by query. A query where every result is mediocre would get its best mediocre result normalised to 1.0 — falsely confident. RRF has no such failure mode.
RRF extends to any number of arms
Add a third arm later — say, a title-match search or a metadata-filtered one — and the formula is unchanged. That's a real architectural benefit.
Ranks discard useful information too
RRF can't tell a 0.9-similarity top hit from a 0.4-similarity top hit. That's the price of not needing calibration, and it's why reranking exists as a separate stage.
Done when: Your hybrid search finds the proper-noun chunks and still handles the semantic question. You have numbers proving both.
L26

Reranking the top 30 down to 8

40 min 0/6

The technology

Why a second stage. Retrieval is optimised for recall over thousands of chunks — cheap and approximate, cast a wide net. Reranking is optimised for precision over 30 candidates — expensive and careful, keep only the best. The two-stage shape is standard across search systems.

LLM-as-reranker. Send the question and the 30 candidates to a cheap model and ask it to score each 0-10 for relevance. Slower and pricier than a real reranker, but requires no new infrastructure and is easy to reason about. Your design document says start here.

Cross-encoders (like bge-reranker-base) are the upgrade path: a small model that reads question and passage together and outputs one relevance score. Far more accurate than embeddings, far cheaper than an LLM call, but you have to host it.

The important instruction: section 7.1 says measure whether reranking actually helps before keeping it, and open decision #2 asks whether it earns its latency. So build it now, and hold the verdict until Lesson 27 gives you an eval set.

Steps

Code

app/retrieval/rerank.py
import json

from litellm import completion
from pydantic import BaseModel, ValidationError

from app.config import settings

PROMPT = """Score each passage 0-10 for how well it helps answer the question.
10 = directly answers it. 0 = unrelated.
Return ONLY a JSON object: {"scores": [{"i": 0, "s": 7}, ...]}
Include every passage index exactly once.

Question: {question}

Passages:
{passages}"""


class Score(BaseModel):
    i: int
    s: int


class Scores(BaseModel):
    scores: list[Score]


def rerank(question: str, candidates: list[dict], top_n: int | None = None,
           max_retries: int = 2) -> list[dict]:
    top_n = top_n or settings.retrieve_final
    if len(candidates) <= top_n:
        return candidates

    passages = "\n\n".join(
        f"[{i}] {c['content'][:600]}" for i, c in enumerate(candidates)
    )
    prompt = PROMPT.format(question=question, passages=passages)

    for _ in range(max_retries):
        raw = completion(
            model=settings.utility_model,
            temperature=0.0,
            response_format={"type": "json_object"},
            messages=[{"role": "user", "content": prompt}],
        ).choices[0].message.content
        try:
            parsed = Scores.model_validate(json.loads(raw))
            break
        except (json.JSONDecodeError, ValidationError):
            continue
    else:
        # model never complied -- fall back to fusion order rather than failing
        return candidates[:top_n]

    lookup = {s.i: s.s for s in parsed.scores}
    ranked = sorted(
        enumerate(candidates), key=lambda pair: -lookup.get(pair[0], 0)
    )
    out = []
    for idx, cand in ranked[:top_n]:
        row = dict(cand)
        row["rerank_score"] = lookup.get(idx, 0)
        out.append(row)
    return out

Why it works this way

Why truncate passages to 600 characters
You're sending 30 passages in one prompt. Full 700-token chunks would be ~21,000 tokens per rerank call, on every question. The first 600 characters are almost always enough to judge relevance.
Why fall back rather than raise
If the utility model can't produce valid JSON, the fusion order is still a perfectly reasonable ranking. Degrading gracefully beats returning a 500 to the user.
response_format={"type": "json_object"}
Constrains OpenAI models to emit valid JSON. Not universally supported — which is exactly why the Pydantic validation and retry are still there.
Done when: Reranking runs, is measurable, and can be switched off with one config value.
L27

The eval harness — stop guessing

60 min 0/8

The technology

Why now and not at the end. Section 12 of your design document is blunt about it: without this you will be tuning by vibes. You are about to make dozens of decisions — chunk size, reranking on or off, k values, prompt wording. Each will feel like an improvement. Roughly half won't be.

The three metrics:

  • recall@k — of the chunks that should have been retrieved, how many were in the top k? Pure retrieval quality, no LLM involved, cheap and deterministic. Your most useful number.
  • groundedness — is every claim in the answer supported by a cited chunk? Judged by a second LLM call.
  • refusal accuracy — did it say "I couldn't find that" exactly when it should have? This is the one that measures the failure mode you actually care about.

The question set. 25-40 questions over a fixed corpus. Crucially, about a quarter must be unanswerable — genuinely absent from the corpus. Those are the most valuable entries, because a system that never refuses scores perfectly on every other metric while being useless.

Steps

Code

eval/questions.yaml
- id: q01
  question: "What are the harm levels?"
  kind: factual
  expected_answer: "Lesser, moderate, severe -- plus the house rule 'grievous'."
  expected_chunk_ids: ["<paste-real-uuid>", "<paste-real-uuid>"]
  should_refuse: false

- id: q02
  question: "Was Peter's character a warlock in our March 2025 session?"
  kind: metadata_filtered
  expected_answer: "Yes -- he casts Eldritch Blast in the 2025-03-14 session."
  expected_chunk_ids: ["<paste-real-uuid>"]
  should_refuse: false

- id: q03
  question: "List every injury type in the ruleset."
  kind: aggregative
  expected_answer: "The complete list from the Injury chapter."
  expected_chunk_ids: ["<uuid>", "<uuid>", "<uuid>", "<uuid>"]
  should_refuse: false

- id: q04
  question: "What is the price of a longsword?"
  kind: unanswerable
  expected_answer: null
  expected_chunk_ids: []
  should_refuse: true
eval/run.py
import uuid

import yaml
from litellm import completion

from app.config import settings
from app.generation.answer import REFUSAL, generate
from app.generation.context import build_context
from app.retrieval.rerank import rerank
from app.retrieval.search import hybrid_search

DEV_USER = uuid.UUID("00000000-0000-0000-0000-000000000001")

JUDGE = """You are grading an answer for GROUNDEDNESS only.
Is every factual claim in the ANSWER supported by the SOURCES?
Reply with exactly one word: GROUNDED or UNGROUNDED.

SOURCES:
{context}

ANSWER:
{answer}"""


def judge_grounded(context: str, answer: str) -> bool:
    verdict = completion(
        model=settings.utility_model,
        temperature=0.0,
        messages=[{"role": "user",
                   "content": JUDGE.format(context=context, answer=answer)}],
    ).choices[0].message.content
    return "UNGROUNDED" not in verdict.upper()


def main() -> None:
    questions = yaml.safe_load(open("eval/questions.yaml"))
    recalls, grounded, refusal_ok = [], [], []

    for q in questions:
        candidates = hybrid_search(DEV_USER, q["question"])
        top = rerank(q["question"], candidates)
        retrieved = {str(c["id"]) for c in top}

        expected = set(q.get("expected_chunk_ids") or [])
        if expected:
            recalls.append(len(expected & retrieved) / len(expected))

        context, used = build_context(top)
        answer, _ = generate(q["question"], context, len(used))

        refused = REFUSAL.lower() in answer.lower()
        refusal_ok.append(refused == q["should_refuse"])
        if not q["should_refuse"]:
            grounded.append(judge_grounded(context, answer))

        flag = "ok " if refused == q["should_refuse"] else "MISS"
        print(f"{flag} {q['id']:>4} [{q['kind']:<17}] {q['question'][:50]}")

    def pct(xs):
        return f"{100 * sum(xs) / len(xs):.1f}%" if xs else "n/a"

    print("\n=== results ===")
    print(f"recall@{settings.retrieve_final:<3}    {pct(recalls)}")
    print(f"groundedness    {pct(grounded)}")
    print(f"refusal acc.    {pct(refusal_ok)}")


if __name__ == "__main__":
    main()

Why it works this way

Why unanswerable questions are the valuable ones
Every RAG system looks good on questions the corpus answers. The one that matters is whether it knows when to stop. A quarter of your set being unanswerable keeps that number honest.
Why recall@k needs no LLM
It's set intersection over chunk ids. Deterministic, free, instant. Run it after every retrieval change; save the expensive LLM-judged metrics for bigger checkpoints.
Spot-check the judge
An LLM grading LLM output has its own failure modes. Read ten of its verdicts by hand before you trust the percentage. Your design document says this explicitly.
Done when: python eval/run.py prints three numbers, and you have used them to settle at least one open decision from section 14. Milestone 2 done.
Module 5

FastAPI — putting a web API in front of it

HTTP, schemas, migrations, streaming

Turn your command-line tool into a web service that a browser can talk to, with proper schema migrations and token-by-token streaming.

4 lessons · L28–L31 ~4 h 0 of 4 lessons complete
L28

HTTP and your first FastAPI endpoint

40 min 0/7

The technology

What HTTP is. A request/response protocol. A client sends a method (GET to read, POST to create, DELETE to remove), a path (/api/documents), optional headers (including auth), and an optional body (usually JSON). The server replies with a status code (200 fine, 401 not logged in, 404 not found, 422 bad input, 500 server broke) and a body.

What FastAPI is. A Python web framework where you write a normal function, add a decorator saying which path and method it serves, and annotate its arguments with types. FastAPI then validates incoming data against those types, converts it, returns a clear 422 when it doesn't match, and generates interactive API documentation — all from the annotations you'd write anyway.

Why async matters here. Your endpoints spend most of their time waiting on an LLM API. A synchronous server blocks a whole worker while waiting. async def lets it serve other requests during the wait. With LLM calls taking seconds, this is the difference between handling 5 concurrent users and 500.

Steps

Code

app/main.py
from contextlib import asynccontextmanager

from fastapi import Depends, FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session

from app.db.models import SessionLocal


def get_db():
    """One session per request, always closed. Injected with Depends."""
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


@asynccontextmanager
async def lifespan(app: FastAPI):
    print("starting up")
    yield
    print("shutting down")


app = FastAPI(title="Loremaster API", lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],   # the Next.js dev server
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


class AskRequest(BaseModel):
    question: str


@app.get("/health")
async def health(db: Session = Depends(get_db)):
    db.execute(text("SELECT 1"))
    return {"status": "ok", "database": "connected"}


@app.post("/api/echo")
async def echo(body: AskRequest):
    if not body.question.strip():
        raise HTTPException(status_code=400, detail="question cannot be empty")
    return {"you_asked": body.question, "length": len(body.question)}

Why it works this way

What CORS is and why you need it
Browsers block a page served from localhost:3000 from calling an API on localhost:8000 unless the API explicitly allows it. That's CORS. Forget this middleware and your frontend gets a confusing browser-console error that looks nothing like a permissions problem.
Why the get_db generator pattern
The code before yield runs before your endpoint, the code after runs when it finishes — even if it raised. Guarantees the connection is returned to the pool. This is FastAPI's dependency-injection idiom and you'll reuse the shape constantly.
Sync SQLAlchemy inside an async endpoint
A slight impurity: your database calls block. It's fine at this scale, and the LLM calls dominate latency anyway. Async SQLAlchemy exists if you later need it.
Done when: /docs opens in your browser and you can call your own endpoints from it.
L29

Alembic — changing the schema without losing data

40 min 0/8

The technology

The problem. So far you've created tables with create_all(), which only creates what doesn't exist. Add a column to a model and it does nothing. Your only recourse is dropping the database — fine now, impossible once real users have uploaded books.

What Alembic is. Version control for your database schema. Each change is a migration: a numbered Python file with upgrade() and downgrade(). Alembic tracks which migrations have run in a table inside the database, so it knows exactly what to apply.

Autogenerate. Alembic can compare your SQLAlchemy models to the live database and write the migration for you. It gets ~90% right and misses things it can't see — generated columns, custom index types, extension types. Always read the generated file before running it.

Your design document specifically calls this out: "Alembic matters more than you'd think once you start changing the chunk schema, which you will." That's a prediction about open decision #3.

Steps

Code

alembic/env.py (the parts to change)
from dotenv import load_dotenv

from app.config import settings
from app.db.models import Base

load_dotenv()

config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)

target_metadata = Base.metadata   # this is what enables --autogenerate
a hand-written migration
def upgrade() -> None:
    op.execute("""
        ALTER TABLE chunks
        ADD COLUMN tsv tsvector
        GENERATED ALWAYS AS (to_tsvector('english', content)) STORED
    """)
    op.execute("CREATE INDEX ix_chunks_tsv ON chunks USING gin (tsv)")


def downgrade() -> None:
    op.execute("DROP INDEX IF EXISTS ix_chunks_tsv")
    op.execute("ALTER TABLE chunks DROP COLUMN IF EXISTS tsv")

Why it works this way

Why write downgrade() even though you rarely run it
Writing it forces you to think about whether the change is reversible. A migration that drops a column can't restore the data — noticing that before you run it in production is the point.
The one migration that will hurt
Changing embedding model means vector(1536) becomes vector(3072) — a migration plus re-embedding every chunk. Your design document says write scripts/reembed.py early, while you have ten documents. Do that in this lesson while it's cheap.
Autogenerate blind spots
It won't see generated columns, HNSW/GIN index types, check constraints on some setups, or anything created with raw SQL. Read every generated file.
Done when: You can add a column to a model, generate a migration, apply it, and roll it back — and you've hand-written one migration for something autogenerate missed.
L30

The documents endpoints

45 min 0/7

The technology

Request and response schemas. Pydantic models define the shape of data crossing your API boundary. They're deliberately not your database models: the database row has an embedding column nobody outside should see, and the API response has fields the database doesn't store. Keeping them separate is what stops internal changes from silently breaking your frontend — and stops internal data from leaking out.

File uploads arrive as multipart/form-data, not JSON. FastAPI handles this with UploadFile, which streams to a temp file rather than loading a 50MB PDF into memory.

202 Accepted. Your design document's API surface specifies POST /api/documents → 202 + document_id. 202 means "I've taken this and will work on it" rather than 200's "done". The client then polls the status endpoint. This is the correct shape for anything slow — and it's what makes Module 7's background worker fit in without changing the API.

Steps

Code

app/schemas.py
import uuid
from datetime import date, datetime
from typing import Literal

from pydantic import BaseModel, ConfigDict


class DocumentOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)

    id: uuid.UUID
    title: str
    doc_type: Literal["ruleset", "transcript"]
    status: Literal["pending", "processing", "ready", "failed"]
    error_message: str | None = None
    game_system: str | None = None
    campaign: str | None = None
    session_date: date | None = None
    page_count: int | None = None
    created_at: datetime
    # note: no embedding, no storage_path, no user_id


class DocumentCreated(BaseModel):
    id: uuid.UUID
    status: str


class Citation(BaseModel):
    n: int
    chunk_id: uuid.UUID
    document_title: str
    page_from: int | None
    page_to: int | None
    heading_path: str | None


class AnswerOut(BaseModel):
    answer: str
    citations: list[Citation]
    verified: bool
app/routers/documents.py
import uuid
from pathlib import Path

from fastapi import (APIRouter, Depends, File, Form, HTTPException, UploadFile,
                     status)
from sqlalchemy.orm import Session

from app.db.models import Document
from app.main import get_db
from app.schemas import DocumentCreated, DocumentOut

router = APIRouter(prefix="/api/documents", tags=["documents"])

# Replaced by the real authenticated user in Lesson 33
DEV_USER = uuid.UUID("00000000-0000-0000-0000-000000000001")
UPLOAD_DIR = Path("data/uploads")


@router.post("", status_code=status.HTTP_202_ACCEPTED,
             response_model=DocumentCreated)
async def upload(
    file: UploadFile = File(...),
    title: str = Form(...),
    doc_type: str = Form(...),
    db: Session = Depends(get_db),
):
    if not file.filename.lower().endswith(".pdf"):
        raise HTTPException(400, "only PDF files are supported")

    UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
    doc_id = uuid.uuid4()
    path = UPLOAD_DIR / f"{doc_id}.pdf"
    path.write_bytes(await file.read())

    doc = Document(id=doc_id, user_id=DEV_USER, title=title,
                   doc_type=doc_type, storage_path=str(path), status="pending")
    db.add(doc)
    db.commit()

    # SEAM: in Lesson 37 this becomes `await redis.enqueue_job("ingest", doc_id)`
    from app.ingestion.pipeline import ingest_document
    ingest_document(doc_id)

    return DocumentCreated(id=doc_id, status="pending")


@router.get("", response_model=list[DocumentOut])
async def list_documents(db: Session = Depends(get_db)):
    return (db.query(Document)
              .filter(Document.user_id == DEV_USER)
              .order_by(Document.created_at.desc())
              .all())


@router.get("/{doc_id}", response_model=DocumentOut)
async def get_document(doc_id: uuid.UUID, db: Session = Depends(get_db)):
    doc = (db.query(Document)
             .filter(Document.id == doc_id, Document.user_id == DEV_USER)
             .first())
    if not doc:
        raise HTTPException(404, "document not found")
    return doc


@router.delete("/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_document(doc_id: uuid.UUID, db: Session = Depends(get_db)):
    doc = (db.query(Document)
             .filter(Document.id == doc_id, Document.user_id == DEV_USER)
             .first())
    if not doc:
        raise HTTPException(404, "document not found")
    Path(doc.storage_path).unlink(missing_ok=True)
    db.delete(doc)          # chunks cascade
    db.commit()

Why it works this way

Why 404 rather than 403 for another user's document
403 confirms the document exists but isn't yours — that's an information leak. 404 reveals nothing. Note that filtering by user_id in the query gives you this behaviour for free.
Why separate schemas from ORM models
Return the ORM model directly and every column you add is instantly public API. Adding an internal field would leak it. The explicit schema is a deliberate wall.
from_attributes=True
Lets Pydantic read from an object's attributes rather than a dict, so you can return a SQLAlchemy row and have it converted automatically.
Done when: You can upload, list, fetch, and delete documents through the API, and a scanned PDF fails with a specific, user-readable message.
L31

Streaming the answer token by token

45 min 0/7

The technology

Why stream. A full answer takes 5-15 seconds. A blank screen for 15 seconds feels broken. Streaming shows the first words in under a second, and the perceived wait collapses even though the total time is identical.

Server-Sent Events (SSE). A one-way stream from server to browser over ordinary HTTP. The response never closes; the server writes lines of the form data: something followed by a blank line, and the browser fires an event for each. Simpler than WebSockets and exactly the right shape here — you only need one direction.

The order of operations matters. Retrieval finishes before generation starts, so you know your citations up front. But you can't send them first if the answer might not cite them all. Send the tokens as they arrive, then a final event: citations frame with the sources actually used and the verification flag.

Steps

Code

app/routers/chat.py
import json
import uuid

from fastapi import APIRouter, Request
from litellm import completion
from pydantic import BaseModel
from sse_starlette.sse import EventSourceResponse

from app.config import settings
from app.generation.answer import SYSTEM, verify_citations
from app.generation.context import build_context
from app.retrieval.rerank import rerank
from app.retrieval.search import hybrid_search

router = APIRouter(prefix="/api", tags=["chat"])
DEV_USER = uuid.UUID("00000000-0000-0000-0000-000000000001")


class ChatRequest(BaseModel):
    question: str


@router.post("/chat")
async def chat(body: ChatRequest, request: Request):

    async def event_stream():
        candidates = hybrid_search(DEV_USER, body.question)
        top = rerank(body.question, candidates)
        context, used = build_context(top)

        stream = completion(
            model=settings.chat_model,
            temperature=settings.temperature,
            stream=True,
            messages=[
                {"role": "system", "content": SYSTEM},
                {"role": "user",
                 "content": f"{context}\n\nQuestion: {body.question}"},
            ],
        )

        parts: list[str] = []
        for chunk in stream:
            if await request.is_disconnected():
                return                       # user left; stop paying for tokens
            delta = chunk.choices[0].delta.content
            if delta:
                parts.append(delta)
                yield {"event": "token", "data": delta}

        answer = "".join(parts)
        citations = [
            {"n": i, "chunk_id": str(c["id"]), "document_title": c["title"],
             "page_from": c["page_from"], "page_to": c["page_to"],
             "heading_path": c["heading_path"]}
            for i, c in enumerate(used, 1)
        ]
        yield {
            "event": "citations",
            "data": json.dumps({
                "citations": citations,
                "verified": not verify_citations(answer, len(used)),
            }),
        }

    return EventSourceResponse(event_stream())

Why it works this way

Why SSE and not WebSockets
WebSockets are bidirectional and need their own connection lifecycle, reconnection logic, and often a different proxy configuration. You only need server-to-client. SSE is plain HTTP and reconnects on its own.
Retrieval before the first token
There's an unavoidable 1-3 second gap while search and reranking run. Send an early event: status frame saying "searching your documents" so the UI has something honest to show.
Streaming breaks the retry-once pattern
You can't un-send tokens. That's why the design surfaces an unverified flag rather than silently regenerating — an honest badge beats a hidden failure.
Done when: curl -N shows the answer arriving word by word, followed by a citations frame. Milestone-4-shaped API is in place.
Module 6

Milestone 3 — auth and tenant isolation

The part where a bug becomes a data breach

Add real users with Supabase, verify their tokens in FastAPI, and make the database itself refuse to return another user's rows — then try to break it on purpose.

4 lessons · L32–L35 ~4 h 0 of 4 lessons complete
L32

Supabase — hosted Postgres, auth, and storage

40 min 0/8

The technology

What Supabase is. A managed platform bundling a real PostgreSQL database (with pgvector available), an authentication service, and object storage. Your design document picks it to remove two problems that aren't your learning goal: implementing password resets and OAuth callback flows, and running a file server.

What you're outsourcing, precisely. Signup, login, email confirmation, password reset, Google OAuth, and session refresh. What you keep — and what you actually learn — is token verification and request-scoped identity, which is the half that matters and the half that gets built wrong.

Two databases now. Keep your local Docker Postgres for development; use Supabase's for anything deployed. Same schema, driven by the same Alembic migrations. Never point development at the production database.

Steps

Code

add to .env
SUPABASE_URL=https://yourproject.supabase.co
SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_KEY=eyJ...          # server only -- never send to a browser
SUPABASE_JWT_SECRET=your-jwt-secret  # Settings > API > JWT Settings
SUPABASE_DATABASE_URL=postgresql+psycopg://postgres.xxx:pass@...pooler.supabase.com:6543/postgres

Why it works this way

Why not build auth yourself?
Password hashing, timing-safe comparison, reset tokens with expiry, email delivery, OAuth state parameters, refresh rotation. Each is a place to introduce a real vulnerability, and none of them teach you anything about RAG.
Why keep the local database too
Fast, free, and you can wipe it without consequence. Supabase's free tier also pauses inactive projects, which is annoying mid-lesson.
The service role key is a skeleton key
It bypasses every RLS policy you're about to write. Leaked, it exposes every user's documents. It belongs in server environment variables and nowhere else — never in NEXT_PUBLIC_ anything.
Done when: A Supabase project exists with your schema migrated and two test users created.
L33

JWTs — verifying who is calling

45 min 0/7

The technology

What a JWT is. Three base64 segments separated by dots: header, payload, signature. The payload holds claims — sub (the user id), exp (expiry), email. The payload is not encrypted; anyone can read it. What the signature guarantees is that nobody has changed it.

Why that's enough. The token is signed with a secret only Supabase and your backend know. If a user edits the payload to claim someone else's sub, the signature no longer matches and verification fails. So you can trust the contents of a token whose signature checks out — and only then.

The flow. The frontend logs in with Supabase and receives a JWT. It attaches Authorization: Bearer <token> to every API call. FastAPI verifies the signature and expiry, extracts sub, and that becomes the user_id your repository functions require.

The rule that matters most: never, ever decode without verifying. jwt.decode(token, options={"verify_signature": False}) exists for debugging and is a complete authentication bypass if it reaches production.

Steps

Code

app/auth.py
import os
import uuid

import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

security = HTTPBearer()
JWT_SECRET = os.environ["SUPABASE_JWT_SECRET"]


async def get_current_user(
    creds: HTTPAuthorizationCredentials = Depends(security),
) -> uuid.UUID:
    try:
        payload = jwt.decode(
            creds.credentials,
            JWT_SECRET,
            algorithms=["HS256"],
            audience="authenticated",
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "token expired")
    except jwt.InvalidTokenError as exc:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"invalid token: {exc}")

    sub = payload.get("sub")
    if not sub:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "token has no subject")
    return uuid.UUID(sub)
using it in an endpoint
@router.get("", response_model=list[DocumentOut])
async def list_documents(
    user_id: uuid.UUID = Depends(get_current_user),
    db: Session = Depends(get_db),
):
    return (db.query(Document)
              .filter(Document.user_id == user_id)
              .order_by(Document.created_at.desc())
              .all())

Why it works this way

Why algorithms=["HS256"] is explicit
There is a classic attack where the caller sets the algorithm to none and the library, trying to be helpful, accepts an unsigned token. Naming the allowed algorithm closes it. Never pass the algorithm from the token itself.
Why audience="authenticated"
Supabase sets this claim. Checking it means a token minted for a different purpose or project can't be replayed against your API.
HS256 versus asymmetric keys
Supabase also offers asymmetric (RS256/ES256) signing keys, verified against a public JWKS endpoint rather than a shared secret. HS256 is simpler to start with; the shape of the verification code is the same either way, so check your project's JWT settings and match them.
Likely snag. If you see InvalidAudienceError, check the exact aud value in your decoded token on jwt.io and match it. If the secret is wrong you get InvalidSignatureError — these two errors mean very different things.
Done when: Every endpoint requires a valid token, and identity comes from the verified sub claim rather than a hardcoded UUID.
L34

Row-Level Security — the real guarantee

50 min 0/8

The technology

Why application checks aren't enough. You now filter by user_id in every query. That's layer 1 of section 5 — and it's one forgotten WHERE clause away from a cross-tenant leak. It will happen: a debugging query, a new endpoint written at 1am, a refactor that drops a filter.

What RLS is. Postgres can attach a policy to a table, and then every query against that table — no matter where it comes from — silently gains that condition. Forget the WHERE clause and you get zero rows instead of someone else's rulebook.

How your identity reaches the database. Postgres has session variables. At the start of each transaction your backend runs SET LOCAL app.current_user_id = '<uuid>' using the id from the verified JWT. The policy compares against that variable. LOCAL scopes it to the transaction, so it can't leak into the next request through a pooled connection — that detail is essential.

The role matters. RLS does not apply to the table owner, and does not apply to a role with BYPASSRLS. Your application must connect as a restricted role with neither, or every policy you write is decorative.

Steps

Code

Run in psql as the owner
-- A role that RLS actually applies to
CREATE ROLE loremaster_app LOGIN PASSWORD 'apppassword';
GRANT CONNECT ON DATABASE loremaster TO loremaster_app;
GRANT USAGE ON SCHEMA public TO loremaster_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public
  TO loremaster_app;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO loremaster_app;

ALTER TABLE documents     ENABLE ROW LEVEL SECURITY;
ALTER TABLE chunks        ENABLE ROW LEVEL SECURITY;
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages      ENABLE ROW LEVEL SECURITY;

CREATE POLICY documents_owner ON documents FOR ALL
  USING (user_id = current_setting('app.current_user_id', true)::uuid);

CREATE POLICY chunks_owner ON chunks FOR ALL
  USING (user_id = current_setting('app.current_user_id', true)::uuid);

CREATE POLICY conversations_owner ON conversations FOR ALL
  USING (user_id = current_setting('app.current_user_id', true)::uuid);

CREATE POLICY messages_owner ON messages FOR ALL
  USING (conversation_id IN (
    SELECT id FROM conversations
    WHERE user_id = current_setting('app.current_user_id', true)::uuid
  ));
app/main.py -- set the variable per request
import uuid

from fastapi import Depends
from sqlalchemy import text

from app.auth import get_current_user
from app.db.models import SessionLocal


def get_db_for_user(user_id: uuid.UUID = Depends(get_current_user)):
    """One transaction per request, with the RLS identity set on it."""
    db = SessionLocal()
    try:
        db.execute(
            text("SET LOCAL app.current_user_id = :uid"), {"uid": str(user_id)}
        )
        yield db
        db.commit()
    except Exception:
        db.rollback()
        raise
    finally:
        db.close()

Why it works this way

SET LOCAL, never SET
SET persists for the whole connection. Connections are pooled and reused across requests, so the next user's request could inherit the previous user's identity. That is a textbook cross-tenant leak and it is one word away.
The true in current_setting(..., true)
It means "return NULL if unset" instead of raising. With NULL the comparison is false and you get zero rows — a safe default. Without it, an unset variable throws an error that some code path might catch and ignore.
Why messages get a subquery policy
The messages table has no user_id of its own; ownership goes through conversations. Note the contrast with chunks, where your design document deliberately denormalised user_id precisely to avoid this kind of indirection on the hot path.
Done when: Removing a WHERE user_id filter from your code does not leak data, because the database refuses.
L35

Attack your own application

40 min 0/10

The technology

Why this is a lesson and not a footnote. Milestone 3 in your design document says: "Create two users, prove with a deliberate cross-tenant attempt that isolation holds." Believing your isolation works and having demonstrated it are different states of knowledge. This lesson moves you from the first to the second.

What you're testing. Not whether the happy path works — whether the adversarial path fails. Six specific attacks, each targeting a different assumption.

Then automate it. A one-off manual check rots. Written as a test suite, it runs on every change forever, and it will catch the refactor six months from now that quietly drops a filter.

Steps

Code

tests/test_isolation.py
import os

import pytest
from fastapi.testclient import TestClient

from app.main import app

client = TestClient(app)
A = {"Authorization": f"Bearer {os.environ['TOKEN_USER_A']}"}
B = {"Authorization": f"Bearer {os.environ['TOKEN_USER_B']}"}
DOC_A = os.environ["DOC_ID_USER_A"]


def test_b_cannot_read_a_document():
    assert client.get(f"/api/documents/{DOC_A}", headers=B).status_code == 404


def test_b_cannot_delete_a_document():
    assert client.delete(f"/api/documents/{DOC_A}", headers=B).status_code == 404
    assert client.get(f"/api/documents/{DOC_A}", headers=A).status_code == 200


def test_b_retrieval_cannot_see_a_content():
    r = client.post("/api/chat", headers=B,
                    json={"question": "what are the harm levels"})
    assert "couldn't find that" in r.text.lower()


def test_no_token_is_rejected():
    for method, path in [("get", "/api/documents"),
                         ("post", "/api/chat"),
                         ("get", f"/api/documents/{DOC_A}")]:
        assert getattr(client, method)(path).status_code == 401


def test_forged_token_is_rejected():
    forged = os.environ["TOKEN_USER_B"][:-4] + "AAAA"
    r = client.get("/api/documents", headers={"Authorization": f"Bearer {forged}"})
    assert r.status_code == 401

Why it works this way

Attack 3 is the one that catches real bugs
Attacks 1, 2, and 4 test your endpoint filters. Attack 3 tests the retrieval path, which is a different code path with its own SQL — and the one your design document calls the most security-critical query in the system.
Why automate
Isolation is the property most likely to break silently during a refactor, because nothing visibly stops working. A test suite is the only thing that notices.
What this doesn't test
Prompt injection — a document containing text designed to manipulate the model. Worth thinking about, but distinct from tenant isolation, since injected text still only comes from that user's own materials.
Done when: Six adversarial tests pass, and you have demonstrated rather than assumed that isolation holds.
Module 7

Milestone 4 — background jobs

Because a 100-page PDF cannot be ingested inside an HTTP request

Move ingestion into a separate worker process so uploads return instantly and the frontend can show real progress.

3 lessons · L36–L38 ~3 h 0 of 3 lessons complete
L36

Redis and ARQ — the job queue

40 min 0/7

The technology

The problem. Ingesting a 100-page PDF takes several minutes: extraction, chunking, dozens of embedding calls. Do it inside the upload request and the browser times out at 30-60 seconds, the user has no idea whether it worked, and a retry starts the whole thing again.

The pattern. The API writes a job to a queue and returns 202 immediately. A separate worker process picks jobs off the queue and does the slow work, updating the document's status as it goes. The frontend polls that status.

What Redis is. An in-memory data store, very fast, used here purely as the queue — a list the API pushes to and the worker pops from.

What ARQ is. A small async job queue built on Redis. Chosen over Celery because it's async-native (matching FastAPI) and roughly a tenth of the configuration surface.

Two processes now. This is the first time your app is more than one program. uvicorn serves HTTP; arq runs jobs. Both talk to the same Postgres and the same Redis. Neither can do the other's work.

Steps

Code

add to docker-compose.yml
  redis:
    image: redis:7-alpine
    container_name: loremaster-redis
    ports:
      - "6379:6379"
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:
app/worker.py
import asyncio

from arq.connections import RedisSettings

REDIS = RedisSettings(host="localhost", port=6379)


async def hello(ctx, name: str):
    print(f"worker: starting job for {name}")
    await asyncio.sleep(5)
    print(f"worker: done with {name}")
    return f"hello {name}"


class WorkerSettings:
    functions = [hello]
    redis_settings = REDIS
    max_jobs = 2              # embedding calls are the bottleneck, not CPU
    job_timeout = 900         # 15 minutes for a big book
enqueue from anywhere
from arq import create_pool

from app.worker import REDIS

redis = await create_pool(REDIS)
job = await redis.enqueue_job("hello", "Anna")
print(job.job_id)

Why it works this way

Why a separate process rather than a background thread
A thread dies with the web server, so a deploy or a crash loses in-flight work. A separate process can be restarted, scaled, and monitored independently — and Redis holds the queue across restarts.
max_jobs = 2
Ingestion is I/O-bound on embedding API calls, and those APIs rate-limit. Running twenty at once just produces twenty rate-limit errors. Tune this against your provider's limits.
At-least-once delivery
If the worker dies mid-job, the job may run again. That's why your ingestion must be idempotent — the file hash check from Lesson 20 is what makes a re-run safe.
Done when: Two terminals: a web server and a worker. You can enqueue a job from one and watch it run in the other.
L37

Move ingestion into the worker

50 min 0/8

The technology

The status machine from section 6 of your design document: pending → processing → ready | failed. Each transition is a database write, so the frontend can poll and show something truthful at every moment.

Error handling is the substance of this lesson. When ingestion fails inside a background job, nobody is watching. The job must catch its own errors, write status='failed' with a human-readable error_message, and never leave a document stuck in processing forever.

The distinct failure states matter to the user: "this PDF is a scan" is actionable (re-export it, or OCR it yourself); "something went wrong" is not. Your design document insists on surfacing that as its own state.

Steps

Code

app/worker.py -- the real job
import uuid
from pathlib import Path

from arq.connections import RedisSettings

from app.db.models import Document, SessionLocal
from app.ingestion.chunk import chunk_document
from app.ingestion.embed import embed_chunks
from app.ingestion.extract import extract_pages, is_scanned
from app.ingestion.normalise import normalise
from app.db.store import store_chunks

REDIS = RedisSettings(host="localhost", port=6379)


def _set_status(doc_id, status, error=None, page_count=None):
    with SessionLocal() as db:
        doc = db.get(Document, doc_id)
        doc.status = status
        if error is not None:
            doc.error_message = error
        if page_count is not None:
            doc.page_count = page_count
        db.commit()


async def ingest_document(ctx, document_id: str):
    doc_id = uuid.UUID(document_id)

    with SessionLocal() as db:
        doc = db.get(Document, doc_id)
        path = Path(doc.storage_path)
        user_id = doc.user_id
        session_date = doc.session_date

    try:
        _set_status(doc_id, "processing")

        # Design doc section 6, step 0 -- reject scans with a clear message
        if is_scanned(path):
            _set_status(
                doc_id, "failed",
                error="This PDF contains images rather than selectable text "
                      "(it looks like a scan). Re-export it from the original "
                      "source, or run OCR before uploading.",
            )
            return

        pages = normalise(extract_pages(path))
        chunks = chunk_document(pages)
        if not chunks:
            _set_status(doc_id, "failed",
                        error="No readable text was found in this document.")
            return

        date_str = session_date.isoformat() if session_date else None
        vectors = embed_chunks(chunks, session_date=date_str)

        store_chunks(doc_id, user_id, chunks, vectors)   # one transaction
        _set_status(doc_id, "ready", page_count=len(pages))
        print(f"ingested {doc_id}: {len(chunks)} chunks from {len(pages)} pages")

    except Exception as exc:
        _set_status(doc_id, "failed", error=f"Ingestion failed: {exc}")
        raise            # re-raise so ARQ logs it and retry policy applies


class WorkerSettings:
    functions = [ingest_document]
    redis_settings = REDIS
    max_jobs = 2
    job_timeout = 900

Why it works this way

Why catch, record, then re-raise
The catch guarantees the user sees a status. The re-raise guarantees you see a stack trace in the worker log. Swallowing the exception gives the user a message and you nothing to debug with.
Why the worker opens its own database sessions
It's a separate process with no HTTP request and no JWT. Note that it also has to bypass or explicitly set the RLS identity — the worker acts on behalf of a user it knows from the document row, not from a token.
Stuck in processing is the worst state
It means the worker crashed hard. Add a periodic sweep that fails documents stuck in processing for over 30 minutes, so the UI never lies indefinitely.
Done when: Upload returns instantly, the status walks through its states, and every failure mode ends in failed with a message a human can act on.
L38

Supabase Storage for the original files

35 min 0/8

The technology

Why not the local disk. Your design document rejects it explicitly: managed hosts like Railway and Render give you an ephemeral filesystem. Files written during a request survive until the next deploy or restart, then vanish. Worse, with two server instances the file is on one machine and the next request hits the other.

Object storage solves this. You upload the file to a service, get back a path, and store the path. Any process on any machine can fetch it. Supabase Storage is S3-compatible and already part of your stack.

Buckets and policies. A bucket is a namespace. Make it private and store files under a per-user prefix like {user_id}/{document_id}.pdf. To let a user download their own original, generate a signed URL — a temporary link that expires — rather than making the bucket public.

Steps

Code

app/storage.py
import os
import uuid
from pathlib import Path

from supabase import Client, create_client

BUCKET = "documents"

_client: Client = create_client(
    os.environ["SUPABASE_URL"],
    os.environ["SUPABASE_SERVICE_KEY"],   # server-side only
)


def upload_pdf(user_id: uuid.UUID, doc_id: uuid.UUID, data: bytes) -> str:
    path = f"{user_id}/{doc_id}.pdf"
    _client.storage.from_(BUCKET).upload(
        path, data, {"content-type": "application/pdf"}
    )
    return path


def download_to_temp(storage_path: str) -> Path:
    data = _client.storage.from_(BUCKET).download(storage_path)
    tmp = Path("/tmp") / Path(storage_path).name
    tmp.write_bytes(data)
    return tmp


def signed_url(storage_path: str, expires_seconds: int = 300) -> str:
    res = _client.storage.from_(BUCKET).create_signed_url(
        storage_path, expires_seconds
    )
    return res["signedURL"]


def delete_object(storage_path: str) -> None:
    _client.storage.from_(BUCKET).remove([storage_path])

Why it works this way

Why the per-user path prefix
Even though your API controls access, the prefix means a misconfigured bucket policy still can't hand user A's files to user B by accident. Same defence-in-depth reasoning as RLS.
Why signed URLs and not proxying downloads
Streaming a 50MB PDF through your API server ties up a worker for the whole transfer. A signed URL lets the browser fetch it directly from storage while your server stays free.
Storage and database can drift
Delete a row but fail to delete the object and you're paying to store an orphan. A periodic reconciliation job is worth writing eventually. Milestone 4 done.
Done when: Originals live in object storage, the worker fetches them from there, and deleting a document removes both the rows and the file.
Module 8

Milestone 5 — the frontend

Next.js, streaming UI, and clickable citations

Build the interface: log in, upload with live status, chat with tokens appearing as they arrive, and citations you can click to see the source text.

5 lessons · L39–L43 ~5.5 h 0 of 5 lessons complete
L39

JavaScript and TypeScript for Python people

45 min 0/6

The technology

Why you need this at all. Browsers run JavaScript, not Python. There is no way around it. The good news is that if you know Python, you know 80% of JavaScript already — the differences are mostly syntax plus one genuinely new idea.

The genuinely new idea is asynchrony. JavaScript in a browser is single-threaded and cannot block: if it waited for a network response, the whole page would freeze. So anything slow returns a Promise — a placeholder for a value that will exist later. await unwraps it. Python's async/await is the same concept and borrowed the syntax, so this should feel familiar.

What TypeScript adds. Types, checked before the code runs. const x: string = 5 is an error in your editor rather than a mystery at runtime. Same benefit as Python type hints, but enforced rather than advisory.

Node and npm. Node runs JavaScript outside a browser (that's how the dev server and the build tools run). npm is its pip. package.json is its requirements.txt.

Steps

Code

Install Node in WSL
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
node --version
npm --version
Python -> TypeScript, side by side
// Python: def greet(name: str) -> str: return f"Hi {name}"
function greet(name: string): string {
  return `Hi ${name}`;             // backticks, ${} instead of f""
}

// Python: greet = lambda name: f"Hi {name}"
const greet2 = (name: string): string => `Hi ${name}`;

// Python: items = [1, 2, 3];  doubled = [x * 2 for x in items]
const items: number[] = [1, 2, 3];
const doubled = items.map((x) => x * 2);
const evens = items.filter((x) => x % 2 === 0);

// Python: d = {"a": 1};  d["a"]
const d: Record<string, number> = { a: 1 };
console.log(d.a, d["a"]);          // both work

// Python: class + dataclass  ->  interface (types only, no runtime cost)
interface Document {
  id: string;
  title: string;
  status: "pending" | "processing" | "ready" | "failed";
  errorMessage?: string;           // ? means optional
}

// Python: async def get(): r = await client.get(url); return r.json()
async function getDocs(): Promise<Document[]> {
  const res = await fetch("http://localhost:8000/api/documents");
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

Why it works this way

Why const everywhere
It prevents reassignment of the binding, which removes a whole class of bugs. Note it doesn't freeze the contents — you can still push to a const array. Use let only when you genuinely reassign.
=== versus ==
== coerces types before comparing, which gives you 0 == "" being true. Always use ===. There is no good reason for the other one.
Interfaces vanish at runtime
TypeScript types are erased during compilation. They catch mistakes while you write; they do not validate data arriving from your API. For that you still need runtime checks — which is exactly why Pydantic exists on the Python side.
Done when: You can write a typed async function that calls your FastAPI health endpoint and prints the result.
L40

Next.js and logging in

50 min 0/9

The technology

What React is. A library for building interfaces out of components — functions that return markup. When a component's state changes, React re-runs the function and updates only the parts of the page that differ. You describe what the UI should look like for a given state; React handles the DOM.

What Next.js adds. Routing by folder structure (a folder app/chat/ with a page.tsx becomes the /chat route), a dev server, a production build, and the server/client component split.

Server vs client components is the one concept that confuses everyone. By default a component renders on the server and sends HTML — fast, and it can hold secrets. Add "use client" at the top of the file and it also runs in the browser, which is required for anything interactive: state, event handlers, effects. Your chat page must be a client component; a static layout need not be.

Steps

Code

lib/supabase.ts
import { createBrowserClient } from "@supabase/ssr";

export const supabase = createBrowserClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
app/login/page.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";

export default function LoginPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState<string | null>(null);
  const router = useRouter();

  async function handleLogin(e: React.FormEvent) {
    e.preventDefault();
    setError(null);
    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });
    if (error) setError(error.message);
    else router.push("/documents");
  }

  return (
    <form onSubmit={handleLogin} className="mx-auto mt-24 max-w-sm space-y-4">
      <h1 className="text-2xl font-semibold">Sign in to Loremaster</h1>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="you@example.com"
        className="w-full rounded border px-3 py-2"
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="password"
        className="w-full rounded border px-3 py-2"
      />
      {error && <p className="text-sm text-red-600">{error}</p>}
      <button className="w-full rounded bg-black px-3 py-2 text-white">
        Sign in
      </button>
      <button
        type="button"
        onClick={() => supabase.auth.signInWithOAuth({ provider: "google" })}
        className="w-full rounded border px-3 py-2"
      >
        Continue with Google
      </button>
    </form>
  );
}
lib/api.ts
import { supabase } from "./supabase";

const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";

export async function apiFetch(path: string, init: RequestInit = {}) {
  const { data } = await supabase.auth.getSession();
  const token = data.session?.access_token;

  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      ...init.headers,
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
  });

  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  return res;
}

Why it works this way

NEXT_PUBLIC_ means public
Any variable with that prefix is baked into the JavaScript bundle and readable by anyone who opens devtools. The anon key is designed for that; the service role key would be a total compromise.
Why the anon key is safe in a browser
It only permits what your Supabase RLS policies permit. It's an identifier for the project, not a permission grant.
Why one apiFetch wrapper
Same reasoning as making user_id the first argument of every repository function: centralise the thing you must never forget, so forgetting it isn't possible.
Done when: You can log in with a test user, land on a page, and see the JWT in browser storage.
L41

The upload screen with live status

45 min 0/8

The technology

What you're building. A page listing the user's documents with their status, plus an upload form. Because ingestion is now a background job, the page must poll and update.

useState and useEffect. useState gives a component a value that, when changed, triggers a re-render. useEffect runs code after a render — fetching data, starting a timer — and returns a cleanup function that runs when the component goes away. Forgetting the cleanup is the most common React bug: a polling timer that keeps running after you navigate away, forever.

shadcn/ui. Not a component library you install as a dependency — it copies component source files into your project, which you then own and edit. Good for learning, because you can read exactly what a button is.

Steps

Code

app/documents/page.tsx
"use client";

import { useCallback, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";

type Doc = {
  id: string;
  title: string;
  doc_type: string;
  status: "pending" | "processing" | "ready" | "failed";
  error_message: string | null;
  page_count: number | null;
};

const TERMINAL = new Set(["ready", "failed"]);

export default function DocumentsPage() {
  const [docs, setDocs] = useState<Doc[]>([]);
  const [busy, setBusy] = useState(false);

  const load = useCallback(async () => {
    const res = await apiFetch("/api/documents");
    setDocs(await res.json());
  }, []);

  useEffect(() => {
    load();
  }, [load]);

  // Poll only while something is still in flight, and always clean up.
  useEffect(() => {
    const pending = docs.some((d) => !TERMINAL.has(d.status));
    if (!pending) return;
    const timer = setInterval(load, 2000);
    return () => clearInterval(timer);
  }, [docs, load]);

  async function handleUpload(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setBusy(true);
    try {
      const form = new FormData(e.currentTarget);
      await apiFetch("/api/documents", { method: "POST", body: form });
      e.currentTarget.reset();
      await load();
    } finally {
      setBusy(false);
    }
  }

  return (
    <main className="mx-auto max-w-3xl space-y-8 p-8">
      <h1 className="text-2xl font-semibold">Your materials</h1>

      <form onSubmit={handleUpload} className="space-y-3 rounded border p-4">
        <input name="title" placeholder="Title" required
               className="w-full rounded border px-3 py-2" />
        <select name="doc_type" className="w-full rounded border px-3 py-2">
          <option value="ruleset">Ruleset</option>
          <option value="transcript">Session transcript</option>
        </select>
        <input type="file" name="file" accept=".pdf" required />
        <button disabled={busy}
                className="rounded bg-black px-4 py-2 text-white disabled:opacity-50">
          {busy ? "Uploading..." : "Upload"}
        </button>
      </form>

      <ul className="space-y-2">
        {docs.map((d) => (
          <li key={d.id} className="rounded border p-3">
            <div className="flex items-center justify-between">
              <span className="font-medium">{d.title}</span>
              <StatusBadge status={d.status} />
            </div>
            {d.status === "failed" && d.error_message && (
              <p className="mt-2 text-sm text-red-700">{d.error_message}</p>
            )}
            {d.status === "ready" && (
              <p className="mt-1 text-sm text-gray-500">
                {d.page_count} pages -- ready to search
              </p>
            )}
          </li>
        ))}
      </ul>
    </main>
  );
}

function StatusBadge({ status }: { status: Doc["status"] }) {
  const styles: Record<Doc["status"], string> = {
    pending: "bg-gray-100 text-gray-700",
    processing: "bg-blue-100 text-blue-700",
    ready: "bg-green-100 text-green-700",
    failed: "bg-red-100 text-red-700",
  };
  return (
    <span className={`rounded px-2 py-0.5 text-xs ${styles[status]}`}>
      {status}
    </span>
  );
}

Why it works this way

Why the cleanup function is not optional
Without return () => clearInterval(timer), every render starts another timer and none of them stop. Navigate around a few times and you're hammering your API dozens of times a second.
Why polling and not WebSockets
Ingestion status changes maybe four times over several minutes. Polling every 2 seconds is trivially cheap and vastly simpler. Reach for a persistent connection when you actually need sub-second updates.
useCallback around load
Without it, load is a new function on every render, so the effect that depends on it re-runs every render — an infinite loop. This is React's most common footgun.
Done when: You can upload a PDF in the browser and watch its status move to ready on its own, and a scanned PDF shows a specific, readable error.
L42

The chat screen, with streaming

50 min 0/8

The technology

Reading an SSE stream in the browser. The built-in EventSource API only does GET requests and can't send headers, so it can't carry your Authorization token or a POST body. Instead you use fetch with a streaming response body and parse the SSE frames yourself. It's about 20 lines and worth understanding.

The frame format. Events are separated by blank lines. Within an event, lines look like event: token and data: some text. You accumulate bytes, split on double newlines, and handle each complete frame. The tricky part is that a network packet can end mid-frame, so you must keep the incomplete tail in a buffer for next time.

The UX that matters. Show a "searching your materials..." state during the 1-3 seconds of retrieval before the first token, so the gap is explained rather than mysterious.

Steps

Code

app/chat/page.tsx -- the streaming core
"use client";

import { useState } from "react";
import { supabase } from "@/lib/supabase";

type Citation = {
  n: number;
  chunk_id: string;
  document_title: string;
  page_from: number | null;
  heading_path: string | null;
};

const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";

export default function ChatPage() {
  const [question, setQuestion] = useState("");
  const [answer, setAnswer] = useState("");
  const [citations, setCitations] = useState<Citation[]>([]);
  const [verified, setVerified] = useState(true);
  const [phase, setPhase] = useState<"idle" | "searching" | "streaming">("idle");

  async function ask(e: React.FormEvent) {
    e.preventDefault();
    setAnswer("");
    setCitations([]);
    setVerified(true);
    setPhase("searching");

    const { data } = await supabase.auth.getSession();
    const res = await fetch(`${BASE}/api/chat`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${data.session?.access_token}`,
      },
      body: JSON.stringify({ question }),
    });

    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const frames = buffer.split("\n\n");
      buffer = frames.pop() ?? "";        // keep the incomplete tail

      for (const frame of frames) {
        let event = "message";
        let payload = "";
        for (const line of frame.split("\n")) {
          if (line.startsWith("event:")) event = line.slice(6).trim();
          else if (line.startsWith("data:")) payload += line.slice(5).trimStart();
        }

        if (event === "token") {
          setPhase("streaming");
          setAnswer((prev) => prev + payload);
        } else if (event === "citations") {
          const parsed = JSON.parse(payload);
          setCitations(parsed.citations);
          setVerified(parsed.verified);
        }
      }
    }
    setPhase("idle");
  }

  return (
    <main className="mx-auto max-w-2xl space-y-6 p-8">
      <form onSubmit={ask} className="flex gap-2">
        <input
          value={question}
          onChange={(e) => setQuestion(e.target.value)}
          placeholder="Ask about your materials..."
          className="flex-1 rounded border px-3 py-2"
        />
        <button className="rounded bg-black px-4 py-2 text-white">Ask</button>
      </form>

      {phase === "searching" && (
        <p className="text-sm text-gray-500">Searching your materials...</p>
      )}

      {answer && (
        <div className="space-y-4">
          <p className="whitespace-pre-wrap leading-relaxed">{answer}</p>

          {!verified && (
            <p className="rounded bg-amber-50 p-2 text-sm text-amber-800">
              Unverified: this answer referenced a source that could not be
              matched. Check the citations carefully.
            </p>
          )}

          <ul className="space-y-1 border-t pt-3 text-sm text-gray-600">
            {citations.map((c) => (
              <li key={c.n}>
                [{c.n}] {c.document_title}
                {c.page_from ? `, p.${c.page_from}` : ""}
                {c.heading_path ? ` -- ${c.heading_path}` : ""}
              </li>
            ))}
          </ul>
        </div>
      )}
    </main>
  );
}

Why it works this way

Why the buffer tail matters
Network data arrives in arbitrary packets. A frame can be split across two reads. Drop the incomplete tail and you silently lose characters mid-answer — a bug that only appears under real network conditions, never on localhost.
setAnswer((prev) => prev + payload)
The function form reads the latest state. Writing setAnswer(answer + payload) captures a stale value from the closure and you lose tokens. Classic React trap.
Why not EventSource
It's GET-only and cannot set an Authorization header. You'd have to put the JWT in the query string, which then lands in server logs. Manual parsing is the right trade.
Done when: You can type a question in the browser and watch the answer appear word by word, followed by its citations.
L43

Clickable citations — the trust feature

40 min 0/8

The technology

Why this is the most important UI in the product. Section 8 of your design document is unambiguous: clickable citations are your most powerful anti-confabulation tool, because they move verification to the human — the person who actually knows whether the answer is right. Everything else in section 8 reduces the error rate. This makes the remaining errors visible.

What you build. Find every [n] in the answer text, turn it into a button, and on click fetch that chunk's full source text and show it in a panel beside the answer.

Rendering markers as buttons. You can't inject HTML into React text — and shouldn't, since model output is untrusted. Instead split the string on the citation pattern and map the pieces to an array of text nodes and button elements.

Steps

Code

components/AnswerWithCitations.tsx
"use client";

import { useState } from "react";
import { apiFetch } from "@/lib/api";

type Citation = {
  n: number;
  chunk_id: string;
  document_title: string;
  page_from: number | null;
  heading_path: string | null;
};

type Source = { content: string } & Citation;

export function AnswerWithCitations({
  answer,
  citations,
}: {
  answer: string;
  citations: Citation[];
}) {
  const [open, setOpen] = useState<Source | null>(null);

  async function show(c: Citation) {
    const res = await apiFetch(`/api/chunks/${c.chunk_id}`);
    const chunk = await res.json();
    setOpen({ ...c, content: chunk.content });
  }

  // Split on [n] and keep the delimiters, so we can map them to buttons.
  const parts = answer.split(/(\[\d+\])/g);

  return (
    <div className="flex gap-6">
      <p className="flex-1 whitespace-pre-wrap leading-relaxed">
        {parts.map((part, i) => {
          const match = part.match(/^\[(\d+)\]$/);
          if (!match) return <span key={i}>{part}</span>;

          const n = Number(match[1]);
          const citation = citations.find((c) => c.n === n);
          if (!citation) return <span key={i}>{part}</span>;   // no silent break

          return (
            <button
              key={i}
              onClick={() => show(citation)}
              title={`${citation.document_title}, p.${citation.page_from}`}
              className="mx-0.5 rounded bg-blue-100 px-1 text-xs font-medium
                         text-blue-800 hover:bg-blue-200"
            >
              {n}
            </button>
          );
        })}
      </p>

      {open && (
        <aside className="w-80 shrink-0 rounded border bg-gray-50 p-4">
          <div className="mb-2 flex items-start justify-between">
            <div className="text-xs text-gray-500">
              <div className="font-medium text-gray-800">
                {open.document_title}
              </div>
              {open.page_from && <div>p.{open.page_from}</div>}
              {open.heading_path && <div>{open.heading_path}</div>}
            </div>
            <button onClick={() => setOpen(null)} className="text-gray-400">
              close
            </button>
          </div>
          <p className="whitespace-pre-wrap text-sm leading-relaxed">
            {open.content}
          </p>
        </aside>
      )}
    </div>
  );
}

Why it works this way

Why fetch the chunk rather than send it with the answer
The chunks are already in the context block you paid for, but sending all eight full chunks to the browser on every answer is a lot of payload for text the user probably won't open. Fetch on demand.
Why unmatched numbers render as plain text
Your backend already flagged the answer as unverified. The UI shouldn't crash or show a dead button on top of that — degrade quietly and let the badge do the talking.
This is the feature that earns trust
Users don't trust a system because it claims to be grounded. They trust it because they clicked three citations, found them accurate, and stopped checking.
Done when: Clicking a citation number in an answer shows you the exact source passage with its page number. Milestone 5 done.
Module 9

Milestone 6 — the hard retrieval problems

The two questions your design document was built around

Make "was Peter a warlock in our March 2025 session" and "what are all the injury types in ruleset X" both work properly. These are the queries that separate this from a tutorial RAG.

3 lessons · L44–L46 ~4 h 0 of 3 lessons complete
L44

Query understanding — natural language to a structured query

50 min 0/7

The technology

The problem. "Was Peter's character a warlock in our March 2025 session?" contains three separate instructions: search for Peter and warlock, only look at transcripts, only look at March 2025. Throw the whole sentence at an embedding model and "March 2025" becomes a weak semantic signal instead of the hard filter it should be.

The solution (section 7.2). Before retrieving, a small fast model converts the question into structured JSON: a search_text, a set of filters, and a scope of narrow or broad. Filters become SQL WHERE clauses; scope selects the retrieval strategy in Lesson 46.

Resolving document names. Pass the user's own document titles into the prompt so "ruleset X" or "the Blades book" maps to a real document_id. The model can only match against titles it can see.

The rule that prevents the worst bug: if a filter matches zero documents, do not silently drop it. Tell the user "you haven't uploaded anything from March 2025." Silently widening the search is how you get a confident, well-cited answer about entirely the wrong session.

Steps

Code

app/retrieval/understand.py
import json
import uuid
from datetime import date
from typing import Literal

from litellm import completion
from pydantic import BaseModel, ValidationError

from app.config import settings

PROMPT = """Convert the user's question into a structured search query.

Today's date is {today}.

The user's documents:
{documents}

Return ONLY JSON of this shape:
{{
  "search_text": "the words worth searching for, proper nouns kept",
  "filters": {{
    "doc_type": "ruleset" | "transcript" | null,
    "document_ids": ["uuid", ...],
    "session_date_from": "YYYY-MM-DD" | null,
    "session_date_to": "YYYY-MM-DD" | null
  }},
  "scope": "narrow" | "broad"
}}

"scope" is "broad" when the question asks for a complete list, an overview,
or every instance of something ("what are all the...", "list the...",
"summarise..."). Otherwise it is "narrow".

Question: {question}"""


class Filters(BaseModel):
    doc_type: Literal["ruleset", "transcript"] | None = None
    document_ids: list[uuid.UUID] = []
    session_date_from: date | None = None
    session_date_to: date | None = None


class StructuredQuery(BaseModel):
    search_text: str
    filters: Filters = Filters()
    scope: Literal["narrow", "broad"] = "narrow"


def understand(question: str, documents: list[dict],
               max_retries: int = 2) -> StructuredQuery:
    doc_lines = "\n".join(
        f'- {d["id"]} | "{d["title"]}" | {d["doc_type"]}'
        f'{" | session " + str(d["session_date"]) if d.get("session_date") else ""}'
        for d in documents
    ) or "(none)"

    prompt = PROMPT.format(today=date.today().isoformat(),
                           documents=doc_lines, question=question)

    for _ in range(max_retries):
        raw = completion(
            model=settings.utility_model,
            temperature=0.0,
            response_format={"type": "json_object"},
            messages=[{"role": "user", "content": prompt}],
        ).choices[0].message.content
        try:
            return StructuredQuery.model_validate(json.loads(raw))
        except (json.JSONDecodeError, ValidationError):
            continue

    # Never fail the user's question because the parser misbehaved.
    return StructuredQuery(search_text=question)
applying filters to both arms
def _filter_sql(f: Filters) -> tuple[str, dict]:
    clauses, params = [], {}
    if f.doc_type:
        clauses.append("AND d.doc_type = :doc_type")
        params["doc_type"] = f.doc_type
    if f.document_ids:
        clauses.append("AND d.id = ANY(:doc_ids)")
        params["doc_ids"] = [str(i) for i in f.document_ids]
    if f.session_date_from:
        clauses.append("AND d.session_date >= :date_from")
        params["date_from"] = f.session_date_from
    if f.session_date_to:
        clauses.append("AND d.session_date <= :date_to")
        params["date_to"] = f.session_date_to
    return " ".join(clauses), params

Why it works this way

Why a cheap model here
This call happens on every single question. It's a simple extraction task that a small model does well. Section 9 of your design document splits chat and utility models precisely for this — often a 10x cost difference for no quality loss.
Why fall back to the raw question
If the parser fails, a plain unfiltered search still returns something useful. Failing the whole request because a helper call misbehaved is a bad trade.
Why search_text is not just the question
"Was Peter's character a warlock in our March 2025 session?" contains filler that dilutes the embedding. "Peter character class warlock" is a much better search string once the date has been lifted into a filter.
Done when: The Peter question parses into a transcript filter, a March 2025 date range, and a clean search string — and an impossible filter produces a specific message instead of a wrong answer.
L45

Follow-up questions and conversation memory

40 min 0/8

The technology

The problem. "Was he a warlock then too?" is meaningless on its own. The embedding of that sentence matches nothing useful, and the query parser can't extract a date from it.

The rule from section 8, mechanism 3 of your design document: conversation history is never a substitute for retrieval. Re-retrieve on every turn. The temptation is to answer follow-ups from the previous turn's context because it's already there — and that's exactly how you end up answering about the wrong session with total confidence.

Query rewriting. Before anything else, send the recent history plus the new question to the utility model and ask for a standalone rewrite: "Was he a warlock then too?" plus prior context becomes "Was Peter's character a warlock in the January 2025 session?". Then run the normal pipeline on the rewrite.

Persisting conversations. The conversations and messages tables from section 4 hold the history, with cited_chunk_ids for provenance and model_used so you can tell later which model produced which answer — genuinely useful when comparing providers in Lesson 48.

Steps

Code

app/retrieval/rewrite.py
from litellm import completion

from app.config import settings

PROMPT = """Rewrite the user's latest question so it stands alone, resolving
every pronoun and implicit reference using the conversation so far.
Keep proper nouns exactly as written. Change nothing else.
If the question already stands alone, return it unchanged.
Return ONLY the rewritten question.

Conversation so far:
{history}

Latest question: {question}"""


def rewrite_followup(question: str, history: list[dict],
                     max_turns: int = 4) -> str:
    if not history:
        return question

    recent = history[-max_turns:]
    rendered = "\n".join(f'{m["role"]}: {m["content"][:400]}' for m in recent)

    rewritten = completion(
        model=settings.utility_model,
        temperature=0.0,
        messages=[{"role": "user",
                   "content": PROMPT.format(history=rendered,
                                            question=question)}],
    ).choices[0].message.content.strip()

    # Guard against a model that decides to answer instead of rewrite.
    return rewritten if 0 < len(rewritten) < 500 else question

Why it works this way

Why rewrite instead of just passing history to the answer model
The retrieval step happens before the answer model sees anything. If the search string is "was he a warlock then too", you retrieve garbage, and no amount of conversational context downstream can fix bad retrieval.
Why truncate each history message
Full previous answers are long and mostly irrelevant to disambiguating a pronoun. The first 400 characters carry the subject matter.
Why store model_used
When you swap providers in Lesson 48 and something reads oddly, the first question is which model wrote it. Without the column you're guessing.
Done when: A three-turn conversation with pronouns and implicit references retrieves correctly at every turn.
L46

The aggregative query — neighbour expansion

50 min 0/8

The technology

Why top-k is the wrong shape for this question. "What are the injury types in ruleset X" wants a complete list. Top-8 retrieval returns the 8 most similar chunks, which will be 8 of the 14 injury-related passages — and the model will present the partial list as if it were complete. That is a confident, well-cited, wrong answer, which is the worst kind.

The strategy from section 7.3: when scope is "broad", change tactics entirely.

  1. Filter to the single document.
  2. Retrieve the top ~5 chunks to locate the right region of the book.
  3. Expand to neighbours — pull every chunk sharing the same heading_path prefix, plus ordinal ± 2 around each hit.
  4. Sort by ordinal and feed the model a contiguous span rather than scattered fragments.
  5. If the span exceeds the context budget, map-reduce: extract from each window, then merge the extractions.

And make partiality visible. The answer prompt already asks the model to state its coverage — "Based on the Injury chapter (pp. 41-46)…" — so a partial answer looks partial to the reader.

Steps

Code

app/retrieval/expand.py
import uuid

from sqlalchemy import text

from app.db.models import SessionLocal

NEIGHBOURS = text("""
    WITH hits AS (
        SELECT ordinal, heading_path
        FROM chunks
        WHERE user_id = :uid AND document_id = :doc AND id = ANY(:ids)
    )
    SELECT c.id, c.content, c.heading_path, c.ordinal,
           c.page_from, c.page_to, d.title
    FROM chunks c
    JOIN documents d ON d.id = c.document_id
    WHERE c.user_id = :uid
      AND c.document_id = :doc
      AND (
        -- everything under the same heading section as a hit
        EXISTS (
          SELECT 1 FROM hits h
          WHERE h.heading_path IS NOT NULL
            AND c.heading_path LIKE h.heading_path || '%'
        )
        -- plus a window of two chunks either side of each hit
        OR EXISTS (
          SELECT 1 FROM hits h
          WHERE c.ordinal BETWEEN h.ordinal - 2 AND h.ordinal + 2
        )
      )
    ORDER BY c.ordinal
""")


def expand_to_neighbours(user_id: uuid.UUID, document_id: uuid.UUID,
                         hit_ids: list[uuid.UUID]) -> list[dict]:
    """Design doc section 7.3: a contiguous span beats scattered fragments."""
    with SessionLocal() as session:
        rows = session.execute(NEIGHBOURS, {
            "uid": str(user_id),
            "doc": str(document_id),
            "ids": [str(i) for i in hit_ids],
        }).mappings().all()
    return [dict(r) for r in rows]
branching on scope
from app.retrieval.expand import expand_to_neighbours
from app.retrieval.rerank import rerank
from app.retrieval.search import hybrid_search


def retrieve(user_id, sq, documents):
    if sq.scope == "broad" and sq.filters.document_ids:
        doc_id = sq.filters.document_ids[0]
        seeds = hybrid_search(user_id, sq.search_text, limit=5)
        seeds = [s for s in seeds if str(s["document_id"]) == str(doc_id)]
        if seeds:
            return expand_to_neighbours(
                user_id, doc_id, [s["id"] for s in seeds]
            )

    candidates = hybrid_search(user_id, sq.search_text)
    return rerank(sq.search_text, candidates)

Why it works this way

Why sort by ordinal
The model reads a chapter far better than it reads eight disconnected paragraphs in similarity order. Contiguity is information — it tells the model these things belong together and in this sequence.
Why broad scope requires a single document
Expanding neighbours across several documents produces an enormous, incoherent span. If the user asks a broad question without naming a document, it's better to ask which one they mean than to guess.
Why map-reduce is the honest fallback
A 40-chunk chapter can exceed any context budget. Extracting from each window and merging is slower and costs more calls, but it's the only way to genuinely cover the whole chapter. Truncating silently is the alternative, and it produces exactly the confident partial answer you're trying to prevent.
Done when: "List every injury type" returns a complete list, verified against the PDF by hand, and narrow questions did not regress on your eval set.
Module 10

Milestone 7 — providers, deployment, and honesty

Prove the abstraction held, then ship it

Swap the LLM for a local model and a second cloud provider, measure the difference on your eval set, deploy the whole thing, and write a README that tells the truth about where user data goes.

4 lessons · L47–L50 ~4 h 0 of 4 lessons complete
L47

The provider interface

35 min 0/6

The technology

What a Protocol is. Python's structural typing: you declare the shape a thing must have, and any class with those methods satisfies it — no inheritance required. It's a contract your type checker enforces.

What you're actually buying. You've been calling litellm.completion() directly from several modules. That's already fairly portable, but it means LiteLLM's specifics — its exception types, its streaming chunk shape, its parameter names — are spread through your codebase. One interface in the middle means nothing else knows which library, let alone which provider, is live.

The interface from section 9 of your design document is small on purpose: complete() and embed(). Resist adding more.

Steps

Code

app/llm/provider.py
from collections.abc import AsyncIterator, Iterator
from typing import Protocol

import litellm

from app.config import settings

Message = dict[str, str]


class LLMProvider(Protocol):
    def complete(self, messages: list[Message], *, model: str | None = None,
                 temperature: float = 0.1,
                 stream: bool = False) -> str | Iterator[str]: ...

    def embed(self, texts: list[str]) -> list[list[float]]: ...


class LiteLLMProvider:
    """The only file in the codebase that imports litellm."""

    def complete(self, messages, *, model=None, temperature=0.1,
                 stream=False, **kwargs):
        model = model or settings.chat_model
        resp = litellm.completion(model=model, messages=messages,
                                  temperature=temperature, stream=stream,
                                  **kwargs)
        if stream:
            def gen():
                for chunk in resp:
                    delta = chunk.choices[0].delta.content
                    if delta:
                        yield delta
            return gen()

        usage = resp.usage
        print(f"[llm] {model} in={usage.prompt_tokens} "
              f"out={usage.completion_tokens}")
        return resp.choices[0].message.content

    def embed(self, texts):
        resp = litellm.embedding(model=settings.embedding_model, input=texts)
        print(f"[embed] {settings.embedding_model} n={len(texts)}")
        return [d["embedding"] for d in resp.data]


llm: LLMProvider = LiteLLMProvider()

Why it works this way

Why the streaming generator is normalised here
Every provider has a slightly different chunk shape. Yielding plain strings means your API layer never has to know. Without this, swapping providers breaks the chat endpoint in a way that's tedious to debug.
Why reembed.py now
Changing embedding model is a schema migration plus re-embedding everything. Writing the script when it takes 30 seconds to run is much more pleasant than writing it when it takes six hours and costs money.
Why not abstract further
It's tempting to add caching, retries, and fallbacks to the interface. Keep it to two methods. Section 2 of your design document rejected LangChain for exactly this reason — abstraction you don't understand is worse than none.
Done when: grep -rn litellm app/ matches exactly one file, and every LLM call logs its token counts.
L48

Run a model on your own machine, and compare

50 min 0/10

The technology

Why this matters beyond curiosity. Section 5 of your design document makes an honest admission: if you call OpenAI or Anthropic, the user's text leaves your infrastructure. Both offer zero-retention terms for API traffic, and you should state plainly in your UI what happens to the data. But a user who won't accept that at all needs somewhere to go — and that somewhere is a local model. This lesson is what makes that promise real.

What Ollama is. A tool that downloads and runs open-weight models locally and exposes an OpenAI-compatible API. LiteLLM talks to it with model="ollama/llama3.1". No key, no network, no bill.

What to expect. Slower, unless you have a good GPU. Noticeably worse at instruction-following, and materially worse at emitting valid JSON — which is exactly why your query parser and reranker validate with Pydantic and retry. Section 9 warns about this specifically; now you get to see it.

Steps

Code

Ollama setup
curl -fsSL https://ollama.com/install.sh | sh
ollama pull llama3.1
ollama pull nomic-embed-text
ollama list
ollama run llama3.1 "say hello in five words"

# then, in .env, change ONE line:
# CHAT_MODEL=ollama/llama3.1
the comparison table for your README
| model                    | recall@8 | groundedness | refusal acc | median latency |
|--------------------------|----------|--------------|-------------|----------------|
| openai/gpt-4o-mini       |          |              |             |                |
| anthropic/claude-sonnet  |          |              |             |                |
| ollama/llama3.1 (local)  |          |              |             |                |

Note: recall@8 should be identical across chat models -- retrieval does not
depend on the chat model. If it moved, something is wrong with your eval,
or your utility model changed too and altered query parsing.

Why it works this way

The recall@8 sanity check
Retrieval quality is a function of embeddings and search, not of the chat model. If recall changes when you only swapped the chat model, your eval has a bug. It's a free correctness check — use it.
Why the embedding swap is the painful one
Different dimensions mean a different column type, an Alembic migration, and re-embedding every chunk. Chat models are swappable; embedding models are a migration. Your design document says exactly this and now you've felt it.
What a local model buys the product
It converts a privacy caveat into a supported deployment option. That's a product decision, not a footnote — your design document's phrasing, and it's right.
Done when: Three rows in your comparison table, all produced by changing environment variables rather than code.
L49

Deploy it

50 min 0/11

The technology

Three things to deploy. The FastAPI server, the ARQ worker, and the Next.js frontend. The database and storage are already hosted by Supabase.

Railway or Render for the backend: both deploy from a GitHub repo, run multiple services from one repo, and provide managed Redis. The worker is a second service from the same repo with a different start command.

Vercel for the frontend — it's built by the same people as Next.js and the deploy is essentially connecting the repo.

The thing that always breaks first is environment variables. Everything works locally because .env exists. On the host it doesn't. Every variable must be set in the host's dashboard, and the two services (API and worker) each need their own copy.

Steps

Code

Dockerfile
FROM python:3.12-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential libpq-dev && rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
the deployment checklist
# Backend service env vars
DATABASE_URL=            # Supabase POOLER url, not the direct one
REDIS_URL=               # from the Railway Redis service
OPENAI_API_KEY=
SUPABASE_URL=
SUPABASE_SERVICE_KEY=
SUPABASE_JWT_SECRET=
CHAT_MODEL=
UTILITY_MODEL=
EMBEDDING_MODEL=
EMBEDDING_DIM=

# Worker service: the SAME list. It is a separate process.

# Frontend (Vercel)
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=

# And do not forget:
#  - CORS allow_origins must include the Vercel domain
#  - Supabase Auth redirect URLs must include the Vercel domain
#  - alembic upgrade head against the production database

Why it works this way

Why the pooler connection string
Supabase's direct connection has a low connection limit. A web server plus a worker plus autoscaling exhausts it quickly, and the failure looks like random intermittent database errors. The pooler URL (port 6543) is what you want for applications.
Why not run migrations automatically on startup
With two services booting simultaneously they'd race, and a failed migration would crash-loop your API. Run it deliberately, watch it succeed, then deploy.
Free tiers sleep
Both Railway and Render idle inactive services, so the first request after a quiet period takes 30 seconds. Fine for a portfolio project; know it so you don't debug a problem that isn't one.
Done when: A stranger with the URL can sign up, upload a PDF, and get a cited answer.
L50

Cost, limits, and telling the truth in your README

40 min 0/9

The technology

Rate limiting (section 10). LLM calls cost money and an upload loop is an easy accidental self-DoS. Limit uploads and questions per user per minute. Without this, one bug in your own frontend can produce a surprising bill overnight.

Cost visibility (section 13). Embedding a 100-page PDF is fractions of a cent. Answering is the recurring cost. You added token logging in Lesson 47 — now turn it into something you can look at: tokens per request, aggregated per user per day.

The honesty section. Your design document is direct about this: state plainly in your UI what happens to the data. If you call OpenAI or Anthropic, the user's uploaded text leaves your infrastructure. Both offer zero-retention terms for API traffic. A user who won't accept that gets pointed at the Ollama deployment. Say this in your README — it's a real product decision, not a footnote.

Steps

Code

rate limiting per user
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address

from app.auth import get_current_user


def user_key(request):
    """Limit per authenticated user, falling back to IP."""
    return request.headers.get("authorization", get_remote_address(request))


limiter = Limiter(key_func=user_key)
app.state.limiter = limiter


@router.post("")
@limiter.limit("10/hour")
async def upload(request: Request, ...):
    ...


@router.post("/chat")
@limiter.limit("30/hour")
async def chat(request: Request, ...):
    ...
the README section that matters
## Where your data goes

Loremaster answers only from the materials you upload. To do that, it sends
the relevant passages of your documents to a language model provider.

**Default configuration** uses OpenAI's API. Your uploaded text is sent to
OpenAI when you ask a question, and when your document is first indexed.
OpenAI's API terms state that API data is not used to train their models and
is retained only briefly for abuse monitoring. Anthropic offers equivalent
terms.

**If that is not acceptable to you**, Loremaster supports running entirely on
your own hardware with Ollama. Set `CHAT_MODEL=ollama/llama3.1` and
`EMBEDDING_MODEL=ollama/nomic-embed-text` and no text leaves your machine.
Answer quality is lower; see the comparison table above.

**Isolation.** Your documents are visible only to your account. This is
enforced in three independent layers: every query filters by user, PostgreSQL
Row-Level Security rejects rows that are not yours regardless of the query,
and there is no shared cache of retrieved passages or embeddings between
users. The isolation tests in `tests/test_isolation.py` verify this.

**Scanned PDFs are rejected**, deliberately. A scan has no text layer, so a
silently-ingested scan would look ready and then fail to answer anything.

Why it works this way

Why rate limiting is a real feature
The failure mode isn't abuse — it's your own retry loop, or a user double-clicking upload on a 300-page book. A limit turns a four-figure surprise into a 429.
Why the honesty section is not boilerplate
Your users are uploading their own creative work and private session recordings. "Your text is sent to OpenAI" is something they deserve to read before they upload, not after. And having a real answer for people who say no is what makes the provider abstraction worth its cost.
What to build next
Section 14's remaining open decisions: campaigns as a first-class entity, and sharing a corpus with a gaming group. That second one moves the isolation boundary from user to workspace — cheap to design for now, expensive to retrofit. Think about it before you have users.
Done when: Rate limits work, you know your cost per question, and your README tells a stranger the truth about where their data goes. The project is finished.