Gemini in Java con Vertex AI e LangChain4j

1. Introduzione

Questo codelab è incentrato sul modello linguistico di grandi dimensioni (LLM) Gemini, ospitato su Vertex AI su Google Cloud. Vertex AI è una piattaforma che comprende tutti i prodotti, i servizi e i modelli di machine learning su Google Cloud.

Utilizzerai Java per interagire con l'API Gemini utilizzando il framework LangChain4j. Osserverai esempi concreti per sfruttare l'LLM per la risposta a domande, la generazione di idee, l'estrazione di contenuti strutturati ed entità, la generazione aumentata per il recupero e le chiamate di funzione.

Cos'è l'AI generativa?

L'IA generativa si riferisce all'uso dell'intelligenza artificiale per creare nuovi contenuti, come testo, immagini, musica, audio e video.

L'IA generativa si basa su modelli linguistici di grandi dimensioni (LLM) in grado di svolgere più attività contemporaneamente ed eseguire attività pronte all'uso come riassunti, domande e risposte, classificazione e altro ancora. Con un addestramento minimo, i modelli di base possono essere adattati per casi d'uso mirati con pochissimi dati di esempio.

Come funziona l'IA generativa?

L'IA generativa funziona utilizzando un modello di machine learning (ML) per apprendere pattern e relazioni in un set di dati di contenuti creati da persone fisiche. Quindi utilizza i pattern appresi per generare nuovi contenuti.

Il modo più comune per addestrare un modello di IA generativa è utilizzare l'apprendimento supervisionato. Al modello viene assegnato un insieme di contenuti creati dall'utente e le relative etichette. Poi impara a generare contenuti simili a quelli creati dall'uomo.

Cosa sono le applicazioni comuni di IA generativa?

L'IA generativa può essere utilizzata per:

  • Migliora le interazioni con i clienti grazie a esperienze di chat e ricerca avanzate.
  • Esplora grandi quantità di dati non strutturati attraverso interfacce conversazionali e riassunti.
  • Assistere nelle attività ripetitive come rispondere a richieste di proposta, localizzare contenuti di marketing in diverse lingue, verificare la conformità dei contratti con i clienti e altro ancora.

Quali offerte di IA generativa offre Google Cloud?

Con Vertex AI, puoi interagire con i modelli di base, personalizzarli e incorporarli nelle tue applicazioni con un'esperienza minima o nulla in termini di ML. Puoi accedere ai modelli di base su Model Garden, ottimizzare i modelli tramite una semplice UI su Vertex AI Studio oppure utilizzare i modelli in un blocco note di data science.

Vertex AI Search and Conversation offre agli sviluppatori il modo più rapido per creare motori di ricerca e chatbot basati sull'IA generativa.

Gemini per Google Cloud è un collaboratore basato sull'IA, basato su Gemini, disponibile in Google Cloud e negli IDE per aiutarti a fare di più in meno tempo. Gemini Code Assist fornisce funzionalità per il completamento e la generazione del codice, nonché spiegazioni del codice e ti consente di chattare con il servizio per porre domande tecniche.

Cos'è Gemini?

Gemini è una famiglia di modelli di IA generativa sviluppati da Google DeepMind progettati per casi d'uso multimodali. Multimodale significa che è in grado di elaborare e generare diversi tipi di contenuti come testo, codice, immagini e audio.

b9913d011999e7c7.png

Gemini è disponibile in diverse varianti e dimensioni:

  • Gemini Ultra: la versione più ampia e potente per attività complesse.
  • Gemini Flash: è la soluzione più veloce e conveniente, ottimizzata per attività con volumi elevati.
  • Gemini Pro: di medie dimensioni, ottimizzato per la scalabilità in varie attività.
  • Gemini Nano: il modello più efficiente, progettato per le attività sul dispositivo.

Funzionalità principali:

  • Multimodalità: la capacità di Gemini di comprendere e gestire più formati di informazioni rappresenta un passo significativo rispetto ai tradizionali modelli linguistici di solo testo.
  • Prestazioni: Gemini Ultra supera l'attuale tecnologia all'avanguardia in molti benchmark ed è stato il primo modello a superare gli esperti umani nel impegnativo benchmark MMLU (Massive Multitask Language Understanding).
  • Flessibilità: le diverse dimensioni di Gemini lo rendono adattabile a vari casi d'uso, dalla ricerca su larga scala al deployment sui dispositivi mobili.

Come puoi interagire con Gemini su Vertex AI da Java?

Avete due opzioni:

  1. La libreria ufficiale API Java Vertex AI per Gemini.
  2. LangChain4j.

In questo codelab, utilizzerai il framework LangChain4j.

Cos'è il framework LangChain4j?

Il framework LangChain4j è una libreria open source per integrare gli LLM nelle tue applicazioni Java, orchestrando vari componenti, come l'LLM stesso, ma anche altri strumenti come database vettoriali (per le ricerche semantiche), caricatori e splitter di documenti (per analizzare documenti e imparare da essi), parser di output e altro ancora.

Il progetto è ispirato al progetto Python LangChain, ma ha l'obiettivo di servire gli sviluppatori Java.

bb908ea1e6c96ac2.png

Cosa imparerai a fare

  • Come configurare un progetto Java per utilizzare Gemini e LangChain4j
  • Come inviare il tuo primo prompt a Gemini in modo programmatico
  • Come trasmettere le risposte in streaming da Gemini
  • Come creare una conversazione tra un utente e Gemini
  • Come utilizzare Gemini in un contesto multimodale inviando sia testo che immagini
  • Come estrarre informazioni strutturate utili da contenuti non strutturati
  • Come manipolare i modelli di prompt
  • Come effettuare la classificazione del testo, ad esempio l'analisi del sentiment
  • Come chattare con i propri documenti (Retrieval Augmented Generation)
  • Come estendere i chatbot con le chiamate di funzione
  • Come utilizzare Gemma in locale con Ollama e TestContainers

Che cosa ti serve

  • Conoscenza del linguaggio di programmazione Java
  • Un progetto Google Cloud
  • Un browser, ad esempio Chrome o Firefox

2. Configurazione e requisiti

Configurazione dell'ambiente da seguire in modo autonomo

  1. Accedi alla console Google Cloud e crea un nuovo progetto o riutilizzane uno esistente. Se non hai ancora un account Gmail o Google Workspace, devi crearne uno.

fbef9caa1602edd0.png

a99b7ace416376c4.png

5e3ff691252acf41.png

  • Il Nome progetto è il nome visualizzato dei partecipanti del progetto. Si tratta di una stringa di caratteri non utilizzata dalle API di Google. Puoi sempre aggiornarla.
  • L'ID progetto è univoco in tutti i progetti Google Cloud ed è immutabile (non può essere modificato dopo essere stato impostato). La console Cloud genera automaticamente una stringa univoca. In genere non ti importa cosa sia. Nella maggior parte dei codelab, devi fare riferimento al tuo ID progetto (in genere identificato come PROJECT_ID). Se non ti piace l'ID generato, potresti generarne un altro casuale. In alternativa, puoi provarne una personalizzata per verificare se è disponibile. Non può essere modificato dopo questo passaggio e rimane per tutta la durata del progetto.
  • Per informazione, c'è un terzo valore, un numero di progetto, utilizzato da alcune API. Scopri di più su tutti e tre questi valori nella documentazione.
  1. Successivamente, dovrai abilitare la fatturazione nella console Cloud per utilizzare risorse/API Cloud. L'esecuzione di questo codelab non ha alcun costo. Per arrestare le risorse ed evitare di incorrere in fatturazione dopo questo tutorial, puoi eliminare le risorse che hai creato o eliminare il progetto. I nuovi utenti di Google Cloud sono idonei al programma prova senza costi di 300$.

Avvia Cloud Shell

Mentre Google Cloud può essere utilizzato da remoto dal tuo laptop, in questo codelab utilizzerai Cloud Shell, un ambiente a riga di comando in esecuzione nel cloud.

Attiva Cloud Shell

  1. Dalla console Cloud, fai clic su Attiva Cloud Shell 853e55310c205094.png.

3c1dabeca90e44e5.png

Se è la prima volta che avvii Cloud Shell, ti viene mostrata una schermata intermedia che descrive la situazione. Se è stata visualizzata una schermata intermedia, fai clic su Continua.

9c92662c6a846a5c.png

Il provisioning e la connessione a Cloud Shell dovrebbero richiedere solo qualche istante.

9f0e51b578fecce5.png

Questa macchina virtuale viene caricata con tutti gli strumenti di sviluppo necessari. Offre una home directory permanente da 5 GB e viene eseguita in Google Cloud, migliorando notevolmente le prestazioni di rete e l'autenticazione. Gran parte, se non tutto, del lavoro in questo codelab può essere svolto con un browser.

Una volta stabilita la connessione a Cloud Shell, dovresti vedere che hai eseguito l'autenticazione e che il progetto è impostato sul tuo ID progetto.

  1. Esegui questo comando in Cloud Shell per verificare che l'account sia autenticato:
gcloud auth list

Output comando

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

To set the active account, run:
    $ gcloud config set account `ACCOUNT`
  1. Esegui questo comando in Cloud Shell per confermare che il comando gcloud è a conoscenza del tuo progetto:
gcloud config list project

Output comando

[core]
project = <PROJECT_ID>

In caso contrario, puoi impostarlo con questo comando:

gcloud config set project <PROJECT_ID>

Output comando

Updated property [core/project].

3. Preparazione dell'ambiente di sviluppo

In questo codelab, utilizzerai il terminale Cloud Shell e l'editor di Cloud Shell per sviluppare i tuoi programmi Java.

Abilita le API Vertex AI

Nella console Google Cloud, assicurati che il nome del progetto sia visualizzato nella parte superiore della console Google Cloud. In caso contrario, fai clic su Seleziona un progetto per aprire il Selettore di progetti e seleziona il progetto che ti interessa.

Puoi abilitare le API Vertex AI dalla sezione Vertex AI della console Google Cloud o dal terminale Cloud Shell.

Per eseguire l'abilitazione dalla console Google Cloud, vai prima alla sezione Vertex AI del menu della console Google Cloud:

451976f1c8652341.png

Fai clic su Abilita tutte le API consigliate nella dashboard di Vertex AI.

Questa operazione abiliterà diverse API, ma la più importante per il codelab è aiplatform.googleapis.com.

In alternativa, puoi anche abilitare questa API dal terminale Cloud Shell con il comando seguente:

gcloud services enable aiplatform.googleapis.com

Clona il repository GitHub

Nel terminale Cloud Shell, clona il repository per questo codelab:

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

Per verificare che il progetto sia pronto per essere eseguito, puoi provare a eseguire il programma "Hello World".

Assicurati di essere nella cartella di primo livello:

cd gemini-workshop-for-java-developers/ 

Crea il wrapper Gradle:

gradle wrapper

Esegui con gradlew:

./gradlew run

Dovresti vedere l'output seguente:

..
> Task :app:run
Hello World!

Apri e configura Cloud Editor

Apri il codice con l'editor di codice Cloud da Cloud Shell:

42908e11b28f4383.png

Nell'editor di codice di Cloud, apri la cartella di origine del codelab selezionando File -> Open Folder e posiziona il puntatore del mouse sulla cartella dell'origine del codelab (ad es. /home/username/gemini-workshop-for-java-developers/).

Installa Gradle per Java

Per fare in modo che l'editor di codice cloud funzioni correttamente con Gradle, installa l'estensione Gradle per Java.

Innanzitutto, vai alla sezione Progetti Java e premi il segno più:

84d15639ac61c197.png

Seleziona Gradle for Java:

34d6c4136a3cc9ff.png

Seleziona la versione di Install Pre-Release:

3b044fb450CCCb7.png

Dopo l'installazione, dovresti vedere i pulsanti Disable e Uninstall:

46410fe86d777f9c.png

Infine, pulisci l'area di lavoro per applicare le nuove impostazioni:

31e27e9bb61d975d.png

Ti verrà chiesto di ricaricare ed eliminare il workshop. Procedi e scegli Reload and delete:

d6303bc49e391dc.png

Se apri uno dei file, ad esempio App.java, ora dovresti vedere l'editor che funziona correttamente con l'evidenziazione della sintassi:

fed1b1b5de0dff58.png

Ora è tutto pronto per eseguire alcuni campioni su Gemini.

Configura le variabili di ambiente

Apri un nuovo terminale nell'editor di codice Cloud selezionando Terminal -> New Terminal. Configura due variabili di ambiente necessarie per eseguire gli esempi di codice:

  • PROJECT_ID: l'ID del tuo progetto Google Cloud
  • LOCATION: la regione in cui è stato eseguito il deployment del modello Gemini

Esporta le variabili come segue:

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

4. Prima chiamata al modello Gemini

Ora che il progetto è configurato correttamente, è il momento di chiamare l'API Gemini.

Dai un'occhiata a QA.java nella directory 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?"));
    }
}

In questo primo esempio, devi importare la classe VertexAiGeminiChatModel, che implementa l'interfaccia ChatModel.

Nel metodo main, configuri il modello linguistico di chat utilizzando lo strumento di creazione per VertexAiGeminiChatModel e specifichi:

  • Progetto
  • Località
  • Nome del modello (gemini-1.5-flash-001).

Ora che il modello linguistico è pronto, puoi chiamare il metodo generate() e passare il prompt, la domanda o le istruzioni da inviare all'LLM. Qui devi porre una semplice domanda su cosa rende blu il cielo.

Modifica questo prompt per provare altre domande o attività.

Esegui l'esempio nella cartella principale del codice sorgente:

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

Dovresti vedere un output simile a questo:

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.

Congratulazioni, hai fatto la tua prima chiamata con Gemini!

Risposta in streaming

Hai notato che la risposta è stata data in una volta sola, dopo pochi secondi? È anche possibile ottenere la risposta in maniera progressiva, grazie alla variante della risposta in modalità flusso. La risposta in modalità flusso, il modello restituisce la risposta pezzo per pezzo, non appena diventa disponibile.

In questo codelab, utilizzeremo la risposta non in streaming, ma diamo un'occhiata alla risposta in modalità flusso per vedere come fare.

In StreamQA.java nella directory app/src/main/java/gemini/workshop puoi vedere la risposta in modalità flusso:

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();
            }
        });
    }
}

Questa volta, importiamo le varianti della classe di streaming VertexAiGeminiStreamingChatModel che implementano l'interfaccia StreamingChatLanguageModel. Ti servirà anche un StreamingResponseHandler.

Questa volta, la firma del metodo generate() è un po' diversa. Invece di restituire una stringa, il tipo restituito è void. Oltre al prompt, devi passare un gestore di risposte dinamiche. Qui, implementi l'interfaccia creando una classe interna anonima, con due metodi onNext(String text) e onError(Throwable error). Il primo viene chiamato ogni volta che è disponibile una nuova risposta, mentre il secondo viene richiamato solo se si verifica un errore.

Corsa:

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

Riceverai una risposta simile a quella della classe precedente, ma questa volta noterai che la risposta verrà visualizzata progressivamente nella shell, anziché attendere la visualizzazione della risposta completa.

Configurazione aggiuntiva

Per la configurazione, abbiamo definito solo il progetto, la località e il nome del modello, ma ci sono altri parametri che puoi specificare per il modello:

  • temperature(Float temp): per definire la creatività desiderata per la risposta (0 corrisponde a una creatività scarsa e spesso più fattuale, mentre 1 indica risultati più creativi)
  • topP(Float topP): per selezionare le possibili parole la cui probabilità totale somma la somma di quel numero in virgola mobile (compreso tra 0 e 1)
  • topK(Integer topK): per selezionare in modo casuale una parola tra il numero massimo di parole probabili per il completamento del testo (da 1 a 40)
  • maxOutputTokens(Integer max): per specificare la lunghezza massima della risposta fornita dal modello (in genere, 4 token rappresentano circa 3 parole)
  • maxRetries(Integer retries): se hai superato la richiesta per quota di tempo o se la piattaforma sta riscontrando problemi tecnici, puoi chiedere al modello di riprovare a effettuare la chiamata 3 volte

Finora hai fatto una singola domanda a Gemini, ma puoi anche avere una conversazione in più passaggi. Questo è quello che esplorerai nella prossima sezione.

5. Prova Gemini

Nel passaggio precedente, hai fatto una singola domanda. È il momento di avere una conversazione reale tra un utente e l'LLM. Ogni domanda e risposta può basarsi sulle precedenti per dar vita a un vero dibattito.

Dai un'occhiata a Conversation.java nella cartella 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));
        });
    }
}

Un paio di nuove importazioni interessanti in questa lezione:

  • MessageWindowChatMemory: una classe che aiuterà a gestire l'aspetto in più passaggi della conversazione e a ricordare a livello locale le domande e le risposte precedenti
  • AiServices: una classe che unisce il modello di chat e la memoria della chat

Nel metodo principale, configurerai il modello, la memoria della chat e il servizio di IA. Il modello viene configurato come di consueto con le informazioni sul progetto, sulla località e sul nome del modello.

Per la memoria della chat, utilizziamo il builder di MessageWindowChatMemory per creare una memoria che conservi gli ultimi 20 messaggi scambiati. È una finestra scorrevole sulla conversazione il cui contesto è conservato localmente nel nostro client di classe Java.

Quindi creerai il AI service che associa il modello di chat alla memoria chat.

Nota come il servizio di IA utilizza un'interfaccia ConversationService personalizzata che abbiamo definito, che LangChain4j implementa, e che accetta una query String e restituisce una risposta String.

È il momento di parlare con Gemini. Innanzitutto, viene inviato un semplice saluto, poi una prima domanda sulla Torre Eiffel per sapere in quale paese si trova. Tieni presente che l'ultima frase è correlata alla risposta alla prima domanda, perché ti chiedi quanti abitanti ci sono nel paese in cui è situata la Torre Eiffel, senza menzionare esplicitamente il paese indicato nella risposta precedente. Mostra che le domande e le risposte precedenti vengono inviate con ogni prompt.

Esegui l'esempio:

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

Dovresti vedere tre risposte simili a queste:

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.

Puoi porre domande a turno singolo o tenere conversazioni multi-turno con Gemini, ma finora l'input è stato solo testo. E le immagini? Esploriamo le immagini nel passaggio successivo.

6. Multimodalità con Gemini

Gemini è un modello multimodale. Non solo accetta testo come input, ma accetta anche immagini o persino video come input. In questa sezione vedremo un caso d'uso per combinare testo e immagini.

Pensi che Gemini riconoscerà questo gatto?

af00516493ec9ade.png

Immagine di un gatto nella neve scattata da Wikipediahttps://upload.wikimedia.org/wikipedia/commons/b/b6/Felis_catus-cat_on_snow.jpg

Dai un'occhiata a Multimodal.java nella directory 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());
    }
}

Nelle importazioni, noterai che distinguiamo tra diversi tipi di messaggi e contenuti. Un elemento UserMessage può contenere sia un oggetto TextContent sia un oggetto ImageContent. Si tratta di un approccio multimodalità, ovvero un mix di testo e immagini. Il modello restituisce un Response che contiene un AiMessage.

Dopodiché recuperi AiMessage dalla risposta tramite content() e poi il testo del messaggio grazie a text().

Esegui l'esempio:

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

Il nome dell'immagine ti ha sicuramente dato un'idea di cosa conteneva, ma l'output di Gemini è simile al seguente:

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.

La combinazione di immagini e prompt di testo apre casi d'uso interessanti. Puoi creare applicazioni in grado di:

  • Riconosci il testo nelle immagini.
  • Verifica che un'immagine sia sicura da visualizzare.
  • Crea didascalie per le immagini.
  • Esegui ricerche in un database di immagini con descrizioni in testo normale.

Oltre a estrarre informazioni dalle immagini, puoi anche estrarre informazioni da testo non strutturato. Questo è quello che tratteremo nella prossima sezione.

7. Estrae informazioni strutturate da testo non strutturato

Esistono molte situazioni in cui le informazioni importanti vengono fornite nei documenti dei report, nelle email o in altri testi di formato esteso in modo non strutturato. Idealmente, vorresti essere in grado di estrarre i dettagli chiave contenuti nel testo non strutturato, sotto forma di oggetti strutturati. Vediamo come fare.

Supponiamo che tu voglia estrarre il nome e l'età di una persona, data una biografia o una descrizione di quella persona. Puoi indicare all'LLM di estrarre il codice JSON da testo non strutturato con un prompt ottimizzato in modo intelligente (comunemente noto come "ingegneria dei prompt").

Dai un'occhiata a ExtractData.java in 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
    }
}

Diamo un'occhiata ai vari passaggi in questo file:

  • Un record Person viene definito per rappresentare i dettagli che descrivono una persona ( nome ed età).
  • L'interfaccia PersonExtractor è definita con un metodo che, data una stringa di testo non strutturata, restituisce un'istanza Person.
  • extractPerson() è annotato con un'annotazione @UserMessage che associa un prompt all'elemento. Questo è il prompt che il modello utilizzerà per estrarre le informazioni e restituire i dettagli sotto forma di documento JSON, che verrà analizzato per te e non eseguirà il marshalling in un'istanza Person.

Ora diamo un'occhiata ai contenuti del metodo main():

  • Viene creata un'istanza del modello di chat. Tieni presente che utilizziamo un valore temperature molto basso di zero e un valore topK di uno solo, per garantire una risposta molto deterministica. Inoltre, il modello può seguire meglio le istruzioni. In particolare, non vogliamo che Gemini aggrega la risposta JSON con markup Markdown aggiuntivo.
  • Un oggetto PersonExtractor viene creato grazie alla classe AiServices di LangChain4j.
  • Quindi, puoi semplicemente chiamare Person person = extractor.extractPerson(...) per estrarre i dettagli della persona dal testo non strutturato e ottenere un'istanza Person con il nome e l'età.

Esegui l'esempio:

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

Dovresti vedere l'output seguente:

Anna
23

Sì, sono Anna e hanno 23 anni!

Con questo approccio AiServices, operi con oggetti molto digitati. Non stai interagendo direttamente con l'LLM. Al contrario, stai lavorando con classi concrete, come il record Person, per rappresentare le informazioni personali estratte, e hai un oggetto PersonExtractor con un metodo extractPerson() che restituisce un'istanza Person. La nozione di LLM è estrapolata e, come sviluppatore Java, stai semplicemente manipolando classi e oggetti normali.

8. Strutturare i prompt con modelli di prompt

Quando interagisci con un LLM utilizzando un insieme comune di istruzioni o domande, c'è una parte di quel prompt che non cambia mai, mentre le altre contengono i dati. Ad esempio, se vuoi creare delle ricette, puoi utilizzare un prompt come "Sei uno chef di talento, crea una ricetta con i seguenti ingredienti: ..." e poi aggiungere gli ingredienti alla fine del testo. Ecco a cosa servono i modelli di prompt, un po' come le stringhe interpolate nei linguaggi di programmazione. Un modello di prompt contiene segnaposto che puoi sostituire con i dati corretti per una determinata chiamata all'LLM.

Più concretamente, analizziamo TemplatePrompt.java nella directory 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());
    }
}

Come al solito, configuri il modello VertexAiGeminiChatModel, con un alto livello di creatività con una temperatura elevata e valori di topP e topK elevati. Poi creerai un'istruzione PromptTemplate con il relativo metodo statico from(), passando la stringa del nostro prompt e utilizzando le variabili segnaposto con doppie parentesi graffe: {{dish}} e {{ingredients}}.

Per creare il prompt finale, chiami apply() che prende una mappa di coppie chiave/valore che rappresentano il nome del segnaposto e il valore della stringa con cui sostituirlo.

Infine, chiami il metodo generate() del modello Gemini creando un messaggio utente da quel prompt con l'istruzione prompt.toUserMessage().

Esegui l'esempio:

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

Dovresti vedere un output generato simile a questo:

**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.

Modifica i valori di dish e ingredients nella mappa e modifica la temperatura, topK e tokP ed esegui nuovamente il codice. In questo modo potrai osservare l'effetto della modifica di questi parametri sull'LLM.

I modelli di prompt sono un buon modo per avere istruzioni riutilizzabili e parametrizzabili per le chiamate LLM. Puoi trasmettere i dati e personalizzare le richieste in base a valori diversi forniti dagli utenti.

9. Classificazione del testo con prompt few-shot

Gli LLM sono piuttosto bravi a classificare il testo in diverse categorie. Puoi aiutare un LLM in questa attività fornendo alcuni esempi di testi e le relative categorie associate. Questo approccio viene spesso chiamato poche richieste di inquadratura.

Dai un'occhiata a TextClassification.java nella directory app/src/main/java/gemini/workshop per eseguire un tipo specifico di classificazione del testo: l'analisi del sentiment.

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());
    }
}

Nel metodo main(), crei il modello di chat con Gemini come al solito, ma con un numero massimo di token di output ridotto, perché vuoi solo una risposta breve: il testo è POSITIVE, NEGATIVE o NEUTRAL.

Quindi, creerai un modello di prompt riutilizzabile con la tecnica di prompt few-shot, indicando al modello alcuni esempi di input e output. Ciò consente anche al modello di seguire l'output effettivo. Gemini non risponderà con una frase sostenuta, ma gli verrà chiesto di rispondere con una sola parola.

Applichi le variabili con il metodo apply(), per sostituire il segnaposto {{text}} con il parametro reale ("I love strawberries") e trasformi quel modello in un messaggio per gli utenti con toUserMessage().

Esegui l'esempio:

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

Dovresti visualizzare una sola parola:

POSITIVE

Sembra che amare le fragole sia un sentimento positivo.

10. Retrieval-Augmented Generation

gli LLM sono addestrati su una grande quantità di testo. Tuttavia, le loro conoscenze riguardano solo le informazioni che ha visto durante la formazione. Se vengono rilasciate nuove informazioni dopo la data limite per l'addestramento del modello, questi dettagli non saranno disponibili per il modello. Pertanto, il modello non sarà in grado di rispondere a domande su informazioni che non ha visto.

Ecco perché approcci come RAG (Retrieval Augmented Generation) aiutano a fornire le informazioni aggiuntive che un LLM potrebbe dover conoscere per soddisfare le richieste dei suoi utenti, per rispondere con informazioni che potrebbero essere più attuali o su informazioni private non accessibili durante l'addestramento.

Torniamo alle conversazioni. Questa volta potrai porre domande sui tuoi documenti. Creerai un chatbot in grado di recuperare informazioni pertinenti da un database contenente i tuoi documenti suddivisi in parti più piccole ("blocchi") e queste informazioni verranno utilizzate dal modello per fondare le sue risposte, invece di basarsi esclusivamente sulle conoscenze contenute nell'addestramento.

Nella RAG sono previste due fasi:

  1. Fase di importazione: i documenti vengono caricati in memoria e suddivisi in blocchi più piccoli e gli incorporamenti vettoriali (una rappresentazione vettoriale multidimensionale elevata dei blocchi) vengono calcolati e archiviati in un database vettoriale in grado di eseguire ricerche semantiche. Questa fase di importazione viene solitamente eseguita una volta sola, quando è necessario aggiungere nuovi documenti al corpus dei documenti.

cd07d33d20ffa1c8.png

  1. Fase di query: ora gli utenti possono porre domande sui documenti. La domanda verrà trasformata anche in un vettore e confrontata con tutti gli altri vettori nel database. I vettori più simili sono generalmente semanticamente correlati e vengono restituiti dal database vettoriale. All'LLM viene poi fornito il contesto della conversazione, i blocchi di testo che corrispondono ai vettori restituiti dal database e gli viene chiesto di fondare la risposta esaminando questi blocchi.

a1d2e2deb83c6d27.png

Preparare i documenti

Per questa nuova demo, ti verranno poste domande sull'articolo di ricerca "Attention is all you need". Descrive l'architettura di rete neurale del trasformatore, sviluppata da Google, che è il modo in cui tutti i moderni modelli linguistici di grandi dimensioni vengono implementati oggi.

L'articolo è già stato scaricato in attention-is-all-you-need.pdf nel repository.

Implementare il chatbot

Vediamo come creare l'approccio in due fasi: prima con l'importazione del documento, poi la quantità di tempo delle query quando gli utenti pongono domande sul documento.

In questo esempio, entrambe le fasi sono implementate nella stessa classe. Normalmente, avrai un'applicazione che si occupa dell'importazione e un'altra applicazione che offre l'interfaccia del chatbot agli utenti.

Inoltre, in questo esempio utilizzeremo un database vettoriale in memoria. In uno scenario di produzione reale, le fasi di importazione e query sarebbero separate in due applicazioni distinte e i vettori saranno mantenuti in un database autonomo.

Importazione di documenti

Il primo passaggio della fase di importazione dei documenti consiste nell'individuare il file PDF già scaricato e nel preparare un PdfParser per leggerlo:

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());

Anziché creare il solito modello linguistico di chat, puoi creare un'istanza di un modello di incorporamento. Si tratta di un particolare modello il cui ruolo è creare rappresentazioni vettoriali di parti di testo (parole, frasi o persino paragrafi). Restituisce vettori di numeri in virgola mobile, anziché risposte di testo.

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();

Successivamente, avrai bisogno di alcuni corsi per collaborare insieme al fine di:

  • Carica e dividi il documento PDF in blocchi.
  • Crea incorporamenti vettoriali per tutti questi chunk.
InMemoryEmbeddingStore<TextSegment> embeddingStore = 
    new InMemoryEmbeddingStore<>();

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

Viene creata un'istanza di InMemoryEmbeddingStore, un database vettoriale in memoria, per archiviare gli incorporamenti vettoriali.

Il documento è suddiviso in blocchi grazie alla classe DocumentSplitters. Il testo del file PDF verrà diviso in snippet di 500 caratteri, con una sovrapposizione di 100 caratteri (con il blocco seguente, per evitare di tagliare parole o frasi, in bit e pezzi).

Lo strumento di gestione dei dati di archiviazione collega lo strumento di suddivisione dei documenti, il modello di incorporamento per calcolare i vettori e il database vettoriale in memoria. Successivamente, il metodo ingest() si occuperà di eseguire l'importazione.

Ora, la prima fase è terminata, il documento è stato trasformato in blocchi di testo con i relativi incorporamenti vettoriali associati e archiviato nel database vettoriale.

Porre domande

È ora di prepararsi a fare domande. Crea un modello di chat per avviare la conversazione:

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

È necessaria anche una classe retriever per collegare il database vettoriale (nella variabile embeddingStore) al modello di incorporamento. Il suo compito è interrogare il database vettoriale calcolando un incorporamento vettoriale per la query dell'utente, al fine di trovare vettori simili nel database:

EmbeddingStoreContentRetriever retriever =
    new EmbeddingStoreContentRetriever(embeddingStore, embeddingModel);

Oltre al metodo principale, crea un'interfaccia che rappresenti un assistente esperto LLM, ovvero un'interfaccia che la classe AiServices implementerà per consentirti di interagire con il modello:

interface LlmExpert {
    String ask(String question);
}

A questo punto, puoi configurare un nuovo servizio di IA:

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

Questo servizio associa:

  • Il modello di linguaggio di chat che hai configurato in precedenza.
  • Una memoria della chat per tenere traccia della conversazione.
  • Il recuperatore confronta una query di incorporamento vettoriale con i vettori nel database.
  • Un modello di prompt indica esplicitamente che il modello di chat deve rispondere basando la sua risposta sulle informazioni fornite (ad esempio, gli estratti pertinenti della documentazione il cui incorporamento vettoriale è simile al vettore della domanda dell'utente).
.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())

Sei finalmente pronto a porre le tue domande.

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)));
);

Il codice sorgente completo si trova in RAG.java nella directory app/src/main/java/gemini/workshop:

Esegui l'esempio:

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

Nell'output dovresti vedere le risposte alle tue domande:

=== 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. Chiamate di funzione

Ci sono anche situazioni in cui vorresti che un LLM abbia accesso a sistemi esterni, come un'API web remota che recupera informazioni o esegue un'azione oppure servizi che eseguono qualche tipo di calcolo. Ad esempio:

API web remoto:

  • Monitora e aggiorna gli ordini dei clienti.
  • Trova o crea un ticket in Issue Tracker.
  • Recupera dati in tempo reale come quotazioni di borsa o misurazioni dei sensori IoT.
  • Invia un'email.

Strumenti di calcolo:

  • Una calcolatrice per problemi matematici più avanzati.
  • Interpretazione del codice per l'esecuzione di codice quando gli LLM hanno bisogno di una logica di ragionamento.
  • Converti le richieste in linguaggio naturale in query SQL in modo che un LLM possa eseguire query su un database.

La chiamata di funzione è la capacità del modello di richiedere una o più chiamate di funzione da effettuare per suo conto, in modo da poter rispondere correttamente al prompt di un utente con più dati aggiornati.

Data un particolare prompt di un utente e la conoscenza di funzioni esistenti che possono essere pertinenti per quel contesto, un LLM può rispondere con una richiesta di chiamata di funzione. L'applicazione che integra l'LLM può quindi chiamare la funzione e rispondere all'LLM con una risposta. L'LLM restituisce quindi una risposta testuale.

Quattro passaggi della chiamata di funzione

Vediamo un esempio di chiamata di funzione: come ottenere informazioni sulle previsioni meteo.

Se chiedi a Gemini o a qualsiasi altro LLM informazioni sul meteo a Parigi, risponderanno dicendo che non ha informazioni sulle previsioni meteo. Se vuoi che l'LLM abbia accesso in tempo reale ai dati meteo, devi definire alcune funzioni che può utilizzare.

Osserva il seguente diagramma:

31e0c2aba5e6f21c.png

1️⃣ Innanzitutto, un utente chiede informazioni sul meteo a Parigi. L'app di chatbot sa che sono disponibili una o più funzioni per aiutare l'LLM a completare la query. Il chatbot invia il prompt iniziale e l'elenco delle funzioni che è possibile chiamare. In questo caso, una funzione chiamata getWeather() che prende un parametro di stringa per la posizione.

8863be53a73c4a70.png

Poiché l'LLM non è a conoscenza delle previsioni meteo, invece di rispondere tramite testo, invia una richiesta di esecuzione della funzione. Il chatbot deve chiamare la funzione getWeather() con "Paris" come parametro di posizione.

d1367cc69c07b14d.png

2️⃣ Il chatbot richiama quella funzione per conto dell'LLM e recupera la risposta della funzione. In questo caso, immaginiamo che la risposta sia {"forecast": "sunny"}.

73a5f2ed19f47d8.png

3️⃣ L'app di chatbot invia la risposta JSON all'LLM.

20832cb1ee6fbfeb.png

4️⃣ L'LLM esamina la risposta JSON, interpreta queste informazioni e alla fine risponde con il testo che indica che il tempo è soleggiato a Parigi.

Ogni passaggio come codice

Innanzitutto, configurerai il modello Gemini come di consueto:

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

Devi specificare una specifica dello strumento che descriva la funzione che può essere chiamata:

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();

Sono definiti il nome della funzione, così come il nome e il tipo di parametro, ma tieni presente che sia la funzione che i parametri contengono descrizioni. Le descrizioni sono molto importanti e aiutano l'LLM a comprendere davvero cosa può fare una funzione e quindi a valutare se questa funzione deve essere chiamata nel contesto della conversazione.

Iniziamo dal passaggio 1 inviando la domanda iniziale sul meteo a Parigi:

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);

Nel passaggio 2 passiamo lo strumento che vogliamo venga utilizzato dal modello e il modello risponde con una richiesta di esecuzione troppo lunga:

// 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());

Passaggio 3: A questo punto, sappiamo quale funzione vorrebbe che l'LLM chiamasse. Nel codice, non stiamo effettuando una chiamata reale a un'API esterna, ma restituiamo direttamente una previsione meteo ipotetica:

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

Nel passaggio 4, l'LLM apprende il risultato dell'esecuzione della funzione e può quindi sintetizzare una risposta testuale:

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

L'output è:

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.

Puoi vedere nell'output sopra la richiesta di esecuzione dello strumento, nonché la risposta.

Il codice sorgente completo si trova in FunctionCalling.java nella directory app/src/main/java/gemini/workshop:

Esegui l'esempio:

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

Dovresti vedere un output simile al seguente:

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 gestisce la chiamata di funzione

Nel passaggio precedente, hai visto come le normali interazioni di domanda/risposta di testo e richiesta/risposta della funzione sono interlacciate e, nel frattempo, hai fornito direttamente la risposta della funzione richiesta, senza chiamare una funzione reale.

Tuttavia, LangChain4j offre anche un'astrazione di livello superiore che può gestire le chiamate di funzione in modo trasparente per te, mentre gestisce la conversazione come al solito.

Chiamata di funzione singola

Esaminiamo FunctionCallingAssistant.java un po' alla volta.

Per prima cosa, crei un record che rappresenterà la struttura dei dati di risposta della funzione:

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

La risposta contiene informazioni sulla posizione, sulla previsione e sulla temperatura.

Quindi crei una classe che contiene la funzione effettiva che vuoi rendere disponibile al modello:

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);
        }
    }
}

Tieni presente che questa classe contiene una singola funzione, ma è annotata con l'annotazione @Tool che corrisponde alla descrizione della funzione che il modello può richiedere di chiamare.

Anche i parametri della funzione (uno qui) sono annotati, ma con questa breve annotazione @P, che fornisce anche una descrizione del parametro. Potresti aggiungere tutte le funzioni che vuoi, per renderle disponibili al modello, in scenari più complessi.

In questa lezione vengono restituite alcune risposte predefinite, ma se si desidera chiamare un servizio di previsione meteo esterno reale, è nel corpo di quel metodo che si deve effettuare la chiamata a quel servizio.

Come abbiamo visto quando hai creato un ToolSpecification nell'approccio precedente, è importante documentare il funzionamento di una funzione e descrivere a cosa corrispondono i parametri. Questo aiuta il modello a capire come e quando questa funzione può essere utilizzata.

Successivamente, LangChain4j ti consente di fornire un'interfaccia corrispondente al contratto che vuoi utilizzare per interagire con il modello. Ecco un'interfaccia semplice che accetta una stringa che rappresenta il messaggio dell'utente e restituisce una stringa corrispondente alla risposta del modello:

interface WeatherAssistant {
    String chat(String userMessage);
}

È anche possibile utilizzare firme più complesse che coinvolgano UserMessage (per un messaggio utente) o AiMessage (per una risposta modello) di LangChain4j o persino TokenStream, se vuoi gestire situazioni più avanzate, poiché quegli oggetti più complicati contengono anche informazioni aggiuntive come il numero di token consumati e così via. Tuttavia, per semplicità, utilizzeremo semplicemente la stringa nell'input e la stringa nell'output.

Termina con il metodo main() che collega tutti i pezzi:

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?"));
}

Come al solito, configuri il modello di chat con Gemini. Quindi, crei un'istanza del tuo servizio di previsione meteo contenente la "funzione" che il modello ci chiederà di chiamare.

Ora utilizzerai di nuovo la classe AiServices per associare il modello di chat, la memoria di chat e lo strumento (ad es. il servizio di previsioni meteo con la sua funzione). AiServices restituisce un oggetto che implementa l'interfaccia WeatherAssistant che hai definito. Non ti resta che chiamare il metodo chat() di quell'assistente. Quando la richiami, vedrai solo le risposte di testo, ma le richieste di chiamata di funzione e le risposte alla chiamata di funzione non saranno visibili allo sviluppatore e queste richieste verranno gestite automaticamente e in modo trasparente. Se Gemini ritiene che una funzione debba essere chiamata, risponderà con la richiesta di chiamata di funzione e LangChain4j si occuperà di chiamare la funzione locale per tuo conto.

Esegui l'esempio:

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

Dovresti vedere un output simile al seguente:

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

Questo è un esempio di funzione singola.

Chiamate di funzione multiple

Inoltre, puoi avere più funzioni e lasciare che LangChain4j gestisca più chiamate di funzione per tuo conto. Dai un'occhiata a MultiFunctionCallingAssistant.java per un esempio con più funzioni.

Ha una funzione per convertire le valute:

@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;
}

Un'altra funzione per ottenere il valore di un'azione:

@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;
}

Un'altra funzione per applicare una percentuale a un determinato importo:

@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;
}

Puoi quindi combinare tutte queste funzioni e una classe MultiTools e porre domande come "Qual è il 10% del prezzo delle azioni AAPL convertito da 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?"));
}

Eseguilo come segue:

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

Dovresti inoltre vedere le diverse funzioni denominate:

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.

Verso gli agenti

La chiamata di funzione è un ottimo meccanismo di estensione per i modelli linguistici di grandi dimensioni come Gemini. Ci permette di creare sistemi più complessi, spesso chiamati "agenti" o "assistente IA". Questi agenti possono interagire con il mondo esterno tramite API esterne e con servizi che possono avere effetti collaterali sull'ambiente esterno (come l'invio di email, la creazione di ticket ecc.)

Quando crei agenti così potenti, dovresti farlo in modo responsabile. È consigliabile considerare l'utilizzo di human-in-the-loop prima di eseguire azioni automatiche. È importante tenere in considerazione la sicurezza quando si progettano agenti basati su LLM che interagiscono con il mondo esterno.

13. Esecuzione di Gemma con Ollama e TestContainers

Finora abbiamo usato Gemini, ma c'è anche Gemma, il suo modello più piccolo.

Gemma è una famiglia di modelli aperti leggeri e all'avanguardia basati sulla stessa ricerca e tecnologia utilizzate per creare i modelli Gemini. Gemma è disponibile in due varianti Gemma1 e Gemma2 ciascuna con diverse misure. Gemma1 è disponibile in due taglie: 2B e 7B. Gemma2 è disponibile in due taglie: 9B e 27B. I pesi sono disponibili liberamente e le dimensioni ridotte consentono di eseguirlo autonomamente, anche su laptop o in Cloud Shell.

Come si fa a correre Gemma?

Ci sono molti modi per eseguire Gemma: nel cloud, tramite Vertex AI con il clic di un pulsante o GKE con alcune GPU, ma puoi anche eseguirlo in locale.

Una buona opzione per eseguire Gemma in locale è con Ollama, uno strumento che ti consente di eseguire piccoli modelli, come Llama 2, Mistral e molti altri sulla tua macchina locale. È simile a Docker ma per gli LLM.

Installa Ollama seguendo le istruzioni per il tuo sistema operativo.

Se utilizzi un ambiente Linux, devi abilitare Ollama dopo l'installazione.

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

Dopo l'installazione in locale, puoi eseguire i comandi per eseguire il pull di un modello:

ollama pull gemma:2b

Attendi il pull del modello. L'operazione può richiedere un po' di tempo.

Esegui il modello:

ollama run gemma:2b

Ora puoi interagire con il modello:

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

Per uscire dal prompt premi Ctrl+D

Esecuzione di Gemma in Ollama su TestContainers

Anziché dover installare ed eseguire Ollama in locale, puoi utilizzare Ollama all'interno di un container, gestito da TestContainers.

TestContainers non è solo utile per i test, ma puoi anche utilizzarlo per eseguire i container. Puoi persino sfruttare una specifica OllamaContainer.

Ecco un quadro completo:

2382c05a48708dfd.png

Implementazione

Esaminiamo GemmaWithOllamaContainer.java un po' alla volta.

Innanzitutto, devi creare un container Ollama derivato che esegue il pull del modello Gemma. Questa immagine esiste già da un'esecuzione precedente o verrà creata. Se l'immagine esiste già, starai semplicemente dicendo a TestContainers che vuoi sostituire l'immagine Ollama predefinita con la tua variante basata su 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"));
    }
}

Successivamente, creerai e avvii un container di test Ollama e quindi crei un modello di chat Ollama, puntando all'indirizzo e alla porta del container con il modello che vuoi utilizzare. Infine, richiami model.generate(yourPrompt) come al solito:

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);
}

Eseguilo come segue:

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

La prima esecuzione richiede un po' di tempo per creare ed eseguire il container, ma al termine, dovresti vedere Gemma che risponde:

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 è in esecuzione in Cloud Shell.

14. Complimenti

Congratulazioni, hai creato la tua prima applicazione di chat con l'IA generativa in Java utilizzando LangChain4j e l'API Gemini. Lungo il percorso hai scoperto che i modelli linguistici di grandi dimensioni multimodali sono piuttosto potenti e in grado di gestire varie attività come domande e risposte, anche sulla tua documentazione, estrazione di dati, interazione con API esterne e altro ancora.

Passaggi successivi

Ora tocca a te migliorare le tue applicazioni con potenti integrazioni LLM.

Per approfondire

Documenti di riferimento