VLM Parsers & Inference

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Baseline date: 2026-10-12. This page was written against Apache Tika 4.1.0 (the latest release on Maven Central on that date), Java 21 and later for the examples (Tika 4.x needs Java 17), Spring Boot 4.x on Spring Framework 7 and the Docker image apache/tika:4.1.0-full. Everything on this page is new in 4.x: Tika 3.3.x has no VLM parsers and no inference section, so the 3.x notes here are about what 4.x replaced. Both modules are marked experimental by the project: the classes, the configuration keys of an engine entry, the inference section and the tk:chunks layout may change in a minor release without a deprecation cycle. The engines map and the text-recognizers list are declared stable. Pin the Tika version you validated against.

A vision-language model (VLM) reads an image and answers in text. Tika 4.x uses one in two ways. As a text recognizer, a VLM takes Tesseract’s place: it transcribes a scanned page or an embedded picture into Markdown, which Tika turns into XHTML and adds to the document text. As an inference engine, a model produces something other than text, today an embedding vector, for the extracted text, the pictures, the rendered pages or the audio and video of a document tree; the vectors land in the tk:chunks metadata field, ready for a vector index. This page covers both, for the Bookshelf intake service that receives manuscripts, catalogues, scanned loan forms and book covers.

Related pages: Configuration (the tika-config.json file and TikaLoader), PDF Parsing (the pages block that decides when a page is rendered), OCR with Tesseract (the local alternative to a VLM), Extending Tika (writing your own engine), Content Handlers & Output Formats (the Markdown handler) and Embedded Documents & Recursive Parsing (the metadata list the vectors are written to).

How this page was checked: every key name, default and class name was read from the tika-vlm and tika-inference 4.1.0 sources (tag 4.1.0 of apache/tika) and the 4.1.x documentation pages listed in References. The Java programs, the JSON configurations marked run and the tika-app command were compiled and run against Tika 4.1.0 on Java 25, talking to a small local mock of the OpenAI chat-completions and embeddings APIs on 127.0.0.1:8000 (written for this page, never a paid service). The outputs shown are therefore real Tika output, but the model answers are canned: the mock always returns the same Markdown and four-number vectors. The Claude and Gemini parsers, Ollama, vLLM, Jina, Azure OpenAI and tika-server calls were not run; their configurations follow the 4.1.x docs and the source. Every credential is read from an environment variable, and the values used in the runs were dummy strings.

Where VLMs and inference fit

The two features answer different questions, and 4.1 configures them in different sections of tika-config.json:

Text recognizer (OCR by a VLM) Inference (embeddings)

Question

What does this picture say?

What is this text, picture, page or clip like?

Output

Text, merged into the document’s content (tk:content in recursive output)

A vector per unit, in tk:chunks

Config sections

engines + text-recognizers

engines + inference (+ pages.inference for PDFs)

Engine types

openai-vlm-parser, claude-vlm-parser, gemini-vlm-parser (and tesseract-ocr-parser, tess4j-parser)

openai-embedding-engine

Who calls it

The image parser and the PDF parser, as they meet an image or render a page

One dispatcher, after the parse of the whole document tree

Module

tika-vlm

tika-inference (dispatcher and SPI in tika-core)

Status in 4.1.0

Experimental classes; the text-recognizers list is stable

Experimental

Both start from the same engines map: a name you choose, mapped to one engine type and its settings. An engine knows nothing about documents. A text-recognizers entry or an inference binding names the engine and says what it is fed. Two names may point at the same endpoint with different models, and secrets live only in the engine entry.

The flow, from a Bookshelf document to text and vectors:

flowchart LR doc["Document
(scanned-loan-form.pdf, manuscript.docx, book-cover.jpg)"] --> parse["AutoDetectParser
parses the tree"] parse --> units{"Units released"} units -->|"rendered pages
(pages.text = AUTO or OCR)"| rec["Text recognizer engine
openai / claude / gemini VLM"] units -->|"embedded and top-level images"| rec units -->|"PAGES (pages.inference)"| emb["Inference engine
openai-embedding-engine"] units -->|"IMAGES, MEDIA segments"| emb parse -->|"after parse: tk:content of every document"| chunk["markdown-chunker
(TEXT binding)"] chunk --> emb rec -->|"Markdown answer to XHTML"| text["Text
tk:content, vlm:* keys"] emb -->|"one vector per unit"| vec["Vectors
tk:chunks with locators"] text --> out["Metadata list
(-J, /rmeta, Pipes RMETA)"] vec --> out

The diagram shows the order that matters. A recognizer runs during the parse, as the PDF or image parser meets each page or picture, so its text is part of the document text. Inference runs after the walk of the tree: images, pages and media are flushed at the end of the top-level parse, and the TEXT stage runs over the finished metadata list. That is why text vectors are computed from the text the recognizer produced.

Modules and dependencies

tika-vlm holds the three VLM parsers and the Markdown-to-XHTML emitter. tika-inference holds the embedding engine, the Markdown chunker and the deprecated 4.0 embedding filters. Both live under tika-parsers-ml and are not part of tika-parsers-standard-package, so a library user adds them explicitly. Both depend on tika-http-jdk, Tika’s small client over the JDK HttpClient; no vendor SDK is pulled in. tika-vlm also brings commonmark-java (with the GFM tables and strikethrough extensions) and jsoup for the Markdown and HTML conversion.

<dependencies>
    <dependency>
        <groupId>org.apache.tika</groupId>
        <artifactId>tika-core</artifactId>
        <version>4.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.apache.tika</groupId>
        <artifactId>tika-parsers-standard-package</artifactId>
        <version>4.1.0</version>
        <type>pom</type>
    </dependency>
    <dependency>
        <groupId>org.apache.tika</groupId>
        <artifactId>tika-serialization</artifactId>   <!-- TikaLoader, tika-config.json -->
        <version>4.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.apache.tika</groupId>
        <artifactId>tika-vlm</artifactId>             <!-- VLM text recognizers -->
        <version>4.1.0</version>
    </dependency>
    <dependency>
        <groupId>org.apache.tika</groupId>
        <artifactId>tika-inference</artifactId>       <!-- embedding engine and chunker -->
        <version>4.1.0</version>
    </dependency>
</dependencies>
dependencies {
    implementation(platform("org.apache.tika:tika-bom:4.1.0"))
    implementation("org.apache.tika:tika-core")
    implementation("org.apache.tika:tika-parsers-standard-package")
    implementation("org.apache.tika:tika-serialization")
    implementation("org.apache.tika:tika-vlm")
    implementation("org.apache.tika:tika-inference")
}

Source: VLM Parsers, 4.1.x docs and the tika-vlm / tika-inference 4.1.0 POMs on Maven Central. The Maven form was used for every run on this page; the Gradle form follows the BOM and was not run.

The distributions already include both modules: the 4.1.0 POMs of tika-app and tika-server-standard declare tika-vlm and tika-inference, so a CLI or server user only writes configuration. The apache/tika:<version>-full image adds ffmpeg, which MEDIA bindings need; the minimal image and the gRPC image do not ship it.

None of the engines is loaded by default. In 4.0.x openai-vlm-parser registered itself through the service loader; in 4.1.0 every VLM parser and the embedding engine carry @TikaComponent(spi = false), so nothing calls a model until a configuration names it.

The three VLM parsers

All three extend AbstractVLMParser, share one configuration class (VLMOCRConfig) and implement TextRecognizer. They differ in the wire format, the authentication and the media types they accept.

OpenAIVLMParser ClaudeVLMParser GeminiVLMParser

Config key

openai-vlm-parser

claude-vlm-parser

gemini-vlm-parser

Endpoint

Any OpenAI-compatible chat completions API: vLLM, Ollama, LiteLLM, a FastAPI wrapper, OpenAI, Azure OpenAI

Anthropic Messages API (/v1/messages)

Google Gemini generateContent (/v1beta/models/{model}:generateContent)

Default baseUrl

http://127.0.0.1:8000

https://api.anthropic.com

https://generativelanguage.googleapis.com

Default model

jinaai/jina-vlm

claude-sonnet-4-20250514

gemini-2.5-flash

Authentication

Authorization: Bearer <apiKey>; no header when apiKey is empty

x-api-key header, plus anthropic-version: 2023-06-01

key query parameter on the URL

Startup health check

GET {baseUrl}/v1/models, skipped when completionsPath is not the default

None

GET {baseUrl}/v1beta/models?key=...

Image types

png, jpeg, tiff, bmp, gif, jp2, jpx, x-portable-pixmap, webp

png, jpeg, gif, webp

png, jpeg, tiff, bmp, gif, jp2, jpx, x-portable-pixmap, webp, heic, heif

PDF

No

application/pdf as a document block (deprecated route, below)

application/pdf (deprecated route, below)

Token counts read from

usage.prompt_tokens, usage.completion_tokens

usage.input_tokens, usage.output_tokens

usageMetadata.promptTokenCount, candidatesTokenCount

Source: the OpenAIVLMParser, ClaudeVLMParser and GeminiVLMParser 4.1.0 sources and AbstractVLMParser Javadoc. The defaults are the values in the constructors; the model names are whatever the project chose for 4.1.0 and are not a recommendation. Set model explicitly, because vendors retire model identifiers on their own schedule.

Every parse writes three metadata keys, all in the vlm: namespace (AbstractVLMParser.VLM_META):

Key Type Meaning

vlm:model

text

The model the request used.

vlm:prompt-tokens

integer

Input tokens the endpoint reported (the image counts here).

vlm:completion-tokens

integer

Output tokens the endpoint reported.

The token counts are the only cost signal Tika gives you. Aggregate them per document and per day; see Cost, privacy and data residency.

Configuring a VLM as a text recognizer

The 4.1 shape names the engine once under engines and lists it in text-recognizers. Every default parser stays loaded; the image parser and the PDF parser call the recognizer for the bytes they have already parsed.

A local OpenAI-compatible model (run)

This is the configuration the intake service uses on a workstation, against a model served on the same machine. The key is optional for a local server; it is shown read from the environment because a shared vLLM or LiteLLM gateway usually wants one.

{
  "engines": {
    "local-vlm": {
      "openai-vlm-parser": {
        "baseUrl": "http://127.0.0.1:8000",
        "model": "mock-vlm",
        "apiKey": "${env:BOOKSHELF_VLM_KEY}",
        "timeoutMillis": 600000,
        "maxTokens": 4096
      }
    }
  },
  "text-recognizers": [ { "engine": "local-vlm" } ]
}

${env:NAME} is the 4.1 environment interpolation: the loader replaces it with the variable’s value when the file is read. Only that exact form is touched (${NAME}, $NAME and ${sys:...} stay literal), the forked workers of tika-server and Pipes inherit the variable, and per-request configuration can never read the server’s environment. A missing variable stops the load with its name and JSON path, never a value. The run without the variable failed with:

org.apache.tika.exception.TikaConfigException: environment variable BOOKSHELF_VLM_KEY is not set;
referenced at /engines/local-vlm/openai-vlm-parser/apiKey

The Java program loads that file, parses the cover and prints what the recognizer added:

import java.nio.file.Path;

import org.apache.tika.config.loader.TikaLoader;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.RecursiveParserWrapper;
import org.apache.tika.sax.BasicContentHandlerFactory;
import org.apache.tika.sax.RecursiveParserWrapperHandler;

public class VlmIntake {
    public static void main(String[] args) throws Exception {
        TikaLoader loader = TikaLoader.load(Path.of(args[0]));              // tika-config.json
        RecursiveParserWrapper parser = new RecursiveParserWrapper(loader.loadAutoDetectParser());
        ParseContext context = loader.loadParseContext();
        RecursiveParserWrapperHandler handler = new RecursiveParserWrapperHandler(
                new BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.MARKDOWN, -1));
        try (TikaInputStream tis = TikaInputStream.get(Path.of(args[1]))) {  // book-cover.jpg
            parser.parse(tis, handler, new Metadata(), context);
        }
        for (Metadata m : handler.getMetadataList()) {
            System.out.println("Content-Type          : " + m.get("Content-Type"));
            System.out.println("tk:parsed-by          : " + String.join(", ", m.getValues("tk:parsed-by")));
            System.out.println("vlm:model             : " + m.get("vlm:model"));
            System.out.println("vlm:prompt-tokens     : " + m.get("vlm:prompt-tokens"));
            System.out.println("vlm:completion-tokens : " + m.get("vlm:completion-tokens"));
            System.out.println("tk:content            :\n" + m.get("tk:content"));
        }
    }
}
$ export BOOKSHELF_VLM_KEY=...        # any value for a local server; never commit it
$ java -cp target/classes:$(cat cp.txt) VlmIntake tika-config.json book-cover.jpg
Content-Type          : image/jpeg
tk:parsed-by          : org.apache.tika.parser.DefaultParser, org.apache.tika.parser.image.JpegParser, org.apache.tika.parser.vlm.OpenAIVLMParser
vlm:model             : mock-vlm
vlm:prompt-tokens     : 812
vlm:completion-tokens : 41
tk:content            :
# The Lighthouse Keeper

A novel by **A. Writer**

|Edition|Year|
|---|---|
|First|2026|

Source: VLM Parsers, 4.1.x docs and Secrets from the environment, 4.1.x docs. Run against the local mock; the text and the token counts are the mock’s canned answer. The mock logged one GET /v1/models at load and one POST /v1/chat/completions with the keys model, messages and max_tokens.

tk:parsed-by shows the 4.1 model: JpegParser parsed the image (EXIF and the rest of the metadata) and called the recognizer, which is listed after it. A recognizer never displaces the parser registered for a type.

Anthropic Claude (not run)

{
  "engines": {
    "claude": {
      "claude-vlm-parser": {
        "apiKey": "${env:ANTHROPIC_API_KEY}",
        "model": "claude-sonnet-4-20250514",
        "maxTokens": 4096,
        "timeoutMillis": 300000
      }
    }
  },
  "text-recognizers": [ { "engine": "claude" } ]
}

Source: VLM Parsers, 4.1.x docs (the claude-vlm-basic.json example, with the key moved to the environment as the 4.1.0 sample tika-server/docker-build/sample-configs/vision/vlm-claude.json does). Not run: it calls a paid API. The model name is the 4.1.0 default; check Anthropic’s current model list before you deploy.

ClaudeVLMParser has no startup probe, so a wrong key surfaces on the first document as an HTTP 401 in the parse exception, not at load. Send it a small test file in your deployment checks.

Google Gemini (not run)

{
  "engines": {
    "gemini": {
      "gemini-vlm-parser": {
        "apiKey": "${env:GEMINI_API_KEY}",
        "model": "gemini-2.5-flash",
        "maxTokens": 4096
      }
    }
  },
  "text-recognizers": [ { "engine": "gemini" } ]
}

Source: VLM Parsers, 4.1.x docs (gemini-vlm-basic.json, key moved to the environment). Not run. The docs say to change baseUrl for Vertex AI or a proxy; the URL path /v1beta/models/{model}:generateContent is fixed in the code, so a Vertex endpoint with a different path needs a gateway in front.

The Gemini parser puts the API key in the request URL (?key=...), both for the startup probe and for every parse. URLs end up in proxy logs, HTTP client debug logs and error messages more easily than headers. Keep debug logging of the HTTP client off, and if you route through a proxy, make sure it does not log query strings.

Azure OpenAI and custom paths

OpenAIVLMParser has a completionsPath (default /v1/chat/completions) for endpoints whose path differs, such as an Azure deployment (/openai/deployments/{deployment}/chat/completions?api-version=...). A non-default path also turns the /v1/models probe off. Azure wants the key in an api-key header with no Bearer prefix. The Java class has setApiKeyHeaderName and setApiKeyPrefix for that, but in 4.1.0 those two are not JSON keys: adding them to the engine entry failed the load with

Unrecognized field "apiKeyHeaderName" (class org.apache.tika.parser.vlm.VLMOCRConfig), not marked as ignorable
(15 known properties: "inlineContent", "maxRetries", "prompt", "apiKey", "timeoutMillis", "model", "textRecognizer",
"allowRuntimePrompt", "minFileSizeToOcr", "completionsPath", "baseUrl", "skipOcr", "maxFileSizeToOcr",
"maxImagePixels", "maxTokens")

So from tika-config.json the VLM parser always sends Authorization: Bearer. For Azure, either construct OpenAIVLMParser in Java and call the setters, or put an OpenAI-shaped gateway (LiteLLM, for instance) in front. The embedding engine is different: openai-embedding-engine does accept apiKeyHeaderName and apiKeyPrefix in JSON (see Vendor keys: requestParameters). The error message above is also the most reliable list of the VLM keys in 4.1.0.

Source: the OpenAIVLMParser 4.1.0 source and its Javadoc, and a load of that configuration on 4.1.0.

VLMOCRConfig options

Key Default Meaning and cost

baseUrl

per parser (table above)

Endpoint root, no trailing slash. Cannot be changed per request.

model

per parser

Model identifier sent in the request and written to vlm:model. Cannot be changed per request; configure a second engine for a second model.

prompt

"Extract all visible text from this image. …​" (Markdown transcription prompt)

The instruction sent with the image. Changing it per request needs allowRuntimePrompt.

maxTokens

4096

Ceiling on generated tokens, and so on output cost per call. Per request it may be lowered, never raised.

timeoutMillis

120000

HTTP read timeout, also used for the startup probe. Local models need far more (the docs suggest 600000 for a laptop).

maxRetries

4

Retries of a 429, 502, 503 or 504 with a jittered backoff inside the parse’s time budget; 0 fails at once. Blocked per request.

apiKey

""

Sent as described in the parser table. Use ${env:NAME}. Blocked per request.

completionsPath

/v1/chat/completions

OpenAI-compatible parser only; Claude and Gemini ignore it.

inlineContent

true

For an image embedded inline (embedded resource type INLINE), also write the recognized text into the parent document’s content, as Tesseract does.

skipOcr

false

Kill switch. The parser advertises no types and does nothing. Allowed per request.

textRecognizer

true

Says the prompt transcribes. Set false for a captioning or tagging prompt: the engine still runs, but the PDF parser’s AUTO strategy never replaces a page’s extracted text with its answer.

minFileSizeToOcr

0

Smaller inputs are skipped, in bytes.

maxFileSizeToOcr

52428800 (50 MB)

Larger inputs are skipped, in bytes.

maxImagePixels

100000000

Images with a larger decoded area are skipped before any request (decompression-bomb and cost guard); -1 disables. Blocked per request.

allowRuntimePrompt

false

Whether a caller-supplied JSON config may change prompt and textRecognizer. Security-relevant, see Per-request configuration.

Source: VLM Parsers, configuration options reference, 4.1.x docs, cross-checked with the field initializers in VLMOCRConfig 4.1.0 and the VLMOCRConfig Javadoc.

On top of the engine’s own gates, a text-recognizers entry takes engine, _mime-include, _mime-exclude, _min-width and _min-height. Use _min-width/_min-height to keep icons and logos away from a paid model: an image whose recorded tiff:ImageWidth/tiff:ImageLength is smaller is never sent. Whatever the entry says, an image under 2 x 2 pixels never reaches any engine.

{
  "text-recognizers": [
    { "engine": "local-vlm", "_mime-exclude": ["image/gif"], "_min-width": 200, "_min-height": 200 }
  ]
}

Startup behaviour

What happens when the engine cannot be reached is a frequent surprise, so it is worth spelling out:

  • AbstractVLMParser.initialize() probes the health URL (OpenAI-compatible and Gemini only). If the probe fails, it logs a warning and the parser advertises no media types for the rest of the run. It does not retry later.

  • The text-recognizers loader then refuses an engine with no types. The run against a closed port failed the load with: Text recognizer engine "local-vlm" advertises no media types (a _mime-include list does not count). Is the engine unavailable (missing native binary, unreachable inference server) or configured to skip enrichment?

  • So: start the model server before Tika, and in Docker Compose or Kubernetes make Tika wait for the model’s readiness. A tika-server that restarts on failure (restart: on-failure) recovers once the model is up.

  • The OpenAI probe calls /v1/models. A home-made server that only implements /v1/chat/completions fails the probe; add the /v1/models route (the 4.1.x local-server guide does) or use a non-default completionsPath.

  • Claude has no probe, and the Gemini probe only proves that the model list is readable. A wrong key or a model your key cannot use fails at the first parse.

Source: the AbstractVLMParser 4.1.0 source and the Running a Local VLM Server, 4.1.x docs page. Both failure messages above are from runs on 4.1.0.

Scanned PDFs: the pages block decides

The PDF parser asks the recognizer for text only when its pages.text policy says so. The default is AUTO: pages whose extracted text is poor are rendered and sent. scanned-loan-form.pdf has no text layer, so AUTO sends every page; OCR sends every page of every PDF, which is what this run forced on the one-page catalogue.pdf to show the output shape:

{
  "engines": {
    "local-vlm": {
      "openai-vlm-parser": {
        "baseUrl": "http://127.0.0.1:8000",
        "model": "mock-vlm",
        "apiKey": "${env:BOOKSHELF_VLM_KEY}",
        "timeoutMillis": 600000
      }
    }
  },
  "text-recognizers": [ { "engine": "local-vlm" } ],
  "parsers": [
    { "default-parser": {} },
    { "pdf-parser": { "pages": { "text": "OCR", "render": { "dpi": 150, "imageType": "RGB" } } } }
  ]
}

The relevant part of the XHTML output (ToXMLContentHandler) and metadata:

<body><div class="page"><div class="ocr"><h1>The Lighthouse Keeper</h1>
<p>A novel by <b>A. Writer</b></p>
<table><thead><tr><th>Edition</th><th>Year</th></tr></thead>
<tbody><tr><td>First</td><td>2026</td></tr></tbody></table>
</div></div></body>
tk:parsed-by=org.apache.tika.parser.CompositeParser, org.apache.tika.parser.pdf.PDFParser
tk:parsed-by-full-set=org.apache.tika.parser.CompositeParser, org.apache.tika.parser.pdf.PDFParser, org.apache.tika.parser.vlm.OpenAIVLMParser
pdf:ocr-page-count=1

Source: Pages: rendering, OCR policy, emitted page images, 4.1.x docs and OCR and Inference Recipes, 4.1.x docs. Run against the mock (whitespace in the XHTML shortened). The page text is the mock’s answer, not the catalogue’s real text, because OCR replaced it.

The render settings matter for a VLM more than for Tesseract. The pages.render defaults (300 dpi grayscale PNG) are tuned for Tesseract; a VLM charges by image size and usually reads 150 dpi colour as well. Every page is one request, so ocr.maxPages (in the PDF parser’s pages.ocr block) is the main cost cap on a long scanned document. The PDF Parsing page covers the pages block in full.

The deprecated route: a VLM as the PDF parser

In 4.0, and still in 4.1 with a deprecation warning, Claude or Gemini could replace the PDF parser and receive the whole PDF file:

{
  "parsers": [
    { "default-parser": { "exclude": ["pdf-parser"] } },
    { "claude-vlm-parser": { "apiKey": "${env:ANTHROPIC_API_KEY}", "model": "claude-sonnet-4-20250514" } }
  ]
}

Avoid it in new code. An engine under parsers is deprecated since 4.1.0 and fails config load in 4.2.0. It also loses the PDF structure (attachments, bookmarks, forms are no longer parsed) and makes the VLM the recognizer for embedded images in every other format too. The 4.2-compatible shape is "pages": {"text": "OCR"} with the VLM in text-recognizers, as above; it sends page images, not the file. There is no 4.2 shape yet for sending the PDF file itself.

Markdown-to-XHTML conversion

A model answers in Markdown, and Tika does not paste that answer as plain text. MarkdownToXHTMLEmitter parses it with commonmark-java and emits real XHTML elements: headings, paragraphs, <b>/<i>, lists and tables (GFM tables and strikethrough are enabled). The run above shows a Markdown pipe table becoming <table>, <thead>, <th> and <td>, wrapped in <div class="ocr">. Because the result is XHTML, every Tika output format sees structure: the Markdown handler writes the pipe table back (as in the tk:content above), the XML handler writes the <table>, the text handler writes the cell text.

Document-OCR models (the docs name jina-ocr-v1 and DeepSeek-OCR) write tables as raw HTML inside the Markdown whatever the prompt says. Tika parses such an HTML block leniently with jsoup and re-emits only the structural elements (table, tr, th, td, p, lists, headings), dropping every attribute except colspan and rowspan. Other elements give their text; script and style give nothing. Inline HTML tags are dropped and their text kept, except <br>. That is a safety property as well as a convenience: a model that echoes markup from a hostile image cannot inject scripts or event attributes into Tika’s XHTML.

The conversion is also why a VLM’s output is better than raw OCR for the RAG pipeline downstream: headings survive as headings, so the Markdown chunker can split on them (see Why Markdown is required for inference). It does not make the model correct. A VLM can misread, skip or invent text, and nothing in Tika checks the transcription against the image.

Source: Markdown-to-XHTML conversion, 4.1.x docs and the MarkdownToXHTMLEmitter 4.1.0 source.

Per-request configuration

There are two paths with different trust.

In-process Java: trusted

Code that builds the ParseContext is trusted by construction. Put a VLMOCRConfig in it and every field applies:

import org.apache.tika.exception.TikaConfigException;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.vlm.VLMOCRConfig;

public final class CoverCaptionContext {
    private CoverCaptionContext() {
    }

    /** A captioning prompt for the cover workflow: not a transcription, so textRecognizer is false. */
    public static ParseContext forCovers() throws TikaConfigException {
        VLMOCRConfig override = new VLMOCRConfig();
        override.setBaseUrl("http://127.0.0.1:8000");
        override.setModel("mock-vlm");
        override.setPrompt("Describe this book cover in one sentence. Return Markdown.");
        override.setTextRecognizer(false);
        override.setMaxTokens(256);
        ParseContext context = new ParseContext();
        context.set(VLMOCRConfig.class, override);
        return context;
    }
}

Source: Per-request configuration, 4.1.x docs and the VLMOCRConfig 4.1.0 setters. Compiled and run against the mock. The object _replaces the engine’s configuration for that parse (it is not merged): in the run, the request carried no Authorization header, because the override’s empty apiKey replaced the engine’s. Set every field you rely on, including baseUrl, model and, for a hosted endpoint, apiKey (read it from the environment, never a literal).

Caller-supplied JSON: restricted

JSON that arrives with a request, through the tika-server /config endpoints or gRPC parse_context_json (each gated by its own allowPerRequestConfig), goes through VLMOCRConfig.RuntimeConfig, which refuses cost- and security-sensitive changes. In Java the same untrusted path is ParseContext.setJsonConfig(key, json), which this program used to try five overrides against the engine above (maxTokens 4096 at load):

import java.nio.file.Path;

import org.apache.tika.config.loader.TikaLoader;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.Parser;
import org.apache.tika.sax.ToMarkdownContentHandler;

public class PerRequest {
    public static void main(String[] args) throws Exception {
        TikaLoader loader = TikaLoader.load(Path.of(args[0]));
        Parser parser = loader.loadAutoDetectParser();
        String[] overrides = {
            "{\"maxTokens\": 1024}", "{\"maxTokens\": 9000}", "{\"model\": \"another-model\"}",
            "{\"prompt\": \"Describe the cover.\"}", "{\"skipOcr\": true}"
        };
        for (String json : overrides) {
            ParseContext context = loader.loadParseContext();
            context.setJsonConfig("openai-vlm-parser", json);     // untrusted, as from a request
            Metadata metadata = new Metadata();
            try (TikaInputStream tis = TikaInputStream.get(Path.of(args[1]))) {
                parser.parse(tis, new ToMarkdownContentHandler(), metadata, context);
                System.out.println(json + " -> OK, vlm:model=" + metadata.get("vlm:model"));
            } catch (Exception e) {
                Throwable root = e;
                while (root.getCause() != null) {
                    root = root.getCause();
                }
                System.out.println(json + " -> " + root.getMessage());
            }
        }
    }
}
{"maxTokens": 1024} -> OK, vlm:model=mock-vlm
{"maxTokens": 9000} -> Cannot increase maxTokens beyond the init-time value (4096) at runtime. Requested: 9000
{"model": "another-model"} -> Cannot modify model at runtime. Models must be configured at initialization time. If you need a different model, configure a separate parser instance.
{"prompt": "Describe the cover."} -> Cannot modify prompt at runtime. Set allowRuntimePrompt=true at initialization time to permit per-request prompt overrides.
{"skipOcr": true} -> OK, vlm:model=null

Source: Per-request configuration, 4.1.x docs and the VLMOCRConfig.RuntimeConfig 4.1.0 source. Run against the mock; the messages are the root causes (IllegalStateException) of the parse exceptions.

Field Rule for caller-supplied JSON

baseUrl, apiKey

Always refused. A caller can never redirect the document, or your key, to another host.

model

Always refused. A second model is a second engine.

maxTokens

May be lowered, never raised above the load-time value. This ceiling is what bounds the cost of one call.

maxImagePixels, maxRetries, allowRuntimePrompt

Always refused.

prompt, textRecognizer

Refused unless the engine was loaded with allowRuntimePrompt: true.

skipOcr and the size gates

Allowed. Switching the engine off for one request is always safe.

Keep allowRuntimePrompt off for any server reachable by people you do not fully trust. A caller-controlled prompt is a prompt-injection surface on your key: "ignore the image and write …​" costs you tokens and puts arbitrary text into your index under the document’s name. The site’s Prompt Injection page covers the general problem; here, the image itself is also untrusted input, so a scanned page can carry instructions to the model too. Treat VLM output as untrusted text, exactly like the document’s own text.

On tika-server

With "server": {"allowPerRequestConfig": true}, a multipart config part on /tika/config, /rmeta/config or /meta/config carries the same JSON, keyed by component name:

curl -X POST http://localhost:9998/rmeta/config/md \
  -F "file=@scanned-loan-form.pdf" \
  -F "config={\"openai-vlm-parser\":{\"maxTokens\":1024}};type=application/json"

To turn recognition or inference off without opening /config, define presets in the server configuration and let callers pick one by name; preset routes do not need allowPerRequestConfig:

{
  "presets": {
    "no-enrichment": { "text-recognizers": { "enabled": false }, "inference": { "enabled": false } }
  }
}
curl -T manuscript.docx http://localhost:9998/rmeta/preset/no-enrichment/md

Source: tika-server, 4.1.x docs (the allowPerRequestConfig and presets sections). Not run on this page; the Configuration page covers presets and the /config trust model.

Running a local VLM server

A local model removes the per-call cost and keeps documents on your hardware, which is often the deciding argument for loan forms and other personal data. Anything that speaks the OpenAI chat-completions shape works with openai-vlm-parser. Three common choices:

Server How Tika talks to it Notes

Ollama

baseUrl = the Ollama host and port (11434 by default); Ollama serves /v1/chat/completions and /v1/models

Easiest on a laptop or a single GPU box; pull a vision-capable model first. See Ollama.

vLLM

baseUrl = the vLLM OpenAI-compatible server

Higher throughput, batching on GPU. See vLLM and SGLang.

A FastAPI wrapper around a Hugging Face model

Must implement /v1/chat/completions and /v1/models

The 4.1.x guide ships one for jinaai/jina-vlm and Qwen/Qwen2-VL-2B-Instruct on Apple Silicon or CUDA.

A Compose file for the Bookshelf stack, with Ollama next to tika-server on a private network:

services:
  ollama:
    image: ollama/ollama:latest          # pin a version in production
    volumes:
      - ollama-models:/root/.ollama
    # no ports: only tika reaches it, over the compose network

  tika:
    image: apache/tika:4.1.0-full
    command: -c /tika-config.json
    restart: on-failure                  # retries until the model server answers /v1/models
    ports:
      - "127.0.0.1:9998:9998"
    volumes:
      - ./tika-config.json:/tika-config.json:ro
    depends_on:
      - ollama

volumes:
  ollama-models:
{
  "server": {},
  "engines": {
    "local-vlm": {
      "openai-vlm-parser": {
        "baseUrl": "http://ollama:11434",
        "model": "qwen2.5vl",
        "timeoutMillis": 600000,
        "maxTokens": 4096
      }
    }
  },
  "text-recognizers": [ { "engine": "local-vlm" } ]
}

The empty "server": {} is required: tika-server 4.1.0 refuses to start from a file without a server section.

docker compose up -d ollama
docker compose exec ollama ollama pull qwen2.5vl   # any vision-capable model you are licensed to use
docker compose up -d tika
curl -T book-cover.jpg http://localhost:9998/tika/md

Source: Running a Local VLM Server, 4.1.x docs, docker-compose-tika-vision.yml in Tika 4.1.0 and Ollama, OpenAI compatibility. Not run. Note that the 4.1.0 sample maps host port 8000 to Ollama’s 11434 but points Tika at vlm-server:8000, which is the host-side port; inside the Compose network use the container port, as above. The model tag is an example, not a tested choice.

Practical notes for local models:

  • Timeouts. The 4.1.x guide measures 15 to 30 seconds per page image for a 2.4B model on an M3 Max, and 20 to 30 seconds to load the model. Set timeoutMillis to 600000 or more, and size the parse’s own timeout-limits (one hour total, two minutes without progress by default) so a 40-page scan fits; the wait on the model counts as progress.

  • Throughput. A single local model is a queue. More Tika workers than the model can serve only lengthen the queue; keep numClients (tika-server, Pipes) near the number of concurrent requests the model server handles.

  • Licences. Model weights carry their own licence. The guide notes that jinaai/jina-vlm (the default model name in VLMOCRConfig) is CC BY-NC 4.0, non-commercial, while Qwen/Qwen2-VL-2B-Instruct is Apache 2.0. Check before a commercial deployment.

  • Bind locally. The guide’s server binds 127.0.0.1 only. A model server has no notion of Tika’s trust model; keep it off public interfaces.

Inference: engines and bindings

Inference in 4.1 has three parts, each answering one question:

  • engines: what can be called. Name, engine type, endpoint, model, key.

  • inference: what runs on what. A list of bindings; each names an engine, an input kind and the tasks.

  • pages.inference on the PDF parser: what a rendering parser releases (TEXT, the default, and/or PAGES).

The only engine type in 4.1.0 is openai-embedding-engine, which posts to an OpenAI-shaped /v1/embeddings endpoint, and the only task is embed. A chat task on a chat engine (a caption or tags per picture) is listed by the project as 4.2 work.

The embedding engine

Key Default Meaning

baseUrl

http://localhost:8000

Endpoint root; the engine appends embeddingsPath.

model

""

Model identifier.

apiKey

""

Use ${env:NAME}.

timeoutMillis

120000

Per request.

maxBatchSize

32

Inputs per request. A document tree’s units are sent in batches of this size.

maxRetries

4

Retries of 429/502/503/504 (see Hosted engines and concurrency).

embeddingsPath

/v1/embeddings

For Azure deployments and gateways.

apiKeyHeaderName

Authorization

api-key for Azure.

apiKeyPrefix

`Bearer ` (with a space)

"" for Azure.

requestParameters

empty map

Vendor keys copied into every request body (see Vendor keys: requestParameters).

imageInput

object

How an image goes into input: object sends {"image": "data:..."} (Jina’s form); data-uri sends a bare data URI (LiteLLM’s form).

mediaInput

none

object sends audio and video as {"audio": ...} / {"video": ...}; required for MEDIA bindings.

Source: Inference: engines and bindings, 4.1.x docs and the field initializers in OpenAIEmbeddingEngine 4.1.0 (Javadoc).

Bindings

Key Meaning

id

Names the binding; defaults to <engine>-<input>. Unique in the list; it is the producer of every chunk it writes.

engine

A name from engines. An unknown name fails the load.

input

TEXT, IMAGES, PAGES or MEDIA (table below).

modality

text, visual or audio. Implied for TEXT, IMAGES and PAGES; a MEDIA binding must say visual or audio.

tasks

["embed"] (the default and the only task in 4.1.0). embed on a recognizer such as Tesseract fails the load.

maxChunks

Units one tree may send: pages of one document for PAGES, chunks of the tree for TEXT, segments of one file for MEDIA (480 unless set). -1 for no limit. This is the cost cap per document.

maxBytes

Largest unit accepted (20 MB unless set, -1 for none); larger units are skipped and counted in a warning.

chunker

TEXT only: {"markdown-chunker": {"maxChunkChars": 1500, "overlapChars": 200}}. Without one, a document’s whole text is one chunk.

minWidth, minHeight

IMAGES only: smaller recorded images are not offered (2 unless set, so spacers never are).

enabled

false keeps the binding idle until a request selects it.

_mime-include, _mime-exclude

Narrow by media type. With neither, an IMAGES binding skips vector and CAD image types (SVG, EMF, DWG) that no embedding endpoint takes.

Input Units Where the vector lands

TEXT

The tk:content of every document in the tree, cut by the chunker, after the parse

On the document the text came from, with a text locator (character offsets)

IMAGES

Every image document: inline pictures, image attachments, a top-level image file (not page renders)

In recursive output, on the containing document with an embedded locator naming the picture; an attachment keeps its own

PAGES

One render per page, from a parser that releases pages (the PDF parser with pages.inference: ["PAGES"])

On the PDF, with a paginated locator naming the page

MEDIA

Audio and video cut by ffmpeg into segments (25 s windows, 5 s overlap by default)

On the media document, with a temporal locator and a correlator shared by a segment’s audio and visual chunks

Source: Inference: engines and bindings, 4.1.x docs and the InferenceBinding 4.1.0 source (Javadoc).

How it runs

Parsers do not call embedding engines. Every image, audio or video document the parse meets, however deep, is offered to one InferenceDispatcher after its own parser has run. The dispatcher keeps the units of the whole tree, as files it owns, until the top-level parse ends, then sends each binding’s units to its engine in requests of up to maxBatchSize. A .docx with twelve pictures is one embeddings request, not twelve; if the endpoint rejects one image in a batch, the rest are retried one by one. This flush is still inside parse(), under the parse’s timeout, and the unit files are deleted when it ends. A failed request marks the top-level document with a warning; it never fails the parse.

The TEXT stage is separate: it runs after parse() returns, over the finished metadata list and before the metadata filters. tika-app, tika-server and Pipes call it for you. In the Java API you call it yourself, as TikaCLI does in 4.1.0; otherwise TEXT bindings silently do nothing. This was the first thing the run below found: with a plain RecursiveParserWrapper, the page vectors appeared and the text vectors did not.

The Bookshelf vector intake (run)

Text vectors for every document, and a vector per page for PDFs, on one embedding endpoint:

{
  "engines": {
    "embedder": {
      "openai-embedding-engine": {
        "baseUrl": "http://127.0.0.1:8000",
        "model": "mock-embed",
        "apiKey": "${env:BOOKSHELF_EMBED_KEY}",
        "maxBatchSize": 16,
        "requestParameters": { "dimensions": 4 }
      }
    }
  },
  "inference": [
    { "id": "text-vectors", "engine": "embedder", "input": "TEXT", "tasks": ["embed"],
      "chunker": { "markdown-chunker": { "maxChunkChars": 1500, "overlapChars": 200 } },
      "maxChunks": 500 },
    { "id": "page-vectors", "engine": "embedder", "input": "PAGES", "tasks": ["embed"], "maxChunks": 50 }
  ],
  "parsers": [
    { "default-parser": {} },
    { "pdf-parser": { "pages": { "inference": ["TEXT", "PAGES"],
                                 "render": { "dpi": 96, "imageType": "RGB" } } } }
  ]
}
import java.nio.file.Path;
import java.util.List;

import org.apache.tika.config.loader.TikaLoader;
import org.apache.tika.io.TikaInputStream;
import org.apache.tika.metadata.Metadata;
import org.apache.tika.parser.ParseContext;
import org.apache.tika.parser.RecursiveParserWrapper;
import org.apache.tika.parser.inference.EngineRegistry;
import org.apache.tika.parser.inference.InferenceDispatcher;
import org.apache.tika.sax.BasicContentHandlerFactory;
import org.apache.tika.sax.RecursiveParserWrapperHandler;

public class BookshelfVectors {
    public static void main(String[] args) throws Exception {
        TikaLoader loader = TikaLoader.load(Path.of(args[0]));               // tika-config.json
        try (EngineRegistry engines = loader.get(EngineRegistry.class)) {     // closes the HTTP clients
            RecursiveParserWrapper parser = new RecursiveParserWrapper(loader.loadAutoDetectParser());
            ParseContext context = loader.loadParseContext();
            RecursiveParserWrapperHandler handler = new RecursiveParserWrapperHandler(
                    new BasicContentHandlerFactory(BasicContentHandlerFactory.HANDLER_TYPE.MARKDOWN, -1));
            try (TikaInputStream tis = TikaInputStream.get(Path.of(args[1]))) {
                parser.parse(tis, handler, new Metadata(), context);          // IMAGES, PAGES, MEDIA flush here
            }
            List<Metadata> metadataList = handler.getMetadataList();
            InferenceDispatcher inference = loader.get(InferenceDispatcher.class);
            if (inference != null) {
                inference.text(metadataList, context);                        // the TEXT stage
            }
            loader.loadMetadataFilters().filter(metadataList);                // filters run last
            for (Metadata m : metadataList) {
                System.out.println(m.get("Content-Type") + " -> tk:chunks = " + m.get("tk:chunks"));
            }
        }
    }
}
$ export BOOKSHELF_EMBED_KEY=...
$ java -cp target/classes:$(cat cp.txt) BookshelfVectors tika-config.json catalogue.pdf
application/pdf -> tk:chunks = [{"vector":"PczMzT5MzM0+mZmaPszMzQ==","producer":"page-vectors","modality":"visual",
  "locators":{"paginated":[{"page":1}]}},{"text":"Spring catalogue  \n\nBookshelf spring catalogue Forty titles from
  independent publishers.","vector":"PczMzT5MzM0+mZmaPszMzQ==","producer":"text-vectors","modality":"text",
  "locators":{"text":[{"start_offset":0,"end_offset":88}]}}]

Source: Inference: engines and bindings, 4.1.x docs, OCR and Inference Recipes, 4.1.x docs and the TikaCLI 4.1.0 source (the inference.text(...) call before the metadata filters). Run against the mock (output wrapped here). EngineRegistry and InferenceDispatcher are in tika-core (org.apache.tika.parser.inference); the docs ask library users to close the registry themselves.

What the run showed:

  • tk:chunks is one metadata value holding a JSON array. Each chunk has vector, producer (the binding id), modality and locators; text chunks also carry their text. The vector is base64 of big-endian float32 values: the mock’s [0.1, 0.2, 0.3, 0.4] decodes from PczMzT5MzM0+mZmaPszMzQ== exactly. Decode it with ByteBuffer.wrap(bytes).asFloatBuffer() in Java (big-endian is the ByteBuffer default).

  • tk:inference-released was TEXT and PAGES on the PDF. With ["PAGES"] alone the TEXT stage skips the PDF, so it is embedded once, by its pages.

  • requestParameters arrived in the request body: the mock logged the keys dimensions, input and model.

  • For publisher-email.eml (an email with a zip holding a .docx, a PDF and a text note) the TEXT stage sent one request with eight chunks for the whole tree, and each chunk landed on the document its text came from: the email, the .docx, the PDF, a plain-text note and the zip.

The same configuration through tika-app, which runs the TEXT stage itself:

$ java -jar tika-app-4.1.0.jar --config=tika-config.json -J catalogue.pdf

The run (with the tika-app 4.1.0 classes on the class path) produced the same tk:chunks value, plus "tk:inference-released": ["TEXT", "PAGES"] and "tk:content-handler-type": "MARKDOWN". The CLI is still described as in flux for 4.x; check --help of the version you run.

Hosted engines and concurrency

A hosted engine limits requests in flight per key and answers the excess with 429 at once, usually without Retry-After; a saturated one answers 502, 503 or 504. Tika retries those four, maxRetries times, with a jittered backoff from 250 ms doubling to 4 s (or the server’s Retry-After), and never past the time the parse granted the call. A 400 or 401 fails at once. The VLM parsers use the same policy.

The retries absorb collisions between a few workers; they do not create capacity. The rule from the docs: keep numClients (tika-server, Pipes) at or below the key’s concurrency, counting every engine the workers call (a VLM recognizer and an embedding engine on the same vendor share the key), and raise the key’s tier before raising the workers. As an example of the scale, the docs quote Jina’s limits as two requests in flight on a free key, 50 on a paid key and 500 on premium.

Vendor keys: requestParameters

Most embedding services accept the OpenAI shape plus keys of their own. requestParameters is copied verbatim into every request body next to model and input; the engine neither knows nor checks the names, so use your vendor’s. Three kinds recur: a Matryoshka dimension (dimensions, output_dimension), a query-versus-document hint (task, input_type, task_type; use the document or passage value, since Tika indexes) and normalize/truncate flags. Two rules are enforced: model and input are refused, and so is any key that changes the response away from float vectors by index (encoding_format, embedding_type, output_type, output_dtype, return_multivector).

Jina omni, one space for text, pictures and pages (not run, paid API):

{
  "engines": {
    "jina": { "openai-embedding-engine": {
      "baseUrl": "https://api.jina.ai", "apiKey": "${env:JINA_API_KEY}",
      "model": "jina-embeddings-v5-omni-small", "maxBatchSize": 16,
      "requestParameters": { "task": "retrieval.passage", "dimensions": 1024, "normalized": true } } }
  },
  "inference": [
    { "id": "pictures", "engine": "jina", "input": "IMAGES", "tasks": ["embed"], "maxBytes": 5000000 },
    { "id": "pages",    "engine": "jina", "input": "PAGES",  "tasks": ["embed"], "maxBytes": 5000000 },
    { "id": "text",     "engine": "jina", "input": "TEXT",   "tasks": ["embed"],
      "chunker": { "markdown-chunker": { "maxChunkChars": 1500, "overlapChars": 200 } } }
  ],
  "parsers": [
    { "default-parser": {} },
    { "pdf-parser": { "pages": { "inference": ["TEXT", "PAGES"], "render": { "dpi": 96, "imageType": "RGB" } } } }
  ]
}

Azure OpenAI, key in an api-key header with no prefix and the deployment in the path (not run):

{
  "engines": {
    "azure": { "openai-embedding-engine": {
      "baseUrl": "https://bookshelf-search.openai.azure.com",
      "embeddingsPath": "/openai/deployments/text-embedding-3-large/embeddings?api-version=2024-10-21",
      "apiKeyHeaderName": "api-key", "apiKeyPrefix": "", "apiKey": "${env:AZURE_OPENAI_KEY}",
      "requestParameters": { "dimensions": 1024 } } }
  }
}

Source: Inference, hosted engines and vendor keys, 4.1.x docs. Neither was run. The resource name is a placeholder; plain OpenAI is the same engine with the defaults and "baseUrl": "https://api.openai.com".

For services whose request shape is not OpenAI’s (Google embedContent, Amazon Bedrock InvokeModel, Cohere /v2/embed), the docs recommend an OpenAI-shaped gateway such as LiteLLM in front, holding the cloud credentials, with "imageInput": "data-uri". See LLM Gateways for running one.

Media, in one paragraph

A MEDIA binding embeds audio or video. Tika cuts the file with ffmpeg (found on the PATH at load; the -full image ships it) into 25-second windows starting every 20 seconds, as 360p H.264 silent clips for visual and mono Opus for audio, under the parse’s time budget. Each segment’s chunks carry a temporal locator (start_ms, end_ms) and a correlator such as t:20000-45000, so the audio and visual vectors of one segment can be grouped. The engine needs "mediaInput": "object", and the segment grid is set per request in {"parse-context": {"media": {"segment": {...}}}}. The Bookshelf intake has no audio, so this page does not go further; the 4.1.x Inference page covers the limits.

Per request

{ "parse-context": { "inference": { "bindings": ["page-vectors"], "enabled": true } } }

enabled: false runs nothing for the request; bindings names the bindings that run (every enabled binding when absent). A name that is not configured fails the request. Engines can never be defined or changed per request, and the block carries names only, so it is safe in a preset or on a Pipes tuple.

OCR and vector recipes

The 4.1.x recipes page gives complete configurations for the common combinations. In Bookshelf terms:

Goal Sections used Bookshelf file

OCR scanned pages only, locally

engines (Tesseract) + text-recognizers

scanned-loan-form.pdf

Hosted document OCR instead of Tesseract

engines (openai-vlm-parser, model jina-ocr-v1) + text-recognizers

scanned-loan-form.pdf, book-cover.jpg

Text vectors for every document

engines (embedding) + a TEXT binding

manuscript.docx, publisher-email.eml

Page vectors, no OCR

a PAGES binding + pages.inference: ["PAGES"], pages.text: "EXTRACT", "text-recognizers": []

catalogue.pdf

OCR when needed and a vector on every page

Tesseract recognizer + PAGES binding; one render serves both

scanned-loan-form.pdf

PDFs by pages, everything else by text

TEXT and PAGES bindings; the PDF releases only PAGES

mixed intake

OCR off for PDFs only

recognizer + pages.text: "EXTRACT"

born-digital catalogues

Everything off for one request

{"parse-context": {"text-recognizers": {"enabled": false}, "inference": {"enabled": false}}}

bulk re-index of known files

The hosted OCR recipe, with the key from the environment (not run, paid API):

{
  "engines": {
    "jina-ocr": { "openai-vlm-parser": {
        "baseUrl": "https://api.jina.ai",
        "model": "jina-ocr-v1",
        "apiKey": "${env:JINA_API_KEY}",
        "prompt": "Transcribe the provided document image into a clean Markdown format, preserving the natural reading order.",
        "maxTokens": 8192 } }
  },
  "text-recognizers": [ { "engine": "jina-ocr" } ]
}

Two observations from the recipes page are worth repeating. The startup check reads Jina’s public /v1/models, so a wrong key passes the load and fails at the first document with HTTP 401. And the model returns tables as raw HTML; Tika turns them into <table>, as described above.

One render per page can feed both a recognizer and a PAGES binding, so keep the render settings the recognizer needs when you combine them (300 dpi grayscale suits Tesseract, a VLM is usually happy with 150 dpi colour):

{
  "engines": {
    "tesseract": { "tesseract-ocr-parser": { "language": "eng" } },
    "embedder": { "openai-embedding-engine": { "baseUrl": "http://127.0.0.1:8000", "model": "siglip2" } }
  },
  "text-recognizers": [ { "engine": "tesseract" } ],
  "inference": [
    { "id": "page-vectors", "engine": "embedder", "input": "PAGES", "tasks": ["embed"], "maxChunks": 50 }
  ],
  "parsers": [
    { "default-parser": {} },
    { "pdf-parser": { "pages": { "inference": ["PAGES"], "render": { "imageType": "RGB" } } } }
  ]
}

Source: OCR and Inference Recipes, 4.1.x docs (the "Hosted OCR: jina-ocr-v1" and "OCR when needed and a vector on every page" recipes, engine names adapted). Not run; the Tesseract side is covered on OCR with Tesseract.

Why Markdown is required for inference

The TEXT stage, and the 4.0 embedding filters before it, chunk tk:content with MarkdownChunker. The chunker looks for boundaries in priority order:

  1. Headings (# to ######): the strongest boundary, and the only split made when a section already fits in maxChunkChars.

  2. Blank lines: paragraph boundaries, to subdivide an oversized section.

  3. A hard split at maxChunkChars: the last resort for a single paragraph that is still too long.

Each chunk overlaps the previous one by overlapChars (200 by default; it must be smaller than maxChunkChars, or the load fails). Plain text has no headings, so a ten-page report chunks at arbitrary paragraph breaks instead of its sections, the heading text that tells the embedding model what a chunk is about is lost, and tables and lists lose their structure. Nothing fails; the chunks are only worse.

Two mechanisms keep this right in 4.x:

  • The default handler is Markdown. BasicContentHandlerFactory defaults to HANDLER_TYPE.MARKDOWN, and tika-server and tika-app default to Markdown output, so an unconfigured setup already chunks well. In Java, pass HANDLER_TYPE.MARKDOWN explicitly, as the programs above do.

  • A runtime warning when it is overridden. The handler factory records its type in tk:content-handler-type (the CLI run above shows MARKDOWN), and the embedding code logs a WARN when it is anything else. It is a check on a recorded value, not a guess about the content.

The project considered failing the load, or silently switching the handler, and rejected both: the first would make deliberate plain-text chunking impossible, the second would change tk:content under consumers that asked for TEXT.

For Pipes, use the CONCATENATE or RMETA parse mode with inference. CONTENT_ONLY emits only the raw tk:content string, so the chunks and vectors are dropped at emit time. The Elasticsearch and OpenSearch emitters have a chunkStrategy option for writing chunks as their own documents.

The site’s Document Ingestion & Chunking page compares chunking strategies in general; Tika’s chunker is a structural one with a fixed overlap.

The 4.0 shapes and their deprecation

Tika 4.0.0 shipped embeddings as metadata filters and an image parser. 4.1.0 keeps them working, with a WARN at startup, and the project states they are removed in 4.2.0:

4.0 shape What it did 4.1 replacement

openai-embedding-filter in metadata-filters

Chunked and embedded each document’s tk:content, one document per request

An engine plus a TEXT binding, which batches the whole tree

jina-embedding-filter

The same against Jina, with a task option (default retrieval.passage)

openai-embedding-engine with Jina’s keys in requestParameters

openai-image-embedding-parser as a recognizer

Embedded one image per request

An engine plus an IMAGES binding

A VLM or OCR engine under parsers

Configured the engine and made it a parser for unclaimed types

engines + text-recognizers; fails the load in 4.2.0

A 4.0 filter configuration, kept here only to recognise it when migrating:

{
  "content-handler-factory": {
    "basic-content-handler-factory": { "type": "MARKDOWN", "writeLimit": -1 }
  },
  "metadata-filters": [
    { "openai-embedding-filter": {
        "baseUrl": "https://api.openai.com",
        "model": "text-embedding-3-small",
        "apiKey": "${env:OPENAI_API_KEY}",
        "maxChunkChars": 1500,
        "overlapChars": 200 } }
  ]
}

Source: Content Handler Requirements for Inference, 4.1.x docs and Migrating to Tika 4.x, 4.1.x docs. Not run. In 4.1.0 a request can no longer name these filters in a per-request metadata-filters list (4.0.x accepted a caller-supplied baseUrl there, which sent documents wherever the caller chose); the flat {"openai-embedding-filter": {"skipEmbedding": true}} switch still works.

Cost, privacy and data residency

A VLM or embedding engine is the one place where Tika sends document content to another system. Everything in Tika’s security model about untrusted input still applies, and the following comes on top. The Cost Management and Sensitive Information pages cover the general practice.

Cost:

  • Every page and every picture is a call. With AUTO, a 40-page scanned loan form is 40 VLM calls; an email with a signature logo in every reply is one call per logo unless _min-width/_min-height or minWidth/minHeight keep it out. Set ocr.maxPages in the PDF parser’s pages block, maxChunks on bindings and maxTokens on VLM engines.

  • Retries cost too. A 502 that succeeds on the fourth retry may have been billed four times, depending on the vendor.

  • Measure. Sum vlm:prompt-tokens and vlm:completion-tokens from the output. Embedding engines report no usage in Tika’s metadata, so count units (chunks) instead.

  • Re-parsing is not free. Re-indexing a corpus re-runs every model call. Store the vectors with the document version, and use a no-enrichment preset for re-parses that only need metadata.

Privacy and residency:

  • The bytes leave your network. The image or page render is sent base64-encoded in the request body. A hosted vendor’s retention, training-use and logging terms then apply, along with the region its endpoint runs in. These terms are vendor contracts, not Tika settings, and this page does not describe any vendor’s; read them before sending personal data.

  • Choose the region with the endpoint. Residency is decided by baseUrl: a regional endpoint, a private cloud deployment (an Azure resource in your region, a Vertex endpoint behind a gateway) or a local model. Tika has no routing of its own, and a caller can never change baseUrl per request.

  • Local is the default answer for sensitive files. For the loan forms, a model on your own hardware (Ollama, vLLM) keeps the data in place. Use separate engines, and if needed separate tika-server instances, for files that may go to a hosted model and files that may not; presets select the configuration per workflow.

  • Keys. Read keys with ${env:NAME}, inject them from your secret store (Kubernetes Secret, Docker secret, Vault) and never commit them. Note the limit the docs state: with a persistent config store (configStoreType file or Ignite), resolved fetcher and emitter configurations are written to that store. Gemini’s key travels in the URL.

  • Output is untrusted. A model can hallucinate text that was not on the page, and a hostile image can carry prompt injection. Never let VLM text drive actions (routing, approvals) without a check, and keep allowRuntimePrompt off.

Memory, performance and robustness

  • The whole input is held in memory. AbstractVLMParser.parse reads the file fully and base64-encodes it, so a 50 MB input (the default maxFileSizeToOcr) is about 50 MB of bytes plus about 67 MB of base64 text plus the JSON body, per concurrent parse. Lower maxFileSizeToOcr and keep page renders small.

  • The model dominates latency. Seconds per page for a hosted model, tens of seconds for a laptop model. The progress timeout counts the wait as progress, but the total task timeout still applies; size it for your longest document.

  • Use the forked paths for untrusted files. In tika-server and Pipes the parse runs in a forked JVM, so a stuck model call ends with the fork, not with your application. The engines' HTTP clients are closed when the pipes server shuts down; in a library, close the EngineRegistry.

  • Failures are contained. A failed recognizer is reported through the parser’s normal exception handling and does not stop other recognizers; a failed embedding request becomes a warning on the root document. Check tk:exception:warn (and the other tk:exception:* keys) in your intake reports.

  • Two recognizers on one type both run. If you list Tesseract and a VLM for the same images, both run and their text is concatenated (a startup WARN names them). Choose one per media type with _mime-include/_mime-exclude.

What changed: 1.x to 4.x

Line Model and inference support

1.x (Tika in Action)

None of this exists. The book (chapter 5 on parsing) predates OCR integration; Tesseract came later in the 1.x line. Later 1.x releases added machine-learning parsers such as the TensorFlow-backed object recognition parser.

2.x and 3.x

The ML parsers moved under tika-parsers-ml: in 3.3.2 that directory holds tika-dl, tika-age-recogniser, tika-parser-advancedmedia-module (with ObjectRecognitionParser), tika-parser-nlp-module and tika-transcribe-aws. No VLM parsers, no embeddings, XML configuration.

4.0

tika-vlm with the three VLM parsers (openai-vlm-parser auto-registered); embeddings as openai-embedding-filter, jina-embedding-filter and openai-image-embedding-parser; engines configured under parsers; Markdown as the default handler. The old ML modules are gone: in 4.1.0 tika-parsers-ml holds tika-inference, tika-vlm, the NLP modules, tika-parser-tess4j-module and tika-transcribe-aws.

4.1

engines map, the stable text-recognizers list and the experimental inference section with TEXT, IMAGES, PAGES and MEDIA bindings; pages.inference; textRecognizer and allowRuntimePrompt; ${env:NAME} interpolation; no VLM auto-registration; the 4.0 shapes deprecated.

4.2 (announced)

Engines under parsers fail the load; the 4.0 filters and image embedding parser are removed; batched recognition per document, document-level tasks and chat tasks (captions, tags) are planned.

Source: the 3.3.2 and 4.1.0 source trees of apache/tika (directory listings of tika-parsers/tika-parsers-ml), Migrating to Tika 4.x, 4.1.x docs and Tika in Action. The 1.x history of the ML parsers is from the project’s module layout, not re-checked release by release.

Pitfalls

  • TEXT bindings do nothing from a bare RecursiveParserWrapper. Call InferenceDispatcher.text(...) after the parse, or use tika-app, tika-server or Pipes.

  • Unreachable model at startup fails the load for OpenAI-compatible and Gemini engines. Order your containers and retry the start.

  • A wrong Claude or Jina key passes startup and fails on the first document.

  • apiKeyHeaderName/apiKeyPrefix are not JSON keys for the VLM parser in 4.1.0 (they are for the embedding engine).

  • The default model names are not a recommendation. jinaai/jina-vlm is non-commercial; the Claude and Gemini defaults will be retired by their vendors.

  • A captioning prompt with textRecognizer: true lets AUTO replace a page’s real text with a description. Set textRecognizer: false for anything that is not a transcription.

  • A non-Markdown handler degrades chunking silently except for a WARN.

  • CONTENT_ONLY in Pipes discards the vectors.

  • Engine entries under parsers stop loading in 4.2.0. Migrate now.

  • Everything here is experimental. Re-read the release notes before every Tika upgrade and pin the version.

Checklist

  • Add tika-vlm and/or tika-inference (library) or use the tika-app / tika-server distributions, which include both.

  • Configure engines under engines, recognizers in text-recognizers, embeddings in inference.

  • Read every key with ${env:NAME}; never put a key in tika-config.json or in source control.

  • Set model, maxTokens, timeoutMillis, maxFileSizeToOcr, maxImagePixels and the minimum image size explicitly.

  • Cap pages and chunks (ocr.maxPages, maxChunks) and keep numClients within the key’s concurrency.

  • Use textRecognizer: false for non-transcription prompts and keep allowRuntimePrompt: false.

  • Use the Markdown handler, and CONCATENATE or RMETA in Pipes.

  • In Java: run the TEXT stage, then the metadata filters, and close the EngineRegistry.

  • Decide per document class whether it may leave your network; use a local model or a regional endpoint where not.

  • Record vlm:* token counts and the chunk counts for cost tracking.