Um índice GIN real, e uma query que nunca o tocou
O Catalog::SearchQuery é a classe por trás da busca do catálogo desta aplicação. Até 2 de setembro de 2026, ela filtrava cursos e aulas assim:
# app/queries/catalog/search_query.rb, antes de 2 de setembro de 2026
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(term)}%"
title_sql = ->(table) { Arel.sql("COALESCE(#{table}.title->>#{ActiveRecord::Base.connection.quote(locale.to_s)}, #{table}.title->>'en')") }
courses = ::Catalog::Course.active
.where("#{title_sql.call('courses')} ILIKE ?", pattern)
courses.title é jsonb. O título de cada curso é um pequeno documento, {"en" => "...", "pt-BR" => "..."}, e não uma coluna de texto simples. A migration que acrescentou índices de desempenho às tabelas principais, 20260301202548_add_performance_indexes_to_core_tables.rb, colocou um índice GIN direto nessa coluna, sob um comentário que diz para que serve:
# ── JSONB GIN Indexes (Search & Filtering) ──────────────────
# Using gin index for PostgreSQL jsonb translatable fields
add_index :courses, :title, using: :gin
add_index :courses, :description, using: :gin
add_index :categories, :name, using: :gin
add_index :lessons, :title, using: :gin
São quatro índices GIN nas quatro colunas jsonb de onde a aplicação mostra texto, rotulados para busca e filtragem. O SearchQuery buscava em courses.title e lessons.title, então, no papel, o índice e a query batiam.
A query fez uma leitura sequencial mesmo assim
A correção removeu esse índice, então o experimento abaixo roda em courses_old: uma cópia temporária da tabela courses de desenvolvimento (361 linhas) com o índice GIN original recriado em title. A busca, como rodava em inglês:
EXPLAIN SELECT * FROM courses_old
WHERE COALESCE(title->>'en', title->>'en') ILIKE '%rails%';
Seq Scan on courses_old (cost=0.00..22.32 rows=1 width=271)
Filter: (COALESCE((title ->> 'en'::text), (title ->> 'en'::text)) ~~* '%rails%'::text)
Um Seq Scan lê e confere cada linha, e o índice GIN em title não aparece no plano. Com algumas centenas de linhas isso não é lento, mas "não é lento" é outra afirmação, diferente de "o índice ajudou". Este plano não o usou.
O mesmo índice, uma query que o usa
Esta é a mesma coluna e o mesmo índice, consultados com o operador de contenção do Postgres em vez de uma busca por padrão:
EXPLAIN SELECT * FROM courses_old WHERE title @> '{"en": "Ruby basics"}';
Bitmap Heap Scan on courses_old (cost=12.98..16.99 rows=1 width=271)
Recheck Cond: (title @> '{"en": "Ruby basics"}'::jsonb)
-> Bitmap Index Scan on courses_old_title (cost=0.00..12.98 rows=1 width=0)
Index Cond: (title @> '{"en": "Ruby basics"}'::jsonb)
É um Bitmap Index Scan no mesmo índice, na mesma coluna. O índice nunca esteve quebrado. Ele foi feito para uma pergunta que a busca da aplicação nunca fazia.
Do que trata este curso
Um índice GIN não é uma coisa só. É um mecanismo de armazenamento, e a classe de operadores dele decide que perguntas ele sabe responder sobre um valor. "Este documento contém esse par" é uma pergunta. "Este texto bate com este padrão" é outra, e acrescentar using: :gin a uma migration não escolhe entre elas. O resto deste curso mostra por que um índice GIN atendeu a uma query e ignorou outra na mesma coluna, e como foi a correção da aplicação.
Pontos-chave
- O
Catalog::SearchQueryfiltracourses.titleelessons.titlecomILIKEsobre o texto extraído por->>: uma busca por padrão, e não uma operação dejsonb. - A migration original criou índices GIN em
courses.title,courses.description,categories.nameelessons.title, rotulados para busca e filtragem. - Com o índice original recriado numa cópia de
courses, a query de busca fez umSeq Scan, e o índice GIN não apareceu no plano. - O mesmo índice, consultado com
@>em vez deILIKE, produziu umBitmap Index Scan, então o problema nunca foi o índice.
Continue com a aula 2
jsonb_ops: o que a opclass padrão indexa
A classe padrão jsonb_ops registra seis operadores, como @> e existência de chave, e nenhum deles é LIKE ou ILIKE.
Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.