Búsquedas eficientes con Hibernate Search y Spring Boot

Búsquedas eficientes con Hibernate Search y Spring Boot
Photo by Mick Haupt / Unsplash

En el artículo anterior ya explicamos que es Hibernate Search y como puede ayudarnos a mejorar las búsquedas de texto en nuestras aplicaciones, vamos a dejar la teoría de lado y empezar con un ejemplo básico de búsqueda difusa.

¿Qué es Hibernate Search?
En este post exploraremos Hibernate Search, cubriremos un poco de la teoría para poder mejorar el rendimiento de las búsquedas y empezaremos explicando que es Hibernate. Hibernate es uno de los ORM’s más populares que existen en Java y se utiliza para interactuar con la base de datos, utilizando ob…

Prerrequisitos

  • Java 17
  • Docker
  • IDE
  • Git
  • Maven

Génesis

Creamos un proyecto con Spring Initializr  que ya tenga la mayoría de las dependencias que necesitamos, será una API REST de búsqueda con una tabla llamada colonia, tiene 144,946 registros, pocos, pero nos sirven de referencia.

Los objetivos principales que debemos cumplir:

  • Generar los archivos índices( "/index")
  • Búsqueda por nombre(/{nombre a buscar})

Vamos a agregar las siguientes clases:

  • Colonia.java (Entidad para manejar la tabla)
  • ColoniaService.java (Service de toda la vida)
  • ColoniaController.java (Clase para declarar nuestros endpoints).

Configuración de Maven

Las dependencias de Hibernate Search por defecto no vienen en Spring Boot y hay que agregarlas manualmente, es importante revisar la compatibilidad de las versiones de Hibernate Search e Hibernate ORM.

<dependency>
    <groupId>org.hibernate.search</groupId>
    <artifactId>hibernate-search-mapper-orm-orm6</artifactId>
    <version>6.1.7.Final</version>
</dependency>
<dependency>
    <groupId>org.hibernate.search</groupId>
    <artifactId>hibernate-search-backend-lucene</artifactId>
    <version>6.1.7.Final</version>
</dependency>

Conexión a base de datos

Primero levantamos nuestra base de datos de prueba con Docker

docker run -p 5432:5432 -e POSTGRES_PASSWORD=sepomex -e POSTGRES_USER=sepomex -d jesusperales/sepomex-db-postgresql:latest

Agregamos lo siguiente al application.properties para conectar a la base de datos e indicar donde se almacenarán los archivos de los índices.

spring.datasource.url=jdbc:postgresql://localhost:5432/sepomex
spring.datasource.username=sepomex
spring.datasource.password=sepomex
spring.jpa.properties.hibernate.search.backend.directory.type=local-filesystem
spring.jpa.properties.hibernate.search.backend.directory.root=sepomex-indices/
spring.jpa.properties.hibernate.show_sql=true

La entidad

La clase Colonia.java es la representación de nuestra tabla como un objeto Java, también es donde configuramos como queremos que Hibernate Search la indexe.

@Indexed hace que se cree un índice para esta entidad y la librería se encargará de mantenerlo actualizado, si tu entidad no tiene esta anotación Hibernate Search la ignorará.

@Entity(name = "colonia")
@Indexed
public class Colonia  implements Serializable {
...
}

@FullTextField es una anotación para indicar que queremos que se indexe un campo y que en su contenido se encuentran una o más palabras, en este caso son nombres de colonias.

...
private Long id;

@FullTextField
@Column(name = "nombre", nullable = false)
private String nombre;
...

Generar los índices

Debido a que tenemos información preexistente, vamos a generar los índices de forma manual haciendo uso del Mass Indexer.

@Autowired
private EntityManager entityManager;

public boolean index() {
    try{
        SearchSession searchSession = Search.session( entityManager );
        searchSession.massIndexer()
                .startAndWait();
        return true;
    }catch ( InterruptedException ie){
        System.err.println(ie.getMessage());
        return false;
    }
}

Recuperamos un objeto de sesión de búsqueda de Hibernate a partir del EntityManager de JPA.

El método startAndWait() inicia la indexación masiva y espera a que se complete antes de retornar un valor booleano.

Este proceso indexa todos los datos en la tabla especificada para que puedan ser buscados por Hibernate Search.

Tener la funcionalidad de generar los índices a la mano es muy útil cuando se nos presenta algunos de los siguientes eventos:

  • Información preexistente
  • Archivos de índices borrados
  • Modificación en la estructura de la tabla (se agregan campos nuevos y queremos indexarlos)
  • Agregar/Actualizar/Borrar información de la tabla(Ejecuciones SQL directas que no pasan por Hibernate)

La búsqueda

Será un método donde tendremos de parámetro un nombre y retornamos una lista de colonias que coincidan con él.

public List<Colonia> search(String nombre){
    SearchSession searchSession = Search.session( entityManager );
    SearchResult<Colonia> result = searchSession.search( Colonia.class )
            .where( f -> f.match()
                    .field( "nombre" )
                    .matching( nombre )
                    .fuzzy() )
            .fetchAll();
    return result.hits();
}

Creamos un objeto de sesión de búsqueda de Hibernate a partir del EntityManager de JPA. Luego, se utiliza el objeto searchSession para buscar en la tabla "Colonia" utilizando el método search().

Se utiliza la búsqueda de coincidencia difusa, fuzzy(), lo que significa que devuelve resultados que no son exactamente iguales al término de búsqueda.

Y al final decimos que queremos todos los resultados, fetchAll() y utilizamos hits() para obtener la lista de las coincidencias.

En este caso retornamos toda la información, pero es posible recuperarla de forma paginada para evitar colapsos por la saturación de los recursos.

Los endpoints

La parte más sencilla de todas es nuestro Controller.

@Autowired
private ColoniaService coloniaService;

@GetMapping("/")
private boolean index(){
    return coloniaService.index();
}

@GetMapping("/{nombre}")
List<Colonia> search(@PathVariable String nombre){
    return coloniaService.search(nombre);
}

La función de indexación masiva la dejamos en raíz, pero en proyectos reales, debe estar muy bien protegida debido a que consume muchos recursos.

Si agregamos cualquier cosa después del slash "/", se tomará como el nombre que queremos buscar.

La ejecución

Levantamos nuestro proyecto que corre en el puerto 8080 y simplemente abrimos el navegador en la dirección http://localhost:8080 y esperamos a que termine el proceso de indexación.

Para hacer búsquedas únicamente agregamos el nombre de una colonia después del slash "/", por ejemplo http://localhost:8080/Cañada Blanca

Si nos fijamos en el directorio donde está corriendo el proyecto, se creó una carpeta llamada sepomex-indices, es aquí donde se encuentra la información de los índices de Lucene de la tabla, antes se podía navegar por ellos utilizando Luke, pero el proyecto ya se encuentra abandonado.

El proceso hace las siguientes acciones

  1. Ejecuta una búsqueda difusa en los índices de Lucene.
  2. Encontramos las coincidencias en los índices.
  3. Obtiene los IDs de las coincidencias en los índices de Lucene
  4. Se genera una consulta SQL con el operador IN, agregando los IDs que se encontraron en los índices de Lucen.
  5. Ejecutamos las consulta y obtenemos los resultados de la base de datos SQL.

Si observamos el log de nuestra aplicación encontraremos consultas SQL como esta

Hibernate: select c1_0.id,c1_0.nombre from colonia c1_0 where c1_0.id in(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)

Para una base de datos SQL siempre será más sencillo recuperar registros por IDs, la carga de la búsqueda del texto pasan a ser procesadas por Hibernate Search y Lucene.

Dejo la muestra del JSON obtenido en un Gist de Github.

https://gist.github.com/ripper2hl/49f92d119f7182bd87b70f97a0b48a45

Conclusión

Ejemplificamos una de tantas soluciones para optimizar las búsquedas en bases de datos utilizando Hibernate Search. Integramos las dependencias necesarias a Maven y Spring Boot. También vimos cómo indexar manualmente los datos y un ejemplo sencillo de búsqueda difusa.

En resumen, al utilizar Hibernate Search podemos mejorar la experiencia de usuario y tener una mayor eficiencia en la gestión de datos.

En el siguiente post reemplazaré Lucene por Elasticsearch, ya que considero que es una funcionalidad interesante de Hibernate Search que he explorado muy poco.

Todo el código lo puedes descargar en Github

GitHub - ripper2hl/ejemplo-hibernate-search: Búsquedas eficientes con Hibernate Search y Spring
Búsquedas eficientes con Hibernate Search y Spring - GitHub - ripper2hl/ejemplo-hibernate-search: Búsquedas eficientes con Hibernate Search y Spring

Fuentes