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,361 +1,250 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi/escape"
3
4
  require "json"
4
- require_relative "http_transport"
5
5
 
6
6
  module Noiseless
7
7
  module Adapters
8
8
  module ExecutionModules
9
9
  module TypesenseExecution
10
10
  include HttpTransport
11
+ include TypesenseQuery
11
12
 
12
- private
13
-
14
- # Override AST to Hash conversion for Typesense query format
15
- def ast_to_hash(ast_node)
16
- result = {}
17
-
18
- # Build search query from match nodes
19
- query_parts = build_search_query(ast_node.bool)
20
- result[:q] = query_parts unless query_parts.empty?
21
-
22
- # Build query_by from multi_match nodes
23
- query_by_fields = build_query_by_fields(ast_node.bool)
24
- result[:query_by] = query_by_fields unless query_by_fields.empty?
25
-
26
- # Build filter expressions from filter nodes
27
- filter_expr = build_filter_expression(ast_node.bool)
28
- result[:filter_by] = filter_expr unless filter_expr.empty?
29
-
30
- # Build sort expressions from sort nodes
31
- sort_expr = build_sort_expression(ast_node.sort)
32
- result[:sort_by] = sort_expr unless sort_expr.empty?
33
-
34
- # Add pagination
35
- pagination = build_pagination_params(ast_node.paginate)
36
- result.merge!(pagination)
37
-
38
- # Field collapsing -> Typesense group_by
39
- if ast_node.collapse
40
- result[:group_by] = ast_node.collapse.field
41
- result[:group_limit] = 1 # Collapse shows 1 per group by default
42
- if ast_node.collapse.max_concurrent_group_searches
43
- # Typesense v30+: improve found accuracy for grouped results up to this threshold.
44
- result[:group_max_candidates] = ast_node.collapse.max_concurrent_group_searches
45
- end
46
- end
47
-
48
- # Aggregations -> Typesense facet_by
49
- if ast_node.aggregations.any?
50
- facet_fields = ast_node.aggregations
51
- .select { |agg| agg.type == :terms }
52
- .filter_map(&:field)
53
-
54
- result[:facet_by] = facet_fields.join(",") if facet_fields.any?
13
+ # Typesense stores date fields as int64, so times travel as epoch seconds.
14
+ TYPESENSE_JSON = JSON::Coder.new do |object|
15
+ if object.respond_to?(:to_time) then object.to_time.to_i
16
+ elsif object.is_a?(BigDecimal) then object.to_f
17
+ else object.respond_to?(:as_json) ? object.as_json : object.to_s
55
18
  end
19
+ end
56
20
 
57
- # Vector search -> Typesense vector_query
58
- if ast_node.vector_search?
59
- vector = ast_node.vector
60
- # Typesense uses format: "field_name:([vector], k:N)"
61
- vector_str = vector.embedding.join(",")
62
- result[:vector_query] = "#{vector.field}:([#{vector_str}], k:#{vector.k})"
63
- end
64
-
65
- # Hybrid search -> Typesense native hybrid with q + vector_query
66
- if ast_node.hybrid_search?
67
- hybrid = ast_node.hybrid
68
- vector = hybrid.vector
69
- vector_str = vector.embedding.join(",")
70
-
71
- # Typesense natively supports hybrid by combining q and vector_query
72
- result[:q] = hybrid.text_query
73
- result[:vector_query] = "#{vector.field}:([#{vector_str}], k:#{vector.k}, alpha:#{hybrid.vector_weight})"
74
- end
21
+ private
75
22
 
76
- # Image search -> Typesense image embedding search
77
- if ast_node.image_search?
78
- img = ast_node.image_query
79
- # Typesense accepts image URL or base64 directly in vector_query
80
- result[:vector_query] = "#{img.field}:(#{img.image_data}, k:#{img.k})"
81
- end
23
+ def dump_json(object) = TYPESENSE_JSON.dump(object)
82
24
 
83
- # Conversational/RAG search
84
- if ast_node.conversational?
85
- conv = ast_node.conversation
86
- result[:conversation] = true
87
- result[:conversation_model_id] = conv.model_id
88
- result[:conversation_id] = conv.conversation_id if conv.conversation_id
89
- result[:system_prompt] = conv.system_prompt if conv.system_prompt
90
- end
25
+ def execute_search(query_hash, indexes: [], **_opts)
26
+ collections = Array(indexes).map(&:to_s)
27
+ raise Noiseless::SearchError, "Typesense search needs a collection" if collections.empty?
91
28
 
92
- # JOINs across collections
93
- if ast_node.has_joins?
94
- include_fields = ast_node.joins.map do |join_node|
95
- fields = join_node.include_fields.join(", ")
96
- "$#{join_node.collection}(#{fields})"
97
- end
98
- result[:include_fields] = include_fields.join(", ")
99
- end
29
+ aggregation_names = query_hash[:aggregation_names] || {}
30
+ params = query_hash.except(:aggregation_names, :remove_duplicates)
31
+ context = "search #{collections.join(',')}"
100
32
 
101
- # Union-search related options (Typesense v30+).
102
- result[:remove_duplicates] = ast_node.remove_duplicates unless ast_node.remove_duplicates.nil?
103
- result[:facet_sample_slope] = ast_node.facet_sample_slope unless ast_node.facet_sample_slope.nil?
104
- result[:pinned_hits] = ast_node.pinned_hits unless ast_node.pinned_hits.nil?
33
+ result = if collections.one?
34
+ body = { searches: [params.merge(collection: collections.first)] }
35
+ single = post_json!("/multi_search", body, context:).fetch("results").first
36
+ raise_search_error!(single, context) if single["error"]
37
+ single
38
+ else
39
+ body = { union: true, searches: collections.map { params.except(:page, :per_page).merge(collection: it) } }
40
+ body[:remove_duplicates] = query_hash[:remove_duplicates] unless query_hash[:remove_duplicates].nil?
41
+ post_json!("/multi_search?page=#{params[:page]}&per_page=#{params[:per_page]}", body, context:)
42
+ end
105
43
 
106
- result
44
+ search_response(result, collections.first, aggregation_names)
107
45
  end
108
46
 
109
- def build_search_query(bool_node)
110
- # Combine all match queries into a single search string
111
- queries = bool_node.must.filter_map do |node|
112
- case node
113
- when AST::Match
114
- "#{node.field}:#{node.value}"
115
- when AST::MultiMatch
116
- # For Typesense, multi_match becomes a broader search across fields
117
- node.query
118
- when AST::Range
119
- # Range queries are handled in filters, not search
120
- nil
121
- else
122
- node.respond_to?(:value) ? "#{node.field}:#{node.value}" : nil
123
- end
124
- end
125
- queries.join(" ")
47
+ def post_json!(path, body, context:)
48
+ response = post_request(path, dump_json(body))
49
+ parse_json_response!(response, error_class: Noiseless::SearchError, context:)
50
+ ensure
51
+ response&.close
126
52
  end
127
53
 
128
- def build_query_by_fields(bool_node)
129
- # Extract fields from multi_match nodes for Typesense query_by parameter
130
- fields = bool_node.must.filter_map do |node|
131
- case node
132
- when AST::MultiMatch
133
- node.fields
134
- end
135
- end.flatten.uniq
136
-
137
- fields.join(",")
54
+ def raise_search_error!(result, context)
55
+ raise Noiseless::SearchError.new("#{context}: #{result['error']}", status: result["code"])
138
56
  end
139
57
 
140
- def build_filter_expression(bool_node)
141
- # Convert filter and range nodes to Typesense filter expressions
142
- filters = bool_node.filter.map { |filter| "#{filter.field}:=#{filter.value}" }
58
+ def search_response(result, default_collection, aggregation_names)
59
+ hits = if result["grouped_hits"]
60
+ result["grouped_hits"].filter_map { it["hits"].first }
61
+ else
62
+ result["hits"] || []
63
+ end
143
64
 
144
- # Add range filters from must clause
145
- range_filters = bool_node.must.filter_map do |node|
146
- next unless node.is_a?(AST::Range)
65
+ {
66
+ "took" => result["search_time_ms"] || 0,
67
+ "timed_out" => false,
68
+ "hits" => {
69
+ "total" => { "value" => result["found"] || 0, "relation" => "eq" },
70
+ "max_score" => nil,
71
+ "hits" => hits.map { search_hit(it, default_collection) }
72
+ },
73
+ "aggregations" => facet_aggregations(result["facet_counts"], aggregation_names)
74
+ }
75
+ end
147
76
 
148
- conditions = []
149
- conditions << "#{node.field}:>#{node.gt}" if node.gt
150
- conditions << "#{node.field}:>=#{node.gte}" if node.gte
151
- conditions << "#{node.field}:<#{node.lt}" if node.lt
152
- conditions << "#{node.field}:<=#{node.lte}" if node.lte
153
- conditions.join(" && ")
154
- end
77
+ def search_hit(hit, default_collection)
78
+ score = hit.dig("hybrid_search_info", "rank_fusion_score") ||
79
+ (hit["vector_distance"] && (1.0 - hit["vector_distance"])) ||
80
+ hit["text_match"] || 1.0
155
81
 
156
- (filters + range_filters).compact.join(" && ")
82
+ {
83
+ "_index" => hit["collection"] || default_collection,
84
+ "_id" => hit.dig("document", "id"),
85
+ "_score" => score,
86
+ "_source" => hit["document"]
87
+ }
157
88
  end
158
89
 
159
- def build_sort_expression(sort_nodes)
160
- # Convert sort nodes to Typesense sort format
161
- sorts = sort_nodes.map do |sort|
162
- direction = sort.direction == :desc ? "desc" : "asc"
163
- "#{sort.field}:#{direction}"
90
+ def facet_aggregations(facet_counts, aggregation_names)
91
+ Array(facet_counts).each_with_object({}) do |facet, aggregations|
92
+ name = aggregation_names.fetch(facet["field_name"], facet["field_name"])
93
+ buckets = facet["counts"].map { { "key" => it["value"], "doc_count" => it["count"] } }
94
+ aggregations[name] = { "buckets" => buckets }
164
95
  end
165
- sorts.join(",")
166
96
  end
167
97
 
168
- def build_pagination_params(paginate_node)
169
- return { page: 1, per_page: 20 } unless paginate_node
170
-
171
- {
172
- page: paginate_node.page,
173
- per_page: paginate_node.per_page
174
- }
175
- end
98
+ def execute_bulk(actions, **_opts)
99
+ items = Array.new(actions.size)
100
+ indexed = actions.each_with_index.group_by { |action, _| action.dig(:index, :_index) || action.dig(:delete, :_index) }
176
101
 
177
- def execute_search(query_hash, collections: [], **_opts)
178
- collection_path = collections.any? ? "/collections/#{collections.first}/documents/search" : "/multi_search"
102
+ indexed.each do |collection, pairs|
103
+ imports, deletes = pairs.partition { |action, _| action[:index] }
104
+ import_documents(collection, imports, items) if imports.any?
105
+ delete_documents(collection, deletes, items) if deletes.any?
106
+ end
179
107
 
180
- # Convert query_hash to URL params for Typesense
181
- params = query_hash.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
182
- path = "#{collection_path}?#{params}"
108
+ { "items" => items, "errors" => items.any? { it.values.first["error"] } }
109
+ end
183
110
 
184
- response = get_request(path)
185
- result = JSON.parse(response.read)
111
+ def import_documents(collection, pairs, items)
112
+ jsonl = pairs.map { |action, _| dump_json(typesense_document(collection, action[:index][:data], action[:index][:_id])) }.join("\n")
113
+ response = post_request("/collections/#{collection}/documents/import?action=upsert", jsonl, content_type: "text/plain")
114
+ body = response.read
115
+ unless response.success?
116
+ raise Noiseless::RequestError.new("import #{collection}: #{error_message(body, response.status)}", status: response.status)
117
+ end
186
118
 
187
- # Convert Typesense format to Elasticsearch-like format
188
- {
189
- took: result["search_time_ms"] || 0,
190
- timed_out: false,
191
- _shards: { total: 1, successful: 1, skipped: 0, failed: 0 },
192
- hits: {
193
- total: { value: result["found"] || 0, relation: "eq" },
194
- max_score: nil,
195
- hits: (result["hits"] || []).map do |hit|
196
- {
197
- _index: collections.first || "typesense",
198
- _type: "_doc",
199
- _id: hit["document"]["id"],
200
- _score: hit["text_match"] || 1.0,
201
- _source: hit["document"]
202
- }
203
- end
204
- }
205
- }
206
- rescue StandardError => e
207
- # Return empty response on error to maintain compatibility
208
- {
209
- took: 0,
210
- timed_out: false,
211
- _shards: { total: 0, successful: 0, skipped: 0, failed: 0 },
212
- hits: {
213
- total: { value: 0, relation: "eq" },
214
- max_score: nil,
215
- hits: []
216
- },
217
- error: {
218
- type: e.class.name,
219
- reason: e.message
119
+ body.each_line.zip(pairs).each do |line, (action, position)|
120
+ result = JSON.parse(line)
121
+ items[position] = {
122
+ "index" => {
123
+ "_index" => collection,
124
+ "_id" => action[:index][:_id].to_s,
125
+ "status" => result["success"] ? 201 : result["code"],
126
+ "error" => (result["error"] unless result["success"])
127
+ }.compact
220
128
  }
221
- }
129
+ end
222
130
  ensure
223
131
  response&.close
224
132
  end
225
133
 
226
- def execute_bulk(actions, **_opts)
227
- # Typesense uses different endpoints for different operations
228
- results = actions.map do |action|
229
- if action[:index]
230
- collection = action[:index][:_index]
231
- id = action[:index][:_id]
232
- document = action[:index][:data]
233
-
234
- path = "/collections/#{collection}/documents"
235
- body = JSON.generate(document.merge(id: id))
236
-
237
- response = post_request(path, body)
238
- result = JSON.parse(response.read)
239
- response.close
240
-
241
- { index: { _id: result["id"], status: 201, result: "created" } }
242
- elsif action[:delete]
243
- collection = action[:delete][:_index]
244
- id = action[:delete][:_id]
245
-
246
- path = "/collections/#{collection}/documents/#{id}"
247
-
248
- response = delete_request(path)
249
- response.close
250
-
251
- { delete: { _id: id, status: 200, result: "deleted" } }
252
- else
253
- { error: { status: 400, error: "Unsupported action" } }
254
- end
134
+ def delete_documents(collection, pairs, items)
135
+ ids = pairs.map { |action, _| action[:delete][:_id] }
136
+ filter = CGI.escape("id:#{filter_value(ids.map(&:to_s))}")
137
+ response = delete_request("/collections/#{collection}/documents?filter_by=#{filter}")
138
+ parse_json_response!(response, context: "delete documents #{collection}")
139
+
140
+ pairs.each do |action, position|
141
+ items[position] = { "delete" => { "_index" => collection, "_id" => action[:delete][:_id].to_s, "status" => 200 } }
255
142
  end
143
+ ensure
144
+ response&.close
145
+ end
256
146
 
257
- { items: results }
258
- rescue StandardError => e
259
- { items: [], errors: true, error: { type: e.class.name, reason: e.message } }
147
+ def typesense_document(collection, document, id)
148
+ shape_document(collection, document).merge("id" => id.to_s)
260
149
  end
261
150
 
262
- def execute_create_index(collection_name, mappings: nil, **_opts)
263
- # Typesense calls indexes "collections"
264
- schema = {
265
- name: collection_name,
266
- fields: []
267
- }
151
+ def shape_document(collection, document)
152
+ arrays = array_fields(collection)
153
+ document.to_h.to_h do |key, value|
154
+ key = key.to_s
155
+ [key, arrays.include?(key) && !value.nil? && !value.is_a?(Array) ? [value] : value]
156
+ end
157
+ end
268
158
 
269
- # Convert mappings to Typesense schema if provided
270
- if mappings && mappings["properties"]
271
- schema[:fields] = mappings["properties"].map do |field_name, field_config|
272
- {
273
- name: field_name,
274
- type: map_type_to_typesense(field_config["type"] || "string"),
275
- facet: field_config["facet"] || false
276
- }
277
- end
159
+ def array_fields(collection)
160
+ (@array_fields ||= {})[collection.to_s] ||= begin
161
+ response = get_request("/collections/#{collection}")
162
+ fields = response.success? ? JSON.parse(response.read).fetch("fields") : []
163
+ fields.filter_map { it["name"] if it["type"].end_with?("[]") }.to_set
164
+ ensure
165
+ response&.close
278
166
  end
167
+ end
279
168
 
280
- body = JSON.generate(schema)
281
- response = post_request("/collections", body)
282
- result = JSON.parse(response.read)
169
+ def error_message(body, status)
170
+ parsed = JSON.parse(body)
171
+ parsed.is_a?(Hash) && parsed["message"] ? parsed["message"] : "HTTP #{status}"
172
+ rescue JSON::ParserError
173
+ "HTTP #{status}"
174
+ end
283
175
 
284
- { acknowledged: true, index: result["name"] }
285
- rescue StandardError => e
286
- { acknowledged: false, error: { type: e.class.name, reason: e.message } }
176
+ def execute_create_index(collection_name, mappings: nil, **_opts)
177
+ @array_fields&.delete(collection_name.to_s)
178
+ properties = (mappings || {}).with_indifferent_access[:properties] || {}
179
+ fields = properties.map { |name, config| typesense_field(name, config) }
180
+ fields = [{ name: ".*", type: "auto" }] if fields.empty?
181
+
182
+ response = post_request("/collections", dump_json(name: collection_name, fields:))
183
+ result = parse_json_response!(response, context: "create collection #{collection_name}")
184
+
185
+ { "acknowledged" => true, "index" => result["name"] }
287
186
  ensure
288
187
  response&.close
289
188
  end
290
189
 
190
+ def typesense_field(name, config)
191
+ type = map_type_to_typesense(config[:type].to_s)
192
+ field = { name: name.to_s, type: type, optional: true }
193
+ field[:num_dim] = config[:dims] || config[:dimension] if type == "float[]"
194
+ field[:facet] = true if config[:facet] || config[:type].to_s == "keyword"
195
+ field[:reference] = config[:reference] if config[:reference]
196
+ field
197
+ end
198
+
291
199
  def execute_delete_index(collection_name, **_opts)
200
+ @array_fields&.delete(collection_name.to_s)
292
201
  response = delete_request("/collections/#{collection_name}")
293
- JSON.parse(response.read)
202
+ return { "acknowledged" => true, "result" => "not_found" } if response.status == 404
294
203
 
295
- { acknowledged: true }
296
- rescue StandardError => e
297
- { acknowledged: false, error: { type: e.class.name, reason: e.message } }
204
+ parse_json_response!(response, context: "delete collection #{collection_name}")
205
+ { "acknowledged" => true }
298
206
  ensure
299
207
  response&.close
300
208
  end
301
209
 
302
210
  def execute_index_exists?(collection_name)
303
- response = head_request("/collections/#{collection_name}")
304
- response.success?
305
- rescue StandardError
306
- false
211
+ response = get_request("/collections/#{collection_name}")
212
+ exists_response?(response, context: "collection exists #{collection_name}")
307
213
  ensure
308
214
  response&.close
309
215
  end
310
216
 
311
217
  def execute_index_document(collection, id, document, **_opts)
312
- path = "/collections/#{collection}/documents"
313
- body = JSON.generate(document.merge(id: id))
314
-
315
- response = post_request(path, body)
316
- result = JSON.parse(response.read)
218
+ response = post_request("/collections/#{collection}/documents?action=upsert",
219
+ dump_json(typesense_document(collection, document, id)))
220
+ result = parse_json_response!(response, context: "index document #{collection}/#{id}")
317
221
 
318
- { _index: collection, _id: result["id"], result: "created" }
319
- rescue StandardError => e
320
- { _index: collection, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
222
+ { "_index" => collection, "_id" => result["id"], "result" => "upserted" }
321
223
  ensure
322
224
  response&.close
323
225
  end
324
226
 
325
227
  def execute_update_document(collection, id, changes, **_opts)
326
- # Typesense doesn't have partial updates, so we need to fetch and merge
327
- get_response = get_request("/collections/#{collection}/documents/#{id}")
328
- document = JSON.parse(get_response.read)
329
- get_response.close
330
-
331
- updated_document = document.merge(changes).merge(id: id)
332
- body = JSON.generate(updated_document)
333
-
334
- response = put_request("/collections/#{collection}/documents/#{id}", body)
335
- result = JSON.parse(response.read)
228
+ response = patch_request("/collections/#{collection}/documents/#{id}", dump_json(shape_document(collection, changes)))
229
+ result = parse_json_response!(response, context: "update document #{collection}/#{id}")
336
230
 
337
- { _index: collection, _id: result["id"], result: "updated" }
338
- rescue StandardError => e
339
- { _index: collection, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
231
+ { "_index" => collection, "_id" => result["id"], "result" => "updated" }
340
232
  ensure
341
- response&.close if defined?(response)
233
+ response&.close
342
234
  end
343
235
 
344
236
  def execute_delete_document(collection, id, **_opts)
345
- response = delete_request("/collections/#{collection}/documents/#{id}")
237
+ response = delete_request("/collections/#{collection}/documents/#{id}?ignore_not_found=true")
238
+ parse_json_response!(response, context: "delete document #{collection}/#{id}")
346
239
 
347
- { _index: collection, _id: id, result: "deleted" }
348
- rescue StandardError => e
349
- { _index: collection, _id: id, result: "error", error: { type: e.class.name, reason: e.message } }
240
+ { "_index" => collection, "_id" => id.to_s, "result" => "deleted" }
350
241
  ensure
351
242
  response&.close
352
243
  end
353
244
 
354
245
  def execute_document_exists?(collection, id)
355
- response = head_request("/collections/#{collection}/documents/#{id}")
356
- response.success?
357
- rescue StandardError
358
- false
246
+ response = get_request("/collections/#{collection}/documents/#{id}?include_fields=id")
247
+ exists_response?(response, context: "document exists #{collection}/#{id}")
359
248
  ensure
360
249
  response&.close
361
250
  end
@@ -364,7 +253,6 @@ module Noiseless
364
253
  response = get_request("/health")
365
254
  health_data = JSON.parse(response.read)
366
255
 
367
- # Convert Typesense health format to match expected format
368
256
  {
369
257
  cluster_name: "typesense",
370
258
  status: health_data["ok"] ? "green" : "red",
@@ -392,30 +280,18 @@ module Noiseless
392
280
 
393
281
  def default_headers
394
282
  headers = super
395
-
396
- # Add Typesense API key if configured
397
- if @connection_params && @connection_params[:api_key]
398
- headers << ["X-TYPESENSE-API-KEY",
399
- @connection_params[:api_key]]
400
- end
401
-
283
+ headers << ["X-TYPESENSE-API-KEY", @connection_params[:api_key]] if @connection_params&.dig(:api_key)
402
284
  headers
403
285
  end
404
286
 
405
- # rubocop:disable-next Lint/DuplicateBranch
406
287
  def map_type_to_typesense(elasticsearch_type)
407
- # Map Elasticsearch types to Typesense types
408
288
  case elasticsearch_type
409
- when "text", "keyword"
410
- "string"
411
- when "long", "integer", "short", "byte", "date"
412
- "int64" # date uses Unix timestamps
413
- when "double", "float", "half_float", "scaled_float"
414
- "float"
415
- when "boolean"
416
- "bool"
417
- else
418
- "string" # Default to string for unknown types
289
+ when "long", "integer", "short", "byte", "date" then "int64"
290
+ when "double", "float", "half_float", "scaled_float" then "float"
291
+ when "boolean" then "bool"
292
+ when "dense_vector", "knn_vector" then "float[]"
293
+ when "keyword" then "string[]"
294
+ else "string"
419
295
  end
420
296
  end
421
297
  end