Disclaimer: Dieser Artikel ist Teil einer Serie, wenn du den ersten Teil noch nicht gelesen hast dann findest du ihn hier.
Nachdem wir beim letzten Mal einen ersten Kontakt mit Open Source LLMs hatten, geht es heute endlich ans Codieren! Die Chatbots, die wir lokal verfügbar gemacht haben, sind zwar ein spannender Einstieg, doch abgesehen von der Code Completion mit Continue, waren sie nicht wirklich praktisch. So viel Spaß es auch machen kann, mit diesen Modellen zu chatten, haben wir noch kaum einen Mehrwert generiert. Wenn ihr euch erinnert, war Llama3 noch nicht einmal in der Lage, korrekt zu beschreiben, was Ollama ist. Derzeit kennen unsere Modelle noch keine interessanten Informationen über ihren Kontext. In einer zunehmenden Anzahl von Projekten im Bereich der LLMs geht es genau darum, diese Modelle zu kontextualisieren. Das bedeutet, ihnen auf verschiedene Weise Informationen zu übermitteln, damit sie gezielt und mit internem Fachwissen Fragen beantworten können.
Eine Methode, um dies zu bewerkstelligen, die sogenannte Retrieval-Augmented Generation (RAG), werde ich im Folgenden vorstellen. Da dies eher ein Integrations- als ein Expertenproblem ist, eignet es sich perfekt für den Werkzeugkasten eines Full Stack Entwicklers. Als solcher versuche ich außerdem, möglichst im selben Technologie-Stack zu bleiben. Deshalb werden wir das Ganze rein in Typescript schreiben, vom rudimentären Frontend-Chat bis zur Integration an unsere fleißigen, lokalen LLMs. Am Ende können wir in einem echten UI ganz normale Fragen an die KI stellen und sogar darauf hoffen, dass sie dieses Mal das Ollama Projekt kennt. Also nichts wie rein in den Code, viel Spaß!
Was ist eigentlich ein RAG?
Die Idee eines RAGs ist simpel: Anstatt ein KI-Modell nur mit einer Frage zu füttern, wird es gleichzeitig noch mit Texten ausgestattet, mithilfe derer es dann in der Lage sein sollte, die Frage genauer zu beantworten. Dazu wird „einfach“ eine Suche, ein sogenanntes Retrieval, über eine Wissensdatenbank gemacht, um die bestmöglichen Texte herauszufinden. Denn wenn wir zum Beispiel 200 PDFs im System haben, können wir die meisten Modelle nicht konstant mit so viel Input versorgen, mal ganz abgesehen von den schrecklichen Performance-Implikationen.
Um herauszufinden, welche Dokumente den besten Kontext liefern könnten, werden Embeddings genutzt. Das sind Mechanismen, mithilfe derer unstrukturierte Daten wie Texte quantifizierbar gemacht werden. Die Technologie dahinter ist super interessant, einen tollen Artikel über deren Entwicklung findet ihr hier, aber das würde wohl zu weit führen.
Interessant für uns ist, dass Embeddings letztlich immer Vektoren produzieren, die dann eine gewisse Aussagekraft über den eingebetteten Inhalt haben. Generiert man ausreichend Vektoren und versucht dann, sie mit dem Embedding der Chatbot-Frage zu vergleichen, lassen sich durch die mathematische (z.B. euklidische) Distanz verwandte Texte finden. Keine Sorge, wir fangen jetzt nicht gleich an, irgendwelche Vektoren miteinander zu vergleichen; dazu gibt es natürlich Bibliotheken und spezifische Vektordatenbanken, aber interessant ist es natürlich trotzdem. 🙂
Setup
Vorneweg muss ich noch anmerken, dass der komplette Code, den ich heute bespreche, unter diesem Repo mitverfolgt werden kann. Ich weiß, wie nervtötend es sein kann, schon das einfachste TypeScript-Setup zu erstellen. Daher werde ich einfach davon ausgehen, dass ihr ein solches schon vor euch habt und komme gleich ans Eingemachte.
Für unser kleines Projekt benötigen wir, neben unseren lokalen Modellen mit Ollama, noch folgende Bibliotheken:
- LangChain ist eine Integrations-Bibliothek, um die Kommunikation mit LLMs zu vereinfachen. Sie stellt Tools für die Erstellung von RAGs bereit und hat, im Gegensatz zum ebenfalls bekannten LlamaIndex, eine weit umfangreichere TypeScript-Version. Zusätzlich werden wir auch die Bibliothek langchain/community installieren, um Zugriff auf eine ganze Reihe an kleinen Helferlein zu bekommen.
- Mit Hnswlib können wir unsere Vektoren In-Memory speichern und gleichzeitig ihre nächsten Verwandten finden. Bei größeren Projekten wäre dies wohl die erste Komponente, die wir ersetzen müssten, um Arbeitsspeicher und Persistenz zu gewährleisten, doch für unseren Proof of Concept (PoC) reicht es erstmal aus.
- Durch die Peer-Dependency pdf-parse ermöglichen wir LangChain zudem, PDFs zu verarbeiten.
- Backroad ist ein JavaScript-Tool, um schnell Frontend-Prototypen zu erstellen. Es ermöglicht uns, nicht lange über VueJS vs React philosophieren zu müssen. Wir fokussieren uns stattdessen mehr auf den interessanten RAG-Teil des Projekts.
All diese Bibliotheken installieren wir zum Beispiel mit yarn:
1yarn add @backroad/backroad @langchain/community @langchain/core hnswlib-node pdf-parse
Nicht zuletzt gilt es noch eine sinnvolle Run Configuration im package.json
zu hinterlegen:
1"scripts": {
2 "dev": "npx ts-node index.ts"
3},
Für den „intelligenten“ Teil unseres RAGs benötigen wir zwei Modelle. Unser altbekanntes llama3, um ihm beizubringen, was Ollama eigentlich wirklich ist (ollama pull llama3
), und für das Erstellen der Embedding-Vektoren nutzen wir nomic-embed-text, ein eigens für diesen Zweck trainiertes Modell: ollama pull nomic-embed-text
.
Außerdem brauchen wir natürlich eine Wissensdatenbank. Da wir ja beim letzten Mal schon geklärt hatten, was Ollama wirklich ist, liegt es nahe, einfach einen Teil des Codecentric Blogs herunterzuladen. Der Einfachheit halber habe ich das mit dem „Print to PDF“-Tool meines Browsers gemacht und so ein paar wenige Blogartikel erstellt, die wir später als Wissensdatenbank wieder einlesen können.
Konfigurieren und weiter Konfigurieren
Da LangChain uns schon so einiges abnimmt, können wir uns voll auf die Konfiguration konzentrieren. Ein erster Schritt ist, unseren Kontext bereitzustellen. Dazu habe ich alle PDFs in den Ordner data
verfrachtet und lade sie nun mit LangChain in den Arbeitsspeicher. Ohne Voreinstellung lädt der PDFLoader jede PDF-Seite als eigenes Dokument hoch.
1async function loadFiles(path: string): Promise<Document[]> {
2 const loader = new DirectoryLoader(path, {
3 ".pdf": (path) => new PDFLoader(path),
4 })
5 return loader.load()
6}
Im Anschluss können wir die Daten einbetten und in unsere kleine In-Memory-Datenbank überführen. Da unser Embedder auch mit Ollama gehostet wird, nutzen wir die von der Community bereitgestellte OllamaEmbeddings
Komponente. Jeder LangChain-VectorStore implementiert die fromDocuments
-Methode, mit der wir letztlich eine DB-Instanz von den bereitgestellten Dokumenten erstellen.
1async function initializeVectorDatabase(documents: Document[]) {
2 const embeddings = new OllamaEmbeddings({ model: "nomic-embed-text" })
3 return HNSWLib._fromDocuments_(documents, embeddings)
4}
Diese Teile dann zu einer funktionalen RAG-Pipeline zu kombinieren sieht wie folgt aus.
1async function initializeRagChain(filePath: string): Promise<RunnableSequence> {
2 const docs = await loadFiles(filePath)
3 const vectorStore = await initializeVectorDatabase(docs)
4 const retriever = vectorStore.asRetriever()
5
6 const prompt =
7 PromptTemplate._fromTemplate_(`Answer the question based only on the following context:
8 {context}
9 Question: {question}`)
10
11 const chatModel = new ChatOllama({
12 model: "llama3",
13 })
14
15 return RunnableSequence._from_([
16 {
17 context: retriever.pipe(_formatDocumentsAsString_),
18 question: new RunnablePassthrough(),
19 },
20 prompt,
21 chatModel,
22 new StringOutputParser(),
23 ])
24}
Vom initialisierten VectorStore können wir uns einen Retriever generieren, durch den wir jene Dokumente bekommen, welche der Anfrage am nächsten sind.
Im Prompt definieren wir nun, wie genau die gefundenen Dokumente als Kontext an das Chat-Modell übergeben werden sollen. Die in geschweiften Klammern gesetzten Platzhalter werden später von unserer LangChain-Pipeline ersetzt. In diesem Fall weisen wir die KI darauf hin, dass sie ausschließlich Informationen auf Basis des vorgegebenen Kontextes liefern darf.
Zuletzt erstellen wir die Pipeline. LangChain hat eigens dafür eine eigene kleine DSL (Domain-Specific Language) entwickelt: LCEL. Eine RunnableSequence wird durch eine Reihe von Aktionen initialisiert, die sequentiell auf die Eingabeparameter ausgeführt werden. Unsere Schritte sind wie folgt:
- Ausführen des Retrievers: Da per Default die drei Dokumente mit der größten Verwandtschaft zur Anfrage zurückgegeben werden, braucht es noch den
formatDocumentsAsString
-Helfer. Dieser reiht die gefundenen Dokumente einfach untereinander in einen String. Für diequestion
, den zweiten Parameter unseres Prompts, wird einfach das initiale Argument weiter durchgereicht. Nach diesem Schritt ist der Parameter für die nächste Funktion ein Objekt mit den Felderncontext
undquestion
. - Füllen des Prompts: Hier wird das PromptTemplate mit dem Ergebnis des Retrievers gefüllt.
- Anfrage an das LLM: Der Prompt mitsamt Kontext aus der Wissensdatenbank wird an das lokale LLM übergeben. Hic sunt dracones.
- Output parsen: Für unseren Chatbot brauchen wir einen einfachen String.
Lass mich mal sehen!
Wie versprochen, benötigen wir ein kleines UI, um dem Full-Stack-Anspruch und unserer CSS-Manie gerecht zu werden. Ohne die Frontend-Spezialisten zu sehr vor den Kopf stoßen zu wollen, beschränken wir uns aber auf das minimalste Setup. Perfekt dafür geeignet ist Backroad. Hiermit lassen sich einfache Prototypen ausnahmsweise mal wirklich in Minuten realisieren. Mit der run
-Funktion können wir eine Sequenz starten, die bei jeder Datenänderung erneut ausgeführt wird und so einen dynamischen Inhalt rendern kann. Wir hinterlegen eine Variable messages
, um auf Änderungen reagieren zu können und fangen mit einer einfachen Chatnachricht an.
Alle Messages werden dann über die schon vorgegebene ChatMessage
-Funktion gerendert, und am Ende des Chats brauchen wir noch ein Input, das auf neue Eingaben wartet. Ist eine Frage eingegeben worden, wird die zuvor übergebene Funktion askRAG
mit dem Text der Frage aufgerufen und das Ergebnis in die messages
-Variable geschrieben. Durch diese Datenänderung wird dank Backroad einfach erneut alles gerendert, doch dieses Mal mit einer frischen Antwort des Modells.
1function startChatUI(askRAG: Function) {
2 _run_(async (br) => {
3 const messages = br.getOrDefault("messages", [
4 { by: "ai", content: "Wie kann ich dir helfen?" },
5 ])
6 br.write({ body: `# Chatte mit deinen Dokumenten \n---` })
7
8 messages.forEach((message) => {
9 br.chatMessage({ by: message.by }).write({ body: message.content })
10 })
11
12 const input = br.chatInput({ id: "input" })
13 if (input) {
14 const response = await askRAG(input)
15 br.setValue("messages", [
16 ...messages,
17 { by: "human", content: input },
18 { by: "ai", content: response },
19 ])
20 }
21 })
22}
Was ist denn nun Ollama?
Um das herauszufinden fehlt nur noch das Zusammenstöpseln von Front- und Backend.
1const main = async () => {
2 const chain = await initializeRagChain("./data")
3 startChatUI((input: string) => chain.invoke(input))
4}
5main()
Wir initialisieren die LangChain-Pipeline auf Basis unserer PDF-Sammlung und übergeben dann an Backroad eine Funktion. Diese nimmt einen Texteingabewert und startet die Pipeline mit invoke
. Mit yarn dev
starten wir dann im Terminal unser Projekt. Das Ergebnis können wir uns jetzt im Browser unter http://localhost:3333 ansehen. Wenn wir nun fragen, wer oder was wirklich Ollama ist, antwortet das Modell wie folgt:
Damit kommen der Wahrheit schon ein ganzes Stück näher. Ollama ist nun kein „mesoamerikanisches Ballspiel” mehr sondern wenigstens ein AI System oder Chatbot.
Jetzt kann ich RAGs, was kommt als nächstes?
Leider ist die ganze Wahrheit nicht so leicht wie dieser erste PoC. RAGs sind vom grundlegenden Konzept zwar recht simpel, doch wie so oft liegt der Teufel im Detail. Es gibt einige Stellschrauben, an denen wir nun noch drehen könnten, um das RAG genauer, hilfreicher und schneller zu machen. Ein paar mögliche Ansätze wären zum Beispiel:
Data Loading
Derzeit laden wir all unsere Dokumente mit dem bereitgestellten PDFLoader
. Besonders bei PDFs ist die Bandbreite an Textarten jedoch enorm. Die Nutzung von spezialisierteren Lademechanismen oder einer entsprechenden API wie zum Beispiel LlamaParse kann helfen, mehr sinnvolle und für das LLM lesbare Informationen bereitzustellen.
Text Splitting
Der genutzte PDFLoader
teilt Texte auf Basis von PDF-Seiten ein. Diese Größe ist vielleicht für unseren ersten Prototypen ausreichend, sorgt aber dafür, dass viel Text als Kontext mitgegeben wird, der nicht hilfreich ist. Außerdem können LLMs nur eine bestimmte Menge an Text gleichzeitig verarbeiten, weshalb es helfen könnte, kleinere Textabschnitte zu indexieren statt ganzer A4-Seiten. Die Menge an Kontext, die das Modell bekommt, ist ebenso relevant für das Ergebnis.
Embeddings & LLMs
Natürlich lassen sich auch unterschiedlichste Modelle und vor allem Embeddings nutzen. So gibt es zum Beispiel Embeddings, die besser mit deutschen Texten umgehen können oder potentere Chatmodelle wie ChatGPT.
Prompt Engineering
Auch durch das strukturierte Anpassen des Prompts lässt sich die Qualität verbessern. Wenn ihr dazu mehr wissen wollt lest euch doch mal unseren aktuellen Artikel durch.
Quellen
Im Gegensatz zu traditionellen LLMs nutzt unsere Pipeline nur ganz bestimmte Quellen. Um die Nachvollziehbarkeit des Systems zu erhöhen, könnte der Kontext mit in die Antwort gegeben werden.
Datenbank
Eine dedizierte Vektordatenbank würde unter anderem Persistenz mit sich bringen. So könnten leicht deutlich größere Wissensdatenbanken indexiert werden.
Falls du selbst einmal Hand anlegen möchtest um herauszufinden ob der ein oder andere Ansatz schon Verbesserungen mitbringt kannst du mal in das begleitende Github-Repo schauen. Unter enhancements
habe ich ein paar der Vorschläge animplementiert. Dieses spannende Video zur Optimierung von RAGs könnte dir zudem weitere Ansätze liefern die richtigen Stellschrauben zu finden.
Wie genau wir herausfinden können, welche dieser und weiterer Anpassungen nun den besten Mehrwert für das System haben, schauen wir uns aber genauer im nächsten Teil der Serie an. Welche Metriken lassen sich auf solche RAGs anlegen, und wie können Qualität bewertet und optimiert werden? Ich möchte versuchen, messbar zu machen, worauf es wirklich ankommt. Doch bis dahin: Happy Coding!
Weitere Beiträge
von Robin Schlenker
Dein Job bei codecentric?
Jobs
Agile Developer und Consultant (w/d/m)
Alle Standorte
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.
Gemeinsam bessere Projekte umsetzen.
Wir helfen deinem Unternehmen.
Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.
Hilf uns, noch besser zu werden.
Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.
Blog-Autor*in
Robin Schlenker
Full Stack Consultant
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.
Du hast noch Fragen zu diesem Thema? Dann sprich mich einfach an.