Las bases de datos están destinadas a almacenar y consultar datos de manera eficiente. El problema es que hay muchos tipos diferentes de datos que podemos almacenar:números, cadenas, JSON, datos geométricos. Las bases de datos utilizan diferentes métodos para almacenar diferentes tipos de datos:estructura de tabla, índices. No siempre la misma forma de almacenar y consultar los datos es eficiente para todos sus tipos, lo que hace bastante difícil usar una solución única para todos. Como resultado, las bases de datos intentan usar diferentes enfoques para diferentes tipos de datos. Por ejemplo, en MySQL o MariaDB tenemos una solución genérica de buen rendimiento como InnoDB, que funciona bien en la mayoría de los casos, pero también tenemos funciones separadas para trabajar con datos JSON, índices espaciales separados para acelerar la consulta de datos geométricos o índices de texto completo. , ayudando con los datos de texto. En este blog, veremos cómo se puede usar MariaDB para trabajar con datos de texto completo.
Los índices regulares de B+Tree en InnoDB también se pueden usar para acelerar las búsquedas de datos de texto. El problema principal es que, debido a su estructura y naturaleza, solo pueden ayudar con la búsqueda de los prefijos más a la izquierda. También es costoso indexar grandes volúmenes de texto (lo cual, dadas las limitaciones del prefijo más a la izquierda, realmente no tiene sentido). ¿Por qué? Echemos un vistazo a un ejemplo simple. Tenemos la siguiente oración:
“El veloz zorro marrón salta sobre el perro perezoso”
Usando índices regulares en InnoDB podemos indexar la oración completa:
“El veloz zorro marrón salta sobre el perro perezoso”
El punto es que, al buscar estos datos, tenemos que buscar el prefijo completo más a la izquierda. Así que una consulta como:
SELECT text FROM mytable WHERE sentence LIKE “The quick brown fox jumps”;
Se beneficiará de este índice pero una consulta como:
SELECT text FROM mytable WHERE sentence LIKE “quick brown fox jumps”;
No lo haré. No hay ninguna entrada en el índice que comience con 'rápido'. Hay una entrada en el índice que contiene 'rápido' pero comienza con 'El', por lo que no se puede usar. Como resultado, es virtualmente imposible consultar datos de texto de manera eficiente utilizando índices B+Tree. Afortunadamente, tanto MyISAM como InnoDB han implementado índices FULLTEXT, que se pueden usar para trabajar con datos de texto en MariaDB. La sintaxis es ligeramente diferente a la de los SELECT regulares, echemos un vistazo a lo que podemos hacer con ellos. En cuanto a los datos, utilizamos un archivo de índice aleatorio del volcado de la base de datos de Wikipedia. La estructura de datos es la siguiente:
617:11539268:Arthur Hamerschlag
617:11539269:Rooster Cogburn (character)
617:11539275:Membership function
617:11539282:Secondarily Generalized Tonic-Clonic Seizures
617:11539283:Corporate Challenge
617:11539285:Perimeter Mall
617:11539286:1994 St. Louis Cardinals season
Como resultado, creamos una tabla con dos columnas BIG INT y una VARCHAR.
MariaDB [(none)]> CREATE TABLE ft_data.ft_table (c1 BIGINT, c2 BIGINT, c3 VARCHAR, PRIMARY KEY (c1, c2);
Posteriormente cargamos los datos:
MariaDB [ft_data]> LOAD DATA INFILE '/vagrant/enwiki-20190620-pages-articles-multistream-index17.txt-p11539268p13039268' IGNORE INTO TABLE ft_table COLUMNS TERMINATED BY ':';
MariaDB [ft_data]> ALTER TABLE ft_table ADD FULLTEXT INDEX idx_ft (c3);
Query OK, 0 rows affected (5.497 sec)
Records: 0 Duplicates: 0 Warnings: 0
También creamos el índice FULLTEXT. Como puede ver, la sintaxis para eso es similar a la del índice normal, solo tuvimos que pasar la información sobre el tipo de índice como predeterminado a B+Tree. Entonces estábamos listos para ejecutar algunas consultas.
MariaDB [ft_data]> SELECT * FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('Starship');
+-----------+----------+------------------------------------+
| c1 | c2 | c3 |
+-----------+----------+------------------------------------+
| 119794610 | 12007923 | Starship Troopers 3 |
| 250627749 | 12479782 | Miranda class starship (Star Trek) |
| 250971304 | 12481409 | Starship Hospital |
| 253430758 | 12489743 | Starship Children's Hospital |
+-----------+----------+------------------------------------+
4 rows in set (0.009 sec)
Como puede ver, la sintaxis de SELECT es ligeramente diferente a lo que estamos acostumbrados. Para la búsqueda de texto completo, debe usar la sintaxis MATCH() … AGAINST (), donde en MATCH() pasa la columna o columnas que desea buscar y en AGAINST() pasa una lista de palabras clave delimitadas por comas. Puede ver en el resultado que, de forma predeterminada, la búsqueda no distingue entre mayúsculas y minúsculas y busca en toda la cadena, no solo al principio, como ocurre con los índices B+Tree. Comparemos cómo se vería si agregáramos un índice normal en la columna 'c3':los índices FULLTEXT y B+Tree pueden coexistir en la misma columna sin ningún problema. Cuál se usaría se decide en función de la sintaxis SELECT.
MariaDB [ft_data]> ALTER TABLE ft_data.ft_table ADD INDEX idx_c3 (c3);
Query OK, 0 rows affected (1.884 sec)
Records: 0 Duplicates: 0 Warnings: 0
Después de que se haya creado el índice, echemos un vistazo al resultado de la búsqueda:
MariaDB [ft_data]> SELECT * FROM ft_data.ft_table WHERE c3 LIKE 'Starship%';
+-----------+----------+------------------------------+
| c1 | c2 | c3 |
+-----------+----------+------------------------------+
| 253430758 | 12489743 | Starship Children's Hospital |
| 250971304 | 12481409 | Starship Hospital |
| 119794610 | 12007923 | Starship Troopers 3 |
+-----------+----------+------------------------------+
3 rows in set (0.001 sec)
Como puede ver, nuestra consulta devolvió solo tres filas. Esto es de esperar ya que estamos buscando filas que solo comiencen con una cadena "Starship".
MariaDB [ft_data]> EXPLAIN SELECT * FROM ft_data.ft_table WHERE c3 LIKE 'Starship%'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: ft_table
type: range
possible_keys: idx_c3,idx_ft
key: idx_c3
key_len: 103
ref: NULL
rows: 3
Extra: Using where; Using index
1 row in set (0.000 sec)
Cuando revisamos la salida EXPLAIN, podemos ver que el índice se ha utilizado para buscar los datos. Pero, ¿qué pasa si queremos buscar todas las filas que contienen la cadena 'Starship', sin importar si está al principio o no? Tenemos que escribir la siguiente consulta:
MariaDB [ft_data]> SELECT * FROM ft_data.ft_table WHERE c3 LIKE '%Starship%';
+-----------+----------+------------------------------------+
| c1 | c2 | c3 |
+-----------+----------+------------------------------------+
| 250627749 | 12479782 | Miranda class starship (Star Trek) |
| 253430758 | 12489743 | Starship Children's Hospital |
| 250971304 | 12481409 | Starship Hospital |
| 119794610 | 12007923 | Starship Troopers 3 |
+-----------+----------+------------------------------------+
4 rows in set (0.084 sec)
El resultado coincide con lo que obtuvimos de la búsqueda de texto completo.
MariaDB [ft_data]> EXPLAIN SELECT * FROM ft_data.ft_table WHERE c3 LIKE '%Starship%'\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: ft_table
type: index
possible_keys: NULL
key: idx_c3
key_len: 103
ref: NULL
rows: 473367
Extra: Using where; Using index
1 row in set (0.000 sec)
Sin embargo, EXPLAIN es diferente:como puede ver, todavía usa el índice, pero esta vez hace un escaneo de índice completo. Eso es posible ya que indexamos la columna c3 completa para que todos los datos estén disponibles en el índice. El escaneo de índice dará como resultado lecturas aleatorias de la tabla, pero para una tabla tan pequeña, MariaDB decidió que era más eficiente que leer toda la tabla. Tenga en cuenta el tiempo de ejecución:0,084 s para nuestro SELECT regular. Comparando esto con la consulta de texto completo, es malo:
MariaDB [ft_data]> SELECT * FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('Starship');
+-----------+----------+------------------------------------+
| c1 | c2 | c3 |
+-----------+----------+------------------------------------+
| 119794610 | 12007923 | Starship Troopers 3 |
| 250627749 | 12479782 | Miranda class starship (Star Trek) |
| 250971304 | 12481409 | Starship Hospital |
| 253430758 | 12489743 | Starship Children's Hospital |
+-----------+----------+------------------------------------+
4 rows in set (0.001 sec)
Como puede ver, la consulta que usa el índice FULLTEXT tardó 0.001 s en ejecutarse. Estamos hablando aquí de diferencias de órdenes de magnitud.
MariaDB [ft_data]> EXPLAIN SELECT * FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('Starship')\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: ft_table
type: fulltext
possible_keys: idx_ft
key: idx_ft
key_len: 0
ref:
rows: 1
Extra: Using where
1 row in set (0.000 sec)
Así es como se ve el resultado de EXPLAIN para la consulta usando el índice FULLTEXT - ese hecho se indica por tipo:texto completo.
Las consultas de texto completo también tienen otras características. Es posible, por ejemplo, devolver filas que podrían ser relevantes para el término de búsqueda. MariaDB busca palabras ubicadas cerca de la fila que busca y luego ejecuta una búsqueda también para ellas.
MariaDB [(none)]> SELECT * FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('Starship');
+-----------+----------+------------------------------------+
| c1 | c2 | c3 |
+-----------+----------+------------------------------------+
| 119794610 | 12007923 | Starship Troopers 3 |
| 250627749 | 12479782 | Miranda class starship (Star Trek) |
| 250971304 | 12481409 | Starship Hospital |
| 253430758 | 12489743 | Starship Children's Hospital |
+-----------+----------+------------------------------------+
4 rows in set (0.001 sec)
En nuestro caso, la palabra 'Starship' se puede relacionar con palabras como 'Troopers', 'class', 'Star Trek', 'Hospital', etc. Para usar esta función, debemos ejecutar la consulta con el modificador "WITH QUERY EXPANSION":
MariaDB [(none)]> SELECT * FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('Starship' WITH QUERY EXPANSION) LIMIT 10;
+-----------+----------+-------------------------------------+
| c1 | c2 | c3 |
+-----------+----------+-------------------------------------+
| 250627749 | 12479782 | Miranda class starship (Star Trek) |
| 119794610 | 12007923 | Starship Troopers 3 |
| 253430758 | 12489743 | Starship Children's Hospital |
| 250971304 | 12481409 | Starship Hospital |
| 277700214 | 12573467 | Star ship troopers |
| 86748633 | 11886457 | Troopers Drum and Bugle Corps |
| 255120817 | 12495666 | Casper Troopers |
| 396408580 | 13014545 | Battle Android Troopers |
| 12453401 | 11585248 | Star trek tos |
| 21380240 | 11622781 | Who Mourns for Adonais? (Star Trek) |
+-----------+----------+-------------------------------------+
10 rows in set (0.002 sec)
La salida contenía una gran cantidad de filas, pero esta muestra es suficiente para ver cómo funciona. La consulta devolvió filas como:
“Troopers Drum and Bugle Corps”
“Batalla de soldados androides”
Esos se basan en la búsqueda de la palabra 'Troopers'. También devolvió filas con cadenas como:
“Star trek tos”
“¿Quién llora por Adonai? (Viaje a las estrellas)”
Que, obviamente, se basan en la búsqueda de la palabra 'Start Trek'.
Si necesita más control sobre el término que desea buscar, puede usar "EN MODO BOOLEAN". Permite utilizar operadores adicionales. La lista completa está en la documentación, mostraremos solo un par de ejemplos.
Digamos que queremos buscar no solo la palabra 'Estrella' sino también otras palabras que comienzan con la cadena 'Estrella':
MariaDB [(none)]> SELECT * FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('Star*' IN BOOLEAN MODE) LIMIT 10;
+----------+----------+---------------------------------------------------+
| c1 | c2 | c3 |
+----------+----------+---------------------------------------------------+
| 20014704 | 11614055 | Ringo Starr and His third All-Starr Band-Volume 1 |
| 154810 | 11539775 | Rough blazing star |
| 154810 | 11539787 | Great blazing star |
| 234851 | 11540119 | Mary Star of the Sea High School |
| 325782 | 11540427 | HMS Starfish (19S) |
| 598616 | 11541589 | Dwarf (star) |
| 1951655 | 11545092 | Yellow starthistle |
| 2963775 | 11548654 | Hydrogenated starch hydrolysates |
| 3248823 | 11549445 | Starbooty |
| 3993625 | 11553042 | Harvest of Stars |
+----------+----------+---------------------------------------------------+
10 rows in set (0.001 sec)
Como puede ver, en la salida tenemos filas que contienen cadenas como 'Estrellas', 'Estrella de mar' o 'almidón'.
Otro caso de uso para el modo BOOLEAN. Digamos que queremos buscar filas que sean relevantes para la Cámara de Representantes de Pensilvania. Si ejecutamos una consulta regular, obtendremos resultados relacionados de alguna manera con cualquiera de esas cadenas:
MariaDB [ft_data]> SELECT COUNT(*) FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('House, Representatives, Pennsylvania');
+----------+
| COUNT(*) |
+----------+
| 1529 |
+----------+
1 row in set (0.005 sec)
MariaDB [ft_data]> SELECT * FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('House, Representatives, Pennsylvania') LIMIT 20;
+-----------+----------+--------------------------------------------------------------------------+
| c1 | c2 | c3 |
+-----------+----------+--------------------------------------------------------------------------+
| 198783294 | 12289308 | Pennsylvania House of Representatives, District 175 |
| 236302417 | 12427322 | Pennsylvania House of Representatives, District 156 |
| 236373831 | 12427423 | Pennsylvania House of Representatives, District 158 |
| 282031847 | 12588702 | Pennsylvania House of Representatives, District 47 |
| 282031847 | 12588772 | Pennsylvania House of Representatives, District 196 |
| 282031847 | 12588864 | Pennsylvania House of Representatives, District 92 |
| 282031847 | 12588900 | Pennsylvania House of Representatives, District 93 |
| 282031847 | 12588904 | Pennsylvania House of Representatives, District 94 |
| 282031847 | 12588909 | Pennsylvania House of Representatives, District 193 |
| 303827502 | 12671054 | Pennsylvania House of Representatives, District 55 |
| 303827502 | 12671089 | Pennsylvania House of Representatives, District 64 |
| 337545922 | 12797838 | Pennsylvania House of Representatives, District 95 |
| 219202000 | 12366957 | United States House of Representatives House Resolution 121 |
| 277521229 | 12572732 | United States House of Representatives proposed House Resolution 121 |
| 20923615 | 11618759 | Special elections to the United States House of Representatives |
| 20923615 | 11618772 | List of Special elections to the United States House of Representatives |
| 37794558 | 11693157 | Nebraska House of Representatives |
| 39430531 | 11699551 | Belgian House of Representatives |
| 53779065 | 11756435 | List of United States House of Representatives elections in North Dakota |
| 54048114 | 11757334 | 2008 United States House of Representatives election in North Dakota |
+-----------+----------+--------------------------------------------------------------------------+
20 rows in set (0.003 sec)
Como puede ver, encontramos algunos datos útiles, pero también encontramos datos que no son relevantes para nuestra búsqueda. Afortunadamente, podemos refinar dicha consulta:
MariaDB [ft_data]> SELECT * FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('+House, +Representatives, +Pennsylvania' IN BOOLEAN MODE);
+-----------+----------+-----------------------------------------------------+
| c1 | c2 | c3 |
+-----------+----------+-----------------------------------------------------+
| 198783294 | 12289308 | Pennsylvania House of Representatives, District 175 |
| 236302417 | 12427322 | Pennsylvania House of Representatives, District 156 |
| 236373831 | 12427423 | Pennsylvania House of Representatives, District 158 |
| 282031847 | 12588702 | Pennsylvania House of Representatives, District 47 |
| 282031847 | 12588772 | Pennsylvania House of Representatives, District 196 |
| 282031847 | 12588864 | Pennsylvania House of Representatives, District 92 |
| 282031847 | 12588900 | Pennsylvania House of Representatives, District 93 |
| 282031847 | 12588904 | Pennsylvania House of Representatives, District 94 |
| 282031847 | 12588909 | Pennsylvania House of Representatives, District 193 |
| 303827502 | 12671054 | Pennsylvania House of Representatives, District 55 |
| 303827502 | 12671089 | Pennsylvania House of Representatives, District 64 |
| 337545922 | 12797838 | Pennsylvania House of Representatives, District 95 |
+-----------+----------+-----------------------------------------------------+
12 rows in set (0.001 sec)
Como puede ver, al agregar el operador '+' dejamos en claro que solo estamos interesados en la salida donde existe la palabra dada. Como resultado, los datos que obtuvimos en respuesta son exactamente lo que buscábamos.
También podemos excluir palabras de la búsqueda. Digamos que estamos buscando cosas voladoras pero nuestros resultados de búsqueda están contaminados por diferentes animales voladores que no nos interesan. Podemos deshacernos fácilmente de zorros, ardillas y ranas:
MariaDB [ft_data]> SELECT * FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('+flying -fox* -squirrel* -frog*' IN BOOLEAN MODE) LIMIT 10;
+----------+----------+-----------------------------------------------------+
| c1 | c2 | c3 |
+----------+----------+-----------------------------------------------------+
| 13340153 | 11587884 | List of surviving Boeing B-17 Flying Fortresses |
| 16774061 | 11600031 | Flying Dutchman Funicular |
| 23137426 | 11631421 | 80th Flying Training Wing |
| 26477490 | 11646247 | Kites and Kite Flying |
| 28568750 | 11655638 | Fear of Flying |
| 28752660 | 11656721 | Flying Machine (song) |
| 31375047 | 11666654 | Flying Dutchman (train) |
| 32726276 | 11672784 | Flying Wazuma |
| 47115925 | 11728593 | The Flying Locked Room! Kudou Shinichi's First Case |
| 64330511 | 11796326 | The Church of the Flying Spaghetti Monster |
+----------+----------+-----------------------------------------------------+
10 rows in set (0.001 sec)
La característica final que nos gustaría mostrar es la capacidad de buscar la cita exacta:
MariaDB [ft_data]> SELECT * FROM ft_data.ft_table WHERE MATCH(c3) AGAINST ('"People\'s Republic of China"' IN BOOLEAN MODE) LIMIT 10;
+-----------+----------+------------------------------------------------------------------------------------------------------+
| c1 | c2 | c3 |
+-----------+----------+------------------------------------------------------------------------------------------------------+
| 12093896 | 11583713 | Religion in the People's Republic of China |
| 25280224 | 11640533 | Political rankings in the People's Republic of China |
| 43930887 | 11716084 | Cuisine of the People's Republic of China |
| 62272294 | 11789886 | Office of the Commissioner of the Ministry of Foreign Affairs of the People's Republic of China in t |
| 70970904 | 11824702 | Scouting in the People's Republic of China |
| 154301063 | 12145003 | Tibetan culture under the People's Republic of China |
| 167640800 | 12189851 | Product safety in the People's Republic of China |
| 172735782 | 12208560 | Agriculture in the people's republic of china |
| 176185516 | 12221117 | Special Economic Zone of the People's Republic of China |
| 197034766 | 12282071 | People's Republic of China and the United Nations |
+-----------+----------+------------------------------------------------------------------------------------------------------+
10 rows in set (0.001 sec)
Como puede ver, la búsqueda de texto completo en MariaDB funciona bastante bien, también es más rápida y flexible que la búsqueda usando índices B+Tree. Sin embargo, tenga en cuenta que esta no es una forma de manejar grandes volúmenes de datos; con el crecimiento de los datos, la viabilidad de esta solución se reducirá. Aún así, para los conjuntos de datos pequeños, esta solución es perfectamente válida. Definitivamente puede ganar más tiempo para, eventualmente, implementar soluciones dedicadas de búsqueda de texto completo como Sphinx o Lucene. Por supuesto, todas las funciones que describimos están disponibles en los clústeres de MariaDB implementados desde ClusterControl.