I blog about Ruby on Rails, coding, and servers

Rails Concerns V: Searchable With Elasticsearch

Reading time 9 minutes

I use the wonderful elasticsearch for my searching needs. I described in previousposts how I use and test elasticsearch in general; but in my current project, I found myself using elasticsearch in a very similar way across all my models. Call me crazy, but that sounds like a concern to me!

As a result of this concern, I ended up having a really neat abstraction that allowed me to search across all my models using elastcisearch’s multi-index search functionality. The end result of this concern was not only less duplicated code; it was a useful utility function that acted on all the models that implemented it.

The Setup

I have multiple models that are searchable, all of which are searchable in somewhat similar ways. For example, users need left-handed ngram indexing for their names (for autocompletion), but also full searching on the same field; similarly, the titles of content work almost exactly the same way. The only difference between the two was the weights they should use, so I set them up similarly in tire:

ElasticSearchAnalysis is a constant that contains the settings for the partial and full analyzers referenced in the mappings. Of course, I index more fields for content, but ultimately I was using the searchers in the two models in a very similar way: a boolean should of all the different mappings conjoined together. As I was working on the code for the two different models, it was looking more and more similar… and then when I added in searching to tags and it was just about the same thing, I figured it was time to come up with a concern. I elected to call it searchable and wanted it to look something like this:

If you’ve been following my series on concerns, this shouldn’t be very surprising stuff. When you call searchable, the fields are added to an instance variable: then, when you call search on the model, we concatenate all the fields together and boolean search across on each of them. So once this is all set up, you’d use it like this:

Extending to More Like This

Of course, that searcher private method is just begging for another use. Why abstract it out so cleverly and not do something with it? Let’s use elasticsearch’s more like this query so we can quickly find objects like each other (to display in an attractive sidebar, for example). For this to work, in addition to having tire in your Gemfile, you’ll also need tire-contrib. So make sure you have it there or else this will explode.

Now you can say User.more_like('Josh Symonds') and it’ll find all documents with a more_like_this query for my name. Clever!

Searching Across Multiple Models

If you have a single search field on your site (like in the top navbar), most likely you’ll want to search across multiple models with it: the user could be searching for a person, or a piece of content, or a tag. There’s no easy way to know for sure what it is they want, so we should search across all of the fields and order the results by their relevance. Though this sounds complicated, with this concern, this is actually surprisingly easy.

We changed the searchable method slightly. Now, in addition to adding to an instance variable, it adds to a hash that the module itself keeps track of: this hash contains all the models as keys, and all their fields as values. Then, when we use it, it constructs a search across all those models’ indexes for all the fields those models should be searching. To give a concrete example, let’s say we use Searchable.search('Josh Symonds') and we have indexes on content titles and user names. This is what the resulting tire query will look like:

If you have some models that should be more relevant (like an exact tag match should be the most relevant result), give those mappings an appropriate boost inside the tire mappings for the model. Also keep in mind this will return an array of potentially very different objects: users and contents, in this case. You should either make sure they’re all duck-typed correctly together, or check their type before acting on them. Finally, this will only work in development if you load each model before calling Searchable.search. Just entering the constant name of the model should be enough, but if you don’t, then the module won’t know to search with that model. Such is the danger of lazy loading in development.

Josh Symonds performs devops and server wrangling on cloud-scale infrastructures, deploys amazing web applications with Ruby on Rails, and creates awesome iOS apps with Objective-C and RubyMotion. He is founder and CTO of Symonds & Son, a development shop focused on quality and excellence.