OCR for Devanagari Conjunct Ligatures
9 min read · updated August 11, 2026
An OCR engine reports a low error rate on your Hindi corpus and the extracted text is still unusable for the documents that matter. The errors are almost all in one place — the fused consonant clusters — and because those are a modest share of the characters, a corpus-level figure averages them into invisibility.
What a conjunct is
Devanagari is an abugida: a consonant letter carries an inherent a vowel unless something removes it. The thing that removes it is the virama, encoded as U+094D, and when a virama sits between two consonants the pair is normally not rendered as two letters with a mark between them. It is rendered as a conjunct — a single fused glyph.
There are two shapes this takes. Some consonants have a half form: the letter loses its vertical stem and joins directly to the following letter, so the cluster is written as one horizontally connected shape. Others stack vertically, with the first consonant above the second. And a handful are irreducible ligatures whose shape cannot be decomposed into its parts at all — the clusters conventionally transliterated kṣa, tra and jña are the standard examples, and none of them looks like the letters that form it.
How many conjuncts exist in practice is a property of the font rather than of the script — a typeface that supports Hindi, Marathi and Sanskrit will carry several hundred conjunct glyphs, and a display face may carry far fewer and fall back to a half-form rendering. The Unicode Standard’s chapter on South Asian scripts documents the virama model and the rendering choices it permits, and it is the right place to check before assuming any particular behaviour.
Why they break recognisers
Three properties combine, and each one alone would be manageable.
- The visual unit is not the character. A conjunct is one connected shape that maps to three or more code points — consonant, virama, consonant. A recogniser producing one label per visual unit needs a label set that includes every conjunct, not just every letter.
- The distribution has a long, thin tail. A few conjuncts are extremely common and hundreds are rare. Training data is distributed the same way, so the rare ones have very few examples, and the rare ones are also the visually unusual ones. This is the worst possible pairing.
- Components are distorted, not merely joined. In a stacked conjunct the upper component is compressed and often loses part of its shape. A classifier that learned the letter in isolation has not seen what remains of it, so decomposing the glyph into known letters does not work.
The consequence is that conjunct errors are qualitatively different from ordinary substitution errors. The engine does not return a slightly wrong cluster; it frequently returns a plausible simple consonant sequence with the virama dropped, which changes the word and leaves valid Devanagari behind. Nothing downstream can tell.
The headline bar and segmentation
Devanagari joins the letters of a word under a continuous horizontal bar, the shirorekha. Like Arabic joining, this means a word is one connected component and the classical trick of segmenting on vertical gaps does not apply.
The traditional workaround is to detect the headline, remove it, and then segment the disconnected remainder. It works reasonably for simple letter sequences and fails specifically on conjuncts: a vertically stacked cluster is still one connected blob after the bar is gone, because the components are joined below the line rather than by it. So the segmentation stage hands the classifier exactly the shapes it is least equipped for, and does so silently. This is the mechanical reason conjunct errors cluster rather than scattering.
Line-level sequence models avoid the explicit segmentation step and handle this better, in the same way they helped Arabic. They do not remove the data-distribution problem: a rare conjunct is still rare in the training lines.
Visual order is not logical order
This one produces bugs that look like OCR errors and are not, and it catches almost everybody once.
The vowel sign i, U+093F, is drawn to the left of the consonant it modifies, but in the Unicode encoding it is stored after that consonant. The reph — the r-sound at the start of a cluster — is drawn as a hook above the end of the cluster, far to the right of where it is stored. So the left-to-right sequence of marks on the page is not the code-point sequence of the text.
An OCR engine that recognises glyphs and emits them in the order it scanned them produces text in visual order. It renders identically on screen, which is why nobody notices, and it fails every exact-match search, every string comparison and every deduplication check against correctly encoded text. If your Devanagari extraction looks right and does not match, test for this before testing anything else: compare the code-point sequence of an extracted word against the same word typed natively.
Normalisation is worth applying either way. Devanagari has precomposed forms and combining sequences that represent the same text, and the nukta-bearing letters in particular can arrive both ways; the general mechanics are in the difference between NFC and NFKC normalisation. Normalisation does not fix visual-order output — that requires reordering — but it removes a second class of near-miss.
The metric that shows the problem
The reason this failure survives evaluation is arithmetic. If conjuncts are a modest fraction of the characters in a corpus and the engine gets most simple characters right, then even a very poor conjunct accuracy moves the overall character error rate by a small amount — an amount easily attributed to scan quality. The corpus-level number is not wrong; it is answering a question you did not mean to ask.
Compute a conjunct-restricted error rate instead. Filter the ground truth for clusters containing U+094D, align them with the recognised output, and score only those positions.
import regex # the third-party module, for \X grapheme-cluster support
VIRAMA = "\u094D"
def conjunct_clusters(text):
"""Grapheme clusters that contain a virama, i.e. the fused ones."""
return [g for g in regex.findall(r"\X", text) if VIRAMA in g]
def conjunct_error_rate(truth, hypothesis):
t = conjunct_clusters(truth)
h = conjunct_clusters(hypothesis)
# Align on the full cluster sequence, then compare only these positions;
# a dropped virama shows up as a missing cluster, which is the common error.
return levenshtein(t, h) / max(len(t), 1)
# Report both. The gap between them is the finding.
print("overall CER ", cer(truth, hypothesis))
print("conjunct CER ", conjunct_error_rate(truth, hypothesis))Report the two side by side. A large gap tells you the engine is fine on simple text and unusable on Sanskrit-heavy, legal or technical material where conjunct density is much higher than in casual prose — and that is a routing decision, not a scan-quality one. It also tells you whether a language-model correction pass is worth adding: correcting a dropped virama from context is something a model can do, and it is a much narrower task than correcting arbitrary OCR noise. The downstream effects on embedding and chunking are covered in splitting Devanagari text for embedding.