Commit d83495919997af721f79f0ea2316897c5a56be9d

Authored by Andrew Kane
1 parent df6c952e

Added suggestions

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 = {
... ...
lib/searchkick/results.rb 0 → 100644
... ... @@ -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 &lt; 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
... ... @@ -48,7 +48,8 @@ class Product &lt; ActiveRecord::Base
48 48 ["burger", "hamburger"],
49 49 ["bandaid", "bandag"]
50 50 ],
51   - autocomplete: [:name]
  51 + autocomplete: [:name],
  52 + suggest: [:name]
52 53  
53 54 attr_accessor :conversions, :user_ids
54 55  
... ...