35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Full-Text Search: pg_search, tsvector en Postgres Zonder Elasticsearch

Rails Full-Text Search: pg_search, tsvector en Postgres Zonder Elasticsearch

Roger Heykoop
Ruby on Rails, DevOps
Rails full-text search met pg_search: tsvector-indexen, gerangschikte resultaten, trigram-overeenkomst en waarom Postgres Elasticsearch vervangt voor de meeste apps.

Het eerste productie-incident waarbij ik ooit voor zoekopdrachten werd gebeld, was ook meteen het meest onnodige. De staging-omgeving van een klant was volledig plat — niet traag, gewoon plat — omdat hun drieknoops-Elasticsearch-cluster was gecrasht en de Rails-app geen fallback had. De dataset bestond uit vijftigduizend productrecords. Ik heb SQLite die vijftigduizend rijen sneller zien afhandelen dan dat cluster deed wanneer het wél werkte. We vervingen het die weekend door Postgres full-text search. Dat was acht jaar geleden. De pg_search-implementatie draait nog steeds zonder één onderhoudsincident.

Elasticsearch heeft legitieme toepassingen. Als je een zoekmachine bouwt, meertalige stemming over tientallen talen nodig hebt, of autocomplete op tientallen miljoenen rijen, dan verdient het zijn operationele complexiteit. Voor de andere negentig procent van Rails-applicaties — de apps met minder dan een miljoen rijen, één Postgres-database en een team kleiner dan tien engineers — doet Postgres het werk, en pg_search maakt het toegankelijk.

Voeg de gem toe:

# Gemfile
gem "pg_search", "~> 2.3"

Geen extra services, geen apart proces dat blijft draaien, geen synchronisatielaag voor indexen. pg_search genereert SQL die Postgres native uitvoert via zijn ingebouwde full-text zoekengine.

Neem het op in elk model dat je wilt doorzoeken:

class Article < ApplicationRecord
  include PgSearch::Model

  pg_search_scope :search,
    against: [:title, :body],
    using: {
      tsearch: { prefix: true }
    }
end

against geeft aan welke kolommen worden doorzocht. tsearch gebruikt Postgres’s eigen tokenizer — die breekt tekst op in lexemen, past stamming toe (“deploying”, “deployed”, “deployment” matchen allemaal op “deploy”) en zoekt via een inverteerde index. Met prefix: true matcht “rails” ook “railscast” en “railsconf” — onmisbaar voor elke search-as-you-type interface.

Zoeken werkt precies als elke andere scope:

Article.search("turbo streams")
# Genereert:
# WHERE to_tsvector('english', articles.title || ' ' || articles.body) 
#       @@ to_tsquery('english', 'turbo & streams')

Combineerbaar met gewone scopes:

Article.published.where(category: "rails").search("background jobs")

Gewogen kolommen en gerangschikte resultaten

Niet alle treffers wegen even zwaar. Een zoekopdracht naar “sidekiq” die de artikeltitel raakt, is relevanter dan een vermelding ergens in de tekst. pg_search koppelt kolommen aan Postgres’s vier gewichtsniveaus — A, B, C, D — waarbij A standaard ongeveer vier keer zwaarder telt dan D.

pg_search_scope :search,
  against: {
    title:    "A",
    subtitle: "B",
    body:     "C"
  },
  using: {
    tsearch: { prefix: true, dictionary: "english" }
  }

Sorteer resultaten op relevantie via de virtuele rankkolom:

Article.search("kamal deploy")
       .with_pg_search_rank
       .order(pg_search_rank: :desc)

with_pg_search_rank voegt een pg_search_rank float-kolom toe aan de SELECT. Voor een zoekresultatenpagina waarbij gebruikers het meest relevante resultaat bovenaan verwachten, is dit doorgaans alle ranking-logica die je nodig hebt.

Full-text search is exact op lexemen. Zoek naar “deplyo” en je krijgt niets. Gebruikers maken typefouten, mobiele toetsenborden corrigeren slecht, en productnamen worden verkeerd gespeld. pg_search ondersteunt trigram-overeenkomst via Postgres’s pg_trgm-extensie, die tekst opsplitst in overlappende drietekenvolgorden en een overeenkomstsscore berekent.

Schakel de extensie in via een migratie:

class AddPgTrgmExtension < ActiveRecord::Migration[8.0]
  def change
    enable_extension "pg_trgm"
  end
end

Combineer trigram met full-text in één scope:

pg_search_scope :search,
  against: [:title, :body],
  using: {
    tsearch: { prefix: true },
    trigram: { word_similarity: true }
  }

Met beide engines gecombineerd matcht “deplyo” op “deploy” via trigram, en “deploying rails 8” via prefix full-text search. pg_search combineert de rankscores van beide. Je krijgt typefouttolerantie zonder extra infrastructuur.

De word_similarity-optie — beschikbaar sinds pg_trgm 9.6 — vergelijkt losse woorden in plaats van de volledige query met de volledige kolom. Dat is belangrijk bij het zoeken in een titel als “Deploying Rails 8 to Production with Kamal 2” op de query “kamal” — woordovereenkomst vindt het; volledige tekenreeksovereenkomst scoort het bijna op nul.

tsvector-kolommen: de index vooraf berekenen voor schaal

De standaard pg_search-configuratie berekent de tsvector bij elke query via to_tsvector(kolom). Voor tabellen kleiner dan honderdduizend rijen is dit snel genoeg. Voor grotere datasets of endpoints onder aanhoudende belasting bereken je de vector vooraf en laat je Postgres een GIN-index gebruiken.

Voeg de kolom en index toe:

class AddSearchVectorToArticles < ActiveRecord::Migration[8.0]
  def change
    add_column :articles, :search_vector, :tsvector
    add_index  :articles, :search_vector, using: :gin,
               name: "index_articles_on_search_vector"
  end
end

Onderhoud de kolom met een Postgres-trigger zodat hij gesynchroniseerd blijft zonder applicatiecode te hoeven aanraken:

class AddSearchVectorTrigger < ActiveRecord::Migration[8.0]
  def up
    execute <<~SQL
      CREATE OR REPLACE FUNCTION articles_search_vector_update()
      RETURNS trigger AS $$
      BEGIN
        NEW.search_vector :=
          setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') ||
          setweight(to_tsvector('english', coalesce(NEW.body,  '')), 'C');
        RETURN NEW;
      END;
      $$ LANGUAGE plpgsql;

      CREATE TRIGGER articles_search_vector_trigger
      BEFORE INSERT OR UPDATE ON articles
      FOR EACH ROW EXECUTE FUNCTION articles_search_vector_update();
    SQL
  end

  def down
    execute <<~SQL
      DROP TRIGGER IF EXISTS articles_search_vector_trigger ON articles;
      DROP FUNCTION IF EXISTS articles_search_vector_update();
    SQL
  end
end

Vertel pg_search de voorberekende kolom te gebruiken:

pg_search_scope :search,
  against: [:title, :body],
  using: {
    tsearch: {
      prefix:          true,
      tsvector_column: "search_vector"
    }
  }

Zoekopdrachten raken nu rechtstreeks de GIN-index. Op een tabel met een half miljoen rijen brengt dit zoektijden terug van 200–400ms naar 5–20ms.

Vul bestaande rijen in batches bij zodat je de tabel niet blokkeert:

class BackfillArticlesSearchVector < ActiveRecord::Migration[8.0]
  disable_ddl_transaction!

  def up
    Article.in_batches(of: 1_000) do |batch|
      batch.touch_all
      sleep 0.05
    end
  end
end

touch_all werkt updated_at bij op elke rij, wat de trigger activeert en search_vector vult. De sleep houdt de belasting beheersbaar op productiedatabases. Voor de algemene principes achter zero-downtime schemawijzigingen is de handleiding voor zero-downtime databasemigraties het lezen waard voordat je dit op een live tabel uitvoert.

Multi-model zoekopdrachten in je Rails-applicatie

Een veelgehoorde eis: zoek tegelijk in artikelen, producten en gebruikers en geef een uniforme resultatenpagina terug. De multisearch-functionaliteit van pg_search regelt dit zonder ongerelateerde tabellen te joinen.

Declareer elk model als doorzoekbaar:

class Article < ApplicationRecord
  include PgSearch::Model
  multisearchable against: [:title, :body]
end

class Product < ApplicationRecord
  include PgSearch::Model
  multisearchable against: [:name, :description]
end

pg_search beheert een pg_search_documents-tabel — één rij per doorzoekbaar record — en houdt die in sync via callbacks bij opslaan en verwijderen. Zoek erin:

results = PgSearch.multisearch("rails deployment")

results.each do |result|
  record = result.searchable  # het originele Article of Product
  puts "#{record.class.name}: #{record.try(:title) || record.try(:name)}"
end

Filteren op modeltype:

PgSearch.multisearch("kamal").where(searchable_type: "Article")

Voeg indexen toe op de documentstabel om trage scans te voorkomen naarmate die groeit:

add_index :pg_search_documents, :searchable_type
add_index :pg_search_documents, [:searchable_type, :searchable_id], unique: true

Herbouw de index nadat je een model voor het eerst aan multisearch hebt toegevoegd:

bin/rails pg_search:multisearch:rebuild[Article]
bin/rails pg_search:multisearch:rebuild[Product]

Wanneer Postgres full-text search niet genoeg is

Na negentien jaar Rails heb ik precies vier keer werkelijk een dedicated zoekservice nodig gehad. Elke keer was ten minste één van de volgende situaties van toepassing:

Meertalige stamming in één index. Nederlandse en Engelse records in dezelfde tabel, met taalspecifieke stopwoorden en stemmers. Postgres tsvector ondersteunt meerdere woordenboeken, maar elk document via het juiste woordenboek routeren op basis van een taalkolom — en vervolgens correct over die mix zoeken — wordt snel foutgevoelig.

Autocomplete op serieuze schaal. Trigram-overeenkomst op een GIN-index werkt prima tot een paar miljoen rijen. Daarboven — denk aan productcatalogi met tientallen miljoenen SKU’s en latentie-eisen van vijftig milliseconden — wil je een speciaal gebouwd hulpmiddel als Typesense of Algolia.

Gefacetteerde zoekopdrachten met aggregaties. Als je live tellingen per categorie nodig hebt, gefilterd op datumbereik en gesorteerd op een aangepaste bedrijfsscore, ben je in feite de aggregatiepijplijn van Elasticsearch in SQL aan het nabouwen. Op een gegeven moment wordt de query ononderhoudbaar.

Zoekanalyses. Weten wat gebruikers hebben gezocht, welke resultaten ze aanklikten en welke query’s niets opleverden, is waardevolle productdata. Elasticsearch heeft dit ingebouwd. Postgres niet.

Als geen van deze situaties van toepassing is — en voor de meeste applicaties is dat zo — breng dan pg_search in productie. Je kunt later altijd migreren naar een dedicated service wanneer je de grenzen echt bereikt. Die complexiteit uitstellen tot ze haar plek verdient is dezelfde discipline als incrementele Rails-upgrades: los de problemen van morgen niet op vandaag.


pg_search geeft je gerangschikte, gewogen, typefouttolerant full-text search op je bestaande Postgres-database, opgezet in ongeveer twintig minuten. Bereken een tsvector-kolom voor en voeg een GIN-index toe wanneer je data groeit voorbij honderdduizend rijen. Voeg trigram-overeenkomst toe wanneer gebruikers beginnen te klagen over typefouten. Grijp naar een dedicated zoekservice wanneer je de bovenstaande gevallen echt tegenkomt — niet eerder.

Zoekfunctionaliteit inbouwen in een Rails-app en onzeker of je Elasticsearch nodig hebt? TTB Software levert al negentien jaar Rails-applicaties. We geven je een eerlijk antwoord over wat je echt nodig hebt — en bouwen het goed.

Veelgestelde vragen

Wat is het verschil tussen pg_search en Elasticsearch voor Rails?

pg_search gebruikt Postgres’s ingebouwde full-text zoekengine — tsvector, tsquery, GIN-indexen — rechtstreeks in je bestaande database. Geen apart service, geen synchronisatie. Elasticsearch is een op zichzelf staande JVM-gebaseerde zoekdienst met een eigen HTTP-API, aparte index en operationele overhead. pg_search is de juiste standaard voor applicaties met minder dan een miljoen doorzoekbare rijen. Elasticsearch verdient zijn complexiteit wanneer je gefacetteerde aggregaties, meertalige stamming of autocomplete op tientallen miljoenen rijen nodig hebt.

Hoe voeg ik een GIN-index toe voor full-text search in een Rails-migratie?

Gebruik add_index :tabelnaam, :search_vector_kolom, using: :gin. Als je de tsvector niet vooraf berekent (geen aparte kolom), kun je de functie-expressie indexeren: add_index :articles, "to_tsvector('english', title)", using: :gin, name: "index_articles_title_fts". GIN-indexen zijn de standaardkeuze voor tsvector-kolommen — snel voor zoekopdrachten, iets langzamer bij bijwerken dan GiST. Voor de meeste leesintensieve zoekwerklast is GIN de juiste keuze.

Hoe gaat pg_search om met meerdere talen in Rails?

Configureer het woordenboek per scope: using: { tsearch: { dictionary: "dutch" } } voor een Nederlandstalige app. Voor meertalige inhoud waarbij één record meerdere talen kan bevatten, bereken je de tsvector-kolom handmatig in een trigger die het woordenboek wisselt op basis van een locale-kolom op het record. Voor een tweetalige site met aparte taalversies is het eenvoudiger om twee aparte zoekscopes te onderhouden die elk vertaalde kolomvarianten aanspreken.

Werkt pg_search samen met Rails-scopes en paginering?

Ja — pg_search-scopes leveren gewone ActiveRecord-relaties op. Je kunt ze vrijelijk combineren: Article.published.search("kamal").with_pg_search_rank.order(pg_search_rank: :desc).page(params[:page]).per(20). Zowel pagy als kaminari werken zonder aanpassingen. Als je select gebruikt om kolommen te beperken, voeg dan pg_search_rank expliciet toe aan je selectlijst — anders voegt with_pg_search_rank een virtuele kolom toe die mogelijk ontbreekt in een aangepaste SELECT.

#rails #pg_search #full-text-search #postgres #tsvector #trigram #elasticsearch-alternative #ruby
R

About the Author

Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation