A retrieval-augmented study assistant for Year III, Part I electrical engineering
(DSP, Digital Control, Switchgear & Protection, Machine Design, Thermodynamics, Power Electronics).
It answers only from approved, shareable class material, cites [file, page] for every claim,
and says I could not find this in the documents. when the material has no evidence.
Rules and targets live in scope.md.
PDFs ─ ocr ─ extract ─ clean ─ chunk ─ embed ─► Chroma (dense) + chunks.jsonl (BM25) [ingestion/]
│
question ─► scope gate ─► subject router ─► hybrid search (BM25 + dense, RRF) ─► evidence gate
│ [app/, retrieval/]
refusal ◄─ citation validator ◄─ LLM (context-only prompt) ◄────────┘ [generation/]
- Scope gate (
app/scope.py): refuses ENEE 301, exam prediction, assignments, real equipment settings. - Hybrid retrieval: BM25 (exact terms like "Nyquist") fused with bge embeddings by reciprocal rank fusion,
optional subject filter, optional cross-encoder reranker (
retrieval/rerank.py). - Evidence gate: refuses before calling the model when neither retriever finds a close match
(
min_dense_sim,min_bm25inapp/config.py). - Citation validator: every claim needs a
[file, page]that exists in the retrieved context. One repair attempt, then the answer is replaced by the refusal sentence. Nothing uncited is shown. - Shareable-only: chunks carry
shareable; anything notyesis dropped before the API call. Indexes built before this flag exist are rejected, rebuild them (below).
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # add ANTHROPIC_API_KEYPut PDFs in corpus/<subject>/ with corpus/manifest.csv, then:
python -m ingestion.validate_corpus
python -m ingestion.build_class # class index: shareable=yes only
python -m app.cli "state the ROC of a right-sided sequence" --sources
python -m app.cli # interactiveText extraction misses what is drawn: circuit diagrams, waveforms, root-locus plots, tables that come out as scrambled text, handwriting. ColPali embeds each PDF page as an image (~1000 patch vectors) and scores a question against it with late-interaction MaxSim, so those pages become searchable. It is off by default; nothing above changes until you turn it on.
pip install -r requirements-visual.txt # adds colpali-engine (+ torch, transformers>=5.3)
python -m ingestion.build_visual --class # embed shareable=yes pages (GPU strongly advised)
COPILOT_VISUAL=1 python -m app.cli "sketch the root locus of ..." --sources # or: --visualHow it fits the pipeline:
page images ─ ColPali ─► vectordb_visual_class/ (emb.npy + pages.jsonl) [ingestion/build_visual.py]
question ─► ... BM25 + dense + ColPali pages ─► RRF ─► shareable filter ─► evidence gate
│
hit on a page that also has a text chunk → boosts that chunk (no duplicate)
hit on an image-only page → its own hit, cited as [file, page]
▼
pages that pass `min_visual` are re-rendered from the PDF and sent to Claude with the text
- Same safety rules. Page images obey
shareable: non-shareable pages are filtered before anything is rendered or sent. Citations still need a[file, page]that is in the context, and a page whose image cannot be rendered and has no text is dropped (nothing to cite, so no evidence). - Calibrate
COPILOT_MIN_VISUAL. The score is the mean MaxSim per query vector.0.50is a starting guess, not a measured value. Too low and weak page matches pass the evidence gate (and get sent as images); too high and ColPali never counts as evidence. Runpython -m eval.run_evaland pick a value between your answerable and unanswerable questions, as forCOPILOT_MIN_DENSE.python -m eval.run_retrieval_eval(below) does this for you and prints a suggested value. - Cost. Embedding is a one-off per document (shards in
<index>/.shards, resumable, skipped on re-run). ColPali-v1.3 is a ~3B model: use a GPU.COPILOT_VISUAL_MODELalso accepts the ColQwen2, ColQwen2.5 and ColIdefics3 (colSmol, smaller, for laptops) families; onlycolpali-v1.3is the default and the others are mapped but untested. Changing the model requires a rebuild (the app refuses to mix them). At answer time each matched page is one extra image on the API call (COPILOT_MAX_IMAGES, default 3). - Eval and fine-tuning: see the next two sections.
- Scope. scope.md lists "content that needs images, handwriting or scanned pages" as
out of scope for Part I. This feature is what would lift that, but scope changes need a version bump and an
eval re-run, so
scope.mdis left as is; update it when you adopt visual retrieval. - Known limits: the optional cross-encoder reranker reads text only (leave it off with visual on), and the eval's faithfulness judge sees retrieved text, not images, so it can under-score answers that rely on a figure.
eval.run_eval grades whole answers. eval.run_retrieval_eval isolates the retrievers, so it is
cheap, deterministic, and the right tool for comparing BM25, dense, hybrid and ColPali, for
calibrating COPILOT_MIN_VISUAL, and for judging a fine-tuned model. It reads the same frozen
question file (eval/source/questions.jsonl).
python -m eval.run_retrieval_eval # bm25, dense, hybrid, visual, hybrid+visual
python -m eval.run_retrieval_eval --modes hybrid,hybrid+visual --out eval/retrieval_report.jsonMetrics are page-level (chunks are collapsed to unique [file, page] in rank order, so text
and visual retrievers are comparable): recall@k, p@k, hit@k, mrr, ndcg@k, overall and per
subject. chunk_p@5 is also reported with the same definition as R3, to relate it to run_eval.
should_refuse questions feed the calibration: it prints the smallest COPILOT_MIN_VISUAL that
keeps at least 90% of them below the gate (R2), and how many answerable questions still clear it.
ColPali is already strong. Fine-tuning only pays off if the base model misses your material (EE jargon, your notes' layout, handwriting). Treat it as an experiment the frozen eval decides.
pip install -r requirements-visual.txt # training needs nothing beyond this (no `datasets`)
python -m ingestion.build_visual --class # page index; also the source of pages for step 1
python -m eval.run_retrieval_eval --out eval/retrieval_report.json # baseline, BEFORE training
python -m training.make_pairs --per-page 3 --out train/pairs.jsonl --dry-run # see the plan/cost
python -m training.make_pairs --per-page 3 --out train/pairs.jsonl # Claude writes questions
python -m training.train_colpali --pairs train/pairs.jsonl --dry-run # validate, show plan
python -m training.train_colpali --pairs train/pairs.jsonl --out adapters/ee-colpali --epochs 2
python -m ingestion.build_visual --class --adapter adapters/ee-colpali --out vectordb_visual_ft
python -m eval.run_retrieval_eval --adapter adapters/ee-colpali --visual-dir vectordb_visual_ft \
--out eval/retrieval_report_ft.json
python -m eval.run_retrieval_eval --compare eval/retrieval_report.json eval/retrieval_report_ft.json --strictAdopt it by setting COPILOT_VISUAL_ADAPTER=adapters/ee-colpali and COPILOT_VISUAL_DIR=vectordb_visual_ft.
The index records the model and adapter that built it and the app refuses a mismatch, because
base and fine-tuned embeddings are not comparable.
What the training does: LoRA (r=32) on the language model's attention/MLP layers and the output
projection, vision tower frozen; in-batch contrastive loss (the library's ColbertLoss), where each
query's page is the positive and every other page in the batch a negative. Batches never contain
the same page twice. Each epoch it measures recall on held-out pages and keeps the best epoch.
Data rules, enforced in code (training/data.py, with tests):
- Shareable only.
make_pairssends nothing butshareable=yespages to the API (no override), and training drops pairs for non-shareable documents unless you pass--include-non-shareable(the weights would then encode that content, so don't share the adapter). - No eval leakage. Training queries that equal or nearly equal an eval question are dropped, and
by default so is every pair on an eval question's gold page (a model that memorised those pages
would flatter the eval). Training refuses to start without
eval/source/questions.jsonlunless you pass--no-eval-guard. When you have decided to ship, you may retrain once with--allow-eval-pagesfor production, but then that adapter can no longer be evaluated cleanly. - Held-out validation is split by page, never by query.
Things to know before you spend GPU time:
- Batch size is your number of negatives.
--grad-accumdoes not add any. Use the largest batch that fits; 8 is a floor, not a recommendation. The default--lr 2e-5is a starting point. - Synthetic questions are only as good as their prompts. Read a sample of
train/pairs.jsonlbefore training. Generated questions are phrased like the page; real student questions are not. - Validation gain is not a verdict. A small held-out set from the same documents is optimistic.
run_retrieval_eval --compare --strict(fails on a >0.02 drop in recall@5 or MRR forhybrid+visual, tunable with--tolerance) against the frozen questions is the real test. - The trainer warns if the best epoch does not beat the base model on validation.
Write questions to eval/source/questions.jsonl (git-ignored, format in eval/questions.example.jsonl;
list all relevant pages in gold so P@5 is meaningful), then:
python -m eval.run_eval --strict # R1 citations, R2 refusal, R3 P@5, R4 faithfulnessThe report (eval/report.json) includes the top similarity per question so you can calibrate
COPILOT_MIN_DENSE / COPILOT_MIN_BM25 (answerable questions above, refusals below).
python -m pytest -q # offline: fake embeddings and fake LLM, no downloads, no API keytests/test_training_loop.py trains a tiny stand-in model on CPU and needs torch + peft +
colpali-engine; it is skipped automatically where they are not installed (the light CI job).