diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/AdditionalDevUiCardBuildItem.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/AdditionalDevUiCardBuildItem.java new file mode 100644 index 000000000..82ccd85ba --- /dev/null +++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/AdditionalDevUiCardBuildItem.java @@ -0,0 +1,41 @@ +package io.quarkiverse.langchain4j.deployment.devui; + +import java.util.Collections; +import java.util.Map; + +import io.quarkus.builder.item.MultiBuildItem; + +public final class AdditionalDevUiCardBuildItem extends MultiBuildItem { + + private final String title; + private final String icon; + private final String componentLink; + private final Map buildTimeData; + + public AdditionalDevUiCardBuildItem(String title, String icon, String componentLink) { + this(title, icon, componentLink, Collections.emptyMap()); + } + + public AdditionalDevUiCardBuildItem(String title, String icon, String componentLink, Map buildTimeData) { + this.title = title; + this.icon = icon; + this.componentLink = componentLink; + this.buildTimeData = buildTimeData; + } + + public String getTitle() { + return title; + } + + public String getIcon() { + return icon; + } + + public String getComponentLink() { + return componentLink; + } + + public Map getBuildTimeData() { + return buildTimeData; + } +} diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/LangChain4jDevUIProcessor.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/LangChain4jDevUIProcessor.java index 9356c857f..f2eb422a2 100644 --- a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/LangChain4jDevUIProcessor.java +++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/LangChain4jDevUIProcessor.java @@ -33,7 +33,8 @@ CardPageBuildItem cardPage(List aiServices, List inProcessEmbeddingModelBuildItems, List embeddingStoreBuildItem, List chatModelProviders, - Optional inMemoryEmbeddingStoreBuildItem) { + Optional inMemoryEmbeddingStoreBuildItem, + List additionalDevUiCardBuildItems) { CardPageBuildItem card = new CardPageBuildItem(); addAiServicesPage(card, aiServices); if (toolsMetadataBuildItem != null) { @@ -50,6 +51,15 @@ CardPageBuildItem cardPage(List aiServices, if (!chatModelProviders.isEmpty()) { addChatPage(card, aiServices); } + + for (AdditionalDevUiCardBuildItem additionalDevUiCardBuildItem : additionalDevUiCardBuildItems) { + card.addPage(Page.webComponentPageBuilder() + .title(additionalDevUiCardBuildItem.getTitle()) + .icon(additionalDevUiCardBuildItem.getIcon()) + .componentLink(additionalDevUiCardBuildItem.getComponentLink())); + + additionalDevUiCardBuildItem.getBuildTimeData().forEach((k, v) -> card.addBuildTimeData(k, v)); + } return card; } diff --git a/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/OpenWebUIDevUIProcessor.java b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/OpenWebUIDevUIProcessor.java new file mode 100644 index 000000000..53bb74c72 --- /dev/null +++ b/core/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/devui/OpenWebUIDevUIProcessor.java @@ -0,0 +1,18 @@ +package io.quarkiverse.langchain4j.deployment.devui; + +import io.quarkiverse.langchain4j.runtime.devui.OpenWebUIJsonRPCService; +import io.quarkus.deployment.IsDevelopment; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; +import io.quarkus.devui.spi.JsonRPCProvidersBuildItem; + +public final class OpenWebUIDevUIProcessor { + + @BuildStep(onlyIf = IsDevelopment.class) + void registerOpenWebUiCard(BuildProducer jsonRPCProviders, + CuratedApplicationShutdownBuildItem closeBuildItem) { + jsonRPCProviders.produce(new JsonRPCProvidersBuildItem(OpenWebUIJsonRPCService.class)); + closeBuildItem.addCloseTask(OpenWebUIJsonRPCService.CLOSE_TASK, true); + } +} diff --git a/core/deployment/src/main/resources/dev-ui/qwc-chat.js b/core/deployment/src/main/resources/dev-ui/qwc-chat.js index 9b9644910..9f212b5b2 100644 --- a/core/deployment/src/main/resources/dev-ui/qwc-chat.js +++ b/core/deployment/src/main/resources/dev-ui/qwc-chat.js @@ -10,6 +10,7 @@ import '@vaadin/progress-bar'; import '@vaadin/text-field'; import '@vaadin/icon'; import '@vaadin/icons'; +import 'qui-alert'; import { JsonRpc } from 'jsonrpc'; import { systemMessages } from 'build-time-data'; @@ -69,7 +70,10 @@ export class QwcChat extends LitElement { _systemMessages: {state: true}, _systemMessageDisabled: {state: true}, _ragEnabled: {state: true}, - _showToolRelatedMessages: {state: true} + _streamingChatSupported: {state: true}, + _streaminChatEnabled: {state: true}, + _showToolRelatedMessages: {state: true}, + _observer: {state:false}, } constructor() { @@ -83,8 +87,29 @@ export class QwcChat extends LitElement { this._unfilteredChatItems = []; this._chatItems = []; this.jsonRpc.reset({systemMessage: this._systemMessage}); + this._streamingChatSupported = this.jsonRpc.isStreamingChatSupported(); + this._streamingChatEnabled = this._streamingChatSupported && !this._ragEnabled; } + _connect() { + } + + _disconnect() { + if (this._observer) { + this._observer.unsubscribe(); + } + } + + connectedCallback() { + super.connectedCallback(); + this._connect(); + } + + disconnectedCallback() { + this._disconnect(); + super.disconnectedCallback(); + } + render() { this._filterChatItems(); return html` @@ -96,6 +121,15 @@ export class QwcChat extends LitElement {
+
${this._renderSystemPane()} @@ -119,7 +153,7 @@ export class QwcChat extends LitElement { `; } - + _checkForEnterOrTab(e){ if ((e.which == 13 || e.which == 0)){ this._cementSystemMessage(); @@ -159,13 +193,40 @@ export class QwcChat extends LitElement { this._cementSystemMessage(); this._addUserMessage(message); this._showProgressBar(); - - this.jsonRpc.chat({message: message, ragEnabled: this._ragEnabled}).then(jsonRpcResponse => { - this._showResponse(jsonRpcResponse); - }).catch((error) => { - this._showError(error); - this._hideProgressBar(); - }); + + if (this._streamingChatEnabled) { + var msg = ""; + var index = this._addBotMessage(msg); + try { + this._observer = this.jsonRpc.streamingChat({message: message, ragEnabled: this._ragEnabled}) + .onNext(jsonRpcResponse => { + if (jsonRpcResponse.result.error) { + this._showError(jsonRpcResponse.result.error); + this._hideProgressBar(); + } else if (jsonRpcResponse.result.message) { + this._updateMessage(index, jsonRpcResponse.result.message); + this._hideProgressBar(); + } else { + msg += jsonRpcResponse.result.token; + this._updateMessage(index, msg); + } + }) + .onError((error) => { + this._showError(error); + this._hideProgressBar(); + }); + } catch(error) { + this._showError(error); + this._hideProgressBar(); + } + } else { + this.jsonRpc.chat({message: message, ragEnabled: this._ragEnabled}).then(jsonRpcResponse => { + this._showResponse(jsonRpcResponse); + }).catch((error) => { + this._showError(error); + this._hideProgressBar(); + }); + } } } @@ -248,7 +309,12 @@ status = ${item.toolExecutionResult.text}`); } _addBotMessage(message){ - this._addMessage(message, "AI", 3); + return this._addMessage(message, "AI", 3); + } + + _updateMessage(index, message) { + this._unfilteredChatItems[index].text = message; + this._unfilteredChatItems = [...this._unfilteredChatItems]; } _addUserMessage(message){ @@ -262,7 +328,7 @@ status = ${item.toolExecutionResult.text}`); } _addMessage(message, user, colorIndex){ - this._addMessageItem(this._createNewItem(message, user, colorIndex)); + return this._addMessageItem(this._createNewItem(message, user, colorIndex)); } _createNewItem(message, user, colorIndex) { @@ -284,9 +350,11 @@ status = ${item.toolExecutionResult.text}`); } _addMessageItem(newItem) { + var newIndex = this._unfilteredChatItems.length; this._unfilteredChatItems = [ ...this._unfilteredChatItems, newItem]; + return newIndex; } _hideNewConversationButton(){ diff --git a/core/deployment/src/main/resources/dev-ui/qwc-open-webui.js b/core/deployment/src/main/resources/dev-ui/qwc-open-webui.js new file mode 100644 index 000000000..f36693be2 --- /dev/null +++ b/core/deployment/src/main/resources/dev-ui/qwc-open-webui.js @@ -0,0 +1,379 @@ +import { LitElement, html, css } from 'lit'; +import { JsonRpc } from 'jsonrpc'; +import '@vaadin/checkbox'; +import '@vaadin/progress-bar'; +import { envVarMappings } from 'build-time-data'; + +class OpenWebUI extends LitElement { + + jsonRpc = new JsonRpc(this); + + static styles = css` + :host { + margin: 20px; + display: flex; + flex-direction: column; + align-items: left; + justify-content: center; + font-family: Arial, sans-serif; + } + div { + background-color: #f9f9f9; + padding: 20px; + border-radius: 5px; + box-shadow: 0 0 10px rgba(0,0,0,0.1); + } + label { + font-weight: bold; + } + .large-input { + width: 500px; + } + button { + padding: 10px 20px; + border: none; + border-radius: 5px; + background-color: #007BFF; + color: white; + cursor: pointer; + } + button:hover { + background-color: #0056b3; + } + button:disabled { + background-color: #cccccc; + cursor: not-allowed; + } + + button:disabled:hover { + background-color: #cccccc; + } + h2 { + color: #333; + } + a { + color: #007BFF; + text-decoration: none; + } + a:hover { + text-decoration: underline; + } + .hide { + visibility: hidden; + } + .show { + visibility: visible; + } + `; + + static properties = { + container: {state: true}, + image: {state: true}, + requestGpu: {state: true}, + portBindings: {state: true}, + envVars: {state: true}, + envVarMappings: {state: true}, + volumes: {state: true}, + url: {state: true}, + signedUp: {state: true}, + autoSingup: {state: true}, + username: {state: true}, + email: {state: true}, + password: {state: true}, + _progressBarClass: {state: true}, + } + + constructor() { + super(); + this.image = "ghcr.io/open-webui/open-webui:main"; + this.requestGpu = false; + this.portBindings = {3000 : 8080}; + this.envVars = {}; + this.envVarMappings = envVarMappings; + this.volumes = {"open-webui": "/app/backend/data"}; + this.signedUp = false; + this.autoSignup = true; + this.username = "quarkus"; + this.email = "quarkus@quarkus.io"; + this.password = "quarkus"; + Object.entries(this.envVarMappings).forEach(([envVarKey, quarkusConfigPropertyKey]) => { + this.jsonRpc.getConfigValue({key: quarkusConfigPropertyKey}).then((resp) => { + const envVarValue = resp.result; + if (envVarValue) { + this.envVars[envVarKey] = envVarValue; + } + }); + }); + this._hideProgressBar(); + this._refresh(); + } + + + render() { + return html`
+

+ ${this._renderContainerInfo()} +

+ ${this._renderCreateOptions()} + +
+ + +
+
`; + } + + + _renderCreateOptions() { + return html` +
+
+ + +
+
+ + +
+
+ Auto Signup + + { + this.autoSignup = event.target.checked; + this.requestUpdate(); + }}"> + +

+ + +

+

+ + +

+

+ + +

+
+
+ Port Bindings + ${Object.entries(this.portBindings).map(([key, value], index) => html` +
+ + + +
+ `)} + +
+
+ Volumes + ${Object.entries(this.volumes).map(([key, value], index) => html` +
+ + + +
+ `)} + +
+
+ Environment Variables + ${Object.entries(this.envVars).map(([key, value], index) => html` +
+ + + +
+ `)} + +
+
+ `; + } + + _refresh() { + this.jsonRpc.inspectOpenWebUIContainer().then((resp) => { + this.container = resp.result; + this.requestUpdate(); + }); + + this.jsonRpc.getOpenWebUIUrl().then((resp) => { + this.url = resp.result; + this.requestUpdate(); + }); + + } + + _renderContainerInfo() { + if (this.container) { + return html` +
+

Open WebUI container

+

${this.container.id}

+

${this.container.name}

+

${this.container.config.image}

+

${this.container.config.cmd.join(" ")}

+

${this.url}

+
+ `; + } + return html``; + } + + + _startOpenWebUI() { + this._showProgressBar(); + this.jsonRpc.startOpenWebUI({image: this.image, requestGpu: this.requestGpu, portBindings: this.portBindings, envVars: this.envVars, volumes: this.volumes}).then(() => { + this._hideProgressBar(); + this._refresh(); + if (this.autoSignup) { + // Ensure that we do have a URL before trying to sign up + this.jsonRpc.getOpenWebUIUrl().then(() => { + this._openWebUISignup().then(() => { + this.signUp = true; + }); + }); + } + }).catch(() => { + this._hideProgressBar(); + }); + } + + _stopOpenWebUI() { + this._showProgressBar(); + this.jsonRpc.stopOpenWebUI().then(() => { + this._hideProgressBar(); + this._refresh(); + }).catch(() => { + this._hideProgressBar(); + }); + } + + _openWebUISignup(timeout=10000, interval=1000) { + const signupUrl = this.url + "/api/v1/auths/signup"; + const payload = { name: this.username, email: this.email, password: this.password }; + return new Promise((resolve, reject) => { + const startTime = Date.now(); + function attemptFetch() { + fetch(signupUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload) + }).then(response => { + if (response.ok) { + resolve('OpenWebUI ready'); + } else { + throw new Error('OpenWebUI not ready'); + } + }).catch(error => { + const currentTime = Date.now(); + if (currentTime - startTime >= timeout) { + reject('Timed out waiting for OpenWebUI to be ready'); + } else { + setTimeout(attemptFetch, interval); + } + }); + } + attemptFetch(); + }); + } + + // Port Bindings + _changePortBindingKey(index, newKey) { + const oldKey = Object.keys(this.portBindings)[index]; + const value = this.portBindings[oldKey]; + delete this.portBindings[oldKey]; + this.portBindings[newKey] = value; + this.requestUpdate(); + } + + _changePortBindingValue(index, newValue) { + const key = Object.keys(this.portBindings)[index]; + this.portBindings[key] = newValue; + this.requestUpdate(); + } + + _removePortBinding(key) { + delete this.portBindings[key]; + this.requestUpdate(); + } + + _addPortBinding() { + const max = Object.keys(this.portBindings).reduce((a, b) => Math.max(a, b), 0); + const next = max + 1; + this.portBindings[next] = ""; + this.requestUpdate(); + } + + + // Env Vars + _changeEnvVarKey(index, newKey) { + const oldKey = Object.keys(this.envVars)[index]; + const value = this.envVars[oldKey]; + delete this.envVars[oldKey]; + this.envVars[newKey] = value; + this.requestUpdate(); + } + + _changeEnvVarValue(index, newValue) { + const key = Object.keys(this.envVars)[index]; + this.envVars[key] = newValue; + this.requestUpdate(); + } + + _removeEnvVar(key) { + delete this.envVars[key]; + this.requestUpdate(); + } + + _addEnvVar() { + this.envVars[""] = ""; + this.requestUpdate(); + } + + // Volumes + _changeVolumeKey(index, newKey) { + const oldKey = Object.keys(this.volumes)[index]; + const value = this.volumes[oldKey]; + delete this.volumes[oldKey]; + this.volumes[newKey] = value; + this.requestUpdate(); + } + + _changeVolumeValue(index, newValue) { + const key = Object.keys(this.volumes)[index]; + this.volumes[key] = newValue; + this.requestUpdate(); + } + + _removeVolume(key) { + delete this.volumes[key]; + this.requestUpdate(); + } + + _addVolume() { + this.volumes[""] = ""; + this.requestUpdate(); + } + + _hideProgressBar(){ + this._progressBarClass = "hide"; + } + + _showProgressBar(){ + this._progressBarClass = "show"; + } +} +customElements.define('qwc-open-webui', OpenWebUI); + + + + + diff --git a/core/runtime/pom.xml b/core/runtime/pom.xml index 1bcbcfb95..d366e57ce 100644 --- a/core/runtime/pom.xml +++ b/core/runtime/pom.xml @@ -26,6 +26,13 @@ quarkus-vertx + + io.quarkus + quarkus-devservices-common + compile + ${quarkus.version} + + io.quarkiverse.langchain4j quarkus-langchain4j-core-runtime-spi diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/QuarkusJsonCodecFactory.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/QuarkusJsonCodecFactory.java index 263f237d4..2051251cb 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/QuarkusJsonCodecFactory.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/QuarkusJsonCodecFactory.java @@ -31,6 +31,9 @@ public Json.JsonCodec create() { private static class Codec implements Json.JsonCodec { + private static final String JSON_START_MARKER = "```json\n"; + private static final String JSON_END_MARKER = "\n```"; + @Override public String toJson(Object o) { try { @@ -43,7 +46,8 @@ public String toJson(Object o) { @Override public T fromJson(String json, Class type) { try { - return ObjectMapperHolder.MAPPER.readValue(json, type); + String sanitizedJson = sanitize(json, type); + return ObjectMapperHolder.MAPPER.readValue(sanitizedJson, type); } catch (JsonProcessingException e) { if ((e instanceof JsonParseException) && (type.isEnum())) { // this is the case where LangChain4j simply passes the string value of the enum to Json.fromJson() @@ -55,6 +59,16 @@ public T fromJson(String json, Class type) { } } + private String sanitize(String original, Class type) { + if (String.class.equals(type)) { + return original; + } + if (original.startsWith(JSON_START_MARKER) && original.endsWith(JSON_END_MARKER)) { + return original.substring(JSON_START_MARKER.length(), original.length() - JSON_END_MARKER.length()); + } + return original; + } + @Override public InputStream toInputStream(Object o, Class type) throws IOException { return new ByteArrayInputStream(ObjectMapperHolder.WRITER.writeValueAsBytes(o)); diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/devui/ChatJsonRPCService.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/devui/ChatJsonRPCService.java index fe9aeabe7..378ee93f1 100644 --- a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/devui/ChatJsonRPCService.java +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/devui/ChatJsonRPCService.java @@ -4,6 +4,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; @@ -21,7 +22,9 @@ import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.ChatMemoryProvider; +import dev.langchain4j.model.StreamingResponseHandler; import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.chat.StreamingChatLanguageModel; import dev.langchain4j.model.output.Response; import dev.langchain4j.model.output.TokenUsage; import dev.langchain4j.rag.RetrievalAugmentor; @@ -35,11 +38,14 @@ import io.quarkus.arc.All; import io.quarkus.arc.Arc; import io.quarkus.logging.Log; +import io.smallrye.mutiny.Multi; +import io.vertx.core.json.JsonObject; @ActivateRequestContext public class ChatJsonRPCService { private final ChatLanguageModel model; + private final Optional streamingModel; private final ChatMemoryProvider memoryProvider; @@ -52,11 +58,13 @@ public class ChatJsonRPCService { private final Map toolExecutors; public ChatJsonRPCService(@All List models, // don't use ChatLanguageModel model because it results in the default model not being configured + @All List streamingModels, @All List> retrievalAugmentorSuppliers, @All List retrievalAugmentors, ChatMemoryProvider memoryProvider, QuarkusToolExecutorFactory toolExecutorFactory) { this.model = models.get(0); + this.streamingModel = streamingModels.isEmpty() ? Optional.empty() : Optional.of(streamingModels.get(0)); this.retrievalAugmentor = null; for (Supplier supplier : retrievalAugmentorSuppliers) { this.retrievalAugmentor = supplier.get(); @@ -118,6 +126,66 @@ public String reset(String systemMessage) { return "OK"; } + public boolean isStreamingChatSupported() { + return streamingModel.isPresent(); + } + + public Multi streamingChat(String message, boolean ragEnabled) { + ChatMemory m = currentMemory.get(); + if (m == null) { + reset(""); + m = currentMemory.get(); + } + final ChatMemory memory = m; + + // create a backup of the chat memory, because we are now going to + // add a new message to it, and might have to remove it if the chat + // request fails - unfortunately the ChatMemory API doesn't allow + // removing single messages + List chatMemoryBackup = memory.messages(); + + return Multi.createFrom().emitter(em -> { + try { + if (retrievalAugmentor != null && ragEnabled) { + UserMessage userMessage = UserMessage.from(message); + Metadata metadata = Metadata.from(userMessage, currentMemoryId.get(), memory.messages()); + memory.add(retrievalAugmentor.augment(userMessage, metadata)); + } else { + + memory.add(new UserMessage(message)); + } + + StreamingChatLanguageModel streamingModel = this.streamingModel.orElseThrow(IllegalStateException::new); + + streamingModel.generate(memory.messages(), new StreamingResponseHandler() { + @Override + public void onComplete(Response response) { + memory.add(response.content()); + String message = response.content().text(); + em.emit(new JsonObject().put("message", message)); + em.complete(); + } + + @Override + public void onNext(String token) { + em.emit(new JsonObject().put("token", token)); + } + + @Override + public void onError(Throwable error) { + em.fail(error); + } + }); + } catch (Throwable t) { + // restore the memory from the backup + memory.clear(); + chatMemoryBackup.forEach(memory::add); + Log.warn(t); + em.fail(t); + } + }); + } + public ChatResultPojo chat(String message, boolean ragEnabled) { ChatMemory memory = currentMemory.get(); if (memory == null) { diff --git a/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/devui/OpenWebUIJsonRPCService.java b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/devui/OpenWebUIJsonRPCService.java new file mode 100644 index 000000000..7ae098d92 --- /dev/null +++ b/core/runtime/src/main/java/io/quarkiverse/langchain4j/runtime/devui/OpenWebUIJsonRPCService.java @@ -0,0 +1,148 @@ +package io.quarkiverse.langchain4j.runtime.devui; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import jakarta.enterprise.context.control.ActivateRequestContext; + +import org.eclipse.microprofile.config.ConfigProvider; +import org.testcontainers.DockerClientFactory; + +import com.github.dockerjava.api.command.CreateContainerResponse; +import com.github.dockerjava.api.command.InspectContainerResponse; +import com.github.dockerjava.api.exception.NotFoundException; +import com.github.dockerjava.api.model.Bind; +import com.github.dockerjava.api.model.Container; +import com.github.dockerjava.api.model.DeviceRequest; +import com.github.dockerjava.api.model.HostConfig; +import com.github.dockerjava.api.model.PortBinding; +import com.github.dockerjava.api.model.Volume; + +@ActivateRequestContext +public class OpenWebUIJsonRPCService { + + public static final Runnable CLOSE_TASK = () -> { + OpenWebUIJsonRPCService service = new OpenWebUIJsonRPCService(); + service.stopOpenWebUI(); + }; + + private static final String CONTAINER_NAME_PREFIX = "quarkus-open-webui-"; + + public boolean isOpenWebUIRunning() { + var container = inspectOpenWebUIContainer(); + return container != null && container.getState().getRunning(); + } + + public String getOpenWebUIUrl() { + var container = inspectOpenWebUIContainer(); + if (container != null) { + return container.getNetworkSettings().getPorts().getBindings().values().stream().flatMap(Arrays::stream) + .map(p -> "http://localhost" + ":" + p.getHostPortSpec()) + .findFirst() + .orElse(null); + } + return null; + } + + public InspectContainerResponse inspectOpenWebUIContainer() { + return DockerClientFactory.lazyClient().listContainersCmd().exec() + .stream() + .filter(OpenWebUIJsonRPCService::isOpenWebUIContainer) + .findFirst() + .map(c -> c.getId()) + .map(id -> DockerClientFactory.lazyClient().inspectContainerCmd(id).exec()) + .orElse(null); + } + + public CreateContainerResponse startOpenWebUI(String image, + boolean requestGpu, + Map portBindings, + Map envVars, + Map volumes) { + if (!isOpenWebUIRunning()) { + + List allPortBindings = new ArrayList<>(); + List allEnvVars = new ArrayList<>(); + List allBinds = new ArrayList<>(); + List allVolumes = new ArrayList<>(); + List allDeviceRequests = new ArrayList<>(); + + try { + var images = DockerClientFactory.lazyClient().listImagesCmd().exec(); + if (images.stream().filter(i -> i.getRepoTags() != null) + .noneMatch(i -> Arrays.asList(i.getRepoTags()).contains(image))) { + DockerClientFactory.lazyClient().pullImageCmd(image).start().awaitCompletion(); + } + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + for (var e : portBindings.entrySet()) { + allPortBindings.add(PortBinding.parse(e.getKey() + ":" + e.getValue())); + } + + for (var e : envVars.entrySet()) { + allEnvVars.add(e.getKey() + "=" + e.getValue()); + } + + for (var e : volumes.entrySet()) { + String path = e.getKey(); + Volume volume = new Volume(e.getValue()); + allBinds.add(new Bind(path, volume)); + allVolumes.add(volume); + try { + DockerClientFactory.lazyClient().inspectVolumeCmd(path).exec(); + } catch (NotFoundException nfe) { + DockerClientFactory.lazyClient().createVolumeCmd().withName(path).exec(); + } + } + + if (requestGpu) { + DeviceRequest gpu = new DeviceRequest().withCount(-1).withCapabilities(List.of(List.of("gpu"))); + allDeviceRequests.add(gpu); + } + + HostConfig hostConfig = new HostConfig() + .withBinds(allBinds) + .withPortBindings(allPortBindings) + .withExtraHosts("host.docker.internal:host-gateway") + .withDeviceRequests(allDeviceRequests); + + CreateContainerResponse container = DockerClientFactory.lazyClient() + .createContainerCmd(image) + .withEnv(allEnvVars) + .withVolumes(allVolumes) + .withName(CONTAINER_NAME_PREFIX + System.currentTimeMillis()) + .withHostConfig(hostConfig) + .exec(); + + DockerClientFactory.lazyClient().startContainerCmd(container.getId()).exec(); + return container; + } + return null; + } + + public boolean stopOpenWebUI() { + InspectContainerResponse container = inspectOpenWebUIContainer(); + if (container != null) { + DockerClientFactory.lazyClient().stopContainerCmd(container.getId()).exec(); + DockerClientFactory.lazyClient().removeContainerCmd(container.getId()).exec(); + return true; + } + return false; + } + + public String getConfigValue(String key) { + try { + return ConfigProvider.getConfig().getValue(key, String.class); + } catch (Exception e) { + return null; + } + } + + private static boolean isOpenWebUIContainer(Container c) { + return Arrays.stream(c.getNames()).anyMatch(n -> n.startsWith("/" + CONTAINER_NAME_PREFIX)); + } +} diff --git a/docs/modules/ROOT/assets/images/easy-rag-reuse-embeddings.png b/docs/modules/ROOT/assets/images/easy-rag-reuse-embeddings.png new file mode 100644 index 000000000..87dd411be Binary files /dev/null and b/docs/modules/ROOT/assets/images/easy-rag-reuse-embeddings.png differ diff --git a/docs/modules/ROOT/pages/easy-rag.adoc b/docs/modules/ROOT/pages/easy-rag.adoc index c8efeb1f7..a1c0e3492 100644 --- a/docs/modules/ROOT/pages/easy-rag.adoc +++ b/docs/modules/ROOT/pages/easy-rag.adoc @@ -41,6 +41,26 @@ NOTE: If you add two or more artifacts that provide embedding models, Quarkus will ask you to choose one of them using the `quarkus.langchain4j.embedding-model.provider` property. +== Reusing embeddings + +You might find that when doing local development that computing embeddings takes some time. This pain can often be felt in dev mode when it may take several minutes between restarts. + +That's where reusable ingestions come in! If you don't configure a persistent embedding store and set + +[source,properties,subs=attributes+] +---- +quarkus.langchain4j.easy-rag.reuse-embeddings.enabled=true +---- + +then the `easy-rag` extension will detect the local embeddings. If they exist then they are loaded into the embedding store. If they don't exist, they are computed as normal and then written out to the file `easy-rag-embeddings.json` in the current directory. + +NOTE: You can customize the embeddings file by setting the `quarkus.langchain4j.easy-rag.reuse-embeddings.file` property. + +See this diagram which describes the flow: + +.Reusable ingestion flow +image::easy-rag-reuse-embeddings.png[align="center"] + == Getting started with a ready-to-use example To see Easy RAG in action, use the project `samples/chatbot-easy-rag` in the @@ -83,4 +103,10 @@ meaning all files recursively. For finer-grained control of the Apache Tika parsers (for example, to turn off OCR capabilities), you can use a regular XML config file recognized by Tika (see https://tika.apache.org/2.9.2/configuring.html[Tika -documentation]), and specify `-Dtika.config` to point at the file. \ No newline at end of file +documentation]), and specify `-Dtika.config` to point at the file. + +== Configuration + +Several configuration properties are available: + +include::includes/quarkus-langchain4j-easy-rag.adoc[leveloffset=+1,opts=optional] \ No newline at end of file diff --git a/docs/modules/ROOT/pages/includes/quarkus-langchain4j-easy-rag.adoc b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-easy-rag.adoc new file mode 100644 index 000000000..b8f38dce7 --- /dev/null +++ b/docs/modules/ROOT/pages/includes/quarkus-langchain4j-easy-rag.adoc @@ -0,0 +1,168 @@ + +:summaryTableId: quarkus-langchain4j-easy-rag +[.configuration-legend] +icon:lock[title=Fixed at build time] Configuration property fixed at build time - All other configuration properties are overridable at runtime +[.configuration-reference.searchable, cols="80,.^10,.^10"] +|=== + +h|[[quarkus-langchain4j-easy-rag_configuration]]link:#quarkus-langchain4j-easy-rag_configuration[Configuration property] + +h|Type +h|Default + +a| [[quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-path]]`link:#quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-path[quarkus.langchain4j.easy-rag.path]` + + +[.description] +-- +Path to the directory containing the documents to be ingested. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_EASY_RAG_PATH+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_EASY_RAG_PATH+++` +endif::add-copy-button-to-env-var[] +--|string +|required icon:exclamation-circle[title=Configuration property is required] + + +a| [[quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-path-matcher]]`link:#quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-path-matcher[quarkus.langchain4j.easy-rag.path-matcher]` + + +[.description] +-- +Matcher used for filtering which files from the directory should be ingested. This uses the `java.nio.file.FileSystem` path matcher syntax. Example: `glob:++**++.txt` to recursively match all files with the `.txt` extension. The default is `glob:++**++`, recursively matching all files. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_EASY_RAG_PATH_MATCHER+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_EASY_RAG_PATH_MATCHER+++` +endif::add-copy-button-to-env-var[] +--|string +|`glob:**` + + +a| [[quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-recursive]]`link:#quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-recursive[quarkus.langchain4j.easy-rag.recursive]` + + +[.description] +-- +Whether to recursively ingest documents from subdirectories. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_EASY_RAG_RECURSIVE+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_EASY_RAG_RECURSIVE+++` +endif::add-copy-button-to-env-var[] +--|boolean +|`true` + + +a| [[quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-max-segment-size]]`link:#quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-max-segment-size[quarkus.langchain4j.easy-rag.max-segment-size]` + + +[.description] +-- +Maximum segment size when splitting documents, in tokens. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_EASY_RAG_MAX_SEGMENT_SIZE+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_EASY_RAG_MAX_SEGMENT_SIZE+++` +endif::add-copy-button-to-env-var[] +--|int +|`300` + + +a| [[quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-max-overlap-size]]`link:#quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-max-overlap-size[quarkus.langchain4j.easy-rag.max-overlap-size]` + + +[.description] +-- +Maximum overlap (in tokens) when splitting documents. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_EASY_RAG_MAX_OVERLAP_SIZE+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_EASY_RAG_MAX_OVERLAP_SIZE+++` +endif::add-copy-button-to-env-var[] +--|int +|`30` + + +a| [[quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-max-results]]`link:#quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-max-results[quarkus.langchain4j.easy-rag.max-results]` + + +[.description] +-- +Maximum number of results to return when querying the retrieval augmentor. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_EASY_RAG_MAX_RESULTS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_EASY_RAG_MAX_RESULTS+++` +endif::add-copy-button-to-env-var[] +--|int +|`5` + + +a| [[quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-ingestion-strategy]]`link:#quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-ingestion-strategy[quarkus.langchain4j.easy-rag.ingestion-strategy]` + + +[.description] +-- +The strategy to decide whether document ingestion into the store should happen at startup or not. The default is ON. Changing to OFF generally only makes sense if running against a persistent embedding store that was already populated. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_EASY_RAG_INGESTION_STRATEGY+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_EASY_RAG_INGESTION_STRATEGY+++` +endif::add-copy-button-to-env-var[] +-- a| +`on`, `off` +|`on` + + +a| [[quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-reuse-embeddings-enabled]]`link:#quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-reuse-embeddings-enabled[quarkus.langchain4j.easy-rag.reuse-embeddings.enabled]` + + +[.description] +-- +Whether or not to reuse embeddings. Defaults to `false`. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_EASY_RAG_REUSE_EMBEDDINGS_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_EASY_RAG_REUSE_EMBEDDINGS_ENABLED+++` +endif::add-copy-button-to-env-var[] +--|boolean +|`false` + + +a| [[quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-reuse-embeddings-file]]`link:#quarkus-langchain4j-easy-rag_quarkus-langchain4j-easy-rag-reuse-embeddings-file[quarkus.langchain4j.easy-rag.reuse-embeddings.file]` + + +[.description] +-- +The file path to load/save embeddings, assuming `quarkus.langchain4j.easy-rag.reuse-embeddings.enabled == true`. + +Defaults to `easy-rag-embeddings.json` in the current directory. + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_LANGCHAIN4J_EASY_RAG_REUSE_EMBEDDINGS_FILE+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_LANGCHAIN4J_EASY_RAG_REUSE_EMBEDDINGS_FILE+++` +endif::add-copy-button-to-env-var[] +--|string +|`easy-rag-embeddings.json` + +|=== \ No newline at end of file diff --git a/docs/pom.xml b/docs/pom.xml index 01ba330bd..650332afa 100644 --- a/docs/pom.xml +++ b/docs/pom.xml @@ -76,6 +76,11 @@ quarkus-langchain4j-watsonx ${project.version} + + io.quarkiverse.langchain4j + quarkus-langchain4j-easy-rag + ${project.version} + io.quarkiverse.antora quarkus-antora @@ -100,6 +105,19 @@ quarkus-langchain4j-anthropic-deployment ${project.version} + + io.quarkiverse.langchain4j + quarkus-langchain4j-easy-rag-deployment + ${project.version} + pom + test + + + * + * + + + io.quarkiverse.langchain4j quarkus-langchain4j-openai-deployment @@ -310,6 +328,7 @@ ${project.basedir}/../target/asciidoc/generated/config/ quarkus-langchain4j.adoc quarkus-langchain4j-anthropic.adoc + quarkus-langchain4j-easy-rag.adoc quarkus-langchain4j-openai.adoc quarkus-langchain4j-huggingface.adoc quarkus-langchain4j-ollama.adoc diff --git a/docs/src/main/resources/application.properties b/docs/src/main/resources/application.properties index b6bea363d..b3ebca027 100644 --- a/docs/src/main/resources/application.properties +++ b/docs/src/main/resources/application.properties @@ -10,6 +10,8 @@ quarkus.langchain4j.pinecone.index-name=abc quarkus.langchain4j.pinecone.project-id=abc quarkus.langchain4j.pinecone.api-key=abc quarkus.langchain4j.redis.dimension=180 +quarkus.langchain4j.easy-rag.path=abc +quarkus.langchain4j.easy-rag.ingestion-strategy=off quarkus.redis.hosts=redis://localhost:6379 diff --git a/embedding-stores/pgvector/deployment/pom.xml b/embedding-stores/pgvector/deployment/pom.xml index 8a70a304b..31d3f22f6 100644 --- a/embedding-stores/pgvector/deployment/pom.xml +++ b/embedding-stores/pgvector/deployment/pom.xml @@ -54,7 +54,7 @@ io.quarkus - quarkus-junit5 + quarkus-junit5-internal test diff --git a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/ColumnsTest.java b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/ColumnsTest.java index 51e25ed6a..9900ba044 100644 --- a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/ColumnsTest.java +++ b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/ColumnsTest.java @@ -1,27 +1,28 @@ package io.quarkiverse.langchain4j.pgvector.test; -import java.util.Map; - +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.QuarkusTestProfile; -import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.QuarkusUnitTest; -@QuarkusTest -@TestProfile(ColumnsTest.TestProfile.class) class ColumnsTest extends LangChain4jPgVectorBaseTest { - public static class TestProfile implements QuarkusTestProfile { - @Override - public Map getConfigOverrides() { - return Map.of( - "quarkus.langchain4j.pgvector.metadata.storage-mode", "COLUMN_PER_KEY", - "quarkus.langchain4j.pgvector.metadata.column-definitions", "key text NULL, name text NULL, " + - "age float NULL, city varchar null, country varchar null", - "quarkus.langchain4j.pgvector.metadata.indexes", "key, name, age"); - } - } + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset( + "quarkus.langchain4j.pgvector.dimension=384\n" + + "quarkus.langchain4j.pgvector.drop-table-first=true\n" + + "quarkus.class-loading.parent-first-artifacts=ai.djl.huggingface:tokenizers\n" + + "quarkus.log.category.\"io.quarkiverse.langchain4j.pgvector\".level=DEBUG\n\n" + + "quarkus.langchain4j.pgvector.metadata.storage-mode=COLUMN_PER_KEY\n" + + "quarkus.langchain4j.pgvector.metadata.column-definitions=key text NULL, name text NULL, " + + "age float NULL, city varchar null, country varchar null\n" + + "quarkus.langchain4j.pgvector.metadata.indexes=key, name, age"), + "application.properties")); @Test // do not test parent method to avoid defining all the metadata fields diff --git a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONBMultiIndexTest.java b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONBMultiIndexTest.java index 4b47e631f..edd82cba6 100644 --- a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONBMultiIndexTest.java +++ b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONBMultiIndexTest.java @@ -1,26 +1,27 @@ package io.quarkiverse.langchain4j.pgvector.test; -import java.util.Map; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.QuarkusTestProfile; -import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.QuarkusUnitTest; -@QuarkusTest -@TestProfile(JSONBMultiIndexTest.TestProfile.class) public class JSONBMultiIndexTest extends LangChain4jPgVectorBaseTest { - public static class TestProfile implements QuarkusTestProfile { - - @Override - public Map getConfigOverrides() { - return Map.of( - "quarkus.langchain4j.pgvector.metadata.storage-mode", "COMBINED_JSONB", - "quarkus.langchain4j.pgvector.metadata.column-definitions", "metadata_b JSONB NULL", - "quarkus.langchain4j.pgvector.metadata.indexes", - "(metadata_b->'key'), (metadata_b->'name'), (metadata_b->'age')", - "quarkus.langchain4j.pgvector.metadata.index-type", "GIN"); - } - } + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset( + "quarkus.langchain4j.pgvector.dimension=384\n" + + "quarkus.langchain4j.pgvector.drop-table-first=true\n" + + "quarkus.class-loading.parent-first-artifacts=ai.djl.huggingface:tokenizers\n" + + "quarkus.log.category.\"io.quarkiverse.langchain4j.pgvector\".level=DEBUG\n\n" + + "quarkus.langchain4j.pgvector.metadata.storage-mode=COMBINED_JSONB\n" + + "quarkus.langchain4j.pgvector.metadata.column-definitions=metadata_b JSONB NULL\n" + + "quarkus.langchain4j.pgvector.metadata.indexes=(metadata_b->'key'), (metadata_b->'name'), (metadata_b->'age')\n" + + + "quarkus.langchain4j.pgvector.metadata.index-type=GIN"), + "application.properties")); } diff --git a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONBTest.java b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONBTest.java index 620366786..944a0e93e 100644 --- a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONBTest.java +++ b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONBTest.java @@ -1,24 +1,25 @@ package io.quarkiverse.langchain4j.pgvector.test; -import java.util.Map; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.extension.RegisterExtension; -import io.quarkus.test.junit.QuarkusTest; -import io.quarkus.test.junit.QuarkusTestProfile; -import io.quarkus.test.junit.TestProfile; +import io.quarkus.test.QuarkusUnitTest; -@QuarkusTest -@TestProfile(JSONBTest.TestProfile.class) public class JSONBTest extends LangChain4jPgVectorBaseTest { - public static class TestProfile implements QuarkusTestProfile { - - @Override - public Map getConfigOverrides() { - return Map.of( - "quarkus.langchain4j.pgvector.metadata.storage-mode", "COMBINED_JSONB", - "quarkus.langchain4j.pgvector.metadata.column-definitions", "metadata JSONB NULL", - "quarkus.langchain4j.pgvector.metadata.indexes", "metadata"); - } - } + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset( + "quarkus.langchain4j.pgvector.dimension=384\n" + + "quarkus.langchain4j.pgvector.drop-table-first=true\n" + + "quarkus.class-loading.parent-first-artifacts=ai.djl.huggingface:tokenizers\n" + + "quarkus.log.category.\"io.quarkiverse.langchain4j.pgvector\".level=DEBUG\n\n" + + "quarkus.langchain4j.pgvector.metadata.storage-mode=COMBINED_JSONB\n" + + "quarkus.langchain4j.pgvector.metadata.column-definitions=metadata JSONB NULL\n" + + "quarkus.langchain4j.pgvector.metadata.indexes=metadata"), + "application.properties")); } diff --git a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONTest.java b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONTest.java index 771bc69db..05570a120 100644 --- a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONTest.java +++ b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/JSONTest.java @@ -1,8 +1,23 @@ package io.quarkiverse.langchain4j.pgvector.test; -import io.quarkus.test.junit.QuarkusTest; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; -@QuarkusTest public class JSONTest extends LangChain4jPgVectorBaseTest { + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset( + "quarkus.langchain4j.pgvector.dimension=384\n" + + "quarkus.langchain4j.pgvector.drop-table-first=true\n" + + "quarkus.class-loading.parent-first-artifacts=ai.djl.huggingface:tokenizers\n" + + "quarkus.log.category.\"io.quarkiverse.langchain4j.pgvector\".level=DEBUG\n\n"), + "application.properties")); + // Default behavior } diff --git a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/LangChain4jPgVectorBaseTest.java b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/LangChain4jPgVectorBaseTest.java index 68ce18467..271c742fa 100644 --- a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/LangChain4jPgVectorBaseTest.java +++ b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/LangChain4jPgVectorBaseTest.java @@ -18,11 +18,15 @@ import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.store.embedding.EmbeddingSearchRequest; import dev.langchain4j.store.embedding.EmbeddingStore; -import dev.langchain4j.store.embedding.EmbeddingStoreWithFilteringIT; +import dev.langchain4j.store.embedding.EmbeddingStoreIT; import dev.langchain4j.store.embedding.filter.MetadataFilterBuilder; import io.quarkus.logging.Log; -abstract class LangChain4jPgVectorBaseTest extends EmbeddingStoreWithFilteringIT { +// FIXME: this should extend EmbeddingStoreWithFilteringIT, but that class +// contains tests parametrized through @MethodSource, which is not supported +// by the quarkus-junit5-internal testing framework +abstract class LangChain4jPgVectorBaseTest extends EmbeddingStoreIT { + private static final EmbeddingModel embeddingModel = new AllMiniLmL6V2QuantizedEmbeddingModel(); @Inject protected EmbeddingStore pgvectorEmbeddingStore; diff --git a/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/PgVectorDataSourceTest.java b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/PgVectorDataSourceTest.java new file mode 100644 index 000000000..3d32de58c --- /dev/null +++ b/embedding-stores/pgvector/deployment/src/test/java/io/quarkiverse/langchain4j/pgvector/test/PgVectorDataSourceTest.java @@ -0,0 +1,60 @@ +package io.quarkiverse.langchain4j.pgvector.test; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; + +import javax.sql.DataSource; + +import jakarta.inject.Inject; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.store.embedding.EmbeddingStore; +import io.quarkus.test.QuarkusUnitTest; + +/** + * Verify use of a non-default postgresql datasource + */ +public class PgVectorDataSourceTest { + + @RegisterExtension + static final QuarkusUnitTest test = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset( + // DevServicesConfigBuilderCustomizer overrides the image-name only + // for the default DS, so in this case we have to override it manually + "quarkus.datasource.embeddings-ds.devservices.image-name=pgvector/pgvector:pg16\n" + + "quarkus.langchain4j.pgvector.datasource=embeddings-ds\n" + + "quarkus.langchain4j.pgvector.dimension=1536\n" + + "quarkus.datasource.embeddings-ds.db-kind=postgresql\n"), + "application.properties")); + + @io.quarkus.agroal.DataSource("embeddings-ds") + DataSource ds; + + @Inject + EmbeddingStore embeddingStore; + + @Test + public void verifyThatEmbeddingsTableIsCreated() throws SQLException { + // make sure the store is initialized... + embeddingStore.toString(); + try (Connection connection = ds.getConnection()) { + try (Statement statement = connection.createStatement()) { + try (ResultSet rs = statement.executeQuery( + "SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'embeddings')")) { + rs.next(); + Assertions.assertTrue(rs.getBoolean(1)); + } + } + } + } +} diff --git a/embedding-stores/pgvector/deployment/src/test/resources/application.properties b/embedding-stores/pgvector/deployment/src/test/resources/application.properties deleted file mode 100644 index 3980c6bc6..000000000 --- a/embedding-stores/pgvector/deployment/src/test/resources/application.properties +++ /dev/null @@ -1,10 +0,0 @@ -quarkus.langchain4j.pgvector.dimension=384 -quarkus.langchain4j.pgvector.drop-table-first=true - -# This is the recommendation from djl to avoid loading the embedding model in this test class' static initializer, -# because otherwise we hit java.lang.UnsatisfiedLinkError: -# Native Library (/path/to/the/library) already loaded in another classloader -# because the test class is loaded by JUnit and by Quarkus in different class loaders. -quarkus.class-loading.parent-first-artifacts=ai.djl.huggingface:tokenizers - -%test.quarkus.log.category."io.quarkiverse.langchain4j.pgvector".level=DEBUG diff --git a/embedding-stores/pgvector/runtime/src/main/java/io/quarkiverse/langchain4j/pgvector/runtime/PgVectorEmbeddingStoreRecorder.java b/embedding-stores/pgvector/runtime/src/main/java/io/quarkiverse/langchain4j/pgvector/runtime/PgVectorEmbeddingStoreRecorder.java index 4b411a0bd..5e828c755 100644 --- a/embedding-stores/pgvector/runtime/src/main/java/io/quarkiverse/langchain4j/pgvector/runtime/PgVectorEmbeddingStoreRecorder.java +++ b/embedding-stores/pgvector/runtime/src/main/java/io/quarkiverse/langchain4j/pgvector/runtime/PgVectorEmbeddingStoreRecorder.java @@ -1,7 +1,6 @@ package io.quarkiverse.langchain4j.pgvector.runtime; import java.util.List; -import java.util.Optional; import java.util.function.Function; import jakarta.enterprise.inject.Default; @@ -19,10 +18,13 @@ public class PgVectorEmbeddingStoreRecorder { public Function, PgVectorEmbeddingStore> embeddingStoreFunction( PgVectorEmbeddingStoreConfig config, String datasourceName) { return context -> { - AgroalDataSource dataSource = Optional.ofNullable(datasourceName) - .map(DataSourceLiteral::new) - .map(dl -> context.getInjectedReference(AgroalDataSource.class, dl)) - .orElse(context.getInjectedReference(AgroalDataSource.class, new Default.Literal())); + AgroalDataSource dataSource = null; + if (datasourceName != null) { + dataSource = context.getInjectedReference(AgroalDataSource.class, + new DataSourceLiteral(datasourceName)); + } else { + dataSource = context.getInjectedReference(AgroalDataSource.class, new Default.Literal()); + } dataSource.flush(AgroalDataSource.FlushMode.GRACEFUL); dataSource.setPoolInterceptors(List.of(new PgVectorAgroalPoolInterceptor())); diff --git a/model-providers/ollama/deployment/src/main/java/io/quarkiverse/langchain4j/ollama/deployment/devui/OllamaDevUiProcessor.java b/model-providers/ollama/deployment/src/main/java/io/quarkiverse/langchain4j/ollama/deployment/devui/OllamaDevUiProcessor.java new file mode 100644 index 000000000..5468fb783 --- /dev/null +++ b/model-providers/ollama/deployment/src/main/java/io/quarkiverse/langchain4j/ollama/deployment/devui/OllamaDevUiProcessor.java @@ -0,0 +1,18 @@ +package io.quarkiverse.langchain4j.ollama.deployment.devui; + +import java.util.Map; + +import io.quarkiverse.langchain4j.deployment.devui.AdditionalDevUiCardBuildItem; +import io.quarkus.deployment.IsDevelopment; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; + +public final class OllamaDevUiProcessor { + + @BuildStep(onlyIf = IsDevelopment.class) + void registerOpenWebUiCard(BuildProducer producer) { + producer.produce(new AdditionalDevUiCardBuildItem("Open WebUI", "font-awesome-solid:globe", "qwc-open-webui.js", + Map.of("envVarMappings", + Map.of("OLLAMA_BASE_URL", "quarkus.langchain4j.ollama.base-url")))); + } +} diff --git a/model-providers/openai/openai-common/deployment/src/main/java/io/quarkiverse/langchain4j/openai/deployment/devui/OpenAiDevUIProcessor.java b/model-providers/openai/openai-common/deployment/src/main/java/io/quarkiverse/langchain4j/openai/deployment/devui/OpenAiDevUIProcessor.java new file mode 100644 index 000000000..9abb1defe --- /dev/null +++ b/model-providers/openai/openai-common/deployment/src/main/java/io/quarkiverse/langchain4j/openai/deployment/devui/OpenAiDevUIProcessor.java @@ -0,0 +1,18 @@ +package io.quarkiverse.langchain4j.openai.deployment.devui; + +import java.util.Map; + +import io.quarkiverse.langchain4j.deployment.devui.AdditionalDevUiCardBuildItem; +import io.quarkus.deployment.IsDevelopment; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; + +public final class OpenAiDevUIProcessor { + + @BuildStep(onlyIf = IsDevelopment.class) + void registerOpenWebUiCard(BuildProducer producer) { + producer.produce(new AdditionalDevUiCardBuildItem("Open WebUI", "font-awesome-solid:globe", "qwc-open-webui.js", + Map.of("envVarMappings", + Map.of("OPENAI_API_KEY", "quarkus.langchain4j.openai.api-key")))); + } +} diff --git a/model-providers/openai/openai-vanilla/deployment/src/test/java/org/acme/examples/aiservices/AiServicesTest.java b/model-providers/openai/openai-vanilla/deployment/src/test/java/org/acme/examples/aiservices/AiServicesTest.java index 614753458..844f90fe2 100644 --- a/model-providers/openai/openai-vanilla/deployment/src/test/java/org/acme/examples/aiservices/AiServicesTest.java +++ b/model-providers/openai/openai-vanilla/deployment/src/test/java/org/acme/examples/aiservices/AiServicesTest.java @@ -295,7 +295,7 @@ interface Chef { void test_create_recipe_from_list_of_ingredients() throws IOException { setChatCompletionMessageContent( // this is supposed to be a string inside a json string hence all the escaping... - "{\\n\\\"title\\\": \\\"Greek Salad\\\",\\n\\\"description\\\": \\\"A refreshing and tangy salad with Mediterranean flavors.\\\",\\n\\\"steps\\\": [\\n\\\"Chop, dice, and slice.\\\",\\n\\\"Mix veggies with feta.\\\",\\n\\\"Drizzle with olive oil.\\\",\\n\\\"Toss gently, then serve.\\\"\\n],\\n\\\"preparationTimeMinutes\\\": 15\\n}"); + "```json\\n{\\n\\\"title\\\": \\\"Greek Salad\\\",\\n\\\"description\\\": \\\"A refreshing and tangy salad with Mediterranean flavors.\\\",\\n\\\"steps\\\": [\\n\\\"Chop, dice, and slice.\\\",\\n\\\"Mix veggies with feta.\\\",\\n\\\"Drizzle with olive oil.\\\",\\n\\\"Toss gently, then serve.\\\"\\n],\\n\\\"preparationTimeMinutes\\\": 15\\n}\\n```"); Chef chef = AiServices.create(Chef.class, createChatModel()); Recipe result = chef.createRecipeFrom("cucumber", "tomato", "feta", "onion", "olives"); diff --git a/rag/easy-rag/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/EasyRagProcessor.java b/rag/easy-rag/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/EasyRagProcessor.java index 7509337f5..c55454383 100644 --- a/rag/easy-rag/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/EasyRagProcessor.java +++ b/rag/easy-rag/deployment/src/main/java/io/quarkiverse/langchain4j/deployment/EasyRagProcessor.java @@ -55,6 +55,7 @@ public void createInMemoryEmbeddingStoreIfNoOtherExists( BuildProducer beanProducer, List embeddingStores, EasyRagRecorder recorder, + EasyRagConfig config, BuildProducer inMemoryEmbeddingStoreBuildItemBuildProducer) { if (embeddingStores.isEmpty()) { beanProducer.produce(SyntheticBeanBuildItem @@ -68,7 +69,7 @@ public void createInMemoryEmbeddingStoreIfNoOtherExists( .defaultBean() .unremovable() .scope(ApplicationScoped.class) - .supplier(recorder.inMemoryEmbeddingStoreSupplier()) + .supplier(recorder.inMemoryEmbeddingStoreSupplier(config)) .done()); inMemoryEmbeddingStoreBuildItemBuildProducer.produce(new InMemoryEmbeddingStoreBuildItem()); } diff --git a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagNotRecursiveTest.java b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagNotRecursiveTest.java index caea1be69..f1c1da278 100644 --- a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagNotRecursiveTest.java +++ b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagNotRecursiveTest.java @@ -1,9 +1,11 @@ package io.quarkiverse.langchain4j.test; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import java.util.logging.LogRecord; import jakarta.inject.Inject; @@ -29,13 +31,24 @@ public class EasyRagNotRecursiveTest { .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) .addAsResource(new StringAsset("quarkus.langchain4j.easy-rag.path=src/test/resources/ragdocuments\n" + "quarkus.langchain4j.easy-rag.recursive=false\n"), - "application.properties")); + "application.properties")) + .setLogRecordPredicate(record -> true) + .assertLogRecords(EasyRagNotRecursiveTest::verifyLogRecords); @Inject InMemoryEmbeddingStore embeddingStore; Embedding DUMMY_EMBEDDING = new Embedding(new float[384]); + private static void verifyLogRecords(List logRecords) { + assertThat(logRecords.stream().map(LogRecord::getMessage)) + .contains( + "Ingesting documents from path: src/test/resources/ragdocuments, path matcher = glob:**, recursive = false") + .contains("Ingested 1 files as 1 documents") + .doesNotContain("Writing embeddings to %s") + .doesNotContain("Reading embeddings from %s"); + } + @Test public void verifyOnlyTheRootDirectoryIsIngested() { List> relevant = embeddingStore.findRelevant(DUMMY_EMBEDDING, 3); diff --git a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagPathMatcherTest.java b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagPathMatcherTest.java index 74d2cf323..aa790cafe 100644 --- a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagPathMatcherTest.java +++ b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagPathMatcherTest.java @@ -1,8 +1,10 @@ package io.quarkiverse.langchain4j.test; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import java.util.List; +import java.util.logging.LogRecord; import jakarta.inject.Inject; @@ -28,13 +30,24 @@ public class EasyRagPathMatcherTest { .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) .addAsResource(new StringAsset("quarkus.langchain4j.easy-rag.path=src/test/resources/ragdocuments\n" + "quarkus.langchain4j.easy-rag.path-matcher=glob:*.pdf\n"), - "application.properties")); + "application.properties")) + .setLogRecordPredicate(record -> true) + .assertLogRecords(EasyRagPathMatcherTest::verifyLogRecords); @Inject InMemoryEmbeddingStore embeddingStore; Embedding DUMMY_EMBEDDING = new Embedding(new float[384]); + private static void verifyLogRecords(List logRecords) { + assertThat(logRecords.stream().map(LogRecord::getMessage)) + .contains( + "Ingesting documents from path: src/test/resources/ragdocuments, path matcher = glob:*.pdf, recursive = true") + .contains("Ingested 1 files as 1 documents") + .doesNotContain("Writing embeddings to %s") + .doesNotContain("Reading embeddings from %s"); + } + @Test public void verifyPathMatchingOnlyPdf() { List> relevant = embeddingStore.findRelevant(DUMMY_EMBEDDING, 3); diff --git a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsAlreadyExistTest.java b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsAlreadyExistTest.java new file mode 100644 index 000000000..d91a5b26b --- /dev/null +++ b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsAlreadyExistTest.java @@ -0,0 +1,66 @@ +package io.quarkiverse.langchain4j.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.List; +import java.util.logging.LogRecord; + +import jakarta.enterprise.inject.spi.CDI; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.store.embedding.EmbeddingMatch; +import dev.langchain4j.store.embedding.EmbeddingStore; +import io.quarkus.test.QuarkusUnitTest; + +class EasyRagReuseEmbeddingsAlreadyExistTest { + private static final String EMBEDDING_FILE_NAME = "embeddings.json"; + private static final Path EMBEDDINGS_DIR = Path.of("src", "test", "resources", "embeddings"); + + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset(""" + quarkus.langchain4j.easy-rag.path=src/test/resources/ragdocuments + quarkus.langchain4j.easy-rag.reuse-embeddings.enabled=true + quarkus.langchain4j.easy-rag.reuse-embeddings.file=%s + """.formatted(embeddingsFile())), + "application.properties")) + .setLogRecordPredicate(record -> true) + .assertLogRecords(EasyRagReuseEmbeddingsAlreadyExistTest::verifyLogRecords); + + private static Path embeddingsFile() { + return EMBEDDINGS_DIR.resolve(EMBEDDING_FILE_NAME).toAbsolutePath(); + } + + private static void verifyLogRecords(List logRecords) { + assertThat(logRecords.stream().map(LogRecord::getMessage)) + .doesNotContain( + "Ingesting documents from path: src/test/resources/ragdocuments, path matcher = glob:**, recursive = true") + .doesNotContain("Ingested 2 files as 2 documents") + .contains("Reading embeddings from %s") + .doesNotContain("Writing embeddings to %s"); + } + + @Test + public void verifyThatEmbeddingsAreIngested() { + EmbeddingModel embeddingModel = CDI.current().select(EmbeddingModel.class).get(); + EmbeddingStore embeddingStore = CDI.current().select(EmbeddingStore.class).get(); + Embedding question = embeddingModel.embed("When was Charlie born?").content(); + List> matches = embeddingStore.findRelevant(question, 1); + assertTrue(matches.get(0).embedded().text().contains("2005")); + + question = embeddingModel.embed("When was David born?").content(); + matches = embeddingStore.findRelevant(question, 1); + assertTrue(matches.get(0).embedded().text().contains("2003")); + } +} diff --git a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsDontAlreadyExistBaseTest.java b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsDontAlreadyExistBaseTest.java new file mode 100644 index 000000000..de1a14b75 --- /dev/null +++ b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsDontAlreadyExistBaseTest.java @@ -0,0 +1,51 @@ +package io.quarkiverse.langchain4j.test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.List; +import java.util.logging.LogRecord; + +import jakarta.enterprise.inject.spi.CDI; + +import org.junit.jupiter.api.Test; + +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.store.embedding.EmbeddingMatch; +import dev.langchain4j.store.embedding.EmbeddingStore; + +abstract class EasyRagReuseEmbeddingsDontAlreadyExistBaseTest { + protected static void verifyLogRecords(List logRecords) { + assertThat(logRecords.stream().map(LogRecord::getMessage)) + .contains( + "Ingesting documents from path: src/test/resources/ragdocuments, path matcher = glob:**, recursive = true") + .contains("Ingested 2 files as 2 documents") + .contains("Writing embeddings to %s") + .doesNotContain("Reading embeddings from %s"); + } + + protected abstract Path getEmbeddingsFile(); + + @Test + void verifyThatEmbeddingsFileIsGenerated() { + assertThat(getEmbeddingsFile()) + .isNotNull() + .isNotEmptyFile(); + } + + @Test + public void verifyThatDocumentsAreIngested() { + EmbeddingModel embeddingModel = CDI.current().select(EmbeddingModel.class).get(); + EmbeddingStore embeddingStore = CDI.current().select(EmbeddingStore.class).get(); + Embedding question = embeddingModel.embed("When was Charlie born?").content(); + List> matches = embeddingStore.findRelevant(question, 1); + assertTrue(matches.get(0).embedded().text().contains("2005")); + + question = embeddingModel.embed("When was David born?").content(); + matches = embeddingStore.findRelevant(question, 1); + assertTrue(matches.get(0).embedded().text().contains("2003")); + } +} diff --git a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsDontAlreadyExistTest.java b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsDontAlreadyExistTest.java new file mode 100644 index 000000000..38d73d8bf --- /dev/null +++ b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsDontAlreadyExistTest.java @@ -0,0 +1,54 @@ +package io.quarkiverse.langchain4j.test; + +import static java.util.Comparator.reverseOrder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +class EasyRagReuseEmbeddingsDontAlreadyExistTest extends EasyRagReuseEmbeddingsDontAlreadyExistBaseTest { + private static final String EMBEDDING_FILE_NAME = "embeddings.json"; + + // Didn't use @TempDir because it didn't work well with @RegisterExtension + private static final Path TEMP_DIR = Path.of("target", "test-generated-data", + EasyRagReuseEmbeddingsDontAlreadyExistTest.class.getSimpleName()); + + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset(""" + quarkus.langchain4j.easy-rag.path=src/test/resources/ragdocuments + quarkus.langchain4j.easy-rag.reuse-embeddings.enabled=true + quarkus.langchain4j.easy-rag.reuse-embeddings.file=%s + """.formatted(embeddingsFile())), + "application.properties")) + .setLogRecordPredicate(record -> true) + .assertLogRecords(EasyRagReuseEmbeddingsDontAlreadyExistBaseTest::verifyLogRecords); + + private static Path embeddingsFile() { + return TEMP_DIR.resolve(EMBEDDING_FILE_NAME).toAbsolutePath(); + } + + @AfterAll + static void cleanup() throws IOException { + // Clean up the temp directory + Files.walk(TEMP_DIR.getParent()) + .sorted(reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + + @Override + protected Path getEmbeddingsFile() { + return embeddingsFile(); + } +} diff --git a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsFileNotSetTest.java b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsFileNotSetTest.java new file mode 100644 index 000000000..23a82a779 --- /dev/null +++ b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagReuseEmbeddingsFileNotSetTest.java @@ -0,0 +1,38 @@ +package io.quarkiverse.langchain4j.test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.JavaArchive; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.test.QuarkusUnitTest; + +class EasyRagReuseEmbeddingsFileNotSetTest extends EasyRagReuseEmbeddingsDontAlreadyExistBaseTest { + private static final Path EMBEDDINGS_FILE = Path.of(".", "easy-rag-embeddings.json"); + + @RegisterExtension + static final QuarkusUnitTest unitTest = new QuarkusUnitTest() + .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) + .addAsResource(new StringAsset(""" + quarkus.langchain4j.easy-rag.path=src/test/resources/ragdocuments + quarkus.langchain4j.easy-rag.reuse-embeddings.enabled=true + """), + "application.properties")) + .setLogRecordPredicate(record -> true) + .assertLogRecords(EasyRagReuseEmbeddingsDontAlreadyExistBaseTest::verifyLogRecords); + + @AfterAll + static void cleanup() throws IOException { + Files.deleteIfExists(EMBEDDINGS_FILE); + } + + @Override + protected Path getEmbeddingsFile() { + return EMBEDDINGS_FILE; + } +} diff --git a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagTest.java b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagTest.java index e9d505e75..12bb4a8fa 100644 --- a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagTest.java +++ b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagTest.java @@ -1,9 +1,11 @@ package io.quarkiverse.langchain4j.test; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; +import java.util.logging.LogRecord; import jakarta.enterprise.inject.spi.CDI; @@ -29,7 +31,18 @@ public class EasyRagTest { static final QuarkusUnitTest unitTest = new QuarkusUnitTest() .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) .addAsResource(new StringAsset("quarkus.langchain4j.easy-rag.path=src/test/resources/ragdocuments"), - "application.properties")); + "application.properties")) + .setLogRecordPredicate(record -> true) + .assertLogRecords(EasyRagTest::verifyLogRecords); + + private static void verifyLogRecords(List logRecords) { + assertThat(logRecords.stream().map(LogRecord::getMessage)) + .contains( + "Ingesting documents from path: src/test/resources/ragdocuments, path matcher = glob:**, recursive = true") + .contains("Ingested 2 files as 2 documents") + .doesNotContain("Writing embeddings to %s") + .doesNotContain("Reading embeddings from %s"); + } // The following three tests verify that when using Easy RAG, // EmbeddingModel, an EmbeddingStore, and an EasyRetrievalAugmentor are always present even when the application diff --git a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagWithExplicitAugmentorTest.java b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagWithExplicitAugmentorTest.java index e5944249f..bbe1e14e5 100644 --- a/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagWithExplicitAugmentorTest.java +++ b/rag/easy-rag/deployment/src/test/java/io/quarkiverse/langchain4j/test/EasyRagWithExplicitAugmentorTest.java @@ -1,7 +1,11 @@ package io.quarkiverse.langchain4j.test; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; +import java.util.List; +import java.util.logging.LogRecord; + import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -22,7 +26,9 @@ public class EasyRagWithExplicitAugmentorTest { static final QuarkusUnitTest unitTest = new QuarkusUnitTest() .setArchiveProducer(() -> ShrinkWrap.create(JavaArchive.class) .addAsResource(new StringAsset("quarkus.langchain4j.easy-rag.path=src/test/resources/ragdocuments"), - "application.properties")); + "application.properties")) + .setLogRecordPredicate(record -> true) + .assertLogRecords(EasyRagWithExplicitAugmentorTest::verifyLogRecords); @ApplicationScoped public static class ExplicitRetrievalAugmentor implements RetrievalAugmentor { @@ -35,6 +41,15 @@ public UserMessage augment(UserMessage userMessage, Metadata metadata) { @Inject RetrievalAugmentor retrievalAugmentor; + private static void verifyLogRecords(List logRecords) { + assertThat(logRecords.stream().map(LogRecord::getMessage)) + .contains( + "Ingesting documents from path: src/test/resources/ragdocuments, path matcher = glob:**, recursive = true") + .contains("Ingested 2 files as 2 documents") + .doesNotContain("Writing embeddings to %s") + .doesNotContain("Reading embeddings from %s"); + } + @Test public void verifyThatExplicitRetrievalAugmentorHasPriority() { ; diff --git a/rag/easy-rag/deployment/src/test/resources/embeddings/embeddings.json b/rag/easy-rag/deployment/src/test/resources/embeddings/embeddings.json new file mode 100644 index 000000000..d692620d9 --- /dev/null +++ b/rag/easy-rag/deployment/src/test/resources/embeddings/embeddings.json @@ -0,0 +1 @@ +{"entries":[{"id":"d1612780-24d5-48e8-a31c-b46dbc1ad471","embedding":{"vector":[0.045135025,0.073510095,0.04813875,-0.013284253,-0.023743173,0.042266004,-0.048340943,-0.03448187,0.008505617,0.10675045,-0.04233652,-0.035601623,0.009175558,-0.102935016,0.045197096,0.06014795,-0.11391906,-0.08182968,0.021679107,-2.3025274E-4,0.012659923,0.04331008,0.06703022,-0.016654879,0.03323931,0.060536027,0.012877402,0.04598586,0.01694247,-0.0010889419,0.02167591,0.060496546,-0.017175209,-0.048835922,-0.02320778,0.06783746,-0.037409708,0.09428667,0.006009915,0.08585313,0.033552792,-0.08661226,-0.0562934,-0.046199143,-0.019091172,0.014105753,0.018041613,0.016481277,0.0025679034,0.018352006,-0.09204122,0.019181387,0.09192198,-0.09509428,-0.0095681995,0.0019404201,-9.193116E-4,-0.0038204808,-0.0049186475,-0.036245313,0.044061936,0.034093134,-0.08055166,0.0056324075,0.019110112,0.018263007,0.02598879,-0.08387872,0.027386894,-0.04742602,-0.040733576,0.01346537,0.041427583,-0.04347134,0.04622787,-0.02776898,0.07357554,0.052306533,-0.05616064,0.018510863,-0.011554855,-0.093158714,-0.06165758,-0.026399774,0.038717296,0.0203514,0.034080368,3.7068184E-4,-0.09266503,-0.03153288,-0.06266244,-0.033481594,0.0027271938,0.050582502,-0.03358599,-0.043623812,-0.0037420338,-0.01246431,-0.023336604,0.060763247,-0.046852965,-0.08009537,-0.011724214,0.082497634,0.015089333,-0.012084132,-0.028716575,0.035305932,0.012804084,-0.05374241,-0.010906741,-0.05008357,-0.014693725,0.05998996,0.059351686,0.059364304,-0.06476137,0.018183455,-2.4492232E-4,-0.046543732,0.018333659,0.06380865,-0.05032575,0.04006229,-0.08846682,-0.054440312,-0.050892323,-5.280468E-33,-0.0054010693,0.0409841,-0.0061633987,0.053447157,-0.06175275,0.014426243,-0.016276835,0.008766443,0.012495828,-0.07386898,0.010256694,-0.07766882,0.09949759,-0.039197,-0.025273899,0.07355532,-0.05384932,-0.0036467342,0.075604744,0.013186399,0.016877605,-0.075876184,2.8124545E-4,-0.014811496,0.021651471,-0.03717504,0.14364687,0.0292767,0.05299484,-0.009572067,-0.027864033,0.021100447,0.049267676,-0.08038273,0.017773261,0.08894684,-0.06065078,-0.016888898,-0.03822921,-0.01120442,0.029077945,0.018251574,0.012428798,0.05502008,-0.115292005,0.07140895,-0.03666491,-0.047880705,0.12988405,-0.06238627,0.103615895,0.0058788066,-0.02623644,-0.061094634,-0.05155839,-0.050462592,-0.042635113,0.0438238,-0.026859924,0.0023598715,0.05603867,0.02890869,0.014765195,0.05002888,-0.093731776,-0.052426346,0.110056356,0.017394101,0.05587815,-0.03933037,-0.012851437,-0.0068633705,0.06188856,-0.0733047,-0.0068994113,0.014506713,-0.02214844,-0.057952568,0.044047266,0.10691592,0.036940522,0.023489997,0.057715382,-0.10525122,-0.04955978,-0.10554584,0.034215964,0.03287414,0.0028138943,0.005593057,0.0982133,-0.08128959,0.02274518,-0.021499386,-0.020100119,1.52415975E-33,-0.0638313,0.021531895,0.05417523,0.0025239906,0.060293704,-0.033491794,0.025482817,0.08976565,-0.02756399,-0.06329521,0.08461123,0.04599475,0.064812824,0.019763123,0.031823035,0.020583522,-0.030165805,0.035834298,0.022820992,0.035885748,0.07696155,0.0048643695,-0.09761399,-0.066032715,-0.019264523,-0.018095523,0.079535134,0.031662434,-0.057179905,-0.13127151,-0.089253895,0.026428502,-0.12914449,-0.04512151,-0.049174726,0.0605117,-0.085024394,-0.023564003,0.027720235,-0.014513834,-0.015512396,-8.75764E-4,-0.0065531335,0.10772839,0.0209603,-0.045188684,0.0198825,0.020051833,0.038363196,0.03927858,-0.041711815,0.03170112,-0.010136009,-0.09540222,0.09649893,0.052636545,-0.006367059,0.0058026887,0.061879136,0.00646942,-0.03219125,0.05023678,0.072006956,-0.008293231,-0.107517086,0.021181198,0.0026742388,-0.06255375,0.0019297572,0.020002697,0.068631224,0.022616958,-0.063627884,-0.047525026,-0.08249897,-0.037294623,-0.08075913,0.07165043,-0.027520357,-0.010701786,-0.035335746,0.03680294,-0.015537419,0.083221965,-0.05184095,0.025159294,0.07346135,-0.048755415,-0.02500379,0.01703941,0.040149152,-0.020136638,-0.054918837,-0.071505524,0.049348384,-1.6230377E-8,0.07478851,-0.00783506,-0.08623158,-0.044692736,0.04431356,0.09454772,0.010153704,0.03519507,0.02114495,0.04142283,0.024695642,0.03608493,0.013839026,-0.009790407,0.055882044,-0.0054693613,0.0105540035,0.027825015,0.0022923267,0.031903986,-0.033080067,0.031236239,0.03814469,0.1098003,0.05734744,-0.013319506,-0.032052584,-0.056380708,-0.043201666,-0.07881596,0.0031635247,0.0323488,-0.061995797,-0.034463,0.06835623,-0.038064256,-0.04023466,0.011581823,-0.06516491,0.025640165,0.08497651,0.060434885,-0.01357468,0.06278275,-0.014774637,-0.048512805,-0.056812726,-0.034271747,0.0046775886,-0.03091918,-0.023558578,1.7641665E-4,0.008344692,-0.015590776,0.016150286,-0.034836464,-0.14590278,-0.034911714,-0.033673894,0.033660762,0.019168071,0.004226793,0.050638102,-6.272846E-4]},"embedded":{"text":"David was born in 2003.","metadata":{"metadata":{"index":"0"}}}},{"id":"d5ffb985-f256-4b52-95c0-6c8f29be1e14","embedding":{"vector":[-0.071238145,-0.06562912,0.040615246,0.07285426,-0.07183264,0.09071255,-0.035927366,0.01352495,-0.04098103,0.068934925,0.012162433,-0.027731352,0.012000037,-0.114181556,0.022879355,0.086100265,-0.020730382,-0.04448075,0.01740649,-0.031563148,-0.053922515,0.011428661,0.0033498849,0.029647682,0.002941382,0.055307224,0.06471977,0.008845861,-0.01655351,-0.019454686,0.043811332,0.037972596,-0.050834455,0.015462609,0.011257718,0.012840651,0.0073684594,0.086997,-0.0049014585,0.03570943,0.008978784,-0.11936574,-0.02897162,-0.005917516,0.07162893,0.019833516,0.017347226,0.015035676,0.043894995,-0.018604334,-0.13292569,-0.020353375,-0.008370791,-0.072605185,-0.026309457,-0.021614699,0.03764209,-0.02643846,0.008316269,0.032193597,-0.04663208,0.037460215,-0.06923009,-0.0314963,0.049521383,0.04547627,-0.045138963,-0.086059466,-0.014312765,-0.06266891,0.019594152,0.0108154705,0.027151072,0.015769213,-0.025555218,-0.05551207,0.035761815,0.09848909,0.06546461,-1.9690246E-4,-0.055171654,-0.12960148,-0.05671564,0.02267742,0.026258921,0.007716896,-0.07443658,0.026811978,-0.08125349,0.02595152,-0.03387203,-0.03888516,-0.016715048,0.082924925,-0.029124323,-0.041375417,-0.08870744,0.027859403,-0.019471789,0.049012683,-0.06386057,0.031190943,0.05863189,0.032352086,0.022636129,-0.0027169543,0.045267384,0.028461449,0.03338642,-0.07842534,-0.020846618,-0.068428285,-0.027484484,0.080323145,0.042543873,0.029204197,-0.018537523,-0.010523835,0.020067409,-0.040029645,0.02631605,0.12692834,-0.015900463,0.022041991,-0.14773293,-0.06252257,-0.035501182,-4.948107E-33,0.041032918,-0.04956801,0.010867924,0.08771603,-0.09462966,0.07385154,-0.053455234,-0.06213682,-0.013302567,-0.040152945,0.0047680647,-0.054932002,-0.011076627,-0.08410533,0.043474644,0.108934425,-0.11082059,0.023358513,0.036556304,0.068314835,0.049789075,-0.046267997,-0.04637036,-0.040582694,0.054383665,0.030810922,0.013645057,-0.0601998,-0.011414667,0.049095694,-0.010808822,0.08493228,0.066820756,-0.0547643,0.0037198593,0.0132811265,-0.07298582,0.022252167,-0.031180125,0.02331074,0.021692796,-0.042203847,-0.007360994,0.06099436,-0.0673283,0.012556697,0.036982752,-0.0508395,0.14063145,-0.10946531,0.08918658,-0.03775696,0.0067613986,0.0031604513,0.05568755,-0.04525638,0.00902254,0.06406785,0.051635005,-0.021297669,0.063460626,0.10671544,0.016770147,0.01909828,-0.04109723,-0.071142435,0.16298677,0.099477716,-0.017480232,-0.026804179,0.02041919,-0.0055444553,0.07874199,-0.092391126,-0.084692866,-0.06170974,0.034449417,0.0041000335,-0.0041127275,0.091277,0.0527933,0.00948111,0.06216052,-0.041188415,0.034297835,-1.8833327E-4,0.12201836,0.010557979,-0.091322824,-0.03916834,0.06102675,-0.0012751634,-8.404061E-4,-0.027123602,-0.020886412,1.2071558E-33,-0.0121133225,0.005911391,0.032852698,0.05292497,-0.06738109,-0.0752711,0.03445934,0.050528385,-0.013791451,-0.0062816064,-0.0026459377,0.026099429,0.058935687,-0.046622496,0.051774804,4.4015187E-4,-0.083675966,0.028038055,-0.014171763,-0.03720798,0.04062112,0.0566225,-0.06390688,-0.046507526,0.046979446,-0.029209463,0.020604046,0.07132489,0.0039132643,-0.038628336,-0.08370859,0.040471718,-0.019777115,-0.11042246,-0.088916555,0.026875999,-0.028638545,0.0279322,0.06523172,-0.06617747,-0.009930826,0.03050462,-0.03950507,0.028993847,0.02910221,-0.014119551,0.031132573,0.014662588,0.027607648,0.01250606,0.028503697,0.018392604,-0.0674902,-0.013136314,0.004448732,0.05675201,-0.053984318,0.041346874,0.041586492,-0.010844549,-0.03999589,-0.0056468383,0.083038464,-0.07377866,-0.05752231,0.05724265,0.051011287,-0.023209596,0.04531494,4.7979967E-5,0.024006892,0.0069602104,-0.0017488088,0.018176528,-0.023124108,0.064418405,-0.04906278,0.082277745,-0.02848131,0.0331234,-0.09920031,0.035822406,-0.04785838,0.09899727,-0.056904614,-0.04133547,-0.03777053,-0.012930006,0.052285273,-0.020416401,-0.03320558,-0.05398676,-0.02950514,-0.08626267,-0.07032903,-1.5840133E-8,0.05586833,0.06782453,-0.041841757,0.017902263,0.0695844,0.058868315,-0.07694986,-0.015074563,0.03834971,0.057221305,0.02843693,0.02066231,0.020811347,0.017069906,0.025175354,5.9036684E-4,-0.02533897,-0.030733049,0.037525274,0.086212605,0.013568109,-0.018703844,0.09762896,0.07750786,0.0016675835,-0.095908225,-0.08215967,-0.018692221,-0.09167409,-0.04359743,0.010635103,0.013770285,-0.022655021,-0.04775953,0.03353941,-0.057220463,-0.016216293,-0.047150172,-0.04616967,0.011197724,0.030672241,-0.043694366,0.053334326,0.034736536,-0.014219882,-0.03174944,0.040864855,-0.014121127,-0.020339392,-0.015986783,-0.07996518,0.046284936,0.027405977,-0.051501412,0.023819098,0.0659109,-0.0103584295,-0.047945816,0.058520295,0.015966317,-0.011778799,0.010725641,-0.009360151,0.014496517]},"embedded":{"text":"Charlie was born in 2005.","metadata":{"metadata":{"index":"0"}}}}]} \ No newline at end of file diff --git a/rag/easy-rag/runtime/src/main/java/io/quarkiverse/langchain4j/easyrag/runtime/EasyRagConfig.java b/rag/easy-rag/runtime/src/main/java/io/quarkiverse/langchain4j/easyrag/runtime/EasyRagConfig.java index 2e1018e19..a5fca53c3 100644 --- a/rag/easy-rag/runtime/src/main/java/io/quarkiverse/langchain4j/easyrag/runtime/EasyRagConfig.java +++ b/rag/easy-rag/runtime/src/main/java/io/quarkiverse/langchain4j/easyrag/runtime/EasyRagConfig.java @@ -2,6 +2,7 @@ import static io.quarkus.runtime.annotations.ConfigPhase.RUN_TIME; +import io.quarkus.runtime.annotations.ConfigGroup; import io.quarkus.runtime.annotations.ConfigRoot; import io.smallrye.config.ConfigMapping; import io.smallrye.config.WithDefault; @@ -56,4 +57,30 @@ public interface EasyRagConfig { @WithDefault("ON") IngestionStrategy ingestionStrategy(); + /** + * Configuration related to the reusing of embeddings. + *

+ * Currently only supported when using an in-memory embedding store. + *

+ */ + ReuseEmbeddingsConfig reuseEmbeddings(); + + @ConfigGroup + interface ReuseEmbeddingsConfig { + /** + * Whether or not to reuse embeddings. Defaults to {@code false}. + */ + @WithDefault("false") + boolean enabled(); + + /** + * The file path to load/save embeddings, assuming + * {@code quarkus.langchain4j.easy-rag.reuse-embeddings.enabled == true}. + *

+ * Defaults to {@code easy-rag-embeddings.json} in the current directory. + *

+ */ + @WithDefault("easy-rag-embeddings.json") + String file(); + } } diff --git a/rag/easy-rag/runtime/src/main/java/io/quarkiverse/langchain4j/easyrag/runtime/EasyRagRecorder.java b/rag/easy-rag/runtime/src/main/java/io/quarkiverse/langchain4j/easyrag/runtime/EasyRagRecorder.java index f5d9f34be..fbbf30137 100644 --- a/rag/easy-rag/runtime/src/main/java/io/quarkiverse/langchain4j/easyrag/runtime/EasyRagRecorder.java +++ b/rag/easy-rag/runtime/src/main/java/io/quarkiverse/langchain4j/easyrag/runtime/EasyRagRecorder.java @@ -1,6 +1,9 @@ package io.quarkiverse.langchain4j.easyrag.runtime; +import java.io.IOException; import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.PathMatcher; import java.util.List; import java.util.function.Function; @@ -35,37 +38,83 @@ public void ingest(EasyRagConfig config, BeanContainer beanContainer) { LOGGER.info("Skipping document ingestion as per configuration"); return; } + EmbeddingStore embeddingStore = beanContainer.beanInstance(EmbeddingStore.class); EmbeddingModel embeddingModel = beanContainer.beanInstance(EmbeddingModel.class); + if (config.reuseEmbeddings().enabled() && (embeddingStore instanceof InMemoryEmbeddingStore)) { + Path embeddingsFile = Path.of(config.reuseEmbeddings().file()).toAbsolutePath(); + + // If the embeddings file already exists it would have been ingested + // when the InMemoryEmbeddingStore bean was created + // See the inMemoryEmbeddingStoreSupplier method + if (!Files.exists(embeddingsFile)) { + // embeddingsFile doesn't exist, so ingest the documents and then write out the results + try { + Files.createDirectories(embeddingsFile.getParent()); + } catch (IOException ex) { + throw new RuntimeException(ex); + } + + ingestDocumentsFromFilesystem(config, embeddingStore, embeddingModel); + LOGGER.infof("Writing embeddings to %s", embeddingsFile); + ((InMemoryEmbeddingStore) embeddingStore).serializeToFile(embeddingsFile); + } else { + // This is here because in the case where the file exists, the EmbeddingStore will be + // lazily initialized upon first use. We want it eagerly initialized. + // We need to call a method on the bean instance to eagerly initialize it + // https://github.com/quarkusio/quarkus/issues/41159 may make this less "hacky" in the future + embeddingStore.toString(); + } + } else { + ingestDocumentsFromFilesystem(config, embeddingStore, embeddingModel); + } + } + + private void ingestDocumentsFromFilesystem(EasyRagConfig config, EmbeddingStore embeddingStore, + EmbeddingModel embeddingModel) { PathMatcher pathMatcher = FileSystems.getDefault().getPathMatcher(config.pathMatcher()); LOGGER.info("Ingesting documents from path: " + config.path() + ", path matcher = " + config.pathMatcher() + ", recursive = " + config.recursive()); - List documents = null; - if (config.recursive()) { - documents = FileSystemDocumentLoader.loadDocumentsRecursively(config.path(), pathMatcher); - } else { - documents = FileSystemDocumentLoader.loadDocuments(config.path(), pathMatcher); - } + + List documents = config.recursive() + ? FileSystemDocumentLoader.loadDocumentsRecursively(config.path(), pathMatcher) + : FileSystemDocumentLoader.loadDocuments(config.path(), pathMatcher); + DocumentSplitter documentSplitter = DocumentSplitters.recursive(config.maxSegmentSize(), config.maxOverlapSize(), new HuggingFaceTokenizer()); + List splitDocuments = documentSplitter .splitAll(documents) .stream() .map(split -> new Document(split.text())) .toList(); + EmbeddingStoreIngestor.builder() .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build() .ingest(splitDocuments); + LOGGER.info("Ingested " + documents.size() + " files as " + splitDocuments.size() + " documents"); } - public Supplier inMemoryEmbeddingStoreSupplier() { + public Supplier> inMemoryEmbeddingStoreSupplier(EasyRagConfig config) { return new Supplier<>() { @Override - public InMemoryEmbeddingStore get() { + public InMemoryEmbeddingStore get() { + if ((config.ingestionStrategy() == IngestionStrategy.ON) && config.reuseEmbeddings().enabled()) { + // Want to reuse existing embeddings + Path embeddingsFile = Path.of(config.reuseEmbeddings().file()).toAbsolutePath(); + + // If the file exists then read it and populate + if (Files.isRegularFile(embeddingsFile)) { + LOGGER.infof("Reading embeddings from %s", embeddingsFile); + return InMemoryEmbeddingStore.fromFile(embeddingsFile); + } + } + + // Otherwise just return an empty store return new InMemoryEmbeddingStore<>(); } }; diff --git a/samples/chatbot-easy-rag/pom.xml b/samples/chatbot-easy-rag/pom.xml index fe30fdac4..28283a8bc 100644 --- a/samples/chatbot-easy-rag/pom.xml +++ b/samples/chatbot-easy-rag/pom.xml @@ -15,7 +15,7 @@ UTF-8 quarkus-bom io.quarkus - 3.9.4 + 3.11.1 true 3.2.5 0.15.1 diff --git a/samples/chatbot-easy-rag/src/main/resources/application.properties b/samples/chatbot-easy-rag/src/main/resources/application.properties index 8887463b6..7d931b1a6 100644 --- a/samples/chatbot-easy-rag/src/main/resources/application.properties +++ b/samples/chatbot-easy-rag/src/main/resources/application.properties @@ -1,4 +1,4 @@ -quarkus.langchain4j.openai.timeout=60s +quarkus.langchain4j.timeout=60s quarkus.langchain4j.easy-rag.path=src/main/resources/catalog quarkus.langchain4j.easy-rag.max-segment-size=100 diff --git a/samples/chatbot/pom.xml b/samples/chatbot/pom.xml index 9a59acbd3..28ad53fe1 100644 --- a/samples/chatbot/pom.xml +++ b/samples/chatbot/pom.xml @@ -15,7 +15,7 @@ UTF-8 quarkus-bom io.quarkus - 3.9.4 + 3.11.1 true 3.2.5 0.15.1 diff --git a/samples/chatbot/src/main/resources/application.properties b/samples/chatbot/src/main/resources/application.properties index 9a71b2867..d2303c3d9 100644 --- a/samples/chatbot/src/main/resources/application.properties +++ b/samples/chatbot/src/main/resources/application.properties @@ -1,2 +1,2 @@ quarkus.langchain4j.redis.dimension=1536 -quarkus.langchain4j.openai.timeout=60s \ No newline at end of file +quarkus.langchain4j.timeout=60s diff --git a/samples/cli-translator/pom.xml b/samples/cli-translator/pom.xml index aa2071b29..ca4ec4795 100644 --- a/samples/cli-translator/pom.xml +++ b/samples/cli-translator/pom.xml @@ -15,7 +15,7 @@ UTF-8 quarkus-bom io.quarkus - 3.8.2 + 3.8.5 true 3.2.5 0.15.1 diff --git a/samples/cli-translator/src/main/resources/application.properties b/samples/cli-translator/src/main/resources/application.properties index bb59e4f66..82a560e04 100644 --- a/samples/cli-translator/src/main/resources/application.properties +++ b/samples/cli-translator/src/main/resources/application.properties @@ -1,5 +1,5 @@ +quarkus.langchain4j.timeout=60s quarkus.langchain4j.openai.api-key=${OPENAI_API_KEY} quarkus.langchain4j.openai.chat-model.temperature=0 -quarkus.langchain4j.openai.timeout=60s quarkus.banner.enabled=false -quarkus.log.level=WARN \ No newline at end of file +quarkus.log.level=WARN diff --git a/samples/email-a-poem/pom.xml b/samples/email-a-poem/pom.xml index 0d28c1d18..342bfeb41 100644 --- a/samples/email-a-poem/pom.xml +++ b/samples/email-a-poem/pom.xml @@ -15,7 +15,7 @@ UTF-8 quarkus-bom io.quarkus - 3.8.2 + 3.8.5 true 3.2.5 0.15.1 diff --git a/samples/fraud-detection/pom.xml b/samples/fraud-detection/pom.xml index 1ca230ed8..7b82a7b20 100644 --- a/samples/fraud-detection/pom.xml +++ b/samples/fraud-detection/pom.xml @@ -15,7 +15,7 @@ UTF-8 quarkus-bom io.quarkus - 3.8.2 + 3.8.5 true 3.2.5 0.15.1 diff --git a/samples/jbang-joke-bot/jokes.java b/samples/jbang-joke-bot/jokes.java index 2cf9f6704..19d6118e1 100755 --- a/samples/jbang-joke-bot/jokes.java +++ b/samples/jbang-joke-bot/jokes.java @@ -1,7 +1,7 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? -//DEPS io.quarkus.platform:quarkus-bom:3.9.4@pom +//DEPS io.quarkus.platform:quarkus-bom:3.11.1@pom //DEPS io.quarkus:quarkus-picocli -//DEPS io.quarkiverse.langchain4j:quarkus-langchain4j-openai:0.15.0 +//DEPS io.quarkiverse.langchain4j:quarkus-langchain4j-openai:0.15.1 //Q:CONFIG quarkus.banner.enabled=false //Q:CONFIG quarkus.log.level=WARN import dev.langchain4j.model.chat.ChatLanguageModel; diff --git a/samples/review-triage/pom.xml b/samples/review-triage/pom.xml index b6288a450..39a0a5656 100644 --- a/samples/review-triage/pom.xml +++ b/samples/review-triage/pom.xml @@ -15,7 +15,7 @@ UTF-8 quarkus-bom io.quarkus - 3.8.2 + 3.8.5 true 3.2.5 0.15.1 diff --git a/samples/review-triage/src/main/resources/application.properties b/samples/review-triage/src/main/resources/application.properties index 797aad6b9..f9d0456be 100644 --- a/samples/review-triage/src/main/resources/application.properties +++ b/samples/review-triage/src/main/resources/application.properties @@ -1,5 +1,5 @@ quarkus.langchain4j.openai.chat-model.temperature=0.5 -quarkus.langchain4j.openai.timeout=60s +quarkus.langchain4j.timeout=60s # using a json_object response format requires at least gpt-3.5-turbo-1106 or more recent # see https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format diff --git a/samples/sql-chatbot/pom.xml b/samples/sql-chatbot/pom.xml index 00b3661bf..5e192426e 100644 --- a/samples/sql-chatbot/pom.xml +++ b/samples/sql-chatbot/pom.xml @@ -15,7 +15,7 @@ UTF-8 quarkus-bom io.quarkus - 3.9.4 + 3.11.1 true 3.2.5 0.15.1 diff --git a/samples/sql-chatbot/src/main/resources/application.properties b/samples/sql-chatbot/src/main/resources/application.properties index 595cc9c6d..8fb28bb44 100644 --- a/samples/sql-chatbot/src/main/resources/application.properties +++ b/samples/sql-chatbot/src/main/resources/application.properties @@ -1,4 +1,4 @@ -quarkus.langchain4j.openai.timeout=60s +quarkus.langchain4j.timeout=60s csv.file=src/main/resources/data/movies.csv quarkus.hibernate-orm.database.generation=drop-and-create