Inicio / Artículos / Spring AI frente a LangChain4j: mismo RAG, diferentes decisiones ocultas

Spring AI frente a LangChain4j: mismo RAG, diferentes decisiones ocultas

Dos implementaciones de Java RAG basadas en un mismo manual demostraron que las diferencias en el recuento de líneas disminuyen con la integración a Spring, y una respuesta incorrecta sobre permisos reveló los valores predeterminados de fragmentación.

736 palabras

Se construyó dos veces el mismo pipeline RAG. El primer resultado parecía decisivo. Un experimento cambió esa conclusión.

Borrar 85 líneas de Java de un servicio RAG no modificó nada visible para los usuarios, y puso fin a una discusión de dos semanas dentro del equipo sobre frameworks.

Los seguidores de Spring AI y los de LangChain4j llevaron cada uno sus diapositivas. Lo que nadie trajo fue una implementación idéntica; ambas versiones se desarrollaron bajo condiciones exactamente iguales.

Las condiciones permanecieron fijas: un PDF de recursos humanos de 240 páginas, un modelo de embedding, Postgres junto con pgvector, un modelo de chat y un conjunto de 200 preguntas definido antes incluso de que existiera cualquiera de los códigos.

Objetivo: si el comportamiento difería, la causa debería ser el framework, no las diferencias arquitectónicas entre dos diseños desarrollados manualmente.

Estructura compartida del pipeline

No había nada exótico en el proceso de procesamiento:

Question
   |
   v
Embed Query
   |
   v
Vector Search
   |
   v
Top 4 Chunks
   |
   v
Build Context
   |
   v
Chat Model
   |
   v
Answer

La fase de ingesta también se mantuvo intencionadamente aburrida:

PDF
 ↓
Extract Text
 ↓
Split Into Chunks
 ↓
Generate Embeddings
 ↓
Store In pgvector

Se trataba de una comparación entre marcos, no de una arquitectura novedosa que pudiera confundir los resultados.

Version de Spring AI

La configuración siguió siendo muy sencilla:

spring:
  ai:
    openai.api-key: ${OPENAI_KEY}
    vectorstore.pgvector:
      initialize-schema: true

La ingesta como componente de Spring:

@Component
class Ingest {
  Ingest(VectorStore store) {
    var docs =
        new TikaDocumentReader(
            "classpath:/docs/handbook.pdf").get();

store.add(new TokenTextSplitter().apply(docs));
  }
}

Interfaz HTTP para las consultas:

@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 ocultaba la recuperación de información, el montaje del contexto y la inyección de prompts. El proceso de recuperación apenas requería código personalizado, lo cual parecía una ventaja hasta más adelante.

Version de LangChain4j

La estructura explícita mostraba más componentes desde el principio:

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 construcción del recuperador de información también era completamente visible:

var retriever =
    EmbeddingStoreContentRetriever.builder()
        .embeddingStore(store)
        .embeddingModel(embed)
        .maxResults(4)
        .minScore(0.6)
        .build();

Es legible, sí. ¿Más compacto que Spring AI? No al principio. La cantidad de código en Java en la aplicación fue de aproximadamente 41 frente a 126 — casi tres veces más en el lado de LangChain4j. La discusión parecía decidirse a favor de la brevedad.

Luego el sistema respondió a una pregunta sobre la licencia de paternidad.

La respuesta que no estaba en el manual

Al preguntar cuántos días de paternidad otorga el manual, el modelo respondió con confianza:

Employees are entitled to 12 days of paternity leave,
subject to the conditions listed in the policy.

El manual indica 15 días. El número 12 nunca aparece en esa política. El contexto recuperado reveló la verdadera situación: las tablas de la política habían sido fragmentadas por el divisor predeterminado. Las filas relacionadas se dividieron en varios segmentos; el recuperador devolvió fragmentos individualmente plausibles; el modelo los unió para formar una respuesta incorrecta pero coherente.

El error existía antes del modelo de chat. Los recuentos de líneas del framework no habían permitido predecir la calidad.

La brecha de 3× en realidad no se debía al framework

La línea que más importaba:

new TokenTextSplitter().apply(docs)

Una sola llamada ocultaba decisiones que no habían sido revisadas: el manejo de tablas, los límites entre bloques, las superposiciones, los metadatos que sobrevivían y lo que realmente veía el sistema de recuperación de información. Spring AI facilitaba ignorar esas decisiones. LangChain4j, en cambio, hacía que más de ellas fueran visibles en el código de la aplicación.

La primera comparación también contrastó a Spring AI, integrado con Spring Boot, con una configuración más manual de LangChain4j. Al reconstruir LangChain4j con su integración en Spring, el número de líneas del código disminuyó a 58. La diferencia se redujo de aproximadamente 3 veces a unos 1,4 veces. La complejidad pasó al framework, en lugar de desaparecer del sistema.

Qué elegir en la práctica

  • Servicios existentes de Spring Boot → Spring AI para seguir convenciones y simplificar el uso en casos comunes de RAG.
  • Java independiente o recuperación personalizada compleja → LangChain4j cuando son importantes las partes explícitas del pipeline y la posibilidad de ajustar el fragmentado y la recuperación.
  • No elija únicamente en función del número de líneas. Después de crear ambos, el filtro útil se convirtió en: ¿cuántas opciones del pipeline está dispuesto a dejar dentro de los valores predeterminados del framework? Comience por ahí, antes de que alguien elimine ochenta y cinco líneas y declare victoria.