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