How to Implement Elasticsearch in a Rails Web App. Part 2
Tags: rails, ruby, tutorial, showdev
Published: 2019-07-02 12:52:55

---
title: How to Implement Elasticsearch in a Rails Web App. Part 2
published: true
description: When developing a web app, pay special attention to data analyzing and searching algorithms. For this purpose, Elasticsearch may become your best solution. Check a detailed tutorial on how to implement this powerful full-text search engine in a Rails Web app.

tags: rails, ruby, tutorial, showdev

cover_image: https://thepracticaldev.s3.amazonaws.com/i/mzgq5w3er0d5pggrik7d.jpg
---
*This article was originally published on [Codica blog](https://www.codica.com/blog/developing-rails-web-app-with-elasticsearch/).*

Today, we are going to provide you with the second part of the article [How to Implement Elasticsearch When Developing a Rails Web App](https://www.codica.com/blog/developing-rails-web-app-with-elasticsearch/). Here we will discuss the process of Elasticsearch implementation, adding functionality, and how to make the research available with API.

Let’s get started.
## Step #3: Using Elasticsearch with Rails
To implement Elasticsearch to the Rails app, add the following gems to **Gemfile**:

```ruby
gem 'elasticsearch-model'
gem 'elasticsearch-rails'
```

To install these gems run <code>bundle install</code>.

Now it’s time to add actual functionality to the **location** model. Use the so-called <code>concerns</code>.

We create a new <code>app/models/concerns/searchable.rb</code> file.

Afterwards, we add the following code snippet:

```ruby
module Searchable
  extend ActiveSupport::Concern

  included do
    include Elasticsearch::Model
    include Elasticsearch::Model::Callbacks
  end
end
```

Include the created module to the location model:

```ruby
class Location < ApplicationRecord
  include Searchable
end
```

The steps we reproduce are as follows:
* With <code>Elasticsearch::Model</code> module, add Elasticsearch integration to the model.
* With <code>Elasticsearch::Model::Callbacks</code>, add callbacks.

Now, let’s index our model.

We open the Rails <code>rails c</code> console and launch <code>Location.import force: true</code>.

With force: true option, you can build an index if it doesn't exist. To be sure that the index has been created, open Kibana dev tools at [http://localhost:5601/](http://localhost:5601/) and insert <code>GET _cat/indices?v</code>.

So, we have built the index with the name **locations**:
![](https://thepracticaldev.s3.amazonaws.com/i/rtuu3yt5cunc3kvm6pcv.png)


The index was created automatically. It means that the default configuration was applied to all fields.

Now we are going to develop a test query. Find more information about Elasticsearch Query DSL [here](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html).

Open Kibana development tools and go to [http://localhost:5601](http://localhost:5601).

Insert the snippet:
```ruby
GET locations/_search
{
  "query": {
    "match_all": {}
  
}
```
![](https://thepracticaldev.s3.amazonaws.com/i/kq79f1pcxapjin8knsp7.png)


Don’t forget about the **hits** attribute of the response’s JSON and especially its **_source** attribute. Take into consideration that all fields in the **Location model** were serialized and indexed.

To make a test query through the Rails app open <code>rails c</code> console and insert the following:

```ruby
results = Location.search(‘san’)
results.map(&:name) # => ["san francisco", "american samoa"]
```
## Step #4: Creating a custom index with autocomplete functionality

At this step, we have to delete the previous index and then create a new one. To do this just open <code>rails c Location.__elasticsearch__.delete_index!</code>.

Now it’s time to edit the <code>app/models/concerns/searchable.rb</code> file. It would look like this:

```ruby
module Searchable
 extend ActiveSupport::Concern

 included do
   include Elasticsearch::Model
   include Elasticsearch::Model::Callbacks

   def as_indexed_json(_options = {})
     as_json(only: %i[name level])
   end

   settings settings_attributes do
     mappings dynamic: false do
       indexes :name, type: :text, analyzer: :autocomplete
       indexes :level, type: :keyword
     end
   end

   def self.search(query, filters)
     set_filters = lambda do |context_type, filter|
       @search_definition[:query][:bool][context_type] |= [filter]
     end

     @search_definition = {
       size: 5,
       query: {
         bool: {
           must: [],
           should: [],
           filter: []
         
       
     

     if query.blank?
       set_filters.call(:must, match_all: {})
     else
       set_filters.call(
         :must,
         match: {
           name: {
             query: query,
             fuzziness: 1
           
         
       
     end

     if filters[:level].present?
       set_filters.call(:filter, term: { level: filters[:level] })
     end

     __elasticsearch__.search(@search_definition)
   end
 end

 class_methods do
   def settings_attributes
     
       index: {
         analysis: {
           analyzer: {
             autocomplete: {
               type: :custom,
               tokenizer: :standard,
               filter: %i[lowercase autocomplete]
             
           },
           filter: {
             autocomplete: {
               type: :edge_ngram,
               min_gram: 2,
               max_gram: 25
             
           
         
       
     
   end
 end
end
```

In this snippet, we are serializing our model attributes to JSON with the key <code>as_indexed_json</code> method. 
In fact, we will work only with two fields, i.e. <code>name</code> and <code>level</code>*:

```ruby
   def as_indexed_json(_options = {})
     as_json(only: %i[name level])
   end
```

Let’s define the index configuration:

```ruby
   settings settings_attributes do
     mappings dynamic: false do
       # we use our autocomplete custom analyzer that we have defined above
       indexes :name,  type: :text, analyzer: :autocomplete
       indexes :level, type: :keyword
     end
   end

   def settings_attributes
     
       index: {
         analysis: {
           analyzer: {
             # we define custom analyzer with name autocomplete
             autocomplete: {
               # type should be custom for custom analyzers
               type: :custom,
               # we use standard tokenizer
               tokenizer: :standard,
               # we apply two token filters
               # autocomplete filter is a custom filter that we defined above
               filter: %i[lowercase autocomplete]
             
           },
           filter: {
             # we define custom token filter with name autocomplete
             autocomplete: {
               type: :edge_ngram,
               min_gram: 2,
               max_gram: 25
             
           
         
       
     
   end
 end
```

Let’s define an <code>autocomplete</code> custom analyzer with <code>standard tokenizer</code> and with <code>lowercase</code> and <code>autocomplete</code> filters.

<code>Autocomplete</code> filter is of <code>edge_ngram</code> type. The <code>edge_ngram</code> tokenizer splits the text into smaller parts (grams).
For instance, the word <code>“ruby”</code> will be divided into <code>[“ru”, “rub”, “ruby”]</code>.

When implementing autocomplete functionality, <code>edge_ngram</code> come in handy. Still, there is one more way to integrate the options needed. It is called [completion suggester](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters-completion.html) approach.

We use mappings with the **name** and **level** fields. The **keyword** data type is applied to the **level** field. The **text** data type is used with both the **name** field and our custom <code>autocomplete</code> analyzer.

And now, we are going to explain the **search** method we apply:

```ruby
   def self.search(query, filters)
     # a lambda function adds conditions to a search definition
     set_filters = lambda do |context_type, filter|
       @search_definition[:query][:bool][context_type] |= [filter]
     end

     @search_definition = {
       # we indicate that there should be no more than 5 documents to return
       size: 5,
       # we define an empty query with the ability to
       # dynamically change the definition
       # Query DSL https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl.html
       query: {
         bool: {
           must: [],
           should: [],
           filter: []
         
       
     

     # match all documents
     if query.blank?
       set_filters.call(:must, match_all: {})
     else
       set_filters.call(
         :must,
         match: {
           name: {
             query: query,
             # fuzziness means you can make one typo and still match your document
             fuzziness: 1
           
         
       
     end

     # the system will return only those documents that pass this filter
     if filters[:level].present?
       set_filters.call(:filter, term: { level: filters[:level] })
     end

     __elasticsearch__.search(@search_definition)
   end
```

Open the Rails console and check the request to make sure the project works properly:

```ruby
rails c
results = Location.search('san francisco', {})
results.map(&:name) # => ["san francisco", "american samoa"]
```

Also, we are going to verify if the product performance is correct with a few mistakes in the request. The purpose is to make sure the project functions accurately:

```ruby
results = Location.search('Asan francus', {})
results.map(&:name) # => ["san francisco"]
```

We have one filter defined. It is applied to **Location** filter by **level**. The database contains two objects in the database with the same name, i.e. **New York**, which are of different levels. The first level concerns to the state, and the second one - to the city:

```ruby
results = ation.import force: true=>"new york", :level=>"state"}

results = Location.search('new york', { level: city })
results.map { |result| { name: result.name, level: result.level } }
# [{:name=>"new york", :level=>"city"}
```
## Step #5: Making the search request available by API 
In the final stage, we are going to create a controller through which the search queries will pass:

```ruby
rails generate controller Home search
```

For this purpose, open **app/controllers/home_controller.rb** and insert the following snippet in it:

```ruby
class HomeController < ApplicationController
 def search
   results = Location.search(search_params[:q], search_params)

   locations = results.map do |r|
     r.merge(r.delete('_source')).merge('id': r.delete('_id'))
   end

   render json: { locations: locations }, status: :ok
 end

 private

 def search_params
   params.permit(:q, :level)
 end
end
```

To see the project performance, run the Rails server by typing in <code>rails s</code> and then navigate to <code>http://localhost:3000//home/search?q=new&level=state</code>.

In the code, we ask all files containing the **name “new”** and whose **level** is equal to the state.

The response looks like:
![](https://thepracticaldev.s3.amazonaws.com/i/aljkzl3bveue45but8qr.png)


Our test Rails web app is ready, with the basic functionality of the searching service implemented.
## Conclusion
Here we presented you the second part of the article covering Elasticsearch integration with Rails web app and hope that our tutorial was helpful.
Stay tuned and read the full article version here: [How to Implement Elasticsearch When Developing a Rails Web App](https://www.codica.com/blog/developing-rails-web-app-with-elasticsearch/).