Rails Searchkick: Productie Full-Text Zoeken met Elasticsearch en OpenSearch
Rails Searchkick integreert Elasticsearch en OpenSearch met ActiveRecord via synoniemen, boosting, facetten, autocomplete en zero-downtime herindexering.
De productcatalogus had drieëntwintigduizend artikelen. Het zoekveld op de homepage was een LIKE-query. Dat wist ik omdat ik de codebase in januari overnam en de grootste klacht van de klant — de klacht die in elk NPS-commentaar stond, waarvoor het salesteam zich steeds verontschuldigde — was dat zoeken kapot was. Typ “laptoptas” en krijg nul resultaten. Typ “tas” alleen en krijg 4.800 resultaten gesorteerd op database-id. De oplossing die iedereen onmogelijk leek duurde vier dagen.
Rails Searchkick is een gem van Andrew Kane die Elasticsearch en OpenSearch achter een ActiveRecord-achtige API plaatst. Na negentien jaar Rails heb ik zoeken op tientallen manieren ingebouwd — Sphinx, Solr, een Postgres tsvector-aanpak die ik voor de meeste projecten gebruik (zie de pg_search-handleiding), twee aangepaste vectorindexen en drie Searchkick-integraties. Searchkick verdient zijn plek wanneer je synoniemen nodig hebt, taalanalyse, boosting op basis van willekeurige documentvelden, gefacetteerde navigatie en een index die live herbouwd kan worden — zonder één regel Elasticsearch-JSON te schrijven. Dit bericht is een productiegids, geen README-walkthrough.
Wanneer Searchkick het Juiste Gereedschap Is
Vraag jezelf eerst af of Postgres je er al brengt. pg_search verwerkt full-text zoeken op één databasetabel en is operationeel gratis — geen extra service om te draaien, geen indexsynchronisatie te onderhouden. Het werkt goed voor zoeken op modellen met minder dan een paar miljoen rijen en bescheiden vereisten voor relevantiebeheer.
Searchkick is de operationele overhead waard wanneer ten minste één van de volgende punten van toepassing is:
- Je hebt synoniemenverwerking nodig: “laptop” moet “notebook” matchen, “mobiel” moet “gsm” matchen. Postgres ondersteunt synoniemwoordenboeken maar ze zijn omslachtig te configureren en te herladen zonder herstart.
-
Je hebt gefacetteerde navigatie nodig: het soort waarbij het klikken op “Laptops” resultaten filtert en tegelijkertijd “Op voorraad (142) Niet op voorraad (38)” toont zonder een tweede query. Elasticsearch-aggregaties doen dit in één verzoek. - Je hebt model-overschrijdend zoeken nodig: gebruikers zoeken vanuit één zoekvenster over producten, blogposts en documentatie. TSVECTORS samenvoegen over tabellen in Postgres wordt snel onoverzichtelijk.
- Je hebt miljoenen doorzoekbare documenten en full-text queries leggen meetbare druk op je Postgres-primaire database.
- Je hebt autocomplete nodig met woord-begin-matching bij lage latency:
word_startin Searchkick is hiervoor gebouwd, en het querypad is geoptimaliseerd op een manier die Postgres-trigrammen niet zijn.
Als geen van deze punten van toepassing is, blijf dan bij Postgres. Elasticsearch of OpenSearch aan je stack toevoegen betekent nog een service om te deployen, monitoren, schalen en upgraden.
Searchkick Instellen
Voeg de gem toe:
# Gemfile
gem "searchkick"
# Voor Elasticsearch
gem "elasticsearch", ">= 7"
# Of voor OpenSearch
gem "opensearch-ruby"
Vertel Searchkick welke client te gebruiken:
# config/initializers/searchkick.rb
Searchkick.client_type = :elasticsearch # of :opensearch
Searchkick.aws_credentials = {
region: ENV.fetch("AWS_REGION", "eu-west-1"),
access_key_id: ENV["AWS_ACCESS_KEY_ID"],
secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"]
} if Rails.env.production?
Searchkick.search_timeout = 3 # seconden; fail open als ES traag is
De search_timeout-instelling staat niet in de meeste tutorials maar is belangrijk in productie. Als Elasticsearch traag of niet beschikbaar is, wil je dat het verzoek time-out gaat en terugvalt op iets (al is het een lege resultatenset met een “zoeken is tijdelijk niet beschikbaar”-melding) in plaats van dat een Puma-thread dertig seconden bezet blijft.
Voeg searchkick toe aan je model en definieer search_data:
class Product < ApplicationRecord
belongs_to :category
belongs_to :brand
searchkick(
word_start: [:name],
text_middle: [:description],
synonyms: [
["laptop", "notebook", "draagbare computer"],
["tv", "televisie", "smart tv"],
["koelkast", "koelkastcombinatie", "vriezer"]
],
language: "dutch",
callbacks: :async
)
def search_data
{
name: name,
description: description,
category_name: category.name,
brand_name: brand.name,
tags: tag_list,
price: price.to_f,
in_stock: in_stock?,
views_count: views_count,
orders_count: orders_count,
published_at: published_at
}
end
end
search_data is het contract tussen je model en de index. Geef alleen terug wat zoeken nodig heeft. Als een veld niet in search_data staat, kan het niet worden doorzocht of gefilterd. Dit is een functie, geen beperking — het houdt de index klein en voorkomt onbedoelde blootstelling van gevoelige kolommen.
De optie callbacks: :async betekent dat Searchkick een achtergrondtaak in de wachtrij plaatst om de index bij te werken wanneer een record wijzigt, in plaats van het inline te doen. Dit is bijna altijd wat je in productie wilt. De synchrone terugval vertraagt je schrijfbewerkingen en maakt Elasticsearch-uitvalbaarheid tot ActiveRecord-fouten. Stel je achtergrondtaakverwerker in en gebruik async.
Bouw de index voor de eerste keer:
bundle exec rake searchkick:reindex CLASS=Product
Rails Searchkick Zoekopdrachten: Relevantie vanaf Dag Één
De meest eenvoudige zoekopdracht:
results = Product.search("laptoptas")
Die ene aanroep regelt tokenisatie, analyse, fuzzy matching en relevantieclassificatie. Het results-object gedraagt zich als een ActiveRecord-relatie voor de meeste doeleinden — je kunt .to_a aanroepen, itereren met .each en .total_count opvragen.
Veldgewichten
Niet alle velden zijn gelijk. Een match in de productnaam moet hoger scoren dan een match in de beschrijving:
results = Product.search(
"laptoptas",
fields: [
{ name: :word_start }, # voorvoegselmatching op naam voor autocomplete-gevoel
{ name: 10 }, # naammatches zijn 10x waard
{ description: 1 },
{ category_name: 3 },
{ brand_name: 5 }
]
)
De optie word_start zorgt ervoor dat "lap" matcht met "laptop". De gehele vermenigvuldiger bepaalt hoeveel een match in dat veld bijdraagt aan de relevantiepunten. Besteed een middag aan het afstemmen van deze getallen op echte zoekopdrachten uit je zoeklogboeken — de standaardwaarden zijn een startpunt, geen eindpunt.
Boosting op Documentvelden
Relevantiepunten uit tekstmatching zijn één signaal. Bedrijfslogica is een ander. Een product met tienduizend bestellingen moet waarschijnlijk hoger scoren dan een identiek product met tien bestellingen, als alles gelijk is:
results = Product.search(
"laptoptas",
boost_by: {
orders_count: { factor: 2, missing: 1 },
views_count: { factor: 1, missing: 1 }
},
boost_where: { in_stock: { factor: 3 } }
)
boost_by past een vermenigvuldigingsfactor toe op de relevantiepunten op basis van de veldwaarde. boost_where past een vlakke factor toe wanneer het veld overeenkomt met een waarde — hier krijgen producten op voorraad 3x. De parameter missing verwerkt documenten waarbij het veld null is.
Boosting is het mechanisme dat het verschil maakt tussen zoekopdrachten die “technisch correcte” resultaten retourneren en zoekopdrachten die intelligent aanvoelen. De productmanager die niet kan uitleggen waarom een begraven resultaat omhoog zou moeten worden geboosted, heeft het er meestal bij het rechte eind.
Rails Searchkick Synoniemen: Zoekopdrachten die je Domein Begrijpen
Elk domein heeft woordenschatlacunes. Een klant die op een fitnesssite zoekt naar “loopschoenen” moet producten vinden die als “trainers” zijn getagd. Een klant die zoekt naar “koelkast” moet “koelkastcombinatie” vinden. Postgres kan dit met synoniemwoordenboeken, maar de configuratie staat op de databaseserver en vereist een herstart om opnieuw te laden. Searchkick-synoniemen staan in je applicatiecode en worden toegepast tijdens het indexeren.
class Product < ApplicationRecord
searchkick synonyms: [
# Eénrichtingsuitbreiding
{ "trainers" => ["loopschoenen", "sneakers", "sportschoenen"] },
# Bidirectioneel: elk van deze matcht elk ander
["koelkast", "koelkastcombinatie", "vriezer", "koel-vriescombinatie"],
["tv", "televisie", "smart tv", "flatscreen"],
["mobiel", "gsm", "smartphone", "handset"]
]
end
Synoniemen worden toegepast tijdens het indexeren, niet tijdens het zoeken, wat één belangrijke implicatie heeft: het wijzigen van je synoniemen vereist een herindexering. Dit is geen probleem met Searchkick’s zero-downtime herindexering (hieronder behandeld), maar het betekent wel dat je synoniemen niet kunt afstemmen en het effect binnen een minuut kunt zien zoals je dat kunt met een where-clausule.
Een patroon dat ik gebruik: bewaar synoniemen in een YAML-bestand dat in het model wordt geladen. Hiermee kun je synoniemen wijzigen zonder modelcode aan te raken, en je kunt het synoniemenbestand naast de geïndexeerde inhoud versiebeheren.
# config/search/product_synonyms.yml
- [laptop, notebook, draagbare computer]
- [tv, televisie, smart tv, flatscreen]
- [trainers, loopschoenen, sportschoenen]
class Product < ApplicationRecord
SYNONYMS = YAML.load_file(
Rails.root.join("config/search/product_synonyms.yml")
).freeze
searchkick synonyms: SYNONYMS
end
Facetten: Filter-UI’s Bouwen Zonder Extra Queries
Gefacetteerde navigatie — de zijbalk op een e-commerce productlijst die categorieën, merken, prijsklassen en voorraadtellingen toont — is een van de dingen die Searchkick goed doet en Postgres onhandig aanpakt. Een Elasticsearch-aggregatie retourneert zowel de gefilterde resultaten als de facettellingen in één verzoek.
results = Product.search(
"laptop",
where: {
price: { gte: 500, lte: 2000 },
in_stock: true
},
aggs: [:category_name, :brand_name, :price_range],
smart_aggs: true # pas where-filters toe op aggs
)
# In de view
results.aggs["category_name"]["buckets"].each do |bucket|
puts "#{bucket['key']}: #{bucket['doc_count']}"
end
smart_aggs: true vertelt Searchkick om elke aggregatie te berekenen met alle andere actieve filters toegepast, maar niet het filter op het eigen veld van die aggregatie. Dit is het gedrag dat gebruikers verwachten: het selecteren van “Laptops” in het categoriefilter moet nog steeds andere categorieën tonen met hun bijgewerkte tellingen, niet alle andere categorieën verbergen.
De prijsklasse-aggregatie heeft een histogramdefintie nodig:
results = Product.search(
"laptop",
aggs: {
price_range: {
ranges: [
{ to: 500 },
{ from: 500, to: 1000 },
{ from: 1000, to: 2000 },
{ from: 2000 }
]
}
}
)
Rails Searchkick Autocomplete
Snelle autocomplete die gedeeltelijke woorden verwerkt is een van de meest merkbare UX-verbeteringen die je aan een zoekinterface kunt maken. De truc is een speciaal eindpunt dat een word_start-query uitvoert op alleen het naamveld, niet op het volledige document:
# app/controllers/searches_controller.rb
class SearchesController < ApplicationController
def autocomplete
results = Product.search(
params[:q],
fields: [{ name: :word_start }],
match: :word_start,
limit: 8,
load: false, # laad geen ActiveRecord-objecten
misspellings: { below: 5 }
)
render json: results.map { |r| { id: r.id, name: r.name, price: r.price } }
end
end
load: false is belangrijk voor autocomplete. Het vertelt Searchkick om documenten terug te geven vanuit het Elasticsearch-antwoord zonder extra SQL-queries te sturen om de ActiveRecord-objecten te laden. Voor een autocompletedropdown die alleen naam en prijs nodig heeft, moet je name en price in search_data hebben en de SQL-ronde trip overslaan.
De optie misspellings: { below: 5 } schakelt fuzzy matching in voor zoekopdrachten langer dan vijf tekens. Kortere zoekopdrachten krijgen exacte matching — je wilt niet dat “lap” fuzzy-matcht met “kop”.
Verbind dit met een Stimulus-controller met debouncing:
// app/javascript/controllers/search_autocomplete_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["input", "results"]
static values = { url: String, delay: { type: Number, default: 200 } }
connect() { this.debouncedSearch = this.debounce(this.search.bind(this), this.delayValue) }
inputChanged() { this.debouncedSearch() }
async search() {
const q = this.inputTarget.value.trim()
if (q.length < 2) { this.resultsTarget.innerHTML = ""; return }
const response = await fetch(`${this.urlValue}?q=${encodeURIComponent(q)}`)
const data = await response.json()
this.resultsTarget.innerHTML = data.map(item =>
`<a href="/products/${item.id}" class="block px-4 py-2 hover:bg-gray-50">${item.name} — €${item.price}</a>`
).join("")
}
debounce(fn, ms) {
let timer
return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms) }
}
}
Zero-Downtime Searchkick Herindexering in Productie
Dit is de functie die Searchkick bruikbaar maakt in productie in plaats van een aansprakelijkheid. Wanneer je Product.reindex aanroept, doet Searchkick het volgende:
- Maakt een nieuwe index met een tijdgestempelde naam (bijv.
products_20260908123456). - Bouwt de nieuwe index op de achtergrond terwijl de huidige index query’s blijft bedienen.
- Werkt de
products-alias atomair bij zodat die wijst naar de nieuwe index. - Verwijdert de oude index.
De aliaswisseling is atomair op het Elasticsearch-niveau. Vanuit het perspectief van de applicatie is er geen moment waarop de alias nergens naar wijst. De oude index bedient query’s tot het moment dat de alias wordt bijgewerkt.
# Volledige herindexering — veilig om op elk moment tegen productie te draaien
Product.reindex
# Async herindexering (stuurt naar achtergrondtaak, keert onmiddellijk terug)
Product.reindex_async
# Specifieke records bijwerken (goedkoper dan volledige herindexering)
Product.searchkick_index.bulk_update(Product.where(updated_at: 1.hour.ago..))
Voeg de volledige herindexering toe aan je deployment wanneer je search_data of de Searchkick-configuratie van het model wijzigt:
# deploy.yml (Kamal)
hooks:
post-deploy:
- docker exec rails bash -c "bundle exec rake searchkick:reindex CLASS=Product"
Of voer het uit als onderdeel van je migratiewerkstroom. De kerngedisciplineer: wanneer je verandert wat search_data retourneert, moet je herindexeren voordat je verwacht dat de nieuwe velden in resultaten verschijnen. De oude index blijft werken; hij heeft alleen de nieuwe gegevens niet.
Voor async herindexering die wordt geactiveerd door modelcallbacks om te werken, heb je een taakreij nodig. Searchkick levert Searchkick::BulkReindexJob mee die je configureert met je wachtrijnaam:
# config/initializers/searchkick.rb
Searchkick.queue_name = :search_reindex # wijd een wachtrij om herindexering bedrijfstaken niet te blokkeren
Productieconfiguratie en Valkuilen
Elasticsearch Heapgrootte
De meest voorkomende Elasticsearch-productiefout is heap-uitputting. De regel is: de helft van het beschikbare RAM, gemaximeerd op 30 GB. Op een server van 16 GB stel je -Xms8g -Xmx8g in. Stel het nooit in boven 30 GB, ongeacht hoeveel RAM je hebt — het gedrag van de JVM-garbage collector verandert boven die drempelwaarde.
# elasticsearch/config/jvm.options
-Xms8g
-Xmx8g
Index-shards
De standaard is vijf shards. Voor de meeste Rails-applicaties met minder dan vijf miljoen documenten per model zijn één of twee shards correct. Meer shards betekent meer overhead, tragere kleine query’s en meer bestanden om te beheren. Je stelt shards per model in:
class Product < ApplicationRecord
searchkick shards: 2, replicas: 1
end
replicas: 1 betekent één kopie van elke shard naast de primaire — je hebt redundantie zonder opslag te verdubbelen op een cluster van drie nodes.
Omgaan met Elasticsearch-onbeschikbaarheid
De applicatie mag niet kapotgaan wanneer Elasticsearch niet beschikbaar is. Wikkel zoekopdrachten in een rescue:
def search_products(query, filters: {})
Product.search(query, where: filters, limit: 20)
rescue Searchkick::Error, Faraday::Error => e
Rails.logger.warn("Searchkick niet beschikbaar: #{e.message}")
Product.none
end
Product.none retourneert een lege ActiveRecord-relatie die de rest van de aanroepketen kan verwerken zonder te weten dat zoeken mislukt is. Je kunt ook terugvallen op een eenvoudigere Postgres-query als zoekresultaatkwaliteit minder belangrijk is dan beschikbaarheid.
Index en Database Gesynchroniseerd Houden
Met callbacks: :async wordt de indexupdate onmiddellijk in de wachtrij geplaatst wanneer een record wordt opgeslagen. Als je wachtrij verstopt is of een worker crasht, kunnen records in de database bestaan maar nog niet in de index. Voor de meeste applicaties is dit acceptabel — een vertraging van één minuut voordat een nieuw aangemaakt product in zoekresultaten verschijnt is prima. Voor tijdgevoelige gegevens gebruik je synchrone callbacks voor de specifieke bewerkingen die er toe doen:
class Product < ApplicationRecord
searchkick callbacks: :async
after_destroy_commit { self.class.searchkick_index.remove(self) }
end
Verwijderingen zijn meestal de moeite waard om synchroon of bijna-synchroon te maken. Een verwijderd record dat in zoekresultaten verschijnt is schadelijker dan een nieuw record dat iets later verschijnt.
Searchkick Zoekkwaliteit Monitoren
De relevantieafstelling die je bij de installatie doet, zal driften. Producten veranderen. Voorraden veranderen. Klantvocabulaire evolueert. De enige manier om te weten of je zoekopdracht beter of slechter wordt, is het meten ervan.
Log elke zoekopdracht zonder resultaten en elke zoekopdracht waarbij de gebruiker niets klikte:
def search_products(query, filters: {})
results = Product.search(query, where: filters, limit: 20)
if results.total_count.zero?
Rails.logger.warn("[zoeken] nul_resultaten", { query: query, filters: filters })
end
results
end
Stuur deze logboeken naar je observabiliteitsstack en bekijk de meest voorkomende nulresultatenzoekopdrachten wekelijks. De meeste zijn synoniemenhiaten die je in vijf minuten kunt sluiten.
Rails Searchkick vs pg_search: De Beslissing
Gebruik pg_search wanneer:
- Je één model hebt om te doorzoeken en minder dan een paar miljoen rijen.
- Je geen gefacetteerde navigatie nodig hebt.
- Je geen synoniemen nodig hebt (of bereid bent Postgres-synoniemwoordenboeken te beheren).
- Je nul operationele overhead wilt.
Gebruik Searchkick wanneer:
- Je synoniemen nodig hebt beheerd in applicatiecode.
- Je gefacetteerde navigatie nodig hebt (Elasticsearch-aggregaties zijn hiervoor gebouwd).
- Je autocomplete nodig hebt met woord-begin-matching bij consistente lage latency.
- Je over meerdere modellen zoekt vanuit één zoekvak.
- Je Postgres-primaire de belasting van full-text queries voelt.
Er is ook een middenweg: Searchkick ondersteund door Amazon OpenSearch Service of Elastic Cloud, waar de operationele last verschuift naar een beheerde service. Ik draai dit in productie voor klanten die Searchkick’s API willen zonder Elasticsearch-servers te beheren. De Searchkick-client is compatibel met beide; je stelt ELASTICSEARCH_URL in om naar het beheerde eindpunt te wijzen en de gem merkt het verschil niet.
Veelgestelde Vragen
Hoe configureer ik Rails Searchkick-synoniemen?
Geef een synonyms:-array door aan de searchkick-klassenmethode. Elk element is ofwel een array van bidirectionele equivalenten of een hash voor eénrichtingsuitbreiding. Synoniemen worden toegepast tijdens het indexeren, dus je moet Model.reindex aanroepen nadat je ze hebt gewijzigd voordat de nieuwe synoniemen effect hebben in zoekresultaten.
Wat is zero-downtime herindexering in Searchkick?
Wanneer je Model.reindex aanroept, bouwt Searchkick de nieuwe index op onder een tijdgestempelde naam terwijl de huidige index query’s blijft bedienen. Zodra de nieuwe index klaar is, wisselt het atomair de alias — de benoemde pointer die je applicatie bevraagt — om naar de nieuwe index te wijzen. Er is geen moment waarop de alias naar een onvolledige of ontbrekende index wijst. De oude index wordt verwijderd na de wisseling.
Moet ik Searchkick of pg_search gebruiken voor Rails full-text zoeken?
pg_search is de juiste keuze voor de meeste Rails-applicaties: geen extra service, nul operationele overhead, goede relevantie met Postgres full-text zoeken. Searchkick verdient zijn overhead wanneer je synoniemen, gefacetteerde navigatie, model-overschrijdend zoeken of Elasticsearch’s fijnmazige relevantiecontroles nodig hebt. Als je het niet weet, begin dan met pg_search en migreer naar Searchkick wanneer je de limieten bereikt.
Hoe stel ik Searchkick-facetten in voor e-commerce filtering?
Geef een aggs:-array door met de veldnamen waarop je wilt facetteren, en stel smart_aggs: true in zodat elke aggregatie wordt berekend met alle andere actieve filters toegepast. De .aggs-hash van het resultaatobject bevat buckettellingen per waarde voor elk gefacetteerd veld. Je kunt aggregaties combineren met where:-clausules om meervoudige selectiefacettering te implementeren.
Heb je een Rails-applicatie met zoekopdrachten die het bedrijf in verlegenheid brengen? TTB Software heeft productzoekopdrachten, documentsearch en cataloguszoekopdrachten herbouwd voor klanten door heel Europa. Fixed-scope levering, negentien jaar Rails-ervaring, nul tolerantie voor LIKE-queries op productietabellen.
Related Articles
Rails Samengestelde Primaire Sleutels: CPK, Legacy Schema's en Natuurlijke Sleutels in ActiveRecord
Rails samengestelde primaire sleutels laten ActiveRecord werken met meerkolomse PKs. Leer CPK-setup, associaties, leg...
Rails Action Text: Rijke Teksteditor, Aangepaste Bijlagen, PostgreSQL-zoeken en Valkuilen in Productie met Trix
Rails Action Text maakt rijke tekstediting mogelijk met Trix. Leer bijlagen, renderers, PostgreSQL-zoeken, N+1-oploss...
Rails PostgreSQL Exclusion Constraints: Voorkom Dubbele Boekingen met tsrange en btree_gist
Rails PostgreSQL exclusion constraints stoppen dubbele boekingen op databaselaag. Gebruik tsrange, btree_gist en Rail...