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.











