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,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "pgvector_support"
|
|
4
3
|
|
|
5
4
|
module Noiseless
|
|
6
5
|
module Adapters
|
|
@@ -10,61 +9,84 @@ module Noiseless
|
|
|
10
9
|
# and optionally pgvector for semantic search
|
|
11
10
|
module PostgresqlExecution
|
|
12
11
|
include PgvectorSupport
|
|
13
|
-
|
|
14
|
-
SIMILARITY_THRESHOLD = 0.3
|
|
15
|
-
DEFAULT_LIMIT = 20
|
|
12
|
+
include PostgresqlQuery
|
|
16
13
|
|
|
17
14
|
private
|
|
18
15
|
|
|
19
16
|
def execute_search(query_hash, model_class: nil, **)
|
|
20
17
|
model = resolve_model(query_hash[:indexes], model_class)
|
|
21
|
-
|
|
18
|
+
raise Noiseless::SearchError, "no ActiveRecord model for index #{query_hash[:indexes]&.first.inspect}" unless model
|
|
19
|
+
|
|
20
|
+
with_search_errors do
|
|
21
|
+
next execute_vector_search(model, query_hash) if query_hash[:vector] || query_hash[:hybrid]
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
scope = build_search_scope(model, query_hash)
|
|
24
|
+
total = scope.except(:order, :limit, :offset).count
|
|
25
|
+
records = apply_pagination(scope, query_hash[:paginate]).to_a
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
format_as_search_response(records, model, total: total)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
29
30
|
|
|
30
|
-
|
|
31
|
+
def with_search_errors
|
|
32
|
+
yield
|
|
33
|
+
rescue ActiveRecord::ConnectionNotEstablished => e
|
|
34
|
+
raise Noiseless::ConnectionError, "postgresql unreachable: #{e.message}"
|
|
35
|
+
rescue Noiseless::Error
|
|
36
|
+
raise
|
|
31
37
|
rescue StandardError => e
|
|
32
|
-
|
|
38
|
+
raise Noiseless::SearchError, "postgresql search failed: #{e.message}"
|
|
33
39
|
end
|
|
34
40
|
|
|
35
41
|
def execute_vector_search(model, query_hash)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
42
|
+
raise Noiseless::SearchError, "vector search requires the pgvector extension" unless pgvector_available?
|
|
43
|
+
|
|
44
|
+
scope = apply_filter_clauses(model.all, query_hash[:bool]&.filter || [], model)
|
|
45
|
+
neighbors, k = nearest(scope, query_hash)
|
|
46
|
+
|
|
47
|
+
paginate_node = query_hash[:paginate]
|
|
48
|
+
return format_vector_response(neighbors.to_a, model) unless paginate_node
|
|
49
|
+
|
|
50
|
+
records = paginate_neighbors(neighbors, paginate_node, k).to_a
|
|
51
|
+
format_vector_response(records, model, total: [scope.count, k].min)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def nearest(scope, query_hash)
|
|
55
|
+
if (hybrid = query_hash[:hybrid])
|
|
56
|
+
vector = hybrid.vector
|
|
57
|
+
neighbors = hybrid_search(scope, text_query: hybrid.text_query, embedding: vector.embedding, text_fields: hybrid.fields,
|
|
58
|
+
vector_column: vector.field, text_weight: hybrid.text_weight,
|
|
59
|
+
vector_weight: hybrid.vector_weight, limit: vector.k)
|
|
60
|
+
return [neighbors, vector.k]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
vector = query_hash[:vector]
|
|
64
|
+
[vector_search(scope, vector.embedding, column: vector.field, limit: vector.k, distance_metric: vector.distance_metric),
|
|
65
|
+
vector.k]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def paginate_neighbors(scope, paginate_node, k)
|
|
69
|
+
page = paginate_node.page || 1
|
|
70
|
+
per_page = paginate_node.per_page || DEFAULT_LIMIT
|
|
71
|
+
offset = (page - 1) * per_page
|
|
72
|
+
limit = (k - offset).clamp(0, per_page)
|
|
73
|
+
return scope.none if limit.zero?
|
|
74
|
+
|
|
75
|
+
scope.limit(limit).offset(offset)
|
|
58
76
|
end
|
|
59
77
|
|
|
60
|
-
def format_vector_response(records, model,
|
|
78
|
+
def format_vector_response(records, model, total: records.size)
|
|
61
79
|
hits = records.map do |record|
|
|
62
|
-
|
|
80
|
+
score = if record.has_attribute?(:combined_score)
|
|
81
|
+
record.combined_score
|
|
82
|
+
else
|
|
83
|
+
1.0 - (record.respond_to?(:vector_distance) ? record.vector_distance : 0)
|
|
84
|
+
end
|
|
63
85
|
{
|
|
64
86
|
"_index" => model.table_name,
|
|
65
87
|
"_id" => record.id.to_s,
|
|
66
|
-
"_score" =>
|
|
67
|
-
"_source" => record.as_json(except: [
|
|
88
|
+
"_score" => score,
|
|
89
|
+
"_source" => record.as_json(except: %w[vector_distance combined_score])
|
|
68
90
|
}
|
|
69
91
|
end
|
|
70
92
|
|
|
@@ -73,7 +95,7 @@ module Noiseless
|
|
|
73
95
|
"timed_out" => false,
|
|
74
96
|
"_shards" => { "total" => 1, "successful" => 1, "skipped" => 0, "failed" => 0 },
|
|
75
97
|
"hits" => {
|
|
76
|
-
"total" => { "value" =>
|
|
98
|
+
"total" => { "value" => total, "relation" => "eq" },
|
|
77
99
|
"max_score" => hits.first&.dig("_score"),
|
|
78
100
|
"hits" => hits
|
|
79
101
|
}
|
|
@@ -101,8 +123,6 @@ module Noiseless
|
|
|
101
123
|
def execute_index_exists?(index_name)
|
|
102
124
|
model = resolve_model([index_name])
|
|
103
125
|
model.present? && model.table_exists?
|
|
104
|
-
rescue StandardError
|
|
105
|
-
false
|
|
106
126
|
end
|
|
107
127
|
|
|
108
128
|
# Document writes are no-ops: the table IS the index, so queries always
|
|
@@ -124,8 +144,6 @@ module Noiseless
|
|
|
124
144
|
def execute_document_exists?(index, id)
|
|
125
145
|
model = resolve_model([index])
|
|
126
146
|
model&.exists?(id: id) || false
|
|
127
|
-
rescue StandardError
|
|
128
|
-
false
|
|
129
147
|
end
|
|
130
148
|
|
|
131
149
|
def execute_cluster_health(**)
|
|
@@ -144,184 +162,6 @@ module Noiseless
|
|
|
144
162
|
}
|
|
145
163
|
end
|
|
146
164
|
|
|
147
|
-
# Query building methods
|
|
148
|
-
|
|
149
|
-
def build_search_scope(model, query_hash)
|
|
150
|
-
scope = model.all
|
|
151
|
-
|
|
152
|
-
# Apply must clauses (full-text search)
|
|
153
|
-
scope = apply_must_clauses(scope, query_hash[:bool]&.must || [], model)
|
|
154
|
-
|
|
155
|
-
# Apply filter clauses (exact matches)
|
|
156
|
-
scope = apply_filter_clauses(scope, query_hash[:bool]&.filter || [], model)
|
|
157
|
-
|
|
158
|
-
# Apply sorting (pagination is applied by the caller, after counting)
|
|
159
|
-
apply_sorting(scope, query_hash[:sort] || [], model)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def apply_must_clauses(scope, must_nodes, model)
|
|
163
|
-
return scope if must_nodes.empty?
|
|
164
|
-
|
|
165
|
-
must_nodes.each do |node|
|
|
166
|
-
scope = case node
|
|
167
|
-
when AST::Match
|
|
168
|
-
apply_match(scope, node, model)
|
|
169
|
-
when AST::MultiMatch
|
|
170
|
-
apply_multi_match(scope, node, model)
|
|
171
|
-
when AST::Wildcard
|
|
172
|
-
apply_wildcard(scope, node)
|
|
173
|
-
when AST::Range
|
|
174
|
-
apply_range(scope, node)
|
|
175
|
-
when AST::Prefix
|
|
176
|
-
apply_prefix(scope, node)
|
|
177
|
-
else
|
|
178
|
-
scope
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
scope
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def apply_match(scope, node, model)
|
|
186
|
-
field = node.field.to_s
|
|
187
|
-
value = node.value.to_s
|
|
188
|
-
|
|
189
|
-
# Mapping-only fields (denormalized into a search index, not columns)
|
|
190
|
-
# cannot be satisfied here; fail closed rather than matching everything.
|
|
191
|
-
return scope.none unless column?(model, field)
|
|
192
|
-
|
|
193
|
-
# Use pg_trgm similarity for fuzzy matching with unaccent
|
|
194
|
-
if trgm_available? && text_column?(model, field)
|
|
195
|
-
scope.where(
|
|
196
|
-
"unaccent(#{quoted_column(field)}) % unaccent(?) OR " \
|
|
197
|
-
"unaccent(#{quoted_column(field)}) ILIKE unaccent(?)",
|
|
198
|
-
value,
|
|
199
|
-
"%#{sanitize_like(value)}%"
|
|
200
|
-
)
|
|
201
|
-
else
|
|
202
|
-
# Fallback to ILIKE
|
|
203
|
-
scope.where("#{quoted_column(field)} ILIKE ?", "%#{sanitize_like(value)}%")
|
|
204
|
-
end
|
|
205
|
-
end
|
|
206
|
-
|
|
207
|
-
def apply_multi_match(scope, node, model)
|
|
208
|
-
query = node.query.to_s
|
|
209
|
-
# Drop mapping-only fields; if none of the requested fields are real
|
|
210
|
-
# columns the query cannot be satisfied — fail closed.
|
|
211
|
-
fields = node.fields.map(&:to_s).select { |field| column?(model, field) }
|
|
212
|
-
return scope.none if fields.empty?
|
|
213
|
-
|
|
214
|
-
conditions = fields.map do |field|
|
|
215
|
-
if trgm_available? && text_column?(model, field)
|
|
216
|
-
"(unaccent(#{quoted_column(field)}) % unaccent(?) OR " \
|
|
217
|
-
"unaccent(#{quoted_column(field)}) ILIKE unaccent(?))"
|
|
218
|
-
else
|
|
219
|
-
"#{quoted_column(field)} ILIKE ?"
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
params = fields.flat_map do |field|
|
|
224
|
-
if trgm_available? && text_column?(model, field)
|
|
225
|
-
[query, "%#{sanitize_like(query)}%"]
|
|
226
|
-
else
|
|
227
|
-
["%#{sanitize_like(query)}%"]
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
scope.where(conditions.join(" OR "), *params)
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def apply_wildcard(scope, node)
|
|
235
|
-
field = node.field.to_s
|
|
236
|
-
# Convert OpenSearch wildcards to SQL: * -> %, ? -> _
|
|
237
|
-
pattern = node.value.to_s.tr("*", "%").tr("?", "_")
|
|
238
|
-
|
|
239
|
-
scope.where("#{quoted_column(field)} ILIKE ?", pattern)
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def apply_range(scope, node)
|
|
243
|
-
field = quoted_column(node.field.to_s)
|
|
244
|
-
|
|
245
|
-
scope = scope.where("#{field} >= ?", node.gte) if node.gte
|
|
246
|
-
scope = scope.where("#{field} <= ?", node.lte) if node.lte
|
|
247
|
-
scope = scope.where("#{field} > ?", node.gt) if node.gt
|
|
248
|
-
scope = scope.where("#{field} < ?", node.lt) if node.lt
|
|
249
|
-
|
|
250
|
-
scope
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
def apply_prefix(scope, node)
|
|
254
|
-
scope.where("#{quoted_column(node.field.to_s)} ILIKE ?", "#{sanitize_like(node.value)}%")
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
def apply_filter_clauses(scope, filter_nodes, model = nil)
|
|
258
|
-
return scope if filter_nodes.empty?
|
|
259
|
-
|
|
260
|
-
filter_nodes.each do |node|
|
|
261
|
-
value = node.value
|
|
262
|
-
|
|
263
|
-
scope = if value.is_a?(Hash) && value[:geo_distance]
|
|
264
|
-
apply_geo_filter(scope, node)
|
|
265
|
-
elsif model && !column?(model, node.field.to_s)
|
|
266
|
-
# A filter on a mapping-only field cannot be enforced;
|
|
267
|
-
# silently dropping it would broaden results, so fail closed.
|
|
268
|
-
scope.none
|
|
269
|
-
else
|
|
270
|
-
scope.where(node.field => value)
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
|
|
274
|
-
scope
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def apply_geo_filter(scope, node)
|
|
278
|
-
# Requires PostGIS
|
|
279
|
-
geo_config = node.value[:geo_distance]
|
|
280
|
-
distance = geo_config[:distance]
|
|
281
|
-
field = node.field.to_s
|
|
282
|
-
|
|
283
|
-
# Find the geo point in config
|
|
284
|
-
geo_point = geo_config.find { |_k, v| v.is_a?(Hash) && v[:lat] && v[:lon] }&.last
|
|
285
|
-
return scope unless geo_point
|
|
286
|
-
|
|
287
|
-
# Use PostGIS ST_DWithin for efficient geo filtering
|
|
288
|
-
scope.where(
|
|
289
|
-
"ST_DWithin(#{field}::geography, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)",
|
|
290
|
-
geo_point[:lon],
|
|
291
|
-
geo_point[:lat],
|
|
292
|
-
parse_distance(distance)
|
|
293
|
-
)
|
|
294
|
-
rescue StandardError
|
|
295
|
-
# If PostGIS not available, skip geo filter
|
|
296
|
-
scope
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
def apply_sorting(scope, sort_nodes, model = nil)
|
|
300
|
-
return scope if sort_nodes.empty?
|
|
301
|
-
|
|
302
|
-
order_clauses = sort_nodes.filter_map do |node|
|
|
303
|
-
field = node.field.to_s
|
|
304
|
-
# Sorting on a mapping-only field is cosmetic — drop it instead of
|
|
305
|
-
# erroring the whole query.
|
|
306
|
-
next if model && !column?(model, field)
|
|
307
|
-
|
|
308
|
-
direction = node.direction.to_s.upcase == "DESC" ? "DESC" : "ASC"
|
|
309
|
-
"#{quoted_column(field)} #{direction}"
|
|
310
|
-
end
|
|
311
|
-
return scope if order_clauses.empty?
|
|
312
|
-
|
|
313
|
-
scope.order(Arel.sql(order_clauses.join(", ")))
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
def apply_pagination(scope, paginate_node)
|
|
317
|
-
page = paginate_node&.page || 1
|
|
318
|
-
per_page = paginate_node&.per_page || DEFAULT_LIMIT
|
|
319
|
-
|
|
320
|
-
offset = (page - 1) * per_page
|
|
321
|
-
|
|
322
|
-
scope.limit(per_page).offset(offset)
|
|
323
|
-
end
|
|
324
|
-
|
|
325
165
|
# Response formatting
|
|
326
166
|
|
|
327
167
|
def format_as_search_response(records, model, total: records.size)
|
|
@@ -346,33 +186,6 @@ module Noiseless
|
|
|
346
186
|
}
|
|
347
187
|
end
|
|
348
188
|
|
|
349
|
-
def empty_response
|
|
350
|
-
{
|
|
351
|
-
"took" => 0,
|
|
352
|
-
"timed_out" => false,
|
|
353
|
-
"_shards" => { "total" => 1, "successful" => 1, "skipped" => 0, "failed" => 0 },
|
|
354
|
-
"hits" => {
|
|
355
|
-
"total" => { "value" => 0, "relation" => "eq" },
|
|
356
|
-
"max_score" => nil,
|
|
357
|
-
"hits" => []
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
end
|
|
361
|
-
|
|
362
|
-
def error_response(error)
|
|
363
|
-
{
|
|
364
|
-
"took" => 0,
|
|
365
|
-
"timed_out" => false,
|
|
366
|
-
"_shards" => { "total" => 1, "successful" => 0, "skipped" => 0, "failed" => 1 },
|
|
367
|
-
"hits" => {
|
|
368
|
-
"total" => { "value" => 0, "relation" => "eq" },
|
|
369
|
-
"max_score" => nil,
|
|
370
|
-
"hits" => []
|
|
371
|
-
},
|
|
372
|
-
"error" => { "type" => error.class.name, "reason" => error.message }
|
|
373
|
-
}
|
|
374
|
-
end
|
|
375
|
-
|
|
376
189
|
# Helper methods
|
|
377
190
|
|
|
378
191
|
def resolve_model(indexes, model_class = nil)
|
|
@@ -387,57 +200,14 @@ module Noiseless
|
|
|
387
200
|
# Try cached model first (populated via register_model)
|
|
388
201
|
return @model_class_cache[index_name] if @model_class_cache&.key?(index_name)
|
|
389
202
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
model_name.constantize
|
|
393
|
-
rescue NameError
|
|
394
|
-
nil
|
|
203
|
+
candidate = index_name.to_s.classify.safe_constantize
|
|
204
|
+
candidate if active_record_model?(candidate)
|
|
395
205
|
end
|
|
396
206
|
|
|
397
207
|
def active_record_model?(klass)
|
|
398
208
|
klass.is_a?(Class) && klass < ActiveRecord::Base
|
|
399
209
|
end
|
|
400
210
|
|
|
401
|
-
def trgm_available?
|
|
402
|
-
@trgm_available ||= available_extensions.include?("pg_trgm")
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
def unaccent_available?
|
|
406
|
-
@unaccent_available ||= available_extensions.include?("unaccent")
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
def column?(model, field)
|
|
410
|
-
model.columns_hash.key?(field.to_s)
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
def text_column?(model, field)
|
|
414
|
-
column = model.columns_hash[field.to_s]
|
|
415
|
-
column && %i[string text citext].include?(column.type) && !column.array
|
|
416
|
-
end
|
|
417
|
-
|
|
418
|
-
def quoted_column(field)
|
|
419
|
-
ActiveRecord::Base.connection.quote_column_name(field)
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
def sanitize_like(value)
|
|
423
|
-
# Escape special LIKE characters
|
|
424
|
-
value.to_s.gsub(/[%_\\]/) { |x| "\\#{x}" }
|
|
425
|
-
end
|
|
426
|
-
|
|
427
|
-
def parse_distance(distance)
|
|
428
|
-
# Parse OpenSearch distance format (e.g., "10km", "5mi")
|
|
429
|
-
case distance.to_s
|
|
430
|
-
when /(\d+(?:\.\d+)?)\s*km/i
|
|
431
|
-
::Regexp.last_match(1).to_f * 1000
|
|
432
|
-
when /(\d+(?:\.\d+)?)\s*mi/i
|
|
433
|
-
::Regexp.last_match(1).to_f * 1609.34
|
|
434
|
-
when /(\d+(?:\.\d+)?)\s*m/i
|
|
435
|
-
::Regexp.last_match(1).to_f
|
|
436
|
-
else
|
|
437
|
-
distance.to_f
|
|
438
|
-
end
|
|
439
|
-
end
|
|
440
|
-
|
|
441
211
|
def process_bulk_action(action)
|
|
442
212
|
if action[:index]
|
|
443
213
|
{ "index" => execute_index_document(action[:index][:_index], action[:index][:_id], nil) }
|