Cliffton
Back to projects

markpoint

SoloAug 2026 – presentOpen demo

30,176
mark points parsed from 315 papers
1,109 + 347
backend and front-end tests, all passing
23 / 28
hand-labelled marking judgements agreed
¼ cost
after benchmarking five models and moving to Gemini 3.7 Flash

The problem

A student revising for A-Level Biology wants two things a search box cannot give them: which past papers a question has appeared in before, and why their written answer lost marks. Generic chatbots answer both badly, inventing paper references and marking generously.

How a question finds its papers

Stage A · whole corpusStage B · shortlist only10,892 questions315 past papers · 30,176 mark pointsPostgres ts_rank_cdlexical recallpgvector HNSWdense recallreciprocal-rank fusioncombined by rank, never by scoreIDF-weighted Jaccardreranks the top ~50citations assembled in SQLa model never emits a paper number

Approach

  • Parsed 315 CIE Biology past papers into 10,892 questions and 30,176 atomised mark points, keeping accept and reject variants separate, with per-family parsers keyed on subject, paper and syllabus era. Each parse carries a confidence score, and low-confidence output routes to a human review screen rather than into the corpus.
  • Built retrieval in two stages rather than one blended score. Stage A recalls over the whole corpus by reciprocal-rank fusion of Postgres full-text ranking with dense pgvector search, fused by rank because the two scores live on unrelated scales. Stage B reranks the top 50 by IDF-weighted Jaccard over mark-point sets, which is the strongest signal available but cannot be indexed for approximate search, so it can only ever be a reranker.
  • Assembled every citation from SQL joins. A model may rank and explain, but it may never emit a paper identifier, which removes hallucinated citations by construction instead of mitigating them by prompt.
  • Marked one mark point at a time, each verdict carrying the ID of the point it judged, and ran whole-paper marking concurrently rather than batching points into a single prompt. Batching would break the per-point rule, and models are trained to be generous, which is the exact failure the product exists to correct.

Killing a feature that looked like it worked

A lexical matcher linking multiple-choice questions to the structured questions that explain them produced 128 links. Read one at a time they looked fine. Read as a distribution they pointed at just 17 target questions, one of them 51 times, because Postgres ts_rank_cd carries no document-length term and the longest candidate wins on shared vocabulary regardless of subject. Score did not correlate with quality, so no threshold could fix it. The feature was disabled rather than shipped, with the measurement written into the code.

Stack

  • FastAPI
  • PostgreSQL 17
  • pgvector
  • React 19
  • Docker