JDFJDF/ docs

RAG benchmark — JDF vs PDF

The question: if the same document enters a RAG pipeline as a PDF or as a JDF, which one lets the retriever find the right passage, and what does each cost to index and query? Everything else is held equal: same content, same embedding models, same lexical retriever, same questions, same hit rule. Everything on this page is generated from bench/results/*.json; nothing is typed in by hand.

Run it yourself. Python only — no Node toolchain needed.
git clone https://github.com/uurtech/jdf && cd jdf/bench
pip install -r requirements.txt
python rag_bench.py                      # accuracy: BM25 + a local embedding model
python rag_bench.py --embedder ollama:nomic-embed-text,st:BAAI/bge-base-en-v1.5
python cost_bench.py                     # RAG cost: 1,000 PDF files vs 1,000 JDF files
python rag_bench.py --verify             # re-run and compare with the published numbers
Each installed PDF parser (PyMuPDF, pdfplumber, pypdf, poppler's pdftotext) becomes a pipeline; missing ones are skipped. Nothing from the corpus leaves your machine.

In 42 seconds

Every number on screen is read from docs/bench.json. Source: demos/rag-benchmark.

Retrieval accuracy

PDF side: pdf → text → LangChain-style fixed chunks (1000/200 and 2000/200 chars) → retriever. JDF side: jdf → jdf chunk --strategy section → retriever — section-aware chunks, tables serialised as Header: value rows, the heading breadcrumb prefixed to the text that gets embedded. Third pipeline, PDF → jdf convert → jdf chunk: the same PDFs converted with the shipped importer (tables rebuilt as real table elements, headings detected from font size), then chunked like native JDF — the path for anyone who only has PDFs today. Same retrievers for all. A retrieved chunk is a hit when it comes from the right document and contains both the answer and its row/subject key.

BM25 — lexical, offline, deterministic

PipelineChunksR@1k tokensR@2k tokensTop-1Top-5MRR@10Table cells R@1kCtx tokens @5
JDF · jdf chunk (section, 512 tok)192100.0%100.0%89.6%100.0%0.945100.0%798
PDF → jdf convert → jdf chunk (section, 512 tok)216100.0%100.0%85.4%99.5%0.925100.0%848
PDF · PyMuPDF get_text() · fixed 1000/20015398.4%100.0%75.0%99.0%0.858100.0%1,194
PDF · PyMuPDF get_text() · fixed 2000/2009097.9%100.0%84.4%100.0%0.91899.2%1,759
PDF · pdfplumber extract_text() · fixed 1000/20015099.5%100.0%75.5%100.0%0.865100.0%1,190
PDF · pdfplumber extract_text() · fixed 2000/2009097.9%100.0%84.4%100.0%0.91899.2%1,744
PDF · pypdf extract_text() · fixed 1000/20015199.5%100.0%75.0%100.0%0.863100.0%1,193
PDF · pypdf extract_text() · fixed 2000/2009097.9%100.0%84.4%100.0%0.91899.2%1,755
PDF · pdftotext -layout (poppler) · fixed 1000/20026885.9%91.1%68.2%87.0%0.76689.2%1,016
PDF · pdftotext -layout (poppler) · fixed 2000/2009692.7%98.4%73.4%99.5%0.85292.5%2,193

Embeddings — nomic-embed-text

PipelineChunksR@1k tokensR@2k tokensTop-1Top-5MRR@10Table cells R@1kCtx tokens @5
JDF · jdf chunk (section, 512 tok)19299.0%100.0%76.6%99.0%0.86498.3%753
PDF → jdf convert → jdf chunk (section, 512 tok)21699.0%99.5%80.2%98.4%0.88498.3%721
PDF · PyMuPDF get_text() · fixed 1000/20015381.8%98.4%41.7%91.1%0.60680.0%1,226
PDF · PyMuPDF get_text() · fixed 2000/2009077.1%99.5%63.0%99.0%0.76082.5%1,781
PDF · pdfplumber extract_text() · fixed 1000/20015080.7%99.0%42.2%91.1%0.60879.2%1,219
PDF · pdfplumber extract_text() · fixed 2000/2009077.1%99.0%63.5%97.9%0.76382.5%1,773
PDF · pypdf extract_text() · fixed 1000/20015183.3%97.9%41.1%92.2%0.60581.7%1,224
PDF · pypdf extract_text() · fixed 2000/2009077.1%99.0%63.0%97.4%0.75982.5%1,774
PDF · pdftotext -layout (poppler) · fixed 1000/20026871.9%86.5%35.9%73.4%0.50870.8%1,040
PDF · pdftotext -layout (poppler) · fixed 2000/2009662.0%89.1%46.4%93.8%0.64260.0%2,269

Embeddings — BAAI/bge-small-en-v1.5

PipelineChunksR@1k tokensR@2k tokensTop-1Top-5MRR@10Table cells R@1kCtx tokens @5
JDF · jdf chunk (section, 512 tok)19296.9%100.0%46.9%91.7%0.64695.0%716
PDF → jdf convert → jdf chunk (section, 512 tok)21695.8%98.4%40.1%91.7%0.60194.2%523
PDF · PyMuPDF get_text() · fixed 1000/20015374.5%94.3%34.4%83.3%0.53071.7%1,214
PDF · PyMuPDF get_text() · fixed 2000/2009064.1%92.2%50.0%90.6%0.64967.5%1,783
PDF · pdfplumber extract_text() · fixed 1000/20015075.0%93.8%34.9%84.4%0.53472.5%1,210
PDF · pdfplumber extract_text() · fixed 2000/2009066.1%92.2%50.5%91.7%0.65969.2%1,771
PDF · pypdf extract_text() · fixed 1000/20015175.0%94.8%31.8%84.4%0.51572.5%1,210
PDF · pypdf extract_text() · fixed 2000/2009066.1%90.1%52.1%88.5%0.66569.2%1,777
PDF · pdftotext -layout (poppler) · fixed 1000/20026866.7%82.8%34.9%71.4%0.48761.7%1,044
PDF · pdftotext -layout (poppler) · fixed 2000/2009648.4%87.0%38.0%95.3%0.57249.2%2,211

Embeddings — sentence-transformers/all-MiniLM-L6-v2

PipelineChunksR@1k tokensR@2k tokensTop-1Top-5MRR@10Table cells R@1kCtx tokens @5
JDF · jdf chunk (section, 512 tok)19297.4%100.0%40.1%91.1%0.59995.8%693
PDF → jdf convert → jdf chunk (section, 512 tok)21696.4%100.0%34.4%78.1%0.53794.2%529
PDF · PyMuPDF get_text() · fixed 1000/20015370.3%90.1%30.2%80.2%0.49263.3%1,204
PDF · PyMuPDF get_text() · fixed 2000/2009060.9%82.8%45.8%81.8%0.60663.3%1,695
PDF · pdfplumber extract_text() · fixed 1000/20015065.6%89.1%28.1%78.1%0.47458.3%1,193
PDF · pdfplumber extract_text() · fixed 2000/2009061.5%83.9%45.8%82.8%0.60562.5%1,684
PDF · pypdf extract_text() · fixed 1000/20015166.7%88.0%28.1%78.6%0.47759.2%1,197
PDF · pypdf extract_text() · fixed 2000/2009062.5%84.9%42.7%81.8%0.59063.3%1,693
PDF · pdftotext -layout (poppler) · fixed 1000/20026866.1%82.8%29.2%68.8%0.44160.8%1,021
PDF · pdftotext -layout (poppler) · fixed 2000/2009654.2%83.9%37.0%91.7%0.57150.8%2,214

Embeddings — BAAI/bge-base-en-v1.5

PipelineChunksR@1k tokensR@2k tokensTop-1Top-5MRR@10Table cells R@1kCtx tokens @5
JDF · jdf chunk (section, 512 tok)19296.9%100.0%63.0%94.8%0.75895.0%728
PDF → jdf convert → jdf chunk (section, 512 tok)21699.5%99.5%54.2%94.8%0.69499.2%552
PDF · PyMuPDF get_text() · fixed 1000/20015378.6%97.4%31.8%90.1%0.52881.7%1,220
PDF · PyMuPDF get_text() · fixed 2000/2009056.8%94.3%46.9%92.7%0.63069.2%1,793
PDF · pdfplumber extract_text() · fixed 1000/20015080.2%97.4%32.3%90.6%0.53483.3%1,215
PDF · pdfplumber extract_text() · fixed 2000/2009057.8%95.3%47.4%93.2%0.63470.0%1,778
PDF · pypdf extract_text() · fixed 1000/20015177.6%98.4%30.2%91.7%0.51978.3%1,215
PDF · pypdf extract_text() · fixed 2000/2009057.3%94.8%46.9%91.1%0.63169.2%1,790
PDF · pdftotext -layout (poppler) · fixed 1000/20026874.0%87.5%34.4%76.6%0.50473.3%1,037
PDF · pdftotext -layout (poppler) · fixed 2000/2009644.8%89.6%34.4%95.3%0.54748.3%2,296

R@1k tokens = the answer was inside the first 1,000 tokens of retrieved context (chunk-size neutral: a 2,000-character PDF chunk “hits” more often at top-1 simply because it is a quarter of the document, and then costs 3× the tokens). Hit = right document and the chunk contains both the answer and its row/subject key. 24 documents, 120 pages, 192 questions (120 table cells, 48 prose, 24 list). Embeddings run locally (nomic-embed-text via Ollama 0.32.6; BAAI/bge-small-en-v1.5 via sentence-transformers 6.0.1; sentence-transformers/all-MiniLM-L6-v2 via sentence-transformers 6.0.1; BAAI/bge-base-en-v1.5 via sentence-transformers 6.0.1). JDF chunk hashes verified; editing one paragraph re-embeds 1 of 192 chunks. Apple M5, 2026-09-13.

RAG cost — 1,000 PDF files vs 1,000 JDF files

Same pipeline on both sides, only the input format differs: chunks → embeddings → vector store → top-5 context → LLM. The 24-document corpus is cycled to 1,000 files per format; embedding tokens are counted from the chunks each pipeline produces; embedding time is measured throughput; dollar figures are tokens × the published prices in bench/prices.json. The benchmark never calls a paid API.

Per 1,000 documentsJDF · jdf chunk (section)PDF · pypdf extract_text() · fixed 1000/200
Accuracy · answer in first 1,000 tokens (nomic-embed-text)99.0%83.3%
Accuracy · top-1 hit76.6%41.1%
Chunks8,0006,291
Embedding tokens, initial index1,305,8201,390,646
Embedding cost · OpenAI text-embedding-3-small$0.0261$0.0278
Local embedding time · bge-small-en-v1.5 (measured throughput)43.8 s32.4 s
Vector-store payload5.8 MB5.6 MB
Re-embed tokens when one paragraph changes in every document91,0001,461,000
Re-index cost · OpenAI text-embedding-3-small$0.0018$0.0292
LLM input tokens per 1,000,000 queries (top-5 context)753,364,5831,223,625,000
LLM input cost · Claude Sonnet 5 input$1,506.73$2,447.25

1,000 files per format = the 24-document corpus cycled. Tokens are counted from the chunks each pipeline produces (ceil(chars/4), both sides); embedding time is measured throughput on Apple M5 (2026-09-13) applied to the totals. Prices from bench/prices.json (as of 2026-09-12); edit it for your provider — the benchmark never calls a paid API. The PDF column is the PDF pipeline that scored best in the accuracy run. Scaling: figures are measured at 1,000 documents and 1,000,000 queries; anything at other volumes (10,000 documents, 10M queries) is a linear estimate, not a measurement — index and re-index costs scale with documents, query cost scales with questions asked. Re-index: JDF re-embeds only chunks whose content hash changed (jdf embed --incremental); a PDF has no chunk identity, so an edit means re-chunking and re-embedding the whole document.

Method

Corpus

24 “Quarterly Operations Report” documents, 4–5 pages each, generated deterministically from a fixed seed. Each has eight sections: two prose sections, five tables (regional performance, headcount, pricing, service levels, vendor spend) and a risk list, plus a repeating page header and footer. The JDF files are the originals; the PDFs were printed from them by the real jdf.js renderer in Google Chrome, so the PDF text layer is clean and complete — the PDF side is not handicapped by scans or exotic fonts. Every document, every question and every retrieved rank is in bench/.

Questions

192, generated with the corpus and stored with ground truth: 120 table-cell lookups (“What was Acme Logistics's churn rate in EMEA for Q3 2025?”), 48 prose facts (on-call lead, capex budget), 24 list items (risk owner). Every question names the document the way a user would.

Why a generated corpus

A retrieval benchmark needs to know, for every question, which passage answers it. Public PDF collections don't ship that, and hand-labelling hundreds of answers is where benchmark bias usually creeps in. Generating the documents makes the ground truth exact and the whole thing auditable, and --verify proves nobody edited a document or a question after the fact by checking SHA-256 hashes against the manifest. The obvious limitation: synthetic reports are cleaner than real ones — real PDFs are usually worse for the PDF side (scans, multi-column layouts, broken tables), so treat these numbers as a floor for the gap. If you run the pipelines on your own corpus, open an issue with the numbers.

Reading the accuracy numbers honestly

Top-1 depends on chunk size: a 2,000-character PDF chunk is a quarter of the document, so it “hits” more often at rank 1 — and then costs three times the tokens per query. With the two smallest embedding models the PDF 2,000-char configuration edges out JDF at top-1; on the chunk-size-neutral metric (answer within the first 1,000 tokens of context) JDF leads with every retriever tested. Both views are in the tables above.