Aprende a implementar tu propio motor de búsqueda difuso en Symfony 5 con una base de datos MySQL.

Cómo implementar un motor de búsqueda difusa (FullText ) usando TNTSearch en Symfony 5

Si tu aplicación Symfony 5 usa MySQL como el administrador de base de datos predeterminado, sabes lo difícil que es desarrollar una función de búsqueda difusa en tu base de datos e incluso peor cuando estás trabajando con Doctrine. La mejor y más efectiva solución sería migrar los datos que necesita buscar a elasticsearch, sin embargo, si no desea implementar nuevas tecnologías en su proyecto, puede quedarse con las predeterminadas y bien conocidas. Al igual que existen herramientas para hacer PDF de forma nativa con solo PHP (como TCPDF), para la implementación de la búsqueda difusa en su proyecto PHP existe TNTSearch.

TNTSearch es un motor de búsqueda de texto completo con todas las funciones escrito íntegramente en PHP. Su configuración simple le permite agregar una experiencia de búsqueda asombrosa a su sitio en solo minutos. También tiene una función de búsqueda geográfica y un clasificador de texto. Las características más conocidas de este proyecto son:

  • Búsqueda borrosa
  • a medida que escribe funcionalidad
  • geo-búsqueda
  • clasificación de texto
  • derivando
  • tokenizadores personalizados
  • algoritmo de clasificación bm25 ( sí, cosas científicas muy importantes  que podemos ignorar inicialmente)
  • búsqueda booleana
  • resaltado de resultados

En este artículo, le explicaremos cómo implementar esta función de búsqueda difusa en su aplicación. 

1. Instale TNTSearch

Al igual que Symfony 5, la biblioteca TNTSearch requiere la instalación de las siguientes extensiones en su sistema:

  • PHP> = 7.1
  • Extensión PHP PDO
  • Extensión PHP SQLite
  • Extensión PHP mbstring

Durante la instalación, composer verificará de todos modos si las extensiones mencionadas están disponibles en la versión de PHP instalada y la instalará o no. Abre una terminal, cambia al directorio raíz de tu proyecto Symfony e instala la biblioteca con el siguiente comando:

composer require teamtnt/tntsearch

El paquete tiene un montón de funciones auxiliares como jaro-winkler y similitud de coseno para cálculos de distancia. Admite derivaciones para inglés, croata, árabe, italiano, ruso, portugués y ucraniano. Después de la instalación del paquete, podremos continuar con la creación de nuestro motor de búsqueda difuso.

Para obtener más información sobre este proyecto, visite el repositorio oficial en Github aquí .

2. Cree un archivo de índice de búsqueda

Ahora, para comenzar, debes comprender básicamente cómo funciona la funcionalidad de la biblioteca. En primer lugar, deberás crear un archivo de índice con todos los datos de tu base de datos que deseas exponer para una búsqueda aproximada. Por ejemplo, en este artículo tendremos una única tabla en nuestra base de datos MySQL que corresponde a una colección de músicos conocidos mundialmente, podemos ver la tabla a través de PHPMyAdmin por ejemplo:

Artists Table MySQL Database FuzzySearch

Ahora que sabemos qué contendrá nuestro índice, comenzaremos a escribir código en nuestro proyecto para generarlo. Básicamente, la biblioteca TNTSearch requiere una conexión directa a su base de datos MySQL como se muestra en el siguiente ejemplo:

use TeamTNT\TNTSearch\TNTSearch;

$tnt = new TNTSearch;

$tnt->loadConfig([
    'driver'    => 'mysql',
    'host'      => 'localhost',
    'database'  => 'dbname',
    'username'  => 'user',
    'password'  => 'pass',
    'storage'   => '/var/www/tntsearch/examples/',
    'stemmer'   => \TeamTNT\TNTSearch\Stemmer\PorterStemmer::class//optional
]);

Sin embargo, como está trabajando con un proyecto de Symfony 5, sabes que la configuración de la base de datos está almacenada en el archivo .env en el directorio raíz de su proyecto y la línea se ve así:

DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7

Entonces, para obtener la información en Symfony, crearemos el siguiente método (este tutorial se seguirá dentro de un solo controlador, solo con fines de aprendizaje, puede modificar la lógica ya sea exponiendo la generación del índice a través de un comando en caso de que no necesite ser modificado constantemente):

<?php

// src/Controller/DefaultController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

class DefaultController extends AbstractController
{    
    /**
     * Devuelve una matriz con la configuración de TNTSearch con el
     * base de datos utilizada por el proyecto Symfony.
     * 
     * @return type
     */
    private function getTNTSearchConfiguration(){
        $databaseURL = $_ENV['DATABASE_URL'];
        
        $databaseParameters = parse_url($databaseURL);
        
        $config = [
            'driver'    => $databaseParameters["scheme"],
            'host'      => $databaseParameters["host"],
            'database'  => str_replace("/", "", $databaseParameters["path"]),
            'username'  => $databaseParameters["user"],
            'password'  => $databaseParameters["pass"],
            // En Windows:
            // C:\\xampp738\\htdocs\\myproject/fuzzy_storage
            // O Linux:
            // /var/www/vhosts/myproject/fuzzy_storage
            // 
            // Cree el directorio fuzzy_storage en su proyecto para almacenar el archivo de índice
            'storage'   => '/var/www/vhosts/myproject/fuzzy_storage/',
            // Un stemmer es opcional
            'stemmer'   => \TeamTNT\TNTSearch\Stemmer\PorterStemmer::class
        ];
        
        return $config;
    }
}

Como explica el método, devolverá el arreglo con la configuración de la base de datos que usaremos para crear el índice con TNTSearch. También mencionamos que necesita crear en este tutorial, el directorio fuzzy_storage en el directorio raíz de su proyecto, ya que guardaremos los índices allí, sin embargo, puede cambiarlo según sus necesidades. Teniendo ahora el objeto de configuración, procederemos a la creación de nuestra primera ruta en el controlador, que te permitirá crear el archivo índice. Nuestra ruta será /generate-indexy tendrá el siguiente código (omitiremos el método getTNTSearchConfiguration en nuestros ejemplos, sin embargo debería existir):

<?php

// src/Controller/DefaultController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

// Importar TNTSearch
use TeamTNT\TNTSearch\TNTSearch;

class DefaultController extends AbstractController
{
    /**
     * @Route("/generate-index", name="app_generate-index")
     */
    public function generate_index()
    {
        $tnt = new TNTSearch;

        // Obtener y cargar la configuración que se puede generar con el método descrito anteriormente
        $configuration = $this->getTNTSearchConfiguration();
        $tnt->loadConfig($configuration);

        // El archivo de índice tendrá el siguiente nombre, no dude en cambiarlo como desee
        $indexer = $tnt->createIndex('artists.index');
        
         // El resultado con todas las filas de la consulta serán los datos
         // que el motor usará para buscar, en nuestro caso solo queremos 2 columnas
         // (tenga en cuenta que es necesario incluir la clave principal)
        $indexer->query('SELECT id, name, slug FROM artists;');
        
        // ¡Genere archivo de índice!
        $indexer->run();

        return new Response(
            '<html><body>¡Índice generado con éxito!</body></html>'
        );
    }

    /// ... ///
}

Como resultado, visitar el proyecto en el navegador web http://app/generate-index devolverá el siguiente resultado (ya que tenemos 7400 filas en la tabla):

Processed 1000 rows 
Processed 2000 rows 
Processed 3000 rows 
Processed 4000 rows 
Processed 5000 rows 
Processed 6000 rows 
Processed 7000 rows 
Total rows 7435 Index succesfully generated !

Y en el directorio mencionado encontrará el archivo de índice que se puede utilizar para realizar búsquedas difusas de los datos:

TNTSearch Index File Symfony 5

Ahora que tenemos el índice, podemos comenzar la búsqueda aproximada.

3. Búsqueda difusa

¡Casi estámos allí! Como se mencionó, ahora con el índice, podemos buscar fácilmente lo que el usuario esté buscando. En nuestro controlador de ejemplo, tendremos una ruta /search que mostrará los resultados devueltos por el método de búsqueda de TNTSearch. Esto se puede lograr simplemente inicializando una instancia de TNTSearch con su configuración, seleccionando el archivo de índice con el método selectIndex y luego ejecutando el método de búsqueda desde nuestra instancia. Este método espera como argumentos la cadena de búsqueda y como segundo parámetro el límite de resultados que por defecto es 100. 

Devolveremos el resultado como una cadena JSON como se describe en el siguiente controlador:

<?php

// src/Controller/DefaultController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

// Importar TNTSearch
use TeamTNT\TNTSearch\TNTSearch;
use Symfony\Component\HttpFoundation\JsonResponse;

class DefaultController extends AbstractController
{
    /**
     * @Route("/search", name="app_search")
     */
    public function search()
    {
        $tnt = new TNTSearch;

        // Obtener y cargar la configuración que se puede generar con el método descrito anteriormente
        $configuration = $this->getTNTSearchConfiguration();
        $tnt->loadConfig($configuration);
        
        // Utilice el índice generado en el paso anterior
        $tnt->selectIndex('artists.index');
        
        $maxResults = 20;
        
        // Busca una banda llamada como "Guns n' roses"
        $results = $tnt->search("Gans n rosas", $maxResults);
        
        return new JsonResponse($results);
    } 
    
    // ... //
}

La respuesta de este controlador será una matriz devuelta por el método de búsqueda de TNTSearch que tiene la siguiente estructura:

{
  "ids": [
    946,
    2990,
    4913,
    5564,
    5751,
    1924,
    4794,
    5541,
    5560,
    5725,
    5757,
    6581,
    7370
  ],
  "hits": 13,
  "execution_time": "1.087 ms"
}

Como puede ver, TNTSearch devuelve solo una colección de la clave principal de las filas en la tabla de la base de datos que mejor coinciden con los criterios de búsqueda. Normalmente, en un proyecto Symfony usarás Doctrine para manipular los datos de tu base de datos, por lo que necesitarás buscar los elementos por su clave principal a través de Doctrine. Por ejemplo, en nuestro proyecto tenemos una entidad con todos los campos de la base de datos identificados como:

<?php

// /src/Entity/Artists.php
namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Artists
 *
 * @ORM\Table(name="artists", indexes={@ORM\Index(name="first_character", columns={"first_character"})})
 * @ORM\Entity
 */
class Artists
{
    /**
     * @var int
     *
     * @ORM\Column(name="id", type="bigint", nullable=false)
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255, nullable=false)
     */
    private $name;

    /**
     * @var string
     *
     * @ORM\Column(name="slug", type="string", length=255, nullable=false)
     */
    private $slug;

    /**
     * @var string|null
     *
     * @ORM\Column(name="first_character", type="string", length=1, nullable=true, options={"default"="NULL"})
     */
    private $firstCharacter = 'NULL';

    /**
     * @var string|null
     *
     * @ORM\Column(name="description", type="text", length=65535, nullable=true, options={"default"="NULL"})
     */
    private $description = 'NULL';

    /**
     * @var string|null
     *
     * @ORM\Column(name="image", type="string", length=255, nullable=true, options={"default"="NULL"})
     */
    private $image = 'NULL';

    public function getId(): ?string
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(string $name): self
    {
        $this->name = $name;

        return $this;
    }

    public function getSlug(): ?string
    {
        return $this->slug;
    }

    public function setSlug(string $slug): self
    {
        $this->slug = $slug;

        return $this;
    }

    public function getFirstCharacter(): ?string
    {
        return $this->firstCharacter;
    }

    public function setFirstCharacter(?string $firstCharacter): self
    {
        $this->firstCharacter = $firstCharacter;

        return $this;
    }

    public function getDescription(): ?string
    {
        return $this->description;
    }

    public function setDescription(?string $description): self
    {
        $this->description = $description;

        return $this;
    }

    public function getImage(): ?string
    {
        return $this->image;
    }

    public function setImage(?string $image): self
    {
        $this->image = $image;

        return $this;
    }
}

Entonces, simplemente podríamos buscar cada elemento a través del repositorio por su identificación. Es muy importante tener en cuenta que TNTSearch devuelve los resultados por las mejores coincidencias primero, por eso buscamos cada elemento y los almacenamos en una matriz como se describe en el siguiente código:

<?php

// src/Controller/DefaultController.php
namespace App\Controller;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;

// Importar TNTSearch
use TeamTNT\TNTSearch\TNTSearch;
use Symfony\Component\HttpFoundation\JsonResponse;
use App\Entity\Artists;

class DefaultController extends AbstractController
{
    /**
     * @Route("/search", name="app_search")
     */
    public function search()
    {
        $em = $this->getDoctrine()->getManager();
        
        $tnt = new TNTSearch;

        // Obtener y cargar la configuración que se puede generar con el método descrito anteriormente
        $configuration = $this->getTNTSearchConfiguration();
        $tnt->loadConfig($configuration);
        
        // Utilice el índice generado en el paso anterior
        $tnt->selectIndex('artists.index');
        
        $maxResults = 20;
        
        // Busca una banda llamada como "Guns n' roses"
        $results = $tnt->search("Gans n rosas", $maxResults);
        
        // Mantenga una referencia al repositorio de artistas de Doctrine
        $artistsRepository = $em->getRepository(Artists::class);
        
        // Almacene los resultados en una matriz
        $rows = [];
        
        foreach($results["ids"] as $id){
            // Puedes optimizar esto usando la función FIELD de MySQL si está usando mysql
            // más información en: https://ourcodeworld.com/articles/read/1162/how-to-order-a-doctrine-2-query-result-by-a-specific-order-of-an-array-using-mysql-in-symfony-5
            $artist = $artistsRepository->find($id);
            
            $rows[] = [
                'id' => $artist->getId(),
                'name' => $artist->getName()
            ];
        }
        
        // Devolver los resultados al usuario.
        return new JsonResponse($rows);
    }
}

Luego en nuestra ruta de búsqueda, tendremos una respuesta como la siguiente:

[
  {
    "id": "946",
    "name": "Black N Blue"
  },
  {
    "id": "2990",
    "name": "Guns N' Roses"
  },
  {
    "id": "4913",
    "name": "Nigrino, N"
  },
  {
    "id": "5564",
    "name": "Rage N Rox"
  },
  {
    "id": "5751",
    "name": "Robin N Looza"
  },
  {
    "id": "1924",
    "name": "Demon n'Angel"
  },
  {
    "id": "4794",
    "name": "N.E.R.D"
  },
  {
    "id": "5541",
    "name": "R.I.S.E.N."
  },
  {
    "id": "5560",
    "name": "Rag'n'Bone Man"
  },
  {
    "id": "5725",
    "name": "Rínon Nínqueon"
  },
  {
    "id": "5757",
    "name": "Rock'n'Roll Soldiers"
  },
  {
    "id": "6581",
    "name": "T.N.F."
  },
  {
    "id": "7370",
    "name": "Youssou N'Dour feat. Neneh Cherry"
  }
]

Como era de esperar, incluso cuando escribimos Guns 'n Roses de forma incorrecta, en nuestros mejores resultados, ¡la banda correcta estaba en la lista! Gracias a esta biblioteca, estará implementando la búsqueda difusa en su proyecto en minutos y solo usando PHP.

Que te diviertas ❤️!


Interesado en la programación desde los 14 años, Carlos es un programador autodidacta, fundador y autor de la mayoría de los artículos de Our Code World.

Conviertete en un programador más sociable

Patrocinadores