Thai OCR for knowledge bases and document ingestion
Read Thai PDFs and scans into structured Markdown, so an index holds the document's headings and tables instead of a wall of text.
What comes back
# คู่มือพนักงาน
## ๓. การลาพักร้อน
พนักงานที่ทำงานครบหนึ่งปีมีสิทธิลาพักร้อนได้
ปีละหกวันทำการ
### ๓.๑ การยื่นคำขอ
ยื่นล่วงหน้าไม่น้อยกว่าเจ็ดวันheading ข้อกำหนดทางเทคนิค
table 14 rows x 4 columns
paragraph ค่าที่ระบุเป็นค่าที่วัดที่อุณหภูมิห้อง## หน้า 1
# ประกาศ เรื่อง แนวปฏิบัติการเบิกจ่าย
## หน้า 2
๑. ให้ถือปฏิบัติตั้งแต่วันที่ ๑ ตุลาคม ๒๕๖๙
๒. ยกเลิกประกาศฉบับลงวันที่ ๑๕ มีนาคม ๒๕๖๗The reads above are illustrations of shape, not captured responses. The OCR page carries the model's own witnessed read of a real specimen, with its capture date.
How ingestion works
Retrieval is only as good as what went into the index, and in Thailand a large share of what an assistant needs to know is a PDF that was scanned rather than typed. Extracting a flat string from those pages produces something an index will happily store and an assistant will quote badly, because every heading, every table, and every page boundary has been dissolved into it. The read that helps is the one that comes back with its structure intact.
- Read once, index the textSend each document in batches of up to 50 pages. Ingestion is a one-time cost per document, and the text is yours to store, chunk, and embed however your stack prefers.
- Chunk on structure, not on lengthSplit on the headings the read returned. A section-sized chunk keeps a rule and its exception together, which a fixed character window will separate sooner or later.
- Cite the pageKeep the page number the read came back with. An assistant that can name the page it answered from is one a reader can check.
What structure gives a chunker
flat text ...ครบหนึ่งปีมีสิทธิลาพักร้อนได้ปีละหกวันทำการ๓.๑ การยื่นคำขอยื่นล่วงหน้าไม่น้อยกว่าเจ็ดวัน... → split on 500 characters, mid-sentence, mid-clause markdown ## ๓. การลาพักร้อน ### ๓.๑ การยื่นคำขอ → split on headings, one section per chunk
Thai writes without spaces between words, so a flat extraction gives a splitter nothing to cut on except a character count. Headings and tables give it real boundaries.
An ingestion run, priced
| Workload | Pages | Credits |
|---|---|---|
| One handbook (180 pages) | 180 | 1,170 |
| One request, at the 50-page ceiling | 50 | 325 |
| A 500-document corpus | 39,000 credits | |
At 12 pages a document and 6.5 credits a page, an ingestion budget is arithmetic you can do before writing any code.
What a citation looks like afterwards
question ลาพักร้อนได้ปีละกี่วัน
chunk คู่มือพนักงาน · ๓. การลาพักร้อน · หน้า 12
answer หกวันทำการต่อปี สำหรับพนักงาน
ที่ทำงานครบหนึ่งปี
the page number came from the read,
so a reader can open page 12 and checkThe response keeps its pages separate, so the page number survives into the index and out again into the answer. An assistant that can name its page is one a reader can verify.
For developers
Markdown for a chunker that splits on headings; structured output when your pipeline would rather walk typed blocks than parse them back out.
# Encode without line wrapping: wrapped base64 breaks the JSON string.
DOC=$(base64 < invoice.pdf | tr -d '\n')
# --max-time covers a multi-page document; curl defaults to no limit.
curl -X POST https://api.paxalabs.com/v1/ocr \
--max-time 300 \
-H "Authorization: Bearer $PAXA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"document\": \"$DOC\", \"model\": \"paxa-ocr-lite-v1\"}"DOC=$(base64 < invoice.pdf | tr -d '\n')
curl -X POST https://api.paxalabs.com/v1/ocr \
--max-time 300 \
-H "Authorization: Bearer $PAXA_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"document\": \"$DOC\", \"model\": \"paxa-ocr-lite-v1\", \"output\": \"structured\"}"import base64
import os
import requests
with open("invoice.pdf", "rb") as file:
document = base64.b64encode(file.read()).decode()
response = requests.post(
"https://api.paxalabs.com/v1/ocr",
headers={"Authorization": f"Bearer {os.environ['PAXA_API_KEY']}"},
json={"document": document, "model": "paxa-ocr-lite-v1"},
# A multi-page document can run for minutes; give it room.
timeout=300,
)
response.raise_for_status()
body = response.json()
print(body["pages"][0]["markdown"], body["usage"]["credits"], "credits")A language with no word spaces punishes naive chunking
Chunkers usually fall back on character counts, and in a language written with spaces that mostly lands between words. Thai has no spaces between words, so a fixed window cuts wherever it happens to land, including inside a word and inside a numeral. Structure is the way out: a read that returns headings, lists, and tables gives a splitter boundaries the document itself put there.
Common questions
- Do you do the chunking and embedding too?
- No. This is the read. What you index, how you chunk it, and which embedding model you use stay in your stack, where they belong.
- Which output should a retrieval pipeline use?
- Markdown, if your chunker splits on headings, which most do. Structured output if your pipeline would rather walk typed blocks (heading, paragraph, list, table, figure) than parse Markdown back apart.
- What about documents that are already digital text?
- If a PDF already carries a reliable text layer, extract it directly and save the credits. The read is for pages where that text does not exist or cannot be trusted.
- Can the same pipeline handle documents in other languages?
- The read handles the page it is given. If the corpus is multilingual and the readers are Thai, the translation API is the next call: 14 source languages into Thai, with the document's own context carried along.
Start with free credits
Sign in with Google, GitHub, or Hugging Face and spend 100 one-time free credits on your own text.