05_BDV/06 -- Búsqueda semántica.md

Búsqueda semántica

El subcapítulo anterior dejó el vector ya persistido en module_embeddings_<tipo>. Este recorre la otra mitad del módulo: cómo se expone esa búsqueda al exterior y cómo está organizado el código para que añadir nuevos modos de consulta no obligue a tocar el existente.

El patrón Criteria

Criteria es una variante aplicada del Specification Pattern descrito por Evans y Fowler. La regla fundamental es que ese objeto vive en el dominio y que expresa qué se quiere buscar sin comprometerse con cómo, el motor de almacenamiento es el que lo resolverá.

Sin Criteria, la capa de aplicación se ensucia con todos los filtros posible que se le puede aplicar a cada entidad, y además los repositorios tienden a una explosión combinatoria de métodos específicos: findByEntityType, findByEntityTypeAndIds, findByEntityTypeAndIdsWithMinScore... Un único método matching(Criteria) — en este módulo, searchSimilar(criteria, vector) — reemplaza toda esa familia sin perder expresividad.

EmbeddingSearchCriteria

EmbeddingSearchCriteria vive en embeddings-domain/api/ y agrupa los filtros aplicables a una búsqueda semántica de chunks: el texto de consulta, el tipo de entidad sobre el que acotar, el topN que limita el número de coincidencias...

public record EmbeddingSearchCriteria(
        String queryText,
        EntityType entityType,
        int topN,
        List<Long> candidateIds,  // Todavía no lo analizaremos
        Double minScore) {
    ...

    public static EmbeddingSearchCriteria forEntity(final String queryText, 
           final EntityType type, final int topN) {
        return new EmbeddingSearchCriteria(queryText, type, topN, null, null);
    }
    public static EmbeddingSearchCriteria allEntities(final String queryText, 
           final int topN) {
        return new EmbeddingSearchCriteria(queryText, null, topN, null, null);
    }
    ...
}

Lo que tiene las siguientes ventajas para nuestro propio caso:

Ventaja del patrónManifestación concreta en dwall-module-embeddings
Un solo método de búsqueda en el repositoriosearchSimilar(criteria, vector) cubre filtrado por tipo, por candidatos y por score sin fragmentarse
Dominio agnóstico del motorEl service no conoce <=>, ni ::vector, ni el formato del literal de pgvector
Extensibilidad sin friccionesAñadir un predicado nuevo (hasMaxAge, hasOwnerFilter) requiere una factory adicional y un if en el adapter, no nuevas clases en el puerto
Frontera dominio/infraestructuraEl Criteria atraviesa controller → service → adapter como objeto opaco, sin que ninguna capa intermedia lo abra
Referencia teórica

La explicación canónica del patrón está en la nota del curso DDD — Agregados, donde se desarrolla la versión genérica del Criteria, la convivencia con paginación cursor y la justificación de su ubicación en el Shared Bounded Context.

El service: orquestador delgado

Como se puede observar, absolutamente toda la lógica detras de la busqueda de los 8 recursos diferentes (y la busqueda general), se queda resumida en tres líneas por método y ningún if.

...
    public List<EmbeddingSearchResult> search(final EmbeddingSearchCriteria 
                                              criteria) {
        final float[] vector = embeddingGenerator.generate(criteria.queryText(), 
                      EmbeddingTaskType.RETRIEVAL_QUERY);
        return embeddingSearchRepository.searchSimilar(criteria, vector);
    }
...

Eso es lo que el patrón Criteria habilita: el service no necesita saber qué tipo de búsqueda está orquestando. Su trabajo se reduce a generar el vector de la query con RETRIEVAL_QUERY — el taskType que activa el modo "consulta" de Gemini, distinto del RETRIEVAL_DOCUMENT que se usó en la indexación — y delegar al repositorio. Sin reglas de negocio propias: viven en el Criteria (qué combinaciones son válidas) o en el adapter (cómo se traducen a SQL).

Del Criteria al SQL: el adapter

El adapter EmbeddingSearchRepositoryImpl vive en embeddings-persistence/repository/. Es donde el Criteria se materializa en consulta jOOQ:

@Override
public List<EmbeddingSearchResult> searchSimilar(
    final EmbeddingSearchCriteria criteria,
    final float[] queryVector
) {
    final String vectorLiteral = VectorLiteralConverter.convert(queryVector);
    final List<EmbeddingMatch> matches = criteria.hasEntityFilter()
        ? fetchFromEntityTable(criteria, vectorLiteral)
        : fetchFromUnifiedView(criteria, vectorLiteral);
    return matches.stream()
        .filter(match -> match.entityType() != null)
        .flatMap(match -> toSearchResult(match).stream())
        .collect(Collectors.toList());
}

La primera decisión la toma el adapter consultando criteria.hasEntityFilter(): si el Criteria está acotado, ataca la tabla específica de ese tipo (pgvector indexa más eficientemente sobre tablas individuales que sobre vistas); si no, ataca la vista module_embeddings_unified.

Cada predicado del Criteria se traduce a una condición jOOQ, y DSL.trueCondition() actúa como elemento neutro cuando no hay filtro. Añadir un predicado nuevo — hasMaxAge(), hasOwnerFilter(), lo que sea — solo requiere otro if aquí, sin tocar la cláusula SELECT ni la conversión de resultados. La consulta crece en filtros sin fragmentarse en variantes con explosión combinatoria de métodos.

Diagrama de secuencia

El Criteria atraviesa toda la cadena sin transformarse, lo que el controller construye es exactamente lo que el adapter interpreta, pasando por el service como un objeto opaco que ni siquiera se inspecciona. Esa transparencia entre capas es la prueba de que la frontera entre dominio e infraestructura está bien trazada.