Skip to main content

Extracting text from documents with GLM-OCR

GLM-OCR is a document OCR model — built only for reading text, not for chat or image understanding. Use it when you need to digitise PDFs, parse scanned invoices, or pull fields from forms.

Setup

Install the OpenAI library and store your API key from mStudio:

user@local $ pip install openai
user@local $ export OPENAI_API_KEY="sk-…"

All examples below use a shared client:

from openai import OpenAI

client = OpenAI(base_url="https://llm.aihosting.mittwald.de/v1")

Extracting text from a PDF

The most common use case: reading a scanned or digital PDF. The integrated proxy converts each page to an image automatically — you just send the file as a base64 data URI.

import base64
from openai import OpenAI

client = OpenAI(base_url="https://llm.aihosting.mittwald.de/v1")

with open("invoice.pdf", "rb") as f:
pdf_b64 = base64.b64encode(f.read()).decode()

response = client.chat.completions.create(
model="GLM-OCR",
messages=[{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:application/pdf;base64,{pdf_b64}"},
},
{"type": "text", "text": "Extract all text from this document."},
],
}],
temperature=0.1,
)

print(response.choices[0].message.content)

Up to 30 pages are processed per request — see Batch-processing large PDFs for larger documents.

Extracting text from images

Scanned pages sent as JPEG, PNG, WebP, or SVG are processed directly by the model. Change the MIME type in the data URI to match your file:

import base64
from openai import OpenAI

client = OpenAI(base_url="https://llm.aihosting.mittwald.de/v1")

# Works with: image/jpeg image/png image/webp image/svg+xml
MIME = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png",
"webp": "image/webp", "svg": "image/svg+xml"}

def ocr_image(path: str) -> str:
ext = path.rsplit(".", 1)[-1].lower()
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
response = client.chat.completions.create(
model="GLM-OCR",
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:{MIME[ext]};base64,{b64}"}},
{"type": "text", "text": "Extract all text from this image."},
],
}],
temperature=0.1,
)
return response.choices[0].message.content

print(ocr_image("scan.jpg"))
print(ocr_image("diagram.png"))
print(ocr_image("chart.webp"))
print(ocr_image("drawing.svg"))

Reading Office documents (DOCX, PPTX, XLSX)

GLM-OCR accepts Word, PowerPoint, and Excel files directly — no conversion on your side. Only the MIME type in the data URI changes:

import base64
from openai import OpenAI

client = OpenAI(base_url="https://llm.aihosting.mittwald.de/v1")

MIME = {
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}

def read_office_doc(path: str) -> str:
ext = path.rsplit(".", 1)[-1].lower()
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
response = client.chat.completions.create(
model="GLM-OCR",
messages=[{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:{MIME[ext]};base64,{b64}"},
},
{"type": "text", "text": "Extract all text from this document."},
],
}],
temperature=0.1,
)
return response.choices[0].message.content

print(read_office_doc("contract.docx"))
print(read_office_doc("slides.pptx"))
print(read_office_doc("report.xlsx"))

Markdown document output

Ask GLM-OCR to preserve document structure (headings, bullet lists, emphasis) when the output will be displayed or stored for RAG. Being explicit about the formatting scheme is required — the model needs concrete instructions to produce heading syntax reliably:

import base64
from openai import OpenAI

client = OpenAI(base_url="https://llm.aihosting.mittwald.de/v1")

with open("report.pdf", "rb") as f:
pdf_b64 = base64.b64encode(f.read()).decode()

response = client.chat.completions.create(
model="GLM-OCR",
messages=[{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:application/pdf;base64,{pdf_b64}"},
},
{
"type": "text",
"text": (
"Extract the text from this document and format it as Markdown. "
"Use # for main headings, ## for subheadings, and - for bullet lists."
),
},
],
}],
temperature=0.1,
)

markdown = response.choices[0].message.content
print(markdown)
# Example output:
# # Annual Report 2024
#
# Revenue: 50,000 EUR
#
# - Q1: 12,000 EUR
# - Q2: 13,000 EUR

Extracting structured data from invoices (KIE)

Key Information Extraction (KIE) lets you pull specific fields out of a document as a JSON object. Describe the schema you want in plain language — the model fills in the values it finds.

The model always wraps its JSON output in markdown code fences regardless of how the prompt is worded — strip them before parsing:

import base64
import json
import re
from openai import OpenAI

client = OpenAI(base_url="https://llm.aihosting.mittwald.de/v1")

with open("invoice.pdf", "rb") as f:
pdf_b64 = base64.b64encode(f.read()).decode()

response = client.chat.completions.create(
model="GLM-OCR",
messages=[{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:application/pdf;base64,{pdf_b64}"},
},
{
"type": "text",
"text": (
"Extract the following fields from this invoice and return them as a JSON object:\n"
'{ "invoice_number": "", "date": "", "vendor": "", '
'"total_amount": "", "line_items": [] }'
),
},
],
}],
temperature=0.1,
)

# Strip the markdown code fences the model always adds around JSON
raw = response.choices[0].message.content
clean = re.sub(r"^```[a-z]*\n?", "", raw.strip())
clean = re.sub(r"\n?```$", "", clean)
data = json.loads(clean)
print(data)

The same approach works for any document type — just adapt the schema to the fields you need:

# German Lohnsteuerbescheinigung
"text": (
"Extrahiere die folgenden Felder und gib sie als JSON zurück:\n"
'{ "steuernummer": "", "veranlagungszeitraum": "", "bruttoarbeitslohn": "", '
'"lohnsteuer": "", "solidaritaetszuschlag": "" }'
)

Batch-processing large PDFs

The 30-page limit applies per request. Split large documents with pypdf and concatenate the results:

import base64
from io import BytesIO
from pypdf import PdfReader, PdfWriter
from openai import OpenAI

client = OpenAI(base_url="https://llm.aihosting.mittwald.de/v1")

def _ocr_batch(pdf_bytes: bytes) -> str:
b64 = base64.b64encode(pdf_bytes).decode()
resp = client.chat.completions.create(
model="GLM-OCR",
messages=[{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:application/pdf;base64,{b64}"}},
{"type": "text", "text": "Extract all text from this document."},
],
}],
temperature=0.1,
)
return resp.choices[0].message.content

def ocr_large_pdf(path: str, batch_size: int = 30) -> str:
reader = PdfReader(path)
parts = []
for start in range(0, len(reader.pages), batch_size):
writer = PdfWriter()
for page in reader.pages[start : start + batch_size]:
writer.add_page(page)
buf = BytesIO()
writer.write(buf)
parts.append(_ocr_batch(buf.getvalue()))
return "\n\n".join(parts)

text = ocr_large_pdf("annual_report.pdf")

Full pipeline: OCR → embedding → search → answer

Combine four mittwald AI Hosting models into a complete document question-answering pipeline:

  1. GLM-OCR digitises your documents — using Markdown output mode so headings and structure are preserved, which produces cleaner chunks and better retrieval quality than flat text
  2. Qwen3-Embedding-8B turns text chunks into vectors for a fast first retrieval pass
  3. Qwen3-VL-Reranker-2B re-scores the retrieved candidates against the full question and narrows them down to the most relevant few
  4. Qwen3.5-122B-A10B-FP8 answers questions in natural language based on the reranked context
user@local $ pip install openai pypdf requests
import base64
import math
import os
import re
from io import BytesIO
from pypdf import PdfReader, PdfWriter
import requests
from openai import OpenAI

client = OpenAI(base_url="https://llm.aihosting.mittwald.de/v1")


# ── Step 1: OCR (Markdown mode for clean structure) ───────────────────────────
# Using Markdown output mode instead of plain text produces structured chunks
# that respect document headings — significantly improving retrieval precision.

MIME = {
"pdf": "application/pdf",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"png": "image/png",
"webp": "image/webp",
"svg": "image/svg+xml",
"html": "text/html",
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}

def ocr_document(path: str) -> str:
ext = path.rsplit(".", 1)[-1].lower()
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
resp = client.chat.completions.create(
model="GLM-OCR",
messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"data:{MIME.get(ext, 'application/pdf')};base64,{b64}"}},
{
"type": "text",
"text": (
"Extract the text from this document and format it as Markdown. "
"Use # for main headings, ## for subheadings, and - for bullet lists."
),
},
]}],
temperature=0.1,
)
return resp.choices[0].message.content


# ── Step 2: Chunk (split at markdown headings, then by word count) ────────────

def chunk_text(text: str, size: int = 400, overlap: int = 50) -> list[str]:
# Split at top-level headings first to keep sections together
sections = re.split(r"(?m)^(?=# )", text)
chunks: list[str] = []
for section in sections:
words = section.split()
i = 0
while i < len(words):
chunks.append(" ".join(words[i : i + size]))
i += size - overlap
return [c for c in chunks if c.strip()]


# ── Step 3: Embed with Qwen3-Embedding-8B ────────────────────────────────────

def embed(texts: list[str]) -> list[list[float]]:
resp = client.embeddings.create(model="Qwen3-Embedding-8B", input=texts)
return [item.embedding for item in resp.data]


# ── Step 4: Retrieve via cosine similarity ────────────────────────────────────

def cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(x * x for x in b))
return dot / (norm + 1e-9)

def retrieve(query_vec: list[float], index: list, top_k: int = 3) -> list[str]:
scored = [(cosine(query_vec, vec), chunk) for vec, chunk in index]
return [chunk for _, chunk in sorted(scored, reverse=True)[:top_k]]

Reranking retrieved chunks

Embedding similarity is fast — one dot product per candidate — but imprecise: it scores the query and each chunk independently, so it can miss a chunk that answers the question in different words than the question itself uses. A reranker looks at the query and one chunk together, as a pair, and scores how well that specific pair matches — then reorders the shortlist by that score. It's slower per candidate than vector search, so run it only on the handful of chunks retrieve() already narrowed things down to, never on the whole index.

Qwen3-VL-Reranker-2B is served via the /v1/rerank endpoint rather than /v1/chat/completions:

# ── Reranking: precision pass over the retrieved candidates ───────────────────

def rerank(
query: str,
chunks: list[str],
top_k: int = 3,
instruction: str | None = None,
) -> list[str]:
payload = {"model": "Qwen3-VL-Reranker-2B", "query": query, "documents": chunks}
if instruction:
payload["instruction"] = instruction
resp = requests.post(
"https://llm.aihosting.mittwald.de/v1/rerank",
headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
json=payload,
timeout=30,
)
resp.raise_for_status()
results = resp.json()["results"]
ranked = sorted(results, key=lambda r: r["relevance_score"], reverse=True)
return [chunks[r["index"]] for r in ranked[:top_k]]

The response's results list gives an index (position in the documents array you sent) and a relevance_score (higher is better) for each candidate. The optional instruction parameter tells the reranker what retrieval task to optimise for — omit it to fall back to a generic default.

Retrieve a wider candidate set from the vector index, then let the reranker narrow it down before answering:

# ── Step 5: Answer with Qwen3.5-122B-A10B-FP8 ────────────────────────────────
# Qwen3.5 has thinking mode enabled by default — disable it for Q&A tasks so
# the answer is returned in message.content rather than reasoning_content.
# See: /docs/v2/platform/aihosting/models/qwen3-5-122b-a10b-fp8

def answer(question: str, chunks: list[str]) -> str:
context = "\n\n---\n\n".join(chunks)
resp = client.chat.completions.create(
model="Qwen3.5-122B-A10B-FP8",
messages=[
{
"role": "system",
"content": "Answer questions based only on the provided document context. Be concise and precise.",
},
{
"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {question}",
},
],
temperature=0.7,
top_p=0.8,
max_tokens=1024,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
return resp.choices[0].message.content


# ── Build index ───────────────────────────────────────────────────────────────

# Accepts PDFs, images (jpg/png/webp/svg), DOCX, PPTX, XLSX, HTML — mix freely
documents = ["invoice.pdf", "contract.docx"]

index: list[tuple[list[float], str]] = []
for path in documents:
text = ocr_document(path)
chunks = chunk_text(text)
vectors = embed(chunks)
index.extend(zip(vectors, chunks))

print(f"Indexed {len(index)} chunks from {len(documents)} documents.")


# ── Ask questions ─────────────────────────────────────────────────────────────

questions = [
"What is the total invoice amount?",
"How long is the contract term?",
]

for q in questions:
[q_vec] = embed([q])
candidates = retrieve(q_vec, index, top_k=10)
context_chunks = rerank(
q,
candidates,
top_k=3,
instruction="Given a question, find the document passage that best answers it.",
)
print(f"\nQ: {q}")
print(f"A: {answer(q, context_chunks)}")

Indexing multiple documents

Documents of different types mix freely in one index — build it the same way regardless of how many files you have:

documents = ["contract_a.pdf", "contract_b.docx", "appendix.xlsx"]

index: list[tuple[list[float], str]] = []
for path in documents:
text = ocr_document(path)
chunks = chunk_text(text)
vectors = embed(chunks)
index.extend(zip(vectors, chunks))

print(f"Indexed {len(index)} chunks from {len(documents)} documents.")

# Query across all documents
question = "What are the penalty clauses?"
[q_vec] = embed([question])
candidates = retrieve(q_vec, index, top_k=10)
top_chunks = rerank(question, candidates, top_k=3)
print(answer(question, top_chunks))

This reuses ocr_document, chunk_text, embed, retrieve, rerank, and answer exactly as defined in the pipeline above — indexing more documents only changes the size of documents, not the pipeline itself.

Multimodal variant: reranking with images

For documents that started out as scanned images — scan.jpg, diagram.png, and the like from Extracting text from images above — the original image still carries signal the OCR'd text can lose: a stamp, a logo, a highlighted total, the layout of a chart. Qwen3-VL-Reranker-2B accepts documents that combine text and image content, so you can hand the reranker both the OCR'd chunk and the page it came from.

This only works cleanly when a chunk maps to exactly one source image — a single-page scan, not a multi-page PDF split into many chunks. Track the source path alongside each chunk when indexing:

def encode_image(path: str) -> str:
ext = path.rsplit(".", 1)[-1].lower()
with open(path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return f"data:{MIME.get(ext, 'image/jpeg')};base64,{b64}"


image_documents = ["scan.jpg", "diagram.png", "chart.webp"]

image_index: list[tuple[str, str]] = [] # (chunk_text, source_image_path)
for path in image_documents:
text = ocr_document(path)
for chunk in chunk_text(text):
image_index.append((chunk, path))

Extend rerank() to score an image alongside each chunk's text — pass each document as a content list instead of a plain string:

def rerank_multimodal(
query: str,
chunks: list[str],
images: list[str],
top_k: int = 3,
instruction: str | None = None,
) -> list[str]:
documents = [
{
"content": [
{"type": "text", "text": chunk},
{"type": "image_url", "image_url": {"url": encode_image(image_path)}},
]
}
for chunk, image_path in zip(chunks, images)
]
payload: dict = {"model": "Qwen3-VL-Reranker-2B", "query": query, "documents": documents}
if instruction:
payload["instruction"] = instruction
resp = requests.post(
"https://llm.aihosting.mittwald.de/v1/rerank",
headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
json=payload,
timeout=30,
)
resp.raise_for_status()
results = resp.json()["results"]
ranked = sorted(results, key=lambda r: r["relevance_score"], reverse=True)
return [chunks[r["index"]] for r in ranked[:top_k]]

Call it with the parallel chunk and image lists built above, instead of rerank():

chunks = [c for c, _ in image_index]
images = [p for _, p in image_index]
top_chunks = rerank_multimodal("What does the chart show?", chunks, images, top_k=3)

Text-only fallback

Most documents don't need the image — plain OCR text reranks just as well and costs less to send. Omitting the image_url content and passing plain strings as documents is exactly what rerank() in the full pipeline above already does. Reach for rerank_multimodal() only when the visual layout — a stamp, a chart, a highlighted figure — actually matters to the answer.

Full pipeline with n8n and pgvector

n8n is a workflow automation platform that lets you build the same OCR → embed → search → answer pipeline visually — without writing application code. mittwald provides a dedicated n8n hosting guide that covers running n8n together with a pgvector database on mittwald Container Hosting. This is the recommended production setup.

Because mittwald AI Hosting exposes an OpenAI-compatible API, n8n's built-in OpenAI nodes work out of the box with a custom base URL.

Deploy n8n + pgvector on mittwald

The mittwald n8n guide describes deploying a complete stack via mw stack deploy. The essential Docker Compose configuration:

services:
n8n:
image: n8nio/n8n:stable
ports:
- "5678:5678"
volumes:
- n8n_data:/home/node/.n8n
environment:
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST=pgvector
- DB_POSTGRESDB_DATABASE=n8n
- DB_POSTGRESDB_USER=n8n
- DB_POSTGRESDB_PASSWORD=<password>

pgvector:
image: ankane/pgvector:latest
environment:
- POSTGRES_DB=n8n
- POSTGRES_USER=n8n
- POSTGRES_PASSWORD=<password>
volumes:
- pgvector_data:/var/lib/postgresql/data

volumes:
n8n_data:
pgvector_data:

See the mittwald n8n guide for the complete setup instructions including custom domain, SSL, and mStudio configuration.

Alternative: Qdrant

Qdrant is a dedicated vector search engine with a REST and gRPC API. Run it as a container alongside your application:

services:
qdrant:
image: qdrant/qdrant:latest
ports:
- "6333:6333" # REST API
- "6334:6334" # gRPC
volumes:
- qdrant_data:/qdrant/storage

volumes:
qdrant_data:

Deploy with mw stack deploy and connect from Python using the official client:

# pip install qdrant-client openai
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from openai import OpenAI
import uuid

ai = OpenAI(base_url="https://llm.aihosting.mittwald.de/v1")
qdrant = QdrantClient(host="localhost", port=6333)

# Create collection (run once)
qdrant.create_collection(
collection_name="documents",
vectors_config=VectorParams(size=4096, distance=Distance.COSINE),
)

def embed(texts: list[str]) -> list[list[float]]:
resp = ai.embeddings.create(model="Qwen3-Embedding-8B", input=texts)
return [item.embedding for item in resp.data]

def index_chunks(chunks: list[str]) -> None:
vectors = embed(chunks)
qdrant.upsert(
collection_name="documents",
points=[
PointStruct(id=str(uuid.uuid4()), vector=vec, payload={"text": chunk})
for vec, chunk in zip(vectors, chunks)
],
)

def search(question: str, top_k: int = 3) -> list[str]:
[q_vec] = embed([question])
result = qdrant.query_points(collection_name="documents", query=q_vec, limit=top_k)
return [hit.payload["text"] for hit in result.points]

Alternative: ChromaDB

ChromaDB offers a simple HTTP API that works well for prototyping and smaller document collections:

services:
chromadb:
image: chromadb/chroma:latest
ports:
- "8000:8000"
volumes:
- chroma_data:/chroma/chroma

volumes:
chroma_data:

Connect from Python:

# pip install chromadb openai
import chromadb
from openai import OpenAI

ai = OpenAI(base_url="https://llm.aihosting.mittwald.de/v1")
chroma = chromadb.HttpClient(host="localhost", port=8000)
collection = chroma.get_or_create_collection("documents")

def embed(texts: list[str]) -> list[list[float]]:
resp = ai.embeddings.create(model="Qwen3-Embedding-8B", input=texts)
return [item.embedding for item in resp.data]

def index_chunks(chunks: list[str]) -> None:
vectors = embed(chunks)
collection.add(
ids=[str(i) for i in range(len(chunks))],
embeddings=vectors,
documents=chunks,
)

def search(question: str, top_k: int = 3) -> list[str]:
[q_vec] = embed([question])
results = collection.query(query_embeddings=[q_vec], n_results=top_k)
return results["documents"][0]

Credential setup in n8n

Create a new OpenAI API credential in n8n and override the base URL:

FieldValue
API Keyyour mStudio AI Hosting key (sk-…)
Base URLhttps://llm.aihosting.mittwald.de/v1

GLM-OCR, Qwen3-Embedding-8B, and Qwen3.5-122B-A10B-FP8 all share this single credential.

Document ingestion workflow

Build this n8n workflow to automatically digitise and index new documents:

[Trigger: new file / webhook / schedule]

[Read Binary File]

[Code node] base64-encode file → data URI

[HTTP Request] POST https://llm.aihosting.mittwald.de/v1/chat/completions
model: GLM-OCR
prompt: "Extract the text and format it as Markdown. Use # for headings and - for lists."

[Code node] split Markdown into 400-word chunks with 50-word overlap

[Embeddings OpenAI node] model: Qwen3-Embedding-8B

[Postgres (pgvector) node] INSERT chunks + embeddings into the vector table

The GLM-OCR call uses an HTTP Request node because the file must be base64-encoded first:

// n8n Code node — encode binary to base64 data URI
const binaryData = $input.first().binary.data;
const b64 = binaryData.data; // n8n already base64-encodes binary items
const mime = binaryData.mimeType; // e.g. "application/pdf"
return [{ json: { dataUri: `data:${mime};base64,${b64}` } }];

Question-answering workflow

A second workflow answers user questions on demand:

[Trigger: webhook ?question=How+long+is+the+contract%3F]

[Embeddings OpenAI node] embed the question (Qwen3-Embedding-8B)

[Postgres (pgvector) node] SELECT top-3 chunks by vector similarity

[OpenAI Chat node] model: Qwen3.5-122B-A10B-FP8
system: "Answer only from the provided context."
user: "Context: {{chunks}}\n\nQuestion: {{question}}"

[Respond to Webhook] return the answer as JSON

Prompting tips

Tables — preserve structure as Markdown

Without guidance, the model may flatten table rows into plain text. Ask for markdown tables explicitly:

Extract all text from this document.
Preserve table structures as markdown tables where applicable.

Tables — output as HTML

For spreadsheet data that needs to be inserted into a database or further processed, request HTML:

Extract the table from this document and return it as an HTML <table> element with <tr> and <td> tags.

Formulas — output LaTeX

For documents with mathematical content, request LaTeX so the result can be re-rendered downstream:

Extract all text from this document.
Render mathematical formulas in LaTeX: $...$ for inline math, $$...$$ for display math.

Low-quality scans — flag uncertain words

When working with faded or degraded originals, instruct the model to mark unclear words rather than silently guess:

Extract all legible text from this document.
If a word is unclear, write your best guess followed by [?].

Multilingual documents

GLM-OCR natively supports Chinese, English, German, French, Spanish, Russian, Japanese, and Korean in a single request — no language parameter needed. For mixed-language documents, instruct the model to preserve the original language of each section:

Extract all text from this document. Keep each text passage in its original language — do not translate.