minifts 1.0.0

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.
data/lib/minifts.rb ADDED
@@ -0,0 +1,944 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "minifts/version"
6
+ require_relative "minifts/searchable_map"
7
+
8
+ # A tiny, dependency-free full-text search engine, held entirely in memory.
9
+ #
10
+ # This is a Ruby port of the JavaScript {https://github.com/lucaong/minisearch
11
+ # MiniSearch} library, preserving its BM25+ scoring, prefix and fuzzy matching,
12
+ # query combinators, auto-suggestions, and JSON index format (indexes are
13
+ # interchangeable with the JS library).
14
+ #
15
+ # The public API mirrors the original, translated to Ruby conventions: options
16
+ # are passed as a Hash with snake_case symbol keys, callables are anything
17
+ # responding to +call+ (lambdas, procs, method objects), and field names are
18
+ # strings.
19
+ #
20
+ # @example
21
+ # ms = MiniFTS.new(fields: ["title", "text"], store_fields: ["title"])
22
+ # ms.add_all([
23
+ # { "id" => 1, "title" => "Moby Dick", "text" => "Call me Ishmael" },
24
+ # { "id" => 2, "title" => "Neuromancer", "text" => "The sky above the port" },
25
+ # ])
26
+ # ms.search("ishmael") # => [{ id: 1, score: ..., ... }]
27
+ # ms.search("neuro", prefix: true)
28
+ class MiniFTS
29
+ # Raised for invalid usage (missing options, duplicate/absent IDs, bad
30
+ # combinators, incompatible serialized indexes).
31
+ class Error < StandardError; end
32
+
33
+ # Combination operators.
34
+ OR = "or"
35
+ AND = "and"
36
+ AND_NOT = "and_not"
37
+
38
+ # The special value passed to {#search} to match every document.
39
+ WILDCARD = Object.new
40
+ def WILDCARD.to_s
41
+ "*"
42
+ end
43
+ WILDCARD.freeze
44
+
45
+ # BM25+ scoring parameters (see {#search}).
46
+ DEFAULT_BM25_PARAMS = { k: 1.2, b: 0.7, d: 0.5 }.freeze
47
+
48
+ # This regular expression matches any Unicode space, newline, or punctuation
49
+ # character. It mirrors the JavaScript source's SPACE_OR_PUNCTUATION.
50
+ SPACE_OR_PUNCTUATION = /[\n\r\p{Z}\p{P}]+/.freeze
51
+
52
+ # Default tokenizer: split on whitespace and punctuation. Mirrors JavaScript's
53
+ # +String.prototype.split+, which keeps leading/trailing empty tokens (limit
54
+ # -1) and yields +[""]+ for the empty string.
55
+ DEFAULT_TOKENIZE = lambda do |text, _field_name = nil|
56
+ text.empty? ? [""] : text.split(SPACE_OR_PUNCTUATION, -1)
57
+ end
58
+
59
+ # Default term processor: downcase. Ruby performs full Unicode case folding.
60
+ DEFAULT_PROCESS_TERM = lambda do |term, _field_name = nil|
61
+ term.downcase
62
+ end
63
+
64
+ # Default field extractor: index documents as Hashes keyed by field name.
65
+ DEFAULT_EXTRACT_FIELD = lambda do |document, field_name|
66
+ document[field_name]
67
+ end
68
+
69
+ # Default field stringifier.
70
+ DEFAULT_STRINGIFY_FIELD = lambda do |field_value, _field_name|
71
+ field_value.to_s
72
+ end
73
+
74
+ # Default logger: write warnings to $stderr.
75
+ DEFAULT_LOGGER = lambda do |level, message, _code = nil|
76
+ warn("[minifts] #{level}: #{message}")
77
+ end
78
+
79
+ DEFAULT_OPTIONS = {
80
+ id_field: "id",
81
+ extract_field: DEFAULT_EXTRACT_FIELD,
82
+ stringify_field: DEFAULT_STRINGIFY_FIELD,
83
+ tokenize: DEFAULT_TOKENIZE,
84
+ process_term: DEFAULT_PROCESS_TERM,
85
+ fields: nil,
86
+ search_options: nil,
87
+ store_fields: [],
88
+ logger: DEFAULT_LOGGER,
89
+ auto_vacuum: true
90
+ }.freeze
91
+
92
+ DEFAULT_SEARCH_OPTIONS = {
93
+ combine_with: OR,
94
+ prefix: false,
95
+ fuzzy: false,
96
+ max_fuzzy: 6,
97
+ boost: {},
98
+ weights: { fuzzy: 0.45, prefix: 0.375 },
99
+ bm25: DEFAULT_BM25_PARAMS
100
+ }.freeze
101
+
102
+ DEFAULT_AUTO_SUGGEST_OPTIONS = {
103
+ combine_with: AND,
104
+ prefix: ->(_term, i, terms) { i == terms.length - 1 }
105
+ }.freeze
106
+
107
+ DEFAULT_VACUUM_OPTIONS = { batch_size: 1000, batch_wait: 10 }.freeze
108
+ DEFAULT_VACUUM_CONDITIONS = { min_dirt_factor: 0.1, min_dirt_count: 20 }.freeze
109
+ DEFAULT_AUTO_VACUUM_OPTIONS = DEFAULT_VACUUM_OPTIONS.merge(DEFAULT_VACUUM_CONDITIONS).freeze
110
+
111
+ # Sentinel distinguishing "no argument" from an explicit +nil+ in {#remove_all}.
112
+ NO_DOCUMENTS = Object.new
113
+ private_constant :NO_DOCUMENTS
114
+
115
+ # The wildcard symbol, mirroring +MiniSearch.wildcard+.
116
+ def self.wildcard
117
+ WILDCARD
118
+ end
119
+
120
+ # @param options [Hash] configuration. +:fields+ (an Array of field-name
121
+ # strings to index) is required. See the README for the full list.
122
+ def initialize(options = {})
123
+ raise Error, 'MiniFTS: option "fields" must be provided' if options[:fields].nil?
124
+
125
+ auto_vacuum =
126
+ if options[:auto_vacuum].nil? || options[:auto_vacuum] == true
127
+ DEFAULT_AUTO_VACUUM_OPTIONS
128
+ else
129
+ options[:auto_vacuum]
130
+ end
131
+
132
+ @options = DEFAULT_OPTIONS.merge(options)
133
+ @options[:auto_vacuum] = auto_vacuum
134
+ @options[:search_options] = DEFAULT_SEARCH_OPTIONS.merge(options[:search_options] || {})
135
+ @options[:auto_suggest_options] = DEFAULT_AUTO_SUGGEST_OPTIONS.merge(options[:auto_suggest_options] || {})
136
+
137
+ reset_index
138
+ add_fields(@options[:fields])
139
+ end
140
+
141
+ # Adds a document to the index.
142
+ def add(document)
143
+ extract_field = @options[:extract_field]
144
+ stringify_field = @options[:stringify_field]
145
+ tokenize = @options[:tokenize]
146
+ process_term = @options[:process_term]
147
+
148
+ id = extract_field.call(document, @options[:id_field])
149
+ raise Error, "MiniFTS: document does not have ID field \"#{@options[:id_field]}\"" if id.nil?
150
+ raise Error, "MiniFTS: duplicate ID #{id}" if @id_to_short_id.key?(id)
151
+
152
+ short_document_id = add_document_id(id)
153
+ save_stored_fields(short_document_id, document)
154
+
155
+ @options[:fields].each do |field|
156
+ field_value = extract_field.call(document, field)
157
+ next if field_value.nil?
158
+
159
+ tokens = tokenize.call(stringify_field.call(field_value, field), field)
160
+ field_id = @field_ids[field]
161
+
162
+ unique_terms = tokens.uniq.length
163
+ add_field_length(short_document_id, field_id, @document_count - 1, unique_terms)
164
+
165
+ tokens.each do |term|
166
+ processed_term = process_term.call(term, field)
167
+ if processed_term.is_a?(Array)
168
+ processed_term.each { |t| add_term(field_id, short_document_id, t) }
169
+ elsif truthy?(processed_term)
170
+ add_term(field_id, short_document_id, processed_term)
171
+ end
172
+ end
173
+ end
174
+
175
+ nil
176
+ end
177
+
178
+ # Adds all the given documents to the index.
179
+ def add_all(documents)
180
+ documents.each { |document| add(document) }
181
+ nil
182
+ end
183
+
184
+ # Removes the given document from the index. The document must NOT have changed
185
+ # since indexing, or the index can be corrupted. Requires the full document;
186
+ # see {#discard} for an ID-only alternative.
187
+ def remove(document)
188
+ tokenize = @options[:tokenize]
189
+ process_term = @options[:process_term]
190
+ extract_field = @options[:extract_field]
191
+ stringify_field = @options[:stringify_field]
192
+
193
+ id = extract_field.call(document, @options[:id_field])
194
+ raise Error, "MiniFTS: document does not have ID field \"#{@options[:id_field]}\"" if id.nil?
195
+
196
+ short_id = @id_to_short_id[id]
197
+ raise Error, "MiniFTS: cannot remove document with ID #{id}: it is not in the index" if short_id.nil?
198
+
199
+ @options[:fields].each do |field|
200
+ field_value = extract_field.call(document, field)
201
+ next if field_value.nil?
202
+
203
+ tokens = tokenize.call(stringify_field.call(field_value, field), field)
204
+ field_id = @field_ids[field]
205
+
206
+ unique_terms = tokens.uniq.length
207
+ remove_field_length(short_id, field_id, @document_count, unique_terms)
208
+
209
+ tokens.each do |term|
210
+ processed_term = process_term.call(term, field)
211
+ if processed_term.is_a?(Array)
212
+ processed_term.each { |t| remove_term(field_id, short_id, t) }
213
+ elsif truthy?(processed_term)
214
+ remove_term(field_id, short_id, processed_term)
215
+ end
216
+ end
217
+ end
218
+
219
+ @stored_fields.delete(short_id)
220
+ @document_ids.delete(short_id)
221
+ @id_to_short_id.delete(id)
222
+ @field_length.delete(short_id)
223
+ @document_count -= 1
224
+ nil
225
+ end
226
+
227
+ # Removes the given documents. Called with no argument, removes ALL documents
228
+ # (faster than passing them all).
229
+ def remove_all(documents = NO_DOCUMENTS)
230
+ if documents.equal?(NO_DOCUMENTS)
231
+ reset_index
232
+ elsif documents.nil?
233
+ raise Error, "Expected documents to be present. Omit the argument to remove all documents."
234
+ else
235
+ documents.each { |document| remove(document) }
236
+ end
237
+ nil
238
+ end
239
+
240
+ # Discards the document with the given ID: it stops appearing in searches
241
+ # immediately, but its references are cleaned from the index lazily (on the
242
+ # next search that encounters them, or by {#vacuum}). Only needs the ID.
243
+ def discard(id)
244
+ short_id = @id_to_short_id[id]
245
+ raise Error, "MiniFTS: cannot discard document with ID #{id}: it is not in the index" if short_id.nil?
246
+
247
+ @id_to_short_id.delete(id)
248
+ @document_ids.delete(short_id)
249
+ @stored_fields.delete(short_id)
250
+
251
+ (@field_length[short_id] || []).each_with_index do |field_length, field_id|
252
+ next if field_length.nil?
253
+
254
+ remove_field_length(short_id, field_id, @document_count, field_length)
255
+ end
256
+
257
+ @field_length.delete(short_id)
258
+ @document_count -= 1
259
+ @dirt_count += 1
260
+
261
+ maybe_auto_vacuum
262
+ nil
263
+ end
264
+
265
+ # Discards several documents, triggering at most one automatic vacuum at the end.
266
+ def discard_all(ids)
267
+ auto_vacuum = @options[:auto_vacuum]
268
+ begin
269
+ @options[:auto_vacuum] = false
270
+ ids.each { |id| discard(id) }
271
+ ensure
272
+ @options[:auto_vacuum] = auto_vacuum
273
+ end
274
+
275
+ maybe_auto_vacuum
276
+ nil
277
+ end
278
+
279
+ # Replaces an existing document with an updated version (same ID). Equivalent
280
+ # to {#discard} followed by {#add}.
281
+ def replace(updated_document)
282
+ id = @options[:extract_field].call(updated_document, @options[:id_field])
283
+ discard(id)
284
+ add(updated_document)
285
+ nil
286
+ end
287
+
288
+ # Cleans up references to discarded documents from the inverted index. In this
289
+ # Ruby port vacuuming is synchronous (there is no main thread to protect), so
290
+ # the +batch_size+/+batch_wait+ options are accepted but ignored.
291
+ def vacuum(_options = {})
292
+ perform_vacuuming
293
+ nil
294
+ end
295
+
296
+ # @return [Integer] documents discarded since the last vacuum
297
+ attr_reader :dirt_count
298
+
299
+ # @return [Float] a 0..1 indication of how many index references are obsolete
300
+ def dirt_factor
301
+ @dirt_count.fdiv(1 + @document_count + @dirt_count)
302
+ end
303
+
304
+ # @return [Integer] number of searchable documents
305
+ attr_reader :document_count
306
+
307
+ # @return [Integer] number of distinct terms in the index
308
+ def term_count
309
+ @index.size
310
+ end
311
+
312
+ # @return [Boolean] whether a document with the given ID is present and searchable
313
+ def has?(id)
314
+ @id_to_short_id.key?(id)
315
+ end
316
+
317
+ # @return [Hash, nil] the stored fields for the given document ID, or +nil+
318
+ def get_stored_fields(id)
319
+ short_id = @id_to_short_id[id]
320
+ return nil if short_id.nil?
321
+
322
+ @stored_fields[short_id]
323
+ end
324
+
325
+ # Searches for documents matching +query+.
326
+ #
327
+ # @param query [String, Hash, Object] a query string, a combination Hash with a
328
+ # +:queries+ key, or {WILDCARD}
329
+ # @param search_options [Hash] per-search options overriding the defaults
330
+ # @return [Array<Hash>] results sorted by descending score. Each is a Hash with
331
+ # +:id+, +:score+, +:terms+ (matched document terms), +:query_terms+, +:match+,
332
+ # plus any stored fields (under their string keys).
333
+ def search(query, search_options = {})
334
+ search_options_with_defaults = @options[:search_options].merge(search_options)
335
+
336
+ raw_results = execute_query(query, search_options)
337
+ results = []
338
+
339
+ raw_results.each do |doc_id, data|
340
+ terms = data[R_TERMS]
341
+ quality = terms.empty? ? 1 : terms.length
342
+
343
+ match = data[R_MATCH]
344
+ result = {
345
+ id: @document_ids[doc_id],
346
+ score: data[R_SCORE] * quality,
347
+ terms: match.keys,
348
+ query_terms: terms,
349
+ match: match
350
+ }
351
+
352
+ stored = @stored_fields[doc_id]
353
+ result.merge!(stored) if stored
354
+
355
+ filter = search_options_with_defaults[:filter]
356
+ results.push(result) if filter.nil? || filter.call(result)
357
+ end
358
+
359
+ # For a wildcard query with no document boost, every score is equal, so
360
+ # there is no point sorting.
361
+ return results if wildcard_query?(query) && search_options_with_defaults[:boost_document].nil?
362
+
363
+ sort_by_score(results)
364
+ end
365
+
366
+ # Provides auto-suggestions for +query_string+: modified queries derived from
367
+ # it, each with a relevance score, sorted by descending score. By default
368
+ # prefix-searches the last term and combines terms with AND.
369
+ def auto_suggest(query_string, options = {})
370
+ options = @options[:auto_suggest_options].merge(options)
371
+
372
+ suggestions = {}
373
+
374
+ search(query_string, options).each do |result|
375
+ terms = result[:terms]
376
+ phrase = terms.join(" ")
377
+ suggestion = suggestions[phrase]
378
+ if suggestion
379
+ suggestion[:score] += result[:score]
380
+ suggestion[:count] += 1
381
+ else
382
+ suggestions[phrase] = { score: result[:score], terms: terms, count: 1 }
383
+ end
384
+ end
385
+
386
+ results = []
387
+ suggestions.each do |suggestion, data|
388
+ results.push(suggestion: suggestion, terms: data[:terms], score: data[:score].fdiv(data[:count]))
389
+ end
390
+
391
+ sort_by_score(results)
392
+ end
393
+
394
+ # Returns the default value of a constructor option, or raises for unknown names.
395
+ def self.get_default(option_name)
396
+ key = option_name.to_sym
397
+ raise Error, "MiniFTS: unknown option \"#{option_name}\"" unless DEFAULT_OPTIONS.key?(key)
398
+
399
+ DEFAULT_OPTIONS[key]
400
+ end
401
+
402
+ # Serializes the index to a plain Hash matching the JavaScript AsPlainObject
403
+ # shape (string keys, +serializationVersion: 2+).
404
+ def as_plain_object
405
+ index = []
406
+ @index.each do |term, field_index|
407
+ data = {}
408
+ # JavaScript objects iterate integer-like keys in ascending numeric order,
409
+ # so JSON.stringify emits a term's field ids sorted; Ruby Hashes preserve
410
+ # insertion order (a term first seen in a higher field would otherwise
411
+ # serialize its field ids out of order). Sort to stay byte-identical.
412
+ field_index.keys.sort.each { |field_id| data[field_id] = field_index[field_id] }
413
+ index.push([term, data])
414
+ end
415
+
416
+ {
417
+ "documentCount" => @document_count,
418
+ "nextId" => @next_id,
419
+ "documentIds" => @document_ids,
420
+ "fieldIds" => @field_ids,
421
+ "fieldLength" => @field_length,
422
+ "averageFieldLength" => @avg_field_length,
423
+ "storedFields" => @stored_fields,
424
+ "dirtCount" => @dirt_count,
425
+ "index" => index,
426
+ "serializationVersion" => 2
427
+ }
428
+ end
429
+
430
+ # Serializes the index to a JSON string. Integer keys are rendered as strings,
431
+ # and whole-valued averageFieldLength entries as integers, producing output
432
+ # byte-interchangeable with the JavaScript library (see
433
+ # {#whole_float_as_integer}).
434
+ def to_json(*args)
435
+ plain = as_plain_object
436
+ plain["averageFieldLength"] = plain["averageFieldLength"].map { |value| whole_float_as_integer(value) }
437
+ plain.to_json(*args)
438
+ end
439
+
440
+ # Deserializes a JSON index produced by {#to_json} (or by the JS library),
441
+ # given the same options originally used.
442
+ def self.load_json(json, options = {})
443
+ raise Error, "MiniFTS: loadJSON should be given the same options used when serializing the index" if options.nil?
444
+
445
+ load(JSON.parse(json), options)
446
+ end
447
+
448
+ # Deserializes an already-parsed plain-object index (string keys), given the
449
+ # same options originally used.
450
+ def self.load(js, options)
451
+ serialization_version = js["serializationVersion"]
452
+ unless [1, 2].include?(serialization_version)
453
+ raise Error, "MiniFTS: cannot deserialize an index created with an incompatible version"
454
+ end
455
+
456
+ ms = new(options)
457
+ ms.send(:load_plain_object, js)
458
+ ms
459
+ end
460
+
461
+ private
462
+
463
+ # JavaScript's +JSON.stringify+ renders a whole-valued number without a
464
+ # decimal point (+4+), whereas Ruby renders a Float as +4.0+.
465
+ # averageFieldLength is the only engine-computed float in the serialized
466
+ # index, so we match JavaScript's spelling to keep a Ruby-serialized index
467
+ # byte-identical to a JavaScript one. The value is numerically unchanged, and
468
+ # reloading +4+ yields the same Integer that a JavaScript-produced index
469
+ # already does.
470
+ def whole_float_as_integer(value)
471
+ value.is_a?(Float) && value.finite? && value == value.to_i ? value.to_i : value
472
+ end
473
+
474
+ def reset_index
475
+ @index = SearchableMap.new
476
+ @document_count = 0
477
+ @document_ids = {}
478
+ @id_to_short_id = {}
479
+ @field_ids = {}
480
+ @field_length = {}
481
+ @avg_field_length = []
482
+ @next_id = 0
483
+ @stored_fields = {}
484
+ @dirt_count = 0
485
+ end
486
+
487
+ def load_plain_object(js)
488
+ serialization_version = js["serializationVersion"]
489
+
490
+ @document_count = js["documentCount"]
491
+ @next_id = js["nextId"]
492
+ @field_ids = js["fieldIds"]
493
+ @avg_field_length = js["averageFieldLength"]
494
+ @dirt_count = js["dirtCount"] || 0
495
+
496
+ @document_ids = object_to_numeric_map(js["documentIds"])
497
+ @field_length = object_to_numeric_map(js["fieldLength"])
498
+ @stored_fields = object_to_numeric_map(js["storedFields"])
499
+
500
+ @id_to_short_id = {}
501
+ @document_ids.each { |short_id, id| @id_to_short_id[id] = short_id }
502
+
503
+ @index = SearchableMap.new
504
+ js["index"].each do |term, data|
505
+ data_map = {}
506
+ data.each do |field_id, index_entry|
507
+ index_entry = index_entry["ds"] if serialization_version == 1
508
+ data_map[field_id.to_i] = object_to_numeric_map(index_entry)
509
+ end
510
+ @index.set(term, data_map)
511
+ end
512
+ end
513
+
514
+ def object_to_numeric_map(object)
515
+ map = {}
516
+ object.each { |key, value| map[key.to_i] = value }
517
+ map
518
+ end
519
+
520
+ # --- indexing internals -------------------------------------------------
521
+
522
+ def add_document_id(document_id)
523
+ short_document_id = @next_id
524
+ @id_to_short_id[document_id] = short_document_id
525
+ @document_ids[short_document_id] = document_id
526
+ @document_count += 1
527
+ @next_id += 1
528
+ short_document_id
529
+ end
530
+
531
+ def add_fields(fields)
532
+ fields.each_with_index { |field, i| @field_ids[field] = i }
533
+ end
534
+
535
+ def add_field_length(document_id, field_id, count, length)
536
+ field_lengths = (@field_length[document_id] ||= [])
537
+ field_lengths[field_id] = length
538
+
539
+ average_field_length = @avg_field_length[field_id] || 0
540
+ total_field_length = (average_field_length * count) + length
541
+ @avg_field_length[field_id] = total_field_length.fdiv(count + 1)
542
+ end
543
+
544
+ def remove_field_length(_document_id, field_id, count, length)
545
+ if count == 1
546
+ @avg_field_length[field_id] = 0
547
+ return
548
+ end
549
+
550
+ total_field_length = (@avg_field_length[field_id] * count) - length
551
+ @avg_field_length[field_id] = total_field_length.fdiv(count - 1)
552
+ end
553
+
554
+ def save_stored_fields(document_id, doc)
555
+ store_fields = @options[:store_fields]
556
+ return if store_fields.nil? || store_fields.empty?
557
+
558
+ extract_field = @options[:extract_field]
559
+ document_fields = (@stored_fields[document_id] ||= {})
560
+
561
+ store_fields.each do |field_name|
562
+ field_value = extract_field.call(doc, field_name)
563
+ document_fields[field_name] = field_value unless field_value.nil?
564
+ end
565
+ end
566
+
567
+ def add_term(field_id, document_id, term)
568
+ index_data = @index.fetch(term) { {} }
569
+
570
+ field_index = index_data[field_id]
571
+ if field_index.nil?
572
+ field_index = {}
573
+ field_index[document_id] = 1
574
+ index_data[field_id] = field_index
575
+ else
576
+ docs = field_index[document_id]
577
+ field_index[document_id] = (docs || 0) + 1
578
+ end
579
+ end
580
+
581
+ def remove_term(field_id, document_id, term)
582
+ unless @index.has?(term)
583
+ warn_document_changed(document_id, field_id, term)
584
+ return
585
+ end
586
+
587
+ index_data = @index.fetch(term) { {} }
588
+
589
+ field_index = index_data[field_id]
590
+ if field_index.nil? || field_index[document_id].nil?
591
+ warn_document_changed(document_id, field_id, term)
592
+ elsif field_index[document_id] <= 1
593
+ if field_index.size <= 1
594
+ index_data.delete(field_id)
595
+ else
596
+ field_index.delete(document_id)
597
+ end
598
+ else
599
+ field_index[document_id] = field_index[document_id] - 1
600
+ end
601
+
602
+ @index.delete(term) if @index.get(term).empty?
603
+ end
604
+
605
+ def warn_document_changed(short_document_id, field_id, term)
606
+ field_name = @field_ids.key(field_id)
607
+ return if field_name.nil?
608
+
609
+ logger = @options[:logger]
610
+ return if logger.nil?
611
+
612
+ logger.call(
613
+ "warn",
614
+ "MiniFTS: document with ID #{@document_ids[short_document_id]} has changed before " \
615
+ "removal: term \"#{term}\" was not present in field \"#{field_name}\". Removing a " \
616
+ "document after it has changed can corrupt the index!",
617
+ "version_conflict"
618
+ )
619
+ end
620
+
621
+ # --- vacuuming ----------------------------------------------------------
622
+
623
+ def maybe_auto_vacuum
624
+ return if @options[:auto_vacuum] == false
625
+
626
+ opts = @options[:auto_vacuum]
627
+ conditions = { min_dirt_count: opts[:min_dirt_count], min_dirt_factor: opts[:min_dirt_factor] }
628
+ perform_vacuuming if vacuum_conditions_met?(conditions)
629
+ end
630
+
631
+ def vacuum_conditions_met?(conditions)
632
+ return true if conditions.nil?
633
+
634
+ min_dirt_count = conditions[:min_dirt_count] || DEFAULT_AUTO_VACUUM_OPTIONS[:min_dirt_count]
635
+ min_dirt_factor = conditions[:min_dirt_factor] || DEFAULT_AUTO_VACUUM_OPTIONS[:min_dirt_factor]
636
+
637
+ dirt_count >= min_dirt_count && dirt_factor >= min_dirt_factor
638
+ end
639
+
640
+ def perform_vacuuming
641
+ initial_dirt_count = @dirt_count
642
+
643
+ # Snapshot terms so we can safely delete from the tree while iterating.
644
+ @index.keys.each do |term|
645
+ fields_data = @index.get(term)
646
+ next if fields_data.nil?
647
+
648
+ fields_data.keys.each do |field_id|
649
+ field_index = fields_data[field_id]
650
+ field_index.keys.each do |short_id|
651
+ next if @document_ids.key?(short_id)
652
+
653
+ if field_index.size <= 1
654
+ fields_data.delete(field_id)
655
+ else
656
+ field_index.delete(short_id)
657
+ end
658
+ end
659
+ end
660
+
661
+ @index.delete(term) if @index.get(term).empty?
662
+ end
663
+
664
+ @dirt_count -= initial_dirt_count
665
+ end
666
+
667
+ # --- query execution ----------------------------------------------------
668
+
669
+ # Positional slots for the internal per-document result record built during
670
+ # query execution. This record is transient: {#term_results} assembles it, the
671
+ # combinators merge it across query specs, and {#search} transcribes it into
672
+ # the public result Hash. Because it is never serialized and always has exactly
673
+ # these three fields, it is a small positional Array rather than a symbol-keyed
674
+ # Hash — an embedded 3-element Array is several times lighter than a 3-key Hash,
675
+ # and this record is the dominant search allocation. The MATCH slot stays a Hash
676
+ # (it is handed straight to the public output). JS keeps a plain object here;
677
+ # this positional form is a Ruby-side allocation optimization, output-identical.
678
+ R_SCORE = 0
679
+ R_TERMS = 1
680
+ R_MATCH = 2
681
+ private_constant :R_SCORE, :R_TERMS, :R_MATCH
682
+
683
+ def execute_query(query, search_options = {})
684
+ return execute_wildcard_query(search_options) if wildcard_query?(query)
685
+
686
+ unless query.is_a?(String)
687
+ options = search_options.merge(query)
688
+ options.delete(:queries)
689
+ results = query[:queries].map { |subquery| execute_query(subquery, options) }
690
+ return combine_results(results, options[:combine_with])
691
+ end
692
+
693
+ options = { tokenize: @options[:tokenize], process_term: @options[:process_term] }
694
+ .merge(@options[:search_options])
695
+ .merge(search_options)
696
+
697
+ search_tokenize = options[:tokenize]
698
+ search_process_term = options[:process_term]
699
+
700
+ terms = search_tokenize.call(query)
701
+ .flat_map { |term| search_process_term.call(term) }
702
+ .select { |term| truthy?(term) }
703
+
704
+ queries = terms.each_with_index.map { |term, i| term_to_query_spec(options, term, i, terms) }
705
+ results = queries.map { |spec| execute_query_spec(spec, options) }
706
+
707
+ combine_results(results, options[:combine_with])
708
+ end
709
+
710
+ def execute_query_spec(query, search_options)
711
+ options = @options[:search_options].merge(search_options)
712
+
713
+ fields = options[:fields] || @options[:fields]
714
+ boosts = {}
715
+ fields.each do |field|
716
+ boost = options[:boost][field]
717
+ boosts[field] = truthy?(boost) ? boost : 1
718
+ end
719
+
720
+ boost_document = options[:boost_document]
721
+ max_fuzzy = options[:max_fuzzy]
722
+ bm25params = options[:bm25]
723
+
724
+ weights = DEFAULT_SEARCH_OPTIONS[:weights].merge(options[:weights] || {})
725
+ fuzzy_weight = weights[:fuzzy]
726
+ prefix_weight = weights[:prefix]
727
+
728
+ term = query[:term]
729
+ term_boost = query[:term_boost]
730
+
731
+ data = @index.get(term)
732
+ results = term_results(term, term, 1, term_boost, data, boosts, boost_document, bm25params)
733
+
734
+ prefix_matches = @index.at_prefix(term) if query[:prefix]
735
+
736
+ fuzzy_matches = nil
737
+ if query[:fuzzy]
738
+ fuzzy = query[:fuzzy] == true ? 0.2 : query[:fuzzy]
739
+ max_distance = fuzzy < 1 ? [max_fuzzy, (term.length * fuzzy).round].min : fuzzy
740
+ fuzzy_matches = @index.fuzzy_get(term, max_distance) if truthy?(max_distance)
741
+ end
742
+
743
+ if prefix_matches
744
+ prefix_matches.each do |prefix_term, prefix_data|
745
+ distance = prefix_term.length - term.length
746
+ next if distance.zero?
747
+
748
+ # A term reachable by prefix is always scored as a prefix result, never a
749
+ # fuzzy one.
750
+ fuzzy_matches&.delete(prefix_term)
751
+
752
+ # Weight approaches 0 as distance grows, starting from prefix_weight. The
753
+ # decay is slower than for fuzzy matches, since prefix matches stay
754
+ # relevant longer.
755
+ weight = prefix_weight * prefix_term.length / (prefix_term.length + (0.3 * distance))
756
+ term_results(term, prefix_term, weight, term_boost, prefix_data, boosts, boost_document, bm25params, results)
757
+ end
758
+ end
759
+
760
+ if fuzzy_matches
761
+ fuzzy_matches.keys.each do |fuzzy_term|
762
+ fuzzy_data, distance = fuzzy_matches[fuzzy_term]
763
+ next if distance.zero?
764
+
765
+ weight = fuzzy_weight * fuzzy_term.length / (fuzzy_term.length + distance)
766
+ term_results(term, fuzzy_term, weight, term_boost, fuzzy_data, boosts, boost_document, bm25params, results)
767
+ end
768
+ end
769
+
770
+ results
771
+ end
772
+
773
+ def execute_wildcard_query(search_options)
774
+ results = {}
775
+ options = @options[:search_options].merge(search_options)
776
+ boost_document = options[:boost_document]
777
+
778
+ @document_ids.each do |short_id, id|
779
+ score = boost_document ? boost_document.call(id, "", @stored_fields[short_id]) : 1
780
+ results[short_id] = [score, [], {}]
781
+ end
782
+
783
+ results
784
+ end
785
+
786
+ def combine_results(results, combine_with = OR)
787
+ return {} if results.empty?
788
+
789
+ combine_with = OR if combine_with.nil?
790
+ operator = combine_with.to_s.downcase
791
+
792
+ combinator =
793
+ case operator
794
+ when OR then method(:combine_or)
795
+ when AND then method(:combine_and)
796
+ when AND_NOT then method(:combine_and_not)
797
+ end
798
+ raise Error, "Invalid combination operator: #{combine_with}" if combinator.nil?
799
+
800
+ combined = results.reduce { |a, b| combinator.call(a, b) }
801
+ combined.nil? ? {} : combined
802
+ end
803
+
804
+ def combine_or(a, b)
805
+ b.each do |doc_id, b_value|
806
+ existing = a[doc_id]
807
+ if existing.nil?
808
+ a[doc_id] = b_value
809
+ else
810
+ existing[R_SCORE] += b_value[R_SCORE]
811
+ existing[R_MATCH].merge!(b_value[R_MATCH])
812
+ assign_unique_terms(existing[R_TERMS], b_value[R_TERMS])
813
+ end
814
+ end
815
+ a
816
+ end
817
+
818
+ def combine_and(a, b)
819
+ combined = {}
820
+ b.each do |doc_id, b_value|
821
+ existing = a[doc_id]
822
+ next if existing.nil?
823
+
824
+ assign_unique_terms(existing[R_TERMS], b_value[R_TERMS])
825
+ combined[doc_id] = [
826
+ existing[R_SCORE] + b_value[R_SCORE],
827
+ existing[R_TERMS],
828
+ existing[R_MATCH].merge!(b_value[R_MATCH])
829
+ ]
830
+ end
831
+ combined
832
+ end
833
+
834
+ def combine_and_not(a, b)
835
+ b.each_key { |doc_id| a.delete(doc_id) }
836
+ a
837
+ end
838
+
839
+ def term_results(source_term, derived_term, term_weight, term_boost, field_term_data, field_boosts,
840
+ boost_document_fn, bm25params, results = {})
841
+ return results if field_term_data.nil?
842
+
843
+ field_boosts.each do |field, field_boost|
844
+ field_id = @field_ids[field]
845
+
846
+ field_term_freqs = field_term_data[field_id]
847
+ next if field_term_freqs.nil?
848
+
849
+ matching_fields = field_term_freqs.size
850
+ avg_field_length = @avg_field_length[field_id]
851
+
852
+ field_term_freqs.keys.each do |doc_id|
853
+ unless @document_ids.key?(doc_id)
854
+ remove_term(field_id, doc_id, derived_term)
855
+ matching_fields -= 1
856
+ next
857
+ end
858
+
859
+ doc_boost =
860
+ if boost_document_fn
861
+ boost_document_fn.call(@document_ids[doc_id], derived_term, @stored_fields[doc_id])
862
+ else
863
+ 1
864
+ end
865
+ next unless truthy?(doc_boost)
866
+
867
+ term_freq = field_term_freqs[doc_id]
868
+ field_length = @field_length[doc_id][field_id]
869
+
870
+ raw_score = calc_bm25_score(term_freq, matching_fields, @document_count, field_length, avg_field_length,
871
+ bm25params)
872
+ weighted_score = term_weight * term_boost * field_boost * doc_boost * raw_score
873
+
874
+ result = results[doc_id]
875
+ if result
876
+ result[R_SCORE] += weighted_score
877
+ assign_unique_term(result[R_TERMS], source_term)
878
+ match = result[R_MATCH][derived_term]
879
+ if match
880
+ match.push(field)
881
+ else
882
+ result[R_MATCH][derived_term] = [field]
883
+ end
884
+ else
885
+ results[doc_id] = [weighted_score, [source_term], { derived_term => [field] }]
886
+ end
887
+ end
888
+ end
889
+
890
+ results
891
+ end
892
+
893
+ def calc_bm25_score(term_freq, matching_count, total_count, field_length, avg_field_length, bm25params)
894
+ k = bm25params[:k]
895
+ b = bm25params[:b]
896
+ d = bm25params[:d]
897
+
898
+ inv_doc_freq = Math.log(1 + ((total_count - matching_count + 0.5) / (matching_count + 0.5)))
899
+ inv_doc_freq * (d + (term_freq * (k + 1) / (term_freq + (k * (1 - b + (b * field_length / avg_field_length))))))
900
+ end
901
+
902
+ def term_to_query_spec(options, term, i, terms)
903
+ fuzzy_opt = options[:fuzzy]
904
+ fuzzy = fuzzy_opt.respond_to?(:call) ? fuzzy_opt.call(term, i, terms) : (fuzzy_opt || false)
905
+
906
+ prefix_opt = options[:prefix]
907
+ prefix = prefix_opt.respond_to?(:call) ? prefix_opt.call(term, i, terms) : (prefix_opt == true)
908
+
909
+ boost_term_opt = options[:boost_term]
910
+ term_boost = boost_term_opt.respond_to?(:call) ? boost_term_opt.call(term, i, terms) : 1
911
+
912
+ { term: term, fuzzy: fuzzy, prefix: prefix, term_boost: term_boost }
913
+ end
914
+
915
+ def assign_unique_term(target, term)
916
+ target.push(term) unless target.include?(term)
917
+ end
918
+
919
+ def assign_unique_terms(target, source)
920
+ source.each { |term| target.push(term) unless target.include?(term) }
921
+ end
922
+
923
+ # Stable descending sort by score, matching JavaScript's stable Array#sort:
924
+ # equal scores keep their original relative order.
925
+ def sort_by_score(results)
926
+ # sort_by.with_index sorts the results directly, keeping the stable descending
927
+ # order (index breaks score ties, matching JS's stable sort) without the
928
+ # per-element [result, i] pairs and the trailing map that each_with_index needs.
929
+ results.sort_by.with_index { |result, i| [-result[:score], i] }
930
+ end
931
+
932
+ def wildcard_query?(query)
933
+ query.equal?(WILDCARD)
934
+ end
935
+
936
+ # Mirrors JavaScript truthiness for the small set of values that flow through
937
+ # options and processed terms: nil, false, 0, "", and NaN are falsy.
938
+ def truthy?(value)
939
+ return false if value.nil? || value == false || value == "" || value == 0
940
+ return false if value.is_a?(Float) && value.nan?
941
+
942
+ true
943
+ end
944
+ end