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,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require_relative "es_compatible_execution"
5
4
 
6
5
  module Noiseless
7
6
  module Adapters
@@ -11,33 +10,82 @@ module Noiseless
11
10
 
12
11
  private
13
12
 
13
+ def ast_to_hash(ast_node)
14
+ result = super
15
+
16
+ if (knn = result.delete(:knn))
17
+ must = result.dig(:query, :bool, :must)
18
+ result[:query] = must ? { bool: { must: [*must, knn], filter: result.dig(:query, :bool, :filter) }.compact } : knn
19
+ result[:_source] = { excludes: [ast_node.vector.field.to_s] }
20
+ end
21
+
22
+ if ast_node.hybrid_search?
23
+ hybrid = ast_node.hybrid
24
+ result[:query][:hybrid][:pagination_depth] = [result[:from].to_i + result[:size].to_i, hybrid.vector.k].max
25
+ result[:search_pipeline] ||= rrf_pipeline(hybrid)
26
+ result[:_source] = { excludes: [hybrid.vector.field.to_s] }
27
+ end
28
+
29
+ result
30
+ end
31
+
32
+ def build_knn_query(vector_node, filters: nil)
33
+ options = { vector: vector_node.embedding, k: vector_node.k }
34
+ options[:filter] = { bool: { filter: filters } } if filters.present?
35
+ { knn: { vector_node.field.to_s => options } }
36
+ end
37
+
38
+ def build_hybrid_query(hybrid_node, query)
39
+ raise ArgumentError, "OpenSearch hybrid search takes filters, not must clauses" if query&.dig(:bool, :must).present?
40
+
41
+ text = { multi_match: { query: hybrid_node.text_query, fields: hybrid_node.fields.presence }.compact }
42
+ hybrid = { queries: [text, build_knn_query(hybrid_node.vector)] }
43
+ filters = query&.dig(:bool, :filter)
44
+ hybrid[:filter] = { bool: { filter: filters } } if filters.present?
45
+ { hybrid: hybrid }
46
+ end
47
+
48
+ def rrf_pipeline(hybrid_node)
49
+ weights = [hybrid_node.text_weight, hybrid_node.vector_weight]
50
+ ranker = { combination: { technique: "rrf", rank_constant: 60, parameters: { weights: weights } } }
51
+ { phase_results_processors: [{ "score-ranker-processor": ranker }] }
52
+ end
53
+
54
+ def encode_vector(values) = [values.pack("e*")].pack("m0")
55
+
14
56
  def execute_search(query_hash, indexes: [], **_opts)
15
57
  index_path = indexes.any? ? indexes.join(",") : "_all"
16
- path = "/#{index_path}/_search"
17
- body = JSON.generate(query_hash)
58
+ pipeline = query_hash[:search_pipeline]
59
+ body = pipeline.is_a?(Hash) ? query_hash : query_hash.except(:search_pipeline)
60
+ path = "/#{index_path}/_search#{query_string(search_pipeline: (pipeline unless pipeline.is_a?(Hash)))}"
18
61
 
19
- response = post_request(path, body)
62
+ response = post_request(path, dump_json(body))
20
63
  parse_json_response!(response, error_class: Noiseless::SearchError, context: "search #{index_path}")
21
64
  ensure
22
65
  response&.close
23
66
  end
24
67
 
25
68
  def execute_create_index(index_name, mappings: nil, settings: nil, **opts)
69
+ settings = (settings || {}).deep_merge(index: { knn: true }) if knn_mapping?(mappings)
26
70
  body = opts.dup
27
71
  body[:mappings] = mappings if mappings
28
72
  body[:settings] = settings if settings
29
73
 
30
- response = put_request("/#{index_name}", body.any? ? JSON.generate(body) : nil)
74
+ response = put_request("/#{index_name}", body.any? ? dump_json(body) : nil)
31
75
  parse_json_response!(response, context: "create index #{index_name}")
32
76
  ensure
33
77
  response&.close
34
78
  end
35
79
 
36
- def execute_index_document(index, id, document, **_opts)
37
- path = "/#{index}/_doc/#{id}"
38
- body = JSON.generate(document)
80
+ def knn_mapping?(mappings)
81
+ properties = (mappings || {}).with_indifferent_access[:properties] || {}
82
+ properties.values.any? { it[:type].to_s == "knn_vector" }
83
+ end
84
+
85
+ def execute_index_document(index, id, document, refresh: nil, **_opts)
86
+ path = "/#{index}/_doc/#{id}#{query_string(refresh:)}"
39
87
 
40
- response = put_request(path, body)
88
+ response = put_request(path, dump_json(encode_document(document)))
41
89
  parse_json_response!(response, context: "index document #{index}/#{id}")
42
90
  ensure
43
91
  response&.close
@@ -64,25 +112,11 @@ module Noiseless
64
112
  response&.close
65
113
  end
66
114
 
67
- # OpenSearch-specific features
68
- def execute_point_in_time_search(query_hash, pit_id:, **_opts)
69
- # Point-in-time search for consistent pagination
70
- enhanced_query = query_hash.merge(pit: { id: pit_id })
71
- body = JSON.generate(enhanced_query)
72
-
73
- response = post_request("/_search", body)
74
- parse_json_response!(response, error_class: Noiseless::SearchError, context: "point-in-time search")
75
- ensure
76
- response&.close
77
- end
115
+ def pit_path = "_search/point_in_time"
116
+ def pit_close_body(pit_id) = { pit_id: [pit_id] }
78
117
 
79
118
  def execute_search_template(template_id:, params: {}, **_opts)
80
- # OpenSearch search templates
81
- template_query = {
82
- id: template_id,
83
- params: params
84
- }
85
- body = JSON.generate(template_query)
119
+ body = dump_json(id: template_id, params: params)
86
120
 
87
121
  response = post_request("/_search/template", body)
88
122
  parse_json_response!(response, error_class: Noiseless::SearchError, context: "search template #{template_id}")
@@ -90,118 +124,72 @@ module Noiseless
90
124
  response&.close
91
125
  end
92
126
 
93
- # ============================================
94
- # Search Pipeline API (OpenSearch 3.x)
95
- # ============================================
96
-
97
- def execute_create_pipeline(name, request_processors:, response_processors:, description: nil)
98
- body = {
99
- description: description,
100
- request_processors: request_processors,
101
- response_processors: response_processors
102
- }.compact
103
-
104
- response = put_request("/_search/pipeline/#{name}", JSON.generate(body))
105
- JSON.parse(response.read)
106
- rescue StandardError => e
107
- { acknowledged: false, error: { type: e.class.name, reason: e.message } }
127
+ def execute_json(verb, path, body = nil, context:)
128
+ response = case verb
129
+ when :get then get_request(path)
130
+ when :delete then delete_request(path)
131
+ else send(:"#{verb}_request", path, body && dump_json(body))
132
+ end
133
+ parse_json_response!(response, context:)
108
134
  ensure
109
135
  response&.close
110
136
  end
111
137
 
112
- def execute_get_pipeline(name)
113
- response = get_request("/_search/pipeline/#{name}")
114
- JSON.parse(response.read)
115
- rescue StandardError => e
116
- { error: { type: e.class.name, reason: e.message } }
138
+ def execute_exists?(path, context:)
139
+ response = get_request(path)
140
+ exists_response?(response, context:)
117
141
  ensure
118
142
  response&.close
119
143
  end
120
144
 
121
- def execute_list_pipelines
122
- response = get_request("/_search/pipeline")
123
- JSON.parse(response.read)
124
- rescue StandardError => e
125
- { error: { type: e.class.name, reason: e.message } }
126
- ensure
127
- response&.close
145
+ def execute_create_pipeline(name, request_processors: nil, response_processors: nil, phase_results_processors: nil,
146
+ description: nil)
147
+ body = { description:, request_processors:, response_processors:, phase_results_processors: }.compact
148
+ execute_json(:put, "/_search/pipeline/#{name}", body, context: "create pipeline #{name}")
128
149
  end
129
150
 
130
- def execute_delete_pipeline(name)
131
- response = delete_request("/_search/pipeline/#{name}")
132
- JSON.parse(response.read)
133
- rescue StandardError => e
134
- { acknowledged: false, error: { type: e.class.name, reason: e.message } }
135
- ensure
136
- response&.close
137
- end
151
+ def execute_get_pipeline(name) = execute_json(:get, "/_search/pipeline/#{name}", context: "get pipeline #{name}")
152
+ def execute_list_pipelines = execute_json(:get, "/_search/pipeline", context: "list pipelines")
153
+ def execute_delete_pipeline(name) = execute_json(:delete, "/_search/pipeline/#{name}", context: "delete pipeline #{name}")
154
+ def execute_pipeline_exists?(name) = execute_exists?("/_search/pipeline/#{name}", context: "pipeline exists #{name}")
138
155
 
139
- def execute_pipeline_exists?(name)
140
- response = head_request("/_search/pipeline/#{name}")
141
- response.success?
142
- rescue StandardError
143
- false
144
- ensure
145
- response&.close
156
+ def execute_create_rule(feature_type, description:, value:, **attributes)
157
+ body = { description:, **attributes, feature_type => value }
158
+ execute_json(:put, "/_rules/#{feature_type}", body, context: "create #{feature_type} rule")
146
159
  end
147
160
 
148
- # ============================================
149
- # Query Rules API (OpenSearch 3.x)
150
- # ============================================
161
+ def execute_update_rule(feature_type, id, **changes)
162
+ execute_json(:put, "/_rules/#{feature_type}/#{id}", changes, context: "update #{feature_type} rule #{id}")
163
+ end
151
164
 
152
- def execute_create_rule(feature_type, rule_id, attributes:, feature_value:)
153
- body = {
154
- match_criteria: {
155
- query: attributes
156
- },
157
- feature_value: feature_value
158
- }
165
+ def execute_get_rule(feature_type, id)
166
+ execute_json(:get, "/_rules/#{feature_type}/#{id}", context: "get #{feature_type} rule #{id}").fetch("rules").first
167
+ end
159
168
 
160
- response = put_request("/_rules/#{feature_type}/#{rule_id}", JSON.generate(body))
161
- JSON.parse(response.read)
162
- rescue StandardError => e
163
- { acknowledged: false, error: { type: e.class.name, reason: e.message } }
164
- ensure
165
- response&.close
169
+ def execute_list_rules(feature_type, search_after: nil)
170
+ path = "/_rules/#{feature_type}#{query_string(search_after:)}"
171
+ execute_json(:get, path, context: "list #{feature_type} rules").fetch("rules")
166
172
  end
167
173
 
168
- def execute_get_rule(feature_type, rule_id)
169
- response = get_request("/_rules/#{feature_type}/#{rule_id}")
170
- JSON.parse(response.read)
171
- rescue StandardError => e
172
- { error: { type: e.class.name, reason: e.message } }
173
- ensure
174
- response&.close
174
+ def execute_delete_rule(feature_type, id)
175
+ execute_json(:delete, "/_rules/#{feature_type}/#{id}", context: "delete #{feature_type} rule #{id}")
175
176
  end
176
177
 
177
- def execute_list_rules(feature_type, search_after: nil)
178
- path = "/_rules/#{feature_type}"
179
- path += "?search_after=#{search_after}" if search_after
178
+ def execute_rule_exists?(feature_type, id)
179
+ execute_exists?("/_rules/#{feature_type}/#{id}", context: "#{feature_type} rule exists #{id}")
180
+ end
180
181
 
181
- response = get_request(path)
182
- JSON.parse(response.read)
183
- rescue StandardError => e
184
- { rules: [], error: { type: e.class.name, reason: e.message } }
185
- ensure
186
- response&.close
182
+ def execute_create_workload_group(name, resource_limits:, resiliency_mode: "soft")
183
+ body = { name:, resiliency_mode:, resource_limits: }
184
+ execute_json(:put, "/_wlm/workload_group", body, context: "create workload group #{name}")
187
185
  end
188
186
 
189
- def execute_delete_rule(feature_type, rule_id)
190
- response = delete_request("/_rules/#{feature_type}/#{rule_id}")
191
- JSON.parse(response.read)
192
- rescue StandardError => e
193
- { acknowledged: false, error: { type: e.class.name, reason: e.message } }
194
- ensure
195
- response&.close
187
+ def execute_get_workload_group(name)
188
+ execute_json(:get, "/_wlm/workload_group/#{name}", context: "get workload group #{name}").fetch("workload_groups").first
196
189
  end
197
190
 
198
- def execute_rule_exists?(feature_type, rule_id)
199
- response = head_request("/_rules/#{feature_type}/#{rule_id}")
200
- response.success?
201
- rescue StandardError
202
- false
203
- ensure
204
- response&.close
191
+ def execute_delete_workload_group(name)
192
+ execute_json(:delete, "/_wlm/workload_group/#{name}", context: "delete workload group #{name}")
205
193
  end
206
194
  end
207
195
  end
@@ -26,9 +26,9 @@ module Noiseless
26
26
  #
27
27
  def vector_search(scope, embedding, column: :embedding, limit: 20, distance_threshold: nil,
28
28
  distance_metric: :cosine)
29
- return scope unless pgvector_available?
29
+ return scope.none unless pgvector_available?
30
30
 
31
- vector_string = "[#{embedding.join(',')}]"
31
+ vector_string = vector_literal(embedding)
32
32
  distance_op = distance_operator(distance_metric)
33
33
 
34
34
  # Build the query with distance calculation
@@ -63,30 +63,19 @@ module Noiseless
63
63
  #
64
64
  def hybrid_search(scope, text_query:, embedding:, text_fields:, vector_column: :embedding,
65
65
  text_weight: 0.5, vector_weight: 0.5, limit: 20)
66
- return scope unless pgvector_available?
67
-
68
- vector_string = "[#{embedding.join(',')}]"
69
- text_conditions = text_fields.map { |f| "similarity(#{quoted_column(f)}, ?)" }.join(" + ")
70
- text_similarity_count = text_fields.size
71
-
72
- # Normalized combined score
73
- scope.select(
74
- "#{scope.table_name}.*",
75
- # Text similarity (0-1 per field, averaged)
76
- Arel.sql(
77
- "(#{text_conditions}) / #{text_similarity_count} * #{text_weight} AS text_score"
78
- ),
79
- # Vector similarity (convert distance to similarity: 1 - distance for cosine)
80
- "(1 - (#{quoted_column(vector_column)} <=> '#{vector_string}')) * #{vector_weight} AS vector_score",
81
- # Combined score
82
- "(((#{text_conditions}) / #{text_similarity_count}) * #{text_weight} + " \
83
- "(1 - (#{quoted_column(vector_column)} <=> '#{vector_string}')) * #{vector_weight}) AS combined_score"
84
- ).where(
85
- "#{text_conditions} > 0 OR #{quoted_column(vector_column)} IS NOT NULL",
86
- *Array.new(text_similarity_count, text_query)
87
- ).order(Arel.sql("combined_score DESC"))
66
+ return scope.none unless pgvector_available?
67
+ raise ArgumentError, "hybrid_search needs text_fields to run the text query against" if text_fields.empty?
68
+
69
+ column = quoted_column(vector_column)
70
+ text_score = "(#{text_fields.map { "similarity(#{quoted_column(it)}, :query)" }.join(' + ')}) / #{text_fields.size}"
71
+ vector_score = "(1 - (#{column} <=> '#{vector_literal(embedding)}'))"
72
+ combined = "#{text_score} * #{Float(text_weight)} + #{vector_score} * #{Float(vector_weight)}"
73
+ bind = ->(sql) { scope.sanitize_sql_array([sql, { query: text_query.to_s }]) }
74
+
75
+ scope.select("#{scope.table_name}.*", Arel.sql(bind.call("#{combined} AS combined_score")))
76
+ .where(bind.call("#{text_score} > 0 OR #{column} IS NOT NULL"))
77
+ .order(Arel.sql("combined_score DESC"))
88
78
  .limit(limit)
89
- .tap { |s| s.bind_values.concat(Array.new(text_similarity_count, text_query)) }
90
79
  end
91
80
 
92
81
  # Execute a KNN (K-Nearest Neighbors) search
@@ -101,7 +90,7 @@ module Noiseless
101
90
  def knn_search(model, embedding, k: 10, column: :embedding, filters: {})
102
91
  return [] unless pgvector_available?
103
92
 
104
- vector_string = "[#{embedding.join(',')}]"
93
+ vector_string = vector_literal(embedding)
105
94
 
106
95
  scope = model.all
107
96
  scope = scope.where(filters) if filters.any?
@@ -124,36 +113,36 @@ module Noiseless
124
113
  def store_embedding(record, embedding, column: :embedding)
125
114
  return false unless pgvector_available?
126
115
 
127
- vector_string = "[#{embedding.join(',')}]"
128
- record.update_column(column, vector_string)
116
+ record.update_column(column, vector_literal(embedding))
129
117
  end
130
118
 
131
119
  # Batch store embeddings
132
120
  #
133
121
  # @param model [Class] The ActiveRecord model
134
- # @param embeddings [Hash<String, Array<Float>>] Map of ID -> embedding
122
+ # @param embeddings [Hash<Object, Array<Float>>] Map of primary key -> embedding
135
123
  # @param column [Symbol] The column to store embeddings
124
+ # @return [Integer] Number of rows updated
136
125
  #
137
126
  def batch_store_embeddings(model, embeddings, column: :embedding)
138
127
  return 0 unless pgvector_available?
139
-
140
- # Use UPDATE FROM VALUES for efficient batch update
141
- values = embeddings.map do |id, emb|
142
- "(#{ActiveRecord::Base.connection.quote(id)}, '[#{emb.join(',')}]'::vector)"
143
- end.join(",")
144
-
145
- sql = <<~SQL.squish
146
- UPDATE #{model.table_name}
147
- SET #{column} = v.embedding
148
- FROM (VALUES #{values}) AS v(id, embedding)
149
- WHERE #{model.table_name}.id = v.id::uuid
150
- SQL
151
-
152
- ActiveRecord::Base.connection.execute(sql)
153
- embeddings.size
154
- rescue StandardError => e
155
- Rails.logger.error("Failed to batch store embeddings: #{e.message}")
156
- 0
128
+ return 0 if embeddings.empty?
129
+
130
+ model.with_connection do |connection|
131
+ table = connection.quote_table_name(model.table_name)
132
+ primary_key = model.primary_key
133
+ pk_type = model.columns_hash.fetch(primary_key.to_s).sql_type
134
+
135
+ values = embeddings.map do |id, embedding|
136
+ "(#{connection.quote(id)}::#{pk_type}, '#{vector_literal(embedding)}'::vector)"
137
+ end
138
+
139
+ connection.exec_update(<<~SQL.squish, "Noiseless batch_store_embeddings")
140
+ UPDATE #{table}
141
+ SET #{connection.quote_column_name(column)} = v.embedding
142
+ FROM (VALUES #{values.join(', ')}) AS v(id, embedding)
143
+ WHERE #{table}.#{connection.quote_column_name(primary_key)} = v.id
144
+ SQL
145
+ end
157
146
  end
158
147
 
159
148
  # Find similar records to a given record
@@ -181,6 +170,12 @@ module Noiseless
181
170
 
182
171
  private
183
172
 
173
+ # pgvector operators take no bind parameter in SELECT or ORDER BY, so
174
+ # the literal is interpolated; coercion guarantees it holds only floats.
175
+ def vector_literal(embedding)
176
+ "[#{AST::Vector.coerce_embedding(embedding).join(',')}]"
177
+ end
178
+
184
179
  def distance_operator(metric)
185
180
  case metric
186
181
  when :l2, :euclidean