DEV Community

Jony Hayama
Jony Hayama

Posted on

Otimizando um campo de Autocomplete com Postgres e Rails

Sabe quando você e seu time erra a mão na hora de estimar uma task?

Task: Criar campo autocomplete que deve sugerir "tags" para o usuário
Estimado em: 3SP

Assim que comecei a trabalhar task ouvi o Faustão falando no meu ouvido: "E-r-r-r-r-r-oooouu".

Existem inúmeras otimizações que podem ser feitas em um campo desses que fiquei até desnorteado.

Gostaria de compartilhar um pouco do que acabei produzindo nessa task. Estou longe de dizer que esta é a melhor forma possível de fazer um campo como esse, mas acredito que é um bom ponta-pé inicial.

Para facilitar a conversa e deixar esse post mais interessante, ao invés de buscar tags, vamos buscar personagens da Marvel.

Se quiser dar uma olhada no resultado final, acessa aí:

O que eu fiz no Postgres

Se você ainda não conheçe, a Marvel fornece uma API para podermos coletar dados: https://developer.marvel.com/

Como meu objetivo aqui é falar sobre o Postgres, fiz alguns requests para a API, salvei os dados que me interessam em um CSV e criei um seed para importar esses dados no banco.

Se quiser, pode baixar o repositório do projeto e rodar rails db:seed, o CSV está commitado 😜.

Vamos considerar, portanto, que temos uma tabela chamada characters e que precisamos fazer uma busca na coluna name para devolver o resultado dessa pesquisa para o usuário.

Minha primeira ideia foi bem simples:

Character.where('name ILIKE ?', "%#{params[:query]}%").pluck(:name)
# SELECT "characters"."name" FROM "characters" WHERE (name ILIKE '%Iron Man%');
# Execution Time: 2.995 ms
Enter fullscreen mode Exit fullscreen mode

⚠️ OBS:
1 - Vou sempre deixar query gerada pelo ActiveRecord comentada em baixo da instrução para facilitar o papo 😜
2 - Considere que estamos em um Controller e que params[:query] = 'Iron Man'

Logo de cara temos uma otimização a fazer: Em linhas gerais, o Postgres é mais performático fazendo comparações com LIKE do que ILIKE.

Nós conseguimos exatamente o mesmo resultado, porém com um tempo de execução menor, se escrevermos:

Character.where('lower(name) LIKE ?', "%#{params[:query].downcase}%").pluck(:name)
# SELECT "characters"."name" FROM "characters" WHERE (lower(name) LIKE '%iron man%');
# Execution Time: 1.012 ms
Enter fullscreen mode Exit fullscreen mode

A próxima otimização seria criar um índice para a coluna name que está sendo alvo da busca.

Eu já estava pronto para criar uma migration com add_index :characters, :name quando descobri que é possível criar índices baseados em expressões.

Algo simples como:

CREATE INDEX index_characters_lower_name ON characters (lower(name));
Enter fullscreen mode Exit fullscreen mode

O que, naturalmente, traria ainda mais performance já que estamos fazendo a busca baseado exatamente nessa expressão.

Foi então que, antes mesmo de ter tempo de implementar esse índice, o StackOverflow me indagou...

StackOverflow: Já ouviu falar da palavra de pg_trgm?
Eu: Caramba, como será que se fala "trgm"?
Documentação do Postgres: it's "Trigram"
Google Translate: "Trigram" = "Trigrama" em terras tupiniquins.
Eu: Mas o que diabos é um trigrama?
Google: vocábulo de três letras.
Eu: que joia

Lendo a documentação com um pouco mais de carinho e, sem entrar em muitos detalhes (até porque eu acho que nem conseguiria), entendi que:

Usando Trigramas, o Postgres consegue gerar um valor numérico para dizer qual é a similaridade entre dois termos, sendo 0 completamente diferente e 1 exatamente igual.

Isso quer dizer que, com uma simples ordenação, eu poderia retornar sugestões de personagens com mais assertividade:

SELECT
  name,
  similarity(name, 'iron man') AS score
FROM characters
WHERE lower(name) LIKE '%iron man%' 
ORDER BY score DESC;
Enter fullscreen mode Exit fullscreen mode

Corri, abri o rails dbconsole, colei a query e...

ERROR:  function similarity(character varying, unknown) does not exist
LINE 3:   similarity(name, 'iron man') AS score
          ^
HINT:  No function matches the given name and argument types. You might need to add explicit type casts.
Enter fullscreen mode Exit fullscreen mode

De volta à documentação, descobri que é necessário habilitar a extesão primeiro 🙈... então criei uma migration rapidinho:

class EnablePgTrgmExtension < ActiveRecord::Migration[6.0]
  def change
    enable_extension 'pg_trgm'
  end
end
# CREATE EXTENSION pg_trgm;
Enter fullscreen mode Exit fullscreen mode

Agora, com a extensão ativada, query executada, temos o seguinte resultado:

name score
Iron Man 1
Iron Man (Ultimate) 0.5
Iron Man (Marvel Heroes) 0.42857143
Iron Man/Tony Stark (MAA) 0.4090909
Iron Man (Marvel War of Heroes) 0.32142857
Iron Man (Iron Man 3 - The Official Game) 0.31034482
Iron Man (LEGO Marvel Super Heroes) 0.28125
M.O.D.O.K. (Iron Man 3 - The Official Game) 0.25714287
War Machine (Iron Man 3 - The Official Game) 0.23076923
Crimson Dynamo (Iron Man 3 - The Official Game) 0.20930232

Repare que temos uma ordenação bem diferente em relação à nossa primeira query:

name
Crimson Dynamo (Iron Man 3 - The Official Game)
Iron Man
Iron Man (Iron Man 3 - The Official Game)
Iron Man (LEGO Marvel Super Heroes)
Iron Man (Marvel Heroes)
Iron Man (Marvel War of Heroes)
Iron Man (Ultimate)
Iron Man/Tony Stark (MAA)
M.O.D.O.K. (Iron Man 3 - The Official Game)
War Machine (Iron Man 3 - The Official Game)

Você deve estar se perguntando por que comecei a falar da ordenação antes de criar o índice né?

Simples: podemos criar um índice para o trigrama 😜

Então, finalmente, bora para a migration:

class AddCharacterNameLowerTagIndex < ActiveRecord::Migration[6.0]
  def up
    execute "CREATE INDEX index_characters_lower_name ON characters USING gin (lower(name) gin_trgm_ops);"
  end

  def down
    execute "DROP INDEX index_characters_lower_name;"
  end
end
Enter fullscreen mode Exit fullscreen mode

O que eu fiz no Rails

Agora que temos uma query bacana, é só uma questão de reescrevê-la utilizando o ActiveRecord, certo?

query = params[:query].downcase
Character.select("name, similarity(name, '#{query}') AS score").where('lower(name) LIKE ?', "%#{query}%").order('score DESC').load.pluck(:name)
Enter fullscreen mode Exit fullscreen mode

🤔 Ficou meio grande né?

Sem contar que, naturalmente, a gente não deve simplesmente concatenar um parâmetro enviado pelo usuário na nossa query...

Infelizmente o select() não tem a mesma sanitização que o where() que te permite fazer parameter binding.

Para driblar esse problema, podemos usar um método chamado sanitize_sql_array() para nos auxiliar:

query = params[:query].downcase
Character.select(ActiveRecord::Base::sanitize_sql_array(["name, similarity(name, ?) AS score", query])).where('lower(name) LIKE ?', "%#{query}%").order('score DESC').load.pluck(:name)
Enter fullscreen mode Exit fullscreen mode

parece que piorou

🤔 Como estamos acessando ActiveRecord::Base, há grandes chances de isso ficar melhor no Model...vamos transformar isso num scopo?

# app/models/character.rb
class Character < ApplicationRecord
  scope :order_by_name_similarity, ->(name) {
    select('*')
      .select(sanitize_sql_array(["similarity(name, ?) AS score", name]))
      .order("score DESC")
  }
end
Enter fullscreen mode Exit fullscreen mode

Repare que optei por deixar o padrão select(*) ao invés de select(:name), dessa forma temos o objeto completo e podemos utilizar outros dados no retorno (se necessário).

Agora a consulta em si fica bem mais simples:

query = params[:query].downcase
Character.where('lower(name) LIKE ?', "%#{query}%").order_by_name_similarity(query)
# SELECT *, similarity(name, 'iron man') AS score FROM "characters" WHERE (lower(name) LIKE '%iron man%') ORDER BY score DESC
Enter fullscreen mode Exit fullscreen mode

Acho interessante também adicionar um limit(10) para não ter uma lista muito grande de opções aparecendo para o usuário.

No final do dia, o método no Controller ficou assim:

def index
  query = params[:query].downcase
  @characters = Character.where('lower(name) LIKE ?', "%#{query}%").order_by_name_similarity(query).limit(10)
  # SELECT *, similarity(name, 'iron man') AS score FROM "characters" WHERE (lower(name) LIKE '%iron man%') ORDER BY score DESC LIMIT 10

  expires_in 10.minutes
end
Enter fullscreen mode Exit fullscreen mode

Repare que também adicionei um expires_in 10.minutes para o navegador pode cachear essa resposta.

É claro que cada caso é um caso e - talvez - 10 minutos não seja a melhor estratégia, mas meu ponto é: é importante ter alguma estratégia de cache.


Não sei você, mas pra mim tudo isso passou longe dos 3SPs estimados inicialmente. Especialmente se considerarmos que ainda falta fazer o front.

Para falar a verdade mesmo, eu dei uma ajustada na história... quando disse que "antes mesmo de criar o índice"... convenhamos... você sabe que eu fiz, desfiz e fiz de novo né? 😜

Só organizei a história de uma forma mais cronológica, mas rolou muita pesquisa, teste e re-teste até chegar nessa nessa solução.

E olha que só falamos de Back...vou deixar o Front para um próximo post.

PS: Não encontrei um lugar no meio do texto, mas queria deixar uma queryzinha de presente que talvez lhe seja útil:

SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'characters';
Enter fullscreen mode Exit fullscreen mode

Ess query lista os índices de uma determinada tabela.

EM TEMPO:

Quando comecei a trabalhar no front, comecei a tentar faze a busca por outras personagens.

Foi nessa que eu descobri que o nome oficial é "Spider-man". Não é "Spider man", nem "spiderman"...

Para a busca por "Spider man", podemos fazer um mini-hack rapidinho:

query = params[:query].downcase.gsub(/ /, '%')
Enter fullscreen mode Exit fullscreen mode

Já para "spiderman" acho que a coisa complica mais e - talvez - pg_trgm não seja o ideal...

É nessa hora que ferramentas como Elastic Search, Algolia e afins podem ser aliadas...mas tudo tem seu custo (seja financeiro ou de implementação) 😜


Tem ideias, críticas ou sugestões? comenta aí :D

Top comments (0)