Os bancos de dados destinam-se a armazenar e consultar dados com eficiência. O problema é que existem muitos tipos diferentes de dados que podemos armazenar:números, strings, JSON, dados geométricos. Os bancos de dados usam métodos diferentes para armazenar diferentes tipos de dados - estrutura de tabelas, índices. Nem sempre a mesma maneira de armazenar e consultar os dados é eficiente para todos os seus tipos, tornando bastante difícil o uso de uma solução única. Como resultado, os bancos de dados tentam usar diferentes abordagens para diferentes tipos de dados. Por exemplo, no MySQL ou MariaDB temos uma solução genérica e de bom desempenho como o InnoDB, que funciona bem na maioria dos casos, mas também temos funções separadas para trabalhar com dados JSON, índices espaciais separados para acelerar a consulta de dados geométricos ou índices de texto completo , ajudando com dados de texto. Neste blog, veremos como o MariaDB pode ser usado para trabalhar com dados de texto completo.
Índices B+Tree regulares no InnoDB também podem ser usados para acelerar as pesquisas dos dados de texto. O principal problema é que, devido à sua estrutura e natureza, eles só podem ajudar na busca pelos prefixos mais à esquerda. Também é caro indexar grandes volumes de texto (o que, dadas as limitações do prefixo mais à esquerda, não faz sentido). Por quê? Vejamos um exemplo simples. Temos a seguinte frase:
“A rápida raposa marrom pula sobre o cachorro preguiçoso”
Usando índices regulares no InnoDB, podemos indexar a frase completa:
“A rápida raposa marrom pula sobre o cachorro preguiçoso”
O ponto é que, ao procurar esses dados, temos que pesquisar o prefixo completo mais à esquerda. Então, uma consulta como:
SELECT text FROM mytable WHERE sentence LIKE “The quick brown fox jumps”;
Se beneficiará deste índice, mas uma consulta como:
SELECT text FROM mytable WHERE sentence LIKE “quick brown fox jumps”;
Não vou. Não há nenhuma entrada no índice que comece com 'rápido'. Há uma entrada no índice que contém 'quick', mas começa em 'The', portanto, não pode ser usada. Como resultado, é praticamente impossível consultar dados de texto com eficiência usando índices B+Tree. Felizmente, tanto o MyISAM quanto o InnoDB implementaram índices FULLTEXT, que podem ser usados para realmente trabalhar com dados de texto no MariaDB. A sintaxe é um pouco diferente dos SELECTs normais, vamos dar uma olhada no que podemos fazer com eles. Quanto aos dados, usamos o arquivo de índice aleatório do dump do banco de dados da Wikipedia. A estrutura de dados é a seguinte:
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, criamos uma tabela com duas colunas BIG INT e uma VARCHAR.
MariaDB [(none)]> CREATE TABLE ft_data.ft_table (c1 BIGINT, c2 BIGINT, c3 VARCHAR, PRIMARY KEY (c1, c2);
Depois carregamos os dados:
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
Também criamos o índice FULLTEXT. Como você pode ver, a sintaxe para isso é semelhante ao índice regular, apenas tivemos que passar as informações sobre o tipo de índice, pois o padrão é B+Tree. Então estávamos prontos para executar algumas 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 você pode ver, a sintaxe do SELECT é um pouco diferente do que estamos acostumados. Para pesquisa de texto completo você deve usar a sintaxe MATCH() … AGAINST(), onde em MATCH() você passa a coluna ou colunas que deseja pesquisar e em AGAINST() você passa uma lista de palavras-chave delimitadas por vírgulas. Você pode ver na saída que, por padrão, a pesquisa não diferencia maiúsculas de minúsculas e pesquisa a string inteira, não apenas o início, como acontece com os índices B+Tree. Vamos comparar como ficaria se adicionarmos um índice normal na coluna ‘c3’ - os índices FULLTEXT e B+Tree podem coexistir na mesma coluna sem problemas. O que seria usado é decidido com base na sintaxe 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
Após a criação do índice, vamos dar uma olhada na saída da pesquisa:
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 você pode ver, nossa consulta retornou apenas três linhas. Isso é esperado, pois estamos procurando por linhas que começam apenas com uma string '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)
Quando verificamos a saída EXPLAIN, podemos ver que o índice foi usado para pesquisar os dados. Mas e se quisermos procurar todas as linhas que contêm a string ‘Starship’, não importa se está no início ou não. Devemos escrever a seguinte 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)
A saída corresponde ao que obtivemos da pesquisa 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)
O EXPLAIN é diferente - como você pode ver, ele ainda usa o índice, mas desta vez ele faz uma varredura completa do índice. Isso é possível, pois indexamos a coluna c3 completa para que todos os dados estejam disponíveis no índice. A varredura de índice resultará em leituras aleatórias da tabela, mas para uma tabela tão pequena, o MariaDB decidiu que é mais eficiente do que ler a tabela inteira. Observe o tempo de execução:0,084s para nosso SELECT regular. Comparando isso com a consulta de texto completo, é ruim:
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 você pode ver, a consulta que usa o índice FULLTEXT levou 0,001s para ser executada. Estamos falando aqui de diferenças de ordens de magnitude.
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)
Veja como a saída EXPLAIN se parece para a consulta usando o índice FULLTEXT - esse fato é indicado pelo tipo:fulltext.
As consultas de texto completo também têm alguns outros recursos. É possível, por exemplo, retornar linhas que possam ser relevantes para o termo de pesquisa. O MariaDB procura por palavras localizadas perto da linha que você procura e, em seguida, executa uma pesquisa também por elas.
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)
No nosso caso, a palavra ‘Starship’ pode estar relacionada a palavras como ‘Troopers’, ‘class’, ‘Star Trek’, ‘Hospital’ etc.
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)
A saída continha um grande número de linhas, mas esta amostra é suficiente para ver como funciona. A consulta retornou linhas como:
“Troopers Drum and Bugle Corps”
“Batalha aos soldados Android”
Esses são baseados na busca pela palavra “Troopers”. Ele também retornou linhas com strings como:
“Jornada nas Estrelas”
“Quem chora por Adonais? (Jornada nas Estrelas)"
Que, obviamente, são baseados na busca pela palavra ‘Start Trek’.
Se você precisar de mais controle sobre o termo que deseja pesquisar, pode usar “IN BOOLEAN MODE”. Permite usar operadores adicionais. A lista completa está na documentação, vamos mostrar apenas alguns exemplos.
Digamos que queremos pesquisar não apenas a palavra 'Estrela', mas também outras palavras que começam com a string 'Estrela':
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 você pode ver, na saída temos linhas que contêm strings como 'Stars', 'Starfish' ou 'amido'.
Outro caso de uso para o modo BOOLEAN. Digamos que queremos pesquisar linhas relevantes para a Câmara dos Deputados da Pensilvânia. Se executarmos uma consulta regular, obteremos resultados de alguma forma relacionados a qualquer uma dessas strings:
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 você pode ver, encontramos alguns dados úteis, mas também encontramos dados que não são totalmente relevantes para nossa pesquisa. Felizmente, podemos refinar essa 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 você pode ver, ao adicionar o operador '+', deixamos claro que estamos interessados apenas na saída onde existe uma determinada palavra. Como resultado, os dados que obtivemos em resposta são exatamente o que estávamos procurando.
Também podemos excluir palavras da pesquisa. Digamos que estamos procurando por coisas voadoras, mas nossos resultados de pesquisa estão contaminados por diferentes animais voadores nos quais não estamos interessados. Podemos facilmente nos livrar de raposas, esquilos e sapos:
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)
O recurso final que gostaríamos de mostrar é a capacidade de pesquisar a cotação exata:
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 você pode ver, a pesquisa de texto completo no MariaDB funciona muito bem, também é mais rápida e flexível do que a pesquisa usando índices B+Tree. Por favor, tenha em mente que esta não é de forma alguma uma maneira de lidar com grandes volumes de dados - com o crescimento dos dados, a viabilidade desta solução será reduzida. Ainda assim, para os pequenos conjuntos de dados, esta solução é perfeitamente válida. Definitivamente, você pode ganhar mais tempo para, eventualmente, implementar soluções de pesquisa de texto completo dedicadas, como Sphinx ou Lucene. Obviamente, todos os recursos que descrevemos estão disponíveis em clusters MariaDB implantados no ClusterControl.