Paulo Henrique Rodrigues Pinheiro

Um blog sobre programação para programadores!


SQLite sem like, mas dando match

Usando full text search (FTS5) no sqlite3, ao invés de like

Ilustração de Annie Ruygt: um latão antropomorfizado, com uma lupa procurando algo em um livro, flutuando no universo

Ilustração de Annie Ruygt

O problema sou eu

Tenho dados estáticos e quero fazer buscas por trechos de palavras. Não é uma base gigantesca, não estamos falando de big data.

Uma base simples, do Código Brasileiro de Ocupação, o famoso CBO, que classifica as profissões, nas bases de dados do governo federal.

Para preencher formulários, com busca incremental, é necessária uma API que retorne rápido bons resultados para entradas incrementais. Outra questão, é ter em um banco de dados de pessoas, uma certa padronização das funções em que trabalham, para facilitar buscas e dar mais sentido a agregações.

Para essa quantidade de dados, um like até que resolve. Mas então fico pensando como não é ecologicamente correto fazer uma busca dessas no banco inteiro toda vez que se recebe uma nova string.

O sqlite3 (qualquer servidor de dados que entenda SQL) é rápido o suficiente pra fazer essas buscas, mas com o modo esbanjador de recursos ativado.

Pegando os arquivos

No site do Ministério do Trabalho temos um seção dedicada ao CBO, em que se pode pegar um arquivo zip com os dados. Precisamos do arquivo "Estrutura CBO CSV".

Dentro dele, teremos algo assim:


CBO2002 - Familia.csv
CBO2002 - Grande Grupo.csv
CBO2002 - Ocupacao.csv
CBO2002 - PerfilOcupacional.csv
CBO2002 - Sinonimo.csv
CBO2002 - SubGrupo.csv
CBO2002 - SubGrupo Principal.csv

Os arquivos tem esse nome horrível e conteúdo em ISO-8859-1:


CBO2002 - Familia.csv:            ISO-8859 text, with CRLF, LF line terminators
CBO2002 - Grande Grupo.csv:       ISO-8859 text, with CRLF, LF line terminators
CBO2002 - Ocupacao.csv:           ISO-8859 text, with CRLF, LF line terminators
CBO2002 - PerfilOcupacional.csv:  ISO-8859 text, with CRLF, LF line terminators
CBO2002 - Sinonimo.csv:           ISO-8859 text, with CRLF, LF line terminators
CBO2002 - SubGrupo.csv:           ISO-8859 text, with CRLF, LF line terminators
CBO2002 - SubGrupo Principal.csv: ISO-8859 text, with CRLF, LF line terminators

Pra facilitar, fiz um tratamento nos dados com esse script:


from pathlib import Path


PREFIX = 'CBO2002 - '

p = Path('.')
for file in p.glob(f'{PREFIX}*.csv'):
    _, new_name = file.name.split(PREFIX)
    new_name = new_name.replace(' ', '_')
    new_name = new_name.lower()
    print(file.name, '->', new_name)

    with file.open('r', encoding='ISO-8859-1') as f:
        data = f.read()

    with open(new_name, 'w', encoding='UTF-8') as f:
        f.write(data)

Resultando nesses arquivos:


familia.csv
grande_grupo.csv
ocupacao.csv
perfilocupacional.csv
sinonimo.csv
subgrupo.csv
subgrupo_principal.csv

Que, para nossa alegria, agora estão em UTF-8:


familia.csv:                      Unicode text, UTF-8 text
grande_grupo.csv:                 Unicode text, UTF-8 text
ocupacao.csv:                     Unicode text, UTF-8 text
perfilocupacional.csv:            Unicode text, UTF-8 text
sinonimo.csv:                     Unicode text, UTF-8 text
subgrupo.csv:                     Unicode text, UTF-8 text
subgrupo_principal.csv:           Unicode text, UTF-8 text

Recurso especial no SQLite3

Assim como em outras engines, o SQLite3 fornece uma extensão que provê o recurso de fazer buscas por trechos de palavras, em uma estrutura preprocessada (Full Text Search), indexada (Reverse Index) de forma a tornar essa busca mais eficiente: SQLite3 FTS5.

Primeira providência é saber se temos essa extensão:


$ sqlite3 cbo.db 'PRAGMA compile_options;' | grep -i fts
ENABLE_FTS3
ENABLE_FTS3_PARENTHESIS
ENABLE_FTS4
ENABLE_FTS5

Oba, sem problemas aqui. Caso queira testar e não encontre essa opção (difícil, pois a partir da versão 3.9.0, de 2015, vem por padrão), basta seguir as instruções de compilação.

Usando, enfim, o SQLite3

Os arquivos que interessam aqui são os que de ocupações e sinônimos, que são exatamente isso que dizem :).

Para poder fazer as pesquisas, precisamos carregar os dados de uma forma especial. Será criada uma tabela virtual para as pesquisas, e a engine cuidará dos pormenores práticos. Mais uma vez, um programa em Python para fazer essa nobre tarefa:


import sqlite3
import unicodedata


con = sqlite3.connect('cbo.db')
curr = con.cursor()


def ingest(filename, tipo):
    with open('ocupacao.csv') as f:
        for line in f:
            data = line.replace('\n', '')
            cbo, cargo_acentuado = data.split(';')
            cargo = unicodedata.normalize('NFD', cargo_acentuado)
            cargo = cargo.encode('ascii', 'ignore')
            print(cbo, cargo_acentuado, cargo)
            curr.execute('INSERT INTO ocupacoes (cbo, cargo, cargo_acentuado, tipo) VALUES (?,?,?,?)', (cbo, cargo, cargo_acentuado, tipo))

    con.commit()


curr.execute('CREATE VIRTUAL TABLE IF NOT EXISTS ocupacoes USING fts5(cbo UNINDEXED, cargo, cargo_acentuado, tipo UNINDEXED, tokenize=\'trigram\');')
ingest('ocupacao.csv', 'ocupacao')
ingest('sinonimo.csv', 'sinonimo')

O primeiro passo é criar a tabela virtual:


CREATE VIRTUAL TABLE IF NOT EXISTS ocupacoes
USING fts5(
    cbo UNINDEXED,
    cargo,
    cargo_acentuado,
    tipo UNINDEXED,
    tokenize='trigram');

Perceba os campos marcados como UNINDEXED. Como sugere a notação, eles não estarão disponíveis para pesquisa. Note-se também que os dados são salvos com acentuação, sem acentuação. Isso é para pesquisar sem se preocupar com acentuação, e ainda poder voltar os resultados escritos de forma correta.

Uma parte fundamental é o parâmetro tokenize, que recebe o valor trigram, responsável por nos permitir buscas a partir de três caracteres. Outras opções permitem apenas busca por palavras inteiras.

Feito isso, insiramos os dados, como costumeiro.

Eu tinha em mente subir os dados na tabela virtual e em outra tabela, para poder comparar o uso de recursos, mas temos os dados acessíveis em uma tabela real:


sqlite3 cbo.db
SQLite version 3.40.1 2022-12-28 14:03:47
Enter ".help" for usage hints.
sqlite> .tables
ocupacoes          ocupacoes_content  ocupacoes_docsize
ocupacoes_config   ocupacoes_data     ocupacoes_idx    
sqlite> .schema ocupacoes_content 
CREATE TABLE IF NOT EXISTS 'ocupacoes_content'(id INTEGER PRIMARY KEY, c0, c1, c2, c3);
sqlite> SELECT * FROM ocupacoes_content LIMIT 10;
1|CODIGO|TITULO|TITULO|ocupacao
2|010105|Oficial general da aeronautica|Oficial general da aeronáutica|ocupacao
3|010110|Oficial general do exercito|Oficial general do exército|ocupacao
4|010115|Oficial general da marinha|Oficial general da marinha|ocupacao
5|010205|Oficial da aeronautica|Oficial da aeronáutica|ocupacao
6|010210|Oficial do exercito|Oficial do exército|ocupacao
7|010215|Oficial da marinha|Oficial da marinha|ocupacao
8|010305|Praca da aeronautica|Praça da aeronáutica|ocupacao
9|010310|Praca do exercito|Praça do exército|ocupacao
10|010315|Praca da marinha|Praça da marinha|ocupacao

A primeira linha da resposta é uma vergonha. Usando o módulo CSV do Python, isso seria facilmente resolvido.

Comparando as formas de pesquisa

Uma pesquisa normal com like (procurando "desenvolvedor de sistemas"), poderia ser algo assim (credo, não acredito que escrevi esse query):


sqlite> SELECT c0, c1, c2 FROM ocupacoes_content WHERE c1 LIKE '%desen%sist%' ORDER BY c1;
212405|Analista de desenvolvimento de sistemas|Analista de desenvolvimento de sistemas
212405|Analista de desenvolvimento de sistemas|Analista de desenvolvimento de sistemas
317110|Desenvolvedor de sistemas de tecnologia da informacao (tecnico)|Desenvolvedor de sistemas de tecnologia da informação (técnico)
317110|Desenvolvedor de sistemas de tecnologia da informacao (tecnico)|Desenvolvedor de sistemas de tecnologia da informação (técnico)
142510|Gerente de desenvolvimento de sistemas|Gerente de desenvolvimento de sistemas
142510|Gerente de desenvolvimento de sistemas|Gerente de desenvolvimento de sistemas

Já uma busca usando os recursos corretos :), pode ser algo assim:


sqlite> SELECT cbo, tipo, cargo_acentuado, rank FROM ocupacoes WHERE ocupacoes MATCH '{cargo}:desen sist' ORDER BY rank DESC;
317110|ocupacao|Desenvolvedor de sistemas de tecnologia da informação (técnico)|-7.23465736090208
317110|sinonimo|Desenvolvedor de sistemas de tecnologia da informação (técnico)|-7.23465736090208
212405|ocupacao|Analista de desenvolvimento de sistemas|-9.04498744725366
212405|sinonimo|Analista de desenvolvimento de sistemas|-9.04498744725366
142510|ocupacao|Gerente de desenvolvimento de sistemas|-9.1408897903197
142510|sinonimo|Gerente de desenvolvimento de sistemas|-9.1408897903197

Observe que a tabela virtual tem um mágico campo rank.

Medindo

Temos tudo que precisamos: dados e meios de pesquisa diferentes, para medir e comparar. Como a preocupação principal aqui é uso de recursos, optei por uma ferramente muito interessante: perf. Mediremos instruções e condicionais, especialmente, para saber como vão as coisas em termos de processamento.

Os parâmetros para realizar essa medição foram:


perf stat \
    --repeat 1000 \
    --event task-clock:u,cycles:u,instructions:u,branches:u \
    COMANDO_A_MEDIR > /dev/null

O que é redirecionado para /dev/null são as saídas do comando a medir.

Para a busca com LIKE, rolou esse resultado:


perf stat --repeat 1000 --event task-clock:u,cycles:u,instructions:u,branches:u sqlite3 cbo.db "SELECT c0, c1, c2 FROM ocupacoes_content WHERE c1 LIKE '%desen%sist%' ORDER BY c1;" > /dev/null

 Performance counter stats for 'sqlite3 cbo.db SELECT c0, c1, c2 FROM ocupacoes_content WHERE c1 LIKE '%desen%sist%' ORDER BY c1;' (1000 runs):

          4.025,52 msec task-clock:u                     #  838,798 CPUs utilized               ( +-  0,93% )
     4.486.277.785      cycles:u                         #    2,392 GHz                         ( +-  0,91% )
     7.259.950.764      instructions:u                   #    3,21  insn per cycle              ( +-  0,91% )
     1.419.992.765      branches:u                       #  757,083 M/sec                       ( +-  0,91% )

         0,0047992 +- 0,0000567 seconds time elapsed  ( +-  1,18% )

Já para o MATCH, rolou essa resposta:


perf stat --repeat 1000 --event task-clock:u,cycles:u,instructions:u,branches:u sqlite3 cbo.db "SELECT cbo, tipo, cargo_acentuado FROM ocupacoes WHERE ocupacoes MATCH '{cargo}:desen sist' ORDER BY rank DESC;" > /dev/null

 Performance counter stats for 'sqlite3 cbo.db SELECT cbo, tipo, cargo_acentuado FROM ocupacoes WHERE ocupacoes MATCH '{cargo}:desen sist' ORDER BY rank DESC;' (1000 runs):

          2.732,01 msec task-clock:u                     #  776,632 CPUs utilized               ( +-  0,92% )
     2.813.593.248      cycles:u                         #    2,122 GHz                         ( +-  0,91% )
     3.130.459.757      instructions:u                   #    2,23  insn per cycle              ( +-  0,91% )
       594.198.882      branches:u                       #  448,092 M/sec                       ( +-  0,91% )

         0,0035178 +- 0,0000376 seconds time elapsed  ( +-  1,07% )

Observe que os números para o FTS5 são bem melhores, embora nada de extraordinário, mas fico feliz pela possibilidade de uma boa economia de recursos.

Finalizando

Um texto que me ajudou com o perf, foi esse (além do manual - man perf e man perf-stat):


https://pt.linux-console.net/?p=1477

E não poderia deixar de mencionar que esse texto foi feito com ❤️amor❤️ numa madrugada entre sexta e sábado, com aquele silêncio urbano quebrado apenas por algumas músicas de Miles Davis.