Commit cd9aa612d66ace44be9253cd69d5e8e54c6dcbe7
1 parent
26a57398
Exists in
master
and in
21 other branches
First version of typeahead - closes #5
Showing
5 changed files
with
101 additions
and
16 deletions
Show diff stats
README.md
@@ -17,6 +17,7 @@ Plus: | @@ -17,6 +17,7 @@ Plus: | ||
17 | - query like SQL - no need to learn a new query language | 17 | - query like SQL - no need to learn a new query language |
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 | +- typeahead / autocomplete [master branch] | ||
20 | 21 | ||
21 | :tangerine: Battle-tested at [Instacart](https://www.instacart.com) | 22 | :tangerine: Battle-tested at [Instacart](https://www.instacart.com) |
22 | 23 | ||
@@ -130,6 +131,22 @@ To change this, use: | @@ -130,6 +131,22 @@ To change this, use: | ||
130 | Product.search "fresh honey", partial: true # fresh OR honey | 131 | Product.search "fresh honey", partial: true # fresh OR honey |
131 | ``` | 132 | ``` |
132 | 133 | ||
134 | +### Typeahead / Autocomplete [master branch] | ||
135 | + | ||
136 | +You must specify which fields use this feature since this can increase the index size significantly. Don’t worry - this gives you blazing faster queries. | ||
137 | + | ||
138 | +```ruby | ||
139 | +class Product < ActiveRecord::Base | ||
140 | + searchkick typeahead: [:name] | ||
141 | +end | ||
142 | +``` | ||
143 | + | ||
144 | +Reindex and search with: | ||
145 | + | ||
146 | +```ruby | ||
147 | +Product.search "puddi", typeahead: true | ||
148 | +``` | ||
149 | + | ||
133 | ### Synonyms | 150 | ### Synonyms |
134 | 151 | ||
135 | ```ruby | 152 | ```ruby |
@@ -349,11 +366,10 @@ end | @@ -349,11 +366,10 @@ end | ||
349 | 366 | ||
350 | ## Thanks | 367 | ## Thanks |
351 | 368 | ||
352 | -Thanks to Karel Minarik for [Tire](https://github.com/karmi/tire) and Jaroslav Kalistsuk for [zero downtime reindexing](https://gist.github.com/jarosan/3124884). | 369 | +Thanks to Karel Minarik for [Tire](https://github.com/karmi/tire), Jaroslav Kalistsuk for [zero downtime reindexing](https://gist.github.com/jarosan/3124884), and Alex Leschenko for [Elasticsearch autocomplete](https://github.com/leschenko/elasticsearch_autocomplete). |
353 | 370 | ||
354 | ## TODO | 371 | ## TODO |
355 | 372 | ||
356 | -- Custom results for each user | ||
357 | - Make Searchkick work with any language | 373 | - Make Searchkick work with any language |
358 | - Built-in synonyms from WordNet | 374 | - Built-in synonyms from WordNet |
359 | - [Did you mean?](http://www.elasticsearch.org/guide/reference/api/search/suggest/) | 375 | - [Did you mean?](http://www.elasticsearch.org/guide/reference/api/search/suggest/) |
lib/searchkick/reindex.rb
@@ -68,6 +68,17 @@ module Searchkick | @@ -68,6 +68,17 @@ module Searchkick | ||
68 | type: "custom", | 68 | type: "custom", |
69 | tokenizer: "standard", | 69 | tokenizer: "standard", |
70 | filter: ["standard", "lowercase", "asciifolding", "stop", "snowball"] | 70 | filter: ["standard", "lowercase", "asciifolding", "stop", "snowball"] |
71 | + }, | ||
72 | + # https://github.com/leschenko/elasticsearch_autocomplete/blob/master/lib/elasticsearch_autocomplete/analyzers.rb | ||
73 | + searchkick_typeahead_index: { | ||
74 | + type: "custom", | ||
75 | + tokenizer: "searchkick_typeahead_ngram", | ||
76 | + filter: ["lowercase", "asciifolding"] | ||
77 | + }, | ||
78 | + searchkick_typeahead_search: { | ||
79 | + type: "custom", | ||
80 | + tokenizer: "keyword", | ||
81 | + filter: ["lowercase", "asciifolding"] | ||
71 | } | 82 | } |
72 | }, | 83 | }, |
73 | filter: { | 84 | filter: { |
@@ -82,9 +93,18 @@ module Searchkick | @@ -82,9 +93,18 @@ module Searchkick | ||
82 | output_unigrams: false, | 93 | output_unigrams: false, |
83 | output_unigrams_if_no_shingles: true | 94 | output_unigrams_if_no_shingles: true |
84 | } | 95 | } |
96 | + }, | ||
97 | + tokenizer: { | ||
98 | + searchkick_typeahead_ngram: { | ||
99 | + type: "edgeNGram", | ||
100 | + min_gram: 1, | ||
101 | + max_gram: 50 | ||
102 | + } | ||
85 | } | 103 | } |
86 | } | 104 | } |
87 | }.merge(options[:settings] || {}) | 105 | }.merge(options[:settings] || {}) |
106 | + | ||
107 | + # synonyms | ||
88 | synonyms = options[:synonyms] || [] | 108 | synonyms = options[:synonyms] || [] |
89 | if synonyms.any? | 109 | if synonyms.any? |
90 | settings[:analysis][:filter][:searchkick_synonym] = { | 110 | settings[:analysis][:filter][:searchkick_synonym] = { |
@@ -103,6 +123,8 @@ module Searchkick | @@ -103,6 +123,8 @@ module Searchkick | ||
103 | end | 123 | end |
104 | 124 | ||
105 | mapping = {} | 125 | mapping = {} |
126 | + | ||
127 | + # conversions | ||
106 | if options[:conversions] | 128 | if options[:conversions] |
107 | mapping[:conversions] = { | 129 | mapping[:conversions] = { |
108 | type: "nested", | 130 | type: "nested", |
@@ -113,6 +135,18 @@ module Searchkick | @@ -113,6 +135,18 @@ module Searchkick | ||
113 | } | 135 | } |
114 | end | 136 | end |
115 | 137 | ||
138 | + # typeahead | ||
139 | + (options[:typeahead] || []).each do |field| | ||
140 | + mapping[field] = { | ||
141 | + type: "multi_field", | ||
142 | + fields: { | ||
143 | + field => {type: "string", index: "not_analyzed"}, | ||
144 | + "analyzed" => {type: "string", index: "analyzed"}, | ||
145 | + "typeahead" => {type: "string", index: "analyzed", analyzer: "searchkick_typeahead_index"} | ||
146 | + } | ||
147 | + } | ||
148 | + end | ||
149 | + | ||
116 | mappings = { | 150 | mappings = { |
117 | document_type.to_sym => { | 151 | document_type.to_sym => { |
118 | properties: mapping, | 152 | properties: mapping, |
lib/searchkick/search.rb
@@ -3,7 +3,20 @@ module Searchkick | @@ -3,7 +3,20 @@ module Searchkick | ||
3 | 3 | ||
4 | def search(term, options = {}) | 4 | def search(term, options = {}) |
5 | term = term.to_s | 5 | term = term.to_s |
6 | - fields = options[:fields] ? options[:fields].map{|f| "#{f}.analyzed" } : ["_all"] | 6 | + fields = |
7 | + if options[:fields] | ||
8 | + if options[:typeahead] | ||
9 | + options[:fields].map{|f| "#{f}.typeahead" } | ||
10 | + else | ||
11 | + options[:fields].map{|f| "#{f}.analyzed" } | ||
12 | + end | ||
13 | + else | ||
14 | + if options[:typeahead] | ||
15 | + (@searchkick_options[:typeahead] || []).map{|f| "#{f}.typeahead" } | ||
16 | + else | ||
17 | + ["_all"] | ||
18 | + end | ||
19 | + end | ||
7 | operator = options[:partial] ? "or" : "and" | 20 | operator = options[:partial] ? "or" : "and" |
8 | load = options[:load].nil? ? true : options[:load] | 21 | load = options[:load].nil? ? true : options[:load] |
9 | load = (options[:include] ? {include: options[:include]} : true) if load | 22 | load = (options[:include] ? {include: options[:include]} : true) if load |
@@ -22,18 +35,22 @@ module Searchkick | @@ -22,18 +35,22 @@ module Searchkick | ||
22 | query do | 35 | query do |
23 | boolean do | 36 | boolean do |
24 | must do | 37 | must do |
25 | - dis_max do | ||
26 | - query do | ||
27 | - match fields, term, boost: 10, operator: operator, analyzer: "searchkick_search" | ||
28 | - end | ||
29 | - query do | ||
30 | - match fields, term, boost: 10, operator: operator, analyzer: "searchkick_search2" | ||
31 | - end | ||
32 | - query do | ||
33 | - match fields, term, use_dis_max: false, fuzziness: 1, max_expansions: 1, operator: operator, analyzer: "searchkick_search" | ||
34 | - end | ||
35 | - query do | ||
36 | - match fields, term, use_dis_max: false, fuzziness: 1, max_expansions: 1, operator: operator, analyzer: "searchkick_search2" | 38 | + if options[:typeahead] |
39 | + match fields, term, analyzer: "searchkick_typeahead_search" | ||
40 | + else | ||
41 | + dis_max do | ||
42 | + query do | ||
43 | + match fields, term, boost: 10, operator: operator, analyzer: "searchkick_search" | ||
44 | + end | ||
45 | + query do | ||
46 | + match fields, term, boost: 10, operator: operator, analyzer: "searchkick_search2" | ||
47 | + end | ||
48 | + query do | ||
49 | + match fields, term, use_dis_max: false, fuzziness: 1, max_expansions: 1, operator: operator, analyzer: "searchkick_search" | ||
50 | + end | ||
51 | + query do | ||
52 | + match fields, term, use_dis_max: false, fuzziness: 1, max_expansions: 1, operator: operator, analyzer: "searchkick_search2" | ||
53 | + end | ||
37 | end | 54 | end |
38 | end | 55 | end |
39 | end | 56 | end |
test/match_test.rb
@@ -110,4 +110,21 @@ class TestMatch < Minitest::Unit::TestCase | @@ -110,4 +110,21 @@ class TestMatch < Minitest::Unit::TestCase | ||
110 | assert_search "almondmilks", ["Almond Milk"] | 110 | assert_search "almondmilks", ["Almond Milk"] |
111 | end | 111 | end |
112 | 112 | ||
113 | + # typeahead | ||
114 | + | ||
115 | + def test_typeahead | ||
116 | + store_names ["Hummus"] | ||
117 | + assert_search "hum", ["Hummus"], typeahead: true | ||
118 | + end | ||
119 | + | ||
120 | + def test_typeahead_two_words | ||
121 | + store_names ["Organic Hummus"] | ||
122 | + assert_search "hum", [], typeahead: true | ||
123 | + end | ||
124 | + | ||
125 | + def test_typeahead_fields | ||
126 | + store_names ["Hummus"] | ||
127 | + assert_search "hum", ["Hummus"], typeahead: true, fields: [:name] | ||
128 | + end | ||
129 | + | ||
113 | end | 130 | end |
test/test_helper.rb
@@ -47,7 +47,8 @@ class Product < ActiveRecord::Base | @@ -47,7 +47,8 @@ class Product < ActiveRecord::Base | ||
47 | ["qtip", "cotton swab"], | 47 | ["qtip", "cotton swab"], |
48 | ["burger", "hamburger"], | 48 | ["burger", "hamburger"], |
49 | ["bandaid", "bandag"] | 49 | ["bandaid", "bandag"] |
50 | - ] | 50 | + ], |
51 | + typeahead: [:name] | ||
51 | 52 | ||
52 | attr_accessor :conversions, :user_ids | 53 | attr_accessor :conversions, :user_ids |
53 | 54 |