Beliebte Suchanfragen
//

GenAI für Full Stack EntwicklerInnen: RAG Evaluation mit TypeScript (Teil 3)

3.7.2024 | 15 Minuten Lesezeit

Disclaimer: Dieser Artikel ist Teil einer Serie. Lies am besten zuerst Teil 1 und Teil 2, um auf dem neuesten Stand zu sein.

In der traditionellen Softwareentwicklung sind Tests ein essenzieller Bestandteil. Wir nutzen E2E-Tests, Unit- und Integrationstests, Security-Scans und andere Methoden, um sicherzustellen, dass das Produkt unseren Ansprüchen entspricht. Selbstverständlich gibt es auch im GenAI-Umfeld ähnliche Ansätze, die sich jedoch als etwas komplexer erweisen. Da wir es hier mit nicht-deterministischen Systemen zu tun haben, ist ein simpler Unit-Test oft wenig aussagekräftig. Mal antwortet das LLM mit X, mal mit Y. Gerade deshalb ist die strukturierte Evaluierung eines RAG-Systems ein Schritt, den wir gehen müssen (und wollen). Letztendlich wirst du auch als Full-Stack-EntwicklerIn stets testen, testen und nochmals testen.

Im Folgenden beschreibe ich verschiedene Metriken und ihre konkrete Implementierung, um zu zeigen, wie sich letztendlich doch verlässliche Zahlen an ein RAG-System koppeln lassen. Daraufhin nutzen wir dieses Wissen, um herauszufinden, welche Stellschrauben für unser aktuelles RAG die wichtigsten sind. Wir werden das System schrittweise verbessern und überprüfen, ob sich wirklich etwas verbessert hat. Das Ganze wird Full-Stack-freundlich in TypeScript entwickelt, mit so vielen frei verfügbaren Tools wie möglich. Mit diesem Ansatz werden wir unserem RAG letzten Endes vielleicht sogar entlocken können, was wir in unserem Kontext denn nun wirklich als ollama verstehen.

Voraussetzungen

Um diesem Artikel folgen zu können, kannst du ganz einfach das Github-Repo klonen. Im evaluations-Ordner findest du alle Dateien des heutigen Themas. Die modifizierte RAG-Pipeline kannst du dann mit yarn serve-for-evaluation ausführen.

Leider kommen wir dieses Mal nicht ganz ohne einen Internetzugang aus. Um Antworten unseres RAGs zu bewerten, werden üblicherweise LLMs verwendet, und wir könnten versuchen, das lokal abzubilden. Doch ich habe die Erfahrung gemacht, dass unser bisheriges Llama3 das nicht ausreichend zuverlässig schafft. Deshalb empfehle ich die Nutzung eines möglichst kompetenten Modells, in unserem Fall eine auf Azure gehostete OpenAI-Instanz. Das hat zusätzlich den Vorteil, dass wir unsere Testdaten ebenso mit ChatGPT generieren können und nicht allzu viel manuelle Vorarbeit benötigen. Wie du eine Azure OpenAI-Instanz einrichtest, findest du in der Azure Dokumentation.

Für die Konfiguration von LangChain musst du nun eine Datei azure.env ins Projektverzeichnis ablegen mit den folgenden Informationen:

1export AZURE_OPENAI_API_INSTANCE_NAME=<AZURE_REGION>  
2export AZURE_OPENAI_API_DEPLOYMENT_NAME=<AZURE_DEPLOYMENT>  
3export AZURE_OPENAI_API_KEY=<AZURE_AUTH_KEY>
4export AZURE_OPENAI_API_VERSION="2024-02-01"  
5export AZURE_OPENAI_BASE_PATH=https://<AZURE_DOMAIN>/openai/deployments

Wie wird ein RAG evaluiert?

Die grundsätzliche Idee für die Evaluierung eines RAGs ist, auf Basis der Eingabe, der Antwort, des Kontextes und einer Idealantwort (im Englischen als „Ground Truth" bekannt), verschiedene Metriken zu messen. Da RAGs meist Text ausgeben und die Bewertung von Text auf deterministische Weise relativ schwierig ist, nutzt man hier wiederum ein LLM (LLM-as-a-judge). Diese sind schließlich auf Texterfassung spezialisiert.

Was wollen wir eigentlich herausfinden?

Bevor wir mit dem Testen anfangen, sollten wir darüber nachdenken, was wir überhaupt wissen wollen. In unserem minimalen Fall habe ich gleich mehrere Ansätze, über die ich gerne weitere Details wissen möchte:

  1. Sind die Antworten des RAGs faktisch korrekt?
  2. Nutzt das RAG ausschließlich Antworten aus der Wissensdatenbank?
  3. Wie konkret (oder relevant) sind die Antworten für die gestellte Frage?

Zusätzlich habe ich auch noch ein paar Bonusanforderungen, die ich gerne überprüfen und verbessern möchte. Mir ist aufgefallen, dass das RAG meistens auf Englisch antwortet, selbst wenn ich eine Frage auf Deutsch gestellt habe. Außerdem ist das System verhältnismäßig langsam, vielleicht gibt es ja eine Konfiguration, mit der wir schneller bessere Antworten generieren lassen können?

  1. Sind die Antworten in der richtigen Sprache?
  2. Wie schnell antwortet das RAG?

Sinnvolle Metriken für unser RAG

Glücklicherweise sind wir nicht die Ersten, die die Qualität eines RAGs evaluieren möchten. Ein beliebtes Framework hierfür ist Ragas. Dort werden verschiedene qualitative Metriken beschrieben, von denen wir einige gebrauchen können. Die folgenden Ansätze werden wir heute implementieren:

  • Answer Relevance: Hierbei wird gemessen wie relevant die Antworten des RAGs für die Frage sind. Um das zu evaluieren, bedarf es eines gewissen Aufwands. Zunächst werden von der RAG-Antwort mithilfe eines LLMs künstlich Fragen generiert. Diese werden dann mit der Originalfrage verglichen. Das Vergleichen können wir mit der Vektorenähnlichkeit beider Embeddings erreichen. Sind die Antworten des RAGs ausreichend präzise sollten auch die daraus generierten Fragen näher an der Originalfrage liegen.
  • Faithfulness: Wie gut hat das RAG den Kontext genutzt, um die Antwort zu generieren? Der Ansatz, um das zu testen, ist, zuerst eine Reihe von Aussagen von der RAG-Antwort zu generieren. Das kann ebenso mithilfe von ChatGPT geschehen. Als nächstes wird dann jede Aussage mit dem Kontext aus der Wissensdatenbank verglichen, um herauszufinden, ob sie allein vom Kontext her abgeleitet werden kann. Das Verhältnis von "erschwindelten" zu korrekt abgeleiteten Aussagen ist dann der Faithfulness-Faktor.

Unsere zusätzlichen Messungen können wir wie folgt bemessen:

  • Sprachloyalität: Die Konsistenz von Frage- und Antwortsprache können wir einfach mit ChatGPT überprüfen lassen.
  • Zeit: Noch einfacher lassen sich Anfrage- und Antwortszeit mit TypeScript messen.

Evaluierung von RAGs mit TypeScript

Leider gibt es Stand Juni 2024 keine generischen RAG-Evaluierungstools für TypeScript-Projekte. Die beiden wichtigsten Frameworks für RAGs, LlamaIndex und LangChain, bieten aber wenigstens ein paar rudimentäre Helferfunktionen, mit denen sich für das jeweilige Framework Tests entwickeln lassen. Eine „opinionated" Implementierung unabhängig von der genutzten Bibliothek, wie z.B. Ragas für Python, gibt es jedoch noch nicht. Daher sind wir gezwungen, einige der komplexeren Metriken selbst zu schreiben. Der Ablauf unseres Evaluierungsprozesses ist wie folgt:

  1. Testfragen & Idealantworten erstellen und manuell überprüfen
  2. Das RAG mit diesen Fragen füttern und dessen Antworten plus den genutzten Kontext dokumentieren
  3. Auf Basis dieser Daten die Metriken anwenden
  4. Die Metriken auswerten und vergleichen
  5. Das RAG-System anpassen und erneut Testantworten generieren

Eine Übersichtsgrafik für den oben beschriebenen Ablauf einer RAG Evaluation.Ablauf einer RAG Evaluation

Testfragen generieren

Für alle oben genannten Metriken benötigen wir zunächst einen Datensatz an Fragen und Antworten. Immerhin ist das beantworten von Fragen genau jene Fähigkeit unseres RAGs deren Qualität wir bemessen wollen. Um diese zu bekommen, gibt es verschiedene Ansätze. Die höchste Qualität erreicht man sicher durch einen manuellen Prozess, indem händisch Frage-Antwort Paare auf Basis der Wissensdatenbank geschrieben werden. Zudem gibt es ein paar (mal wieder nur in Python entwickelte) Tools, um solche Fragen automatisch zu generieren. Ein guter Kompromiss für mich ist die Nutzung von ChatGPT. Mit dem folgenden Prompt habe ich 10-20 Fragen automatisch generieren lassen. Für einen vollwertigen Test wäre das zwar ein recht kleiner Datensatz, aber so bekommen wir ausreichend Informationen und müssen später nicht allzu lange warten.

1Deine Aufgabe ist es, genau 10 Fragen aus dem gegebenen Kontext zu formulieren und die Antwort zu jeder Frage zu geben.
2
3Beende jede Frage mit einem '?' Zeichen.
4Die Antwort sollte im JSON-Format mit den Feldern question und groundTruth angegeben werden.
5
6Die Fragen müssen die unten aufgeführten Regeln erfüllen:
71. Die Fragen sollten für Menschen auch ohne den gegebenen Kontext verständlich sein.
82. Die Fragen sollten vollständig aus dem gegebenen Kontext beantwortet werden.
93. Die Fragen sollten sich aus einem Teil des Kontexts ergeben, der wichtige Informationen enthält. Dies kann auch aus Tabellen, Code usw. stammen.
104. Die Antworten auf die Fragen dürfen keine Links enthalten.
115. Die Fragen sollten von mittlerem Schwierigkeitsgrad sein.
126. Die Fragen müssen vernünftig sein und von Menschen verstanden und beantwortet werden können.
137. Verwende keine Formulierungen wie „angegebener Kontext“ usw. in den Fragen.
148. Vermeide Fragen mit dem Wort "und", die in mehr als eine Frage aufgeteilt werden könnten.
159. Die Fragen sollten nicht mehr als 10 Wörter enthalten. Verwende wenn möglich Abkürzungen.
1610. Die Fragen und Antworten sollten auf Deutsch sein.
17
18Kontext:
19...

Das habe ich mit mehreren der Dokumente gemacht und letztlich alle Ergebnisse in die Datei evaluations/data/questions.json kopiert. Diese Fragen sollten aber dennoch einmal manuell durchgesehen werden, um schlechte oder falsch in den Kontext gesetzte Fragen herauszufiltern.

Frag das RAG!

Der nächste Schritt ist die aktuelle Konfiguration des RAGs zu testen. Hierfür senden wir einfach die zuvor gespeicherten Fragen nacheinander an das System. Da wir für unsere Metriken später auch den Retrieval-Kontext benötigen, speichern wir diesen ebenso ab. Auch unsere Zeitmessung muss schon jetzt erfolgen. Im Projekt ausführen kannst du den folgenden Code mit yarn generate-test-data <szenarioName> ausführen.

1async function generateTestData() {  
2  const questions = getQuestionsFromFile()  
3  const chain = await initializeRagChain("./data")  
4  const testData = []  
5  
6  for (const { question, groundTruth } of questions) {  
7    console.log("Requesting answer for question: " + question)  
8    const start = Date.now()  
9    const { answer, context } = await chain.invoke(question)  
10    const end = Date.now()  
11  
12    testData.push({  
13      question,  
14      answer,  
15      groundTruth,  
16      context,  
17      executionTimeInSeconds: (end - start) / 1000,  
18    })  
19  }  
20  writeTestResultToFile(datasetName, testData)  
21}

Das Ergebnis wird dann in evaluations/data/test-data-<szenarioName>.json abgespeichert und sieht dann ungefähr so aus:

1{  
2    "question": "Welche Portnummer nutzt das Open WebUI Interface?",  
3    "groundTruth": "3000",  
4    "answer": "Das Open WebUI Interface wird auf Port 3000 erreichbar sein.",  
5    "context": "System erst einmal nur lokal hoste. Für komplexere Setups ist das\nnatürlich nicht geeignet.\n...",  
6    "executionTimeInSeconds": 3.702  
7  }

Die Zwischenspeicherung unserer Ergebnisse hilft uns später nachzuvollziehen, wie genau ein Metrikwert zustande gekommen ist. Vielleicht war eine Frage nicht gut gestellt oder der Kontext völlig daneben?

Evaluierung des RAGs in TypeScript

Jede unserer Metriken erhält die für sie relevanten Informationen und gibt am Ende einen Wert zwischen 0 und 1 zurück. Je näher der Wert an 1 liegt, desto besser ist die Performance des RAGs in Bezug auf diese spezifische Metrik.

LanguageLoyalty

Die einfachste Metrik ist die Konsistenz der Sprache. Hier können wir einfach einen Custom-Evaluator von LangChain nutzen und ihm mitteilen, welche Fragestellung wir überprüfen wollen.

1async function evaluateLanguageLoyalty({ question, answer }) {  
2  const criteria = {  
3    languageLoyalty: "Is the output in the same language as the input?",  
4  }  
5  const evaluator = await loadEvaluator("criteria", {  
6    criteria,  
7  })  
8  const languageResult = await evaluator.evaluateStrings({  
9    input: question,  
10    prediction: answer,  
11  })  
12  return languageResult.score  
13}

Das Ergebnis von evaluateStrings beinhaltet auch noch ein Feld reasoning, das die Begründung von ChatGPT für seine Entscheidung enthält. Falls eine Bewertung unklar ist, empfehle ich, dort einmal nachzuschauen.

Faithfulness

Schon etwas komplexer wird es bei „Faithfulness". Um herauszufinden wie viele der Aussagen einer möglicherweise langen Antwort halluziniert wurden versuchen wir mithilfe von ChatGPT einige Aussagen zu extrahieren, die auf die gegebene Antwort anwendbar sind. Wir lassen ChatGPT die Fragen mit einem eindeutigen Trennzeichen (XXX) separieren, um es später einfacher zu haben, die einzelnen Fragen zu extrahieren.

1async function generateMinimalStatementsFromAnswer(answer: string) {  
2  const model = new AzureChatOpenAI()  
3  const response = await model.invoke(  
4    `Generate 5 minimal statements which can be directly derived from the following context. Separate each statement only with 'XXX'. The statements should be as clear as possible. \n CONTEXT:\n ${answer}`,  
5  )  
6  return response.content.toString().split("XXX")  
7}

Für die Antwort Der Retriever wird durch das initialisierte VectorStore generiert. generiert ChatGPT folgende Aussagen:

Der Retriever wird generiert.
Der Retriever wird durch VectorStore generiert.
Das VectorStore ist initialisiert.
Ein initialisiertes VectorStore ist erforderlich.
Der Retriever benötigt das initialisierte VectorStore.

Mit diesen fünf Sätzen können wir nun wiederum einen LangChain Evaluator nutzen, um herauszufinden, welche der Aussagen aus dem Kontext ableitbar waren. Von den einzelnen Scores (entweder 0 oder 1) errechnen wir anschließend den Durchschnittswert.

1async function evaluateFaithfulness({ answer, context }) {  
2  const statements = await generateMinimalStatementsFromAnswer(answer)  
3  const criteria = {  
4    faithfulness:  
5      "Can the following statement directly be derived from the input?",  
6  }  
7  const evaluator = await loadEvaluator("criteria", { criteria })  
8  let results = []  
9  for (const statement of statements) {  
10    const faithfulness = await evaluator.evaluateStrings({  
11      input: context,  
12      prediction: statement,  
13    })  
14    results.push(faithfulness.score)  
15  }  
16  return average(results)  
17}

Relevanz

Ähnlich wie bei der vorherigen Metrik müssen wir auch für die Relevanz zunächst ein paar synthetische Daten erheben. Dieses Mal lassen wir ChatGPT allerdings 10 Fragen generieren, die seiner Meinung nach auf die gegebene Antwort passen könnten. Dann erstellen wir einen EmbeddingEvaluator mit LangChain und konfigurieren ihn so, dass er mit einem lokalen Embedding kommuniziert. Dadurch können wir uns etwas Zeit sparen. Auch hier geben wir abschließend den durchschnittlichen Wert der errechneten Ähnlichkeit zurück.

1async function evaluateRelevance({ question, answer }) {  
2  const questions = await generateQuestionsFromAnswer(answer)  
3  const evaluator = await loadEvaluator("embedding_distance", {  
4    embedding: new OllamaEmbeddings({  
5      model: "jina/jina-embeddings-v2-base-de",  
6    }),  
7  })  
8  const relevances = []  
9  for (const syntheticQuestion of questions) {  
10    const relevance = await evaluator.evaluateStrings({  
11      prediction: syntheticQuestion,  
12      reference: question,  
13    })  
14    relevances.push(relevance.score)  
15  }  
16  return average(relevances)  
17}

Die Metriken kombinieren

Die oben gezeigten Metriken können wir nun mit yarn evaluate <szenarioName> über unsere generierten Testdaten laufen lassen. Da hierbei einige Anfragen an ChatGPT gestellt werden, kann dieser Schritt je nach Datensatz mehrere Minuten dauern. Um die Nachvollziehbarkeit zu gewährleisten, empfehle ich, auch hier die genutzten Kontextinformationen wie Frage, Antwort etc. zu speichern. Nebenbei erfassen wir ebenfalls die zuvor erstellte executionTime.

1async function evaluate () {  
2  const testData = getTestResultFromFile(datasetName)  
3  const evaluationResults = []  
4  for (const dataSet of testData) {  
5    console.log(`Evaluation for: ${dataSet.question}`)  
6    const relevance = await evaluateRelevance(dataSet)  
7    const languageLoyalty = await evaluateLanguageLoyalty(dataSet)  
8    const faithfulness = await evaluateFaithfulness(dataSet)  
9    evaluationResults.push({  
10      relevance,  
11      languageLoyalty,  
12      faithfulness,  
13      executionTime: dataSet.executionTimeInSeconds,  
14      question: dataSet.question,  
15      answer: dataSet.answer,  
16      context: dataSet.context,  
17      groundTruth: dataSet.groundTruth,  
18    })  
19  }  
20  writeEvaluationResultToFile(datasetName, evaluationResults)  
21}

Ein mögliches Ergebnis könnte dann wie folgt Aussehen:

1{  
2  "relevance": 0.08137573866625877,  
3  "languageLoyalty": 1,  
4  "faithfulness": 1,  
5  "executionTime": 3.702,  
6  "question": "Welche Portnummer nutzt das Open WebUI Interface?",  
7  "answer": "Das Open WebUI Interface wird auf Port 3000 erreichbar sein.",  
8  "context": "System erst einmal nur lokal hoste. Für komplexe...",  
9  "groundTruth": "3000"  
10}
11...

Auswertung der Ergebnisse

Zur Bewertung der Qualität des Systems können wir nun einen Testlauf starten. Mit averageOfField nutzen wir einen kleinen Helfer, der von einer Liste aus Objekten die Werte aus dem übergebenen Feld holt und den Durchschnitt ermittelt.

1const evaluationResults = getEvaluationResultFromFile(datasetName)  
2const critiques = [  
3  "executionTime",  
4  "relevance",  
5  "languageLoyalty",  
6  "faithfulness",  
7]  
8critiques.forEach((criteria) => {  
9  const average = averageOfField(criteria, evaluationResults)  
10  console.log(`${criteria}: ${average.toFixed(3)}`)  
11})

Ausgeführt mit yarn summarize <szenarioName> sieht das Ergebnis für die Konfiguration von Artikel 2 dann wie folgt aus:

1executionTime: 6.827
2relevance: 0.411
3languageLoyalty: 0.444
4faithfulness: 0.656

Iterative Optimierung des RAG Systems

Wir sind endlich so weit, herauszufinden, wie gut unser bisheriges RAG abschneidet. Das Problem mit den Werten ist natürlich, dass sie für sich allein wenig Aussagekraft haben. Um das zu ändern, können wir nun einige der Stellschrauben des letzten Artikels anwenden. Anschließend lässt sich bewerten, welche Anpassung eine Verbesserung gebracht hat und welche sich vielleicht nicht lohnt. Wir testen die folgenden Szenarien nacheinander. Am Ende wenden wir alle drei Szenarien gleichzeitig an und schauen uns das Ergebnis an.

  • Embedding: Statt nomic-embed-text versuchen wir es mit dem für die deutsche Sprache ausgelegten jina/jina-embeddings-v2-base-de.
  • Prompt: Unser erster Prompt war sehr einfach gehalten. Vielleicht erzielen wir bessere Ergebnisse mit einem Prompt, der gleichzeitig die Sprachkonsistenz fordert? Wir versuchen es mit:
You are an expert in computer science. 
Answer the question based only on the following context. 
Always answer in the same language as the question. 
Answer concise and accurate.
  • Chunks: Durch kleinere Text-Chunks bei der Vektorisierung könnten wir schneller und gleichzeitig genauer werden. Anstatt des Default-Werts von "1 PDF-Seite" werden wir eine Chunk-Größe von 500 testen.

Die Testläufe haben folgende Tabelle hervorgebracht:

SzenarioTimeRelevanceLanguageFaithfulness
Start6.820.410.440.66
Embedding7.150.450.440.82
Prompt6.660.490.6110.711
Chunks4.800.330.6110.77
Kombination3.970.360.890.733

Bei der Änderung des Embeddings lassen sich erste Zeitverbesserungen messen, auch wenn diese marginal sind. Dafür scheint jedoch die Vertrauenswürdigkeit des Systems deutlich besser zu sein. Das ist auch verständlich, denn mit einem besseren Embedding sollte auch das "Textverständnis" höher sein.

Die Anpassung des Prompts hingegen hat vor allem die Sprachkonsistenz erhöht. Auch dies sorgt für zusätzliches Vertrauen in unser Test-Setup, denn die erwarteten Änderungen spiegeln sich in der Statistik wider.

Beim Textsplitting wird das System zwar merklich schneller, verliert aber gleichzeitig an Relevanz. Hier können wir als EntwicklerInnen nun entscheiden, welche Prioritäten wir setzen wollen – auf der Basis von klaren Zahlen. ❤️

Die Kombination aller Anpassungen hat überraschenderweise eine noch bessere Geschwindigkeit, zeigt aber leider keine nennenswert höhere Relevanz. Immerhin schafft sie es, die Sprache deutlich konsistenter zu halten als die anderen Kombinationen.

Diese Konfiguration habe ich letztlich für das RAG genutzt und dieses nach Ollama befragt. Dabei kam heraus, dass Ollama eine Webseite und ein CLI sei. Was, wie ich es im ersten Artikel vorgestellt habe, wohl der Wahrheit ausreichend nahe kommt. Zudem kam die Antwort dieses Mal auf Deutsch!

Auf die Frage wer oder was Ollama ist antwortet Ollama dieses Mala Ollama sei eine Website und ein CLI ToolDie zentrale Frage musste gestellt werden

Was nun?

Auf der Basis der vorgestellten Metriken lässt sich unser RAG nun deutlich einfacher messbar machen. Wir stellen konkrete Änderungen in der Statistik fest, wenn wir Anpassungen am System vornehmen, und können nun iterativ herausfinden, welcher Ansatz die Ausgabe positiv beeinflusst. Falls du Interesse an weiteren, spezialisierteren Metriken hast, schau dir zum Beispiel die Context Recall- und die Context Relevancy-Metrik an. Beide versuchen die Qualität des Retrievers messbar zu machen. Auch weitere sogenannte Critique-Metriken, wie das Erfassen von schadhaften oder boshaften Ausgaben, sind denkbar. Es gibt auch bessere Tests für die Qualität des LLM-Outputs, wie zum Beispiel die Conciseness-Metrik. Wie du dir vorstellen kannst, hängt die Wahl der Metriken stark vom Use Case des RAGs ab.

Unsere Reise als Full-Stack-EntwicklerInnen in die Welt der generativen KI ist hier aber erst am Anfang. Wir sind mittlerweile in der Lage, Sprachmodelle lokal zu nutzen, sie mit unserem privaten Kontext zu füttern und zu evaluieren, ob sie dabei gute Arbeit leisten. Doch ob mit oder ohne Kontext – derzeit sind unsere Modelle nicht viel mehr als Textgeneratoren. Diese Texte sind oft unstrukturiert und wenig vorhersehbar. Verlässliche Systeme oder gar die Einbindung in ein komplexeres Interface ist damit noch nicht möglich. Wie wir die Stärken eines LLMs außerhalb eines Chat-Interfaces nutzen können, dessen Antwort als zuverlässiges API-Interface nutzen und damit herkömmliche UI-Workflows ablösen können bespreche ich im nächsten Artikel. Ich freue mich schon darauf!

Beitrag teilen

Gefällt mir

5

//

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.