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 c4da338913a9 CAMEL-26208: camel-spring-ai-embeddings - a List body
always gives a List of vectors, and its elements are converted to text (#29031)
c4da338913a9 is described below
commit c4da338913a9c51d38a0ec6d9736b8a94b6c7d8f
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Nov 3 11:45:46 2026 +0100
CAMEL-26208: camel-spring-ai-embeddings - a List body always gives a List
of vectors, and its elements are converted to text (#29031)
* CAMEL-26208: camel-spring-ai-embeddings - a List body always gives a …
…List of vectors, and its elements are converted to text
A List body with one text gave a single float[] instead of a List<float[]>,
so the
output shape depended on the list size. A List body now always gives a
List<float[]>
with the CamelSpringAiEmbeddings and CamelSpringAiEmbeddingInputTexts
headers.
A List body was cast to List<String> unchecked, so a List of other objects
failed
with a ClassCastException inside Spring AI. Each element is now converted
to a
String, and a null element fails with an IllegalArgumentException. An empty
List
gives an empty List without calling the model (Ollama rejects an empty
request).
* Merge remote-tracking branch 'origin/main' into CAMEL-26208-spring-ai…
…-embeddings-list
# Conflicts:
# docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_24.adoc
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Claus Ibsen <[email protected]>
---
.../catalog/components/spring-ai-embeddings.json | 4 +-
.../docs/spring-ai-embeddings-component.adoc | 6 ++-
.../springai/embeddings/spring-ai-embeddings.json | 4 +-
.../main/docs/spring-ai-embeddings-component.adoc | 6 ++-
.../embeddings/SpringAiEmbeddingsEndpoint.java | 10 ++--
.../embeddings/SpringAiEmbeddingsProducer.java | 25 ++++++++--
.../SpringAiEmbeddingsComponentTest.java | 57 ++++++++++++++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 11 +++++
8 files changed, 109 insertions(+), 14 deletions(-)
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json
index 6b33fa8447b1..e182c253f1ef 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/components/spring-ai-embeddings.json
@@ -38,8 +38,8 @@
"CamelSpringAiEmbeddingInputTexts": { "index": 5, "kind": "header",
"displayName": "", "group": "producer", "label": "", "required": false,
"javaType": "java.util.List<String>", "deprecated": false, "deprecationNote":
"", "autowired": false, "secret": false, "description": "List of input texts
that were embedded", "constantName":
"org.apache.camel.component.springai.embeddings.SpringAiEmbeddingsHeaders#INPUT_TEXTS"
}
},
"bodyTypes": {
- "producerIn": { "carriers": [ "any" ], "description": "The text to embed,
or a List of texts for a batch. Another body is converted to text (a Map or a
POJO as its toString()), and no body fails" },
- "producerOut": { "carriers": [ "list" ], "description": "The vector as a
float[] for one text, also for a List with one text, and a List of float[] for
a List of more texts. The Embedding and the input text are in headers, which
spring-ai-vector-store reads" }
+ "producerIn": { "carriers": [ "any" ], "description": "The text to embed,
or a List of texts for a batch. Another body, and each element of a List, is
converted to text (a Map or a POJO as its toString()); no body, or a null
element in a List, fails" },
+ "producerOut": { "carriers": [ "list" ], "description": "The vector as a
float[] for one text, and a List of float[] for a List of texts, also for a
List with one text or none. The Embedding and the input text are in headers,
which spring-ai-vector-store reads" }
},
"properties": {
"embeddingId": { "index": 0, "kind": "path", "displayName": "Embedding
Id", "group": "producer", "label": "", "required": true, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "description": "The id" },
diff --git
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc
index ecd88fd3fca8..99ab8537b98c 100644
---
a/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc
+++
b/catalog/camel-catalog/src/generated/resources/org/apache/camel/catalog/docs/spring-ai-embeddings-component.adoc
@@ -50,6 +50,9 @@ The component accepts two types of input:
* **Single text**: A String containing the text to embed
* **Batch processing**: A List<String> containing multiple texts to embed at
once
+Another body, and each element of a List, is converted to a String. A `null`
element in a List fails the exchange
+with an `IllegalArgumentException`.
+
The embedding vectors are returned in the message body, with additional
metadata available in headers.
=== Single Text Example
@@ -145,7 +148,8 @@ YAML::
----
====
-When processing a List<String>, the component returns:
+When processing a List<String>, the component returns the following, also for
a List with a single text. An empty
+List gives an empty List, without calling the embedding model.
* **Message Body**: List of embedding vectors as `List<float[]>`
* **Headers**:
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json
index 6b33fa8447b1..e182c253f1ef 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/generated/resources/META-INF/org/apache/camel/component/springai/embeddings/spring-ai-embeddings.json
@@ -38,8 +38,8 @@
"CamelSpringAiEmbeddingInputTexts": { "index": 5, "kind": "header",
"displayName": "", "group": "producer", "label": "", "required": false,
"javaType": "java.util.List<String>", "deprecated": false, "deprecationNote":
"", "autowired": false, "secret": false, "description": "List of input texts
that were embedded", "constantName":
"org.apache.camel.component.springai.embeddings.SpringAiEmbeddingsHeaders#INPUT_TEXTS"
}
},
"bodyTypes": {
- "producerIn": { "carriers": [ "any" ], "description": "The text to embed,
or a List of texts for a batch. Another body is converted to text (a Map or a
POJO as its toString()), and no body fails" },
- "producerOut": { "carriers": [ "list" ], "description": "The vector as a
float[] for one text, also for a List with one text, and a List of float[] for
a List of more texts. The Embedding and the input text are in headers, which
spring-ai-vector-store reads" }
+ "producerIn": { "carriers": [ "any" ], "description": "The text to embed,
or a List of texts for a batch. Another body, and each element of a List, is
converted to text (a Map or a POJO as its toString()); no body, or a null
element in a List, fails" },
+ "producerOut": { "carriers": [ "list" ], "description": "The vector as a
float[] for one text, and a List of float[] for a List of texts, also for a
List with one text or none. The Embedding and the input text are in headers,
which spring-ai-vector-store reads" }
},
"properties": {
"embeddingId": { "index": 0, "kind": "path", "displayName": "Embedding
Id", "group": "producer", "label": "", "required": true, "type": "string",
"javaType": "java.lang.String", "deprecated": false, "deprecationNote": "",
"autowired": false, "secret": false, "description": "The id" },
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc
index ecd88fd3fca8..99ab8537b98c 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/docs/spring-ai-embeddings-component.adoc
@@ -50,6 +50,9 @@ The component accepts two types of input:
* **Single text**: A String containing the text to embed
* **Batch processing**: A List<String> containing multiple texts to embed at
once
+Another body, and each element of a List, is converted to a String. A `null`
element in a List fails the exchange
+with an `IllegalArgumentException`.
+
The embedding vectors are returned in the message body, with additional
metadata available in headers.
=== Single Text Example
@@ -145,7 +148,8 @@ YAML::
----
====
-When processing a List<String>, the component returns:
+When processing a List<String>, the component returns the following, also for
a List with a single text. An empty
+List gives an empty List, without calling the embedding model.
* **Message Body**: List of embedding vectors as `List<float[]>`
* **Headers**:
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java
index a0e3683f2a24..e82a65c38365 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsEndpoint.java
@@ -38,12 +38,12 @@ import org.apache.camel.support.DefaultEndpoint;
Category.AI
}, headersClass = SpringAiEmbeddingsHeaders.class)
@BodyTypes(producerIn = @BodyType(value = Carrier.ANY,
- description = "The text to embed, or a List
of texts for a batch. Another body is"
- + " converted to text (a Map
or a POJO as its toString()), and no body"
- + " fails"),
+ description = "The text to embed, or a List
of texts for a batch. Another body, and"
+ + " each element of a List, is
converted to text (a Map or a POJO as its"
+ + " toString()); no body, or a
null element in a List, fails"),
producerOut = @BodyType(value = Carrier.LIST,
- description = "The vector as a float[] for
one text, also for a List with one text,"
- + " and a List of float[] for
a List of more texts. The Embedding and"
+ description = "The vector as a float[] for
one text, and a List of float[] for a List"
+ + " of texts, also for a List
with one text or none. The Embedding and"
+ " the input text are in
headers, which spring-ai-vector-store reads"))
public class SpringAiEmbeddingsEndpoint extends DefaultEndpoint {
@Metadata(required = true)
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java
index f844951a8dcc..a3a64b8710ea 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/main/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsProducer.java
@@ -46,9 +46,28 @@ public class SpringAiEmbeddingsProducer extends
DefaultProducer {
// Support both single String and List<String> as input
List<String> inputTexts;
Object body = message.getBody();
+ boolean batch = body instanceof List;
- if (body instanceof List) {
- inputTexts = (List<String>) body;
+ if (body instanceof List<?> list) {
+ inputTexts = new ArrayList<>(list.size());
+ for (int i = 0; i < list.size(); i++) {
+ Object element = list.get(i);
+ String text = element != null
+ ?
exchange.getContext().getTypeConverter().convertTo(String.class, exchange,
element) : null;
+ if (text == null) {
+ throw new IllegalArgumentException(
+ "Element " + i + " of the List body must be text,
but was: "
+ + (element != null ?
element.getClass().getName() : "null"));
+ }
+ inputTexts.add(text);
+ }
+ if (inputTexts.isEmpty()) {
+ // nothing to embed, so do not call the model
+ message.setBody(new ArrayList<float[]>());
+ message.setHeader(SpringAiEmbeddingsHeaders.EMBEDDINGS, new
ArrayList<Embedding>());
+ message.setHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS,
inputTexts);
+ return;
+ }
} else {
String str = message.getBody(String.class);
if (str != null) {
@@ -65,7 +84,7 @@ public class SpringAiEmbeddingsProducer extends
DefaultProducer {
EmbeddingOptions.builder().build()));
if (response.getResults() != null && !response.getResults().isEmpty())
{
- if (inputTexts.size() == 1) {
+ if (!batch) {
Embedding embedding = response.getResults().get(0);
message.setBody(embedding.getOutput());
message.setHeader(SpringAiEmbeddingsHeaders.EMBEDDING_INDEX,
embedding.getIndex());
diff --git
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java
index 3fe09376371d..61472572c4a4 100644
---
a/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java
+++
b/components/camel-spring-parent/camel-spring-ai/camel-spring-ai-embeddings/src/test/java/org/apache/camel/component/springai/embeddings/SpringAiEmbeddingsComponentTest.java
@@ -19,8 +19,10 @@ package org.apache.camel.component.springai.embeddings;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
import org.apache.camel.CamelContext;
+import org.apache.camel.Exchange;
import org.apache.camel.Message;
import org.apache.camel.test.junit6.CamelTestSupport;
import org.junit.jupiter.api.Test;
@@ -35,6 +37,8 @@ import static org.assertj.core.api.Assertions.assertThat;
public class SpringAiEmbeddingsComponentTest extends CamelTestSupport {
+ private final List<List<String>> requests = new CopyOnWriteArrayList<>();
+
@Override
protected CamelContext createCamelContext() throws Exception {
CamelContext context = super.createCamelContext();
@@ -46,6 +50,7 @@ public class SpringAiEmbeddingsComponentTest extends
CamelTestSupport {
EmbeddingModel embeddingModel = new EmbeddingModel() {
@Override
public EmbeddingResponse call(EmbeddingRequest request) {
+ requests.add(request.getInstructions());
List<Embedding> embeddings = new ArrayList<>();
int index = 0;
for (String text : request.getInstructions()) {
@@ -134,4 +139,56 @@ public class SpringAiEmbeddingsComponentTest extends
CamelTestSupport {
assertThat(vectors.get(0)).hasSameSizeAs(vectors.get(1));
assertThat(vectors.get(0)).hasSameSizeAs(vectors.get(2));
}
+
+ @Test
+ public void testListOfOneTextGivesList() {
+ Message result = fluentTemplate.to("spring-ai-embeddings:one")
+ .withBody(List.of("hello"))
+ .request(Message.class);
+
+ assertThat(result.getBody()).isInstanceOf(List.class);
+ List<float[]> vectors = result.getBody(List.class);
+ assertThat(vectors).hasSize(1);
+ assertThat(vectors.get(0)).containsExactly(0.1f, 0.2f, 0.3f, 0.4f,
0.5f);
+
+ assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS,
List.class)).containsExactly("hello");
+ assertThat(result.getHeader(SpringAiEmbeddingsHeaders.EMBEDDINGS,
List.class)).hasSize(1);
+
assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXT)).isNull();
+ }
+
+ @Test
+ public void testListOfNonStringsIsConvertedToText() {
+ Message result = fluentTemplate.to("spring-ai-embeddings:convert")
+ .withBody(List.of(42, new StringBuilder("hello")))
+ .request(Message.class);
+
+ assertThat(requests).containsExactly(List.of("42", "hello"));
+ List<float[]> vectors = result.getBody(List.class);
+ assertThat(vectors).hasSize(2);
+ assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS,
List.class)).containsExactly("42", "hello");
+ }
+
+ @Test
+ public void testListWithNullElementFails() {
+ Exchange exchange
+ = template.send("spring-ai-embeddings:null", e ->
e.getMessage().setBody(Arrays.asList("hello", null)));
+
+ assertThat(exchange.getException())
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("Element 1 of the List body");
+ assertThat(requests).isEmpty();
+ }
+
+ @Test
+ public void testEmptyListGivesEmptyList() {
+ Message result = fluentTemplate.to("spring-ai-embeddings:empty")
+ .withBody(new ArrayList<String>())
+ .request(Message.class);
+
+ assertThat(result.getBody()).isInstanceOf(List.class);
+ assertThat(result.getBody(List.class)).isEmpty();
+ assertThat(result.getHeader(SpringAiEmbeddingsHeaders.EMBEDDINGS,
List.class)).isEmpty();
+ assertThat(result.getHeader(SpringAiEmbeddingsHeaders.INPUT_TEXTS,
List.class)).isEmpty();
+ assertThat(requests).isEmpty();
+ }
}
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 7e9a617b9da7..b7c41924e999 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
@@ -940,6 +940,17 @@ stream failed after part of the request had already been
sent.
The JSON of the event is now written before the request is sent, with a
`Content-Length` instead of a chunked request
body. A body or a header that cannot be written as JSON fails the exchange
without sending anything to Splunk.
+=== camel-spring-ai-embeddings - a List body always gives a List of vectors
+
+A `List` body with one text now gives a `List<float[]>` with one vector, as a
`List` of more texts does, and sets the
+`CamelSpringAiEmbeddings` and `CamelSpringAiEmbeddingInputTexts` headers.
Before, it gave a single `float[]` and set the
+`CamelSpringAiEmbedding`, `CamelSpringAiEmbeddingIndex` and
`CamelSpringAiEmbeddingInputText` headers, as for a `String`
+body. A `String` body still gives a single `float[]`.
+
+Each element of a `List` body is now converted to a `String`, and a `null`
element fails with an
+`IllegalArgumentException`. Before, a `List` of other objects failed with a
`ClassCastException` in the embedding
+model. An empty `List` now gives an empty `List` without calling the embedding
model.
+
=== camel-spring-batch - the CamelSpringBatchJobName header needs
jobFromHeader=true
The producer now takes the job name from the `CamelSpringBatchJobName` header
only when the endpoint has