Acelera las búsquedas por texto con pg_search

Por

Photo by Susan Yin on Unsplash
Para búsquedas simples de texto dentro de nuestros productos hemos utilizado desde hace un buen tiempo la gema pg_search. Es súper simple y actúa directo sobre Postgres. Es una buena solución si no necesitas una búsqueda muy elaborada, pero cuando comenzamos a utilizarla en un modelo con más de cien mil registros aparecieron las complicaciones. Una búsqueda podía tardar hasta 8.5 segundos sólo en la consulta a la DB. La solución a todo esto llegó con un tipo de columnas de PG llamadas tsvector y esta es una breve explicación de cómo utilizarlo en un proyecto Rails.


Primer paso: Utilizar pg_search_scope en vez de multisearch

A grandes rasgos, existen dos técnicas para buscar utilizando esta gema, multisearch y pg_search_scope. El primero es el más común pues permite incluir diferentes modelos en un mismo índice para tener una búsqueda global dentro de tu aplicación, mientras que el segundo permite búsquedas dentro de un modelo (incluyendo asociaciones) con técnicas avanzadas.

Hasta aquí estábamos antes como equipo y por lo mismo utilizábamos sólo multisearch, hasta que se hicieron evidentes los dos grandes problemas de esta técnica:

  1. Necesitas mucho más código para configurarla. Sólo hay que ver esta comparación:
# ===== MULTISEARCH ===== #
include PgSearch::Model
multisearchable against: [:attributes, :you, :want, :to, :index]
def search(query)  
  found_ids = PgSearch.multisearch(query).pluck(:searchable_id)
  YourModel.where(id: found_ids)
end
# configure type search options
PgSearch.multisearch_options = {
  using: :tsearch
}
YourModel.search('Whatever you want to search') 
# ===== SCOPES ===== #
include PgSearch::Model
pg_search_scope :search_scope_name, against: [:attributes, :you, :want, :to, :index]
YourModel.search_scope_name('Whatever you want to search')

2. ¡Es extremadamente lenta! 🐌


Segundo paso: Configurar tu scope para soportar tsvector

Cómo se ve en el bloque anterior de código, pg_search_scope está declarando dos parámetros: el nombre del scope y el listado de atributos que se escuchan para la búsqueda (against). Para soportar tsvector es necesario agregar el tercer parámetro using:

pg_search_scope :search_scope_name, 
  against: [
    :attributes, :you, :want, :to, :index
  ],
  using: {
    tsearch: {
      tsvector_column: 'tsv_column_name'
    }
  }


Tercer paso: Crear la migración que agregará la columna de tipo tsvector

Conceptualmente esto está bien explicado en este artículo y en esta parte de la documentación, pero les mostraré la manera Rails friendly de hacerlo.

Esta migración incluirá tres cosas:

  1. Agregar la columna a tu modelo.
  2. Crear el índice. Para este propósito puedes utilizar índices GIN o GIST. Para obtener resultados más precisos en las consultas se recomienda inclinarse por GIN, pero eso lo debes decidir tú según tus requerimientos. Acá muy bien explicadas las diferencias entre GIN y GIST.
  3. Generar un trigger que actualizará en el índice los registros nuevos o actualizados. Acá la mayor magia la realiza la función de PG tsvector_update_trigger que se encarga de ejecutar el trigger. Para saber más de esta función puedes revisar su documentación acá.
class AddTsvectorColumnToYourModel < ActiveRecord::Migration[5.2]
 def up
    # 1. Add column to your model
    add_column :your_model, :tsv_column_name, :tsvector
    # 2. Create index
    add_index(:webpros, :tsv_webpros, using: 'gin')
    ​# 3. Add trigger
 ​execute <<-SQ

     ​DROP TRIGGER IF EXISTS your_model_tsv_update ON your_model

     ​CREATE TRIGGER your_model_tsv_update BEFORE INSERT OR UPDAT

     ​ON your_model FOR EACH ROW EXECUTE PROCEDUR

     ​tsvector_update_trigger(tsv_column_name, 'pg_catalog.simple', attributes, you, want, to, index

   ​SQ

 ​en
  def down
    remove_column :your_model, :tsv_column_name
    execute <<-SQL
      DROP TRIGGER IF EXISTS your_model_tsv_update on your_model;
    SQL
  end
end

 

  Existe una manera de reemplazar estos bloques de SQL por algo más ActiveRecord style utilizando la gema hair_trigger, pero puede ser un poco exagerado agregar una gema sólo para eso.

Cuarto paso: Correr migraciones e indexar todos los registros creados antes de esta implementación

Luego de correr las migraciones sólo debes actualizar todos los registros existentes del modelo para que queden agregados al índice. Para esto basta con algo como:

YourModel.find_each(&:touch)
# Or if you don't want to change the updated_at attribute
ActiveRecord::Base.connection.execute("UPDATE your_model SET id = id")

🎉  Luego de eso ya estarás listo para realizar tus búsquedas. Así de fácil:

YourModel.search_scope_name('Whatever you want to search')


Conclusión

No fue necesario movernos a una implementación más complicada o cambiar a otras tecnologías como Elastic Search que nos hubiesen retrasado innecesariamente. Las consultas para nuestro modelo pasaron de tardar cerca de 8.5seg a tan solo 23ms 😄


**************

Probablemente a medida que sigamos creciendo necesitaremos movernos a implementaciones más robustas como Elastic Search, pero algo que he aprendido en el desarrollo de software es evitar The premature optimization, de hecho hay papers por ahí como este que profundizan en el tema.


Lo más reciente en Blog