Estás viendo una versión antigua de esta página. Ve a la versión actual.

Comparar con el actual Ver el historial de la página

Versión 1 Actual »


Todas las pruebas se han realizado levantando la API en local y apuntando al entorno de test.

Error principal que he encontrado

Creo que lo principal que hace que el orden que devuelven las búsquedas se cambie (y por lo tanto funcione mal) es la reordenación que se hace por EMA, que puede ser útil en otros lugares, pero no en este caso. En concreto, habrá que borrar las siguientes líneas del método searchByUser del archivo ServiciosEndPoint.java:

		//Las busquedas las ordenamos por EMA
		List <Servicio> listSort = filterAndSortUtils.filterAndSort(FILTERBYALLSLIDERS, SORTBYEMA, searchResultByProfile);

Con esto debe mejorar incluso con la búsqueda actual que tenemos con Oracle Search. De todas formas he probado una alternativa a Oracle Search, que explico a continuación.

Hibernate Search 8.2.0 + Lucene

Para cambiar la búsqueda de Oracle Search, que se ejecuta en base de datos, a Hibernate Search + Lucene, que indexa los datos de las entidades que se le indique y realiza la búsqueda en local, he hecho los siguientes cambios.

pom.xml

Dentro de <dependencyManagement> he añadido la siguiente dependencia:

      <dependency>
            <groupId>org.hibernate.search</groupId>
            <artifactId>hibernate-search-bom</artifactId>
            <version>8.2.0.Final</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>

Y dentro del bloque general de dependencias <dependencies>, he añadido las siguientes:

  <!-- Hibernate search + Lucene -->
   <!-- Mapeo con Hibernate ORM (JPA) -->
    <dependency>
        <groupId>org.hibernate.search</groupId>
        <artifactId>hibernate-search-mapper-orm</artifactId>
    </dependency>
    <!-- Backend Lucene -->
    <dependency>
        <groupId>org.hibernate.search</groupId>
        <artifactId>hibernate-search-backend-lucene</artifactId>
    </dependency>

application.properties

Hay que añadir las siguientes propiedades:

# Búsqueda Hibernate Search + Lucene
spring.jpa.properties.hibernate.search.backend.lucene_version=9.12.3
spring.jpa.properties.hibernate.search.backend.analysis.configurer=class:es.um.atica.p15s.portalservicios.search.LuceneSearchAnalysisConfig
spring.jpa.properties.hibernate.search.backend.directory.type=local-filesystem
spring.jpa.properties.hibernate.search.backend.directory.root=./indexes

Se especifica versión de Lucene, la clase en la que se configura la búsqueda con Lucene, que los datos indexados se guarden en local, y en la carpeta indexes.

PortalServiciosApplication.java

He añadido la anotación @EnableScheduling a la clase para habilitar que se puedan ejecutar tareas cada cierto tiempo. Esto se utilizará para programar la reindexación de los datos, pues si se hace una modificación directamente en base de datos, esos cambios no se reflejarán directamente en los datos que tiene indexados la api localmente, sólo se actualizan si se hacen modificaciones a través de la propia API.

Además, al final de la clase he añadido el siguiente bean para que siempre que arranque la aplicación se indexen los datos:

	// Indexar datos para la búsqueda
	@Bean
	ApplicationRunner indexData(SearchIndexer indexer) {
		return args -> {
			indexer.buildIndexes();
		};
	}

SearchIndexer.java

He añadido la siguiente clase en el paquete search. Se encarga de programar la reindexación cada x tiempo para que se actualicen los datos. Aquí está para que se ejecute una vez al día, a las 3:33:33, pero esto se modifica a como veamos más adecuado.

package es.um.atica.p15s.portalservicios.search;

import org.hibernate.search.mapper.orm.Search;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import es.um.atica.p15s.portalservicios.model.Servicio;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import jakarta.transaction.Transactional;

@Service
public class SearchIndexer {

    @PersistenceContext
    private EntityManager entityManager;

    @Scheduled(
        cron = "33 33 3 * * *", // Se ejecuta todos los días a las 3:33:33 AM (segundo minuto hora día-del-mes mes día-de-la-semana)
        zone = "Europe/Madrid"
    )
    @Transactional
    public void buildIndexes() throws InterruptedException {
        Search.session(entityManager)
              .massIndexer(Servicio.class)
              .startAndWait();
    }
}

LuceneSearchAnalysisConfig.java

También en el mismo paquete he añadido esta clase para configurar la búsqueda que hace Lucene. He dejado comentado SpanishLightStemFilterFactory, que es un propio filtro que trae Lucene para búsquedas en español, porque creo que trae sus propias stop words (palabras a ignorar) y probablemente incluyen "mis", que es una palabra frecuente en los nombres de nuestros servicios.

package es.um.atica.p15s.portalservicios.search;

import org.hibernate.search.backend.lucene.analysis.LuceneAnalysisConfigurationContext;
import org.hibernate.search.backend.lucene.analysis.LuceneAnalysisConfigurer;

import org.apache.lucene.analysis.core.LowerCaseFilterFactory;
import org.apache.lucene.analysis.miscellaneous.ASCIIFoldingFilterFactory;
import org.apache.lucene.analysis.ngram.EdgeNGramFilterFactory;
import org.apache.lucene.analysis.standard.StandardTokenizerFactory;
import org.apache.lucene.analysis.es.SpanishLightStemFilterFactory;

public class LuceneSearchAnalysisConfig implements LuceneAnalysisConfigurer {

    @Override
    public void configure(LuceneAnalysisConfigurationContext context) {

        /*
         * Analizador para búsquedas normales
         */
        context.analyzer("spanish")
                .custom()
                .tokenizer(StandardTokenizerFactory.class)
                .tokenFilter(LowerCaseFilterFactory.class)
                .tokenFilter(ASCIIFoldingFilterFactory.class);
        // .tokenFilter(SpanishLightStemFilterFactory.class);

        /*
         * Analizador para autocompletado
         */
        context.analyzer("autocomplete")
                .custom()
                .tokenizer(StandardTokenizerFactory.class)
                .tokenFilter(LowerCaseFilterFactory.class)
                .tokenFilter(ASCIIFoldingFilterFactory.class)
                .tokenFilter(EdgeNGramFilterFactory.class)
                .param("minGramSize", "1")
                .param("maxGramSize", "20");

        /*
         * Normalizador para búsquedas exactas y ordenación
         */
        context.normalizer("lowercase")
                .custom()
                .tokenFilter(LowerCaseFilterFactory.class)
                .tokenFilter(ASCIIFoldingFilterFactory.class);
    }
}


Se definen dos analizadores, "spanish" y "autocomplete". Cada analizador tendrá sus índices diferentes, más adelante se puede ver como un mismo atributo de una entidad puede tener la anotación para indexarlo varias veces para que se incluya en índices de búsqueda diferentes. Y en este caso, ¿por qué tenemos dos?

Bien, el analizador spanish está pensado para búsqueda semántica: elimina mayúsculas y hacentos, reduce palabras a su raíz (eliminando prefijos y sufijos) y elimina stop words si se configuran. Un ejemplo podría ser algo así, si el nombre es "Matrícula Universitaria", este analizador lo transformaría a:

matricul
universitari

El analizador autocomplete está pensado para encontrar coincidencias escribiendo sólo el principio. En este caso, el analizador genera muchos prefijos, algo así por ejemplo:

ma
mat
matr
matri
matric
matricu
matricul
matricula

un
uni
univ
unive
univers
universi
...

ServicioSearchService.java

package es.um.atica.p15s.portalservicios.search;

import java.util.List;

import org.hibernate.search.mapper.orm.Search;
import org.springframework.stereotype.Service;

import es.um.atica.p15s.portalservicios.model.Servicio;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;

@Service
public class ServicioSearchService {

    @PersistenceContext
    private EntityManager entityManager;

    public List<Servicio> search(String searchTerm) {
        return Search.session(entityManager)
        .search(Servicio.class)
        .where(f -> f.bool().with(b -> {

            b.should(
                f.match()
                    .fields(
                        "name",
                        "alternateName",
                        "description"
                    )
                    .matching(searchTerm)
                    .fuzzy(1)
                    .boost(3f)
            );

            b.should(
                f.match()
                    .fields(
                        "name_autocomplete",
                        "alternateName_autocomplete"
                    )
                    .matching(searchTerm)
                    .boost(6f)
            );

        }))
        .sort(f -> f.score())
        .fetchHits(20);
    }
}

La búsqueda vemos que ejecuta dos b.should, es decir, busca en los dos índices, y f.bool actúa como un OR. Esto es, se juntan los resultados de ambas búsquedas. con .fields indicamos los campos a incluir en la búsqueda (son los nombres de los campos en los índices), y con .boost se le da un peso a cada búsqueda. En este caso, la búsqueda autocomplete tiene mayor peso, por lo que sus coincidencias tendrán mayor preferencia. Además, en el analizador spanish se indica fuzzy(1), lo que hace una búsqueda con un margen de error de un carácter. Finalmente se indica que se ordenen por el score que tiene cada coincidencia, y se pide que se devuelvan hasta 20 resultados.

Esto es una versión inicial de la búsqueda, seguro que se puede mejorar. Por ejemplo, se podrían añadir más b.should, uno por cada campo, y asignarle a cada campo el boost que consideremos para afinar los resultados y la importancia de cada campo. Algo así:

            b.should(
                f.match()
                    .field("name")
                    .matching(texto)
                    .boost(5f)
            );

            b.should(
                f.match()
                    .field("alternateName")
                    .matching(texto)
                    .boost(4f)
            );

            b.should(
                f.match()
                    .field("description")
                    .matching(texto)
                    .boost(1f)
            );

            b.should(
                f.match()
                    .field("name_autocomplete")
                    .matching(texto)
                    .boost(8f)
            );

            b.should(
                f.match()
                    .field("alternateName_autocomplete")
                    .matching(texto)
                    .boost(6f)
            );

Entidades

En las entidades Servicio.java y Categoria.java, he añadido las anotaciones correspondientes para indexar los atributos deseados. En concreto, las siguientes anotaciones:

  • Indexed: habilita el indexado para la búsqueda. Si se le indica sólo analyzer, utiliza el mismo analizador tanto para la búsqueda como para la indexación. Si además se especifica searchAnalyzer, podemos tener el analyzer indicado para la indexación, pero utilizar otro analizador para la búsqueda. En nuestro caso, para los casos de autocomplete, utilizamos ese analizador sólo para la indexación, pero para la búsqueda utilizamos el spanish, pues será más eficiente.
  • FullTextField: texto sobre el que se quiere hacer búsqueda de texto por un analizador.
  • KeywordField: sirve para ordenar por un campo, normalizando su valor.
  • IndexedEmbedded: para indexar también una entidad relacionada.

En Servicio.java, he añadido lo siguiente:

@Indexed // Habilita el indexado para la búsqueda
@Table(name = "P15S_SERVICIOS", schema = "SUMA_LR")
public class Servicio {
    ...

	@FullTextField(name = "name", analyzer = "spanish")
	@FullTextField(name = "name_autocomplete", analyzer = "autocomplete", searchAnalyzer = "spanish")
	@KeywordField(name = "name_sort", normalizer = "lowercase", sortable = Sortable.YES)
	@Column(name = "P15S_SERV_NAME")
	@JsonView(ServicioView.DescServicio.class)
	private String name;

	@FullTextField(analyzer = "spanish")
	@FullTextField(name = "description_autocomplete", analyzer = "autocomplete", searchAnalyzer = "spanish")
	@Column(name = "P15S_SERV_DESC")
	@Lob
	@JsonView(ServicioView.FullServicio.class)
	private String description;

    ...

	@IndexedEmbedded(includeDepth = 1)
	@Column(name = "P15S_SERV_CATEGORIAS")
	@ManyToMany(fetch = FetchType.EAGER, cascade = { CascadeType.PERSIST, CascadeType.MERGE })
	@JoinTable(name = "P15S_SERV_CAT", schema = "SUMA_LR", joinColumns = @JoinColumn(name = "P15S_SERV_ID"), inverseJoinColumns = @JoinColumn(name = "P15S_CAT_ID"))
	@JsonView(ServicioView.DescServicio.class)
	private List<Categoria> category;

	@FullTextField(analyzer = "spanish")
	@FullTextField(name = "alternateName_autocomplete", analyzer = "autocomplete", searchAnalyzer = "spanish")
	@ElementCollection
	@CollectionTable(name = "P15S_SERV_KEYWORDS", schema = "SUMA_LR", joinColumns = @JoinColumn(name = "P15S_SERV_ID"))
	@Column(name = "P15S_KEYWORD")
	@JsonView(ServicioView.DescServicio.class)
	private Set<String> alternateName = new HashSet<>();

    ...

	@FullTextField(analyzer = "spanish")
	@FullTextField(name = "termsOfService_autocomplete", analyzer = "autocomplete", searchAnalyzer = "spanish")
	@Column(name = "P15S_SERV_TOS")
	@JsonView(ServicioView.DescServicio.class)
	private String termsOfService;

    ...

	@IndexedEmbedded(includeDepth = 1) // Si tienes relación con el padre, esto permite buscar por el nombre del padre
	@ManyToOne
	@JoinColumn(name = "P15S_SERV_IDSUPERCARD", referencedColumnName = "P15S_SERV_ID")
	private Servicio superCard;

Y en Category.java:

    @FullTextField(analyzer = "spanish")
    @Column(name = "P15S_CAT_NAME")
    @JsonProperty("name")
    @JsonView(ServicioView.DescServicio.class)
    private String name;

De esta forma, de cada servicio se están incluyendo para la búsqueda spanish los campos name, description, alternateName, y termsOfService. Aparte, se embebe el servicio padre (superCard) si lo hay (se utilizarán los mismos campos del padre), y también se embebe la categoría, sólo con el atributo name. Para el índice del analizador autocomplete se utilizan los mismos campos a excepción del name de la categoría.

ServiciosEndPoint.java

Por último, en el archivo donde se definen los endpoints, he añadido ServiciosSearchService:

	@Autowired
	ServicioSearchService servicioSearchService;

En los métodos search y getSearchResultServiciosProfiles he sustituido la llamada a la búsqueda antigua por la nueva, sustituyendo la instrucción

Collection<Servicio> searchResult = searchEngine.search(...

por

Collection<Servicio> searchResult = servicioSearchService.search(searchString);

En el método search, debajo de esto he añadido la actualización del contador de búsqueda:

		// Contador de búsqueda
		int count = searchResult != null ? searchResult.size() : 0;
		searchCounter.increment(Integer.toString(count));

Quito la ordenación por EMA, que trastoca el orden devuelto por las búsquedas y hace que funcionen regular, quitando en searchByUser la línea del método:

filterAndSortUtils.filterAndSort

Y finalmente, tanto en search como en searchByUser, en lugar de hacer

return endPointUtils.queryWithMap(searchResult, converterDTO);

hago:

return searchResult.stream().map(x -> converterDTO.apply(x)).toList();

Además se ha reescrito alguna parte y añadido comentarios, pero cosas menores.

Cuando tenga la merge request con los cambios la añado para consultarlo todo correctamente.

Comparativa de búsquedas

Comparativas de búsquedas suplantando a vlo@um.es para ser admin y tener todos los servicios visibles.

Original vs Quitando ordenación por EMA (mismas queries OracleSearch)

Original vs Hibernate Search + Lucene sin ordenar por EMA

Quitando ordenación por EMA (mismas queries OracleSearch) vs Hibernate Search + Lucene sin ordenar por EMA

No voy a repetir las mismas búsquedas, de los dos apartados de arriba podemos comparar fijándonos en los resultados de la derecha. A mí me da la sensación de que los resultados con Hibernate Search + Lucene son algo mejores.

Comparativa de rendimiento

Tiempo de respuesta

Hibernate Search + Lucene

OracleSearch sin ordenación por EMA

Sin diferencias notables.

Memoria

Hibernate Search + Lucene (reindexando cada 30s): vemos que la memoria que se reserva se mantiene constante, el reindexado no tiene una sobrecarga, y a partir de la mitad empiezo a hacer búsquedas manualmente (no es una recarga de muchos usuarios al mismo tiempo, pero para hacernos una idea inicial).

OracleSearch original: reserva menos espacio, a ojo por la gráfica unos 50MB menos, también se mantiene estable. Prueba más o menos igual, las oscilaciones azules de la derecha es cuando he empezado a hacer búsquedas.

Test de carga Hibernate Search + Lucene

Test de carga con 20 hilos, 15s de ramp-up period, 200 peticiones por minuto como target throughput, durante 15 minutos.

Tiempos de respuesta:

Memoria:

Ventajas y desventajas de cada opción

La principal ventaja de OracleSearch es, evidentemente, que ya está implementado. Sólo con quitar la ordenación por EMA ya mejora bastante la búsqueda. La desventaja es que para cada búsqueda se hace una consulta a base de datos, con el recargo que eso supone. Además, para modificar la búsqueda hay que tocar en base de datos, crear nuevos índices para tener en cuenta más campos si se quiere, modificar el pl/sql en los tres entornos, lo cual añade más complejidad que si se hace directamente en la API.

Con Hibernate Search y Lucene las ventajas son esas, evitas pasar por base de datos más que para indexar los datos, por lo que escala mejor cuando hay muchas peticiones (el tiempo de respuesta no parece resentirse mucho al no tener que ir a base de datos con cada petición) y se puede ajustar la búsqueda directamente en la api de forma más sencilla (desde mi punto de vista al menos).

El punto negativo es que se puede dar una situación en la que se modifique algo en base de datos y, como el indexado de los datos no es inmediato, puede tardar en reflejarse el cambio hasta que se vuelva a indexar. Se puede ajustar el tiempo entre indexados como queramos, yo lo he puesto al arrancar la aplicación y una vez al día pensando en que estos cambios no serán muy comunes, pero quizás podemos incluso crear un nuevo endpoint para forzar el reindexado cuando queramos (si por ejemplo se añade un nuevo servicio o algo así y queremos tenerlo disponible en la búsqueda inmediatamente). Además, la aplicación parece reservar algo más de memoria por generar los índices, unos 50MB, pero no creo que sea muy significativo o que tenga un impacto en el rendimiento. Los indexados me están tardando entre 1 y 3 segundos, y cuando se reindexa, durante esos segundos siguen estando los datos anteriores disponibles, así que la búsqueda no se rompe mientras se reindexa, por lo que no debe de haber problema.

Conclusión

Por mí se puede cambiar a Hibernate Search + Lucene, tanto por rendimiento como por la facilidad a la hora de poder modificarla directamente en la API en vez de tener que tocar base de datos, que a mí me parece más engorroso.

  • Sin etiquetas