Embeddings -- Capa de aplicación
El subcapítulo anterior cerró con el evento entregado al @EventListener por Spring, fuera ya de la transacción del usuario. La responsabilidad acababa de cruzar la frontera entre la base de datos y la aplicación. Este subcapítulo recoge esa responsabilidad y la sigue hasta su destino final: el vector persistido en module_embeddings_variable.
El listener: el adapter de entrada
Tal como ya hicimos en el módulo de descripciones, tendremos una clase Spring por tipo de entidad, suscrita a un evento concreto, que se limita a invocar al caso de uso correspondiente.
@Service
@DomainEventSubscriber(VariableChanged.class)
@RequiredArgsConstructor
public class OnVariableChangedGenerateEmbedding {
private final VariableEmbeddingGenerator variableEmbeddingGenerator;
@EventListener
public void on(final VariableChanged event) {
variableEmbeddingGenerator.generate(event.getId());
}
}El listener traduce el evento de dominio en una llamada al generator y se aparta. Es el driving adapter del hexágono: el día que la fuente del evento cambie (otro bus, otra cola), solo este fichero se modifica.
El generator: el orquestador del módulo
Una vez el listener llama a su respectivo XxXEmbeddingGenerator, esta clase se encarga de toda la orquestación. Es la única pieza del módulo que ve a todos los colaboradores a la vez, y la única que toma decisiones. El listener se limita a entregarle el entityId, y a partir de ese momento el generator orquesta el ciclo completo del embedding: obtener el contexto, enriquecer el texto, buscar si ya existe un embedding equivalente, generar el vector y persistirlo.
Para cada recurso existe una carpeta de casos de uso — por ejemplo, embeddings-app/useCase/variable/ — donde viven tanto su listener como su generador. Las siete líneas del segundo encadenan las cinco responsabilidades que componen el caso de uso de extremo a extremo:
public void generate(final Long entityId) {
final VariableContext variable = variableEmbeddingRepository.find(entityId);
final String enrichedText = textBuilder.build(variable);
if (variableEmbeddingRepository.searchStoredText(entityId).
filter(enrichedText::equals).isPresent()) {
return;
}
final float[] vector = embeddingGenerator.generate(enrichedText, EmbeddingTaskType.RETRIEVAL_DOCUMENT);
variableEmbeddingRepository.save(variable.entityId(), enrichedText, vector);
}Cada línea corresponde a una etapa del ciclo, y conviene mirarlas con calma — cada una toca una pieza distinta del módulo y deja ver cómo encaja el patrón hexagonal en la práctica.
1. Obtener el contexto
El primer paso carga el VariableContext desde la vista module_embeddings_variable_context. La pieza interesante es el VO en sí, que vive en embeddings-domain/variable/:
public record VariableContext(
Long entityId,
String entityName,
String clientReference,
String description,
String unit,
List<TagReference> tags) implements EntityContext {
}Es un Java record — una clase inmutable que en términos de DDD encarna un Value Object. Implementa la interfaz general EntityContext que comparten todos los tipos del módulo. La llamada variableEmbeddingRepository.find(entityId) carga el contexto desde la proyección que lo alimenta — la vista module_embeddings_variable_context, ya diseñada en el subcapítulo del schema.
2. Enriquecer el texto
El VariableEmbeddingTextBuilder convierte el VO en un bloque Markdown. Es la pieza que decide cómo se le presenta la entidad a Gemini. Para una variable agregada, por ejemplo, el resultado sería:
"""
# Indicator Gearbox temperature / Oil temperature
Variable agregada calculada a partir de múltiples señales operacionales. Combina datos de varios aerogeneradores para análisis de flota.
## AGGREGATE-VARIABLE
### Variable ID: 47599
Unit of measurement: %
Formula expression: a / b
- a → variable #13469
- b → variable #13481
"""El resultado es un fragmento Markdown con jerarquía: un # para el título (nombre + referencia de cliente), descripción libre debajo, y un bloque ## VARIABLE o ## AGGREGATE-VARIABLE con los detalles técnicos. El formato no es decorativo: los embeddings que produce Gemini son sensibles a la estructura del prompt, y darle al modelo señales claras (cabeceras, etiquetas, secciones) afecta directamente a la calidad del vector.
Lo que importa aquí desde la perspectiva arquitectónica es que el text builder vive en embeddings-domain — no en la app, no en persistencia. Es una pieza pura del dominio, sin dependencias externas y plenamente testeable con una entrada y una salida (de hecho hay un VariableEmbeddingTextBuilderTest que ejercita exactamente eso). El generator delega esta responsabilidad porque construir el texto es la lógica de dominio del módulo de embeddings — no un detalle de implementación.
3. Buscar el texto almacenado
La línea de deduplicación vuelve a utilizar el repositorio para recuperar el texto que se usó la última vez:
public Optional<String> searchStoredText(final Long entityId) {
return Optional.ofNullable(
context.select(MODULE_EMBEDDINGS_VARIABLE.EMBEDDING_TEXT)
.from(MODULE_EMBEDDINGS_VARIABLE)
.where(MODULE_EMBEDDINGS_VARIABLE.ENTITY_ID.eq(entityId))
.fetchOne(MODULE_EMBEDDINGS_VARIABLE.EMBEDDING_TEXT)
);
}Es una consulta de igualdad textual — busca por entity_id y devuelve el embedding_text que la última generación dejó almacenado. Aquí el objetivo es cortar el flujo si el texto enriquecido coincide byte a byte con el ya almacenado: si la entidad no ha cambiado desde el punto de vista de su representación textual, no tiene sentido pedir un vector nuevo.
4. Generar el vector
La única llamada externa de coste real ocurre en este paso. El adapter es GeminiClient, que implementa el puerto EmbeddingGenerator:
@Service
public class GeminiClient implements EmbeddingGenerator {
private final EmbeddingModel embeddingModel;
@Override
@Retryable(
retryFor = RestClientResponseException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 1000, multiplier = 2)
)
public float[] generate(final String text, final EmbeddingTaskType taskType) {
return embeddingModel.call(
new EmbeddingRequest(List.of(text), GoogleGenAiTextEmbeddingOptions.builder()
.taskType(toSpringTaskType(taskType))
.dimensions(768)
.build())
).getResults().getFirst().getOutput();
}
}Tres detalles merecen comentario:
@Retryablecon backoff exponencial. Tres intentos, primer reintento al segundo, segundo al cuarto. Cubre fallos transitorios habituales en cualquier llamada HTTP (latencia puntual, rate limit esporádico, conexión cortada). Si los tres fallan, la excepción sube y el evento queda registrado en la tabla de reintentos del bus.dimensions(768)debe coincidir con la columnavector(768)definida en el esquema; cambiarlo en un sitio sin cambiarlo en el otro deja la base de datos inconsistente.- Spring AI como capa intermedia. No se llama directamente al endpoint REST de Gemini: se usa
EmbeddingModeldespring-ai, que abstrae la API de Google detrás de una interfaz común a todos los proveedores. Una iteración anterior del módulo hablaba con Gemini a mano víaRestClient; al introducirspring-aila migración tocó un único fichero (GeminiClient) y ni el generator ni los tests se enteraron — la justificación de fondo se aborda en el capítulo del Agente Orquestador, donde la abstracción multi-proveedor empieza a aportar valor real.
5. Persistir el resultado
El último paso almacena el trío (entityId, enrichedText, vector) con un INSERT ... ON CONFLICT DO UPDATE:
public void save(final Long entityId, final String embeddingText, final float[] vector) {
final String vectorLiteral = VectorLiteralConverter.convert(vector);
context.insertInto(MODULE_EMBEDDINGS_VARIABLE)
.set(MODULE_EMBEDDINGS_VARIABLE.ENTITY_ID, entityId)
.set(MODULE_EMBEDDINGS_VARIABLE.EMBEDDING_TEXT, embeddingText)
.set(MODULE_EMBEDDINGS_VARIABLE.UPDATED_AT, OffsetDateTime.now())
.set(EMBEDDING, DSL.field("?::vector", String.class, vectorLiteral))
.onConflict(MODULE_EMBEDDINGS_VARIABLE.ENTITY_ID)
.doUpdate()
.set(MODULE_EMBEDDINGS_VARIABLE.EMBEDDING_TEXT, DSL.excluded(MODULE_EMBEDDINGS_VARIABLE.EMBEDDING_TEXT))
.set(MODULE_EMBEDDINGS_VARIABLE.UPDATED_AT, OffsetDateTime.now())
.set(EMBEDDING, DSL.field("excluded.embedding", String.class))
.execute();
}El ON CONFLICT DO UPDATE aporta idempotencia: si el mismo evento se procesa dos veces por reintento, el resultado final es un único registro consistente. A partir de este punto, el embedding queda disponible para las búsquedas semánticas que vendrán en el siguiente subcapítulo.
Recursos vs. descripciones: el porqué del router
Todo el flujo descrito hasta aquí asume que el evento que dispara la generación nace de un cambio en el recurso — un VariableChanged, un RuleChanged. Y es cierto, pero la descripción de un recurso vive en su módulo aparte y, cuando cambia, el embedding también debe regenerarse — su texto enriquecido depende de ella.
La salida es complementar el camino del trigger con un segundo listener que escucha el evento DescriptionChanged del módulo de descripciones y delega en una pieza dedicada: un router que conoce a todos los generators y sabe a cuál llamar según el tipo de entidad cuya descripción ha cambiado.
@Service
@RequiredArgsConstructor
public class DescriptionEmbeddingRouter {
private final VariableEmbeddingGenerator variableEmbeddingGenerator;
private final AggregateVariableEmbeddingGenerator aggregateVariableEmbeddingGenerator;
private final RuleEmbeddingGenerator ruleEmbeddingGenerator;
private final TagEmbeddingGenerator tagEmbeddingGenerator;
private final QueryEmbeddingGenerator queryEmbeddingGenerator;
private final UserEmbeddingGenerator userEmbeddingGenerator;
private final GroupEmbeddingGenerator groupEmbeddingGenerator;
private final CustomerEmbeddingGenerator customerEmbeddingGenerator;
private final FileEmbeddingGenerator fileEmbeddingGenerator;
public void generate(final String entityType, final Long entityId) {
switch (entityType) {
case "VARIABLE" -> variableEmbeddingGenerator.generate(entityId);
case "AGGREGATE_VARIABLE" ->
aggregateVariableEmbeddingGenerator.generate(entityId);
case "RULE" -> ruleEmbeddingGenerator.generate(entityId);
case "TAG" -> tagEmbeddingGenerator.generate(entityId);
case "QUERY" -> queryEmbeddingGenerator.generate(entityId);
case "USER" -> userEmbeddingGenerator.generate(entityId);
case "GROUP" -> groupEmbeddingGenerator.generate(entityId);
case "CUSTOMER" -> customerEmbeddingGenerator.generate(entityId);
case "FILE" -> fileEmbeddingGenerator.generate(entityId);
default -> { }
}
}
}Un switch sobre el entityType y una invocación al generator correspondiente — nada más. El router no tiene lógica de negocio ni habla con la base de datos: su única misión es traducir un evento genérico de descripción en una llamada al generator específico de cada tipo de recurso. Esa modestia es lo que lo hace elegante. Es la única pieza del módulo donde se cruzan los contextos descripciones y embeddings; cada generator sigue siendo autónomo y desconoce por completo que existe el módulo de descripciones — lo que recibe es un entityId, igual que cuando lo invoca su propio listener de recurso.
Esta separación tiene una consecuencia directa: cuando un usuario edita simultáneamente el nombre de una variable y su descripción, el sistema acaba lanzando dos eventos independientes — un VariableChanged desde la tabla de variables y un DescriptionChanged desde la tabla de descripciones. Ambos terminan invocando a VariableEmbeddingGenerator.generate(entityId). En principio, dos llamadas a Gemini para un mismo cambio efectivo.
Aquí es donde la deduplicación por texto del paso 3 deja de ser un detalle y se convierte en la pieza que hace defendible todo el diseño. Si el usuario cambia el recurso y su descripción se lanzan dos eventos: el primero genera y persiste el embedding; el segundo construye el mismo texto enriquecido, detecta la igualdad en el paso 3 y termina sin llamar a Gemini. Es un trato explícito: aceptar una pequeña redundancia en la entrada a cambio de mantener completamente limpia la frontera entre los dos módulos.
Antes de cerrar el subcapítulo conviene mirar al generator desde otro ángulo: lo que no contiene. No sabe SQL — lo delega en el repositorio. No sabe HTTP — lo delega en el EmbeddingGenerator. No sabe Markdown — lo delega en el TextBuilder. No sabe pgvector — la palabra ni aparece en su código. Toda su lógica son cinco invocaciones encadenadas y una condición de retorno temprano. Esa pureza es lo que permite testearlo con un puñado de dobles y olvidarse del entorno: cualquier cosa que cambie en el almacén vectorial o en la API de Gemini ocurre detrás de los puertos, y el generator no se entera.
Diagrama de secuencia
El diagrama no incluye el trigger ni el bus porque ya se cubrieron en el subcapítulo anterior: comienza exactamente donde el listener recibe el dispatch. Es solo lo que ocurre dentro del módulo de embeddings, una vez la responsabilidad ha cruzado al lado de la aplicación.