tiny-quick-gem 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +7 -0
  2. data/searchkick-6.1.2/CHANGELOG.md +919 -0
  3. data/searchkick-6.1.2/LICENSE.txt +22 -0
  4. data/searchkick-6.1.2/README.md +2348 -0
  5. data/searchkick-6.1.2/lib/searchkick/bulk_reindex_job.rb +19 -0
  6. data/searchkick-6.1.2/lib/searchkick/controller_runtime.rb +40 -0
  7. data/searchkick-6.1.2/lib/searchkick/hash_wrapper.rb +41 -0
  8. data/searchkick-6.1.2/lib/searchkick/index.rb +480 -0
  9. data/searchkick-6.1.2/lib/searchkick/index_cache.rb +30 -0
  10. data/searchkick-6.1.2/lib/searchkick/index_options.rb +652 -0
  11. data/searchkick-6.1.2/lib/searchkick/indexer.rb +43 -0
  12. data/searchkick-6.1.2/lib/searchkick/log_subscriber.rb +57 -0
  13. data/searchkick-6.1.2/lib/searchkick/middleware.rb +19 -0
  14. data/searchkick-6.1.2/lib/searchkick/model.rb +120 -0
  15. data/searchkick-6.1.2/lib/searchkick/multi_search.rb +46 -0
  16. data/searchkick-6.1.2/lib/searchkick/process_batch_job.rb +20 -0
  17. data/searchkick-6.1.2/lib/searchkick/process_queue_job.rb +33 -0
  18. data/searchkick-6.1.2/lib/searchkick/query.rb +1372 -0
  19. data/searchkick-6.1.2/lib/searchkick/railtie.rb +7 -0
  20. data/searchkick-6.1.2/lib/searchkick/record_data.rb +147 -0
  21. data/searchkick-6.1.2/lib/searchkick/record_indexer.rb +174 -0
  22. data/searchkick-6.1.2/lib/searchkick/reindex_queue.rb +57 -0
  23. data/searchkick-6.1.2/lib/searchkick/reindex_v2_job.rb +17 -0
  24. data/searchkick-6.1.2/lib/searchkick/relation.rb +728 -0
  25. data/searchkick-6.1.2/lib/searchkick/relation_indexer.rb +184 -0
  26. data/searchkick-6.1.2/lib/searchkick/reranking.rb +28 -0
  27. data/searchkick-6.1.2/lib/searchkick/results.rb +359 -0
  28. data/searchkick-6.1.2/lib/searchkick/script.rb +11 -0
  29. data/searchkick-6.1.2/lib/searchkick/version.rb +3 -0
  30. data/searchkick-6.1.2/lib/searchkick/where.rb +11 -0
  31. data/searchkick-6.1.2/lib/searchkick.rb +388 -0
  32. data/searchkick-6.1.2/lib/tasks/searchkick.rake +37 -0
  33. data/tiny-quick-gem.gemspec +12 -0
  34. metadata +73 -0
@@ -0,0 +1,2348 @@
1
+ # Searchkick
2
+
3
+ :rocket: Intelligent search made easy
4
+
5
+ **Searchkick learns what your users are looking for.** As more people search, it gets smarter and the results get better. It’s friendly for developers - and magical for your users.
6
+
7
+ Searchkick handles:
8
+
9
+ - stemming - `tomatoes` matches `tomato`
10
+ - special characters - `jalapeno` matches `jalapeño`
11
+ - extra whitespace - `dishwasher` matches `dish washer`
12
+ - misspellings - `zuchini` matches `zucchini`
13
+ - custom synonyms - `pop` matches `soda`
14
+
15
+ Plus:
16
+
17
+ - query like SQL - no need to learn a new query language
18
+ - reindex without downtime
19
+ - easily personalize results for each user
20
+ - autocomplete
21
+ - “Did you mean” suggestions
22
+ - supports many languages
23
+ - works with Active Record and Mongoid
24
+
25
+ Check out [Searchjoy](https://github.com/ankane/searchjoy) for analytics and [Autosuggest](https://github.com/ankane/autosuggest) for query suggestions
26
+
27
+ :tangerine: Battle-tested at [Instacart](https://www.instacart.com/opensource)
28
+
29
+ [![Build Status](https://github.com/ankane/searchkick/actions/workflows/build.yml/badge.svg)](https://github.com/ankane/searchkick/actions)
30
+
31
+ ## Contents
32
+
33
+ - [Getting Started](#getting-started)
34
+ - [Querying](#querying)
35
+ - [Indexing](#indexing)
36
+ - [Intelligent Search](#intelligent-search)
37
+ - [Instant Search / Autocomplete](#instant-search--autocomplete)
38
+ - [Aggregations](#aggregations)
39
+ - [Testing](#testing)
40
+ - [Deployment](#deployment)
41
+ - [Performance](#performance)
42
+ - [Advanced Search](#advanced)
43
+ - [Reference](#reference)
44
+ - [Contributing](#contributing)
45
+
46
+ Searchkick 6.0 was recently released! See [how to upgrade](#upgrading)
47
+
48
+ ## Getting Started
49
+
50
+ Install [Elasticsearch](https://www.elastic.co/downloads/elasticsearch) or [OpenSearch](https://opensearch.org/downloads.html). For Homebrew, use:
51
+
52
+ ```sh
53
+ brew install opensearch
54
+ brew services start opensearch
55
+ ```
56
+
57
+ Add these lines to your application’s Gemfile:
58
+
59
+ ```ruby
60
+ gem "searchkick"
61
+
62
+ gem "elasticsearch" # select one
63
+ gem "opensearch-ruby" # select one
64
+ ```
65
+
66
+ The latest version works with Elasticsearch 8 and 9 and OpenSearch 2 and 3. For Elasticsearch 7 and OpenSearch 1, use version 5.5.2 and [this readme](https://github.com/ankane/searchkick/blob/v5.5.2/README.md).
67
+
68
+ Add `searchkick` to models you want to search.
69
+
70
+ ```ruby
71
+ class Product < ApplicationRecord
72
+ searchkick
73
+ end
74
+ ```
75
+
76
+ Add data to the search index.
77
+
78
+ ```ruby
79
+ Product.reindex
80
+ ```
81
+
82
+ And to query, use:
83
+
84
+ ```ruby
85
+ products = Product.search("apples")
86
+ products.each do |product|
87
+ puts product.name
88
+ end
89
+ ```
90
+
91
+ Searchkick supports the complete [Elasticsearch Search API](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html) and [OpenSearch Search API](https://opensearch.org/docs/latest/opensearch/rest-api/search/). As your search becomes more advanced, we recommend you use the [search server DSL](#advanced) for maximum flexibility.
92
+
93
+ ## Querying
94
+
95
+ Query like SQL
96
+
97
+ ```ruby
98
+ Product.search("apples").where(in_stock: true).limit(10).offset(50)
99
+ ```
100
+
101
+ Search specific fields
102
+
103
+ ```ruby
104
+ fields(:name, :brand)
105
+ ```
106
+
107
+ Where
108
+
109
+ ```ruby
110
+ where(store_id: 1, expires_at: Time.now..)
111
+ ```
112
+
113
+ [These types of filters are supported](#filtering)
114
+
115
+ Order
116
+
117
+ ```ruby
118
+ order(_score: :desc) # most relevant first - default
119
+ ```
120
+
121
+ [All of these sort options are supported](https://www.elastic.co/guide/en/elasticsearch/reference/current/sort-search-results.html)
122
+
123
+ Limit / offset
124
+
125
+ ```ruby
126
+ limit(20).offset(40)
127
+ ```
128
+
129
+ Select
130
+
131
+ ```ruby
132
+ select(:name)
133
+ ```
134
+
135
+ [These source filtering options are supported](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-fields.html#source-filtering)
136
+
137
+ ### Results
138
+
139
+ Searches return a `Searchkick::Relation` object. This responds like an array to most methods.
140
+
141
+ ```ruby
142
+ results = Product.search("milk")
143
+ results.size
144
+ results.any?
145
+ results.each { |result| ... }
146
+ ```
147
+
148
+ By default, ids are fetched from the search server and records are fetched from your database. To fetch everything from the search server, use:
149
+
150
+ ```ruby
151
+ Product.search("apples").load(false)
152
+ ```
153
+
154
+ Get total results
155
+
156
+ ```ruby
157
+ results.total_count
158
+ ```
159
+
160
+ Get the time the search took (in milliseconds)
161
+
162
+ ```ruby
163
+ results.took
164
+ ```
165
+
166
+ Get the full response from the search server
167
+
168
+ ```ruby
169
+ results.response
170
+ ```
171
+
172
+ **Note:** By default, Elasticsearch and OpenSearch [limit paging](#deep-paging) to the first 10,000 results for performance. This applies to the total count as well.
173
+
174
+ ### Filtering
175
+
176
+ Equal
177
+
178
+ ```ruby
179
+ where(store_id: 1)
180
+ ```
181
+
182
+ Not equal
183
+
184
+ ```ruby
185
+ where.not(store_id: 2)
186
+ ```
187
+
188
+ Greater than (`gt`), less than (`lt`), greater than or equal (`gte`), less than or equal (`lte`)
189
+
190
+ ```ruby
191
+ where(expires_at: {gt: Time.now})
192
+ ```
193
+
194
+ Range
195
+
196
+ ```ruby
197
+ where(orders_count: 1..10)
198
+ ```
199
+
200
+ In
201
+
202
+ ```ruby
203
+ where(aisle_id: [25, 30])
204
+ ```
205
+
206
+ Not in
207
+
208
+ ```ruby
209
+ where.not(aisle_id: [25, 30])
210
+ ```
211
+
212
+ Contains all
213
+
214
+ ```ruby
215
+ where(user_ids: {all: [1, 3]})
216
+ ```
217
+
218
+ Like
219
+
220
+ ```ruby
221
+ where(category: {like: "%frozen%"})
222
+ ```
223
+
224
+ Case-insensitive like
225
+
226
+ ```ruby
227
+ where(category: {ilike: "%frozen%"})
228
+ ```
229
+
230
+ Regular expression
231
+
232
+ ```ruby
233
+ where(category: /frozen .+/)
234
+ ```
235
+
236
+ Prefix
237
+
238
+ ```ruby
239
+ where(category: {prefix: "frozen"})
240
+ ```
241
+
242
+ Exists
243
+
244
+ ```ruby
245
+ where(store_id: {exists: true})
246
+ ```
247
+
248
+ Combine filters with OR
249
+
250
+ ```ruby
251
+ where(_or: [{in_stock: true}, {backordered: true}])
252
+ ```
253
+
254
+ ### Boosting
255
+
256
+ Boost important fields
257
+
258
+ ```ruby
259
+ fields("title^10", "description")
260
+ ```
261
+
262
+ Boost by the value of a field (field must be numeric)
263
+
264
+ ```ruby
265
+ boost_by(:orders_count) # give popular documents a little boost
266
+ boost_by(orders_count: {factor: 10}) # default factor is 1
267
+ ```
268
+
269
+ Boost matching documents
270
+
271
+ ```ruby
272
+ boost_where(user_id: 1)
273
+ boost_where(user_id: {value: 1, factor: 100}) # default factor is 1000
274
+ boost_where(user_id: [{value: 1, factor: 100}, {value: 2, factor: 200}])
275
+ ```
276
+
277
+ Boost by recency
278
+
279
+ ```ruby
280
+ boost_by_recency(created_at: {scale: "7d", decay: 0.5})
281
+ ```
282
+
283
+ You can also boost by:
284
+
285
+ - [Conversions](#intelligent-search)
286
+ - [Distance](#boost-by-distance)
287
+
288
+ ### Get Everything
289
+
290
+ Use a `*` for the query.
291
+
292
+ ```ruby
293
+ Product.search("*")
294
+ ```
295
+
296
+ ### Pagination
297
+
298
+ Plays nicely with kaminari and will_paginate.
299
+
300
+ ```ruby
301
+ # controller
302
+ @products = Product.search("milk").page(params[:page]).per_page(20)
303
+ ```
304
+
305
+ View with kaminari
306
+
307
+ ```erb
308
+ <%= paginate @products %>
309
+ ```
310
+
311
+ View with will_paginate
312
+
313
+ ```erb
314
+ <%= will_paginate @products %>
315
+ ```
316
+
317
+ ### Partial Matches
318
+
319
+ By default, results must match all words in the query.
320
+
321
+ ```ruby
322
+ Product.search("fresh honey") # fresh AND honey
323
+ ```
324
+
325
+ To change this, use:
326
+
327
+ ```ruby
328
+ Product.search("fresh honey").operator("or") # fresh OR honey
329
+ ```
330
+
331
+ By default, results must match the entire word - `back` will not match `backpack`. You can change this behavior with:
332
+
333
+ ```ruby
334
+ class Product < ApplicationRecord
335
+ searchkick word_start: [:name]
336
+ end
337
+ ```
338
+
339
+ And to search (after you reindex):
340
+
341
+ ```ruby
342
+ Product.search("back").fields(:name).match(:word_start)
343
+ ```
344
+
345
+ Available options are:
346
+
347
+ Option | Matches | Example
348
+ --- | --- | ---
349
+ `:word` | entire word | `apple` matches `apple`
350
+ `:word_start` | start of word | `app` matches `apple`
351
+ `:word_middle` | any part of word | `ppl` matches `apple`
352
+ `:word_end` | end of word | `ple` matches `apple`
353
+ `:text_start` | start of text | `gre` matches `green apple`, `app` does not match
354
+ `:text_middle` | any part of text | `een app` matches `green apple`
355
+ `:text_end` | end of text | `ple` matches `green apple`, `een` does not match
356
+
357
+ The default is `:word`. The most matches will happen with `:word_middle`.
358
+
359
+ To specify different matching for different fields, use:
360
+
361
+ ```ruby
362
+ Product.search(query).fields({name: :word_start}, {brand: :word_middle})
363
+ ```
364
+
365
+ ### Exact Matches
366
+
367
+ To match a field exactly (case-sensitive), use:
368
+
369
+ ```ruby
370
+ Product.search(query).fields({name: :exact})
371
+ ```
372
+
373
+ ### Phrase Matches
374
+
375
+ To only match the exact order, use:
376
+
377
+ ```ruby
378
+ Product.search("fresh honey").match(:phrase)
379
+ ```
380
+
381
+ ### Stemming and Language
382
+
383
+ Searchkick stems words by default for better matching. `apple` and `apples` both stem to `appl`, so searches for either term will have the same matches.
384
+
385
+ Searchkick defaults to English for stemming. To change this, use:
386
+
387
+ ```ruby
388
+ class Product < ApplicationRecord
389
+ searchkick language: "german"
390
+ end
391
+ ```
392
+
393
+ See the [list of languages](https://www.elastic.co/guide/en/elasticsearch/reference/current/analysis-stemmer-tokenfilter.html#analysis-stemmer-tokenfilter-configure-parms). A few languages require plugins:
394
+
395
+ - `chinese` - [analysis-ik plugin](https://github.com/medcl/elasticsearch-analysis-ik)
396
+ - `chinese2` - [analysis-smartcn plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-smartcn.html)
397
+ - `japanese` - [analysis-kuromoji plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-kuromoji.html)
398
+ - `korean` - [analysis-openkoreantext plugin](https://github.com/open-korean-text/elasticsearch-analysis-openkoreantext)
399
+ - `korean2` - [analysis-nori plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-nori.html)
400
+ - `polish` - [analysis-stempel plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-stempel.html)
401
+ - `ukrainian` - [analysis-ukrainian plugin](https://www.elastic.co/guide/en/elasticsearch/plugins/7.4/analysis-ukrainian.html)
402
+ - `vietnamese` - [analysis-vietnamese plugin](https://github.com/duydo/elasticsearch-analysis-vietnamese)
403
+
404
+ You can also use a Hunspell dictionary for stemming.
405
+
406
+ ```ruby
407
+ class Product < ApplicationRecord
408
+ searchkick stemmer: {type: "hunspell", locale: "en_US"}
409
+ end
410
+ ```
411
+
412
+ Disable stemming with:
413
+
414
+ ```ruby
415
+ class Image < ApplicationRecord
416
+ searchkick stem: false
417
+ end
418
+ ```
419
+
420
+ Exclude certain words from stemming with:
421
+
422
+ ```ruby
423
+ class Image < ApplicationRecord
424
+ searchkick stem_exclusion: ["apples"]
425
+ end
426
+ ```
427
+
428
+ Or change how words are stemmed:
429
+
430
+ ```ruby
431
+ class Image < ApplicationRecord
432
+ searchkick stemmer_override: ["apples => other"]
433
+ end
434
+ ```
435
+
436
+ ### Synonyms
437
+
438
+ ```ruby
439
+ class Product < ApplicationRecord
440
+ searchkick search_synonyms: [["pop", "soda"], ["burger", "hamburger"]]
441
+ end
442
+ ```
443
+
444
+ Call `Product.reindex` after changing synonyms. Synonyms are applied at search time before stemming, and can be a single word or multiple words.
445
+
446
+ For directional synonyms, use:
447
+
448
+ ```ruby
449
+ search_synonyms: ["lightbulb => halogenlamp"]
450
+ ```
451
+
452
+ ### Dynamic Synonyms
453
+
454
+ The above approach works well when your synonym list is static, but in practice, this is often not the case. When you analyze search conversions, you often want to add new synonyms without a full reindex. We recommend placing synonyms in a file on the search server (in the `config` directory). This allows you to reload synonyms without reindexing.
455
+
456
+ ```txt
457
+ pop, soda
458
+ burger, hamburger
459
+ ```
460
+
461
+ Then use:
462
+
463
+ ```ruby
464
+ class Product < ApplicationRecord
465
+ searchkick search_synonyms: "synonyms.txt"
466
+ end
467
+ ```
468
+
469
+ And reload with:
470
+
471
+ ```ruby
472
+ Product.search_index.reload_synonyms
473
+ ```
474
+
475
+ ### Misspellings
476
+
477
+ By default, Searchkick handles misspelled queries by returning results with an [edit distance](https://en.wikipedia.org/wiki/Levenshtein_distance) of one.
478
+
479
+ You can change this with:
480
+
481
+ ```ruby
482
+ Product.search("zucini").misspellings(edit_distance: 2) # zucchini
483
+ ```
484
+
485
+ To prevent poor precision and improve performance for correctly spelled queries (which should be a majority for most applications), Searchkick can first perform a search without misspellings, and if there are too few results, perform another with them.
486
+
487
+ ```ruby
488
+ Product.search("zuchini").misspellings(below: 5)
489
+ ```
490
+
491
+ If there are fewer than 5 results, a 2nd search is performed with misspellings enabled. The result of this query is returned.
492
+
493
+ Turn off misspellings with:
494
+
495
+ ```ruby
496
+ Product.search("zuchini").misspellings(false) # no zucchini
497
+ ```
498
+
499
+ Specify which fields can include misspellings with:
500
+
501
+ ```ruby
502
+ Product.search("zucini").fields(:name, :color).misspellings(fields: [:name])
503
+ ```
504
+
505
+ > When doing this, you must also specify fields to search
506
+
507
+ ### Bad Matches
508
+
509
+ If a user searches `butter`, they may also get results for `peanut butter`. To prevent this, use:
510
+
511
+ ```ruby
512
+ Product.search("butter").exclude("peanut butter")
513
+ ```
514
+
515
+ You can map queries and terms to exclude with:
516
+
517
+ ```ruby
518
+ exclude_queries = {
519
+ "butter" => ["peanut butter"],
520
+ "cream" => ["ice cream", "whipped cream"]
521
+ }
522
+
523
+ Product.search(query).exclude(exclude_queries[query])
524
+ ```
525
+
526
+ You can demote results by boosting by a factor less than one:
527
+
528
+ ```ruby
529
+ Product.search("butter").boost_where(category: {value: "pantry", factor: 0.5})
530
+ ```
531
+
532
+ ### Emoji
533
+
534
+ Search :ice_cream::cake: and get `ice cream cake`!
535
+
536
+ Add this line to your application’s Gemfile:
537
+
538
+ ```ruby
539
+ gem "gemoji-parser"
540
+ ```
541
+
542
+ And use:
543
+
544
+ ```ruby
545
+ Product.search("🍨🍰").emoji
546
+ ```
547
+
548
+ ## Indexing
549
+
550
+ Control what data is indexed with the `search_data` method. Call `Product.reindex` after changing this method.
551
+
552
+ ```ruby
553
+ class Product < ApplicationRecord
554
+ belongs_to :department
555
+
556
+ def search_data
557
+ {
558
+ name: name,
559
+ department_name: department.name,
560
+ on_sale: sale_price.present?
561
+ }
562
+ end
563
+ end
564
+ ```
565
+
566
+ Searchkick uses `find_in_batches` to import documents. To eager load associations, use the `search_import` scope.
567
+
568
+ ```ruby
569
+ class Product < ApplicationRecord
570
+ scope :search_import, -> { includes(:department) }
571
+ end
572
+ ```
573
+
574
+ By default, all records are indexed. To control which records are indexed, use the `should_index?` method.
575
+
576
+ ```ruby
577
+ class Product < ApplicationRecord
578
+ def should_index?
579
+ active # only index active records
580
+ end
581
+ end
582
+ ```
583
+
584
+ If a reindex is interrupted, you can resume it with:
585
+
586
+ ```ruby
587
+ Product.reindex(resume: true)
588
+ ```
589
+
590
+ For large data sets, try [parallel reindexing](#parallel-reindexing).
591
+
592
+ ### To Reindex, or Not to Reindex
593
+
594
+ #### Reindex
595
+
596
+ - when you install or upgrade searchkick
597
+ - change the `search_data` method
598
+ - change the `searchkick` method
599
+
600
+ #### No need to reindex
601
+
602
+ - app starts
603
+
604
+ ### Strategies
605
+
606
+ There are four strategies for keeping the index synced with your database.
607
+
608
+ 1. Inline (default)
609
+
610
+ Anytime a record is inserted, updated, or deleted
611
+
612
+ 2. Asynchronous
613
+
614
+ Use background jobs for better performance
615
+
616
+ ```ruby
617
+ class Product < ApplicationRecord
618
+ searchkick callbacks: :async
619
+ end
620
+ ```
621
+
622
+ Jobs are added to a queue named `searchkick`.
623
+
624
+ 3. Queuing
625
+
626
+ Push ids of records that need updated to a queue and reindex in the background in batches. This is more performant than the asynchronous method, which updates records individually. See [how to set up](#queuing).
627
+
628
+ 4. Manual
629
+
630
+ Turn off automatic syncing
631
+
632
+ ```ruby
633
+ class Product < ApplicationRecord
634
+ searchkick callbacks: false
635
+ end
636
+ ```
637
+
638
+ And reindex a record or relation manually.
639
+
640
+ ```ruby
641
+ product.reindex
642
+ # or
643
+ store.products.reindex(mode: :async)
644
+ ```
645
+
646
+ You can also do bulk updates.
647
+
648
+ ```ruby
649
+ Searchkick.callbacks(:bulk) do
650
+ Product.find_each(&:update_fields)
651
+ end
652
+ ```
653
+
654
+ Or temporarily skip updates.
655
+
656
+ ```ruby
657
+ Searchkick.callbacks(false) do
658
+ Product.find_each(&:update_fields)
659
+ end
660
+ ```
661
+
662
+ Or override the model’s strategy.
663
+
664
+ ```ruby
665
+ product.reindex(mode: :async) # :inline or :queue
666
+ ```
667
+
668
+ ### Associations
669
+
670
+ Data is **not** automatically synced when an association is updated. If this is desired, add a callback to reindex:
671
+
672
+ ```ruby
673
+ class Image < ApplicationRecord
674
+ belongs_to :product
675
+
676
+ after_commit :reindex_product
677
+
678
+ def reindex_product
679
+ product.reindex
680
+ end
681
+ end
682
+ ```
683
+
684
+ ### Default Scopes
685
+
686
+ If you have a default scope that filters records, use the `should_index?` method to exclude them from indexing:
687
+
688
+ ```ruby
689
+ class Product < ApplicationRecord
690
+ default_scope { where(deleted_at: nil) }
691
+
692
+ def should_index?
693
+ deleted_at.nil?
694
+ end
695
+ end
696
+ ```
697
+
698
+ If you want to index and search filtered records, set:
699
+
700
+ ```ruby
701
+ class Product < ApplicationRecord
702
+ searchkick unscope: true
703
+ end
704
+ ```
705
+
706
+ ## Intelligent Search
707
+
708
+ The best starting point to improve your search **by far** is to track searches and conversions. [Searchjoy](https://github.com/ankane/searchjoy) makes it easy.
709
+
710
+ ```ruby
711
+ Product.search("apple").track(user_id: current_user.id)
712
+ ```
713
+
714
+ [See the docs](https://github.com/ankane/searchjoy) for how to install and use. Focus on top searches with a low conversion rate.
715
+
716
+ Searchkick can then use the conversion data to learn what users are looking for. If a user searches for “ice cream” and adds Ben & Jerry’s Chunky Monkey to the cart (our conversion metric at Instacart), that item gets a little more weight for similar searches. This can make a huge difference on the quality of your search.
717
+
718
+ Add conversion data with:
719
+
720
+ ```ruby
721
+ class Product < ApplicationRecord
722
+ has_many :conversions, class_name: "Searchjoy::Conversion", as: :convertable
723
+ has_many :searches, class_name: "Searchjoy::Search", through: :conversions
724
+
725
+ searchkick conversions_v2: [:conversions] # name of field
726
+
727
+ def search_data
728
+ {
729
+ name: name,
730
+ conversions: searches.group(:query).distinct.count(:user_id)
731
+ # {"ice cream" => 234, "chocolate" => 67, "cream" => 2}
732
+ }
733
+ end
734
+ end
735
+ ```
736
+
737
+ Reindex and set up a cron job to add new conversions daily. For zero downtime deployment, temporarily set `conversions_v2(false)` in your search calls until the data is reindexed.
738
+
739
+ ### Performant Conversions
740
+
741
+ A performant way to do conversions is to cache them to prevent N+1 queries. For Postgres, create a migration with:
742
+
743
+ ```ruby
744
+ add_column :products, :search_conversions, :jsonb
745
+ ```
746
+
747
+ For MySQL, use `:json`, and for others, use `:text` with a [JSON serializer](https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html).
748
+
749
+ Next, update your model. Create a separate method for conversion data so you can use [partial reindexing](#partial-reindexing).
750
+
751
+ ```ruby
752
+ class Product < ApplicationRecord
753
+ searchkick conversions_v2: [:conversions]
754
+
755
+ def search_data
756
+ {
757
+ name: name,
758
+ category: category
759
+ }.merge(conversions_data)
760
+ end
761
+
762
+ def conversions_data
763
+ {
764
+ conversions: search_conversions || {}
765
+ }
766
+ end
767
+ end
768
+ ```
769
+
770
+ Deploy and reindex your data. For zero downtime deployment, temporarily set `conversions_v2(false)` in your search calls until the data is reindexed.
771
+
772
+ ```ruby
773
+ Product.reindex
774
+ ```
775
+
776
+ Then, create a job to update the conversions column and reindex records with new conversions. Here’s one you can use for Searchjoy:
777
+
778
+ ```ruby
779
+ class UpdateConversionsJob < ApplicationJob
780
+ def perform(class_name, since: nil, update: true, reindex: true)
781
+ model = Searchkick.load_model(class_name)
782
+
783
+ # get records that have a recent conversion
784
+ recently_converted_ids =
785
+ Searchjoy::Conversion.where(convertable_type: class_name, created_at: since..)
786
+ .order(:convertable_id).distinct.pluck(:convertable_id)
787
+
788
+ # split into batches
789
+ recently_converted_ids.in_groups_of(1000, false) do |ids|
790
+ if update
791
+ # fetch conversions
792
+ conversions =
793
+ Searchjoy::Conversion.where(convertable_id: ids, convertable_type: class_name)
794
+ .joins(:search).where.not(searchjoy_searches: {user_id: nil})
795
+ .group(:convertable_id, :query).distinct.count(:user_id)
796
+
797
+ # group by record
798
+ conversions_by_record = {}
799
+ conversions.each do |(id, query), count|
800
+ (conversions_by_record[id] ||= {})[query] = count
801
+ end
802
+
803
+ # update conversions column
804
+ model.transaction do
805
+ conversions_by_record.each do |id, conversions|
806
+ model.where(id: id).update_all(search_conversions: conversions)
807
+ end
808
+ end
809
+ end
810
+
811
+ if reindex
812
+ # reindex conversions data
813
+ model.where(id: ids).reindex(:conversions_data, ignore_missing: true)
814
+ end
815
+ end
816
+ end
817
+ end
818
+ ```
819
+
820
+ Run the job:
821
+
822
+ ```ruby
823
+ UpdateConversionsJob.perform_now("Product")
824
+ ```
825
+
826
+ And set it up to run daily.
827
+
828
+ ```ruby
829
+ UpdateConversionsJob.perform_later("Product", since: 1.day.ago)
830
+ ```
831
+
832
+ ## Personalized Results
833
+
834
+ Order results differently for each user. For example, show a user’s previously purchased products before other results.
835
+
836
+ ```ruby
837
+ class Product < ApplicationRecord
838
+ def search_data
839
+ {
840
+ name: name,
841
+ orderer_ids: orders.pluck(:user_id) # boost this product for these users
842
+ }
843
+ end
844
+ end
845
+ ```
846
+
847
+ Reindex and search with:
848
+
849
+ ```ruby
850
+ Product.search("milk").boost_where(orderer_ids: current_user.id)
851
+ ```
852
+
853
+ ## Instant Search / Autocomplete
854
+
855
+ Autocomplete predicts what a user will type, making the search experience faster and easier.
856
+
857
+ ![Autocomplete](https://gist.githubusercontent.com/ankane/b6988db2802aca68a589b31e41b44195/raw/40febe948427e5bc53ec4e5dc248822855fef76f/autocomplete.png)
858
+
859
+ **Note:** To autocomplete on search terms rather than results, check out [Autosuggest](https://github.com/ankane/autosuggest).
860
+
861
+ **Note 2:** If you only have a few thousand records, don’t use Searchkick for autocomplete. It’s *much* faster to load all records into JavaScript and autocomplete there (eliminates network requests).
862
+
863
+ First, specify which fields use this feature. This is necessary since autocomplete can increase the index size significantly, but don’t worry - this gives you blazing fast queries.
864
+
865
+ ```ruby
866
+ class Movie < ApplicationRecord
867
+ searchkick word_start: [:title, :director]
868
+ end
869
+ ```
870
+
871
+ Reindex and search with:
872
+
873
+ ```ruby
874
+ Movie.search("jurassic pa").fields(:title).match(:word_start)
875
+ ```
876
+
877
+ Use a front-end library like [typeahead.js](https://twitter.github.io/typeahead.js/) to show the results.
878
+
879
+ #### Here’s how to make it work with Rails
880
+
881
+ First, add a route and controller action.
882
+
883
+ ```ruby
884
+ class MoviesController < ApplicationController
885
+ def autocomplete
886
+ render json: Movie.search(params[:query]).fields("title^5", "director")
887
+ .match(:word_start).limit(10).load(false).misspellings(below: 5).map(&:title)
888
+ end
889
+ end
890
+ ```
891
+
892
+ **Note:** Use `load(false)` and `misspellings(below: n)` (or `misspellings(false)`) for best performance.
893
+
894
+ Then add the search box and JavaScript code to a view.
895
+
896
+ ```html
897
+ <input type="text" id="query" name="query" />
898
+
899
+ <script src="jquery.js"></script>
900
+ <script src="typeahead.bundle.js"></script>
901
+ <script>
902
+ var movies = new Bloodhound({
903
+ datumTokenizer: Bloodhound.tokenizers.whitespace,
904
+ queryTokenizer: Bloodhound.tokenizers.whitespace,
905
+ remote: {
906
+ url: '/movies/autocomplete?query=%QUERY',
907
+ wildcard: '%QUERY'
908
+ }
909
+ });
910
+ $('#query').typeahead(null, {
911
+ source: movies
912
+ });
913
+ </script>
914
+ ```
915
+
916
+ ## Suggestions
917
+
918
+ ![Suggest](https://gist.githubusercontent.com/ankane/b6988db2802aca68a589b31e41b44195/raw/40febe948427e5bc53ec4e5dc248822855fef76f/recursion.png)
919
+
920
+ ```ruby
921
+ class Product < ApplicationRecord
922
+ searchkick suggest: [:name] # fields to generate suggestions
923
+ end
924
+ ```
925
+
926
+ Reindex and search with:
927
+
928
+ ```ruby
929
+ products = Product.search("peantu butta").suggest
930
+ products.suggestions # ["peanut butter"]
931
+ ```
932
+
933
+ ## Aggregations
934
+
935
+ [Aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations.html) provide aggregated search data.
936
+
937
+ ![Aggregations](https://gist.githubusercontent.com/ankane/b6988db2802aca68a589b31e41b44195/raw/40febe948427e5bc53ec4e5dc248822855fef76f/facets.png)
938
+
939
+ ```ruby
940
+ products = Product.search("chuck taylor").aggs(:product_type, :gender, :brand)
941
+ products.aggs
942
+ ```
943
+
944
+ By default, `where` conditions apply to aggregations.
945
+
946
+ ```ruby
947
+ Product.search("wingtips").where(color: "brandy").aggs(:size)
948
+ # aggregations for brandy wingtips are returned
949
+ ```
950
+
951
+ Change this with:
952
+
953
+ ```ruby
954
+ Product.search("wingtips").where(color: "brandy").aggs(:size).smart_aggs(false)
955
+ # aggregations for all wingtips are returned
956
+ ```
957
+
958
+ Set `where` conditions for each aggregation separately with:
959
+
960
+ ```ruby
961
+ Product.search("wingtips").aggs(size: {where: {color: "brandy"}})
962
+ ```
963
+
964
+ Limit
965
+
966
+ ```ruby
967
+ Product.search("apples").aggs(store_id: {limit: 10})
968
+ ```
969
+
970
+ Order
971
+
972
+ ```ruby
973
+ Product.search("wingtips").aggs(color: {order: {"_key" => "asc"}}) # alphabetically
974
+ ```
975
+
976
+ [All of these options are supported](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-terms-aggregation.html#search-aggregations-bucket-terms-aggregation-order)
977
+
978
+ Ranges
979
+
980
+ ```ruby
981
+ price_ranges = [{to: 20}, {from: 20, to: 50}, {from: 50}]
982
+ Product.search("*").aggs(price: {ranges: price_ranges})
983
+ ```
984
+
985
+ Minimum document count
986
+
987
+ ```ruby
988
+ Product.search("apples").aggs(store_id: {min_doc_count: 2})
989
+ ```
990
+
991
+ Script support
992
+
993
+ ```ruby
994
+ Product.search("*").aggs(color: {script: {source: "'Color: ' + _value"}})
995
+ ```
996
+
997
+ Date histogram
998
+
999
+ ```ruby
1000
+ Product.search("pear").aggs(products_per_year: {date_histogram: {field: :created_at, interval: :year}})
1001
+ ```
1002
+
1003
+ For other aggregation types, including sub-aggregations, use `body_options`:
1004
+
1005
+ ```ruby
1006
+ Product.search("orange").body_options(aggs: {price: {histogram: {field: :price, interval: 10}}})
1007
+ ```
1008
+
1009
+ ## Highlight
1010
+
1011
+ Specify which fields to index with highlighting.
1012
+
1013
+ ```ruby
1014
+ class Band < ApplicationRecord
1015
+ searchkick highlight: [:name]
1016
+ end
1017
+ ```
1018
+
1019
+ Highlight the search query in the results.
1020
+
1021
+ ```ruby
1022
+ bands = Band.search("cinema").highlight
1023
+ ```
1024
+
1025
+ View the highlighted fields with:
1026
+
1027
+ ```ruby
1028
+ bands.with_highlights.each do |band, highlights|
1029
+ highlights[:name] # "Two Door <em>Cinema</em> Club"
1030
+ end
1031
+ ```
1032
+
1033
+ To change the tag, use:
1034
+
1035
+ ```ruby
1036
+ Band.search("cinema").highlight(tag: "<strong>")
1037
+ ```
1038
+
1039
+ To highlight and search different fields, use:
1040
+
1041
+ ```ruby
1042
+ Band.search("cinema").fields(:name).highlight(fields: [:description])
1043
+ ```
1044
+
1045
+ By default, the entire field is highlighted. To get small snippets instead, use:
1046
+
1047
+ ```ruby
1048
+ bands = Band.search("cinema").highlight(fragment_size: 20)
1049
+ bands.with_highlights(multiple: true).each do |band, highlights|
1050
+ highlights[:name].join(" and ")
1051
+ end
1052
+ ```
1053
+
1054
+ Additional options can be specified for each field:
1055
+
1056
+ ```ruby
1057
+ Band.search("cinema").fields(:name).highlight(fields: {name: {fragment_size: 200}})
1058
+ ```
1059
+
1060
+ You can find available highlight options in the [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/reference/current/highlighting.html) or [OpenSearch](https://opensearch.org/docs/latest/search-plugins/searching-data/highlight/) reference.
1061
+
1062
+ ## Similar Items
1063
+
1064
+ Find similar items
1065
+
1066
+ ```ruby
1067
+ product = Product.first
1068
+ product.similar.fields(:name).where(size: "12 oz")
1069
+ ```
1070
+
1071
+ ## Geospatial Searches
1072
+
1073
+ ```ruby
1074
+ class Restaurant < ApplicationRecord
1075
+ searchkick locations: [:location]
1076
+
1077
+ def search_data
1078
+ attributes.merge(location: {lat: latitude, lon: longitude})
1079
+ end
1080
+ end
1081
+ ```
1082
+
1083
+ Reindex and search with:
1084
+
1085
+ ```ruby
1086
+ Restaurant.search("pizza").where(location: {near: {lat: 37, lon: -114}, within: "100mi"}) # or 160km
1087
+ ```
1088
+
1089
+ Bounded by a box
1090
+
1091
+ ```ruby
1092
+ Restaurant.search("sushi").where(location: {top_left: {lat: 38, lon: -123}, bottom_right: {lat: 37, lon: -122}})
1093
+ ```
1094
+
1095
+ **Note:** `top_right` and `bottom_left` also work
1096
+
1097
+ Bounded by a polygon
1098
+
1099
+ ```ruby
1100
+ Restaurant.search("dessert").where(location: {geo_polygon: {points: [{lat: 38, lon: -123}, {lat: 39, lon: -123}, {lat: 37, lon: 122}]}})
1101
+ ```
1102
+
1103
+ ### Boost By Distance
1104
+
1105
+ Boost results by distance - closer results are boosted more
1106
+
1107
+ ```ruby
1108
+ Restaurant.search("noodles").boost_by_distance(location: {origin: {lat: 37, lon: -122}})
1109
+ ```
1110
+
1111
+ Also supports [additional options](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-function-score-query.html#function-decay)
1112
+
1113
+ ```ruby
1114
+ Restaurant.search("wings").boost_by_distance(location: {origin: {lat: 37, lon: -122}, function: "linear", scale: "30mi", decay: 0.5})
1115
+ ```
1116
+
1117
+ ### Geo Shapes
1118
+
1119
+ You can also index and search geo shapes.
1120
+
1121
+ ```ruby
1122
+ class Restaurant < ApplicationRecord
1123
+ searchkick geo_shape: [:bounds]
1124
+
1125
+ def search_data
1126
+ attributes.merge(
1127
+ bounds: {
1128
+ type: "envelope",
1129
+ coordinates: [{lat: 4, lon: 1}, {lat: 2, lon: 3}]
1130
+ }
1131
+ )
1132
+ end
1133
+ end
1134
+ ```
1135
+
1136
+ See the [Elasticsearch documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/geo-shape.html) for details.
1137
+
1138
+ Find shapes intersecting with the query shape
1139
+
1140
+ ```ruby
1141
+ Restaurant.search("soup").where(bounds: {geo_shape: {type: "polygon", coordinates: [[{lat: 38, lon: -123}, ...]]}})
1142
+ ```
1143
+
1144
+ Falling entirely within the query shape
1145
+
1146
+ ```ruby
1147
+ Restaurant.search("salad").where(bounds: {geo_shape: {type: "circle", relation: "within", coordinates: {lat: 38, lon: -123}, radius: "1km"}})
1148
+ ```
1149
+
1150
+ Not touching the query shape
1151
+
1152
+ ```ruby
1153
+ Restaurant.search("burger").where(bounds: {geo_shape: {type: "envelope", relation: "disjoint", coordinates: [{lat: 38, lon: -123}, {lat: 37, lon: -122}]}})
1154
+ ```
1155
+
1156
+ ## Inheritance
1157
+
1158
+ Searchkick supports single table inheritance.
1159
+
1160
+ ```ruby
1161
+ class Dog < Animal
1162
+ end
1163
+ ```
1164
+
1165
+ In your parent model, set:
1166
+
1167
+ ```ruby
1168
+ class Animal < ApplicationRecord
1169
+ searchkick inheritance: true
1170
+ end
1171
+ ```
1172
+
1173
+ The parent and child model can both reindex.
1174
+
1175
+ ```ruby
1176
+ Animal.reindex
1177
+ Dog.reindex # equivalent, all animals reindexed
1178
+ ```
1179
+
1180
+ And to search, use:
1181
+
1182
+ ```ruby
1183
+ Animal.search("*") # all animals
1184
+ Dog.search("*") # just dogs
1185
+ Animal.search("*").type(Cat, Dog) # just cats and dogs
1186
+ ```
1187
+
1188
+ **Notes:**
1189
+
1190
+ 1. The `suggest` option retrieves suggestions from the parent at the moment.
1191
+
1192
+ ```ruby
1193
+ Dog.search("airbudd").suggest # suggestions for all animals
1194
+ ```
1195
+ 2. This relies on a `type` field that is automatically added to the indexed document. Be wary of defining your own `type` field in `search_data`, as it will take precedence.
1196
+
1197
+ ## Debugging Queries
1198
+
1199
+ To help with debugging queries, you can use:
1200
+
1201
+ ```ruby
1202
+ Product.search("soap").debug
1203
+ ```
1204
+
1205
+ This prints useful info to `stdout`.
1206
+
1207
+ See how the search server scores your queries with:
1208
+
1209
+ ```ruby
1210
+ Product.search("soap").explain.response
1211
+ ```
1212
+
1213
+ See how the search server tokenizes your queries with:
1214
+
1215
+ ```ruby
1216
+ Product.search_index.tokens("Dish Washer Soap", analyzer: "searchkick_index")
1217
+ # ["dish", "dishwash", "washer", "washersoap", "soap"]
1218
+
1219
+ Product.search_index.tokens("dishwasher soap", analyzer: "searchkick_search")
1220
+ # ["dishwashersoap"] - no match
1221
+
1222
+ Product.search_index.tokens("dishwasher soap", analyzer: "searchkick_search2")
1223
+ # ["dishwash", "soap"] - match!!
1224
+ ```
1225
+
1226
+ Partial matches
1227
+
1228
+ ```ruby
1229
+ Product.search_index.tokens("San Diego", analyzer: "searchkick_word_start_index")
1230
+ # ["s", "sa", "san", "d", "di", "die", "dieg", "diego"]
1231
+
1232
+ Product.search_index.tokens("dieg", analyzer: "searchkick_word_search")
1233
+ # ["dieg"] - match!!
1234
+ ```
1235
+
1236
+ See the [complete list of analyzers](lib/searchkick/index_options.rb#L36).
1237
+
1238
+ ## Testing
1239
+
1240
+ As you iterate on your search, it’s a good idea to add tests.
1241
+
1242
+ For performance, only enable Searchkick callbacks for the tests that need it.
1243
+
1244
+ ### Rails
1245
+
1246
+ Add to your `test/test_helper.rb`:
1247
+
1248
+ ```ruby
1249
+ module ActiveSupport
1250
+ class TestCase
1251
+ parallelize_setup do |worker|
1252
+ Searchkick.index_suffix = worker
1253
+
1254
+ # reindex models for parallel tests
1255
+ Product.reindex
1256
+ end
1257
+ end
1258
+ end
1259
+
1260
+ # reindex models for non-parallel tests
1261
+ Product.reindex
1262
+
1263
+ # and disable callbacks
1264
+ Searchkick.disable_callbacks
1265
+ ```
1266
+
1267
+ And use:
1268
+
1269
+ ```ruby
1270
+ class ProductTest < ActiveSupport::TestCase
1271
+ setup do
1272
+ Searchkick.enable_callbacks
1273
+ end
1274
+
1275
+ teardown do
1276
+ Searchkick.disable_callbacks
1277
+ end
1278
+
1279
+ test "search" do
1280
+ Product.create!(name: "Apple")
1281
+ Product.search_index.refresh
1282
+ assert_equal ["Apple"], Product.search("apple").map(&:name)
1283
+ end
1284
+ end
1285
+ ```
1286
+
1287
+ ### Minitest
1288
+
1289
+ Add to your `test/test_helper.rb`:
1290
+
1291
+ ```ruby
1292
+ # reindex models
1293
+ Product.reindex
1294
+
1295
+ # and disable callbacks
1296
+ Searchkick.disable_callbacks
1297
+ ```
1298
+
1299
+ And use:
1300
+
1301
+ ```ruby
1302
+ class ProductTest < Minitest::Test
1303
+ def setup
1304
+ Searchkick.enable_callbacks
1305
+ end
1306
+
1307
+ def teardown
1308
+ Searchkick.disable_callbacks
1309
+ end
1310
+
1311
+ def test_search
1312
+ Product.create!(name: "Apple")
1313
+ Product.search_index.refresh
1314
+ assert_equal ["Apple"], Product.search("apple").map(&:name)
1315
+ end
1316
+ end
1317
+ ```
1318
+
1319
+ ### RSpec
1320
+
1321
+ Add to your `spec/spec_helper.rb`:
1322
+
1323
+ ```ruby
1324
+ RSpec.configure do |config|
1325
+ config.before(:suite) do
1326
+ # reindex models
1327
+ Product.reindex
1328
+
1329
+ # and disable callbacks
1330
+ Searchkick.disable_callbacks
1331
+ end
1332
+
1333
+ config.around(:each, search: true) do |example|
1334
+ Searchkick.callbacks(nil) do
1335
+ example.run
1336
+ end
1337
+ end
1338
+ end
1339
+ ```
1340
+
1341
+ And use:
1342
+
1343
+ ```ruby
1344
+ describe Product, search: true do
1345
+ it "searches" do
1346
+ Product.create!(name: "Apple")
1347
+ Product.search_index.refresh
1348
+ assert_equal ["Apple"], Product.search("apple").map(&:name)
1349
+ end
1350
+ end
1351
+ ```
1352
+
1353
+ ### Factory Bot
1354
+
1355
+ Define a trait for each model:
1356
+
1357
+ ```ruby
1358
+ FactoryBot.define do
1359
+ factory :product do
1360
+ trait :reindex do
1361
+ after(:create) do |product, _|
1362
+ product.reindex(refresh: true)
1363
+ end
1364
+ end
1365
+ end
1366
+ end
1367
+ ```
1368
+
1369
+ And use:
1370
+
1371
+ ```ruby
1372
+ FactoryBot.create(:product, :reindex)
1373
+ ```
1374
+
1375
+ ### GitHub Actions
1376
+
1377
+ Check out [setup-elasticsearch](https://github.com/ankane/setup-elasticsearch) for an easy way to install Elasticsearch:
1378
+
1379
+ ```yml
1380
+ - uses: ankane/setup-elasticsearch@v1
1381
+ ```
1382
+
1383
+ And [setup-opensearch](https://github.com/ankane/setup-opensearch) for an easy way to install OpenSearch:
1384
+
1385
+ ```yml
1386
+ - uses: ankane/setup-opensearch@v1
1387
+ ```
1388
+
1389
+ ## Deployment
1390
+
1391
+ For the search server, Searchkick uses `ENV["ELASTICSEARCH_URL"]` for Elasticsearch and `ENV["OPENSEARCH_URL"]` for OpenSearch. This defaults to `http://localhost:9200`.
1392
+
1393
+ - [Elastic Cloud](#elastic-cloud)
1394
+ - [Amazon OpenSearch Service](#amazon-opensearch-service)
1395
+ - [Heroku](#heroku)
1396
+ - [Self-Hosted and Other](#self-hosted-and-other)
1397
+
1398
+ ### Elastic Cloud
1399
+
1400
+ Create an initializer `config/initializers/elasticsearch.rb` with:
1401
+
1402
+ ```ruby
1403
+ ENV["ELASTICSEARCH_URL"] = "https://user:password@host:port"
1404
+ ```
1405
+
1406
+ Then deploy and reindex:
1407
+
1408
+ ```sh
1409
+ rake searchkick:reindex:all
1410
+ ```
1411
+
1412
+ ### Amazon OpenSearch Service
1413
+
1414
+ Create an initializer `config/initializers/opensearch.rb` with:
1415
+
1416
+ ```ruby
1417
+ ENV["OPENSEARCH_URL"] = "https://es-domain-1234.us-east-1.es.amazonaws.com:443"
1418
+ ```
1419
+
1420
+ To use signed requests, include in your Gemfile:
1421
+
1422
+ ```ruby
1423
+ gem "faraday_middleware-aws-sigv4"
1424
+ ```
1425
+
1426
+ and add to your initializer:
1427
+
1428
+ ```ruby
1429
+ Searchkick.aws_credentials = {
1430
+ access_key_id: ENV["AWS_ACCESS_KEY_ID"],
1431
+ secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"],
1432
+ region: "us-east-1"
1433
+ }
1434
+ ```
1435
+
1436
+ Then deploy and reindex:
1437
+
1438
+ ```sh
1439
+ rake searchkick:reindex:all
1440
+ ```
1441
+
1442
+ ### Heroku
1443
+
1444
+ Choose an add-on: [Bonsai](https://elements.heroku.com/addons/bonsai), [SearchBox](https://elements.heroku.com/addons/searchbox), or [Elastic Cloud](https://elements.heroku.com/addons/foundelasticsearch).
1445
+
1446
+ For Elasticsearch on Bonsai:
1447
+
1448
+ ```sh
1449
+ heroku addons:create bonsai
1450
+ heroku config:set ELASTICSEARCH_URL=`heroku config:get BONSAI_URL`
1451
+ ```
1452
+
1453
+ For OpenSearch on Bonsai:
1454
+
1455
+ ```sh
1456
+ heroku addons:create bonsai --engine=opensearch
1457
+ heroku config:set OPENSEARCH_URL=`heroku config:get BONSAI_URL`
1458
+ ```
1459
+
1460
+ For SearchBox:
1461
+
1462
+ ```sh
1463
+ heroku addons:create searchbox:starter
1464
+ heroku config:set ELASTICSEARCH_URL=`heroku config:get SEARCHBOX_URL`
1465
+ ```
1466
+
1467
+ For Elastic Cloud (previously Found):
1468
+
1469
+ ```sh
1470
+ heroku addons:create foundelasticsearch
1471
+ heroku addons:open foundelasticsearch
1472
+ ```
1473
+
1474
+ Visit the Shield page and reset your password. You’ll need to add the username and password to your url. Get the existing url with:
1475
+
1476
+ ```sh
1477
+ heroku config:get FOUNDELASTICSEARCH_URL
1478
+ ```
1479
+
1480
+ And add `elastic:password@` right after `https://` and add port `9243` at the end:
1481
+
1482
+ ```sh
1483
+ heroku config:set ELASTICSEARCH_URL=https://elastic:password@12345.us-east-1.aws.found.io:9243
1484
+ ```
1485
+
1486
+ Then deploy and reindex:
1487
+
1488
+ ```sh
1489
+ heroku run rake searchkick:reindex:all
1490
+ ```
1491
+
1492
+ ### Self-Hosted and Other
1493
+
1494
+ Create an initializer with:
1495
+
1496
+ ```ruby
1497
+ ENV["ELASTICSEARCH_URL"] = "https://user:password@host:port"
1498
+ # or
1499
+ ENV["OPENSEARCH_URL"] = "https://user:password@host:port"
1500
+ ```
1501
+
1502
+ Then deploy and reindex:
1503
+
1504
+ ```sh
1505
+ rake searchkick:reindex:all
1506
+ ```
1507
+
1508
+ ### Data Protection
1509
+
1510
+ We recommend encrypting data at rest and in transit (even inside your own network). This is especially important if you send [personal data](https://en.wikipedia.org/wiki/Personally_identifiable_information) of your users to the search server.
1511
+
1512
+ Bonsai, Elastic Cloud, and Amazon OpenSearch Service all support encryption at rest and HTTPS.
1513
+
1514
+ ### Automatic Failover
1515
+
1516
+ Create an initializer with multiple hosts:
1517
+
1518
+ ```ruby
1519
+ ENV["ELASTICSEARCH_URL"] = "https://user:password@host1,https://user:password@host2"
1520
+ # or
1521
+ ENV["OPENSEARCH_URL"] = "https://user:password@host1,https://user:password@host2"
1522
+ ```
1523
+
1524
+ ### Client Options
1525
+
1526
+ Create an initializer with:
1527
+
1528
+ ```ruby
1529
+ Searchkick.client_options[:reload_connections] = true
1530
+ ```
1531
+
1532
+ See the docs for [Elasticsearch](https://www.elastic.co/guide/en/elasticsearch/client/ruby-api/current/advanced-config.html) or [Opensearch](https://rubydoc.info/gems/opensearch-transport#configuration) for a complete list of options.
1533
+
1534
+ ### Lograge
1535
+
1536
+ Add the following to `config/environments/production.rb`:
1537
+
1538
+ ```ruby
1539
+ config.lograge.custom_options = lambda do |event|
1540
+ options = {}
1541
+ options[:search] = event.payload[:searchkick_runtime] if event.payload[:searchkick_runtime].to_f > 0
1542
+ options
1543
+ end
1544
+ ```
1545
+
1546
+ See [Production Rails](https://github.com/ankane/production_rails) for other good practices.
1547
+
1548
+ ## Performance
1549
+
1550
+ ### Persistent HTTP Connections
1551
+
1552
+ Significantly increase performance with persistent HTTP connections. Add [Typhoeus](https://github.com/typhoeus/typhoeus) to your Gemfile and it’ll automatically be used.
1553
+
1554
+ ```ruby
1555
+ gem "typhoeus"
1556
+ ```
1557
+
1558
+ To reduce log noise, create an initializer with:
1559
+
1560
+ ```ruby
1561
+ Ethon.logger = Logger.new(nil)
1562
+ ```
1563
+
1564
+ ### Searchable Fields
1565
+
1566
+ By default, all string fields are searchable (can be used in `fields` option). Speed up indexing and reduce index size by only making some fields searchable.
1567
+
1568
+ ```ruby
1569
+ class Product < ApplicationRecord
1570
+ searchkick searchable: [:name]
1571
+ end
1572
+ ```
1573
+
1574
+ ### Filterable Fields
1575
+
1576
+ By default, all string fields are filterable (can be used in `where` option). Speed up indexing and reduce index size by only making some fields filterable.
1577
+
1578
+ ```ruby
1579
+ class Product < ApplicationRecord
1580
+ searchkick filterable: [:brand]
1581
+ end
1582
+ ```
1583
+
1584
+ **Note:** Non-string fields are always filterable and should not be passed to this option.
1585
+
1586
+ ### Parallel Reindexing
1587
+
1588
+ For large data sets, you can use background jobs to parallelize reindexing.
1589
+
1590
+ ```ruby
1591
+ Product.reindex(mode: :async)
1592
+ # {index_name: "products_production_20250111210018065"}
1593
+ ```
1594
+
1595
+ Once the jobs complete, promote the new index with:
1596
+
1597
+ ```ruby
1598
+ Product.search_index.promote(index_name)
1599
+ ```
1600
+
1601
+ You can optionally track the status with Redis:
1602
+
1603
+ ```ruby
1604
+ Searchkick.redis = Redis.new
1605
+ ```
1606
+
1607
+ And use:
1608
+
1609
+ ```ruby
1610
+ Searchkick.reindex_status(index_name)
1611
+ ```
1612
+
1613
+ You can also have Searchkick wait for reindexing to complete
1614
+
1615
+ ```ruby
1616
+ Product.reindex(mode: :async, wait: true)
1617
+ ```
1618
+
1619
+ You can use your background job framework to control concurrency. For Solid Queue, create an initializer with:
1620
+
1621
+ ```ruby
1622
+ module SearchkickBulkReindexConcurrency
1623
+ extend ActiveSupport::Concern
1624
+
1625
+ included do
1626
+ limits_concurrency to: 3, key: ""
1627
+ end
1628
+ end
1629
+
1630
+ Rails.application.config.after_initialize do
1631
+ Searchkick::BulkReindexJob.include(SearchkickBulkReindexConcurrency)
1632
+ end
1633
+ ```
1634
+
1635
+ This will allow only 3 jobs to run at once.
1636
+
1637
+ ### Refresh Interval
1638
+
1639
+ You can specify a longer refresh interval while reindexing to increase performance.
1640
+
1641
+ ```ruby
1642
+ Product.reindex(mode: :async, refresh_interval: "30s")
1643
+ ```
1644
+
1645
+ **Note:** This only makes a noticeable difference with parallel reindexing.
1646
+
1647
+ When promoting, have it restored to the value in your mapping (defaults to `1s`).
1648
+
1649
+ ```ruby
1650
+ Product.search_index.promote(index_name, update_refresh_interval: true)
1651
+ ```
1652
+
1653
+ ### Queuing
1654
+
1655
+ Push ids of records needing reindexing to a queue and reindex in bulk for better performance. First, set up Redis in an initializer. We recommend using [connection_pool](https://github.com/mperham/connection_pool).
1656
+
1657
+ ```ruby
1658
+ Searchkick.redis = ConnectionPool.new { Redis.new }
1659
+ ```
1660
+
1661
+ And ask your models to queue updates.
1662
+
1663
+ ```ruby
1664
+ class Product < ApplicationRecord
1665
+ searchkick callbacks: :queue
1666
+ end
1667
+ ```
1668
+
1669
+ Then, set up a background job to run.
1670
+
1671
+ ```ruby
1672
+ Searchkick::ProcessQueueJob.perform_later(class_name: "Product")
1673
+ ```
1674
+
1675
+ You can check the queue length with:
1676
+
1677
+ ```ruby
1678
+ Product.search_index.reindex_queue.length
1679
+ ```
1680
+
1681
+ For more tips, check out [Keeping Elasticsearch in Sync](https://www.elastic.co/blog/found-keeping-elasticsearch-in-sync).
1682
+
1683
+ ### Routing
1684
+
1685
+ Searchkick supports [routing](https://www.elastic.co/blog/customizing-your-document-routing), which can significantly speed up searches.
1686
+
1687
+ ```ruby
1688
+ class Business < ApplicationRecord
1689
+ searchkick routing: true
1690
+
1691
+ def search_routing
1692
+ city_id
1693
+ end
1694
+ end
1695
+ ```
1696
+
1697
+ Reindex and search with:
1698
+
1699
+ ```ruby
1700
+ Business.search("ice cream").routing(params[:city_id])
1701
+ ```
1702
+
1703
+ ### Partial Reindexing
1704
+
1705
+ Reindex a subset of attributes to reduce time spent generating search data and cut down on network traffic.
1706
+
1707
+ ```ruby
1708
+ class Product < ApplicationRecord
1709
+ def search_data
1710
+ {
1711
+ name: name,
1712
+ category: category
1713
+ }.merge(prices_data)
1714
+ end
1715
+
1716
+ def prices_data
1717
+ {
1718
+ price: price,
1719
+ sale_price: sale_price
1720
+ }
1721
+ end
1722
+ end
1723
+ ```
1724
+
1725
+ And use:
1726
+
1727
+ ```ruby
1728
+ Product.reindex(:prices_data)
1729
+ ```
1730
+
1731
+ Ignore errors for missing documents with:
1732
+
1733
+ ```ruby
1734
+ Product.reindex(:prices_data, ignore_missing: true)
1735
+ ```
1736
+
1737
+ ## Advanced
1738
+
1739
+ Searchkick makes it easy to use the Elasticsearch or OpenSearch DSL on its own.
1740
+
1741
+ ### Advanced Mapping
1742
+
1743
+ Create a custom mapping:
1744
+
1745
+ ```ruby
1746
+ class Product < ApplicationRecord
1747
+ searchkick mappings: {
1748
+ properties: {
1749
+ name: {type: "keyword"}
1750
+ }
1751
+ }
1752
+ end
1753
+ ```
1754
+ **Note:** If you use a custom mapping, you'll need to use [custom searching](#advanced-search) as well.
1755
+
1756
+ To keep the mappings and settings generated by Searchkick, use:
1757
+
1758
+ ```ruby
1759
+ class Product < ApplicationRecord
1760
+ searchkick merge_mappings: true, mappings: {...}
1761
+ end
1762
+ ```
1763
+
1764
+ ### Advanced Search
1765
+
1766
+ And use the `body` option to search:
1767
+
1768
+ ```ruby
1769
+ products = Product.search.body(query: {match: {name: "milk"}})
1770
+ ```
1771
+
1772
+ View the response with:
1773
+
1774
+ ```ruby
1775
+ products.response
1776
+ ```
1777
+
1778
+ To modify the query generated by Searchkick, use:
1779
+
1780
+ ```ruby
1781
+ products = Product.search("milk").body_options(min_score: 1)
1782
+ ```
1783
+
1784
+ or
1785
+
1786
+ ```ruby
1787
+ products =
1788
+ Product.search("apples") do |body|
1789
+ body[:min_score] = 1
1790
+ end
1791
+ ```
1792
+
1793
+ ### Client
1794
+
1795
+ To access the `Elasticsearch::Client` or `OpenSearch::Client` directly, use:
1796
+
1797
+ ```ruby
1798
+ Searchkick.client
1799
+ ```
1800
+
1801
+ ## Multi Search
1802
+
1803
+ To batch search requests for performance, use:
1804
+
1805
+ ```ruby
1806
+ products = Product.search("snacks")
1807
+ coupons = Coupon.search("snacks")
1808
+ Searchkick.multi_search([products, coupons])
1809
+ ```
1810
+
1811
+ Then use `products` and `coupons` as typical results.
1812
+
1813
+ **Note:** Errors are not raised as with single requests. Use the `error` method on each query to check for errors.
1814
+
1815
+ ## Multiple Models
1816
+
1817
+ Search across multiple models with:
1818
+
1819
+ ```ruby
1820
+ Searchkick.search("milk").models(Product, Category)
1821
+ ```
1822
+
1823
+ Boost specific models with:
1824
+
1825
+ ```ruby
1826
+ indices_boost(Category => 2, Product => 1)
1827
+ ```
1828
+
1829
+ ## Multi-Tenancy
1830
+
1831
+ Check out [this great post](https://www.tiagoamaro.com.br/2014/12/11/multi-tenancy-with-searchkick/) on the [Apartment](https://github.com/influitive/apartment) gem. Follow a similar pattern if you use another gem.
1832
+
1833
+ ## Scroll API
1834
+
1835
+ Searchkick also supports the [scroll API](https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#scroll-search-results). Scrolling is not intended for real time user requests, but rather for processing large amounts of data.
1836
+
1837
+ ```ruby
1838
+ Product.search("*").scroll("1m") do |batch|
1839
+ # process batch ...
1840
+ end
1841
+ ```
1842
+
1843
+ You can also scroll batches manually.
1844
+
1845
+ ```ruby
1846
+ products = Product.search("*").scroll("1m")
1847
+ while products.any?
1848
+ # process batch ...
1849
+
1850
+ products = products.scroll
1851
+ end
1852
+
1853
+ products.clear_scroll
1854
+ ```
1855
+
1856
+ ## Deep Paging
1857
+
1858
+ By default, Elasticsearch and OpenSearch limit paging to the first 10,000 results. [Here’s why](https://www.elastic.co/guide/en/elasticsearch/guide/current/pagination.html). We don’t recommend changing this, but if you really need all results, you can use:
1859
+
1860
+ ```ruby
1861
+ class Product < ApplicationRecord
1862
+ searchkick deep_paging: true
1863
+ end
1864
+ ```
1865
+
1866
+ If you just need an accurate total count, you can instead use:
1867
+
1868
+ ```ruby
1869
+ Product.search("pears").body_options(track_total_hits: true)
1870
+ ```
1871
+
1872
+ ## Nested Data
1873
+
1874
+ To query nested data, use dot notation.
1875
+
1876
+ ```ruby
1877
+ Product.search("san").fields("store.city").where("store.zip_code" => 12345)
1878
+ ```
1879
+
1880
+ ## Nearest Neighbor Search
1881
+
1882
+ *Available for Elasticsearch 8.6+ and OpenSearch 2.4+*
1883
+
1884
+ ```ruby
1885
+ class Product < ApplicationRecord
1886
+ searchkick knn: {embedding: {dimensions: 3, distance: "cosine"}}
1887
+ end
1888
+ ```
1889
+
1890
+ Also supports `euclidean` and `inner_product`
1891
+
1892
+ Reindex and search with:
1893
+
1894
+ ```ruby
1895
+ Product.search.knn(field: :embedding, vector: [1, 2, 3]).limit(10)
1896
+ ```
1897
+
1898
+ ### HNSW Options
1899
+
1900
+ Nearest neighbor search uses [HNSW](https://en.wikipedia.org/wiki/Hierarchical_navigable_small_world) for indexing.
1901
+
1902
+ Specify `m` and `ef_construction`
1903
+
1904
+ ```ruby
1905
+ class Product < ApplicationRecord
1906
+ searchkick knn: {embedding: {dimensions: 3, distance: "cosine", m: 16, ef_construction: 100}}
1907
+ end
1908
+ ```
1909
+
1910
+ Specify `ef_search`
1911
+
1912
+ ```ruby
1913
+ Product.search.knn(field: :embedding, vector: [1, 2, 3], ef_search: 40).limit(10)
1914
+ ```
1915
+
1916
+ ## Semantic Search
1917
+
1918
+ First, add [nearest neighbor search](#nearest-neighbor-search) to your model
1919
+
1920
+ ```ruby
1921
+ class Product < ApplicationRecord
1922
+ searchkick knn: {embedding: {dimensions: 768, distance: "cosine"}}
1923
+ end
1924
+ ```
1925
+
1926
+ Generate an embedding for each record (you can use an external service or a library like [Informers](https://github.com/ankane/informers))
1927
+
1928
+ ```ruby
1929
+ embed = Informers.pipeline("embedding", "Snowflake/snowflake-arctic-embed-m-v1.5")
1930
+ embed_options = {model_output: "sentence_embedding", pooling: "none"} # specific to embedding model
1931
+
1932
+ Product.find_each do |product|
1933
+ embedding = embed.(product.name, **embed_options)
1934
+ product.update!(embedding: embedding)
1935
+ end
1936
+ ```
1937
+
1938
+ For search, generate an embedding for the query (the query prefix is specific to the [embedding model](https://huggingface.co/Snowflake/snowflake-arctic-embed-m-v1.5))
1939
+
1940
+ ```ruby
1941
+ query_prefix = "Represent this sentence for searching relevant passages: "
1942
+ query_embedding = embed.(query_prefix + query, **embed_options)
1943
+ ```
1944
+
1945
+ And perform nearest neighbor search
1946
+
1947
+ ```ruby
1948
+ Product.search.knn(field: :embedding, vector: query_embedding).limit(20)
1949
+ ```
1950
+
1951
+ See a [full example](examples/semantic.rb)
1952
+
1953
+ ## Hybrid Search
1954
+
1955
+ Perform keyword search and semantic search in parallel
1956
+
1957
+ ```ruby
1958
+ keyword_search = Product.search(query).limit(20)
1959
+ semantic_search = Product.search.knn(field: :embedding, vector: query_embedding).limit(20)
1960
+ Searchkick.multi_search([keyword_search, semantic_search])
1961
+ ```
1962
+
1963
+ To combine the results, use Reciprocal Rank Fusion (RRF)
1964
+
1965
+ ```ruby
1966
+ Searchkick::Reranking.rrf(keyword_search, semantic_search).first(5)
1967
+ ```
1968
+
1969
+ Or a reranking model
1970
+
1971
+ ```ruby
1972
+ rerank = Informers.pipeline("reranking", "mixedbread-ai/mxbai-rerank-xsmall-v1")
1973
+ results = (keyword_search.to_a + semantic_search.to_a).uniq
1974
+ rerank.(query, results.map(&:name)).first(5).map { |v| results[v[:doc_id]] }
1975
+ ```
1976
+
1977
+ See a [full example](examples/hybrid.rb)
1978
+
1979
+ ## Reference
1980
+
1981
+ Reindex one record
1982
+
1983
+ ```ruby
1984
+ product = Product.find(1)
1985
+ product.reindex
1986
+ ```
1987
+
1988
+ Reindex multiple records
1989
+
1990
+ ```ruby
1991
+ Product.where(store_id: 1).reindex
1992
+ ```
1993
+
1994
+ Reindex associations
1995
+
1996
+ ```ruby
1997
+ store.products.reindex
1998
+ ```
1999
+
2000
+ Remove old indices
2001
+
2002
+ ```ruby
2003
+ Product.search_index.clean_indices
2004
+ ```
2005
+
2006
+ Use custom settings
2007
+
2008
+ ```ruby
2009
+ class Product < ApplicationRecord
2010
+ searchkick settings: {number_of_shards: 3}
2011
+ end
2012
+ ```
2013
+
2014
+ Use a different index name
2015
+
2016
+ ```ruby
2017
+ class Product < ApplicationRecord
2018
+ searchkick index_name: "products_v2"
2019
+ end
2020
+ ```
2021
+
2022
+ Use a dynamic index name
2023
+
2024
+ ```ruby
2025
+ class Product < ApplicationRecord
2026
+ searchkick index_name: -> { "#{name.tableize}-#{I18n.locale}" }
2027
+ end
2028
+ ```
2029
+
2030
+ Prefix the index name
2031
+
2032
+ ```ruby
2033
+ class Product < ApplicationRecord
2034
+ searchkick index_prefix: "datakick"
2035
+ end
2036
+ ```
2037
+
2038
+ For all models
2039
+
2040
+ ```ruby
2041
+ Searchkick.index_prefix = "datakick"
2042
+ ```
2043
+
2044
+ Use a different term for boosting by conversions
2045
+
2046
+ ```ruby
2047
+ Product.search("banana").conversions_v2(term: "organic banana")
2048
+ ```
2049
+
2050
+ Define multiple conversion fields
2051
+
2052
+ ```ruby
2053
+ class Product < ApplicationRecord
2054
+ has_many :searches, class_name: "Searchjoy::Search"
2055
+
2056
+ searchkick conversions_v2: ["unique_conversions", "total_conversions"]
2057
+
2058
+ def search_data
2059
+ {
2060
+ name: name,
2061
+ unique_conversions: searches.group(:query).distinct.count(:user_id),
2062
+ total_conversions: searches.group(:query).count
2063
+ }
2064
+ end
2065
+ end
2066
+ ```
2067
+
2068
+ And specify which to use
2069
+
2070
+ ```ruby
2071
+ Product.search("banana") # boost by both fields (default)
2072
+ Product.search("banana").conversions_v2("total_conversions") # only boost by total_conversions
2073
+ Product.search("banana").conversions_v2(false) # no conversion boosting
2074
+ ```
2075
+
2076
+ Change timeout
2077
+
2078
+ ```ruby
2079
+ Searchkick.timeout = 15 # defaults to 10
2080
+ ```
2081
+
2082
+ Set a lower timeout for searches
2083
+
2084
+ ```ruby
2085
+ Searchkick.search_timeout = 3
2086
+ ```
2087
+
2088
+ Change the search method name
2089
+
2090
+ ```ruby
2091
+ Searchkick.search_method_name = :lookup
2092
+ ```
2093
+
2094
+ Change the queue name
2095
+
2096
+ ```ruby
2097
+ Searchkick.queue_name = :search_reindex # defaults to :searchkick
2098
+ ```
2099
+
2100
+ Change the queue name or priority for a model
2101
+
2102
+ ```ruby
2103
+ class Product < ApplicationRecord
2104
+ searchkick job_options: {queue: "critical", priority: 10}
2105
+ end
2106
+ ```
2107
+
2108
+ Change the queue name or priority for a specific call
2109
+
2110
+ ```ruby
2111
+ Product.reindex(mode: :async, job_options: {queue: "critical", priority: 10})
2112
+ ```
2113
+
2114
+ Change the parent job
2115
+
2116
+ ```ruby
2117
+ Searchkick.parent_job = "ApplicationJob" # defaults to "ActiveJob::Base"
2118
+ ```
2119
+
2120
+ Eager load associations
2121
+
2122
+ ```ruby
2123
+ Product.search("milk").includes(:brand, :stores)
2124
+ ```
2125
+
2126
+ Eager load different associations by model
2127
+
2128
+ ```ruby
2129
+ Searchkick.search("*").models(Product, Store).model_includes(Product => [:store], Store => [:product])
2130
+ ```
2131
+
2132
+ Run additional scopes on results
2133
+
2134
+ ```ruby
2135
+ Product.search("milk").scope_results(->(r) { r.with_attached_images })
2136
+ ```
2137
+
2138
+ Set opaque id for slow logs
2139
+
2140
+ ```ruby
2141
+ Product.search("milk").opaque_id("some-id")
2142
+ # or
2143
+ Searchkick.multi_search(searches, opaque_id: "some-id")
2144
+ ```
2145
+
2146
+ Specify default fields to search
2147
+
2148
+ ```ruby
2149
+ class Product < ApplicationRecord
2150
+ searchkick default_fields: [:name]
2151
+ end
2152
+ ```
2153
+
2154
+ Turn off special characters
2155
+
2156
+ ```ruby
2157
+ class Product < ApplicationRecord
2158
+ # A will not match Ä
2159
+ searchkick special_characters: false
2160
+ end
2161
+ ```
2162
+
2163
+ Turn on stemming for conversions
2164
+
2165
+ ```ruby
2166
+ class Product < ApplicationRecord
2167
+ searchkick stem_conversions: true
2168
+ end
2169
+ ```
2170
+
2171
+ Make search case-sensitive
2172
+
2173
+ ```ruby
2174
+ class Product < ApplicationRecord
2175
+ searchkick case_sensitive: true
2176
+ end
2177
+ ```
2178
+
2179
+ **Note:** If misspellings are enabled (default), results with a single character case difference will match. Turn off misspellings if this is not desired.
2180
+
2181
+ Change import batch size
2182
+
2183
+ ```ruby
2184
+ class Product < ApplicationRecord
2185
+ searchkick batch_size: 200 # defaults to 1000
2186
+ end
2187
+ ```
2188
+
2189
+ Create index without importing
2190
+
2191
+ ```ruby
2192
+ Product.reindex(import: false)
2193
+ ```
2194
+
2195
+ Use a different id
2196
+
2197
+ ```ruby
2198
+ class Product < ApplicationRecord
2199
+ def search_document_id
2200
+ custom_id
2201
+ end
2202
+ end
2203
+ ```
2204
+
2205
+ Add [request parameters](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-search.html#search-search-api-query-params) like `search_type`
2206
+
2207
+ ```ruby
2208
+ Product.search("carrots").request_params(search_type: "dfs_query_then_fetch")
2209
+ ```
2210
+
2211
+ Set options across all models
2212
+
2213
+ ```ruby
2214
+ Searchkick.model_options = {
2215
+ batch_size: 200
2216
+ }
2217
+ ```
2218
+
2219
+ Reindex conditionally
2220
+
2221
+ ```ruby
2222
+ class Product < ApplicationRecord
2223
+ searchkick callback_options: {if: :search_data_changed?}
2224
+
2225
+ def search_data_changed?
2226
+ previous_changes.include?("name")
2227
+ end
2228
+ end
2229
+ ```
2230
+
2231
+ Reindex all models - Rails only
2232
+
2233
+ ```sh
2234
+ rake searchkick:reindex:all
2235
+ ```
2236
+
2237
+ Turn on misspellings after a certain number of characters
2238
+
2239
+ ```ruby
2240
+ Product.search("api").misspellings(prefix_length: 2) # api, apt, no ahi
2241
+ ```
2242
+
2243
+ BigDecimal values are indexed as floats by default so they can be used for boosting. Convert them to strings to keep full precision.
2244
+
2245
+ ```ruby
2246
+ class Product < ApplicationRecord
2247
+ def search_data
2248
+ {
2249
+ units: units.to_s("F")
2250
+ }
2251
+ end
2252
+ end
2253
+ ```
2254
+
2255
+ ## Gotchas
2256
+
2257
+ ### Consistency
2258
+
2259
+ Elasticsearch and OpenSearch are eventually consistent, meaning it can take up to a second for a change to reflect in search. You can use the `refresh` method to have it show up immediately.
2260
+
2261
+ ```ruby
2262
+ product.save!
2263
+ Product.search_index.refresh
2264
+ ```
2265
+
2266
+ ### Inconsistent Scores
2267
+
2268
+ Due to the distributed nature of Elasticsearch and OpenSearch, you can get incorrect results when the number of documents in the index is low. You can [read more about it here](https://www.elastic.co/blog/understanding-query-then-fetch-vs-dfs-query-then-fetch). To fix this, do:
2269
+
2270
+ ```ruby
2271
+ class Product < ApplicationRecord
2272
+ searchkick settings: {number_of_shards: 1}
2273
+ end
2274
+ ```
2275
+
2276
+ For convenience, this is set by default in the test environment.
2277
+
2278
+ ## Upgrading
2279
+
2280
+ ### 6.0
2281
+
2282
+ Searchkick 6 brings a new query builder API:
2283
+
2284
+ ```ruby
2285
+ Product.search("apples").where(in_stock: true).limit(10).offset(50)
2286
+ ```
2287
+
2288
+ All existing options can be used as methods, or you can continue to use the existing API.
2289
+
2290
+ This release also significantly improves the performance of searches when using conversions. To upgrade conversions without downtime, add `conversions_v2` to your model and an additional field to `search_data`:
2291
+
2292
+ ```ruby
2293
+ class Product < ApplicationRecord
2294
+ searchkick conversions: [:conversions], conversions_v2: [:conversions_v2]
2295
+
2296
+ def search_data
2297
+ conversions = searches.group(:query).distinct.count(:user_id)
2298
+ {
2299
+ conversions: conversions,
2300
+ conversions_v2: conversions
2301
+ }
2302
+ end
2303
+ end
2304
+ ```
2305
+
2306
+ Reindex, then remove `conversions`:
2307
+
2308
+ ```ruby
2309
+ class Product < ApplicationRecord
2310
+ searchkick conversions_v2: [:conversions_v2]
2311
+
2312
+ def search_data
2313
+ {
2314
+ conversions_v2: searches.group(:query).distinct.count(:user_id)
2315
+ }
2316
+ end
2317
+ end
2318
+ ```
2319
+
2320
+ Other improvements include the option to ignore errors for missing documents with partial reindexing and more customization for background jobs. Check out the [changelog](https://github.com/ankane/searchkick/blob/master/CHANGELOG.md) for the full list of changes.
2321
+
2322
+ ## History
2323
+
2324
+ View the [changelog](https://github.com/ankane/searchkick/blob/master/CHANGELOG.md)
2325
+
2326
+ ## Thanks
2327
+
2328
+ Thanks to Karel Minarik for [Elasticsearch Ruby](https://github.com/elasticsearch/elasticsearch-ruby) and [Tire](https://github.com/karmi/retire), Jaroslav Kalistsuk for [zero downtime reindexing](https://gist.github.com/jarosan/3124884), and Alex Leschenko for [Elasticsearch autocomplete](https://github.com/leschenko/elasticsearch_autocomplete).
2329
+
2330
+ ## Contributing
2331
+
2332
+ Everyone is encouraged to help improve this project. Here are a few ways you can help:
2333
+
2334
+ - [Report bugs](https://github.com/ankane/searchkick/issues)
2335
+ - Fix bugs and [submit pull requests](https://github.com/ankane/searchkick/pulls)
2336
+ - Write, clarify, or fix documentation
2337
+ - Suggest or add new features
2338
+
2339
+ To get started with development:
2340
+
2341
+ ```sh
2342
+ git clone https://github.com/ankane/searchkick.git
2343
+ cd searchkick
2344
+ bundle install
2345
+ bundle exec rake test
2346
+ ```
2347
+
2348
+ Feel free to open an issue to get feedback on your idea before spending too much time on it.