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

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


The following commit(s) were added to refs/heads/main by this push:
     new ffc272852e6f CAMEL-26204: camel-langchain4j-embeddingstore - 
auto-embed a TextSegment body as is and a List body as a batch, fail a Map or 
POJO body, and REMOVE takes only a text id (#29029)
ffc272852e6f is described below

commit ffc272852e6ffe508c3e82b0f44328a1b1a77910
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 3 09:12:26 2026 +0100

    CAMEL-26204: camel-langchain4j-embeddingstore - auto-embed a TextSegment 
body as is and a List body as a batch, fail a Map or POJO body, and REMOVE 
takes only a text id (#29029)
    
    * CAMEL-26204: camel-langchain4j-embeddingstore - auto-embed a TextSegm…
    
    …ent body as is and a List body as a batch, and REMOVE takes only a text id
    
    With an embeddingModel and no embedding header, ADD and SEARCH read the body
    as a String, so a TextSegment body was embedded and stored as its toString()
    and a List body was embedded and stored as one text such as "[a, b]".
    A TextSegment body is now embedded and stored as is, with its metadata. ADD
    embeds a List of String or TextSegment as a batch, one entry per element,
    and returns the ids; SEARCH fails on a List body.
    
    REMOVE converted any body to a String, so a Map or an Integer body removed
    a made-up id and nothing failed. It now fails unless the body is text (a
    String, bytes or a stream) or a Collection of String ids.
    
    * CAMEL-26204: camel-langchain4j-embeddingstore - auto-embedding fails …
    
    …a Map or POJO body instead of embedding its toString()
    
    With an embeddingModel and no embedding header, ADD and SEARCH read the 
body with getBody(String.class),
    so a Map or a POJO was embedded and stored as its toString(). They now 
embed text, bytes, a stream, a
    Reader or a file read as text, or a TextSegment as is, and fail any other 
body with an
    InvalidPayloadException that says to convert the body to text first. When 
the text segment header is
    set, the body is not embedded and not checked. The body types no longer 
advertise any body for ADD and
    SEARCH. The REMOVE rules are unchanged.
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
    Signed-off-by: Claus Ibsen <[email protected]>
---
 .../components/langchain4j-embeddingstore.json     |   2 +-
 .../docs/langchain4j-embeddingstore-component.adoc |  18 +-
 .../embeddingstore/langchain4j-embeddingstore.json |   2 +-
 .../docs/langchain4j-embeddingstore-component.adoc |  18 +-
 .../LangChain4jEmbeddingStoreEndpoint.java         |  23 ++-
 .../LangChain4jEmbeddingStoreProducer.java         | 145 ++++++++++++++--
 .../LangChain4jEmbeddingStoreAutoEmbedTest.java    | 187 +++++++++++++++++++++
 ...ngChain4jEmbeddingStoreBatchOperationsTest.java |  67 ++++++++
 .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc    |  16 ++
 9 files changed, 447 insertions(+), 31 deletions(-)

diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-embeddingstore.json
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-embeddingstore.json
index 03dd71668c8d..809a7df2e72a 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-embeddingstore.json
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/langchain4j-embeddingstore.json
@@ -44,7 +44,7 @@
     "CamelLangchain4jEmbeddingStoreEmbeddingIds": { "index": 5, "kind": 
"header", "displayName": "", "group": "producer", "label": "", "required": 
false, "javaType": "java.util.List<java.lang.String>", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "description": 
"Caller-supplied embedding IDs for batch ADD operations", "constantName": 
"org.apache.camel.component.langchain4j.embeddingstore.LangChain4jEmbeddingStoreHeaders#EMBEDDING_IDS"
 }
   },
   "bodyTypes": {
-    "producerIn": { "carriers": [ "any" ], "byOption": [ { "option": "action", 
"value": "REMOVE", "carriers": [ "text", "list" ] } ], "description": "ADD and 
SEARCH take the embedding from the CamelLangChain4jEmbeddingsEmbedding header 
(a batch ADD from the CamelLangChain4jEmbeddingsEmbeddings header) and ignore 
the body, but with an embeddingModel and no such header they embed the body 
read as a String, so no body fails and another body, a TextSegment too, is 
embedded as its toString(). [...]
+    "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "list" ], 
"byOption": [ { "option": "action", "value": "SEARCH", "carriers": [ "text", 
"bytes", "stream", "file" ] }, { "option": "action", "value": "REMOVE", 
"carriers": [ "text", "list" ] } ], "description": "ADD and SEARCH take the 
embedding from the CamelLangChain4jEmbeddingsEmbedding header (a batch ADD from 
the CamelLangChain4jEmbeddingsEmbeddings header) and ignore the body, but with 
an embeddingModel and no such [...]
     "producerOut": { "carriers": [ "text", "list", "unchanged" ], "byOption": 
[ { "option": "action", "value": "ADD", "carriers": [ "text", "list" ] }, { 
"option": "action", "value": "REMOVE", "carriers": [ "unchanged" ] }, { 
"option": "action", "value": "SEARCH", "carriers": [ "list" ] } ], 
"description": "By the action, from the endpoint or the 
CamelLangchain4jEmbeddingStoreAction header: the id of the added embedding as a 
String for ADD, a List of the ids for a batch ADD, and a List o [...]
   },
   "properties": {
diff --git 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-embeddingstore-component.adoc
 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-embeddingstore-component.adoc
index 3b16a2653206..de4143183b87 100644
--- 
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-embeddingstore-component.adoc
+++ 
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/langchain4j-embeddingstore-component.adoc
@@ -78,6 +78,18 @@ When an `EmbeddingModel` is configured (either explicitly or 
via autowiring from
 
 If the `CamelLangChain4jEmbeddingsEmbedding` header is present, it always 
takes precedence over auto-embedding.
 
+The body is embedded as follows:
+
+* A `TextSegment` body is embedded and stored as is, with its metadata.
+* Text, bytes, a stream or a file is read as a `String`, and stored as a 
`TextSegment` of that text.
+* Any other body, such as a `Map` or a POJO, fails the exchange with an 
`InvalidPayloadException` instead of being
+  embedded as its `toString()`: turn it into text first, with 
`convertBodyTo(String.class)` or a marshal to JSON, say.
+  When the `CamelLangChain4jEmbeddingsTextSegment` header is set, the body is 
not embedded and not checked.
+* For ADD, a `List` of `String` or `TextSegment` is embedded and stored as a 
batch, one entry per element, and the
+  response body is the `List` of the generated IDs (the 
`CamelLangchain4jEmbeddingStoreEmbeddingIds` header supplies
+  your own IDs). A `List` with another element type, or an empty `List`, fails 
with `IllegalArgumentException`.
+* For SEARCH, the body is one query: a `List` body fails with 
`IllegalArgumentException`.
+
 [tabs]
 ====
 Java::
@@ -263,7 +275,8 @@ The REMOVE operation supports multiple strategies:
 
 ==== Remove by Single ID
 
-Delete a single embedding by its ID (body is a `String`):
+Delete a single embedding by its ID (body is a `String`, or bytes or a stream 
read as a `String`). Another body type,
+such as a `Map` or a number, fails with `IllegalArgumentException`:
 
 [tabs]
 ====
@@ -297,7 +310,8 @@ YAML::
 
 ==== Remove by Collection of IDs
 
-Delete multiple embeddings by passing a `Collection<String>` as the body:
+Delete multiple embeddings by passing a `Collection<String>` as the body. A 
collection with an element that is not a
+`String` fails with `IllegalArgumentException`:
 
 [source,java]
 ----
diff --git 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/embeddingstore/langchain4j-embeddingstore.json
 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/embeddingstore/langchain4j-embeddingstore.json
index 03dd71668c8d..809a7df2e72a 100644
--- 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/embeddingstore/langchain4j-embeddingstore.json
+++ 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/generated/resources/META-INF/org/apache/camel/component/langchain4j/embeddingstore/langchain4j-embeddingstore.json
@@ -44,7 +44,7 @@
     "CamelLangchain4jEmbeddingStoreEmbeddingIds": { "index": 5, "kind": 
"header", "displayName": "", "group": "producer", "label": "", "required": 
false, "javaType": "java.util.List<java.lang.String>", "deprecated": false, 
"deprecationNote": "", "autowired": false, "secret": false, "description": 
"Caller-supplied embedding IDs for batch ADD operations", "constantName": 
"org.apache.camel.component.langchain4j.embeddingstore.LangChain4jEmbeddingStoreHeaders#EMBEDDING_IDS"
 }
   },
   "bodyTypes": {
-    "producerIn": { "carriers": [ "any" ], "byOption": [ { "option": "action", 
"value": "REMOVE", "carriers": [ "text", "list" ] } ], "description": "ADD and 
SEARCH take the embedding from the CamelLangChain4jEmbeddingsEmbedding header 
(a batch ADD from the CamelLangChain4jEmbeddingsEmbeddings header) and ignore 
the body, but with an embeddingModel and no such header they embed the body 
read as a String, so no body fails and another body, a TextSegment too, is 
embedded as its toString(). [...]
+    "producerIn": { "carriers": [ "text", "bytes", "stream", "file", "list" ], 
"byOption": [ { "option": "action", "value": "SEARCH", "carriers": [ "text", 
"bytes", "stream", "file" ] }, { "option": "action", "value": "REMOVE", 
"carriers": [ "text", "list" ] } ], "description": "ADD and SEARCH take the 
embedding from the CamelLangChain4jEmbeddingsEmbedding header (a batch ADD from 
the CamelLangChain4jEmbeddingsEmbeddings header) and ignore the body, but with 
an embeddingModel and no such [...]
     "producerOut": { "carriers": [ "text", "list", "unchanged" ], "byOption": 
[ { "option": "action", "value": "ADD", "carriers": [ "text", "list" ] }, { 
"option": "action", "value": "REMOVE", "carriers": [ "unchanged" ] }, { 
"option": "action", "value": "SEARCH", "carriers": [ "list" ] } ], 
"description": "By the action, from the endpoint or the 
CamelLangchain4jEmbeddingStoreAction header: the id of the added embedding as a 
String for ADD, a List of the ids for a batch ADD, and a List o [...]
   },
   "properties": {
diff --git 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/main/docs/langchain4j-embeddingstore-component.adoc
 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/main/docs/langchain4j-embeddingstore-component.adoc
index 3b16a2653206..de4143183b87 100644
--- 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/main/docs/langchain4j-embeddingstore-component.adoc
+++ 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/main/docs/langchain4j-embeddingstore-component.adoc
@@ -78,6 +78,18 @@ When an `EmbeddingModel` is configured (either explicitly or 
via autowiring from
 
 If the `CamelLangChain4jEmbeddingsEmbedding` header is present, it always 
takes precedence over auto-embedding.
 
+The body is embedded as follows:
+
+* A `TextSegment` body is embedded and stored as is, with its metadata.
+* Text, bytes, a stream or a file is read as a `String`, and stored as a 
`TextSegment` of that text.
+* Any other body, such as a `Map` or a POJO, fails the exchange with an 
`InvalidPayloadException` instead of being
+  embedded as its `toString()`: turn it into text first, with 
`convertBodyTo(String.class)` or a marshal to JSON, say.
+  When the `CamelLangChain4jEmbeddingsTextSegment` header is set, the body is 
not embedded and not checked.
+* For ADD, a `List` of `String` or `TextSegment` is embedded and stored as a 
batch, one entry per element, and the
+  response body is the `List` of the generated IDs (the 
`CamelLangchain4jEmbeddingStoreEmbeddingIds` header supplies
+  your own IDs). A `List` with another element type, or an empty `List`, fails 
with `IllegalArgumentException`.
+* For SEARCH, the body is one query: a `List` body fails with 
`IllegalArgumentException`.
+
 [tabs]
 ====
 Java::
@@ -263,7 +275,8 @@ The REMOVE operation supports multiple strategies:
 
 ==== Remove by Single ID
 
-Delete a single embedding by its ID (body is a `String`):
+Delete a single embedding by its ID (body is a `String`, or bytes or a stream 
read as a `String`). Another body type,
+such as a `Map` or a number, fails with `IllegalArgumentException`:
 
 [tabs]
 ====
@@ -297,7 +310,8 @@ YAML::
 
 ==== Remove by Collection of IDs
 
-Delete multiple embeddings by passing a `Collection<String>` as the body:
+Delete multiple embeddings by passing a `Collection<String>` as the body. A 
collection with an element that is not a
+`String` fails with `IllegalArgumentException`:
 
 [source,java]
 ----
diff --git 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/main/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreEndpoint.java
 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/main/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreEndpoint.java
index 8c1b63b80c98..35e64fd6b09c 100644
--- 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/main/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreEndpoint.java
+++ 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/main/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreEndpoint.java
@@ -44,16 +44,25 @@ import org.apache.camel.support.DefaultEndpoint;
                      Category.AI
              },
              headersClass = LangChain4jEmbeddingStoreHeaders.class)
-@BodyTypes(producerIn = @BodyType(value = Carrier.ANY,
-                                  byOption = @BodyType.When(option = "action", 
value = "REMOVE",
-                                                            carriers = { 
Carrier.TEXT, Carrier.LIST }),
+@BodyTypes(producerIn = @BodyType(value = { Carrier.TEXT, Carrier.BYTES, 
Carrier.STREAM, Carrier.FILE, Carrier.LIST },
+                                  byOption = {
+                                          @BodyType.When(option = "action", 
value = "SEARCH",
+                                                         carriers = {
+                                                                 Carrier.TEXT, 
Carrier.BYTES, Carrier.STREAM,
+                                                                 Carrier.FILE 
}),
+                                          @BodyType.When(option = "action", 
value = "REMOVE",
+                                                         carriers = { 
Carrier.TEXT, Carrier.LIST }) },
                                   description = "ADD and SEARCH take the 
embedding from the"
                                                 + " 
CamelLangChain4jEmbeddingsEmbedding header (a batch ADD from the"
                                                 + " 
CamelLangChain4jEmbeddingsEmbeddings header) and ignore the body, but with"
-                                                + " an embeddingModel and no 
such header they embed the body read as a String,"
-                                                + " so no body fails and 
another body, a TextSegment too, is embedded as its"
-                                                + " toString(). REMOVE takes 
the id as text or a Collection of ids, unless the"
-                                                + " 
CamelLangchain4jEmbeddingStoreFilter header is set"),
+                                                + " an embeddingModel and no 
such header they embed the body: text, bytes, a"
+                                                + " stream or a file read as 
text, or a LangChain4j TextSegment as is with its"
+                                                + " metadata. Another body, 
such as a Map or a POJO, fails, as does no body:"
+                                                + " convert it to text first. 
ADD embeds a List of String or TextSegment as a"
+                                                + " batch, one entry per 
element, and SEARCH fails on a List. REMOVE takes the"
+                                                + " id as text or a Collection 
of String ids, unless the"
+                                                + " 
CamelLangchain4jEmbeddingStoreFilter header is set, and fails on another"
+                                                + " body"),
            producerOut = @BodyType(value = { Carrier.TEXT, Carrier.LIST, 
Carrier.UNCHANGED },
                                    byOption = {
                                            @BodyType.When(option = "action", 
value = "ADD",
diff --git 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/main/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreProducer.java
 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/main/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreProducer.java
index a6ef24df2525..e772289e111f 100644
--- 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/main/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreProducer.java
+++ 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/main/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreProducer.java
@@ -16,6 +16,13 @@
  */
 package org.apache.camel.component.langchain4j.embeddingstore;
 
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.InputStream;
+import java.io.Reader;
+import java.nio.ByteBuffer;
+import java.nio.file.Path;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
@@ -30,8 +37,11 @@ import dev.langchain4j.store.embedding.EmbeddingStore;
 import dev.langchain4j.store.embedding.filter.Filter;
 import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
+import org.apache.camel.InvalidPayloadException;
 import org.apache.camel.Message;
 import org.apache.camel.NoSuchHeaderException;
+import org.apache.camel.StreamCache;
+import org.apache.camel.WrappedFile;
 import 
org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders;
 import org.apache.camel.support.DefaultProducer;
 
@@ -140,6 +150,13 @@ public class LangChain4jEmbeddingStoreProducer extends 
DefaultProducer {
      * <li>Without text segments: calls {@code addAll(embeddings)}</li>
      * </ul>
      *
+     * <p>
+     * <b>Auto-embedding</b> - with an {@code embeddingModel} and no embedding 
header, a {@code TextSegment} body is
+     * embedded and stored as is, text, bytes, a stream or a file is read as 
text, and a {@code Collection} of
+     * {@code String} or {@code TextSegment} is embedded and stored as a 
batch. Another body, such as a {@code Map} or a
+     * POJO, fails as an {@code InvalidPayloadException}.
+     * </p>
+     *
      * @param  exchange  the Camel exchange containing the embedding data
      * @throws Exception if the add operation fails
      */
@@ -152,7 +169,18 @@ public class LangChain4jEmbeddingStoreProducer extends 
DefaultProducer {
         // Check for batch embeddings header first
         List<Embedding> embeddings = 
in.getHeader(LangChain4jEmbeddingsHeaders.EMBEDDINGS, List.class);
         if (embeddings != null) {
-            addBatch(in, store, embeddings);
+            addBatch(in, store, embeddings, resolveTextSegments(in));
+            return;
+        }
+
+        // Auto-embed a collection body as a batch, one entry per element (the 
embedding and text segment headers
+        // take precedence over the body, as for a single embedding)
+        if (config.getEmbeddingModel() != null && 
in.getHeader(LangChain4jEmbeddingsHeaders.EMBEDDING) == null
+                && in.getHeader(LangChain4jEmbeddingsHeaders.TEXT_SEGMENT) == 
null
+                && in.getBody() instanceof Collection<?> collection) {
+            List<TextSegment> textSegments = toTextSegments(collection);
+            List<Embedding> embedded = 
config.getEmbeddingModel().embedAll(textSegments).content();
+            addBatch(in, store, embedded, textSegments);
             return;
         }
 
@@ -181,9 +209,7 @@ public class LangChain4jEmbeddingStoreProducer extends 
DefaultProducer {
     }
 
     @SuppressWarnings("unchecked")
-    private void addBatch(Message in, EmbeddingStore<TextSegment> store, 
List<Embedding> embeddings) {
-        List<String> callerIds = 
in.getHeader(LangChain4jEmbeddingStoreHeaders.EMBEDDING_IDS, List.class);
-
+    private static List<TextSegment> resolveTextSegments(Message in) {
         // Resolve text segments from header (set by batch embeddings 
producer) or body
         List<TextSegment> textSegments = 
in.getHeader(LangChain4jEmbeddingsHeaders.TEXT_SEGMENTS, List.class);
         if (textSegments == null) {
@@ -192,6 +218,34 @@ public class LangChain4jEmbeddingStoreProducer extends 
DefaultProducer {
                 textSegments = (List<TextSegment>) body;
             }
         }
+        return textSegments;
+    }
+
+    private static List<TextSegment> toTextSegments(Collection<?> collection) {
+        if (collection.isEmpty()) {
+            throw new IllegalArgumentException("ADD with auto-embedding 
requires a non-empty List body");
+        }
+        List<TextSegment> textSegments = new ArrayList<>(collection.size());
+        int index = 0;
+        for (Object element : collection) {
+            if (element instanceof TextSegment textSegment) {
+                textSegments.add(textSegment);
+            } else if (element instanceof String text) {
+                textSegments.add(TextSegment.from(text));
+            } else {
+                throw new IllegalArgumentException(
+                        "ADD with auto-embedding requires a List of String or 
TextSegment, but the element at index "
+                                                   + index + " is " + 
typeOf(element));
+            }
+            index++;
+        }
+        return textSegments;
+    }
+
+    @SuppressWarnings("unchecked")
+    private void addBatch(
+            Message in, EmbeddingStore<TextSegment> store, List<Embedding> 
embeddings, List<TextSegment> textSegments) {
+        List<String> callerIds = 
in.getHeader(LangChain4jEmbeddingStoreHeaders.EMBEDDING_IDS, List.class);
 
         // Validate sizes match when caller IDs are provided
         if (callerIds != null && callerIds.size() != embeddings.size()) {
@@ -252,17 +306,27 @@ public class LangChain4jEmbeddingStoreProducer extends 
DefaultProducer {
         Object body = in.getBody();
 
         // Batch removal by collection of IDs
-        if (body instanceof Collection) {
-            Collection<String> ids = (Collection<String>) body;
-            if (ids.isEmpty()) {
+        if (body instanceof Collection<?> collection) {
+            if (collection.isEmpty()) {
                 throw new IllegalArgumentException(
                         "REMOVE action requires a non-empty Collection<String> 
body for batch ID removal");
             }
-            store.removeAll(ids);
+            for (Object id : collection) {
+                if (!(id instanceof String)) {
+                    throw new IllegalArgumentException(
+                            "REMOVE action requires a Collection<String> body 
for batch ID removal, but it contains "
+                                                       + typeOf(id));
+                }
+            }
+            store.removeAll((Collection<String>) collection);
             return;
         }
 
-        // Single ID removal
+        // Single ID removal, the id is text (a String, or bytes or a stream 
to read as a String)
+        if (body != null && !isText(body)) {
+            throw new IllegalArgumentException(
+                    "REMOVE action requires the ID as a String body, but the 
body is " + typeOf(body));
+        }
         String id = in.getBody(String.class);
         if (id != null && !id.isEmpty()) {
             store.remove(id);
@@ -342,7 +406,7 @@ public class LangChain4jEmbeddingStoreProducer extends 
DefaultProducer {
         }
     }
 
-    private EmbeddingResult resolveEmbedding(Exchange exchange) throws 
NoSuchHeaderException {
+    private EmbeddingResult resolveEmbedding(Exchange exchange) throws 
NoSuchHeaderException, InvalidPayloadException {
         final Message in = exchange.getMessage();
         LangChain4jEmbeddingStoreConfiguration config = 
getEndpoint().getConfiguration();
 
@@ -350,15 +414,9 @@ public class LangChain4jEmbeddingStoreProducer extends 
DefaultProducer {
         TextSegment textSegment = 
in.getHeader(LangChain4jEmbeddingsHeaders.TEXT_SEGMENT, TextSegment.class);
 
         if (embedding == null && config.getEmbeddingModel() != null) {
-            String text = in.getBody(String.class);
-            if (text == null) {
-                throw new IllegalArgumentException(
-                        "Message body cannot be converted to String for 
auto-embedding. "
-                                                   + "Either set the body to a 
text value or provide a pre-computed embedding via the "
-                                                   + 
LangChain4jEmbeddingsHeaders.EMBEDDING + " header.");
-            }
+            // the text segment header takes precedence over the body
             if (textSegment == null) {
-                textSegment = TextSegment.from(text);
+                textSegment = textSegmentOf(exchange);
             }
             embedding = 
config.getEmbeddingModel().embed(textSegment).content();
         }
@@ -373,6 +431,57 @@ public class LangChain4jEmbeddingStoreProducer extends 
DefaultProducer {
         return new EmbeddingResult(embedding, textSegment);
     }
 
+    /**
+     * The body to auto-embed: a TextSegment as is, and text, bytes, a stream, 
a Reader or a file read as text. Any
+     * other body, such as a Map or a POJO, fails as an 
InvalidPayloadException instead of being embedded as its
+     * toString().
+     */
+    private static TextSegment textSegmentOf(Exchange exchange) throws 
InvalidPayloadException {
+        final Message in = exchange.getMessage();
+        Object body = in.getBody();
+        if (body instanceof TextSegment segment) {
+            return segment;
+        }
+        if (body instanceof Collection) {
+            // ADD embeds a collection as a batch, a search takes one query
+            throw new IllegalArgumentException(
+                    "Auto-embedding requires one text as the body (a String or 
a TextSegment), but the body is "
+                                               + typeOf(body));
+        }
+        if (body != null && !isReadableAsText(body)) {
+            throw new InvalidPayloadException(
+                    exchange, String.class, in,
+                    "auto-embedding reads text, bytes, a stream, a Reader, a 
file or a TextSegment; convert the body to"
+                                                + " text before the endpoint, 
such as with convertBodyTo(String) or a"
+                                                + " marshal to JSON, or set 
the "
+                                                + 
LangChain4jEmbeddingsHeaders.EMBEDDING + " header");
+        }
+        String text = in.getBody(String.class);
+        if (text == null) {
+            throw new IllegalArgumentException(
+                    "Message body cannot be converted to String for 
auto-embedding. "
+                                               + "Either set the body to a 
text value or provide a pre-computed embedding via the "
+                                               + 
LangChain4jEmbeddingsHeaders.EMBEDDING + " header.");
+        }
+        return TextSegment.from(text);
+    }
+
+    /** A body Camel converts to its text, rather than to the toString() of an 
object. */
+    private static boolean isReadableAsText(Object body) {
+        return isText(body) || body instanceof ByteBuffer || body instanceof 
ByteArrayOutputStream
+                || body instanceof File || body instanceof Path || body 
instanceof WrappedFile;
+    }
+
+    /** The id of a REMOVE: text, or bytes or a stream read as text. */
+    private static boolean isText(Object body) {
+        return body instanceof CharSequence || body instanceof byte[] || body 
instanceof InputStream
+                || body instanceof Reader || body instanceof StreamCache;
+    }
+
+    private static String typeOf(Object value) {
+        return value == null ? "null" : value.getClass().getName();
+    }
+
     private record EmbeddingResult(Embedding embedding, TextSegment 
textSegment) {
     }
 
diff --git 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/test/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreAutoEmbedTest.java
 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/test/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreAutoEmbedTest.java
index 0f5adf0b96ee..ecef42da5c44 100644
--- 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/test/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreAutoEmbedTest.java
+++ 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/test/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreAutoEmbedTest.java
@@ -16,16 +16,21 @@
  */
 package org.apache.camel.component.langchain4j.embeddingstore;
 
+import java.nio.charset.StandardCharsets;
 import java.util.List;
+import java.util.Map;
 
+import dev.langchain4j.data.document.Metadata;
 import dev.langchain4j.data.embedding.Embedding;
 import dev.langchain4j.data.segment.TextSegment;
 import dev.langchain4j.model.embedding.EmbeddingModel;
 import 
dev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel;
+import dev.langchain4j.store.embedding.EmbeddingMatch;
 import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
 import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
 import org.apache.camel.CamelContext;
 import org.apache.camel.Exchange;
+import org.apache.camel.InvalidPayloadException;
 import org.apache.camel.NoSuchHeaderException;
 import org.apache.camel.RoutesBuilder;
 import org.apache.camel.builder.RouteBuilder;
@@ -67,6 +72,9 @@ class LangChain4jEmbeddingStoreAutoEmbedTest extends 
CamelTestSupport {
                 from("direct:search")
                         
.to("langchain4j-embeddingstore:test?action=SEARCH&returnTextContent=true");
 
+                from("direct:search-matches")
+                        .to("langchain4j-embeddingstore:test?action=SEARCH");
+
                 from("direct:add-no-model")
                         .to("no-model-store:test?action=ADD");
             }
@@ -158,4 +166,183 @@ class LangChain4jEmbeddingStoreAutoEmbedTest extends 
CamelTestSupport {
         assertThat(results).isNotEmpty();
         assertThat(results.get(0)).contains("Apache Camel");
     }
+
+    @Test
+    void addTextSegmentBodyStoresTheSegment() {
+        TextSegment segment = TextSegment.from("I like football.", 
Metadata.from("source", "notes"));
+
+        String id = fluentTemplate.to("direct:add")
+                .withBody(segment)
+                .request(String.class);
+
+        List<EmbeddingMatch<TextSegment>> matches = search("I like football.", 
5);
+        assertThat(matches).hasSize(1);
+        assertThat(matches.get(0).embeddingId()).isEqualTo(id);
+        assertThat(matches.get(0).embedded().text()).isEqualTo("I like 
football.");
+        
assertThat(matches.get(0).embedded().metadata().getString("source")).isEqualTo("notes");
+        // the embedding is the one of the segment text, not of its toString
+        assertThat(matches.get(0).score()).isGreaterThan(0.99);
+    }
+
+    @Test
+    void addListOfStringsBodyAddsOneEntryPerElement() {
+        List<String> ids = fluentTemplate.to("direct:add")
+                .withBody(List.of("I like football.", "The weather is good 
today."))
+                .request(List.class);
+
+        assertThat(ids).hasSize(2);
+        List<EmbeddingMatch<TextSegment>> matches = search("sports", 5);
+        assertThat(matches).hasSize(2);
+        assertThat(matches).extracting(m -> m.embedded().text())
+                .containsExactly("I like football.", "The weather is good 
today.");
+        
assertThat(matches).extracting(EmbeddingMatch::embeddingId).containsExactlyInAnyOrderElementsOf(ids);
+    }
+
+    @Test
+    void addListOfTextSegmentsBodyKeepsTheMetadata() {
+        List<TextSegment> segments = List.of(
+                TextSegment.from("I like football.", Metadata.from("index", 
"1")),
+                TextSegment.from("The weather is good today.", 
Metadata.from("index", "2")));
+
+        List<String> ids = fluentTemplate.to("direct:add")
+                .withBody(segments)
+                .withHeader(LangChain4jEmbeddingStoreHeaders.EMBEDDING_IDS, 
List.of("id-1", "id-2"))
+                .request(List.class);
+
+        assertThat(ids).containsExactly("id-1", "id-2");
+        List<EmbeddingMatch<TextSegment>> matches = search("sports", 5);
+        assertThat(matches).hasSize(2);
+        assertThat(matches.get(0).embeddingId()).isEqualTo("id-1");
+        
assertThat(matches.get(0).embedded().metadata().getString("index")).isEqualTo("1");
+        assertThat(matches.get(1).embeddingId()).isEqualTo("id-2");
+        
assertThat(matches.get(1).embedded().metadata().getString("index")).isEqualTo("2");
+    }
+
+    @Test
+    void addListBodyWithTextSegmentHeaderStoresTheHeaderSegment() {
+        String id = fluentTemplate.to("direct:add")
+                .withBody(List.of("I like football.", "The weather is good 
today."))
+                .withHeader(LangChain4jEmbeddingsHeaders.TEXT_SEGMENT, 
TextSegment.from("XYZZY-marker-42"))
+                .request(String.class);
+
+        List<EmbeddingMatch<TextSegment>> matches = search("XYZZY-marker-42", 
5);
+        assertThat(matches).hasSize(1);
+        assertThat(matches.get(0).embeddingId()).isEqualTo(id);
+        
assertThat(matches.get(0).embedded().text()).isEqualTo("XYZZY-marker-42");
+    }
+
+    @Test
+    void addListWithAnElementThatIsNotTextFails() {
+        Exchange result = fluentTemplate.to("direct:add")
+                .withBody(List.of("I like football.", 42))
+                .send();
+
+        assertThat(result.getException())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("index 1")
+                .hasMessageContaining("java.lang.Integer");
+        assertThat(search("sports", 5)).isEmpty();
+    }
+
+    @Test
+    void addEmptyListFails() {
+        Exchange result = fluentTemplate.to("direct:add")
+                .withBody(List.of())
+                .send();
+
+        assertThat(result.getException())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("non-empty List body");
+    }
+
+    @Test
+    void searchWithTextSegmentBody() {
+        TextSegment segment = TextSegment.from("Apache Camel is a powerful 
integration framework");
+        embeddingStore.add(embeddingModel.embed(segment).content(), segment);
+
+        List<EmbeddingMatch<TextSegment>> matches = 
fluentTemplate.to("direct:search-matches")
+                .withBody(segment)
+                .request(List.class);
+
+        assertThat(matches).hasSize(1);
+        assertThat(matches.get(0).score()).isGreaterThan(0.99);
+    }
+
+    @Test
+    void searchWithListBodyFails() {
+        Exchange result = fluentTemplate.to("direct:search")
+                .withBody(List.of("sports", "weather"))
+                .send();
+
+        assertThat(result.getException())
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("one text");
+    }
+
+    @Test
+    void addMapBodyFailsAndStoresNothing() {
+        Exchange result = fluentTemplate.to("direct:add")
+                .withBody(Map.of("text", "I like football."))
+                .send();
+
+        assertThat(result.getException())
+                .isInstanceOf(InvalidPayloadException.class)
+                .hasMessageContaining("convert the body to text");
+        assertThat(search("I like football.", 5)).isEmpty();
+    }
+
+    @Test
+    void addPojoBodyFailsAndStoresNothing() {
+        Exchange result = fluentTemplate.to("direct:add")
+                .withBody(new Note("I like football."))
+                .send();
+
+        assertThat(result.getException())
+                .isInstanceOf(InvalidPayloadException.class)
+                .hasMessageContaining("convert the body to text");
+        assertThat(search("I like football.", 5)).isEmpty();
+    }
+
+    @Test
+    void searchMapBodyFails() {
+        Exchange result = fluentTemplate.to("direct:search")
+                .withBody(Map.of("text", "sports"))
+                .send();
+
+        assertThat(result.getException())
+                .isInstanceOf(InvalidPayloadException.class)
+                .hasMessageContaining("convert the body to text");
+    }
+
+    @Test
+    void addBytesBodyIsEmbeddedAsText() {
+        fluentTemplate.to("direct:add")
+                .withBody("I like football.".getBytes(StandardCharsets.UTF_8))
+                .send();
+
+        List<EmbeddingMatch<TextSegment>> matches = search("I like football.", 
5);
+        assertThat(matches).hasSize(1);
+        assertThat(matches.get(0).embedded().text()).isEqualTo("I like 
football.");
+    }
+
+    @Test
+    void addMapBodyWithTextSegmentHeaderStoresTheHeaderSegment() {
+        fluentTemplate.to("direct:add")
+                .withBody(Map.of("text", "ignored"))
+                .withHeader(LangChain4jEmbeddingsHeaders.TEXT_SEGMENT, 
TextSegment.from("XYZZY-marker-42"))
+                .send();
+
+        List<EmbeddingMatch<TextSegment>> matches = search("XYZZY-marker-42", 
5);
+        assertThat(matches).hasSize(1);
+        
assertThat(matches.get(0).embedded().text()).isEqualTo("XYZZY-marker-42");
+    }
+
+    private record Note(String text) {
+    }
+
+    private List<EmbeddingMatch<TextSegment>> search(String text, int 
maxResults) {
+        Embedding queryEmbedding = embeddingModel.embed(text).content();
+        return embeddingStore.search(
+                
EmbeddingSearchRequest.builder().queryEmbedding(queryEmbedding).maxResults(maxResults).build()).matches();
+    }
 }
diff --git 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/test/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreBatchOperationsTest.java
 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/test/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreBatchOperationsTest.java
index b8e79ccbf660..3983ed538c89 100644
--- 
a/components/camel-ai/camel-langchain4j-embeddingstore/src/test/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreBatchOperationsTest.java
+++ 
b/components/camel-ai/camel-langchain4j-embeddingstore/src/test/java/org/apache/camel/component/langchain4j/embeddingstore/LangChain4jEmbeddingStoreBatchOperationsTest.java
@@ -16,9 +16,11 @@
  */
 package org.apache.camel.component.langchain4j.embeddingstore;
 
+import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 
 import dev.langchain4j.data.embedding.Embedding;
 import dev.langchain4j.data.segment.TextSegment;
@@ -296,6 +298,64 @@ class LangChain4jEmbeddingStoreBatchOperationsTest extends 
CamelTestSupport {
         assertThat(embeddingStore.getRemoveSingleInvocations()).isEqualTo(1);
     }
 
+    // ---- REMOVE: the id must be text ----
+
+    @Test
+    @DisplayName("REMOVE with a byte[] body removes the id read as a String")
+    void removeSingleIdFromBytes() {
+        String id = embeddingStore.add(Embedding.from(new float[] { 0.9f }));
+        embeddingStore.resetCounters();
+
+        Exchange result = fluentTemplate.to("langchain4j-embeddingstore:test")
+                .withHeader(LangChain4jEmbeddingStoreHeaders.ACTION, 
LangChain4jEmbeddingStoreAction.REMOVE)
+                .withBody(id.getBytes(StandardCharsets.UTF_8))
+                .request(Exchange.class);
+
+        assertThat(result.getException()).isNull();
+        assertThat(embeddingStore.getRemoveSingleInvocations()).isEqualTo(1);
+        assertThat(embeddingStore.getLastRemovedId()).isEqualTo(id);
+    }
+
+    @Test
+    @DisplayName("REMOVE with a Map body fails and removes nothing")
+    void removeWithMapBodyThrows() {
+        Exchange result = fluentTemplate.to("langchain4j-embeddingstore:test")
+                .withHeader(LangChain4jEmbeddingStoreHeaders.ACTION, 
LangChain4jEmbeddingStoreAction.REMOVE)
+                .withBody(Map.of("id", "abc"))
+                .request(Exchange.class);
+
+        
assertThat(result.getException()).isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("REMOVE action requires the ID as a 
String body");
+        assertThat(embeddingStore.getRemoveSingleInvocations()).isZero();
+    }
+
+    @Test
+    @DisplayName("REMOVE with an Integer body fails and removes nothing")
+    void removeWithIntegerBodyThrows() {
+        Exchange result = fluentTemplate.to("langchain4j-embeddingstore:test")
+                .withHeader(LangChain4jEmbeddingStoreHeaders.ACTION, 
LangChain4jEmbeddingStoreAction.REMOVE)
+                .withBody(42)
+                .request(Exchange.class);
+
+        
assertThat(result.getException()).isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("java.lang.Integer");
+        assertThat(embeddingStore.getRemoveSingleInvocations()).isZero();
+    }
+
+    @Test
+    @DisplayName("REMOVE with a Collection of non-String ids fails and removes 
nothing")
+    void removeWithCollectionOfIntegersThrows() {
+        Exchange result = fluentTemplate.to("langchain4j-embeddingstore:test")
+                .withHeader(LangChain4jEmbeddingStoreHeaders.ACTION, 
LangChain4jEmbeddingStoreAction.REMOVE)
+                .withBody(List.of(1, 2))
+                .request(Exchange.class);
+
+        
assertThat(result.getException()).isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("Collection<String>")
+                .hasMessageContaining("java.lang.Integer");
+        assertThat(embeddingStore.getRemoveAllByIdsInvocations()).isZero();
+    }
+
     /**
      * Extended recording store that tracks which method variants are called.
      */
@@ -310,6 +370,7 @@ class LangChain4jEmbeddingStoreBatchOperationsTest extends 
CamelTestSupport {
         private int removeAllByFilterInvocations;
         private int removeAllInvocations;
         private String lastCallerSuppliedId;
+        private String lastRemovedId;
 
         @Override
         public String add(Embedding embedding) {
@@ -344,6 +405,7 @@ class LangChain4jEmbeddingStoreBatchOperationsTest extends 
CamelTestSupport {
         @Override
         public void remove(String id) {
             removeSingleInvocations++;
+            lastRemovedId = id;
             super.remove(id);
         }
 
@@ -375,6 +437,7 @@ class LangChain4jEmbeddingStoreBatchOperationsTest extends 
CamelTestSupport {
             removeAllByFilterInvocations = 0;
             removeAllInvocations = 0;
             lastCallerSuppliedId = null;
+            lastRemovedId = null;
         }
 
         int getAddWithIdInvocations() {
@@ -412,5 +475,9 @@ class LangChain4jEmbeddingStoreBatchOperationsTest extends 
CamelTestSupport {
         String getLastCallerSuppliedId() {
             return lastCallerSuppliedId;
         }
+
+        String getLastRemovedId() {
+            return lastRemovedId;
+        }
     }
 }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
index 0017d0fa6bfa..bfbe13f9cc4f 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
@@ -633,6 +633,22 @@ which embeds its own relocated copy of Vert.x. The `vertx` 
option of `camel-aran
 See the 
https://github.com/fabric8io/kubernetes-client/blob/main/doc/MIGRATION-v8.md[Kubernetes
 client 8 migration guide]
 for the other changes.
 
+=== camel-langchain4j-embeddingstore - auto-embedding a TextSegment, a List, a 
Map or a POJO body, and the REMOVE id
+
+With an `embeddingModel` and no `CamelLangChain4jEmbeddingsEmbedding` header, 
ADD and SEARCH now embed a `TextSegment`
+body as is, with its metadata. Before, they embedded and stored its 
`toString()`.
+
+They now fail the exchange with an `InvalidPayloadException` for a body that 
is not text, bytes, a stream, a file or a
+`TextSegment`, such as a `Map` or a POJO. Before, such a body was embedded and 
stored as its `toString()`, for example
+`{text=...}`. Convert the body to text before the endpoint, with 
`convertBodyTo(String.class)` or a marshal to JSON.
+
+ADD now embeds a `List` of `String` or `TextSegment` as a batch, one entry per 
element, and returns the `List` of the
+IDs. Before, the whole `List` was embedded and stored as one text (such as 
`[a, b]`), and the body was one ID. A `List`
+with another element type fails. SEARCH fails on a `List` body.
+
+REMOVE now fails when the body is not text (a `String`, bytes or a stream) or 
a `Collection` of `String` IDs. Before, a
+`Map` or a number body removed the ID made of its `toString()`, which usually 
removed nothing.
+
 === camel-resilience4j, camel-microprofile-fault-tolerance - a circuit breaker 
in a doTry
 
 A circuit breaker in the steps of a `doTry` no longer ends that `doTry` for 
the steps after it. Before, when the

Reply via email to