Skip to content
shipped

TruthBase: verifiable RAG with citation-to-highlight receipts

Verifiable RAG over PDFs on Nuxt 4: answers cite their sources, and a click on a citation scrolls the PDF to the highlighted passage. Live on Vercel Hobby since 2026-10-21, on free tiers at 0 EUR/month.

stack:
Nuxt 4 + Nitro · Supabase Postgres (pgvector + tsvector) · Groq gpt-oss-120b / gpt-oss-20b · Vercel AI SDK v7 · gemini-embedding-001 @ 768 dims · Gemini Flash-Lite (fallback) · Nuxt UI v4 · unpdf + PDF.js · promptfoo + Langfuse · Playwright · Vercel Hobby
when:
2026
published:
TruthBase system architecture The deployed architecture, in two lanes. Ingest: the operator ingests a PDF from the command line; unpdf and PDF.js extract the text with per-glyph positions, sentences are packed into page-bounded chunks of up to 800 characters, Gemini embeds each chunk as a 768-dimension L2-normalized vector, and Supabase Postgres stores it with pgvector next to a tsvector full-text index. Query: a question, plus up to three rewrites when it is ambiguous, fans out to an exact cosine search and a tsvector full-text search; the two result lists are fused by reciprocal rank fusion at k = 60, reranked by gpt-oss-20b, and answered by Groq gpt-oss-120b with Gemini Flash-Lite as fallback. The grounded answer streams back with citations, and clicking a citation opens the exact PDF page with the source passage highlighted by a position overlay. INGEST PDF via CLI unpdf parse + glyph positions sentence chunks 800 chars, per page Gemini embeddings 768d, L2-normalized Supabase Postgres pgvector + tsvector QUERY question + 0–3 rewrites dense · exact cosine lexical · tsvector FTS RRF fuse k = 60 LLM rerank gpt-oss-20b Groq gpt-oss-120b fallback: Gemini Flash-Lite grounded answer streamed, with citations citation click → exact PDF page passage highlight via position overlay
TruthBase system architecture · scroll sideways to see all of it

The problem

Every RAG demo can produce an answer with a citation. Almost none can prove the citation. The usual pattern (“[1] report.pdf, p. 12”) asks the reader to open the document and go hunting, so in practice nobody verifies anything and the citation is decoration. My thesis for TruthBase is that trust needs receipts: clicking a citation should scroll the PDF viewer to the exact page and paint a highlight over the exact passage that supports the sentence you just read.

That one interaction is the whole project. It forces most of the interesting engineering: positions have to be captured at ingestion time (retrofitting them means re-parsing the entire corpus), stored in a scale-independent form, and reconstructed accurately across zoom levels. The retrieval pipeline behind it also has to be good enough that the highlighted passage genuinely supports the answer.

Architecture

TruthBase runs live at truthbase.elbaymalik.com: one Nuxt 4 app whose secret-bearing and compute-heavy work (embedding, retrieval, generation) runs in Nitro server routes, deployed as a single Node 24 Vercel Function in dub1, next to the Supabase database in AWS eu-west-1. Nothing secret reaches the client. The demo corpus is four public PDFs (NIST CSF 2.0, NIST AI RMF 1.0, and the RAPTOR and Self-RAG papers), ingested from an operator CLI rather than the browser.

Ingestion extracts each page’s text items with unpdf and PDF.js, then replays the page’s operator list with PDF.js’s own text-advance math to get every glyph’s x-extent. Chunking is page-bounded sentence packing: up to 800 characters with 15% overlap, the smallest size that keeps all 60 golden evidence spans inside one chunk, because the highlight a reader sees is the chunk. Every chunk stores one rect per rendered line in PDF space (chunks.highlight jsonb) next to a generated tsvector and a 768-dimension gemini-embedding-001 vector, L2-normalized by hand because Google only normalizes the full 3072-dim output. The viewer draws the stored rects as they are, converted with PDF.js’s viewport.convertToViewportPoint on every zoom. It never re-derives them from the browser text layer, which lays text out in a substitute font; if the rendered text no longer matches the chunk, it finds the passage by text and shows an “approximate” badge.

A question first passes consume_quotas, one Postgres call that checks the client’s per-minute and daily counters and the demo’s global daily budget, all or nothing, and answers 429 before any model call. The question is embedded while a self-gated rewriter on Groq’s gpt-oss-20b decides whether it is ambiguous enough to split into up to three queries. Retrieval is hybrid and stays inside Postgres: hybrid_search, one SQL RPC, fuses a full-text arm and an exact cosine arm with weighted Reciprocal Rank Fusion. A listwise LLM reranker on gpt-oss-20b orders the fused top 15; its first four picks lead and the fused order fills the rest, up to eight sources. Rewriting and reranking are optional at run time: an error or a timeout falls back to the fused order. The server streams the sources with their stored rects first, then the answer from Groq’s gpt-oss-120b (Gemini Flash-Lite if Groq fails) through the Vercel AI SDK v7, which cites [n] markers or replies NO_ANSWER. Every chat request is traced to Langfuse. The decisions behind it are logged below, each with the alternative it beat.

Evaluation design

The ground truth is a golden set of 50 questions over the four PDFs: 45 answerable, each with its exact supporting quote and page number, and 5 unanswerable (TruthBase docs/golden/golden.json). At n = 45, one question moves a metric by 2.2 points. The same records drive the Playwright highlight tests, because “did the eval cite the right chunk?” and “did the highlight land on the right rect?” are the same question asked at two layers.

Evaluation runs in two layers. A deterministic retrieval gate is set up in CI for every pull request (its last CI run was on 2026-10-21): the real hybrid_search on a committed corpus fixture, failing on any drop from TruthBase docs/evals/retrieval-baseline.json. The model-dependent evals spend free quota, so they run on demand: one script per retrieval technique, and a promptfoo LLM eval with a separate judge model, scheduled weekly.

The plan named its own falsifier: if hybrid search failed to beat dense-only on golden recall, chunking would be revisited before any further retrieval tricks. At Phase 4 the two tied at 73% recall@8 (TruthBase docs/evals/phase-4-baseline.json). An audit found that the keyword arm ANDed every term and scored 27% on its own. Switching it to OR over the question’s own lexemes, with weights and k chosen from a 72-configuration grid under cross-validation, took hybrid recall@8 to 87% against 76% for dense-only, on 45 answerable questions over 564 chunks. At the shipped 15 candidates, the reranker lifted MRR from 0.515 to 0.735 and nDCG@5 from 0.638 to 0.811, and recall@5 from 76% to 84% (TruthBase docs/evals/phase-7-groq-20b-15.json). Query rewriting with the shipped gpt-oss-20b raised recall@5 on the four ambiguous questions from 75% to 100% and overall recall@5 from 76% to 80%, and left recall@8 at 87% (TruthBase docs/evals/phase-8-groq-20b-gated.json).

The latest answer eval runs the shipped pipeline end to end on all 50 questions: rewriting and reranking on gpt-oss-20b, answers on gpt-oss-120b (TruthBase docs/evals/phase-8-groq.json). It refused all 5 unanswerable questions, answered 93% (42 of 45) of the answerable ones and none without a citation. At least one citation was correct for 86% of answered questions, 80% of answerable ones, against 69% in the Phase 5 run. Citation precision was 56%: a lower bound, since a citation to a correct passage that is not the golden evidence counts as wrong. False refusals fell from 7 in the Phase 5 run to 3, still above the target of at most 1: two questions whose evidence retrieval misses, and one table that extraction flattened into running text. The first full promptfoo run then judged faithfulness at 95.2% and citation precision at 92.5% (TruthBase docs/evals/phase-9-llm.json, 2026-10-23), using a qwen model as judge against targets of 0.90. A hand check of 5 answers found the judge lenient in how it splits answers into claims and assigns their citations. Corrected for those answers, the pooled figures are about 92–94% and 88–90%. The run still fails its gate on 4 false refusals, so the repo records no LLM baseline yet.

The highlight itself is checked at three levels. pnpm check:viewer drives the real viewer in headless Chromium over all 60 golden spans at zoom 50, 100 and 200%: since 2026-10-25 all 180 checks pass against the hosted corpus, with a placement error of at most 0.03 px. Vitest pins span edges within ±0.05 pt of MuPDF, an independent PDF engine. Playwright repeats the geometry and ink checks in CI, last run on 2026-10-21. Before that, two cases caused four misses: a big Σ operator that hung below the em box the vertical model covers, and a vector-drawn frame that is page graphics, not text. The operator is now boxed from its glyph outlines, and the ink check counts text ink only.

Cost and latency as design inputs

The original budget was a hard ceiling of 5 EUR/month. The live demo costs 0 EUR/month: every service runs on a free tier, and the free-tier limits were treated as inputs to the design, not surprises (TruthBase README.md, Cost and free-tier limits). Groq allows each model 8K tokens a minute, 1K requests and 200K tokens a day, so answers run on gpt-oss-120b and rewriting and reranking on gpt-oss-20b, each against its own quota. Postgres enforces 10 chats a minute and 30 a day per client and a budget of 80 chats a day for the whole demo; past about 60 enhanced chats a day, gpt-oss-20b runs out and chat answers from the fused order. Supabase’s free 500 MB holds the 564 chunks in about 2 MB of vectors, and a keep-alive workflow is scheduled twice a week against its 7-day inactivity pause; its last successful run was on 2026-10-19. Vercel Hobby allows 4 Active CPU-hours a month, which holds because waiting on a model does not count as active CPU.

On a local production build, Lighthouse scores the landing page 98 on mobile and 100 on desktop, with 100 for accessibility, a mobile LCP of 2.40 s and a CLS of 0 (Lighthouse 13.5.0, median of 3 runs, 2026-10-20). A spot check on the live deployment on 2026-10-21 measured 1.7 s for a search and 1.5 s to the first token of a chat answer: one check, not a percentile. Dev-server traces put time to first token at 1.35–1.6 s without rewrites and 2.2–2.9 s when the question is rewritten (TruthBase docs/TECH_NOTES.md §15), so the p50 under real traffic depends on how many questions get rewritten, and it stays pending until Langfuse has that traffic. Token spend per query is not published either: Langfuse records tokens per model call, and the rewriter alone costs about 650 tokens per question, but no whole-query figure has been taken yet.

What’s built, what’s next

TruthBase has been live at truthbase.elbaymalik.com since 2026-10-21, with no login: ask a question, click a citation, and the PDF opens at the right page with the passage highlighted. Roadmap phases 0 to 15 are built, from the golden set, ingestion and the highlight viewer through hybrid search, reranking, query rewriting, tracing, abuse protection, accessibility, the Vitest and Playwright suites and the deployment, except one: the eval harness (phase 9) stays unticked until its first full promptfoo run lands. Reranking and query rewriting were re-measured at their shipped settings on 2026-10-21, and the whole pipeline end to end.

The known gaps are written down in the repo. Six golden questions are never retrieved in the top 15 (three multi-page or multi-hop, three numeric or factoid details), and query rewriting does not reach them. Extraction flattens tables into running text, which causes refusals even when the evidence is retrieved. False refusals are down to 3 in the end-to-end answer eval, still above the target of 1. Prompt injection through document text is reduced but not prevented. The next phase, 11b, is bring-your-own uploads behind Supabase Auth, with per-user quotas and a prompt-injection review before uploads open; it is deferred.

Key decisions

  1. 01 Hybrid retrieval inside Postgres

    pgvector and native tsvector full-text search, fused with weighted Reciprocal Rank Fusion in one SQL RPC on Supabase: a keyword arm that ORs the question's own lexemes (ts_rank_cd, weight 0.5), an exact cosine arm (weight 1), k = 60 and 40 candidates per arm.

    External vector databases are not free and not needed at 564 chunks, and ParadeDB's BM25 is unavailable on hosted Supabase. One database keeps fusion in SQL and the retrieval path testable in CI. The weights came from a 72-configuration grid with cross-validation: 45 questions cannot fit a learned fusion.

    Measured: recall@8 87% against 76% for dense-only, after the keyword arm was switched from AND to OR (TruthBase docs/adr/0006-hybrid-search.md).

    rejected: Pinecone · Weaviate · pg_search/BM25 (ParadeDB) · A learned fusion

  2. 02 768-dim Matryoshka-truncated embeddings

    gemini-embedding-001 truncated to 768 dimensions, L2-normalized by hand before storage.

    Full-width vectors would spend the 500 MB Supabase free tier faster, and Google L2-normalizes only the 3072-dim output, so truncated vectors must be normalized by hand for cosine search. 564 chunks take about 2 MB of vectors.

    rejected: Full 3072-dim Gemini vectors · OpenAI embeddings · Jina as primary

  3. 03 Groq primary, Gemini fallback for generation

    Groq gpt-oss-120b streams answers at temperature 0, capped at 700 output tokens; Gemini Flash-Lite takes over automatically on a 429 or a 5xx.

    OpenAI and Anthropic generation are not free, and the budget is a hard constraint. The plan named llama-3.3-70b, which left Groq's free tier before the build reached it, and Gemini 2.5 Flash-Lite, which is closed to new keys; gpt-oss-120b and a newer Flash-Lite replaced them.

    The model change is recorded in TruthBase docs/adr/0001-stack-selection.md and docs/adr/0005-citations-and-grounding.md.

    rejected: OpenAI · Anthropic

  4. 04 LLM-as-reranker on Groq

    A listwise rerank of the fused top 15 on gpt-oss-20b with structured output: the top 4 reranked picks lead, the fused order fills the rest, and a 4 s timeout falls back to the fused order.

    Dedicated rerank APIs have trial caps and add a vendor, and a cross-encoder needs a host. Gemini Flash-Lite ranked best but had a p95 of 15.5 s; gpt-oss-120b would share the answer model's quota. Keeping the fused top 4 holds recall@8 at 87%, where a full rerank lost one cross-document question.

    Accepted after re-measurement at the shipped 15 candidates: MRR 0.515 → 0.735 and nDCG@5 0.638 → 0.811, level with the 20-candidate run at about 3.0K instead of 3.9K tokens per call; with the document filter on, MRR 0.547 → 0.791 (TruthBase docs/adr/0007-reranking.md, docs/evals/phase-7-groq-20b-15.json).

    rejected: Cohere Rerank · Jina Reranker · Self-hosted cross-encoder · Gemini Flash-Lite as reranker · gpt-oss-120b as reranker

  5. 05 Page-bounded sentence chunks of 800 characters

    Sentences packed greedily up to 800 characters (about 200 tokens) with 15% overlap, never across a page, each character keeping a pointer to its source text item for the highlight rects.

    The highlight a reader sees is the chunk, so chunk size is a citation decision. 800 characters is the smallest setting that keeps all 60 golden evidence spans inside one chunk; the plan's 512 tokens would highlight half a page. Semantic chunking was about 14x slower and spends embedding quota; contextual retrieval costs an LLM call per chunk.

    This replaced the plan's recursive ~512-token splitter before any retrieval was measured (TruthBase docs/adr/0003-chunking.md).

    rejected: Recursive 512-token splitting (the plan's default) · Cross-page chunks · Semantic chunking · Contextual retrieval

  6. 06 Two eval layers: a deterministic gate and a promptfoo LLM eval

    revisiting

    A retrieval gate on every pull request (the real hybrid_search on a committed corpus fixture, any drop fails), plus a promptfoo LLM eval run on demand and weekly, with a qwen judge and disk-cached model calls.

    An LLM eval on every pull request would spend the free quota and gate on noise; the retrieval gate is deterministic, so any drop is real. Ragas is Python in a TypeScript repo. The judge is a different model from the one that answers, because a model grading its own answers favours them.

    Proposed in the repo. The first full LLM eval (2026-10-23) measured faithfulness at 95.2% and judged citation precision at 92.5%. It fails its gate on 4 false refusals, and a hand check found the judge lenient (about 88–90% citation precision once corrected), so there is no baseline yet (TruthBase docs/adr/0008-evals-and-ci-gate.md, docs/evals/phase-9-llm.json).

    rejected: The LLM eval on every pull request · promptfoo for the retrieval gate · Ragas · gpt-oss-120b as judge

  7. 07 Per-line highlight rects in PDF space, from a glyph replay

    At ingestion, replay each page's operator list with PDF.js's own text-advance math to get every glyph's x-extent, store one rect per rendered line in PDF space, and have the viewer draw those rects unchanged, with a text-match fallback.

    The browser text layer lays text out in a substitute font and measured up to 18.6 pt off; advance widths alone were up to 12 pt off; one box per chunk spans columns. The replay put span edges within 0.001 pt of MuPDF in the spike.

    99.6% of text items get exact per-character positions; the rest fall back to a proportional cut (TruthBase docs/adr/0004-extraction-and-highlights.md).

    rejected: Refining edges from the browser text layer · Font advance widths only · Server-side canvas rendering · One box per chunk

  8. 08 Self-gated query rewriting

    One rewrite call on gpt-oss-20b that returns zero to three queries, with a document catalog and entity preservation for tokens with digits; the lists are fused with plain RRF and reranked against the original question.

    Expanding every question cost precision (recall@8 87% → 84%). Gated, it raised recall@5 on ambiguous questions from 75% to 100% and no tag slice lost recall. HyDE's fluent wrong answers pulled in the wrong NIST controls.

    Chosen on gpt-oss-120b, then accepted after measuring the shipped gpt-oss-20b rewriter: recall@5 on ambiguous questions 75% → 100%, overall recall@5 76% → 80%, recall@8 unchanged at 87%, 0.4 rewrites per question (TruthBase docs/adr/0009-query-rewriting.md, docs/evals/phase-8-groq-20b-gated.json).

    rejected: Expanding every question · HyDE · A rule-based ambiguity detector · A separate classifier call

  9. 09 Rate limits and daily budgets in Postgres

    Per-client caps of 10 chats a minute and 30 a day, plus a global budget of 80 chats a day, counted all or nothing by one consume_quotas call before any model call; a clean 429 with Retry-After; client IPs stored only as an HMAC; fail open if the counter store errors.

    Groq's binding limit is per day, so the budget is a calendar day; a per-minute limit tight enough to protect it would block a normal visitor. On serverless, an in-memory limiter counts per instance and resets on every cold start. Postgres is already there, exact across instances, and the minute window rides on the same call.

    This replaced the plan's 150 requests per 5 minutes via nuxt-security, which stays in front as a cheap per-instance first line (TruthBase docs/adr/0010-abuse-protection.md).

    rejected: An in-memory limiter alone (nuxt-security) · Mandatory sign-in · CAPTCHA · A Vercel WAF rule as the only layer · Counting tokens instead of requests

  10. 10 Vercel Hobby, one function next to the database

    The whole Nuxt app as one Node 24 Vercel Function in dub1 (Dublin, next to Supabase in AWS eu-west-1), static assets on the CDN, ingestion from the operator's CLI, Langfuse spans flushed through waitUntil.

    Hobby is free, needs no card and runs the same Node server code, PDF.js assets included. Workers Paid needs a card and a port to workerd; free Workers' 10 ms of CPU is tight even without ingestion and means two deploy targets; Render's cold start takes about a minute.

    Live since 2026-10-21. Hobby is for non-commercial use, which a portfolio demo without ads, payments or client work is (TruthBase docs/adr/0011-deployment-topology.md).

    rejected: Cloudflare Workers Paid ($5/month) · Free Workers plus a Node ingestion host · Google Cloud Run · Render free tier · An Oracle Cloud VM

Evaluation results

Highlight, retrieval and answer quality
MetricValueStatus
Highlight placement checks passed, 60 golden spans × zoom 50/100/200%180/180measured

The real viewer in headless Chromium; placement error ≤ 0.03 px. Until 2026-10-25 it was 176/180: big operators now get glyph boxes from their outlines, and the ink check counts text ink only.

Highlight edges vs MuPDF, every golden span±0.05 ptmeasured

Vitest pins each span edge to MuPDF, an independent PDF engine.

Recall@8, dense-only → hybrid76% → 87%measured

45 answerable questions, 564 chunks; the hybrid figure is the floor of the retrieval gate in CI, which last ran on 2026-10-21.

MRR, fused → reranked0.515 → 0.735measured

45 questions at the shipped 15 candidates. With the document filter on (41 questions): 0.547 → 0.791. Rerank call p50 0.65 s / p95 1.15 s.

nDCG@5, fused → reranked0.638 → 0.811measured

Same run: recall@5 76% → 84%, recall@8 unchanged at 87%. With the document filter on: nDCG@5 0.627 → 0.825, recall@5 78% → 88%.

Recall@5 on ambiguous questions, without → with multi-query75% → 100%measured

4 ambiguous questions, with the shipped gpt-oss-20b rewriter. Overall recall@5 76% → 80%, recall@8 unchanged at 87%; 0.4 rewrites per question.

Unanswerable questions refused5/5measured

The shipped pipeline end to end, 50 questions: rewrite and rerank on gpt-oss-20b, answers on gpt-oss-120b. 93% (42/45) of answerable questions answered, none without a citation. 3 false refusals (Phase 5: 7) against a target of at most 1: two retrieval misses and one flattened table.

Correct citation, of answerable questions69% → 80%measured

At least one golden-evidence citation, Phase 5 retrieval → the shipped pipeline, both answered by gpt-oss-120b. 86% of answered questions.

Citation precision, lower bound56%measured

The shipped pipeline (Phase 5: 55% Groq, 57% Gemini). Only golden-evidence chunks count, so a citation to a correct passage that is not the golden one counts as wrong.

Faithfulness and judged citation precision (LLM judge)95.2% / 92.5%measured

First full promptfoo run (2026-10-23): the shipped pipeline on all 50 questions, pooled over the 41 it answered and judged by a qwen model against targets of 0.90. A hand check of 5 answers found the judge lenient; corrected for those, about 92–94% / 88–90%. The run fails its gate on 4 false refusals (target at most 1), so there is no baseline yet (ADR-0008).

Measured: TruthBase ROADMAP.md Phase 5 (pnpm check:viewer, 2026-10-25, against the hosted corpus) · TruthBase docs/adr/0004-extraction-and-highlights.md · TruthBase docs/evals/retrieval-baseline.json · TruthBase docs/evals/phase-7-groq-20b-15.json · TruthBase docs/evals/phase-8-groq-20b-gated.json · TruthBase docs/evals/phase-8-groq.json · TruthBase docs/evals/phase-8-groq.json (before: docs/evals/phase-5-groq.json) · TruthBase docs/evals/phase-9-llm.json

Cost & latency

Cost & latency
MetricValueStatus
Monthly infrastructure cost0 EUR/monthmeasured

Every service runs on a free tier; the original design ceiling was 5 EUR/month.

Lighthouse performance, mobile / desktop98 / 100measured

Accessibility 100 on both.

Mobile LCP / CLS2.40 s / 0measured
Production spot check: search / chat first token1.7 s / 1.5 smeasured

One check on the live deployment, not a percentile.

Time to first token, p50 on real trafficn/apending

Read from Langfuse traces of production traffic (pnpm sample:traces). The target is under 2 s; rewritten questions take longer.

Token spend per queryn/apending

Langfuse records tokens per model call, but no whole-query figure is published yet. The rewriter alone is about 650 tokens per question.

Measured: TruthBase README.md, Cost and free-tier limits · TruthBase README.md, Quality (Lighthouse 13.5.0, local production build, median of 3, 2026-10-20) · TruthBase ROADMAP.md Phase 14 (2026-10-21)

Safety & failure modes

Risks

  • A public demo can exhaust Groq's free tier (per model: 8K tokens a minute, 1K requests and 200K tokens a day) and break the demo for everyone until midnight UTC
  • Hallucinated claims dressed in citation chips: false confidence is the exact failure mode the project exists to prevent
  • Documents embedded through Gemini's free tier may be used by Google to improve its products
  • Drifted highlight rects could 'verify' an answer with the wrong passage

Mitigations

  • Per-minute and daily caps per client plus a global daily budget, counted atomically in Postgres before any model call, with a clean 429 and Retry-After, and automatic failover to Gemini Flash-Lite
  • Sources passed as numbered, quoted data, a NO_ANSWER refusal path and only in-range markers rendered as chips; 5 of 5 unanswerable questions refused and none answered without a citation in the end-to-end answer eval; faithfulness first measured at 95.2% by an LLM judge that a hand check found lenient (about 92–94% corrected), in a run that fails its gate on false refusals
  • The demo corpus is four public, redistributable PDFs ingested by the operator; uploads stay closed until a prompt-injection review (Phase 11b)
  • Rects stored in PDF space and drawn unchanged, a text-match fallback with an 'approximate' badge, and three checks: Vitest against MuPDF, the real viewer over every golden span, and Playwright in CI, last run on 2026-10-21