Building RAG Over a Right-to-Left PDF
10 min read · updated August 11, 2026
A PDF does not store sentences. It stores instructions to draw runs of glyphs at coordinates, and an extractor reconstructs reading order by guessing from geometry. For left-to-right text the guess is almost always right. For Arabic and Hebrew it is a coin flip, and when it goes wrong the text is not corrupted in a way anything downstream detects.
Why PDFs scramble RTL text
The page content stream contains show-text operators with positions. A typical extractor collects the runs on a line, sorts them by increasing x coordinate, and concatenates. For a left-to-right line that reproduces reading order exactly. For a right-to-left line, increasing x is reverse reading order, so the first word spoken ends up last in the string.
Whether that happens depends on the producer as much as the extractor. Some producers emit one show-text operator per line in logical order and let the viewer do bidi layout; those extract cleanly. Others, particularly older typesetting tools and anything that rasterised and re-vectorised, emit each visual segment as its own operator in visual order, sometimes with the glyphs themselves already reversed. There is no flag in the file that tells you which you have.
A mixed line is the worst case. Arabic text containing an English product name or a number is bidirectional: the Arabic runs go one way and the embedded Latin run goes the other, and an x-sorted extraction gets the relative order of the runs wrong even when each run is internally fine. That is the same underlying mechanism as digits appearing reversed in Arabic output.
Three states your text can be in
- Logical order, abstract letters. What you want. Characters are in speaking order and each letter is its base code point. Renders correctly in any bidi-aware viewer, matches queries typed on an Arabic or Hebrew keyboard.
- Logical order, presentation forms. Order is right but letters are stored as positional shape variants from the Arabic Presentation Forms blocks. Looks fine in a terminal and matches nothing. Fixed by NFKC.
- Visual order. Characters are in the order they were drawn, so the string is the sentence backwards, often per line rather than per paragraph. This is the state that has to be detected before anything else, because every later step operates on the wrong sequence.
Visual order and presentation forms travel together often enough that people conflate them. They are independent: you can have either, both, or neither.
The diagnostic
Do not try to detect this by looking at rendered output — your terminal, your browser and your editor all apply bidi layout, and a visually-ordered string rendered by a bidi-aware renderer looks reversed, while a logically-ordered one looks right. That is a usable signal only if you know which renderer you are looking at, so it is safer to inspect code points directly.
- Pick a page you can read, or have someone read, and find one short line whose first word you know.
- Extract that line and print its code points in order with names.
- Check whether the first code point belongs to the first word of the sentence as spoken. If it belongs to the last word, you have visual order.
- Check whether any code point falls in U+FB50–U+FDFF or U+FE70–U+FEFF. If so, you have presentation forms as well.
- Repeat on a line containing a number or an English word, because mixed-direction lines can be scrambled while pure lines are fine.
import fitz, unicodedata # PyMuPDF
doc = fitz.open("report_ar.pdf")
page = doc[0]
# "dict" gives per-line structure including a writing-direction vector.
d = page.get_text("dict")
for block in d["blocks"]:
for line in block.get("lines", []):
text = "".join(span["text"] for span in line["spans"])
if not text.strip():
continue
print(line["dir"], repr(text[:40]))
for ch in text[:12]:
print(" ", hex(ord(ch)), unicodedata.name(ch, "?"))
break
break
# dir == (1.0, 0.0) -> the line was laid out left-to-right
# Presentation forms -> any codepoint in FB50..FDFF or FE70..FEFFThe dir vector reports the writing direction the extractor inferred for the line, and it is a hint rather than an answer: a producer that drew RTL text as a sequence of left-to-right runs reports the direction of the runs. Treat a surprising dir as a reason to look at the code points, not as the conclusion.
Repairing each state
Presentation forms: apply NFKC. The forms carry compatibility decompositions to their base letters and ligatures decompose into their components, so one call fixes the whole class.
Visual order: this cannot be fixed reliably by reversing the string. Reversing a pure-Arabic line works; reversing a line with an embedded English phrase or number reverses that too, and now the English is backwards. The correct repair is to apply the Unicode bidirectional algorithm in the direction opposite to the one that produced the damage — a visual-to-logical conversion — which libraries expose as the inverse of the usual display transform. If you have a choice, the far better repair is to extract differently:
- Try another extractor before writing any reordering code. Different libraries reconstruct order with different heuristics and one of them frequently gets a given producer right.
- Extract with per-span coordinates rather than as flat text, then sort spans yourself by decreasing x within a line for RTL paragraphs. You have more information than the flat-text path does.
- If the PDF is a scan, ignore all of this and run OCR, which produces logical order directly because it recognises words rather than sorting glyphs.
The pipeline, once the text is right
- Extract, then run the diagnostic on a sample from every distinct producer in the corpus. Check the PDF
Producermetadata field and sample one document per value — files from one producer behave alike, and this turns a per-document problem into a per-producer one. - Repair to logical order and abstract letters. Assert afterwards that no code point remains in the presentation-form ranges.
- Normalise and fold for search as described in building RAG over Arabic script documents, keeping the unfolded text for display.
- Segment on Arabic punctuation, pack to a token budget, and index with a language tag.
- Add a permanent guard to ingest: reject or quarantine any document where more than a small fraction of code points are presentation forms. This is the check that catches the day someone adds a new source and the whole class of bugs returns.
The guard matters more than the one-off repair. Extraction quality is a property of the document supply, and document supply changes without anyone telling the retrieval team.