Searchkickは、Elasticsearchを利用した検索機能をサポートするGemです。
Searchkickでは、以下のような検索機能を利用できます。
- ステミング:例えば、
tomatoesでtomatoにもマッチします。 - 特殊文字:例えば、
jalapenoでjalapeñoにもマッチします。 - 余分な空白:例えば、
dishwasherでdish washerにもマッチします。 - スペルミス:例えば、
zuchiniでzucchiniにもマッチします。 - カスタム同義語:例えば、
popでsodaにもマッチします。
さらに、以下の機能もサポートしています。
- SQLのようなクエリ
- ダウンタイムなしの再インデックス
- ユーザーごとの検索結果のパーソナライズ
- オートコンプリート
- 「もしかして(Did you mean)」候補
- 多言語対応
- ActiveRecord、Mongoid、NoBrainerとの連携
1. はじめに
Searchkick Gemについて学習するため、最初にRailsプロジェクトを作成します。
rails new elastic_search
rails g model product name
rails db:migrate
必要なGemをインストールします。
# Gemfile
gem "ffaker"
gem "searchkick"
seedファイルを作成します。
# db/seeds.rb
100.times do
Product.create name: FFaker::Lorem.words.join(" ")
end
ProductモデルにSearchkickを追加します。
# app/models/product.rb
class Product < ApplicationRecord
searchkick
end
RailsコンソールでProductの再インデックスを実行し、データをElasticsearchサーバーへ送信します。
Product.reindex
再インデックスが完了したら、Searchkickを使って商品を検索できます。
Product.search("perferendis")
すべての商品を検索する場合は、キーワードに"*"を指定します。
Product.search("*")
コンソールログを見ると、SearchkickがElasticsearch DSLを使用したcurl形式のリクエストを生成していることを確認できます。Elasticsearch DSLについては、次の記事で詳しく説明します。
参考ソースコードには、上記2種類の基本的なクエリを試すためのサービスが実装されています。
Search::AllService.new.perform
Search::SimpleService.new("your key word").perform
2. クエリ
a. 特定の値を使用するwhere
Searchkickでは、SQLクエリのような形式で検索できます。
例:
Product.search "your key word", where: { in_stock: true }
このクエリでは、whereパラメータにキーと値から構成されるHashを渡します。
- キー:属性名
- 値:その属性に対する検索条件
Searchkickはフィルタリングを行い、whereパラメータの条件を満たす商品のみを検索します。
上記の例では、in_stock属性がtrueの商品だけが検索対象になります。
参考ソースコードには、whereクエリを試すためのサービスが実装されています。
Search::Where::SimpleService
.new("your key word", in_stock: false)
.perform
b. 範囲を使用するwhere
属性値がtrueやfalseのような特定の値ではなく、10から20までのような一定の範囲に含まれる場合は、gteとlteを使用します。
例:
Product.search "your key word",
where: { orders_count: { gte: 10, lte: 20 } }
Product.search "your key word",
where: { orders_count: (10..20) }
参考ソースコードには、gteとlteを使用したwhereクエリを試すためのサービスが実装されています。
Search::Where::RangeService
.new("your key word", gte: "10", lte: "20")
.perform
c. inを使用するwhere
属性値が[1, 2, 3, 4, 5]のような連続した範囲ではなく、[1, 3, 5]のような離散的な配列である場合は、inを使用します。
例:
Product.search "your key word",
where: { store_id: { in: [1, 3, 5, 7, 9] } }
参考ソースコードには、inを使用したwhereクエリを試すためのサービスが実装されています。
Search::Where::InService
.new("your key word", store_ids: [1, 3, 5, 7, 9])
.perform
d. notを使用するwhere
notはinとは反対の条件です。
Searchkickは、属性値が指定した配列に含まれていない商品を検索します。
例:
Product.search "your key word",
where: { store_id: { not: [1, 3, 5, 7, 9] } }
参考ソースコードには、notを使用したwhereクエリを試すためのサービスが実装されています。
Search::Where::NotService
.new("your key word", store_ids: [1, 3, 5, 7, 9])
.perform
e. allを使用するwhere
inと同様に、Searchkickではallも使用できます。
allを指定すると、属性値が指定した配列と一致する商品を検索します。
例:
# product.store_idが1、3、5、7、9のいずれかである商品を返す
Product.search "your key word",
where: { store_id: { in: [1, 3, 5, 7, 9] } }
# product.store_idが[1, 3, 5, 7, 9]である商品を返す
Product.search "your key word",
where: { store_id: { all: [1, 3, 5, 7, 9] } }
参考ソースコードには、このクエリを試すためのサービスが実装されています。
Search::Where::AllService
.new("your key word", store_ids: [1, 3, 5, 7, 9])
.perform
Search::Where::AllService
.new("your key word", store_ids: [1])
.perform
最初のクエリは常に空の配列を返します。これは、product.store_idが配列を返すことがないためです。
f. 配列属性に対するall
上記の理由から、allは単一の値を返す属性よりも、配列を返す属性に対して使用するのが一般的です。
以下のような関連を持つモデルを追加します。
# app/models/product.rb
has_many :order_details
has_many :orders, through: :order_details
# app/models/order.rb
has_many :order_details
has_many :products, through: :order_details
これにより、product.order_idsを呼び出し、配列を取得できます。
Product.first.order_ids
# => [1, 2, 3]
次に、allを使用してorder_idsから商品を検索します。
例:
Product.search(
"your key word",
where: { order_ids: [1, 2, 3] }
)
しかし、この検索結果は常に空の配列になります。
g. all、search_data、search_import
検索結果が空になる理由は、再インデックス時にSearchkickがElasticsearchへ送信するデータが、デフォルトではproductsテーブルのカラムだけだからです。
つまり、SearchkickからElasticsearchへ送信されるのは、以下のようなデータです。
idnamecreated_atupdated_atin_stockorders_countstore_id
そのため、商品のorder_idsを条件に検索しても、Elasticsearchは常に空の配列を返します。
Elasticsearchへ送信するデータは、search_dataメソッドで定義できます。また、以下のようにsearch_importスコープも追加します。
# app/models/product.rb
class Product < ApplicationRecord
belongs_to :store
has_many :order_details
has_many :orders, through: :order_details
searchkick
scope :search_import, -> { includes(:orders) }
def search_data
attributes.merge(order_ids: order_ids)
end
end
Productの再インデックスを実行します。
Product.reindex
再度allを使用したクエリを実行すると、検索結果を取得できます。
Product.search(
"your key word",
where: { order_ids: [1, 2, 3] }
)
search_dataとsearch_importについては、後のセクションで詳しく説明します。
なお、allクエリは、商品のorder_idsが指定した配列と同じ、またはその配列を含む場合にのみ、その商品を検索します。
参考ソースコードには、このクエリを試すためのサービスが実装されています。
Search::Where::AllWithArrayService
.new("your key word", order_ids: [1, 2, 3])
.perform
h. existsを使用するwhere
exists: trueを使用すると、store_idが存在する商品だけをフィルタリングできます。
Product.search(
"your key word",
where: { store_id: { exists: true } }
)
一方、store_idがnilの商品をフィルタリングする場合は、exists: trueを使用するのではなく、store_id: nilを直接指定します。
Product.search(
"your key word",
where: { store_id: nil }
)
参考ソースコードには、上記2つのケースを試すためのサービスが実装されています。
Search::Where::ExistsService
.new("your key word", store_exists: true)
.perform
Search::Where::ExistsService
.new("your key word", store_exists: false)
.perform
i. andを使用するwhere
whereのデフォルト演算子はandです。
返されるレコードは、whereに指定したすべての条件を満たす必要があります。
例:
Product.search(
"your key word",
where: {
store_id: 1,
orders_count: { gte: 70, lte: 100 }
}
)
このクエリは、_andを使用して次のように書き換えられます。
Product.search(
"your key word",
where: {
_and: [
{ store_id: 1 },
{ orders_count: (70..100) }
]
}
)
参考ソースコードには、_andクエリを試すためのサービスが実装されています。
Search::Where::AndService
.new(
"your key word",
store_id: 1,
orders_count: 70..100
)
.perform
j. orを使用するwhere
2つの条件のうち、いずれか一方を満たす商品をフィルタリングする場合は、_orクエリを使用します。
Product.search(
"your key word",
where: {
_or: [
{ store_id: 1 },
{ orders_count: (70..100) }
]
}
)
参考ソースコードには、_orクエリを試すためのサービスが実装されています。
Search::Where::OrService
.new(
"your key word",
store_id: 1,
orders_count: 70..100
)
.perform
k. _notを使用するwhere
_notクエリには、複数の属性ではなく、1つの属性のみを渡すことができます。
store_idに対して_notを使用する例:
Product.search(
"your key word",
where: {
_not: { store_id: 1 }
}
)
Search::Where::NotStoreIdService
.new("your key word", store_id: 1)
.perform
orders_countに対して_notを使用する例:
Product.search(
"your key word",
where: {
_not: { orders_count: 70..100 }
}
)
Search::Where::NotOrdersCountService
.new("your key word", orders_count: 70..100)
.perform
複数の属性を渡した場合、Searchkickは最後に指定された属性だけを使用してフィルタリングします。
例:
Product.search(
"your key word",
where: {
_not: { store_id: 1 },
_not: { orders_count: 70..100 }
}
)
これは、以下のクエリと同じです。
Product.search(
"your key word",
where: {
_not: { orders_count: 70..100 }
}
)
3. 検索対象フィールド
デフォルトでは、Elasticsearchは商品のすべての属性を検索対象とします。
fieldsオプションを使用すると、Elasticsearchが検索する属性を限定できます。
例:
Product.search("your key word", fields: ["*"])
Product.search("your key word", fields: [:name])
Product.search("your key word", fields: [:description])
参考ソースコードには、fieldsオプションを使用した検索を試すためのサービスが実装されています。
Search::FieldsService
.new("your key word", fields: ["*"])
.perform
Search::FieldsService
.new("your key word", fields: [:name])
.perform
Search::FieldsService
.new("your key word", fields: [:description])
.perform
4. limitとoffset
Searchkickには、SQLのLIMITとOFFSETに相当するlimitとoffsetオプションがあります。
例:
Product.search(
"your key word",
limit: 10,
offset: 20
)
この場合、Searchkickは検索された商品の先頭20件をスキップし、その後の10件だけを返します。
参考ソースコードには、limitとoffsetを使用した検索を試すためのサービスが実装されています。
Search::LimitOffsetService
.new("your key word", limit: 10, offset: 20)
.perform
5. 検索結果
Searchkickのsearchメソッドは、Searchkick::Resultsオブジェクトを返します。
このオブジェクトは配列のように扱うことができ、以下のような配列メソッドを呼び出せます。
results = Product.search("your key word")
results.size
results.length
results.any?
results.each { |result| ... }
Searchkick::Resultsには、tookとresponseというメソッドもあります。
検索にかかった時間をミリ秒単位で取得します。
results.took
Elasticsearchから返されたレスポンスをJSON形式で取得します。
results.response
Searchkick::Resultsには、total_countメソッドもあります。
total_countは、ページネーションを適用する前に検索された商品の総件数を返します。これは、count、length、sizeとは異なります。
例:
Product.search("your key word", limit: 10).length
# => 10
Product.search("your key word", limit: 10).size
# => 10
Product.search("your key word", limit: 10).count
# => 10
Product.search("your key word", limit: 10).total_count
# => 100