Spring AI contre LangChain4j : même RAG, choix cachés différents
Deux versions de Java RAG basées sur le même manuel ont montré que les écarts de comptage des lignes diminuaient avec l’intégration à Spring, et une réponse incorrecte concernant les congés a révélé les paramètres par défaut de segmentation.
Le même pipeline RAG a été développé à deux reprises. Le premier résultat semblait décisif. Une expérience a renversé cette conclusion.
La suppression de 85 lignes de code Java dans un service RAG n’a rien changé pour les utilisateurs — et a mis fin à une discussion d’une semaine entre les membres de l’équipe au sujet des frameworks.
Les partisans de Spring AI et ceux de LangChain4j ont chacun apporté des diapositives. Ce que personne n’a apporté, c’est une implémentation identique. Les deux versions ont été écrites dans des conditions strictement identiques.
Les conditions sont restées inchangées : un PDF RH de 240 pages, un modèle d’embedding, Postgres avec pgvector, un modèle de chat, ainsi qu’un ensemble de 200 questions défini avant même l’existence des deux bases de code.
Objectif : si le comportement différait, c’était le framework qui en était la cause — et non des écarts d’architecture entre deux conceptions manuelles.
Forme commune du pipeline
Rien d’exotique dans le processus de traitement :
Question
|
v
Embed Query
|
v
Vector Search
|
v
Top 4 Chunks
|
v
Build Context
|
v
Chat Model
|
v
Answer
L’ingestion était délibérément tout aussi ennuyeuse :
PDF
↓
Extract Text
↓
Split Into Chunks
↓
Generate Embeddings
↓
Store In pgvector
L’objectif était une comparaison des frameworks, et non une architecture novatrice qui aurait embrouillé les résultats.
Version Spring AI
La configuration est restée très légère :
spring:
ai:
openai.api-key: ${OPENAI_KEY}
vectorstore.pgvector:
initialize-schema: true
Ingestion en tant que composant Spring :
@Component
class Ingest {
Ingest(VectorStore store) {
var docs =
new TikaDocumentReader(
"classpath:/docs/handbook.pdf").get();
store.add(new TokenTextSplitter().apply(docs));
}
}
Interface HTTP pour les questions :
@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 masquait la récupération des données, l’assemblage du contexte et l’injection de prompts. Le chemin de récupération nécessitait à peine du code personnalisé — ce qui semblait être un avantage jusqu’à plus tard.
Version LangChain4j
La construction explicite montrait davantage de mécanismes dès le départ :
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));
La création du récupérateur de données était tout aussi transparente :
var retriever =
EmbeddingStoreContentRetriever.builder()
.embeddingStore(store)
.embeddingModel(embed)
.maxResults(4)
.minScore(0.6)
.build();
Lisible, oui. Compact par rapport à Spring AI ? Pas au début. La longueur en lignes de code Java était d’environ 41 contre 126 — soit presque trois fois plus du côté de LangChain4j. Le débat semblait se décider en faveur de la concision.
Puis le système a répondu à une question concernant le congé de paternité.
La réponse qui n’était pas dans le manuel
Lorsqu’on a demandé combien de jours de congé de paternité accordait le manuel, le modèle a répondu avec assurance :
Employees are entitled to 12 days of paternity leave,
subject to the conditions listed in the policy.
Le manuel indique 15 jours. Le chiffre 12 n’apparaît jamais dans cette politique. Le contexte récupéré révélait la vérité : les tableaux de la politique avaient été fragmentés par le séparateur par défaut. Les lignes associées étaient réparties en plusieurs parties ; le système de récupération a renvoyé des fragments plausibles individuellement ; le modèle les a assemblés pour former une réponse erronée mais fluide.
Ce bug existait avant le modèle de chat. Le comptage des lignes dans le framework n’avait pas permis de prédire la qualité.
L’écart de 3× n’était pas vraiment dû au framework
La ligne la plus importante :
new TokenTextSplitter().apply(docs)
Une seule appelation permettait de prendre des décisions qui n’avaient pas été examinées : gestion de la table, limites des blocs, chevauchements, métadonnées restantes, et ce que la récupération voit réellement. Spring AI rendait facile l’ignorance de ces choix. LangChain4j, quant à lui, en rendait davantage visibles dans le code de l’application.
La première comparaison montrait également des différences entre Spring AI intégré à Spring Boot et une configuration plus manuelle de LangChain4j. En reconstruisant LangChain4j avec son intégration Spring, le nombre de lignes du programme est passé à 58. L’écart est ainsi passé d’environ 3 fois à environ 1,4 fois. La complexité s’était déplacée vers le framework, sans disparaître du système.
Que choisir en pratique
- Services Spring Boot existants → Spring AI pour les conventions et moins de formalités dans les cas ordinaires de RAG.
Ne choisissez pas uniquement en fonction du nombre de lignes. Après avoir construit les deux solutions, le critère utile devient : combien de choix de pipeline êtes-vous prêt à laisser aux paramètres par défaut du framework ? Commencez par là — avant que quelqu’un ne supprime quatre-vingt-cinq lignes et ne proclame sa victoire.