Content Handler Requirements for Inference

A TEXT inference binding reads extracted text from the tk:content metadata field, splits it with the markdown-chunker, and sends the resulting chunks to an embeddings engine. The chunker splits on markdown structure, so the content handler that produced tk:content has to be MARKDOWN for it to do its job.

tika-inference is experimental. Its classes, configuration keys and the tk:chunks layout may change in minor releases without a deprecation cycle, and no compatibility shims are kept for earlier shapes.

Since 4.x that is the default, so the common case needs no configuration. This page records why, and what happens if you override it.

Why Markdown

MarkdownChunker looks for structural boundaries in priority order:

  1. Headings (# H1, H2, …​ up to ##) — the strongest semantic boundary, and the only split it makes when a section already fits inside maxChunkChars.

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

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

Plain text offers only the last two. The practical differences:

  • A 10-page PDF with headings chunks at its heading boundaries in markdown, but at arbitrary paragraph breaks in plain text.

  • Tables, code blocks, and lists keep their structure in markdown, which improves embedding quality.

  • The heading text itself lands inside the chunk, giving the embedding model context about what the chunk is about.

Nothing fails when the content is plain text — the chunks are simply worse.

How the Default Is Enforced

  • The default handler type is MARKDOWN. BasicContentHandlerFactory defaults to HANDLER_TYPE.MARKDOWN, so a config that does not mention content-handler-factory is already correct for inference. Markdown is close enough to a superset of plain text for search and indexing; a consumer that genuinely needs plain text sets "type": "TEXT".

  • The content handler factory records its type in tk:content-handler-type, so a consumer can tell which handler produced tk:content.

Configuration Example

The minimum for an inference run is an engine and a TEXT binding:

{
  "engines": {
    "embedder": { "openai-embedding-engine": { "baseUrl": "http://localhost:8000",
                                               "model": "text-embedding-3-small" } }
  },
  "inference": [
    { "id": "text-vectors", "engine": "embedder", "input": "TEXT", "tasks": ["embed"] }
  ]
}

A full pipeline, with the handler type stated explicitly and chunks emitted to Elasticsearch:

{
  "content-handler-factory": {
    "basic-content-handler-factory": {
      "type": "MARKDOWN",
      "writeLimit": -1
    }
  },
  "engines": {
    "embedder": {
      "openai-embedding-engine": {
        "baseUrl": "https://api.openai.com",
        "model": "text-embedding-3-small",
        "apiKey": "${env:OPENAI_API_KEY}"
      }
    }
  },
  "inference": [
    { "id": "text-vectors", "engine": "embedder", "input": "TEXT", "tasks": ["embed"],
      "chunker": { "markdown-chunker": { "maxChunkChars": 1500, "overlapChars": 200 } } }
  ],
  "fetchers": {
    "fs": {
      "file-system-fetcher": {
        "basePath": "/data/documents"
      }
    }
  },
  "emitters": {
    "es": {
      "es-emitter": {
        "esUrl": "http://localhost:9200/documents",
        "idField": "doc_id"
      }
    }
  },
  "pipes-iterator": {
    "file-system-pipes-iterator": {
      "basePath": "/data/documents",
      "fetcherId": "fs",
      "emitterId": "es"
    }
  },
  "pipes": {
    "parseMode": "CONCATENATE",
    "numClients": 4
  }
}
Use CONCATENATE or RMETA for inference. CONTENT_ONLY parses the same way but emits only the raw tk:content string, so the chunks and vectors the binding produces are discarded at emit time.