Jina AI, part of Elastic, has released jina-ocr-v1, an end-to-end visual document parser. It takes PDFs, scans, tables, charts or invoices and returns clean Markdown in 1 pass. The model has 3.4B total parameters, with about 570M decoder parameters active per token. A speculative decoding head ships inside the checkpoint. Jina AI built it to serve on low-budget GPUs such as the NVIDIA L4. The technical report lists 91.14 on OmniDocBench v1.6 and 83.4 on olmOCR-Bench.
Is it deployable? Yes, for research and non-commercial use. The open weights are about 6.8 GB in BF16 and run on Transformers or vLLM. The CC BY-NC 4.0 license means commercial use requires contacting Jina AI.
What is jina-ocr-v1?
The model post-trains DeepSeek-OCR and keeps its 2 efficiency components. DeepEncoder has about 380M parameters and chains SAM, a 16x convolutional compressor and CLIP-L. It turns a 1024×1024 page view from 4,096 patches into 256 visual tokens. A dynamic-resolution mode adds up to 9 local tiles at 100 tokens each. That caps a page at 1,156 visual tokens.
The decoder is DeepSeek-3B-MoE with 12 layers, 64 routed experts and 2 shared experts. Top-6 routing activates about 570M parameters per token. The position limit is 32,768. Output is Markdown, with tables in HTML and formulas in LaTeX.
How FastMTP Speculative Decoding Works
OCR output is near-deterministic and locally structured. That makes it a good fit for speculative decoding. Jina AI adds a FastMTP head: 1 dense draft block applied recursively for K=3 steps. Draft parameters stay constant as depth grows.
The decoder then verifies the drafts greedily. It accepts the longest prefix that matches its own choices and commits 1 more token itself. If all 3 drafts match, that extra token is a bonus. The committed text always equals plain greedy decoding, so the speedup is lossless. At K=3 the model commits 2.73 tokens per step on average.
<div class="stage">
<div class="card">
<div class="lab" style="margin-bottom:8px">Draft block proposes</div>
<div class="slots" id="slots"></div>
</div>
<div class="card">
<div class="lab" style="margin-bottom:8px">Verifier decides</div>
<p class="status" id="status" aria-live="polite">Press Step once or let it play.</p>
</div>
</div>
<div class="page" id="page" aria-label="Parsed output so far"></div>
<div class="legend">
<span><i style="background:rgba(0,145,145,.35);box-shadow:inset 0 -2px 0 #009191"></i>Accepted draft token</span>
<span><i style="background:#F3FAF9;box-shadow:inset 0 -2px 0 #0B2B2C"></i>Verifier’s own token</span>
<span><i style="background:rgba(235,97,97,.3)"></i>Rejected draft, never written</span>
</div>
<div class="stats">
<div class="stat"><b id="s-pass">0</b><span>verifier passes</span></div>
<div class="stat"><b id="s-tok">0</b><span>tokens committed</span></div>
<div class="stat"><b id="s-tpp">0.00</b><span>tokens per pass</span></div>
</div>
<div class="lane"><div class="lt"><span>With FastMTP</span><span id="l-spec">0 of 0 tokens</span></div><div class="track"><div class="fill" id="f-spec"></div></div></div>
<div class="lane"><div class="lt"><span>Plain decoding, 1 token per pass</span><span id="l-plain">0 of 0 tokens</span></div><div class="track"><div class="fill plain" id="f-plain"></div></div></div>
<div class="measured" id="measured"></div>
<p class="note">The token split and the accept pattern in this demo are invented for illustration. The output always equals the plain greedy output, which is the lossless guarantee in the paper. Measured figures come from Jina AI’s L4 tests on olmOCR-Bench at batch size 1.</p>
</section>
<section class="pane" id="p-enc" role="tabpanel" aria-labelledby="t-enc">
<div class="pipe">
<div class="pstep"><b>Page image</b><span>PDF page, scan, table, chart or invoice</span></div>
<div class="pstep"><b>DeepEncoder</b><span>About 380M: SAM 80M, 16x conv compressor, CLIP-L 300M</span></div>
<div class="pstep"><b>MoE decoder</b><span>About 3B total, about 570M active per token</span></div>
<div class="pstep hot"><b>FastMTP head</b><span>1 shared draft block, reused for K=3 steps</span></div>
</div>
<div class="two">
<div>
<canvas id="cv" width="600" height="600" aria-label="Animation of 4,096 image patches merging into 256 visual tokens"></canvas>
<div class="row" style="margin-top:10px"><button class="btn" id="compress">Compress the page</button></div>
</div>
<div>
<h3>4,096 patches become 256 tokens</h3>
<p class="m">The encoder turns a 1024 x 1024 global view into 256 visual tokens. Fewer visual tokens means a shorter prefix for the decoder to read.</p>
<div class="big" id="vt">256</div>
<div class="lab">visual tokens for this page</div>
<div class="formula" id="fm">256 + 100 x 0 = 256</div>
<p class="m" style="margin-top:14px">Dense pages add local tiles in dynamic-resolution (Gundam) mode. Each tile costs 100 tokens, up to 9 tiles.</p>
<label class="lab" for="nt">Local tiles: <span id="ntv">0</span></label><br>
<input type="range" id="nt" min="0" max="9" step="1" value="0">
<div class="tiles" id="tiles"></div>
</div>
</div>
<div class="card" style="margin-top:16px">
<h3>Only 6 of 64 routed experts fire per token</h3>
<p class="m">The decoder has 12 layers with 64 routed experts and 2 shared experts. Top-6 routing keeps about 570M parameters active, while all weights stay in memory.</p>
<div class="experts" id="experts"></div>
<div class="shared"><span class="ex sh"></span><span class="ex sh"></span><span>2 shared experts, always on</span></div>
<div class="row"><button class="btn ghost" id="route">Route next token</button><span class="lab" id="rt">Token 0</span></div>
<p class="note">Which experts light up here is random and only shows the ratio.</p>
</div>
</section>
<section class="pane" id="p-spd" role="tabpanel" aria-labelledby="t-spd">
<div class="row">
<div class="seg" id="hwseg" role="group" aria-label="Test setup">
<button data-hw="l4" aria-pressed="true">NVIDIA L4, 1 document</button>
<button data-hw="a100" aria-pressed="false">A100, batch serving</button>
</div>
<div class="seg" id="modeseg" role="group" aria-label="Execution mode">
<button data-mode="eager" aria-pressed="true">Eager</button>
<button data-mode="graph" aria-pressed="false">CUDA graphs</button>
</div>
</div>
<div class="bars" id="bars"></div>
<p class="readout" id="readout"></p>
<p class="note" id="spdnote"></p>
</section>
<section class="pane" id="p-rew" role="tabpanel" aria-labelledby="t-rew">
<p>GRPO post-training multiplies graded checks, each run by deterministic code against a reference. Floors stop 1 failed check from wiping out the signal for a mostly correct page.</p>
<div class="row" style="margin-bottom:14px">
<span class="lab">Try a page</span>
<button class="btn ghost" data-pre="clean">Clean page</button>
<button class="btn ghost" data-pre="tag">1 unclosed table tag</button>
<button class="btn ghost" data-pre="loop">Repetition loop</button>
</div>
<div class="card" id="sliders"></div>
<div class="res">
<div class="stat" id="r-with"><b>0.00</b><span>reward with floors</span><div class="gauge"><div></div></div></div>
<div class="stat" id="r-wo"><b>0.00</b><span>reward without floors</span><div class="gauge"><div></div></div></div>
</div>
<p class="readout" id="rewmsg"></p>
<p class="note">Illustrative calculator. Floors are from the paper: 0.2 for structural, unit-test and format terms, 0.1 for the table term. Repetition is left unfloored on purpose, and the paper gives no floor for content. It also lists formula and format terms, and says enabled terms and floors vary by run.</p>
</section>
<div class="foot">
<span>Sources: <a href="https://arxiv.org/abs/2609.03181" target="_blank" rel="noopener">Jina-OCR-v1 paper</a>, <a href="https://huggingface.co/jinaai/jina-ocr-v1" target="_blank" rel="noopener">model card</a>, <a href="https://jina.ai/models/jina-ocr-v1" target="_blank" rel="noopener">Jina model page</a>. Figures checked September 18, 2026.</span>
<span>Built by <span class="brand">Marktechpost</span></span>
</div>
</div>
<script>
(function(){
var RM = window.matchMedia && window.matchMedia(‘(prefers-reduced-motion: reduce)’).matches;
function $(id){return document.getElementById(id);}
function postH(){
try{ parent.postMessage({__jxH: $(‘jx’).offsetHeight + 40}, ‘*’); }catch(e){}
}
window.addEventListener(‘load’, postH);
window.addEventListener(‘resize’, postH);
if(window.ResizeObserver){ new ResizeObserver(postH).observe($(‘jx’)); }
/* ———- tabs ———- */
var tabs = [].slice.call(document.querySelectorAll(‘#jx .tab’));
var current = ‘p-sim’;
function selectTab(tab){
tabs.forEach(function(t){
var on = t === tab;
t.setAttribute(‘aria-selected’, on ? ‘true’ : ‘false’);
t.tabIndex = on ? 0 : -1;
$(t.getAttribute(‘aria-controls’)).classList.toggle(‘on’, on);
});
current = tab.getAttribute(‘aria-controls’);
if(current === ‘p-enc’){ sizeCanvas(); drawGrid(gridT); if(!played){ played = true; runCompress(); } }
if(current === ‘p-spd’){ renderSpeed(); }
if(current !== ‘p-sim’ && playing){ setPlaying(false); }
postH();
}
tabs.forEach(function(t, i){
t.addEventListener(‘click’, function(){ selectTab(t); });
t.addEventListener(‘keydown’, function(e){
if(e.key === ‘ArrowRight’ || e.key === ‘ArrowLeft’){
var n = tabs[(i + (e.key === ‘ArrowRight’ ? 1 : tabs.length – 1)) % tabs.length];
n.focus(); selectTab(n);
}
});
});
/* ———- simulator ———- */
// [text, structural?, wrong-draft alternative]
var T = [
[‘# ‘,1],[‘Invoice’,0,’Receipt’],[‘ 2041′,0,’ 2014′],[‘n’,1],
[‘<table>’,1],[‘n’,1],
[‘<tr>’,1],[‘<th>’,1],[‘Item’,0,’Items’],[‘</th>’,1],[‘<th>’,1],[‘Qty’,0,’Count’],[‘</th>’,1],[‘<th>’,1],[‘Price’,0,’Cost’],[‘</th>’,1],[‘</tr>’,1],[‘n’,1],
[‘<tr>’,1],[‘<td>’,1],[‘GPU’,0,’CPU’],[‘ hours’,0,’ hour’],[‘</td>’,1],[‘<td>’,1],[’12’,0,’17’],[‘</td>’,1],[‘<td>’,1],[‘$38′,0,’$33’],[‘.40′,0,’.48′],[‘</td>’,1],[‘</tr>’,1],[‘n’,1],
[‘<tr>’,1],[‘<td>’,1],[‘Storage’,0,’Storing’],[‘</td>’,1],[‘<td>’,1],[‘3′,0,’8’],[‘</td>’,1],[‘<td>’,1],[‘$4′,0,’$1’],[‘.50′,0,’.58′],[‘</td>’,1],[‘</tr>’,1],[‘n’,1],
[‘</table>’,1],[‘n’,1],
[‘**Total:**’,0,’**Sum:**’],[‘ $42′,0,’ $47′],[‘.90′,0,’.98′]
];
var MEASURED = {
0:’Draft head off. The decoder emits 1 token per pass. Jina AI measured 42.7 tokens per second on an L4 in eager mode.’,
1:’Measured at K=1: 1.83 tokens per step, 82.6% of drafts accepted, 1.50x faster in eager mode.’,
2:’Measured at K=2: 2.38 tokens per step, 69.1% of drafts accepted, 1.82x faster in eager mode.’,
3:’Measured at K=3: 2.73 tokens per step, 57.6% of drafts accepted, 1.95x faster in eager mode. One speculative step costs about 1.4 plain steps, which is why 2.73 tokens per step becomes 1.95x.’
};
var K = 3, pos = 0, passes = 0, playing = false, runId = 0, busy = false, seed = 7;
function rnd(){ seed = (seed * 1664525 + 1013904223) % 4294967296; return seed / 4294967296; }
function sleep(ms){ return new Promise(function(r){ setTimeout(r, RM ? Math.min(ms, 60) : ms); }); }
function show(s){ return s === ‘n’ ? ‘\n’ : s; }
function resetSim(){
runId++; busy = false; pos = 0; passes = 0; seed = 7;
$(‘page’).innerHTML = ‘<span class="caret" id="caret"></span>’;
$(‘status’).textContent = K === 0 ? ‘Draft head is off. The decoder writes 1 token per pass.’ : ‘The draft block is ready to propose ‘ + K + (K === 1 ? ‘ token.’ : ‘ tokens.’);
buildSlots(); updateStats();
$(‘measured’).textContent = MEASURED[K];
postH();
}
function buildSlots(){
var h = ”;
for(var i = 0; i < Math.max(K, 1); i++){ h += ‘<span class="slot">’ + (K === 0 ? ‘off’ : ‘draft ‘ + (i + 1)) + ‘</span>’; }
$(‘slots’).innerHTML = h;
}
function updateStats(){
$(‘s-pass’).textContent = passes;
$(‘s-tok’).textContent = pos;
$(‘s-tpp’).textContent = passes ? (pos / passes).toFixed(2) : ‘0.00’;
var plain = Math.min(T.length, passes);
$(‘l-spec’).textContent = pos + ‘ of ‘ + T.length + ‘ tokens’;
$(‘l-plain’).textContent = plain + ‘ of ‘ + T.length + ‘ tokens’;
$(‘f-spec’).style.width = (100 * pos / T.length) + ‘%’;
$(‘f-plain’).style.width = (100 * plain / T.length) + ‘%’;
}
function commit(text, cls){
var caret = $(‘caret’);
if(text === ‘n’){ caret.parentNode.insertBefore(document.createElement(‘br’), caret); return; }
var s = document.createElement(‘span’);
s.className = ‘tk ‘ + cls; s.textContent = text;
caret.parentNode.insertBefore(s, caret);
}
async function stepOnce(){
if(busy || pos >= T.length) return;
busy = true; var my = runId;
var slots = [].slice.call($(‘slots’).children);
slots.forEach(function(s, i){ s.className = ‘slot’; s.textContent = K === 0 ? ‘off’ : ‘draft ‘ + (i + 1); });
var kk = Math.min(K, T.length – pos), m = 0, guesses = [];
// decide accept pattern
for(var i = 0; i < kk; i++){
var tok = T[pos + i];
var p = (tok[1] ? 0.96 : 0.42) * Math.pow(0.93, i);
var ok = rnd() < p;
guesses.push(ok ? tok[0] : (tok[2] || ‘<td>’));
if(ok && m === i) m++;
}
if(kk > 0){
$(‘status’).textContent = ‘The shared draft block proposes ‘ + kk + (kk === 1 ? ‘ token.’ : ‘ tokens, reusing the same weights at each depth.’);
for(i = 0; i < kk; i++){
await sleep(140); if(my !== runId) return;
slots[i].className = ‘slot draft pop’; slots[i].textContent = show(guesses[i]);
}
await sleep(240); if(my !== runId) return;
$(‘status’).textContent = ‘The verifier checks ‘ + (kk === 1 ? ‘it’ : ‘all ‘ + kk) + ‘ in a single pass.’;
slots.forEach(function(s){ s.classList.add(‘scan’); });
await sleep(330); if(my !== runId) return;
for(i = 0; i < kk; i++){
slots[i].className = ‘slot ‘ + (i < m ? ‘ok’ : (i === m ? ‘bad’ : ‘drop’));
}
}
passes++;
var extra = pos + m < T.length ? T[pos + m] : null;
if(K === 0){
$(‘status’).textContent = ‘The decoder writes 1 token. No drafts to check.’;
} else if(m === kk && extra){
$(‘status’).textContent = (kk === 1 ? ‘Draft accepted’ : ‘All ‘ + kk + ‘ accepted’) + ‘, plus 1 bonus token from the verifier. ‘ + (m + 1) + ‘ tokens in 1 pass.’;
} else if(extra){
$(‘status’).textContent = m + ‘ accepted. Draft ‘ + (m + 1) + ‘ did not match, so the verifier writes "’ + show(extra[0]) + ‘" instead. ‘ + (m + 1) + (m + 1 === 1 ? ‘ token’ : ‘ tokens’) + ‘ in 1 pass.’;
} else {
$(‘status’).textContent = m + ‘ accepted. End of page.’;
}
await sleep(220); if(my !== runId) return;
for(i = 0; i < m; i++){ commit(T[pos + i][0], ‘d’); }
if(extra){ commit(extra[0], ‘v’); }
pos += m + (extra ? 1 : 0);
updateStats();
if(pos >= T.length){
await sleep(200); if(my !== runId) return;
var out = T.map(function(t){ return t[0]; }).join(”);
$(‘status’).textContent = ‘Done in ‘ + passes + ‘ verifier passes. Plain decoding needs ‘ + T.length + ‘. The text is identical either way (‘ + out.length + ‘ characters).’;
setPlaying(false);
}
busy = false; postH();
}
async function loop(){
var my = runId;
while(playing && pos < T.length && my === runId){
await stepOnce();
await sleep(320);
}
}
function setPlaying(v){
playing = v; $(‘play’).textContent = v ? ‘Pause’ : (pos >= T.length ? ‘Play again’ : ‘Play’);
if(v){ if(pos >= T.length){ resetSim(); } loop(); }
}
$(‘play’).addEventListener(‘click’, function(){ setPlaying(!playing); });
$(‘stepb’).addEventListener(‘click’, function(){ if(playing){ setPlaying(false); } if(pos >= T.length){ resetSim(); } stepOnce(); });
$(‘reset’).addEventListener(‘click’, function(){ var was = playing; playing = false; resetSim(); setPlaying(was); });
[].slice.call(document.querySelectorAll(‘#kseg button’)).forEach(function(b){
b.addEventListener(‘click’, function(){
K = +b.dataset.k;
[].slice.call(document.querySelectorAll(‘#kseg button’)).forEach(function(x){ x.setAttribute(‘aria-pressed’, x === b ? ‘true’ : ‘false’); });
var was = playing; playing = false; resetSim(); setPlaying(was || true);
});
});
/* ———- page to tokens ———- */
var cv = $(‘cv’), ctx = cv.getContext(‘2d’), gridT = 0, played = false, animId = 0;
function sizeCanvas(){
var w = Math.round(Math.min(300, cv.clientWidth || 300) * (window.devicePixelRatio || 1));
if(w && cv.width !== w){ cv.width = w; cv.height = w; }
}
function drawGrid(t){
var W = cv.width, c = W / 64, C = W / 16, i, j;
ctx.clearRect(0, 0, W, W);
ctx.fillStyle = ‘#F3FAF9’; ctx.fillRect(0, 0, W, W);
// faux document content
ctx.fillStyle = ‘#0B2B2C’;
ctx.fillRect(W * .09, W * .08, W * .46, W * .035);
ctx.fillStyle = ‘rgba(11,43,44,.55)’;
for(i = 0; i < 5; i++){ ctx.fillRect(W * .09, W * (.17 + i * .045), W * (i === 4 ? .5 : .82), W * .014); }
ctx.strokeStyle = ‘rgba(11,43,44,.7)’; ctx.lineWidth = Math.max(1, W / 300);
for(i = 0; i < 4; i++){ for(j = 0; j < 3; j++){ ctx.strokeRect(W * (.09 + j * .273), W * (.44 + i * .07), W * .273, W * .07); } }
for(i = 0; i < 4; i++){ ctx.fillRect(W * .09, W * (.78 + i * .045), W * (i === 3 ? .36 : .82), W * .014); }
// fine grid fades out
if(t < 1){
ctx.strokeStyle = ‘rgba(0,145,145,’ + (0.5 * (1 – t)) + ‘)’; ctx.lineWidth = 1;
ctx.beginPath();
for(i = 0; i <= 64; i++){ ctx.moveTo(i * c, 0); ctx.lineTo(i * c, W); ctx.moveTo(0, i * c); ctx.lineTo(W, i * c); }
ctx.stroke();
}
// coarse token cells sweep in
if(t > 0){
for(i = 0; i < 16; i++){ for(j = 0; j < 16; j++){
var d = (i + j) / 30, a = Math.max(0, Math.min(1, (t * 1.6 – d) * 2.2));
if(a <= 0) continue;
ctx.fillStyle = ‘rgba(0,145,145,’ + (0.16 * a) + ‘)’;
ctx.fillRect(j * C + 1, i * C + 1, C – 2, C – 2);
ctx.strokeStyle = ‘rgba(0,145,145,’ + (0.9 * a) + ‘)’; ctx.lineWidth = Math.max(1, W / 260);
ctx.strokeRect(j * C + 1, i * C + 1, C – 2, C – 2);
}}
}
// counter chip
var n = Math.round(4096 – (4096 – 256) * t);
var label = n.toLocaleString(‘en-US’) + (t < 1 ? ‘ patches’ : ‘ tokens’);
ctx.font = ‘700 ‘ + Math.round(W * .058) + ‘px system-ui,sans-serif’;
var tw = ctx.measureText(label).width;
ctx.fillStyle = ‘#052526’; ctx.fillRect(W – tw – W * .07, W * .885, tw + W * .05, W * .09);
ctx.fillStyle = ‘#3BDACE’; ctx.textBaseline = ‘middle’; ctx.fillText(label, W – tw – W * .045, W * .932);
}
function runCompress(){
var my = ++animId, start = null, dur = RM ? 1 : 1700;
gridT = 0; drawGrid(0);
function frame(ts){
if(my !== animId) return;
if(start === null) start = ts;
var t = Math.min(1, (ts – start) / dur);
gridT = t < .5 ? 2 * t * t : 1 – Math.pow(-2 * t + 2, 2) / 2;
drawGrid(gridT);
if(t < 1) requestAnimationFrame(frame);
}
setTimeout(function(){ requestAnimationFrame(frame); }, RM ? 0 : 450);
}
$(‘compress’).addEventListener(‘click’, runCompress);
var tilesEl = $(’tiles’), th = ”;
for(var q = 0; q < 9; q++){ th += ‘<div class="tile">100</div>’; }
tilesEl.innerHTML = th;
function setTiles(n){
[].slice.call(tilesEl.children).forEach(function(el, i){ el.classList.toggle(‘on’, i < n); });
var total = 256 + 100 * n;
$(‘ntv’).textContent = n;
$(‘vt’).textContent = total.toLocaleString(‘en-US’);
$(‘fm’).textContent = ‘256 + 100 x ‘ + n + ‘ = ‘ + total.toLocaleString(‘en-US’) + (n === 9 ? ‘ (per-page maximum)’ : ”);
}
$(‘nt’).addEventListener(‘input’, function(e){ setTiles(+e.target.value); });
setTiles(0);
var exEl = $(‘experts’), eh = ”, tokN = 0;
for(q = 0; q < 64; q++){ eh += ‘<span class="ex"></span>’; }
exEl.innerHTML = eh;
function route(){
var pool = [], pick = {}, i;
for(i = 0; i < 64; i++) pool.push(i);
for(i = 0; i < 6; i++){ var r = Math.floor(Math.random() * pool.length); pick[pool.splice(r, 1)[0]] = 1; }
[].slice.call(exEl.children).forEach(function(el, idx){ el.classList.toggle(‘on’, !!pick[idx]); });
tokN++; $(‘rt’).textContent = ‘Token ‘ + tokN + ‘: 6 routed + 2 shared experts active’;
}
$(‘route’).addEventListener(‘click’, route);
setInterval(function(){ if(current === ‘p-enc’ && !RM && !document.hidden) route(); }, 1100);
route();
/* ———- speed ———- */
var L4 = {
eager:[[0,42.7,’1.00x’,”,”],[1,64.0,’1.50x’,’82.6%’,’1.83′],[2,77.9,’1.82x’,’69.1%’,’2.38′],[3,83.1,’1.95x’,’57.6%’,’2.73′]],
graph:[[0,158.3,’1.00x’,”,”],[1,185.6,’1.17x’,’82.9%’,’1.83′],[2,183.8,’1.16x’,’69.3%’,’2.38′],[3,172.9,’1.09x’,’57.9%’,’2.74′]]
};
var A100 = [[‘jina-ocr-v1′,’olmOCR-Bench 83.4’,2.57],[‘olmOCR-2′,’olmOCR-Bench 82.4’,1.22],[‘Surya OCR 2′,’3,568 tokens per page’,1.05],[‘dots.mocr’,’olmOCR-Bench 83.9′,0.55],[‘chandra-ocr-2′,’olmOCR-Bench 85.8’,0.38]];
var hw = ‘l4’, mode = ‘eager’;
function renderSpeed(){
var h = ”, rows, max, best = 0, i;
$(‘modeseg’).style.display = hw === ‘l4’ ? ” : ‘none’;
if(hw === ‘l4’){
rows = L4[mode]; max = 200;
for(i = 1; i < rows.length; i++){ if(rows[i][1] > rows[best][1]) best = i; }
rows.forEach(function(r, idx){
h += ‘<div class="bar’ + (idx === best ? ‘ best’ : ”) + ‘"><div class="nm">’ + (r[0] === 0 ? ‘Draft head off’ : ‘K = ‘ + r[0]) + ‘<small>’ + (r[0] === 0 ? ‘plain decoding’ : r[3] + ‘ accepted, ‘ + r[4] + ‘ per step’) + ‘</small></div><div class="tr"><div class="fl" data-w="’ + (100 * r[1] / max) + ‘"></div></div><div class="vl">’ + r[1].toFixed(1) + ‘<small>tok/s, ‘ + r[2] + ‘</small></div></div>’;
});
$(‘readout’).textContent = mode === ‘eager’
? ‘In eager mode, each plain step is slow, so drafting pays off most. K=3 lifts decoding from 42.7 to 83.1 tokens per second.’
: ‘CUDA graphs already make plain steps fast, so a speculative step costs relatively more. K=1 is the best setting at 185.6 tokens per second.’;
$(‘spdnote’).textContent = ‘NVIDIA L4, olmOCR-Bench, batch size 1, measured by Jina AI. Accept rates and tokens per step barely change between modes. Not comparable with the A100 batch figures.’;
} else {
rows = A100; max = 2.8;
rows.forEach(function(r, idx){
h += ‘<div class="bar’ + (idx === 0 ? ‘ best’ : ”) + ‘"><div class="nm">’ + r[0] + ‘<small>’ + r[1] + ‘</small></div><div class="tr"><div class="fl" data-w="’ + (100 * r[2] / max) + ‘"></div></div><div class="vl">’ + r[2].toFixed(2) + ‘<small>pages/s</small></div></div>’;
});
$(‘readout’).textContent = ‘Pages per second is tokens per second divided by tokens per page. jina-ocr-v1 pairs 2,792 tokens per second with 1,085 tokens per page. Surya OCR 2 is faster per token at 3,760 but writes 3,568 tokens per page.’;
$(‘spdnote’).textContent = ‘One A100 40 GB, concurrency 32, 1,403 olmOCR-Bench pages, measured by Jina AI. 5 of the 14 systems in the comparison are shown. chandra-ocr-2 and dots.mocr score higher on olmOCR-Bench but parse fewer pages per second.’;
}
$(‘bars’).innerHTML = h;
requestAnimationFrame(function(){ requestAnimationFrame(function(){
[].slice.call(document.querySelectorAll(‘#bars .fl’)).forEach(function(el){ el.style.width = el.dataset.w + ‘%’; });
}); });
postH();
}
function seg(id, attr, fn){
[].slice.call(document.querySelectorAll(‘#’ + id + ‘ button’)).forEach(function(b){
b.addEventListener(‘click’, function(){
[].slice.call(document.querySelectorAll(‘#’ + id + ‘ button’)).forEach(function(x){ x.setAttribute(‘aria-pressed’, x === b ? ‘true’ : ‘false’); });
fn(b.dataset[attr]); renderSpeed();
});
});
}
seg(‘hwseg’, ‘hw’, function(v){ hw = v; });
seg(‘modeseg’, ‘mode’, function(v){ mode = v; });
/* ———- rewards ———- */
var TERMS = [
{k:’content’, n:’Content match’, f:0, v:.95},
{k:’table’, n:’Table structure’, f:.1, v:.9},
{k:’struct’, n:’Valid markup’, f:.2, v:1},
{k:’unit’, n:’Unit tests passed’, f:.2, v:.8},
{k:’rep’, n:’No repetition’, f:0, v:1}
];
var PRE = {
clean:{content:.95, table:.9, struct:1, unit:.8, rep:1},
tag:{content:.95, table:.9, struct:0, unit:.8, rep:1},
loop:{content:.9, table:.9, struct:1, unit:.6, rep:0}
};
var sh = ”;
TERMS.forEach(function(t){
sh += ‘<div class="sl"><label for="r-‘ + t.k + ‘">’ + t.n + ‘</label><input type="range" id="r-‘ + t.k + ‘" min="0" max="1" step="0.05" value="’ + t.v + ‘"><span class="fv" id="v-‘ + t.k + ‘"></span><span class="fl2">’ + (t.f ? ‘floor ‘ + t.f : ‘no floor’) + ‘</span></div>’;
});
$(‘sliders’).innerHTML = sh;
function calc(){
var a = 1, b = 1, zeroBy = null, hardZero = null;
TERMS.forEach(function(t){
var v = +$(‘r-‘ + t.k).value;
$(‘v-‘ + t.k).textContent = v.toFixed(2);
a *= Math.max(v, t.f); b *= v;
if(v === 0 && !zeroBy) zeroBy = t;
if(v === 0 && !t.f && !hardZero) hardZero = t;
});
function put(id, val){
var el = $(id); el.querySelector(‘b’).textContent = val.toFixed(2);
el.querySelector(‘.gauge div’).style.width = (100 * val) + ‘%’;
el.classList.toggle(‘zero’, val === 0);
}
put(‘r-with’, a); put(‘r-wo’, b);
var msg;
if(a === 0){ msg = hardZero.k === ‘rep’ ? ‘Repetition has no floor, so a degenerate loop zeroes the reward. Jina AI leaves it unfloored because loops can inflate the content score.’ : ‘The paper gives no floor for the content term, so a page with no matching text earns nothing.’; }
else if(b === 0){ msg = ‘Without floors, the failed "’ + zeroBy.n.toLowerCase() + ‘" check zeroes the product and the page teaches nothing. With the floor, the reward stays at ‘ + a.toFixed(2) + ‘ and the gradient survives.’; }
else { msg = ‘Every term is graded between 0 and 1, so a partly correct page still earns partial credit.’; }
$(‘rewmsg’).textContent = msg;
postH();
}
TERMS.forEach(function(t){ $(‘r-‘ + t.k).addEventListener(‘input’, calc); });
[].slice.call(document.querySelectorAll(‘[data-pre]’)).forEach(function(b){
b.addEventListener(‘click’, function(){
var p = PRE[b.dataset.pre];
TERMS.forEach(function(t){ $(‘r-‘ + t.k).value = p[t.k]; });
calc();
});
});
calc();
/* ———- start ———- */
resetSim();
if(!RM){ setTimeout(function(){ setPlaying(true); }, 700); } else { $(‘play’).textContent = ‘Play’; }
})();
</script>
</body>
</html>
” style=”width:100%;border:0;height:600px;display:block;overflow:hidden” loading=”lazy” scrolling=”no” title=”How jina-ocr-v1 reads a page: interactive explainer”>
Post-Training With Dense Verifiable Rewards
Post-training combines instruction alignment, robustness fine-tuning on degraded pages, and GRPO. Every reward term is deterministic code scored against a reference transcription. The terms cover content, formulas, tables, structural validity, unit tests, repetition and format.
The terms are multiplied, and each one is graded, so partly correct pages earn partial credit. Structural, unit-test and format terms are floored at 0.2, and the table term at 0.1. The repetition term has no floor, because loops can inflate the content score.
On natural pages, the formula and table rewards apply to few samples. Jina AI therefore built JinaOCRSynth, synthetic pages packed with both, each carrying olmOCR-Bench-style unit tests. An agent also merges candidate checkpoints under a fixed evaluation budget. The draft head is trained last, against the frozen final verifier.
Benchmarks and Throughput
| Model | Params as listed in the paper | OmniDocBench v1.6 | olmOCR-Bench |
|---|---|---|---|
| jina-ocr-v1 | 3B/570M | 91.14 | 83.4 |
| DeepSeek-OCR | 3B/570M | not listed | 76.0 |
| DeepSeek-OCR-2 | 3B/570M | 90.25 | not listed |
| PaddleOCR-VL-1.6 | 0.9B | 96.34 | not listed |
| chandra-ocr-2 | 4B | not listed | 85.8 |
| Qwen3-VL-235B | 235B/22B | 89.78 | not listed |
For MoE models, params show decoder total and active counts. The whole jina-ocr-v1 model is about 3.4B.
The model does not lead on accuracy. PaddleOCR-VL-1.6 and HunyuanOCR-1.5 (94.74) score higher on OmniDocBench. chandra-ocr-2 and dots.mocr (83.9) score higher on olmOCR-Bench. Post-training does add 7.4 points over the DeepSeek-OCR backbone on olmOCR-Bench.
Throughput is the main result. On 1 A100 40 GB at concurrency 32, jina-ocr-v1 parses 2.57 pages per second. That is the highest of 14 systems Jina AI measured, against 1.22 for olmOCR-2 and 0.38 for chandra-ocr-2. It emits 1,085 output tokens per page. Jina AI says that is the shortest output among systems scoring above 83.
On an NVIDIA L4 at batch size 1, eager decoding rises from 42.7 to 83.1 tokens per second. That is a 1.95x speedup at a 57.6% acceptance rate. With CUDA graphs the baseline is already 158.3 tokens per second. There, K=1 works best at 185.6 tokens per second, a 1.17x gain.
How to Run It
The quickest route is Jina Reader. Send a URL to r.jina.ai with the header X-Respond-With: jina-ocr-v1. Reader fetches the page or PDF, runs the model and returns Markdown. An X-Page header transcribes 1 page of a longer document.
Jina AI also hosts an OpenAI-compatible endpoint at https://api.jina.ai/v1/chat/completions. A hosted demo is available for quick tests.
For self-hosting, weights and custom code ship in 1 repository and load with trust_remote_code=True. FastMTP requires vLLM 0.21 or later and a one-time register() call. The Transformers path runs the MoE decoder alone and ignores the draft weights.
Key Takeaways
- 3.4B total parameters, about 570M active per token, built on DeepSeek-OCR.
- FastMTP drafts 3 tokens per step, and greedy verification keeps decoding lossless.
- Scores 91.14 on OmniDocBench v1.6 and 83.4 on olmOCR-Bench.
- Reaches 2.57 pages per second on 1 A100, the highest of 14 measured systems.
- Available on Hugging Face and through a Jina Reader header today.
Check out the Paper, Model weights, Release post, Model page and Announcement. All credit goes to the researcher of this project. Also, feel free to follow us on Twitter and don’t forget to join our 150k+ML SubReddit and Subscribe to our Newsletter. Wait! are you on telegram? now you can join us on telegram as well.
Need to partner with us for promoting your GitHub Repo OR Hugging Face Page OR Product Release OR Webinar etc.? Connect with us
The post Jina AI Releases jina-ocr-v1: A 3.4B MoE Document Parser With Built-In Speculative Decoding for Low-Budget GPUs appeared first on MarkTechPost.