noiseless 0.7.1 → 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 -293
  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)
52
+ end
53
+
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)
58
76
  end
59
77
 
60
- def format_vector_response(records, model, _vector_node)
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,184 +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)
173
- when AST::Range
174
- apply_range(scope, node)
175
- when AST::Prefix
176
- apply_prefix(scope, node)
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 with unaccent
194
- if trgm_available? && text_column?(model, field)
195
- scope.where(
196
- "unaccent(#{quoted_column(field)}) % unaccent(?) OR " \
197
- "unaccent(#{quoted_column(field)}) ILIKE unaccent(?)",
198
- value,
199
- "%#{sanitize_like(value)}%"
200
- )
201
- else
202
- # Fallback to ILIKE
203
- scope.where("#{quoted_column(field)} ILIKE ?", "%#{sanitize_like(value)}%")
204
- end
205
- end
206
-
207
- def apply_multi_match(scope, node, model)
208
- query = node.query.to_s
209
- # Drop mapping-only fields; if none of the requested fields are real
210
- # columns the query cannot be satisfied — fail closed.
211
- fields = node.fields.map(&:to_s).select { |field| column?(model, field) }
212
- return scope.none if fields.empty?
213
-
214
- conditions = fields.map do |field|
215
- if trgm_available? && text_column?(model, field)
216
- "(unaccent(#{quoted_column(field)}) % unaccent(?) OR " \
217
- "unaccent(#{quoted_column(field)}) ILIKE unaccent(?))"
218
- else
219
- "#{quoted_column(field)} ILIKE ?"
220
- end
221
- end
222
-
223
- params = fields.flat_map do |field|
224
- if trgm_available? && text_column?(model, field)
225
- [query, "%#{sanitize_like(query)}%"]
226
- else
227
- ["%#{sanitize_like(query)}%"]
228
- end
229
- end
230
-
231
- scope.where(conditions.join(" OR "), *params)
232
- end
233
-
234
- def apply_wildcard(scope, node)
235
- field = node.field.to_s
236
- # Convert OpenSearch wildcards to SQL: * -> %, ? -> _
237
- pattern = node.value.to_s.tr("*", "%").tr("?", "_")
238
-
239
- scope.where("#{quoted_column(field)} ILIKE ?", pattern)
240
- end
241
-
242
- def apply_range(scope, node)
243
- field = quoted_column(node.field.to_s)
244
-
245
- scope = scope.where("#{field} >= ?", node.gte) if node.gte
246
- scope = scope.where("#{field} <= ?", node.lte) if node.lte
247
- scope = scope.where("#{field} > ?", node.gt) if node.gt
248
- scope = scope.where("#{field} < ?", node.lt) if node.lt
249
-
250
- scope
251
- end
252
-
253
- def apply_prefix(scope, node)
254
- scope.where("#{quoted_column(node.field.to_s)} ILIKE ?", "#{sanitize_like(node.value)}%")
255
- end
256
-
257
- def apply_filter_clauses(scope, filter_nodes, model = nil)
258
- return scope if filter_nodes.empty?
259
-
260
- filter_nodes.each do |node|
261
- value = node.value
262
-
263
- scope = if value.is_a?(Hash) && value[:geo_distance]
264
- apply_geo_filter(scope, node)
265
- elsif model && !column?(model, node.field.to_s)
266
- # A filter on a mapping-only field cannot be enforced;
267
- # silently dropping it would broaden results, so fail closed.
268
- scope.none
269
- else
270
- scope.where(node.field => value)
271
- end
272
- end
273
-
274
- scope
275
- end
276
-
277
- def apply_geo_filter(scope, node)
278
- # Requires PostGIS
279
- geo_config = node.value[:geo_distance]
280
- distance = geo_config[:distance]
281
- field = node.field.to_s
282
-
283
- # Find the geo point in config
284
- geo_point = geo_config.find { |_k, v| v.is_a?(Hash) && v[:lat] && v[:lon] }&.last
285
- return scope unless geo_point
286
-
287
- # Use PostGIS ST_DWithin for efficient geo filtering
288
- scope.where(
289
- "ST_DWithin(#{field}::geography, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)",
290
- geo_point[:lon],
291
- geo_point[:lat],
292
- parse_distance(distance)
293
- )
294
- rescue StandardError
295
- # If PostGIS not available, skip geo filter
296
- scope
297
- end
298
-
299
- def apply_sorting(scope, sort_nodes, model = nil)
300
- return scope if sort_nodes.empty?
301
-
302
- order_clauses = sort_nodes.filter_map do |node|
303
- field = node.field.to_s
304
- # Sorting on a mapping-only field is cosmetic — drop it instead of
305
- # erroring the whole query.
306
- next if model && !column?(model, field)
307
-
308
- direction = node.direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
309
- "#{quoted_column(field)} #{direction}"
310
- end
311
- return scope if order_clauses.empty?
312
-
313
- scope.order(Arel.sql(order_clauses.join(", ")))
314
- end
315
-
316
- def apply_pagination(scope, paginate_node)
317
- page = paginate_node&.page || 1
318
- per_page = paginate_node&.per_page || DEFAULT_LIMIT
319
-
320
- offset = (page - 1) * per_page
321
-
322
- scope.limit(per_page).offset(offset)
323
- end
324
-
325
165
  # Response formatting
326
166
 
327
167
  def format_as_search_response(records, model, total: records.size)
@@ -346,33 +186,6 @@ module Noiseless
346
186
  }
347
187
  end
348
188
 
349
- def empty_response
350
- {
351
- "took" => 0,
352
- "timed_out" => false,
353
- "_shards" => { "total" => 1, "successful" => 1, "skipped" => 0, "failed" => 0 },
354
- "hits" => {
355
- "total" => { "value" => 0, "relation" => "eq" },
356
- "max_score" => nil,
357
- "hits" => []
358
- }
359
- }
360
- end
361
-
362
- def error_response(error)
363
- {
364
- "took" => 0,
365
- "timed_out" => false,
366
- "_shards" => { "total" => 1, "successful" => 0, "skipped" => 0, "failed" => 1 },
367
- "hits" => {
368
- "total" => { "value" => 0, "relation" => "eq" },
369
- "max_score" => nil,
370
- "hits" => []
371
- },
372
- "error" => { "type" => error.class.name, "reason" => error.message }
373
- }
374
- end
375
-
376
189
  # Helper methods
377
190
 
378
191
  def resolve_model(indexes, model_class = nil)
@@ -387,57 +200,14 @@ module Noiseless
387
200
  # Try cached model first (populated via register_model)
388
201
  return @model_class_cache[index_name] if @model_class_cache&.key?(index_name)
389
202
 
390
- # Try to infer model from index name
391
- model_name = index_name.to_s.classify
392
- model_name.constantize
393
- rescue NameError
394
- nil
203
+ candidate = index_name.to_s.classify.safe_constantize
204
+ candidate if active_record_model?(candidate)
395
205
  end
396
206
 
397
207
  def active_record_model?(klass)
398
208
  klass.is_a?(Class) && klass < ActiveRecord::Base
399
209
  end
400
210
 
401
- def trgm_available?
402
- @trgm_available ||= available_extensions.include?("pg_trgm")
403
- end
404
-
405
- def unaccent_available?
406
- @unaccent_available ||= available_extensions.include?("unaccent")
407
- end
408
-
409
- def column?(model, field)
410
- model.columns_hash.key?(field.to_s)
411
- end
412
-
413
- def text_column?(model, field)
414
- column = model.columns_hash[field.to_s]
415
- column && %i[string text citext].include?(column.type) && !column.array
416
- end
417
-
418
- def quoted_column(field)
419
- ActiveRecord::Base.connection.quote_column_name(field)
420
- end
421
-
422
- def sanitize_like(value)
423
- # Escape special LIKE characters
424
- value.to_s.gsub(/[%_\\]/) { |x| "\\#{x}" }
425
- end
426
-
427
- def parse_distance(distance)
428
- # Parse OpenSearch distance format (e.g., "10km", "5mi")
429
- case distance.to_s
430
- when /(\d+(?:\.\d+)?)\s*km/i
431
- ::Regexp.last_match(1).to_f * 1000
432
- when /(\d+(?:\.\d+)?)\s*mi/i
433
- ::Regexp.last_match(1).to_f * 1609.34
434
- when /(\d+(?:\.\d+)?)\s*m/i
435
- ::Regexp.last_match(1).to_f
436
- else
437
- distance.to_f
438
- end
439
- end
440
-
441
211
  def process_bulk_action(action)
442
212
  if action[:index]
443
213
  { "index" => execute_index_document(action[:index][:_index], action[:index][:_id], nil) }