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 1f6e2782ae TIKA-4970: write image and page embeddings once per 
document (#3342)
1f6e2782ae is described below

commit 1f6e2782ae2994d394e3d70a857fc11b1c6859fc
Author: Tim Allison <[email protected]>
AuthorDate: Mon Oct 19 15:35:22 2026 +0100

    TIKA-4970: write image and page embeddings once per document (#3342)
---
 CHANGES.txt                                        |  4 ++
 .../org/apache/tika/inference/ChunkTarget.java     |  7 +-
 .../java/org/apache/tika/inference/EmbedTask.java  | 75 ++++++++++++++--------
 .../org/apache/tika/inference/EmbedTaskTest.java   | 66 +++++++++++++++++++
 4 files changed, 125 insertions(+), 27 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 4b04bc03bc..4946d32dbf 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -162,6 +162,10 @@ Release 4.2.0 - unreleased
      MetadataFilter; 4.1.0 failed to install on its own in Equinox/p2
      (TIKA-4945).
 
+   * The embed task writes a document's image and page embeddings to
+     tk:chunks once per binding instead of once per image or page, which
+     re-serialized the whole array each time (TIKA-4970).
+
 Release 4.1.0 - 9/26/2026
 
   HIGHLIGHTS
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
index a279552396..de869b1939 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
@@ -82,6 +82,11 @@ public final class ChunkTarget {
 
     /** Tags the chunks with the locator, if any, and appends them to the 
field. */
     public void write(List<Chunk> chunks, String field) throws IOException {
+        ChunkSerializer.mergeInto(metadata, tag(chunks), field);
+    }
+
+    /** Tags the chunks with the locator, if any, for a caller that writes 
them itself. */
+    List<Chunk> tag(List<Chunk> chunks) {
         if (locator != null) {
             for (Chunk chunk : chunks) {
                 if (chunk.getLocators() != null) {
@@ -89,6 +94,6 @@ public final class ChunkTarget {
                 }
             }
         }
-        ChunkSerializer.mergeInto(metadata, chunks, field);
+        return chunks;
     }
 }
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 e23f9e2448..b9aba497ee 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
@@ -99,31 +99,58 @@ public class EmbedTask implements InferenceTask {
         }
         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(binding, batch, embedder, context);
-            } catch (TikaException e) {
-                if (batch.size() == 1) {
-                    first = first == null ? e : first;
-                    continue;
-                }
-                // one bad image fails a whole batch; retry singly so the rest 
keep their vectors
-                for (InferenceUnit unit : batch) {
-                    try {
-                        embed(binding, List.of(unit), embedder, context);
-                    } catch (TikaException single) {
-                        first = first == null ? single : first;
+        // written once per destination at the end: each write re-serializes 
the whole field
+        List<Owned> embedded = new ArrayList<>();
+        try {
+            for (int start = 0; start < units.size(); start += batchSize) {
+                List<InferenceUnit> batch = units.subList(start, 
Math.min(start + batchSize, units.size()));
+                try {
+                    embed(binding, batch, embedder, context, embedded);
+                } catch (TikaException e) {
+                    if (batch.size() == 1) {
+                        first = first == null ? e : first;
+                        continue;
+                    }
+                    // one bad image fails a whole batch; retry singly so the 
rest keep their vectors
+                    for (InferenceUnit unit : batch) {
+                        try {
+                            embed(binding, List.of(unit), embedder, context, 
embedded);
+                        } catch (TikaException single) {
+                            first = first == null ? single : first;
+                        }
                     }
                 }
             }
+        } catch (IOException | RuntimeException e) {
+            write(embedded, e);
+            throw e;
         }
+        write(embedded, null);
         if (first != null) {
             throw first;
         }
     }
 
-    /** A chunk of text and the document it belongs to. */
+    /** Writes each destination's chunks at once; a write failure rides on the 
failure already in flight. */
+    private static void write(List<Owned> embedded, Exception inFlight) throws 
IOException {
+        for (List<Owned> byOwner : byOwner(embedded)) {
+            List<Chunk> chunks = new ArrayList<>(byOwner.size());
+            for (Owned o : byOwner) {
+                chunks.add(o.chunk());
+            }
+            try {
+                ChunkTarget.self(byOwner.get(0).owner())
+                        .write(chunks, 
TikaCoreProperties.TIKA_CHUNKS.getName());
+            } catch (IOException e) {
+                if (inFlight == null) {
+                    throw e;
+                }
+                inFlight.addSuppressed(e);
+            }
+        }
+    }
+
+    /** A chunk and the document it is written to. */
     private record Owned(Chunk chunk, Metadata owner) {
     }
 
@@ -174,14 +201,7 @@ public class EmbedTask implements InferenceTask {
                 }
             }
         }
-        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());
-        }
+        write(embedded, null);
         if (first != null) {
             throw first;
         }
@@ -382,8 +402,9 @@ public class EmbedTask implements InferenceTask {
         return chunks;
     }
 
+    /** Embeds the batch and adds each unit's chunk, tagged for its 
destination, to {@code embedded}. */
     private static void embed(InferenceBinding binding, List<InferenceUnit> 
batch,
-                              EmbeddingEngine embedder, ParseContext context)
+                              EmbeddingEngine embedder, ParseContext context, 
List<Owned> embedded)
             throws IOException, TikaException {
         List<byte[]> images = new ArrayList<>(batch.size());
         List<String> mimeTypes = new ArrayList<>(batch.size());
@@ -405,7 +426,9 @@ public class EmbedTask implements InferenceTask {
             chunk.setVector(vectors.get(i));
             chunk.setProducer(binding.getId());
             chunk.setModality(binding.getModality());
-            ChunkTarget.of(unit).write(List.of(chunk), 
TikaCoreProperties.TIKA_CHUNKS.getName());
+            ChunkTarget target = ChunkTarget.of(unit);
+            target.tag(List.of(chunk));
+            embedded.add(new Owned(chunk, target.getMetadata()));
         }
     }
 }
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 72143330d2..c6d824c7e6 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
@@ -24,7 +24,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 
 import com.fasterxml.jackson.databind.JsonNode;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -38,6 +40,7 @@ import org.apache.tika.http.TikaTestHttpServer;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.TikaCoreProperties;
 import org.apache.tika.metadata.TikaPagedText;
+import org.apache.tika.metadata.writelimiter.MetadataWriteLimiter;
 import org.apache.tika.mime.MediaType;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.inference.Engine;
@@ -126,6 +129,56 @@ public class EmbedTaskTest {
         }
     }
 
+    /** Each write re-serializes the field, so a destination is written once, 
not once per unit. */
+    @Test
+    public void testOneWritePerDestination() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0, 
1)));
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0, 
1)));
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0)));
+        int[] parentWrites = new int[1];
+        int[] otherWrites = new int[1];
+        Metadata parent = new Metadata(countingChunkWrites(parentWrites));
+        Metadata other = new Metadata(countingChunkWrites(otherWrites));
+        List<InferenceUnit> units = List.of(unit(parent, "a", null), 
unit(other, "b", null),
+                unit(parent, "c", null), unit(parent, "d", null), unit(other, 
"e", null));
+
+        new EmbedTask().run(binding(), units, engine, new ParseContext());
+
+        assertEquals(3, server.getRequestCount());
+        assertEquals(1, parentWrites[0]);
+        assertEquals(1, otherWrites[0]);
+        List<Chunk> chunks = 
ChunkSerializer.fromJson(parent.get(TikaCoreProperties.TIKA_CHUNKS));
+        assertEquals(3, chunks.size());
+        assertEquals("/a", 
chunks.get(0).getLocators().getEmbedded().get(0).getIdPath());
+        assertEquals("/c", 
chunks.get(1).getLocators().getEmbedded().get(0).getIdPath());
+        assertEquals("/d", 
chunks.get(2).getLocators().getEmbedded().get(0).getIdPath());
+        assertEquals(2, 
ChunkSerializer.fromJson(other.get(TikaCoreProperties.TIKA_CHUNKS)).size());
+    }
+
+    private static MetadataWriteLimiter countingChunkWrites(int[] count) {
+        return new MetadataWriteLimiter() {
+            @Override
+            public void add(String field, String value, Map<String, String[]> 
data) {
+                String[] values = data.get(field);
+                if (values == null) {
+                    set(field, value, data);
+                    return;
+                }
+                String[] appended = Arrays.copyOf(values, values.length + 1);
+                appended[values.length] = value;
+                data.put(field, appended);
+            }
+
+            @Override
+            public void set(String field, String value, Map<String, String[]> 
data) {
+                if (TikaCoreProperties.TIKA_CHUNKS.getName().equals(field)) {
+                    count[0]++;
+                }
+                data.put(field, new String[]{value});
+            }
+        };
+    }
+
     @Test
     public void testNoParentKeepsTheChunk() throws Exception {
         server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0)));
@@ -251,6 +304,19 @@ public class EmbedTaskTest {
                 "the good unit keeps its vector");
     }
 
+    @Test
+    public void testFailureKeepsWhatWasEmbedded() throws Exception {
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0, 
1)));
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0)));
+        Metadata parent = new Metadata();
+        List<InferenceUnit> units = List.of(unit(parent, "a", null), 
unit(parent, "b", null),
+                unit(parent, "c", "not-a-page"));
+        assertThrows(NumberFormatException.class,
+                () -> new EmbedTask().run(binding(), units, engine, new 
ParseContext()));
+        assertEquals(2, 
ChunkSerializer.fromJson(parent.get(TikaCoreProperties.TIKA_CHUNKS)).size(),
+                "the first batch is written before the failure propagates");
+    }
+
     @Test
     public void testResponseCountMustMatch() throws Exception {
         server.enqueue(new TikaTestHttpServer.MockResponse(200, response(0)));

Reply via email to