diff --git a/README.md b/README.md index 53279b1..7901954 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Plus: - reindex without downtime - easily personalize results for each user [master branch] - autocomplete [master branch] +- “Did you mean” suggestions [master branch] :tangerine: Battle-tested at [Instacart](https://www.instacart.com) @@ -244,11 +245,30 @@ Reindex and search with: Product.search "milk", user_id: 8 ``` +### Suggestions [master branch] + +Did you mean: :sunglasses: + +```ruby +class Product < ActiveRecord::Base + searchkick suggest: [:name] # fields to generate suggestions +end +``` + +Reindex and search with: + +```ruby +products = Product.search "peantu butta", suggest: true +products.suggestion # peanut butter +``` + +Returns `nil` when there are no suggestions. + ### Facets ```ruby -search = Product.search "2% Milk", facets: [:store_id, :aisle_id] -p search.facets +products = Product.search "2% Milk", facets: [:store_id, :aisle_id] +p products.facets ``` Advanced @@ -371,8 +391,6 @@ Thanks to Karel Minarik for [Tire](https://github.com/karmi/tire), Jaroslav Kali ## TODO - Make Searchkick work with any language -- Built-in synonyms from WordNet -- [Did you mean?](http://www.elasticsearch.org/guide/reference/api/search/suggest/) ## Contributing diff --git a/lib/searchkick.rb b/lib/searchkick.rb index 2c2184b..fe38fe1 100644 --- a/lib/searchkick.rb +++ b/lib/searchkick.rb @@ -1,9 +1,10 @@ +require "tire" require "searchkick/version" require "searchkick/reindex" +require "searchkick/results" require "searchkick/search" require "searchkick/model" require "searchkick/tasks" -require "tire" # TODO find better ActiveModel hook ActiveModel::AttributeMethods::ClassMethods.send(:include, Searchkick::Model) diff --git a/lib/searchkick/reindex.rb b/lib/searchkick/reindex.rb index 5338cd8..50fa74a 100644 --- a/lib/searchkick/reindex.rb +++ b/lib/searchkick/reindex.rb @@ -137,16 +137,24 @@ module Searchkick } end - # autocomplete - (options[:autocomplete] || []).each do |field| - mapping[field] = { + # autocomplete and suggest + autocomplete = options[:autocomplete] || [] + suggest = options[:suggest] || [] + (autocomplete + suggest).uniq.each do |field| + field_mapping = { type: "multi_field", fields: { field => {type: "string", index: "not_analyzed"}, - "analyzed" => {type: "string", index: "analyzed"}, - "autocomplete" => {type: "string", index: "analyzed", analyzer: "searchkick_autocomplete_index"} + "analyzed" => {type: "string", index: "analyzed"} } } + if autocomplete.include?(field) + field_mapping[:fields]["autocomplete"] = {type: "string", index: "analyzed", analyzer: "searchkick_autocomplete_index"} + end + if suggest.include?(field) + field_mapping[:fields]["suggest"] = {type: "string", index: "analyzed", analyzer: "standard"} + end + mapping[field] = field_mapping end mappings = { diff --git a/lib/searchkick/results.rb b/lib/searchkick/results.rb new file mode 100644 index 0000000..a040eff --- /dev/null +++ b/lib/searchkick/results.rb @@ -0,0 +1,23 @@ +module Searchkick + class Results < Tire::Results::Collection + + # TODO use all fields + # return nil suggestion if term does not change + def suggestion + if @response["suggest"] + original_term = options[:term].downcase + suggestion = original_term.dup + @response["suggest"].values.first.each do |s| + first_option = s["options"].first + if first_option + suggestion.sub!(s["text"], first_option["text"]) + end + end + suggestion == original_term ? nil : suggestion + else + raise "Pass `suggest: true` to the search method for suggestions" + end + end + + end +end diff --git a/lib/searchkick/search.rb b/lib/searchkick/search.rb index bf8782e..51a3d2a 100644 --- a/lib/searchkick/search.rb +++ b/lib/searchkick/search.rb @@ -17,19 +17,22 @@ module Searchkick ["_all"] end end + operator = options[:partial] ? "or" : "and" + + # model and eagar loading load = options[:load].nil? ? true : options[:load] load = (options[:include] ? {include: options[:include]} : true) if load + + # pagination page = options.has_key?(:page) ? [options[:page].to_i, 1].max : nil - tire_options = { - load: load, - page: page, - per_page: options[:limit] || options[:per_page] || 100000 # return all - } - tire_options[:index] = options[:index_name] if options[:index_name] + per_page = options[:limit] || options[:per_page] + offset = options[:offset] || (page && per_page && (page - 1) * per_page) + index_name = options[:index_name] || index.name - collection = - tire.search tire_options do + # TODO lose Tire DSL for more flexibility + s = + Tire::Search::Search.new do query do custom_filters_score do query do @@ -82,7 +85,8 @@ module Searchkick score_mode "total" end end - from options[:offset] if options[:offset] + size per_page if per_page + from offset if offset explain options[:explain] if options[:explain] # order @@ -173,7 +177,24 @@ module Searchkick end end - collection + payload = s.to_hash + + # suggested fields + suggest_fields = options[:fields] || @searchkick_options[:suggest] || [] + if options[:suggest] and suggest_fields.any? + payload[:suggest] = {text: term} + suggest_fields.each do |field| + payload[:suggest][field] = { + term: { + field: "#{field}.suggest", + suggest_mode: "popular" + } + } + end + end + + search = Tire::Search::Search.new(index_name, load: load, payload: payload) + Searchkick::Results.new(search.json, search.options.merge(term: term)) end end diff --git a/test/match_test.rb b/test/match_test.rb index 4eecc99..3bc17f4 100644 --- a/test/match_test.rb +++ b/test/match_test.rb @@ -127,4 +127,26 @@ class TestMatch < Minitest::Unit::TestCase assert_search "hum", ["Hummus"], autocomplete: true, fields: [:name] end + # suggest + + def test_suggest + store_names ["Great White Shark", "Hammerhead Shark", "Tiger Shark"] + assert_suggest "How Big is a Tigre Shar?", "how big is a tiger shark?" + end + + def test_suggest_perfect + store_names ["Tiger Shark", "Great White Shark"] + assert_suggest "Tiger Shark", nil # no correction + end + + def test_suggest_without_option + assert_raises(RuntimeError){ Product.search("hi").suggestion } + end + + protected + + def assert_suggest(term, expected) + assert_equal expected, Product.search(term, suggest: true).suggestion + end + end diff --git a/test/test_helper.rb b/test/test_helper.rb index ce23db1..81497ed 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -48,7 +48,8 @@ class Product < ActiveRecord::Base ["burger", "hamburger"], ["bandaid", "bandag"] ], - autocomplete: [:name] + autocomplete: [:name], + suggest: [:name] attr_accessor :conversions, :user_ids -- libgit2 0.21.0