LangChain4j: potenciando tus aplicaciones Java con Inteligencia Artificial (IA) y Modelos de Lenguaje de Gran Escala (LLM). Primeros pasos.

LangChain4J

0
549

LangChain4j se presenta como un aliado indispensable para integrar en nuestras aplicaciones Java, de manera muy sencilla, capacidades de Inteligencia Artificial y Modelos de Lenguaje a Gran Escala (LLM). En este tutorial descubriremos cómo unifica las APIs de distintos proveedores del LLMs, ofrece una serie de herramientas integradas y nos sumerge en un nuevo universo de posibilidades, inimaginable hace unos pocos años.

Contenido.

1. Introducción.

El objetivo de LangChain4j es simplificar la integración de capacidades de Inteligencia Artificial (IA) y Modelos de Lenguaje de Gran Escala (LLM) en aplicaciones Java.

LangChain4j comenzó su desarrollo a principios de 2023 en pleno auge de ChatGPT y debido a la falta de opciones en Java para las numerosas bibliotecas y marcos de trabajo de LLM. Aunque LangChain esté en el nombre, el proyecto es una fusión de ideas y conceptos de LangChain, Haystack, LlamaIndex y la comunidad en general, con innovaciones propias.

Dado que tanto LangChain como LangChain4j están evolucionando rápidamente, puede haber características implementadas en la versión de Python o JS/TS que aún no estén presentes en la versión de Java; no obstante, los conceptos fundamentales, su estructura general y el lenguaje de dominio son, en gran medida, los mismos en ambos frameworks.

Para facilitar su integración, el propio LangChain4j incluye integración con Quarkus y Spring Boot, en los ejemplos de este tutorial usaremos esta última.

Existen otras alternativas como Spring AI que también proporciona un api unificada en Java para distintos LLMs y prácticamente todas las capacidades de LangChain4j, si bien hay que decir que tanto el uno como el otro se encuentran en una etapa muy temprana en la que están evolucionando muy rápidamente, eso puede significar que la documentación esté incompleta y los ejemplos que proporcionan se queden rápidamente obsoletos o no funcionen con todos los LLMs.

2. Entorno.

El tutorial está escrito usando el siguiente entorno:

  • Hardware: Portátil MacBook Pro 16′ (Apple M1 Pro, 32GB DDR4).
  • Sistema Operativo: Mac OS Sonoma 14.2.1

3.Configuración

Suponiendo que estamos trabajando con Maven lo primero, como siempre, será añadir las dependencias de las librerías que vamos a usar en nuestro pom.xml, a saber:

<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j</artifactId>
  <version>0.27.1</version>
</dependency>

 

Nuestra intención no es más que hacer una primera prueba de concepto, unos primeros pasos con Spring Boot y, lo siguiente, será añadir la dependencia del starter para trabajar con la plataforma de NLP (Procesamiento de Lenguaje Natural) del LLM que hayamos seleccionado, en nuestro caso OpenAI con GPT-3.5.

<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-open-ai-spring-boot-starter</artifactId>
  <version>0.27.1</version>
</dependency>

 

A continuación basta con añadir las siguientes propiedades en nuestro application.yml

langchain4j:
open-ai:
  chat-model:
    api-key: [YOUR-OPEN-AI-API-KEY-HERE]
    model-name: gpt-3.5-turbo
    temperature: 0.0
    timeout: PT60S
    log-requests: true
    log-responses: true

 

Solo tendrías que configurar una api-key generada en la web de OpenAI para tus pruebas, tienes un mes gratis con unos créditos de 5$ de regalo.

3.1. Configuración de LangChain4j para LocalAI.

Ahora mismo LangChain4j proporciona un starter de Spring Boot tanto para OpenAI como Ollama, aunque también hay una PR en vuelo para proporcionar un starter para Azure AI.

Si bien podríamos generarnos un starter o proporcionar soporte para cualquier otra plataforma de NLP soportada por la librería de una manera muy sencilla, por ejemplo para para LocalAI, en vez de usar OpenAI con GPT-3, podemos optar por hacer pruebas localmente bajo cualquiera de los modelos de LLM soportados por LocalAI:

Añadiríamos la dependencia de la librería en el pom.xml:

<dependency>
  <groupId>dev.langchain4j</groupId>
  <artifactId>langchain4j-local-ai</artifactId>
  <version>0.27.1</version>
</dependency>

 

Creamos una clase para las propiedades similar a la del resto de starters:

package com.izertis.ai.config;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.NestedConfigurationProperty;

@Getter
@Setter
@ConfigurationProperties(prefix = LocalAiProperties.PREFIX)
public class LocalAiProperties {

  static final String PREFIX = "langchain4j.local-ai";

  @NestedConfigurationProperty
  ChatModelProperties chatModel;
}

 

Idem para las propiedades del chat, donde permitiremos la configuración de las propiedades de cada framework, en este caso, al menos:

package com.izertis.ai.config;

import lombok.Getter;
import lombok.Setter;

import java.time.Duration;

@Getter
@Setter
public class ChatModelProperties {

  String baseUrl;

  String modelName;

  Double temperature;

  Duration timeout;

}

 

Y configuraremos un bean de soporte de tip ChatLanguageModel cuya implementación sea para el modelo de LocalAI.

package com.izertis.ai.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;
import java.util.Scanner;
import java.util.Set;

@Configuration
@EnableConfigurationProperties(LocalAiProperties.class)
public class AiConfig {

  @Bean
  @ConditionalOnProperty("langchain4j.local-ai.chat-model.base-url")
  ChatLanguageModel openAiChatModel(LocalAiProperties properties) {
      final ChatModelProperties chatModelProperties = properties.getChatModel();
      return LocalAiChatModel.builder()
              .baseUrl(chatModelProperties.getBaseUrl())
              .modelName(chatModelProperties.getModelName())
              .temperature(chatModelProperties.getTemperature())
              .logRequests(true) // TODO: configure also using properties
              .logResponses(true)
              .build();
  }
}

 

Por último, configuraremos las propiedades del LLM en nuestro application.yml

langchain4j:
local-ai:
  chat-model:
    model-name: ggml-gpt4all-j
    temperature: 0.0
    timeout: PT60S
    log-requests: true
    log-responses: true

En otro tutorial podríamos ver cómo correr LocalAI u Ollama en local para hacer nuestras pruebas, en este tutorial nos vamos a centrar en completarlas con OpenAI.

4. Nuestra primera IA, !chispas!.

Una vez tenemos configurado el starter y la configuración del LLM, lo siguiente que haremos es una prueba basada en un chat interactivo por línea de comandos, también dejaremos una interfaz de usuario mejor para otro tutorial.

Añadiremos el siguiente bean a nuestra configuración:

  @Bean
  ApplicationRunner interactiveChatRunner(WorkaholicsAgent agent) {
      return args -> {
          Scanner scanner = new Scanner(System.in);

          while (true) {
              System.out.print("User: ");
              String userMessage = scanner.nextLine();

              if ("exit".equalsIgnoreCase(userMessage)) {
                  break;
              }

              String agentMessage = agent.chat(userMessage);
              System.out.println("Agent: " + agentMessage);
          }

          scanner.close();
      };
  }

 

Y crearemos un servicio de inteligencia artificial basado en el modelo de lenguaje configurado en las propiedades a través del starter, al que podemos configurar también cierta memoria en relación a los mensajes del chat. El servicio de LLM es stateless, es decir, no mantiene estado entre llamadas, si queremos que conteste con cierto contexto en relación a las preguntas anteriores, debemos almacenarlas y reenviarlas en cada petición, con langChain4j es tan simple como configurar un ChatMemory, en este caso de tipo MessageWindowChatMemory, con un máximo de 20 mensajes.

  @Bean
  public WorkaholicsAgent customerSupportAgent(ChatLanguageModel chatLanguageModel) {
      return AiServices.builder(WorkaholicsAgent.class)
              .chatLanguageModel(chatLanguageModel)
              .chatMemory(MessageWindowChatMemory.withMaxMessages(20))
              .build();
  }

 

El servicio de inteligencia artificial se basa en el concepto de agente, en el cuál podemos configurar cierto comportamiento a través de un mensaje de sistema, que acompañará siempre a nuestras llamadas al LLM, será el primer mensaje del mismo, para que siempre tenga un contexto inicial de cómo queremos que se comporte.

package com.izertis.ai;

import dev.langchain4j.service.SystemMessage;

public interface WorkaholicsAgent {

  @SystemMessage({
          "You are an expert agent in the tutorials published by the best technology site in Spanish called "adictos al trabajo",",
          "accessible through the url "https://www.adictosaltrabajo.com/".",
          "You will provide accurate information on their content and examples based solely on the documentation provided on the site itself."
  })
  String chat(String userMessage);

}

 

Con lo que llevamos de tutorial, ya podemos levantar la aplicación y empezar a interactuar con el chat para realizar preguntas como las siguientes:

User: hola, quien eres?
Agent: ¡Hola! Soy un agente experto en los tutoriales publicados por el mejor sitio de tecnología en español llamado "adictos al trabajo". ¿En qué puedo ayudarte hoy?

User: cuáles son los últimos tutoriales publicados en adictos al trabajo?
Agent: Lamentablemente, no tengo acceso en tiempo real a la información sobre los últimos tutoriales publicados en el sitio "adictos al trabajo". Te recomendaría visitar su página principal en https://www.adictosaltrabajo.com/ para ver los tutoriales más recientes que hayan sido publicados. Allí podrás encontrar una lista actualizada de los últimos contenidos disponibles en el sitio.

 

El resultado es tan decepcionante como este, no tiene ni idea del contenido de los tutoriales de adictos al trabajo,… 🙁

Y es que un LLM (Modelo de Lenguaje de Gran Escala, por sus siglas en inglés) es una herramienta de procesamiento de lenguaje natural que utiliza inteligencia artificial para comprender y generar texto en lenguaje humano. Estos modelos, como GPT-3.5, son entrenados en grandes cantidades de datos textuales (hasta 170 billones de parámetros) y pueden realizar tareas diversas, como responder preguntas, generar texto creativo, traducir idiomas y más.

Sin embargo, es importante tener en cuenta que los LLM, incluyendo GPT, no tienen acceso a información en tiempo real. En concreto GPT-3.5 se detiene en enero de 2022, y no tienen la capacidad de acceder a eventos actuales o proporcionar información actualizada después de la fecha en la que están entrenados.

5. Ingestando nuestra propia información: embeddings (Vector) stores.

Si queremos que nuestra inteligencia artificial tenga almacenada nuestra propia información actualizada podríamos entrenar y mantener nuestro propio LLM, pero eso sería muy costoso; para esta tarea en concreto podemos usar nuestros propios embeddings.

Un embedding es una representación numérica de palabras o frases en un espacio vectorial. Este enfoque transforma palabras o secuencias de palabras en vectores de números reales, de tal manera que las relaciones semánticas entre las palabras se preservan en el espacio vectorial.

Cuando interactuamos con la inteligencia artificial podemos configurar nuestro propio embedding como una fuente más de sus recursos, de tal modo que buscará similitudes semánticas en nuestro propio repositorio para proporcionar esa información textual al LLM antes de proporcionar una respuesta.

Con LangChain4j, configurar un embedding es tan simple como configurar los siguientes beans, haremos uso de un embedding en memoria pero, claro está, para un entorno productivo deberíamos almacenar esa información en un almacenamiento persistente y tenemos múltiples opciones: ChromaDB, Elasticsearch, Milvus, Neo4j, OpenSearch, Pinecone, Qdrant, Redis, Vespa, Weaviate,…

  @Bean
  EmbeddingModel embeddingModel() {
      return new AllMiniLmL6V2EmbeddingModel();
  }

  @Bean
  EmbeddingStore embeddingStore(EmbeddingModel embeddingModel, ResourceLoader resourceLoader) throws IOException {
      return new InMemoryEmbeddingStore();
  }

  @Bean
  ContentRetriever contentRetriever(EmbeddingStore embeddingStore, EmbeddingModel embeddingModel) {

      // You will need to adjust these parameters to find the optimal setting, which will depend on two main factors:
      // - The nature of your data
      // - The embedding model you are using
      int maxResults = 1;
      double minScore = 0.6;

      return EmbeddingStoreContentRetriever.builder()
              .embeddingStore(embeddingStore)
              .embeddingModel(embeddingModel)
              .maxResults(maxResults)
              .minScore(minScore)
              .build();
  }

 

A continuación modificamos ligeramente nuestra IA para configurar el content retriever de nuestro embedding:

    @Bean
  public WorkaholicsAgent workaholicsAgent(ChatLanguageModel chatLanguageModel,
                                               ContentRetriever contentRetriever) {
      return AiServices.builder(WorkaholicsAgent.class)
              .chatLanguageModel(chatLanguageModel)
              .chatMemory(MessageWindowChatMemory.withMaxMessages(20))
              .contentRetriever(contentRetriever)
              .build();
  }

 

Y, lo más importante es añadir información sobre nuestro negocio al embedding, en nuestro caso vamos a probar a ingestar algunos tutoriales de adictos al trabajo, como sigue:

package com.izertis.ai.config;

import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.document.loader.UrlDocumentLoader;
import dev.langchain4j.data.document.parser.TextDocumentParser;
import dev.langchain4j.data.document.splitter.DocumentSplitters;
import dev.langchain4j.data.document.transformer.HtmlTextExtractor;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.openai.OpenAiTokenizer;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.EmbeddingStoreIngestor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import java.util.Map;

import static dev.langchain4j.model.openai.OpenAiModelName.GPT_3_5_TURBO;

@Component
@Slf4j
public class WebCrawler {

  @Autowired
  private EmbeddingStore embeddingStore;

  @Autowired
  private EmbeddingModel embeddingModel;

  @PostConstruct
  void init(){
      ingestUrl("https://www.adictosaltrabajo.com/2023/08/21/devops-eso-es-cosa-del-pasado-conoce-mlops/");
      ingestUrl("https://www.adictosaltrabajo.com/2023/07/27/nltk-python/");
      ingestUrl("https://www.adictosaltrabajo.com/2023/05/10/como-ia-puede-mejorar-eficiencia-programador/");
      ingestUrl("https://www.adictosaltrabajo.com/2023/05/06/diagramas-de-arquitectura-con-c4-model/");
      ingestUrl("https://www.adictosaltrabajo.com/2023/05/12/structurizr-para-generar-diagramas-de-arquitectura-con-c4-model/");
  }

  private void ingestUrl(String url) {
      log.info("ingesting {} ", url);
      final Document document = UrlDocumentLoader.load(url, new TextDocumentParser());
      final HtmlTextExtractor transformer = new HtmlTextExtractor(".td-container", Map.of("title", "h1.entry-title", "author", ".td-post-author-name", "date", ".td-post-date", "visits", ".td-post-views"), true);
      final var transformedDocument = transformer.transform(document);

      DocumentSplitter splitter = DocumentSplitters.recursive(100, 5, new OpenAiTokenizer(GPT_3_5_TURBO));
      final EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
              .documentSplitter(splitter)
              .embeddingModel(embeddingModel)
              .embeddingStore(embeddingStore)
              .build();
      ingestor.ingest(transformedDocument);
  }
}

No es cuestión de hacer un web scraping de toda la página de adictos al trabajo, para el propósito de este primer ejercicio nos basta con ingestar unos cuántos tutoriales. Debemos prestar atención al parser de html para seleccionar, con un selector de css el nodo del árbol DOM que queremos ingestar, no nos interesa extraer la información repetida de la cabecera y pie de página y, así mismo, podemos configurar ciertos metadatos en la extracción como el título, el autor, la fecha, las visitas, quizás no lo usaremos en este primer tutorial, pero lo dejaremos preparado para más adelante.

Tenemos, de fábrica, extractores de contenido en diversos formatos y tokenizadores que permitirán la extracción e ingesta de información, de una manera adecuada y acorde a nuestro LLM.

Y ahora vamos a ver cómo responde:

User: qué tutoriales se han publicado últimamente en adictos al trabajo?
Agent: Recientemente se ha publicado el tutorial "Potenciando el Potencial: Cómo la IA puede mejorar la eficiencia de un programador" en "adictos al trabajo". Puedes encontrar más información sobre este tutorial en el siguiente enlace: [Potenciando el Potencial: Cómo la IA puede mejorar la eficiencia de un programador](https://www.adictosaltrabajo.com/category/tutoriales/)

User: qué información tienes sobre mlops?
Agent: En el contexto de MLOps, se destaca la automatización y simplificación de la implementación y despliegue de modelos de Machine Learning en producción. Una práctica común es el despliegue automatizado utilizando lo que se conoce como pipeline de entrenamiento de ML. Esta automatización y simplificación son similares a las prácticas de entrega continua o despliegue continuo (CD) en otros contextos de desarrollo de software.

User: donde puedo leer más sobre mlops?
Agent: Puedes leer más sobre MLOps en el artículo titulado "DevOps, eso es cosa del pasado: conoce MLOps" publicado en el sitio web de "adictos al trabajo" en la fecha 21 de agosto de 2023. Puedes acceder al artículo a través del siguiente enlace: [DevOps, eso es cosa del pasado: conoce MLOps](https://www.adictosaltrabajo.com/2023/08/21/devops-eso-es-cosa-del-pasado-conoce-mlops/#respond)

User: qué información tienes sobre c4m y structurizr?
Agent: Structurizr está diseñado para ayudar al modelado con C4 model, cuyo objetivo es la visualización de la arquitectura de nuestro software desde distintos puntos de vista usando un modelo único. Puedes encontrar más información sobre este tema en el siguiente enlace: [Diagramas de arquitectura con C4 model](https://www.adictosaltrabajo.com/2023/05/06/diagramas-de-arquitectura-con-c4-model/)

User: y qué librería puedo usar para generar modelos de c4m?
Agent: Puedes utilizar Structurizr para generar diagramas de arquitectura con C4 model. Puedes encontrar más información sobre cómo utilizar Structurizr para este propósito en el siguiente enlace: [Structurizr para generar diagramas de arquitectura con C4 model](https://www.adictosaltrabajo.com/2023/05/06/diagramas-de-arquitectura-con-c4-model/)

 

Ahora si tiene algo más de contexto sobre las publicaciones en base a la información que hemos ingestado, pero aún no sabría contestar a preguntas del tipo: ¿cuántos tutoriales hay publicados? o ¿cuál es el tutorial qué más visitas tiene?, vemos que nos contesta con cierto contexto pero cuando le preguntamos por el último tutorial publicado, nos está devolviendo la información del penúltimo y, es que, en realidad el LLM no tiene ningún contexto temporal a no ser que le informemos de ello.

Podemos modificar el prompt del sistema para informarle de la fecha actual de la siguiente forma:

    @SystemMessage({
            "You are an expert agent in the tutorials published by the best technology site in Spanish called \"adictos al trabajo\",",
            "accessible through the url \"https://www.adictosaltrabajo.com/\".",
            "You will provide accurate information on their content and examples based solely on the documentation provided on the site itself.",
            "Today is {{current_date}}."
    })
    String chat(String userMessage);

 

Si bien, aún no anterior, si queremos que nos devuelva la información correcta deberíamos convertir la información no estructurada que estamos ingestando en información estructurada o saber cómo explotar los metadatos de los documentos que estamos ingestando.

De momento, como una primera prueba de concepto y sobre todo, si queremos ver cómo funciona activando los logs de request y response, para analizar cómo decide de dónde obtener la información y qué información envía al LLM, nos sirve para ir trasteando.

Para la recuperación del contenido textual, la clave está en el tokenizador, en generar el vector de la misma forma que lo hace el LLM que hemos seleccionado y en la parametrización del retriever.

6. Referencias.

7. Conclusiones.

En estos primeros pasos hemos visto cómo realizar una primera configuración de langChain4J para Spring Boot ingestando una serie de documentos para probar la recuperación de información textual en base al procesamiento de los mismos.

Salvando las distancias, sobre todo temporales, algo similar sin hacer un procesamiento del lenguaje, más allá de analizar raíces semánticas, configurar filtros de stop words e indexar contenidos de documentos binarios, ya lo hacíamos hace casi 20 años con librerías y frameworks como lucene y lius.

La potencia actual es como combinar esa recuperación textual de contenidos con un motor de procesamiento del lenguaje que devuelva una respuesta, en base a nuestros propios contenidos, en un lenguaje conversacional.

Y esto no ha hecho más que empezar, en breve publicaremos más tutoriales analizando todo el potencial de la librería.

Stay tuned!

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad