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.
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.
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.
[‘
‘,1],[‘
‘,1],[‘
‘,1],[‘
‘,1],[‘\n’,1],
[‘
‘,1],[‘
‘,1],[‘
‘,1],[‘
‘,1],[‘\n’,1],
[‘
‘,1],[‘
‘,1],[‘
‘,1],[‘
‘,1],[‘\n’,1],
[”,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 = ‘‘;
$(‘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 += ‘‘ + (K === 0 ? ‘off’ : ‘draft ‘ + (i + 1)) + ‘‘; }
$(‘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] || ‘
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 += ‘
100
‘; }
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 += ‘‘; }
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 += ‘
‘;
});
$(‘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 += ‘
‘;
});
$(‘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 += ‘
‘ + (t.f ? ‘floor ‘ + t.f : ‘no floor’) + ‘
‘;
});
$(‘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”; }
})();
