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
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module Adapters
5
+ module ExecutionModules
6
+ # Translates the noiseless AST into Typesense search parameters.
7
+ module TypesenseQuery
8
+ private
9
+
10
+ def ast_to_hash(ast_node)
11
+ result = {}
12
+
13
+ query_parts = build_search_query(ast_node.bool)
14
+ result[:q] = query_parts.empty? ? "*" : query_parts
15
+
16
+ query_by_fields = build_query_by_fields(ast_node.bool)
17
+ result[:query_by] = query_by_fields unless query_by_fields.empty?
18
+
19
+ filter_expr = build_filter_expression(ast_node.bool)
20
+ result[:filter_by] = filter_expr unless filter_expr.empty?
21
+
22
+ sort_expr = build_sort_expression(ast_node.sort)
23
+ result[:sort_by] = sort_expr unless sort_expr.empty?
24
+
25
+ result.merge!(build_pagination_params(ast_node.paginate))
26
+ result[:enable_highlight_v1] = false
27
+
28
+ if ast_node.collapse
29
+ result[:group_by] = ast_node.collapse.field
30
+ result[:group_limit] = 1
31
+ end
32
+
33
+ terms_aggregations = ast_node.aggregations.select { it.type == :terms && it.field }
34
+ if terms_aggregations.any?
35
+ result[:facet_by] = terms_aggregations.map(&:field).uniq.join(",")
36
+ result[:aggregation_names] = terms_aggregations.to_h { [it.field, it.name] }
37
+ end
38
+
39
+ if ast_node.vector_search?
40
+ vector = ast_node.vector
41
+ result[:vector_query] = "#{vector.field}:([#{vector.embedding.join(',')}], k:#{vector.k})"
42
+ result[:exclude_fields] = vector.field.to_s
43
+ end
44
+
45
+ if ast_node.hybrid_search?
46
+ hybrid = ast_node.hybrid
47
+ vector = hybrid.vector
48
+ raise ArgumentError, "Typesense hybrid search needs fields: to run the text query against" if hybrid.fields.empty?
49
+
50
+ result[:q] = hybrid.text_query
51
+ result[:query_by] = hybrid.fields.join(",")
52
+ result[:vector_query] =
53
+ "#{vector.field}:([#{vector.embedding.join(',')}], k:#{vector.k}, alpha:#{hybrid.vector_weight})"
54
+ result[:exclude_fields] = vector.field.to_s
55
+ end
56
+
57
+ if ast_node.image_search?
58
+ img = ast_node.image_query
59
+ result[:vector_query] = "#{img.field}:([], image:#{img.image_data}, k:#{img.k})"
60
+ result[:exclude_fields] = img.field.to_s
61
+ end
62
+
63
+ if ast_node.conversational?
64
+ conv = ast_node.conversation
65
+ result[:conversation] = true
66
+ result[:conversation_model_id] = conv.model_id
67
+ result[:conversation_id] = conv.conversation_id if conv.conversation_id
68
+ end
69
+
70
+ if ast_node.has_joins?
71
+ result[:include_fields] = ast_node.joins.map { "$#{it.collection}(#{it.include_fields.presence&.join(', ') || '*'})" }.join(", ")
72
+ inner = ast_node.joins.select(&:inner_join?).map { "$#{it.collection}(id:*)" }
73
+ result[:filter_by] = [result[:filter_by], *inner].compact.join(" && ") if inner.any?
74
+ end
75
+
76
+ result[:remove_duplicates] = ast_node.remove_duplicates unless ast_node.remove_duplicates.nil?
77
+ result[:facet_sample_slope] = ast_node.facet_sample_slope unless ast_node.facet_sample_slope.nil?
78
+ result[:pinned_hits] = ast_node.pinned_hits unless ast_node.pinned_hits.nil?
79
+
80
+ result
81
+ end
82
+
83
+ def build_search_query(bool_node)
84
+ bool_node.must.filter_map do |node|
85
+ case node
86
+ when AST::Match then node.value.to_s
87
+ when AST::MultiMatch then node.query.to_s
88
+ end
89
+ end.join(" ")
90
+ end
91
+
92
+ def build_query_by_fields(bool_node)
93
+ bool_node.must.flat_map do |node|
94
+ case node
95
+ when AST::Match then [node.field.to_s]
96
+ when AST::MultiMatch then node.fields.map(&:to_s)
97
+ else []
98
+ end
99
+ end.uniq.join(",")
100
+ end
101
+
102
+ def build_filter_expression(bool_node)
103
+ filters = bool_node.filter.map { |filter| "#{filter.field}:=#{filter_value(filter.value)}" }
104
+
105
+ range_filters = bool_node.must.filter_map do |node|
106
+ next unless node.is_a?(AST::Range)
107
+
108
+ conditions = []
109
+ conditions << "#{node.field}:>#{filter_value(node.gt)}" if node.gt
110
+ conditions << "#{node.field}:>=#{filter_value(node.gte)}" if node.gte
111
+ conditions << "#{node.field}:<#{filter_value(node.lt)}" if node.lt
112
+ conditions << "#{node.field}:<=#{filter_value(node.lte)}" if node.lte
113
+ conditions.join(" && ")
114
+ end
115
+
116
+ (filters + range_filters).compact.join(" && ")
117
+ end
118
+
119
+ def filter_value(value)
120
+ return value.to_time.to_i.to_s if value.respond_to?(:to_time) && !value.is_a?(String)
121
+
122
+ case value
123
+ when Array then "[#{value.map { filter_value(it) }.join(',')}]"
124
+ when Numeric, true, false then value.to_s
125
+ else
126
+ string = value.to_s
127
+ raise ArgumentError, "Typesense filter values cannot contain backticks: #{string.inspect}" if string.include?("`")
128
+
129
+ "`#{string}`"
130
+ end
131
+ end
132
+
133
+ def build_sort_expression(sort_nodes)
134
+ sort_nodes.map { |sort| "#{sort.field}:#{sort.direction == :desc ? 'desc' : 'asc'}" }.join(",")
135
+ end
136
+
137
+ def build_pagination_params(paginate_node)
138
+ { page: paginate_node&.page || 1, per_page: paginate_node&.per_page || 20 }
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -9,7 +9,9 @@ module Noiseless
9
9
  end
10
10
 
11
11
  def get(index:)
12
- @adapter.execute_index_exists?(index) ? { index => {} } : raise("Index not found")
12
+ raise Noiseless::Error, "Index not found: #{index}" unless @adapter.index_exists?(index).wait
13
+
14
+ { index => {} }
13
15
  end
14
16
 
15
17
  def stats(index:)
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "execution_modules/opensearch_execution"
4
3
 
5
4
  module Noiseless
6
5
  module Adapters
@@ -10,152 +9,95 @@ module Noiseless
10
9
  ClusterAPI = Adapters::ClusterAPI
11
10
  IndicesAPI = Adapters::IndicesAPI
12
11
 
13
- # OpenSearch-specific features
14
- def point_in_time_search(ast_node, pit_id:, **)
15
- query_hash = ast_to_hash(ast_node)
16
- Async do
17
- execute_point_in_time_search(query_hash, pit_id: pit_id, **)
18
- end
19
- end
20
-
21
12
  def search_template(template_id:, params: {}, **)
22
13
  Async do
23
14
  execute_search_template(template_id: template_id, params: params, **)
24
15
  end
25
16
  end
26
17
 
27
- # Cluster health API - needed for Rails healthcheck
28
18
  def cluster
29
19
  @cluster ||= ClusterAPI.new(self)
30
20
  end
31
21
 
32
- # Indices API - needed for index management operations
33
22
  def indices
34
23
  @indices ||= IndicesAPI.new(self)
35
24
  end
36
25
 
37
- # Search Pipelines API - OpenSearch 3.x feature
38
26
  def pipelines
39
27
  @pipelines ||= PipelinesAPI.new(self)
40
28
  end
41
29
 
42
- # Query Rules API - OpenSearch 3.x feature
43
30
  def rules
44
31
  @rules ||= RulesAPI.new(self)
45
32
  end
46
33
 
47
- # Raw search for CommonShare compatibility
34
+ def workload_groups
35
+ @workload_groups ||= WorkloadGroupsAPI.new(self)
36
+ end
37
+
48
38
  def search_raw(query_body, indexes: [], **)
49
39
  Async do
50
40
  execute_search(query_body, indexes: indexes, **)
51
41
  end
52
42
  end
53
43
 
54
- # Search Pipelines API for OpenSearch 3.x
55
- # Pipelines can include request and response processors for neural search, reranking, etc.
56
44
  class PipelinesAPI
57
45
  def initialize(adapter)
58
46
  @adapter = adapter
59
47
  end
60
48
 
61
- # Create or update a search pipeline
62
- # @param name [String] Pipeline name
63
- # @param request_processors [Array<Hash>] Request phase processors
64
- # @param response_processors [Array<Hash>] Response phase processors
65
- # @param description [String, nil] Optional description
66
- def create(name, request_processors: [], response_processors: [], description: nil)
49
+ def create(name, request_processors: nil, response_processors: nil, phase_results_processors: nil, description: nil)
67
50
  Sync do
68
- @adapter.send(:execute_create_pipeline, name,
69
- request_processors: request_processors,
70
- response_processors: response_processors,
71
- description: description)
51
+ @adapter.send(:execute_create_pipeline, name, request_processors:, response_processors:,
52
+ phase_results_processors:, description:)
72
53
  end
73
54
  end
74
55
 
75
56
  alias put create
76
57
 
77
- # Get a specific pipeline
78
- def get(name)
79
- Sync do
80
- @adapter.send(:execute_get_pipeline, name)
81
- end
82
- end
83
-
84
- # List all pipelines
85
- def list
86
- Sync do
87
- @adapter.send(:execute_list_pipelines)
88
- end
89
- end
58
+ def get(name) = Sync { @adapter.send(:execute_get_pipeline, name) }
59
+ def list = Sync { @adapter.send(:execute_list_pipelines) }
60
+ def delete(name) = Sync { @adapter.send(:execute_delete_pipeline, name) }
61
+ def exists?(name) = Sync { @adapter.send(:execute_pipeline_exists?, name) }
90
62
 
91
63
  alias all list
92
-
93
- # Delete a pipeline
94
- def delete(name)
95
- Sync do
96
- @adapter.send(:execute_delete_pipeline, name)
97
- end
98
- end
99
-
100
- # Check if a pipeline exists
101
- def exists?(name)
102
- Sync do
103
- @adapter.send(:execute_pipeline_exists?, name)
104
- end
105
- end
106
64
  end
107
65
 
108
- # Query Rules API for OpenSearch 3.x
109
- # Rules allow pinning, boosting, or hiding specific results based on query patterns
66
+ # Rule-based auto-tagging; needs the workload-management plugin.
110
67
  class RulesAPI
111
68
  def initialize(adapter)
112
69
  @adapter = adapter
113
70
  end
114
71
 
115
- # Create or update a rule
116
- # @param feature_type [String] Feature type (e.g., 'pinned_queries')
117
- # @param rule_id [String] Unique rule identifier
118
- # @param attributes [Hash] Rule matching attributes
119
- # @param feature_value [Hash] The feature value to apply
120
- def create(feature_type, rule_id, attributes:, feature_value:)
121
- Sync do
122
- @adapter.send(:execute_create_rule, feature_type, rule_id,
123
- attributes: attributes,
124
- feature_value: feature_value)
125
- end
72
+ def create(feature_type, description:, value:, **attributes)
73
+ Sync { @adapter.send(:execute_create_rule, feature_type, description:, value:, **attributes) }
126
74
  end
127
75
 
128
- alias put create
129
-
130
- # Get a specific rule
131
- def get(feature_type, rule_id)
132
- Sync do
133
- @adapter.send(:execute_get_rule, feature_type, rule_id)
134
- end
135
- end
76
+ def update(feature_type, id, **changes) = Sync { @adapter.send(:execute_update_rule, feature_type, id, **changes) }
77
+ def get(feature_type, id) = Sync { @adapter.send(:execute_get_rule, feature_type, id) }
136
78
 
137
- # List rules for a feature type
138
79
  def list(feature_type, search_after: nil)
139
- Sync do
140
- @adapter.send(:execute_list_rules, feature_type, search_after: search_after)
141
- end
80
+ Sync { @adapter.send(:execute_list_rules, feature_type, search_after:) }
142
81
  end
143
82
 
83
+ def delete(feature_type, id) = Sync { @adapter.send(:execute_delete_rule, feature_type, id) }
84
+ def exists?(feature_type, id) = Sync { @adapter.send(:execute_rule_exists?, feature_type, id) }
85
+
144
86
  alias all list
87
+ end
145
88
 
146
- # Delete a rule
147
- def delete(feature_type, rule_id)
148
- Sync do
149
- @adapter.send(:execute_delete_rule, feature_type, rule_id)
150
- end
89
+ # Workload groups that workload_group rules point at; needs the workload-management plugin.
90
+ class WorkloadGroupsAPI
91
+ def initialize(adapter)
92
+ @adapter = adapter
151
93
  end
152
94
 
153
- # Check if a rule exists
154
- def exists?(feature_type, rule_id)
155
- Sync do
156
- @adapter.send(:execute_rule_exists?, feature_type, rule_id)
157
- end
95
+ def create(name, resource_limits:, resiliency_mode: "soft")
96
+ Sync { @adapter.send(:execute_create_workload_group, name, resource_limits:, resiliency_mode:) }
158
97
  end
98
+
99
+ def get(name) = Sync { @adapter.send(:execute_get_workload_group, name) }
100
+ def delete(name) = Sync { @adapter.send(:execute_delete_workload_group, name) }
159
101
  end
160
102
 
161
103
  private
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "execution_modules/postgresql_execution"
4
3
 
5
4
  module Noiseless
6
5
  module Adapters
@@ -21,7 +20,7 @@ module Noiseless
21
20
 
22
21
  attr_reader :model_class_cache
23
22
 
24
- def initialize(hosts: nil, **connection_params) # rubocop:disable Lint/UnusedMethodArgument
23
+ def initialize(hosts: nil, **connection_params)
25
24
  @connection_params = connection_params
26
25
  @model_class_cache = {}
27
26
 
@@ -44,7 +43,8 @@ module Noiseless
44
43
  sort: ast_node.sort,
45
44
  paginate: ast_node.paginate,
46
45
  indexes: ast_node.indexes, # maps to table/model
47
- vector: ast_node.vector # for pgvector semantic search
46
+ vector: ast_node.vector, # for pgvector semantic search
47
+ hybrid: ast_node.hybrid
48
48
  }
49
49
  end
50
50
 
@@ -52,7 +52,7 @@ module Noiseless
52
52
  def search(ast_node, model_class: nil, response_type: nil, **)
53
53
  query_hash = ast_to_hash(ast_node)
54
54
 
55
- Async do
55
+ Async(finished: false) do
56
56
  raw_response = instrument(:search, indexes: ast_node.indexes, query: query_hash) do
57
57
  execute_search(query_hash, model_class: model_class, **)
58
58
  end
@@ -112,7 +112,7 @@ module Noiseless
112
112
  { "indices" => { index => {} } }
113
113
  end
114
114
 
115
- def refresh(index:) # rubocop:disable Lint/UnusedMethodArgument
115
+ def refresh(index:)
116
116
  # No-op for PostgreSQL - queries always see latest data
117
117
  { "_shards" => { "total" => 1, "successful" => 1, "failed" => 0 } }
118
118
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "execution_modules/typesense_execution"
4
3
 
5
4
  module Noiseless
6
5
  module Adapters
@@ -20,7 +19,7 @@ module Noiseless
20
19
  end
21
20
 
22
21
  class IndicesAPI < Adapters::IndicesAPI
23
- def refresh(index: nil) # rubocop:disable Lint/UnusedMethodArgument
22
+ def refresh(index: nil)
24
23
  # Typesense doesn't require explicit refresh - documents are immediately available
25
24
  { "_shards" => { "total" => 1, "successful" => 1, "failed" => 0 } }
26
25
  end
@@ -29,28 +29,5 @@ module Noiseless
29
29
  @sub_aggregations << aggregation
30
30
  end
31
31
  end
32
-
33
- class AggregationBuilder
34
- attr_reader :aggregations
35
-
36
- def initialize
37
- @aggregations = []
38
- end
39
-
40
- def agg(name, type, field: nil, **, &)
41
- sub_aggs = []
42
- if block_given?
43
- sub_builder = AggregationBuilder.new
44
- sub_builder.instance_eval(&)
45
- sub_aggs = sub_builder.aggregations
46
- end
47
-
48
- aggregation = Aggregation.new(name, type, field: field, sub_aggregations: sub_aggs, **)
49
- @aggregations << aggregation
50
- aggregation
51
- end
52
-
53
- alias aggregation agg
54
- end
55
32
  end
56
33
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Noiseless
4
+ module AST
5
+ class AggregationBuilder
6
+ attr_reader :aggregations
7
+
8
+ def initialize
9
+ @aggregations = []
10
+ end
11
+
12
+ def agg(name, type, field: nil, **, &)
13
+ sub_aggs = []
14
+ if block_given?
15
+ sub_builder = AggregationBuilder.new
16
+ sub_builder.instance_eval(&)
17
+ sub_aggs = sub_builder.aggregations
18
+ end
19
+
20
+ aggregation = Aggregation.new(name, type, field: field, sub_aggregations: sub_aggs, **)
21
+ @aggregations << aggregation
22
+ aggregation
23
+ end
24
+
25
+ alias aggregation agg
26
+ end
27
+ end
28
+ end
@@ -5,16 +5,18 @@ module Noiseless
5
5
  # Hybrid search node combining text and vector search
6
6
  # Supports weighted combination of BM25 text scores and kNN vector scores
7
7
  class Hybrid < Node
8
- attr_reader :text_query, :vector, :text_weight, :vector_weight
8
+ attr_reader :text_query, :vector, :fields, :text_weight, :vector_weight
9
9
 
10
10
  # @param text_query [String] The text query for BM25 matching
11
11
  # @param vector [AST::Vector] The vector search node
12
+ # @param fields [Array<Symbol>] Fields the text query runs against
12
13
  # @param text_weight [Float] Weight for text search score (0.0-1.0)
13
14
  # @param vector_weight [Float] Weight for vector search score (0.0-1.0)
14
- def initialize(text_query, vector, text_weight: 0.5, vector_weight: 0.5)
15
+ def initialize(text_query, vector, fields: [], text_weight: 0.5, vector_weight: 0.5)
15
16
  super()
16
17
  @text_query = text_query
17
18
  @vector = vector
19
+ @fields = Array(fields).map(&:to_s)
18
20
  @text_weight = text_weight
19
21
  @vector_weight = vector_weight
20
22
  end
@@ -3,18 +3,17 @@
3
3
  module Noiseless
4
4
  module AST
5
5
  # Join node for cross-collection queries (Typesense feature)
6
- # Allows including related documents from other collections
6
+ # Allows including related documents from other collections. The relation
7
+ # comes from a reference field in the collection schema.
7
8
  class Join < Node
8
- attr_reader :collection, :on, :include_fields, :strategy
9
+ attr_reader :collection, :include_fields, :strategy
9
10
 
10
11
  # @param collection [String, Symbol] The collection to join
11
- # @param on [Hash] Join conditions (e.g., { foreign_key: :local_key })
12
12
  # @param include_fields [Array<String, Symbol>] Fields to include from joined collection
13
13
  # @param strategy [Symbol] Join strategy :left or :inner (default: :left)
14
- def initialize(collection, on:, include_fields: [], strategy: :left)
14
+ def initialize(collection, include_fields: [], strategy: :left)
15
15
  super()
16
16
  @collection = collection.to_s
17
- @on = on
18
17
  @include_fields = Array(include_fields).map(&:to_s)
19
18
  @strategy = strategy
20
19
  end
@@ -5,18 +5,39 @@ module Noiseless
5
5
  # Vector search node for semantic/embedding-based search
6
6
  # Used with pgvector in PostgreSQL or knn in OpenSearch
7
7
  class Vector < Node
8
+ DISTANCE_METRICS = %i[cosine l2 euclidean inner_product].freeze
9
+
8
10
  attr_reader :field, :embedding, :k, :distance_metric
9
11
 
12
+ # Embeddings are rendered into SQL literals and engine query strings, so
13
+ # anything that is not a finite number is rejected rather than escaped.
14
+ def self.coerce_embedding(embedding)
15
+ values = Array(embedding).map do |value|
16
+ number = Float(value)
17
+ raise ArgumentError, "embedding contains a non-finite value: #{value.inspect}" unless number.finite?
18
+
19
+ number
20
+ end
21
+ raise ArgumentError, "embedding must not be empty" if values.empty?
22
+
23
+ values
24
+ rescue TypeError => e
25
+ raise ArgumentError, "embedding must contain only numbers (#{e.message})"
26
+ end
27
+
10
28
  # @param field [Symbol, String] The embedding column/field
11
29
  # @param embedding [Array<Float>] The query embedding vector
12
30
  # @param k [Integer] Number of nearest neighbors (default: 10)
13
- # @param distance_metric [Symbol] :cosine, :l2, or :inner_product (default: :cosine)
31
+ # @param distance_metric [Symbol] :cosine, :l2, :euclidean or :inner_product (default: :cosine)
14
32
  def initialize(field, embedding, k: 10, distance_metric: :cosine)
15
33
  super()
16
34
  @field = field
17
- @embedding = embedding
18
- @k = k
19
- @distance_metric = distance_metric
35
+ @embedding = embedding.nil? ? nil : self.class.coerce_embedding(embedding)
36
+ @k = Integer(k)
37
+ @distance_metric = distance_metric.to_sym
38
+ return if DISTANCE_METRICS.include?(@distance_metric)
39
+
40
+ raise ArgumentError, "distance_metric must be one of #{DISTANCE_METRICS.join(', ')}, got #{distance_metric.inspect}"
20
41
  end
21
42
 
22
43
  def dimension
@@ -19,11 +19,7 @@ module Noiseless
19
19
  **)
20
20
  @errors.clear
21
21
 
22
- # Create index if force is true
23
- if force
24
- delete_index
25
- create_index
26
- end
22
+ recreate_index if force
27
23
 
28
24
  # Get records to import
29
25
  records = resolve_records(relation_or_records)
@@ -40,7 +36,7 @@ module Noiseless
40
36
  # Execute bulk operation
41
37
  begin
42
38
  client = Noiseless.connections.client(@connection)
43
- response = client.bulk(actions, refresh: refresh, **)
39
+ response = client.bulk(actions, refresh: refresh, **).wait
44
40
 
45
41
  # Check for errors in response
46
42
  collect_errors(response, processed_batch)
@@ -95,6 +91,8 @@ module Noiseless
95
91
  document = transform ? transform.call(record) : default_transform(record)
96
92
  next unless document
97
93
 
94
+ document = Embedding.wrap(document, vector_fields)
95
+
98
96
  {
99
97
  index: {
100
98
  _index: index_name,
@@ -141,7 +139,7 @@ module Noiseless
141
139
  end
142
140
 
143
141
  def collect_errors(response, batch)
144
- return unless response.is_a?(Hash) && response["items"]
142
+ return unless response.is_a?(Hash) && response["errors"] && response["items"]
145
143
 
146
144
  response["items"].each_with_index do |item, index|
147
145
  action = item.keys.first
@@ -166,30 +164,21 @@ module Noiseless
166
164
  end
167
165
  end
168
166
 
169
- def delete_index
167
+ def recreate_index
170
168
  client = Noiseless.connections.client(@connection)
171
- client.delete_index(index_name)
172
- rescue StandardError => _e
173
- # Index might not exist, which is fine
174
- nil
175
- end
169
+ client.delete_index(index_name).wait
170
+ result = client.create_index(index_name, **index_config).wait
171
+ return if result.with_indifferent_access[:acknowledged]
176
172
 
177
- def create_index
178
- return unless model_class.respond_to?(:mapping)
173
+ raise Noiseless::Error, "index #{index_name} was deleted but could not be recreated: #{result.inspect}"
174
+ end
179
175
 
180
- mapping_block = model_class.mapping
181
- return unless mapping_block
176
+ def index_config
177
+ model_class.respond_to?(:mapping) ? MappingDefinitionProcessor.process(model_class.mapping) : {}
178
+ end
182
179
 
183
- begin
184
- _client = Noiseless.connections.client(@connection)
185
- # This would need to be implemented in the adapter
186
- # client.create_index(index_name, mapping: mapping_block)
187
- rescue StandardError => e
188
- @errors << {
189
- error: "Failed to create index: #{e.message}",
190
- index: index_name
191
- }
192
- end
180
+ def vector_fields
181
+ @vector_fields ||= Embedding.vector_fields(model_class)
193
182
  end
194
183
  end
195
184
  end