Commit d83495919997af721f79f0ea2316897c5a56be9d
1 parent
df6c952e
Exists in
master
and in
21 other branches
Added suggestions
Showing
7 changed files
with
115 additions
and
21 deletions
Show diff stats
README.md
@@ -18,6 +18,7 @@ Plus: | @@ -18,6 +18,7 @@ Plus: | ||
18 | - reindex without downtime | 18 | - reindex without downtime |
19 | - easily personalize results for each user [master branch] | 19 | - easily personalize results for each user [master branch] |
20 | - autocomplete [master branch] | 20 | - autocomplete [master branch] |
21 | +- “Did you mean” suggestions [master branch] | ||
21 | 22 | ||
22 | :tangerine: Battle-tested at [Instacart](https://www.instacart.com) | 23 | :tangerine: Battle-tested at [Instacart](https://www.instacart.com) |
23 | 24 | ||
@@ -244,11 +245,30 @@ Reindex and search with: | @@ -244,11 +245,30 @@ Reindex and search with: | ||
244 | Product.search "milk", user_id: 8 | 245 | Product.search "milk", user_id: 8 |
245 | ``` | 246 | ``` |
246 | 247 | ||
248 | +### Suggestions [master branch] | ||
249 | + | ||
250 | +Did you mean: :sunglasses: | ||
251 | + | ||
252 | +```ruby | ||
253 | +class Product < ActiveRecord::Base | ||
254 | + searchkick suggest: [:name] # fields to generate suggestions | ||
255 | +end | ||
256 | +``` | ||
257 | + | ||
258 | +Reindex and search with: | ||
259 | + | ||
260 | +```ruby | ||
261 | +products = Product.search "peantu butta", suggest: true | ||
262 | +products.suggestion # peanut butter | ||
263 | +``` | ||
264 | + | ||
265 | +Returns `nil` when there are no suggestions. | ||
266 | + | ||
247 | ### Facets | 267 | ### Facets |
248 | 268 | ||
249 | ```ruby | 269 | ```ruby |
250 | -search = Product.search "2% Milk", facets: [:store_id, :aisle_id] | ||
251 | -p search.facets | 270 | +products = Product.search "2% Milk", facets: [:store_id, :aisle_id] |
271 | +p products.facets | ||
252 | ``` | 272 | ``` |
253 | 273 | ||
254 | Advanced | 274 | Advanced |
@@ -371,8 +391,6 @@ Thanks to Karel Minarik for [Tire](https://github.com/karmi/tire), Jaroslav Kali | @@ -371,8 +391,6 @@ Thanks to Karel Minarik for [Tire](https://github.com/karmi/tire), Jaroslav Kali | ||
371 | ## TODO | 391 | ## TODO |
372 | 392 | ||
373 | - Make Searchkick work with any language | 393 | - Make Searchkick work with any language |
374 | -- Built-in synonyms from WordNet | ||
375 | -- [Did you mean?](http://www.elasticsearch.org/guide/reference/api/search/suggest/) | ||
376 | 394 | ||
377 | ## Contributing | 395 | ## Contributing |
378 | 396 |
lib/searchkick.rb
1 | +require "tire" | ||
1 | require "searchkick/version" | 2 | require "searchkick/version" |
2 | require "searchkick/reindex" | 3 | require "searchkick/reindex" |
4 | +require "searchkick/results" | ||
3 | require "searchkick/search" | 5 | require "searchkick/search" |
4 | require "searchkick/model" | 6 | require "searchkick/model" |
5 | require "searchkick/tasks" | 7 | require "searchkick/tasks" |
6 | -require "tire" | ||
7 | 8 | ||
8 | # TODO find better ActiveModel hook | 9 | # TODO find better ActiveModel hook |
9 | ActiveModel::AttributeMethods::ClassMethods.send(:include, Searchkick::Model) | 10 | ActiveModel::AttributeMethods::ClassMethods.send(:include, Searchkick::Model) |
lib/searchkick/reindex.rb
@@ -137,16 +137,24 @@ module Searchkick | @@ -137,16 +137,24 @@ module Searchkick | ||
137 | } | 137 | } |
138 | end | 138 | end |
139 | 139 | ||
140 | - # autocomplete | ||
141 | - (options[:autocomplete] || []).each do |field| | ||
142 | - mapping[field] = { | 140 | + # autocomplete and suggest |
141 | + autocomplete = options[:autocomplete] || [] | ||
142 | + suggest = options[:suggest] || [] | ||
143 | + (autocomplete + suggest).uniq.each do |field| | ||
144 | + field_mapping = { | ||
143 | type: "multi_field", | 145 | type: "multi_field", |
144 | fields: { | 146 | fields: { |
145 | field => {type: "string", index: "not_analyzed"}, | 147 | field => {type: "string", index: "not_analyzed"}, |
146 | - "analyzed" => {type: "string", index: "analyzed"}, | ||
147 | - "autocomplete" => {type: "string", index: "analyzed", analyzer: "searchkick_autocomplete_index"} | 148 | + "analyzed" => {type: "string", index: "analyzed"} |
148 | } | 149 | } |
149 | } | 150 | } |
151 | + if autocomplete.include?(field) | ||
152 | + field_mapping[:fields]["autocomplete"] = {type: "string", index: "analyzed", analyzer: "searchkick_autocomplete_index"} | ||
153 | + end | ||
154 | + if suggest.include?(field) | ||
155 | + field_mapping[:fields]["suggest"] = {type: "string", index: "analyzed", analyzer: "standard"} | ||
156 | + end | ||
157 | + mapping[field] = field_mapping | ||
150 | end | 158 | end |
151 | 159 | ||
152 | mappings = { | 160 | mappings = { |
@@ -0,0 +1,23 @@ | @@ -0,0 +1,23 @@ | ||
1 | +module Searchkick | ||
2 | + class Results < Tire::Results::Collection | ||
3 | + | ||
4 | + # TODO use all fields | ||
5 | + # return nil suggestion if term does not change | ||
6 | + def suggestion | ||
7 | + if @response["suggest"] | ||
8 | + original_term = options[:term].downcase | ||
9 | + suggestion = original_term.dup | ||
10 | + @response["suggest"].values.first.each do |s| | ||
11 | + first_option = s["options"].first | ||
12 | + if first_option | ||
13 | + suggestion.sub!(s["text"], first_option["text"]) | ||
14 | + end | ||
15 | + end | ||
16 | + suggestion == original_term ? nil : suggestion | ||
17 | + else | ||
18 | + raise "Pass `suggest: true` to the search method for suggestions" | ||
19 | + end | ||
20 | + end | ||
21 | + | ||
22 | + end | ||
23 | +end |
lib/searchkick/search.rb
@@ -17,19 +17,22 @@ module Searchkick | @@ -17,19 +17,22 @@ module Searchkick | ||
17 | ["_all"] | 17 | ["_all"] |
18 | end | 18 | end |
19 | end | 19 | end |
20 | + | ||
20 | operator = options[:partial] ? "or" : "and" | 21 | operator = options[:partial] ? "or" : "and" |
22 | + | ||
23 | + # model and eagar loading | ||
21 | load = options[:load].nil? ? true : options[:load] | 24 | load = options[:load].nil? ? true : options[:load] |
22 | load = (options[:include] ? {include: options[:include]} : true) if load | 25 | load = (options[:include] ? {include: options[:include]} : true) if load |
26 | + | ||
27 | + # pagination | ||
23 | page = options.has_key?(:page) ? [options[:page].to_i, 1].max : nil | 28 | page = options.has_key?(:page) ? [options[:page].to_i, 1].max : nil |
24 | - tire_options = { | ||
25 | - load: load, | ||
26 | - page: page, | ||
27 | - per_page: options[:limit] || options[:per_page] || 100000 # return all | ||
28 | - } | ||
29 | - tire_options[:index] = options[:index_name] if options[:index_name] | 29 | + per_page = options[:limit] || options[:per_page] |
30 | + offset = options[:offset] || (page && per_page && (page - 1) * per_page) | ||
31 | + index_name = options[:index_name] || index.name | ||
30 | 32 | ||
31 | - collection = | ||
32 | - tire.search tire_options do | 33 | + # TODO lose Tire DSL for more flexibility |
34 | + s = | ||
35 | + Tire::Search::Search.new do | ||
33 | query do | 36 | query do |
34 | custom_filters_score do | 37 | custom_filters_score do |
35 | query do | 38 | query do |
@@ -82,7 +85,8 @@ module Searchkick | @@ -82,7 +85,8 @@ module Searchkick | ||
82 | score_mode "total" | 85 | score_mode "total" |
83 | end | 86 | end |
84 | end | 87 | end |
85 | - from options[:offset] if options[:offset] | 88 | + size per_page if per_page |
89 | + from offset if offset | ||
86 | explain options[:explain] if options[:explain] | 90 | explain options[:explain] if options[:explain] |
87 | 91 | ||
88 | # order | 92 | # order |
@@ -173,7 +177,24 @@ module Searchkick | @@ -173,7 +177,24 @@ module Searchkick | ||
173 | end | 177 | end |
174 | end | 178 | end |
175 | 179 | ||
176 | - collection | 180 | + payload = s.to_hash |
181 | + | ||
182 | + # suggested fields | ||
183 | + suggest_fields = options[:fields] || @searchkick_options[:suggest] || [] | ||
184 | + if options[:suggest] and suggest_fields.any? | ||
185 | + payload[:suggest] = {text: term} | ||
186 | + suggest_fields.each do |field| | ||
187 | + payload[:suggest][field] = { | ||
188 | + term: { | ||
189 | + field: "#{field}.suggest", | ||
190 | + suggest_mode: "popular" | ||
191 | + } | ||
192 | + } | ||
193 | + end | ||
194 | + end | ||
195 | + | ||
196 | + search = Tire::Search::Search.new(index_name, load: load, payload: payload) | ||
197 | + Searchkick::Results.new(search.json, search.options.merge(term: term)) | ||
177 | end | 198 | end |
178 | 199 | ||
179 | end | 200 | end |
test/match_test.rb
@@ -127,4 +127,26 @@ class TestMatch < Minitest::Unit::TestCase | @@ -127,4 +127,26 @@ class TestMatch < Minitest::Unit::TestCase | ||
127 | assert_search "hum", ["Hummus"], autocomplete: true, fields: [:name] | 127 | assert_search "hum", ["Hummus"], autocomplete: true, fields: [:name] |
128 | end | 128 | end |
129 | 129 | ||
130 | + # suggest | ||
131 | + | ||
132 | + def test_suggest | ||
133 | + store_names ["Great White Shark", "Hammerhead Shark", "Tiger Shark"] | ||
134 | + assert_suggest "How Big is a Tigre Shar?", "how big is a tiger shark?" | ||
135 | + end | ||
136 | + | ||
137 | + def test_suggest_perfect | ||
138 | + store_names ["Tiger Shark", "Great White Shark"] | ||
139 | + assert_suggest "Tiger Shark", nil # no correction | ||
140 | + end | ||
141 | + | ||
142 | + def test_suggest_without_option | ||
143 | + assert_raises(RuntimeError){ Product.search("hi").suggestion } | ||
144 | + end | ||
145 | + | ||
146 | + protected | ||
147 | + | ||
148 | + def assert_suggest(term, expected) | ||
149 | + assert_equal expected, Product.search(term, suggest: true).suggestion | ||
150 | + end | ||
151 | + | ||
130 | end | 152 | end |
test/test_helper.rb
@@ -48,7 +48,8 @@ class Product < ActiveRecord::Base | @@ -48,7 +48,8 @@ class Product < ActiveRecord::Base | ||
48 | ["burger", "hamburger"], | 48 | ["burger", "hamburger"], |
49 | ["bandaid", "bandag"] | 49 | ["bandaid", "bandag"] |
50 | ], | 50 | ], |
51 | - autocomplete: [:name] | 51 | + autocomplete: [:name], |
52 | + suggest: [:name] | ||
52 | 53 | ||
53 | attr_accessor :conversions, :user_ids | 54 | attr_accessor :conversions, :user_ids |
54 | 55 |