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
| v1 | v2 | |
|---|---|---|
| Scope | One endpoint: send the whole PDF's text to Gemini and return its summary | Library, summaries, cited Q&A, key facts, comparison, search |
| Trust | Free text, no sources | Every claim carries quotes; each quote is checked against the page and shown verified, approximate or not found |
| Long documents | Whole text in one prompt, however long | Single pass up to ~180k characters, map-reduce above; questions use retrieval |
| Retrieval | None | Local embeddings (bge-small) + BM25, fused with reciprocal rank fusion; measured in an eval |
| Files | PDF by filename; crashed on a missing folder | PDF, DOCX, TXT, MD detected from bytes; encrypted, scanned and oversized files rejected with clear messages |
| Security | Uploaded filename used as a file path; a script printed the API key | Server-generated file names, per-workspace bearer tokens, rate limiting, no internals in errors |
| Reliability | Crashed at start-up without a key; every error a 500 | Runs without a key (AI features say so); typed errors with stable codes; JSON logs with request ids |
| Quality | No tests; requirements file unreadable by pip | 42 backend tests, 9 frontend tests, CI, retrieval eval, Docker |
| Interface | Swagger UI only | A 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)
Answering (each request)
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
- Drop a file, or try one of four sample documents. Upload errors explain what to do (“this looks like a scanned PDF”).
- The document opens as real pages beside the analysis; the outline jumps between sections.
- The summary arrives with a grounding note: “All 23 claims are backed by quotes found in the document.”
- Click any page chip: the viewer scrolls to the page and highlights the exact quote.
- 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:
| Mode | hit@1 | hit@3 | hit@6 | MRR |
|---|---|---|---|---|
| Dense | 0.83 | 0.957 | 0.979 | 0.892 |
| BM25 | 0.681 | 0.872 | 0.936 | 0.787 |
| Hybrid (RRF) | 0.787 | 0.915 | 1.0 | 0.866 |
| Mode | hit@1 | hit@3 | hit@6 | MRR |
|---|---|---|---|---|
| Dense | 0.809 | 0.936 | 0.979 | 0.871 |
| BM25 | 0.596 | 0.745 | 0.872 | 0.695 |
| Hybrid (RRF) | 0.745 | 0.894 | 0.957 | 0.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:
| Metric | Value |
|---|---|
| Answers containing every expected fact | 0.979 (46/47) |
| Unanswerable questions correctly refused | 0.857 (6/7) |
| Answerable questions wrongly refused | 0.0 |
| Claims whose quote verified in the source | 1.0 |
| Claims labelled “stated” that didn’t verify | 0 |
| Latency p50 / p95 | 1.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.