32
loading...
This website collects cookies to deliver better user experience
This is not the tutorial for building the web search engine like Google. What you will be building is a search engine for the Rails App where you can search for any string inside any table in the app. This will help you in adding the functionality of app wide search.
git clone [email protected]:coolprobn/rails-search-engine.git
Run only once
elasticsearch
Run in background and on machine restart
brew services start elastic/tap/elasticsearch-full
http://localhost:9200/
{
"name" : "Prabins-MacBook-Pro.local",
"cluster_name" : "elasticsearch_cool",
"cluster_uuid" : "J2CAnnSoRI6p2zZGV3K8eg",
"version" : {
"number" : "7.13.3",
"build_flavor" : "default",
"build_type" : "tar",
"build_hash" : "5d21bea28db1e89ecc1f66311ebdec9dc3aa7d64",
"build_date" : "2021-07-02T12:06:10.804015202Z",
"build_snapshot" : false,
"lucene_version" : "8.8.2",
"minimum_wire_compatibility_version" : "6.8.0",
"minimum_index_compatibility_version" : "6.0.0-beta1"
},
"tagline" : "You Know, for Search"
}
Gemfile.rb
# Elasticsearch for powerful searching
gem 'elasticsearch-model'
gem 'elasticsearch-rails'
bundle install
class Author < ApplicationRecord
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
end
app/models/concerns/searchable.rb
and add the followingmodule Searchable
extend ActiveSupport::Concern
included do
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
end
end
# app/models/author.rb
class Author < ApplicationRecord
include Searchable
# remaining code
end
lib/tasks/elastic_search.rake
and add the following:namespace :elastic_search do
desc 'Index models to elasticsearch'
task :index_models, [:models] => :environment do |_, args|
# eager load first so that models are available for next step
Rails.application.eager_load!
# include all models inside the app/models folder
default_models = ApplicationRecord.descendants.map(&:to_s)
argument_models = args[:models]&.split(',')&.map(&:strip)
models = argument_models || default_models
model_classes = models.map { |model| model.underscore.camelize.constantize }
model_classes.each do |model|
model.import force: true
end
end
end
[:models]
allows the rake task to accept arguments, in this case rake task accepts string separated by "," i.e. each model name which needs to be indexed is separated by a comma.Rails.application.eager_load!
loads all the classes inside the "app" folder, this is so that all model names are available to this rake task in the next step.ApplicationRecord.descendants.map(&:to_s)
returns array of model names inside the folder app/models
meaning all models in the app will be indexed. If this is not what you desire you can replace the code with array of model name like ['Author', 'Article']
split
method and then unnecessary whitespace are removed with strip
methodmodel_classes
, each model name is converted to camel case to maintain consistency and avoid errors, and then each model name which is in string is converted to constant with the help of the method constantize
.import
provided by elasticsearch gem.Index all models
If you are running the rake task for the first time, it's better if you don't pass any argument since you want records from all models to be indexed.
Run the following command in that case:
rails elastic_search:index_models
For new models
If you add new models, you will normally want to only index that model, for that you can pass the names of new models when executing the rake task
rails "elastic_search:index_models[Comment\, Tag]"
You need to escape comma (,) with \
otherwise it will be treated as second argument to rake task and only "Comment" will be passed to the argument model_names.
> Author.search('Jane').results
> response.first.as_json
=> {"_index"=>"authors", "_type"=>"_doc", "_id"=>"2", "_score"=>0.6931471, "_source"=>{"id"=>2, "first_name"=>"Jane", "last_name"=>"Jones", "email"=>"[email protected]", "nickname"=>"marvellous.jane", "created_at"=>"2021-07-25T15:18:18.192Z", "updated_at"=>"2021-07-25T15:18:18.192Z"}}
ModelName.search 'query'
e.g. Author.search 'Jane'
> Author.search('Jane').results
> response.first.as_json
=> {"_index"=>"authors", "_type"=>"_doc", "_id"=>"2", "_score"=>0.6931471, "_source"=>{"id"=>2, "first_name"=>"Jane", "last_name"=>"Jones", "email"=>"[email protected]", "nickname"=>"marvellous.jane", "created_at"=>"2021-07-25T15:18:18.192Z", "updated_at"=>"2021-07-25T15:18:18.192Z"}}
Elasticsearch::Model.search('query', [ModelName1, ModelName2])
e.g. Elasticsearch::Model.search('ruby', [Article, Category])
Elasticsearch::Model.search('Ruby', [Article, Category]).results.as_json
=> [{"_index"=>"categories", "_type"=>"_doc", "_id"=>"1", "_score"=>1.5697745, "_source"=>{"id"=>1, "title"=>"ruby", "created_at"=>"2021-07-25T15:18:18.202Z", "updated_at"=>"2021-07-25T15:18:18.202Z"}}, {"_index"=>"articles", "_type"=>"_doc", "_id"=>"2", "_score"=>1.2920684, "_source"=>{"id"=>2, "title"=>"Build Twitter Bot with Ruby", "content"=>"Today, we will be building a bot for Twitter that will retweet all hashtags related to #ruby or #rails. We can also configure it to retweet any hashtags so you can use this tutorial to create bot that can retweet whatever hashtag you want. Yes, and we will be building this Twitter bot with Ruby.\n\nWe will be using Twitter gem (Github) to help us in getting up and running quickly with Twitter APIs.\n", "published_on"=>"2021-04-23T05:00:00.000Z", "author_id"=>1, "created_at"=>"2021-07-25T15:18:18.236Z", "updated_at"=>"2021-07-25T15:18:18.236Z"}}, {"_index"=>"articles", "_type"=>"_doc", "_id"=>"4", "_score"=>0.83619946, "_source"=>{"id"=>4, "title"=>"Setup Factory Bot in Rails", "content"=>"Factory Bot is a library for setting up test data objects in Ruby. Today we will be setting up Factory Bot in Rails which uses RSpec for testing. If you are using different test suite, you can view all supported configurations in the official github repository of Factory Bot.\n", "published_on"=>"2021-06-13T13:00:00.000Z", "author_id"=>2, "created_at"=>"2021-07-25T15:18:18.245Z", "updated_at"=>"2021-07-25T15:18:18.245Z"}}]
to_a
For e.g. Author.search('john').records.to_a
Author.search('john').records.to_a
Author Load (0.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = $1 [["id", 1]]
=> [#<Author id: 1, first_name: "John", last_name: "Doe", email: "[email protected]", nickname: "john101", created_at: "2021-07-25 15:18:18.190094000 +0000", updated_at: "2021-07-25 15:18:18.190094000 +0000">]
touch app/controllers/search_controller.rb
class SearchController < ApplicationController
def search
if params[:q].blank?
@results = []
else
@results = Elasticsearch::Model
.search(params[:q])
.results.as_json
.group_by { |result| result['_index'] }
end
end
end
config/routes.rb
get :search, to: 'search#search'
$ mkdir app/views/search
$ touch app/views/search/search.html.erb
<h1>App Search</h1>
<%= form_for search_path, method: :get do |f| %>
<p>
<%= f.label "Search for" %>
<%= text_field_tag :q, params[:q] %>
<%= submit_tag "Search", name: nil %>
</p>
<% end %>
<% if params[:q] && @results.blank? %>
<p>No results found for <%= params[:q] %></p>
<% end %>
<% @results.each do |group, records| %>
<h3><%= group.titleize %></h3>
<ul>
<% records.each do |record| %>
<% record_link = "#{group}/#{record['_id']}" %>
<li>
<%= link_to record_link, record_link %>
</li>
<% end %>
</ul>
<% end %>
rails s
and go to localhost:3000/search
, you will see a view with search box in it like this:@results = Elasticsearch::Model
.search(params[:q], [], { body: highlighted_fields })
.results.as_json
.group_by { |result| result['_index'] }
private
def highlight_fields
{
highlight: {
fields: {
pre_tags: ['<strong>'],
post_tags: ['</strong>'],
first_name: {},
last_name: {},
nickname: {},
email: {},
title: {},
content: {}
}
}
}
end
pre
and post
tags; here "em" tag is overridden by "strong" tag because I felt that bold text catches more attention than italicized text. You can ignore these two tags and remove them completely if you think italicized texts work great.class SearchController < ApplicationController
def search
if params[:q].blank?
@results = []
else
@results = Elasticsearch::Model
.search(params[:q], [], { body: highlight_fields })
.results.as_json
.group_by { |result| result['_index'] }
end
end
private
def highlight_fields
{
highlight: {
fields: {
pre_tags: ['<strong>'],
post_tags: ['</strong>'],
first_name: {},
last_name: {},
nickname: {},
email: {},
title: {},
content: {}
}
}
}
end
end
<% record['highlight']&.each do |key, snippet| %>
<p><%= "#{key} - " %> <%= sanitize(snippet[0]) %></p>
<% end %>
<h1>App Search</h1>
<%= form_for search_path, method: :get do |f| %>
<p>
<%= f.label "Search for" %>
<%= text_field_tag :q, params[:q] %>
<%= submit_tag "Search", name: nil %>
</p>
<% end %>
<% if params[:q] && @results.blank? %>
<p>No results found for <%= params[:q] %></p>
<% end %>
<% @results.each do |group, records| %>
<h3><%= group.titleize %></h3>
<ul>
<% records.each do |record| %>
<% record_link = "api/v1/#{group}/#{record['_id']}" %>
<li>
<%= link_to record_link, record_link %>
<% record['highlight']&.each do |key, snippet| %>
<p><%= "#{key} - " %> <%= sanitize(snippet[0]) %></p>
<% end %>
</li>
<% end %>
</ul>
<% end %>