search mobile facets autocomplete spellcheck crawler rankings weights synonyms analytics engage api customize documentation install setup technology content domains user history info home business cart chart contact email activate analyticsalt analytics autocomplete cart contact content crawling custom documentation domains email engage faceted history info install mobile person querybuilder search setup spellcheck synonyms weights engage_search_term engage_related_content engage_next_results engage_personalized_results engage_recent_results success add arrow-down arrow-left arrow-right arrow-up caret-down caret-left caret-right caret-up check close content conversions-small conversions details edit grid help small-info error live magento minus move photo pin plus preview refresh search settings small-home stat subtract text trash unpin wordpress x alert case_deflection advanced-permissions keyword-detection predictive-ai sso

Using App Search in Ruby on Rails

This tutorial will guide you through adding a simple search feature to a Ruby on Rails application using Swiftype's App Search. If you'd rather look over the finished product, take a look at this branch of the tutorial on Github.

Requirements

You'll need a recent version of Ruby to follow this tutorial. If you do not have Ruby installed on your machine, you can use the installation method of your choice, or follow the instructions listed here.

Setup

To get started, clone the tutorial repository and run bin/setup. This will install bundler and the required gems, setup the SQLite database, and populate it with seed data.

$ git clone git@github.com:Swiftype/app-search-rails-tutorial.git
$ cd app-search-rails-tutorial

$ bin/setup

To make sure everything is in order, start the app with rails server.

$ rails server
=> Booting Puma
=> Rails 5.2.0 application starting in development
=> Run `rails server -h` for more startup options
Puma starting in single mode...
* Version 3.11.3 (ruby 2.5.1-p57), codename: Love Song
* Min threads: 5, max threads: 5
* Environment: development
* Listening on tcp://0.0.0.0:3000
Use Ctrl-C to stop

Once the server has started, point your browser at localhost:3000. You should see something like this:

Initial App Screenshot

You may have noticed that the app currently returns every gem, regardless of what you enter in the search box. Let's see if we can improve our search results with Swiftype App Search.

Setup Swiftype App Search

Head over to Swiftype App Search and create a free trial account. Once you've created your account and logged in for the first time, you'll be prompted to create an engine. An engine is primarily a repository for your indexed search records, but it also stores search analytics and any search configurations that you require. For the purposes of this tutorial, you should name your engine ruby-gems.

Create Engine

Install & Configure Swiftype App Search Client

Let's add the App Search client to our app so we can start working with it. Open up the Gemfile, and add:

gem 'swiftype-app-search', '~> 0.1.1'

Then, run bundle install to install the gem.

$ bundle install

Finally, you'll need your credentials to authorize yourself to the Swiftype App Search API. Take note of your credentials on the App Search Dashboard. You'll need your Account Key near the top and your API Key.

There are many different ways to keep track of API keys and other secret information in your development environment (The dotenv gem, for example). For the purposes of this tutorial, we're going to simply store them in a yaml config file.

The tutorial's setup script has created config/swiftype.yml for the purpose; go ahead and open it now and fill it out with the Account Key and api-key you found in the previous step.

# config/swiftype.yml

app_search_account_key: [YOUR ACCOUNT HOST KEY HERE] # it should start with "host-"
app_search_api_key: [YOUR API KEY HERE] # it should start with "api-"

Now lets add a simple initializer to load this configuration.

# config/initializers/swiftype.rb

Rails.application.configure do
  swiftype_config = YAML.load_file(Rails.root.join('config', 'swiftype.yml'))

  config.x.swiftype.app_search_account_key = swiftype_config['app_search_account_key']
  config.x.swiftype.app_search_api_key = swiftype_config['app_search_api_key']
end

We'll be using this client in a few places, so let's wrap it in a small class.

# app/lib/search.rb

class Search
  ENGINE_NAME = 'ruby-gems'

  def self.client
    @client ||= SwiftypeAppSearch::Client.new(
      account_host_key: Rails.configuration.x.swiftype.app_search_account_key,
      api_key: Rails.configuration.x.swiftype.app_search_api_key,
    )
  end
end

The version of Rails used in this tutorial uses the Spring application preloader by default. You may need to restart Spring to pick up the app/lib directory.

$ bundle exec spring stop

Hook Into the Model Lifecycle

The database records are the "source of truth" for your RubyGems data, so every time one of the records change, you will want to update the related document in your App Search engine. We can take advantage of ActiveRecord lifecycle callbacks to keep the two data sets in sync. Add an after_commit callback to notify App Search of any new or changed records committed to the database, and an after_destroy callback for when a record is removed.

# app/models/ruby_gem.rb

class RubyGem < ApplicationRecord
  validates :name, presence: true, uniqueness: true

  after_commit do |record|
    client = Search.client
    document = record.as_json(only: [:id, :name, :authors, :info, :downloads])

    client.index_document(Search::ENGINE_NAME, document)
  end

  after_destroy do |record|
    client = Search.client
    document = record.as_json(only: [:id])

    client.destroy_documents(Search::ENGINE_NAME, [ document[:id] ])
  end

 # ...

end
In this tutorial, we are calling the Swiftype App Search API synchronously within the lifecycle callback. It is recommended that you instead call the App Search API (or any external service) from within an asynchronous job, to avoid holding up any request to your own application. If you aren't familiar with asynchronous job services, take a look at ActiveJob framework provided by Rails.

Demonstrate Lifecycle Hooks

If you're curious to see the after_commit hook in action, open up a rails console and make a small change to a RubyGem record.

$ rails console
# ...
irb(main):008:0> puma = RubyGem.find_by_name('puma')
=> # ...
irb(main):009:0> puma.info += ' Also, pumas are fast.'
=> # ...
irb(main):010:0> puma.save
=> true

The call to save should trigger the after_commit callback. If you open the documents panel in the App Search Dashboard, you should see a document that corresponds to the puma gem, your first indexed document!

Puma Document

Index Records in Batches

If you're building App Search into an application from the start, you may not need to worry about indexing existing data; the after_commit hook will handle new records as they are added. However, the tutorial app already has more than 11,000 RubyGem records. We can write a rake task to index them all without waiting for individual after_commit hooks on each record.

# lib/tasks/app_search.rake

namespace :app_search do
  desc "index every Ruby Gem in batches of 100"
  task seed: [:environment] do |t|
    client = Search.client

    RubyGem.in_batches(of: 100) do |gems|
      Rails.logger.info "Indexing #{gems.count} gems..."

      documents = gems.map { |gem| gem.as_json(only: [:id, :name, :authors, :info, :downloads]) }

      client.index_documents(Search::ENGINE_NAME, documents)
    end
  end
end

Now lets run this task from the command line. Consider watching the log file in another terminal so you can see it in action with this command: tail -F log/development.log.)

$ rails app_search:seed

If you take another look at the documents panel in the App Search Dashboard, you should see all of your documents are now indexed.

All the Documents

You should now have all of your documents indexed in Swiftype App Search and have lifecycle callbacks keeping them up to date. You're finally ready to modify the RubyGemsController#index action to search using your Swiftype App Search engine.

# app/controllers/ruby_gems_controller.rb

class RubyGemsController < ApplicationController

  PAGE_SIZE = 30

  def index
    if search_params[:q].present?
      @current_page = (search_params[:page] || 1).to_i

      search_client = Search.client
      search_options = {
        page: {
          current: @current_page,
          size: PAGE_SIZE,
        },
      }

      search_response = search_client.search(Search::ENGINE_NAME, search_params[:q], search_options)
      @total_pages = search_response['meta']['page']['total_pages']
      result_ids = search_response['results'].map { |rg| rg['id']['raw'].to_i }

      @search_results = RubyGem.where(id: result_ids).sort_by { |rg| result_ids.index(rg.id) }
    end
  end

  def show
    @rubygem = RubyGem.find(params[:id])
  end

  private

  def search_params
    params.permit(:q, :page)
  end
end

Open up localhost:3000 and try it out!

Tuning Relevance

By default, our relevance isn't great -- searching with a query string of "rake" returns the rake gem at the 14th result. Usually, users will search by the name of the gem, so we should give that field more importance than the other ones. By default, App Search treats all fields with equal importance. We can give the name field a higher weight than info and authors so that when a query string has a hit on the name field, it will have the most impact on the final document score.

# app/controllers/ruby_gems_controller.rb

# ...

def index
  if search_params[:q].present?
    @current_page = (search_params[:page] || 1).to_i

    search_client = Search.client
    search_options = {
      search_fields: {
        name: { weight: 2.0 },
        info: {},
        authors: {},
      },
      page: {
        current: @current_page,
        size: PAGE_SIZE,
      },
    }

    search_response = search_client.search(Search::ENGINE_NAME, search_params[:q], search_options)
    @total_pages = search_response['meta']['page']['total_pages']
    result_ids = search_response['results'].map { |rg| rg['id']['raw'].to_i }

    @search_results = RubyGem.where(id: result_ids).sort_by { |rg| result_ids.index(rg.id) }
  end
end

# ...
If you provide the search_fields option to the searching API, you must include every field you would like to be included in the search. This is why we added info and authors, even though we aren't currently passing any search options for those fields.

If you search again, the rake gem should be the first result! For more information about weights, check out the weights section of the App Search searching guide.

Add a Search Filter

Now that you have a tuned search query, lets add an option to our search interface that filters out RubyGems that do not have very many downloads. To start, add a checkbox to the search form to show only gems with over a million downloads.

# app/views/ruby_gems/index.html.erb

# ...

  <%= form_tag({}, {method: :get}) do %>
    <div class="form-group row">
      <%= text_field_tag(:q, params[:q], class: "form-control", placeholder: "My favorite gem...") %>
    </div>

     <div class="form-check">
       <%= check_box_tag('popular', 1, params[:popular], class: 'form-check-input') %>
       <label class="form-check-label" for="popular">Only include gems with more than a million downloads.</label>
     </div>

    <div class="form-group row">
      <%= submit_tag("Search", class: "btn btn-primary mb-2") %>
    </div>
  <% end %>

# ...

Now, back in the controller, add a filter to your search options when the popular parameter is present. Swiftype App Search allows us to pass filters along with our search options.

# app/controllers/ruby_gems_controller.rb

  # ...

  def index
    if search_params[:q].present?
      @current_page = (search_params[:page] || 1).to_i

      search_client = Search.client
      search_options = {
        search_fields: {
          name: { weight: 2.0 },
          info: {},
          authors: {},
        },
        page: {
          current: @current_page,
          size: PAGE_SIZE,
        },
      }

      if search_params[:popular].present?
        search_options[:filters] = {
          downloads: { from: 1_000_000 },
        }
      end

      # ...

    end

  private

  def search_params
    params.permit(:q, :page, :popular)
  end

  # ...

Now try searching for a gem with fewer downloads, like heyzap-authlogic-oauth. With the checkbox unchecked, it's the first result! With it checked, we instead get gems with a wider audience, like authlogic and oauth.

Keep Learning!