BlogOCR

Feeding Thai documents to an LLM: OCR comes first

A PDF is a drawing of text, and a Thai one fails a language model twice. How to read the pages first, what to hand the model, and how to pay for the reading once.

· 9 min read

A PDF is a drawing of text. It records where each glyph sits on the page, and whether the characters behind those glyphs are recoverable depends on how the file was made. A language model reasons over text it receives, and for a Thai document the text it receives is usually wrong in one of two ways. A text layer, when there is one, carries Thai in an order no reader wrote. A scan carries no text at all, and a model asked to read the image is guessing at marks a few pixels tall.

This guide is about the step between the file and the prompt. It covers what a model receives from a Thai PDF and why a reader built for Thai pages goes first. It then covers what shape to hand the model, how to handle tables and page boundaries, and how to pay for the reading once. The examples use Paxa OCR Lite for the reading, and the shape of the pipeline holds for any reader that returns structure.

What a model receives from a PDF#

Sending a PDF to a model API does one of two things on your behalf, and hides which. The service extracts the text layer and puts it in the prompt, or it renders the pages to images and puts those in the prompt. Both paths work well enough on an English contract. Both fail on a Thai one for reasons that are specific to the script.

A Thai text layer is built from the glyphs the producing software placed. Thai fonts keep repositioned copies of tone marks and upper vowels as private-use glyphs, shifted to clear a tall consonant such as ป or ฝ, and a text layer that names those glyphs extracts as code points no text tool recognizes. Other producers emit a mark before the letter it sits on. The extraction post catalogs the failures. The line below is one printed line and two things a text layer makes of it.

one line, two text layers
printed        ปุ๋ยอินทรีย์ ถุงละ ๒๕๐ บาท
text layer A   ปยอินทรีย ถงละ ๒๕๐ บาท
text layer B   ป๋ ุยอินทรีย ์ ถ ุงละ ๒๕๐ บาท

The first layer dropped every mark the font stored as a private glyph. The second kept the marks and stored two of them out of order. A model reading either receives a string that spells nothing. It is not reading nonsense, because it can often reconstruct the word from the letters around it, and that is worse. The model fills the gap with the most likely word, and the most likely word on an invoice is a plausible wrong number.

Why a Thai scan needs a reader before the model#

The image path avoids the text layer and meets the script directly. A Thai line uses four vertical levels. A vowel can hang below the consonant, a vowel or ั can sit above it, and a tone mark sits above that. One column stacks up to three glyphs, and the marks that carry the meaning are the smallest things on the page. The four tone marks, ่ ้ ๊ ๋, and the upper vowels ิ ี ึ ื are told apart by a stroke or a dot a few pixels wide at a normal scan resolution.

A general model resizes an image to a fixed budget of pixels before it reads it, and a full page of Thai at that budget leaves a tone mark with too little to go on. The model reads the consonants, which are large, and infers the marks, which are small. The result reads as fluent Thai and disagrees with the page on the words that differ by one mark. ข่าว and ข้าว are one such pair, and an amount column has a dozen more.

A reader trained on Thai pages reads at the page's resolution and outputs characters once. Text is what a model is good at. Reading a page once and handing the model the text puts each system on the task it does well. It also makes the reading a stored artifact a person can open and check.

Markdown as the interchange format#

Ask the reader for Markdown. POST /v1/ocr returns each page as GitHub-flavored Markdown in reading order by default. Headings arrive as heading marks, lists as list items, and tables as pipe tables. The structure is carried in plain characters that survive a prompt unchanged, and a model has read more Markdown than any other structured text. The page below is one page of a quotation as the reader returns it.

one page as Markdown
# ใบเสนอราคา

เลขที่ QT-2569-0142 วันที่ 15 กรกฎาคม 2569

| รายการ | จำนวน | ราคาต่อหน่วย | รวม |
| --- | --- | --- | --- |
| ปุ๋ยอินทรีย์ 25 กก. | 40 ถุง | 250 | 10,000 |
| ค่าขนส่ง | 1 | 800 | 800 |

ยืนราคา 30 วัน นับจากวันที่ในใบเสนอราคา

Everything the model needs is on the page and nothing else is. There is no geometry, no font name, no bounding box, and no confidence score to explain away in the prompt. The table is a table, and a question about the total finds the total in a cell under a column heading. Join pages with a blank line to rebuild the document, and the document is a prompt.

Two things are worth adding before the join. Mark each page boundary with a comment such as <!-- page 3 -->, which a Markdown renderer hides and a model reads as a marker, and the model can cite the page in its answer. And keep the Thai digits as printed. A model reads ๒๕๐ correctly, and a citation that shows the reader's text should match the scan. The search index post covers the normalized copy a retrieval index needs beside the original.

Tables, running headers, and page boundaries#

A table is one object, and it should reach the model whole. A pipe table split across two pages by the scan arrives as two tables with one header. The pipeline should join them when the second page opens on rows with no heading above them. A table wider than the prompt can bear is the one case where the Markdown shape fights the model, and the structured output below is the answer to it.

Running headers and footers repeat on every page. A document id, a company name, a page number, and a confidentiality line arrive once per page and add nothing after the first. Detect lines that recur at the top or bottom of most pages and drop them, and keep the page number in the boundary marker where it does the work of a citation.

A long document is read in parts. One request reads up to 50 pages and 10 MiB, and a 200-page manual is 4 requests. Number the pages continuously across the parts, because the page is what an answer cites, and a part that restarts at page 1 breaks every citation after the first part.

Read once, and pay for the reading once#

A page costs 6.5 credits whatever it holds. The 200-page manual is 1,300 credits, which is $1.30, and that is the cost of every question ever asked of it if the reading is stored. A pipeline that reads the document inside each request pays that again per question. Key the cache on a hash of the file's bytes, and a document uploaded twice is read once.

the read-once pipeline
key    = sha256(bytes)
pages  = cache.get(key)
if pages is missing:
    pages = POST /v1/ocr { document: base64(bytes), model, output: "markdown" }
    cache.put(key, pages)
text   = join(pages, page => "<!-- page " + page.page + " -->\n" + page.markdown)
answer = model(system, text, question)

Store the reader's output beside the source hash, and rebuild the prompt from stored text every time. The page count is read before the charge, and a document the reader cannot open is refused without one. A request that fails after the charge is refunded. Send an Idempotency-Key on the read and a retry after a timeout replays the stored result without a second charge. The OCR guide covers the billing rules.

The prompt around the reading#

The reading is the document. The prompt around it sets what the model may do with it, and three rules matter more for a Thai document than for an English one.

  • Answer from the text alone, and say when the text does not contain the answer. A model that falls back on what it knows about Thai invoices in general will produce a plausible field the page never had.
  • Quote Thai verbatim. A model asked to summarize will normalize spellings and drop particles, and a quotation that differs from the page by one mark is a quotation a reviewer cannot find.
  • Keep the digits and the calendar as printed, and convert on a separate line. ๒๕๖๙, 2569, and 2026 name the same year in three notations, and a model that converts in place leaves the citation pointing at text that no longer matches the page.
a system prompt for a read document
You are answering questions about a Thai document.
The document follows, one page per <!-- page N --> marker.
Answer only from the document. When the answer is absent, say so.
Quote Thai text exactly as written, and name the page marker it came from.
Keep digits, amounts, and years as printed. Put any conversion on a separate line.

A photo of a document goes through the same path. An image counts as one page, the reader accepts PNG, JPEG, and WebP beside PDF, and a phone photo of a receipt costs 6.5 credits like any other page. Photograph the whole page, and the reader returns its text in the same Markdown a scan gets.

Structured blocks for extraction#

Set output to "structured" and each page arrives as typed blocks in reading order. Headings carry a level, lists carry their items, and tables carry rows of cells. A pipeline that extracts fields can route each block by type. Tables go to code, which reads a cell by column name without a model. Paragraphs go to the model with the heading trail above them as context. Figures carry a caption and nothing else.

The rule that follows is worth stating plainly. Leave every number that sits in a table block to the code. The code reads it exactly, and the model reads it probably. Ask the model for what the code cannot do, which is to say which of three paragraphs states the payment terms, or whether a clause obliges the buyer or the seller. Validate what comes back against a schema, and keep the block the answer came from as the citation.

A wide table is the other reason to use blocks. Flatten each row into a line of column name and value pairs. A table that would have wrapped past recognition as pipes becomes a list, and the model reads it without losing which value belonged to which column.

Where the model still earns its place#

The reader turns the page into text. The model does the rest. It summarizes a fifty-page regulation, answers a question across a folder of contracts, and classifies a document by its first page. It normalizes the four ways a Thai document writes a date into one field, and it drafts the reply to a letter. Every one of those tasks is a text task, and every one of them is only as good as the text it starts from.

Next steps#

  • Read the OCR guide for the outputs and limits, and the reference for the page and block fields.
  • Read the search index post for chunking the stored text when the folder outgrows one prompt.
  • Read the extraction post for what a Thai text layer gets wrong and how to check a reading.
  • Open the documents tool to read one page and see the Markdown and the blocks it returns.