This is an automated email from the ASF dual-hosted git repository.
JiriOndrusek pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-quarkus.git
The following commit(s) were added to refs/heads/main by this push:
new 2d6e024d43 Fixes #9013. Harden the RAG augmentor bridge
2d6e024d43 is described below
commit 2d6e024d43edbae2f0bd7b65ed504bd1af869a97
Author: Jiří Ondrušek <[email protected]>
AuthorDate: Mon Aug 17 07:44:30 2026 +0000
Fixes #9013. Harden the RAG augmentor bridge
A designated default augmentor instead of an ambiguous lookup that
silently disabled RAG, plus a retrieval filter hook that fails closed.
Co-Authored-By: Claude Fable 5 <[email protected]>
---
.../extensions/langchain4j-embeddingstore.adoc | 73 +++++++
extensions-support/langchain4j/deployment/pom.xml | 5 +
.../SupportQuarkusLangchain4jProcessor.java | 224 +++++++++++++++++----
.../RagAugmentorDefaultResolutionTest.java | 88 ++++++++
.../DefaultRetrievalAugmentorSupplier.java | 39 +++-
.../langchain4j/QuarkusLangchain4jRecorder.java | 7 +-
.../support/langchain4j/RagAugmentorName.java | 42 ++++
.../support/langchain4j/RagBridgeConfig.java | 12 ++
.../langchain4j/RagRetrievalFilterSupplier.java | 65 ++++++
.../runtime/src/main/doc/usage.adoc | 71 +++++++
.../ragbridge/it/RagBridgeResource.java | 110 ++++++++++
.../ragbridge/it/TestTenantFilterSupplier.java | 70 +++++++
.../ragbridge/it/MultiAugmentorProfile.java | 7 +-
.../ragbridge/it/MultiAugmentorTest.java | 54 ++++-
...entorProfile.java => RagRetrievalFilterIT.java} | 13 +-
.../ragbridge/it/RagRetrievalFilterTest.java | 122 +++++++++++
.../ragbridge/it/SingleAugmentorTest.java | 6 +-
17 files changed, 947 insertions(+), 61 deletions(-)
diff --git
a/docs/modules/ROOT/pages/reference/extensions/langchain4j-embeddingstore.adoc
b/docs/modules/ROOT/pages/reference/extensions/langchain4j-embeddingstore.adoc
index 70ebefe845..b1e1e1b927 100644
---
a/docs/modules/ROOT/pages/reference/extensions/langchain4j-embeddingstore.adoc
+++
b/docs/modules/ROOT/pages/reference/extensions/langchain4j-embeddingstore.adoc
@@ -62,6 +62,79 @@ from("direct:ingest-products")
A store only referenced from routes does not need to be injected anywhere in
Java code; it is retained and instantiated lazily on first use. If the Camel
registry already resolves a different `EmbeddingStore` under the same name (for
example a `@Named` bean), that existing bean keeps winning lookups and a
warning is logged at startup.
+[id="extensions-langchain4j-embeddingstore-usage-retrieval-augmentors-for-registeraiservice"]
+=== Retrieval augmentors for `@RegisterAiService`
+
+With Quarkus LangChain4j present, a `RetrievalAugmentor` is produced
automatically from the `@Default` `EmbeddingStore` and `EmbeddingModel` beans,
so an `@RegisterAiService` interface answers from the same store a Camel route
ingests into. Nothing is produced when the application declares its own
`RetrievalAugmentor`.
+
+Augmentors can also be declared explicitly, one per store:
+
+[source,properties]
+----
+quarkus.camel.langchain4j.rag.augmentors.products.embedding-store-name=products
+quarkus.camel.langchain4j.rag.augmentors.products.default=true
+quarkus.camel.langchain4j.rag.augmentors.support.embedding-store-name=support-docs
+----
+
+Each entry produces a `@Named` `RetrievalAugmentor`. With more than one
configured, exactly one must be marked `default=true`: that one serves the
unqualified lookup `@RegisterAiService` performs, while the others remain
selectable by name. Configuring several without marking one fails the build,
because an ambiguous unqualified lookup disables RAG for every AI service
without reporting anything. A single entry needs no marking.
+
+Selecting one of the others means naming it through a supplier, since
`@RegisterAiService` either takes the unqualified augmentor or a `Supplier`
class:
+
+[source,java]
+----
+public class SupportAugmentorSupplier implements Supplier<RetrievalAugmentor> {
+ @Inject
+ @Named("support")
+ RetrievalAugmentor augmentor;
+
+ @Override
+ public RetrievalAugmentor get() {
+ return augmentor;
+ }
+}
+
+@RegisterAiService(retrievalAugmentor = SupportAugmentorSupplier.class)
+public interface SupportAssistant {
+ String chat(String question);
+}
+----
+
+[id="extensions-langchain4j-embeddingstore-usage-filtering-what-retrieval-may-see"]
+=== Filtering what retrieval may see
+
+Metadata stored next to a segment — a tenant key, a classification — isolates
nothing unless retrieval filters on it. A single `RagRetrievalFilterSupplier`
bean, if present, is consulted on every retrieval performed by a produced
augmentor:
+
+[source,java]
+----
+@ApplicationScoped
+public class TenantFilterSupplier implements RagRetrievalFilterSupplier {
+ @Inject
+ CurrentTenant currentTenant; // request scoped
+
+ @Override
+ public Filter filter(Query query, String augmentorName, String
embeddingStoreName) {
+ if (!"products".equals(embeddingStoreName)) {
+ return null; // only this store carries tenant metadata
+ }
+ String tenant = currentTenant.name();
+ if (tenant == null) {
+ // fail closed: returning null here would serve every tenant's
documents
+ throw new IllegalStateException("No tenant in scope");
+ }
+ return metadataKey("tenant").isEqualTo(tenant);
+ }
+}
+----
+
+The one bean serves every produced augmentor, so it is told which augmentor
retrieves and which store is being searched; both are `null` for the
auto-produced default augmentor, which is backed by the `@Default` beans.
Filtering on a metadata key a store does not carry matches nothing, so return
`null` for the stores an implementation does not know about.
+
+Returning `null` means unfiltered retrieval, as it does in LangChain4j itself,
so an implementation that cannot determine the caller must throw instead. The
tenant may equally come from `query.metadata().chatMemoryId()`, which is the
only source that still works when retrieval does not run on the caller's
thread. At most one implementation may exist, and it must not be `@Dependent`;
both are build failures.
+
+Two limits are worth knowing before treating this as an access control:
+
+* The filter reaches only the augmentors this extension produces. Easy RAG, or
an application-provided `RetrievalAugmentor`, replaces them, and the filter is
then never consulted — the build logs a warning when it detects that
combination.
+* `EmbeddingStore` implementations are not required to support filtering. One
that ignores the filter returns every match, so verify against the store
actually in use.
+
[id="extensions-langchain4j-embeddingstore-quarkus-langchain4j-bom"]
== LangChain4j usage
diff --git a/extensions-support/langchain4j/deployment/pom.xml
b/extensions-support/langchain4j/deployment/pom.xml
index d0e48ad94c..e5a8f024a1 100644
--- a/extensions-support/langchain4j/deployment/pom.xml
+++ b/extensions-support/langchain4j/deployment/pom.xml
@@ -47,6 +47,11 @@
<artifactId>camel-ai-tool</artifactId>
<optional>true</optional>
</dependency>
+ <dependency>
+ <groupId>io.quarkus</groupId>
+ <artifactId>quarkus-junit-internal</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git
a/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/SupportQuarkusLangchain4jProcessor.java
b/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/SupportQuarkusLangchain4jProcessor.java
index 9cfe643323..815265a93f 100644
---
a/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/SupportQuarkusLangchain4jProcessor.java
+++
b/extensions-support/langchain4j/deployment/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/SupportQuarkusLangchain4jProcessor.java
@@ -16,11 +16,13 @@
*/
package org.apache.camel.quarkus.component.support.langchain4j.deployment;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.TreeMap;
import java.util.stream.Collectors;
import dev.langchain4j.guardrail.Guardrail;
@@ -33,9 +35,11 @@ import io.quarkus.arc.deployment.AdditionalBeanBuildItem;
import io.quarkus.arc.deployment.BeanDiscoveryFinishedBuildItem;
import io.quarkus.arc.deployment.GeneratedBeanBuildItem;
import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor;
+import io.quarkus.arc.deployment.QualifierRegistrarBuildItem;
import io.quarkus.arc.deployment.SyntheticBeanBuildItem;
import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem;
import io.quarkus.arc.deployment.UnremovableBeanBuildItem;
+import
io.quarkus.arc.deployment.ValidationPhaseBuildItem.ValidationErrorBuildItem;
import io.quarkus.arc.processor.BeanInfo;
import io.quarkus.deployment.annotations.BuildProducer;
import io.quarkus.deployment.annotations.BuildStep;
@@ -51,15 +55,19 @@ import io.quarkus.gizmo.ClassCreator;
import io.quarkus.gizmo.MethodCreator;
import io.quarkus.gizmo.MethodDescriptor;
import io.quarkus.gizmo.ResultHandle;
+import io.quarkus.runtime.configuration.ConfigurationException;
import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.context.Dependent;
import jakarta.inject.Named;
import jakarta.inject.Singleton;
import
org.apache.camel.quarkus.component.support.langchain4j.AiToolSpecConverter;
import
org.apache.camel.quarkus.component.support.langchain4j.CamelAiToolProvider;
import
org.apache.camel.quarkus.component.support.langchain4j.CamelAiToolsInterceptor;
import
org.apache.camel.quarkus.component.support.langchain4j.QuarkusLangchain4jRecorder;
+import org.apache.camel.quarkus.component.support.langchain4j.RagAugmentorName;
import org.apache.camel.quarkus.component.support.langchain4j.RagBridgeConfig;
import
org.apache.camel.quarkus.component.support.langchain4j.RagBridgeConfig.AugmentorConfig;
+import
org.apache.camel.quarkus.component.support.langchain4j.RagRetrievalFilterSupplier;
import org.apache.camel.quarkus.core.deployment.spi.CamelRegistryBuildItem;
import org.apache.camel.quarkus.core.deployment.spi.CamelRuntimeTaskBuildItem;
import org.jboss.jandex.AnnotationInstance;
@@ -84,6 +92,9 @@ class SupportQuarkusLangchain4jProcessor {
.createSimple("org.apache.camel.quarkus.component.support.langchain4j.CamelAiTools");
private static final DotName EMBEDDING_STORE_NAME_DOTNAME = DotName
.createSimple("io.quarkiverse.langchain4j.EmbeddingStoreName");
+ private static final DotName RAG_RETRIEVAL_FILTER_SUPPLIER_DOTNAME =
DotName
+ .createSimple(RagRetrievalFilterSupplier.class.getName());
+ private static final DotName DEPENDENT_DOTNAME =
DotName.createSimple(Dependent.class.getName());
private static final Logger LOG =
Logger.getLogger(SupportQuarkusLangchain4jProcessor.class);
@@ -242,6 +253,55 @@ class SupportQuarkusLangchain4jProcessor {
.build());
}
+ // The retrieval filter supplier is only resolved programmatically at
augmentor creation
+ // time, so ArC would remove a user's implementation as unused.
+ @BuildStep
+ UnremovableBeanBuildItem retainRagRetrievalFilterSuppliers() {
+ return
UnremovableBeanBuildItem.beanTypes(RAG_RETRIEVAL_FILTER_SUPPLIER_DOTNAME);
+ }
+
+ /**
+ * The retrieval filter is an access control, so every way of disabling it
silently is a build
+ * failure or a warning: two implementations make the lookup ambiguous, a
{@code @Dependent}
+ * one is resolved once and never destroyed, and a filter no produced
augmentor will ever
+ * consult isolates nothing.
+ */
+ @BuildStep
+ void validateRagRetrievalFilterSupplier(BeanDiscoveryFinishedBuildItem
beanDiscovery,
+ RagBridgeConfig ragBridgeConfig,
+ BuildProducer<ValidationErrorBuildItem> validationErrors) {
+ BeanCensus census = BeanCensus.of(beanDiscovery);
+ if (census.filterSuppliers().isEmpty()) {
+ return;
+ }
+
+ if (census.filterSuppliers().size() > 1) {
+ validationErrors.produce(new ValidationErrorBuildItem(new
ConfigurationException(
+ "Found " + census.filterSuppliers().size() + "
RagRetrievalFilterSupplier beans ("
+ + census.filterSuppliers().stream().map(bean ->
bean.getBeanClass().toString())
+ .collect(Collectors.joining(", "))
+ + "). Exactly one is allowed: an ambiguous lookup
would leave every retrieval "
+ + "unfiltered instead of failing.")));
+ return;
+ }
+
+ BeanInfo supplier = census.filterSuppliers().get(0);
+ if (DEPENDENT_DOTNAME.equals(supplier.getScope().getDotName())) {
+ validationErrors.produce(new ValidationErrorBuildItem(new
ConfigurationException(
+ "RagRetrievalFilterSupplier bean " +
supplier.getBeanClass()
+ + " is @Dependent. Use @ApplicationScoped,
@RequestScoped or @Singleton: the supplier "
+ + "is resolved once per augmentor, so a @Dependent
instance would never be destroyed.")));
+ return;
+ }
+
+ if (!census.producesAugmentor(ragBridgeConfig, new
EasyRagPresent().getAsBoolean())) {
+ LOG.warnf("RagRetrievalFilterSupplier bean %s will never be
consulted: it filters only the "
+ + "RetrievalAugmentors produced by this extension, and
none is produced here (Easy RAG or an "
+ + "application-provided RetrievalAugmentor takes over).
Retrieval is NOT filtered.",
+ supplier.getBeanClass());
+ }
+ }
+
// A store declared only for use from a Camel route is never injected
anywhere in Java,
// so ArC would remove it as unused and registerNamedEmbeddingStores would
find nothing.
@BuildStep
@@ -296,6 +356,16 @@ class SupportQuarkusLangchain4jProcessor {
}
}
+ // RagAugmentorName carries @Qualifier, but it lives in the support
runtime artifact, which
+ // ships no Jandex index. Without this registration ArC does not know the
annotation at all
+ // and the synthetic bean below fails the build with
+ // "Annotation class not available: @RagAugmentorName".
+ @BuildStep
+ QualifierRegistrarBuildItem registerRagAugmentorNameQualifier() {
+ return new QualifierRegistrarBuildItem(
+ () ->
Map.of(DotName.createSimple(RagAugmentorName.class.getName()), Set.of()));
+ }
+
/**
* Produces {@link RetrievalAugmentor} CDI beans that bridge Camel
ingestion routes with
* {@code @RegisterAiService} RAG.
@@ -304,8 +374,9 @@ class SupportQuarkusLangchain4jProcessor {
* Two modes:
* <ul>
* <li><b>Explicit config</b> — each entry under {@code
quarkus.camel.langchain4j.rag.augmentors.<name>}
- * produces a {@code @Named("<name>")} RetrievalAugmentor backed by the
configured store.
- * If only one entry exists, it is also marked as {@code defaultBean()}
for auto-discovery.</li>
+ * produces a {@code @Named("<name>")} RetrievalAugmentor backed by the
configured store. The entry
+ * marked {@code default=true} also serves the unqualified lookup; with a
single entry that marking
+ * is optional, with several it is required.</li>
* <li><b>Auto-detection</b> — when no config entries exist, at least one
EmbeddingStore and one
* EmbeddingModel are present, and no RetrievalAugmentor exists yet, a
default one is produced
* backed by the {@code @Default} CDI bean.</li>
@@ -319,49 +390,43 @@ class SupportQuarkusLangchain4jProcessor {
QuarkusLangchain4jRecorder recorder,
BuildProducer<SyntheticBeanBuildItem> syntheticBeans) {
- DotName embeddingStoreDN =
DotName.createSimple(EmbeddingStore.class.getName());
- DotName embeddingModelDN =
DotName.createSimple(EmbeddingModel.class.getName());
- DotName retrievalAugmentorDN =
DotName.createSimple(RetrievalAugmentor.class.getName());
-
- int embeddingStoreCount = 0;
- int embeddingModelCount = 0;
- boolean hasRetrievalAugmentor = false;
-
- for (BeanInfo bean :
beanDiscovery.beanStream().collect(Collectors.toList())) {
- for (Type type : bean.getTypes()) {
- DotName typeName = type.name();
- if (typeName.equals(embeddingStoreDN)) {
- embeddingStoreCount++;
- } else if (typeName.equals(embeddingModelDN)) {
- embeddingModelCount++;
- } else if (typeName.equals(retrievalAugmentorDN)) {
- hasRetrievalAugmentor = true;
- }
- }
+ BeanCensus census = BeanCensus.of(beanDiscovery);
+
+ // Effective augmentors: one per explicit config entry. Sorted, so
that a message naming
+ // them reads the same on every build - SmallRye's map is not
declaration-ordered.
+ Map<String, AugmentorDefinition> effective = new TreeMap<>();
+ for (Map.Entry<String, AugmentorConfig> entry :
ragBridgeConfig.augmentors().entrySet()) {
+ AugmentorConfig cfg = entry.getValue();
+ effective.put(entry.getKey(), new AugmentorDefinition(
+ cfg.embeddingStoreName(),
cfg.embeddingModelName().orElse(null), cfg.defaultAugmentor()));
}
- Map<String, AugmentorConfig> augmentors = ragBridgeConfig.augmentors();
+ if (!effective.isEmpty()) {
+ String designatedDefault = resolveDesignatedDefault(effective,
census.retrievalAugmentor());
- if (!augmentors.isEmpty()) {
- for (Map.Entry<String, AugmentorConfig> entry :
augmentors.entrySet()) {
+ for (Map.Entry<String, AugmentorDefinition> entry :
effective.entrySet()) {
String name = entry.getKey();
- AugmentorConfig cfg = entry.getValue();
+ AugmentorDefinition def = entry.getValue();
- LOG.debugf("Registering named RetrievalAugmentor '%s' backed
by EmbeddingStore '%s'",
- name, cfg.embeddingStoreName());
+ LOG.debugf("Registering named RetrievalAugmentor '%s' backed
by EmbeddingStore '%s'%s",
+ name, def.embeddingStoreName(),
name.equals(designatedDefault) ? " (default)" : "");
SyntheticBeanBuildItem.ExtendedBeanConfigurator configurator =
SyntheticBeanBuildItem
.configure(RetrievalAugmentor.class)
.scope(ApplicationScoped.class)
.addQualifier().annotation(Named.class).addValue("value", name).done()
.setRuntimeInit()
-
.supplier(recorder.createDefaultRetrievalAugmentorSupplier(
- cfg.embeddingStoreName(),
cfg.embeddingModelName().orElse(null)));
+ .supplier(recorder.createRetrievalAugmentorSupplier(
+ def.embeddingStoreName(),
def.embeddingModelName(), name));
- // Single augmentor configured and no user/Easy-RAG augmentor
present:
- // mark as defaultBean() so @RegisterAiService discovers it
without a qualifier
- if (augmentors.size() == 1 && !hasRetrievalAugmentor) {
+ if (name.equals(designatedDefault)) {
+ // keeps @Named only: the implicit @Default makes it the
one candidate the
+ // unqualified Instance<RetrievalAugmentor> lookup of
Quarkus LangChain4j sees
configurator.defaultBean();
+ } else {
+ // a real qualifier suppresses the implicit @Default (CDI
rule), so this bean
+ // stays selectable by name without making the unqualified
lookup ambiguous
+
configurator.addQualifier().annotation(RagAugmentorName.class).addValue("value",
name).done();
}
syntheticBeans.produce(configurator.done());
@@ -369,11 +434,11 @@ class SupportQuarkusLangchain4jProcessor {
return;
}
- if (hasRetrievalAugmentor) {
+ if (census.retrievalAugmentor()) {
return;
}
- if (embeddingStoreCount >= 1 && embeddingModelCount >= 1) {
+ if (census.embeddingStores() >= 1 && census.embeddingModels() >= 1) {
LOG.debug("EmbeddingStore and EmbeddingModel CDI beans detected"
+ " - registering default RetrievalAugmentor backed by
@Default store");
syntheticBeans.produce(SyntheticBeanBuildItem
@@ -381,8 +446,97 @@ class SupportQuarkusLangchain4jProcessor {
.scope(ApplicationScoped.class)
.defaultBean()
.setRuntimeInit()
-
.supplier(recorder.createDefaultRetrievalAugmentorSupplier(null, null))
+ .supplier(recorder.createRetrievalAugmentorSupplier(null,
null, null))
.done());
}
}
+
+ /**
+ * Decides which augmentor is the unqualified default, or fails the build:
silence here would
+ * mean an ambiguous CDI lookup and RAG silently switched off for every AI
service.
+ */
+ static String resolveDesignatedDefault(Map<String, AugmentorDefinition>
effective,
+ boolean hasRetrievalAugmentor) {
+ List<String> marked = effective.entrySet().stream()
+ .filter(e -> e.getValue().markedDefault())
+ .map(Map.Entry::getKey)
+ .toList();
+
+ if (marked.size() > 1) {
+ throw new ConfigurationException(
+ "Multiple retrieval augmentors are marked default: " +
marked + ". Mark exactly one with "
+ +
"quarkus.camel.langchain4j.rag.augmentors.<name>.default=true");
+ }
+
+ if (hasRetrievalAugmentor) {
+ // a user-provided RetrievalAugmentor bean already serves the
unqualified lookup
+ if (marked.size() == 1) {
+ throw new ConfigurationException(
+ "Retrieval augmentor '" + marked.get(0) + "' is marked
default, but the application "
+ + "already provides a RetrievalAugmentor bean.
Remove the default marking — "
+ + "produced augmentors remain selectable by
name.");
+ }
+ return null;
+ }
+
+ if (marked.size() == 1) {
+ return marked.get(0);
+ }
+
+ if (effective.size() == 1) {
+ return effective.keySet().iterator().next();
+ }
+
+ throw new ConfigurationException(
+ effective.size() + " retrieval augmentors are configured (" +
String.join(", ", effective.keySet())
+ + ") but none is marked default. An unmarked ambiguity
would silently disable RAG for "
+ + "every AI service, so the build stops instead. Mark
exactly one with "
+ +
"quarkus.camel.langchain4j.rag.augmentors.<name>.default=true");
+ }
+
+ record AugmentorDefinition(String embeddingStoreName, String
embeddingModelName, boolean markedDefault) {
+ }
+
+ /** One pass over the discovered beans, answering everything the RAG
bridge decides on. */
+ record BeanCensus(int embeddingStores, int embeddingModels, boolean
retrievalAugmentor,
+ List<BeanInfo> filterSuppliers) {
+
+ static BeanCensus of(BeanDiscoveryFinishedBuildItem beanDiscovery) {
+ DotName embeddingStoreDN =
DotName.createSimple(EmbeddingStore.class.getName());
+ DotName embeddingModelDN =
DotName.createSimple(EmbeddingModel.class.getName());
+ DotName retrievalAugmentorDN =
DotName.createSimple(RetrievalAugmentor.class.getName());
+
+ int embeddingStores = 0;
+ int embeddingModels = 0;
+ boolean retrievalAugmentor = false;
+ List<BeanInfo> filterSuppliers = new ArrayList<>();
+
+ for (BeanInfo bean :
beanDiscovery.beanStream().collect(Collectors.toList())) {
+ for (Type type : bean.getTypes()) {
+ DotName typeName = type.name();
+ if (typeName.equals(embeddingStoreDN)) {
+ embeddingStores++;
+ } else if (typeName.equals(embeddingModelDN)) {
+ embeddingModels++;
+ } else if (typeName.equals(retrievalAugmentorDN)) {
+ retrievalAugmentor = true;
+ } else if
(typeName.equals(RAG_RETRIEVAL_FILTER_SUPPLIER_DOTNAME)) {
+ filterSuppliers.add(bean);
+ }
+ }
+ }
+ return new BeanCensus(embeddingStores, embeddingModels,
retrievalAugmentor, filterSuppliers);
+ }
+
+ /** Whether {@link #registerDefaultRetrievalAugmentor} will produce an
augmentor to filter. */
+ boolean producesAugmentor(RagBridgeConfig ragBridgeConfig, boolean
easyRagPresent) {
+ if (easyRagPresent) {
+ return false;
+ }
+ if (!ragBridgeConfig.augmentors().isEmpty()) {
+ return true;
+ }
+ return !retrievalAugmentor && embeddingStores >= 1 &&
embeddingModels >= 1;
+ }
+ }
}
diff --git
a/extensions-support/langchain4j/deployment/src/test/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/RagAugmentorDefaultResolutionTest.java
b/extensions-support/langchain4j/deployment/src/test/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/RagAugmentorDefaultResolutionTest.java
new file mode 100644
index 0000000000..fbd3d3afe4
--- /dev/null
+++
b/extensions-support/langchain4j/deployment/src/test/java/org/apache/camel/quarkus/component/support/langchain4j/deployment/RagAugmentorDefaultResolutionTest.java
@@ -0,0 +1,88 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.support.langchain4j.deployment;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import io.quarkus.runtime.configuration.ConfigurationException;
+import
org.apache.camel.quarkus.component.support.langchain4j.deployment.SupportQuarkusLangchain4jProcessor.AugmentorDefinition;
+import org.junit.jupiter.api.Test;
+
+import static
org.apache.camel.quarkus.component.support.langchain4j.deployment.SupportQuarkusLangchain4jProcessor.resolveDesignatedDefault;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The designated-default rules: two augmentors with no default marked must
fail the build —
+ * an unmarked ambiguity would make the unqualified {@code
Instance<RetrievalAugmentor>} lookup
+ * unresolvable and silently disable RAG for every AI service.
+ */
+class RagAugmentorDefaultResolutionTest {
+
+ @Test
+ void singleAugmentorIsImplicitlyDefault() {
+ assertEquals("products",
resolveDesignatedDefault(augmentors("products", false), false));
+ }
+
+ @Test
+ void multipleAugmentorsWithOneMarkedResolveToIt() {
+ Map<String, AugmentorDefinition> effective = augmentors("products",
false, "support", true);
+ assertEquals("support", resolveDesignatedDefault(effective, false));
+ }
+
+ @Test
+ void multipleAugmentorsWithNoneMarkedFailTheBuild() {
+ Map<String, AugmentorDefinition> effective = augmentors("products",
false, "support", false);
+ ConfigurationException e = assertThrows(ConfigurationException.class,
+ () -> resolveDesignatedDefault(effective, false));
+ assertTrue(e.getMessage().contains("none is marked default"),
e.getMessage());
+ assertTrue(e.getMessage().contains(".default=true"), e.getMessage());
+ }
+
+ @Test
+ void multipleAugmentorsMarkedDefaultFailTheBuild() {
+ Map<String, AugmentorDefinition> effective = augmentors("products",
true, "support", true);
+ ConfigurationException e = assertThrows(ConfigurationException.class,
+ () -> resolveDesignatedDefault(effective, false));
+ assertTrue(e.getMessage().contains("Multiple retrieval augmentors are
marked default"), e.getMessage());
+ }
+
+ @Test
+ void userProvidedAugmentorSuppressesAnyDefault() {
+ assertNull(resolveDesignatedDefault(augmentors("products", false,
"support", false), true));
+ }
+
+ @Test
+ void userProvidedAugmentorConflictsWithMarkedDefault() {
+ ConfigurationException e = assertThrows(ConfigurationException.class,
+ () -> resolveDesignatedDefault(augmentors("products", true),
true));
+ assertTrue(e.getMessage().contains("already provides a
RetrievalAugmentor"), e.getMessage());
+ }
+
+ private static Map<String, AugmentorDefinition> augmentors(Object...
nameAndDefaultPairs) {
+ Map<String, AugmentorDefinition> map = new LinkedHashMap<>();
+ for (int i = 0; i < nameAndDefaultPairs.length; i += 2) {
+ String name = (String) nameAndDefaultPairs[i];
+ boolean markedDefault = (Boolean) nameAndDefaultPairs[i + 1];
+ map.put(name, new AugmentorDefinition(name + "-store", null,
markedDefault));
+ }
+ return map;
+ }
+}
diff --git
a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/DefaultRetrievalAugmentorSupplier.java
b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/DefaultRetrievalAugmentorSupplier.java
index b60cd2d82b..99c2455371 100644
---
a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/DefaultRetrievalAugmentorSupplier.java
+++
b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/DefaultRetrievalAugmentorSupplier.java
@@ -16,6 +16,7 @@
*/
package org.apache.camel.quarkus.component.support.langchain4j;
+import java.util.List;
import java.util.function.Supplier;
import dev.langchain4j.data.segment.TextSegment;
@@ -26,6 +27,7 @@ import
dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever;
import dev.langchain4j.store.embedding.EmbeddingStore;
import io.quarkiverse.langchain4j.EmbeddingStoreName;
import io.quarkus.arc.Arc;
+import io.quarkus.arc.InstanceHandle;
import jakarta.enterprise.inject.literal.NamedLiteral;
import jakarta.enterprise.util.TypeLiteral;
import org.jboss.logging.Logger;
@@ -42,6 +44,7 @@ public class DefaultRetrievalAugmentorSupplier implements
Supplier<RetrievalAugm
private static final TypeLiteral<EmbeddingStore<TextSegment>>
EMBEDDING_STORE_TYPE = new TypeLiteral<>() {
};
+ private final String augmentorName;
private final String embeddingStoreName;
private final String embeddingModelName;
@@ -50,8 +53,14 @@ public class DefaultRetrievalAugmentorSupplier implements
Supplier<RetrievalAugm
}
public DefaultRetrievalAugmentorSupplier(String embeddingStoreName, String
embeddingModelName) {
+ this(embeddingStoreName, embeddingModelName, null);
+ }
+
+ public DefaultRetrievalAugmentorSupplier(String embeddingStoreName, String
embeddingModelName,
+ String augmentorName) {
this.embeddingStoreName = embeddingStoreName;
this.embeddingModelName = embeddingModelName;
+ this.augmentorName = augmentorName;
}
@Override
@@ -95,11 +104,33 @@ public class DefaultRetrievalAugmentorSupplier implements
Supplier<RetrievalAugm
+ " (store=%s, model=%s)", embeddingStoreName != null ?
embeddingStoreName : "@Default",
embeddingModelName != null ? embeddingModelName : "@Default");
+ EmbeddingStoreContentRetriever.EmbeddingStoreContentRetrieverBuilder
retriever = EmbeddingStoreContentRetriever
+ .builder()
+ .embeddingStore(store)
+ .embeddingModel(model);
+
+ // The retrieval-side isolation hook: segment metadata written at
ingestion time becomes
+ // an actual access control. Resolved with listAll rather than
instance(), which matches
+ // on @Default only and answers "unavailable" for an ambiguity -
either would drop the
+ // filter silently and serve every tenant's documents. The build
rejects both cases; this
+ // is the second line of defence.
+ List<InstanceHandle<RagRetrievalFilterSupplier>> filterSuppliers =
Arc.container()
+ .listAll(RagRetrievalFilterSupplier.class);
+ if (filterSuppliers.size() > 1) {
+ throw new IllegalStateException("Found " + filterSuppliers.size()
+ + " RagRetrievalFilterSupplier beans, expected at most
one: "
+ + filterSuppliers.stream().map(handle ->
handle.getBean().getBeanClass().getName()).toList());
+ }
+ if (!filterSuppliers.isEmpty()) {
+ RagRetrievalFilterSupplier filterSupplier =
filterSuppliers.get(0).get();
+ retriever.dynamicFilter(query -> filterSupplier.filter(query,
augmentorName, embeddingStoreName));
+ LOG.debugf("Retrieval filter %s active for augmentor '%s' over
store '%s'",
+ filterSupplier.getClass().getName(), augmentorName,
+ embeddingStoreName != null ? embeddingStoreName :
"@Default");
+ }
+
return DefaultRetrievalAugmentor.builder()
- .contentRetriever(EmbeddingStoreContentRetriever.builder()
- .embeddingStore(store)
- .embeddingModel(model)
- .build())
+ .contentRetriever(retriever.build())
.build();
}
}
diff --git
a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/QuarkusLangchain4jRecorder.java
b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/QuarkusLangchain4jRecorder.java
index 9c72c20d1a..7b80f57df8 100644
---
a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/QuarkusLangchain4jRecorder.java
+++
b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/QuarkusLangchain4jRecorder.java
@@ -96,8 +96,9 @@ public class QuarkusLangchain4jRecorder {
}
}
- public Supplier<RetrievalAugmentor>
createDefaultRetrievalAugmentorSupplier(
- String embeddingStoreName, String embeddingModelName) {
- return new DefaultRetrievalAugmentorSupplier(embeddingStoreName,
embeddingModelName);
+ /** @param augmentorName {@code null} for the auto-produced default
augmentor. */
+ public Supplier<RetrievalAugmentor> createRetrievalAugmentorSupplier(
+ String embeddingStoreName, String embeddingModelName, String
augmentorName) {
+ return new DefaultRetrievalAugmentorSupplier(embeddingStoreName,
embeddingModelName, augmentorName);
}
}
diff --git
a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/RagAugmentorName.java
b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/RagAugmentorName.java
new file mode 100644
index 0000000000..8d8ac05aef
--- /dev/null
+++
b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/RagAugmentorName.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.support.langchain4j;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import jakarta.inject.Qualifier;
+
+/**
+ * Qualifies a produced {@link dev.langchain4j.rag.RetrievalAugmentor} bean by
name.
+ *
+ * <p>
+ * Used only internally to mark RagAugmentors, which are not default. See
+ * SupportQuarkusLangchain4jProcessor.registerDefaultRetrievalAugmentor
+ * Named annotation cannot be used, as it makes beans default.
+ */
+@Qualifier
+@Documented
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.FIELD,
ElementType.PARAMETER })
+public @interface RagAugmentorName {
+
+ String value();
+}
diff --git
a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/RagBridgeConfig.java
b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/RagBridgeConfig.java
index 4744841a31..9f81a76545 100644
---
a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/RagBridgeConfig.java
+++
b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/RagBridgeConfig.java
@@ -22,6 +22,8 @@ import java.util.Optional;
import io.quarkus.runtime.annotations.ConfigPhase;
import io.quarkus.runtime.annotations.ConfigRoot;
import io.smallrye.config.ConfigMapping;
+import io.smallrye.config.WithDefault;
+import io.smallrye.config.WithName;
// BUILD_AND_RUN_TIME_FIXED: augmentor bean definitions are baked in at build
time and cannot change at runtime
@ConfigRoot(phase = ConfigPhase.BUILD_AND_RUN_TIME_FIXED)
@@ -54,5 +56,15 @@ public interface RagBridgeConfig {
* When not set, the default (unnamed) EmbeddingModel is used.
*/
Optional<String> embeddingModelName();
+
+ /**
+ * Marks this augmentor as the one {@code @RegisterAiService} AI
services use when they
+ * do not select an augmentor explicitly. Exactly one augmentor must
be marked when more
+ * than one is configured — otherwise the build fails, because an
unmarked ambiguity
+ * would silently disable RAG for every AI service in the application.
+ */
+ @WithName("default")
+ @WithDefault("false")
+ boolean defaultAugmentor();
}
}
diff --git
a/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/RagRetrievalFilterSupplier.java
b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/RagRetrievalFilterSupplier.java
new file mode 100644
index 0000000000..a8e403b034
--- /dev/null
+++
b/extensions-support/langchain4j/runtime/src/main/java/org/apache/camel/quarkus/component/support/langchain4j/RagRetrievalFilterSupplier.java
@@ -0,0 +1,65 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.support.langchain4j;
+
+import dev.langchain4j.rag.query.Query;
+import dev.langchain4j.store.embedding.filter.Filter;
+
+/**
+ * The retrieval-side isolation hook: implemented as a CDI bean, consulted on
<em>every</em>
+ * retrieval performed by a {@link dev.langchain4j.rag.RetrievalAugmentor}
produced by this
+ * extension. Segment metadata written at ingestion time (a tenant key, a
classification, an
+ * owner) isolates nothing on its own; this is what turns it into an access
control.
+ *
+ * <p>
+ * Typical implementation: derive the caller's tenant from the request and
return
+ * {@code metadataKey("tenant").isEqualTo(tenant)}.
+ *
+ * <p>
+ * At most one implementation may exist and it must not be {@code @Dependent}
— both are build
+ * failures. Declare it {@code @ApplicationScoped}, {@code @RequestScoped} or
{@code @Singleton};
+ * it is resolved once per augmentor, so per-request state belongs in an
injected request-scoped
+ * bean rather than in a field.
+ *
+ * <p>
+ * <strong>The filter is only as strong as the store.</strong> {@code
EmbeddingStore}
+ * implementations are not required to support filtering, and one that ignores
the filter returns
+ * every match. Verify against the store actually in use before relying on
this for isolation.
+ */
+public interface RagRetrievalFilterSupplier {
+
+ /**
+ * @param query the retrieval query, whose {@link
Query#metadata()} carries the
+ * {@code chatMemoryId} of the calling AI
service when there is one
+ * @param augmentorName the name of the augmentor performing the
retrieval, as configured
+ * under {@code
quarkus.camel.langchain4j.rag.augmentors.<name>};
+ * {@code null} for the auto-produced default
augmentor
+ * @param embeddingStoreName the store being searched, named by
+ * {@code
quarkus.camel.langchain4j.rag.augmentors.<name>.embedding-store-name};
+ * {@code null} when the augmentor is backed by
the {@code @Default}
+ * store. The same supplier serves every
produced augmentor, so this
+ * is what tells a filter written for one
store's metadata whether it
+ * applies at all — return {@code null} for the
stores it does not
+ * know, since filtering on an absent metadata
key matches nothing.
+ * @return the filter to apply, or {@code null} for
unfiltered retrieval, as
+ * in LangChain4j itself. {@code null}
therefore means "no
+ * restriction", never "deny": an
implementation that cannot
+ * determine the caller must throw, so that
retrieval fails instead
+ * of returning every tenant's documents.
+ */
+ Filter filter(Query query, String augmentorName, String
embeddingStoreName);
+}
diff --git
a/extensions/langchain4j-embeddingstore/runtime/src/main/doc/usage.adoc
b/extensions/langchain4j-embeddingstore/runtime/src/main/doc/usage.adoc
index 46609a802e..daf2cf98c1 100644
--- a/extensions/langchain4j-embeddingstore/runtime/src/main/doc/usage.adoc
+++ b/extensions/langchain4j-embeddingstore/runtime/src/main/doc/usage.adoc
@@ -12,3 +12,74 @@ from("direct:ingest-products")
----
A store only referenced from routes does not need to be injected anywhere in
Java code; it is retained and instantiated lazily on first use. If the Camel
registry already resolves a different `EmbeddingStore` under the same name (for
example a `@Named` bean), that existing bean keeps winning lookups and a
warning is logged at startup.
+
+=== Retrieval augmentors for `@RegisterAiService`
+
+With Quarkus LangChain4j present, a `RetrievalAugmentor` is produced
automatically from the `@Default` `EmbeddingStore` and `EmbeddingModel` beans,
so an `@RegisterAiService` interface answers from the same store a Camel route
ingests into. Nothing is produced when the application declares its own
`RetrievalAugmentor`.
+
+Augmentors can also be declared explicitly, one per store:
+
+[source,properties]
+----
+quarkus.camel.langchain4j.rag.augmentors.products.embedding-store-name=products
+quarkus.camel.langchain4j.rag.augmentors.products.default=true
+quarkus.camel.langchain4j.rag.augmentors.support.embedding-store-name=support-docs
+----
+
+Each entry produces a `@Named` `RetrievalAugmentor`. With more than one
configured, exactly one must be marked `default=true`: that one serves the
unqualified lookup `@RegisterAiService` performs, while the others remain
selectable by name. Configuring several without marking one fails the build,
because an ambiguous unqualified lookup disables RAG for every AI service
without reporting anything. A single entry needs no marking.
+
+Selecting one of the others means naming it through a supplier, since
`@RegisterAiService` either takes the unqualified augmentor or a `Supplier`
class:
+
+[source,java]
+----
+public class SupportAugmentorSupplier implements Supplier<RetrievalAugmentor> {
+ @Inject
+ @Named("support")
+ RetrievalAugmentor augmentor;
+
+ @Override
+ public RetrievalAugmentor get() {
+ return augmentor;
+ }
+}
+
+@RegisterAiService(retrievalAugmentor = SupportAugmentorSupplier.class)
+public interface SupportAssistant {
+ String chat(String question);
+}
+----
+
+=== Filtering what retrieval may see
+
+Metadata stored next to a segment — a tenant key, a classification — isolates
nothing unless retrieval filters on it. A single `RagRetrievalFilterSupplier`
bean, if present, is consulted on every retrieval performed by a produced
augmentor:
+
+[source,java]
+----
+@ApplicationScoped
+public class TenantFilterSupplier implements RagRetrievalFilterSupplier {
+ @Inject
+ CurrentTenant currentTenant; // request scoped
+
+ @Override
+ public Filter filter(Query query, String augmentorName, String
embeddingStoreName) {
+ if (!"products".equals(embeddingStoreName)) {
+ return null; // only this store carries tenant metadata
+ }
+ String tenant = currentTenant.name();
+ if (tenant == null) {
+ // fail closed: returning null here would serve every tenant's
documents
+ throw new IllegalStateException("No tenant in scope");
+ }
+ return metadataKey("tenant").isEqualTo(tenant);
+ }
+}
+----
+
+The one bean serves every produced augmentor, so it is told which augmentor
retrieves and which store is being searched; both are `null` for the
auto-produced default augmentor, which is backed by the `@Default` beans.
Filtering on a metadata key a store does not carry matches nothing, so return
`null` for the stores an implementation does not know about.
+
+Returning `null` means unfiltered retrieval, as it does in LangChain4j itself,
so an implementation that cannot determine the caller must throw instead. The
tenant may equally come from `query.metadata().chatMemoryId()`, which is the
only source that still works when retrieval does not run on the caller's
thread. At most one implementation may exist, and it must not be `@Dependent`;
both are build failures.
+
+Two limits are worth knowing before treating this as an access control:
+
+* The filter reaches only the augmentors this extension produces. Easy RAG, or
an application-provided `RetrievalAugmentor`, replaces them, and the filter is
then never consulted — the build logs a warning when it detects that
combination.
+* `EmbeddingStore` implementations are not required to support filtering. One
that ignores the filter returns every match, so verify against the store
actually in use.
diff --git
a/integration-tests/langchain4j-rag-bridge-ql4j/src/main/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/RagBridgeResource.java
b/integration-tests/langchain4j-rag-bridge-ql4j/src/main/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/RagBridgeResource.java
index b0ef892923..b653357fdd 100644
---
a/integration-tests/langchain4j-rag-bridge-ql4j/src/main/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/RagBridgeResource.java
+++
b/integration-tests/langchain4j-rag-bridge-ql4j/src/main/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/RagBridgeResource.java
@@ -16,17 +16,28 @@
*/
package org.apache.camel.quarkus.component.langchain4j.ragbridge.it;
+import java.util.List;
+import java.util.Map;
+
+import dev.langchain4j.data.document.Metadata;
+import dev.langchain4j.data.message.UserMessage;
+import dev.langchain4j.data.segment.TextSegment;
+import dev.langchain4j.model.embedding.EmbeddingModel;
+import dev.langchain4j.rag.AugmentationRequest;
+import dev.langchain4j.rag.AugmentationResult;
import dev.langchain4j.rag.RetrievalAugmentor;
import dev.langchain4j.store.embedding.EmbeddingStore;
import jakarta.enterprise.inject.Instance;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import org.apache.camel.CamelContext;
import org.apache.camel.ProducerTemplate;
@@ -41,6 +52,19 @@ public class RagBridgeResource {
@Named("products")
Instance<RetrievalAugmentor> namedAugmentorInstance;
+ // the augmentor that is NOT the designated default, so it carries
@RagAugmentorName on top
+ // of @Named - the qualifier that must keep it selectable by name
+ @Inject
+ @Named("support")
+ Instance<RetrievalAugmentor> supportAugmentorInstance;
+
+ @Inject
+ @Named("defaultStore")
+ EmbeddingStore<TextSegment> defaultStore;
+
+ @Inject
+ EmbeddingModel embeddingModel;
+
@Inject
ProducerTemplate producerTemplate;
@@ -97,4 +121,90 @@ public class RagBridgeResource {
public String ask(String question) {
return aiService.chat(question);
}
+
+ @GET
+ @Path("/support-augmentor-present")
+ @Produces(MediaType.TEXT_PLAIN)
+ public boolean isSupportAugmentorPresent() {
+ return supportAugmentorInstance.isResolvable();
+ }
+
+ // --- retrieval filter hook
-----------------------------------------------------------
+
+ /** Seeds the default store with a tenant-tagged segment, like the ingest
extension does. */
+ @POST
+ @Path("/seed/{tenant}")
+ @Consumes(MediaType.TEXT_PLAIN)
+ @Produces(MediaType.TEXT_PLAIN)
+ public String seed(@PathParam("tenant") String tenant, String text) {
+ TextSegment segment = TextSegment.from(text,
Metadata.from(Map.of("cq_tenant", tenant)));
+ defaultStore.add(embeddingModel.embed(segment).content(), segment);
+ return "seeded";
+ }
+
+ /** Lets a test start from a known store, since the default store is
shared with other tests. */
+ @DELETE
+ @Path("/seed")
+ @Produces(MediaType.TEXT_PLAIN)
+ public String clearSeeds() {
+ defaultStore.removeAll();
+ return "cleared";
+ }
+
+ @POST
+ @Path("/tenant-filter/{tenant}")
+ @Produces(MediaType.TEXT_PLAIN)
+ public String setTenantFilter(@PathParam("tenant") String tenant) {
+ TestTenantFilterSupplier.tenant = "none".equals(tenant) ? null :
tenant;
+ return "ok";
+ }
+
+ @GET
+ @Path("/filter/last-augmentor")
+ @Produces(MediaType.TEXT_PLAIN)
+ public String lastFilteredAugmentor() {
+ return String.valueOf(TestTenantFilterSupplier.lastAugmentorName);
+ }
+
+ @GET
+ @Path("/filter/last-store")
+ @Produces(MediaType.TEXT_PLAIN)
+ public String lastFilteredStore() {
+ return String.valueOf(TestTenantFilterSupplier.lastEmbeddingStoreName);
+ }
+
+ /**
+ * Runs a produced augmentor directly, returning the retrieved segment
texts. {@code memoryId}
+ * travels in the query metadata, the only tenant source that survives
retrieval running off
+ * the caller's thread.
+ */
+ @POST
+ @Path("/augment")
+ @Consumes(MediaType.TEXT_PLAIN)
+ @Produces(MediaType.APPLICATION_JSON)
+ public List<String> augment(@QueryParam("augmentor") String augmentor,
+ @QueryParam("memoryId") String memoryId, String question) {
+ UserMessage userMessage = UserMessage.from(question);
+ AugmentationResult result = augmentorFor(augmentor)
+ .augment(new AugmentationRequest(userMessage,
+ dev.langchain4j.rag.query.Metadata.from(userMessage,
memoryId, null)));
+ return result.contents().stream().map(content ->
content.textSegment().text()).toList();
+ }
+
+ private RetrievalAugmentor augmentorFor(String augmentor) {
+ return switch (augmentor == null ? "default" : augmentor) {
+ case "products" -> namedAugmentorInstance.get();
+ case "support" -> supportAugmentorInstance.get();
+ default -> retrievalAugmentorInstance.get();
+ };
+ }
+
+ @DELETE
+ @Path("/filter/last-call")
+ @Produces(MediaType.TEXT_PLAIN)
+ public String forgetLastFilterCall() {
+ TestTenantFilterSupplier.forgetLastCall();
+ return "forgotten";
+ }
+
}
diff --git
a/integration-tests/langchain4j-rag-bridge-ql4j/src/main/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/TestTenantFilterSupplier.java
b/integration-tests/langchain4j-rag-bridge-ql4j/src/main/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/TestTenantFilterSupplier.java
new file mode 100644
index 0000000000..8d737a3909
--- /dev/null
+++
b/integration-tests/langchain4j-rag-bridge-ql4j/src/main/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/TestTenantFilterSupplier.java
@@ -0,0 +1,70 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.langchain4j.ragbridge.it;
+
+import dev.langchain4j.rag.query.Query;
+import dev.langchain4j.store.embedding.filter.Filter;
+import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder;
+import jakarta.enterprise.context.ApplicationScoped;
+import
org.apache.camel.quarkus.component.support.langchain4j.RagRetrievalFilterSupplier;
+
+/**
+ * The retrieval-side isolation hook under test: every retrieval through a
produced augmentor is
+ * filtered to one tenant's documents. The tenant comes either from state set
out of band (via
+ * REST) or from the query's {@code chatMemoryId} — the second source is the
one that keeps
+ * working when retrieval runs off the caller's thread, so both are exercised.
+ *
+ * <p>
+ * The bean stays inert until a test switches it on: this is the only
implementation in the
+ * application, so a filter applied by default would silently strip the
context out of every
+ * other test's AI service call.
+ */
+@ApplicationScoped
+public class TestTenantFilterSupplier implements RagRetrievalFilterSupplier {
+
+ /** Set to this instead of a tenant to take the tenant from the query's
chat memory id. */
+ static final String FROM_QUERY = "from-query";
+ /** Tells "the hook ran and was handed null" apart from "the hook never
ran". */
+ static final String NOT_CALLED = "<not-called>";
+
+ static volatile String tenant;
+ static volatile String lastAugmentorName;
+ static volatile String lastEmbeddingStoreName;
+
+ static void forgetLastCall() {
+ lastAugmentorName = NOT_CALLED;
+ lastEmbeddingStoreName = NOT_CALLED;
+ }
+
+ @Override
+ public Filter filter(Query query, String augmentorName, String
embeddingStoreName) {
+ lastAugmentorName = augmentorName;
+ lastEmbeddingStoreName = embeddingStoreName;
+ if (tenant == null) {
+ return null;
+ }
+ String effectiveTenant = FROM_QUERY.equals(tenant) ?
chatMemoryId(query) : tenant;
+ return effectiveTenant == null
+ ? null
+ :
MetadataFilterBuilder.metadataKey("cq_tenant").isEqualTo(effectiveTenant);
+ }
+
+ private static String chatMemoryId(Query query) {
+ Object memoryId = query.metadata() == null ? null :
query.metadata().chatMemoryId();
+ return memoryId == null ? null : memoryId.toString();
+ }
+}
diff --git
a/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorProfile.java
b/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorProfile.java
index 112155722d..0f5cff5848 100644
---
a/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorProfile.java
+++
b/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorProfile.java
@@ -23,8 +23,13 @@ import io.quarkus.test.junit.QuarkusTestProfile;
public class MultiAugmentorProfile implements QuarkusTestProfile {
@Override
public Map<String, String> getConfigOverrides() {
+ // Two augmentors require exactly one designated default — without the
marking the build
+ // fails (see
SupportQuarkusLangchain4jProcessor.resolveDesignatedDefault and its test)
return Map.of(
"quarkus.camel.langchain4j.rag.augmentors.products.embedding-store-name",
"products",
-
"quarkus.camel.langchain4j.rag.augmentors.support.embedding-store-name",
"support");
+ "quarkus.camel.langchain4j.rag.augmentors.products.default",
"true",
+ // the same store on purpose: this augmentor is here to prove
that the non-default
+ // one stays selectable by name, so creating it must not fail
on a missing store
+
"quarkus.camel.langchain4j.rag.augmentors.support.embedding-store-name",
"products");
}
}
diff --git
a/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorTest.java
b/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorTest.java
index 60845f0cbe..1f044e848e 100644
---
a/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorTest.java
+++
b/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorTest.java
@@ -19,14 +19,16 @@ package
org.apache.camel.quarkus.component.langchain4j.ragbridge.it;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.junit.TestProfile;
import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
import static org.hamcrest.Matchers.is;
/**
- * Verifies that when multiple augmentors are configured, no default
(unqualified)
- * RetrievalAugmentor bean is produced. Each augmentor should only be
resolvable
- * via its {@code @Named} qualifier to prevent silent auto-selection.
+ * Verifies that with multiple augmentors configured and one marked {@code
default=true}, the
+ * designated one serves the unqualified lookup (RAG stays on) while the
others remain
+ * resolvable by name. Two augmentors with no default marked fail the build
instead of silently
+ * disabling RAG — covered by {@code RagAugmentorDefaultResolutionTest} in the
support module.
*
* <p>
* No {@code @QuarkusIntegrationTest} counterpart exists because this test uses
@@ -41,12 +43,12 @@ import static org.hamcrest.Matchers.is;
class MultiAugmentorTest {
@Test
- void noDefaultAugmentorWithMultipleConfigured() {
+ void designatedDefaultAugmentorResolvableWithMultipleConfigured() {
RestAssured.given()
.get("/rag-bridge/augmentor-present")
.then()
.statusCode(200)
- .body(is("false"));
+ .body(is("true"));
}
@Test
@@ -57,4 +59,46 @@ class MultiAugmentorTest {
.statusCode(200)
.body(is("true"));
}
+
+ /**
+ * The augmentor that is not the designated default: it carries {@code
@RagAugmentorName} to
+ * suppress the implicit {@code @Default}, and the whole design depends on
that qualifier
+ * leaving {@code @Named} lookup intact.
+ */
+ @Test
+ void nonDefaultAugmentorStillResolvableByName() {
+ RestAssured.given()
+ .get("/rag-bridge/support-augmentor-present")
+ .then()
+ .statusCode(200)
+ .body(is("true"));
+ }
+
+ /**
+ * The retrieval filter is one application-wide bean shared by every
augmentor, so it is told
+ * both which augmentor retrieves and which store is being searched.
+ */
+ @Test
+ void retrievalFilterReceivesTheAugmentorAndStoreNames() {
+
RestAssured.delete("/rag-bridge/filter/last-call").then().statusCode(200);
+
+ RestAssured.given()
+ .contentType(ContentType.TEXT)
+ .queryParam("augmentor", "support")
+ .body("anything")
+ .post("/rag-bridge/augment")
+ .then()
+ .statusCode(200);
+
+ RestAssured.get("/rag-bridge/filter/last-augmentor")
+ .then()
+ .statusCode(200)
+ .body(is("support"));
+
+ // the store this augmentor is configured with, not the augmentor's
own name
+ RestAssured.get("/rag-bridge/filter/last-store")
+ .then()
+ .statusCode(200)
+ .body(is("products"));
+ }
}
diff --git
a/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorProfile.java
b/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/RagRetrievalFilterIT.java
similarity index 66%
copy from
integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorProfile.java
copy to
integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/RagRetrievalFilterIT.java
index 112155722d..ddb7fe2688 100644
---
a/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/MultiAugmentorProfile.java
+++
b/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/RagRetrievalFilterIT.java
@@ -16,15 +16,8 @@
*/
package org.apache.camel.quarkus.component.langchain4j.ragbridge.it;
-import java.util.Map;
+import io.quarkus.test.junit.QuarkusIntegrationTest;
-import io.quarkus.test.junit.QuarkusTestProfile;
-
-public class MultiAugmentorProfile implements QuarkusTestProfile {
- @Override
- public Map<String, String> getConfigOverrides() {
- return Map.of(
-
"quarkus.camel.langchain4j.rag.augmentors.products.embedding-store-name",
"products",
-
"quarkus.camel.langchain4j.rag.augmentors.support.embedding-store-name",
"support");
- }
+@QuarkusIntegrationTest
+class RagRetrievalFilterIT extends RagRetrievalFilterTest {
}
diff --git
a/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/RagRetrievalFilterTest.java
b/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/RagRetrievalFilterTest.java
new file mode 100644
index 0000000000..60037e8945
--- /dev/null
+++
b/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/RagRetrievalFilterTest.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.quarkus.component.langchain4j.ragbridge.it;
+
+import java.util.List;
+
+import io.quarkus.test.junit.QuarkusTest;
+import io.restassured.RestAssured;
+import io.restassured.http.ContentType;
+import io.restassured.specification.RequestSpecification;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The retrieval-side isolation hook: a {@code RagRetrievalFilterSupplier}
bean filters every
+ * retrieval through the produced augmentor — tenant metadata written at
ingestion time becomes
+ * an actual access control.
+ *
+ * <p>
+ * Each test starts from an emptied default store. That store is shared with
the other tests in
+ * this profile, and the retriever returns its top 3 matches: without the
reset, whether an
+ * assertion holds would depend on how many documents earlier tests happened
to leave behind.
+ */
+@QuarkusTest
+class RagRetrievalFilterTest {
+
+ private static final String QUESTION = "What does the clause say about the
quota?";
+
+ @BeforeEach
+ void emptyStore() {
+ RestAssured.delete("/rag-bridge/seed").then().statusCode(200);
+ }
+
+ @Test
+ void tenantFilterIsolatesRetrieval() {
+ seedBothTenants();
+
+ // unfiltered: both tenants' documents are retrievable
+ List<String> unfiltered = augment(QUESTION, null);
+ assertTrue(unfiltered.stream().anyMatch(text ->
text.contains("ALPHA-ONLY")), "got: " + unfiltered);
+ assertTrue(unfiltered.stream().anyMatch(text ->
text.contains("BETA-ONLY")), "got: " + unfiltered);
+
+ // filtered to alpha: beta's documents must be invisible
+
RestAssured.post("/rag-bridge/tenant-filter/alpha").then().statusCode(200);
+ List<String> filtered = augment(QUESTION, null);
+ assertTrue(filtered.stream().anyMatch(text ->
text.contains("ALPHA-ONLY")), "got: " + filtered);
+ assertFalse(filtered.stream().anyMatch(text ->
text.contains("BETA-ONLY")),
+ "the tenant filter must isolate retrieval, got: " + filtered);
+ }
+
+ /**
+ * The tenant taken from the {@code Query} the augmentor passes in, rather
than from state the
+ * supplier reads elsewhere: this is what makes the hook usable when
retrieval does not run on
+ * the caller's thread.
+ */
+ @Test
+ void tenantIsDerivedFromTheQuery() {
+ seedBothTenants();
+
RestAssured.post("/rag-bridge/tenant-filter/from-query").then().statusCode(200);
+
+ List<String> filtered = augment(QUESTION, "beta");
+ assertTrue(filtered.stream().anyMatch(text ->
text.contains("BETA-ONLY")), "got: " + filtered);
+ assertFalse(filtered.stream().anyMatch(text ->
text.contains("ALPHA-ONLY")),
+ "the chat memory id must select the tenant, got: " + filtered);
+ }
+
+ /**
+ * The auto-produced default augmentor has neither a name nor a configured
store — it is backed
+ * by the {@code @Default} beans — and the SPI is told exactly that rather
than something made
+ * up.
+ */
+ @Test
+ void defaultRagHasEmptyStoreAndAugmentor() {
+
RestAssured.delete("/rag-bridge/filter/last-call").then().statusCode(200);
+
+ augment(QUESTION, null);
+
+ RestAssured.get("/rag-bridge/filter/last-augmentor")
+ .then().statusCode(200).body(is("null"));
+ RestAssured.get("/rag-bridge/filter/last-store")
+ .then().statusCode(200).body(is("null"));
+ }
+
+ static void seedBothTenants() {
+ seed("alpha", "The ALPHA-ONLY clause: tenants of type alpha may exceed
the quota.");
+ seed("beta", "The BETA-ONLY clause: tenants of type beta must not
exceed the quota.");
+ }
+
+ static void seed(String tenant, String text) {
+ RestAssured.given().contentType(ContentType.TEXT).body(text)
+ .post("/rag-bridge/seed/" + tenant)
+ .then().statusCode(200);
+ }
+
+ static List<String> augment(String question, String memoryId) {
+ RequestSpecification request =
RestAssured.given().contentType(ContentType.TEXT).body(question);
+ if (memoryId != null) {
+ request = request.queryParam("memoryId", memoryId);
+ }
+ return request.post("/rag-bridge/augment")
+ .then().statusCode(200)
+ .extract().jsonPath().getList("", String.class);
+ }
+}
diff --git
a/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/SingleAugmentorTest.java
b/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/SingleAugmentorTest.java
index 54000a4dd9..0a07c3a1ca 100644
---
a/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/SingleAugmentorTest.java
+++
b/integration-tests/langchain4j-rag-bridge-ql4j/src/test/java/org/apache/camel/quarkus/component/langchain4j/ragbridge/it/SingleAugmentorTest.java
@@ -27,9 +27,9 @@ import static org.hamcrest.Matchers.not;
/**
* Verifies explicit single-augmentor configuration: a single named augmentor
is
- * configured via {@code quarkus.camel.langchain4j.rag.augmentors.products}, so
- * it is also marked as {@code defaultBean()} for auto-discovery by
- * {@code @RegisterAiService}.
+ * configured via {@code quarkus.camel.langchain4j.rag.augmentors.products},
and with
+ * nothing to be ambiguous with it serves the unqualified lookup of
+ * {@code @RegisterAiService} without needing {@code default=true}.
*
* <p>
* No {@code @QuarkusIntegrationTest} counterpart exists because this test uses