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,607 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Redis
|
|
4
|
+
module Commands
|
|
5
|
+
# Redis Query Engine (RediSearch, the +FT.*+ command family). See
|
|
6
|
+
# {file:specs/query-engine.md} for the layered design and an abstractions-vs-raw-commands guide.
|
|
7
|
+
#
|
|
8
|
+
# Replies are reshaped by {Search::ResultParser} into {Search::SearchResult} /
|
|
9
|
+
# {Search::Document} (for +FT.SEARCH+), {Search::AggregateResult} (for +FT.AGGREGATE+ /
|
|
10
|
+
# +FT.CURSOR READ+), or plain Hashes/Arrays. Reshaping is identical under RESP2 and RESP3.
|
|
11
|
+
module Search
|
|
12
|
+
# Create a search index over HASH or JSON keys.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# schema = Redis::Commands::Search::Schema.build { text_field :title }
|
|
16
|
+
# redis.ft_create("idx", schema, prefix: "doc")
|
|
17
|
+
# # => "OK"
|
|
18
|
+
#
|
|
19
|
+
# @param index_name [String] the index name
|
|
20
|
+
# @param schema [Search::Schema] the field schema; the +SCHEMA+ clause is rendered from it
|
|
21
|
+
# @param storage_type [String, Symbol, nil] the data type to index, e.g. +"HASH"+ or +"JSON"+
|
|
22
|
+
# (emitted as +ON <type>+); ignored when +definition+ is given
|
|
23
|
+
# @param prefix [String, nil] index keys whose name starts with +"<prefix>:"+; ignored when
|
|
24
|
+
# +definition+ is given
|
|
25
|
+
# @param stopwords [Array<String>, nil] a custom stopword list (emits +STOPWORDS+)
|
|
26
|
+
# @param max_text_fields [Boolean] emit +MAXTEXTFIELDS+
|
|
27
|
+
# @param skip_initial_scan [Boolean] emit +SKIPINITIALSCAN+ (do not backfill existing keys)
|
|
28
|
+
# @param definition [Search::IndexDefinition, nil] a prebuilt +ON .../PREFIX/FILTER/...+ clause;
|
|
29
|
+
# when given it takes precedence over +storage_type+/+prefix+
|
|
30
|
+
# @param temporary [Integer, nil] index lifetime in seconds (emits +TEMPORARY <seconds>+)
|
|
31
|
+
# @param no_term_offsets [Boolean] emit +NOOFFSETS+
|
|
32
|
+
# @param no_highlight [Boolean] emit +NOHL+
|
|
33
|
+
# @param no_field_flags [Boolean] emit +NOFIELDS+
|
|
34
|
+
# @param no_term_frequencies [Boolean] emit +NOFREQS+
|
|
35
|
+
# @return [String] +"OK"+
|
|
36
|
+
# @raise [ArgumentError] if +schema+ is not a {Search::Schema}
|
|
37
|
+
def ft_create(
|
|
38
|
+
index_name, schema, storage_type = nil,
|
|
39
|
+
prefix: nil, stopwords: nil, max_text_fields: false,
|
|
40
|
+
skip_initial_scan: false, definition: nil, temporary: nil,
|
|
41
|
+
no_term_offsets: false, no_highlight: false,
|
|
42
|
+
no_field_flags: false, no_term_frequencies: false, **_options
|
|
43
|
+
)
|
|
44
|
+
raise ArgumentError, "schema must be a Schema object" unless schema.is_a?(Schema)
|
|
45
|
+
|
|
46
|
+
args = [index_name]
|
|
47
|
+
|
|
48
|
+
# Use IndexDefinition if provided, otherwise use legacy parameters
|
|
49
|
+
if definition
|
|
50
|
+
# The definition supplies the ON/PREFIX/FILTER clause. When it doesn't declare an index
|
|
51
|
+
# type, fall back to the storage_type keyword so e.g. a JSON index isn't silently created
|
|
52
|
+
# as HASH. Normalize to the uppercase HASH/JSON form some Query Engine versions require.
|
|
53
|
+
args += ["ON", storage_type.to_s.upcase] if definition.index_type.nil? && storage_type
|
|
54
|
+
args += definition.args
|
|
55
|
+
else
|
|
56
|
+
# Normalize the ON token to HASH/JSON; a lowercase value is rejected by some
|
|
57
|
+
# Query Engine versions (and IndexType/the docs use the uppercase form).
|
|
58
|
+
args += ["ON", storage_type.to_s.upcase] if storage_type
|
|
59
|
+
args += ["PREFIX", 1, "#{prefix}:"] if prefix
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
args << "MAXTEXTFIELDS" if max_text_fields
|
|
63
|
+
|
|
64
|
+
if temporary
|
|
65
|
+
args << "TEMPORARY"
|
|
66
|
+
args << temporary
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
args << "NOOFFSETS" if no_term_offsets
|
|
70
|
+
args << "NOHL" if no_highlight
|
|
71
|
+
args << "NOFIELDS" if no_field_flags
|
|
72
|
+
args << "NOFREQS" if no_term_frequencies
|
|
73
|
+
args << "SKIPINITIALSCAN" if skip_initial_scan
|
|
74
|
+
|
|
75
|
+
# Stopwords
|
|
76
|
+
if stopwords
|
|
77
|
+
args += ['STOPWORDS', stopwords.size]
|
|
78
|
+
stopwords.each do |stopword|
|
|
79
|
+
args << stopword
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Schema Fields
|
|
84
|
+
args += ["SCHEMA"]
|
|
85
|
+
schema.fields.each do |field|
|
|
86
|
+
args += field.to_args
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
send_command([:"FT.CREATE", *args])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Create an index and return a high-level {Search::Index} bound to this client.
|
|
93
|
+
#
|
|
94
|
+
# Unlike {#ft_create} (which returns +"OK"+), this returns a stateful {Search::Index} that
|
|
95
|
+
# remembers the schema and prefix and exposes +#add+/+#search+/+#aggregate+/etc.
|
|
96
|
+
#
|
|
97
|
+
# @example
|
|
98
|
+
# index = redis.create_index("idx", schema, prefix: "doc")
|
|
99
|
+
# index.add("1", title: "hello")
|
|
100
|
+
# index.search("hello").total # => 1
|
|
101
|
+
#
|
|
102
|
+
# @param name [String] the index name
|
|
103
|
+
# @param schema [Search::Schema] the field schema
|
|
104
|
+
# @param storage_type [String] the data type to index (+"hash"+ or +"json"+)
|
|
105
|
+
# @param prefix [String, nil] key prefix for indexed/added documents
|
|
106
|
+
# @param stopwords [Array<String>, nil] custom stopword list
|
|
107
|
+
# @param max_text_fields [Boolean] emit +MAXTEXTFIELDS+
|
|
108
|
+
# @param skip_initial_scan [Boolean] emit +SKIPINITIALSCAN+
|
|
109
|
+
# @param definition [Search::IndexDefinition, nil] prebuilt index definition clause
|
|
110
|
+
# @return [Search::Index] the created index
|
|
111
|
+
# @raise [Redis::CommandError] if +schema+ is not a {Search::Schema} or creation fails
|
|
112
|
+
def create_index(
|
|
113
|
+
name, schema, storage_type: "hash",
|
|
114
|
+
prefix: nil, stopwords: nil, max_text_fields: false,
|
|
115
|
+
skip_initial_scan: false, definition: nil, **options
|
|
116
|
+
)
|
|
117
|
+
raise Redis::CommandError, "schema must be a Schema object" unless schema.is_a?(Schema)
|
|
118
|
+
|
|
119
|
+
begin
|
|
120
|
+
Index.create(
|
|
121
|
+
self, name, schema, storage_type,
|
|
122
|
+
prefix: prefix, stopwords: stopwords,
|
|
123
|
+
max_text_fields: max_text_fields,
|
|
124
|
+
skip_initial_scan: skip_initial_scan,
|
|
125
|
+
definition: definition, **options
|
|
126
|
+
)
|
|
127
|
+
rescue ArgumentError => e
|
|
128
|
+
raise Redis::CommandError, e.message
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Search an index.
|
|
133
|
+
#
|
|
134
|
+
# @example
|
|
135
|
+
# redis.ft_search("idx", "@title:hello", limit: [0, 10], with_scores: true)
|
|
136
|
+
# # => #<SearchResult total=1 ...>
|
|
137
|
+
#
|
|
138
|
+
# @example raw vector (KNN) query
|
|
139
|
+
# redis.ft_search("idx", "(*)=>[KNN 2 @vec $v]", params: { v: blob }, dialect: 2)
|
|
140
|
+
#
|
|
141
|
+
# @param index_name [String] the index (or alias) name
|
|
142
|
+
# @param query [String] the query string (use {Search::Query}+#to_redis_args.first+ to build one)
|
|
143
|
+
# @param options [Hash] search options translated to +FT.SEARCH+ tokens
|
|
144
|
+
# @option options [Boolean] :no_content return ids only (+NOCONTENT+); documents have no fields
|
|
145
|
+
# @option options [Boolean] :verbatim disable stemming (+VERBATIM+)
|
|
146
|
+
# @option options [Boolean] :no_stopwords do not remove stopwords (+NOSTOPWORDS+)
|
|
147
|
+
# @option options [Boolean] :with_scores include the relevance score on each document (+WITHSCORES+)
|
|
148
|
+
# @option options [Boolean] :with_payloads include each document's payload (+WITHPAYLOADS+)
|
|
149
|
+
# @option options [String] :scorer scoring function name (+SCORER+), e.g. +"BM25"+/+"TFIDF"+
|
|
150
|
+
# @option options [Boolean] :explain_score include a score explanation (+EXPLAINSCORE+)
|
|
151
|
+
# @option options [String] :language stemming language (+LANGUAGE+)
|
|
152
|
+
# @option options [Integer] :slop allowed term reordering distance (+SLOP+)
|
|
153
|
+
# @option options [Boolean] :in_order require terms in query order (+INORDER+)
|
|
154
|
+
# @option options [Integer] :timeout per-query timeout in milliseconds (+TIMEOUT+)
|
|
155
|
+
# @option options [String] :expander custom query expander (+EXPANDER+)
|
|
156
|
+
# @option options [Array<String>] :limit_fields restrict full-text matching to these fields (+INFIELDS+)
|
|
157
|
+
# @option options [Array(Integer, Integer)] :limit +[offset, count]+ paging (+LIMIT+)
|
|
158
|
+
# @option options [Array(String, String)] :sortby +[field, "ASC"|"DESC"]+ (+SORTBY+)
|
|
159
|
+
# @option options [String] :sort_by sort field (convenience; pair with +:asc+)
|
|
160
|
+
# @option options [Boolean] :asc sort ascending when +:sort_by+ is used (default; pass
|
|
161
|
+
# +false+ for descending)
|
|
162
|
+
# @option options [Array<Array>] :filter numeric filters, each +[field, min, max]+ (+FILTER+)
|
|
163
|
+
# @option options [Array<Array>] :geo_filter geo filters, each +[field, lon, lat, radius, unit]+
|
|
164
|
+
# @option options [Array<String>] :limit_ids restrict to these keys (+INKEYS+)
|
|
165
|
+
# @option options [Array<String>] :return only return these fields (+RETURN+)
|
|
166
|
+
# @option options [Hash] :summarize +SUMMARIZE+ options (+:fields+, +:frags+, +:len+, +:separator+)
|
|
167
|
+
# @option options [Hash] :highlight +HIGHLIGHT+ options (+:fields+, +:tags+)
|
|
168
|
+
# @option options [Hash, Array] :params query parameter substitutions (+PARAMS+)
|
|
169
|
+
# @option options [Integer] :dialect query dialect version (+DIALECT+)
|
|
170
|
+
# @option options [Hash{String=>Boolean}] :decode_fields field name => whether to JSON-decode its
|
|
171
|
+
# value in the returned {Search::Document}
|
|
172
|
+
# @return [Search::SearchResult] the total count and the matching {Search::Document}s
|
|
173
|
+
def ft_search(index_name, query, **options)
|
|
174
|
+
args = [index_name, query]
|
|
175
|
+
|
|
176
|
+
args << "NOCONTENT" if options[:no_content]
|
|
177
|
+
args << "VERBATIM" if options[:verbatim]
|
|
178
|
+
args << "NOSTOPWORDS" if options[:no_stopwords]
|
|
179
|
+
# EXPLAINSCORE requires WITHSCORES, so emit WITHSCORES whenever an explanation is asked for.
|
|
180
|
+
args << "WITHSCORES" if options[:with_scores] || options[:explain_score]
|
|
181
|
+
args << "WITHPAYLOADS" if options[:with_payloads]
|
|
182
|
+
args << "SCORER" << options[:scorer] if options[:scorer]
|
|
183
|
+
args << "EXPLAINSCORE" if options[:explain_score]
|
|
184
|
+
args << "LANGUAGE" << options[:language] if options[:language]
|
|
185
|
+
args << "SLOP" << options[:slop] if options[:slop]
|
|
186
|
+
args << "INORDER" if options[:in_order]
|
|
187
|
+
args << "TIMEOUT" << options[:timeout] if options[:timeout]
|
|
188
|
+
args << "EXPANDER" << options[:expander] if options[:expander]
|
|
189
|
+
if options[:limit_fields] && !options[:limit_fields].empty?
|
|
190
|
+
args << "INFIELDS" << options[:limit_fields].size
|
|
191
|
+
args.concat(options[:limit_fields].map(&:to_s))
|
|
192
|
+
end
|
|
193
|
+
args << "LIMIT" << options[:limit][0] << options[:limit][1] if options[:limit]
|
|
194
|
+
|
|
195
|
+
# Handle both :sortby (array format) and :sort_by (convenience format)
|
|
196
|
+
if options[:sortby]
|
|
197
|
+
args << "SORTBY" << options[:sortby][0] << options[:sortby][1]
|
|
198
|
+
elsif options[:sort_by]
|
|
199
|
+
# Default to ASC when :asc is omitted (matching Index#search, Query#sort_by and the
|
|
200
|
+
# server's own SORTBY default); only an explicit asc: false sorts descending.
|
|
201
|
+
direction = options[:asc] == false ? 'DESC' : 'ASC'
|
|
202
|
+
args << "SORTBY" << options[:sort_by] << direction
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
options[:filter]&.each do |field, min, max|
|
|
206
|
+
args << "FILTER" << field << min << max
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
options[:geo_filter]&.each do |field, lon, lat, radius, unit|
|
|
210
|
+
args << "GEOFILTER" << field << lon << lat << radius << unit
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
if options[:limit_ids] && !options[:limit_ids].empty?
|
|
214
|
+
args << "INKEYS" << options[:limit_ids].size
|
|
215
|
+
args.concat(options[:limit_ids])
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
if options[:return] && !options[:return].empty?
|
|
219
|
+
args << "RETURN" << options[:return].size
|
|
220
|
+
args.concat(options[:return])
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
if options[:summarize]
|
|
224
|
+
args << "SUMMARIZE"
|
|
225
|
+
if options[:summarize][:fields]&.any?
|
|
226
|
+
args << "FIELDS" << options[:summarize][:fields].size
|
|
227
|
+
args.concat(options[:summarize][:fields].map(&:to_s))
|
|
228
|
+
end
|
|
229
|
+
args << "FRAGS" << options[:summarize][:frags] if options[:summarize][:frags]
|
|
230
|
+
args << "LEN" << options[:summarize][:len] if options[:summarize][:len]
|
|
231
|
+
args << "SEPARATOR" << options[:summarize][:separator] if options[:summarize][:separator]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
if options[:highlight]
|
|
235
|
+
args << "HIGHLIGHT"
|
|
236
|
+
if options[:highlight][:fields]&.any?
|
|
237
|
+
args << "FIELDS" << options[:highlight][:fields].size
|
|
238
|
+
args.concat(options[:highlight][:fields].map(&:to_s))
|
|
239
|
+
end
|
|
240
|
+
if options[:highlight][:tags]
|
|
241
|
+
args << "TAGS" << options[:highlight][:tags][0] << options[:highlight][:tags][1]
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if options[:params]
|
|
246
|
+
if options[:params].is_a?(Hash)
|
|
247
|
+
args << "PARAMS" << (options[:params].length * 2)
|
|
248
|
+
options[:params].each do |k, v|
|
|
249
|
+
args << k.to_s << v
|
|
250
|
+
end
|
|
251
|
+
else
|
|
252
|
+
args << "PARAMS" << options[:params].length
|
|
253
|
+
args.concat(options[:params])
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Default to DIALECT 2 (matching redis-py) unless the caller specified one. An explicit
|
|
258
|
+
# dialect: nil (e.g. flowing from an unset Query option) falls back to the default too, so
|
|
259
|
+
# it behaves the same as omitting the keyword rather than deferring to the server default.
|
|
260
|
+
dialect = options[:dialect] || DEFAULT_DIALECT
|
|
261
|
+
args << "DIALECT" << dialect
|
|
262
|
+
|
|
263
|
+
# WITHSCORES is emitted for explain_score too (EXPLAINSCORE requires it), so the RESP2
|
|
264
|
+
# parser must expect a score column in the same cases or it mis-reads the reply layout.
|
|
265
|
+
with_scores = options[:with_scores] || options[:explain_score]
|
|
266
|
+
with_payloads = options[:with_payloads]
|
|
267
|
+
no_content = options[:no_content]
|
|
268
|
+
decode_fields = options[:decode_fields] || {}
|
|
269
|
+
|
|
270
|
+
send_command(["FT.SEARCH"] + args.flatten.compact) do |reply|
|
|
271
|
+
ResultParser.search(
|
|
272
|
+
reply,
|
|
273
|
+
with_scores: !!with_scores,
|
|
274
|
+
with_payloads: !!with_payloads,
|
|
275
|
+
no_content: !!no_content,
|
|
276
|
+
decode_fields: decode_fields
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Return information and statistics about an index.
|
|
282
|
+
#
|
|
283
|
+
# @example
|
|
284
|
+
# redis.ft_info("idx")["num_docs"] # => 42
|
|
285
|
+
#
|
|
286
|
+
# @param index_name [String] the index name
|
|
287
|
+
# @return [Hash] index metadata (e.g. +"index_name"+, +"num_docs"+, +"attributes"+)
|
|
288
|
+
def ft_info(index_name)
|
|
289
|
+
send_command(["FT.INFO", index_name]) { |reply| ResultParser.hashify_info(reply) }
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Drop an index.
|
|
293
|
+
#
|
|
294
|
+
# @param index_name [String] the index name
|
|
295
|
+
# @param delete_documents [Boolean] also delete the indexed documents (+DD+)
|
|
296
|
+
# @return [String] +"OK"+
|
|
297
|
+
def ft_dropindex(index_name, delete_documents: false)
|
|
298
|
+
args = ["FT.DROPINDEX", index_name]
|
|
299
|
+
args << 'DD' if delete_documents
|
|
300
|
+
send_command(args)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Run an aggregation pipeline, or read the next batch of a cursor.
|
|
304
|
+
#
|
|
305
|
+
# @example with an AggregateRequest
|
|
306
|
+
# req = Redis::Commands::Search::AggregateRequest.new("*")
|
|
307
|
+
# .group_by("@category", Redis::Commands::Search::Reducers.count.as("n"))
|
|
308
|
+
# redis.ft_aggregate("idx", req) # => #<AggregateResult ...>
|
|
309
|
+
#
|
|
310
|
+
# @param index_name [String] the index name
|
|
311
|
+
# @param query [Search::AggregateRequest, Search::Cursor, String] an aggregate request, a
|
|
312
|
+
# cursor to read, or a raw query string followed by raw pipeline +args+
|
|
313
|
+
# @param args [Array] raw pipeline tokens when +query+ is a String
|
|
314
|
+
# (e.g. +"GROUPBY", 1, "@x", "REDUCE", "COUNT", 0, "AS", "n"+)
|
|
315
|
+
# @return [Search::AggregateResult] the result rows (each a Hash) and the cursor id (or nil)
|
|
316
|
+
def ft_aggregate(index_name, query, *args)
|
|
317
|
+
command =
|
|
318
|
+
case query
|
|
319
|
+
when AggregateRequest
|
|
320
|
+
["FT.AGGREGATE", index_name] + query.to_redis_args
|
|
321
|
+
when Cursor
|
|
322
|
+
["FT.CURSOR", "READ", index_name] + query.build_args
|
|
323
|
+
else
|
|
324
|
+
# Raw query string: default to DIALECT 2 (matching redis-py) unless one was passed.
|
|
325
|
+
base = ["FT.AGGREGATE", index_name, query, *args]
|
|
326
|
+
base += ["DIALECT", DEFAULT_DIALECT] unless args.flatten.map(&:to_s).include?("DIALECT")
|
|
327
|
+
base
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
send_command(command) { |reply| ResultParser.aggregate(reply) }
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Return the execution plan for a query without running it.
|
|
334
|
+
#
|
|
335
|
+
# @param index_name [String] the index name
|
|
336
|
+
# @param query [String] the query string
|
|
337
|
+
# @return [String] a human-readable description of the query plan
|
|
338
|
+
def ft_explain(index_name, query)
|
|
339
|
+
send_command(["FT.EXPLAIN", index_name, query])
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Add a field to an existing index's schema (+FT.ALTER ... SCHEMA ADD+).
|
|
343
|
+
#
|
|
344
|
+
# @param index_name [String] the index name
|
|
345
|
+
# @param field_or_args [Search::Field, Array] a {Search::Field} (rendered via +#to_args+) or a
|
|
346
|
+
# raw token array
|
|
347
|
+
# @return [String] +"OK"+
|
|
348
|
+
# @raise [ArgumentError] if +field_or_args+ is neither a Field nor an Array
|
|
349
|
+
def ft_alter(index_name, field_or_args)
|
|
350
|
+
args = [index_name, "SCHEMA", "ADD"]
|
|
351
|
+
if field_or_args.respond_to?(:to_args)
|
|
352
|
+
args += field_or_args.to_args
|
|
353
|
+
elsif field_or_args.is_a?(Array)
|
|
354
|
+
args += field_or_args
|
|
355
|
+
else
|
|
356
|
+
raise ArgumentError, "field_or_args must be a Field object or an array"
|
|
357
|
+
end
|
|
358
|
+
send_command([:"FT.ALTER", *args])
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Read the next batch of results from an aggregation cursor.
|
|
362
|
+
#
|
|
363
|
+
# @param index_name [String] the index name
|
|
364
|
+
# @param cursor_id [Integer] the cursor id returned by a previous WITHCURSOR aggregation
|
|
365
|
+
# @return [Search::AggregateResult] the next rows, with +#cursor+ set to the next cursor id
|
|
366
|
+
# (+0+ when exhausted)
|
|
367
|
+
def ft_cursor_read(index_name, cursor_id)
|
|
368
|
+
send_command(["FT.CURSOR", "READ", index_name, cursor_id]) { |reply| ResultParser.aggregate(reply) }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Discard an aggregation cursor.
|
|
372
|
+
#
|
|
373
|
+
# @param index_name [String] the index name
|
|
374
|
+
# @param cursor_id [Integer] the cursor id
|
|
375
|
+
# @return [String] +"OK"+
|
|
376
|
+
def ft_cursor_del(index_name, cursor_id)
|
|
377
|
+
send_command(["FT.CURSOR", "DEL", index_name, cursor_id])
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Profile the execution of a +SEARCH+ or +AGGREGATE+ query (timing/heuristics).
|
|
381
|
+
#
|
|
382
|
+
# @param index_name [String] the index name
|
|
383
|
+
# @param args [Array] the profile arguments, e.g. +"SEARCH", "QUERY", "<query>"+
|
|
384
|
+
# @return [Array] the raw profile reply (results plus a profiling tree)
|
|
385
|
+
def ft_profile(index_name, *args)
|
|
386
|
+
send_command(["FT.PROFILE", index_name] + args)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Run a hybrid (lexical + vector) search.
|
|
390
|
+
#
|
|
391
|
+
# @param index_name [String] the index name
|
|
392
|
+
# @param query [Search::HybridQuery] the combined SEARCH + VSIM query
|
|
393
|
+
# @param combine_method [Search::CombineResultsMethod, nil] the fusion strategy (RRF/LINEAR)
|
|
394
|
+
# @param post_processing [Search::HybridPostProcessingConfig, nil] a post-fusion pipeline
|
|
395
|
+
# @param params_substitution [Hash, nil] query parameter substitutions (+PARAMS+)
|
|
396
|
+
# @param timeout [Integer, nil] query timeout in milliseconds (+TIMEOUT+)
|
|
397
|
+
# @param cursor [Search::HybridCursorQuery, nil] cursor/pagination config (+WITHCURSOR+)
|
|
398
|
+
# @return [Search::HybridResult] the fused result rows, total, warnings and execution time
|
|
399
|
+
# (or, for a WITHCURSOR query, the per-leg cursor ids)
|
|
400
|
+
def ft_hybrid_search(
|
|
401
|
+
index_name, query:, combine_method: nil, post_processing: nil,
|
|
402
|
+
params_substitution: nil, timeout: nil, cursor: nil, **_options
|
|
403
|
+
)
|
|
404
|
+
args = ["FT.HYBRID", index_name]
|
|
405
|
+
args.concat(query.args)
|
|
406
|
+
args.concat(combine_method.args) if combine_method
|
|
407
|
+
args.concat(post_processing.build_args) if post_processing
|
|
408
|
+
if params_substitution
|
|
409
|
+
args << "PARAMS" << params_substitution.size * 2
|
|
410
|
+
params_substitution.each do |key, value|
|
|
411
|
+
args << key.to_s << value
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
args.concat(["TIMEOUT", timeout]) if timeout
|
|
415
|
+
args.concat(cursor.build_args) if cursor
|
|
416
|
+
# NOTE: unlike FT.SEARCH/FT.AGGREGATE, FT.HYBRID does NOT accept a DIALECT token (the
|
|
417
|
+
# server rejects it: "DIALECT is not supported in FT.HYBRID or any of its subqueries").
|
|
418
|
+
# Its legs use the server's search-default-dialect config, so do not append DEFAULT_DIALECT.
|
|
419
|
+
send_command(args) { |reply| ResultParser.hybrid(reply) }
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Add a suggestion string to an auto-complete dictionary.
|
|
423
|
+
#
|
|
424
|
+
# @param key [String] the suggestion dictionary key
|
|
425
|
+
# @param string [String] the suggestion text
|
|
426
|
+
# @param score [Numeric] the suggestion weight
|
|
427
|
+
# @param options [Hash]
|
|
428
|
+
# @option options [Boolean] :incr increment the existing score instead of replacing it (+INCR+)
|
|
429
|
+
# @option options [String] :payload an opaque payload to store with the suggestion (+PAYLOAD+)
|
|
430
|
+
# @return [Integer] the current size of the suggestion dictionary
|
|
431
|
+
def ft_sugadd(key, string, score, options = {})
|
|
432
|
+
args = ["FT.SUGADD", key, string, score]
|
|
433
|
+
args << 'INCR' if options[:incr]
|
|
434
|
+
args << 'PAYLOAD' << options[:payload] if options[:payload]
|
|
435
|
+
send_command(args)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# Get auto-complete suggestions for a prefix.
|
|
439
|
+
#
|
|
440
|
+
# @param key [String] the suggestion dictionary key
|
|
441
|
+
# @param prefix [String] the prefix to complete
|
|
442
|
+
# @param options [Hash]
|
|
443
|
+
# @option options [Boolean] :fuzzy perform a fuzzy prefix match (+FUZZY+)
|
|
444
|
+
# @option options [Boolean] :with_scores also return each suggestion's score (+WITHSCORES+)
|
|
445
|
+
# @option options [Boolean] :with_payloads also return each suggestion's payload (+WITHPAYLOADS+)
|
|
446
|
+
# @option options [Integer] :max maximum number of suggestions (+MAX+)
|
|
447
|
+
# @return [Array<String>] the suggestions, interleaved with scores/payloads when requested
|
|
448
|
+
def ft_sugget(key, prefix, options = {})
|
|
449
|
+
args = ["FT.SUGGET", key, prefix]
|
|
450
|
+
args << 'FUZZY' if options[:fuzzy]
|
|
451
|
+
args << 'WITHSCORES' if options[:withscores] || options[:with_scores]
|
|
452
|
+
args << 'WITHPAYLOADS' if options[:withpayloads] || options[:with_payloads]
|
|
453
|
+
args << 'MAX' << options[:max] if options[:max]
|
|
454
|
+
send_command(args)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Get the number of entries in a suggestion dictionary.
|
|
458
|
+
#
|
|
459
|
+
# @param key [String] the suggestion dictionary key
|
|
460
|
+
# @return [Integer] the number of suggestions
|
|
461
|
+
def ft_suglen(key)
|
|
462
|
+
send_command(["FT.SUGLEN", key])
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Delete a string from a suggestion dictionary.
|
|
466
|
+
#
|
|
467
|
+
# @param key [String] the suggestion dictionary key
|
|
468
|
+
# @param string [String] the suggestion text to delete
|
|
469
|
+
# @return [Integer] +1+ if the suggestion existed and was deleted, +0+ otherwise
|
|
470
|
+
def ft_sugdel(key, string)
|
|
471
|
+
send_command(["FT.SUGDEL", key, string])
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Perform spelling correction over a query against an index.
|
|
475
|
+
#
|
|
476
|
+
# @example
|
|
477
|
+
# redis.ft_spellcheck("idx", "hello wrld")
|
|
478
|
+
# # => { "wrld" => [{ "suggestion" => "world", "score" => 0.5 }] }
|
|
479
|
+
#
|
|
480
|
+
# @param index_name [String] the index name
|
|
481
|
+
# @param query [String] the query whose terms are checked
|
|
482
|
+
# @param distance [Integer, nil] maximum Levenshtein distance for suggestions (+DISTANCE+)
|
|
483
|
+
# @param include [String, nil] a custom dictionary to include terms from (+TERMS INCLUDE+)
|
|
484
|
+
# @param exclude [String, nil] a custom dictionary to exclude terms from (+TERMS EXCLUDE+)
|
|
485
|
+
# @return [Hash{String=>Array<Hash>}] each misspelled term mapped to an array of
|
|
486
|
+
# +{ "suggestion" => String, "score" => Numeric }+
|
|
487
|
+
def ft_spellcheck(index_name, query, distance: nil, include: nil, exclude: nil)
|
|
488
|
+
args = ["FT.SPELLCHECK", index_name, query]
|
|
489
|
+
args += ["DISTANCE", distance] if distance
|
|
490
|
+
args += ["TERMS", "INCLUDE", include] if include
|
|
491
|
+
args += ["TERMS", "EXCLUDE", exclude] if exclude
|
|
492
|
+
|
|
493
|
+
send_command(args) { |reply| ResultParser.spellcheck(reply) }
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Add or update a synonym group on an index.
|
|
497
|
+
#
|
|
498
|
+
# @param index_name [String] the index name
|
|
499
|
+
# @param group_id [String] the synonym group id
|
|
500
|
+
# @param terms [Array<String>] the terms to add to the group
|
|
501
|
+
# @param skip_initial_scan [Boolean] do not re-scan existing documents (+SKIPINITIALSCAN+)
|
|
502
|
+
# @return [String] +"OK"+
|
|
503
|
+
def ft_synupdate(index_name, group_id, *terms, skip_initial_scan: false)
|
|
504
|
+
args = [index_name, group_id]
|
|
505
|
+
args << "SKIPINITIALSCAN" if skip_initial_scan
|
|
506
|
+
args += terms
|
|
507
|
+
send_command(["FT.SYNUPDATE"] + args)
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Dump the synonym groups of an index.
|
|
511
|
+
#
|
|
512
|
+
# @param index_name [String] the index name
|
|
513
|
+
# @return [Hash{String=>Array<String>}] each term mapped to the synonym group ids it belongs to
|
|
514
|
+
def ft_syndump(index_name)
|
|
515
|
+
send_command(["FT.SYNDUMP", index_name]) { |reply| ResultParser.syndump(reply) }
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# List the distinct values of a TAG field.
|
|
519
|
+
#
|
|
520
|
+
# @param index_name [String] the index name
|
|
521
|
+
# @param field_name [String] the TAG field name
|
|
522
|
+
# @return [Array<String>] the distinct tag values (normalized to lowercase unless the field is
|
|
523
|
+
# case-sensitive)
|
|
524
|
+
def ft_tagvals(index_name, field_name)
|
|
525
|
+
send_command(["FT.TAGVALS", index_name, field_name])
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Add an alias for an index.
|
|
529
|
+
#
|
|
530
|
+
# @param alias_name [String] the alias
|
|
531
|
+
# @param index_name [String] the index the alias points to
|
|
532
|
+
# @return [String] +"OK"+
|
|
533
|
+
def ft_aliasadd(alias_name, index_name)
|
|
534
|
+
send_command(["FT.ALIASADD", alias_name, index_name])
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
# Repoint an existing alias to a different index (or create it if absent).
|
|
538
|
+
#
|
|
539
|
+
# @param alias_name [String] the alias
|
|
540
|
+
# @param index_name [String] the index the alias should point to
|
|
541
|
+
# @return [String] +"OK"+
|
|
542
|
+
def ft_aliasupdate(alias_name, index_name)
|
|
543
|
+
send_command(["FT.ALIASUPDATE", alias_name, index_name])
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Remove an index alias.
|
|
547
|
+
#
|
|
548
|
+
# @param alias_name [String] the alias
|
|
549
|
+
# @return [String] +"OK"+
|
|
550
|
+
def ft_aliasdel(alias_name)
|
|
551
|
+
send_command(["FT.ALIASDEL", alias_name])
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# List all aliases associated with an index (Redis 8.10+).
|
|
555
|
+
#
|
|
556
|
+
# @param index_name [String] the index name
|
|
557
|
+
# @return [Array<String>] the aliases pointing to the index (empty when none)
|
|
558
|
+
# @raise [Redis::CommandError] if the index does not exist
|
|
559
|
+
def ft_aliaslist(index_name)
|
|
560
|
+
send_command(["FT.ALIASLIST", index_name])
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Add terms to a custom dictionary.
|
|
564
|
+
#
|
|
565
|
+
# @param dict_name [String] the dictionary name
|
|
566
|
+
# @param terms [Array<String>] the terms to add
|
|
567
|
+
# @return [Integer] the number of new terms added
|
|
568
|
+
def ft_dictadd(dict_name, *terms)
|
|
569
|
+
send_command(["FT.DICTADD", dict_name] + terms)
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# Remove terms from a custom dictionary.
|
|
573
|
+
#
|
|
574
|
+
# @param dict_name [String] the dictionary name
|
|
575
|
+
# @param terms [Array<String>] the terms to remove
|
|
576
|
+
# @return [Integer] the number of terms removed
|
|
577
|
+
def ft_dictdel(dict_name, *terms)
|
|
578
|
+
send_command(["FT.DICTDEL", dict_name] + terms)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Dump all terms in a custom dictionary.
|
|
582
|
+
#
|
|
583
|
+
# @param dict_name [String] the dictionary name
|
|
584
|
+
# @return [Array<String>] the terms in the dictionary
|
|
585
|
+
def ft_dictdump(dict_name)
|
|
586
|
+
send_command(["FT.DICTDUMP", dict_name])
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Set a runtime Query Engine configuration option.
|
|
590
|
+
#
|
|
591
|
+
# @param option [String] the option name (or +"*"+)
|
|
592
|
+
# @param value [String, Integer] the value
|
|
593
|
+
# @return [String] +"OK"+
|
|
594
|
+
def ft_config_set(option, value)
|
|
595
|
+
send_command(["FT.CONFIG", "SET", option, value])
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# Get a runtime Query Engine configuration option.
|
|
599
|
+
#
|
|
600
|
+
# @param option [String] the option name, or +"*"+ for all options
|
|
601
|
+
# @return [Hash] option name => value
|
|
602
|
+
def ft_config_get(option)
|
|
603
|
+
send_command(["FT.CONFIG", "GET", option]) { |reply| ResultParser.config_get(reply) }
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
end
|