Adeus Elasticsearch: Busca Full-Text 25x Mais Rápida no Postgres
Se você já montou uma stack de busca textual com Elasticsearch ao lado do Postgres, sabe que a dor começa no dia da deploy e só piora. Dois sistemas para manter sincronizados, dois clusters para monitorar, e aquele pipeline de replicação que invariavelmente quebra no pior momento. A PlanetScale decidiu acabar com essa brincadeira e lançou o Tin, uma extensão de full-text search que roda direto dentro do Postgres e, nos benchmarks, destruiu tudo que existia antes.
Eu fico impressionado como a comunidade Postgres continua evoluindo em áreas que antes eram domínio exclusivo de ferramentas dedicadas. Busca vetorial com pgvector, analytics com columnar stores, e agora busca textual de verdade com BM25 scoring e 10 mil queries por segundo. O Postgres virou o canivete suíço que realmente corta.
O problema que ninguém resolveu direito
Todo dev backend já passou por esse dilema: seu app precisa de busca textual, e as opções nativas do Postgres (tsvector + GIN index) são lentas demais para qualquer coisa além do trivial. A saída clássica? Subir um Elasticsearch, configurar um Logstash ou pipeline customizado para sincronizar dados, e rezar para que o eventual consistency não cause bugs visíveis pro usuário.
O custo disso é absurdo:
| Aspecto | Postgres nativo (GIN) | Elasticsearch | Tin | |
|---|---|---|---|---|
| ——— | ———————- | ————— | —– | |
| Infraestrutura extra | Nenhuma | Cluster dedicado | Nenhuma | |
| Sincronização de dados | N/A | Pipeline necessário | N/A | |
| Consistência transacional | ACID | Eventual | ACID | |
| BM25 ranking | Não nativo | Sim | Sim | |
| Concurrent writes + search | Lento | Sim (eventual) | Sim (MVCC) |
Existiam alternativas como o pg_search do ParadeDB (construído sobre o Tantivy, o Lucene em Rust) e o pg_textsearch da comunidade. Mas a PlanetScale testou todas e nenhuma passou no combo completo: queries complexas com WHERE clauses, replicação, backups, visibilidade transacional correta, e performance decente com writes concorrentes.
O que o Tin faz de diferente
O Tin (Text INdex) é uma extensão Postgres que cria um índice de busca textual como qualquer outro índice do banco. Não é um sidecar, não é um serviço separado, não é um hack. É um CREATE INDEX e pronto.
-- Criar o índice
CREATE INDEX idx_articles_body ON articles USING tin(body);
-- Buscar com BM25 scoring
SELECT title, tin.score(ctid) as relevance
FROM articles
WHERE body ==> 'kubernetes deploy rolling update'
ORDER BY tin.score(ctid) DESC
LIMIT 10;
A sintaxe usa o operador ==> para queries de busca, e a função tin.score(ctid) retorna o score BM25 de cada resultado. Simples assim.
Mas o diferencial real está na arquitetura interna, e é aqui que a coisa fica interessante pra quem curte entender o “como” além do “o quê”.
A sacada do ctid: por que isso muda tudo
A maioria dos engines de busca textual (Lucene, Tantivy, e por consequência o ParadeDB) usa IDs sequenciais internos para identificar documentos. Cada documento recebe um número (0, 1, 2, 3…) e as posting lists mapeiam termos para esses IDs.
O problema? Quando você precisa conectar esses IDs de volta às linhas do Postgres, precisa manter uma estrutura de mapeamento: “ID 42 do índice de busca = ctid (5, 12) do Postgres”. Isso consome memória, adiciona indireção em cada lookup, e complica o merge de segmentos porque você precisa renumerar documentos.
O Tin eliminou esse mapeamento inteiro. Em vez de inventar IDs próprios, ele usa o ctid nativo do Postgres como identificador de documento. O ctid é um valor de 48 bits que diz exatamente onde a tupla mora fisicamente no disco: 32 bits para o número do bloco (página) e 16 bits para o offset dentro da página.
Parece uma decisão simples, mas as consequências cascateiam em todo o design:
Compressão bitmap de dois níveis
Como o Postgres organiza dados em páginas de 8KB, e cada página tem no máximo 291 tuplas (na prática, 32 ou menos com colunas TEXT), o Tin consegue representar todos os offsets de uma página em um bitmap de 256 bits. E 256 bits cabem perfeitamente em um registrador AVX2.
Na prática, o Tin comprime as posting lists em dois níveis:
- Bitmap de páginas: quais páginas contêm documentos que matcham o termo
- Bitmap de offsets: dentro de cada página, quais tuplas matcham
Para termos frequentes, a compressão chega a ~1 bit por posting. Para termos raros, ~25 bits. A média fica em ~7 bits por posting, o que é extremamente eficiente.
Operações vetorizadas
Queries de conjunção (AND) e disjunção (OR) viram operações AND/OR diretas nos bitmaps usando instruções AVX2 ou AVX-512. Sem loops, sem branch prediction misses, sem overhead interpretativo. É o tipo de coisa que faz o processador trabalhar do jeito que ele foi projetado.
Merge de segmentos sem renumeração
Quando o índice precisa compactar segmentos (algo que todo engine de busca textual faz periodicamente), o Tin simplesmente concatena bitmaps. Como os ctids significam a mesma coisa em qualquer segmento, não existe write amplification por renumeração de documentos. O ParadeDB e o Tantivy, por comparação, precisam reescrever posting lists inteiras durante o merge.
O truque do COUNT(*) que impressiona
Uma das coisas mais legais que o Tin faz é otimizar queries de COUNT(*) em buscas por disjunção. Normalmente, para contar quantos documentos matcham “kubernetes OR docker”, você precisa ler todas as posting lists, fazer merge, e contar o resultado.
O Tin faz diferente: ele olha os bitmaps de página de cada termo. Se os bitmaps de duas posting lists não têm bits em comum em nível de página (ou seja, nenhuma página contém documentos de ambos os termos), ele simplesmente soma os contadores exatos armazenados em cada segmento. Sem ler um único posting.
Isso explica os números insanos no benchmark de COUNT: 10.260 queries por segundo contra 291 do ParadeDB. 35 vezes mais rápido, com 13 vezes menos memória por query.
Benchmarks: os números não mentem
A PlanetScale rodou benchmarks extensivos usando o dump do Stack Exchange (85GB, 150 milhões de documentos) e a Wikipedia em inglês (8GB). Os resultados são impressionantes.
Build do índice
| Sistema | Tempo | Tamanho | RAM necessária | |
|---|---|---|---|---|
| ——— | ——- | ——— | —————- | |
| Tin | 8min 10s | 50.7 GB | 32 GB | |
| ParadeDB | 19min 20s | 52.1 GB | 64 GB | |
| pg_textsearch | 26min 49s | 41.5 GB | 128 GB | |
| Postgres GIN | 2h 09min | 28.0 GB | 64 GB |
O Tin constrói o índice em menos da metade do tempo do ParadeDB e com metade da RAM. O GIN nativo do Postgres leva mais de 2 horas, o que é quase impraticável para datasets desse tamanho.
Queries mistas (conjunção + disjunção + frase, top-10 com BM25)
| Sistema | QPS | Latência p99 | |
|---|---|---|---|
| ——— | —– | ————- | |
| Tin | 199 | 256ms | |
| ParadeDB | 7.9 | 6.765ms |
25x mais throughput. 26x menos latência. E isso com queries mistas, que é o cenário mais realista de uma aplicação real.
Conjunção e frase (top-10, somente leitura)
| Sistema | QPS | Latência p99 | |
|---|---|---|---|
| ——— | —– | ————- | |
| Tin | 242 | 212ms | |
| ParadeDB | 24 | 1.279ms | |
| Postgres GIN | 0.4 | 288.066ms |
O GIN consegue processar 0.4 queries por segundo nesse cenário. Quase meio minuto de latência p99. É o tipo de número que faz você entender por que todo mundo foge pro Elasticsearch.
Disjunção com writes concorrentes (1.000 updates/seg target)
| Sistema | QPS | Latência p99 | Updates completados | |
|---|---|---|---|---|
| ——— | —– | ————- | ——————- | |
| Tin | 125 | 354ms | 270.279 | |
| ParadeDB | 2.2 | 12.634ms | 185.584 | |
| pg_textsearch | 3.5 | 8.409ms | 735 |
Esse é o cenário mais brutal: busca textual enquanto o banco recebe 1.000 updates por segundo. O pg_textsearch praticamente morreu (735 updates completados de centenas de milhares). O Tin manteve 125 QPS com 270 mil updates processados.
COUNT(*) disjunção (Wikipedia 8GB, in-memory)
| Sistema | QPS | Latência p99 | Dados lidos/query | |
|---|---|---|---|---|
| ——— | —– | ————- | —————— | |
| Tin | 10.260 | 2ms | 1.7 MB | |
| ParadeDB | 291 | 95ms | 22 MB | |
| Postgres GIN | 1.4 | 30.292ms | 2.5 MB |
Dez mil queries por segundo com 2ms de latência. E lendo 13 vezes menos dados por query que o ParadeDB. É aqui que a otimização de bitmaps de página mostra toda a sua força.
MVCC: o detalhe que todo mundo ignora (e não deveria)
Um dos maiores problemas de plugar um engine de busca em cima do Postgres é a visibilidade transacional. Se a transação A inseriu um documento e ainda não commitou, a transação B deveria ver esse documento nos resultados de busca? Obviamente não. Mas garantir isso com um índice externo é surpreendentemente difícil.
O Tin resolve isso de forma elegante. Como ele é um índice nativo do Postgres, as queries de busca que precisam ler tuplas do heap (top-k, por exemplo) automaticamente passam pela verificação de visibilidade do Postgres. Se a tupla não é visível para sua transação, ela simplesmente não aparece.
Para queries de COUNT(*) (que não leem o heap), o Tin usa os visibility maps do Postgres. Páginas marcadas como “all-visible” podem ser contadas sem verificação individual. Para páginas não totalmente visíveis, ele faz a verificação tupla a tupla.
Quando um VACUUM roda, o Tin marca as posições dos documentos deletados em um bitmap de “liveness” por segmento, permitindo que esses espaços sejam ignorados em buscas futuras sem precisar reescrever o índice.
E o Lead? A versão open source para dev e CI
Junto com o Tin, a PlanetScale lançou o Lead: uma extensão open source com a mesma API e funcionalidades, mas otimizada para ambientes de desenvolvimento, teste e CI. O Lead roda offline, não precisa de conexão com nenhum serviço externo, e aceita exatamente o mesmo SQL que o Tin.
A ideia é simples: você desenvolve e testa com o Lead, deploya em produção com o Tin. Sem surpresas de compatibilidade.
-- Funciona igual no Lead e no Tin
CREATE INDEX idx_search ON posts USING tin(content);
SELECT * FROM posts WHERE content ==> 'rust async await' LIMIT 5;
O código do Lead está no GitHub da PlanetScale para quem quiser explorar.
Quando usar (e quando NÃO usar) o Tin
Vamos ser honestos: o Tin não substitui o Elasticsearch em 100% dos casos. Existe nuance.
Use o Tin quando:
- Sua busca textual roda no mesmo banco Postgres dos dados da aplicação
- Você precisa de consistência transacional entre dados e índice de busca
- Seu dataset cabe em um único servidor Postgres (mesmo que seja grande)
- Você quer eliminar a complexidade de manter Elasticsearch/OpenSearch
- Writes concorrentes são parte do seu workload
Considere manter o Elasticsearch quando:
- Você precisa de busca distribuída em múltiplos nós
- Autocomplete avançado com suggestions é crítico
- Agregações complexas (facets, histogramas) são o core do produto
- Você já tem uma infraestrutura Elastic madura e funcionando
A grande maioria dos projetos, na minha experiência, cai no primeiro grupo. Aquele “a gente precisa de Elasticsearch” geralmente é um “a gente precisa de busca textual boa” disfarçado, e o Tin entrega isso sem a bagagem operacional.
O que isso significa para o ecossistema Postgres
O lançamento do Tin segue uma tendência clara: o Postgres está engolindo funcionalidades que antes exigiam ferramentas separadas. pgvector para busca vetorial, TimescaleDB para séries temporais, pg_cron para agendamento, e agora Tin para full-text search.
Para startups e times pequenos, isso é libertador. Em vez de operar 5 bancos de dados diferentes (um relacional, um para busca, um para cache, um para filas, um para analytics), você opera um Postgres com extensões. Menos moving parts, menos pontos de falha, menos noites acordado debugando sincronização.
A ParadeDB e o pg_search fizeram um trabalho sólido trazendo busca BM25 para o Postgres, e merecem crédito por abrir esse caminho. Mas os benchmarks do Tin mostram que existe espaço para ir muito além em performance, especialmente quando você redesenha a arquitetura do zero em vez de adaptar um engine existente (Tantivy/Lucene) para rodar dentro do Postgres.
A PlanetScale claramente investiu pesado em engenharia de baixo nível aqui. Usar instruções AVX2/AVX-512 para operações em bitmaps, explorar a estrutura de páginas do Postgres para compressão, eliminar indireções de mapeamento de IDs. São decisões que exigem entender profundamente tanto o Postgres quanto arquitetura de CPUs modernas.
Se você tem um projeto onde a busca textual é uma funcionalidade (e não o produto inteiro), o Tin provavelmente acaba com qualquer argumento para manter um Elasticsearch ao lado. Menos infraestrutura, menos complexidade, mais performance. E com o Lead para desenvolvimento local, o ciclo de dev fica completo.
A única questão que resta é: quando a AWS vai oferecer isso como serviço gerenciado e cobrar 10x o preço?













