Multi-model searching using Elasticsearch vol. 2

Multi-model searching using Elasticsearch vol. 2

In the previous post we saw how to install Elasticsearch and import data needed for searching. We also set up basic searching for the User and House models. In the next post we will see how to improve searching intelligence, but right now let’s take care of the main part of our functionality - multi model searching.


This a part of a three post series:

  1. Part 1 - basic setup
  2. Part 2 - multi model searching
  3. Part 3 - improving searching intelligence

TL;DR

I've created a sample app which is a foundation for my blogposts. If you already are familiar with Elasticsearch you can check it out right away. It's a complete demo with some complex searching using nGrams.

Multi-model searching

The elasticserch-model gem provides an easy way to search within multiple models:

Elasticsearch::Model.search(query_to_search, [User, House])

As you can see the search method needs 2 arguments: your query to search by and an array of models. So without further ado let’s create a service object which will be responsible for searching:

class Autocompleter < Struct.new(:query)
  MODELS_TO_SEARCH = [User, House]

  def self.call(query)
    new(query).call
  end

  def call
    results.map do |result|
      {
        hint: build_hint(result),
        record_type: result.class.name,
        record_id: result.id
      }
    end
  end

  private

  def results
    Elasticsearch::Model.search(query, MODELS_TO_SEARCH).records
  end

  def build_hint(record)
    case record.class.to_s
    when "User" then "Name: #{record.name}, City: #{record.city}"
    when "House" then "City: #{record.city}, Info: #{record.information}"
    end
  end
end

Calling Autocompleter.call(“query”) will return an array of hashes with matched data from both models. If you are building some kind of form where user will be able to select some records you basically can return 3 things from each result:

  • Record type to know what kind of record user selected
  • Record id to know which record of given type user selected
  • Some kind of text describing given record which will be shown to a user. Let’s assume that right now it should contain the most important columns from both models, like: column_name: value, etc.. For example for User - Name: “Dawid”, City: “Bialystok”

Presenting results

So let’s stop for a while and talk about possible solutions for creating the description in the build_hint method:

  • Right now it just uses a case conditional and returns specific text for given class
def build_hint(record)
  case record.class.to_s
  when "User" then "Name: #{record.name}, City: #{record.city}"
  when "House" then "City: #{record.city}, Info: #{record.information}"
  end
end

It just doesn’t feel right. It is polluting the Autocompleter class with the knowledge of how to present results, it would be difficult to test in isolation and with some possible changes in the future in can look even worse.

  • You could write some specific method in each model or even overwrite the to_s method. It looks like a better solution, but such method in a class tends to be overused later. What if you think you are doing a good job and use it in some other place? Then changing the way we present search results would also affect different place in our app. Also it looks like moving too much logic to a model and can introduce the god object anti-pattern.
  • So what is the best way to get rid of conditionals? Let’s use polymorphism. So the build_hintmethod will only delegate it to another service object, which is responsible only for presenting results. Here’s the whole implementation:
# In Autocompleter
def build_hint(record)
  BuildHint.call(record)
end

class BuildHint < Struct.new(:record)
  def self.call(record)
    new(record).call
  end

  def call
    result_builder.autocomplete_hint
  end

  private

  def result_builder
    "#{record.class}ResultBuilder".constantize.new(record)
  end
end

class ResultBuilderBase
  def initialize(record)
    @record = record
  end

  private

  attr_reader :record
end

class HouseResultBuilder < ResultBuilderBase
  def autocomplete_hint
    "City: #{record.city}, Info: #{record.information}"
  end
end

class UserResultBuilder < ResultBuilderBase
  def autocomplete_hint
    "Name: #{record.name}, City: #{record.city}"
  end
end

I really like this solution. Couple of small classes that are easy to read, understand and most important part is that they are easy to test and change in the future without affecting anything else.

Searching examples

As in the previous post let’s create some data:

User.create(name: "John Doe", city: "San Francisco")
User.create(name: "John Rambo", city: "New York")

House.create(city: "New York", information: "Rambo’s house")
House.create(city: "Los Angeles", information: "Large villa")

And now it’s time to see how it works:

Autocompleter.call("rambo")
=> [{:hint=>"City: New York, Info: Rambo’s house", :record_type=>"House", :record_id=>12},
{:hint=>"Name: John Rambo, City: New York", :record_type=>"User", :record_id=>16}]

Autocompleter.call("john")
=> [{:hint=>"Name: John Rambo, City: New York", :record_type=>"User", :record_id=>16},
{:hint=>"Name: John Doe, City: San Francisco", :record_type=>"User", :record_id=>14}]

Autocompleter.call("new york")
=> [{:hint=>"City: New York, Info: Rambo’s house", :record_type=>"House", :record_id=>12},
{:hint=>"Name: John Rambo, City: New York", :record_type=>"User", :record_id=>16}]

Pretty cool, right? Seems like everything is working as expected.

Wrapping up

The most important functionality is already there. By now we got Elasticsearch running, importing data automatically after each record update and made a multi-model search. In the next post I’ll show you how you can improve the searching intelligence by specifying custom analyzers when indexing and searching. Stay tuned and thanks for reading.