Full-text search with Elasticsearch in Rails
SQL LIKE is not search. A deep-dive on adding real full-text search to a Rails app with Elasticsearch — analyzers, mappings, relevance, keeping the index in sync — and an honest take on when Postgres is enough instead.
At some point a feature request arrives that sounds simple — “let users search the
products” — and you reach for WHERE name LIKE '%query%'. It works in the demo and
falls apart in reality: it cannot rank results, it ignores typos and word stems, it
cannot search across several fields with different weights, and with a leading
wildcard it cannot use an index, so it gets slow. LIKE is substring matching. What
the request actually wants is search — and that is a different kind of tool.
Elasticsearch is the one most teams reach for. Here is how to add it to a Rails app,
and an honest discussion of when you should not.
What Elasticsearch actually does
Elasticsearch is a distributed search engine built on Lucene. The thing that makes it search rather than matching is the inverted index combined with analysis. When you index a document, Elasticsearch runs its text through an analyzer that lowercases it, splits it into tokens, strips stop-words, and reduces words to their stems (“running” → “run”). It stores a map from each token to the documents containing it. A query is analyzed the same way and looked up in that index, so “running shoes” matches a document containing “run shoe” and comes back ranked by relevance — how well each document matches — not in arbitrary order.
That analysis step is the whole difference from LIKE. It is why search tolerates
plurals and word forms, why it can weight a title match above a description match,
and why it returns the best results first.
Wiring it into Rails
The elasticsearch-rails and elasticsearch-model gems integrate it with
ActiveRecord. You include a module and declare what to index:
class Product < ActiveRecord::Base
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks # auto-update the index on save/destroy
settings index: { number_of_shards: 1 } do
mappings dynamic: false do
indexes :name, analyzer: "english", boost: 3
indexes :description, analyzer: "english"
indexes :brand, type: "keyword" # exact-match, not analyzed
end
end
end
Two ideas in that mapping repay study. The analyzer per field controls how text
is tokenized — english applies English stemming and stop-words to name and
description. The boost makes a match in name count three times as much as
one in description, so a product whose title contains the query ranks above one
that merely mentions it. And brand is a keyword (not analyzed) because you want
exact, faceted matching on it, not stemming. Choosing analyzed vs keyword per field
is most of what good search relevance comes down to.
Searching is then a method call that returns ranked records:
Product.search("running shoes").records.to_a
Relevance is the real work
Getting results back is easy; getting the right results in the right order is the job. A few levers you will actually use:
- Multi-field queries with weights. A
multi_matchquery searches several fields at once and combines their scores, with per-field boosts — the bread and butter of product or article search. - Fuzziness for typos. Elasticsearch can match within an edit distance, so “Manchestr” finds “Manchester” — the trigram idea, built in.
- Filters vs queries. A query contributes to the relevance score; a filter (in stock? price under £50?) just includes or excludes and is cached and fast. Use filters for yes/no constraints and queries for “how well does this match”, and never make a binary constraint affect the score.
Product.search(query: {
bool: {
must: { multi_match: { query: "running shoes", fields: ["name^3", "description"], fuzziness: "AUTO" } },
filter: { term: { in_stock: true } }
}
})
Relevance tuning is iterative — you look at real queries, see what ranks wrong, and adjust boosts, analyzers, and field choices. Budget time for it; the default config is a starting point, not the answer.
Keeping the index in sync: the hard part
Here is the operational reality nobody mentions in the getting-started guide: you now
have two data stores, your database and your search index, and they will drift.
Elasticsearch::Model::Callbacks updates the index on save and destroy, which is
fine for simple cases but quietly breaks in several common ones:
- Bulk operations bypass callbacks.
update_all,delete_all, raw SQL, and imports do not fire ActiveRecord callbacks, so the index silently goes stale. - Indexing in a callback couples your request to Elasticsearch. If ES is slow or down, your save is slow or fails. Indexing belongs in a background job, not inline in the web request.
- Related-record changes. If a product’s searchable text includes its category name, renaming the category must reindex the products — which a callback on the product will not catch.
The patterns that survive production: reindex via a background job (enqueue on change, let the worker push to ES, so a search outage never breaks a save), schedule a periodic full reindex to repair drift, and use Elasticsearch aliases so you can rebuild an index from scratch and swap it in atomically with zero downtime. Treat the index as a derived store that must be rebuildable from the database at any time, because eventually you will need to rebuild it.
When Postgres is enough instead
Before you take on a second data store, ask whether you need to. Running, monitoring, and syncing Elasticsearch is real operational weight, and for a great many apps Postgres can do the job:
pg_trgmgives you trigram fuzzy matching andILIKEacceleration — great for autocomplete and typo-tolerant matching on a column.- Postgres full-text search (
tsvector/tsquery, with a GIN index) does stemming, ranking, and multi-field search entirely inside the database you already run — no second store, no sync problem, transactional consistency for free.
Our rule of thumb: if search is a feature of your app — a product catalog, a
document store, anything where relevance, facets, and scale are central — Elasticsearch
earns its operational cost. If search is a convenience — letting users find a record
by name — start with Postgres FTS and only graduate to Elasticsearch when you hit its
limits. Adding a search engine you did not need is a classic way to double your
operational burden for a feature a tsvector column would have handled.
Verdict
Elasticsearch turns “search the products” from a fragile LIKE clause into real,
ranked, typo-tolerant, multi-field search, and the Rails integration makes indexing
and querying pleasant. The catch is not the querying — it is the syncing: you have
adopted a second, derived data store, and the work is keeping it correct via
background jobs, periodic reindexing, and rebuildable aliases. Take that work
seriously and Elasticsearch is superb at what it does. But check first whether
Postgres full-text search covers your need — for a lot of apps it does, and the best
second data store is the one you did not have to add.