En el post anterior, “Deep Learning” sobre Spring AI: RAG, Embeddings y Vector Databases, exploramos el patrón RAG y vimos que básicamente consistía en dos fases: la primera, de ingesta y transformación de datos; y la segunda, de ejecución.

En esta ocasión vemos las opciones que nos proporciona Spring AI para la primera fase de ingesta (ETL) del patrón RAG.

Además, nos adentraremos en uno de los conceptos que más auge está teniendo en los últimos tiempos dentro de la IA: el MCP.

Si te has perdido alguno de los posts de la serie, puedes echarles un vistazo a continuación:

  1. "Deep Learning" sobre Spring AI: primeros pasos
  2. “Deep Learning” sobre Spring AI: multimodularidad, prompts y observabilidad
  3. "Deep Learning" sobre Spring AI: Advisors, Structured Output y Tool Calling
  4. “Deep Learning” sobre Spring AI: RAG, Embeddings y Vector Databases

ETL

Dentro del patrón RAG, el framework ETL organiza el flujo de procesamiento de datos, desde el momento de obtención de datos sin procesar hasta conseguir guardar datos estructurados en una base de datos vectorial.

API Overview

Las pipelines ETL crean, transforman y guardan Documents, existiendo tres componentes principales:

  • DocumentReader, que implementa Supplier<List<Document>>.
  • DocumentTransformer, que implementa Function<List<Document>, List<Document>>.
  • DocumentWriter, que implementa Consumer<List<Document>>.

Para construir una pipeline ETL se puede encadenar una instancia de cada tipo anterior:

Pipeline de ETL

Por ejemplo, con las instancias:

  • PagePdfDocumentReader como implementación de DocumentReader.
  • TokenTextSplitter como implementación de DocumentTransformer.
  • VectorStore como implementación de DocumentWriter.

Se podría usar el siguiente código para ejecutar el patrón RAG en su fase de ingesta de datos:

vectorStore.accept(textSplitter.apply(pdfReader.get()));

Interfaces

En la siguiente imagen se pueden ver las interfaces e implementaciones que soportan esta fase de ETL en Spring AI:

Interfaces e implementaciones que soportan esta fase de ETL en Spring AI.
  • DocumentReader: proporciona una fuente de documentos de distintos orígenes:
public interface DocumentReader extends Supplier<List<Document>> {
    default List<Document> read() {
        return get();
    }
}
  • DocumentTransformer: transforma documentos:
public interface DocumentTransformer extends Function<List<Document>, List<Document>> {
    default List<Document> transform(List<Document> transform) {
        return apply(transform);
    }
}
  • DocumentWriter: prepara documentos para guardar:
public interface DocumentWriter extends Consumer<List<Document>> {
    default void write(List<Document> documents) {
        accept(documents);
    }
}

En las siguientes secciones inspeccionaremos más a fondo cada una de ellas.

Document Readers

Algunas implementaciones existentes son:

1. JSON: procesa documentos JSON convirtiéndolos en Documents.

public class CustomJsonReader {
    ...
    public List<Document> loadJson() {
        JsonReader jsonReader = new JsonReader(this.resource, "etiqueta", "content");
        return jsonReader.get();
    }
}

Parámetros posibles en el constructor:

  • resource: recurso Spring que apunta al fichero JSON.
  • jsonKeysToUse: array de claves del JSON que se deben usar como contenido en el Document de salida.
  • jsonMetadataGenerator: objeto para crear metadatos para cada Document.

Comportamiento para cada objeto JSON (en un array o como objeto sencillo):

  • Se extrae el contenido basando en el parámetro jsonKeysToUse.
  • Si no se indican claves, se usa el JSON entero como contenido.
  • Se generan los metadatos indicados en jsonMetadataGenerator.
  • Se crea el Document con el contenido y los metadatos.

2. Text: procesa documentos de texto convirtiéndolos en Documents.

public class CustomTextReader {
    ...
    public List<Document> loadText() {
        TextReader textReader = new TextReader(this.resource);
        textReader.getCustomMetadata().put("filename", "text-source.txt");
    return textReader.read();
    }
}

Parámetros posibles en el constructor:

  • resourceUrl: string que representa la URL del recurso a leer.
  • resource: recurso a leer.

Comportamiento:

  • Lee todo el contenido del fichero de texto a un Document.
  • El contenido del fichero se transforma en el contenido del Document.
  • Se añaden metadatos automáticamente:
    • charset: el formato de caracteres usado para leer el fichero de texto.
    • source: el nombre del fichero de entrada.
  • Se añaden los metadatos indicados con getCustomMetadata()

3. HTML con Jsoup: procesa documentos HTML transformándolos en Documents usando la librería JSoup.

4. Markdown: procesa documentos Mardown convirtiéndolos en Documents. Se debe incluir la siguiente dependencia:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-markdown-document-reader</artifactId>
</dependency>
public class CustomMarkdownReader {
    ...
    public List<Document> loadMarkdown() {
        MarkdownDocumentReaderConfig config = MarkdownDocumentReaderConfig.builder()        .withHorizontalRuleCreateDocument(true).withIncludeCodeBlock(false).withIncludeBlockquote(false).withAdditionalMetadata("filename", "README.md").build();

        MarkdownDocumentReader reader = new MarkdownDocumentReader(this.resource, config);
        return reader.get();
    }
}

La clase MarkdownDocumentReaderConfig permite hacer algunas personalizaciones:

  • horizontalRuleCreateDocument: reglas horizontales crearán nuevos Document.
  • includeCodeBlock: si se quiere incluir bloques de código en el mismo Document que el resto del texto.
  • includeBlockquote: si se quiere incluir bloques de cita en el mismo Document que el resto del texto.
  • additionalMetadata: añadir metadatos a los Document creados.

5. PDF Page: gracias a la librería Apache PdfBox se pueden parsear ficheros PDF a través de la clase PagePdfDocumentReader. Se tiene que añadir la siguiente dependencia:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
private Resource arancelesPdf;
...
var pdfReader = new PagePdfDocumentReader(arancelesPdf);

6. PDF Paragraph: con la misma librería que el PDF Page permite dividir el PDF en párrafos y transformar cada uno de ellos en un Document.

private Resource arancelesPdf;
...
var pdfReader = new ParagraphPdfDocumentReader(arancelesPdf);

7. Tika: una librería Apache Tika para extraer texto de ficheros con distintos formatos (PDF, DOC/DOCX, PPT/PPTX, HTML).

Se incluye la siguiente dependencia:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
public class CustomTikaReader {
    ...
    public List<Document> loadData() {
        TikaDocumentReader tikaDocumentReader = new TikaDocumentReader(this.resource);
        return tikaDocumentReader.read();
    }
}

Transformers

Algunas implementaciones existentes son:

1. TextSplitter: la clase abstracta base que ayuda a dividir los ficheros para que no superen los límites de tokens.

2. TokenTextSplitter: implementación de TextSplitter que divide el texto en piezas según el número de tokens:

public class CustomTokenTextSplitter {

    public List<Document> splitCustomized(List<Document> documents) {
        TokenTextSplitter splitter = new TokenTextSplitter(10, 5, 2, 15, true);
        return splitter.apply(documents);
    }
}

Parámetros posibles en el constructor:

  • defaultChunkSize: tamaño de cada bloque de texto en tokens.
  • minChunkSizeChars: el mínimo número de caracteres en cada bloque de texto.
  • minChunkLengthToEmbed: longitud mínima de un bloque para ser incluido.
  • maxNumChunks: máximo número de bloques que se generarán desde un texto.
  • keepSeparator: si se quieren mantener los separadores (como los saltos de línea) en los bloques generados.

Comportamiento:

  • Se codifica el texto origen en tokens.
  • Se divide el texto en bloques en base a defaultChunkSize.
  • Por cada bloque:
    • Se decodifica el bloque otra vez a texto.
    • Se busca un lugar apropiado para el corte (punto, exclamación, interrogación) después de minChunkSizeChars.
    • Si se encuentra el punto de corte, se corta el bloque.
    • Se hace limpieza del bloque según keepSeparator.
    • Si el bloque es más grande que minChunkLengthToEmbed se añade a la salida.
  • Se continúa con el proceso hasta que se procesen todos los tokens o se llegue al maxNumChunks.
  • Cualquier texto que quede se añade como un bloque final si es más grande que minChunkLengthToEmbed.

3. ContentFormatTransformer: asegura formatos de contenido uniformes en todos los documentos.

4. KeywordMetadataEnricher: usa IA generativa para extraer las palabras clave del fichero y añadirlas como metadata.

public class CustomKeywordEnricher {

    private final ChatModel chatModel;

    CustomKeywordEnricher(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    public List<Document> enrichDocuments(List<Document> documents) {
        KeywordMetadataEnricher enricher = new  KeywordMetadataEnricher(this.chatModel, 2);
        return enricher.apply(documents);
    }
}

Parámetros posibles en el constructor:

  • chatModel: el modelo de IA para generar las palabras clave.
  • keywordCount: número de palabras clave a extraer para cada Document.

Comportamiento:

  • Para cada fichero de input, crea un prompt usando su contenido.
  • Se envía el prompt al chatModel para generar las palabras clave.
  • Las palabras generadas se añaden a los metadatos.
  • Los ficheros son retornados.

5. SummaryMetadataEnricher: usa IA generativa para crear resúmenes de los ficheros y añadirlos como metadatos.

public class CustomSummaryEnricher {

    private final SummaryMetadataEnricher enricher;

    CustomSummaryEnricher(SummaryMetadataEnricher enricher) {
        this.enricher = enricher;
}

    public List<Document> enrichDocuments(List<Document> documents) {
        return this.enricher.apply(documents);
    }
}

@Configuration
public class SummaryMetadataConfig {

    @Bean
    SummaryMetadataEnricher summaryMetadata(ChatModel chatmodel) {
        return new SummaryMetadataEnricher(chatmodel, List.of(SummaryType.PREVIOUS, SummaryType.CURRENT, SummaryType.NEXT));
    }
}

Parámetros posibles en el constructor:

  • chatModel: el modelo de IA para generar los resúmenes.
  • summaryTypes: una lista de tipos de resúmenes a generar (PREVIOUS, CURRENT, NEXT).
  • summaryTemplate: una plantilla para la generación de resúmenes.
  • metadataMode: indica cómo manejar los metadatos cuando se generen los resúmenes.

Comportamiento:

  • Para cada Document, se crea un prompt usando el contenido del Document además de la plantilla de resúmenes.
  • Se envía el prompt al chatModel.
  • Según el summaryType se añaden los siguientes metadatos a cada fichero:
    • section_summary: resumen del fichero actual.
    • prev_section_summary: resumen del anterior fichero.
    • next_section_summary: resumen del siguiente fichero.
  • Los Documents enriquecidos son devueltos.

Writers

Algunas implementaciones existentes son:

1. File: implementación que escribe el contenido de una lista de Documents en un fichero.

public class CustomDocumentWriter {

    public void writeDocuments(List<Document> documents) {
        FileDocumentWriter writer = new FileDocumentWriter("./src/main/resources/static/docs/output.txt", true, MetadataMode.ALL, false);
        writer.accept(documents);
    }
}

Parámetros posibles en el constructor:

  • fileName: el nombre del fichero de salida.
  • withDocumentMarkers: si se incluyen marcadores de documento en la salida.
  • metadaMode: indica el contenido del documento que se escribirá en el fichero.
  • append: si se escriben los datos al final del fichero o al inicio.

Comportamiento:

  • Se abre un FileWriter para el nombre indicado.
  • Para cada Document en la lista:
    • Si withDocumentMarkers = true, se incluye una marca de documento con el índice y los números de página del documento.
    • Escribe el contenido del documento en el metadaMode indicado.
  • Cerrar el fichero cuando se hayan escrito todos los Documents.

2. VectorStore: integración con las distintas VectorStores.

Demo ETL

Definimos los siguientes endpoints para ver el funcionamiento de readers, transformers y writers:

Readers:

  • /readers/json: endpoint que hace uso del JSON reader para obtener un listado de Documents que consiste en las etiquetas de las tendencias tecnológicas para el año 2025.
Funcionamiento de readers: /readers/json
  • /readers/text: endpoint que que usa el text reader para obtener el texto entero que se encuentra en un fichero como un Document.
Funcionamiento de readers: /readers/text
  • /readers/markdown: endpoint que emplea el markdown reader para recuperar un listado de Documents a partir de un fichero de tipo markdown.
Funcionamiento de readers: /readers/markdown
  • /readers/tika: endpoint que ejecuta el tika reader para generar un Document a partir de un fichero de código Java.
Funcionamiento de readers: /readers/tika

Transformers:

  • /transformers/token: endpoint que transforma un listado de Documents en otro listado de Documents según la configuración de chunks indicada.
Funcionamiento de transformers: /transformers/token
  • /transformers/keyword: endpoint que transforma un listado de Documents en otro listado de Documents con las palabras clave de los anteriores (excerpt_keywords).
Funcionamiento transformers: /transformers/keyword
  • /transformers/summary: endpoint que transforma un listado de Documents en otro listado de Documents que contienen los resúmenes de los primeros.
Funcionamiento transformers: /transformers/summary

Writers:

  • /writers/file: endpoint que escribe una serie de Documents en un fichero de texto (src/main/resources/static/docs/output.txt).
Funcionamiento writers: /writers/file

RAG: empleando algunas de las funcionalidades anteriores para el patrón RAG, se puede crear una endpoint que a raíz de uno o varios ficheros de código existentes se le pueda pedir recomendaciones de código:

  • /rag/load-code: endpoint que carga un fichero (/src/main/resources/static/docs/ParadigmaSpecialService.java) de código de ejemplo en la vector-store previamente transformándolo en embeddings.
package com.example.springai.demo.springai_demo.application;

import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;

@Service
@Slf4j
public class ParadigmaSpecialService {

    public void callingSpecialService() {
        log.info("This is the implementation for the special service with Paradigma rules");
    }
}
  • /rag/no-rag: endpoint que simplemente lanza una consulta preguntando por un ejemplo de función especial hecha por Paradigma.
Funcionamiento rag: /rag/no-rag
  • /rag/code: endpoint que usa el patrón RAG sobre el fichero de código cargado previamente (como en otras ocasiones el LLM empleado influirá considerablemente en la precisión de la respuesta).
Funcionamiento rag: /rag/code

En este enlace se puede descargar el código de la aplicación de ejemplo.

MCP

Originalmente creado por Anthropic, el MCP o Model Context Protocol es un protocolo que estandariza la interacción entre aplicaciones y los LLMs.

Como se comenta por la red, se podría decir que el MCP es como el USB-C. De la misma forma en la que el USB-C es una conexión estándar entre distintos dispositivos, MCP sería un protocolo estándar para conectar los modelos de IA a aplicaciones u orígenes de datos.

Surge para la integración de datos y herramientas con los LLMs, ofreciendo:

  • Integraciones pre-configuradas que se pueden conectar directamente al LLM.
  • La flexibilidad de poder cambiar de LLM.
  • Las mejores prácticas para securizar los datos que están dentro de nuestra infraestructura.

Básicamente se trataría de un concepto similar (pero enfocado desde otra perspectiva) al Tool-Calling visto previamente de cara a crear funcionalidades que se puedan construir una vez y que sean aprovechadas por diferentes clientes/aplicaciones de una forma transparente.

Demo MCP

Como en muchas ocasiones, se afianza mejor el concepto a través de ejemplos. Vamos a levantar una demo con un caso práctico para, por ejemplo, conocer qué ficheros tenemos en una carpeta concreta de nuestro sistema usando un LLM.

Para ver en cómo nos ayuda el MCP, seleccionamos uno de los múltiples clientes y servidores de ejemplo que podemos encontrar en las referencias de la especificación. En este caso, hacemos uso del cliente LibreChat y del servidor filesystem.

En primer lugar, en caso de tener ejecutándose el servicio de Ollama en el sistema, se tendrá que desactivar (en Linux se lanza el comando: systemctl stop ollama.service).

Posteriormente, ejecutamos LibreChat con la configuración por defecto asociada a Ollama usando docker-compose (además de esta configuración por defecto habrá que entrar en el contenedor en el que se ejecuta Ollama para descargar el LLM correspondiente).

Accediendo a la interfaz web (por defecto en http://localhost:3080/) y una vez registrados y hecho el login (la cuenta que se crea es local), podemos ver la típica interfaz de chat que tan extendida está últimamente:

Imagen de una interfaz similar a la de ChatGPT.

Como se puede observar, ya se ha seleccionado el modelo correspondiente de Ollama y le preguntamos sobre algunos ficheros existentes en una carpeta de nuestro equipo (en este caso al ejecutarse Ollama en un contenedor, la carpeta deberá estar disponible en dicho contenedor).

Selección del modelo correspondiente de Ollama.

Debido a que el modelo no tiene acceso a nuestro sistema de ficheros, nos responde que no nos puede ayudar.

A continuación, reiniciamos el docker-compose habilitando en esta ocasión la configuración de los servidores MCP. Volvemos a entrar en la interfaz observando cómo ya se ha habilitado la opción de “MCP Servers” en la sección de mensajes con el correspondiente server configurado:

Opción de “MCP Servers” habilitada en la interfaz.

Realizando la misma consulta que en el caso previo, se puede verificar la función que ejecuta el servidor MCP sobre nuestro sistema de ficheros así como la respuesta acorde del modelo:

Verificación de la función que ejecuta el servidor MCP sobre nuestro sistema de ficheros, parte 1
Verificación de la función que ejecuta el servidor MCP sobre nuestro sistema de ficheros, parte 2

En este punto, lo que hemos conseguido con el MCP es extender la información disponible (contexto) para que el LLM pueda ayudarnos con más tareas que de primeras no sería posible.

Conclusiones

En este post de la serie dedicada a Spring AI, continuamos revisando la fase de ingesta de datos del patrón RAG y cómo implementarlo.

También hemos hablado de una de las últimas funcionalidades de moda en el mundo de la IA, el MCP (Model Context Protocol), visualizando todo su potencial a través de un ejemplo práctico.

En la siguiente entrega ahondaremos en la implementación del MCP sobre Spring AI, tanto por la parte cliente como por la parte servidor.

Referencias

Cuéntanos qué te parece.

Los comentarios serán moderados. Serán visibles si aportan un argumento constructivo. Si no estás de acuerdo con algún punto, por favor, muestra tus opiniones de manera educada.