This tutorial walks you through building search for a small media library app (books and authors) using Chewy 8.x, Elasticsearch 8.x and Rails 7.2+. By the end you will have an index, automatic model updates, a reusable search form object, a controller that serves results, and tests.
| Dependency | Version |
|---|---|
| Ruby | 3.2+ |
| Rails | 7.2+ |
| Chewy | 8.x |
| Elasticsearch | 8.x |
Start Elasticsearch with Docker:
docker run --rm -p 9200:9200 -e "discovery.type=single-node" \
-e "xpack.security.enabled=false" elasticsearch:8.15.0Add Chewy to your Gemfile and bundle:
gem 'chewy'Generate the config file:
rails g chewy:installThis creates config/chewy.yml. A minimal setup:
# config/chewy.yml
development:
host: 'localhost:9200'
test:
host: 'localhost:9200'
prefix: 'test'See configuration.md for the full list of options including async strategies, AWS and Elastic Cloud setups.
For this tutorial we have two ActiveRecord models:
# app/models/author.rb
class Author < ApplicationRecord
has_many :books
end
# app/models/book.rb
class Book < ApplicationRecord
belongs_to :author
endWith a schema roughly like:
create_table :authors do |t|
t.string :name
t.timestamps
end
create_table :books do |t|
t.string :title
t.text :description
t.string :genre
t.integer :year
t.references :author
t.timestamps
endCreate app/chewy/books_index.rb:
class BooksIndex < Chewy::Index
settings analysis: {
analyzer: {
sorted: {
tokenizer: 'keyword',
filter: ['lowercase']
}
}
}
index_scope Book.includes(:author)
field :title, type: 'text' do
field :sorted, analyzer: 'sorted' # keyword sub-field for sorting
end
field :description, type: 'text'
field :genre, type: 'keyword'
field :year, type: 'integer'
field :author, type: 'object', value: ->(book) { {name: book.author.name} } do
field :name, type: 'text' do
field :raw, type: 'keyword'
end
end
endKey points:
index_scopetells Chewy which records to index and lets it eager-load associations.- The
sortedsub-field ontitleuses akeywordtokenizer so you canorder('title.sorted')without case-sensitivity issues. - The
authorobject is denormalized into the book document — this is how you search across associations with Elasticsearch.
See indexing.md for the full field DSL, crutches and witchcraft.
Add update_index callbacks so Chewy knows when to reindex:
class Book < ApplicationRecord
belongs_to :author
update_index('books') { self }
end
class Author < ApplicationRecord
has_many :books
# When an author's name changes, reindex all their books
update_index('books') { books }
endThe first argument is the index name (without the Index suffix).
The block returns the object(s) that need reindexing — for the Author callback
that means all of the author's books, since the author name is denormalized
into each book document.
Populate the index for the first time:
rails chewy:reset[books]Or from Ruby code:
BooksIndex.reset!Verify with a quick query in the console:
Chewy.strategy(:urgent)
BooksIndex.query(match_all: {}).countIf you save a model with an update_index callback outside a strategy block,
Chewy raises Chewy::UndefinedUpdateStrategy. This is intentional — it forces
you to pick the right strategy for the context.
In a Rails app the middleware sets :atomic for controller actions automatically.
For other contexts, wrap your code:
Chewy.strategy(:atomic) do
Book.find_each { |b| b.update!(title: b.title.titleize) }
end| Strategy | When to use |
|---|---|
:atomic |
Default for web requests. Batches updates, one bulk call at end of block. |
:urgent |
Rails console / one-off scripts. Updates immediately per save. |
:sidekiq |
Background reindexing via Sidekiq. |
:active_job |
Background reindexing via ActiveJob. |
:bypass |
Tests or migrations where you don't want automatic indexing. |
See configuration.md for the full
list including :lazy_sidekiq and :delayed_sidekiq.
A search form object is a plain Ruby class that composes Chewy scopes into a single query. This pattern keeps search logic out of your controllers and makes it easy to test.
# app/form_objects/book_search.rb
class BookSearch
include ActiveModel::Model
include ActiveModel::Attributes
attribute :q, :string
attribute :genre, :string
attribute :year_from, :integer
attribute :year_to, :integer
attribute :author, :string
attribute :sort, :string
# Returns a Chewy::Search::Request
def search
[keyword_query, genre_filter, year_filter, author_filter, sorting]
.compact
.reduce(BooksIndex.all) { |scope, clause| scope.merge(clause) }
end
private
def keyword_query
return if q.blank?
BooksIndex.query(
multi_match: {
query: q,
fields: ['title^2', 'description', 'author.name'],
type: 'best_fields'
}
)
end
def genre_filter
return if genre.blank?
BooksIndex.filter(term: {genre: genre})
end
def year_filter
range = {}
range[:gte] = year_from if year_from.present?
range[:lte] = year_to if year_to.present?
return if range.empty?
BooksIndex.filter(range: {year: range})
end
def author_filter
return if author.blank?
BooksIndex.filter(match: {'author.name': author})
end
def sorting
case sort
when 'title'
BooksIndex.order('title.sorted': :asc)
when 'year_desc'
BooksIndex.order(year: :desc)
when 'year_asc'
BooksIndex.order(year: :asc)
else
nil # relevance (default _score ordering)
end
end
endEach private method returns a Chewy::Search::Request or nil.
The search method merges them together — Chewy scopes are chainable and
mergeable just like ActiveRecord scopes.
class BooksController < ApplicationController
def index
form = BookSearch.new(search_params)
@books = form.search
.load(scope: -> { includes(:author) })
.page(params[:page]).per(20)
rescue Elastic::Transport::Transport::Errors::BadRequest
# Malformed user query — fall back to empty results
@books = Book.none.page(params[:page])
flash.now[:alert] = 'Invalid search query.'
end
private
def search_params
params.permit(:q, :genre, :year_from, :year_to, :author, :sort)
end
end.load(scope: -> { includes(:author) })fetches the actual ActiveRecord objects (with eager-loaded authors) so you can use them in views..page/.perwork via Kaminari integration.- The
rescuecatches malformed queries (e.g. unbalanced parentheses in aquery_stringquery) so they don't crash the page.
In the index we defined a sorted sub-field on title with a keyword
analyzer. This lets us sort alphabetically without tokenization artifacts:
BooksIndex.order('title.sorted': :asc)You can combine multiple sort clauses:
BooksIndex.order(year: :desc, 'title.sorted': :asc)The default sort is by _score (relevance). To sort explicitly by score:
BooksIndex.order(:_score)See querying.md for more details.
Add to spec/spec_helper.rb (or rails_helper.rb):
require 'chewy/rspec'
RSpec.configure do |config|
config.before(:suite) do
Chewy.strategy(:bypass)
end
endThe update_index matcher verifies that model changes trigger the right
index operations:
RSpec.describe Book, type: :model do
specify do
book = create(:book)
expect { book.update!(title: 'New Title') }
.to update_index(BooksIndex).and_reindex(book)
end
specify do
book = create(:book)
expect { book.destroy! }
.to update_index(BooksIndex).and_delete(book)
end
endTo test that your queries return the right documents, import data into a real Elasticsearch index and query it:
RSpec.describe BookSearch do
before do
BooksIndex.purge!
Chewy.strategy(:urgent) do
create(:book, title: 'Elasticsearch in Action', genre: 'tech', year: 2015)
create(:book, title: 'Ruby Under a Microscope', genre: 'tech', year: 2013)
create(:book, title: 'Moby Dick', genre: 'fiction', year: 1851)
end
BooksIndex.refresh
end
it 'filters by genre' do
results = BookSearch.new(genre: 'tech').search
expect(results.count).to eq(2)
end
it 'searches by keyword' do
results = BookSearch.new(q: 'Elasticsearch').search
expect(results.count).to eq(1)
end
endSee testing.md for the full RSpec/Minitest API including
mock_elasticsearch_response and Minitest helpers.
- Configuration — strategies, async workers, notifications
- Indexing — full field DSL, crutches, witchcraft, geo points
- Import — batching, raw import, journaling
- Querying — DSL details, pagination, scroll API, loading
- Rake Tasks — resetting, syncing, parallel execution
- Testing — matchers, mocking, DatabaseCleaner
- Troubleshooting — common errors and debugging