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.
- checksums.yaml +4 -4
- data/README.md +2 -2
- data/lib/noiseless/adapter.rb +28 -35
- data/lib/noiseless/adapters/elasticsearch.rb +0 -1
- data/lib/noiseless/adapters/execution_modules/elasticsearch_execution.rb +8 -6
- data/lib/noiseless/adapters/execution_modules/es_compatible_execution.rb +65 -17
- data/lib/noiseless/adapters/execution_modules/http_transport.rb +29 -18
- data/lib/noiseless/adapters/execution_modules/opensearch_execution.rb +102 -114
- data/lib/noiseless/adapters/execution_modules/pgvector_support.rb +42 -47
- data/lib/noiseless/adapters/execution_modules/postgresql_execution.rb +63 -324
- data/lib/noiseless/adapters/execution_modules/postgresql_query.rb +269 -0
- data/lib/noiseless/adapters/execution_modules/typesense_execution.rb +172 -296
- data/lib/noiseless/adapters/execution_modules/typesense_query.rb +143 -0
- data/lib/noiseless/adapters/indices_api.rb +3 -1
- data/lib/noiseless/adapters/open_search.rb +30 -88
- data/lib/noiseless/adapters/postgresql.rb +5 -5
- data/lib/noiseless/adapters/typesense.rb +1 -2
- data/lib/noiseless/ast/aggregation.rb +0 -23
- data/lib/noiseless/ast/aggregation_builder.rb +28 -0
- data/lib/noiseless/ast/hybrid.rb +4 -2
- data/lib/noiseless/ast/join.rb +4 -5
- data/lib/noiseless/ast/vector.rb +25 -4
- data/lib/noiseless/bulk_importer.rb +16 -27
- data/lib/noiseless/callbacks.rb +4 -30
- data/lib/noiseless/connection_manager.rb +9 -3
- data/lib/noiseless/controller_runtime.rb +37 -0
- data/lib/noiseless/document_manager.rb +21 -36
- data/lib/noiseless/embedding.rb +33 -0
- data/lib/noiseless/instrumentation.rb +2 -136
- data/lib/noiseless/introspection/mermaid_diagrams.rb +143 -0
- data/lib/noiseless/introspection/query_visualizer.rb +3 -228
- data/lib/noiseless/introspection.rb +1 -1
- data/lib/noiseless/log_subscriber.rb +103 -0
- data/lib/noiseless/model.rb +2 -2
- data/lib/noiseless/multi_search.rb +2 -4
- data/lib/noiseless/pagination.rb +3 -3
- data/lib/noiseless/query_builder.rb +5 -4
- data/lib/noiseless/railtie.rb +0 -2
- data/lib/noiseless/response/records.rb +2 -1
- data/lib/noiseless/runtime_reset_middleware.rb +1 -1
- data/lib/noiseless/search_index_update_job.rb +13 -14
- data/lib/noiseless/test_case.rb +0 -1
- data/lib/noiseless/version.rb +1 -1
- data/lib/noiseless.rb +1 -10
- metadata +31 -13
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
def
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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)
|
|
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
|
|
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:)
|
|
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)
|
|
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
|
data/lib/noiseless/ast/hybrid.rb
CHANGED
|
@@ -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
|
data/lib/noiseless/ast/join.rb
CHANGED
|
@@ -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, :
|
|
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,
|
|
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
|
data/lib/noiseless/ast/vector.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
167
|
+
def recreate_index
|
|
170
168
|
client = Noiseless.connections.client(@connection)
|
|
171
|
-
client.delete_index(index_name)
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
178
|
-
|
|
173
|
+
raise Noiseless::Error, "index #{index_name} was deleted but could not be recreated: #{result.inspect}"
|
|
174
|
+
end
|
|
179
175
|
|
|
180
|
-
|
|
181
|
-
|
|
176
|
+
def index_config
|
|
177
|
+
model_class.respond_to?(:mapping) ? MappingDefinitionProcessor.process(model_class.mapping) : {}
|
|
178
|
+
end
|
|
182
179
|
|
|
183
|
-
|
|
184
|
-
|
|
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
|