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
@@ -7,8 +7,6 @@ module Noiseless
7
7
  extend ActiveSupport::Concern
8
8
 
9
9
  included do
10
- after_save :update_search_index_on_save
11
- after_destroy :remove_from_search_index
12
10
  after_commit :update_search_index_on_commit, on: %i[create update]
13
11
  after_commit :remove_from_search_index_on_commit, on: :destroy
14
12
  end
@@ -28,11 +26,11 @@ module Noiseless
28
26
  end
29
27
 
30
28
  def skip_auto_index
31
- previous_value = Thread.current[:noiseless_skip_auto_index]
32
- Thread.current[:noiseless_skip_auto_index] = true
29
+ previous_value = Fiber[:noiseless_skip_auto_index]
30
+ Fiber[:noiseless_skip_auto_index] = true
33
31
  yield
34
32
  ensure
35
- Thread.current[:noiseless_skip_auto_index] = previous_value
33
+ Fiber[:noiseless_skip_auto_index] = previous_value
36
34
  end
37
35
  end
38
36
 
@@ -40,7 +38,7 @@ module Noiseless
40
38
 
41
39
  def should_update_search_index?
42
40
  return false unless Noiseless.config.auto_index
43
- return false if Thread.current[:noiseless_skip_auto_index]
41
+ return false if Fiber[:noiseless_skip_auto_index]
44
42
  return false unless self.class.auto_index_enabled?
45
43
 
46
44
  # Only update if we have searchable content
@@ -51,14 +49,6 @@ module Noiseless
51
49
  end
52
50
  end
53
51
 
54
- def update_search_index_on_save
55
- return unless should_update_search_index?
56
-
57
- update_search_index_async if noiseless_new_record? || (respond_to?(:changed?) && changed?)
58
- rescue Noiseless::Error => e
59
- handle_search_index_error(e, :update)
60
- end
61
-
62
52
  def update_search_index_on_commit
63
53
  return unless should_update_search_index?
64
54
 
@@ -67,14 +57,6 @@ module Noiseless
67
57
  handle_search_index_error(e, :update)
68
58
  end
69
59
 
70
- def remove_from_search_index
71
- return unless should_update_search_index?
72
-
73
- remove_from_search_index_async
74
- rescue Noiseless::Error => e
75
- handle_search_index_error(e, :delete)
76
- end
77
-
78
60
  def remove_from_search_index_on_commit
79
61
  return unless should_update_search_index?
80
62
 
@@ -127,13 +109,5 @@ module Noiseless
127
109
  logger.error "Noiseless: Failed to #{operation} search index for #{self.class.name}##{id}: #{error.message}"
128
110
  end
129
111
  end
130
-
131
- def noiseless_new_record?
132
- if respond_to?(:persisted?)
133
- !persisted?
134
- else
135
- !id
136
- end
137
- end
138
112
  end
139
113
  end
@@ -9,8 +9,10 @@ module Noiseless
9
9
 
10
10
  # Register a named client statically from YAML (boot-time only).
11
11
  # request_timeout: :default uses the adapter default, nil disables the deadline.
12
- def register(name, adapter:, hosts:, timeout: nil, request_timeout: :default)
13
- @configs[name.to_sym] = { adapter: adapter, hosts: hosts, timeout: timeout, request_timeout: request_timeout }
12
+ # Any other keys (e.g. api_key) are passed to the adapter unchanged.
13
+ def register(name, adapter:, hosts:, timeout: nil, request_timeout: :default, **options)
14
+ @configs[name.to_sym] = { adapter: adapter, hosts: hosts, timeout: timeout, request_timeout: request_timeout,
15
+ options: options }
14
16
  end
15
17
 
16
18
  # Retrieve a client; defaults to :primary
@@ -20,11 +22,15 @@ module Noiseless
20
22
  # Lazy-load the adapter only when actually used
21
23
  @clients[name] ||= begin
22
24
  config = @configs.fetch(name) { raise "Unknown connection: #{name}" }
23
- params = { hosts: config[:hosts] }
25
+ params = { **config[:options], hosts: config[:hosts] }
24
26
  params[:timeout] = config[:timeout] unless config[:timeout].nil?
25
27
  params[:request_timeout] = config[:request_timeout] unless config[:request_timeout] == :default
26
28
  Adapters.lookup(config[:adapter], **params)
27
29
  end
28
30
  end
31
+
32
+ private
33
+
34
+ def instance_variables_to_inspect = instance_variables - %i[@configs]
29
35
  end
30
36
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ # Runtime tracking for Rails
5
+ module ControllerRuntime
6
+ extend ActiveSupport::Concern
7
+
8
+ protected
9
+
10
+ def append_info_to_payload(payload)
11
+ super
12
+ payload[:noiseless_runtime] = noiseless_runtime
13
+ end
14
+
15
+ def cleanup_view_runtime
16
+ runtime_before_render = noiseless_runtime
17
+ runtime = super
18
+ runtime_after_render = noiseless_runtime
19
+ runtime + runtime_after_render - runtime_before_render
20
+ end
21
+
22
+ private
23
+
24
+ def noiseless_runtime
25
+ ActiveSupport::IsolatedExecutionState[:noiseless_runtime] ||= 0
26
+ end
27
+
28
+ module ClassMethods
29
+ def log_process_action(payload)
30
+ messages = super
31
+ runtime = payload[:noiseless_runtime]
32
+ messages << ("Noiseless: %.1fms" % runtime) if runtime&.positive?
33
+ messages
34
+ end
35
+ end
36
+ end
37
+ end
@@ -11,14 +11,7 @@ module Noiseless
11
11
  document = build_document
12
12
  return false unless document
13
13
 
14
- client = Noiseless.connections.client(@connection)
15
- client.index_document(
16
- index: index_name,
17
- id: document_id,
18
- document: document,
19
- refresh: refresh,
20
- **
21
- )
14
+ await { client.index_document(index: index_name, id: document_id, document: document, refresh: refresh, **) }
22
15
  end
23
16
 
24
17
  def update_document(refresh: false, detect_changes: true, **)
@@ -28,14 +21,7 @@ module Noiseless
28
21
  changes = extract_changes
29
22
  return false if changes.empty?
30
23
 
31
- client = Noiseless.connections.client(@connection)
32
- client.update_document(
33
- index: index_name,
34
- id: document_id,
35
- changes: changes,
36
- refresh: refresh,
37
- **
38
- )
24
+ await { client.update_document(index: index_name, id: document_id, changes: changes, refresh: refresh, **) }
39
25
  else
40
26
  # Fall back to full document update
41
27
  index_document(refresh: refresh, **)
@@ -43,35 +29,34 @@ module Noiseless
43
29
  end
44
30
 
45
31
  def delete_document(refresh: false, **)
46
- client = Noiseless.connections.client(@connection)
47
- client.delete_document(
48
- index: index_name,
49
- id: document_id,
50
- refresh: refresh,
51
- **
52
- )
32
+ await { client.delete_document(index: index_name, id: document_id, refresh: refresh, **) }
53
33
  end
54
34
 
55
35
  def document_exists?
56
- client = Noiseless.connections.client(@connection)
57
- client.document_exists?(
58
- index: index_name,
59
- id: document_id
60
- )
36
+ await { client.document_exists?(index: index_name, id: document_id) }
61
37
  end
62
38
 
63
39
  private
64
40
 
41
+ def client = Noiseless.connections.client(@connection)
42
+
43
+ def await = Sync { yield.wait }
44
+
65
45
  attr_reader :model_instance
66
46
 
67
47
  def build_document
68
- if model_instance.respond_to?(:to_search_document)
69
- model_instance.to_search_document
70
- elsif model_instance.respond_to?(:to_h)
71
- model_instance.to_h
72
- elsif model_instance.respond_to?(:attributes)
73
- model_instance.attributes
74
- end
48
+ document = if model_instance.respond_to?(:to_search_document)
49
+ model_instance.to_search_document
50
+ elsif model_instance.respond_to?(:to_h)
51
+ model_instance.to_h
52
+ elsif model_instance.respond_to?(:attributes)
53
+ model_instance.attributes
54
+ end
55
+ document && Embedding.wrap(document, vector_fields)
56
+ end
57
+
58
+ def vector_fields
59
+ @vector_fields ||= Embedding.vector_fields(model_instance.class)
75
60
  end
76
61
 
77
62
  def document_id
@@ -131,7 +116,7 @@ module Noiseless
131
116
  end
132
117
  end
133
118
 
134
- changes
119
+ Embedding.wrap(changes, vector_fields)
135
120
  end
136
121
  end
137
122
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ # A dense vector document field. Elasticsearch and OpenSearch send it as a
5
+ # base64 float32 string, which is far smaller and faster to ingest than a
6
+ # JSON float array; every other encoder sees a plain array.
7
+ Embedding = Data.define(:values) do
8
+ def initialize(values:)
9
+ super(values: AST::Vector.coerce_embedding(values))
10
+ end
11
+
12
+ def to_a = values
13
+ def as_json(*) = values
14
+ def to_json(*) = values.to_json(*)
15
+ end
16
+
17
+ class Embedding
18
+ VECTOR_TYPES = %w[dense_vector knn_vector].freeze
19
+
20
+ def self.vector_fields(model_class)
21
+ return [] unless model_class.respond_to?(:mapping) && model_class.mapping
22
+
23
+ properties = MappingDefinitionProcessor.process(model_class.mapping).dig(:mappings, :properties).to_h
24
+ properties.filter_map { |name, config| name.to_s if VECTOR_TYPES.include?(config[:type].to_s) }
25
+ end
26
+
27
+ def self.wrap(document, fields)
28
+ return document if fields.empty? || !document.respond_to?(:to_h)
29
+
30
+ document.to_h.to_h { |key, value| [key, fields.include?(key.to_s) && value.is_a?(Array) ? new(values: value) : value] }
31
+ end
32
+ end
33
+ end
@@ -33,142 +33,8 @@ module Noiseless
33
33
  end
34
34
 
35
35
  def add_to_runtime(duration)
36
- Thread.current[:noiseless_runtime] ||= 0
37
- Thread.current[:noiseless_runtime] += duration * 1000 # Convert to milliseconds
38
- end
39
- end
40
-
41
- # Log subscriber for Rails integration
42
- class LogSubscriber < ActiveSupport::LogSubscriber
43
- def search(event)
44
- return unless logger.debug?
45
-
46
- indexes = event.payload[:indexes]&.join(", ") || "unknown"
47
- duration = event.duration.round(2)
48
-
49
- debug "Noiseless Search (#{duration}ms) indexes=[#{indexes}] #{query_summary(event.payload[:query])}"
50
- end
51
-
52
- def bulk(event)
53
- return unless logger.debug?
54
-
55
- actions_count = event.payload[:actions_count] || 0
56
- duration = event.duration.round(2)
57
-
58
- debug "Noiseless Bulk (#{duration}ms) actions=#{actions_count}"
59
- end
60
-
61
- def index_document(event)
62
- return unless logger.debug?
63
-
64
- index = event.payload[:index]
65
- id = event.payload[:id]
66
- duration = event.duration.round(2)
67
-
68
- debug "Noiseless Index Document (#{duration}ms) index=#{index} id=#{id}"
69
- end
70
-
71
- def update_document(event)
72
- return unless logger.debug?
73
-
74
- index = event.payload[:index]
75
- id = event.payload[:id]
76
- changes_count = event.payload[:changes_count] || 0
77
- duration = event.duration.round(2)
78
-
79
- debug "Noiseless Update Document (#{duration}ms) index=#{index} id=#{id} changes=#{changes_count}"
80
- end
81
-
82
- def delete_document(event)
83
- return unless logger.debug?
84
-
85
- index = event.payload[:index]
86
- id = event.payload[:id]
87
- duration = event.duration.round(2)
88
-
89
- debug "Noiseless Delete Document (#{duration}ms) index=#{index} id=#{id}"
90
- end
91
-
92
- def create_index(event)
93
- return unless logger.debug?
94
-
95
- index = event.payload[:index]
96
- duration = event.duration.round(2)
97
-
98
- debug "Noiseless Create Index (#{duration}ms) index=#{index}"
99
- end
100
-
101
- def delete_index(event)
102
- return unless logger.debug?
103
-
104
- index = event.payload[:index]
105
- duration = event.duration.round(2)
106
-
107
- debug "Noiseless Delete Index (#{duration}ms) index=#{index}"
108
- end
109
-
110
- private
111
-
112
- def query_summary(query)
113
- return "empty" unless query.is_a?(Hash)
114
-
115
- parts = []
116
-
117
- if query[:query]&.dig(:bool, :must)&.any?
118
- must_count = query[:query][:bool][:must].size
119
- parts << "must:#{must_count}"
120
- end
121
-
122
- if query[:query]&.dig(:bool, :filter)&.any?
123
- filter_count = query[:query][:bool][:filter].size
124
- parts << "filter:#{filter_count}"
125
- end
126
-
127
- if query[:sort]&.any?
128
- sort_count = query[:sort].size
129
- parts << "sort:#{sort_count}"
130
- end
131
-
132
- if query[:from] || query[:size]
133
- parts << "from:#{query[:from] || 0}"
134
- parts << "size:#{query[:size] || 20}"
135
- end
136
-
137
- parts.join(" ")
138
- end
139
- end
140
-
141
- # Runtime tracking for Rails
142
- module ControllerRuntime
143
- extend ActiveSupport::Concern
144
-
145
- protected
146
-
147
- def append_info_to_payload(payload)
148
- super
149
- payload[:noiseless_runtime] = noiseless_runtime
150
- end
151
-
152
- def cleanup_view_runtime
153
- runtime_before_render = noiseless_runtime
154
- runtime = super
155
- runtime_after_render = noiseless_runtime
156
- runtime + runtime_after_render - runtime_before_render
157
- end
158
-
159
- private
160
-
161
- def noiseless_runtime
162
- Thread.current[:noiseless_runtime] ||= 0
163
- end
164
-
165
- module ClassMethods
166
- def log_process_action(payload)
167
- messages = super
168
- runtime = payload[:noiseless_runtime]
169
- messages << ("Noiseless: %.1fms" % runtime) if runtime&.positive?
170
- messages
171
- end
36
+ state = ActiveSupport::IsolatedExecutionState
37
+ state[:noiseless_runtime] = (state[:noiseless_runtime] || 0) + (duration * 1000)
172
38
  end
173
39
  end
174
40
  end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Introspection
5
+ # Mermaid renderings of an AST and of a query's execution flow.
6
+ class MermaidDiagrams
7
+ def self.generate_flow_diagram(explanation)
8
+ # Create a sequence diagram showing the query execution flow
9
+ diagram = "sequenceDiagram\n".dup
10
+ diagram << " participant Client\n"
11
+ diagram << " participant Adapter\n"
12
+ diagram << " participant Engine\n"
13
+
14
+ explanation[:execution_plan].each do |step|
15
+ diagram << case step[:description]
16
+ when /validate/i
17
+ " Client->>Adapter: #{step[:description]}\n"
18
+ when /convert/i, /format/i
19
+ " Adapter->>Adapter: #{step[:description]}\n"
20
+ when /execute/i, /query/i
21
+ " Adapter->>Engine: #{step[:description]}\n"
22
+ else
23
+ " Engine->>Adapter: #{step[:description]}\n"
24
+ end
25
+ end
26
+
27
+ diagram << " Adapter->>Client: Return results\n"
28
+ diagram
29
+ end
30
+
31
+ # New methods using your diagram/mermaid gems
32
+ def self.ast_to_mermaid_flowchart(ast_node)
33
+ return "# Mermaid diagrams require 'diagrams' and 'mermaid' gems\n# Add to Gemfile: gem 'diagrams'; gem 'mermaid'" unless DIAGRAMS_AVAILABLE
34
+
35
+ diagram = Diagrams::FlowchartDiagram.new(version: "1.0")
36
+
37
+ # Convert AST structure to flowchart nodes and edges
38
+ add_ast_node_to_flowchart(diagram, ast_node, "root")
39
+
40
+ diagram.to_mermaid
41
+ end
42
+
43
+ def self.ast_to_mermaid_class_diagram(ast_node)
44
+ return "# Mermaid diagrams require 'diagrams' and 'mermaid' gems\n# Add to Gemfile: gem 'diagrams'; gem 'mermaid'" unless DIAGRAMS_AVAILABLE
45
+
46
+ diagram = Diagrams::ClassDiagram.new(version: "1.0")
47
+
48
+ # Create a class representation of the AST structure
49
+ root_class = Diagrams::Elements::ClassEntity.new(
50
+ name: ast_node.class.name.split("::").last,
51
+ attributes: ast_node.instance_variables.map do |var|
52
+ "#{var.to_s.delete('@')}: #{ast_node.instance_variable_get(var).class.name.split('::').last}"
53
+ end,
54
+ methods: ast_node.public_methods(false).map { |method| "+#{method}()" }
55
+ )
56
+
57
+ diagram.add_class(root_class)
58
+
59
+ # Add child nodes as related classes
60
+ add_ast_children_to_class_diagram(diagram, ast_node, root_class.name)
61
+
62
+ diagram.to_mermaid
63
+ end
64
+
65
+ def self.add_ast_node_to_flowchart(diagram, node, node_id)
66
+ # Add current node
67
+ flowchart_node = Diagrams::Elements::Node.new(
68
+ id: node_id,
69
+ label: node.class.name.split("::").last.to_s
70
+ )
71
+ diagram.add_node(flowchart_node)
72
+
73
+ # Add child nodes and connect them
74
+ return unless node.respond_to?(:instance_variables)
75
+
76
+ node.instance_variables.each_with_index do |var, _index|
77
+ child_value = node.instance_variable_get(var)
78
+
79
+ if child_value.is_a?(Noiseless::AST::Node)
80
+ child_id = "#{node_id}_#{var.to_s.delete('@')}"
81
+ add_ast_node_to_flowchart(diagram, child_value, child_id)
82
+
83
+ edge = Diagrams::Elements::Edge.new(
84
+ source_id: node_id,
85
+ target_id: child_id,
86
+ label: var.to_s.delete("@")
87
+ )
88
+ diagram.add_edge(edge)
89
+ elsif child_value.is_a?(Array) && child_value.any?(Noiseless::AST::Node)
90
+ child_value.each_with_index do |item, item_index|
91
+ next unless item.is_a?(Noiseless::AST::Node)
92
+
93
+ child_id = "#{node_id}_#{var.to_s.delete('@')}_#{item_index}"
94
+ add_ast_node_to_flowchart(diagram, item, child_id)
95
+
96
+ edge = Diagrams::Elements::Edge.new(
97
+ source_id: node_id,
98
+ target_id: child_id,
99
+ label: "#{var.to_s.delete('@')}[#{item_index}]"
100
+ )
101
+ diagram.add_edge(edge)
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ def self.add_ast_children_to_class_diagram(diagram, node, parent_class_name)
108
+ return unless node.respond_to?(:instance_variables)
109
+
110
+ node.instance_variables.each do |var|
111
+ child_value = node.instance_variable_get(var)
112
+
113
+ next unless child_value.is_a?(Noiseless::AST::Node)
114
+
115
+ child_class_name = child_value.class.name.split("::").last
116
+
117
+ # Add child class if not already added
118
+ unless diagram.classes.any? { |c| c.name == child_class_name }
119
+ child_class = Diagrams::Elements::ClassEntity.new(
120
+ name: child_class_name,
121
+ attributes: child_value.instance_variables.map do |cv|
122
+ "#{cv.to_s.delete('@')}: #{child_value.instance_variable_get(cv).class.name.split('::').last}"
123
+ end
124
+ )
125
+ diagram.add_class(child_class)
126
+ end
127
+
128
+ # Add relationship
129
+ relationship = Diagrams::Elements::Relationship.new(
130
+ source_class_name: parent_class_name,
131
+ target_class_name: child_class_name,
132
+ type: "composition",
133
+ label: var.to_s.delete("@")
134
+ )
135
+ diagram.add_relationship(relationship)
136
+
137
+ # Recursively add children
138
+ add_ast_children_to_class_diagram(diagram, child_value, child_class_name)
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end