This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 481fd774a4 TIKA-4889: TEXT input for inference bindings (#3159)
481fd774a4 is described below

commit 481fd774a40e5d2c6ed989b1656a84dbc1e91266
Author: Tim Allison <[email protected]>
AuthorDate: Mon Sep 14 17:47:58 2026 -0400

    TIKA-4889: TEXT input for inference bindings (#3159)
---
 CHANGES.txt                                        |   9 ++
 .../ROOT/pages/configuration/inference.adoc        |  47 +++++++-
 .../ROOT/pages/configuration/metadata-filters.adoc |   6 +-
 .../src/main/java/org/apache/tika/cli/TikaCLI.java |   5 +
 .../src/main/java/org/apache/tika/gui/TikaGUI.java |   5 +
 .../apache/tika/cli/TikaCLITextVectorsTest.java    | 134 +++++++++++++++++++++
 .../apache/tika/metadata/TikaCoreProperties.java   |   8 ++
 .../tika/parser/inference/InferenceBinding.java    |  13 ++
 .../tika/parser/inference/InferenceDispatcher.java |  83 ++++++++++++-
 .../tika/parser/inference/InferenceUnit.java       |  26 +++-
 .../apache/tika/parser/inference/TextChunker.java  |  23 ++--
 .../parser/inference/InferenceTextStageTest.java   | 127 +++++++++++++++++++
 .../apache/tika/metadata/metadata-key-fields.json  |   1 +
 .../org/apache/tika/metadata/metadata-keys.json    |   1 +
 .../main/java/org/apache/tika/inference/Chunk.java |  10 ++
 .../org/apache/tika/inference/ChunkSerializer.java |   6 +
 .../java/org/apache/tika/inference/EmbedTask.java  | 120 ++++++++++++++++--
 .../org/apache/tika/inference/EmbeddingEngine.java |   4 +
 .../org/apache/tika/inference/MarkdownChunker.java |  54 ++++++++-
 .../tika/inference/OpenAIEmbeddingEngine.java      |  35 ++++--
 .../apache/tika/inference/ChunkSerializerTest.java |  10 ++
 .../org/apache/tika/inference/EmbedTaskTest.java   |  56 +++++++++
 .../apache/tika/inference/MarkdownChunkerTest.java |  21 ++++
 .../tika/inference/OpenAIEmbeddingEngineTest.java  |  13 ++
 .../apache/tika/parser/pdf/AbstractPDF2XHTML.java  |   6 +
 .../org/apache/tika/parser/pdf/PDFParserTest.java  |   3 +
 .../tika/pipes/core/server/ConnectionHandler.java  |   3 +-
 .../apache/tika/pipes/core/server/EmitHandler.java |  29 ++++-
 .../apache/tika/pipes/core/server/PipesServer.java |   6 +-
 .../pipes/core/server/SharedServerResources.java   |  13 +-
 .../core/server/EmitHandlerUserMetadataTest.java   |   2 +-
 .../apache/tika/pipes/core/PipesClientTest.java    |  20 +++
 .../configs/tika-config-inference-bindings.json    |   5 +-
 .../apache/tika/config/loader/InferenceLoader.java |  33 ++++-
 .../tika/config/loader/InferenceLoaderTest.java    |  53 ++++++++
 .../org/apache/tika/config/loader/TestChunker.java |  35 ++++--
 .../server/standard/InferenceBindingsWireTest.java |  30 +++++
 .../configs/tika-config-inference-bindings.json    |   3 +-
 38 files changed, 993 insertions(+), 65 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 6c214ece66..3f0127b135 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,10 +1,19 @@
 Release 4.1.0 - unreleased
 
+   * TEXT input for inference bindings: a binding on TEXT with a chunker
+     (markdown-chunker) embeds the text of every document in a tree in
+     batched requests, after the parse and before the metadata filters; each
+     chunk lands on its document with a text locator. Chunks now name the
+     binding that wrote them as "producer". A PDF released as PAGES records
+     tk:inference-released and is not embedded twice. The embedding filters
+     keep working (TIKA-4889).
+     
    * "pdf-parser": {"maxRenderedPages": N} bounds how many pages the
      RENDER_PAGES_BEFORE_PARSE and RENDER_PAGES_AT_PAGE_END image strategies
      render, counted from the first page and independent of maxPages: text
      extraction can cover the whole document while only the first page is
      rendered, as for a thumbnail (TIKA-4856).
+   
    * PDF: a bookmark outline is now walked without recursion, and lists nest at
      most 50 deep with deeper items joining the deepest list (TIKA-4894).
 
diff --git a/docs/modules/ROOT/pages/configuration/inference.adoc 
b/docs/modules/ROOT/pages/configuration/inference.adoc
index c2069243e6..a4439cde7a 100644
--- a/docs/modules/ROOT/pages/configuration/inference.adoc
+++ b/docs/modules/ROOT/pages/configuration/inference.adoc
@@ -68,11 +68,14 @@ A list of bindings. Each names an engine, says what it is 
fed, and what to ask:
   picture, an image attachment, a top-level image file; a page render emitted 
as an embedded
   document is not one. `PAGES` is one image per rendered page of a document 
whose parser
   renders pages for inference: the PDF parser, when its `inference.input` says 
so (see
-  <<pages>>). (`TEXT` and `MEDIA` follow.)
+  <<pages>>). `TEXT` is the extracted text of every document in the tree (see 
<<text>>).
+  (`MEDIA` follows.)
 `tasks`:: One call per task on the same engine and input. `embed` writes one 
vector chunk per
   unit into `tk:chunks`, with the page locator when the unit has a page.
 `maxChunks`:: How many units one document tree may send; for `PAGES`, how many 
pages of one
-  document; -1 for no limit.
+  document; for `TEXT`, how many chunks across the tree; -1 for no limit.
+`chunker`:: `TEXT` only: how a document's text is cut, `{ "markdown-chunker": 
{ "maxChunkChars":
+  1500, "overlapChars": 200 } }`. Without one the whole text is one chunk.
 `maxBytes`:: Largest unit the binding accepts (20 MB unless set; -1 for no 
limit); larger
   ones are skipped and counted.
 `enabled`:: `false` keeps the binding configured but idle until a request 
selects it.
@@ -151,6 +154,46 @@ Per request, like every PDF setting:
                      "inference": { "bindings": ["page-vectors"] } } }
 ----
 
+[#text]
+== Text
+
+A `TEXT` binding embeds the extracted text of every document in the tree, the 
container and
+its attachments alike:
+
+[source,json]
+----
+{
+  "engines": {
+    "embedder": { "openai-embedding-engine": { "baseUrl": "http://embed:8000";, 
"model": "bge-m3" } }
+  },
+  "inference": [
+    { "id": "text-vectors", "engine": "embedder", "input": "TEXT", "tasks": 
["embed"],
+      "chunker": { "markdown-chunker": { "maxChunkChars": 1500, 
"overlapChars": 200 } },
+      "maxChunks": 500 }
+  ]
+}
+----
+
+It runs after the parse, over the finished metadata list and before the 
metadata filters
+(so a filter shapes what it wrote and cannot starve it): every document's 
`tk:content` is chunked, the chunks of the whole tree are sent in
+`maxBatchSize` requests in document order (a request may hold a container's 
last chunks and
+an attachment's first; the endpoint treats inputs independently and answers by 
index), and
+each chunk lands on the document its text came from, with a `text` locator and 
the binding
+as its `producer`. A rejected request is retried one document at a time. 
`maxChunks` caps the
+chunks of the tree, `maxBytes` the text of one document, and 
`_mime-include`/`_mime-exclude`
+narrow by document type. Use a `MARKDOWN` content handler for the chunker to 
see headings.
+
+A document that released other inputs to inference is not embedded twice: a 
PDF parsed with
+`inference.input: ["PAGES"]` records `tk:inference-released: PAGES` and the 
TEXT stage skips
+it; list `["TEXT", "PAGES"]` to get both. Because this stage runs over the 
metadata list, it
+covers `-J`, `/rmeta` and pipes; the streaming `/tika` endpoint has no list 
and no text
+vectors. It is not a metadata filter: a per-request `metadata-filters` list 
does not touch it,
+and the per-request `inference` block below is the way to switch it off.
+
+The 4.0 `openai-embedding-filter` and `jina-embedding-filter` keep working and 
embed one
+document per request; the binding batches the tree and shares the engine with 
the other
+bindings.
+
 == Per request
 
 [source,json]
diff --git a/docs/modules/ROOT/pages/configuration/metadata-filters.adoc 
b/docs/modules/ROOT/pages/configuration/metadata-filters.adoc
index 262fee13a9..dc19f1fd8b 100644
--- a/docs/modules/ROOT/pages/configuration/metadata-filters.adoc
+++ b/docs/modules/ROOT/pages/configuration/metadata-filters.adoc
@@ -115,8 +115,10 @@ Adding the module to the classpath is enough to make the 
name resolvable.
 
 |`openai-embedding-filter`, `jina-embedding-filter`
 |`tika-inference`
-|Chunks `tk:content` and calls an embeddings endpoint, putting the vectors on 
`tk:chunks`. See
-xref:migration-to-4x/chunk-strategies.adoc[Chunk Strategies].
+|Chunks `tk:content` and calls an embeddings endpoint, putting the vectors on 
`tk:chunks`, one
+document per request. Since 4.1.0 a `TEXT` 
xref:configuration/inference.adoc#text[inference
+binding] does the same for the whole tree in batched requests; it runs before 
this chain and
+is not part of it. See xref:migration-to-4x/chunk-strategies.adoc[Chunk 
Strategies].
 
 |`charsoup-metadata-filter`, `optimaize-metadata-filter`, 
`open-nlp-metadata-filter`
 |`tika-langdetect-charsoup`, `-optimaize`, `-opennlp`
diff --git a/tika-app/src/main/java/org/apache/tika/cli/TikaCLI.java 
b/tika-app/src/main/java/org/apache/tika/cli/TikaCLI.java
index 0a8284ff16..97989e2c11 100644
--- a/tika-app/src/main/java/org/apache/tika/cli/TikaCLI.java
+++ b/tika-app/src/main/java/org/apache/tika/cli/TikaCLI.java
@@ -96,6 +96,7 @@ import org.apache.tika.parser.ParserDecorator;
 import org.apache.tika.parser.PasswordProvider;
 import org.apache.tika.parser.RecursiveParserWrapper;
 import org.apache.tika.parser.digestutils.CommonsDigesterFactory;
+import org.apache.tika.parser.inference.InferenceDispatcher;
 import org.apache.tika.pipes.api.ParseMode;
 import org.apache.tika.pipes.fork.PipesForkParser;
 import org.apache.tika.pipes.fork.PipesForkParserConfig;
@@ -740,6 +741,10 @@ public class TikaCLI {
         JsonMetadataList.setPrettyPrinting(prettyPrint);
         try (Writer writer = getOutputWriter(output, encoding)) {
             List<Metadata> metadataList = handler.getMetadataList();
+            InferenceDispatcher inference = 
tikaLoader.get(InferenceDispatcher.class);
+            if (inference != null) {
+                inference.text(metadataList, context);
+            }
             tikaLoader.loadMetadataFilters().filter(metadataList);
             JsonMetadataList.toJson(metadataList, writer);
         }
diff --git a/tika-app/src/main/java/org/apache/tika/gui/TikaGUI.java 
b/tika-app/src/main/java/org/apache/tika/gui/TikaGUI.java
index a1634a75f0..7eeeff2c41 100644
--- a/tika-app/src/main/java/org/apache/tika/gui/TikaGUI.java
+++ b/tika-app/src/main/java/org/apache/tika/gui/TikaGUI.java
@@ -85,6 +85,7 @@ import org.apache.tika.parser.AutoDetectParser;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
 import org.apache.tika.parser.RecursiveParserWrapper;
+import org.apache.tika.parser.inference.InferenceDispatcher;
 import org.apache.tika.sax.BasicContentHandlerFactory;
 import org.apache.tika.sax.BodyContentHandler;
 import org.apache.tika.sax.ContentHandlerDecorator;
@@ -390,6 +391,10 @@ public class TikaGUI extends JFrame implements 
ActionListener, HyperlinkListener
             StringWriter jsonBuffer = new StringWriter();
             JsonMetadataList.setPrettyPrinting(true);
             List<Metadata> metadataList = 
recursiveParserWrapperHandler.getMetadataList();
+            InferenceDispatcher inference = 
tikaConfig.get(InferenceDispatcher.class);
+            if (inference != null) {
+                inference.text(metadataList, rpwContext);
+            }
             tikaConfig.loadMetadataFilters().filter(metadataList);
             JsonMetadataList.toJson(metadataList, jsonBuffer);
             setText(json, jsonBuffer.toString());
diff --git 
a/tika-app/src/test/java/org/apache/tika/cli/TikaCLITextVectorsTest.java 
b/tika-app/src/test/java/org/apache/tika/cli/TikaCLITextVectorsTest.java
new file mode 100644
index 0000000000..899e7ac1c4
--- /dev/null
+++ b/tika-app/src/test/java/org/apache/tika/cli/TikaCLITextVectorsTest.java
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.cli;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import org.apache.tika.http.TikaTestHttpServer;
+import org.apache.tika.inference.Chunk;
+import org.apache.tika.inference.ChunkSerializer;
+
+/**
+ * The consumer's path for text: a TEXT binding with a chunker, and -J shows 
every document of
+ * a zip with its own text vectors, all from one request that mixed the 
documents' chunks.
+ */
+public class TikaCLITextVectorsTest {
+
+    /** Vector[0] is the input index, so placement is checkable. */
+    private static final String EMBEDDING_RESPONSE = "{\"data\":["
+            + "{\"index\":3,\"embedding\":[3.0,0.5]},"
+            + "{\"index\":0,\"embedding\":[0.0,0.5]},"
+            + "{\"index\":2,\"embedding\":[2.0,0.5]},"
+            + "{\"index\":1,\"embedding\":[1.0,0.5]}]}";
+
+    @TempDir
+    Path tmp;
+
+    @Test
+    public void testOneRequestForTheTreeVectorsOnEachDocument() throws 
Exception {
+        Path zip = tmp.resolve("two-notes.zip");
+        try (ZipOutputStream out = new 
ZipOutputStream(Files.newOutputStream(zip))) {
+            entry(out, "alpha.txt", "alpha is the first note");
+            entry(out, "beta.txt", "beta is the second note");
+        }
+        try (TikaTestHttpServer server = new TikaTestHttpServer()) {
+            server.enqueue(new TikaTestHttpServer.MockResponse(200, 
EMBEDDING_RESPONSE));
+            Path config = tmp.resolve("tika-config.json");
+            Files.writeString(config, """
+                    {
+                      "engines": {
+                        "embedder": { "openai-embedding-engine": { "baseUrl": 
"BASE_URL", "model": "bge" } }
+                      },
+                      "inference": [
+                        { "id": "text-vectors", "engine": "embedder", "input": 
"TEXT", "tasks": ["embed"],
+                          "chunker": { "markdown-chunker": { "maxChunkChars": 
2000, "overlapChars": 0 } } }
+                      ]
+                    }
+                    """.replace("BASE_URL", server.url()));
+
+            JsonNode output = new ObjectMapper().readTree(
+                    run("--config=" + config, "-J", zip.toUri().toString()));
+
+            assertEquals(3, output.size(), "the zip and its two notes");
+            assertEquals(1, server.getRequestCount(), "one request for the 
whole tree");
+            JsonNode request = new 
ObjectMapper().readTree(server.takeRequest().body());
+            // the zip's own markdown content is two headings, so two chunks; 
one per note
+            assertEquals(4, request.get("input").size(), "chunks of all three 
documents in one request");
+            assertEquals("# alpha.txt", request.get("input").get(0).asText());
+            assertEquals("alpha is the first note", 
request.get("input").get(2).asText());
+
+            for (JsonNode document : output) {
+                String content = text(document, "tk:content");
+                List<Chunk> chunks = ChunkSerializer.fromJson(text(document, 
"tk:chunks"));
+                boolean container = content.startsWith("# alpha.txt");
+                assertEquals(container ? 2 : 1, chunks.size(), content);
+                for (Chunk chunk : chunks) {
+                    assertEquals("text-vectors", chunk.getProducer());
+                    assertEquals(2, chunk.getVector().length);
+                    assertTrue(content.contains(chunk.getText()), "the chunk 
is this document's own text");
+                    int start = 
chunk.getLocators().getText().get(0).getStartOffset();
+                    assertEquals(chunk.getText(), content.substring(start, 
start + chunk.getText().length()));
+                }
+                if (content.startsWith("beta")) {
+                    assertEquals(3.0f, chunks.get(0).getVector()[0], "fourth 
input, placed by index");
+                }
+            }
+        }
+    }
+
+    private static void entry(ZipOutputStream zip, String name, String text) 
throws Exception {
+        zip.putNextEntry(new ZipEntry(name));
+        zip.write(text.getBytes(UTF_8));
+        zip.closeEntry();
+    }
+
+    private static String text(JsonNode node, String field) {
+        JsonNode value = node.get(field);
+        assertNotNull(value, field);
+        return value.isArray() ? value.get(0).asText() : value.asText();
+    }
+
+    private static String run(String... args) throws Exception {
+        PrintStream stdout = System.out;
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        try {
+            System.setOut(new PrintStream(out, true, UTF_8.name()));
+            TikaCLI.main(args);
+        } finally {
+            System.setOut(stdout);
+        }
+        String json = out.toString(UTF_8);
+        assertTrue(json.startsWith("["), json);
+        return json;
+    }
+}
diff --git 
a/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java 
b/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java
index 7470a06cad..c528f3f34d 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/TikaCoreProperties.java
@@ -152,6 +152,14 @@ public interface TikaCoreProperties {
      * of document content for downstream indexing and semantic search.
      */
     Property TIKA_CHUNKS = Property.reservedInternalText("tk:chunks");
+
+    /**
+     * The inference inputs a parser released for this document (TEXT, PAGES, 
...), written
+     * only when they differ from the default of its text. The TEXT stage 
skips a document
+     * whose list lacks TEXT, so a PDF released as pages is not embedded twice.
+     */
+    Property INFERENCE_RELEASED =
+            Property.reservedInternalTextBag(TIKA_META_PREFIX + 
"inference-released");
     /**
      * Use this to store parse exception information in the Metadata object.
      */
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceBinding.java
 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceBinding.java
index 90e8d86efb..a9ca185223 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceBinding.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceBinding.java
@@ -34,10 +34,18 @@ public final class InferenceBinding {
     private final int maxChunks;
     private final long maxBytes;
     private final boolean enabled;
+    private final TextChunker chunker;
 
     public InferenceBinding(String id, String engine, InputKind input, 
List<String> tasks,
                             Set<MediaType> include, Set<MediaType> exclude, 
int maxChunks,
                             long maxBytes, boolean enabled) {
+        this(id, engine, input, tasks, include, exclude, maxChunks, maxBytes, 
enabled, null);
+    }
+
+    public InferenceBinding(String id, String engine, InputKind input, 
List<String> tasks,
+                            Set<MediaType> include, Set<MediaType> exclude, 
int maxChunks,
+                            long maxBytes, boolean enabled, TextChunker 
chunker) {
+        this.chunker = chunker;
         this.id = id;
         this.engine = engine;
         this.input = input;
@@ -79,6 +87,11 @@ public final class InferenceBinding {
         return enabled;
     }
 
+    /** How a TEXT binding cuts a document's text; null means the whole text 
is one chunk. */
+    public TextChunker getChunker() {
+        return chunker;
+    }
+
     public boolean accepts(InputKind kind, MediaType type) {
         if (kind != input || type == null) {
             return false;
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
index c5a7e5c5a9..6db6bd0d26 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceDispatcher.java
@@ -17,6 +17,7 @@
 package org.apache.tika.parser.inference;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
@@ -36,6 +37,7 @@ import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.extractor.EmbeddedMetadataLookup;
 import org.apache.tika.io.TemporaryResources;
+import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.mime.MediaType;
@@ -51,7 +53,8 @@ import org.apache.tika.parser.hook.ParseHook;
  * dispatcher owns until the flush. At the flush every unit is re-aimed at the 
metadata the
  * recursive wrapper kept for its document, since the parser's own copy is no 
longer read. A
  * request narrows the bindings through {@link InferenceSelection}. Failures 
mark the document
- * and never fail the parse.
+ * and never fail the parse. TEXT is not offered during the parse: whoever 
holds the finished
+ * metadata list runs {@link #text} over it.
  *
  * @since Apache Tika 4.1
  */
@@ -248,6 +251,84 @@ public final class InferenceDispatcher implements 
ParseHook, TransientParseState
         return kept;
     }
 
+    /**
+     * The {@link InputKind#TEXT} stage. Runs the TEXT bindings over the 
finished metadata list of
+     * a document tree, so every document's text goes to the engine in one 
pass and a batch may
+     * hold chunks of the container and of its attachments alike. Call it 
after the parse and
+     * before any metadata filters. A document that released other inputs to 
inference
+     * ({@link TikaCoreProperties#INFERENCE_RELEASED} without TEXT) is 
skipped. Failures mark
+     * the first document of the list.
+     */
+    public void text(List<Metadata> metadataList, ParseContext context) throws 
TikaException {
+        if (metadataList.isEmpty()) {
+            return;
+        }
+        Metadata root = metadataList.get(0);
+        for (Bound b : running(InputKind.TEXT, context)) {
+            InferenceBinding binding = b.binding();
+            List<InferenceUnit> units = new ArrayList<>();
+            int skipped = 0;
+            for (Metadata metadata : metadataList) {
+                String text = metadata.get(TikaCoreProperties.TIKA_CONTENT);
+                if (text == null || text.isBlank() || !releasedText(metadata)) 
{
+                    continue;
+                }
+                MediaType type = 
MediaType.parse(metadata.get(HttpHeaders.CONTENT_TYPE));
+                if (!binding.accepts(InputKind.TEXT,
+                        type == null ? MediaType.OCTET_STREAM : type)) {
+                    continue;
+                }
+                if (binding.getMaxBytes() >= 0
+                        && text.getBytes(StandardCharsets.UTF_8).length > 
binding.getMaxBytes()) {
+                    skipped++;
+                    continue;
+                }
+                units.add(new InferenceUnit(type, metadata, null, text));
+            }
+            if (skipped > 0) {
+                root.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, 
"inference binding "
+                        + binding.getId() + " over maxBytes: skipped " + 
skipped + " units");
+            }
+            if (units.isEmpty()) {
+                continue;
+            }
+            for (InferenceTask task : b.tasks()) {
+                try {
+                    task.run(binding, units, b.engine(), context);
+                } catch (Exception e) {
+                    LOG.warn("inference binding {} failed", binding.getId(), 
e);
+                    root.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                            "inference binding " + binding.getId() + ": " + 
e.getMessage());
+                }
+            }
+        }
+    }
+
+    /** Absent means the parser released its text; a list without TEXT means 
it did not. */
+    private static boolean releasedText(Metadata metadata) {
+        String[] released = 
metadata.getValues(TikaCoreProperties.INFERENCE_RELEASED);
+        if (released.length == 0) {
+            return true;
+        }
+        for (String r : released) {
+            if (InputKind.TEXT.name().equals(r)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** The bindings of this kind that run for the request: enabled and 
selected. */
+    private List<Bound> running(InputKind kind, ParseContext context) throws 
TikaException {
+        List<Bound> result = new ArrayList<>();
+        for (Bound b : bound) {
+            if (b.binding().getInput() == kind && runs(b.binding(), context)) {
+                result.add(b);
+            }
+        }
+        return result;
+    }
+
     private boolean runs(InferenceBinding binding, ParseContext context) 
throws TikaException {
         if (!binding.isEnabled()) {
             return false;
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
index 0ab0a76934..4724bbc1a1 100644
--- 
a/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
+++ 
b/tika-core/src/main/java/org/apache/tika/parser/inference/InferenceUnit.java
@@ -17,6 +17,7 @@
 package org.apache.tika.parser.inference;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 
@@ -40,6 +41,7 @@ public final class InferenceUnit {
     private final String targetIdPath;
     private final String parentIdPath;
     private final Path path;
+    private final String text;
     private final long size;
     private final int page;
 
@@ -53,12 +55,20 @@ public final class InferenceUnit {
                          Path path, int page) throws IOException {
         this(kind, type, target, parent, 
target.get(TikaCoreProperties.EMBEDDED_ID_PATH),
                 parent == null ? null : 
parent.get(TikaCoreProperties.EMBEDDED_ID_PATH), path,
-                Files.size(path), page);
+                null, Files.size(path), page);
+    }
+
+    /** A {@link InputKind#TEXT} unit: the target's extracted text, held in 
memory. */
+    public InferenceUnit(MediaType type, Metadata target, Metadata parent, 
String text) {
+        this(InputKind.TEXT, type, target, parent,
+                target.get(TikaCoreProperties.EMBEDDED_ID_PATH),
+                parent == null ? null : 
parent.get(TikaCoreProperties.EMBEDDED_ID_PATH), null,
+                text, text.getBytes(StandardCharsets.UTF_8).length, -1);
     }
 
     private InferenceUnit(InputKind kind, MediaType type, Metadata target, 
Metadata parent,
-                          String targetIdPath, String parentIdPath, Path path, 
long size,
-                          int page) {
+                          String targetIdPath, String parentIdPath, Path path, 
String text,
+                          long size, int page) {
         this.kind = kind;
         this.type = type;
         this.target = target;
@@ -66,6 +76,7 @@ public final class InferenceUnit {
         this.targetIdPath = targetIdPath;
         this.parentIdPath = parentIdPath;
         this.path = path;
+        this.text = text;
         this.size = size;
         this.page = page;
     }
@@ -73,7 +84,12 @@ public final class InferenceUnit {
     /** The same unit aimed at the metadata objects that are still read. */
     InferenceUnit retargeted(Metadata target, Metadata parent) {
         return new InferenceUnit(kind, type, target, parent, targetIdPath, 
parentIdPath, path,
-                size, page);
+                text, size, page);
+    }
+
+    /** The text of a {@link InputKind#TEXT} unit; null for the rest. */
+    public String getText() {
+        return text;
     }
 
     /** The 1-based page this unit renders, for {@link InputKind#PAGES}; -1 
otherwise. */
@@ -117,6 +133,6 @@ public final class InferenceUnit {
 
     /** Readable during a task run only; the file is deleted when the flush 
ends. */
     public byte[] getBytes() throws IOException {
-        return Files.readAllBytes(path);
+        return text != null ? text.getBytes(StandardCharsets.UTF_8) : 
Files.readAllBytes(path);
     }
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
 b/tika-core/src/main/java/org/apache/tika/parser/inference/TextChunker.java
similarity index 59%
copy from 
tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
copy to 
tika-core/src/main/java/org/apache/tika/parser/inference/TextChunker.java
index 169b233967..5fcadb98b8 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/inference/TextChunker.java
@@ -14,21 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.inference;
+package org.apache.tika.parser.inference;
 
-import java.io.IOException;
 import java.util.List;
 
-import org.apache.tika.exception.TikaException;
-import org.apache.tika.parser.ParseContext;
-import org.apache.tika.parser.inference.Engine;
-
-/** An engine that turns images into vectors, many per request. */
-public interface EmbeddingEngine extends Engine {
-
-    /** One request; the vectors come back in the order of the images. */
-    List<float[]> embedImages(List<byte[]> images, List<String> mimeTypes, 
ParseContext context)
-            throws IOException, TikaException;
+/**
+ * Cuts a document's text into the pieces a {@link InputKind#TEXT} binding 
sends to its engine.
+ * Configured per binding under {@code "chunker"}.
+ *
+ * @since Apache Tika 4.1
+ */
+public interface TextChunker {
 
-    int getMaxBatchSize();
+    /** Half-open {@code [start, end)} character ranges of the chunks, in 
document order. */
+    List<int[]> spans(String text);
 }
diff --git 
a/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceTextStageTest.java
 
b/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceTextStageTest.java
new file mode 100644
index 0000000000..87e89a2344
--- /dev/null
+++ 
b/tika-core/src/test/java/org/apache/tika/parser/inference/InferenceTextStageTest.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.tika.parser.inference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.metadata.HttpHeaders;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.mime.MediaType;
+import org.apache.tika.parser.ParseContext;
+
+public class InferenceTextStageTest {
+
+    static final class RecordingTask implements InferenceTask {
+        final List<List<InferenceUnit>> runs = new ArrayList<>();
+
+        @Override
+        public void run(InferenceBinding binding, List<InferenceUnit> units, 
Engine engine,
+                        ParseContext context) {
+            runs.add(List.copyOf(units));
+        }
+    }
+
+    private static InferenceBinding text(String id, Set<MediaType> include, 
long maxBytes) {
+        return new InferenceBinding(id, "engine", InputKind.TEXT, 
List.of("t"), include, null,
+                -1, maxBytes, true);
+    }
+
+    private static Metadata doc(String type, String content) {
+        Metadata m = new Metadata();
+        m.set(HttpHeaders.CONTENT_TYPE, type);
+        if (content != null) {
+            m.set(TikaCoreProperties.TIKA_CONTENT, content);
+        }
+        return m;
+    }
+
+    @Test
+    public void testEveryDocumentWithTextIsOneUnitOfOneRun() throws Exception {
+        RecordingTask task = new RecordingTask();
+        InferenceDispatcher dispatcher = new InferenceDispatcher(List.of(
+                new InferenceDispatcher.Bound(text("t", null, -1), new 
Engine() { },
+                        List.of(task))));
+        Metadata root = doc("application/zip", "root text");
+        Metadata child = doc("text/plain", "child text");
+        Metadata blank = doc("image/png", "  ");
+        Metadata pages = doc("application/pdf", "pdf text");
+        pages.add(TikaCoreProperties.INFERENCE_RELEASED, "PAGES");
+        List<Metadata> list = List.of(root, child, blank, pages);
+
+        dispatcher.text(list, new ParseContext());
+
+        assertEquals(1, task.runs.size(), "the whole list in one run");
+        List<InferenceUnit> units = task.runs.get(0);
+        assertEquals(2, units.size(), "blank content and a document released 
as pages are skipped");
+        assertSame(root, units.get(0).getTarget());
+        assertEquals("root text", units.get(0).getText());
+        assertEquals(InputKind.TEXT, units.get(0).getKind());
+        assertSame(child, units.get(1).getTarget());
+        assertEquals(MediaType.TEXT_PLAIN, units.get(1).getType());
+    }
+
+    @Test
+    public void testFiltersBudgetAndSelection() throws Exception {
+        RecordingTask plain = new RecordingTask();
+        RecordingTask small = new RecordingTask();
+        InferenceDispatcher dispatcher = new InferenceDispatcher(List.of(
+                new InferenceDispatcher.Bound(text("plain", 
Set.of(MediaType.TEXT_PLAIN), -1),
+                        new Engine() { }, List.of(plain)),
+                new InferenceDispatcher.Bound(text("small", null, 5), new 
Engine() { },
+                        List.of(small))));
+        Metadata root = doc("application/zip", "root text");
+        Metadata child = doc("text/plain", "abc");
+        List<Metadata> list = List.of(root, child);
+
+        dispatcher.text(list, new ParseContext());
+        assertEquals(1, plain.runs.get(0).size(), "_mime-include narrows by 
document type");
+        assertSame(child, plain.runs.get(0).get(0).getTarget());
+        assertEquals(1, small.runs.get(0).size(), "maxBytes drops the 9-byte 
root");
+        assertTrue(root.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING)
+                .contains("small over maxBytes: skipped 1 units"));
+
+        ParseContext off = new ParseContext();
+        InferenceSelection selection = new InferenceSelection();
+        selection.setEnabled(false);
+        off.set(InferenceSelection.class, selection);
+        dispatcher.text(List.of(doc("text/plain", "x")), off);
+        assertEquals(1, plain.runs.size(), "switched off for the request: no 
run");
+    }
+
+    @Test
+    public void testTaskFailureIsAWarningOnTheRoot() throws Exception {
+        InferenceTask broken = (binding, units, engine, context) -> {
+            throw new IllegalStateException("boom");
+        };
+        InferenceDispatcher dispatcher = new InferenceDispatcher(List.of(
+                new InferenceDispatcher.Bound(text("t", null, -1), new 
Engine() { },
+                        List.of(broken))));
+        Metadata root = doc("text/plain", "hello");
+        dispatcher.text(List.of(root), new ParseContext());
+        assertTrue(root.get(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING)
+                .contains("inference binding t: boom"));
+    }
+}
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
index 99646bb828..6cdb062614 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-key-fields.json
@@ -529,6 +529,7 @@
   
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"FORMAT","key":"dc:format"},
   
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"HAS_SIGNATURE","key":"tk:has-signature"},
   
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"IDENTIFIER","key":"dc:identifier"},
+  
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"INFERENCE_RELEASED","key":"tk:inference-released"},
   
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"INTERNAL_PATH","key":"tk:internal-path"},
   
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"IS_ENCRYPTED","key":"tk:encrypted"},
   
{"class":"org.apache.tika.metadata.TikaCoreProperties","field":"LANGUAGE","key":"dc:language"},
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
index f75945ed23..accd99efd8 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys.json
@@ -603,6 +603,7 @@
   
{"key":"tk:exception:write-limit-reached","namespace":"tk","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"tk:final-embedded-resource-path","namespace":"tk","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"tk:has-signature","namespace":"tk","valueType":"BOOLEAN","cardinality":"SIMPLE","module":"tika-core"},
+  
{"key":"tk:inference-released","namespace":"tk","valueType":"TEXT","cardinality":"BAG","module":"tika-core"},
   
{"key":"tk:internal-path","namespace":"tk","valueType":"TEXT","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"tk:num-images","namespace":"tk","valueType":"INTEGER","cardinality":"SIMPLE","module":"tika-core"},
   
{"key":"tk:orig-resource-name","namespace":"tk","valueType":"TEXT","cardinality":"BAG","module":"tika-core"},
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/Chunk.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/Chunk.java
index a51e95daf9..21a246ee20 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/Chunk.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/Chunk.java
@@ -34,6 +34,7 @@ public class Chunk {
     private final String text;
     private final Locators locators;
     private float[] vector;
+    private String producer;
 
     public Chunk(String text, Locators locators) {
         this.text = text;
@@ -78,6 +79,15 @@ public class Chunk {
         return -1;
     }
 
+    /** The inference binding that wrote this chunk; null for chunks from 
other sources. */
+    public String getProducer() {
+        return producer;
+    }
+
+    public void setProducer(String producer) {
+        this.producer = producer;
+    }
+
     public float[] getVector() {
         return vector;
     }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkSerializer.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkSerializer.java
index bcef910dd2..67fdc53aac 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkSerializer.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkSerializer.java
@@ -60,6 +60,9 @@ public final class ChunkSerializer {
             if (chunk.getVector() != null) {
                 node.put("vector", VectorSerializer.encode(chunk.getVector()));
             }
+            if (chunk.getProducer() != null) {
+                node.put("producer", chunk.getProducer());
+            }
             serializeLocators(node, chunk.getLocators());
         }
         return MAPPER.writeValueAsString(array);
@@ -107,6 +110,9 @@ public final class ChunkSerializer {
             Locators locators = deserializeLocators(node.get("locators"));
 
             Chunk chunk = new Chunk(text, locators);
+            if (node.hasNonNull("producer")) {
+                chunk.setProducer(node.get("producer").asText());
+            }
 
             JsonNode vectorNode = node.get("vector");
             if (vectorNode != null && !vectorNode.isNull()) {
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
index 9fab518a83..58f96a5c86 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbedTask.java
@@ -18,13 +18,16 @@ package org.apache.tika.inference;
 
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.IdentityHashMap;
 import java.util.List;
+import java.util.Map;
 
 import org.apache.tika.annotation.TikaComponent;
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.inference.locator.Locators;
 import org.apache.tika.inference.locator.PaginatedLocator;
+import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.metadata.TikaPagedText;
 import org.apache.tika.parser.ParseContext;
@@ -35,9 +38,11 @@ import org.apache.tika.parser.inference.InferenceUnit;
 import org.apache.tika.parser.inference.InputKind;
 
 /**
- * The {@code embed} task: one vector chunk per unit, written where the unit's 
chunks belong
- * (its parent for an inline picture or a page render, itself otherwise), with 
the page
- * locator when the unit is a page or has one. Units go to the engine in 
batches of its size.
+ * The {@code embed} task. Images and pages: one vector chunk per unit, 
written where the
+ * unit's chunks belong (its parent for an inline picture or a page render, 
itself otherwise),
+ * with the page locator when the unit is a page or has one. Text: the unit's 
text is chunked
+ * and every chunk gets a vector, on the unit's own document. Requests are 
filled to the
+ * engine's batch size; every chunk names the binding as its producer.
  */
 @TikaComponent(name = "embed", spi = false)
 public class EmbedTask implements InferenceTask {
@@ -48,8 +53,8 @@ public class EmbedTask implements InferenceTask {
             throw new TikaConfigException("task \"embed\" needs an embedding 
engine; \""
                     + binding.getEngine() + "\" is not one");
         }
-        if (binding.getInput() != InputKind.IMAGES && binding.getInput() != 
InputKind.PAGES) {
-            throw new TikaConfigException("task \"embed\" takes IMAGES or 
PAGES; binding \""
+        if (binding.getInput() == InputKind.MEDIA) {
+            throw new TikaConfigException("task \"embed\" takes TEXT, IMAGES 
or PAGES; binding \""
                     + binding.getId() + "\" is on " + binding.getInput());
         }
     }
@@ -58,12 +63,16 @@ public class EmbedTask implements InferenceTask {
     public void run(InferenceBinding binding, List<InferenceUnit> units, 
Engine engine,
                     ParseContext context) throws IOException, TikaException {
         EmbeddingEngine embedder = (EmbeddingEngine) engine;
+        if (binding.getInput() == InputKind.TEXT) {
+            embedText(binding, units, embedder, context);
+            return;
+        }
         int batchSize = Math.max(1, embedder.getMaxBatchSize());
         TikaException first = null;
         for (int start = 0; start < units.size(); start += batchSize) {
             List<InferenceUnit> batch = units.subList(start, Math.min(start + 
batchSize, units.size()));
             try {
-                embed(batch, embedder, context);
+                embed(binding, batch, embedder, context);
             } catch (TikaException e) {
                 if (batch.size() == 1) {
                     first = first == null ? e : first;
@@ -72,7 +81,7 @@ public class EmbedTask implements InferenceTask {
                 // one bad image fails a whole batch; retry singly so the rest 
keep their vectors
                 for (InferenceUnit unit : batch) {
                     try {
-                        embed(List.of(unit), embedder, context);
+                        embed(binding, List.of(unit), embedder, context);
                     } catch (TikaException single) {
                         first = first == null ? single : first;
                     }
@@ -84,8 +93,100 @@ public class EmbedTask implements InferenceTask {
         }
     }
 
-    private static void embed(List<InferenceUnit> batch, EmbeddingEngine 
embedder,
-                              ParseContext context) throws IOException, 
TikaException {
+    /** A chunk of text and the document it belongs to. */
+    private record Owned(Chunk chunk, Metadata owner) {
+    }
+
+    /**
+     * Chunks every unit, then fills each request with the next {@code 
maxBatchSize} chunks in
+     * order, so one request may hold a container's last chunks and an 
attachment's first.
+     * {@code maxChunks} caps the chunks of the whole list. A rejected request 
is retried one
+     * document at a time.
+     */
+    private static void embedText(InferenceBinding binding, 
List<InferenceUnit> units,
+                                  EmbeddingEngine embedder, ParseContext 
context)
+            throws IOException, TikaException {
+        List<Owned> owned = new ArrayList<>();
+        for (InferenceUnit unit : units) {
+            String text = unit.getText();
+            List<int[]> spans = binding.getChunker() == null
+                    ? List.of(new int[]{0, text.length()}) : 
binding.getChunker().spans(text);
+            for (int[] span : spans) {
+                Chunk chunk = new Chunk(text.substring(span[0], span[1]), 
span[0], span[1]);
+                chunk.setProducer(binding.getId());
+                owned.add(new Owned(chunk, unit.getTarget()));
+            }
+        }
+        if (binding.getMaxChunks() >= 0 && owned.size() > 
binding.getMaxChunks()) {
+            owned = owned.subList(0, binding.getMaxChunks());
+        }
+        int batchSize = Math.max(1, embedder.getMaxBatchSize());
+        TikaException first = null;
+        List<Owned> embedded = new ArrayList<>();
+        for (int start = 0; start < owned.size(); start += batchSize) {
+            List<Owned> batch = owned.subList(start, Math.min(start + 
batchSize, owned.size()));
+            try {
+                vectors(batch, embedder, context);
+                embedded.addAll(batch);
+            } catch (TikaException e) {
+                if (batch.size() == 1) {
+                    first = first == null ? e : first;
+                    continue;
+                }
+                for (List<Owned> byOwner : byOwner(batch)) {
+                    try {
+                        vectors(byOwner, embedder, context);
+                        embedded.addAll(byOwner);
+                    } catch (TikaException single) {
+                        first = first == null ? single : first;
+                    }
+                }
+            }
+        }
+        for (List<Owned> byOwner : byOwner(embedded)) {
+            List<Chunk> chunks = new ArrayList<>(byOwner.size());
+            for (Owned o : byOwner) {
+                chunks.add(o.chunk());
+            }
+            ChunkTarget.self(byOwner.get(0).owner())
+                    .write(chunks, TikaCoreProperties.TIKA_CHUNKS.getName());
+        }
+        if (first != null) {
+            throw first;
+        }
+    }
+
+    private static void vectors(List<Owned> batch, EmbeddingEngine embedder,
+                                ParseContext context) throws IOException, 
TikaException {
+        List<String> texts = new ArrayList<>(batch.size());
+        for (Owned o : batch) {
+            texts.add(o.chunk().getText());
+        }
+        List<float[]> vectors = embedder.embedTexts(texts, context);
+        for (int i = 0; i < batch.size(); i++) {
+            batch.get(i).chunk().setVector(vectors.get(i));
+        }
+    }
+
+    /** Runs of chunks sharing a document, in first-seen order. */
+    private static List<List<Owned>> byOwner(List<Owned> owned) {
+        List<List<Owned>> groups = new ArrayList<>();
+        Map<Metadata, List<Owned>> byOwner = new IdentityHashMap<>();
+        for (Owned o : owned) {
+            List<Owned> group = byOwner.get(o.owner());
+            if (group == null) {
+                group = new ArrayList<>();
+                byOwner.put(o.owner(), group);
+                groups.add(group);
+            }
+            group.add(o);
+        }
+        return groups;
+    }
+
+    private static void embed(InferenceBinding binding, List<InferenceUnit> 
batch,
+                              EmbeddingEngine embedder, ParseContext context)
+            throws IOException, TikaException {
         List<byte[]> images = new ArrayList<>(batch.size());
         List<String> mimeTypes = new ArrayList<>(batch.size());
         for (InferenceUnit unit : batch) {
@@ -104,6 +205,7 @@ public class EmbedTask implements InferenceTask {
             }
             Chunk chunk = new Chunk(null, locators);
             chunk.setVector(vectors.get(i));
+            chunk.setProducer(binding.getId());
             ChunkTarget.resolve(unit.getTarget(), unit.getParent())
                     .write(List.of(chunk), 
TikaCoreProperties.TIKA_CHUNKS.getName());
         }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
index 169b233967..86a38b1580 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
@@ -30,5 +30,9 @@ public interface EmbeddingEngine extends Engine {
     List<float[]> embedImages(List<byte[]> images, List<String> mimeTypes, 
ParseContext context)
             throws IOException, TikaException;
 
+    /** One request; the vectors come back in the order of the texts. */
+    List<float[]> embedTexts(List<String> texts, ParseContext context)
+            throws IOException, TikaException;
+
     int getMaxBatchSize();
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/MarkdownChunker.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/MarkdownChunker.java
index 2bbbfe184a..5e8262cf6c 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/MarkdownChunker.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/MarkdownChunker.java
@@ -21,6 +21,11 @@ import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.config.Initializable;
+import org.apache.tika.exception.TikaConfigException;
+import org.apache.tika.parser.inference.TextChunker;
+
 /**
  * Splits markdown text into chunks that respect structural boundaries.
  * <p>
@@ -32,7 +37,8 @@ import java.util.regex.Pattern;
  * Consecutive chunks can overlap by a configurable number of characters
  * to avoid losing context at boundaries.
  */
-public class MarkdownChunker {
+@TikaComponent(name = "markdown-chunker", spi = false)
+public class MarkdownChunker implements TextChunker, Initializable {
 
     /**
      * Matches a markdown heading at the start of a line (e.g. {@code ## Foo}).
@@ -40,10 +46,29 @@ public class MarkdownChunker {
      */
     private static final Pattern HEADING_PATTERN = 
Pattern.compile("(?m)^#{1,6} ");
 
-    private final int maxChunkChars;
-    private final int overlapChars;
+    private int maxChunkChars = 1500;
+    private int overlapChars = 200;
+
+    /** The binding form: {@code {"markdown-chunker": {"maxChunkChars": .., 
"overlapChars": ..}}}. */
+    public MarkdownChunker() {
+    }
 
     public MarkdownChunker(int maxChunkChars, int overlapChars) {
+        this.maxChunkChars = maxChunkChars;
+        this.overlapChars = overlapChars;
+        validate();
+    }
+
+    @Override
+    public void initialize() throws TikaConfigException {
+        try {
+            validate();
+        } catch (IllegalArgumentException e) {
+            throw new TikaConfigException("markdown-chunker: " + 
e.getMessage(), e);
+        }
+    }
+
+    private void validate() {
         if (maxChunkChars <= 0) {
             throw new IllegalArgumentException("maxChunkChars must be > 0");
         }
@@ -54,10 +79,33 @@ public class MarkdownChunker {
             throw new IllegalArgumentException(
                     "overlapChars must be < maxChunkChars");
         }
+    }
+
+    public int getMaxChunkChars() {
+        return maxChunkChars;
+    }
+
+    public void setMaxChunkChars(int maxChunkChars) {
         this.maxChunkChars = maxChunkChars;
+    }
+
+    public int getOverlapChars() {
+        return overlapChars;
+    }
+
+    public void setOverlapChars(int overlapChars) {
         this.overlapChars = overlapChars;
     }
 
+    @Override
+    public List<int[]> spans(String text) {
+        List<int[]> spans = new ArrayList<>();
+        for (Chunk chunk : chunk(text)) {
+            spans.add(new int[]{chunk.getStartOffset(), chunk.getEndOffset()});
+        }
+        return spans;
+    }
+
     /**
      * Chunk the given markdown text.
      *
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
index 90e85f5d2d..517844eac4 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIEmbeddingEngine.java
@@ -73,17 +73,38 @@ public class OpenAIEmbeddingEngine implements 
EmbeddingEngine, Initializable, Cl
     @Override
     public List<float[]> embedImages(List<byte[]> images, List<String> 
mimeTypes,
                                      ParseContext context) throws IOException, 
TikaException {
-        if (httpClient == null) {
-            httpClient = TikaHttpClient.build(30);
+        ObjectNode root = request();
+        ArrayNode input = root.putArray("input");
+        for (int i = 0; i < images.size(); i++) {
+            input.addObject().put("image", "data:" + mimeTypes.get(i) + 
";base64,"
+                    + Base64.getEncoder().encodeToString(images.get(i)));
+        }
+        return post(root, images.size(), context);
+    }
+
+    @Override
+    public List<float[]> embedTexts(List<String> texts, ParseContext context)
+            throws IOException, TikaException {
+        ObjectNode root = request();
+        ArrayNode input = root.putArray("input");
+        for (String text : texts) {
+            input.add(text);
         }
+        return post(root, texts.size(), context);
+    }
+
+    private ObjectNode request() {
         ObjectNode root = MAPPER.createObjectNode();
         if (!StringUtils.isBlank(model)) {
             root.put("model", model);
         }
-        ArrayNode input = root.putArray("input");
-        for (int i = 0; i < images.size(); i++) {
-            input.addObject().put("image", "data:" + mimeTypes.get(i) + 
";base64,"
-                    + Base64.getEncoder().encodeToString(images.get(i)));
+        return root;
+    }
+
+    private List<float[]> post(ObjectNode root, int expected, ParseContext 
context)
+            throws IOException, TikaException {
+        if (httpClient == null) {
+            httpClient = TikaHttpClient.build(30);
         }
         Map<String, String> headers = new HashMap<>();
         if (!StringUtils.isBlank(apiKey)) {
@@ -92,7 +113,7 @@ public class OpenAIEmbeddingEngine implements 
EmbeddingEngine, Initializable, Cl
         String url = baseUrl.replaceAll("/+$", "") + embeddingsPath;
         String body = httpClient.postJson(url, root.toString(), headers, 
timeoutMillis, context);
         ParseTimeout.checkpoint(context);
-        return parseResponse(body, images.size());
+        return parseResponse(body, expected);
     }
 
     static List<float[]> parseResponse(String body, int expected) throws 
TikaException {
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkSerializerTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkSerializerTest.java
index a0357b00d3..07381f6949 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkSerializerTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkSerializerTest.java
@@ -152,6 +152,16 @@ public class ChunkSerializerTest {
         
assertNull(restored.get(0).getLocators().getPaginated().get(0).getBbox());
     }
 
+    @Test
+    void testProducerRoundTrips() throws Exception {
+        Chunk chunk = new Chunk("t", 0, 1);
+        chunk.setProducer("text-vectors");
+        List<Chunk> back = 
ChunkSerializer.fromJson(ChunkSerializer.toJson(List.of(chunk)));
+        assertEquals("text-vectors", back.get(0).getProducer());
+        assertNull(ChunkSerializer.fromJson(ChunkSerializer.toJson(
+                List.of(new Chunk("t", 0, 1)))).get(0).getProducer());
+    }
+
     @Test
     void testWithoutVector() throws Exception {
         Chunk c = new Chunk("No vector", 0, 9);
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java
index 0980bd4edd..7759a4d385 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/EmbedTaskTest.java
@@ -161,6 +161,62 @@ public class EmbedTaskTest {
         assertNull(chunks.get(0).getLocators().getEmbedded(), "the pdf keeps 
its own pages");
     }
 
+    /** Text units are chunked, batched across documents, and land on their 
own document. */
+    @Test
+    public void testTextChunksBatchAcrossDocuments() throws Exception {
+        engine.setMaxBatchSize(3);
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0, 1, 
2)));
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0)));
+        Metadata parent = new Metadata();
+        Metadata attachment = new Metadata();
+        attachment.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, 
"ATTACHMENT");
+        attachment.set(TikaCoreProperties.EMBEDDED_ID_PATH, "/1");
+        InferenceBinding text = new InferenceBinding("text-vectors", "clip", 
InputKind.TEXT,
+                List.of("embed"), null, null, -1, -1, true, new 
MarkdownChunker(10, 0));
+        EmbedTask task = new EmbedTask();
+        task.validate(text, engine);
+        // "one two" and "three" from the parent, "four five" and "six" from 
the attachment
+        task.run(text, List.of(
+                new InferenceUnit(MediaType.TEXT_PLAIN, parent, null, "one 
two\n\nthree"),
+                new InferenceUnit(MediaType.TEXT_PLAIN, attachment, parent, 
"four five\n\nsix")),
+                engine, new ParseContext());
+
+        assertEquals(2, server.getRequestCount(), "four chunks in batches of 
three");
+        JsonNode first = 
MAPPER.readTree(server.takeRequest().body()).get("input");
+        assertEquals(List.of("one two", "three", "four five"),
+                List.of(first.get(0).asText(), first.get(1).asText(), 
first.get(2).asText()),
+                "a request holds the parent's last chunk and the attachment's 
first");
+        List<Chunk> parentChunks = ChunkSerializer.fromJson(
+                parent.get(TikaCoreProperties.TIKA_CHUNKS));
+        List<Chunk> attachmentChunks = ChunkSerializer.fromJson(
+                attachment.get(TikaCoreProperties.TIKA_CHUNKS));
+        assertEquals(2, parentChunks.size());
+        assertEquals(2, attachmentChunks.size(), "an attachment keeps its own 
text vectors");
+        assertEquals("three", parentChunks.get(1).getText());
+        assertEquals(9, 
parentChunks.get(1).getLocators().getText().get(0).getStartOffset());
+        assertEquals("text-vectors", parentChunks.get(0).getProducer());
+        assertEquals(2.0f, attachmentChunks.get(0).getVector()[0], "placed by 
index");
+        assertEquals(0.0f, attachmentChunks.get(1).getVector()[0], "second 
request, index 0");
+    }
+
+    /** Without a chunker the whole text is one chunk; maxChunks caps the 
list. */
+    @Test
+    public void testTextWithoutChunkerAndBudget() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0)));
+        Metadata a = new Metadata();
+        Metadata b = new Metadata();
+        InferenceBinding text = new InferenceBinding("t", "clip", 
InputKind.TEXT,
+                List.of("embed"), null, null, 1, -1, true);
+        new EmbedTask().run(text, List.of(
+                new InferenceUnit(MediaType.TEXT_PLAIN, a, null, "whole 
document"),
+                new InferenceUnit(MediaType.TEXT_PLAIN, b, null, "dropped")),
+                engine, new ParseContext());
+        assertEquals(1, server.getRequestCount());
+        assertEquals("whole document", ChunkSerializer.fromJson(
+                a.get(TikaCoreProperties.TIKA_CHUNKS)).get(0).getText());
+        assertNull(b.get(TikaCoreProperties.TIKA_CHUNKS), "over maxChunks");
+    }
+
     @Test
     public void testValidateRejectsANonEmbeddingEngine() {
         assertThrows(TikaConfigException.class,
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/MarkdownChunkerTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/MarkdownChunkerTest.java
index db3a874834..4a1d7b4e9f 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/MarkdownChunkerTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/MarkdownChunkerTest.java
@@ -24,6 +24,8 @@ import java.util.List;
 
 import org.junit.jupiter.api.Test;
 
+import org.apache.tika.exception.TikaConfigException;
+
 public class MarkdownChunkerTest {
 
     @Test
@@ -37,6 +39,25 @@ public class MarkdownChunkerTest {
         assertTrue(chunks.get(1).getText().startsWith("# Heading 2"));
     }
 
+    @Test
+    void testSpansMatchChunkOffsets() throws Exception {
+        MarkdownChunker chunker = new MarkdownChunker();
+        chunker.setMaxChunkChars(10);
+        chunker.setOverlapChars(0);
+        chunker.initialize();
+        String text = "one two\n\nthree four\n\nfive";
+        List<Chunk> chunks = chunker.chunk(text);
+        List<int[]> spans = chunker.spans(text);
+        assertEquals(chunks.size(), spans.size());
+        for (int i = 0; i < chunks.size(); i++) {
+            assertEquals(chunks.get(i).getStartOffset(), spans.get(i)[0]);
+            assertEquals(chunks.get(i).getEndOffset(), spans.get(i)[1]);
+            assertEquals(chunks.get(i).getText(), 
text.substring(spans.get(i)[0], spans.get(i)[1]));
+        }
+        chunker.setOverlapChars(10);
+        assertThrows(TikaConfigException.class, chunker::initialize);
+    }
+
     @Test
     void testOffsets() {
         String md = "# A\n\nText A.\n\n# B\n\nText B.";
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
index 04ed8d387c..fdb658c5f0 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIEmbeddingEngineTest.java
@@ -79,6 +79,19 @@ public class OpenAIEmbeddingEngineTest {
         assertEquals("data:image/jpeg;base64,Yg==", 
body.get("input").get(1).get("image").asText());
     }
 
+    @Test
+    public void testTextsGoAsPlainStrings() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(200,
+                
"{\"data\":[{\"index\":1,\"embedding\":[1.0]},{\"index\":0,\"embedding\":[0.0]}]}"));
+        List<float[]> vectors = engine.embedTexts(List.of("alpha", "beta"), 
new ParseContext());
+        JsonNode request = new 
ObjectMapper().readTree(server.takeRequest().body());
+        assertEquals("clip", request.get("model").asText());
+        assertEquals("alpha", request.get("input").get(0).asText());
+        assertEquals("beta", request.get("input").get(1).asText());
+        assertEquals(0.0f, vectors.get(0)[0]);
+        assertEquals(1.0f, vectors.get(1)[0]);
+    }
+
     @Test
     public void testMalformedResponsesAreRefused() throws Exception {
         String[] bad = {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
index 725f63f508..155df57869 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/main/java/org/apache/tika/parser/pdf/AbstractPDF2XHTML.java
@@ -281,6 +281,12 @@ class AbstractPDF2XHTML extends PDFTextStripper {
             }
         }
         this.pagesForInference = wantsPages;
+        if (wantsPages) {
+            // the TEXT stage skips a document released as pages unless TEXT 
is listed too
+            for (InferenceConfig.Input input : 
config.getInference().getInput()) {
+                metadata.add(TikaCoreProperties.INFERENCE_RELEASED, 
input.name());
+            }
+        }
     }
 
     private static void addNonNullAttribute(String name, String value, 
AttributesImpl attributes) {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
index b48638488e..ca61564f9a 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-pdf-module/src/test/java/org/apache/tika/parser/pdf/PDFParserTest.java
@@ -1978,6 +1978,9 @@ public class PDFParserTest extends TikaTest {
             assertTrue(hook.sizes.get(0) > 0, label + ": the render has 
bytes");
             assertEquals(2, tracker.getNextId() - 1, label + ": one render per 
page");
             assertEquals(strategy == OcrConfig.Strategy.NO_OCR ? 0 : 2, 
recognizer.calls, label);
+            assertArrayEquals(new String[]{"PAGES"},
+                    metadata.getValues(TikaCoreProperties.INFERENCE_RELEASED),
+                    label + ": the PDF says it released its pages, not its 
text");
         }
     }
 
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
index 8b16047e33..b09c3f8f9f 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/ConnectionHandler.java
@@ -315,7 +315,8 @@ public class ConnectionHandler implements Runnable, 
Closeable {
         Long thresholdBytes = 
pipesConfig.getEmitStrategy().getThresholdBytes();
         long threshold = (thresholdBytes != null) ? thresholdBytes : 
EmitStrategyConfig.DEFAULT_DIRECT_EMIT_THRESHOLD_BYTES;
         EmitHandler emitHandler = new 
EmitHandler(resources.getDefaultMetadataFilter(),
-                resources.getEmitStrategy(), resources.getEmitterManager(), 
threshold);
+                resources.getInferenceDispatcher(), 
resources.getEmitStrategy(),
+                resources.getEmitterManager(), threshold);
         return new PipesWorker(fetchEmitTuple, mergedContext, 
resources.getAutoDetectParser(),
                 resources.getEmitterManager(), fetchHandler, parseHandler, 
emitHandler,
                 resources.getDefaultMetadataWriteLimiterFactory(), 
pipesConfig.getParseMode());
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/EmitHandler.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/EmitHandler.java
index 7a73feb4a7..114b5a980f 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/EmitHandler.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/EmitHandler.java
@@ -37,6 +37,7 @@ import org.apache.tika.metadata.filter.MetadataFilter;
 import org.apache.tika.metadata.filter.NoOpFilter;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.ParseRecord;
+import org.apache.tika.parser.inference.InferenceDispatcher;
 import org.apache.tika.pipes.api.FetchEmitTuple;
 import org.apache.tika.pipes.api.ParseMode;
 import org.apache.tika.pipes.api.PipesResult;
@@ -57,13 +58,16 @@ class EmitHandler {
     private static final Logger LOG = 
LoggerFactory.getLogger(EmitHandler.class);
 
     private final MetadataFilter defaultMetadataFilter;
+    private final InferenceDispatcher inferenceDispatcher;
     private final EmitStrategy emitStrategy;
     private final EmitterManager emitterManager;
     private final long directEmitThresholdBytes;
 
 
-    public EmitHandler(MetadataFilter defaultMetadataFilter, EmitStrategy 
emitStrategy, EmitterManager emitterManager, long directEmitThresholdBytes) {
+    public EmitHandler(MetadataFilter defaultMetadataFilter, 
InferenceDispatcher inferenceDispatcher,
+                       EmitStrategy emitStrategy, EmitterManager 
emitterManager, long directEmitThresholdBytes) {
         this.defaultMetadataFilter = defaultMetadataFilter;
+        this.inferenceDispatcher = inferenceDispatcher;
         this.emitStrategy = emitStrategy;
         this.emitterManager = emitterManager;
         this.directEmitThresholdBytes = directEmitThresholdBytes;
@@ -111,11 +115,15 @@ class EmitHandler {
     private PipesResult emitParseDataInternal(FetchEmitTuple t, 
MetadataListAndEmbeddedBytes parseData, ParseContext parseContext) {
         long start = System.currentTimeMillis();
         String stack = getContainerStacktrace(t, parseData.getMetadataList());
+        FetchEmitTuple.ON_PARSE_EXCEPTION onParseException = 
t.getOnParseException();
+        boolean emit = StringUtils.isBlank(stack) ||
+                onParseException == FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT;
+        if (emit) {
+            runTextInference(parseData, parseContext);
+        }
         //we need to apply the metadata filter after we pull out the stacktrace
         filterMetadata(parseData, parseContext);
-        FetchEmitTuple.ON_PARSE_EXCEPTION onParseException = 
t.getOnParseException();
-        if (StringUtils.isBlank(stack) ||
-                onParseException == FetchEmitTuple.ON_PARSE_EXCEPTION.EMIT) {
+        if (emit) {
             injectUserMetadata(t.getMetadata(), parseData.getMetadataList());
             EmitKey emitKey = t.getEmitKey();
             if (StringUtils.isBlank(emitKey.getEmitKey())) {
@@ -319,6 +327,19 @@ class EmitHandler {
         }
     }
 
+    /** The TEXT inference stage: before the filters so they shape its output; 
not for CONTENT_ONLY, whose filter would drop it. */
+    private void runTextInference(MetadataListAndEmbeddedBytes parseData, 
ParseContext parseContext) {
+        if (inferenceDispatcher == null
+                || parseContext.get(ParseMode.class) == 
ParseMode.CONTENT_ONLY) {
+            return;
+        }
+        try {
+            inferenceDispatcher.text(parseData.getMetadataList(), 
parseContext);
+        } catch (TikaException e) {
+            LOG.warn("text inference failed", e);
+        }
+    }
+
     private void filterMetadata(MetadataListAndEmbeddedBytes parseData, 
ParseContext parseContext) {
         MetadataFilter filter = parseContext.get(MetadataFilter.class);
         if (filter == null) {
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
index df8fbb5816..a900caad76 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/PipesServer.java
@@ -62,6 +62,7 @@ import 
org.apache.tika.metadata.writelimiter.MetadataWriteLimiterFactory;
 import org.apache.tika.parser.AutoDetectParser;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.RecursiveParserWrapper;
+import org.apache.tika.parser.inference.InferenceDispatcher;
 import org.apache.tika.pipes.api.FetchEmitTuple;
 import org.apache.tika.pipes.api.PipesResult;
 import org.apache.tika.pipes.core.EmitStrategy;
@@ -185,6 +186,7 @@ public class PipesServer implements AutoCloseable {
     private final PipesConfig pipesConfig;
     private final Socket socket;
     private final MetadataFilter defaultMetadataFilter;
+    private final InferenceDispatcher inferenceDispatcher;
     private final ContentHandlerFactory defaultContentHandlerFactory;
     private final MetadataWriteLimiterFactory 
defaultMetadataWriteLimiterFactory;
     private AutoDetectParser autoDetectParser;
@@ -257,6 +259,7 @@ public class PipesServer implements AutoCloseable {
         this.socket = socket;
         socket.setSoTimeout((int) pipesConfig.getSocketTimeoutMillis());
         this.defaultMetadataFilter = tikaLoader.loadMetadataFilters();
+        this.inferenceDispatcher = tikaLoader.get(InferenceDispatcher.class);
         this.defaultContentHandlerFactory = 
tikaLoader.loadContentHandlerFactory();
         this.defaultMetadataWriteLimiterFactory = 
configContext.get(MetadataWriteLimiterFactory.class);
         this.input = in;
@@ -538,7 +541,8 @@ public class PipesServer implements AutoCloseable {
                 rMetaParser, defaultContentHandlerFactory, 
pipesConfig.getParseMode());
         Long thresholdBytes = 
pipesConfig.getEmitStrategy().getThresholdBytes();
         long threshold = (thresholdBytes != null) ? thresholdBytes : 
EmitStrategyConfig.DEFAULT_DIRECT_EMIT_THRESHOLD_BYTES;
-        EmitHandler emitHandler = new EmitHandler(defaultMetadataFilter, 
emitStrategy, emitterManager, threshold);
+        EmitHandler emitHandler = new EmitHandler(defaultMetadataFilter, 
inferenceDispatcher,
+                emitStrategy, emitterManager, threshold);
         return new PipesWorker(fetchEmitTuple, mergedContext, 
autoDetectParser, emitterManager,
                 fetchHandler, parseHandler, emitHandler, 
defaultMetadataWriteLimiterFactory,
                 pipesConfig.getParseMode());
diff --git 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
index 5c97818673..80aa8e4c18 100644
--- 
a/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
+++ 
b/tika-pipes/tika-pipes-core/src/main/java/org/apache/tika/pipes/core/server/SharedServerResources.java
@@ -32,6 +32,7 @@ import 
org.apache.tika.metadata.writelimiter.MetadataWriteLimiterFactory;
 import org.apache.tika.parser.AutoDetectParser;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.RecursiveParserWrapper;
+import org.apache.tika.parser.inference.InferenceDispatcher;
 import org.apache.tika.pipes.core.EmitStrategy;
 import org.apache.tika.pipes.core.PipesConfig;
 import org.apache.tika.pipes.core.config.ConfigStore;
@@ -62,6 +63,7 @@ public class SharedServerResources {
     private final FetcherManager fetcherManager;
     private final EmitterManager emitterManager;
     private final MetadataFilter defaultMetadataFilter;
+    private final InferenceDispatcher inferenceDispatcher;
     private final ContentHandlerFactory defaultContentHandlerFactory;
     private final MetadataWriteLimiterFactory 
defaultMetadataWriteLimiterFactory;
     private final EmitStrategy emitStrategy;
@@ -73,6 +75,7 @@ public class SharedServerResources {
                                   AutoDetectParser autoDetectParser, Detector 
detector,
                                   RecursiveParserWrapper rMetaParser, 
FetcherManager fetcherManager,
                                   EmitterManager emitterManager, 
MetadataFilter defaultMetadataFilter,
+                                  InferenceDispatcher inferenceDispatcher,
                                   ContentHandlerFactory 
defaultContentHandlerFactory,
                                   MetadataWriteLimiterFactory 
defaultMetadataWriteLimiterFactory,
                                   EmitStrategy emitStrategy, ConfigStore 
configStore,
@@ -86,6 +89,7 @@ public class SharedServerResources {
         this.fetcherManager = fetcherManager;
         this.emitterManager = emitterManager;
         this.defaultMetadataFilter = defaultMetadataFilter;
+        this.inferenceDispatcher = inferenceDispatcher;
         this.defaultContentHandlerFactory = defaultContentHandlerFactory;
         this.defaultMetadataWriteLimiterFactory = 
defaultMetadataWriteLimiterFactory;
         this.emitStrategy = emitStrategy;
@@ -124,6 +128,7 @@ public class SharedServerResources {
 
         // Load filters and factories
         MetadataFilter metadataFilter = tikaLoader.loadMetadataFilters();
+        InferenceDispatcher inferenceDispatcher = 
tikaLoader.get(InferenceDispatcher.class);
         ContentHandlerFactory contentHandlerFactory = 
tikaLoader.loadContentHandlerFactory();
         ParseContext configContext = tikaLoader.loadParseContext();
         MetadataWriteLimiterFactory metadataWriteLimiterFactory =
@@ -136,7 +141,8 @@ public class SharedServerResources {
                 PresetRegistry.load(tikaJsonConfig, 
tikaLoader.getClassLoader());
 
         return new SharedServerResources(tikaLoader, pipesConfig, 
autoDetectParser, detector,
-                rMetaParser, fetcherManager, emitterManager, metadataFilter, 
contentHandlerFactory,
+                rMetaParser, fetcherManager, emitterManager, metadataFilter, 
inferenceDispatcher,
+                contentHandlerFactory,
                 metadataWriteLimiterFactory, emitStrategy, configStore,
                 ExceptionReporting.get(configContext), presetRegistry);
     }
@@ -211,6 +217,11 @@ public class SharedServerResources {
         return defaultMetadataFilter;
     }
 
+    /** Null when no {@code "inference"} bindings are configured. */
+    public InferenceDispatcher getInferenceDispatcher() {
+        return inferenceDispatcher;
+    }
+
     public ContentHandlerFactory getDefaultContentHandlerFactory() {
         return defaultContentHandlerFactory;
     }
diff --git 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/EmitHandlerUserMetadataTest.java
 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/EmitHandlerUserMetadataTest.java
index 6a953d62f2..2021d09b64 100644
--- 
a/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/EmitHandlerUserMetadataTest.java
+++ 
b/tika-pipes/tika-pipes-core/src/test/java/org/apache/tika/pipes/core/server/EmitHandlerUserMetadataTest.java
@@ -36,7 +36,7 @@ public class EmitHandlerUserMetadataTest {
 
     private static EmitHandler emitHandler() {
         // injectUserMetadata touches no instance state; the collaborators are 
irrelevant
-        return new EmitHandler(null, null, null, 0);
+        return new EmitHandler(null, null, null, null, 0);
     }
 
     @Test
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
index b805aba5fd..575905e088 100644
--- 
a/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/java/org/apache/tika/pipes/core/PipesClientTest.java
@@ -107,6 +107,26 @@ public class PipesClientTest {
             Metadata metadata = 
pipesResult.emitData().getMetadataList().get(0);
             assertEquals("mock-images", metadata.get(MockTask.MARKER_KEY));
             assertEquals("1", metadata.get(MockTask.UNITS_KEY));
+
+            // the TEXT stage runs at the emit edge of the fork
+            Files.writeString(inputDir.resolve("note.txt"), "a note with 
text");
+            pipesResult = pipesClient.process(
+                    new FetchEmitTuple("note.txt", new FetchKey(fetcherName, 
"note.txt"),
+                            new EmitKey(), new Metadata(), new ParseContext(),
+                            FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
+            metadata = pipesResult.emitData().getMetadataList().get(0);
+            assertEquals("mock-text", metadata.get(MockTask.MARKER_KEY));
+            assertEquals("1", metadata.get(MockTask.UNITS_KEY));
+
+            // a per-request filter list neither drops the TEXT stage nor runs 
ahead of it
+            ParseContext filtered = new ParseContext();
+            filtered.setJsonConfig("metadata-filters", 
"[\"mock-upper-case-filter\"]");
+            pipesResult = pipesClient.process(
+                    new FetchEmitTuple("note.txt", new FetchKey(fetcherName, 
"note.txt"),
+                            new EmitKey(), new Metadata(), filtered,
+                            FetchEmitTuple.ON_PARSE_EXCEPTION.SKIP));
+            metadata = pipesResult.emitData().getMetadataList().get(0);
+            assertEquals("MOCK-TEXT", metadata.get(MockTask.MARKER_KEY));
         }
     }
 
diff --git 
a/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-inference-bindings.json
 
b/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-inference-bindings.json
index 90b215fb4f..95fabb581c 100644
--- 
a/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-inference-bindings.json
+++ 
b/tika-pipes/tika-pipes-integration-tests/src/test/resources/configs/tika-config-inference-bindings.json
@@ -1,6 +1,9 @@
 {
   "engines": { "mock": { "mock-engine": {} } },
-  "inference": [ { "id": "mock-images", "engine": "mock", "input": "IMAGES", 
"tasks": ["mock-task"] } ],
+  "inference": [
+    { "id": "mock-images", "engine": "mock", "input": "IMAGES", "tasks": 
["mock-task"] },
+    { "id": "mock-text", "engine": "mock", "input": "TEXT", "tasks": 
["mock-task"] }
+  ],
   "content-handler-factory": {
     "basic-content-handler-factory": {
       "type": "TEXT",
diff --git 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
index 3a55a76c02..a345313430 100644
--- 
a/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
+++ 
b/tika-serialization/src/main/java/org/apache/tika/config/loader/InferenceLoader.java
@@ -21,6 +21,7 @@ import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
 import java.util.Set;
 import java.util.regex.Pattern;
 
@@ -35,6 +36,7 @@ import org.apache.tika.parser.inference.InferenceBinding;
 import org.apache.tika.parser.inference.InferenceDispatcher;
 import org.apache.tika.parser.inference.InferenceTask;
 import org.apache.tika.parser.inference.InputKind;
+import org.apache.tika.parser.inference.TextChunker;
 
 /**
  * Loads {@code "inference"}: bindings of a named engine to an input kind and 
tasks. Every
@@ -45,7 +47,7 @@ class InferenceLoader implements 
ComponentLoader<InferenceDispatcher> {
 
     static final String KEY = "inference";
     private static final Set<String> KNOWN = Set.of("id", "engine", "input", 
"tasks",
-            "maxChunks", "maxBytes", "enabled", "_mime-include", 
"_mime-exclude");
+            "maxChunks", "maxBytes", "enabled", "_mime-include", 
"_mime-exclude", "chunker");
     private static final Pattern LEGAL_ID = Pattern.compile("[A-Za-z0-9._-]+");
     /** Largest unit a binding takes unless it says otherwise. */
     static final long DEFAULT_MAX_BYTES = 20L * 1024 * 1024;
@@ -128,9 +130,10 @@ class InferenceLoader implements 
ComponentLoader<InferenceDispatcher> {
                     exclude.add(MediaType.parse(t));
                 }
             }
+            TextChunker chunker = chunker(entry, id, input, context);
             InferenceBinding binding = new InferenceBinding(id, engineName, 
input, taskNames,
                     include, exclude, maxChunks, maxBytes,
-                    entry.path("enabled").asBoolean(true));
+                    entry.path("enabled").asBoolean(true), chunker);
             List<InferenceTask> tasks = new ArrayList<>();
             for (String taskName : taskNames) {
                 InferenceTask task;
@@ -151,6 +154,32 @@ class InferenceLoader implements 
ComponentLoader<InferenceDispatcher> {
         return new InferenceDispatcher(bound);
     }
 
+    /** {@code "chunker": {"<name>": {...}}} on a TEXT binding; absent means 
one chunk per document. */
+    private static TextChunker chunker(JsonNode entry, String id, InputKind 
input,
+                                       LoaderContext context) throws 
TikaConfigException {
+        if (!entry.has("chunker")) {
+            return null;
+        }
+        if (input != InputKind.TEXT) {
+            throw new TikaConfigException("binding \"" + id + "\": \"chunker\" 
applies to TEXT "
+                    + "bindings; " + input + " units are not chunked");
+        }
+        JsonNode node = entry.get("chunker");
+        if (!node.isObject() || node.size() != 1) {
+            throw new TikaConfigException("binding \"" + id + "\": \"chunker\" 
must be one "
+                    + "{\"<chunker>\": {...}} object, e.g. 
{\"markdown-chunker\": "
+                    + "{\"maxChunkChars\": 1500}}");
+        }
+        Map.Entry<String, JsonNode> type = node.fields().next();
+        try {
+            return ComponentInstantiator.instantiateComponent(type.getKey(), 
type.getValue(),
+                    context.getObjectMapper(), context.getClassLoader(), 
TextChunker.class);
+        } catch (TikaConfigException e) {
+            throw new TikaConfigException("binding \"" + id + "\" chunker \"" 
+ type.getKey()
+                    + "\": " + e.getMessage(), e);
+        }
+    }
+
     private static String required(JsonNode entry, String field) throws 
TikaConfigException {
         if (!entry.hasNonNull(field) || !entry.get(field).isTextual()) {
             throw new TikaConfigException("\"" + KEY + "\" entry needs \"" + 
field + "\"");
diff --git 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
index 04c0c0c80b..a5a4b5fc5b 100644
--- 
a/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/InferenceLoaderTest.java
@@ -28,11 +28,16 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.io.TempDir;
 
 import org.apache.tika.exception.TikaConfigException;
 import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.filter.CompositeMetadataFilter;
+import org.apache.tika.metadata.filter.MetadataFilter;
+import org.apache.tika.metadata.filter.NoOpFilter;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.AutoDetectParser;
 import org.apache.tika.parser.ParseContext;
@@ -115,6 +120,54 @@ public class InferenceLoaderTest {
                 new ParseContext()));
     }
 
+    /** A TEXT binding carries its chunker; the metadata-filter chain is not 
its vehicle. */
+    @Test
+    public void testTextBindingLoadsWithChunker() throws Exception {
+        TikaLoader loader = load("{" + ENGINES + ", \"inference\": ["
+                + " { \"id\": \"text-vectors\", \"engine\": \"one\", 
\"input\": \"TEXT\","
+                + "   \"tasks\": [\"test-task\"], \"chunker\": { 
\"test-chunker\": { \"size\": 3 } } } ],"
+                + " \"metadata-filters\": [ { \"no-op-filter\": {} } ] }");
+        InferenceBinding text = 
loader.get(InferenceDispatcher.class).getBound().get(0).binding();
+        assertEquals(InputKind.TEXT, text.getInput());
+        assertTrue(text.getChunker() instanceof TestChunker);
+        assertEquals(3, ((TestChunker) text.getChunker()).getSize());
+        assertEquals(2, text.getChunker().spans("abcde").size());
+
+        MetadataFilter filters = loader.get(MetadataFilter.class);
+        assertTrue(filters instanceof CompositeMetadataFilter);
+        assertEquals(1, ((CompositeMetadataFilter) 
filters).getFilters().size(),
+                "only the configured filter: the TEXT stage is not a filter");
+
+        Path dump = Files.createTempFile(tmp, "dump", ".json");
+        loader.save(dump.toFile());
+        JsonNode dumped = new ObjectMapper().readTree(Files.readString(dump));
+        assertEquals(1, dumped.get("metadata-filters").size());
+        assertEquals("TEXT", 
dumped.get("inference").get(0).get("input").asText());
+
+        TikaLoader noFilters = load("{" + ENGINES + ", \"inference\": ["
+                + " { \"engine\": \"one\", \"input\": \"TEXT\", \"tasks\": 
[\"test-task\"] } ] }");
+        assertSame(NoOpFilter.NOOP_FILTER, noFilters.get(MetadataFilter.class),
+                "a TEXT binding adds nothing to the chain");
+    }
+
+    @Test
+    public void testChunkerMisconfigurationsFailLoad() {
+        String[] bad = {
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", 
\"input\": \"IMAGES\","
+                    + " \"tasks\": [\"test-task\"], \"chunker\": { 
\"test-chunker\": {} } } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", 
\"input\": \"TEXT\","
+                    + " \"tasks\": [\"test-task\"], \"chunker\": { 
\"no-such-chunker\": {} } } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", 
\"input\": \"TEXT\","
+                    + " \"tasks\": [\"test-task\"], \"chunker\": 
\"test-chunker\" } ] }",
+            "{" + ENGINES + ", \"inference\": [ { \"engine\": \"one\", 
\"input\": \"TEXT\","
+                    + " \"tasks\": [\"test-task\"], \"chunker\": { 
\"test-engine\": {} } } ] }",
+        };
+        for (String json : bad) {
+            assertThrows(TikaConfigException.class,
+                    () -> load(json).get(InferenceDispatcher.class), json);
+        }
+    }
+
     @Test
     public void testPerRequestSelectionResolves() throws Exception {
         TikaLoader loader = load("{" + ENGINES + ", \"inference\": ["
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestChunker.java
similarity index 51%
copy from 
tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
copy to 
tika-serialization/src/test/java/org/apache/tika/config/loader/TestChunker.java
index 169b233967..fbb6abbea9 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/EmbeddingEngine.java
+++ 
b/tika-serialization/src/test/java/org/apache/tika/config/loader/TestChunker.java
@@ -14,21 +14,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.apache.tika.inference;
+package org.apache.tika.config.loader;
 
-import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 
-import org.apache.tika.exception.TikaException;
-import org.apache.tika.parser.ParseContext;
-import org.apache.tika.parser.inference.Engine;
+import org.apache.tika.annotation.TikaComponent;
+import org.apache.tika.parser.inference.TextChunker;
 
-/** An engine that turns images into vectors, many per request. */
-public interface EmbeddingEngine extends Engine {
+/** Cuts text into fixed-size pieces; a stand-in for a real chunker in loader 
tests. */
+@TikaComponent(name = "test-chunker", spi = false)
+public class TestChunker implements TextChunker {
 
-    /** One request; the vectors come back in the order of the images. */
-    List<float[]> embedImages(List<byte[]> images, List<String> mimeTypes, 
ParseContext context)
-            throws IOException, TikaException;
+    private int size = 4;
 
-    int getMaxBatchSize();
+    public int getSize() {
+        return size;
+    }
+
+    public void setSize(int size) {
+        this.size = size;
+    }
+
+    @Override
+    public List<int[]> spans(String text) {
+        List<int[]> spans = new ArrayList<>();
+        for (int start = 0; start < text.length(); start += size) {
+            spans.add(new int[]{start, Math.min(start + size, text.length())});
+        }
+        return spans;
+    }
 }
diff --git 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
index ca674fe06f..e6a7bfb224 100644
--- 
a/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
+++ 
b/tika-server/tika-server-standard/src/test/java/org/apache/tika/server/standard/InferenceBindingsWireTest.java
@@ -29,6 +29,8 @@ import java.io.Reader;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
 import javax.imageio.ImageIO;
 
 import jakarta.ws.rs.core.Response;
@@ -47,6 +49,7 @@ import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
 import org.junit.jupiter.api.Test;
 
 import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.parser.mock.MockTask;
 import org.apache.tika.serialization.JsonMetadataList;
 import org.apache.tika.server.core.CXFTestBase;
@@ -130,6 +133,33 @@ public class InferenceBindingsWireTest extends CXFTestBase 
{
         assertEquals("2", metadata.get(MockTask.UNITS_KEY));
     }
 
+    /** The TEXT stage runs in the worker over the whole list: every document 
with text is a unit. */
+    @Test
+    public void testTextBindingSeesTheWholeTree() throws Exception {
+        ByteArrayOutputStream zip = new ByteArrayOutputStream();
+        try (ZipOutputStream out = new ZipOutputStream(zip)) {
+            out.putNextEntry(new ZipEntry("a.txt"));
+            out.write("alpha note".getBytes(UTF_8));
+            out.closeEntry();
+            out.putNextEntry(new ZipEntry("b.txt"));
+            out.write("beta note".getBytes(UTF_8));
+            out.closeEntry();
+        }
+        Response response = WebClient
+                .create(endPoint + META_PATH)
+                .accept("application/json")
+                .put(zip.toByteArray());
+
+        Reader reader = new InputStreamReader((InputStream) 
response.getEntity(), UTF_8);
+        List<Metadata> metadataList = JsonMetadataList.fromJson(reader);
+        assertEquals(3, metadataList.size());
+        for (Metadata metadata : metadataList) {
+            assertEquals("mock-text", metadata.get(MockTask.MARKER_KEY),
+                    metadata.get(TikaCoreProperties.RESOURCE_NAME_KEY));
+            assertEquals("3", metadata.get(MockTask.UNITS_KEY), "one run over 
the three documents");
+        }
+    }
+
     private static byte[] twoPagePdf() throws IOException {
         try (PDDocument document = new PDDocument()) {
             for (int i = 1; i <= 2; i++) {
diff --git 
a/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json
 
b/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json
index 601646c106..f20098f2cc 100644
--- 
a/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json
+++ 
b/tika-server/tika-server-standard/src/test/resources/configs/tika-config-inference-bindings.json
@@ -2,7 +2,8 @@
   "engines": { "mock": { "mock-engine": {} } },
   "inference": [
     { "id": "mock-images", "engine": "mock", "input": "IMAGES", "tasks": 
["mock-task"] },
-    { "id": "mock-pages", "engine": "mock", "input": "PAGES", "tasks": 
["mock-task"] }
+    { "id": "mock-pages", "engine": "mock", "input": "PAGES", "tasks": 
["mock-task"] },
+    { "id": "mock-text", "engine": "mock", "input": "TEXT", "tasks": 
["mock-task"] }
   ],
   "auto-detect-parser": { "throwOnZeroBytes": false },
   "parse-context": { "timeout-limits": { "progressTimeoutMillis": 30000 } }

Reply via email to