ADR-023: Migration from Plain LangChain4j to Quarkus LangChain4j
Status
Accepted (Implemented)
Date
2025-12-07
Deciders
- Development Team
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:
- Declarative AI services via
@RegisterAiService - Native configuration via
application.properties - Built-in RAG support with
@RetrievalAugmentor - Dev UI for testing AI interactions
- Better Quarkus integration (CDI, native build, dev mode)
Decision Drivers
- Simplicity: Declarative over imperative configuration
- Maintainability: Framework handles boilerplate
- Native Support: Better GraalVM native image compatibility
- Developer Experience: Dev UI, hot reload, unified config
- Consistency: Single framework for AI capabilities
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
- Less Boilerplate: Framework handles model/store creation
- Unified Config: All AI settings in
application.properties - Dev UI: Interactive testing at
http://localhost:8080/q/dev-ui - Native Build: Better GraalVM support
- Hot Reload: Model changes in dev mode
Negative
- Version Coupling: Tied to Quarkus LangChain4j release cycle
- Less Control: Framework decisions may not fit all cases
- Learning Curve: New annotations and patterns
Neutral
- Some custom code still needed (ingestors, custom config)
- MCP tools remain unchanged (separate framework)
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
- Dependencies: Replaced plain langchain4j (0.36.2) with quarkus-langchain4j (1.3.1)
- Configuration: Added
quarkus.langchain4j.chat-model.provider=ollamaandquarkus.langchain4j.embedding-model.provider=ollama - EmbeddingModelProvider: Simplified to delegate to CDI-injected
EmbeddingModel - EmbeddingStoreProvider: Simplified to delegate to CDI-injected
EmbeddingStore<TextSegment> - CliRouterAgent: Added
@RegisterAiServiceannotation with tool classes - AskCommand: Simplified to inject
CliRouterAgentvia CDI - TableTools: Renamed
listTablestofindTablesByEntityTypeto 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:
- quarkus-langchain4j 1.3.1 → LangChain4j 1.6.0 → use tika 1.6.0-beta12
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".