Skip to content

Case study · AI engineering and product design

Margin: a document assistant you can check

Margin started as a FastAPI endpoint that sent a PDF's text to Gemini and returned a summary. Version 2 rebuilds it around one idea: an AI summary is only useful if you can check it quickly. Every claim comes with a quote, every quote is verified against the page, and one click shows it highlighted on the real page.

Role
Product, design and engineering
Stack
Python, FastAPI, Gemini, Next.js
Retrieval
bge-small + BM25 + RRF
Status
Working prototype; retrieval and answer evals run

01

The problem and who it's for

Students, researchers, professionals and small teams spend hours reading contracts, papers, policies and minutes to find the few things that matter: what they owe, what was decided, what the evidence says. General chatbots can summarise, but their summaries are hard to trust. Checking a claim means hunting for it in the original, which can take as long as reading the document.

Hypotheses, not findings. These users and pains come from desk research and my own use, not from interviews. The study plan in section 10 is how I'd test them.

02

Vision and solution

Read less, understand more, and check everything. Margin gives you a summary, answers and key facts, and keeps the source one click away. The design principle is that the evidence is part of the answer, not a footnote.

  • Executive summary, takeaways and a section-by-section summary, with a free outline from detected headings.
  • Questions answered only from the document, with a clear “the document doesn't say” when it doesn't.
  • Key facts in six categories: people and organisations, dates, figures, decisions, risks, action items.
  • Comparison of two documents: in common, different, and only in one.
  • A private library with search across every document.

03

What v1 did, and what changed

v1v2
ScopeOne endpoint: send the whole PDF's text to Gemini and return its summaryLibrary, summaries, cited Q&A, key facts, comparison, search
TrustFree text, no sourcesEvery claim carries quotes; each quote is checked against the page and shown verified, approximate or not found
Long documentsWhole text in one prompt, however longSingle pass up to ~180k characters, map-reduce above; questions use retrieval
RetrievalNoneLocal embeddings (bge-small) + BM25, fused with reciprocal rank fusion; measured in an eval
FilesPDF by filename; crashed on a missing folderPDF, DOCX, TXT, MD detected from bytes; encrypted, scanned and oversized files rejected with clear messages
SecurityUploaded filename used as a file path; a script printed the API keyServer-generated file names, per-workspace bearer tokens, rate limiting, no internals in errors
ReliabilityCrashed at start-up without a key; every error a 500Runs without a key (AI features say so); typed errors with stable codes; JSON logs with request ids
QualityNo tests; requirements file unreadable by pip42 backend tests, 9 frontend tests, CI, retrieval eval, Docker
InterfaceSwagger UI onlyA reading workspace: real pages with the cited quote highlighted, on desktop and phone

The v1 issues were found by installing and running the original repository, not just reading it.

04

Architecture and technical decisions

Indexing (once per upload)

Upload · type detected from bytes · limits
PyMuPDF / python-docx · per-page text · column-aware · scanned-page detection
Page-aware chunks with headings
bge-small embeddings (local) → SQLite

Answering (each request)

Question · summary · extraction · comparison
Dense + BM25 → reciprocal rank fusion (or whole document when it fits)
Gemini · structured output · labelled passages
Quote verification → pages → cache → UI highlight
FastAPI backend (Docker), Next.js frontend. Workspace token on every request; per-workspace hourly AI limit.

Check every quote, don't just ask for citations

Models cite the wrong passage or paraphrase while claiming to quote. After every call the server normalises each quote and looks for it in the cited passage, then in the rest of the document. The UI shows the result; a claim the model marked as stated but whose quote isn't found is counted and flagged.

Separate what the document says from what the AI thinks

Every claim is labelled stated or interpretation. Summaries are allowed to synthesise, but the reader always knows which sentences they could find on the page.

Embeddings run locally

bge-small through ONNX: about 130 MB of RAM, no key, no per-call cost, no document text sent anywhere just to index it, and the retrieval eval runs in CI. The trade-off is weaker multilingual and long-context recall than large hosted embedding models.

No vector database

A question searches only the selected document's chunks: hundreds of vectors, a few milliseconds with NumPy. A vector DB would add a service to run without a measurable gain at this scale; the retrieval module is an interface so that can change.

Hybrid retrieval, kept honestly

On this small eval set dense search alone ranked as well as or better than hybrid (see below). Hybrid is kept because BM25 catches identifiers and codes that embeddings blur, which this set covers thinly. The next step is a larger, identifier-heavy set to settle it, not tuning weights to 47 questions.

Whole document when it fits, retrieval when it doesn't

Summaries and extraction read the whole document in one call up to ~180k characters (cheaper and more complete than retrieval for those tasks), and map-reduce beyond that, keeping only points whose quotes were verified. Questions always use retrieval, so they stay fast and cite precisely.

Cache by content, prompt version and model

Re-opening a summary costs nothing. Bumping the prompt version invalidates old results automatically. Only real model calls count towards the hourly limit.

Accounts are a workspace token

A visitor gets a random token kept in the browser; the server stores only its hash and scopes every query to it. No sign-up for a demo, but no recovery either. Real accounts would map users to workspaces.

05

User journey and interface decisions

  1. Drop a file, or try one of four sample documents. Upload errors explain what to do (“this looks like a scanned PDF”).
  2. The document opens as real pages beside the analysis; the outline jumps between sections.
  3. The summary arrives with a grounding note: “All 23 claims are backed by quotes found in the document.”
  4. Click any page chip: the viewer scrolls to the page and highlights the exact quote.
  5. Ask follow-up questions; open “How this was found” to see which passages were searched and how they ranked.
  • Evidence colours mean one thing. Green: quote found word for word. Amber: close match. Red: not found. Interpretations carry a label instead of a colour.
  • Reading type for reading. Source Serif for document text and AI prose, Plex Sans for interface, Plex Mono for metadata, one highlighter colour for evidence.
  • Every state designed: empty library, indexing, loading, AI off, offline demo, server waking up, not found, partial answers.
  • Accessible by default: skip link, real tabs with ARIA, keyboard-operable upload, visible focus, reduced-motion support, light and dark themes, a phone layout that switches between the document and its insights.
  • Cost is visible: each result shows the model, tokens, time and whether it came from cache.

06

RAG and evaluation

The retrieval eval needs no API key and runs in CI. Four synthetic documents (15 pages, 47 chunks) and 47 answerable questions, each labelled with the page that holds the answer. A hit at k means a top-k passage covers that page. Run on 2026-10-21 with bge-small-en-v1.5:

Scoped to the question's document (how Ask works)
Modehit@1hit@3hit@6MRR
Dense0.830.9570.9790.892
BM250.6810.8720.9360.787
Hybrid (RRF)0.7870.9151.00.866
Across the whole library (four documents compete)
Modehit@1hit@3hit@6MRR
Dense0.8090.9360.9790.871
BM250.5960.7450.8720.695
Hybrid (RRF)0.7450.8940.9570.83

What this says: on this set, dense search alone is as good as or better than hybrid; hybrid only wins at hit@6 within a document (the six passages Ask actually sends). Down-weighting BM25 closed the gap, but one question is 0.021 of any rate here, so I didn't tune to it. These documents and questions were written by the same person who built the pipeline, and a document-scoped search picks from about a dozen passages: treat this as a regression suite and a comparison, not real-world accuracy.

Answer quality is measured by a second script (evals/run_generation.py): accuracy against expected facts, correct refusals on 7 unanswerable questions, false refusals, the share of claims whose quotes verify, stated-but-unverified claims, latency and tokens. It runs the real Ask pipeline end to end. Run on 2026-10-21 with gemini-3.5-flash-lite over 54 questions:

Answer quality (47 answerable, 7 unanswerable questions)
MetricValue
Answers containing every expected fact0.979 (46/47)
Unanswerable questions correctly refused0.857 (6/7)
Answerable questions wrongly refused0.0
Claims whose quote verified in the source1.0
Claims labelled “stated” that didn’t verify0
Latency p50 / p951.9 s / 14.5 s
Tokens per question (mean)1,014

The one wrong answer was a real error: asked how long the first contract period is, it inferred “at least twelve months” from a termination clause and missed the stated 24 months, though it did mark the answer as partial. The failed refusal: asked what Neha Kulkarni reported about sales, it said (correctly, with a verified quote) that she sent apologies, instead of saying the minutes don't cover it. Accuracy is a strict string match against facts the builder wrote, on a small synthetic set, using the free tier, where a few responses took 10–15 seconds. Read it as evidence that grounding holds up, not as a benchmark.

07

Challenges, iterations and trade-offs

  • Two-column pages came out interleaved. Sorted text extraction reads straight across both columns. Margin now looks for a vertical gutter between groups of lines and reads each column top to bottom.
  • Highlighting needs the page, not the text. Quotes are located on the rendered page with PyMuPDF and drawn as an overlay, so the highlight lands on what the reader actually sees. When a quote wraps strangely, the viewer says so instead of highlighting the wrong thing.
  • Titles. Files named “scan_0042.pdf” became their title. Margin now prefers the PDF's metadata, then a meaningful filename, then the first heading.
  • Working without a key. An offline demo mode copies sentences from the document so the whole interface can be built and tested without a key. It's labelled as not AI wherever it appears, and none of the portfolio images use it.

08

Testing and verified results

  • 42 backend tests (pytest) with a fake model: parsing (including scanned, encrypted and two-column PDFs), chunking, quote verification, retrieval, workspace isolation, path traversal, upload limits, caching, map-reduce, rate limits and errors.
  • 9 frontend unit tests (Vitest), typecheck and lint.
  • A scripted browser walkthrough of every screen at 1440 px and 390 px, in light and dark: no console errors, no horizontal scroll, skip link first in the tab order.
  • CI runs lint, format, tests and the retrieval eval on every push.

09

Demo

Try it with one of the sample documents from the library. The live demo runs gemini-3.5-flash-lite on free hosting, so the first visit after a quiet spell takes up to a minute to wake the server.

10

What's next, and what I learned

  • Usability study (planned, not done): 5-6 participants (students, an analyst, a paralegal) each answer 4 questions about a 20-page document, once with Margin and once with a plain PDF reader. Measure time to a correct answer, how often they click a citation, and confidence before and after. Key question: does the grounding note change how much people trust and check?
  • Run the answer eval with Gemini and a second model; add an identifier-heavy retrieval set to settle hybrid vs dense.
  • OCR for scanned PDFs, tables extracted as tables, and accounts with sharing for teams.
  • Lesson: “add citations” isn't a feature until something checks them. The verification step and the UI that shows its result are what make the product trustworthy, more than the model choice.