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.
Foundations: skeleton, config, pluggable models
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
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__.pyuv 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)
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 pytestuv 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
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.
# .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
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
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 outnormalize_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
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
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.
Structure-aware ingestion
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)
uv add beautifulsoup4 lxmlapp/ingestion/types.py — the Chunk
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
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, sectionsapp/ingestion/chunker.py — sections → right-sized chunks
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 chunksThree 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
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_chunksThe index abstraction
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
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 sThe 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.
Embeddings and FAISS
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)
uv add faiss-cpuapp/retrieval/faiss_store.py — same interface as NumpyStore
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 sIndexFlatIP = 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
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")Retrieval and reranking
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)
reranker: Literal["cross-encoder", "noop"] = "cross-encoder" # add to Settingsapp/retrieval/retriever.py
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
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).
Grounded generation
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
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
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 failureNote 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
# .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_hereTopics and self-quiz suggestions
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
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.
FastAPI layer + retry
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)
uv add fastapi uvicorn tenacityapp/utils/retry.py — defined ONCE, imported where needed
"""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
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: strapp/main.py — app factory + wiring + health
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)}The frontend (near-complete)
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
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
"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} › {s.sheet} › {s.section} ({s.score})</li>
))}
</ul>
</div>
)}
</article>
)}
</section>
</main>
);
}# frontend/.env.local
NEXT_PUBLIC_API_URL=http://localhost:8000Evaluation
“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)
[
{"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
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
uv add ragas # optional, generation-quality eval# 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]))Containerize
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
# ---- 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
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)
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]Capstone: GitHub Action to reindex on push
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
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).
Appendix A: optional PDF ingestion
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)
uv add pymupdf
# optional OCR fallback for scanned pages:
# uv add pytesseract (also needs the system 'tesseract' binary)app/ingestion/pdf_parser.py
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.
Appendix B: hybrid search (BM25 + FAISS + RRF)
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)
uv add rank-bm25app/retrieval/hybrid.py
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.