Spätestens mit ChatGPT haben es LLMs geschafft, in aller Munde zu sein. Und auch wenn LLMs nur ein Aspekt von AI sind, ist es aktuell der Teil, der wohl am meisten im Fokus steht. Dabei stechen vorrangig die LLMs der großen Firmen, wie ChatGPT von OpenAI, Googles Gemini und Microsoft Copilot heraus. Diese Firmen haben, aus der Historie heraus, die Unmenge an Daten, um diese Modelle anzulernen und auch das notwendige Kleingeld, um die dafür benötigte Hardware und Betriebskosten zu stemmen.

Gerade in Deutschland, bzw. der EU, ist diese Nutzung von Daten aus juristischer Perspektive, insbesondere hinsichtlich Datenschutz und Urheberrecht noch unklar und schwierig. Vor allem da die Anbindung eines LLM ohne spezifische interne Daten in den wenigsten Anwendungsfällen einen wirklichen Mehrwert liefert. Dementsprechend bleiben nur zwei Möglichkeiten. Entweder, wir lernen, mit unseren Daten, ein eigenes LLM an oder wir nutzen ein bestehendes LLM und geben unsere Daten als Kontext mit. Das eigene Anlernen benötigt, neben einer Menge Wissen und Arbeitskraft, dann auch die passende Hardware. Der zweite Weg ist deswegen aktuell der Übliche. Diesen werden wir daher in diesem Post einmal Schritt für Schritt abgehen.

Lokales LLM mit Ollama

Vereinfacht gesagt, handelt es sich bei einem LLM um ein auf Sprache spezialisiertes Modell. Das heißt, ein solches Modell kann entweder Anfragen in natürlicher Sprache verstehen oder Antworten in natürlicher Sprache produzieren oder beides. Dazu gibt es auch noch Modelle, die für einen spezifischen Anwendungsfall, wie das Schreiben oder Diskutieren über Code, optimiert wurden.

Mittlerweile gibt es auch viele Modelle zur freien und lokalen Nutzung. Diese laufen auch auf nicht spezialisierter Hardware. Natürlich muss man hier, im Vergleich zu den großen kommerziellen Cloud-Modellen, Abstriche machen. In der Regel ist die Performanz, vor allem auf normaler Consumer-Hardware, schlechter und auch die Modelle selbst sind qualitativ nicht komplett vergleichbar. Um sich mit dem Thema vertraut zu machen und auszuprobieren, wie man diese in eine Anwendung integrieren kann, reichen diese aber mehr als aus. Der Anlaufpunkt für diese Modelle, und noch mehr, ist Hugging Face - eine Open-Source-Community, die sich auf die Entwicklung von Künstlicher Intelligenz (KI) und Natural Language Processing (NLP) spezialisiert hat.

Um nun so ein LLM lokal zu nutzen, müssen wir uns als Erstes für eine Ablaufumgebung entscheiden. Hier gibt es zahlreiche Möglichkeiten, wie LM Studio, Ollama oder Text Generation Web UI. Für diesen Post nutzen wir Ollama. Ollama ist eine solide Lösung, die sich vor allem auf die Grundlagen, das Verwalten von LLMs, spezialisiert.

Nachdem wir Ollama installiert und gestartet haben können wir über die Kommandozeile Modelle herunterladen und diese starten. In Listing 1 ist zu sehen wie wir einen Prompt mit dem Modell Mistral starten und eine Frage an das LLM formulieren.

~ $ ollama run mistral
>>> What is INNOQ?
 INNOQ is a German consulting company that specializes in software engineering, digital transformation, and innovation.
The company was founded in 1995 and has its headquarters in Munich. INNOQ offers services in areas such as software
architecture, agile development, test automation, DevOps, and data engineering. Their clients come from various
industries, including finance, healthcare, telecommunications, media, and manufacturing. INNOQ's mission is to help
businesses innovate and stay competitive by providing them with the latest technologies and best practices in software
development.

>>> Send a message (/? for help)
Listing 1: Ollama Prompt für Mistral Modell

Bei der ersten Verwendung eines Modells muss dieses vorher jedoch noch heruntergeladen werden, was durchaus dauern kann. So ist das oben verwendete Mistral etwa 4,1 GB groß.

Wir müssen jedoch nicht den interaktiven Prompt nutzen, denn eigentlich ist Ollama ein Serverprozess, der, standardmäßig, auf Port 11434 gestartet wird. Diesen können wir also auch über ein Tool das HTTP-Anfragen absetzen kann, wie curl, ansprechen (siehe Listing 2).

~ $ curl http://localhost:11434/api/generate -d '{
  "model": "mistral",
  "prompt": "What is INNOQ?"
}'
{"model":"mistral","created_at":"...","response":" IN","done":false}
{"model":"mistral","created_at":"...","response":"NO","done":false}
{"model":"mistral","created_at":"...","response":"Q","done":false}
{"model":"mistral","created_at":"...","response":" is","done":false}
{"model":"mistral","created_at":"...","response":" a","done":false}
{"model":"mistral","created_at":"...","response":" German","done":false}
{"model":"mistral","created_at":"...","response":" consulting","done":false}
{"model":"mistral","created_at":"...","response":" company","done":false}
{"model":"mistral","created_at":"...","response":" special","done":false}
{"model":"mistral","created_at":"...","response":"izing","done":false}
{"model":"mistral","created_at":"...","response":" in","done":false}
{"model":"mistral","created_at":"...","response":" software","done":false}
{"model":"mistral","created_at":"...","response":" engineering","done":false}
…
Listing 2: Abfrage der Ollama HTTP API

Spring AI

Um nun dasselbe Model aus einer Spring Boot-Anwendung heraus anzusprechen könnten wir natürlich den dort vorhandenen RestClient nutzen, dieselbe HTTP-Anfrage wie oben erzeugen und absetzen. Glücklicherweise gibt es aber bereits erste Schritte, um diese Abfragen und Konzepte zu kapseln, nämlich Spring AI.

Um die dort vorhandene Unterstützung für Ollama einzubinden, nutzen wir die spring-ai-bom und können anschließend spring-ai-ollama-spring-boot-starter als Abhängigkeit hinzufügen (siehe Listing 3).

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-bom</artifactId>
      <version>0.8.0</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
  </dependency>
</dependencies>
Listing 3: Spring AI Abhängigkeiten

Für dieselbe Abfrage wie bisher können wir jetzt einen Prompt mit einer UserMessage erzeugen und diesen über einen ChatClient an das LLM senden und die Antwort auswerten (siehe Listing 4).

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Bean
    CommandLineRunner chat(ChatClient client) {
        return args -> {
            var query = "What is INNOQ?";

            var prompt = new Prompt(
                    new UserMessage(query));

            var response = client.call(prompt);

            System.out.println(
                    response.getResult().getOutput().getContent());
        };
    }
}
Listing 4: Spring AI ChatClient Nutzung

Um das gewünschte Modell auszuwählen, müssen wir die zusätzlich die Option spring.ai.ollama.chat.options.model, hier über die application.properites (siehe Listing 5) setzen.

spring.ai.ollama.chat.options.model=mistral
spring.main.banner-mode=off
spring.main.web-application-type=none
Listing 5: application.properties der Anwendung

Zusätzlich verhindern wir noch das versucht wird ein HTTP-Server zu starten, da wir diesen für das Beispiel nicht benötigen. Starten wir jetzt die Anwendung erscheint nach einiger Zeit die Antwort des LLMs auf der Standardausgabe (siehe Listing 6).

…: Starting Application using Java 21.0.2 with PID 55277
…
…: Started Application in 1.07 seconds (process running for 1.551)
 INNOQ is a technology and consulting company headquartered in Germany, with a focus on software engineering, …
…
Listing 6: Ausgaben der Anwendung

Kontext und RAG

Ein LLM kann zur Beantwortung der Prompts lediglich die beim Training verwendeten Daten zur Beantwortung nutzen. Deswegen wird etwa die Frage „What date is today?“ mit „I’m an artificial intelligence and don’t have the ability to experience time or know the current date without being connected to a database or external source. However, I can help you check the date if you tell me which specific date you have in mind, or I can tell you the current date if you provide me with your location so I can access an online calendar or database.“ beantwortet.

Wir können dem Prompt aber neben der eigentlichen Frage auch zusätzlichen Kontext mitgeben. Diesen kann das LLM dann mit nutzen. Um dies in der Anwendung zu machen, können wir dem Prompt eine Liste von Nachrichten mitgeben und neben der Frage als UserMessage auch eine SystemMessage erzeugen (siehe Listing 7).


var query = "What date is today?";

var prompt = new Prompt(List.of(
        new SystemMessage("Today is 2024-02-27."),
        new UserMessage(query)));

var response = client.call(prompt);

System.out.println(
        response.getResult().getOutput().getContent());
Listing 7: Übergabe von Kontext als SystemMessage

Mit diesem Kontext bekommen wir nun „Today, February 27, 2024, falls in the year 2024. It is the 58th day of the year in the Gregorian calendar, with only 306 days remaining until the end of the year on December 31. This date represents a Wednesday according to the standard 7-day weekly cycle.“ als Antwort.

Um ein LLM also mit eigenen Daten zu nutzen, müssen wir die für die Beantwortung relevanten Informationen mit in unseren Prompt packen. Dieses hat jedoch in der Regel ein Limit an Tokens, vereinfacht gesagt Wörtern, die es akzeptiert. Das liegt daran, dass bei einer größeren Menge an Tokens die Beantwortung mehr Energie benötigt und demnach länger dauert und teurer wird. Deswegen werden auf LLMs basierende Anwendungen aktuell in der Regel in Kombination mit einer Vektor-basierten Datenbank und dem Ansatz der Retrieval Augmented Generation (RAG) entwickelt.

Vereinfacht gesagt werden hierbei sämtliche Informationen, die wir potenziell als Kontext benötigen mithilfe eines Embedding Modells in Vektoren umgewandelt. Diese, mehrdimensionalen Vektoren werden, inklusive den Daten, anschließend in eine auf Vektoren spezialisierte Datenbank gespeichert. Vor jeder Anfrage an das LLM werden nun aus dieser Datenbank nur noch die Informationen geladen, die für die Anfrage relevant sind. Um zu erkennen, was relevant ist, wird auch die Frage, oder Teile von dieser, in einen Vektor umgewandelt und anschließend sind nur noch die Daten relevant, die in der Nähe dieses Vektors liegen.

Dementsprechend bringt Spring AI auch Unterstützung für das Berechnen dieser Embeddings und die Verbindung zu den gängigen Vektordatenbanken mit. Um etwa Fragen zu den deutschen Standorten von INNOQ zu beantworten können wir diese in einer JSON-Datei ablegen (siehe Listing 8).

[
  {
    "zip": "40789",
    "city": "Monheim am Rhein",
    "address": "Krischerstr. 100"
  },
  {
    "zip": "10999",
    "city": "Berlin",
    "address": "Ohlauer Str. 43"
  },
  {
    "zip": "20537",
    "city": "Hamburg",
    "address": "Wendenstraße 130"
  },
  {
    "zip": "50672",
    "city": "Köln",
    "address": "Spichernstraße 44"
  },
  {
    "zip": "63067",
    "city": "Offenbach",
    "address": "Ludwigstr. 180 E"
  },
  {
    "zip": "80331",
    "city": "München",
    "address": "Kreuzstr. 16"
  }
]
Listing 8: JSON-Datei mit Informationen

Diese laden wir jetzt vor unserer Anfrage in einen VectorStore. Hierfür nutzen wir in diesem Falle eine einfache arbeitsspeicherbasierte Implementierung (siehe Listing 9).


@Bean
VectorStore vectorStore(EmbeddingClient client) {
    return new SimpleVectorStore(client);
}

@Bean
@Order(-1000)
CommandLineRunner load(i
        VectorStore store,
        @Value("classpath:/offices.json") Resource resource) {
    return args -> {
        var documents = new JsonReader(resource,
                "zip", "city", "address")
            .get();
        store.add(documents);
    };
}
Listing 9: Erzeugen und speichern der Embeddings im VectorStore

Neben dem verwendeten JsonReader werden auch noch weitere Implementierungen zur Extraktion von Daten aus anderen Formaten wie PDF mitgeliefert. Und natürlich können wir hier auch eigene schreiben, indem wir das DocumentReader-Interface implementieren.

Zuletzt können wir den befüllten VectorStore jetzt auch bei der Abfrage des LLM nutzen und die Dokumente, die zur Anfrage passen, als Kontext mit übergeben (siehe Listing 10).


@Bean
CommandLineRunner chat(VectorStore store, ChatClient client) {
    return args -> {
        var city = "München";
        var query = "Where is the %s INNOQ office located?"
                .formatted(city);

        var documents = store.similaritySearch("city: " + city).stream()
                .map(Document::getContent)
                .collect(joining("\n"));

        var systemMessage = """
                You are a virtual assistant.
                You are answering questions regarding the INNOQ offices provided within the DOCUMENTS paragraph.
                You are only allowed to use information from the DOCUMENTS paragraph and no other information.
                If you are not sure or don't know honestly state that you don't know.

                DOCUMENTS:
                {documents}
                """;

        System.out.println(documents);

        var prompt = new Prompt(List.of(
                new SystemPromptTemplate(systemMessage)
                        .createMessage(Map.of("documents", documents)),
                new UserMessage(query)));

        var response = client.call(prompt);

        System.out.println(
                response.getResult().getOutput().getContent());
    };
}
Listing 10: Nutzung der Embeddings für RAG

Als Ergebnis antwortet unser LLM nun mit „Based on the information provided in the DOCUMENTS paragraph, the München INNOQ office is located at address Kreuzstr. 16 in city München and zip code 80331.“. Außerdem ist durch das vorherige System.out.println auf der Standardausgabe auch zu sehen, dass nicht alle, sondern nur die Informationen zu vier Standorten mit in den Kontext gegeben wurden.

Fazit

In diesem Post haben wir uns gemeinsam angeschaut, wie wir unter Verwendung von Ollama Large Language Models lokal nutzen können und wie sich diese mithilfe von Spring AI in eine Spring Boot basierte Anwendung integrieren lassen.

Spring AI ist ein noch sehr frisches Projekt und hat erst vor wenigen Tagen mit Version 0.8.0 ein erstes Milestone Release veröffentlicht. Da die Entwicklung hier auch auf die sehr dynamische Situation der LLMs reagieren muss, können sich die hier gezeigten Listings natürlich in Zukunft noch stark verändern. Ich gehe jedoch nicht davon aus, dass das generelle Konzept noch einmal komplett umgeworfen wird.

Neben der Anbindung von Ollama bietet uns Spring AI auch andere Möglichkeiten wie Chat GPT oder das Azure Open AI an. Und neben der Unterstützung von Chat basierten LLMs gibt es auch eine Abstraktion für die Generierung von Bildern.

Dieser Post hat sich dabei darauf beschränkt, die grundlegenden Konzepte kurz und vereinfacht zu erklären und die technische Integration in Spring Boot anhand eines kleinen Beispiels zu zeigen. Um in einer realen Anwendung gute Ergebnisse zu erzielen, müssten wir hauptsächlich in die Embeddings und den Prompt noch deutlich mehr Energie investieren. Diese beiden Stellen sind beim Einsatz von Retrieval Augmented Generation, neben der Wahl des richtigen Modells als Basis, existenziell für die Qualität und somit für den gesamten Anwendungsfall.

Ein besonderer Dank geht an die Kolleg:innen, die fleißig Headerbilder generiert oder den Artikel Korrektur gelesen haben.