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 fa7ea09968 TIKA-4888: a picture's vector lands on the document it 
appears in (#3152)
fa7ea09968 is described below

commit fa7ea0996857fd785d782d0cb887c04b996dddb9
Author: Tim Allison <[email protected]>
AuthorDate: Thu Sep 10 19:21:40 2026 -0400

    TIKA-4888: a picture's vector lands on the document it appears in (#3152)
---
 CHANGES.txt                                        |   8 +
 .../pages/migration-to-4x/chunk-strategies.adoc    |  47 +++++-
 tika-app/pom.xml                                   |   7 +
 .../org/apache/tika/cli/TikaCLIChunkLiftTest.java  | 182 +++++++++++++++++++++
 .../org/apache/tika/extractor/ParentMetadata.java  |  39 +++++
 .../apache/tika/parser/RecursiveParserWrapper.java |  15 +-
 .../org/apache/tika/inference/ChunkSerializer.java |  22 ++-
 .../org/apache/tika/inference/ChunkTarget.java     |  85 ++++++++++
 .../tika/inference/ImageEmbeddingConfig.java       |  15 ++
 .../tika/inference/OpenAIImageEmbeddingParser.java |  12 +-
 .../tika/inference/locator/EmbeddedLocator.java    |  45 +++++
 .../apache/tika/inference/locator/Locators.java    |  22 ++-
 .../apache/tika/inference/ChunkSerializerTest.java |  19 +++
 .../org/apache/tika/inference/ChunkTargetTest.java |  98 +++++++++++
 .../inference/OpenAIImageEmbeddingParserTest.java  |  36 ++++
 15 files changed, 644 insertions(+), 8 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 8c6512827a..7d5a977ed0 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,13 @@
 Release 4.1.0 - unreleased
 
+   * The image embedder writes a picture's vector onto the document the
+     picture appears in (INLINE and RENDERING children) with an "embedded"
+     locator naming the picture; attachments keep their own, and
+     liftToParent: false keeps every picture's vector on the picture.
+     RecursiveParserWrapper exposes the parent's Metadata to a child's parse
+     as ParentMetadata, and ChunkTarget (tika-inference) resolves where a
+     child's chunks land (TIKA-4888).
+     
    * A PDF page rendered as an embedded document (imageStrategy RENDER_PAGES_*)
      is enriched once, by the PDF parser: the text recognizer where the OCR
      strategy says so, every other text-recognizers entry on every page, 
results on
diff --git a/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc 
b/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
index 1acbc366db..c4e7698c19 100644
--- a/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
+++ b/docs/modules/ROOT/pages/migration-to-4x/chunk-strategies.adoc
@@ -48,7 +48,9 @@ CLIP-like image embeddings endpoint, e.g. Jina CLIP v2. 
Carries a `vector` and a
 |===
 
 `SpatialLocator` (bounding box) and `TemporalLocator` (millisecond range) are 
already defined
-and round-tripped by `ChunkSerializer`; no component produces them yet.
+and round-tripped by `ChunkSerializer`; no component produces them yet. 
`EmbeddedLocator`
+(`id_path`, `name`) marks a chunk that was lifted from an embedded document 
into its parent;
+see <<lifting>>.
 
 A single chunk looks like:
 
@@ -109,6 +111,49 @@ Why this shape:
 The trade-offs are the `nested` kNN caveats below, and large documents for 
files with many
 pages.
 
+[#lifting]
+== Where a Chunk Lands: the Parent
+
+An embedded document that is *part of* its parent, a picture in the body of a 
docx or an
+email, should not be a search hit of its own: its vector belongs to the 
document it appears in.
+The image embedder therefore writes a picture's chunk onto the parent, not 
onto the picture,
+so a docx carries one vector per picture in its body and an email the vectors 
of its inline
+pictures. Nothing to configure:
+
+[source,json]
+----
+{
+  "text-recognizers": [
+    { "openai-image-embedding-parser": { "baseUrl": "http://clip-server:8000";,
+                                         "model": "jinaai/jina-clip-v2" } }
+  ],
+  "metadata-filters": [
+    { "openai-embedding-filter": { "baseUrl": "http://embed-server:8000";,
+                                   "model": "text-embedding-3-small" } }
+  ]
+}
+----
+
+The rules:
+
+* `INLINE` and `RENDERING` children hand their chunks to the parent. 
Attachments keep their
+  own: a PDF inside an email is still its own document with its own vectors, 
as above.
+* The parent is the immediate container, so a picture inside an attached docx 
reaches the
+  docx, not the email.
+* Every chunk written to a parent carries an `embedded` locator,
+  `{"id_path": "/1", "name": "image1.png"}`, naming the child it came from, 
beside any
+  locator it already had.
+* `"liftToParent": false` on the embedder keeps every picture's vector on the 
picture, for an
+  index where each picture is a hit of its own.
+* The parent is known only in the recursive (`/rmeta`, `-J`, pipes) parse, 
where the
+  recursive wrapper exposes it to each child's parse as `ParentMetadata`; a 
component that
+  writes chunks for a child resolves the target through `ChunkTarget` 
(`tika-inference`).
+
+PDF page renders follow the same rule by a different route: the PDF parser 
enriches its own
+renders and their vectors land on the PDF directly, one per page with a 
`paginated` locator,
+whether the pages were rendered for OCR or emitted as embedded documents (see
+xref:configuration/parsers/pdf-parser.adoc[PDF Parser]).
+
 == Elasticsearch/OpenSearch Mapping
 
 With `nested` kNN support, a mapping like this works:
diff --git a/tika-app/pom.xml b/tika-app/pom.xml
index fea6bc9351..e161e0ba80 100644
--- a/tika-app/pom.xml
+++ b/tika-app/pom.xml
@@ -105,6 +105,13 @@
       <groupId>org.slf4j</groupId>
       <artifactId>jcl-over-slf4j</artifactId>
     </dependency>
+    <dependency>
+      <groupId>${project.groupId}</groupId>
+      <artifactId>tika-http-jdk</artifactId>
+      <version>${project.version}</version>
+      <classifier>tests</classifier>
+      <scope>test</scope>
+    </dependency>
     <!-- The zip deps are what force reactor ordering; the zips are consumed 
by dependency-plugin below. -->
     <dependency>
       <groupId>org.apache.tika</groupId>
diff --git 
a/tika-app/src/test/java/org/apache/tika/cli/TikaCLIChunkLiftTest.java 
b/tika-app/src/test/java/org/apache/tika/cli/TikaCLIChunkLiftTest.java
new file mode 100644
index 0000000000..a9a554d9dc
--- /dev/null
+++ b/tika-app/src/test/java/org/apache/tika/cli/TikaCLIChunkLiftTest.java
@@ -0,0 +1,182 @@
+/*
+ * 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.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import javax.imageio.ImageIO;
+
+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: a JSON config names the image embedder in 
text-recognizers, and -J
+ * shows the vectors of the pictures in a docx body on the docx itself, each 
naming the
+ * picture it came from. No other configuration: a picture's vector belongs to 
its document.
+ */
+public class TikaCLIChunkLiftTest {
+
+    private static final String EMBEDDING_RESPONSE =
+            "{\"data\":[{\"embedding\":[0.1,0.2,0.3]}]}";
+
+    @TempDir
+    Path tmp;
+
+    @Test
+    public void testPictureVectorsLiftIntoTheDocx() throws Exception {
+        Path docx = tmp.resolve("two-pictures.docx");
+        writeDocx(docx);
+        try (TikaTestHttpServer server = new TikaTestHttpServer()) {
+            server.enqueue(new TikaTestHttpServer.MockResponse(200, 
EMBEDDING_RESPONSE));
+            server.enqueue(new TikaTestHttpServer.MockResponse(200, 
EMBEDDING_RESPONSE));
+            Path config = tmp.resolve("tika-config.json");
+            Files.writeString(config, """
+                    {
+                      "text-recognizers": [
+                        { "openai-image-embedding-parser": { "baseUrl": 
"BASE_URL", "model": "clip" } }
+                      ]
+                    }
+                    """.replace("BASE_URL", server.url()));
+
+            JsonNode output = new ObjectMapper().readTree(
+                    run("--config=" + config, "-J", docx.toUri().toString()));
+
+            assertEquals(2, server.getRequestCount(), "one embedding request 
per picture");
+            JsonNode root = null;
+            List<JsonNode> pictures = new ArrayList<>();
+            for (JsonNode m : output) {
+                if (!m.has("tk:embedded-id-path")) {
+                    root = m;
+                } else if ("INLINE".equals(text(m, 
"tk:embedded-resource-type"))) {
+                    pictures.add(m);
+                }
+            }
+            assertNotNull(root);
+            assertEquals(2, pictures.size());
+            Set<String> picturePaths = new HashSet<>();
+            for (JsonNode picture : pictures) {
+                assertFalse(picture.has("tk:chunks"), "chunks moved off the 
picture");
+                picturePaths.add(text(picture, "tk:embedded-id-path"));
+            }
+
+            List<Chunk> chunks = ChunkSerializer.fromJson(text(root, 
"tk:chunks"));
+            assertEquals(2, chunks.size());
+            Set<String> origins = new HashSet<>();
+            for (Chunk chunk : chunks) {
+                assertEquals(3, chunk.getVector().length);
+                
origins.add(chunk.getLocators().getEmbedded().get(0).getIdPath());
+                
assertTrue(chunk.getLocators().getEmbedded().get(0).getName().endsWith(".png"));
+            }
+            assertEquals(picturePaths, origins);
+        }
+    }
+
+    /** A minimal docx: one paragraph and two pictures related to the main 
document part. */
+    private static void writeDocx(Path docx) throws Exception {
+        String rel = 
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/";;
+        try (ZipOutputStream zip = new 
ZipOutputStream(Files.newOutputStream(docx))) {
+            entry(zip, "[Content_Types].xml", """
+                    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+                    <Types 
xmlns="http://schemas.openxmlformats.org/package/2006/content-types";>
+                    <Default Extension="rels" 
ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
+                    <Default Extension="xml" ContentType="application/xml"/>
+                    <Default Extension="png" ContentType="image/png"/>
+                    <Override PartName="/word/document.xml" 
ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
+                    </Types>
+                    """.getBytes(UTF_8));
+            entry(zip, "_rels/.rels", ("""
+                    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+                    <Relationships 
xmlns="http://schemas.openxmlformats.org/package/2006/relationships";>
+                    <Relationship Id="rId1" Type="RELofficeDocument" 
Target="word/document.xml"/>
+                    </Relationships>
+                    """).replace("REL", rel).getBytes(UTF_8));
+            entry(zip, "word/document.xml", """
+                    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+                    <w:document 
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main";>
+                    <w:body><w:p><w:r><w:t>two 
pictures</w:t></w:r></w:p></w:body></w:document>
+                    """.getBytes(UTF_8));
+            entry(zip, "word/_rels/document.xml.rels", ("""
+                    <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+                    <Relationships 
xmlns="http://schemas.openxmlformats.org/package/2006/relationships";>
+                    <Relationship Id="rId1" Type="RELimage" 
Target="media/image1.png"/>
+                    <Relationship Id="rId2" Type="RELimage" 
Target="media/image2.png"/>
+                    </Relationships>
+                    """).replace("REL", rel).getBytes(UTF_8));
+            entry(zip, "word/media/image1.png", png(1));
+            entry(zip, "word/media/image2.png", png(2));
+        }
+    }
+
+    private static void entry(ZipOutputStream zip, String name, byte[] bytes) 
throws Exception {
+        zip.putNextEntry(new ZipEntry(name));
+        zip.write(bytes);
+        zip.closeEntry();
+    }
+
+    private static byte[] png(int shade) throws Exception {
+        BufferedImage image = new BufferedImage(20, 20, 
BufferedImage.TYPE_INT_RGB);
+        for (int x = 0; x < 20; x++) {
+            for (int y = 0; y < 20; y++) {
+                image.setRGB(x, y, shade * 0x3F3F3F);
+            }
+        }
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        ImageIO.write(image, "png", out);
+        return out.toByteArray();
+    }
+
+    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/extractor/ParentMetadata.java 
b/tika-core/src/main/java/org/apache/tika/extractor/ParentMetadata.java
new file mode 100644
index 0000000000..6505e70bf4
--- /dev/null
+++ b/tika-core/src/main/java/org/apache/tika/extractor/ParentMetadata.java
@@ -0,0 +1,39 @@
+/*
+ * 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.extractor;
+
+import org.apache.tika.metadata.Metadata;
+
+/**
+ * The metadata of the document an embedded document belongs to. The recursive 
parser wrapper
+ * sets it for the duration of a child's parse, so a component enriching the 
child can put a
+ * result where it belongs: a picture's vector on the page it appears in.
+ *
+ * @since Apache Tika 4.1
+ */
+public class ParentMetadata {
+
+    private final Metadata metadata;
+
+    public ParentMetadata(Metadata metadata) {
+        this.metadata = metadata;
+    }
+
+    public Metadata getMetadata() {
+        return metadata;
+    }
+}
diff --git 
a/tika-core/src/main/java/org/apache/tika/parser/RecursiveParserWrapper.java 
b/tika-core/src/main/java/org/apache/tika/parser/RecursiveParserWrapper.java
index 7809deb96a..79470dce11 100644
--- a/tika-core/src/main/java/org/apache/tika/parser/RecursiveParserWrapper.java
+++ b/tika-core/src/main/java/org/apache/tika/parser/RecursiveParserWrapper.java
@@ -31,6 +31,7 @@ import org.apache.tika.exception.WriteLimitReachedException;
 import org.apache.tika.exception.ZeroByteFileException;
 import org.apache.tika.extractor.EmbeddedDocumentUtil;
 import org.apache.tika.extractor.ParentContentHandler;
+import org.apache.tika.extractor.ParentMetadata;
 import org.apache.tika.io.FilenameUtils;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
@@ -140,7 +141,7 @@ public class RecursiveParserWrapper extends ParserDecorator 
{
                     "ContentHandler must implement 
RecursiveParserWrapperHandler");
         }
         EmbeddedParserDecorator decorator =
-                new EmbeddedParserDecorator(getWrappedParser(), "/", "/", 
parserState);
+                new EmbeddedParserDecorator(getWrappedParser(), "/", "/", 
parserState, metadata);
         context.set(Parser.class, decorator);
         ContentHandler localHandler =
                 parserState.recursiveParserWrapperHandler.createHandler();
@@ -214,10 +215,12 @@ public class RecursiveParserWrapper extends 
ParserDecorator {
         private String location = null;
 
         private String embeddedIdPath = null;
-
+        // the document whose embedded documents this decorator parses
+        private final Metadata parentMetadata;
 
         private EmbeddedParserDecorator(Parser parser, String location,
-                                        String embeddedIdPath, ParserState 
parseState) {
+                                        String embeddedIdPath, ParserState 
parseState,
+                                        Metadata parentMetadata) {
             super(parser);
             this.location = location;
             if (!this.location.endsWith("/")) {
@@ -225,6 +228,7 @@ public class RecursiveParserWrapper extends ParserDecorator 
{
             }
             this.embeddedIdPath = embeddedIdPath;
             this.parserState = parseState;
+            this.parentMetadata = parentMetadata;
         }
 
         @Override
@@ -251,7 +255,7 @@ public class RecursiveParserWrapper extends ParserDecorator 
{
             Parser preContextParser = context.get(Parser.class);
             context.set(Parser.class,
                     new EmbeddedParserDecorator(getWrappedParser(), 
objectLocation,
-                            idPath, parserState));
+                            idPath, parserState, metadata));
             long started = System.currentTimeMillis();
             //store the handler that was used before this parse
             //so that you can return it back to its state at the end of this 
parse
@@ -259,6 +263,8 @@ public class RecursiveParserWrapper extends ParserDecorator 
{
 
             ParentContentHandler preParseParentHandler = 
context.get(ParentContentHandler.class);
             context.set(ParentContentHandler.class, new 
ParentContentHandler(preParseHandler));
+            ParentMetadata preParseParentMetadata = 
context.get(ParentMetadata.class);
+            context.set(ParentMetadata.class, new 
ParentMetadata(parentMetadata));
             ContentHandler secureContentHandler =
                     new RecursivelySecureContentHandler(localHandler, tis, 
preParseHandler.handlerCounter,
                     preParseHandler.throwOnWriteLimitReached, context);
@@ -296,6 +302,7 @@ public class RecursiveParserWrapper extends ParserDecorator 
{
                 context.set(Parser.class, preContextParser);
                 context.set(RecursivelySecureContentHandler.class, 
preParseHandler);
                 context.set(ParentContentHandler.class, preParseParentHandler);
+                context.set(ParentMetadata.class, preParseParentMetadata);
                 long elapsedMillis = System.currentTimeMillis() - started;
                 metadata.set(TikaCoreProperties.PARSE_TIME_MILLIS, 
Long.toString(elapsedMillis));
                 parserState.recursiveParserWrapperHandler
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 2119532c65..bcef910dd2 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
@@ -25,6 +25,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.node.ArrayNode;
 import com.fasterxml.jackson.databind.node.ObjectNode;
 
+import org.apache.tika.inference.locator.EmbeddedLocator;
 import org.apache.tika.inference.locator.Locators;
 import org.apache.tika.inference.locator.PaginatedLocator;
 import org.apache.tika.inference.locator.SpatialLocator;
@@ -37,7 +38,7 @@ import org.apache.tika.metadata.TikaCoreProperties;
  * Vectors are stored as base64-encoded big-endian float32 via
  * {@link VectorSerializer}. Locators are nested under a {@code "locators"}
  * object with optional {@code text}, {@code paginated}, {@code spatial},
- * and {@code temporal} arrays.
+ * {@code temporal} and {@code embedded} arrays.
  */
 public final class ChunkSerializer {
 
@@ -172,6 +173,17 @@ public final class ChunkSerializer {
                 o.put("end_ms", t.getEndMs());
             }
         }
+
+        if (loc.getEmbedded() != null && !loc.getEmbedded().isEmpty()) {
+            ArrayNode arr = locNode.putArray("embedded");
+            for (EmbeddedLocator e : loc.getEmbedded()) {
+                ObjectNode o = arr.addObject();
+                o.put("id_path", e.getIdPath());
+                if (e.getName() != null) {
+                    o.put("name", e.getName());
+                }
+            }
+        }
     }
 
     private static Locators deserializeLocators(JsonNode locNode) {
@@ -216,6 +228,14 @@ public final class ChunkSerializer {
             }
         }
 
+        JsonNode embArr = locNode.get("embedded");
+        if (embArr != null && embArr.isArray()) {
+            for (JsonNode n : embArr) {
+                String name = n.has("name") ? n.get("name").asText() : null;
+                locators.addEmbedded(new 
EmbeddedLocator(n.get("id_path").asText(), name));
+            }
+        }
+
         return locators;
     }
 
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
new file mode 100644
index 0000000000..e7b4088705
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ChunkTarget.java
@@ -0,0 +1,85 @@
+/*
+ * 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.inference;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Set;
+
+import org.apache.tika.extractor.ParentMetadata;
+import org.apache.tika.inference.locator.EmbeddedLocator;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+
+/**
+ * Where the chunks produced for a document land. A picture in the body of a 
docx or an
+ * email, or a rendering of a page, is part of its parent, so its chunks go 
onto the parent
+ * with an {@link EmbeddedLocator} naming the child. An attachment, a 
top-level file, or a
+ * child parsed outside the recursive wrapper keeps its own.
+ */
+public final class ChunkTarget {
+
+    private static final Set<String> LIFTED = Set.of(
+            TikaCoreProperties.EmbeddedResourceType.INLINE.name(),
+            TikaCoreProperties.EmbeddedResourceType.RENDERING.name());
+
+    private final Metadata metadata;
+    private final EmbeddedLocator locator;
+
+    private ChunkTarget(Metadata metadata, EmbeddedLocator locator) {
+        this.metadata = metadata;
+        this.locator = locator;
+    }
+
+    public static ChunkTarget resolve(Metadata target, ParseContext context) {
+        ParentMetadata parent = context.get(ParentMetadata.class);
+        String idPath = target.get(TikaCoreProperties.EMBEDDED_ID_PATH);
+        String type = target.get(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE);
+        if (parent == null || idPath == null || type == null || 
!LIFTED.contains(type)) {
+            return new ChunkTarget(target, null);
+        }
+        return new ChunkTarget(parent.getMetadata(),
+                new EmbeddedLocator(idPath, 
target.get(TikaCoreProperties.RESOURCE_NAME_KEY)));
+    }
+
+    /** The target keeps its own chunks. */
+    public static ChunkTarget self(Metadata target) {
+        return new ChunkTarget(target, null);
+    }
+
+    public Metadata getMetadata() {
+        return metadata;
+    }
+
+    /** Names the child the chunks came from; null when they stay on that 
child. */
+    public EmbeddedLocator getLocator() {
+        return locator;
+    }
+
+    /** Tags the chunks with the locator, if any, and appends them to the 
field. */
+    public void write(List<Chunk> chunks, String field) throws IOException {
+        if (locator != null) {
+            for (Chunk chunk : chunks) {
+                if (chunk.getLocators() != null) {
+                    chunk.getLocators().addEmbedded(locator);
+                }
+            }
+        }
+        ChunkSerializer.mergeInto(metadata, chunks, field);
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
index f4de52a093..1d8da98f2f 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/ImageEmbeddingConfig.java
@@ -45,6 +45,13 @@ public class ImageEmbeddingConfig implements Serializable {
      */
     private String outputField = TikaCoreProperties.TIKA_CHUNKS.getName();
 
+    /**
+     * Put a picture's vector on the document the picture appears in (a docx, 
an email, a
+     * rendered page's PDF) rather than on the picture's own embedded 
document; attachments
+     * always keep their own. See {@link ChunkTarget}.
+     */
+    private boolean liftToParent = true;
+
     public String getBaseUrl() {
         return baseUrl;
     }
@@ -109,6 +116,14 @@ public class ImageEmbeddingConfig implements Serializable {
         this.outputField = outputField;
     }
 
+    public boolean isLiftToParent() {
+        return liftToParent;
+    }
+
+    public void setLiftToParent(boolean liftToParent) {
+        this.liftToParent = liftToParent;
+    }
+
     /**
      * Runtime-only config that prevents modification of security-sensitive
      * and cost-sensitive fields ({@code baseUrl}, {@code apiKey},
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
index 92091193e9..bc04bd4954 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/OpenAIImageEmbeddingParser.java
@@ -171,7 +171,9 @@ public class OpenAIImageEmbeddingParser implements Parser, 
Initializable, Closea
         Chunk chunk = new Chunk(null, locators);
         chunk.setVector(vector);
 
-        ChunkSerializer.mergeInto(metadata, List.of(chunk), 
config.getOutputField());
+        ChunkTarget target = config.isLiftToParent()
+                ? ChunkTarget.resolve(metadata, parseContext) : 
ChunkTarget.self(metadata);
+        target.write(List.of(chunk), config.getOutputField());
 
         XHTMLContentHandler xhtml = new XHTMLContentHandler(
                 handler, metadata, parseContext);
@@ -376,6 +378,14 @@ public class OpenAIImageEmbeddingParser implements Parser, 
Initializable, Closea
         defaultConfig.setOutputField(outputField);
     }
 
+    public boolean isLiftToParent() {
+        return defaultConfig.isLiftToParent();
+    }
+
+    public void setLiftToParent(boolean liftToParent) {
+        defaultConfig.setLiftToParent(liftToParent);
+    }
+
     // ---- Azure / endpoint config getters/setters 
----------------------------
 
     public String getEmbeddingsPath() {
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/locator/EmbeddedLocator.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/locator/EmbeddedLocator.java
new file mode 100644
index 0000000000..8f4aec8808
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/locator/EmbeddedLocator.java
@@ -0,0 +1,45 @@
+/*
+ * 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.inference.locator;
+
+/**
+ * The embedded document a chunk was derived from, for a chunk that was lifted 
into its
+ * parent's {@code tk:chunks}: a page render or an inline image whose vector 
belongs to the
+ * document it is part of.
+ */
+public class EmbeddedLocator {
+
+    private final String idPath;
+    private final String name;
+
+    /**
+     * @param idPath the child's {@code tk:embedded-id-path}
+     * @param name   the child's resource name, or null if it had none
+     */
+    public EmbeddedLocator(String idPath, String name) {
+        this.idPath = idPath;
+        this.name = name;
+    }
+
+    public String getIdPath() {
+        return idPath;
+    }
+
+    public String getName() {
+        return name;
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/locator/Locators.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/locator/Locators.java
index edd525c66a..b2047fb0a3 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/locator/Locators.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/main/java/org/apache/tika/inference/locator/Locators.java
@@ -33,6 +33,7 @@ public class Locators {
     private List<PaginatedLocator> paginated;
     private List<SpatialLocator> spatial;
     private List<TemporalLocator> temporal;
+    private List<EmbeddedLocator> embedded;
 
     public Locators() {
     }
@@ -109,6 +110,24 @@ public class Locators {
         return this;
     }
 
+    // ---- embedded ---------------------------------------------------------
+
+    public List<EmbeddedLocator> getEmbedded() {
+        return embedded;
+    }
+
+    public void setEmbedded(List<EmbeddedLocator> embedded) {
+        this.embedded = embedded;
+    }
+
+    public Locators addEmbedded(EmbeddedLocator locator) {
+        if (this.embedded == null) {
+            this.embedded = new ArrayList<>();
+        }
+        this.embedded.add(locator);
+        return this;
+    }
+
     /**
      * @return true if no locators of any type are present
      */
@@ -116,6 +135,7 @@ public class Locators {
         return (text == null || text.isEmpty())
                 && (paginated == null || paginated.isEmpty())
                 && (spatial == null || spatial.isEmpty())
-                && (temporal == null || temporal.isEmpty());
+                && (temporal == null || temporal.isEmpty())
+                && (embedded == null || embedded.isEmpty());
     }
 }
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 2f320731dd..a0357b00d3 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
@@ -25,6 +25,7 @@ import java.util.List;
 
 import org.junit.jupiter.api.Test;
 
+import org.apache.tika.inference.locator.EmbeddedLocator;
 import org.apache.tika.inference.locator.Locators;
 import org.apache.tika.inference.locator.PaginatedLocator;
 import org.apache.tika.inference.locator.SpatialLocator;
@@ -180,4 +181,22 @@ public class ChunkSerializerTest {
         assertEquals("He said \"hello\" & <goodbye>",
                 restored.get(0).getText());
     }
+
+    @Test
+    void testRoundTripEmbeddedLocator() throws Exception {
+        Chunk c = new Chunk(null, new Locators()
+                .addPaginated(new PaginatedLocator(3))
+                .addEmbedded(new EmbeddedLocator("/1/2", "page-3.png")));
+        c.setVector(new float[]{0.5f});
+        Chunk unnamed = new Chunk(null, new Locators().addEmbedded(new 
EmbeddedLocator("/4", null)));
+
+        List<Chunk> restored = 
ChunkSerializer.fromJson(ChunkSerializer.toJson(List.of(c, unnamed)));
+
+        EmbeddedLocator e = restored.get(0).getLocators().getEmbedded().get(0);
+        assertEquals("/1/2", e.getIdPath());
+        assertEquals("page-3.png", e.getName());
+        assertEquals(3, 
restored.get(0).getLocators().getPaginated().get(0).getPage());
+        assertEquals("/4", 
restored.get(1).getLocators().getEmbedded().get(0).getIdPath());
+        
assertNull(restored.get(1).getLocators().getEmbedded().get(0).getName());
+    }
 }
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
new file mode 100644
index 0000000000..fbd7df4ca0
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/ChunkTargetTest.java
@@ -0,0 +1,98 @@
+/*
+ * 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.inference;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.apache.tika.extractor.ParentMetadata;
+import org.apache.tika.inference.locator.Locators;
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.parser.ParseContext;
+
+public class ChunkTargetTest {
+
+    private static Metadata child(String type, String idPath, String name) {
+        Metadata m = new Metadata();
+        if (type != null) {
+            m.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, type);
+        }
+        if (idPath != null) {
+            m.set(TikaCoreProperties.EMBEDDED_ID_PATH, idPath);
+        }
+        m.set(TikaCoreProperties.RESOURCE_NAME_KEY, name);
+        return m;
+    }
+
+    private static ParseContext withParent(Metadata parent) {
+        ParseContext context = new ParseContext();
+        context.set(ParentMetadata.class, new ParentMetadata(parent));
+        return context;
+    }
+
+    @Test
+    public void testInlineAndRenderingLiftToParent() throws Exception {
+        Metadata parent = new Metadata();
+        for (String type : List.of("INLINE", "RENDERING")) {
+            Metadata child = child(type, "/1", "image1.png");
+            ChunkTarget target = ChunkTarget.resolve(child, 
withParent(parent));
+            assertSame(parent, target.getMetadata());
+            assertEquals("/1", target.getLocator().getIdPath());
+            assertEquals("image1.png", target.getLocator().getName());
+        }
+    }
+
+    @Test
+    public void testAttachmentTopLevelAndNoParentKeepTheirOwn() {
+        Metadata parent = new Metadata();
+        Metadata attachment = child("ATTACHMENT", "/2", "report.pdf");
+        assertSame(attachment, ChunkTarget.resolve(attachment, 
withParent(parent)).getMetadata());
+        assertNull(ChunkTarget.resolve(attachment, 
withParent(parent)).getLocator());
+
+        Metadata untyped = child(null, "/3", "mystery.bin");
+        assertSame(untyped, ChunkTarget.resolve(untyped, 
withParent(parent)).getMetadata());
+
+        Metadata orphan = child("INLINE", "/4", "image4.png");
+        assertSame(orphan, ChunkTarget.resolve(orphan, new 
ParseContext()).getMetadata());
+
+        Metadata topLevel = new Metadata();
+        assertSame(topLevel, ChunkTarget.resolve(topLevel, 
withParent(parent)).getMetadata());
+        assertSame(topLevel, ChunkTarget.self(topLevel).getMetadata());
+    }
+
+    @Test
+    public void testWriteTagsAndAppends() throws Exception {
+        Metadata parent = new Metadata();
+        ParseContext context = withParent(parent);
+        for (int i = 1; i <= 2; i++) {
+            Chunk chunk = new Chunk(null, new Locators());
+            chunk.setVector(new float[]{i});
+            ChunkTarget.resolve(child("INLINE", "/" + i, "image" + i + 
".png"), context)
+                    .write(List.of(chunk), 
TikaCoreProperties.TIKA_CHUNKS.getName());
+        }
+        List<Chunk> chunks = 
ChunkSerializer.fromJson(parent.get(TikaCoreProperties.TIKA_CHUNKS));
+        assertEquals(2, chunks.size());
+        assertEquals("/1", 
chunks.get(0).getLocators().getEmbedded().get(0).getIdPath());
+        assertEquals("image2.png", 
chunks.get(1).getLocators().getEmbedded().get(0).getName());
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIImageEmbeddingParserTest.java
 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIImageEmbeddingParserTest.java
index 7ef3b7189d..ffc0e2e15d 100644
--- 
a/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIImageEmbeddingParserTest.java
+++ 
b/tika-parsers/tika-parsers-ml/tika-inference/src/test/java/org/apache/tika/inference/OpenAIImageEmbeddingParserTest.java
@@ -32,6 +32,7 @@ import org.junit.jupiter.api.Test;
 import org.xml.sax.helpers.DefaultHandler;
 
 import org.apache.tika.exception.TikaException;
+import org.apache.tika.extractor.ParentMetadata;
 import org.apache.tika.http.TikaTestHttpServer;
 import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.HttpHeaders;
@@ -242,6 +243,41 @@ public class OpenAIImageEmbeddingParserTest {
         assertEquals(4, merged.get(1).getVector().length);
     }
 
+    /** A picture in a docx body: its vector lands on the docx, naming the 
picture. */
+    @Test
+    void testInlineChildVectorLandsOnParent() throws Exception {
+        Metadata parent = new Metadata();
+        Metadata picture = new Metadata();
+        picture.set(HttpHeaders.CONTENT_TYPE, "image/png");
+        picture.set(TikaCoreProperties.EMBEDDED_RESOURCE_TYPE, "INLINE");
+        picture.set(TikaCoreProperties.EMBEDDED_ID_PATH, "/1");
+        picture.set(TikaCoreProperties.RESOURCE_NAME_KEY, "image1.png");
+        ParseContext context = new ParseContext();
+        context.set(ParentMetadata.class, new ParentMetadata(parent));
+
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, 
buildEmbeddingResponse(3)));
+        try (TikaInputStream tis = TikaInputStream.get(new byte[]{(byte) 0x89, 
'P', 'N', 'G'})) {
+            parser.parse(tis, new DefaultHandler(), picture, context);
+        }
+        assertNull(picture.get(TikaCoreProperties.TIKA_CHUNKS), "the picture 
keeps nothing");
+        List<Chunk> chunks = 
ChunkSerializer.fromJson(parent.get(TikaCoreProperties.TIKA_CHUNKS));
+        assertEquals(1, chunks.size());
+        assertEquals("/1", 
chunks.get(0).getLocators().getEmbedded().get(0).getIdPath());
+        assertEquals("image1.png", 
chunks.get(0).getLocators().getEmbedded().get(0).getName());
+
+        ImageEmbeddingConfig own = new ImageEmbeddingConfig();
+        own.setBaseUrl(server.url());
+        own.setModel("clip");
+        own.setLiftToParent(false);
+        server.enqueue(new TikaTestHttpServer.MockResponse(200, 
buildEmbeddingResponse(3)));
+        try (OpenAIImageEmbeddingParser keeping = new 
OpenAIImageEmbeddingParser(own);
+                TikaInputStream tis = TikaInputStream.get(new byte[]{(byte) 
0x89, 'P', 'N', 'G'})) {
+            keeping.parse(tis, new DefaultHandler(), picture, context);
+        }
+        assertNotNull(picture.get(TikaCoreProperties.TIKA_CHUNKS), 
"liftToParent: false");
+        assertEquals(1, 
ChunkSerializer.fromJson(parent.get(TikaCoreProperties.TIKA_CHUNKS)).size());
+    }
+
     @Test
     void testSupportedTypes() {
         assertTrue(parser.getSupportedTypes(new ParseContext())

Reply via email to