Class: Redis::Commands::Search::Index

Inherits:
Object
  • Object
show all
Defined in:
lib/redis/commands/modules/search/index.rb

Overview

High-level handle to a search index: a Schema, an optional key prefix and a Redis connection bundled together. It is the most ergonomic entry point for HASH/JSON document workflows — it remembers the prefix (so document ids round-trip to the logical id passed to #add), validates field values against the schema, and accepts a Query, a query string, or a block in #search. Obtain one via Redis#create_index or Index.create.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(redis, name, schema, storage_type, prefix: nil, stopwords: nil) ⇒ Index

Returns a new instance of Index.

Parameters:

  • redis (Redis)

    the client used for all index operations

  • name (String)

    the index name

  • schema (Schema)

    the field schema

  • storage_type (String)

    the indexed data type (+"hash"+ or "json")

  • prefix (String, nil) (defaults to: nil)

    key prefix for documents

  • stopwords (Array<String>, nil) (defaults to: nil)

    custom stopword list



23
24
25
26
27
28
29
30
# File 'lib/redis/commands/modules/search/index.rb', line 23

def initialize(redis, name, schema, storage_type, prefix: nil, stopwords: nil)
  @redis = redis
  @name = name
  @prefix = prefix
  @schema = schema
  @storage_type = storage_type
  @stopwords = stopwords
end

Instance Attribute Details

#nameString? (readonly)

Returns:

  • (String)

    the index name

  • (String, nil)

    the literal key prefix prepended to (and stripped from) document ids, e.g. "doc:"; nil when the index manages no single prefix



15
16
17
# File 'lib/redis/commands/modules/search/index.rb', line 15

def name
  @name
end

#prefixString? (readonly)

Returns:

  • (String)

    the index name

  • (String, nil)

    the literal key prefix prepended to (and stripped from) document ids, e.g. "doc:"; nil when the index manages no single prefix



15
16
17
# File 'lib/redis/commands/modules/search/index.rb', line 15

def prefix
  @prefix
end

Class Method Details

.create(redis, name, schema, storage_type, prefix: nil, stopwords: nil, skip_initial_scan: false, **options) ⇒ Index

Create the index on the server (runs FT.CREATE) and return a handle to it.

Examples:

Redis::Commands::Search::Index.create(redis, "idx", schema, "hash", prefix: "doc")

Parameters:

  • redis (Redis)

    the client to use

  • name (String)

    the index name

  • schema (Schema)

    the field schema

  • storage_type (String)

    the indexed data type (+"hash"+ or "json")

  • prefix (String, nil) (defaults to: nil)

    key prefix for documents

  • stopwords (Array<String>, nil) (defaults to: nil)

    custom stopword list

  • skip_initial_scan (Boolean) (defaults to: false)

    do not backfill existing keys (+SKIPINITIALSCAN+)

Returns:

  • (Index)

    the created index

Raises:

  • (ArgumentError)

    if schema is not a Schema



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/redis/commands/modules/search/index.rb', line 46

def self.create(
  redis, name, schema, storage_type,
  prefix: nil, stopwords: nil, skip_initial_scan: false, **options
)
  raise ArgumentError, "Invalid schema" unless schema.is_a?(Schema)

  redis.ft_create(
    name, schema, storage_type,
    prefix: prefix, stopwords: stopwords,
    skip_initial_scan: skip_initial_scan, **options
  )
  # The Index stores the *literal* key prefix it prepends to (and strips from) document
  # ids. A definition (which FT.CREATE prefers over the +prefix:+ keyword) carries its
  # prefixes verbatim, e.g. "bicycle:"; the +prefix:+ keyword form appends the ":". It also
  # records the resolved storage type (HASH/JSON) so #add writes documents the right way.
  new(
    redis, name, schema, resolve_storage_type(storage_type, options[:definition]),
    prefix: key_prefix(prefix, options[:definition]), stopwords: stopwords
  )
end

.key_prefix(prefix, definition) ⇒ Object

The single literal key prefix the Index should manage, or nil when it can't be determined unambiguously (no prefix, or a definition with several prefixes).



69
70
71
72
73
74
75
# File 'lib/redis/commands/modules/search/index.rb', line 69

def self.key_prefix(prefix, definition)
  if definition.is_a?(IndexDefinition) && definition.prefixes.size == 1
    definition.prefixes.first
  elsif prefix
    "#{prefix}:"
  end
end

.resolve_storage_type(storage_type, definition) ⇒ Object

The effective storage type, mirroring FT.CREATE: a definition's own index_type wins when set, otherwise fall back to the storage_type keyword. This keeps #add's write path (hset vs json_set) consistent with the ON clause, so a definition without a type plus storage_type: :json still indexes as JSON.



81
82
83
84
# File 'lib/redis/commands/modules/search/index.rb', line 81

def self.resolve_storage_type(storage_type, definition)
  type = definition&.index_type || storage_type
  type.to_s.casecmp?(IndexType::JSON) ? IndexType::JSON : IndexType::HASH
end

Instance Method Details

#add(doc_id, **fields) ⇒ Integer, String

Add (or replace) a document under the key "<prefix><doc_id>" (or doc_id when no prefix is set). The document is written to match the index's storage type: a HASH (+HSET+) for a HASH index, or a JSON document at the root path (+JSON.SET+) for a JSON index — so the index actually picks it up either way.

For a JSON index the given fields are written verbatim as the JSON document at the root path (+$+), so each key lands at a top-level attribute. This works when the schema indexes root-level JSONPaths (+$.brand AS brand+, so add("1", brand: "x") stores {"brand":"x"} which $.brand matches). It does not build nested structure: a field over a nested path (+$.user.name AS name+) is not satisfied by a flat name: "..." write — pass the full document shape instead (+add("1", user: { name: "Ann" })+).

Examples:

index.add("1", title: "hello", price: 10)

Parameters:

  • doc_id (String)

    the logical document id

  • fields (Hash{Symbol,String => Object})

    the field/value pairs to store

Returns:

  • (Integer, String)

    the HSET reply (HASH index) or the JSON.SET reply (JSON index)

Raises:



105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
# File 'lib/redis/commands/modules/search/index.rb', line 105

def add(doc_id, **fields)
  key = @prefix ? "#{@prefix}#{doc_id}" : doc_id

  # Validate fields
  fields.each do |field_name, value|
    field = @schema.field(field_name)
    if field.is_a?(NumericField) && !value.is_a?(Numeric)
      raise Redis::CommandError, "Invalid value for numeric field '#{field_name}': #{value}"
    end
  end

  begin
    json? ? @redis.json_set(key, "$", fields) : @redis.hset(key, fields)
  rescue Redis::CommandError => error
    raise Redis::CommandError, "Error adding document: #{error.message}"
  end
end

#aggregate(query, *args) ⇒ AggregateResult

Run an aggregation pipeline against the index (delegates to FT.AGGREGATE).

Parameters:

  • query (AggregateRequest, Cursor, String)

    the aggregate request, a cursor, or a raw query string followed by raw pipeline args

  • args (Array)

    raw pipeline tokens when query is a String

Returns:



259
260
261
# File 'lib/redis/commands/modules/search/index.rb', line 259

def aggregate(query, *args)
  @redis.ft_aggregate(@name, query, *args)
end

#alter(field_or_args) ⇒ String

Add a field to the index schema (delegates to FT.ALTER).

Parameters:

  • field_or_args (Search::Field, Array)

    a Field (rendered via #to_args) or a raw token array describing the field to add

Returns:

  • (String)

    "OK"



294
295
296
# File 'lib/redis/commands/modules/search/index.rb', line 294

def alter(field_or_args)
  @redis.ft_alter(@name, field_or_args)
end

#drop(delete_documents: false) ⇒ String

Drop the index (delegates to FT.DROPINDEX).

Parameters:

  • delete_documents (Boolean) (defaults to: false)

    also delete the indexed documents (+DD+)

Returns:

  • (String)

    "OK"



249
250
251
# File 'lib/redis/commands/modules/search/index.rb', line 249

def drop(delete_documents: false)
  @redis.ft_dropindex(@name, delete_documents: delete_documents)
end

#explain(query) ⇒ String

Return the execution plan for a query without running it (delegates to FT.EXPLAIN).

Parameters:

  • query (String)

    the query string

Returns:

  • (String)

    the query plan



285
286
287
# File 'lib/redis/commands/modules/search/index.rb', line 285

def explain(query)
  @redis.ft_explain(@name, query)
end

#hybrid_search(query:, combine_method: nil, post_processing: nil, params_substitution: nil, timeout: nil, cursor: nil) ⇒ Search::HybridResult

Run a hybrid (lexical + vector) search against this index (delegates to FT.HYBRID).

Parameters:

  • query (Search::HybridQuery)

    the combined SEARCH + VSIM query

  • combine_method (Search::CombineResultsMethod, nil) (defaults to: nil)

    the fusion strategy (RRF/LINEAR)

  • post_processing (Search::HybridPostProcessingConfig, nil) (defaults to: nil)

    a post-fusion pipeline

  • params_substitution (Hash, nil) (defaults to: nil)

    query parameter substitutions (+PARAMS+)

  • timeout (Integer, nil) (defaults to: nil)

    query timeout in milliseconds (+TIMEOUT+)

  • cursor (Search::HybridCursorQuery, nil) (defaults to: nil)

    cursor/pagination config (+WITHCURSOR+)

Returns:



272
273
274
275
276
277
278
279
# File 'lib/redis/commands/modules/search/index.rb', line 272

def hybrid_search(query:, combine_method: nil, post_processing: nil,
                  params_substitution: nil, timeout: nil, cursor: nil)
  @redis.ft_hybrid_search(
    @name,
    query: query, combine_method: combine_method, post_processing: post_processing,
    params_substitution: params_substitution, timeout: timeout, cursor: cursor
  )
end

#infoHash

Return information and statistics about the index (delegates to FT.INFO).

Returns:

  • (Hash)

    index metadata



241
242
243
# File 'lib/redis/commands/modules/search/index.rb', line 241

def info
  @redis.ft_info(@name)
end

#profile(*args) ⇒ Array

Profile the execution of a query (delegates to FT.PROFILE).

Parameters:

  • args (Array)

    the profile arguments

Returns:

  • (Array)

    the raw profile reply



338
339
340
# File 'lib/redis/commands/modules/search/index.rb', line 338

def profile(*args)
  @redis.ft_profile(@name, *args)
end

#search(query = nil, query_params: nil, params: nil, dialect: nil, nocontent: nil, verbatim: nil, no_stopwords: nil, with_scores: nil, with_payloads: nil, slop: nil, in_order: nil, language: nil, return_fields: nil, summarize: nil, highlight: nil, sort_by: nil, asc: nil, scorer: nil, explain_score: nil, &block) ⇒ SearchResult

Search the index.

Accepts a Query object, a raw query string, or a block evaluated as a Query. Keyword arguments are applied on top of the query before it is sent. Document ids in the result have the index prefix stripped, so they match the ids passed to #add.

Examples:

with a Query object

index.search(Redis::Commands::Search::Query.new("@title:hello").paging(0, 10))

with a block

index.search { text(:title).match("hello*") }

Parameters:

  • query (Query, String, nil) (defaults to: nil)

    the query (a Query, a query string, or nil when a block is given)

  • query_params (Hash, Array, nil) (defaults to: nil)

    query parameter substitutions (overrides params)

  • params (Hash, Array, nil) (defaults to: nil)

    query parameter substitutions (+PARAMS+)

  • dialect (Integer, nil) (defaults to: nil)

    query dialect version (+DIALECT+)

  • nocontent (Boolean, nil) (defaults to: nil)

    return ids only (+NOCONTENT+)

  • verbatim (Boolean, nil) (defaults to: nil)

    disable stemming (+VERBATIM+)

  • no_stopwords (Boolean, nil) (defaults to: nil)

    do not remove stopwords (+NOSTOPWORDS+)

  • with_scores (Boolean, nil) (defaults to: nil)

    include relevance scores (+WITHSCORES+)

  • with_payloads (Boolean, nil) (defaults to: nil)

    include payloads (+WITHPAYLOADS+)

  • slop (Integer, nil) (defaults to: nil)

    allowed term reordering distance (+SLOP+)

  • in_order (Boolean, nil) (defaults to: nil)

    require terms in query order (+INORDER+)

  • language (String, nil) (defaults to: nil)

    stemming language (+LANGUAGE+)

  • return_fields (Array<String>, nil) (defaults to: nil)

    only return these fields (+RETURN+)

  • summarize (Hash, nil) (defaults to: nil)

    SUMMARIZE options

  • highlight (Hash, nil) (defaults to: nil)

    HIGHLIGHT options

  • sort_by (String, nil) (defaults to: nil)

    sort field (+SORTBY+)

  • asc (Boolean, nil) (defaults to: nil)

    sort ascending (default) when sort_by is given; false for descending

  • scorer (String, nil) (defaults to: nil)

    scoring function name (+SCORER+)

  • explain_score (Boolean, nil) (defaults to: nil)

    include a score explanation (+EXPLAINSCORE+)

Returns:

Raises:

  • (ArgumentError)

    if no usable query is provided



157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/redis/commands/modules/search/index.rb', line 157

def search(query = nil, query_params: nil, params: nil, dialect: nil,
           nocontent: nil, verbatim: nil, no_stopwords: nil, with_scores: nil,
           with_payloads: nil, slop: nil, in_order: nil, language: nil,
           return_fields: nil, summarize: nil, highlight: nil, sort_by: nil, asc: nil,
           scorer: nil, explain_score: nil, &block)
  if block_given?
    query = Query.build(&block)
  elsif query.is_a?(String)
    query = Query.new(query)
  end

  raise ArgumentError, "Invalid query" unless query.is_a?(Query)

  query_string = query.to_redis_args.first

  sort_order = asc == false ? "DESC" : "ASC"
  sortby = sort_by ? [sort_by, sort_order] : query.options[:sortby]
  limit_ids = query.limit_ids_value
  # INKEYS needs full Redis keys. Prepend the index prefix to logical ids (mirroring
  # #add), but leave ids that already carry it alone so callers passing full keys don't
  # get a doubled prefix that matches nothing.
  if limit_ids && @prefix
    limit_ids = limit_ids.map { |id| id.to_s.start_with?(@prefix) ? id : "#{@prefix}#{id}" }
  end

  # An empty RETURN list is not a meaningful per-call value (it would just omit RETURN and
  # return all fields), so treat it as "unset" and fall back to the Query's RETURN list.
  return_fields = nil if return_fields.is_a?(Array) && return_fields.empty?

  # Build a fresh options hash per call. A per-call keyword argument wins when explicitly
  # given (including +false+, to turn a flag off); +nil+ falls back to whatever the Query
  # was built with. The Query is never mutated, so reusing one Query across searches never
  # leaks options between calls and per-call flags can both enable and disable.
  pick = ->(call_value, query_value) { call_value.nil? ? query_value : call_value }

  options = {
    filter: query.filters,
    geo_filter: query.geo_filters,
    limit_ids: limit_ids,
    limit: query.options[:limit],
    sortby: sortby,
    dialect: pick.call(dialect, query.options[:dialect]),
    return: pick.call(return_fields, query.return_fields),
    decode_fields: query.return_fields_decode,
    highlight: pick.call(highlight, query.highlight_options),
    summarize: pick.call(summarize, query.summarize_options),
    verbatim: pick.call(verbatim, query.verbatim_value),
    no_stopwords: pick.call(no_stopwords, query.no_stopwords_value),
    no_content: pick.call(nocontent, query.no_content_value),
    with_scores: pick.call(with_scores, query.options[:withscores]),
    scorer: pick.call(scorer, query.options[:scorer]),
    explain_score: pick.call(explain_score, query.options[:explainscore]),
    language: pick.call(language, query.language_value),
    with_payloads: pick.call(with_payloads, query.with_payloads_value),
    slop: pick.call(slop, query.slop_value),
    in_order: pick.call(in_order, query.in_order_value),
    timeout: query.timeout_value,
    limit_fields: query.limit_fields_value,
    expander: query.expander_value
  }
  substitutions = query_params || params
  options[:params] = substitutions if substitutions

  result = @redis.ft_search(@name, query_string, **options)

  # Strip the index prefix from document IDs if one is set, so callers see the
  # logical doc id they passed to #add rather than the underlying Redis key.
  if @prefix && result.is_a?(SearchResult)
    result.documents.map! do |doc|
      next doc unless doc.id.is_a?(String) && doc.id.start_with?(@prefix)

      Document.new(
        doc.id.delete_prefix(@prefix),
        attributes: doc.attributes, score: doc.score, payload: doc.payload
      )
    end
  end

  result
end

#spellcheck(query, distance: nil, include: nil, exclude: nil) ⇒ Hash{String=>Array<Hash>}

Perform spelling correction over a query against the index (delegates to FT.SPELLCHECK).

Parameters:

  • query (String)

    the query whose terms are checked

  • distance (Integer, nil) (defaults to: nil)

    maximum Levenshtein distance for suggestions (+DISTANCE+)

  • include (String, nil) (defaults to: nil)

    a custom dictionary to include terms from (+TERMS INCLUDE+)

  • exclude (String, nil) (defaults to: nil)

    a custom dictionary to exclude terms from (+TERMS EXCLUDE+)

Returns:

  • (Hash{String=>Array<Hash>})

    misspelled terms mapped to suggestions



305
306
307
# File 'lib/redis/commands/modules/search/index.rb', line 305

def spellcheck(query, distance: nil, include: nil, exclude: nil)
  @redis.ft_spellcheck(@name, query, distance: distance, include: include, exclude: exclude)
end

#syndumpHash{String=>Array<String>}

Dump the synonym groups of the index (delegates to FT.SYNDUMP).

Returns:

  • (Hash{String=>Array<String>})

    each term mapped to its synonym group ids



322
323
324
# File 'lib/redis/commands/modules/search/index.rb', line 322

def syndump
  @redis.ft_syndump(@name)
end

#synupdate(group_id, *terms, skip_initial_scan: false) ⇒ String

Add or update a synonym group on the index (delegates to FT.SYNUPDATE).

Parameters:

  • group_id (String)

    the synonym group id

  • terms (Array<String>)

    the terms to add to the group

  • skip_initial_scan (Boolean) (defaults to: false)

    do not re-scan existing documents (+SKIPINITIALSCAN+)

Returns:

  • (String)

    "OK"



315
316
317
# File 'lib/redis/commands/modules/search/index.rb', line 315

def synupdate(group_id, *terms, skip_initial_scan: false)
  @redis.ft_synupdate(@name, group_id, *terms, skip_initial_scan: skip_initial_scan)
end

#tagvals(field_name) ⇒ Array<String>

List the distinct values of a TAG field (delegates to FT.TAGVALS).

Parameters:

  • field_name (String)

    the TAG field name

Returns:

  • (Array<String>)

    the distinct tag values



330
331
332
# File 'lib/redis/commands/modules/search/index.rb', line 330

def tagvals(field_name)
  @redis.ft_tagvals(@name, field_name)
end