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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +302 -0
- data/Rakefile +36 -0
- data/lib/minifts/searchable_map.rb +401 -0
- data/lib/minifts/version.rb +5 -0
- data/lib/minifts.rb +944 -0
- metadata +54 -0
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
|