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.
- 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 -293
- 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
|
@@ -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
|
|
@@ -11,33 +10,82 @@ module Noiseless
|
|
|
11
10
|
|
|
12
11
|
private
|
|
13
12
|
|
|
13
|
+
def ast_to_hash(ast_node)
|
|
14
|
+
result = super
|
|
15
|
+
|
|
16
|
+
if (knn = result.delete(:knn))
|
|
17
|
+
must = result.dig(:query, :bool, :must)
|
|
18
|
+
result[:query] = must ? { bool: { must: [*must, knn], filter: result.dig(:query, :bool, :filter) }.compact } : knn
|
|
19
|
+
result[:_source] = { excludes: [ast_node.vector.field.to_s] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if ast_node.hybrid_search?
|
|
23
|
+
hybrid = ast_node.hybrid
|
|
24
|
+
result[:query][:hybrid][:pagination_depth] = [result[:from].to_i + result[:size].to_i, hybrid.vector.k].max
|
|
25
|
+
result[:search_pipeline] ||= rrf_pipeline(hybrid)
|
|
26
|
+
result[:_source] = { excludes: [hybrid.vector.field.to_s] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
result
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def build_knn_query(vector_node, filters: nil)
|
|
33
|
+
options = { vector: vector_node.embedding, k: vector_node.k }
|
|
34
|
+
options[:filter] = { bool: { filter: filters } } if filters.present?
|
|
35
|
+
{ knn: { vector_node.field.to_s => options } }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def build_hybrid_query(hybrid_node, query)
|
|
39
|
+
raise ArgumentError, "OpenSearch hybrid search takes filters, not must clauses" if query&.dig(:bool, :must).present?
|
|
40
|
+
|
|
41
|
+
text = { multi_match: { query: hybrid_node.text_query, fields: hybrid_node.fields.presence }.compact }
|
|
42
|
+
hybrid = { queries: [text, build_knn_query(hybrid_node.vector)] }
|
|
43
|
+
filters = query&.dig(:bool, :filter)
|
|
44
|
+
hybrid[:filter] = { bool: { filter: filters } } if filters.present?
|
|
45
|
+
{ hybrid: hybrid }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def rrf_pipeline(hybrid_node)
|
|
49
|
+
weights = [hybrid_node.text_weight, hybrid_node.vector_weight]
|
|
50
|
+
ranker = { combination: { technique: "rrf", rank_constant: 60, parameters: { weights: weights } } }
|
|
51
|
+
{ phase_results_processors: [{ "score-ranker-processor": ranker }] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def encode_vector(values) = [values.pack("e*")].pack("m0")
|
|
55
|
+
|
|
14
56
|
def execute_search(query_hash, indexes: [], **_opts)
|
|
15
57
|
index_path = indexes.any? ? indexes.join(",") : "_all"
|
|
16
|
-
|
|
17
|
-
body =
|
|
58
|
+
pipeline = query_hash[:search_pipeline]
|
|
59
|
+
body = pipeline.is_a?(Hash) ? query_hash : query_hash.except(:search_pipeline)
|
|
60
|
+
path = "/#{index_path}/_search#{query_string(search_pipeline: (pipeline unless pipeline.is_a?(Hash)))}"
|
|
18
61
|
|
|
19
|
-
response = post_request(path, body)
|
|
62
|
+
response = post_request(path, dump_json(body))
|
|
20
63
|
parse_json_response!(response, error_class: Noiseless::SearchError, context: "search #{index_path}")
|
|
21
64
|
ensure
|
|
22
65
|
response&.close
|
|
23
66
|
end
|
|
24
67
|
|
|
25
68
|
def execute_create_index(index_name, mappings: nil, settings: nil, **opts)
|
|
69
|
+
settings = (settings || {}).deep_merge(index: { knn: true }) if knn_mapping?(mappings)
|
|
26
70
|
body = opts.dup
|
|
27
71
|
body[:mappings] = mappings if mappings
|
|
28
72
|
body[:settings] = settings if settings
|
|
29
73
|
|
|
30
|
-
response = put_request("/#{index_name}", body.any? ?
|
|
74
|
+
response = put_request("/#{index_name}", body.any? ? dump_json(body) : nil)
|
|
31
75
|
parse_json_response!(response, context: "create index #{index_name}")
|
|
32
76
|
ensure
|
|
33
77
|
response&.close
|
|
34
78
|
end
|
|
35
79
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
80
|
+
def knn_mapping?(mappings)
|
|
81
|
+
properties = (mappings || {}).with_indifferent_access[:properties] || {}
|
|
82
|
+
properties.values.any? { it[:type].to_s == "knn_vector" }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def execute_index_document(index, id, document, refresh: nil, **_opts)
|
|
86
|
+
path = "/#{index}/_doc/#{id}#{query_string(refresh:)}"
|
|
39
87
|
|
|
40
|
-
response = put_request(path,
|
|
88
|
+
response = put_request(path, dump_json(encode_document(document)))
|
|
41
89
|
parse_json_response!(response, context: "index document #{index}/#{id}")
|
|
42
90
|
ensure
|
|
43
91
|
response&.close
|
|
@@ -64,25 +112,11 @@ module Noiseless
|
|
|
64
112
|
response&.close
|
|
65
113
|
end
|
|
66
114
|
|
|
67
|
-
|
|
68
|
-
def
|
|
69
|
-
# Point-in-time search for consistent pagination
|
|
70
|
-
enhanced_query = query_hash.merge(pit: { id: pit_id })
|
|
71
|
-
body = JSON.generate(enhanced_query)
|
|
72
|
-
|
|
73
|
-
response = post_request("/_search", body)
|
|
74
|
-
parse_json_response!(response, error_class: Noiseless::SearchError, context: "point-in-time search")
|
|
75
|
-
ensure
|
|
76
|
-
response&.close
|
|
77
|
-
end
|
|
115
|
+
def pit_path = "_search/point_in_time"
|
|
116
|
+
def pit_close_body(pit_id) = { pit_id: [pit_id] }
|
|
78
117
|
|
|
79
118
|
def execute_search_template(template_id:, params: {}, **_opts)
|
|
80
|
-
|
|
81
|
-
template_query = {
|
|
82
|
-
id: template_id,
|
|
83
|
-
params: params
|
|
84
|
-
}
|
|
85
|
-
body = JSON.generate(template_query)
|
|
119
|
+
body = dump_json(id: template_id, params: params)
|
|
86
120
|
|
|
87
121
|
response = post_request("/_search/template", body)
|
|
88
122
|
parse_json_response!(response, error_class: Noiseless::SearchError, context: "search template #{template_id}")
|
|
@@ -90,118 +124,72 @@ module Noiseless
|
|
|
90
124
|
response&.close
|
|
91
125
|
end
|
|
92
126
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
request_processors: request_processors,
|
|
101
|
-
response_processors: response_processors
|
|
102
|
-
}.compact
|
|
103
|
-
|
|
104
|
-
response = put_request("/_search/pipeline/#{name}", JSON.generate(body))
|
|
105
|
-
JSON.parse(response.read)
|
|
106
|
-
rescue StandardError => e
|
|
107
|
-
{ acknowledged: false, error: { type: e.class.name, reason: e.message } }
|
|
127
|
+
def execute_json(verb, path, body = nil, context:)
|
|
128
|
+
response = case verb
|
|
129
|
+
when :get then get_request(path)
|
|
130
|
+
when :delete then delete_request(path)
|
|
131
|
+
else send(:"#{verb}_request", path, body && dump_json(body))
|
|
132
|
+
end
|
|
133
|
+
parse_json_response!(response, context:)
|
|
108
134
|
ensure
|
|
109
135
|
response&.close
|
|
110
136
|
end
|
|
111
137
|
|
|
112
|
-
def
|
|
113
|
-
response = get_request(
|
|
114
|
-
|
|
115
|
-
rescue StandardError => e
|
|
116
|
-
{ error: { type: e.class.name, reason: e.message } }
|
|
138
|
+
def execute_exists?(path, context:)
|
|
139
|
+
response = get_request(path)
|
|
140
|
+
exists_response?(response, context:)
|
|
117
141
|
ensure
|
|
118
142
|
response&.close
|
|
119
143
|
end
|
|
120
144
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
{ error: { type: e.class.name, reason: e.message } }
|
|
126
|
-
ensure
|
|
127
|
-
response&.close
|
|
145
|
+
def execute_create_pipeline(name, request_processors: nil, response_processors: nil, phase_results_processors: nil,
|
|
146
|
+
description: nil)
|
|
147
|
+
body = { description:, request_processors:, response_processors:, phase_results_processors: }.compact
|
|
148
|
+
execute_json(:put, "/_search/pipeline/#{name}", body, context: "create pipeline #{name}")
|
|
128
149
|
end
|
|
129
150
|
|
|
130
|
-
def
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
{ acknowledged: false, error: { type: e.class.name, reason: e.message } }
|
|
135
|
-
ensure
|
|
136
|
-
response&.close
|
|
137
|
-
end
|
|
151
|
+
def execute_get_pipeline(name) = execute_json(:get, "/_search/pipeline/#{name}", context: "get pipeline #{name}")
|
|
152
|
+
def execute_list_pipelines = execute_json(:get, "/_search/pipeline", context: "list pipelines")
|
|
153
|
+
def execute_delete_pipeline(name) = execute_json(:delete, "/_search/pipeline/#{name}", context: "delete pipeline #{name}")
|
|
154
|
+
def execute_pipeline_exists?(name) = execute_exists?("/_search/pipeline/#{name}", context: "pipeline exists #{name}")
|
|
138
155
|
|
|
139
|
-
def
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
rescue StandardError
|
|
143
|
-
false
|
|
144
|
-
ensure
|
|
145
|
-
response&.close
|
|
156
|
+
def execute_create_rule(feature_type, description:, value:, **attributes)
|
|
157
|
+
body = { description:, **attributes, feature_type => value }
|
|
158
|
+
execute_json(:put, "/_rules/#{feature_type}", body, context: "create #{feature_type} rule")
|
|
146
159
|
end
|
|
147
160
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
161
|
+
def execute_update_rule(feature_type, id, **changes)
|
|
162
|
+
execute_json(:put, "/_rules/#{feature_type}/#{id}", changes, context: "update #{feature_type} rule #{id}")
|
|
163
|
+
end
|
|
151
164
|
|
|
152
|
-
def
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
query: attributes
|
|
156
|
-
},
|
|
157
|
-
feature_value: feature_value
|
|
158
|
-
}
|
|
165
|
+
def execute_get_rule(feature_type, id)
|
|
166
|
+
execute_json(:get, "/_rules/#{feature_type}/#{id}", context: "get #{feature_type} rule #{id}").fetch("rules").first
|
|
167
|
+
end
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
{ acknowledged: false, error: { type: e.class.name, reason: e.message } }
|
|
164
|
-
ensure
|
|
165
|
-
response&.close
|
|
169
|
+
def execute_list_rules(feature_type, search_after: nil)
|
|
170
|
+
path = "/_rules/#{feature_type}#{query_string(search_after:)}"
|
|
171
|
+
execute_json(:get, path, context: "list #{feature_type} rules").fetch("rules")
|
|
166
172
|
end
|
|
167
173
|
|
|
168
|
-
def
|
|
169
|
-
|
|
170
|
-
JSON.parse(response.read)
|
|
171
|
-
rescue StandardError => e
|
|
172
|
-
{ error: { type: e.class.name, reason: e.message } }
|
|
173
|
-
ensure
|
|
174
|
-
response&.close
|
|
174
|
+
def execute_delete_rule(feature_type, id)
|
|
175
|
+
execute_json(:delete, "/_rules/#{feature_type}/#{id}", context: "delete #{feature_type} rule #{id}")
|
|
175
176
|
end
|
|
176
177
|
|
|
177
|
-
def
|
|
178
|
-
|
|
179
|
-
|
|
178
|
+
def execute_rule_exists?(feature_type, id)
|
|
179
|
+
execute_exists?("/_rules/#{feature_type}/#{id}", context: "#{feature_type} rule exists #{id}")
|
|
180
|
+
end
|
|
180
181
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
{ rules: [], error: { type: e.class.name, reason: e.message } }
|
|
185
|
-
ensure
|
|
186
|
-
response&.close
|
|
182
|
+
def execute_create_workload_group(name, resource_limits:, resiliency_mode: "soft")
|
|
183
|
+
body = { name:, resiliency_mode:, resource_limits: }
|
|
184
|
+
execute_json(:put, "/_wlm/workload_group", body, context: "create workload group #{name}")
|
|
187
185
|
end
|
|
188
186
|
|
|
189
|
-
def
|
|
190
|
-
|
|
191
|
-
JSON.parse(response.read)
|
|
192
|
-
rescue StandardError => e
|
|
193
|
-
{ acknowledged: false, error: { type: e.class.name, reason: e.message } }
|
|
194
|
-
ensure
|
|
195
|
-
response&.close
|
|
187
|
+
def execute_get_workload_group(name)
|
|
188
|
+
execute_json(:get, "/_wlm/workload_group/#{name}", context: "get workload group #{name}").fetch("workload_groups").first
|
|
196
189
|
end
|
|
197
190
|
|
|
198
|
-
def
|
|
199
|
-
|
|
200
|
-
response.success?
|
|
201
|
-
rescue StandardError
|
|
202
|
-
false
|
|
203
|
-
ensure
|
|
204
|
-
response&.close
|
|
191
|
+
def execute_delete_workload_group(name)
|
|
192
|
+
execute_json(:delete, "/_wlm/workload_group/#{name}", context: "delete workload group #{name}")
|
|
205
193
|
end
|
|
206
194
|
end
|
|
207
195
|
end
|
|
@@ -26,9 +26,9 @@ module Noiseless
|
|
|
26
26
|
#
|
|
27
27
|
def vector_search(scope, embedding, column: :embedding, limit: 20, distance_threshold: nil,
|
|
28
28
|
distance_metric: :cosine)
|
|
29
|
-
return scope unless pgvector_available?
|
|
29
|
+
return scope.none unless pgvector_available?
|
|
30
30
|
|
|
31
|
-
vector_string =
|
|
31
|
+
vector_string = vector_literal(embedding)
|
|
32
32
|
distance_op = distance_operator(distance_metric)
|
|
33
33
|
|
|
34
34
|
# Build the query with distance calculation
|
|
@@ -63,30 +63,19 @@ module Noiseless
|
|
|
63
63
|
#
|
|
64
64
|
def hybrid_search(scope, text_query:, embedding:, text_fields:, vector_column: :embedding,
|
|
65
65
|
text_weight: 0.5, vector_weight: 0.5, limit: 20)
|
|
66
|
-
return scope unless pgvector_available?
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
#
|
|
73
|
-
scope.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
),
|
|
79
|
-
# Vector similarity (convert distance to similarity: 1 - distance for cosine)
|
|
80
|
-
"(1 - (#{quoted_column(vector_column)} <=> '#{vector_string}')) * #{vector_weight} AS vector_score",
|
|
81
|
-
# Combined score
|
|
82
|
-
"(((#{text_conditions}) / #{text_similarity_count}) * #{text_weight} + " \
|
|
83
|
-
"(1 - (#{quoted_column(vector_column)} <=> '#{vector_string}')) * #{vector_weight}) AS combined_score"
|
|
84
|
-
).where(
|
|
85
|
-
"#{text_conditions} > 0 OR #{quoted_column(vector_column)} IS NOT NULL",
|
|
86
|
-
*Array.new(text_similarity_count, text_query)
|
|
87
|
-
).order(Arel.sql("combined_score DESC"))
|
|
66
|
+
return scope.none unless pgvector_available?
|
|
67
|
+
raise ArgumentError, "hybrid_search needs text_fields to run the text query against" if text_fields.empty?
|
|
68
|
+
|
|
69
|
+
column = quoted_column(vector_column)
|
|
70
|
+
text_score = "(#{text_fields.map { "similarity(#{quoted_column(it)}, :query)" }.join(' + ')}) / #{text_fields.size}"
|
|
71
|
+
vector_score = "(1 - (#{column} <=> '#{vector_literal(embedding)}'))"
|
|
72
|
+
combined = "#{text_score} * #{Float(text_weight)} + #{vector_score} * #{Float(vector_weight)}"
|
|
73
|
+
bind = ->(sql) { scope.sanitize_sql_array([sql, { query: text_query.to_s }]) }
|
|
74
|
+
|
|
75
|
+
scope.select("#{scope.table_name}.*", Arel.sql(bind.call("#{combined} AS combined_score")))
|
|
76
|
+
.where(bind.call("#{text_score} > 0 OR #{column} IS NOT NULL"))
|
|
77
|
+
.order(Arel.sql("combined_score DESC"))
|
|
88
78
|
.limit(limit)
|
|
89
|
-
.tap { |s| s.bind_values.concat(Array.new(text_similarity_count, text_query)) }
|
|
90
79
|
end
|
|
91
80
|
|
|
92
81
|
# Execute a KNN (K-Nearest Neighbors) search
|
|
@@ -101,7 +90,7 @@ module Noiseless
|
|
|
101
90
|
def knn_search(model, embedding, k: 10, column: :embedding, filters: {})
|
|
102
91
|
return [] unless pgvector_available?
|
|
103
92
|
|
|
104
|
-
vector_string =
|
|
93
|
+
vector_string = vector_literal(embedding)
|
|
105
94
|
|
|
106
95
|
scope = model.all
|
|
107
96
|
scope = scope.where(filters) if filters.any?
|
|
@@ -124,36 +113,36 @@ module Noiseless
|
|
|
124
113
|
def store_embedding(record, embedding, column: :embedding)
|
|
125
114
|
return false unless pgvector_available?
|
|
126
115
|
|
|
127
|
-
|
|
128
|
-
record.update_column(column, vector_string)
|
|
116
|
+
record.update_column(column, vector_literal(embedding))
|
|
129
117
|
end
|
|
130
118
|
|
|
131
119
|
# Batch store embeddings
|
|
132
120
|
#
|
|
133
121
|
# @param model [Class] The ActiveRecord model
|
|
134
|
-
# @param embeddings [Hash<
|
|
122
|
+
# @param embeddings [Hash<Object, Array<Float>>] Map of primary key -> embedding
|
|
135
123
|
# @param column [Symbol] The column to store embeddings
|
|
124
|
+
# @return [Integer] Number of rows updated
|
|
136
125
|
#
|
|
137
126
|
def batch_store_embeddings(model, embeddings, column: :embedding)
|
|
138
127
|
return 0 unless pgvector_available?
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
128
|
+
return 0 if embeddings.empty?
|
|
129
|
+
|
|
130
|
+
model.with_connection do |connection|
|
|
131
|
+
table = connection.quote_table_name(model.table_name)
|
|
132
|
+
primary_key = model.primary_key
|
|
133
|
+
pk_type = model.columns_hash.fetch(primary_key.to_s).sql_type
|
|
134
|
+
|
|
135
|
+
values = embeddings.map do |id, embedding|
|
|
136
|
+
"(#{connection.quote(id)}::#{pk_type}, '#{vector_literal(embedding)}'::vector)"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
connection.exec_update(<<~SQL.squish, "Noiseless batch_store_embeddings")
|
|
140
|
+
UPDATE #{table}
|
|
141
|
+
SET #{connection.quote_column_name(column)} = v.embedding
|
|
142
|
+
FROM (VALUES #{values.join(', ')}) AS v(id, embedding)
|
|
143
|
+
WHERE #{table}.#{connection.quote_column_name(primary_key)} = v.id
|
|
144
|
+
SQL
|
|
145
|
+
end
|
|
157
146
|
end
|
|
158
147
|
|
|
159
148
|
# Find similar records to a given record
|
|
@@ -181,6 +170,12 @@ module Noiseless
|
|
|
181
170
|
|
|
182
171
|
private
|
|
183
172
|
|
|
173
|
+
# pgvector operators take no bind parameter in SELECT or ORDER BY, so
|
|
174
|
+
# the literal is interpolated; coercion guarantees it holds only floats.
|
|
175
|
+
def vector_literal(embedding)
|
|
176
|
+
"[#{AST::Vector.coerce_embedding(embedding).join(',')}]"
|
|
177
|
+
end
|
|
178
|
+
|
|
184
179
|
def distance_operator(metric)
|
|
185
180
|
case metric
|
|
186
181
|
when :l2, :euclidean
|