Skip to content
← All posts
6 min read Dawid Skłodowski

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_match query 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_trgm gives you trigram fuzzy matching and ILIKE acceleration — 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.