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 | 18 | - reindex without downtime |
19 | 19 | - easily personalize results for each user [master branch] |
20 | 20 | - autocomplete [master branch] |
21 | +- “Did you mean” suggestions [master branch] | |
21 | 22 | |
22 | 23 | :tangerine: Battle-tested at [Instacart](https://www.instacart.com) |
23 | 24 | |
... | ... | @@ -244,11 +245,30 @@ Reindex and search with: |
244 | 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 | 267 | ### Facets |
248 | 268 | |
249 | 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 | 274 | Advanced |
... | ... | @@ -371,8 +391,6 @@ Thanks to Karel Minarik for [Tire](https://github.com/karmi/tire), Jaroslav Kali |
371 | 391 | ## TODO |
372 | 392 | |
373 | 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 | 395 | ## Contributing |
378 | 396 | ... | ... |
lib/searchkick.rb
1 | +require "tire" | |
1 | 2 | require "searchkick/version" |
2 | 3 | require "searchkick/reindex" |
4 | +require "searchkick/results" | |
3 | 5 | require "searchkick/search" |
4 | 6 | require "searchkick/model" |
5 | 7 | require "searchkick/tasks" |
6 | -require "tire" | |
7 | 8 | |
8 | 9 | # TODO find better ActiveModel hook |
9 | 10 | ActiveModel::AttributeMethods::ClassMethods.send(:include, Searchkick::Model) | ... | ... |
lib/searchkick/reindex.rb
... | ... | @@ -137,16 +137,24 @@ module Searchkick |
137 | 137 | } |
138 | 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 | 145 | type: "multi_field", |
144 | 146 | fields: { |
145 | 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 | 158 | end |
151 | 159 | |
152 | 160 | mappings = { | ... | ... |
... | ... | @@ -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 | 17 | ["_all"] |
18 | 18 | end |
19 | 19 | end |
20 | + | |
20 | 21 | operator = options[:partial] ? "or" : "and" |
22 | + | |
23 | + # model and eagar loading | |
21 | 24 | load = options[:load].nil? ? true : options[:load] |
22 | 25 | load = (options[:include] ? {include: options[:include]} : true) if load |
26 | + | |
27 | + # pagination | |
23 | 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 | 36 | query do |
34 | 37 | custom_filters_score do |
35 | 38 | query do |
... | ... | @@ -82,7 +85,8 @@ module Searchkick |
82 | 85 | score_mode "total" |
83 | 86 | end |
84 | 87 | end |
85 | - from options[:offset] if options[:offset] | |
88 | + size per_page if per_page | |
89 | + from offset if offset | |
86 | 90 | explain options[:explain] if options[:explain] |
87 | 91 | |
88 | 92 | # order |
... | ... | @@ -173,7 +177,24 @@ module Searchkick |
173 | 177 | end |
174 | 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 | 198 | end |
178 | 199 | |
179 | 200 | end | ... | ... |
test/match_test.rb
... | ... | @@ -127,4 +127,26 @@ class TestMatch < Minitest::Unit::TestCase |
127 | 127 | assert_search "hum", ["Hummus"], autocomplete: true, fields: [:name] |
128 | 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 | 152 | end | ... | ... |
test/test_helper.rb