noiseless 0.7.2 → 0.8.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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/lib/noiseless/adapter.rb +28 -35
  4. data/lib/noiseless/adapters/elasticsearch.rb +0 -1
  5. data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +8 -6
  6. data/lib/noiseless/adapters/execution_modules/es_compatible_execution.rb +65 -17
  7. data/lib/noiseless/adapters/execution_modules/http_transport.rb +29 -18
  8. data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +102 -114
  9. data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +42 -47
  10. data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +63 -324
  11. data/lib/noiseless/adapters/execution_modules/postgresql_query.rb +269 -0
  12. data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +172 -296
  13. data/lib/noiseless/adapters/execution_modules/typesense_query.rb +143 -0
  14. data/lib/noiseless/adapters/indices_api.rb +3 -1
  15. data/lib/noiseless/adapters/open_search.rb +30 -88
  16. data/lib/noiseless/adapters/postgresql.rb +5 -5
  17. data/lib/noiseless/adapters/typesense.rb +1 -2
  18. data/lib/noiseless/ast/aggregation.rb +0 -23
  19. data/lib/noiseless/ast/aggregation_builder.rb +28 -0
  20. data/lib/noiseless/ast/hybrid.rb +4 -2
  21. data/lib/noiseless/ast/join.rb +4 -5
  22. data/lib/noiseless/ast/vector.rb +25 -4
  23. data/lib/noiseless/bulk_importer.rb +16 -27
  24. data/lib/noiseless/callbacks.rb +4 -30
  25. data/lib/noiseless/connection_manager.rb +9 -3
  26. data/lib/noiseless/controller_runtime.rb +37 -0
  27. data/lib/noiseless/document_manager.rb +21 -36
  28. data/lib/noiseless/embedding.rb +33 -0
  29. data/lib/noiseless/instrumentation.rb +2 -136
  30. data/lib/noiseless/introspection/mermaid_diagrams.rb +143 -0
  31. data/lib/noiseless/introspection/query_visualizer.rb +3 -228
  32. data/lib/noiseless/introspection.rb +1 -1
  33. data/lib/noiseless/log_subscriber.rb +103 -0
  34. data/lib/noiseless/model.rb +2 -2
  35. data/lib/noiseless/multi_search.rb +2 -4
  36. data/lib/noiseless/pagination.rb +3 -3
  37. data/lib/noiseless/query_builder.rb +5 -4
  38. data/lib/noiseless/railtie.rb +0 -2
  39. data/lib/noiseless/response/records.rb +2 -1
  40. data/lib/noiseless/runtime_reset_middleware.rb +1 -1
  41. data/lib/noiseless/search_index_update_job.rb +13 -14
  42. data/lib/noiseless/test_case.rb +0 -1
  43. data/lib/noiseless/version.rb +1 -1
  44. data/lib/noiseless.rb +1 -10
  45. metadata +31 -13
  46. data/lib/tasks/benchmark.rake +0 -35
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "pgvector_support"
4
3
 
5
4
  module Noiseless
6
5
  module Adapters
@@ -10,61 +9,84 @@ module Noiseless
10
9
  # and optionally pgvector for semantic search
11
10
  module PostgresqlExecution
12
11
  include PgvectorSupport
13
-
14
- SIMILARITY_THRESHOLD = 0.3
15
- DEFAULT_LIMIT = 20
12
+ include PostgresqlQuery
16
13
 
17
14
  private
18
15
 
19
16
  def execute_search(query_hash, model_class: nil, **)
20
17
  model = resolve_model(query_hash[:indexes], model_class)
21
- return empty_response unless model
18
+ raise Noiseless::SearchError, "no ActiveRecord model for index #{query_hash[:indexes]&.first.inspect}" unless model
19
+
20
+ with_search_errors do
21
+ next execute_vector_search(model, query_hash) if query_hash[:vector] || query_hash[:hybrid]
22
22
 
23
- # Check if this is a vector search
24
- return execute_vector_search(model, query_hash) if query_hash[:vector]
23
+ scope = build_search_scope(model, query_hash)
24
+ total = scope.except(:order, :limit, :offset).count
25
+ records = apply_pagination(scope, query_hash[:paginate]).to_a
25
26
 
26
- scope = build_search_scope(model, query_hash)
27
- total = scope.except(:order, :limit, :offset).count
28
- records = apply_pagination(scope, query_hash[:paginate]).to_a
27
+ format_as_search_response(records, model, total: total)
28
+ end
29
+ end
29
30
 
30
- format_as_search_response(records, model, total: total)
31
+ def with_search_errors
32
+ yield
33
+ rescue ActiveRecord::ConnectionNotEstablished => e
34
+ raise Noiseless::ConnectionError, "postgresql unreachable: #{e.message}"
35
+ rescue Noiseless::Error
36
+ raise
31
37
  rescue StandardError => e
32
- error_response(e)
38
+ raise Noiseless::SearchError, "postgresql search failed: #{e.message}"
33
39
  end
34
40
 
35
41
  def execute_vector_search(model, query_hash)
36
- vector_node = query_hash[:vector]
37
- return empty_response unless vector_node && pgvector_available?
38
-
39
- # Start with base scope
40
- scope = model.all
41
-
42
- # Apply any filters first
43
- scope = apply_filter_clauses(scope, query_hash[:bool]&.filter || [], model)
44
-
45
- # Apply vector search
46
- scope = vector_search(
47
- scope,
48
- vector_node.embedding,
49
- column: vector_node.field,
50
- limit: vector_node.k,
51
- distance_metric: vector_node.distance_metric
52
- )
53
-
54
- records = scope.to_a
55
- format_vector_response(records, model, vector_node)
56
- rescue StandardError => e
57
- error_response(e)
42
+ raise Noiseless::SearchError, "vector search requires the pgvector extension" unless pgvector_available?
43
+
44
+ scope = apply_filter_clauses(model.all, query_hash[:bool]&.filter || [], model)
45
+ neighbors, k = nearest(scope, query_hash)
46
+
47
+ paginate_node = query_hash[:paginate]
48
+ return format_vector_response(neighbors.to_a, model) unless paginate_node
49
+
50
+ records = paginate_neighbors(neighbors, paginate_node, k).to_a
51
+ format_vector_response(records, model, total: [scope.count, k].min)
58
52
  end
59
53
 
60
- def format_vector_response(records, model, _vector_node)
54
+ def nearest(scope, query_hash)
55
+ if (hybrid = query_hash[:hybrid])
56
+ vector = hybrid.vector
57
+ neighbors = hybrid_search(scope, text_query: hybrid.text_query, embedding: vector.embedding, text_fields: hybrid.fields,
58
+ vector_column: vector.field, text_weight: hybrid.text_weight,
59
+ vector_weight: hybrid.vector_weight, limit: vector.k)
60
+ return [neighbors, vector.k]
61
+ end
62
+
63
+ vector = query_hash[:vector]
64
+ [vector_search(scope, vector.embedding, column: vector.field, limit: vector.k, distance_metric: vector.distance_metric),
65
+ vector.k]
66
+ end
67
+
68
+ def paginate_neighbors(scope, paginate_node, k)
69
+ page = paginate_node.page || 1
70
+ per_page = paginate_node.per_page || DEFAULT_LIMIT
71
+ offset = (page - 1) * per_page
72
+ limit = (k - offset).clamp(0, per_page)
73
+ return scope.none if limit.zero?
74
+
75
+ scope.limit(limit).offset(offset)
76
+ end
77
+
78
+ def format_vector_response(records, model, total: records.size)
61
79
  hits = records.map do |record|
62
- distance = record.respond_to?(:vector_distance) ? record.vector_distance : 0
80
+ score = if record.has_attribute?(:combined_score)
81
+ record.combined_score
82
+ else
83
+ 1.0 - (record.respond_to?(:vector_distance) ? record.vector_distance : 0)
84
+ end
63
85
  {
64
86
  "_index" => model.table_name,
65
87
  "_id" => record.id.to_s,
66
- "_score" => 1.0 - distance, # Convert distance to similarity score
67
- "_source" => record.as_json(except: [:vector_distance])
88
+ "_score" => score,
89
+ "_source" => record.as_json(except: %w[vector_distance combined_score])
68
90
  }
69
91
  end
70
92
 
@@ -73,7 +95,7 @@ module Noiseless
73
95
  "timed_out" => false,
74
96
  "_shards" => { "total" => 1, "successful" => 1, "skipped" => 0, "failed" => 0 },
75
97
  "hits" => {
76
- "total" => { "value" => hits.size, "relation" => "eq" },
98
+ "total" => { "value" => total, "relation" => "eq" },
77
99
  "max_score" => hits.first&.dig("_score"),
78
100
  "hits" => hits
79
101
  }
@@ -101,8 +123,6 @@ module Noiseless
101
123
  def execute_index_exists?(index_name)
102
124
  model = resolve_model([index_name])
103
125
  model.present? && model.table_exists?
104
- rescue StandardError
105
- false
106
126
  end
107
127
 
108
128
  # Document writes are no-ops: the table IS the index, so queries always
@@ -124,8 +144,6 @@ module Noiseless
124
144
  def execute_document_exists?(index, id)
125
145
  model = resolve_model([index])
126
146
  model&.exists?(id: id) || false
127
- rescue StandardError
128
- false
129
147
  end
130
148
 
131
149
  def execute_cluster_health(**)
@@ -144,203 +162,6 @@ module Noiseless
144
162
  }
145
163
  end
146
164
 
147
- # Query building methods
148
-
149
- def build_search_scope(model, query_hash)
150
- scope = model.all
151
-
152
- # Apply must clauses (full-text search)
153
- scope = apply_must_clauses(scope, query_hash[:bool]&.must || [], model)
154
-
155
- # Apply filter clauses (exact matches)
156
- scope = apply_filter_clauses(scope, query_hash[:bool]&.filter || [], model)
157
-
158
- # Apply sorting (pagination is applied by the caller, after counting)
159
- apply_sorting(scope, query_hash[:sort] || [], model)
160
- end
161
-
162
- def apply_must_clauses(scope, must_nodes, model)
163
- return scope if must_nodes.empty?
164
-
165
- must_nodes.each do |node|
166
- scope = case node
167
- when AST::Match
168
- apply_match(scope, node, model)
169
- when AST::MultiMatch
170
- apply_multi_match(scope, node, model)
171
- when AST::Wildcard
172
- apply_wildcard(scope, node, model)
173
- when AST::Range
174
- apply_range(scope, node, model)
175
- when AST::Prefix
176
- apply_prefix(scope, node, model)
177
- else
178
- scope
179
- end
180
- end
181
-
182
- scope
183
- end
184
-
185
- def apply_match(scope, node, model)
186
- field = node.field.to_s
187
- value = node.value.to_s
188
-
189
- # Mapping-only fields (denormalized into a search index, not columns)
190
- # cannot be satisfied here; fail closed rather than matching everything.
191
- return scope.none unless column?(model, field)
192
-
193
- # Use pg_trgm similarity for fuzzy matching, accent-insensitive when
194
- # the unaccent extension is present.
195
- if trgm_available? && text_column?(model, field)
196
- scope.where(
197
- "#{fuzzy_column(field)} % #{fuzzy_param} OR " \
198
- "#{fuzzy_column(field)} ILIKE #{fuzzy_param}",
199
- value,
200
- "%#{sanitize_like(value)}%"
201
- )
202
- else
203
- # Fallback to ILIKE
204
- scope.where("#{quoted_column(field)} ILIKE ?", "%#{sanitize_like(value)}%")
205
- end
206
- end
207
-
208
- def apply_multi_match(scope, node, model)
209
- query = node.query.to_s
210
- # Drop mapping-only fields; if none of the requested fields are real
211
- # columns the query cannot be satisfied — fail closed.
212
- fields = node.fields.map(&:to_s).select { |field| column?(model, field) }
213
- return scope.none if fields.empty?
214
-
215
- conditions = fields.map do |field|
216
- if trgm_available? && text_column?(model, field)
217
- "(#{fuzzy_column(field)} % #{fuzzy_param} OR " \
218
- "#{fuzzy_column(field)} ILIKE #{fuzzy_param})"
219
- else
220
- "#{quoted_column(field)} ILIKE ?"
221
- end
222
- end
223
-
224
- params = fields.flat_map do |field|
225
- if trgm_available? && text_column?(model, field)
226
- [query, "%#{sanitize_like(query)}%"]
227
- else
228
- ["%#{sanitize_like(query)}%"]
229
- end
230
- end
231
-
232
- scope.where(conditions.join(" OR "), *params)
233
- end
234
-
235
- def apply_wildcard(scope, node, model = nil)
236
- field = node.field.to_s
237
- return scope.none if model && !column?(model, field)
238
-
239
- # Convert OpenSearch wildcards to SQL: * -> %, ? -> _
240
- pattern = node.value.to_s.tr("*", "%").tr("?", "_")
241
-
242
- scope.where("#{quoted_column(field)} ILIKE ?", pattern)
243
- end
244
-
245
- def apply_range(scope, node, model = nil)
246
- return scope.none if model && !column?(model, node.field.to_s)
247
-
248
- field = quoted_column(node.field.to_s)
249
-
250
- scope = scope.where("#{field} >= ?", node.gte) if node.gte
251
- scope = scope.where("#{field} <= ?", node.lte) if node.lte
252
- scope = scope.where("#{field} > ?", node.gt) if node.gt
253
- scope = scope.where("#{field} < ?", node.lt) if node.lt
254
-
255
- scope
256
- end
257
-
258
- def apply_prefix(scope, node, model = nil)
259
- return scope.none if model && !column?(model, node.field.to_s)
260
-
261
- scope.where("#{quoted_column(node.field.to_s)} ILIKE ?", "#{sanitize_like(node.value)}%")
262
- end
263
-
264
- def apply_filter_clauses(scope, filter_nodes, model = nil)
265
- return scope if filter_nodes.empty?
266
-
267
- filter_nodes.each do |node|
268
- value = node.value
269
-
270
- scope = if value.is_a?(Hash) && value[:geo_distance]
271
- apply_geo_filter(scope, node)
272
- elsif model && !column?(model, node.field.to_s)
273
- # A filter on a mapping-only field cannot be enforced;
274
- # silently dropping it would broaden results, so fail closed.
275
- scope.none
276
- elsif model && array_column?(model, node.field.to_s)
277
- apply_array_filter(scope, node.field.to_s, value, model)
278
- else
279
- scope.where(node.field => value)
280
- end
281
- end
282
-
283
- scope
284
- end
285
-
286
- def apply_array_filter(scope, field, value, model)
287
- cast = "#{model.columns_hash[field].sql_type.sub(/\[\]\z/, '')}[]"
288
- operator = value.is_a?(Array) ? "&&" : "@>"
289
-
290
- scope.where("#{quoted_column(field)} #{operator} ARRAY[?]::#{cast}", value)
291
- end
292
-
293
- def apply_geo_filter(scope, node)
294
- # Requires PostGIS
295
- geo_config = node.value[:geo_distance]
296
- distance = geo_config[:distance]
297
- field = node.field.to_s
298
-
299
- # Find the geo point in config
300
- geo_point = geo_config.find { |_k, v| v.is_a?(Hash) && v[:lat] && v[:lon] }&.last
301
- return scope unless geo_point
302
-
303
- # Use PostGIS ST_DWithin for efficient geo filtering
304
- scope.where(
305
- "ST_DWithin(#{field}::geography, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)",
306
- geo_point[:lon],
307
- geo_point[:lat],
308
- parse_distance(distance)
309
- )
310
- rescue StandardError
311
- # If PostGIS not available, skip geo filter
312
- scope
313
- end
314
-
315
- def apply_sorting(scope, sort_nodes, model = nil)
316
- sorted_fields = []
317
- order_clauses = sort_nodes.filter_map do |node|
318
- field = node.field.to_s
319
- # Sorting on a mapping-only field is cosmetic — drop it instead of
320
- # erroring the whole query.
321
- next if model && !column?(model, field)
322
-
323
- sorted_fields << field
324
- direction = node.direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
325
- "#{quoted_column(field)} #{direction}"
326
- end
327
-
328
- primary_key = model.respond_to?(:primary_key) ? model.primary_key : nil
329
- order_clauses << "#{quoted_column(primary_key)} ASC" if primary_key && !sorted_fields.include?(primary_key.to_s)
330
- return scope if order_clauses.empty?
331
-
332
- scope.order(Arel.sql(order_clauses.join(", ")))
333
- end
334
-
335
- def apply_pagination(scope, paginate_node)
336
- page = paginate_node&.page || 1
337
- per_page = paginate_node&.per_page || DEFAULT_LIMIT
338
-
339
- offset = (page - 1) * per_page
340
-
341
- scope.limit(per_page).offset(offset)
342
- end
343
-
344
165
  # Response formatting
345
166
 
346
167
  def format_as_search_response(records, model, total: records.size)
@@ -365,33 +186,6 @@ module Noiseless
365
186
  }
366
187
  end
367
188
 
368
- def empty_response
369
- {
370
- "took" => 0,
371
- "timed_out" => false,
372
- "_shards" => { "total" => 1, "successful" => 1, "skipped" => 0, "failed" => 0 },
373
- "hits" => {
374
- "total" => { "value" => 0, "relation" => "eq" },
375
- "max_score" => nil,
376
- "hits" => []
377
- }
378
- }
379
- end
380
-
381
- def error_response(error)
382
- {
383
- "took" => 0,
384
- "timed_out" => false,
385
- "_shards" => { "total" => 1, "successful" => 0, "skipped" => 0, "failed" => 1 },
386
- "hits" => {
387
- "total" => { "value" => 0, "relation" => "eq" },
388
- "max_score" => nil,
389
- "hits" => []
390
- },
391
- "error" => { "type" => error.class.name, "reason" => error.message }
392
- }
393
- end
394
-
395
189
  # Helper methods
396
190
 
397
191
  def resolve_model(indexes, model_class = nil)
@@ -406,69 +200,14 @@ module Noiseless
406
200
  # Try cached model first (populated via register_model)
407
201
  return @model_class_cache[index_name] if @model_class_cache&.key?(index_name)
408
202
 
409
- # Try to infer model from index name
410
- model_name = index_name.to_s.classify
411
- model_name.constantize
412
- rescue NameError
413
- nil
203
+ candidate = index_name.to_s.classify.safe_constantize
204
+ candidate if active_record_model?(candidate)
414
205
  end
415
206
 
416
207
  def active_record_model?(klass)
417
208
  klass.is_a?(Class) && klass < ActiveRecord::Base
418
209
  end
419
210
 
420
- def trgm_available?
421
- @trgm_available ||= available_extensions.include?("pg_trgm")
422
- end
423
-
424
- def unaccent_available?
425
- @unaccent_available ||= available_extensions.include?("unaccent")
426
- end
427
-
428
- def column?(model, field)
429
- model.columns_hash.key?(field.to_s)
430
- end
431
-
432
- def text_column?(model, field)
433
- column = model.columns_hash[field.to_s]
434
- column && %i[string text citext].include?(column.type) && !column.array
435
- end
436
-
437
- def array_column?(model, field)
438
- model.columns_hash[field.to_s]&.array
439
- end
440
-
441
- def fuzzy_column(field)
442
- unaccent_available? ? "unaccent(#{quoted_column(field)})" : quoted_column(field)
443
- end
444
-
445
- def fuzzy_param
446
- unaccent_available? ? "unaccent(?)" : "?"
447
- end
448
-
449
- def quoted_column(field)
450
- ActiveRecord::Base.connection.quote_column_name(field)
451
- end
452
-
453
- def sanitize_like(value)
454
- # Escape special LIKE characters
455
- value.to_s.gsub(/[%_\\]/) { |x| "\\#{x}" }
456
- end
457
-
458
- def parse_distance(distance)
459
- # Parse OpenSearch distance format (e.g., "10km", "5mi")
460
- case distance.to_s
461
- when /(\d+(?:\.\d+)?)\s*km/i
462
- ::Regexp.last_match(1).to_f * 1000
463
- when /(\d+(?:\.\d+)?)\s*mi/i
464
- ::Regexp.last_match(1).to_f * 1609.34
465
- when /(\d+(?:\.\d+)?)\s*m/i
466
- ::Regexp.last_match(1).to_f
467
- else
468
- distance.to_f
469
- end
470
- end
471
-
472
211
  def process_bulk_action(action)
473
212
  if action[:index]
474
213
  { "index" => execute_index_document(action[:index][:_index], action[:index][:_id], nil) }