Spring AI gegen LangChain4j: derselbe RAG, unterschiedliche versteckte Entscheidungen
Zwei Java RAG-Implementierungen, die auf einem Handbuch beruhen, zeigten, dass Lücken in der Zeilenanzahl bei Integration mit Spring abnehmen – und eine falsche Antwort bezüglich der Freistellung die Standard-Einstellungen für das Chunking aufdeckte.
Derselbe RAG-Pipeline wurde zweimal erstellt. Die erste Ergebnistabelle schien entscheidend zu sein. Ein Experiment änderte jedoch das Urteil.
Das Löschen von 85 Zeilen Java aus einem RAG-Dienst änderte nichts, was die Nutzer sehen konnten – und beendete eine zweiwöchige Teamdebatte über Frameworks.
Anhänger von Spring AI und LangChain4j brachten jeweils Präsentationsfolien mit. Was niemand mitbrachte, war eine zweite Implementierung. Beide Versionen wurden unter identischen Bedingungen entwickelt.
Die Bedingungen blieben unverändert: ein 240-seitiges HR-PDF, ein Embedding-Modell, Postgres zusammen mit pgvector, ein Chat-Modell sowie eine Sammlung von 200 Fragen, die bereits vor der Existenz beider Codebasen festgelegt worden waren.
Ziel: Falls sich das Verhalten unterschied, sollte die Framework-wahl der Grund sein – nicht Abweichungen in der Architektur zwischen zwei eigenständig entwickelten Designs.
Gleiche Struktur der Pipeline
Auf dem Datenverarbeitungsweg gab es nichts Exotisches:
Question
|
v
Embed Query
|
v
Vector Search
|
v
Top 4 Chunks
|
v
Build Context
|
v
Chat Model
|
v
Answer
Auch die Eingabeprozesse waren absichtlich genauso langweilig:
PDF
↓
Extract Text
↓
Split Into Chunks
↓
Generate Embeddings
↓
Store In pgvector
Es ging um einen Vergleich der Frameworks, nicht um eine neue Architektur, die die Ergebnisse verfälschen würde.
Spring AI-Version
Die Konfiguration blieb sehr klein:
spring:
ai:
openai.api-key: ${OPENAI_KEY}
vectorstore.pgvector:
initialize-schema: true
Die Dateneingabe als Spring-Komponente:
@Component
class Ingest {
Ingest(VectorStore store) {
var docs =
new TikaDocumentReader(
"classpath:/docs/handbook.pdf").get();
store.add(new TokenTextSplitter().apply(docs));
}
}
HTTP-Schnittstelle für Anfragen:
@RestController
class AskApi {
private final ChatClient ai;
AskApi(ChatClient.Builder b, VectorStore store) {
this.ai = b.defaultAdvisors(
new QuestionAnswerAdvisor(store)).build();
}
@GetMapping("/ask")
String ask(@RequestParam String q) {
return ai.prompt().user(q).call().content();
}
}
QuestionAnswerAdvisor verbarg die Datenabfrage, den Kontextaufbau sowie die Prompt-Einbindung. Für den Datensuchweg war kaum spezieller Code nötig – was zunächst wie ein Vorteil erschien, sich aber später als falsch herausstellte.
LangChain4j-Version
Die ausführlichere Darstellung zeigte bereits zu Beginn mehr Komponenten:
var embed =
OpenAiEmbeddingModel.builder()
.apiKey(key)
.build();
var store =
PgVectorEmbeddingStore.builder()
.host("localhost")
.port(5432)
.database("rag")
.user("app")
.password(pw)
.table("chunks")
.dimension(1536)
.build();
EmbeddingStoreIngestor.builder()
.documentSplitter(
DocumentSplitters.recursive(500, 60))
.embeddingModel(embed)
.embeddingStore(store)
.build()
.ingest(
FileSystemDocumentLoader.loadDocument(path));
Auch der Aufbau des Datenabrufers war vollständig sichtbar:
var retriever =
EmbeddingStoreContentRetriever.builder()
.embeddingStore(store)
.embeddingModel(embed)
.maxResults(4)
.minScore(0.6)
.build();
Lesbar, ja. Kompakter im Vergleich zu Spring AI? Zunächst nicht. Die Anzahl der Java-Codezeilen lag bei etwa 41 gegenüber 126 – das sind fast dreimal so viele Zeilen bei LangChain4j. Es schien, als wäre die Debatte bereits wegen der Kürze entschieden.
Dann beantwortete das System eine Frage zur Vaterschaftsurlaub.
Die Antwort, die nicht im Handbuch stand
Als gefragt wurde, wie viele Vaterschaftsurlaubstage das Handbuch vorsieht, antwortete das Modell zuversichtlich:
Employees are entitled to 12 days of paternity leave,
subject to the conditions listed in the policy.
Im Handbuch steht 15 Tage. Die Zahl 12 kommt in dieser Richtlinie nie vor. Der abgerufte Kontext zeigte die wahre Situation: Die Tabellen der Richtlinie waren durch den Standard-Teiler zerschnitten worden. Verwandte Zeilen wurden auf verschiedene Abschnitte aufgeteilt; der Abrufmechanismus lieferte einzelne, plausibel erscheinende Fragmente; das Modell setzte diese zu einer flüssig klingenden, aber falschen Antwort zusammen.
Der Fehler existierte bereits vor dem Chat-Modell. Die Zeilenzählungen des Frameworks hatten die Qualität nicht vorhergesagt.
Die 3×-Lücke lag nicht wirklich am Framework
Die wichtigste Zeile:
new TokenTextSplitter().apply(docs)
Ein einziger Aufruf führte zu Entscheidungen, die nicht überprüft worden waren: Handhabung der Tabelle, Grenzen der Blöcke, Überschneidungen, überlebende Metadaten sowie das, was tatsächlich bei der Abfrage angezeigt wird. Spring AI machte es einfach, diese Entscheidungen zu ignorieren. LangChain4j sorgte dafür, dass mehr davon im Anwendungscode sichtbar wurden.
Auch beim ersten Vergleich unterschied sich die in Spring Boot integrierte Spring AI von einer manuell konfigurierten LangChain4j. Durch die Neukonfiguration von LangChain4j mit der Spring-Integration sank die Anzahl der Zeilen auf 58. Der Unterschied verringerte sich von etwa 3× auf etwa 1,4×. Die Komplexität war in das Framework gewandert, nicht aus dem System verschwunden.
Was in der Praxis gewählt werden sollte
- Bestehende Spring Boot-Dienste → Spring AI für gängige Verfahren und weniger Formalitäten bei herkömmlichen RAG-Anwendungen.
Wählen Sie nicht allein aufgrund der Zeilenanzahl. Nach dem Aufbau beider Lösungen wird der entscheidende Filter folgendermaßen: Wie viele Pipeline-Optionen sind Sie bereit, bei den Standardeinstellungen des Frameworks zu belassen? Beginnen Sie dort – bevor jemand achtundachtzig Zeilen löscht und als Sieger gilt.