01caa77a1f122a1371fed292cab37c72

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:

Presenting results

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

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.

# 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.

×