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 ( |
A vector per unit, in |
Config sections |
|
|
Engine types |
|
|
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 |
|
|
Status in 4.1.0 |
Experimental classes; the |
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:
(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.
Source: Configuration, 4.1.x docs (the text-recognizers
list) and Inference: engines and bindings, 4.1.x docs.
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 |
|
|
|
Endpoint |
Any OpenAI-compatible chat completions API: vLLM, Ollama, LiteLLM, a FastAPI wrapper, OpenAI, Azure OpenAI |
Anthropic Messages API ( |
Google Gemini |
Default |
|||
Default |
|
|
|
Authentication |
|
|
|
Startup health check |
|
None |
|
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 |
No |
|
|
|
Token counts read from |
|
|
|
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 |
|---|---|---|
|
text |
The |
|
integer |
Input tokens the endpoint reported (the image counts here). |
|
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 |
|---|---|---|
|
per parser (table above) |
Endpoint root, no trailing slash. Cannot be changed per request. |
|
per parser |
Model identifier sent in the request and written to |
|
"Extract all visible text from this image. …" (Markdown transcription prompt) |
The instruction sent with the image. Changing it per request needs |
|
|
Ceiling on generated tokens, and so on output cost per call. Per request it may be lowered, never raised. |
|
|
HTTP read timeout, also used for the startup probe. Local models need far more (the docs suggest 600000 for a laptop). |
|
|
Retries of a 429, 502, 503 or 504 with a jittered backoff inside the parse’s time budget; |
|
|
Sent as described in the parser table. Use |
|
|
OpenAI-compatible parser only; Claude and Gemini ignore it. |
|
|
For an image embedded inline (embedded resource type |
|
|
Kill switch. The parser advertises no types and does nothing. Allowed per request. |
|
|
Says the prompt transcribes. Set |
|
|
Smaller inputs are skipped, in bytes. |
|
|
Larger inputs are skipped, in bytes. |
|
|
Images with a larger decoded area are skipped before any request (decompression-bomb and cost guard); |
|
|
Whether a caller-supplied JSON config may change |
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 }
]
}
Source: The text-recognizers list,
4.1.x docs. Not run.
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-recognizersloader 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/completionsfails the probe; add the/v1/modelsroute (the 4.1.x local-server guide does) or use a non-defaultcompletionsPath. -
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" } }
]
}
Source: Using a VLM parser for PDF parsing (deprecated), 4.1.x docs. Not run.
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 |
|---|---|
|
Always refused. A caller can never redirect the document, or your key, to another host. |
|
Always refused. A second model is a second engine. |
|
May be lowered, never raised above the load-time value. This ceiling is what bounds the cost of one call. |
|
Always refused. |
|
Refused unless the engine was loaded with |
|
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 |
|
Easiest on a laptop or a single GPU box; pull a vision-capable model first. See Ollama. |
vLLM |
|
Higher throughput, batching on GPU. See vLLM and SGLang. |
A FastAPI wrapper around a Hugging Face model |
Must implement |
The 4.1.x guide ships one for |
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
timeoutMillisto 600000 or more, and size the parse’s owntimeout-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 inVLMOCRConfig) is CC BY-NC 4.0, non-commercial, whileQwen/Qwen2-VL-2B-Instructis Apache 2.0. Check before a commercial deployment. -
Bind locally. The guide’s server binds
127.0.0.1only. 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.inferenceon the PDF parser: what a rendering parser releases (TEXT, the default, and/orPAGES).
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 |
|---|---|---|
|
Endpoint root; the engine appends |
|
|
|
Model identifier. |
|
|
Use |
|
|
Per request. |
|
|
Inputs per request. A document tree’s units are sent in batches of this size. |
|
|
Retries of 429/502/503/504 (see Hosted engines and concurrency). |
|
|
For Azure deployments and gateways. |
|
|
|
|
`Bearer ` (with a space) |
|
|
empty map |
Vendor keys copied into every request body (see Vendor keys: requestParameters). |
|
|
How an image goes into |
|
none |
|
Source: Inference: engines and bindings, 4.1.x docs
and the field initializers in OpenAIEmbeddingEngine 4.1.0
(Javadoc).
Bindings
| Key | Meaning |
|---|---|
|
Names the binding; defaults to |
|
A name from |
|
|
|
|
|
|
|
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). |
|
Largest unit accepted (20 MB unless set, |
|
TEXT only: |
|
IMAGES only: smaller recorded images are not offered (2 unless set, so spacers never are). |
|
|
|
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 |
|---|---|---|
|
The |
On the document the text came from, with a |
|
Every image document: inline pictures, image attachments, a top-level image file (not page renders) |
In recursive output, on the containing document with an |
|
One render per page, from a parser that releases pages (the PDF parser with |
On the PDF, with a |
|
Audio and video cut by ffmpeg into segments (25 s windows, 5 s overlap by default) |
On the media document, with a |
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:chunksis one metadata value holding a JSON array. Each chunk hasvector,producer(the bindingid),modalityandlocators; text chunks also carry theirtext. Thevectoris base64 of big-endian float32 values: the mock’s[0.1, 0.2, 0.3, 0.4]decodes fromPczMzT5MzM0+mZmaPszMzQ==exactly. Decode it withByteBuffer.wrap(bytes).asFloatBuffer()in Java (big-endian is theByteBufferdefault). -
tk:inference-releasedwasTEXTandPAGESon the PDF. With["PAGES"]alone the TEXT stage skips the PDF, so it is embedded once, by its pages. -
requestParametersarrived in the request body: the mock logged the keysdimensions,inputandmodel. -
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.
Source: Inference: engines and bindings, 4.1.x docs. Not run.
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 |
|
|
Hosted document OCR instead of Tesseract |
|
|
Text vectors for every document |
|
|
Page vectors, no OCR |
a PAGES binding + |
|
OCR when needed and a vector on every page |
Tesseract recognizer + PAGES binding; one render serves both |
|
PDFs by pages, everything else by text |
TEXT and PAGES bindings; the PDF releases only PAGES |
mixed intake |
OCR off for PDFs only |
recognizer + |
born-digital catalogues |
Everything off for one request |
|
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:
-
Headings (
#to######): the strongest boundary, and the only split made when a section already fits inmaxChunkChars. -
Blank lines: paragraph boundaries, to subdivide an oversized section.
-
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.
BasicContentHandlerFactorydefaults toHANDLER_TYPE.MARKDOWN, and tika-server and tika-app default to Markdown output, so an unconfigured setup already chunks well. In Java, passHANDLER_TYPE.MARKDOWNexplicitly, 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 showsMARKDOWN), and the embedding code logs aWARNwhen 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.
Source: Content Handler
Requirements for Inference, 4.1.x docs, Chunk
Strategies, 4.1.x docs and the MarkdownChunker 4.1.0 source
(Javadoc).
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 |
|---|---|---|
|
Chunked and embedded each document’s |
An engine plus a TEXT binding, which batches the whole tree |
|
The same against Jina, with a |
|
|
Embedded one image per request |
An engine plus an IMAGES binding |
A VLM or OCR engine under |
Configured the engine and made it a parser for unclaimed types |
|
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-heightorminWidth/minHeightkeep it out. Setocr.maxPagesin the PDF parser’spagesblock,maxChunkson bindings andmaxTokenson 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-tokensandvlm:completion-tokensfrom 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 changebaseUrlper 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 (configStoreTypefileor 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
allowRuntimePromptoff.
Memory, performance and robustness
-
The whole input is held in memory.
AbstractVLMParser.parsereads the file fully and base64-encodes it, so a 50 MB input (the defaultmaxFileSizeToOcr) is about 50 MB of bytes plus about 67 MB of base64 text plus the JSON body, per concurrent parse. LowermaxFileSizeToOcrand 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 othertk: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
WARNnames 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 |
4.0 |
|
4.1 |
|
4.2 (announced) |
Engines under |
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. CallInferenceDispatcher.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/apiKeyPrefixare 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-vlmis non-commercial; the Claude and Gemini defaults will be retired by their vendors. -
A captioning prompt with
textRecognizer: trueletsAUTOreplace a page’s real text with a description. SettextRecognizer: falsefor anything that is not a transcription. -
A non-Markdown handler degrades chunking silently except for a
WARN. -
CONTENT_ONLYin Pipes discards the vectors. -
Engine entries under
parsersstop 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-vlmand/ortika-inference(library) or use the tika-app / tika-server distributions, which include both. -
Configure engines under
engines, recognizers intext-recognizers, embeddings ininference. -
Read every key with
${env:NAME}; never put a key intika-config.jsonor in source control. -
Set
model,maxTokens,timeoutMillis,maxFileSizeToOcr,maxImagePixelsand the minimum image size explicitly. -
Cap pages and chunks (
ocr.maxPages,maxChunks) and keepnumClientswithin the key’s concurrency. -
Use
textRecognizer: falsefor non-transcription prompts and keepallowRuntimePrompt: false. -
Use the Markdown handler, and
CONCATENATEorRMETAin 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.
References
-
Apache Tika 4.1.x, Content Handler Requirements for Inference
-
Apache Tika 4.1.x, Configuration (text-recognizers, secrets from the environment)
-
Apache Tika 4.1.x, Pages: rendering, OCR policy, emitted page images
-
Apache Tika 4.1.x, tika-server (allowPerRequestConfig, presets)