Elasticsearch is a powerful search engine that can supercharge your Rails app with lightning-fast full-text search capabilities. Let’s dive into how to integrate it with your Rails project and unlock some seriously cool features.
First things first, we need to add the necessary gems to our Gemfile:
gem 'elasticsearch-model'
gem 'elasticsearch-rails'
Don’t forget to run bundle install
after adding these gems.
Now, let’s say we have a Book model that we want to make searchable. We’ll need to include the Elasticsearch modules in our model:
class Book < ApplicationRecord
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
end
The Elasticsearch::Model::Callbacks
module ensures that our Elasticsearch index is updated whenever we create, update, or delete a Book record.
Next, we’ll want to define what fields should be searchable. We can do this by creating an as_indexed_json
method in our model:
def as_indexed_json(options={})
as_json(
only: [:id, :title, :author, :description],
methods: [:category_name]
)
end
def category_name
category.name
end
This method tells Elasticsearch which fields to index. In this case, we’re indexing the book’s id, title, author, description, and the name of its category.
Now, let’s create a search method in our Book model:
def self.search(query)
__elasticsearch__.search(
{
query: {
multi_match: {
query: query,
fields: ['title^10', 'author^5', 'description']
}
}
}
)
end
This method uses Elasticsearch’s multi_match query to search across multiple fields. The ^
syntax allows us to boost certain fields - in this case, matches in the title field are considered 10 times more important than matches in other fields.
To make our search results more relevant, we can add some additional features to our search method. Let’s enhance it with highlighting and aggregations:
def self.search(query)
__elasticsearch__.search(
{
query: {
multi_match: {
query: query,
fields: ['title^10', 'author^5', 'description']
}
},
highlight: {
pre_tags: ['<em>'],
post_tags: ['</em>'],
fields: {
title: {},
author: {},
description: {}
}
},
aggs: {
categories: {
terms: { field: 'category_name.keyword' }
}
}
}
)
end
The highlight
section will wrap matching terms in <em>
tags, making it easy to show users where their search terms appear. The aggs
(short for aggregations) section will give us a count of results by category.
Now, let’s create a controller action to handle our search:
class BooksController < ApplicationController
def search
if params[:q].nil?
@books = []
else
@books = Book.search(params[:q])
end
end
end
And a corresponding view:
<h1>Search Results</h1>
<% if @books.any? %>
<% @books.each do |book| %>
<div class="book">
<h2><%= book.highlight.title ? book.highlight.title.join.html_safe : book.title %></h2>
<p>By <%= book.highlight.author ? book.highlight.author.join.html_safe : book.author %></p>
<p><%= book.highlight.description ? book.highlight.description.join.html_safe : book.description %></p>
</div>
<% end %>
<% else %>
<p>No results found</p>
<% end %>
<h2>Categories</h2>
<ul>
<% @books.aggregations.categories.buckets.each do |bucket| %>
<li><%= bucket['key'] %> (<%= bucket['doc_count'] %>)</li>
<% end %>
</ul>
This view will display our search results with highlighted matching terms, as well as a list of categories with result counts.
One thing to keep in mind is that Elasticsearch operates on an index, not your database directly. When you first set up Elasticsearch, you’ll need to index your existing data:
Book.import
This will create and populate the Elasticsearch index for your Books.
Now, let’s talk about some advanced features that can really take your search to the next level. One cool thing you can do is implement autocomplete functionality. Here’s how you might set that up:
First, we’ll need to update our mapping to include a completion suggester:
class Book < ApplicationRecord
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
settings do
mappings dynamic: 'false' do
indexes :title, type: 'text'
indexes :author, type: 'text'
indexes :description, type: 'text'
indexes :suggest, type: 'completion'
end
end
def as_indexed_json(options={})
{
title: title,
author: author,
description: description,
suggest: {
input: [title, author],
weight: 10
}
}
end
end
Now let’s add a method to our model to handle autocomplete suggestions:
def self.suggest(query)
__elasticsearch__.client.suggest(
index: index_name,
body: {
suggestions: {
text: query,
completion: {
field: 'suggest'
}
}
}
)
end
We can use this in our controller like so:
def autocomplete
render json: Book.suggest(params[:term])
end
Another advanced feature you might want to implement is faceted search. This allows users to filter results based on various attributes. We can modify our search method to handle this:
def self.search(query, filters = {})
definition = {
query: {
bool: {
must: [
{
multi_match: {
query: query,
fields: ['title^10', 'author^5', 'description']
}
}
]
}
}
}
filters.each do |field, value|
definition[:query][:bool][:must] << {
term: { field => value }
}
end
__elasticsearch__.search(definition)
end
Now we can pass filters to our search method:
Book.search("ruby", { category_name: "Programming" })
This will return books that match “ruby” and are in the “Programming” category.
One thing to keep in mind when working with Elasticsearch is that it’s eventually consistent. This means that there might be a slight delay between when you update a record in your database and when that change is reflected in Elasticsearch. In most cases, this isn’t a problem, but if you need real-time consistency, you might need to force an index refresh after updates:
Book.__elasticsearch__.refresh_index!
Another cool feature of Elasticsearch is its ability to handle typos and misspellings. You can enable fuzzy matching in your search queries like this:
def self.search(query)
__elasticsearch__.search(
{
query: {
multi_match: {
query: query,
fields: ['title^10', 'author^5', 'description'],
fuzziness: 'AUTO'
}
}
}
)
end
The fuzziness: 'AUTO'
parameter tells Elasticsearch to automatically handle small typos.
As your application grows, you might find that you need to reindex your data. Maybe you’ve changed your mapping, or you need to pull in data from another source. Here’s a rake task that can help with that:
namespace :elasticsearch do
desc 'Reindex all Elasticsearch models'
task reindex: :environment do
[Book].each do |model|
puts "Reindexing #{model.name}"
model.__elasticsearch__.create_index! force: true
model.import
end
end
end
You can run this task with rake elasticsearch:reindex
.
One last tip: Elasticsearch can be a powerful tool for analytics as well as search. You can use aggregations to get all sorts of interesting data about your documents. For example, let’s say we wanted to get the average number of pages for books in each category:
results = Book.__elasticsearch__.search(
{
size: 0,
aggs: {
categories: {
terms: { field: 'category_name.keyword' },
aggs: {
avg_pages: { avg: { field: 'pages' } }
}
}
}
}
)
results.aggregations.categories.buckets.each do |bucket|
puts "#{bucket['key']}: #{bucket['avg_pages']['value']}"
end
This is just scratching the surface of what you can do with Elasticsearch in Rails. It’s a powerful tool that can add a lot of value to your application, from improving search functionality to providing deep insights into your data.
Remember, while Elasticsearch is amazing, it’s also a complex system. As your use of it grows, you’ll want to keep an eye on performance and resource usage. Consider using bulk operations for large updates, and be mindful of your indexing strategy as your data grows.
Integrating Elasticsearch with Rails opens up a world of possibilities. Whether you’re building a search engine for a large e-commerce site, adding intelligent autocomplete to a documentation platform, or creating complex analytics for a data-heavy application, Elasticsearch and Rails together provide the tools you need to create fast, scalable, and feature-rich search experiences. Happy coding!