Rails Searchkick: Production Full-Text Search with Elasticsearch and OpenSearch
Rails Searchkick brings Elasticsearch and OpenSearch to ActiveRecord with synonyms, boosting, facets, autocomplete, and zero-downtime reindexing for production.
The product catalogue had twenty-three thousand SKUs. The search box on the homepage was a LIKE query. I know, because I inherited the codebase in January and the client’s biggest complaint — the one that appeared in every NPS comment, the one the sales team kept apologising for — was that search was broken. Type “laptop bag” and get zero results. Type “bag” alone and get 4,800 results sorted by database id. The fix everyone assumed was impossible took four days.
Rails Searchkick is a gem by Andrew Kane that wraps Elasticsearch and OpenSearch behind an ActiveRecord-shaped API. After nineteen years of Rails I’ve wired up search in a dozen different ways — Sphinx, Solr, a Postgres tsvector approach I now use for most projects (see the pg_search guide), two custom vector indexes, and three Searchkick integrations. Searchkick earns its place when you need synonyms, language-aware analysis, boosting by arbitrary document fields, faceted navigation, and an index that can be rebuilt hot — all without writing a line of Elasticsearch JSON. This post is a production guide, not a README walkthrough.
When Searchkick Is the Right Tool
Before installing anything, ask yourself whether Postgres gets you there. pg_search handles full-text search on a single database table and is operationally free — no extra service to run, no index sync to maintain. It works well for search on models with fewer than a few million rows where relevance tuning requirements are modest.
Searchkick is worth the operational overhead when at least one of the following is true:
- You need synonym handling: “laptop” should match “notebook”, “mobile” should match “cell phone”. Postgres supports synonym dictionaries but they are annoying to configure and reload without a restart.
-
You need faceted navigation: the kind where clicking “Laptops” filters results and simultaneously shows “In Stock (142) Out of Stock (38)” without a second query. Elasticsearch aggregations do this in a single request. - You need cross-model search: users search across products, blog posts, and documentation from one box. Joining tsvectors across tables in Postgres gets ugly fast.
- You have millions of searchable documents and full-text queries are putting measurable pressure on your Postgres primary.
- You need autocomplete with word-start matching at low latency:
word_startin Searchkick is built for this, and the query path is optimised for it in a way that Postgres trigrams are not.
If none of those apply, stay on Postgres. Adding Elasticsearch or OpenSearch to your stack means one more service to deploy, monitor, scale, and upgrade.
Setting Up Searchkick
Add the gem:
# Gemfile
gem "searchkick"
# For Elasticsearch
gem "elasticsearch", ">= 7"
# Or for OpenSearch
gem "opensearch-ruby"
Tell Searchkick which client to use:
# config/initializers/searchkick.rb
Searchkick.client_type = :elasticsearch # or :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 # seconds; fail open if ES is slow
The search_timeout setting is not in most tutorials but matters in production. If Elasticsearch is slow or unavailable, you want the request to time out and fall back to something (even an empty result set with a “search is temporarily unavailable” message) rather than hold a Puma thread open for thirty seconds.
Add searchkick to your model and define search_data:
class Product < ApplicationRecord
belongs_to :category
belongs_to :brand
searchkick(
word_start: [:name],
text_middle: [:description],
synonyms: [
["laptop", "notebook", "portable computer"],
["tv", "television", "smart tv"],
["fridge", "refrigerator", "freezer"]
],
language: "english",
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 the contract between your model and the index. Return only what search needs. If a field is not in search_data, it cannot be searched or filtered. This is a feature, not a limitation — it keeps the index small and prevents accidental exposure of sensitive columns.
The callbacks: :async option means Searchkick queues a background job to update the index when a record changes instead of doing it inline. This is almost always what you want in production. The sync fallback slows down your writes and turns Elasticsearch unavailability into ActiveRecord errors. Wire up your background job processor and use async.
Build the index for the first time:
bundle exec rake searchkick:reindex CLASS=Product
Rails Searchkick Queries: Relevance from Day One
The most basic search:
results = Product.search("laptop bag")
That single call handles tokenisation, analysis, fuzzy matching, and relevance ranking. The results object behaves like an ActiveRecord relation for most purposes — you can call .to_a, iterate with .each, and access .total_count.
Field Weighting
Not all fields are equal. A match in the product name should outrank a match in the description:
results = Product.search(
"laptop bag",
fields: [
{ name: :word_start }, # prefix matching on name for autocomplete feel
{ name: 10 }, # name matches worth 10x
{ description: 1 },
{ category_name: 3 },
{ brand_name: 5 }
]
)
The word_start option makes "lap" match "laptop". The integer multiplier controls how much a match in that field contributes to the relevance score. Spend an afternoon tuning these numbers against real queries from your search logs — the defaults are a starting point, not a conclusion.
Boosting by Document Fields
Relevance score from text matching is one signal. Business logic is another. A product with ten thousand orders should probably rank above an identical product with ten orders, all else being equal:
results = Product.search(
"laptop bag",
boost_by: {
orders_count: { factor: 2, missing: 1 },
views_count: { factor: 1, missing: 1 }
},
boost_where: { in_stock: { factor: 3 } }
)
boost_by applies a multiplicative factor to the relevance score based on the field value. boost_where applies a flat factor when the field matches a value — here, in-stock products get 3x. The missing parameter handles documents where the field is null.
Boosting is the mechanism that separates a search that returns “technically correct” results from one that feels intelligent. The product manager who cannot explain why a buried result should be boosted is usually right that it should be.
Rails Searchkick Synonyms: Making Search Understand Your Domain
Every domain has vocabulary gaps. A customer searching for “running shoes” on a fitness site should find products tagged as “trainers”. A customer searching “fridge” should find “refrigerator”. Postgres can do this with synonym dictionaries, but the configuration lives in the database server and requires a database restart to reload. Searchkick synonyms live in your application code and are applied at index-build time.
class Product < ApplicationRecord
searchkick synonyms: [
# one-directional: "trainers" expands to include "running shoes"
{ "trainers" => ["running shoes", "sneakers", "athletic footwear"] },
# bidirectional: any of these match any other
["fridge", "refrigerator", "freezer", "cooler"],
["tv", "television", "smart tv", "flatscreen"],
["mobile", "cell phone", "smartphone", "handset"]
]
end
Synonyms are applied at index time, not query time, which has one important implication: changing your synonyms requires a reindex. This is not a problem with Searchkick’s zero-downtime reindexing (covered below), but it does mean you cannot tune synonyms and see the effect in under a minute the way you can tune a where clause.
One pattern I use: store synonyms in a YAML file that is loaded into the model. This lets you change synonyms without touching model code, and you can version-control the synonyms file alongside the indexed content.
# config/search/product_synonyms.yml
- [laptop, notebook, portable computer]
- [tv, television, smart tv, flatscreen]
- [trainers, running shoes, athletic footwear]
class Product < ApplicationRecord
SYNONYMS = YAML.load_file(
Rails.root.join("config/search/product_synonyms.yml")
).freeze
searchkick synonyms: SYNONYMS
end
Facets: Building Filter UIs Without Extra Queries
Faceted navigation — the sidebar on an e-commerce product listing that shows categories, brands, price ranges, and in-stock counts — is one of the things Searchkick does well and Postgres does awkwardly. An Elasticsearch aggregation returns both the filtered results and the facet counts in a single request.
results = Product.search(
"laptop",
where: {
price: { gte: 500, lte: 2000 },
in_stock: true
},
aggs: [:category_name, :brand_name, :price_range],
smart_aggs: true # apply where filters to aggs
)
# In the view
results.aggs["category_name"]["buckets"].each do |bucket|
puts "#{bucket['key']}: #{bucket['doc_count']}"
end
smart_aggs: true tells Searchkick to compute each aggregation with all other filters applied, but not the filter on that aggregation’s own field. This is the behaviour users expect: selecting “Laptops” in the category filter should still show other categories with their updated counts, not hide all other categories.
The price range aggregation needs a histogram definition:
results = Product.search(
"laptop",
aggs: {
price_range: {
ranges: [
{ to: 500 },
{ from: 500, to: 1000 },
{ from: 1000, to: 2000 },
{ from: 2000 }
]
}
}
)
Rails Searchkick Autocomplete
Fast autocomplete that handles partial words is one of the most noticeable UX improvements you can make to a search interface. The trick is a dedicated endpoint that runs a word_start query on the name field only, not the full 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, # do not load ActiveRecord objects
misspellings: { below: 5 }
)
render json: results.map { |r| { id: r.id, name: r.name, price: r.price } }
end
end
load: false is important for autocomplete. It tells Searchkick to return documents from the Elasticsearch response without firing additional SQL queries to load the ActiveRecord objects. For an autocomplete dropdown that only needs name and price, you should have name and price in search_data and skip the SQL round-trip entirely.
The misspellings: { below: 5 } option enables fuzzy matching for queries longer than five characters. Shorter queries get exact matching — you do not want “lap” to fuzzy-match “cup”.
Wire this to a Stimulus controller with debounce:
// 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 Reindexing in Production
This is the feature that makes Searchkick usable in production instead of a liability. When you call Product.reindex, Searchkick:
- Creates a new index with a timestamped name (e.g.
products_20260908123456). - Builds the new index in the background while the current index continues serving queries.
- Atomically updates the
productsalias to point to the new index. - Deletes the old index.
The alias swap is atomic at the Elasticsearch level. From the application’s perspective, there is no moment when the alias points nowhere. The old index serves queries right up to the moment the alias is updated.
# Full reindex — safe to run against production at any time
Product.reindex
# Async reindex (dispatches to background job, returns immediately)
Product.reindex_async
# Update specific records (cheaper than full reindex)
Product.searchkick_index.bulk_update(Product.where(updated_at: 1.hour.ago..))
Add the full reindex to your deployment whenever you change search_data or the model’s Searchkick configuration:
# deploy.yml (Kamal)
hooks:
post-deploy:
- docker exec rails bash -c "bundle exec rake searchkick:reindex CLASS=Product"
Or run it as part of your migration workflow. The key discipline: whenever you change what search_data returns, you must reindex before expecting the new fields to appear in results. The old index continues to work; it just does not have the new data.
For async reindexing triggered by model callbacks to work, you need a job queue. Searchkick ships Searchkick::BulkReindexJob which you configure with your queue name:
# config/initializers/searchkick.rb
Searchkick.queue_name = :search_reindex # dedicate a queue so reindexing does not block business jobs
Production Configuration and Gotchas
Elasticsearch Heap Sizing
The most common production Elasticsearch failure is heap exhaustion. The rule is: half of available RAM, capped at 30 GB. On a 16 GB server, set -Xms8g -Xmx8g. Never set it above 30 GB regardless of how much RAM you have — the JVM garbage collector behaviour changes above that threshold.
# elasticsearch/config/jvm.options
-Xms8g
-Xmx8g
Index Shards
The default is five shards. For most Rails applications with under five million documents per model, one or two shards is correct. More shards means more overhead, slower small queries, and more files to manage. You set shards per model:
class Product < ApplicationRecord
searchkick shards: 2, replicas: 1
end
replicas: 1 means one copy of each shard beyond the primary — you have redundancy without doubling storage on a three-node cluster.
Handling Elasticsearch Unavailability
The application should not break when Elasticsearch is down. Wrap searches in a rescue:
def search_products(query, filters: {})
Product.search(query, where: filters, limit: 20)
rescue Searchkick::Error, Faraday::Error => e
Rails.logger.warn("Searchkick unavailable: #{e.message}")
Product.none
end
Product.none returns an empty ActiveRecord relation that the rest of the call chain can handle without knowing search failed. You can also fall back to a simpler Postgres query if search quality matters less than availability.
Keeping Index and Database in Sync
With callbacks: :async, the index update is queued immediately when a record is saved. If your queue is backed up or a worker crashes, records can exist in the database but not yet in the index. For most applications this is acceptable — a one-minute lag before a newly created product appears in search is fine. For time-sensitive data, use synchronous callbacks on the specific operations that matter:
class Product < ApplicationRecord
searchkick callbacks: :async
after_destroy_commit { self.class.searchkick_index.remove(self) }
end
Deletions are usually worth making synchronous or near-synchronous. A deleted record appearing in search results is more damaging than a new record appearing slightly late.
Monitoring Searchkick Search Quality
The relevance tuning you do at setup will drift. Products change. Inventory changes. Customer vocabulary evolves. The only way to know whether your search is getting better or worse is to measure it.
Log every search with no results and every search where the user clicked nothing:
def search_products(query, filters: {})
results = Product.search(query, where: filters, limit: 20)
if results.total_count.zero?
Rails.logger.warn("[search] zero_results", { query: query, filters: filters })
end
results
end
Feed these logs into your observability stack (pg_stat_statements-style analysis works here too) and review the top no-result queries weekly. Most of them are synonym gaps you can close in five minutes.
Rails Searchkick vs pg_search: The Decision
Use pg_search when:
- You have one model to search and fewer than a few million rows.
- You do not need faceted navigation.
- You do not need synonyms (or you are willing to manage Postgres synonym dictionaries).
- You want zero operational overhead.
Use Searchkick when:
- You need synonyms managed in application code.
- You need faceted navigation (Elasticsearch aggregations are built for this).
- You need autocomplete with word-start matching at consistent low latency.
- You are searching across multiple models from a single box.
- Your Postgres primary is feeling the load of full-text queries.
There is also a middle path: Searchkick backed by Amazon OpenSearch Service or Elastic Cloud, where the operational burden shifts to a managed service. I run this in production for clients who want Searchkick’s API without managing Elasticsearch servers. The Searchkick client is compatible with both; you set ELASTICSEARCH_URL to point at the managed endpoint and the gem does not know the difference.
FAQ
How do I configure Rails Searchkick synonyms?
Pass a synonyms: array to the searchkick class method. Each element is either an array of bidirectional equivalents or a hash for one-directional expansion. Synonyms are applied at index time, so you must call Model.reindex after changing them before the new synonyms take effect in search results.
What is zero-downtime reindexing in Searchkick?
When you call Model.reindex, Searchkick builds the new index under a timestamped name while the current index continues serving queries. Once the new index is ready, it atomically swaps the alias — the named pointer your application queries — to point at the new index. There is no gap where the alias points at an incomplete or missing index. The old index is deleted after the swap.
Should I use Searchkick or pg_search for Rails full-text search?
pg_search is the right choice for most Rails applications: no extra service, zero operational overhead, good relevance with Postgres full-text search. Searchkick earns its overhead when you need synonyms, faceted navigation, cross-model search, or Elasticsearch’s fine-grained relevance controls. If you are not sure, start with pg_search and migrate to Searchkick when you hit its limits.
How do I set up Searchkick facets for e-commerce filtering?
Pass an aggs: array with the field names you want to facet on, and set smart_aggs: true so each aggregation is computed with all other active filters applied. The result object’s .aggs hash contains bucket counts per value for each faceted field. You can combine aggregations with where: clauses to implement multi-select faceting (applying other filters while leaving counts for the current filter dimension unchanged).
Inheriting a Rails app with search that embarrasses the company? TTB Software has rebuilt product search, document search, and catalogue search for clients across the EU. Fixed-scope delivery, nineteen years of Rails experience, zero tolerance for LIKE queries on production tables.
Related Articles
Rails Composite Primary Keys: CPK, Legacy Schemas, and Natural Keys in ActiveRecord
Rails composite primary keys let ActiveRecord model multi-column PKs natively. Learn CPK setup, associations, legacy ...
Rails Action Text: Rich Text Editing, Custom Attachments, PostgreSQL Search, and Production Pitfalls with Trix
Rails Action Text powers rich text editing with Trix. Learn attachments, custom renderers, PostgreSQL search, N+1 fix...
Rails PostgreSQL Exclusion Constraints: Prevent Booking Overlaps with tsrange and btree_gist
Rails PostgreSQL exclusion constraints stop double bookings at the database layer. Use tsrange, btree_gist, and Rails...