Module: ParadeDB::SearchMethods

Defined in:
lib/parade_db/search_methods.rb

Overview

SearchMethods extends ActiveRecord::Relation to add ParadeDB full-text search capabilities. This module is mixed into relations via .search() to provide chainable query methods.

Defined Under Namespace

Modules: AggregationRelation, FacetRelation, PredicateInspector Classes: AggregationQuery, FacetQuery

Constant Summary collapse

AGGREGATE_SAFE_TEXT_TOKENIZERS =
%w[literal literal_normalized].freeze
MLT_OPTION_ALIASES =
{
  min_term_freq: :min_term_frequency,
  min_term_frequency: :min_term_frequency,
  max_query_terms: :max_query_terms,
  min_doc_freq: :min_doc_frequency,
  min_doc_frequency: :min_doc_frequency,
  max_doc_freq: :max_doc_frequency,
  max_doc_frequency: :max_doc_frequency,
  min_word_length: :min_word_length,
  max_word_length: :max_word_length,
  stopwords: :stopwords
}.freeze
MLT_INTEGER_OPTION_KEYS =
%i[
  min_term_frequency
  max_query_terms
  min_doc_frequency
  max_doc_frequency
  min_word_length
  max_word_length
].freeze
MLT_OPTION_ORDER =
%i[
  min_term_frequency
  max_query_terms
  min_doc_frequency
  max_doc_frequency
  min_word_length
  max_word_length
  stopwords
].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#_paradedb_current_fieldObject

Internal state tracking



40
41
42
# File 'lib/parade_db/search_methods.rb', line 40

def _paradedb_current_field
  @_paradedb_current_field
end

#_paradedb_facet_fieldsObject

Returns the value of attribute _paradedb_facet_fields.



41
42
43
# File 'lib/parade_db/search_methods.rb', line 41

def _paradedb_facet_fields
  @_paradedb_facet_fields
end

Instance Method Details

#aggregate_by(*group_fields, exact: nil, **named_aggregations) ⇒ Object

Grouped ParadeDB aggregations:

Product.search(:id).match_all.aggregate_by(:rating, agg: ParadeDB::Aggregations.value_count(:id))


401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
# File 'lib/parade_db/search_methods.rb', line 401

def aggregate_by(*group_fields, exact: nil, **named_aggregations)
  ensure_paradedb_runtime!
  validate_exact_option!(exact)
  normalized_group_fields = normalize_group_fields(group_fields)
  agg_specs = normalize_named_aggregation_specs(named_aggregations)

  rel = self
  rel = rel.ensure_paradedb_predicate unless rel.has_paradedb_predicate?

  group_nodes = normalized_group_fields.map { |field| resolve_group_field_node(field) }
  aggregate_nodes = agg_specs.map do |alias_name, agg_spec|
    render_aggregation_node(agg_spec, exact: exact).as(alias_name.to_s)
  end

  rel.except(:select, :group).select(*group_nodes, *aggregate_nodes).group(*group_nodes)
end

#build_facet_query(fields:, size: 10, order: :count_desc, missing: nil, agg: nil, exact: nil) ⇒ Object

Internal method to build facet query (for testing)



333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'lib/parade_db/search_methods.rb', line 333

def build_facet_query(fields:, size: 10, order: :count_desc, missing: nil, agg: nil, exact: nil)
  ensure_paradedb_runtime!
  facet_args = normalize_facet_inputs(fields: fields, size: size, order: order, missing: missing, agg: agg)
  FacetQuery.build(
    relation: self,
    primary_key: paradedb_runtime_key_field,
    builder: builder,
    fields: facet_args[:fields],
    size: facet_args[:size],
    order: facet_args[:order],
    missing: facet_args[:missing],
    agg: facet_args[:agg],
    exact: exact,
    connection: connection
  )
end

#builderObject



89
90
91
92
93
94
# File 'lib/parade_db/search_methods.rb', line 89

def builder
  @_paradedb_builder ||= begin
    ensure_paradedb_runtime!
    QueryBuilder.new(table_name)
  end
end

#ensure_paradedb_predicateObject



435
436
437
438
# File 'lib/parade_db/search_methods.rb', line 435

def ensure_paradedb_predicate
  # Add pdb.all() sentinel to force aggregate pushdown
  where(grouped(builder.match_all(paradedb_runtime_key_field)))
end

#excluding(term) ⇒ Object



137
138
139
140
141
142
# File 'lib/parade_db/search_methods.rb', line 137

def excluding(term)
  require_search_field!

  neg = builder.match(_paradedb_current_field, term)
  where(grouped(neg.not))
end

#existsObject

Exists wrapper to match rows where the indexed field has a value. Use with .search(:id) (or another exists-compatible indexed field).



216
217
218
219
220
# File 'lib/parade_db/search_methods.rb', line 216

def exists
  require_search_field!

  where(grouped(builder.exists(_paradedb_current_field)))
end

#facets(*fields, size: 10, order: :count_desc, missing: nil, agg: nil, exact: nil) ⇒ Object

---- Facets ----



309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/parade_db/search_methods.rb', line 309

def facets(*fields, size: 10, order: :count_desc, missing: nil, agg: nil, exact: nil)
  ensure_paradedb_runtime!
  validate_exact_option!(exact)
  if exact == false
    raise ArgumentError, "facets(exact: false) requires with_facets so aggregation runs as a window function"
  end

  build_facet_query(
    fields: fields,
    size: size,
    order: order,
    missing: missing,
    agg: agg,
    exact: exact
  ).execute
end

#facets_agg(exact: nil, **named_aggregations) ⇒ Object



326
327
328
329
330
# File 'lib/parade_db/search_methods.rb', line 326

def facets_agg(exact: nil, **named_aggregations)
  validate_exact_option!(exact)
  agg_specs = normalize_named_aggregation_specs(named_aggregations)
  build_aggregation_query(agg_specs, exact: exact).execute
end

#has_paradedb_predicate?Boolean

Returns:

  • (Boolean)


431
432
433
# File 'lib/parade_db/search_methods.rb', line 431

def has_paradedb_predicate?
  PredicateInspector.relation_has_paradedb_predicate?(self)
end

#match_all(term = nil) ⇒ Object



118
119
120
121
122
123
124
125
126
127
128
# File 'lib/parade_db/search_methods.rb', line 118

def match_all(term = nil)
  require_search_field!

  node =
    if term.nil?
      builder.match_all(_paradedb_current_field)
    else
      builder.match(_paradedb_current_field, term)
    end
  where(grouped(node))
end

#match_any(term) ⇒ Object



130
131
132
133
134
135
# File 'lib/parade_db/search_methods.rb', line 130

def match_any(term)
  require_search_field!

  node = builder.match_any(_paradedb_current_field, term)
  where(grouped(node))
end

#more_like_this(key, fields: nil, **options) ⇒ Object



247
248
249
250
251
252
253
254
255
# File 'lib/parade_db/search_methods.rb', line 247

def more_like_this(key, fields: nil, **options)
  ensure_paradedb_runtime!
  runtime_key_field = paradedb_runtime_key_field
  key_value = more_like_this_key_value(key, runtime_key_field)
  pk_node = builder[runtime_key_field]
  mlt_options = normalize_more_like_this_options(options)
  node = builder.more_like_this(pk_node, key_value, fields: fields, options: mlt_options)
  where(grouped(node))
end

#near(proximity) ⇒ Object



184
185
186
187
188
189
# File 'lib/parade_db/search_methods.rb', line 184

def near(proximity)
  require_search_field!

  node = builder.near(_paradedb_current_field, proximity)
  where(grouped(node))
end

#nearest(column, vector, metric: nil) ⇒ Object

Orders by vector distance for Top-K pushdown inside the ParadeDB index. Adds key_field @@@ pdb.all() when the relation has no ParadeDB predicate, since vector ordering requires a @@@ predicate to activate the index scan. Callers must add .limit(k); the metric defaults to the index opclass metric.



422
423
424
425
426
427
428
429
# File 'lib/parade_db/search_methods.rb', line 422

def nearest(column, vector, metric: nil)
  ensure_paradedb_runtime!
  resolved_metric = metric || index_vector_metric(column) || ParadeDB::Vector::DEFAULT_METRIC
  node = builder.vector_distance(column, vector, metric: resolved_metric)

  rel = has_paradedb_predicate? ? self : ensure_paradedb_predicate
  rel.order(node.asc)
end

#parse(query, lenient: nil, conjunction_mode: nil) ⇒ Object

Parse query-string syntax into ParadeDB query AST (e.g. "running AND shoes").



203
204
205
206
207
208
209
210
211
212
# File 'lib/parade_db/search_methods.rb', line 203

def parse(query, lenient: nil, conjunction_mode: nil)
  require_search_field!
  node = builder.parse(
    _paradedb_current_field,
    query,
    lenient: lenient,
    conjunction_mode: conjunction_mode
  )
  where(grouped(node))
end

#phrase(text) ⇒ Object



144
145
146
147
148
149
# File 'lib/parade_db/search_methods.rb', line 144

def phrase(text)
  require_search_field!

  node = builder.phrase(_paradedb_current_field, text)
  where(grouped(node))
end

#phrase_prefix(*terms, max_expansion: nil) ⇒ Object



191
192
193
194
195
196
197
198
199
200
# File 'lib/parade_db/search_methods.rb', line 191

def phrase_prefix(*terms, max_expansion: nil)
  require_search_field!

  node = builder.phrase_prefix(
    _paradedb_current_field,
    *terms,
    max_expansion: max_expansion
  )
  where(grouped(node))
end

#primary_keyObject



100
101
102
# File 'lib/parade_db/search_methods.rb', line 100

def primary_key
  klass.primary_key || :id
end

#range(value = nil, gte: nil, gt: nil, lte: nil, lt: nil, type: nil) ⇒ Object

Range wrapper for numeric/date/timestamp fields in ParadeDB query context. Examples:

Product.search(:rating).range(3..5)
Product.search(:rating).range(gte: 3, lt: 5)


226
227
228
229
230
231
232
# File 'lib/parade_db/search_methods.rb', line 226

def range(value = nil, gte: nil, gt: nil, lte: nil, lt: nil, type: nil)
  require_search_field!

  inferred_type = type || default_range_type_for_field(_paradedb_current_field)
  node = builder.range(_paradedb_current_field, value, gte: gte, gt: gt, lte: lte, lt: lt, type: inferred_type)
  where(grouped(node))
end

#range_term(value, relation: nil, range_type: nil) ⇒ Object



234
235
236
237
238
239
240
241
242
243
244
245
# File 'lib/parade_db/search_methods.rb', line 234

def range_term(value, relation: nil, range_type: nil)
  require_search_field!

  inferred_range_type = range_type || (relation && infer_range_type_for_field(_paradedb_current_field))
  node = builder.range_term(
    _paradedb_current_field,
    value,
    relation: relation,
    range_type: inferred_range_type
  )
  where(grouped(node))
end

#regex(pattern) ⇒ Object



151
152
153
154
155
156
# File 'lib/parade_db/search_methods.rb', line 151

def regex(pattern)
  require_search_field!

  node = builder.regex(_paradedb_current_field, pattern)
  where(grouped(node))
end

#regex_phrase(*patterns, slop: nil, max_expansions: nil) ⇒ Object



158
159
160
161
162
163
164
165
166
167
168
# File 'lib/parade_db/search_methods.rb', line 158

def regex_phrase(*patterns, slop: nil, max_expansions: nil)
  require_search_field!

  node = builder.regex_phrase(
    _paradedb_current_field,
    *patterns,
    slop: slop,
    max_expansions: max_expansions
  )
  where(grouped(node))
end

#search(column) ⇒ Object

---- ParadeDB search entrypoints ----



106
107
108
109
110
111
112
113
114
115
116
# File 'lib/parade_db/search_methods.rb', line 106

def search(column)
  ensure_paradedb_runtime!
  search_column =
    if (column.is_a?(Symbol) || column.instance_of?(String)) &&
       klass.respond_to?(:paradedb_normalize_search_column, true)
      klass.send(:paradedb_normalize_search_column, column)
    else
      column
    end
  extending(SearchMethods).tap { |rel| rel._paradedb_current_field = search_column }
end

#table_nameObject



96
97
98
# File 'lib/parade_db/search_methods.rb', line 96

def table_name
  klass.table_name
end

#term(value) ⇒ Object



170
171
172
173
174
175
# File 'lib/parade_db/search_methods.rb', line 170

def term(value)
  require_search_field!

  node = builder.term(_paradedb_current_field, value)
  where(grouped(node))
end

#term_set(*values) ⇒ Object



177
178
179
180
181
182
# File 'lib/parade_db/search_methods.rb', line 177

def term_set(*values)
  require_search_field!

  node = builder.term_set(_paradedb_current_field, *values)
  where(grouped(node))
end

#with_agg(exact: nil, **named_aggregations) ⇒ Object



380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
# File 'lib/parade_db/search_methods.rb', line 380

def with_agg(exact: nil, **named_aggregations)
  ensure_paradedb_runtime!
  validate_exact_option!(exact)
  agg_specs = normalize_named_aggregation_specs(named_aggregations)
  rel = extending(FacetRelation, AggregationRelation)
  rel._paradedb_facet_fields = agg_specs.keys

  unless rel.has_paradedb_predicate?
    rel = rel.ensure_paradedb_predicate
  end

  facet_selects = agg_specs.map do |alias_name, agg_spec|
    render_aggregation_node(agg_spec, exact: exact).over.as("_#{alias_name}_facet")
  end

  rel = rel.select(klass.arel_table[::Arel.star]) if rel.select_values.empty?
  rel.select(*facet_selects)
end

#with_facets(*fields, size: 10, order: :count_desc, missing: nil, agg: nil, exact: nil) ⇒ Object



350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# File 'lib/parade_db/search_methods.rb', line 350

def with_facets(*fields, size: 10, order: :count_desc, missing: nil, agg: nil, exact: nil)
  ensure_paradedb_runtime!
  validate_exact_option!(exact)
  facet_args = normalize_facet_inputs(fields: fields, size: size, order: order, missing: missing, agg: agg)
  opts = {
    size: facet_args[:size],
    order: facet_args[:order],
    missing: facet_args[:missing],
    agg: facet_args[:agg]
  }
  facet_fields = facet_args[:agg].nil? ? facet_args[:fields] : [:agg]

  rel = extending(FacetRelation)
  rel._paradedb_facet_fields = facet_fields

  # Add pdb.all() if no ParadeDB predicates exist (for aggregate pushdown)
  unless rel.has_paradedb_predicate?
    rel = rel.ensure_paradedb_predicate
  end

  # Add window aggregates to SELECT using native Arel nodes.
  facet_selects = facet_fields.map do |field|
    json = facet_args[:agg] || facet_json(field, opts)
    builder.agg(json, exact: exact).over.as("_#{field}_facet")
  end

  rel = rel.select(klass.arel_table[::Arel.star]) if rel.select_values.empty?
  rel.select(*facet_selects)
end

#with_scoreObject

---- Decorators ----



259
260
261
# File 'lib/parade_db/search_methods.rb', line 259

def with_score
  with_projection(builder.score(paradedb_runtime_key_field).as("search_score"))
end

#with_snippet(column, start_tag: nil, end_tag: nil, max_chars: nil) ⇒ Object



263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/parade_db/search_methods.rb', line 263

def with_snippet(column, start_tag: nil, end_tag: nil, max_chars: nil)
  formatted_args = []
  formatted_args << start_tag unless start_tag.nil?
  formatted_args << end_tag unless end_tag.nil?
  formatted_args << Integer(max_chars) unless max_chars.nil?

  snippet =
    if formatted_args.empty?
      builder.snippet(column)
    else
      builder.snippet(column, *formatted_args)
    end

  with_projection(snippet.as("#{column}_snippet"))
end

#with_snippet_positions(column, as: nil) ⇒ Object



302
303
304
305
# File 'lib/parade_db/search_methods.rb', line 302

def with_snippet_positions(column, as: nil)
  positions = builder.snippet_positions(column)
  with_projection(positions.as(normalize_projection_alias(as, "#{column}_snippet_positions")))
end

#with_snippets(column, start_tag: nil, end_tag: nil, max_chars: nil, limit: nil, offset: nil, sort_by: nil, as: nil) ⇒ Object



279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
# File 'lib/parade_db/search_methods.rb', line 279

def with_snippets(
  column,
  start_tag: nil,
  end_tag: nil,
  max_chars: nil,
  limit: nil,
  offset: nil,
  sort_by: nil,
  as: nil
)
  snippets = builder.snippets(
    column,
    start_tag: start_tag,
    end_tag: end_tag,
    max_num_chars: normalize_integer_option!(max_chars, "max_chars"),
    limit: normalize_integer_option!(limit, "limit"),
    offset: normalize_integer_option!(offset, "offset"),
    sort_by: normalize_snippets_sort_by(sort_by)
  )

  with_projection(snippets.as(normalize_projection_alias(as, "#{column}_snippets")))
end