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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a2305879802aa874c213e1a5a87707ec7222af979d21834f21f4fd2c994cb48
4
- data.tar.gz: 065cf12ecb097ac17ee1e33d99d5df0ccb79ca4ea6713640f82f6bcac2fad51a
3
+ metadata.gz: c023b22c96a7186d99210b2a4420fa6661e3be195522cf86dbe9191d5301acfa
4
+ data.tar.gz: ef2f26a7f11bad65b6a604f08641d398e9da90e637af8bbd3f9a1750e8119589
5
5
  SHA512:
6
- metadata.gz: dc5302df3e73a9a413e1a7382db1334e11dd28ee2564bb5ae9e0f5d6a2d62f7b241584463678ef6f0db6112740c70d5687fdc425903979056c9d61ff10140bc6
7
- data.tar.gz: baba1a26426413fef74f6f900d3146aa9c7bcda385030dd3b722e8277c5dc5b8a25bb79adbf6dc9d0c695e4d3b43e4353e4fe99e3bffb7ec5fc5a0d18ff566cf
6
+ metadata.gz: 272b9ffa8aa20f2719018ca2abbac572640f997742fdbc79c413432b1d35ee97501dd6ea1475ffa02e67d04987cd31595f27c12df824c1c21832acc4f1de875a
7
+ data.tar.gz: 00be400acc534f50c40138e57e4c93118ba6829e14e72ebb7a18df546310168b4aaab9e2e421cfb950c6ad6d9b8dc5bcc68b70d38b071fedb9d35fa04c17854c
data/README.md CHANGED
@@ -6,7 +6,7 @@ Async-first search abstraction for Rails with multi-backend support (OpenSearch,
6
6
 
7
7
  - **Chainable DSL** — fluent query builder with runtime validation
8
8
  - **Multi-backend** — OpenSearch, Elasticsearch, Typesense, PostgreSQL adapters
9
- - **Async-first** — built on Ruby 3.4+ fiber scheduler with non-blocking I/O
9
+ - **Async-first** — built on Ruby 4.0+ fiber scheduler with non-blocking I/O
10
10
  - **HTTP/2 connection pooling** — persistent connections via `Async::Pool`
11
11
  - **Rails integration** — Railtie with log subscriber and controller runtime tracking
12
12
  - **Lazy loading** — adapters loaded on-demand, test files excluded from production
@@ -17,7 +17,7 @@ Async-first search abstraction for Rails with multi-backend support (OpenSearch,
17
17
  gem "noiseless"
18
18
  ```
19
19
 
20
- `noiseless` is a Rails gem. It requires Ruby >= 3.4 and Rails >= 8.1.
20
+ `noiseless` is a Rails gem. It requires Ruby >= 4.0 and Rails >= 8.1.
21
21
 
22
22
  ## Configuration
23
23
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "async"
4
4
  require "json"
5
- require_relative "introspection"
6
5
 
7
6
  module Noiseless
8
7
  class Adapter
@@ -127,6 +126,8 @@ module Noiseless
127
126
 
128
127
  private
129
128
 
129
+ def instance_variables_to_inspect = instance_variables - %i[@connection_params]
130
+
130
131
  # Convert AST to Hash - override in subclasses for adapter-specific format
131
132
  def ast_to_hash(ast_node)
132
133
  result = {}
@@ -153,14 +154,8 @@ module Noiseless
153
154
  # Aggregations
154
155
  result[:aggs] = build_aggregations_hash(ast_node.aggregations) if ast_node.aggregations.any?
155
156
 
156
- # Vector/kNN search (OpenSearch/Elasticsearch compatible)
157
- result[:knn] = build_knn_query(ast_node.vector) if ast_node.vector_search?
158
-
159
- # Hybrid search (combines text + vector with RRF or weighted scoring)
160
- if ast_node.hybrid_search?
161
- hybrid_config = build_hybrid_query(ast_node.hybrid)
162
- result.merge!(hybrid_config)
163
- end
157
+ result[:knn] = build_knn_query(ast_node.vector, filters: result.dig(:query, :bool, :filter)) if ast_node.vector_search?
158
+ result[:query] = build_hybrid_query(ast_node.hybrid, result[:query]) if ast_node.hybrid_search?
164
159
 
165
160
  # Search pipeline (OpenSearch only)
166
161
  result[:search_pipeline] = ast_node.pipeline if ast_node.has_pipeline?
@@ -168,36 +163,27 @@ module Noiseless
168
163
  result
169
164
  end
170
165
 
171
- def build_knn_query(vector_node)
166
+ def build_knn_query(vector_node, filters: nil)
172
167
  {
173
168
  field: vector_node.field.to_s,
174
- query_vector: vector_node.embedding,
169
+ query_vector: encode_vector(vector_node.embedding),
175
170
  k: vector_node.k,
176
- num_candidates: vector_node.k * 10
177
- }
171
+ filter: filters.presence
172
+ }.compact
178
173
  end
179
174
 
180
- # Build hybrid query using RRF (Reciprocal Rank Fusion) for OpenSearch/Elasticsearch
181
- def build_hybrid_query(hybrid_node)
182
- {
183
- query: {
184
- bool: {
185
- should: [
186
- {
187
- match: {
188
- _all: hybrid_node.text_query
189
- }
190
- }
191
- ]
192
- }
193
- },
194
- knn: build_knn_query(hybrid_node.vector),
195
- rank: {
196
- rrf: {
197
- window_size: hybrid_node.vector.k * 2
198
- }
199
- }
200
- }
175
+ def build_hybrid_query(hybrid_node, query)
176
+ bool = query&.fetch(:bool, nil)&.dup || {}
177
+ text = { multi_match: { query: hybrid_node.text_query, fields: hybrid_node.fields.presence, boost: hybrid_node.text_weight }.compact }
178
+ vector = { knn: build_knn_query(hybrid_node.vector, filters: bool[:filter]).merge(boost: hybrid_node.vector_weight) }
179
+
180
+ { bool: bool.merge(should: [text, vector], minimum_should_match: 1) }
181
+ end
182
+
183
+ def encode_vector(values) = [values.pack("g*")].pack("m0")
184
+
185
+ def encode_document(document)
186
+ document.to_h.transform_values { it.is_a?(Noiseless::Embedding) ? encode_vector(it.values) : it }
201
187
  end
202
188
 
203
189
  def build_query_hash(bool_node)
@@ -262,7 +248,7 @@ module Noiseless
262
248
  rescue JSON::ParserError, TypeError
263
249
  nil
264
250
  end
265
- error = payload.is_a?(Hash) ? payload["error"] : nil
251
+ error = payload.is_a?(Hash) ? payload["error"] || payload["message"] : nil
266
252
  reason = if error.is_a?(Hash)
267
253
  [error["type"], error["reason"]].compact.join(": ")
268
254
  elsif error
@@ -274,6 +260,13 @@ module Noiseless
274
260
  raise error_class.new(message, status: response.status, error_type: error.is_a?(Hash) ? error["type"] : nil)
275
261
  end
276
262
 
263
+ def exists_response?(response, context:)
264
+ return true if response.success?
265
+ return false if response.status == 404
266
+
267
+ raise Noiseless::RequestError.new("#{context}: HTTP #{response.status}", status: response.status)
268
+ end
269
+
277
270
  # Override in subclasses
278
271
  def execute_search(_query_hash, **_opts)
279
272
  {
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "execution_modules/elasticsearch_execution"
4
3
 
5
4
  module Noiseless
6
5
  module Adapters
@@ -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
@@ -13,7 +12,7 @@ module Noiseless
13
12
 
14
13
  def execute_search(query_hash, indexes: [], **_opts)
15
14
  path = indexes.any? ? "/#{indexes.join(',')}/_search" : "/_search"
16
- body = JSON.generate(query_hash)
15
+ body = dump_json(query_hash)
17
16
 
18
17
  response = post_request(path, body)
19
18
  parse_json_response!(response, error_class: Noiseless::SearchError, context: "search")
@@ -26,15 +25,15 @@ module Noiseless
26
25
  body[:mappings] = mappings if mappings
27
26
  body[:settings] = settings if settings
28
27
 
29
- response = put_request("/#{index_name}", body.any? ? JSON.generate(body) : nil)
28
+ response = put_request("/#{index_name}", body.any? ? dump_json(body) : nil)
30
29
  parse_json_response!(response, context: "create index #{index_name}")
31
30
  ensure
32
31
  response&.close
33
32
  end
34
33
 
35
- def execute_index_document(index, id, document, **_opts)
36
- path = id ? "/#{index}/_doc/#{id}" : "/#{index}/_doc"
37
- body = JSON.generate(document)
34
+ def execute_index_document(index, id, document, refresh: nil, **_opts)
35
+ path = (id ? "/#{index}/_doc/#{id}" : "/#{index}/_doc") + query_string(refresh:)
36
+ body = dump_json(encode_document(document))
38
37
 
39
38
  response = id ? put_request(path, body) : post_request(path, body)
40
39
  parse_json_response!(response, context: "index document #{index}/#{id}")
@@ -42,6 +41,9 @@ module Noiseless
42
41
  response&.close
43
42
  end
44
43
 
44
+ def pit_path = "_pit"
45
+ def pit_close_body(pit_id) = { id: pit_id }
46
+
45
47
  def execute_cluster_health(**_opts)
46
48
  response = get_request("/_cluster/health")
47
49
  JSON.parse(response.read)
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require_relative "http_transport"
5
4
 
6
5
  module Noiseless
7
6
  module Adapters
@@ -11,20 +10,67 @@ module Noiseless
11
10
  module EsCompatibleExecution
12
11
  include HttpTransport
13
12
 
13
+ # Pages through every hit of a query with a point in time and
14
+ # search_after, which unlike from/size has no depth limit and sees a
15
+ # consistent snapshot. The point in time is always closed.
16
+ def each_page(ast_node, keep_alive: "1m", model_class: nil, response_type: nil)
17
+ return enum_for(__method__, ast_node, keep_alive:, model_class:, response_type:) unless block_given?
18
+
19
+ Sync do
20
+ query = ast_to_hash(ast_node).except(:from)
21
+ query[:sort] = [*query[:sort], { _shard_doc: "asc" }]
22
+ pit_id = execute_open_pit(ast_node.indexes, keep_alive:)
23
+
24
+ loop do
25
+ raw = execute_pit_search(query.merge(pit: { id: pit_id, keep_alive: }))
26
+ hits = raw.dig("hits", "hits")
27
+ break if hits.empty?
28
+
29
+ yield ResponseFactory.create(raw, model_class:, response_type:)
30
+ pit_id = raw["pit_id"] || pit_id
31
+ query[:search_after] = hits.last["sort"]
32
+ end
33
+ ensure
34
+ execute_close_pit(pit_id) if pit_id
35
+ end
36
+ end
37
+
14
38
  private
15
39
 
16
- def execute_bulk(actions, **_opts)
40
+ def execute_open_pit(indexes, keep_alive:)
41
+ response = post_request("/#{indexes.join(',')}/#{pit_path}?keep_alive=#{keep_alive}", nil)
42
+ parse_json_response!(response, context: "open point in time").then { it["pit_id"] || it["id"] }
43
+ ensure
44
+ response&.close
45
+ end
46
+
47
+ def execute_close_pit(pit_id)
48
+ response = delete_request("/#{pit_path}", dump_json(pit_close_body(pit_id)))
49
+ parse_json_response!(response, context: "close point in time")
50
+ ensure
51
+ response&.close
52
+ end
53
+
54
+ def execute_pit_search(query_hash)
55
+ response = post_request("/_search", dump_json(query_hash))
56
+ parse_json_response!(response, error_class: Noiseless::SearchError, context: "point-in-time search")
57
+ ensure
58
+ response&.close
59
+ end
60
+
61
+ def execute_bulk(actions, refresh: nil, **_opts)
17
62
  body = actions.map do |action|
18
63
  if action[:index]
19
64
  action_line = { index: { _index: action[:index][:_index], _id: action[:index][:_id] } }
20
- data_line = action[:index][:data]
21
- "#{JSON.generate(action_line)}\n#{JSON.generate(data_line)}\n"
65
+ data_line = encode_document(action[:index][:data])
66
+ "#{dump_json(action_line)}\n#{dump_json(data_line)}\n"
22
67
  else
23
- "#{JSON.generate(action)}\n"
68
+ "#{dump_json(action)}\n"
24
69
  end
25
70
  end.join
26
71
 
27
- response = post_request("/_bulk", body, content_type: "application/x-ndjson")
72
+ path = "/_bulk#{query_string(refresh:, filter_path: 'errors,items.*.status,items.*.error')}"
73
+ response = post_request(path, body, content_type: "application/x-ndjson")
28
74
  parse_json_response!(response, context: "bulk")
29
75
  ensure
30
76
  response&.close
@@ -41,6 +87,12 @@ module Noiseless
41
87
  response&.close
42
88
  end
43
89
 
90
+ def query_string(refresh: nil, **params)
91
+ params = params.compact
92
+ params[:refresh] = refresh if refresh
93
+ params.empty? ? "" : "?#{params.map { |key, value| "#{key}=#{value}" }.join('&')}"
94
+ end
95
+
44
96
  def execute_refresh_index(index_name)
45
97
  response = post_request("/#{index_name}/_refresh", nil)
46
98
  parse_json_response!(response, context: "refresh index #{index_name}")
@@ -50,24 +102,22 @@ module Noiseless
50
102
 
51
103
  def execute_index_exists?(index_name)
52
104
  response = head_request("/#{index_name}")
53
- response.success?
54
- rescue StandardError
55
- false
105
+ exists_response?(response, context: "index exists #{index_name}")
56
106
  ensure
57
107
  response&.close
58
108
  end
59
109
 
60
- def execute_update_document(index, id, changes, **_opts)
61
- body = JSON.generate(doc: changes)
110
+ def execute_update_document(index, id, changes, refresh: nil, **_opts)
111
+ body = dump_json(doc: encode_document(changes))
62
112
 
63
- response = post_request("/#{index}/_update/#{id}", body)
113
+ response = post_request("/#{index}/_update/#{id}#{query_string(refresh:)}", body)
64
114
  parse_json_response!(response, context: "update document #{index}/#{id}")
65
115
  ensure
66
116
  response&.close
67
117
  end
68
118
 
69
- def execute_delete_document(index, id, **_opts)
70
- response = delete_request("/#{index}/_doc/#{id}")
119
+ def execute_delete_document(index, id, refresh: nil, **_opts)
120
+ response = delete_request("/#{index}/_doc/#{id}#{query_string(refresh:)}")
71
121
  # 404 covers both a missing document and a missing index; either way
72
122
  # the delete is idempotent.
73
123
  return { "_index" => index, "_id" => id, "result" => "not_found" } if response.status == 404
@@ -79,9 +129,7 @@ module Noiseless
79
129
 
80
130
  def execute_document_exists?(index, id)
81
131
  response = head_request("/#{index}/_doc/#{id}")
82
- response.success?
83
- rescue StandardError
84
- false
132
+ exists_response?(response, context: "document exists #{index}/#{id}")
85
133
  ensure
86
134
  response&.close
87
135
  end
@@ -19,7 +19,8 @@ module Noiseless
19
19
  SocketError,
20
20
  IOError,
21
21
  IO::TimeoutError,
22
- Timeout::Error
22
+ Timeout::Error,
23
+ Protocol::HTTP::Error
23
24
  ].freeze
24
25
 
25
26
  # Default per-operation IO timeout (seconds) for the search backend.
@@ -39,13 +40,29 @@ module Noiseless
39
40
  # deadline (long bulk imports may need a higher value or nil).
40
41
  DEFAULT_REQUEST_TIMEOUT = 30
41
42
 
42
- BufferedResponse = Struct.new(:status, :body) do
43
+ # Default cap on concurrent connections per host. Without it the pool
44
+ # opens one socket per concurrent request. Waiting for a free
45
+ # connection counts against request_timeout. Override per-connection
46
+ # with +pool_limit:+.
47
+ DEFAULT_POOL_LIMIT = 16
48
+
49
+ # json 3 serializes objects it does not know with to_s, so a Time
50
+ # became "2025-12-11 14:18:40 UTC", which search engines reject.
51
+ # Falling back to as_json keeps ActiveSupport's ISO 8601 times.
52
+ JSON_CODER = JSON::Coder.new { |object| object.respond_to?(:as_json) ? object.as_json : object.to_s }
53
+
54
+ BufferedResponse = Data.define(:status, :body) do
43
55
  def read = body
44
56
  def success? = (200..299).cover?(status)
45
57
  def close = nil
46
58
  end
47
59
 
48
- def initialize(hosts: [], timeout: DEFAULT_TIMEOUT, request_timeout: DEFAULT_REQUEST_TIMEOUT, **connection_params)
60
+ def initialize(hosts: [], timeout: DEFAULT_TIMEOUT, request_timeout: DEFAULT_REQUEST_TIMEOUT,
61
+ pool_limit: DEFAULT_POOL_LIMIT, **connection_params)
62
+ unless request_timeout.nil? || (request_timeout.is_a?(Numeric) && request_timeout.positive? && request_timeout.finite?)
63
+ raise ArgumentError, "request_timeout must be a positive finite number or nil, got #{request_timeout.inspect}"
64
+ end
65
+
49
66
  # Ensure we always have at least one host
50
67
  hosts_array = Array(hosts)
51
68
  @hosts = hosts_array.empty? ? ["http://localhost:#{default_port}"] : hosts_array
@@ -59,7 +76,7 @@ module Noiseless
59
76
  @clients = {}
60
77
  @hosts.each do |host|
61
78
  endpoint = Async::HTTP::Endpoint.parse(host, timeout: @timeout)
62
- @clients[host] = Async::HTTP::Client.new(endpoint)
79
+ @clients[host] = Async::HTTP::Client.new(endpoint, limit: pool_limit)
63
80
  end
64
81
 
65
82
  super(hosts: @hosts, **connection_params)
@@ -78,27 +95,19 @@ module Noiseless
78
95
  end
79
96
  end
80
97
 
81
- def post_request(path, body, content_type: "application/json")
82
- headers = body ? default_headers + [["content-type", content_type]] : default_headers
98
+ def post_request(path, body, content_type: "application/json") = body_request(:post, path, body, content_type)
99
+ def put_request(path, body, content_type: "application/json") = body_request(:put, path, body, content_type)
100
+ def patch_request(path, body, content_type: "application/json") = body_request(:patch, path, body, content_type)
83
101
 
84
- with_client do |client|
85
- client.post(path, headers, body)
86
- end
87
- end
88
-
89
- def put_request(path, body, content_type: "application/json")
102
+ def body_request(verb, path, body, content_type)
90
103
  headers = body ? default_headers + [["content-type", content_type]] : default_headers
91
104
 
92
105
  with_client do |client|
93
- client.put(path, headers, body)
106
+ client.public_send(verb, path, headers, body)
94
107
  end
95
108
  end
96
109
 
97
- def delete_request(path)
98
- with_client do |client|
99
- client.delete(path, default_headers)
100
- end
101
- end
110
+ def delete_request(path, body = nil) = body_request(:delete, path, body, "application/json")
102
111
 
103
112
  def head_request(path)
104
113
  with_client do |client|
@@ -146,6 +155,8 @@ module Noiseless
146
155
  "search backend unreachable#{location} (#{e.class}: #{e.message})"
147
156
  end
148
157
 
158
+ def dump_json(object) = JSON_CODER.dump(object)
159
+
149
160
  def default_headers
150
161
  [
151
162
  ["accept", "application/json"],