ADR-023: Migration from Plain LangChain4j to Quarkus LangChain4j

Status

Accepted (Implemented)

Date

2025-12-07

Deciders

Context and Problem Statement

The CLI currently uses plain LangChain4j libraries (v0.36.2) with manual CDI wiring. Since we're already on Quarkus, using the Quarkus LangChain4j extension would provide:

Decision Drivers

Decision Outcome

Migrate from plain LangChain4j to Quarkus LangChain4j extension.

Migration Plan

Phase 1: Update Dependencies (pom.xml)

Remove plain LangChain4j:

<!-- REMOVE these -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j</artifactId>
</dependency>
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-ollama</artifactId>
</dependency>
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-anthropic</artifactId>
</dependency>
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-open-ai</artifactId>
</dependency>
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-google-ai-gemini</artifactId>
</dependency>
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-pgvector</artifactId>
</dependency>
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-easy-rag</artifactId>
</dependency>

Add Quarkus LangChain4j:

<properties>
    <!-- Replace langchain4j.version -->
    <!-- NOTE: Using 1.3.1 for compatibility with Quarkus 3.27.1 -->
    <!-- This internally uses LangChain4j 1.6.0 -->
    <quarkus-langchain4j.version>1.3.1</quarkus-langchain4j.version>
</properties>

<!-- Quarkus LangChain4j Core -->
<dependency>
    <groupId>io.quarkiverse.langchain4j</groupId>
    <artifactId>quarkus-langchain4j-core</artifactId>
    <version>${quarkus-langchain4j.version}</version>
</dependency>

<!-- Chat Model Providers -->
<dependency>
    <groupId>io.quarkiverse.langchain4j</groupId>
    <artifactId>quarkus-langchain4j-ollama</artifactId>
    <version>${quarkus-langchain4j.version}</version>
</dependency>
<dependency>
    <groupId>io.quarkiverse.langchain4j</groupId>
    <artifactId>quarkus-langchain4j-anthropic</artifactId>
    <version>${quarkus-langchain4j.version}</version>
</dependency>
<dependency>
    <groupId>io.quarkiverse.langchain4j</groupId>
    <artifactId>quarkus-langchain4j-openai</artifactId>
    <version>${quarkus-langchain4j.version}</version>
    <optional>true</optional>
</dependency>

<!-- PGVector Embedding Store -->
<dependency>
    <groupId>io.quarkiverse.langchain4j</groupId>
    <artifactId>quarkus-langchain4j-pgvector</artifactId>
    <version>${quarkus-langchain4j.version}</version>
</dependency>

<!-- Keep these from plain LangChain4j (no Quarkus equivalent) -->
<!-- NOTE: Use 1.6.0-beta12 to match LangChain4j 1.6.0 from quarkus-langchain4j 1.3.1 -->
<dependency>
    <groupId>dev.langchain4j</groupId>
    <artifactId>langchain4j-document-parser-apache-tika</artifactId>
    <version>1.6.0-beta12</version>
</dependency>
<!-- NOTE: bge-small-en-q removed - using Ollama nomic-embed-text instead -->

Phase 2: Update Configuration (application.properties)

Before (custom properties):

# Custom RAG configuration
idempiere.cli.rag.embedding.model=ollama
idempiere.cli.rag.embedding.table=cli_embeddings
idempiere.cli.rag.embedding.dimension=768
idempiere.cli.rag.ollama.base-url=http://localhost:11434
idempiere.cli.rag.ollama.model=nomic-embed-text
idempiere.cli.rag.vector-db.host=localhost
idempiere.cli.rag.vector-db.port=5432
idempiere.cli.rag.vector-db.name=vector

After (Quarkus LangChain4j):

# ==================== Quarkus LangChain4j Configuration ====================

# Chat Model Provider (default: ollama for local, anthropic for cloud)
quarkus.langchain4j.chat-model.provider=ollama

# Ollama Configuration (local LLM)
quarkus.langchain4j.ollama.base-url=http://localhost:11434
quarkus.langchain4j.ollama.chat-model.model-id=llama3.2
quarkus.langchain4j.ollama.embedding-model.model-id=nomic-embed-text
quarkus.langchain4j.ollama.timeout=120s
quarkus.langchain4j.ollama.log-requests=false
quarkus.langchain4j.ollama.log-responses=false

# Anthropic Configuration (cloud - Claude)
quarkus.langchain4j.anthropic.api-key=${ANTHROPIC_API_KEY:}
quarkus.langchain4j.anthropic.chat-model.model-id=claude-sonnet-4-20250514
quarkus.langchain4j.anthropic.chat-model.max-tokens=4096
quarkus.langchain4j.anthropic.timeout=60s

# OpenAI Configuration (optional cloud)
quarkus.langchain4j.openai.api-key=${OPENAI_API_KEY:}
quarkus.langchain4j.openai.chat-model.model-name=gpt-4o

# ==================== Embedding Configuration ====================

# Embedding Model Provider
# Options: ollama, openai, in-process (for bge-small-en-q)
quarkus.langchain4j.embedding-model.provider=ollama

# PGVector Embedding Store
quarkus.langchain4j.pgvector.datasource=vector
quarkus.langchain4j.pgvector.table=cli_embeddings
quarkus.langchain4j.pgvector.dimension=768
quarkus.langchain4j.pgvector.create-table=true
quarkus.langchain4j.pgvector.drop-table-first=false

# RAG Vector database datasource (PostgreSQL + pgvector)
# Uses RAG_VECTORDB_PG_* env vars to clearly indicate purpose
quarkus.datasource.vector.db-kind=postgresql
quarkus.datasource.vector.jdbc.url=jdbc:postgresql://${RAG_VECTORDB_PG_HOST:localhost}:${RAG_VECTORDB_PG_PORT:5432}/${RAG_VECTORDB_PG_NAME:vector}
quarkus.datasource.vector.username=${RAG_VECTORDB_PG_USER:postgres}
quarkus.datasource.vector.password=${RAG_VECTORDB_PG_PASSWORD:postgres}

# ==================== RAG Configuration ====================

# Content Retriever Settings
quarkus.langchain4j.easy-rag.max-results=5
quarkus.langchain4j.easy-rag.min-score=0.7

# Document Ingestion (still use custom for control)
idempiere.cli.rag.wiki.enabled=true
idempiere.cli.rag.wiki.base-url=https://wiki.idempiere.org/en/
idempiere.cli.rag.k-entry.enabled=true
idempiere.cli.rag.k-entry.client-id=1000014
idempiere.cli.rag.ad-metadata.enabled=true

# ==================== Dev Mode ====================

# Enable Dev UI for LangChain4j
%dev.quarkus.langchain4j.devui.enabled=true

Phase 3: Migrate Services

3.1 Remove EmbeddingModelProvider (Use CDI)

Before:

@ApplicationScoped
public class EmbeddingModelProvider {
    private EmbeddingModel model;

    public EmbeddingModel getModel() {
        if (model == null) {
            model = switch (config.getModelType()) {
                case "ollama" -> OllamaEmbeddingModel.builder()
                    .baseUrl(config.getOllamaBaseUrl())
                    .modelName(config.getOllamaModel())
                    .build();
                case "local" -> new BgeSmallEnV15QuantizedEmbeddingModel();
                default -> throw new IllegalArgumentException("Unknown model");
            };
        }
        return model;
    }
}

After:

// DELETE EmbeddingModelProvider.java - Quarkus auto-provides!

// Just inject wherever needed:
@ApplicationScoped
public class SomeService {

    @Inject
    EmbeddingModel embeddingModel;  // Auto-configured by Quarkus!
}

3.2 Simplify EmbeddingStoreProvider

Before:

@ApplicationScoped
public class EmbeddingStoreProvider {
    private PgVectorEmbeddingStore store;

    public EmbeddingStore<TextSegment> getStore() {
        if (store == null) {
            store = PgVectorEmbeddingStore.builder()
                .host(config.getHost())
                .port(config.getPort())
                // ... lots of configuration
                .build();
        }
        return store;
    }
}

After:

// DELETE most of EmbeddingStoreProvider.java - Quarkus auto-provides!

@ApplicationScoped
public class EmbeddingStoreProvider {

    @Inject
    EmbeddingStore<TextSegment> store;  // Auto-configured by Quarkus!

    // Keep helper methods for statistics, clearing, etc.
    public long getEmbeddingCount() {
        // Direct SQL query (store doesn't expose count)
        return queryCount();
    }
}

3.3 Create Declarative AI Service

Before (AskCommand manual wiring):

@Command(name = "ask")
public class AskCommand implements Callable<Integer> {
    @Override
    public Integer call() {
        ChatLanguageModel model = OllamaChatModel.builder()
            .baseUrl("http://localhost:11434")
            .modelName("llama3")
            .build();

        ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
            .embeddingStore(storeProvider.getStore())
            .embeddingModel(modelProvider.getModel())
            .build();

        // Manual RAG assembly...
    }
}

After (Declarative):

// 1. Define AI Service Interface
@RegisterAiService(
    retrievalAugmentor = IdempiereRagAugmentor.class,
    tools = { RegistryTools.class, QueryTools.class }
)
public interface IdempiereAssistant {

    @SystemMessage("""
        You are an iDempiere development assistant with access to:
        - Application Dictionary knowledge (tables, windows, processes)
        - iDempiere Wiki documentation
        - CloudEmpiere knowledge base articles

        Use the provided context to answer questions accurately.
        Reference specific tables, windows, or documentation when relevant.
        """)
    String chat(@UserMessage String question);

    @SystemMessage("Generate iDempiere plugin code based on requirements.")
    String generateCode(@UserMessage String requirements);
}

// 2. Define RAG Augmentor
@ApplicationScoped
public class IdempiereRagAugmentor implements Supplier<RetrievalAugmentor> {

    @Inject
    EmbeddingStore<TextSegment> embeddingStore;

    @Inject
    EmbeddingModel embeddingModel;

    @Override
    public RetrievalAugmentor get() {
        ContentRetriever retriever = EmbeddingStoreContentRetriever.builder()
            .embeddingStore(embeddingStore)
            .embeddingModel(embeddingModel)
            .maxResults(5)
            .minScore(0.7)
            .build();

        return DefaultRetrievalAugmentor.builder()
            .contentRetriever(retriever)
            .build();
    }
}

// 3. Simplified Command
@Command(name = "ask")
public class AskCommand implements Callable<Integer> {

    @Inject
    IdempiereAssistant assistant;  // That's it!

    @Parameters(description = "Question to ask")
    String question;

    @Override
    public Integer call() {
        String answer = assistant.chat(question);
        System.out.println(answer);
        return 0;
    }
}

3.4 Update RagService

Before:

@ApplicationScoped
public class RagService {
    @Inject EmbeddingStoreProvider storeProvider;
    @Inject EmbeddingModelProvider modelProvider;

    public ToolResult search(String query, String sourceType) {
        EmbeddingModel model = modelProvider.getModel();
        EmbeddingStore<TextSegment> store = storeProvider.getStore();
        // ...
    }
}

After:

@ApplicationScoped
public class RagService {

    @Inject
    EmbeddingStore<TextSegment> embeddingStore;  // Auto-injected!

    @Inject
    EmbeddingModel embeddingModel;  // Auto-injected!

    public ToolResult search(String query, String sourceType) {
        Embedding queryEmbedding = embeddingModel.embed(query).content();
        // Use embeddingStore directly...
    }

    // Keep ingestor injection as-is
    @Inject WikiIngestor wikiIngestor;
    @Inject KEntryIngestor kEntryIngestor;
    @Inject ADMetadataIngestor adMetadataIngestor;
}

Phase 4: Update Ingestors

Ingestors need to use injected EmbeddingStore and EmbeddingModel:

@ApplicationScoped
public class WikiIngestor implements KnowledgeIngestor {

    @Inject
    EmbeddingStore<TextSegment> embeddingStore;

    @Inject
    EmbeddingModel embeddingModel;

    @Inject
    RagConfig config;  // Keep custom config for wiki URLs, etc.

    @Override
    public ToolResult ingest(boolean force) {
        // Use injected embeddingModel and embeddingStore
        List<TextSegment> segments = fetchAndParseWikiPages();

        for (TextSegment segment : segments) {
            Embedding embedding = embeddingModel.embed(segment).content();
            embeddingStore.add(embedding, segment);
        }

        return ToolResult.success("Ingested wiki pages");
    }
}

Phase 5: Multi-Provider Support

Quarkus LangChain4j supports named model providers:

# Default provider
quarkus.langchain4j.chat-model.provider=ollama

# Named providers for different use cases
quarkus.langchain4j.ollama.chat-model.model-id=llama3.2
quarkus.langchain4j.anthropic.chat-model.model-id=claude-sonnet-4-20250514
// Use default (Ollama)
@Inject
ChatLanguageModel defaultModel;

// Use specific provider
@Inject
@ModelName("anthropic")
ChatLanguageModel claudeModel;

// Or in AI Service
@RegisterAiService(modelName = "anthropic")
public interface ClaudeAssistant {
    String chat(String message);
}

File Changes Summary

File Action
pom.xml Replace langchain4j with quarkus-langchain4j
application.properties Migrate to quarkus.langchain4j.*
EmbeddingModelProvider.java DELETE (use CDI injection)
EmbeddingStoreProvider.java Simplify (inject store, keep helpers)
RagService.java Use CDI injection
RagConfig.java Keep for custom settings (wiki URLs, etc.)
*Ingestor.java Use CDI injection
AskCommand.java Use @RegisterAiService
IdempiereAssistant.java NEW - declarative AI service
IdempiereRagAugmentor.java NEW - RAG configuration

Testing the Migration

# 1. Build
mvn clean package -DskipTests

# 2. Test knowledge commands
java -jar target/idempiere-hub-runner.jar knowledge status
java -jar target/idempiere-hub-runner.jar knowledge search "create callout"

# 3. Test ask command
java -jar target/idempiere-hub-runner.jar ask "How do I create a process?"

# 4. Test MCP server (tools should still work)
java -Dquarkus.profile=mcp -jar target/idempiere-hub-runner.jar mcp-server

Rollback Plan

Keep the old code on a branch:

git checkout -b backup/plain-langchain4j
git checkout develop

If migration fails, revert:

git checkout backup/plain-langchain4j -- pom.xml src/

Consequences

Positive

Negative

Neutral

Implementation Notes (2025-12-07)

Final Version Configuration

Component Version Notes
quarkus-langchain4j 1.3.1 Stable with Quarkus 3.27.1
LangChain4j (internal) 1.6.0 Managed by quarkus-langchain4j
langchain4j-document-parser-apache-tika 1.6.0-beta12 Must match internal LangChain4j

Key Changes Made

  1. Dependencies: Replaced plain langchain4j (0.36.2) with quarkus-langchain4j (1.3.1)
  2. Configuration: Added quarkus.langchain4j.chat-model.provider=ollama and quarkus.langchain4j.embedding-model.provider=ollama
  3. EmbeddingModelProvider: Simplified to delegate to CDI-injected EmbeddingModel
  4. EmbeddingStoreProvider: Simplified to delegate to CDI-injected EmbeddingStore<TextSegment>
  5. CliRouterAgent: Added @RegisterAiService annotation with tool classes
  6. AskCommand: Simplified to inject CliRouterAgent via CDI
  7. TableTools: Renamed listTables to findTablesByEntityType to avoid duplicate tool name conflict with MCP

Version Compatibility Lesson

The quarkus-langchain4j extension manages LangChain4j versions internally. When using plain LangChain4j artifacts alongside (like langchain4j-document-parser-apache-tika), the version MUST match the internal LangChain4j version:

Provider Configuration Required

When multiple model providers are available (Ollama, Anthropic, OpenAI), you MUST specify which to use:

quarkus.langchain4j.chat-model.provider=ollama
quarkus.langchain4j.embedding-model.provider=ollama

Otherwise the build fails with "Multiple chat language model providers found".

Path: /docs/developers/architecture/idempiere-hub/023-quarkus-langchain4j-migration