Gemini en Java con Vertex AI y LangChain4j

1. Introducción

Este codelab se enfoca en el modelo grande de lenguaje (LLM) de Gemini, alojado en Vertex AI en Google Cloud. Vertex AI es una plataforma que abarca todos los productos, servicios y modelos de aprendizaje automático disponibles en Google Cloud.

Usarás Java para interactuar con la API de Gemini usando el framework de LangChain4j. Verás ejemplos concretos para aprovechar el LLM para responder preguntas, generar ideas, extraer entidades y contenido estructurado, generar aumento de recuperación y llamar a funciones.

¿Qué es la IA generativa?

La IA generativa se refiere al uso de inteligencia artificial para crear contenido nuevo, como texto, imágenes, música, audio y videos.

La IA generativa usa modelos grandes de lenguaje (LLM) que pueden realizar varias tareas a la vez y de manera predeterminada, como resúmenes, preguntas y respuestas, clasificación y mucho más. Con un entrenamiento mínimo, los modelos de base se pueden adaptar a casos de uso segmentados con muy pocos ejemplos de datos.

¿Cómo funciona la IA generativa?

La IA generativa usa un modelo de aprendizaje automático (AA) para aprender los patrones y relaciones en un conjunto de datos de contenido creado por humanos. Luego, usa los patrones aprendidos para generar contenido nuevo.

La forma más común de entrenar un modelo de IA generativa es con el aprendizaje supervisado. Al modelo se le asigna un conjunto de contenido creado por humanos y las etiquetas correspondientes. Luego, aprende a generar contenido similar al contenido creado por seres humanos.

¿Cuáles son las aplicaciones comunes de IA generativa?

Estos son algunos usos de la IA generativa:

  • Optimiza las interacciones con los clientes a través de experiencias de búsqueda y chat mejoradas.
  • Explora grandes cantidades de datos no estructurados a través de interfaces conversacionales y resúmenes.
  • Brinda asistencia con tareas repetitivas, como responder solicitudes de propuestas, localizar contenido de marketing en diferentes idiomas, revisar el cumplimiento de los contratos de los clientes y mucho más.

¿Qué ofertas de IA generativa tiene Google Cloud?

Con Vertex AI, puedes interactuar con los modelos de base, incorporarlos y personalizarlos en tus aplicaciones con poca o ninguna experiencia en AA. Puedes acceder a los modelos de base en Model Garden, ajustarlos con una IU sencilla en Vertex AI Studio o usar modelos en un notebook de ciencia de datos.

Vertex AI Search and Conversation ofrece a los desarrolladores la forma más rápida de compilar motores de búsqueda y chatbots potenciados por IA generativa.

Con la tecnología de Gemini, Gemini para Google Cloud es un colaborador potenciado por IA, disponible en Google Cloud y en los IDE para ayudarte a realizar más tareas con mayor rapidez. Gemini Code Assist te permite completar y generar código, y explicaciones del código, y te permite chatear con él para hacer preguntas técnicas.

¿Qué es Gemini?

Gemini es una familia de modelos de IA generativos desarrollados por Google DeepMind que están diseñados para casos de uso multimodales. Multimodal significa que puede procesar y generar diferentes tipos de contenido, como texto, código, imágenes y audio.

b9913d011999e7c7.png

Gemini viene en diferentes variaciones y tamaños:

  • Gemini Ultra: La versión más grande y capaz de realizar tareas complejas.
  • Gemini Flash: Es el más rápido y rentable, y está optimizado para tareas de alto volumen.
  • Gemini Pro: Empresa mediana, optimizada para escalar en varias tareas.
  • Gemini Nano: Es el más eficiente, diseñado para tareas en el dispositivo.

Características clave:

  • Multimodalidad: La capacidad de Gemini para comprender y manejar múltiples formatos de información es un paso significativo más allá de los modelos de lenguaje tradicionales de solo texto.
  • Rendimiento: Gemini Ultra tiene un mejor rendimiento que las soluciones de vanguardia actuales en muchas comparativas y fue el primer modelo en superar a expertos humanos en las desafiantes comparativas de MMLU (Massive Multitask Language Understanding).
  • Flexibilidad: Los diferentes tamaños de Gemini permiten que se adapte a diversos casos de uso, desde investigaciones a gran escala hasta su implementación en dispositivos móviles.

¿Cómo puedes interactuar con Gemini en Vertex AI desde Java?

Tienes estas dos opciones:

  1. La biblioteca oficial de la API de Java de Vertex AI para Gemini
  2. LangChain4j.

En este codelab, usarás el framework LangChain4j.

¿Qué es el framework de LangChain4j?

El framework LangChain4j es una biblioteca de código abierto para integrar LLM en tus aplicaciones de Java mediante la organización de varios componentes, como el LLM mismo, pero también otras herramientas, como bases de datos de vectores (para búsquedas semánticas), cargadores y divisores de documentos (para analizar documentos y aprender de ellos), analizadores de salida y mucho más.

El proyecto se inspiró en el proyecto de Python LangChain, pero su objetivo era servir a los desarrolladores de Java.

bb908ea1e6c96ac2.png

Qué aprenderás

  • Cómo configurar un proyecto de Java para usar Gemini y LangChain4j
  • Cómo enviar tu primera instrucción a Gemini de manera programática
  • Cómo transmitir respuestas de Gemini
  • Cómo crear una conversación entre un usuario y Gemini
  • Cómo usar Gemini en un contexto multimodal mediante el envío de imágenes y texto
  • Cómo extraer información estructurada útil de contenido no estructurado
  • Cómo manipular las plantillas de instrucciones
  • Cómo realizar una clasificación de texto, como un análisis de opiniones
  • Cómo chatear con tus propios documentos (generación aumentada de recuperación)
  • Cómo extender tus chatbots con llamadas a función
  • Cómo usar Gemma localmente con Ollama y TestContainers

Requisitos

  • Conocimientos del lenguaje de programación Java
  • Un proyecto de Google Cloud
  • Un navegador, como Chrome o Firefox

2. Configuración y requisitos

Configuración del entorno de autoaprendizaje

  1. Accede a Google Cloud Console y crea un proyecto nuevo o reutiliza uno existente. Si aún no tienes una cuenta de Gmail o de Google Workspace, debes crear una.

fbef9caa1602edd0.png

a99b7ace416376c4.png

5e3ff691252acf41.png

  • El Nombre del proyecto es el nombre visible de los participantes de este proyecto. Es una cadena de caracteres que no se utiliza en las APIs de Google. Puedes actualizarla cuando quieras.
  • El ID del proyecto es único en todos los proyectos de Google Cloud y es inmutable (no se puede cambiar después de configurarlo). La consola de Cloud genera automáticamente una cadena única. Por lo general, no importa cuál sea. En la mayoría de los codelabs, deberás hacer referencia al ID de tu proyecto (suele identificarse como PROJECT_ID). Si no te gusta el ID que se generó, podrías generar otro aleatorio. También puedes probar uno propio y ver si está disponible. No se puede cambiar después de este paso y se usa el mismo durante todo el proyecto.
  • Recuerda que hay un tercer valor, un número de proyecto, que usan algunas APIs. Obtén más información sobre estos tres valores en la documentación.
  1. A continuación, deberás habilitar la facturación en la consola de Cloud para usar las APIs o los recursos de Cloud. Ejecutar este codelab no costará mucho, tal vez nada. Para cerrar recursos y evitar que se generen cobros más allá de este instructivo, puedes borrar los recursos que creaste o borrar el proyecto. Los usuarios nuevos de Google Cloud son aptos para participar en el programa Prueba gratuita de $300.

Inicia Cloud Shell

Si bien Google Cloud se puede operar de manera remota desde tu laptop, en este codelab usarás Cloud Shell, un entorno de línea de comandos que se ejecuta en la nube.

Activar Cloud Shell

  1. En la consola de Cloud, haz clic en Activar Cloud Shell853e55310c205094.png.

8c1dabeca90e44e5.png

Si es la primera vez que inicias Cloud Shell, verás una pantalla intermedia que describe en qué consiste. Si apareció una pantalla intermedia, haz clic en Continuar.

9c92662c6a846a5c.png

El aprovisionamiento y la conexión a Cloud Shell solo tomará unos minutos.

9f0e51b578fecce5.png

Esta máquina virtual está cargada con todas las herramientas de desarrollo necesarias. Ofrece un directorio principal persistente de 5 GB y se ejecuta en Google Cloud, lo que mejora considerablemente el rendimiento de la red y la autenticación. Gran parte de tu trabajo en este codelab, si no todo, se puede hacer con un navegador.

Una vez que te conectes a Cloud Shell, deberías ver que estás autenticado y que el proyecto está configurado con tu ID del proyecto.

  1. En Cloud Shell, ejecuta el siguiente comando para confirmar que tienes la autenticación:
gcloud auth list

Resultado del comando

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Ejecuta el siguiente comando en Cloud Shell para confirmar que el comando de gcloud conoce tu proyecto:
gcloud config list project

Resultado del comando

[core]
project = <PROJECT_ID>

De lo contrario, puedes configurarlo con el siguiente comando:

gcloud config set project <PROJECT_ID>

Resultado del comando

Updated property [core/project].

3. Cómo preparar tu entorno de desarrollo

En este codelab, usarás la terminal y el editor de Cloud Shell para desarrollar tus programas de Java.

Habilita las APIs de Vertex AI

En la consola de Google Cloud, asegúrate de que el nombre de tu proyecto aparezca en la parte superior de la consola de Google Cloud. Si no lo está, haz clic en Seleccionar un proyecto para abrir el Selector de proyectos y selecciona el proyecto deseado.

Puedes habilitar las APIs de Vertex AI desde la sección de Vertex AI de la consola de Google Cloud o desde la terminal de Cloud Shell.

Para habilitarlo desde la consola de Google Cloud, primero ve a la sección Vertex AI del menú de la consola de Google Cloud:

451976f1c8652341.png

Haz clic en Habilitar todas las APIs recomendadas en el panel de Vertex AI.

Esto habilitará varias APIs, pero la más importante para el codelab es aiplatform.googleapis.com.

Como alternativa, también puedes habilitar esta API desde la terminal de Cloud Shell con el siguiente comando:

gcloud services enable aiplatform.googleapis.com

Clonar el repositorio de GitHub

En la terminal de Cloud Shell, clona el repositorio de este codelab:

git clone https://github.com/glaforge/gemini-workshop-for-java-developers.git

Para comprobar que el proyecto esté listo para ejecutarse, intenta ejecutar el llamado “Hello World” .

Asegúrate de estar en la carpeta de nivel superior:

cd gemini-workshop-for-java-developers/ 

Crea el wrapper de Gradle:

gradle wrapper

Ejecuta con gradlew:

./gradlew run

Deberías ver el siguiente resultado:

..
> Task :app:run
Hello World!

Cómo abrir y configurar Cloud Editor

Abre el código con el editor de Cloud Code desde Cloud Shell:

42908e11b28f4383.png

En el editor de Cloud Code, selecciona File -> para abrir la carpeta del código fuente del codelab. Open Folder y apunta a la carpeta de origen del codelab (p. ej., /home/username/gemini-workshop-for-java-developers/).

Cómo instalar Gradle para Java

Para que el editor de código en la nube funcione correctamente con Gradle, instala la extensión Gradle para Java.

Primero, ve a la sección Proyectos de Java y presiona el signo más:

84d15639ac61c197.png

Selecciona Gradle for Java:

34d6c4136a3cc9ff.png

Selecciona la versión de Install Pre-Release:

3b044fb450cccb7.png

Una vez instalado, deberías ver los botones Disable y Uninstall:

46410fe86d777f9c.png

Por último, limpia el lugar de trabajo para que se aplique la nueva configuración:

31e27e9bb61d975d.png

Se te pedirá que vuelvas a cargar y borrar el taller. Elige Reload and delete:

d6303bc49e391dc.png

Si abres uno de los archivos, por ejemplo App.java, deberías ver que el editor funciona correctamente con resaltado de sintaxis:

fed1b1b5de0dff58.png

Ya está todo listo para que ejecutes algunas muestras con Gemini.

Configura variables de entorno

Para abrir una terminal nueva en el editor de Cloud Code, selecciona Terminal -> New Terminal Configura las dos variables de entorno necesarias para ejecutar los ejemplos de código:

  • PROJECT_ID: Es el ID del proyecto de Google Cloud.
  • LOCATION: Es la región en la que se implementa el modelo de Gemini.

Exporta las variables de la siguiente manera:

export PROJECT_ID=$(gcloud config get-value project)
export LOCATION=us-central1

4. Primera llamada al modelo de Gemini

Ahora que el proyecto está configurado correctamente, es momento de llamar a la API de Gemini.

Observa QA.java en el directorio app/src/main/java/gemini/workshop:

package gemini.workshop;

import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.model.chat.ChatLanguageModel;

public class QA {
    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-001")
            .build();

        System.out.println(model.generate("Why is the sky blue?"));
    }
}

En este primer ejemplo, debes importar la clase VertexAiGeminiChatModel, que implementa la interfaz ChatModel.

En el método main, configura el modelo de lenguaje del chat con el compilador para VertexAiGeminiChatModel y especifica lo siguiente:

  • Proyecto
  • Ubicación
  • Nombre del modelo (gemini-1.5-flash-001).

Ahora que el modelo de lenguaje está listo, puedes llamar al método generate() y pasar tu instrucción, tu pregunta o instrucciones para enviarlas al LLM. Aquí, haces una pregunta simple sobre qué hace que el cielo sea azul.

Siéntete libre de cambiar esta consigna para probar diferentes preguntas o tareas.

Ejecuta la muestra en la carpeta raíz del código fuente:

./gradlew run -q -DjavaMainClass=gemini.workshop.QA

Deberías ver un resultado similar al siguiente:

The sky appears blue because of a phenomenon called Rayleigh scattering.
When sunlight enters the atmosphere, it is made up of a mixture of
different wavelengths of light, each with a different color. The
different wavelengths of light interact with the molecules and particles
in the atmosphere in different ways.

The shorter wavelengths of light, such as those corresponding to blue
and violet light, are more likely to be scattered in all directions by
these particles than the longer wavelengths of light, such as those
corresponding to red and orange light. This is because the shorter
wavelengths of light have a smaller wavelength and are able to bend
around the particles more easily.

As a result of Rayleigh scattering, the blue light from the sun is
scattered in all directions, and it is this scattered blue light that we
see when we look up at the sky. The blue light from the sun is not
actually scattered in a single direction, so the color of the sky can
vary depending on the position of the sun in the sky and the amount of
dust and water droplets in the atmosphere.

¡Felicitaciones! Hiciste tu primera llamada a Gemini.

Respuesta de transmisión

¿Notaste que la respuesta se dio de una sola vez, después de unos segundos? También es posible obtener la respuesta de forma progresiva gracias a la variante de respuesta de transmisión. El modelo muestra la respuesta de transmisión parte por parte a medida que está disponible.

En este codelab, seguiremos con la respuesta que no es de transmisión, pero veamos la respuesta de transmisión para ver cómo se puede hacer.

En StreamQA.java, en el directorio app/src/main/java/gemini/workshop, puedes ver la respuesta de transmisión en acción:

package gemini.workshop;

import dev.langchain4j.model.chat.StreamingChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiStreamingChatModel;
import dev.langchain4j.model.StreamingResponseHandler;

public class StreamQA {
    public static void main(String[] args) {
        StreamingChatLanguageModel model = VertexAiGeminiStreamingChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-001")
            .build();
        
        model.generate("Why is the sky blue?", new StreamingResponseHandler<>() {
            @Override
            public void onNext(String text) {
                System.out.println(text);
            }

            @Override
            public void onError(Throwable error) {
                error.printStackTrace();
            }
        });
    }
}

Esta vez, importamos las variantes de clase de transmisión VertexAiGeminiStreamingChatModel, que implementa la interfaz StreamingChatLanguageModel. También necesitarás un StreamingResponseHandler.

Esta vez, la firma del método generate() es un poco diferente. En lugar de mostrar una cadena, el tipo de datos que se muestra es nulo. Además de la instrucción, debes pasar un controlador de respuestas de transmisión. Aquí, implementarás la interfaz creando una clase interna anónima, con dos métodos onNext(String text) y onError(Throwable error). Se llama a la primera cada vez que hay una nueva parte de la respuesta disponible, mientras que a la última solo se la llama si alguna vez se produce un error.

Ejecuta:

./gradlew run -q -DjavaMainClass=gemini.workshop.StreamQA

Obtendrás una respuesta similar a la de la clase anterior, pero esta vez notarás que la respuesta aparece de forma progresiva en la shell, en lugar de esperar a que se muestre la respuesta completa.

Configuración adicional

Para la configuración, solo definimos el proyecto, la ubicación y el nombre del modelo, pero hay otros parámetros que puedes especificar para el modelo:

  • temperature(Float temp): Indica qué tan creativa quieres que sea la respuesta (0 es poco creativa y, a menudo, más fáctica, mientras que 1 es para más resultados de creatividades).
  • topP(Float topP): Para seleccionar las palabras posibles cuya probabilidad total suma ese número de punto flotante (entre 0 y 1)
  • topK(Integer topK): Permite seleccionar de forma aleatoria una palabra de la cantidad máxima de palabras posibles para completar el texto (de 1 a 40).
  • maxOutputTokens(Integer max): Especifica la longitud máxima de la respuesta que da el modelo (por lo general, 4 tokens representan aproximadamente 3 palabras)
  • maxRetries(Integer retries): En caso de que estés al límite de la cuota de solicitudes por tiempo o que la plataforma tenga algún problema técnico, puedes hacer que el modelo vuelva a intentar la llamada 3 veces.

Hasta ahora, le hiciste una sola pregunta a Gemini, pero también puedes tener una conversación de varios turnos. Eso es lo que explorarás en la siguiente sección.

5. Chatea con Gemini

En el paso anterior, hiciste una sola pregunta. Es el momento de tener una conversación real entre un usuario y el LLM. Cada pregunta y respuesta puede basarse en las anteriores para formar un debate real.

Observa Conversation.java en la carpeta app/src/main/java/gemini/workshop:

package gemini.workshop;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.service.AiServices;

import java.util.List;

public class Conversation {
    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-001")
            .build();

        MessageWindowChatMemory chatMemory = MessageWindowChatMemory.builder()
            .maxMessages(20)
            .build();

        interface ConversationService {
            String chat(String message);
        }

        ConversationService conversation =
            AiServices.builder(ConversationService.class)
                .chatLanguageModel(model)
                .chatMemory(chatMemory)
                .build();

        List.of(
            "Hello!",
            "What is the country where the Eiffel tower is situated?",
            "How many inhabitants are there in that country?"
        ).forEach( message -> {
            System.out.println("\nUser: " + message);
            System.out.println("Gemini: " + conversation.chat(message));
        });
    }
}

Algunas importaciones interesantes nuevas en esta clase:

  • MessageWindowChatMemory: Es una clase que ayudará a controlar el aspecto de varios turnos de la conversación y mantendrá en la memoria local las preguntas y respuestas anteriores.
  • AiServices: Es una clase que vinculará el modelo y la memoria del chat.

En el método principal, configurarás el modelo, la memoria de chat y el servicio de IA. El modelo se configura como de costumbre con la información del proyecto, la ubicación y el nombre del modelo.

Para la memoria del chat, usamos el compilador de MessageWindowChatMemory para crear una memoria que conserve los últimos 20 mensajes intercambiados. Es una ventana deslizante sobre la conversación cuyo contexto se guarda a nivel local en nuestro cliente de clase Java.

Luego, crearás el AI service que vincula el modelo del chat con la memoria del chat.

Observa cómo el servicio de IA utiliza una interfaz ConversationService personalizada que definimos, que LangChain4j implementa, toma una consulta String y muestra una respuesta String.

Ahora es momento de conversar con Gemini. Primero, se envía un saludo sencillo y, luego, una primera pregunta sobre la Torre Eiffel para saber en qué país se puede encontrar. Observa que la última oración está relacionada con la respuesta de la primera pregunta, ya que te preguntas cuántos habitantes hay en el país donde se encuentra la Torre Eiffel, sin mencionar de manera explícita el país que se dio en la respuesta anterior. Muestra que las preguntas y respuestas anteriores se envían con cada instrucción.

Ejecuta la muestra:

./gradlew run -q -DjavaMainClass=gemini.workshop.Conversation

Deberías ver tres respuestas similares a las siguientes:

User: Hello!
Gemini: Hi there! How can I assist you today?

User: What is the country where the Eiffel tower is situated?
Gemini: France

User: How many inhabitants are there in that country?
Gemini: As of 2023, the population of France is estimated to be around 67.8 million.

Puedes hacer preguntas de un solo turno o tener conversaciones de varios turnos con Gemini, pero, hasta ahora, solo se ingresaron texto. ¿Qué ocurre con las imágenes? Exploremos las imágenes en el siguiente paso.

6. Multimodalidad con Gemini

Gemini es un modelo multimodal. No solo acepta texto como entrada, sino también imágenes o incluso videos como entrada. En esta sección, verás un caso de uso para combinar imágenes y texto.

¿Crees que Gemini reconocerá a este gato?

af00516493ec9ade.png

Foto de un gato en la nieve tomada de Wikipediahttps://upload.wikimedia.org/wikipedia/commons/b/b6/Felis_catus-cat_on_snow.jpg

Observa Multimodal.java en el directorio app/src/main/java/gemini/workshop:

package gemini.workshop;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.model.output.Response;
import dev.langchain4j.data.message.ImageContent;
import dev.langchain4j.data.message.TextContent;

public class Multimodal {

    static final String CAT_IMAGE_URL =
        "https://upload.wikimedia.org/wikipedia/" +
        "commons/b/b6/Felis_catus-cat_on_snow.jpg";


    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-001")
            .build();

        UserMessage userMessage = UserMessage.from(
            ImageContent.from(CAT_IMAGE_URL),
            TextContent.from("Describe the picture")
        );

        Response<AiMessage> response = model.generate(userMessage);

        System.out.println(response.content().text());
    }
}

En las importaciones, distinguimos diferentes tipos de mensajes y contenidos. Un UserMessage puede contener un objeto TextContent y un ImageContent. Esta es la multimodalidad en juego: mezcla imágenes y texto. El modelo devuelve un Response que contiene un AiMessage.

Luego, recuperas el AiMessage de la respuesta a través de content() y, luego, el texto del mensaje gracias a text().

Ejecuta la muestra:

./gradlew run -q -DjavaMainClass=gemini.workshop.Multimodal

El nombre de la imagen ciertamente te dio una pista de lo que contenía la imagen, pero el resultado de Gemini es similar al siguiente:

A cat with brown fur is walking in the snow. The cat has a white patch of fur on its chest and white paws. The cat is looking at the camera.

Si mezclas imágenes con instrucciones de texto, se abrirán casos de uso interesantes. Puedes crear aplicaciones que pueden hacer lo siguiente:

  • Reconoce texto en imágenes.
  • Comprueba si es seguro mostrar una imagen.
  • Crea leyendas de imágenes.
  • Realiza búsquedas en una base de datos de imágenes con descripciones de texto sin formato.

Además de extraer información de imágenes, también puedes extraer información de texto no estructurado. Eso es lo que aprenderás en la siguiente sección.

7. Extrae información estructurada de texto no estructurado

Hay muchas situaciones en las que se proporciona información importante en documentos de informes, correos electrónicos u otros textos largos de forma no estructurada. Lo ideal es que puedas extraer los detalles clave contenidos en el texto no estructurado, en forma de objetos estructurados. Veamos cómo hacerlo.

Digamos que quieres extraer el nombre y la edad de una persona, según una biografía o descripción de esa persona. Puedes indicarle al LLM que extraiga JSON de texto no estructurado con una instrucción ingeniosamente ajustada (esto se suele denominar "ingeniería de instrucciones").

Echa un vistazo a ExtractData.java en app/src/main/java/gemini/workshop:

package gemini.workshop;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.service.UserMessage;

public class ExtractData {

    static record Person(String name, int age) {}

    interface PersonExtractor {
        @UserMessage("""
            Extract the name and age of the person described below.
            Return a JSON document with a "name" and an "age" property, \
            following this structure: {"name": "John Doe", "age": 34}
            Return only JSON, without any markdown markup surrounding it.
            Here is the document describing the person:
            ---
            {{it}}
            ---
            JSON:
            """)
        Person extractPerson(String text);
    }

    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-001")
            .temperature(0f)
            .topK(1)
            .build();

        PersonExtractor extractor = AiServices.create(PersonExtractor.class, model);

        Person person = extractor.extractPerson("""
            Anna is a 23 year old artist based in Brooklyn, New York. She was born and 
            raised in the suburbs of Chicago, where she developed a love for art at a 
            young age. She attended the School of the Art Institute of Chicago, where 
            she studied painting and drawing. After graduating, she moved to New York 
            City to pursue her art career. Anna's work is inspired by her personal 
            experiences and observations of the world around her. She often uses bright 
            colors and bold lines to create vibrant and energetic paintings. Her work 
            has been exhibited in galleries and museums in New York City and Chicago.    
            """
        );

        System.out.println(person.name());  // Anna
        System.out.println(person.age());   // 23
    }
}

Veamos los distintos pasos de este archivo:

  • Un registro Person se define para representar los detalles que describen a una persona ( nombre y edad).
  • La interfaz PersonExtractor se define con un método que, según una cadena de texto no estructurado, muestra una instancia de Person.
  • El extractPerson() está anotado con una anotación @UserMessage que asocia un mensaje con él. Esa es la indicación que el modelo usará para extraer la información y mostrar los detalles en forma de un documento JSON que se analizará por ti y se desordenará en una instancia de Person.

Ahora, veamos el contenido del método main():

  • Se crea una instancia del modelo de chat. Observa que usamos un temperature muy bajo de cero y una topK de solo uno para garantizar una respuesta muy determinista. Esto también ayuda al modelo a seguir mejor las instrucciones. En particular, no queremos que Gemini una la respuesta JSON con lenguaje de marcado adicional de Markdown.
  • Se crea un objeto PersonExtractor gracias a la clase AiServices de LangChain4j.
  • Luego, puedes llamar a Person person = extractor.extractPerson(...) para extraer los detalles de la persona del texto no estructurado y obtener una instancia de Person con el nombre y la edad.

Ejecuta la muestra:

./gradlew run -q -DjavaMainClass=gemini.workshop.ExtractData

Deberías ver el siguiente resultado:

Anna
23

Sí, soy Ana y tiene 23 años.

Con este enfoque de AiServices, operas con objetos de tipado fuerte. No estás interactuando directamente con el LLM. En cambio, trabajas con clases concretas, como el registro Person para representar la información personal extraída, y tienes un objeto PersonExtractor con un método extractPerson() que muestra una instancia de Person. La noción de LLM se abstrae y, como desarrollador de Java, solo manipulas clases y objetos normales.

8. Estructura instrucciones con plantillas de instrucciones

Cuando interactúas con un LLM usando un conjunto común de instrucciones o preguntas, hay una parte de esa instrucción que nunca cambia, mientras que otras contienen los datos. Por ejemplo, si quieres crear recetas, puedes usar una instrucción como "Eres un chef talentoso, crea una receta con los siguientes ingredientes: ..." y, luego, agregarías los ingredientes al final de ese texto. Para eso se usan las plantillas de instrucciones, de forma similar a las cadenas interpoladas en los lenguajes de programación. Una plantilla de instrucciones contiene marcadores de posición que puedes reemplazar por los datos correctos para una llamada específica al LLM.

Para ser más concretas, estudie TemplatePrompt.java en el directorio app/src/main/java/gemini/workshop:

package gemini.workshop;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.model.input.Prompt;
import dev.langchain4j.model.input.PromptTemplate;
import dev.langchain4j.model.output.Response;

import java.util.HashMap;
import java.util.Map;

public class TemplatePrompt {
    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-001")
            .maxOutputTokens(500)
            .temperature(0.8f)
            .topK(40)
            .topP(0.95f)
            .maxRetries(3)
            .build();

        PromptTemplate promptTemplate = PromptTemplate.from("""
            You're a friendly chef with a lot of cooking experience.
            Create a recipe for a {{dish}} with the following ingredients: \
            {{ingredients}}, and give it a name.
            """
        );

        Map<String, Object> variables = new HashMap<>();
        variables.put("dish", "dessert");
        variables.put("ingredients", "strawberries, chocolate, and whipped cream");

        Prompt prompt = promptTemplate.apply(variables);

        Response<AiMessage> response = model.generate(prompt.toUserMessage());

        System.out.println(response.content().text());
    }
}

Como de costumbre, configurarás el modelo VertexAiGeminiChatModel con un alto nivel de creatividad con una temperatura alta y también valores topP y topK altos. Luego, crea un PromptTemplate con su método estático from(). Para ello, pasa la cadena de nuestra instrucción y usa las variables de marcador de posición de llaves dobles: {{dish}} y {{ingredients}}.

Para crear la instrucción final, llama a apply(), que toma un mapa de pares clave-valor que representan el nombre del marcador de posición y el valor de cadena con el que se reemplazará.

Por último, llama al método generate() del modelo de Gemini creando un mensaje para el usuario a partir de esa instrucción con la instrucción prompt.toUserMessage().

Ejecuta la muestra:

./gradlew run -q -DjavaMainClass=gemini.workshop.TemplatePrompt

Deberías ver un resultado generado similar a este:

**Strawberry Shortcake**

Ingredients:

* 1 pint strawberries, hulled and sliced
* 1/2 cup sugar
* 1/4 cup cornstarch
* 1/4 cup water
* 1 tablespoon lemon juice
* 1/2 cup heavy cream, whipped
* 1/4 cup confectioners' sugar
* 1/4 teaspoon vanilla extract
* 6 graham cracker squares, crushed

Instructions:

1. In a medium saucepan, combine the strawberries, sugar, cornstarch, 
water, and lemon juice. Bring to a boil over medium heat, stirring 
constantly. Reduce heat and simmer for 5 minutes, or until the sauce has 
thickened.
2. Remove from heat and let cool slightly.
3. In a large bowl, combine the whipped cream, confectioners' sugar, and 
vanilla extract. Beat until soft peaks form.
4. To assemble the shortcakes, place a graham cracker square on each of 
6 dessert plates. Top with a scoop of whipped cream, then a spoonful of 
strawberry sauce. Repeat layers, ending with a graham cracker square.
5. Serve immediately.

**Tips:**

* For a more elegant presentation, you can use fresh strawberries 
instead of sliced strawberries.
* If you don't have time to make your own whipped cream, you can use 
store-bought whipped cream.

Puedes cambiar los valores de dish y ingredients en el mapa, ajustar la temperatura, topK y tokP, y volver a ejecutar el código. Esto te permitirá observar el efecto de cambiar estos parámetros en el LLM.

Las plantillas de instrucciones son una buena manera de tener instrucciones reutilizables y parametrizables para las llamadas de LLM. Puedes pasar datos y personalizar mensajes para diferentes valores que proporcionen tus usuarios.

9. Clasificación de texto con instrucciones con ejemplos limitados

Los LLM son bastante buenos para clasificar texto en diferentes categorías. Puedes ayudar a un LLM en esa tarea proporcionando algunos ejemplos de textos y sus categorías asociadas. Este enfoque suele denominarse instrucción de pocos pasos.

Observa TextClassification.java en el directorio app/src/main/java/gemini/workshop para realizar un tipo particular de clasificación de texto: análisis de opiniones.

package gemini.workshop;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.model.input.Prompt;

package gemini.workshop;

import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.model.input.Prompt;
import dev.langchain4j.model.input.PromptTemplate;
import dev.langchain4j.model.output.Response;

import java.util.Map;

public class TextClassification {
    public static void main(String[] args) {
        ChatLanguageModel model = VertexAiGeminiChatModel.builder()
            .project(System.getenv("PROJECT_ID"))
            .location(System.getenv("LOCATION"))
            .modelName("gemini-1.5-flash-001")
            .maxOutputTokens(10)
            .maxRetries(3)
            .build();

        PromptTemplate promptTemplate = PromptTemplate.from("""
            Analyze the sentiment of the text below. Respond only with one word to describe the sentiment.

            INPUT: This is fantastic news!
            OUTPUT: POSITIVE

            INPUT: Pi is roughly equal to 3.14
            OUTPUT: NEUTRAL

            INPUT: I really disliked the pizza. Who would use pineapples as a pizza topping?
            OUTPUT: NEGATIVE

            INPUT: {{text}}
            OUTPUT: 
            """);

        Prompt prompt = promptTemplate.apply(
            Map.of("text", "I love strawberries!"));

        Response<AiMessage> response = model.generate(prompt.toUserMessage());

        System.out.println(response.content().text());
    }
}

En el método main(), creas el modelo de chat de Gemini como de costumbre, pero con un número máximo de tokens de salida pequeño, ya que solo quieres una respuesta breve: el texto es POSITIVE, NEGATIVE o NEUTRAL.

Luego, crearás una plantilla reutilizable de instrucciones con la técnica de instrucciones con ejemplos limitados indicándole al modelo algunos ejemplos de entradas y salidas. Esto también ayuda al modelo a seguir el resultado real. Gemini no responderá con una oración completa, sino con una sola palabra.

Aplica las variables con el método apply() para reemplazar el marcador de posición {{text}} por el parámetro real ("I love strawberries") y convertir esa plantilla en un mensaje para el usuario con toUserMessage().

Ejecuta la muestra:

./gradlew run -q -DjavaMainClass=gemini.workshop.TextClassification

Deberías ver una sola palabra:

POSITIVE

Parece que amar las fresas es un sentimiento positivo.

10. Generación mejorada por recuperación

Los LLM se entrenan con una gran cantidad de texto. Sin embargo, sus conocimientos solo abarcan la información que vieron durante su capacitación. Si hay información nueva publicada después de la fecha límite del entrenamiento de modelos, esos detalles no estarán disponibles para el modelo. Por lo tanto, el modelo no podrá responder preguntas sobre la información que no haya visto.

Es por eso que enfoques como la generación de aumento de recuperación (RAG) ayudan a proporcionar la información adicional que un LLM puede necesitar para cumplir con las solicitudes de sus usuarios y responder con información que podría estar más actualizada o sobre información privada a la que no se puede acceder en el momento del entrenamiento.

Volvamos a las conversaciones. Esta vez, podrás hacer preguntas sobre tus documentos. Compilarás un chatbot que puede recuperar información relevante de una base de datos que contiene tus documentos divididos en partes más pequeñas (“fragmentos”) y que el modelo usará esa información para fundamentar sus respuestas, en lugar de confiar únicamente en el conocimiento incluido en su entrenamiento.

En el formato RAG, hay dos fases:

  1. Fase de transferencia: Los documentos se cargan en la memoria, se dividen en fragmentos más pequeños y las incorporaciones vectoriales (una representación vectorial multidimensional de los fragmentos) se calculan y se almacenan en una base de datos de vectores que es capaz de realizar búsquedas semánticas. Por lo general, esta fase de transferencia se realiza una sola vez, cuando los documentos nuevos deben agregarse al corpus de documentos.

cd07d33d20ffa1c8.png

  1. Fase de consulta: Ahora los usuarios pueden hacer preguntas sobre los documentos. La pregunta también se transformará en un vector y se comparará con todos los demás vectores de la base de datos. Por lo general, los vectores más similares están relacionados semánticamente y los muestra la base de datos de vectores. Luego, al LLM se le da el contexto de la conversación, los fragmentos de texto que corresponden a los vectores devueltos por la base de datos, y se le pide fundamentar su respuesta observando esos fragmentos.

a1d2e2deb83c6d27.png

Prepara tus documentos

En esta nueva demostración, harás preguntas sobre lo siguiente: “Es todo lo que necesitas en el artículo de investigación. Describe la arquitectura de red neuronal de transformación, pionera de Google, que es la forma en que todos los modelos grandes de lenguaje modernos se implementan en la actualidad.

El informe ya está descargado en attention-is-all-you-need.pdf en el repositorio.

Implementa el chatbot

Exploremos cómo compilar el enfoque de 2 fases: primero con la transferencia del documento y, luego, el tiempo de consulta cuando los usuarios hacen preguntas sobre el documento.

En este ejemplo, ambas fases se implementan en la misma clase. Normalmente, tendrías una aplicación que se encarga de la transferencia y otra que ofrezca la interfaz del chatbot a tus usuarios.

Además, en este ejemplo usaremos una base de datos de vectores en la memoria. En una situación de producción real, las fases de transferencia y consulta estarían separadas en dos aplicaciones distintas, y los vectores se conservarían en una base de datos independiente.

Transferencia de documentos

El primer paso de la fase de transferencia de documentos es ubicar el archivo PDF que ya descargamos y preparar un PdfParser para leerlo:

URL url = new URI("https://github.com/glaforge/gemini-workshop-for-java-developers/raw/main/attention-is-all-you-need.pdf").toURL();
ApachePdfBoxDocumentParser pdfParser = new ApachePdfBoxDocumentParser();
Document document = pdfParser.parse(url.openStream());

En lugar de crear el modelo de lenguaje del chat habitual, creas una instancia de un modelo de incorporación. Se trata de un modelo particular cuya función es crear representaciones vectoriales de fragmentos de texto (palabras, oraciones o incluso párrafos). Devuelve vectores de números de punto flotante, en lugar de mostrar respuestas de texto.

VertexAiEmbeddingModel embeddingModel = VertexAiEmbeddingModel.builder()
    .endpoint(System.getenv("LOCATION") + "-aiplatform.googleapis.com:443")
    .project(System.getenv("PROJECT_ID"))
    .location(System.getenv("LOCATION"))
    .publisher("google")
    .modelName("textembedding-gecko@003")
    .maxRetries(3)
    .build();

A continuación, necesitarás algunas clases para colaborar entre sí para lograr lo siguiente:

  • Carga y divide el documento PDF en partes.
  • Crea incorporaciones vectoriales para todos estos fragmentos.
InMemoryEmbeddingStore<TextSegment> embeddingStore = 
    new InMemoryEmbeddingStore<>();

EmbeddingStoreIngestor storeIngestor = EmbeddingStoreIngestor.builder()
    .documentSplitter(DocumentSplitters.recursive(500, 100))
    .embeddingModel(embeddingModel)
    .embeddingStore(embeddingStore)
    .build();
storeIngestor.ingest(document);

Se crea una instancia de InMemoryEmbeddingStore, una base de datos de vectores en la memoria, para almacenar las incorporaciones vectoriales.

El documento se divide en fragmentos gracias a la clase DocumentSplitters. Dividirá el texto del archivo PDF en fragmentos de 500 caracteres, con una superposición de 100 caracteres (con el siguiente fragmento, para evitar que se corten palabras o oraciones, por partes).

La herramienta de transferencia de almacenamiento vincula el divisor de documentos, el modelo de incorporación para calcular los vectores y la base de datos de vectores en la memoria. Luego, el método ingest() se encargará de realizar la transferencia.

Ahora que terminó la primera fase, el documento se transformó en fragmentos de texto con sus incorporaciones vectoriales asociadas y se almacenó en la base de datos de vectores.

Cómo hacer preguntas

Es hora de prepararse para hacer preguntas. Crea un modelo de chat para iniciar la conversación:

ChatLanguageModel model = VertexAiGeminiChatModel.builder()
        .project(System.getenv("PROJECT_ID"))
        .location(System.getenv("LOCATION"))
        .modelName("gemini-1.5-flash-001")
        .maxOutputTokens(1000)
        .build();

También necesitas una clase retriever para vincular la base de datos de vectores (en la variable embeddingStore) con el modelo de incorporación. Su trabajo es consultar la base de datos de vectores mediante el cálculo de una incorporación vectorial para la consulta del usuario, para encontrar vectores similares en la base de datos:

EmbeddingStoreContentRetriever retriever =
    new EmbeddingStoreContentRetriever(embeddingStore, embeddingModel);

Fuera del método principal, crea una interfaz que represente un asistente experto de LLM. Esta es una interfaz que la clase AiServices implementará para que interactúes con el modelo:

interface LlmExpert {
    String ask(String question);
}

En este punto, puedes configurar un nuevo servicio de IA:

LlmExpert expert = AiServices.builder(LlmExpert.class)
    .chatLanguageModel(model)
    .chatMemory(MessageWindowChatMemory.withMaxMessages(10))
    .contentRetriever(retriever)
    .build();

Este servicio se vincula entre sí:

  • El modelo de idioma de chat que configuraste antes
  • Un memoria de chat para hacer un seguimiento de la conversación
  • El retriever compara una consulta de incorporación vectorial con los vectores de la base de datos.
  • Una plantilla de instrucción indica explícitamente que el modelo de chat debe responder basándose su respuesta en la información proporcionada (es decir, los extractos relevantes de la documentación cuya incorporación de vector sea similar al vector de la pregunta del usuario).
.retrievalAugmentor(DefaultRetrievalAugmentor.builder()
    .contentInjector(DefaultContentInjector.builder()
        .promptTemplate(PromptTemplate.from("""
            You are an expert in large language models,\s
            you excel at explaining simply and clearly questions about LLMs.

            Here is the question: {{userMessage}}

            Answer using the following information:
            {{contents}}
            """))
        .build())
    .contentRetriever(retriever)
    .build())

Ya está todo listo para hacer preguntas.

List.of(
    "What neural network architecture can be used for language models?",
    "What are the different components of a transformer neural network?",
    "What is attention in large language models?",
    "What is the name of the process that transforms text into vectors?"
).forEach(query ->
    System.out.printf("%n=== %s === %n%n %s %n%n", query, expert.ask(query)));
);

El código fuente completo se encuentra en RAG.java, en el directorio app/src/main/java/gemini/workshop:

Ejecuta la muestra:

./gradlew -q run -DjavaMainClass=gemini.workshop.RAG

En el resultado, deberías ver las respuestas a tus preguntas:

=== What neural network architecture can be used for language models? === 

 Transformer architecture 


=== What are the different components of a transformer neural network? === 

 The different components of a transformer neural network are:

1. Encoder: The encoder takes the input sequence and converts it into a 
sequence of hidden states. Each hidden state represents the context of 
the corresponding input token.
2. Decoder: The decoder takes the hidden states from the encoder and 
uses them to generate the output sequence. Each output token is 
generated by attending to the hidden states and then using a 
feed-forward network to predict the token's probability distribution.
3. Attention mechanism: The attention mechanism allows the decoder to 
attend to the hidden states from the encoder when generating each output 
token. This allows the decoder to take into account the context of the 
input sequence when generating the output sequence.
4. Positional encoding: Positional encoding is a technique used to 
inject positional information into the input sequence. This is important 
because the transformer neural network does not have any inherent sense 
of the order of the tokens in the input sequence.
5. Feed-forward network: The feed-forward network is a type of neural 
network that is used to predict the probability distribution of each 
output token. The feed-forward network takes the hidden state from the 
decoder as input and outputs a vector of probabilities. 


=== What is attention in large language models? === 

Attention in large language models is a mechanism that allows the model 
to focus on specific parts of the input sequence when generating the 
output sequence. This is important because it allows the model to take 
into account the context of the input sequence when generating each output token.

Attention is implemented using a function that takes two sequences as 
input: a query sequence and a key-value sequence. The query sequence is 
typically the hidden state from the previous decoder layer, and the 
key-value sequence is typically the sequence of hidden states from the 
encoder. The attention function computes a weighted sum of the values in 
the key-value sequence, where the weights are determined by the 
similarity between the query and the keys.

The output of the attention function is a vector of context vectors, 
which are then used as input to the feed-forward network in the decoder. 
The feed-forward network then predicts the probability distribution of 
the next output token.

Attention is a powerful mechanism that allows large language models to 
generate text that is both coherent and informative. It is one of the 
key factors that has contributed to the recent success of large language 
models in a wide range of natural language processing tasks. 


=== What is the name of the process that transforms text into vectors? === 

The process of transforming text into vectors is called **word embedding**.

Word embedding is a technique used in natural language processing (NLP) 
to represent words as vectors of real numbers. Each word is assigned a 
unique vector, which captures its meaning and semantic relationships 
with other words. Word embeddings are used in a variety of NLP tasks, 
such as machine translation, text classification, and question 
answering.

There are a number of different word embedding techniques, but one of 
the most common is the **skip-gram** model. The skip-gram model is a 
neural network that is trained to predict the surrounding words of a 
given word. By learning to predict the surrounding words, the skip-gram 
model learns to capture the meaning and semantic relationships of words.

Once a word embedding model has been trained, it can be used to 
transform text into vectors. To do this, each word in the text is 
converted to its corresponding vector. The vectors for all of the words 
in the text are then concatenated to form a single vector, which 
represents the entire text.

Text vectors can be used in a variety of NLP tasks. For example, text 
vectors can be used to train machine translation models, text 
classification models, and question answering models. Text vectors can 
also be used to perform tasks such as text summarization and text 
clustering. 

11. Llamada a función

También hay situaciones en las que quisieras que un LLM tenga acceso a sistemas externos, como una API web remota que recupera información o tiene una acción, o servicios que realizan algún tipo de procesamiento. Por ejemplo:

APIs web remotas:

  • Realizar un seguimiento de los pedidos de los clientes y actualizarlos
  • Busca o crea un ticket en una herramienta de seguimiento de errores.
  • Recupera datos en tiempo real, como cotizaciones de acciones o mediciones de sensores de IoT.
  • Envía un correo electrónico.

Herramientas de procesamiento:

  • Una calculadora para problemas matemáticos más avanzados.
  • Interpretación de código para ejecutar código cuando los LLM necesitan lógica de razonamiento.
  • Convierte solicitudes de lenguaje natural en consultas en SQL para que un LLM pueda consultar una base de datos.

Las llamadas a función son la capacidad del modelo de solicitar que se realicen una o más llamadas a funciones en su nombre, de modo que pueda responder de forma adecuada la instrucción de un usuario con más datos recientes.

Con una instrucción particular de un usuario y el conocimiento de funciones existentes que pueden ser relevantes para ese contexto, un LLM puede responder con una solicitud de llamada a función. La aplicación que integra el LLM puede entonces llamar a la función y responder al LLM con una respuesta. El LLM lo interpreta con una respuesta textual.

Cuatro pasos de la llamada a función

Veamos un ejemplo de llamada a función: obtener información sobre el pronóstico del tiempo.

Si le preguntas a Gemini o a cualquier otro LLM sobre el clima en París, te responderá diciendo que no tiene información sobre el pronóstico del tiempo. Si quieres que el LLM tenga acceso en tiempo real a los datos meteorológicos, debes definir algunas funciones que puede usar.

Observa el siguiente diagrama:

31e0c2aba5e6f21c.png

1️⃣ Primero, un usuario pregunta sobre el clima en París. La app de chatbot sabe que hay una o más funciones a su disposición para ayudar al LLM a completar la consulta. El chatbot envía la instrucción inicial y la lista de funciones a las que se puede llamar. Aquí, se muestra una función llamada getWeather() que toma un parámetro de cadena para la ubicación.

8863be53a73c4a70.png

Como el LLM no sabe sobre el pronóstico del clima, en lugar de responder por texto, envía una solicitud de ejecución de función. El chatbot debe llamar a la función getWeather() con "Paris" como parámetro de ubicación.

d1367cc69c07b14d.png

2️⃣ El chatbot invoca esa función en nombre del LLM y recupera la respuesta de la función. Aquí, imaginamos que la respuesta es {"forecast": "sunny"}.

73a5f2ed19f47d8.png

3️⃣ La app de chatbot envía la respuesta JSON al LLM.

20832cb1ee6fbfeb.png

4️⃣ El LLM observa la respuesta JSON, interpreta esa información y, finalmente, responde con el texto que indica que el clima está soleado en París.

Cada paso como código

Primero, configurarás el modelo de Gemini como de costumbre:

ChatLanguageModel model = VertexAiGeminiChatModel.builder()
    .project(System.getenv("PROJECT_ID"))
    .location(System.getenv("LOCATION"))
    .modelName("gemini-1.5-flash-001")
    .maxOutputTokens(100)
    .build();

Debes especificar una especificación de herramienta que describa la función a la que se puede llamar:

ToolSpecification weatherToolSpec = ToolSpecification.builder()
    .name("getWeatherForecast")
    .description("Get the weather forecast for a location")
    .addParameter("location", JsonSchemaProperty.STRING,
        JsonSchemaProperty.description("the location to get the weather forecast for"))
    .build();

El nombre de la función y el nombre y tipo del parámetro están definidos, pero ten en cuenta que tanto la función como los parámetros reciben descripciones. Las descripciones son muy importantes y ayudan al LLM a comprender realmente lo que puede hacer una función y, por lo tanto, juzgar si se debe llamar a esa función en el contexto de la conversación.

Comencemos con el paso 1, enviando la pregunta inicial sobre el clima en París:

List<ChatMessage> allMessages = new ArrayList<>();

// 1) Ask the question about the weather
UserMessage weatherQuestion = UserMessage.from("What is the weather in Paris?");
allMessages.add(weatherQuestion);

En el paso 2, pasamos la herramienta que queremos que use el modelo, y el modelo responde con una solicitud de ejecución demasiado:

// 2) The model replies with a function call request
Response<AiMessage> messageResponse = model.generate(allMessages, weatherToolSpec);
ToolExecutionRequest toolExecutionRequest = messageResponse.content().toolExecutionRequests().getFirst();
System.out.println("Tool execution request: " + toolExecutionRequest);
allMessages.add(messageResponse.content());

Paso 3: En este punto, sabemos cuál es la función que el LLM quiere que llamemos. En el código, no estamos haciendo una llamada real a una API externa, solo mostramos un pronóstico del tiempo hipotético directamente:

// 3) We send back the result of the function call
ToolExecutionResultMessage toolExecResMsg = ToolExecutionResultMessage.from(toolExecutionRequest,
    "{\"location\":\"Paris\",\"forecast\":\"sunny\", \"temperature\": 20}");
allMessages.add(toolExecResMsg);

Y en el paso 4, el LLM aprende sobre el resultado de la ejecución de la función y, luego, puede sintetizar una respuesta textual:

// 4) The model answers with a sentence describing the weather
Response<AiMessage> weatherResponse = model.generate(allMessages);
System.out.println("Answer: " + weatherResponse.content().text());

El resultado es el siguiente:

Tool execution request: ToolExecutionRequest { id = null, name = "getWeatherForecast", arguments = "{"location":"Paris"}" }
Answer:  The weather in Paris is sunny with a temperature of 20 degrees Celsius.

Puedes ver en el resultado arriba de la solicitud de ejecución de la herramienta, así como la respuesta.

El código fuente completo se encuentra en FunctionCalling.java, en el directorio app/src/main/java/gemini/workshop:

Ejecuta la muestra:

./gradlew run -q -DjavaMainClass=gemini.workshop.FunctionCalling

Deberías ver un resultado similar al siguiente:

Tool execution request: ToolExecutionRequest { id = null, name = "getWeatherForecast", arguments = "{"location":"Paris"}" }
Answer:  The weather in Paris is sunny with a temperature of 20 degrees Celsius.

12. LangChain4j controla las llamadas a funciones.

En el paso anterior, viste cómo se intercalan las interacciones de pregunta/respuesta de texto normal y solicitud/respuesta de función y, en el medio, proporcionaste la respuesta de función solicitada directamente, sin llamar a una función real.

Sin embargo, LangChain4j también ofrece una abstracción de nivel superior que puede controlar las llamadas a función de manera transparente, mientras maneja la conversación como de costumbre.

Llamada a función única

Veamos los detalles de FunctionCallingAssistant.java.

Primero, crea un registro que representará la estructura de datos de respuesta de la función:

record WeatherForecast(String location, String forecast, int temperature) {}

La respuesta contiene información sobre la ubicación, el pronóstico y la temperatura.

Luego, creas una clase que contiene la función real que quieres que esté disponible para el modelo:

static class WeatherForecastService {
    @Tool("Get the weather forecast for a location")
    WeatherForecast getForecast(@P("Location to get the forecast for") String location) {
        if (location.equals("Paris")) {
            return new WeatherForecast("Paris", "Sunny", 20);
        } else if (location.equals("London")) {
            return new WeatherForecast("London", "Rainy", 15);
        } else {
            return new WeatherForecast("Unknown", "Unknown", 0);
        }
    }
}

Ten en cuenta que esta clase contiene una sola función, pero está anotada con la anotación @Tool, que corresponde a la descripción de la función a la que el modelo puede solicitar llamar.

También se anotan los parámetros de la función (uno solo aquí), pero con esta anotación @P corta, que también proporciona una descripción del parámetro. Puedes agregar tantas funciones como desees, de modo que estén disponibles para el modelo en situaciones más complejas.

En esta clase, devolverás algunas respuestas estándar, pero si deseas llamar a un servicio externo real de pronóstico del tiempo, deberías llamar a ese servicio en el cuerpo de ese método.

Como vimos cuando creaste un ToolSpecification en el enfoque anterior, es importante documentar lo que hace una función y describir a qué corresponden los parámetros. Esto ayuda al modelo a comprender cómo y cuándo se puede usar esta función.

A continuación, LangChain4j te permite proporcionar una interfaz que corresponde al contrato que deseas usar para interactuar con el modelo. Aquí, es una interfaz simple que recibe una cadena que representa el mensaje del usuario y devuelve una cadena correspondiente a la respuesta del modelo:

interface WeatherAssistant {
    String chat(String userMessage);
}

También es posible usar firmas más complejas que involucren el UserMessage (para un mensaje del usuario) o AiMessage (para una respuesta del modelo) de LangChain4j, o incluso un TokenStream, si deseas manejar situaciones más avanzadas, ya que esos objetos más complicados también contienen información adicional, como la cantidad de tokens consumidos, etc. Sin embargo, para simplificar, solo tomaremos una cadena en la entrada y otra en la salida.

Terminemos con el método main(), que une todas las piezas:

public static void main(String[] args) {
    ChatLanguageModel model = VertexAiGeminiChatModel.builder()
        .project(System.getenv("PROJECT_ID"))
        .location(System.getenv("LOCATION"))
        .modelName("gemini-1.5-flash-001")
        .maxOutputTokens(100)
        .build();

    WeatherForecastService weatherForecastService = new WeatherForecastService();

    WeatherAssistant assistant = AiServices.builder(WeatherAssistant.class)
        .chatLanguageModel(model)
        .chatMemory(MessageWindowChatMemory.withMaxMessages(10))
        .tools(weatherForecastService)
        .build();

    System.out.println(assistant.chat("What is the weather in Paris?"));
}

Como de costumbre, configurarás el modelo de chat de Gemini. Luego, crea una instancia del servicio de pronóstico del tiempo que contiene la "función" a las que el modelo nos solicitará que llamemos.

Ahora, vuelve a usar la clase AiServices para vincular el modelo del chat, la memoria del chat y la herramienta (es decir, el servicio de pronóstico del tiempo con su función). AiServices muestra un objeto que implementa la interfaz WeatherAssistant que definiste. Lo único que falta es llamar al método chat() de ese asistente. Cuando la invoques, solo verás las respuestas de texto, pero el desarrollador no podrá ver las solicitudes de llamadas a funciones ni sus respuestas, y esas solicitudes se manejarán de forma automática y transparente. Si Gemini cree que se debería llamar a una función, responderá con la solicitud de llamada a función, y LangChain4j se encargará de llamar a la función local por ti.

Ejecuta la muestra:

./gradlew run -q -DjavaMainClass=gemini.workshop.FunctionCallingAssistant

Deberías ver un resultado similar al siguiente:

OK. The weather in Paris is sunny with a temperature of 20 degrees.

Este fue un ejemplo de una función única.

Varias llamadas a funciones

También puedes tener varias funciones y permitir que LangChain4j se encargue de múltiples llamadas a funciones por ti. Consulta MultiFunctionCallingAssistant.java para ver un ejemplo de varias funciones.

Tiene una función para convertir monedas:

@Tool("Convert amounts between two currencies")
double convertCurrency(
    @P("Currency to convert from") String fromCurrency,
    @P("Currency to convert to") String toCurrency,
    @P("Amount to convert") double amount) {

    double result = amount;

    if (fromCurrency.equals("USD") && toCurrency.equals("EUR")) {
        result = amount * 0.93;
    } else if (fromCurrency.equals("USD") && toCurrency.equals("GBP")) {
        result = amount * 0.79;
    }

    System.out.println(
        "convertCurrency(fromCurrency = " + fromCurrency +
            ", toCurrency = " + toCurrency +
            ", amount = " + amount + ") == " + result);

    return result;
}

Otra función para obtener el valor de una acción:

@Tool("Get the current value of a stock in US dollars")
double getStockPrice(@P("Stock symbol") String symbol) {
    double result = 170.0 + 10 * new Random().nextDouble();

    System.out.println("getStockPrice(symbol = " + symbol + ") == " + result);

    return result;
}

Otra función para aplicar un porcentaje a una cantidad determinada:

@Tool("Apply a percentage to a given amount")
double applyPercentage(@P("Initial amount") double amount, @P("Percentage between 0-100 to apply") double percentage) {
    double result = amount * (percentage / 100);

    System.out.println("applyPercentage(amount = " + amount + ", percentage = " + percentage + ") == " + result);

    return result;
}

Luego, puedes combinar todas estas funciones y una clase MultiTools y hacer preguntas como "¿Cuánto es el 10% del precio de las acciones de AAPL convertido de USD a EUR?".

public static void main(String[] args) {
    ChatLanguageModel model = VertexAiGeminiChatModel.builder()
        .project(System.getenv("PROJECT_ID"))
        .location(System.getenv("LOCATION"))
        .modelName("gemini-1.5-flash-001")
        .maxOutputTokens(100)
        .build();

    MultiTools multiTools = new MultiTools();

    MultiToolsAssistant assistant = AiServices.builder(MultiToolsAssistant.class)
        .chatLanguageModel(model)
        .chatMemory(withMaxMessages(10))
        .tools(multiTools)
        .build();

    System.out.println(assistant.chat(
        "What is 10% of the AAPL stock price converted from USD to EUR?"));
}

Ejecútalo de la siguiente manera:

./gradlew run -q -DjavaMainClass=gemini.workshop.MultiFunctionCallingAssistant

Deberías ver varias funciones llamadas:

getStockPrice(symbol = AAPL) == 172.8022224055534
convertCurrency(fromCurrency = USD, toCurrency = EUR, amount = 172.8022224055534) == 160.70606683716468
applyPercentage(amount = 160.70606683716468, percentage = 10.0) == 16.07060668371647
10% of the AAPL stock price converted from USD to EUR is 16.07060668371647 EUR.

Hacia los agentes

Las llamadas a función son un excelente mecanismo de extensión para modelos grandes de lenguaje como Gemini. Nos permite compilar sistemas más complejos que suelen llamarse “agentes” o “asistentes de IA”. Estos agentes pueden interactuar con el mundo externo a través de APIs externas y con servicios que pueden tener efectos secundarios en el entorno externo (como enviar correos electrónicos, crear tickets, etcétera).

Cuando crees agentes tan potentes, debes hacerlo con responsabilidad. Deberías considerar la interacción humana antes de realizar acciones automáticas. Es importante tener en cuenta la seguridad cuando se diseñan agentes potenciados por LLM que interactúan con el mundo externo.

13. Cómo ejecutar Gemma con Ollama y TestContainers

Hasta ahora, hemos usado Gemini, pero también está Gemma, su modelo de hermana pequeña.

Gemma es una familia de modelos abiertos ligeros y de última generación creados a partir de la misma investigación y tecnología que se usaron para crear los modelos de Gemini. Gemma está disponible en dos variantes, Gemma1 y Gemma2, cada una con varios tamaños. Gemma1 está disponible en dos tamaños: 2B y 7B. Gemma2 está disponible en dos tamaños: 9B y 27B. Sus pesos están disponibles sin costo y su tamaño pequeño significa que puedes ejecutarlo por tu cuenta, incluso en tu laptop o en Cloud Shell.

¿Cómo ejecutas Gemma?

Hay muchas formas de ejecutar Gemma: en la nube, a través de Vertex AI con un clic en un botón o GKE con algunas GPU, pero también puedes ejecutarlo de forma local.

Una buena opción para ejecutar Gemma localmente es con Ollama, una herramienta que te permite ejecutar modelos pequeños, como Llama 2, Mistral y muchos otros en tu máquina local. Es similar a Docker, pero para LLM.

Instala Ollama siguiendo las instrucciones de tu sistema operativo.

Si utilizas un entorno Linux, deberás habilitar Ollama primero luego de instalarlo.

ollama serve > /dev/null 2>&1 & 

Una vez instalado de forma local, puedes ejecutar comandos para extraer un modelo:

ollama pull gemma:2b

Espera a que se extraiga el modelo. Este proceso puede demorar unos minutos.

Ejecuta el modelo:

ollama run gemma:2b

Ahora puedes interactuar con el modelo:

>>> Hello!
Hello! It's nice to hear from you. What can I do for you today?

Para salir del mensaje, presiona Ctrl+D

Cómo ejecutar Gemma en Ollama en TestContainers

En lugar de tener que instalar y ejecutar Ollama de manera local, puedes usar Ollama dentro de un contenedor controlado por TestContainers.

TestContainers no solo es útil para realizar pruebas, sino que también puedes usarlo para ejecutar contenedores. Incluso hay un OllamaContainer específico que puedes aprovechar.

Este es el panorama completo:

2382c05a48708dfd.png

Implementación

Veamos los detalles de GemmaWithOllamaContainer.java.

Primero, debes crear un contenedor Ollama derivado que extraiga el modelo de Gemma. Esta imagen ya existe en una ejecución anterior o se creará. Si la imagen ya existe, solo indicará a TestContainers que deseas sustituir la imagen predeterminada de Ollama por tu variante con la tecnología de Gemma:

private static final String TC_OLLAMA_GEMMA_2_B = "tc-ollama-gemma-2b";

// Creating an Ollama container with Gemma 2B if it doesn't exist.
private static OllamaContainer createGemmaOllamaContainer() throws IOException, InterruptedException {

    // Check if the custom Gemma Ollama image exists already
    List<Image> listImagesCmd = DockerClientFactory.lazyClient()
        .listImagesCmd()
        .withImageNameFilter(TC_OLLAMA_GEMMA_2_B)
        .exec();

    if (listImagesCmd.isEmpty()) {
        System.out.println("Creating a new Ollama container with Gemma 2B image...");
        OllamaContainer ollama = new OllamaContainer("ollama/ollama:0.1.26");
        ollama.start();
        ollama.execInContainer("ollama", "pull", "gemma:2b");
        ollama.commitToImage(TC_OLLAMA_GEMMA_2_B);
        return ollama;
    } else {
        System.out.println("Using existing Ollama container with Gemma 2B image...");
        // Substitute the default Ollama image with our Gemma variant
        return new OllamaContainer(
            DockerImageName.parse(TC_OLLAMA_GEMMA_2_B)
                .asCompatibleSubstituteFor("ollama/ollama"));
    }
}

A continuación, crearás y, luego, iniciarás un contenedor de prueba de Ollama y, luego, crearás un modelo de chat de Ollama. Para ello, apunta la dirección y el puerto del contenedor con el modelo que quieres usar. Por último, solo debes invocar a model.generate(yourPrompt) como de costumbre:

public static void main(String[] args) throws IOException, InterruptedException {
    OllamaContainer ollama = createGemmaOllamaContainer();
    ollama.start();

    ChatLanguageModel model = OllamaChatModel.builder()
        .baseUrl(String.format("http://%s:%d", ollama.getHost(), ollama.getFirstMappedPort()))
        .modelName("gemma:2b")
        .build();

    String response = model.generate("Why is the sky blue?");

    System.out.println(response);
}

Ejecútalo de la siguiente manera:

./gradlew run -q -DjavaMainClass=gemini.workshop.GemmaWithOllamaContainer

La primera ejecución tardará un tiempo en crear y ejecutar el contenedor, pero una vez hecho esto, deberías ver que Gemma responde:

INFO: Container ollama/ollama:0.1.26 started in PT2.827064047S
The sky appears blue due to Rayleigh scattering. Rayleigh scattering is a phenomenon that occurs when sunlight interacts with molecules in the Earth's atmosphere.

* **Scattering particles:** The main scattering particles in the atmosphere are molecules of nitrogen (N2) and oxygen (O2).
* **Wavelength of light:** Blue light has a shorter wavelength than other colors of light, such as red and yellow.
* **Scattering process:** When blue light interacts with these molecules, it is scattered in all directions.
* **Human eyes:** Our eyes are more sensitive to blue light than other colors, so we perceive the sky as blue.

This scattering process results in a blue appearance for the sky, even though the sun is actually emitting light of all colors.

In addition to Rayleigh scattering, other atmospheric factors can also influence the color of the sky, such as dust particles, aerosols, and clouds.

Gemma se está ejecutando en Cloud Shell.

14. Felicitaciones

¡Felicitaciones! Compilaste con éxito tu primera aplicación de chat de IA generativa en Java con LangChain4j y la API de Gemini. Con el tiempo, descubriste que los modelos multimodales grandes de lenguaje son bastante poderosos y capaces de manejar diversas tareas, como preguntas y respuestas, incluso con tu propia documentación, extracción de datos, interacción con APIs externas y mucho más.

¿Qué sigue?

Es tu turno de mejorar tus aplicaciones con potentes integraciones de LLM.

Lecturas adicionales

Documentos de referencia