redis 5.4.1 → 6.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 +4 -4
- data/CHANGELOG.md +52 -1
- data/README.md +165 -12
- data/lib/redis/client.rb +42 -10
- data/lib/redis/commands/geo.rb +108 -6
- data/lib/redis/commands/hashes.rb +166 -2
- data/lib/redis/commands/keys.rb +5 -1
- data/lib/redis/commands/lists.rb +93 -0
- data/lib/redis/commands/modules/json.rb +530 -0
- data/lib/redis/commands/modules/search/aggregation.rb +418 -0
- data/lib/redis/commands/modules/search/dialect.rb +14 -0
- data/lib/redis/commands/modules/search/field.rb +306 -0
- data/lib/redis/commands/modules/search/hybrid.rb +359 -0
- data/lib/redis/commands/modules/search/index.rb +351 -0
- data/lib/redis/commands/modules/search/index_definition.rb +114 -0
- data/lib/redis/commands/modules/search/miscellaneous.rb +607 -0
- data/lib/redis/commands/modules/search/query.rb +738 -0
- data/lib/redis/commands/modules/search/result.rb +488 -0
- data/lib/redis/commands/modules/search/schema.rb +211 -0
- data/lib/redis/commands/modules/search.rb +19 -0
- data/lib/redis/commands/sets.rb +41 -0
- data/lib/redis/commands/sorted_sets.rb +0 -1
- data/lib/redis/commands/streams.rb +28 -8
- data/lib/redis/commands.rb +30 -10
- data/lib/redis/distributed.rb +271 -1
- data/lib/redis/lib_identity.rb +105 -0
- data/lib/redis/subscribe.rb +1 -1
- data/lib/redis/version.rb +1 -1
- data/lib/redis.rb +141 -11
- metadata +22 -9
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Redis
|
|
4
|
+
module Commands
|
|
5
|
+
module Search
|
|
6
|
+
# Base class for a single field in a Redis Query Engine +SCHEMA+ (the
|
|
7
|
+
# field list passed to +FT.CREATE+). Subclasses describe the field type
|
|
8
|
+
# (+TEXT+, +TAG+, +NUMERIC+, +GEO+, +GEOSHAPE+, +VECTOR+).
|
|
9
|
+
class Field
|
|
10
|
+
attr_reader :name, :type, :options, :alias_name
|
|
11
|
+
attr_accessor :query
|
|
12
|
+
|
|
13
|
+
# Build a field definition.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# Redis::Commands::Search::Field.new("title", :text, weight: 5.0, sortable: true)
|
|
17
|
+
#
|
|
18
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
19
|
+
# @param [Symbol] type the field type, e.g. +:text+, +:tag+, +:numeric+
|
|
20
|
+
# @param [Query, nil] query a query the field is bound to, enabling predicate helpers
|
|
21
|
+
# @option options [String] :as an alias for the field, rendered as +AS <alias>+
|
|
22
|
+
# @option options [Boolean] :no_index do not index the field (+NOINDEX+)
|
|
23
|
+
# @option options [Boolean] :index_missing index documents missing the field (+INDEXMISSING+)
|
|
24
|
+
# @option options [Boolean] :index_empty index empty values (+INDEXEMPTY+)
|
|
25
|
+
# @option options [Boolean] :sortable allow sorting by the field (+SORTABLE+)
|
|
26
|
+
# @option options [Boolean] :withsuffixtrie build a suffix trie (+WITHSUFFIXTRIE+)
|
|
27
|
+
def initialize(name, type, query = nil, **options)
|
|
28
|
+
@name = name.to_s
|
|
29
|
+
@type = type
|
|
30
|
+
@query = query
|
|
31
|
+
@options = options
|
|
32
|
+
@alias_name = options.delete(:as)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Render this field as the array of +FT.CREATE+ +SCHEMA+ tokens.
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# Redis::Commands::Search::Field.new("title", :text, weight: 5.0, sortable: true).to_args
|
|
39
|
+
# # => ["title", "TEXT", "WEIGHT", "5.0", "SORTABLE"]
|
|
40
|
+
#
|
|
41
|
+
# @return [Array] the schema tokens for this field
|
|
42
|
+
def to_args
|
|
43
|
+
args = [@name]
|
|
44
|
+
args << "AS" << @alias_name if @alias_name
|
|
45
|
+
args << @type.to_s.upcase
|
|
46
|
+
|
|
47
|
+
# Add type-specific options first (separator, casesensitive for TAG)
|
|
48
|
+
if @type == :tag
|
|
49
|
+
args << "SEPARATOR" << @options[:separator] if @options[:separator]
|
|
50
|
+
args << "CASESENSITIVE" if @options[:case_sensitive]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Add suffix options in specific order: no_index, index_missing, index_empty, sortable, withsuffixtrie
|
|
54
|
+
args << "NOINDEX" if @options[:no_index]
|
|
55
|
+
args << "INDEXMISSING" if @options[:index_missing]
|
|
56
|
+
args << "INDEXEMPTY" if @options[:index_empty]
|
|
57
|
+
args << "SORTABLE" if @options[:sortable]
|
|
58
|
+
args << "WITHSUFFIXTRIE" if @options[:withsuffixtrie]
|
|
59
|
+
|
|
60
|
+
args
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A +TAG+ field, indexing one or more delimited tag tokens.
|
|
65
|
+
class TagField < Field
|
|
66
|
+
# Build a +TAG+ field.
|
|
67
|
+
#
|
|
68
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
69
|
+
# @param [Query, nil] query a query the field is bound to, enabling {#eq}
|
|
70
|
+
# @option options [String] :separator the tag separator character (+SEPARATOR+)
|
|
71
|
+
# @option options [Boolean] :case_sensitive keep tag casing (+CASESENSITIVE+)
|
|
72
|
+
def initialize(name, query = nil, **options)
|
|
73
|
+
super(name, :tag, query, **options)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Add a tag-equality predicate (+@field:{value}+) to the bound query.
|
|
77
|
+
#
|
|
78
|
+
# @param [String] value the tag to match
|
|
79
|
+
# @return [Query] the bound query with the predicate added
|
|
80
|
+
def eq(value)
|
|
81
|
+
query.add_predicate(TagEqualityPredicate.new(@alias_name || name, value))
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A +TEXT+ field, indexing full-text searchable content.
|
|
86
|
+
class TextField < Field
|
|
87
|
+
# Build a +TEXT+ field.
|
|
88
|
+
#
|
|
89
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
90
|
+
# @param [Query, nil] query a query the field is bound to, enabling {#match}
|
|
91
|
+
# @option options [Numeric] :weight the field's scoring weight (+WEIGHT+)
|
|
92
|
+
# @option options [String] :phonetic phonetic matcher, one of +dm:en+, +dm:fr+, +dm:pt+, +dm:es+ (+PHONETIC+)
|
|
93
|
+
# @option options [Boolean] :no_stem disable stemming (+NOSTEM+)
|
|
94
|
+
# @raise [ArgumentError] if +:phonetic+ is not a supported matcher
|
|
95
|
+
def initialize(name, query = nil, **options)
|
|
96
|
+
super(name, :text, query, **options)
|
|
97
|
+
if options[:phonetic]
|
|
98
|
+
valid_matchers = ['dm:en', 'dm:fr', 'dm:pt', 'dm:es']
|
|
99
|
+
unless valid_matchers.include?(options[:phonetic])
|
|
100
|
+
raise ArgumentError, "Invalid phonetic matcher. Supported matchers are: #{valid_matchers.join(', ')}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Render this field as the array of +FT.CREATE+ +SCHEMA+ tokens.
|
|
106
|
+
#
|
|
107
|
+
# @return [Array] the schema tokens for this field
|
|
108
|
+
def to_args
|
|
109
|
+
args = [@name]
|
|
110
|
+
args << "AS" << @alias_name if @alias_name
|
|
111
|
+
args << @type.to_s.upcase
|
|
112
|
+
args << "NOSTEM" if @options[:no_stem]
|
|
113
|
+
args << "WEIGHT" << @options[:weight].to_s if @options[:weight]
|
|
114
|
+
args << "PHONETIC" << @options[:phonetic] if @options[:phonetic]
|
|
115
|
+
|
|
116
|
+
# Add suffix options in specific order: no_index, index_missing, index_empty, sortable, withsuffixtrie
|
|
117
|
+
args << "NOINDEX" if @options[:no_index]
|
|
118
|
+
args << "INDEXMISSING" if @options[:index_missing]
|
|
119
|
+
args << "INDEXEMPTY" if @options[:index_empty]
|
|
120
|
+
args << "SORTABLE" if @options[:sortable]
|
|
121
|
+
args << "WITHSUFFIXTRIE" if @options[:withsuffixtrie]
|
|
122
|
+
|
|
123
|
+
args
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Add a text-match predicate (+@field:(pattern)+) to the bound query.
|
|
127
|
+
#
|
|
128
|
+
# @param [String] pattern the text pattern to match
|
|
129
|
+
# @param [Boolean] raw when +true+, treat +pattern+ as a raw RediSearch expression so
|
|
130
|
+
# operators such as wildcards (+*+) and OR (+|+) apply; when +false+ (default) the
|
|
131
|
+
# pattern is escaped and matched literally
|
|
132
|
+
# @return [Query] the bound query with the predicate added
|
|
133
|
+
def match(pattern, raw: false)
|
|
134
|
+
query.add_predicate(TextMatchPredicate.new(@alias_name || name, pattern, raw: raw))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A +NUMERIC+ field, indexing numeric values for range queries.
|
|
139
|
+
class NumericField < Field
|
|
140
|
+
# Build a +NUMERIC+ field.
|
|
141
|
+
#
|
|
142
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
143
|
+
# @param [Query, nil] query a query the field is bound to, enabling {#gt}, {#lt}, {#between}
|
|
144
|
+
def initialize(name, query = nil, **options)
|
|
145
|
+
super(name, :numeric, query, **options)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Add a greater-than range predicate to the bound query.
|
|
149
|
+
#
|
|
150
|
+
# @param [Numeric] value the exclusive lower bound
|
|
151
|
+
# @return [Query] the bound query with the predicate added
|
|
152
|
+
# @raise [ArgumentError] if +value+ is not numeric
|
|
153
|
+
def gt(value)
|
|
154
|
+
query.add_predicate(RangePredicate.new(@alias_name || name, "(#{Float(value)}", "+inf"))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Add a less-than range predicate to the bound query.
|
|
158
|
+
#
|
|
159
|
+
# @param [Numeric] value the exclusive upper bound
|
|
160
|
+
# @return [Query] the bound query with the predicate added
|
|
161
|
+
# @raise [ArgumentError] if +value+ is not numeric
|
|
162
|
+
def lt(value)
|
|
163
|
+
query.add_predicate(RangePredicate.new(@alias_name || name, "-inf", "(#{Float(value)}"))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Add an inclusive range predicate to the bound query.
|
|
167
|
+
#
|
|
168
|
+
# @param [Numeric] min the inclusive lower bound
|
|
169
|
+
# @param [Numeric] max the inclusive upper bound
|
|
170
|
+
# @return [Query] the bound query with the predicate added
|
|
171
|
+
# @raise [ArgumentError] if +min+ or +max+ is not numeric
|
|
172
|
+
def between(min, max)
|
|
173
|
+
# Coerce to numeric so a value can't inject range/query syntax. RangePredicate renders
|
|
174
|
+
# bounds verbatim, so the field boundary is where untrusted input must be validated.
|
|
175
|
+
query.add_predicate(RangePredicate.new(@alias_name || name, Float(min), Float(max)))
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A +GEO+ field, indexing longitude/latitude pairs for geo queries.
|
|
180
|
+
class GeoField < Field
|
|
181
|
+
# Build a +GEO+ field.
|
|
182
|
+
#
|
|
183
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
184
|
+
# @param [Query, nil] query a query the field is bound to
|
|
185
|
+
def initialize(name, query = nil, **options)
|
|
186
|
+
super(name, :geo, query, **options)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# A +GEOSHAPE+ field, indexing geometric shapes under a coordinate system.
|
|
191
|
+
class GeoShapeField < Field
|
|
192
|
+
FLAT = "FLAT"
|
|
193
|
+
SPHERICAL = "SPHERICAL"
|
|
194
|
+
|
|
195
|
+
# Build a +GEOSHAPE+ field.
|
|
196
|
+
#
|
|
197
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
198
|
+
# @param [String, nil] coord_system the coordinate system, {FLAT} or {SPHERICAL};
|
|
199
|
+
# nil (the default) omits the token so the server default ({SPHERICAL}) applies
|
|
200
|
+
# @option options [Boolean] :no_index do not index the field (+NOINDEX+)
|
|
201
|
+
# @option options [Boolean] :index_missing index documents missing the field (+INDEXMISSING+)
|
|
202
|
+
# @option options [Boolean] :sortable allow sorting by the field (+SORTABLE+)
|
|
203
|
+
def initialize(name, coord_system = nil, **options)
|
|
204
|
+
super(name, :geoshape, nil, **options)
|
|
205
|
+
@coord_system = coord_system
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Render this field as the array of +FT.CREATE+ +SCHEMA+ tokens.
|
|
209
|
+
#
|
|
210
|
+
# @return [Array] the schema tokens for this field
|
|
211
|
+
def to_args
|
|
212
|
+
args = [@name]
|
|
213
|
+
args << "AS" << @alias_name if @alias_name
|
|
214
|
+
args << @type.to_s.upcase
|
|
215
|
+
args << @coord_system if @coord_system
|
|
216
|
+
|
|
217
|
+
# Add suffix options (order mirrors the base Field: NOINDEX, INDEXMISSING, SORTABLE).
|
|
218
|
+
# GEOSHAPE supports INDEXMISSING but not INDEXEMPTY/WITHSUFFIXTRIE.
|
|
219
|
+
args << "NOINDEX" if @options[:no_index]
|
|
220
|
+
args << "INDEXMISSING" if @options[:index_missing]
|
|
221
|
+
args << "SORTABLE" if @options[:sortable]
|
|
222
|
+
|
|
223
|
+
args
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A +VECTOR+ field, indexing embeddings for approximate/exact nearest-neighbor search.
|
|
228
|
+
class VectorField < Field
|
|
229
|
+
attr_reader :algorithm, :attributes
|
|
230
|
+
|
|
231
|
+
# Build a +VECTOR+ field.
|
|
232
|
+
#
|
|
233
|
+
# @example
|
|
234
|
+
# Redis::Commands::Search::Field::VectorField.new(
|
|
235
|
+
# "embedding", "HNSW", { type: "FLOAT32", dim: 4, distance_metric: "L2" }
|
|
236
|
+
# )
|
|
237
|
+
#
|
|
238
|
+
# @param [String, Symbol] name the document attribute the field indexes
|
|
239
|
+
# @param [String, Symbol] algorithm the indexing method, one of +FLAT+, +HNSW+, +SVS-VAMANA+
|
|
240
|
+
# @param [Hash] attributes the vector attributes (e.g. +type+, +dim+, +distance_metric+)
|
|
241
|
+
# @option options [String] :as an alias for the field, rendered as +AS <alias>+
|
|
242
|
+
# @raise [ArgumentError] if +algorithm+ is not a supported indexing method
|
|
243
|
+
# @raise [Redis::CommandError] if +:sortable+ or +:no_index+ is given
|
|
244
|
+
def initialize(name, algorithm, attributes = {}, **options)
|
|
245
|
+
# Validate algorithm
|
|
246
|
+
unless ['FLAT', 'HNSW', 'SVS-VAMANA'].include?(algorithm.to_s.upcase)
|
|
247
|
+
raise ArgumentError,
|
|
248
|
+
"Realtime vector indexing supporting 3 Indexing Methods: 'FLAT', 'HNSW', and 'SVS-VAMANA'"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Validate that sortable and no_index are not used with vector fields
|
|
252
|
+
if options[:sortable]
|
|
253
|
+
raise Redis::CommandError, "Vector fields cannot be sortable"
|
|
254
|
+
end
|
|
255
|
+
if options[:no_index]
|
|
256
|
+
raise Redis::CommandError, "Vector fields cannot have no_index option"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
super(name, :vector, **options)
|
|
260
|
+
@algorithm = algorithm.to_s.upcase
|
|
261
|
+
@attributes = attributes.transform_keys { |k| k.to_s.upcase }.transform_values { |v| v.to_s.upcase }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Set or override a single vector attribute.
|
|
265
|
+
#
|
|
266
|
+
# @param [String, Symbol] key the attribute name (upcased internally)
|
|
267
|
+
# @param [Object] value the attribute value
|
|
268
|
+
# @return [Object] the stored value
|
|
269
|
+
def add_attribute(key, value)
|
|
270
|
+
# Normalize like #initialize does for the kwargs form, so block-DSL attributes
|
|
271
|
+
# (vector_field(...) { type "float32" }) reach FT.CREATE with the expected casing.
|
|
272
|
+
@attributes[key.to_s.upcase] = value.to_s.upcase
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Render this field as the array of +FT.CREATE+ +SCHEMA+ tokens.
|
|
276
|
+
#
|
|
277
|
+
# @return [Array] the schema tokens for this field, including the +VECTOR+ clause
|
|
278
|
+
def to_args
|
|
279
|
+
args = [name]
|
|
280
|
+
args << "AS" << @alias_name if @alias_name
|
|
281
|
+
args += field_args
|
|
282
|
+
args
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Returns field-specific args (without name/alias) for compatibility with tests
|
|
286
|
+
#
|
|
287
|
+
# @return [Array] the +VECTOR+ clause tokens (without name/alias)
|
|
288
|
+
def args
|
|
289
|
+
field_args
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
private
|
|
293
|
+
|
|
294
|
+
def field_args
|
|
295
|
+
args = ['VECTOR']
|
|
296
|
+
args << @algorithm
|
|
297
|
+
args << @attributes.size * 2
|
|
298
|
+
@attributes.each do |k, v|
|
|
299
|
+
args << k << v
|
|
300
|
+
end
|
|
301
|
+
args
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Redis
|
|
4
|
+
module Commands
|
|
5
|
+
module Search
|
|
6
|
+
# The textual +SEARCH+ leg of an FT.HYBRID query.
|
|
7
|
+
#
|
|
8
|
+
# Chainable setters (+scorer+, +yield_score_as+) return +self+; {#args} renders the leg
|
|
9
|
+
# into its argument tokens, beginning with "SEARCH".
|
|
10
|
+
class HybridSearchQuery
|
|
11
|
+
# @return [String] the search query string
|
|
12
|
+
attr_reader :query_string
|
|
13
|
+
|
|
14
|
+
# @param query_string [String] the search query string
|
|
15
|
+
# @param scorer [String, nil] the scoring function
|
|
16
|
+
# @param yield_score_as [String, nil] the alias to expose the search score under
|
|
17
|
+
def initialize(query_string, scorer: nil, yield_score_as: nil)
|
|
18
|
+
@query_string = query_string
|
|
19
|
+
@scorer = scorer
|
|
20
|
+
@yield_score_as = yield_score_as
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Set the scoring function (+SCORER+).
|
|
24
|
+
#
|
|
25
|
+
# @param scorer [String] the scorer name
|
|
26
|
+
# @return [self]
|
|
27
|
+
def scorer(scorer)
|
|
28
|
+
@scorer = scorer
|
|
29
|
+
self
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Expose the search score under an alias (+YIELD_SCORE_AS+).
|
|
33
|
+
#
|
|
34
|
+
# @param alias_name [String] the alias for the score
|
|
35
|
+
# @return [self]
|
|
36
|
+
def yield_score_as(alias_name)
|
|
37
|
+
@yield_score_as = alias_name
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @return [Array] the +SEARCH+ leg argument tokens, beginning with "SEARCH"
|
|
42
|
+
def args
|
|
43
|
+
result = ["SEARCH", @query_string]
|
|
44
|
+
result << "SCORER" << @scorer if @scorer
|
|
45
|
+
result << "YIELD_SCORE_AS" << @yield_score_as if @yield_score_as
|
|
46
|
+
result
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The vector-similarity +VSIM+ leg of an FT.HYBRID query.
|
|
51
|
+
#
|
|
52
|
+
# Chainable setters (+vsim_method_params+, +filter+, +yield_score_as+) return +self+;
|
|
53
|
+
# {#args} renders the leg into its argument tokens, beginning with "VSIM".
|
|
54
|
+
class HybridVsimQuery
|
|
55
|
+
# @return [String] the vector field name
|
|
56
|
+
# @return [String] the query vector data (blob)
|
|
57
|
+
attr_reader :vector_field, :vector_data
|
|
58
|
+
|
|
59
|
+
# @param vector_field_name [String] the vector field to search
|
|
60
|
+
# @param vector_data [String] the query vector blob
|
|
61
|
+
# @param vsim_search_method [Symbol, String, nil] the search method, e.g. +:knn+ or +:range+
|
|
62
|
+
# @param vsim_search_method_params [Hash, nil] the search-method parameters
|
|
63
|
+
# @param filter [HybridFilter, nil] an optional filter applied to the vector leg
|
|
64
|
+
# @param yield_score_as [String, nil] the alias to expose the vector score under
|
|
65
|
+
def initialize(
|
|
66
|
+
vector_field_name:, vector_data:,
|
|
67
|
+
vsim_search_method: nil, vsim_search_method_params: nil,
|
|
68
|
+
filter: nil, yield_score_as: nil
|
|
69
|
+
)
|
|
70
|
+
@vector_field = vector_field_name
|
|
71
|
+
@vector_data = vector_data
|
|
72
|
+
@vsim_method_params = nil
|
|
73
|
+
@filter = filter
|
|
74
|
+
@yield_score_as = yield_score_as
|
|
75
|
+
|
|
76
|
+
if vsim_search_method && vsim_search_method_params
|
|
77
|
+
vsim_method_params(vsim_search_method, **vsim_search_method_params)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Set the vector search method and its parameters (e.g. +KNN+ / +RANGE+).
|
|
82
|
+
#
|
|
83
|
+
# @param method [Symbol, String] the search method (upcased into a token)
|
|
84
|
+
# @param kwargs [Hash] the method parameters, emitted as upcased key/value pairs
|
|
85
|
+
# @return [self]
|
|
86
|
+
def vsim_method_params(method, **kwargs)
|
|
87
|
+
@vsim_method_params = [method.to_s.upcase]
|
|
88
|
+
if kwargs.any?
|
|
89
|
+
@vsim_method_params << kwargs.size * 2
|
|
90
|
+
kwargs.each do |key, value|
|
|
91
|
+
@vsim_method_params << key.to_s.upcase << value
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Set a filter applied to the vector leg (+FILTER+).
|
|
98
|
+
#
|
|
99
|
+
# @param flt [HybridFilter] the filter to apply
|
|
100
|
+
# @return [self]
|
|
101
|
+
def filter(flt)
|
|
102
|
+
@filter = flt
|
|
103
|
+
self
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Expose the vector score under an alias (+YIELD_SCORE_AS+).
|
|
107
|
+
#
|
|
108
|
+
# @param alias_name [String] the alias for the score
|
|
109
|
+
# @return [self]
|
|
110
|
+
def yield_score_as(alias_name)
|
|
111
|
+
@yield_score_as = alias_name
|
|
112
|
+
self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @return [Array] the +VSIM+ leg argument tokens, beginning with "VSIM"
|
|
116
|
+
def args
|
|
117
|
+
result = ["VSIM", @vector_field, @vector_data]
|
|
118
|
+
result.concat(@vsim_method_params) if @vsim_method_params
|
|
119
|
+
result.concat(@filter.args) if @filter
|
|
120
|
+
result << "YIELD_SCORE_AS" << @yield_score_as if @yield_score_as
|
|
121
|
+
result
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# An FT.HYBRID query combining a textual {HybridSearchQuery} leg and a
|
|
126
|
+
# {HybridVsimQuery} vector leg.
|
|
127
|
+
class HybridQuery
|
|
128
|
+
# @param search_query [HybridSearchQuery] the textual search leg
|
|
129
|
+
# @param vector_similarity_query [HybridVsimQuery] the vector-similarity leg
|
|
130
|
+
def initialize(search_query, vector_similarity_query)
|
|
131
|
+
@search_query = search_query
|
|
132
|
+
@vector_similarity_query = vector_similarity_query
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @return [Array] the concatenated argument tokens of both legs (SEARCH then VSIM)
|
|
136
|
+
def args
|
|
137
|
+
result = []
|
|
138
|
+
result.concat(@search_query.args)
|
|
139
|
+
result.concat(@vector_similarity_query.args)
|
|
140
|
+
result
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Constants for the result-combination methods used by {CombineResultsMethod}.
|
|
145
|
+
module CombinationMethods
|
|
146
|
+
# Reciprocal Rank Fusion.
|
|
147
|
+
RRF = "RRF"
|
|
148
|
+
# Linear combination of leg scores.
|
|
149
|
+
LINEAR = "LINEAR"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Constants for the vector search methods used by {HybridVsimQuery}.
|
|
153
|
+
module VectorSearchMethods
|
|
154
|
+
# K-nearest-neighbours search.
|
|
155
|
+
KNN = "KNN"
|
|
156
|
+
# Range search.
|
|
157
|
+
RANGE = "RANGE"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The +COMBINE+ clause of an FT.HYBRID query, describing how the two legs' results are fused.
|
|
161
|
+
class CombineResultsMethod
|
|
162
|
+
# @param method [String] the combination method (see {CombinationMethods})
|
|
163
|
+
# @param kwargs [Hash] the method parameters, emitted as upcased key/value pairs
|
|
164
|
+
def initialize(method, **kwargs)
|
|
165
|
+
@method = method
|
|
166
|
+
@kwargs = kwargs
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @return [Array<String>] the +COMBINE+ argument tokens, beginning with "COMBINE"
|
|
170
|
+
def args
|
|
171
|
+
result = ["COMBINE", @method, (@kwargs.size * 2).to_s]
|
|
172
|
+
@kwargs.each do |key, value|
|
|
173
|
+
result << key.to_s.upcase << value.to_s
|
|
174
|
+
end
|
|
175
|
+
result
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Build a Reciprocal Rank Fusion (+RRF+) combine method.
|
|
179
|
+
#
|
|
180
|
+
# @param window [Integer, nil] the +WINDOW+ size
|
|
181
|
+
# @param constant [Numeric, nil] the +CONSTANT+ value
|
|
182
|
+
# @return [CombineResultsMethod]
|
|
183
|
+
def self.rrf(window: nil, constant: nil)
|
|
184
|
+
# Guard on nil (not truthiness): 0 is a legitimate value and, unlike some languages,
|
|
185
|
+
# truthy in Ruby — but nil-checking keeps "was it provided?" explicit and correct.
|
|
186
|
+
kwargs = {}
|
|
187
|
+
kwargs[:window] = window unless window.nil?
|
|
188
|
+
kwargs[:constant] = constant unless constant.nil?
|
|
189
|
+
new(CombinationMethods::RRF, **kwargs)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Build a +LINEAR+ combine method.
|
|
193
|
+
#
|
|
194
|
+
# @param alpha [Numeric, nil] the weight of the first leg
|
|
195
|
+
# @param beta [Numeric, nil] the weight of the second leg
|
|
196
|
+
# @return [CombineResultsMethod]
|
|
197
|
+
def self.linear(alpha: nil, beta: nil)
|
|
198
|
+
# Guard on nil (not truthiness) so alpha/beta of 0 are still emitted.
|
|
199
|
+
kwargs = {}
|
|
200
|
+
kwargs[:alpha] = alpha unless alpha.nil?
|
|
201
|
+
kwargs[:beta] = beta unless beta.nil?
|
|
202
|
+
new(CombinationMethods::LINEAR, **kwargs)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# A +FILTER+ clause used within a hybrid query leg.
|
|
207
|
+
class HybridFilter
|
|
208
|
+
# @return [Array] the +FILTER+ argument tokens, +["FILTER", conditions]+
|
|
209
|
+
attr_reader :args
|
|
210
|
+
|
|
211
|
+
# @param conditions [String] the filter expression
|
|
212
|
+
def initialize(conditions)
|
|
213
|
+
@args = ["FILTER", conditions]
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Builder for the post-processing pipeline applied to FT.HYBRID results.
|
|
218
|
+
#
|
|
219
|
+
# Chainable setters (+load+, +group_by+, +apply+, +sort_by+, +filter+, +limit+) return
|
|
220
|
+
# +self+; {#build_args} renders the configured steps into argument tokens.
|
|
221
|
+
class HybridPostProcessingConfig
|
|
222
|
+
def initialize
|
|
223
|
+
@load_statements = []
|
|
224
|
+
@apply_statements = []
|
|
225
|
+
@groupby_statements = []
|
|
226
|
+
@sortby_fields = []
|
|
227
|
+
@filter = nil
|
|
228
|
+
@limit = nil
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Add a +LOAD+ step loading the given fields.
|
|
232
|
+
#
|
|
233
|
+
# @param fields [Array<String>] the fields to load (space-separated strings are split)
|
|
234
|
+
# @return [self]
|
|
235
|
+
def load(*fields)
|
|
236
|
+
unless fields.empty?
|
|
237
|
+
fields_str = fields.join(" ")
|
|
238
|
+
fields_list = fields_str.split(" ")
|
|
239
|
+
@load_statements.concat(["LOAD", fields_list.size, *fields_list])
|
|
240
|
+
end
|
|
241
|
+
self
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Add a +GROUPBY+ step with optional +REDUCE+ functions.
|
|
245
|
+
#
|
|
246
|
+
# @param fields [String, Array<String>] the field(s) to group by
|
|
247
|
+
# @param reducers [Array<Reducers>] the reducers applied to each group
|
|
248
|
+
# @return [self]
|
|
249
|
+
def group_by(fields, *reducers)
|
|
250
|
+
ret = ["GROUPBY", Array(fields).size.to_s, *Array(fields)]
|
|
251
|
+
reducers.each do |reducer|
|
|
252
|
+
ret.concat(["REDUCE", reducer.name, reducer.args.size.to_s])
|
|
253
|
+
ret.concat(reducer.args)
|
|
254
|
+
ret.concat(["AS", reducer.alias_name]) if reducer.alias_name
|
|
255
|
+
end
|
|
256
|
+
@groupby_statements.concat(ret)
|
|
257
|
+
self
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Add one +APPLY+ step per expression.
|
|
261
|
+
#
|
|
262
|
+
# @param kwexpr [Hash{Symbol => String}] map of result alias => expression
|
|
263
|
+
# @return [self]
|
|
264
|
+
def apply(**kwexpr)
|
|
265
|
+
apply_args = []
|
|
266
|
+
kwexpr.each do |alias_name, expr|
|
|
267
|
+
ret = ["APPLY", expr]
|
|
268
|
+
ret.concat(["AS", alias_name.to_s]) if alias_name
|
|
269
|
+
apply_args.concat(ret)
|
|
270
|
+
end
|
|
271
|
+
@apply_statements.concat(apply_args)
|
|
272
|
+
self
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Add a +SORTBY+ step.
|
|
276
|
+
#
|
|
277
|
+
# @param sortby_fields [Array<SortbyField>] the fields to sort by
|
|
278
|
+
# @return [self]
|
|
279
|
+
def sort_by(*sortby_fields)
|
|
280
|
+
@sortby_fields = sortby_fields
|
|
281
|
+
self
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Add a +FILTER+ step.
|
|
285
|
+
#
|
|
286
|
+
# @param flt [HybridFilter] the filter to apply
|
|
287
|
+
# @return [self]
|
|
288
|
+
def filter(flt)
|
|
289
|
+
@filter = flt
|
|
290
|
+
self
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Add a +LIMIT+ (paging) step.
|
|
294
|
+
#
|
|
295
|
+
# @param offset [Integer] the index of the first row to return
|
|
296
|
+
# @param num [Integer] the maximum number of rows to return
|
|
297
|
+
# @return [self]
|
|
298
|
+
def limit(offset, num)
|
|
299
|
+
@limit = { offset: offset, num: num }
|
|
300
|
+
self
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Render the post-processing pipeline into its argument tokens.
|
|
304
|
+
#
|
|
305
|
+
# Steps are emitted in order: LOAD, GROUPBY, APPLY, SORTBY, FILTER, LIMIT.
|
|
306
|
+
#
|
|
307
|
+
# @return [Array] the argument token array
|
|
308
|
+
def build_args
|
|
309
|
+
args = []
|
|
310
|
+
args.concat(@load_statements) if @load_statements.any?
|
|
311
|
+
args.concat(@groupby_statements) if @groupby_statements.any?
|
|
312
|
+
args.concat(@apply_statements) if @apply_statements.any?
|
|
313
|
+
if @sortby_fields.any?
|
|
314
|
+
sortby_args = []
|
|
315
|
+
@sortby_fields.each do |f|
|
|
316
|
+
sortby_args.concat(f.args)
|
|
317
|
+
end
|
|
318
|
+
args.concat(["SORTBY", sortby_args.size, *sortby_args])
|
|
319
|
+
end
|
|
320
|
+
args.concat(@filter.args) if @filter
|
|
321
|
+
args.concat(["LIMIT", @limit[:offset], @limit[:num]]) if @limit
|
|
322
|
+
args
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# The +WITHCURSOR+ clause for paging through FT.HYBRID results.
|
|
327
|
+
class HybridCursorQuery
|
|
328
|
+
# @param count [Integer] the cursor +COUNT+ (batch size, 0 to omit)
|
|
329
|
+
# @param max_idle [Integer] the cursor +MAXIDLE+ in milliseconds (0 to omit)
|
|
330
|
+
def initialize(count: 0, max_idle: 0)
|
|
331
|
+
@count = count
|
|
332
|
+
@max_idle = max_idle
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# Render the cursor arguments.
|
|
336
|
+
#
|
|
337
|
+
# @return [Array<String>] the argument tokens, beginning with "WITHCURSOR"
|
|
338
|
+
def build_args
|
|
339
|
+
args = ["WITHCURSOR"]
|
|
340
|
+
args.concat(["COUNT", @count.to_s]) if @count > 0
|
|
341
|
+
args.concat(["MAXIDLE", @max_idle.to_s]) if @max_idle > 0
|
|
342
|
+
args
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# A single field/direction pair for hybrid-search +SORTBY+.
|
|
347
|
+
class SortbyField
|
|
348
|
+
# @return [Array<String>] the argument tokens, +[field, "ASC"|"DESC"]+
|
|
349
|
+
attr_reader :args
|
|
350
|
+
|
|
351
|
+
# @param field [String] the field to sort by
|
|
352
|
+
# @param asc [Boolean] sort ascending when true, descending otherwise
|
|
353
|
+
def initialize(field, asc: true)
|
|
354
|
+
@args = [field, asc ? "ASC" : "DESC"]
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
end
|