Using Meilisearch full-text search engine with Rails

Alexey Ivanov
4 min readOct 30, 2020

--

Meilisearch is “An open source, blazingly fast and hyper relevant search-engine”. In this article, I’ll show you how to integrate it with Rails app.

Quickstart

Download and install process described here: https://docs.meilisearch.com/guides/advanced_guides/installation.html

There’s also a nice article about installing MeiliSearch in production and running it as a service: https://docs.meilisearch.com/running-production

Milisearch has a gem for Ruby apps, so first, you need to add to your Gemfile:

gem 'meilisearch', require: false

And run bundle install. Next, let’s create a file where we gonna keep our connection configuration. You don’t need a master key unless Meilisearch running in a production environment, so you can leave a key empty for your development env. Don’t forget to add this file to .gitignore

config/meilisearch.yml

host: http://127.0.0.1:7700
key: masterkey

Now we are ready to connect to our Meilisearch client, for example like this:

config = YAML.load_file('config/meilisearch.yml')
client = MeiliSearch::Client.new(config['url'], config['api_key'])

Creating modules

We want to do two basic things with our database objects: to index them and to search in indexes. So, we’ll need two modules: Searchable to mix class method that will allow us to search in the index, and Indexable to mix instance methods to model. Both modules will need methods to connect to a client so it’ll be handy to create the third module, MeiliSearchable, and include it in those two. Let’s start with it:

#/lib/modules/meili_searchable.rbrequire 'meilisearch'

module MeiliSearchable

private

def client
config = YAML.load_file('config/meilisearch.yml')

@client ||= MeiliSearch::Client.new(config['host'], config['key'])
end

def index
@index ||= client.index(index_name)
end
def index_name
if self.class.to_s == 'Class'
self.name.downcase.pluralize
else
self.class.name.downcase.pluralize
end
end
end

In this module, the client method is used to connect to MeiliSearch client, and the index method allows us to receive specific index for model on that we call our methods.The index_name method will return our object class name or our class name either module is included to class or class is extended with the module. For example, the Account class index name will be accounts. And for Account class instance it will be the same.

The Indexable module needs only one public method: add_to_index. We will add this method to after_commit callback in our model because add_documents method of MeiliSearch client updates the document if it already exists (it even has alias add_or_replace_documents). Note that there’s also a method update_documents with alias add_or_update_documents. The difference is that the second method won’t remove existing fields in the document if they were sent empty from this method.

module Indexable
include MeiliSearchable

def add_to_index
index.add_documents([self.to_indexed])
end
end

Note the to_indexed method. This method must be implemented in each indexable class and define how exactly our object will be stored in our index. We’ll return to this later.

Finally, we need a Searchable module to search through index

module Searchable
include MeiliSearchable

def search(text)
index.search(text, limit: 1000)
end
end

Let’s continue with our Account example. First of all, we shall use our modules:

class Account < ActiveRecord::Base
include Indexable
extend Searchable
...

We also need to create a callback to add our accounts to index after create or update

after_commit :add_to_index

And, finally, to implement to_indexed method:

def to_indexed
{ id: "account#{id}", account_id: id, email: email }
end

Note that id must be unique through indexes, so we add the ‘account’ prefix to it.

Creating an index

Update: as for meilisearch 0.16, you don’t need to do this, an index will be created automatically after you push first document! https://blog.meilisearch.com/whats-new-in-v0-16-0/

For now, we can automatically add our accounts to index, and to search through it. We only need to create an index to start it all working. Let’s do it with migration:

rails g migration create_meilisearch_accounts_index

And edit it like this:

require 'meilisearch'

class CreateMeilisearchAccountsIndex < ActiveRecord::Migration
include MeiliSearchable

def up
client.create_index('accounts')
end

def down
client.delete_index('accounts')
end
end

That’s it! Now run the migration and have some fun

rails db:migrate

rails c

Account.create(email: some_new@mail.org)

Account.search(‘some’)
=> {“hits”=>[{“id”=>”account53120", “account_id”=>”53120", “email”=>”some@mail.org”}], “offset”=>0, “limit”=>1000, “nbHits”=>1, “exhaustiveNbHits”=>false, “processingTimeMs”=>2, “query”=>”some”}

If you want to fetch objects from your databes, you can do like this:

ids = Account.search('some')['hits']&.map{|r| r['account_id']}

Account.where(id: ids)

Further improvements. Sidekiq

When you have a lot of documents, adding new documents to index might be slow. It’s a good idea to do in asynchronously, for example, with Sidekiq. I won’t show here how to install and configure it, it’s pretty easy to google. After you deal with it, we can create a worker

# app/workers/meili_search_worker.rbclass MeiliSearchWorker
include Sidekiq::Worker

def perform(class_name, id)
class_name.constantize.find(id).add_to_index
end
end

Add this method to our Indexable module

def async_add_to_index
MeiliSearchWorker.perform_async(self.class.name, self.id)
end

and finally change our after_commit Account callback

after_commit :async_add_to_index

Final. Adding existing documents

For an existing app, we can use a simple rake task to add existing documents

namespace :meilisearch do
require 'meilisearch'

task :fill_indexes => [ :environment ] do
Account.find_in_batches(batch_size: 500) do |group|
add_batch(index, group)
end
end

def index
config = YAML.load_file('config/meilisearch.yml')

@index ||= MeiliSearch::Client.new(config['host'], config['key']).index('comments')
end

def add_batch(index, group)
documents = group.map(&:to_indexed)
begin
index.add_documents(documents)
rescue
puts "Got timeout, sleeping for 2"
sleep 2
retry
end
end
end

Note that we use add_documents method here, that adds multiple documents to the index.

Thank you for reading this article, hope it was helpful! Please leave feedback if you have any questions or suggestions.

--

--

No responses yet