Installation and Setting Up

Install Elasticsearch on macOS with Homebrew

1
% brew tap elastic/tap
1
% brew install elastic/tap/elasticsearch-full

Check elasticsearch version

1
elasticsearch --version
1
(Version: 7.17.4, Build: default/tar/79878662c54c886ae89206c685d9f1051a9d6411/2022-05-18T18:04:20.964345128Z, JVM: 18.0.1.1)

Run elasticsearch

1
$ elasticsearch

Visit http://localhost:9200/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "Rafas-MacBook-Pro.local",
"cluster_name": "elasticsearch_rafaltrojanowski",
"cluster_uuid": "oM3h-13nSWSGurimF2cUIg",
"version": {
"number": "7.17.4",
"build_flavor": "default",
"build_type": "tar",
"build_hash": "79878662c54c886ae89206c685d9f1051a9d6411",
"build_date": "2022-05-18T18:04:20.964345128Z",
"build_snapshot": false,
"lucene_version": "8.11.1",
"minimum_wire_compatibility_version": "6.8.0",
"minimum_index_compatibility_version": "6.0.0-beta1"
},
"tagline": "You Know, for Search"
}
NOTE

Install Elasticsearch version 8.x from archive on Linux or MacOS

Install kibana
1
$ brew install elastic/tap/kibana-full

Note: Kibana version must match with Elasticsearch version.

Rails project

1
2
3
4
5
$ gem install rails
$ rails new blog --database=postgresql
$ cd blog
$ rails g scaffold Article title:string body:text
$ rake db:create && rake db:migrate

Visit http://localhost:3000/articles

There is now data yet, we will populate it later.

Gemfile
1
2
3
4
5
6
gem 'elasticsearch-model'
gem 'elasticsearch-rails'

group :development, :test do
gem 'ffaker'
end
1
2
3
$ rails g model user first_name:string last_name:string
$ rails g migration add_user_ref_to_articles user:references
$ rails g resource comment article:references body:text
1
2
3
4
5
6
7
class Article < ApplicationRecord
include Searchable # magic goes there

belongs_to :user

has_many :comments, dependent: :destroy
end
1
2
3
class User < ApplicationRecord
has_many :articles, dependent: :destroy
end
1
2
3
class Comment < ApplicationRecord
belongs_to :article
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# db/seeds.rb
puts "Performing start"
TODO: CREATE INDEX
Comment.destroy_all
Article.destroy_all
User.destroy_all

2.times do |i|
User.create!(first_name: FFaker::Name.first_name, last_name: FFaker::Name.last_name)
end

3.times do |i|
article = Article.create!(
title: FFaker::Book.title,
body: FFaker::Tweet.body,
user: User.all.sample
)

(0..2).to_a.sample.times do
article.comments << Comment.new(body: FFaker::FreedomIpsum.paragraph)
article.save
end

puts i
end

# puts 'import'
# Article.import
puts "Performing stop"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
module Searchable
extend ActiveSupport::Concern

included do
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks

# Mappings
mapping do
# Article fields
indexes :title, type: :text
indexes :body, type: :text
# belongs_to user
indexes :user, type: :object do # object type is default
indexes :first_name, type: :text
indexes :last_name, type: :text
end
# has_many comments
indexes :comments, type: :object do
indexes :body, type: :text
end
end

# Serialization within Elasticsearch
def as_indexed_json(options = {})
as_json(
only: ['title', 'body'],
include: {
user: { only: [:first_name, :last_name] },
comments: { only: :body }
},
)
end

### GET articles/_doc/64091
# {
# "_index" : "articles",
# "_type" : "_doc",
# "_id" : "64091",
# "_version" : 1,
# "_seq_no" : 243,
# "_primary_term" : 3,
# "found" : true,
# "_source" : {
# "title" : "Bloody Monster",
# "body" : "Voluptate laudantium expedita commodi hic odio neque quisquam. Deleniti cupiditate mollitia animi aspernatur. Ex perferendis repudiandae.",
# "user" : {
# "first_name" : "Willis",
# "last_name" : "Nitzsche"
# },
# "comments" : [
# {
# "body" : "Dallas cowboys 7-Eleven MGD 18-wheeler Harley Davidson anti-metric system. Super bowl propane tanks NASA jean shorts potato salad drone strike MOPAR tomahawk cruise missile velcro. John cena juicy flame-grilled shock and awe extra beef national security monster truck rally Fox News Call of Duty Starbucks. Garth brooks Applebee's apple pie Championship Pro Bass Fishing John Cena condiments extra beef."
# }
# ]
# }
# }

# Our first query
def self.search(query)
params = {
query: {
multi_match: {
query: query,
"fields": [
"title",
"body",
"user.first_name",
"user.last_name",
"comments.body"
]
}
}
}

###

# GET articles/_search
# {
# "query": {
# "multi_match": {
# "query": "alive",
# "fields": ["comments.body"]

# }
# }
# }
{
# "took" : 0,
# "timed_out" : false,
# "_shards" : {
# "total" : 1,
# "successful" : 1,
# "skipped" : 0,
# "failed" : 0
# },
# "hits" : {
# "total" : {
# "value" : 2,
# "relation" : "eq"
# },
# "max_score" : 0.58432883,
# "hits" : [
# {
# "_index" : "articles",
# "_type" : "_doc",
# "_id" : "64092",
# "_score" : 0.58432883,
# "_source" : {
# "title" : "Champagne Brain",
# "body" : "Expedita repellat accusamus provident hic. Quisquam inventore eligendi error ratione illo. Maxime dolore quidem voluptates qui. Nesciunt.",
# "user" : {
# "first_name" : "Peggie",
# "last_name" : "Lang"
# },
# "comments" : [
# {
# "body" : "Wanted dead or alive super bowl stars and stripes Van Halen Championship Pro Bass Fishing. Xxxl stars and stripes border wall redwood Garth Brooks commies get out of my country MGD. Lunchables Chuck Norris more bullets national security official sponsor tomahawk cruise missile Dallas Cowboys Hot Pockets bald eagles."
# }
# ]
# }
# },
# {
# "_index" : "articles",
# "_type" : "_doc",
# "_id" : "64093",
# "_score" : 0.4051479,
# "_source" : {
# "title" : "Action Tears",
# "body" : "Commodi atque voluptatum porro placeat. Commodi eum ea consequatur illo iste autem. Voluptatem quia error dicta quis debitis ut voluptates.",
# "user" : {
# "first_name" : "Peggie",
# "last_name" : "Lang"
# },
# "comments" : [
# {
# "body" : "Shopping John Wayne propane tanks DiGiorno Medal of Honor foreign policy. Fbi cia nsa hot dogs the media extra pulled pork WMD Star-Spangled Banner congress. Applebee's tomahawk cruise missile fireworks malls God Bless America red white and blue wanted dead or alive. Pro-wrestling dual-wielded machine guns I only speak American democracy ESPN2."
# },
# {
# "body" : "Liberty 7-Eleven Denny's Grand Slam Breakfast Lynyrd Skynyrd truthers independence 3D Blu-Ray shopping enemies of freedom. Texas mud flaps bald eagles crispy chicken strips Medal of Honor 7-Eleven Checkers NASA. Southwest breakfast burrito Branson Missouri Wal-Mart Chuck Norris 7-Eleven Arnold Schwarzenegger democracy 85oz soda. Pickup trucks DirecTV velcro Chuck Norris microwaved patriotic the government extra beef. Super bowl TGIF credit cards mission accomplished Marlboro reds SUVs 1776 Garth Brooks."
# }
# ]
# }
# }
# ]
# }
# }
self.__elasticsearch__.search(params).records.to_a
end

Keeping index in sync with data

We have a relationships within our articles index and we want to keep index up to date. So every change of comment should update coresponding artile document with that comments and every change in user should update documents with that user. Let’s dive in.

Article.has_many.comments
1
2
3
4
5
class Comment < ApplicationRecord
belongs_to :article, touch: true # add touch: true
# associated object will be touched (the updated_at / updated_on attributes set to current time) when this record is either saved or destroyed.
# (...)
end
1
2
3
4
5
module Searchable
included do
after_touch() { __elasticsearch__.index_document } # add after_touch callback
end
# (...)
Article.belongs_to.user
1
2
3
4
class User < ApplicationRecord
after_update { self.articles.each(&:touch) } # add after_update callback
# (...)
end

Banchmarks

Article.all => 45300

Rendered layout layouts/application.html.erb (Duration: 10556.6ms | Allocations: 5679024)
Completed 200 OK in 10558ms (Views: 10520.9ms | ActiveRecord: 36.3ms | Allocations: 5679325)

10.558 seconds
Completed 200 OK in 30314ms

Article.match_all => 45300

Completed 200 OK in 90763ms (Views: 48884.5ms | ActiveRecord: 41875.3ms | Allocations: 203877009)

44295ms

90.763

Querying
Write

See on large dataset

Pagination

kaminari

will_paginate