This is an automated email from the ASF dual-hosted git repository. davsclaus pushed a commit to branch CAMEL-26204-embeddingstore-body in repository https://gitbox.apache.org/repos/asf/camel.git
commit 645db45c2cf914c81820abbeded3b5200339d8ae Author: Claus Ibsen <[email protected]> AuthorDate: Tue Nov 3 00:21:15 2026 +0100 CAMEL-26204: camel-langchain4j-embeddingstore - auto-embed a TextSegment 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. 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 | 15 ++- .../embeddingstore/langchain4j-embeddingstore.json | 2 +- .../docs/langchain4j-embeddingstore-component.adoc | 15 ++- .../LangChain4jEmbeddingStoreEndpoint.java | 10 +- .../LangChain4jEmbeddingStoreProducer.java | 115 ++++++++++++++++--- .../LangChain4jEmbeddingStoreAutoEmbedTest.java | 123 +++++++++++++++++++++ ...ngChain4jEmbeddingStoreBatchOperationsTest.java | 67 +++++++++++ .../ROOT/pages/camel-4x-upgrade-guide-4_24.adoc | 12 ++ 9 files changed, 333 insertions(+), 28 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..ab1f4f1a8078 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": [ "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: a TextSegment as is with its metadata, another body read as a String, so no body fails. ADD embeds a [...] "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..7655bfd3181d 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,15 @@ 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. +* Any other single body is read as a `String`, and stored as a `TextSegment` of that text. +* 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 +272,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 +307,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..ab1f4f1a8078 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": [ "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: a TextSegment as is with its metadata, another body read as a String, so no body fails. ADD embeds a [...] "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..7655bfd3181d 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,15 @@ 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. +* Any other single body is read as a `String`, and stored as a `TextSegment` of that text. +* 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 +272,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 +307,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..ee08455c318d 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 @@ -50,10 +50,12 @@ import org.apache.camel.support.DefaultEndpoint; 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: a TextSegment as" + + " is with its metadata, another body read as a String, so no body fails. 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..260cb98f0ab2 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,9 @@ */ package org.apache.camel.component.langchain4j.embeddingstore; +import java.io.InputStream; +import java.io.Reader; +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.concurrent.ExecutorService; @@ -32,6 +35,7 @@ import org.apache.camel.CamelContext; import org.apache.camel.Exchange; import org.apache.camel.Message; import org.apache.camel.NoSuchHeaderException; +import org.apache.camel.StreamCache; import org.apache.camel.component.langchain4j.embeddings.LangChain4jEmbeddingsHeaders; import org.apache.camel.support.DefaultProducer; @@ -140,6 +144,12 @@ 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, and a {@code Collection} of {@code String} or {@code TextSegment} is embedded and + * stored as a batch. + * </p> + * * @param exchange the Camel exchange containing the embedding data * @throws Exception if the add operation fails */ @@ -152,7 +162,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 +202,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 +211,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 +299,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); @@ -350,15 +407,28 @@ 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."); - } - if (textSegment == null) { - textSegment = TextSegment.from(text); + Object body = in.getBody(); + if (body instanceof TextSegment segment) { + if (textSegment == null) { + textSegment = segment; + } + } else { + if (textSegment == null && 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)); + } + 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."); + } + if (textSegment == null) { + textSegment = TextSegment.from(text); + } } embedding = config.getEmbeddingModel().embed(textSegment).content(); } @@ -373,6 +443,15 @@ public class LangChain4jEmbeddingStoreProducer extends DefaultProducer { return new EmbeddingResult(embedding, textSegment); } + 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..082ad11c00d2 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 @@ -18,10 +18,12 @@ package org.apache.camel.component.langchain4j.embeddingstore; import java.util.List; +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; @@ -67,6 +69,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 +163,122 @@ 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"); + } + + 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 10f5a47c1b89..03bd3a495d65 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 @@ -611,6 +611,18 @@ 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 or a List 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()`. + +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
