Retrieval‑Augmented Generation · over your own study notes

Build a RAG engine for your notes, one stage at a time.

A build‑along reference for turning your hand‑crafted HTML study sheets into an assistant that answers questions grounded in your own notes, with citations back to the exact sheet. Each milestone is a stage in the pipeline below — read it, code it, run the checkpoint, tick it off.

IngestChunkEmbedIndexRetrieveRerankGenerateServe
12milestones
2optional appendices
localdefault: free + offline
fake→realswap models by config
M00

Foundations: skeleton, config, pluggable models

Why the swappable seam every later stage plugs into  ·  Write ~5 small files  ·  New pydantic-settings, Strategy pattern, factory

You asked for models you can swap and test easily. That one requirement drives this whole milestone. Two things are swappable: the embedder (text → vector) and the chatter (the LLM that writes answers). Everything else — ingestion, index, retrieval — depends only on the Embedder and Chatter interfaces, never on a concrete model. That is the Strategy pattern, and the payoff is that tests inject fakes so they run in milliseconds with no GPU, no keys, no network.

Project layout

bash
mkdir notes-rag && cd notes-rag
uv init backend                          # pyproject.toml, .python-version, .venv-ready scaffold
cd backend
mkdir -p app/{models,ingestion,retrieval,generation,api}
mkdir -p tests scripts
touch app/__init__.py app/models/__init__.py tests/__init__.py

uv init also drops a placeholder main.py and README.md at the project root — harmless, ignore them (the real app entry point is app/main.py from M7).

Dependencies (grows per milestone)

bash
uv add pydantic pydantic-settings numpy
uv add sentence-transformers          # local embeddings, no key
uv add openai anthropic google-generativeai   # only the provider(s) you plan to wire up
uv add --dev pytest

uv add writes straight into pyproject.toml and uv.lock and creates .venv on first use — no manual python -m venv / activate step. From here on, run everything through uv run (e.g. uv run pytest, uv run python scripts/…) so it always uses the project's own locked environment.

app/config.py — typed, validated, loaded once

python
from functools import lru_cache
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    # which components to use (the pluggable switch)
    embedder: Literal["local", "openai", "gemini", "fake"] = "local"
    llm: Literal["openai", "anthropic", "gemini", "fake"] = "fake"   # fake => runs with ZERO keys

    # model identifiers
    local_embed_model: str = "sentence-transformers/all-MiniLM-L6-v2"   # 384-dim
    openai_embed_model: str = "text-embedding-3-small"                  # 1536-dim
    gemini_embed_model: str = "models/text-embedding-004"               # 768-dim
    openai_chat_model: str = "gpt-4.1-nano"
    anthropic_chat_model: str = "claude-haiku-4-5"
    gemini_chat_model: str = "gemini-2.5-flash-lite"
    reranker_model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"

    # secrets (only needed when the matching provider is selected)
    openai_api_key: str = ""
    anthropic_api_key: str = ""
    gemini_api_key: str = ""

    # ingestion / retrieval
    chunk_min_chars: int = 200
    chunk_max_chars: int = 1200
    index_dir: str = "./data/index"
    retrieval_top_k: int = 15    # FAISS returns this many
    rerank_top_k: int = 5        # this many survive reranking

    model_config = SettingsConfigDict(
        env_file=".env", env_file_encoding="utf-8",
        case_sensitive=False, extra="ignore",
    )


@lru_cache(maxsize=1)
def get_settings() -> Settings:
    return Settings()

Literal[...] rejects a bad value at startup with a clear error — a typo in .env fails loudly instead of silently misbehaving. llm="fake" is the default so you develop retrieval fully before touching an API key. @lru_cache makes config one consistent object per process.

bash
# .env.example  (copy to .env, fill only what you need)
EMBEDDER=local
LLM=fake
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GEMINI_API_KEY=

app/models/base.py — the interfaces

python
from typing import Protocol, runtime_checkable
import numpy as np


@runtime_checkable
class Embedder(Protocol):
    dim: int                                   # retrieval needs the vector size
    def embed(self, texts: list[str]) -> np.ndarray: ...   # (n, dim) float32


@runtime_checkable
class Chatter(Protocol):
    def answer(self, prompt: str) -> str: ...

Why Protocol, not inheritance? Any class with the right methods is an Embedder — no base class to import, so provider wrappers stay decoupled. The dim attribute lives on the embedder on purpose: local is 384-dim, OpenAI 1536-dim, Gemini 768-dim, and a FAISS index built with one cannot be queried with the other. Carrying dim lets the index guard against that later.

app/models/embedders.py

python
import hashlib
import numpy as np
from app.config import Settings


class LocalEmbedder:                # sentence-transformers, free + offline
    def __init__(self, settings: Settings):
        from sentence_transformers import SentenceTransformer   # lazy import
        self._model = SentenceTransformer(settings.local_embed_model)
        self.dim = self._model.get_sentence_embedding_dimension()

    def embed(self, texts: list[str]) -> np.ndarray:
        v = self._model.encode(texts, convert_to_numpy=True, normalize_embeddings=True)
        return v.astype(np.float32)


class OpenAIEmbedder:               # hosted, needs OPENAI_API_KEY, 1536-dim
    def __init__(self, settings: Settings):
        from openai import OpenAI
        if not settings.openai_api_key:
            raise ValueError("EMBEDDER=openai but OPENAI_API_KEY is empty")
        self._client = OpenAI(api_key=settings.openai_api_key)
        self._model, self.dim = settings.openai_embed_model, 1536

    def embed(self, texts: list[str]) -> np.ndarray:
        r = self._client.embeddings.create(model=self._model, input=texts)
        return np.array([d.embedding for d in r.data], dtype=np.float32)


class GeminiEmbedder:               # hosted, needs GEMINI_API_KEY, 768-dim
    def __init__(self, settings: Settings):
        import google.generativeai as genai
        if not settings.gemini_api_key:
            raise ValueError("EMBEDDER=gemini but GEMINI_API_KEY is empty")
        genai.configure(api_key=settings.gemini_api_key)
        self._genai, self._model, self.dim = genai, settings.gemini_embed_model, 768

    def embed(self, texts: list[str]) -> np.ndarray:
        out = [self._genai.embed_content(model=self._model, content=t)["embedding"]
               for t in texts]
        return np.array(out, dtype=np.float32)


class FakeEmbedder:                 # deterministic, for tests. no model, no network
    def __init__(self, settings: Settings | None = None, dim: int = 384):
        self.dim = dim

    def embed(self, texts: list[str]) -> np.ndarray:
        out = np.zeros((len(texts), self.dim), dtype=np.float32)
        for i, t in enumerate(texts):
            seed = int(hashlib.sha256(t.encode()).hexdigest(), 16) % (2**32)
            v = np.random.default_rng(seed).standard_normal(self.dim).astype(np.float32)
            out[i] = v / (np.linalg.norm(v) + 1e-9)   # unit length, like the real ones
        return out

normalize_embeddings=True makes cosine similarity equal dot product — which is what FAISS does fast (Milestone 3 relies on this). Lazy imports keep torch out of your fast tests, which only touch FakeEmbedder.

app/models/chatters.py

python
from app.config import Settings


class OpenAIChatter:
    def __init__(self, settings: Settings):
        from openai import OpenAI
        if not settings.openai_api_key:
            raise ValueError("LLM=openai but OPENAI_API_KEY is empty")
        self._client = OpenAI(api_key=settings.openai_api_key)
        self._model = settings.openai_chat_model

    def answer(self, prompt: str) -> str:
        r = self._client.chat.completions.create(
            model=self._model,
            messages=[{"role": "user", "content": prompt}])
        return r.choices[0].message.content


class AnthropicChatter:
    def __init__(self, settings: Settings):
        from anthropic import Anthropic
        if not settings.anthropic_api_key:
            raise ValueError("LLM=anthropic but ANTHROPIC_API_KEY is empty")
        self._client = Anthropic(api_key=settings.anthropic_api_key)
        self._model = settings.anthropic_chat_model

    def answer(self, prompt: str) -> str:
        r = self._client.messages.create(
            model=self._model, max_tokens=1024,
            messages=[{"role": "user", "content": prompt}])
        return r.content[0].text


class GeminiChatter:
    def __init__(self, settings: Settings):
        import google.generativeai as genai
        if not settings.gemini_api_key:
            raise ValueError("LLM=gemini but GEMINI_API_KEY is empty")
        genai.configure(api_key=settings.gemini_api_key)
        self._model = genai.GenerativeModel(settings.gemini_chat_model)

    def answer(self, prompt: str) -> str:
        return self._model.generate_content(prompt).text


class FakeChatter:                 # echoes prompt evidence; for tests + key-free dev
    def answer(self, prompt: str) -> str:
        head = prompt.strip().splitlines()[0][:80] if prompt.strip() else ""
        return f"[FAKE ANSWER] prompt started: {head!r}"

app/models/factory.py — config picks the implementation

python
from functools import lru_cache
from app.config import Settings, get_settings
from app.models.base import Embedder, Chatter
from app.models import embedders, chatters


def build_embedder(s: Settings) -> Embedder:
    return {"local": embedders.LocalEmbedder,
            "openai": embedders.OpenAIEmbedder,
            "gemini": embedders.GeminiEmbedder,
            "fake": embedders.FakeEmbedder}[s.embedder](s)


def build_chatter(s: Settings) -> Chatter:
    return {"openai": chatters.OpenAIChatter,
            "anthropic": chatters.AnthropicChatter,
            "gemini": chatters.GeminiChatter,
            "fake": lambda _s: chatters.FakeChatter()}[s.llm](s)


@lru_cache(maxsize=1)
def get_embedder() -> Embedder: return build_embedder(get_settings())

@lru_cache(maxsize=1)
def get_chatter() -> Chatter: return build_chatter(get_settings())

The factory is the only place that knows concrete classes. build_*(settings) is easy to unit-test; get_*() is the cached accessor the app uses so a heavy model loads once.

M01

Structure-aware ingestion

Why the quality ceiling of the whole system  ·  Write parser, chunker, Chunk type, tests  ·  New chunk-as-unit, semantic vs blind chunking

Say it once: the chunk is the unit of retrieval. The system never retrieves “a note” — it retrieves chunks. So the ceiling of everything downstream is set here. The reference project blindly slices PDFs every ~1000 chars and gets anonymous, boundary-broken chunks. Your HTML already encodes subject → sheet → section → tags. Parse that, and every chunk carries metadata that does three jobs: sharper retrieval, real citations back to a sheet, and clean boundaries.

Dependencies (add)

bash
uv add beautifulsoup4 lxml

app/ingestion/types.py — the Chunk

python
from __future__ import annotations
from dataclasses import dataclass, field, asdict
import hashlib


@dataclass
class Chunk:
    text: str                    # shown to the user
    subject: str                 # "Java", "DSA", "React"...
    sheet: str                   # "Thread Synchronization"
    section: str = ""            # "Producer-Consumer pattern"
    tags: list[str] = field(default_factory=list)
    source: str = ""             # "java/thread-sync.html#producer-consumer"
    chunk_id: str = ""

    def __post_init__(self):
        if not self.chunk_id:
            key = f"{self.source}|{self.section}|{self.text[:64]}"
            self.chunk_id = hashlib.sha1(key.encode()).hexdigest()[:16]

    def embedding_text(self, with_crumb: bool = True) -> str:
        """What we EMBED: breadcrumb + tags + text, so the topic lives in the
        vector. Toggle with_crumb=False for the M1 experiment."""
        if not with_crumb:
            return self.text
        crumb = " > ".join(x for x in [self.subject, self.sheet, self.section] if x)
        tagline = f"tags: {', '.join(self.tags)}" if self.tags else ""
        return f"[{crumb}]\n{tagline}\n{self.text}".strip()

    def to_dict(self) -> dict:
        return asdict(self)

We embed embedding_text() (topic folded in) but display text. The stable hash chunk_id keeps re-indexing from creating duplicates.

app/ingestion/html_parser.py — adjust the # ADJUST lines to your markup

python
from __future__ import annotations
from dataclasses import dataclass
from bs4 import BeautifulSoup, Tag

@dataclass
class RawSection:
    section: str
    text: str
    tags: list[str]

_NOISE = ["script", "style", "nav", "footer", "header", "aside", "button"]

def _clean(node: Tag) -> str:
    for bad in node.find_all(_NOISE):
        bad.decompose()
    return " ".join(node.get_text(separator=" ", strip=True).split())

def parse_sheet(html: str, *, subject_hint="", sheet_hint=""):
    soup = BeautifulSoup(html, "lxml")
    title_el = soup.select_one("h1")                       # ADJUST
    sheet = (title_el.get_text(strip=True) if title_el else "") or sheet_hint
    subject = subject_hint

    sections: list[RawSection] = []
    nodes = soup.select("section")                         # ADJUST
    if nodes:
        for sec in nodes:
            head = sec.find(["h2", "h3", "h4"])            # ADJUST
            name = head.get_text(strip=True) if head else ""
            tags = [t.get_text(strip=True) for t in sec.select(".tag")]   # ADJUST
            body = _clean(sec)
            if body:
                sections.append(RawSection(name, body, tags))
    else:                                                  # heading-walk fallback
        cur = RawSection(sheet, "", [])
        for el in (soup.body or soup).descendants:
            if isinstance(el, Tag) and el.name in ("h2", "h3"):        # ADJUST
                if cur.text.strip():
                    sections.append(cur)
                cur = RawSection(el.get_text(strip=True), "", [])
            elif isinstance(el, Tag) and el.name in ("p", "li", "pre", "code"):
                cur.text += " " + el.get_text(" ", strip=True)
        if cur.text.strip():
            sections.append(cur)
    return subject, sheet, sections

app/ingestion/chunker.py — sections → right-sized chunks

python
from __future__ import annotations
import re
from app.ingestion.types import Chunk
from app.ingestion.html_parser import RawSection
from app.config import Settings

_SENT = re.compile(r"(?<=[.!?])\s+")

def _split_long(text, max_chars):
    if len(text) <= max_chars:
        return [text]
    out, buf = [], ""
    for s in _SENT.split(text):
        if len(buf) + len(s) + 1 > max_chars and buf:
            out.append(buf.strip()); buf = s
        else:
            buf = f"{buf} {s}".strip()
    if buf.strip():
        out.append(buf.strip())
    return out

def _slug(s):
    return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-")

def chunk_sections(subject, sheet, sections: list[RawSection], source, settings: Settings):
    chunks, carry = [], ""
    for sec in sections:
        text = sec.text.strip()
        if not text:
            continue
        # carry a too-short section forward onto the next one
        if len(text) < settings.chunk_min_chars and len(text) + len(carry) < settings.chunk_max_chars:
            carry = f"{carry} {text}".strip()
            continue
        body = f"{carry} {text}".strip() if carry else text
        carry = ""
        for piece in _split_long(body, settings.chunk_max_chars):
            chunks.append(Chunk(text=piece, subject=subject, sheet=sheet,
                                section=sec.section, tags=sec.tags,
                                source=f"{source}#{_slug(sec.section)}"))
    if carry:
        chunks.append(Chunk(text=carry, subject=subject, sheet=sheet, source=source))
    return chunks

Three rules: never merge across sections (two topics blur one vector), split long text at sentence boundaries (not mid-idea), carry tiny sections forward. Because cuts are semantic, you need little or no overlap — add some later only if measurement says so.

app/ingestion/pipeline.py

python
from __future__ import annotations
from pathlib import Path
from app.ingestion.html_parser import parse_sheet
from app.ingestion.chunker import chunk_sections
from app.config import Settings

def ingest_directory(notes_dir, settings: Settings):
    notes_dir = Path(notes_dir)
    all_chunks = []
    for f in sorted(notes_dir.rglob("*.html")):
        subject_hint = f.parent.name.replace("-", " ").title()   # ADJUST
        source = str(f.relative_to(notes_dir))
        html = f.read_text(encoding="utf-8", errors="ignore")
        subject, sheet, sections = parse_sheet(html, subject_hint=subject_hint, sheet_hint=f.stem)
        all_chunks.extend(chunk_sections(subject, sheet, sections, source, settings))
    return all_chunks
M02

The index abstraction

Why make the vector store swappable and dim-guarded  ·  Write a Protocol + an in-memory store  ·  New vector search from scratch (once)

Before FAISS, define what a store is so FAISS is swappable and the index remembers which embedder built it — the guard for the 384-vs-768 trap you predicted in M0. You'll also implement similarity search by hand once, in numpy, so FAISS later is not magic.

app/retrieval/store.py — the interface + a tiny numpy store

python
from __future__ import annotations
from typing import Protocol, runtime_checkable
import json, os
import numpy as np
from app.ingestion.types import Chunk


@runtime_checkable
class VectorStore(Protocol):
    dim: int
    embedder_name: str
    def add(self, chunks: list[Chunk], vectors: np.ndarray) -> None: ...
    def search(self, query_vec: np.ndarray, k: int) -> list[tuple[Chunk, float]]: ...
    def save(self, path: str) -> None: ...


class NumpyStore:
    """In-memory brute-force search. Great for tests and small note corpora
    (a few thousand chunks is instant). Same interface as FAISS later."""
    def __init__(self, dim: int, embedder_name: str = ""):
        self.dim, self.embedder_name = dim, embedder_name
        self._vecs = np.zeros((0, dim), dtype=np.float32)
        self._chunks: list[Chunk] = []

    def add(self, chunks, vectors):
        if vectors.shape[1] != self.dim:
            raise ValueError(f"vector dim {vectors.shape[1]} != store dim {self.dim}")
        self._vecs = np.vstack([self._vecs, vectors.astype(np.float32)])
        self._chunks.extend(chunks)

    def search(self, query_vec, k):
        if not self._chunks:
            return []
        sims = self._vecs @ query_vec.reshape(-1)          # cosine (vectors are unit-norm)
        idx = np.argsort(-sims)[:k]
        return [(self._chunks[i], float(sims[i])) for i in idx]

    def save(self, path):
        os.makedirs(path, exist_ok=True)
        np.save(os.path.join(path, "vecs.npy"), self._vecs)
        meta = {"dim": self.dim, "embedder_name": self.embedder_name,
                "chunks": [c.to_dict() for c in self._chunks]}
        with open(os.path.join(path, "meta.json"), "w") as f:
            json.dump(meta, f)

    @classmethod
    def load(cls, path, expected_embedder=None):
        with open(os.path.join(path, "meta.json")) as f:
            meta = json.load(f)
        if expected_embedder and meta["embedder_name"] != expected_embedder:
            raise ValueError(
                f"index built with '{meta['embedder_name']}' but current embedder is "
                f"'{expected_embedder}'. Re-index (vectors are not interchangeable).")
        s = cls(meta["dim"], meta["embedder_name"])
        s._vecs = np.load(os.path.join(path, "vecs.npy"))
        s._chunks = [Chunk(**c) for c in meta["chunks"]]
        return s

The single line self._vecs @ query_vec is vector search: a dot product against every stored vector, then take the largest. Because everything is unit-normalized (M0), dot product = cosine similarity. FAISS does exactly this, just faster.

M03

Embeddings and FAISS

Why real semantic vectors + a persistent index  ·  Write a FAISS store + build script  ·  New what an index actually is; settle the M1 experiment

Now swap the numpy store for FAISS — same interface, so nothing downstream changes. FAISS gives you a fast index you build once and reload instantly. You'll index your real notes and run your first honest retrieval: no LLM, just “show me the chunks nearest this question.”

Dependencies (add)

bash
uv add faiss-cpu

app/retrieval/faiss_store.py — same interface as NumpyStore

python
from __future__ import annotations
import json, os
import numpy as np
import faiss
from app.ingestion.types import Chunk


class FaissStore:
    def __init__(self, dim: int, embedder_name: str = ""):
        self.dim, self.embedder_name = dim, embedder_name
        self.index = faiss.IndexFlatIP(dim)      # inner product == cosine on unit vectors
        self._chunks: list[Chunk] = []

    def add(self, chunks, vectors):
        if vectors.shape[1] != self.dim:
            raise ValueError(f"vector dim {vectors.shape[1]} != store dim {self.dim}")
        self.index.add(vectors.astype(np.float32))
        self._chunks.extend(chunks)

    def search(self, query_vec, k):
        scores, idx = self.index.search(query_vec.astype(np.float32).reshape(1, -1), k)
        out = []
        for s, i in zip(scores[0], idx[0]):
            if i != -1:
                out.append((self._chunks[i], float(s)))
        return out

    def save(self, path):
        os.makedirs(path, exist_ok=True)
        faiss.write_index(self.index, os.path.join(path, "index.faiss"))
        meta = {"dim": self.dim, "embedder_name": self.embedder_name,
                "chunks": [c.to_dict() for c in self._chunks]}
        with open(os.path.join(path, "meta.json"), "w") as f:
            json.dump(meta, f)

    @classmethod
    def load(cls, path, expected_embedder=None):
        with open(os.path.join(path, "meta.json")) as f:
            meta = json.load(f)
        if expected_embedder and meta["embedder_name"] != expected_embedder:
            raise ValueError(
                f"index built with '{meta['embedder_name']}' but current embedder is "
                f"'{expected_embedder}'. Re-index.")
        s = cls(meta["dim"], meta["embedder_name"])
        s.index = faiss.read_index(os.path.join(path, "index.faiss"))
        s._chunks = [Chunk(**c) for c in meta["chunks"]]
        return s

IndexFlatIP = exact inner-product search, perfect for a personal corpus. (At millions of vectors you'd switch to an approximate index like IVF — but that is the only line that would change, because of the interface.)

scripts/build_index.py — ingest → embed → index → save

python
import sys
from app.config import get_settings
from app.models.factory import get_embedder
from app.ingestion.pipeline import ingest_directory
from app.retrieval.faiss_store import FaissStore

def build(notes_dir, with_crumb=True):
    s, emb = get_settings(), get_embedder()
    chunks = ingest_directory(notes_dir, s)
    vectors = emb.embed([c.embedding_text(with_crumb) for c in chunks])
    store = FaissStore(emb.dim, s.embedder)
    store.add(chunks, vectors)
    store.save(s.index_dir)
    print(f"indexed {len(chunks)} chunks -> {s.index_dir} (embedder={s.embedder}, crumb={with_crumb})")

if __name__ == "__main__":
    build(sys.argv[1] if len(sys.argv) > 1 else "./notes")
M04

Retrieval and reranking

Why FAISS is fast but not the sharpest ranker  ·  Write a retriever + cross-encoder reranker  ·  New two-stage retrieval, bi- vs cross-encoder

Two-stage retrieval: FAISS quickly pulls retrieval_top_k=15 candidates using a bi-encoder (question and chunk embedded separately — fast, approximate). Then a cross-encoder reads each (question, chunk) pair together and scores true relevance — slower, but far sharper. You keep the top rerank_top_k=5. You'll see the reorder happen on your own notes.

config.py (add)

python
reranker: Literal["cross-encoder", "noop"] = "cross-encoder"   # add to Settings

app/retrieval/retriever.py

python
from app.config import Settings
from app.models.base import Embedder
from app.retrieval.store import VectorStore

class Retriever:
    def __init__(self, embedder: Embedder, store: VectorStore, settings: Settings):
        self.embedder, self.store, self.k = embedder, store, settings.retrieval_top_k
    def retrieve(self, query: str):
        qv = self.embedder.embed([query])[0]
        return self.store.search(qv, self.k)        # [(Chunk, score), ...]

app/retrieval/reranker.py

python
from functools import lru_cache
from app.config import Settings


@lru_cache(maxsize=1)
def _cross_encoder(model_name: str):
    from sentence_transformers import CrossEncoder     # lazy + cached (loads once)
    return CrossEncoder(model_name)


class CrossEncoderReranker:
    def __init__(self, settings: Settings):
        self.model_name, self.top_k = settings.reranker_model, settings.rerank_top_k

    def rerank(self, query, candidates):               # candidates: [(Chunk, score)]
        if not candidates:
            return []
        ce = _cross_encoder(self.model_name)
        scores = ce.predict([(query, c.text) for c, _ in candidates])
        ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
        return [(c, float(s)) for (c, _old), s in ranked[: self.top_k]]


class NoopReranker:                                   # for tests / A-B comparison
    def __init__(self, settings: Settings):
        self.top_k = settings.rerank_top_k
    def rerank(self, query, candidates):
        return candidates[: self.top_k]


def build_reranker(settings: Settings):
    return (CrossEncoderReranker if settings.reranker == "cross-encoder" else NoopReranker)(settings)

The cross-encoder uses predict, not encode: it outputs one relevance score per pair, not an embedding. lru_cache loads the model once per process (it downloads weights on first use).

M05

Grounded generation

Why turn chunks into a cited answer that refuses to bluff  ·  Write the RAG pipeline + prompts  ·  New query rewriting, grounding, citations

Now assemble everything into an answer — and flip LLM=fake to a real provider. Three ideas make this trustworthy: query rewriting (an LLM turns a vague question into a better search query), grounding (the answer prompt forbids using anything but the retrieved notes), and citations (every answer points back to the sheet and section it came from).

app/generation/prompts.py

python
REWRITE_PROMPT = """Rewrite the user's question into a concise search query that \
will retrieve the most relevant study notes. Keep the key technical terms. \
Return ONLY the rewritten query, nothing else.

Question: {question}"""

RAG_PROMPT = """You are a study assistant. Answer the question using ONLY the notes \
below. Cite the source of each claim inline as [sheet > section]. If the notes do \
not contain the answer, say exactly: "The notes don't cover this." Do not use outside \
knowledge.

NOTES:
{context}

QUESTION: {question}

ANSWER:"""

app/generation/pipeline.py

python
from app.config import Settings
from app.models.base import Embedder, Chatter
from app.retrieval.store import VectorStore
from app.retrieval.retriever import Retriever
from app.retrieval.reranker import build_reranker
from app.generation.prompts import REWRITE_PROMPT, RAG_PROMPT


def _context(reranked):
    blocks = []
    for i, (c, _s) in enumerate(reranked, 1):
        crumb = " > ".join(x for x in [c.subject, c.sheet, c.section] if x)
        blocks.append(f"[{i}] ({crumb})\n{c.text}")
    return "\n\n".join(blocks)


class RagPipeline:
    def __init__(self, embedder: Embedder, store: VectorStore,
                 chatter: Chatter, settings: Settings):
        self.retriever = Retriever(embedder, store, settings)
        self.reranker = build_reranker(settings)
        self.chatter = chatter

    def answer(self, question: str, rewrite: bool = True) -> dict:
        query = self._rewrite(question) if rewrite else question
        reranked = self.reranker.rerank(query, self.retriever.retrieve(query))
        if not reranked:
            return {"answer": "The notes don't cover this.",
                    "sources": [], "contexts": [], "rewritten_query": query}
        answer = self.chatter.answer(
            RAG_PROMPT.format(context=_context(reranked), question=question))
        sources = [{"subject": c.subject, "sheet": c.sheet, "section": c.section,
                    "source": c.source, "score": round(s, 3)} for c, s in reranked]
        contexts = [c.text for c, _ in reranked]   # the exact note text the LLM saw
        return {"answer": answer, "sources": sources,
                "contexts": contexts, "rewritten_query": query}

    def _rewrite(self, question: str) -> str:
        try:
            return self.chatter.answer(REWRITE_PROMPT.format(question=question)).strip()
        except Exception:
            return question         # rewriting is a nice-to-have, never a hard failure

Note we pass the original question to the answer prompt (what the user actually asked) but retrieve with the rewritten one (better for search). And an empty candidate set short-circuits to an honest refusal instead of asking the LLM to invent something. We also return contexts — the exact note text the LLM was shown — because that, not a citation label, is what an honest faithfulness check (M9) must score against.

Turn on a real LLM

bash
# .env  — pick ONE
LLM=openai
OPENAI_API_KEY=your_key_here
# or:
# LLM=anthropic
# ANTHROPIC_API_KEY=your_key_here
# or (has a free tier):
# LLM=gemini
# GEMINI_API_KEY=your_key_here
M06

Topics and self-quiz suggestions

Why the revision payoff — turn notes into a study tool  ·  Write two LLM helpers  ·  New structured LLM output, sampling a corpus

Two features that make this a study companion, not just a Q&A box. Topics: “what are the 5–8 main ideas in my Java sheets?” for a sidebar. Suggestions: “give me questions to quiz myself.” Both sample chunks and ask the LLM for line-per-item output you parse.

app/generation/study.py

python
from app.ingestion.types import Chunk
from app.models.base import Chatter

TOPICS_PROMPT = """From these study notes, list the 5-8 main topics. \
Format each line exactly as: Title -- one-sentence description. No numbering.

NOTES:
{context}"""

QUIZ_PROMPT = """You are a study coach. From these notes, write exactly 4 questions a \
student should be able to answer to prove they understand the material. One per line, \
no numbering, no answers.

NOTES:
{context}"""


def _sample(chunks: list[Chunk], n: int = 12) -> str:
    """Spread the sample across the corpus so we don't only see the first sheet."""
    if len(chunks) <= n:
        picked = chunks
    else:
        step = len(chunks) // n
        picked = chunks[::step][:n]
    return "\n\n".join(c.text[:400] for c in picked)


def get_topics(chatter: Chatter, chunks: list[Chunk]) -> list[dict]:
    raw = chatter.answer(TOPICS_PROMPT.format(context=_sample(chunks)))
    out = []
    for line in raw.splitlines():
        line = line.strip().lstrip("-*0123456789. ").strip()
        if "--" in line:
            title, desc = line.split("--", 1)
            out.append({"title": title.strip(), "description": desc.strip()})
    return out[:8]


def get_suggestions(chatter: Chatter, chunks: list[Chunk]) -> list[str]:
    raw = chatter.answer(QUIZ_PROMPT.format(context=_sample(chunks)))
    qs = [ln.strip().lstrip("-*0123456789. ").strip()
          for ln in raw.splitlines() if ln.strip()]
    return qs[:4]

Filtering chunks by subject before calling these scopes the topics to one subject (“Topics in my DSA notes”). The parsing is defensive: LLMs sometimes add numbering or blank lines even when told not to, so we strip and cap.

M07

FastAPI layer + retry

Why expose the pipeline over HTTP for the frontend  ·  Write main.py, models.py, routes, retry  ·  New FastAPI, pydantic schemas, tenacity

Wrap the pipeline in a small API: endpoints for /query, /topics, /suggestions, /reindex, and /health. You'll also add retry logic — and, unlike the reference project (which duplicated it in two files), you'll define it once and apply it to the hosted providers.

Dependencies (add)

bash
uv add fastapi uvicorn tenacity

app/utils/retry.py — defined ONCE, imported where needed

python
"""Central retry decorators. Do NOT re-implement retry inside providers -
decorate the provider method with one of these instead."""
import httpx
from tenacity import (retry, stop_after_attempt, wait_exponential,
                      retry_if_exception_type)

_NET = (httpx.TimeoutException, httpx.ConnectError, ConnectionError, OSError)

llm_retry = retry(reraise=True, stop=stop_after_attempt(4),
                  wait=wait_exponential(min=2, max=30),
                  retry=retry_if_exception_type(_NET))

embed_retry = retry(reraise=True, stop=stop_after_attempt(3),
                    wait=wait_exponential(min=1, max=15),
                    retry=retry_if_exception_type(_NET))

Apply them by decorating the provider methods you already wrote in M0 — e.g. put @llm_retry above OpenAIChatter.answer (or AnthropicChatter.answer) and @embed_retry above GeminiEmbedder.embed (or OpenAIEmbedder.embed). One definition, reused. That's the anti-duplication lesson made concrete.

app/api/schemas.py — pydantic request/response

python
from pydantic import BaseModel, Field

class QueryIn(BaseModel):
    question: str = Field(min_length=3, max_length=2000)
    rewrite: bool = True

class Source(BaseModel):
    subject: str; sheet: str; section: str; source: str; score: float

class QueryOut(BaseModel):
    answer: str
    sources: list[Source]
    rewritten_query: str

class Topic(BaseModel):
    title: str; description: str

app/main.py — app factory + wiring + health

python
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware

from app.config import get_settings
from app.models.factory import get_embedder, get_chatter
from app.retrieval.faiss_store import FaissStore
from app.generation.pipeline import RagPipeline
from app.generation.study import get_topics, get_suggestions
from app.ingestion.pipeline import ingest_directory
from app.api.schemas import QueryIn, QueryOut, Topic

state = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    s = get_settings()
    store = FaissStore.load(s.index_dir, expected_embedder=s.embedder)
    state["rag"] = RagPipeline(get_embedder(), store, get_chatter(), s)
    state["chunks"] = store._chunks
    yield
    state.clear()

app = FastAPI(title="Notes-RAG", version="1.0", lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["http://localhost:3000"],
                   allow_methods=["*"], allow_headers=["*"])

@app.get("/health")
def health():
    return {"status": "ok", "chunks": len(state.get("chunks", []))}

@app.post("/query", response_model=QueryOut)
def query(body: QueryIn):
    try:
        return state["rag"].answer(body.question, rewrite=body.rewrite)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/topics", response_model=list[Topic])
def topics(subject: str | None = None):
    chunks = state["chunks"]
    if subject:
        chunks = [c for c in chunks if c.subject.lower() == subject.lower()]
    return get_topics(state["rag"].chatter, chunks)

@app.get("/suggestions")
def suggestions(subject: str | None = None):
    chunks = state["chunks"]
    if subject:
        chunks = [c for c in chunks if c.subject.lower() == subject.lower()]
    return {"suggestions": get_suggestions(state["rag"].chatter, chunks)}
M08

The frontend (near-complete)

Why a real UI over your notes; you're FE-strong so stay on-goal  ·  Write one page + an api client  ·  New nothing new — wire it and move on

You know React/Next, so this is deliberately near-complete: a single page that queries your notes, shows the grounded answer with citations, lists topics in a sidebar, and offers self-quiz chips. Drop it in, point it at the API, done. Resist gold-plating — the learning is in the backend.

frontend/lib/api.ts

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

export type Source = { subject: string; sheet: string; section: string; source: string; score: number };
export type QueryOut = { answer: string; sources: Source[]; rewritten_query: string };

export async function ask(question: string): Promise<QueryOut> {
  const r = await fetch(`${BASE}/query`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ question }),
  });
  if (!r.ok) throw new Error(`query failed: ${r.status}`);
  return r.json();
}

export async function getTopics(subject?: string) {
  const q = subject ? `?subject=${encodeURIComponent(subject)}` : "";
  return (await fetch(`${BASE}/topics${q}`)).json();
}

export async function getSuggestions(subject?: string) {
  const q = subject ? `?subject=${encodeURIComponent(subject)}` : "";
  return (await (await fetch(`${BASE}/suggestions${q}`)).json()).suggestions as string[];
}

frontend/app/page.tsx

tsx
"use client";
import { useEffect, useState } from "react";
import { ask, getTopics, getSuggestions, QueryOut } from "@/lib/api";

export default function Home() {
  const [q, setQ] = useState("");
  const [res, setRes] = useState<QueryOut | null>(null);
  const [loading, setLoading] = useState(false);
  const [topics, setTopics] = useState<{ title: string; description: string }[]>([]);
  const [chips, setChips] = useState<string[]>([]);

  useEffect(() => {
    getTopics().then(setTopics).catch(() => {});
    getSuggestions().then(setChips).catch(() => {});
  }, []);

  async function run(question: string) {
    if (question.trim().length < 3) return;
    setLoading(true); setRes(null);
    try { setRes(await ask(question)); }
    catch (e) { setRes({ answer: "Request failed. Is the API running on :8000?", sources: [], rewritten_query: "" }); }
    finally { setLoading(false); }
  }

  return (
    <main style={{ display: "grid", gridTemplateColumns: "260px 1fr", gap: 32, maxWidth: 1100, margin: "40px auto", padding: "0 20px", fontFamily: "system-ui" }}>
      <aside>
        <h3>Topics</h3>
        <ul style={{ listStyle: "none", padding: 0 }}>
          {topics.map((t) => (
            <li key={t.title} style={{ marginBottom: 12 }}>
              <strong>{t.title}</strong>
              <div style={{ fontSize: 13, color: "#666" }}>{t.description}</div>
            </li>
          ))}
        </ul>
      </aside>

      <section>
        <h1>Ask my notes</h1>
        <div style={{ display: "flex", gap: 8 }}>
          <input value={q} onChange={(e) => setQ(e.target.value)}
                 onKeyDown={(e) => e.key === "Enter" && run(q)}
                 placeholder="e.g. difference between Comparable and Comparator"
                 style={{ flex: 1, padding: 12, fontSize: 15 }} />
          <button onClick={() => run(q)} disabled={loading} style={{ padding: "12px 20px" }}>
            {loading ? "..." : "Ask"}
          </button>
        </div>

        <div style={{ marginTop: 10, display: "flex", flexWrap: "wrap", gap: 8 }}>
          {chips.map((c) => (
            <button key={c} onClick={() => { setQ(c); run(c); }}
                    style={{ fontSize: 13, padding: "6px 10px", borderRadius: 16, cursor: "pointer" }}>
              {c}
            </button>
          ))}
        </div>

        {res && (
          <article style={{ marginTop: 28 }}>
            <p style={{ whiteSpace: "pre-wrap", lineHeight: 1.6 }}>{res.answer}</p>
            {res.sources.length > 0 && (
              <div style={{ marginTop: 20, fontSize: 13, color: "#555" }}>
                <strong>Sources</strong>
                <ul>
                  {res.sources.map((s, i) => (
                    <li key={i}>{s.subject} &rsaquo; {s.sheet} &rsaquo; {s.section} ({s.score})</li>
                  ))}
                </ul>
              </div>
            )}
          </article>
        )}
      </section>
    </main>
  );
}
bash
# frontend/.env.local
NEXT_PUBLIC_API_URL=http://localhost:8000
M09

Evaluation

Why know if it's actually good, and tune with evidence  ·  Write a retrieval eval + optional RAGAS  ·  New recall@k, MRR, faithfulness

“Looks fine” isn't a metric. Two layers: a retrieval eval you fully own (did the right sheet get retrieved?) as the spine, and RAGAS on top for generation quality (is the answer faithful to the context?). Retrieval eval is cheap, deterministic, and catches most problems — start there.

eval/questions.json — label your own set (grow the M1 five)

json
[
  {"q": "difference between Comparable and Comparator", "expect_sheet": "Sorting"},
  {"q": "how does volatile differ from synchronized", "expect_sheet": "Thread Synchronization"},
  {"q": "what is Reciprocal Rank Fusion", "expect_sheet": "Hybrid Search"},
  {"q": "how does useEffect cleanup work", "expect_sheet": "React Foundations"},
  {"q": "what is the equals hashCode contract", "expect_sheet": "Object Class"}
]

scripts/eval_retrieval.py — recall@k and MRR

python
import json
from app.config import get_settings
from app.models.factory import get_embedder
from app.retrieval.faiss_store import FaissStore
from app.retrieval.retriever import Retriever
from app.retrieval.reranker import build_reranker

s = get_settings()
store = FaissStore.load(s.index_dir, expected_embedder=s.embedder)
retr, rr = Retriever(get_embedder(), store, s), build_reranker(s)
data = json.load(open("eval/questions.json"))

hits_k, rr_sum = 0, 0.0
for row in data:
    ranked = rr.rerank(row["q"], retr.retrieve(row["q"]))
    sheets = [c.sheet for c, _ in ranked]
    want = row["expect_sheet"].lower()
    pos = next((i for i, sh in enumerate(sheets) if want in sh.lower()), None)
    if pos is not None:
        hits_k += 1
        rr_sum += 1.0 / (pos + 1)          # reciprocal rank
    print(f"{'OK ' if pos is not None else 'MISS'} rank={pos}  {row['q'][:45]}")

n = len(data)
print(f"\nrecall@{s.rerank_top_k}: {hits_k}/{n} = {hits_k/n:.0%}   MRR: {rr_sum/n:.3f}")

Recall@k = did the right sheet make the final k? MRR rewards it ranking near the top. Now you can tune with numbers: flip breadcrumb on/off, change chunk_max_chars, swap the reranker to noop — and see recall/MRR move. That's the loop.

Optional: RAGAS for generation quality

bash
uv add ragas   # optional, generation-quality eval
python
# RAGAS scores the ANSWER, not just retrieval. It calls an LLM judge (set its
# key), so keep it off your fast path. The two metrics below need NO ground truth.
from datasets import Dataset
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy

def ragas_row(rag, question):
    out = rag.answer(question)
    # contexts MUST be the real chunk text the model saw (out["contexts"]), NOT a
    # "sheet > section" label — faithfulness scores each claim against these strings.
    return {"question": question, "answer": out["answer"],
            "contexts": out["contexts"]}

# ds = Dataset.from_list([ragas_row(rag, r["q"]) for r in data])
# print(evaluate(ds, metrics=[faithfulness, answer_relevancy]))
M10

Containerize

Why runs the same on any machine  ·  Write two Dockerfiles + compose  ·  New multi-stage builds, port mapping, volumes

Package it so “works on my machine” becomes “works anywhere.” A multi-stage build keeps the final image small (build deps stay in the builder stage), and a volume persists your FAISS index across restarts. Backend on 8000, frontend on 3000.

backend/Dockerfile — multi-stage, uv-managed

docker
# ---- builder: resolve + install the locked env ----
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
COPY app ./app
COPY scripts ./scripts
RUN uv sync --frozen --no-dev

# ---- runtime: copy only the built venv + app ----
FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

--frozen installs exactly what's pinned in uv.lock (no re-resolving inside the image); --no-install-project in the first pass caches dependencies in their own layer, then the second uv sync adds the project itself once the source is copied in — so an app-code change doesn't invalidate the slow dependency-install layer. Swap the python:3.13-slim tag if your backend/.python-version pins something else.

frontend/Dockerfile

docker
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app ./
EXPOSE 3000
CMD ["npm", "start"]

docker-compose.yml (project root)

yaml
services:
  backend:
    build: ./backend
    ports: ["8000:8000"]
    env_file: ./backend/.env
    volumes:
      - ./backend/data:/app/data      # persist the FAISS index
      - ./notes:/app/notes:ro         # notes available for /reindex
  frontend:
    build: ./frontend
    ports: ["3000:3000"]
    environment:
      - NEXT_PUBLIC_API_URL=http://localhost:8000
    depends_on: [backend]
M11

Capstone: GitHub Action to reindex on push

Why your notes already live on GitHub — index them automatically  ·  Write one workflow file  ·  New CI, workflow triggers, artifacts

The satisfying finish: when you push a new or edited sheet to your notes repo, a GitHub Action rebuilds the FAISS index for you. Here it builds and uploads the index as a workflow artifact you can download; the same job could instead push the index to a release, a data branch, or your server.

.github/workflows/reindex.yml

yaml
name: Reindex notes

on:
  push:
    paths: ["**/*.html"]          # only when a sheet changes
  workflow_dispatch: {}           # and let me trigger it by hand

jobs:
  build-index:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install uv
        uses: astral-sh/setup-uv@v3
        with:
          enable-cache: true

      - name: Install backend deps
        working-directory: backend
        run: uv sync --frozen

      - name: Build FAISS index from the notes
        working-directory: backend
        env:
          EMBEDDER: local           # no API key needed in CI
          LLM: fake
        run: uv run python scripts/build_index.py ../   # repo root holds the .html sheets

      - name: Upload the index
        uses: actions/upload-artifact@v4
        with:
          name: notes-index
          path: backend/data/index/

Using EMBEDDER=local and LLM=fake means the Action needs no secrets — it only builds vectors, no answering. If you deploy the app somewhere, add a step to publish the index where the running backend can fetch it (or call its /reindex with a token).

A

Appendix A: optional PDF ingestion

Why the same tool over PDFs — and proof the seam works  ·  Write one adapter that reuses everything  ·  New PyMuPDF, OCR fallback

The reference project's biggest file is a four-engine PDF processor — because raw PDFs are messy. You mostly skip that pain (your HTML is clean), but adding a slim PDF path is a great demonstration: it produces the same Chunk objects and flows through the same chunker, index, retrieval, and generation. Nothing downstream changes. That's the payoff of the interfaces you built.

Dependencies (add)

bash
uv add pymupdf
# optional OCR fallback for scanned pages:
# uv add pytesseract   (also needs the system 'tesseract' binary)

app/ingestion/pdf_parser.py

python
from __future__ import annotations
from pathlib import Path
import fitz   # PyMuPDF
from app.ingestion.html_parser import RawSection

def parse_pdf(path, *, subject_hint=""):
    doc = fitz.open(path)
    sections = []
    for i, page in enumerate(doc, 1):
        text = page.get_text().strip()
        if not text:
            text = _ocr(page)                       # scanned page fallback
        if text:
            sections.append(RawSection(section=f"p.{i}", text=" ".join(text.split()), tags=[]))
    sheet = Path(path).stem
    return subject_hint, sheet, sections

def _ocr(page) -> str:
    try:
        import pytesseract
        from PIL import Image
        pix = page.get_pixmap(dpi=200)
        img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
        return pytesseract.image_to_string(img)
    except Exception:
        return ""

It returns the same RawSection list the HTML parser produces, so chunk_sections consumes it unchanged. Extend ingest_directory to route *.pdf to parse_pdf and *.html to parse_sheet — both feed one chunk list.

B

Appendix B: hybrid search (BM25 + FAISS + RRF)

Why you literally have a sheet on this — implement it  ·  Write a keyword index + a fusion function  ·  New lexical vs dense, Reciprocal Rank Fusion

Dense vectors (FAISS) capture meaning but can miss exact terms — a query for ReentrantLock should hit the chunk that literally says ReentrantLock. BM25 is classic keyword scoring that nails that. Hybrid search runs both and fuses the rankings with Reciprocal Rank Fusion — the exact recipe on your own AI & Retrieval sheet.

Dependencies (add)

bash
uv add rank-bm25

app/retrieval/hybrid.py

python
from rank_bm25 import BM25Okapi
from app.ingestion.types import Chunk


class Bm25Index:
    def __init__(self, chunks: list[Chunk]):
        self.chunks = chunks
        self.bm25 = BM25Okapi([c.text.lower().split() for c in chunks])

    def search(self, query: str, k: int):
        scores = self.bm25.get_scores(query.lower().split())
        top = sorted(range(len(scores)), key=lambda i: -scores[i])[:k]
        return [(self.chunks[i], float(scores[i])) for i in top]


def rrf(rankings: list[list[tuple[Chunk, float]]], k: int, c: int = 60):
    """Reciprocal Rank Fusion: each list votes 1/(c + rank). Robust because it
    uses positions, not raw scores (which aren't comparable across methods)."""
    fused: dict[str, list] = {}
    for ranking in rankings:
        for rank, (chunk, _score) in enumerate(ranking):
            entry = fused.setdefault(chunk.chunk_id, [chunk, 0.0])
            entry[1] += 1.0 / (c + rank)
    merged = sorted(fused.values(), key=lambda x: -x[1])
    return [(chunk, score) for chunk, score in merged[:k]]

RRF fuses on rank position, not raw score — smart, because a FAISS cosine of 0.7 and a BM25 score of 12.3 aren't on the same scale, but “ranked 2nd in each list” is. To use it: build a Bm25Index alongside the FAISS store, get both rankings for a query, rrf([dense, lexical], k=retrieval_top_k), then feed the fused list into the same cross-encoder reranker from M4.