Commit 0d315179440a52af38b8936bb0628fddada09fac
1 parent
bd12de70
Exists in
master
and in
21 other branches
Support conversion boosting on multiple fields
Showing
5 changed files
with
121 additions
and
30 deletions
Show diff stats
README.md
@@ -1199,6 +1199,34 @@ class Product < ActiveRecord::Base | @@ -1199,6 +1199,34 @@ class Product < ActiveRecord::Base | ||
1199 | end | 1199 | end |
1200 | ``` | 1200 | ``` |
1201 | 1201 | ||
1202 | +Multiple conversion fields | ||
1203 | + | ||
1204 | +```ruby | ||
1205 | +class Product < ActiveRecord::Base | ||
1206 | + has_many :searches, class_name: "Searchjoy::Search" | ||
1207 | + | ||
1208 | + # searchkick also supports multiple "conversions" fields | ||
1209 | + searchkick conversions: ["unique_user_conversions", "total_conversions"] | ||
1210 | + | ||
1211 | + def search_data | ||
1212 | + { | ||
1213 | + name: name, | ||
1214 | + unique_user_conversions: searches.group(:query).uniq.count(:user_id) | ||
1215 | + # {"ice cream" => 234, "chocolate" => 67, "cream" => 2} | ||
1216 | + total_conversions: searches.group(:query).count | ||
1217 | + # {"ice cream" => 412, "chocolate" => 117, "cream" => 6} | ||
1218 | + } | ||
1219 | + end | ||
1220 | +end | ||
1221 | +``` | ||
1222 | +and during query time: | ||
1223 | + | ||
1224 | +```ruby | ||
1225 | +Product.search("banana") # boost by both fields (default) | ||
1226 | +Product.search("banana", {conversions: "total_conversions"}) # only boost by total_conversions | ||
1227 | +Product.search("banana", {conversions: false}) # no conversion boosting | ||
1228 | +``` | ||
1229 | + | ||
1202 | Change timeout | 1230 | Change timeout |
1203 | 1231 | ||
1204 | ```ruby | 1232 | ```ruby |
lib/searchkick/index.rb
@@ -430,7 +430,7 @@ module Searchkick | @@ -430,7 +430,7 @@ module Searchkick | ||
430 | mapping = {} | 430 | mapping = {} |
431 | 431 | ||
432 | # conversions | 432 | # conversions |
433 | - if (conversions_field = options[:conversions]) | 433 | + Array(options[:conversions]).each do |conversions_field| |
434 | mapping[conversions_field] = { | 434 | mapping[conversions_field] = { |
435 | type: "nested", | 435 | type: "nested", |
436 | properties: { | 436 | properties: { |
@@ -602,9 +602,10 @@ module Searchkick | @@ -602,9 +602,10 @@ module Searchkick | ||
602 | source = source.inject({}) { |memo, (k, v)| memo[k.to_s] = v; memo }.except("_id") | 602 | source = source.inject({}) { |memo, (k, v)| memo[k.to_s] = v; memo }.except("_id") |
603 | 603 | ||
604 | # conversions | 604 | # conversions |
605 | - conversions_field = options[:conversions] | ||
606 | - if conversions_field && source[conversions_field] | ||
607 | - source[conversions_field] = source[conversions_field].map { |k, v| {query: k, count: v} } | 605 | + Array(options[:conversions]).each do |conversions_field| |
606 | + if source[conversions_field] | ||
607 | + source[conversions_field] = source[conversions_field].map { |k, v| {query: k, count: v} } | ||
608 | + end | ||
608 | end | 609 | end |
609 | 610 | ||
610 | # hack to prevent generator field doesn't exist error | 611 | # hack to prevent generator field doesn't exist error |
lib/searchkick/query.rb
@@ -162,8 +162,8 @@ module Searchkick | @@ -162,8 +162,8 @@ module Searchkick | ||
162 | # model and eagar loading | 162 | # model and eagar loading |
163 | load = options[:load].nil? ? true : options[:load] | 163 | load = options[:load].nil? ? true : options[:load] |
164 | 164 | ||
165 | - conversions_field = searchkick_options[:conversions] | ||
166 | - personalize_field = searchkick_options[:personalize] | 165 | + conversions_fields = Array(options[:conversions] || searchkick_options[:conversions]) |
166 | + personalize_field = searchkick_options[:personalize] | ||
167 | 167 | ||
168 | all = term == "*" | 168 | all = term == "*" |
169 | 169 | ||
@@ -275,34 +275,38 @@ module Searchkick | @@ -275,34 +275,38 @@ module Searchkick | ||
275 | } | 275 | } |
276 | end | 276 | end |
277 | 277 | ||
278 | - if conversions_field && options[:conversions] != false | ||
279 | - # wrap payload in a bool query | ||
280 | - script_score = | ||
281 | - if below12? | ||
282 | - {script_score: {script: "doc['count'].value"}} | ||
283 | - else | ||
284 | - {field_value_factor: {field: "#{conversions_field}.count"}} | ||
285 | - end | 278 | + if conversions_fields.present? && options[:conversions] != false |
279 | + shoulds = [] | ||
280 | + conversions_fields.each do |conversions_field| | ||
281 | + # wrap payload in a bool query | ||
282 | + script_score = | ||
283 | + if below12? | ||
284 | + {script_score: {script: "doc['count'].value"}} | ||
285 | + else | ||
286 | + {field_value_factor: {field: "#{conversions_field}.count"}} | ||
287 | + end | ||
286 | 288 | ||
289 | + shoulds << { | ||
290 | + nested: { | ||
291 | + path: conversions_field, | ||
292 | + score_mode: "sum", | ||
293 | + query: { | ||
294 | + function_score: { | ||
295 | + boost_mode: "replace", | ||
296 | + query: { | ||
297 | + match: { | ||
298 | + "#{conversions_field}.query" => term | ||
299 | + } | ||
300 | + } | ||
301 | + }.merge(script_score) | ||
302 | + } | ||
303 | + } | ||
304 | + } | ||
305 | + end | ||
287 | payload = { | 306 | payload = { |
288 | bool: { | 307 | bool: { |
289 | must: payload, | 308 | must: payload, |
290 | - should: { | ||
291 | - nested: { | ||
292 | - path: conversions_field, | ||
293 | - score_mode: "sum", | ||
294 | - query: { | ||
295 | - function_score: { | ||
296 | - boost_mode: "replace", | ||
297 | - query: { | ||
298 | - match: { | ||
299 | - "#{conversions_field}.query" => term | ||
300 | - } | ||
301 | - } | ||
302 | - }.merge(script_score) | ||
303 | - } | ||
304 | - } | ||
305 | - } | 309 | + should: shoulds |
306 | } | 310 | } |
307 | } | 311 | } |
308 | end | 312 | end |
test/boost_test.rb
@@ -10,6 +10,24 @@ class BoostTest < Minitest::Test | @@ -10,6 +10,24 @@ class BoostTest < Minitest::Test | ||
10 | {name: "Tomato C", conversions: {"tomato" => 3}} | 10 | {name: "Tomato C", conversions: {"tomato" => 3}} |
11 | ] | 11 | ] |
12 | assert_order "tomato", ["Tomato C", "Tomato B", "Tomato A"] | 12 | assert_order "tomato", ["Tomato C", "Tomato B", "Tomato A"] |
13 | + assert_equal_scores "tomato", {conversions: false} | ||
14 | + end | ||
15 | + | ||
16 | + def test_multiple_conversions | ||
17 | + skip if elasticsearch_below14? | ||
18 | + | ||
19 | + store [ | ||
20 | + {name: "Speaker A", conversions_a: {"speaker" => 1}, conversions_b: {"speaker" => 6}}, | ||
21 | + {name: "Speaker B", conversions_a: {"speaker" => 2}, conversions_b: {"speaker" => 5}}, | ||
22 | + {name: "Speaker C", conversions_a: {"speaker" => 3}, conversions_b: {"speaker" => 4}}, | ||
23 | + ], Speaker | ||
24 | + | ||
25 | + assert_equal_scores "speaker", {conversions: false}, Speaker | ||
26 | + assert_equal_scores "speaker", {}, Speaker | ||
27 | + assert_equal_scores "speaker", {conversions: ["conversions_a", "conversions_b"]}, Speaker | ||
28 | + assert_equal_scores "speaker", {conversions: ["conversions_b", "conversions_a"]}, Speaker | ||
29 | + assert_order "speaker", ["Speaker C", "Speaker B", "Speaker A"], {conversions: "conversions_a"}, Speaker | ||
30 | + assert_order "speaker", ["Speaker A", "Speaker B", "Speaker C"], {conversions: "conversions_b"}, Speaker | ||
13 | end | 31 | end |
14 | 32 | ||
15 | def test_conversions_stemmed | 33 | def test_conversions_stemmed |
test/test_helper.rb
@@ -93,6 +93,12 @@ if defined?(Mongoid) | @@ -93,6 +93,12 @@ if defined?(Mongoid) | ||
93 | field :name | 93 | field :name |
94 | end | 94 | end |
95 | 95 | ||
96 | + class Speaker | ||
97 | + include Mongoid::Document | ||
98 | + | ||
99 | + field :name | ||
100 | + end | ||
101 | + | ||
96 | class Animal | 102 | class Animal |
97 | include Mongoid::Document | 103 | include Mongoid::Document |
98 | 104 | ||
@@ -137,6 +143,13 @@ elsif defined?(NoBrainer) | @@ -137,6 +143,13 @@ elsif defined?(NoBrainer) | ||
137 | field :name, type: String | 143 | field :name, type: String |
138 | end | 144 | end |
139 | 145 | ||
146 | + class Speaker | ||
147 | + include NoBrainer::Document | ||
148 | + | ||
149 | + field :id, type: Object | ||
150 | + field :name, type: String | ||
151 | + end | ||
152 | + | ||
140 | class Animal | 153 | class Animal |
141 | include NoBrainer::Document | 154 | include NoBrainer::Document |
142 | 155 | ||
@@ -221,6 +234,10 @@ else | @@ -221,6 +234,10 @@ else | ||
221 | t.string :name | 234 | t.string :name |
222 | end | 235 | end |
223 | 236 | ||
237 | + ActiveRecord::Migration.create_table :speakers do |t| | ||
238 | + t.string :name | ||
239 | + end | ||
240 | + | ||
224 | ActiveRecord::Migration.create_table :animals do |t| | 241 | ActiveRecord::Migration.create_table :animals do |t| |
225 | t.string :name | 242 | t.string :name |
226 | t.string :type | 243 | t.string :type |
@@ -233,6 +250,9 @@ else | @@ -233,6 +250,9 @@ else | ||
233 | has_many :products | 250 | has_many :products |
234 | end | 251 | end |
235 | 252 | ||
253 | + class Speaker < ActiveRecord::Base | ||
254 | + end | ||
255 | + | ||
236 | class Animal < ActiveRecord::Base | 256 | class Animal < ActiveRecord::Base |
237 | end | 257 | end |
238 | 258 | ||
@@ -310,6 +330,20 @@ class Store | @@ -310,6 +330,20 @@ class Store | ||
310 | end | 330 | end |
311 | end | 331 | end |
312 | 332 | ||
333 | +class Speaker | ||
334 | + searchkick \ | ||
335 | + conversions: ["conversions_a", "conversions_b"] | ||
336 | + | ||
337 | + attr_accessor :conversions_a, :conversions_b | ||
338 | + | ||
339 | + def search_data | ||
340 | + serializable_hash.except("id").merge( | ||
341 | + conversions_a: conversions_a, | ||
342 | + conversions_b: conversions_b, | ||
343 | + ) | ||
344 | + end | ||
345 | +end | ||
346 | + | ||
313 | class Animal | 347 | class Animal |
314 | searchkick \ | 348 | searchkick \ |
315 | autocomplete: [:name], | 349 | autocomplete: [:name], |
@@ -325,12 +359,14 @@ Product.create!(name: "Set mapping") | @@ -325,12 +359,14 @@ Product.create!(name: "Set mapping") | ||
325 | 359 | ||
326 | Store.reindex | 360 | Store.reindex |
327 | Animal.reindex | 361 | Animal.reindex |
362 | +Speaker.reindex | ||
328 | 363 | ||
329 | class Minitest::Test | 364 | class Minitest::Test |
330 | def setup | 365 | def setup |
331 | Product.destroy_all | 366 | Product.destroy_all |
332 | Store.destroy_all | 367 | Store.destroy_all |
333 | Animal.destroy_all | 368 | Animal.destroy_all |
369 | + Speaker.destroy_all | ||
334 | end | 370 | end |
335 | 371 | ||
336 | protected | 372 | protected |
@@ -355,6 +391,10 @@ class Minitest::Test | @@ -355,6 +391,10 @@ class Minitest::Test | ||
355 | assert_equal expected, klass.search(term, options).map(&:name) | 391 | assert_equal expected, klass.search(term, options).map(&:name) |
356 | end | 392 | end |
357 | 393 | ||
394 | + def assert_equal_scores(term, options = {}, klass = Product) | ||
395 | + assert_equal 1, klass.search(term, options).hits.map { |a| a['_score'] }.uniq.size | ||
396 | + end | ||
397 | + | ||
358 | def assert_first(term, expected, options = {}, klass = Product) | 398 | def assert_first(term, expected, options = {}, klass = Product) |
359 | assert_equal expected, klass.search(term, options).map(&:name).first | 399 | assert_equal expected, klass.search(term, options).map(&:name).first |
360 | end | 400 | end |