LIKE não escala
Se você já fez busca em texto no SQLite, provavelmente apelou pro LIKE '%palavra%'. Funciona numa tabela pequena, mas desmorona quando ela cresce. Não existe índice que ajude — o SQLite precisa varrer linha por linha, passar pra minúsculo e procurar a substring. Tratar limites de palavra, ordenação por relevância, consultas com múltiplas palavras e busca por prefixo fica tudo por sua conta.
O FTS5 é a resposta nativa pra isso. É um tipo de tabela virtual que mantém um índice invertido sobre as suas colunas de texto, entende uma pequena linguagem de consulta e ordena os resultados com BM25. Já vem incluso no SQLite por padrão — não precisa instalar extensão nenhuma.
Criando uma virtual table FTS5
Você cria uma tabela FTS5 com CREATE VIRTUAL TABLE ... USING fts5(...), listando as colunas de texto que quer indexar:
Três coisas valem a pena destacar. As colunas não têm tipo — o FTS5 trata tudo como texto. O operador MATCH é aplicado ao nome da tabela (posts MATCH ...), não a uma coluna. E a busca é case-insensitive e tokenizada, então 'sqlite' encontra SQLite em qualquer uma das linhas.
A linguagem de consulta do MATCH
O MATCH aceita bem mais do que uma única palavra. A string de consulta tem sua própria mini-gramática:
O que cada um faz:
'fts5 AND prefixo'— os dois termos precisam aparecer (em qualquer ordem, em qualquer lugar da linha).'"manter o fts"'— frase exata, nessa ordem.'gatil*'— busca por prefixo, casa comtrigger,triggers,trigonometry...'índice NOT gatilho'— contémindex, mas não contémtrigger.
Também dá pra restringir a busca a uma coluna específica usando column:term, tipo '(consistent with Portuguese column names if applicable)'. A gramática completa ainda inclui parênteses pra agrupar expressões e OR pra alternativas — basicamente o que você esperaria de qualquer mecanismo de busca.
Ranqueando com BM25
Por padrão, o FTS5 adiciona uma coluna oculta rank em cada linha. É a pontuação de relevância BM25 — quanto menor o número, melhor o match. Ordene por ela pra trazer os resultados mais relevantes primeiro:
Quer dar mais peso a algumas colunas que outras? Basta passar os pesos para bm25() — um para cada coluna, na mesma ordem da declaração:
O primeiro post vence porque sqlite aparece no title (peso 10×) e não só no body (peso 1×). Escolha pesos que reflitam como sua aplicação realmente quer ranquear os resultados.
Mantendo o índice sincronizado
A forma mais simples de usar uma tabela FTS5 é deixar ela guardar a própria cópia do texto. Isso funciona bem para dados estilo log, em que você só faz insert, mas a maioria das aplicações já tem uma tabela "de verdade" e quer que o FTS acompanhe ela. O padrão mais limpo nesse caso é uma tabela FTS com external content junto com três triggers.
content='articles' diz ao FTS5 para não guardar o texto em si — ele vai buscar na tabela articles quando precisar. Os triggers replicam as escritas no índice FTS. Assim, articles passa a ser a fonte da verdade e articles_fts fica só como estrutura de busca ao lado.
Aquele INSERT INTO articles_fts(articles_fts, ...) VALUES ('delete', ...) de cara estranha é a sintaxe de comando do FTS5 para mandar o índice remover uma linha.
Snippet e highlight nos resultados
Em geral, queremos exibir um trecho do resultado com os termos da busca destacados. O FTS5 oferece duas funções justamente para isso:
highlight(tabela, indice_coluna, abre, fecha)retorna o texto completo da coluna com os termos encontrados envolvidos pelas marcações.snippet(tabela, indice_coluna, abre, fecha, reticencias, qtd_tokens)retorna um trecho curto centralizado no termo encontrado.
Os índices das colunas começam em zero, seguindo a ordem de declaração. São esses os blocos básicos para aquele efeito de "termos buscados em amarelo" que toda interface de busca precisa ter.
Armadilhas que vale a pena conhecer
Algumas coisas que costumam pegar a galera de surpresa:
MATCHsó funciona em tabelas FTS. Não dá para usarMATCHem uma coluna comum. Se você precisa buscar dentro de uma tabela já existente, use o padrão de conteúdo externo (external content) que mostrei acima.- Não esqueça de ordenar por
rank. Sem isso, o FTS5 devolve as linhas na ordem de armazenamento, que não tem nada a ver com relevância. - O tokenizer faz toda a diferença. O tokenizer padrão (
unicode61) quebra nas fronteiras de palavra Unicode e passa tudo para minúsculas. Para ter stemming (fazercorrercasar comcorrendo), use o tokenizerporter:USING fts5(body, tokenize='porter'). - FTS5 não tolera erros de digitação. Ele faz busca por prefixo, não busca aproximada (fuzzy). Se você precisa daquele "você quis dizer...", isso é uma camada acima do FTS5.
- Tabelas sem conteúdo (
content='') são menores, mas perdem informação. Você consegue buscar nelas, mas não consegue recuperar o texto original — só o rowid. Útil quando o texto está guardado em outro lugar.
A seguir: window functions
O FTS5 cobre a parte de busca em texto. A próxima página fala de um outro tipo de consulta avançada — as window functions, que permitem calcular totais acumulados, rankings e análises por grupo sem precisar colapsar suas linhas em agregações.
Perguntas frequentes
O que é o FTS5 no SQLite?
FTS5 é a extensão nativa de busca full-text do SQLite. Você cria uma tabela virtual especial com CREATE VIRTUAL TABLE ... USING fts5(...) e consulta usando o operador MATCH. Ela tokeniza o texto na inserção, mantém um índice invertido e, por padrão, ranqueia os resultados com BM25.
Qual a diferença entre MATCH e LIKE no SQLite?
O LIKE faz uma varredura linear procurando substring e ignora limites de palavras. Já o MATCH usa o índice invertido do FTS5, então é rápido em tabelas grandes e entende tokens, busca por prefixo (termo*), operadores booleanos (AND, OR, NOT) e busca por frase exata ("frase exata"). Vale lembrar: MATCH só funciona em tabelas virtuais FTS.
Como manter o índice FTS5 sincronizado com a tabela original?
Você tem duas opções: usar uma tabela FTS5 contentless ou de external-content apontando para a tabela real, ou criar triggers AFTER INSERT, AFTER UPDATE e AFTER DELETE que replicam as alterações na tabela FTS. O padrão de external-content (content='posts') tem a vantagem de não duplicar o texto no banco.
Como ranquear resultados de busca full-text no SQLite?
O FTS5 disponibiliza uma coluna oculta rank que retorna o score BM25 (quanto menor, melhor). Basta ordenar por ela: ORDER BY rank. Também dá pra chamar bm25(tabela) explicitamente, ou passar pesos por coluna como bm25(posts, 10.0, 1.0) quando você quer dar mais peso ao título do que ao corpo do texto.