parse-stack-next 5.5.6 → 5.6.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/CHANGELOG.md +225 -0
- data/lib/parse/agent/metadata_dsl.rb +10 -0
- data/lib/parse/embeddings/binding_audit.rb +211 -0
- data/lib/parse/embeddings/media_file.rb +136 -0
- data/lib/parse/embeddings/provider.rb +39 -0
- data/lib/parse/embeddings/streaming_body.rb +170 -0
- data/lib/parse/embeddings/video_source.rb +120 -0
- data/lib/parse/embeddings/voyage.rb +548 -135
- data/lib/parse/embeddings.rb +55 -0
- data/lib/parse/model/core/embed_managed.rb +26 -0
- data/lib/parse/model/core/properties.rb +37 -1
- data/lib/parse/model/core/vector_searchable.rb +30 -3
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/vector_search/hybrid.rb +120 -12
- data/lib/parse/vector_search.rb +132 -7
- metadata +5 -1
data/lib/parse/embeddings.rb
CHANGED
|
@@ -247,6 +247,9 @@ module Parse
|
|
|
247
247
|
@configuration = nil
|
|
248
248
|
@allowed_image_hosts = nil
|
|
249
249
|
@allowed_image_types = nil
|
|
250
|
+
@allowed_video_types = nil
|
|
251
|
+
@max_media_bytes = nil
|
|
252
|
+
BindingAudit.reset!
|
|
250
253
|
@trust_provider_url_fetch = nil
|
|
251
254
|
end
|
|
252
255
|
end
|
|
@@ -323,6 +326,54 @@ module Parse
|
|
|
323
326
|
@allowed_image_types ||= ImageFetch::DEFAULT_ALLOWED_IMAGE_TYPES
|
|
324
327
|
end
|
|
325
328
|
|
|
329
|
+
# Configure the MIME types {VideoSource.verify!} accepts after
|
|
330
|
+
# magic-byte sniffing. Defaults to
|
|
331
|
+
# {VideoSource::DEFAULT_ALLOWED_VIDEO_TYPES}, which is MP4 only —
|
|
332
|
+
# the sole container Voyage accepts. As with images, the sniffed
|
|
333
|
+
# type — never the `Content-Type` header — is what gets checked.
|
|
334
|
+
#
|
|
335
|
+
# Widening this does NOT widen what a provider accepts: the
|
|
336
|
+
# Voyage adapter still enforces its own format and size limits.
|
|
337
|
+
#
|
|
338
|
+
# @param types [Array<String>] MIME type strings.
|
|
339
|
+
# @return [Array<String>]
|
|
340
|
+
def allowed_video_types=(types)
|
|
341
|
+
unless types.is_a?(Array) && !types.empty? &&
|
|
342
|
+
types.all? { |t| t.is_a?(String) && t.include?("/") }
|
|
343
|
+
raise ArgumentError,
|
|
344
|
+
"Parse::Embeddings.allowed_video_types= expects a non-empty Array of " \
|
|
345
|
+
"MIME type Strings (got #{types.inspect})."
|
|
346
|
+
end
|
|
347
|
+
CONFIG_MUTEX.synchronize { @allowed_video_types = types.dup.freeze }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# @return [Array<String>] MIME allowlist for video bytes (frozen).
|
|
351
|
+
def allowed_video_types
|
|
352
|
+
@allowed_video_types ||= VideoSource::DEFAULT_ALLOWED_VIDEO_TYPES
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Per-file ceiling for streamed media, checked by
|
|
356
|
+
# {MediaFile}. Voyage documents 20 MB per image and 20 MB per
|
|
357
|
+
# video; the default matches. Streaming keeps an oversized file
|
|
358
|
+
# from exhausting memory, but the provider still rejects it, so
|
|
359
|
+
# failing locally turns a wasted upload into an immediate error.
|
|
360
|
+
#
|
|
361
|
+
# @param bytes [Integer] positive byte count.
|
|
362
|
+
# @return [Integer]
|
|
363
|
+
def max_media_bytes=(bytes)
|
|
364
|
+
unless bytes.is_a?(Integer) && bytes.positive?
|
|
365
|
+
raise ArgumentError,
|
|
366
|
+
"Parse::Embeddings.max_media_bytes= expects a positive Integer " \
|
|
367
|
+
"(got #{bytes.inspect})."
|
|
368
|
+
end
|
|
369
|
+
CONFIG_MUTEX.synchronize { @max_media_bytes = bytes }
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# @return [Integer] per-file ceiling for {MediaFile}, in bytes.
|
|
373
|
+
def max_media_bytes
|
|
374
|
+
@max_media_bytes ||= MediaFile::DEFAULT_MAX_MEDIA_BYTES
|
|
375
|
+
end
|
|
376
|
+
|
|
326
377
|
# Sentinel-gated opt-in for forwarding image URLs to embedding
|
|
327
378
|
# providers. Assign the exact {TRUST_PROVIDER_URL_FETCH_SENTINEL}
|
|
328
379
|
# String to unlock; any other value (including `true`, `1`,
|
|
@@ -590,5 +641,9 @@ require_relative "embeddings/qwen"
|
|
|
590
641
|
require_relative "embeddings/local_http"
|
|
591
642
|
require_relative "embeddings/spend_cap"
|
|
592
643
|
require_relative "embeddings/image_fetch"
|
|
644
|
+
require_relative "embeddings/video_source"
|
|
645
|
+
require_relative "embeddings/streaming_body"
|
|
646
|
+
require_relative "embeddings/media_file"
|
|
647
|
+
require_relative "embeddings/binding_audit"
|
|
593
648
|
require_relative "embeddings/cache"
|
|
594
649
|
require_relative "embeddings/batch_embedder"
|
|
@@ -642,6 +642,20 @@ module Parse
|
|
|
642
642
|
# `Parse::Embeddings.validate_image_url!`, and call
|
|
643
643
|
# `Provider#embed_image`. Digest tracking elides the provider
|
|
644
644
|
# call when the source has not changed since last save.
|
|
645
|
+
# @!visibility private
|
|
646
|
+
# Run the shared binding validator when the declared provider is
|
|
647
|
+
# actually registered. Delegates to
|
|
648
|
+
# {Parse::Embeddings::BindingAudit} so the lazy path and the
|
|
649
|
+
# explicit `audit_all!` path apply identical rules.
|
|
650
|
+
def self.audit_binding!(klass, directive)
|
|
651
|
+
provider = begin
|
|
652
|
+
Parse::Embeddings.provider(directive.provider_name)
|
|
653
|
+
rescue Parse::Embeddings::ProviderNotRegistered
|
|
654
|
+
return
|
|
655
|
+
end
|
|
656
|
+
Parse::Embeddings::BindingAudit.verify!(klass, directive.into, provider)
|
|
657
|
+
end
|
|
658
|
+
|
|
645
659
|
def self.recompute_embedding!(record, directive)
|
|
646
660
|
input = build_source_input(record, directive)
|
|
647
661
|
stored_digest = record.public_send(directive.digest_field)
|
|
@@ -658,6 +672,18 @@ module Parse
|
|
|
658
672
|
return
|
|
659
673
|
end
|
|
660
674
|
|
|
675
|
+
# Audit the binding BEFORE the digest early-return, not after.
|
|
676
|
+
# A drifted `model:` is a configuration fault, not a property of
|
|
677
|
+
# this particular record: if the check sat behind the digest
|
|
678
|
+
# gate, an unchanged record would skip it and the mismatch would
|
|
679
|
+
# stay invisible until some unrelated record happened to be
|
|
680
|
+
# edited. Saving any record with this directive should surface
|
|
681
|
+
# it. Skipped silently when the provider is not registered — the
|
|
682
|
+
# resolution below still raises for records that actually embed,
|
|
683
|
+
# so an unchanged save keeps its existing no-provider-needed
|
|
684
|
+
# behavior.
|
|
685
|
+
audit_binding!(record.class, directive)
|
|
686
|
+
|
|
661
687
|
digest = digest_for(input)
|
|
662
688
|
return if stored_digest == digest && target_present
|
|
663
689
|
|
|
@@ -159,6 +159,11 @@ module Parse
|
|
|
159
159
|
|
|
160
160
|
# @return [Hash] per-property metadata for `:vector`-typed fields.
|
|
161
161
|
# Maps property names (symbols) to a frozen options hash:
|
|
162
|
+
# Similarity functions Atlas vectorSearch accepts. Validated at
|
|
163
|
+
# declaration time so a typo surfaces when the class loads rather
|
|
164
|
+
# than as an Atlas index error much later.
|
|
165
|
+
VECTOR_SIMILARITIES = %w[euclidean cosine dotProduct].freeze
|
|
166
|
+
|
|
162
167
|
# `{ dimensions: Integer, provider: Symbol, model: String, similarity: Symbol }`.
|
|
163
168
|
# `dimensions:` is required; the rest are optional and only carry
|
|
164
169
|
# meaning for the embedding provider plumbing layered above this
|
|
@@ -396,11 +401,42 @@ module Parse
|
|
|
396
401
|
"Property #{self}##{key} :vector dimensions #{dims} exceeds max " \
|
|
397
402
|
"#{Parse::Vector::MAX_DIMENSIONS}."
|
|
398
403
|
end
|
|
404
|
+
|
|
405
|
+
# `searchable:` separates "can be stored" from "can be
|
|
406
|
+
# indexed". Parse::Vector tolerates up to 16384 dims, but
|
|
407
|
+
# Atlas caps a vectorSearch index at 8192 — so without this,
|
|
408
|
+
# a wider property is declarable, storable, and permanently
|
|
409
|
+
# unsearchable, with the failure surfacing only at query
|
|
410
|
+
# time. Default true: a :vector property exists to be
|
|
411
|
+
# searched, and opting out should be the deliberate act.
|
|
412
|
+
searchable = opts.key?(:searchable) ? opts[:searchable] : true
|
|
413
|
+
unless [true, false].include?(searchable)
|
|
414
|
+
raise ArgumentError,
|
|
415
|
+
"Property #{self}##{key} :vector `searchable:` must be true or false " \
|
|
416
|
+
"(got #{searchable.inspect})."
|
|
417
|
+
end
|
|
418
|
+
if searchable && defined?(Parse::VectorSearch) &&
|
|
419
|
+
dims > Parse::VectorSearch::MAX_DIMENSIONS
|
|
420
|
+
raise ArgumentError,
|
|
421
|
+
"Property #{self}##{key} :vector dimensions #{dims} exceeds the Atlas " \
|
|
422
|
+
"vectorSearch index cap (#{Parse::VectorSearch::MAX_DIMENSIONS}); such a " \
|
|
423
|
+
"vector can be stored but never searched. Reduce dimensions, or declare " \
|
|
424
|
+
"`searchable: false` to acknowledge it is storage-only."
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
similarity = opts[:similarity]
|
|
428
|
+
if similarity && !VECTOR_SIMILARITIES.include?(similarity.to_s)
|
|
429
|
+
raise ArgumentError,
|
|
430
|
+
"Property #{self}##{key} :vector `similarity:` must be one of " \
|
|
431
|
+
"#{VECTOR_SIMILARITIES.inspect} (got #{similarity.inspect})."
|
|
432
|
+
end
|
|
433
|
+
|
|
399
434
|
vector_properties[key] = {
|
|
400
435
|
dimensions: dims,
|
|
401
436
|
provider: opts[:provider],
|
|
402
437
|
model: opts[:model],
|
|
403
|
-
similarity:
|
|
438
|
+
similarity: similarity,
|
|
439
|
+
searchable: searchable,
|
|
404
440
|
}.freeze
|
|
405
441
|
|
|
406
442
|
validates_each key do |record, attribute, value|
|
|
@@ -149,6 +149,12 @@ module Parse
|
|
|
149
149
|
# (fields must be declared `type: "filter"` in the index).
|
|
150
150
|
# @param index [String, nil] explicit vectorSearch index name.
|
|
151
151
|
# Skips auto-discovery when given.
|
|
152
|
+
# @param candidate_limit [Integer, nil] rows Atlas returns before
|
|
153
|
+
# ACL / filter / pointer-field enforcement runs; the result is
|
|
154
|
+
# trimmed to `k` afterwards. Defaults to a multiple of `k` for
|
|
155
|
+
# any caller subject to enforcement, so heavy ACL attrition
|
|
156
|
+
# does not silently return fewer than `k` rows. Raise it when
|
|
157
|
+
# a principal can read only a small fraction of the class.
|
|
152
158
|
# @param num_candidates [Integer, nil] HNSW search width.
|
|
153
159
|
# @param max_time_ms [Integer, nil] server-side timeout.
|
|
154
160
|
# @param raw [Boolean] when true return the raw Mongo documents
|
|
@@ -192,7 +198,8 @@ module Parse
|
|
|
192
198
|
# deadline on the embed step.
|
|
193
199
|
def find_similar(vector: nil, text: nil, k: 10, field: nil, filter: nil,
|
|
194
200
|
vector_filter: nil, index: nil,
|
|
195
|
-
num_candidates: nil,
|
|
201
|
+
num_candidates: nil, candidate_limit: nil,
|
|
202
|
+
max_time_ms: nil, raw: false,
|
|
196
203
|
**scope_opts)
|
|
197
204
|
if vector.nil? && text.nil?
|
|
198
205
|
raise ArgumentError,
|
|
@@ -222,6 +229,7 @@ module Parse
|
|
|
222
229
|
query_vector: query_vector,
|
|
223
230
|
k: k,
|
|
224
231
|
num_candidates: num_candidates,
|
|
232
|
+
candidate_limit: candidate_limit,
|
|
225
233
|
filter: filter,
|
|
226
234
|
vector_filter: vector_filter,
|
|
227
235
|
index: index_name,
|
|
@@ -314,6 +322,7 @@ module Parse
|
|
|
314
322
|
query_vector: qv, field: field_sym, index: vector_index,
|
|
315
323
|
num_candidates: vec[:num_candidates], filter: vec[:filter],
|
|
316
324
|
vector_filter: vec[:vector_filter],
|
|
325
|
+
candidate_limit: vec[:candidate_limit],
|
|
317
326
|
},
|
|
318
327
|
k: k,
|
|
319
328
|
fusion: fusion,
|
|
@@ -327,9 +336,22 @@ module Parse
|
|
|
327
336
|
private
|
|
328
337
|
|
|
329
338
|
def resolve_vector_field!(field)
|
|
330
|
-
declared
|
|
339
|
+
# A property declared `searchable: false` is storage-only. That
|
|
340
|
+
# may be because it exceeds the Atlas index cap, or simply
|
|
341
|
+
# because the author opted it out. Either way, including it in
|
|
342
|
+
# resolution would let a caller reach a field no index serves,
|
|
343
|
+
# so it is excluded from auto-resolution entirely and refused
|
|
344
|
+
# with an explanation when named outright.
|
|
345
|
+
all_declared = vector_properties.keys
|
|
346
|
+
declared = all_declared.reject { |f| vector_properties[f][:searchable] == false }
|
|
347
|
+
|
|
331
348
|
if field
|
|
332
349
|
sym = field.to_sym
|
|
350
|
+
if all_declared.include?(sym) && !declared.include?(sym)
|
|
351
|
+
raise NoVectorProperty,
|
|
352
|
+
"#{self}.find_similar: field :#{sym} is declared `searchable: false` " \
|
|
353
|
+
"(storage-only, not eligible for search) and cannot be searched."
|
|
354
|
+
end
|
|
333
355
|
unless declared.include?(sym)
|
|
334
356
|
raise NoVectorProperty,
|
|
335
357
|
"#{self}.find_similar: field :#{sym} is not a :vector property " \
|
|
@@ -342,7 +364,7 @@ module Parse
|
|
|
342
364
|
end
|
|
343
365
|
if declared.empty?
|
|
344
366
|
raise NoVectorProperty,
|
|
345
|
-
"#{self}.find_similar: no :vector property declared on this class."
|
|
367
|
+
"#{self}.find_similar: no searchable :vector property declared on this class."
|
|
346
368
|
end
|
|
347
369
|
raise AmbiguousVectorField,
|
|
348
370
|
"#{self}.find_similar: class declares multiple :vector properties " \
|
|
@@ -383,6 +405,11 @@ module Parse
|
|
|
383
405
|
"on the property, or pass an explicit `vector:`."
|
|
384
406
|
end
|
|
385
407
|
provider = Parse::Embeddings.provider(provider_name)
|
|
408
|
+
# A query embedded by a different model than the stored vectors
|
|
409
|
+
# returns plausible-looking nonsense rather than an error, so
|
|
410
|
+
# the binding is verified here too — before the spend cap is
|
|
411
|
+
# charged and before any provider round-trip.
|
|
412
|
+
Parse::Embeddings::BindingAudit.verify!(self, resolved_field, provider)
|
|
386
413
|
# Spend cap: every query-embed path (find_similar(text:),
|
|
387
414
|
# hybrid_search(text:), Retrieval.retrieve) funnels through this
|
|
388
415
|
# method, so charging here closes the "direct callers bypass the
|
data/lib/parse/stack/version.rb
CHANGED
|
@@ -66,6 +66,22 @@ module Parse
|
|
|
66
66
|
# uses a comparable internal oversample.
|
|
67
67
|
DEFAULT_OVERSAMPLE_MULTIPLIER = 5
|
|
68
68
|
|
|
69
|
+
# Emitted once per {.search}, mirroring
|
|
70
|
+
# {Parse::VectorSearch::AS_NOTIFICATION_NAME} so hybrid attrition
|
|
71
|
+
# is observable through the same subscriber. Carries `method`
|
|
72
|
+
# (`:rrf_client` / `:rrf_native`), `branch_depth`,
|
|
73
|
+
# `candidate_window`, `post_filter_count`, `returned_count`, and
|
|
74
|
+
# `underfilled`.
|
|
75
|
+
#
|
|
76
|
+
# `branch_depth` is the rows each branch actually retained, and it
|
|
77
|
+
# legitimately DIFFERS between the two methods: the client path
|
|
78
|
+
# enforces ACL inside each branch, so it can retain the narrower
|
|
79
|
+
# fusion depth, while the native path enforces after
|
|
80
|
+
# `$rankFusion` and must therefore retain the full candidate
|
|
81
|
+
# window. Exact parity is not achievable while that ordering
|
|
82
|
+
# difference exists, so the number reported is the one that ran.
|
|
83
|
+
AS_NOTIFICATION_NAME = "parse.vector_search.hybrid"
|
|
84
|
+
|
|
69
85
|
# Hard ceiling on the fused result count, matching
|
|
70
86
|
# {Parse::VectorSearch::MAX_K}.
|
|
71
87
|
MAX_K = Parse::VectorSearch::MAX_K
|
|
@@ -220,7 +236,14 @@ module Parse
|
|
|
220
236
|
end
|
|
221
237
|
k_constant = fusion[:k_constant] || DEFAULT_K_CONSTANT
|
|
222
238
|
weights = fusion[:weights]
|
|
223
|
-
|
|
239
|
+
# Two distinct numbers, deliberately not one. `fusion_depth` is
|
|
240
|
+
# how many rows each branch RETAINS for RRF (bounded by
|
|
241
|
+
# VectorSearch::MAX_K, since it becomes a branch `k`);
|
|
242
|
+
# `candidate_window` is how many rows Atlas CONSIDERS before
|
|
243
|
+
# ACL enforcement (bounded by 10_000). Collapsing them lets
|
|
244
|
+
# the window be multiplied twice — once here and again inside
|
|
245
|
+
# VectorSearch.search — and lets a branch `k` exceed MAX_K.
|
|
246
|
+
fusion_depth, candidate_window = resolve_windows(k_int, vec[:candidate_limit])
|
|
224
247
|
|
|
225
248
|
# NOTE (deviation from plan §8.3): the default fuses CLIENT-SIDE.
|
|
226
249
|
# The native single-roundtrip `$rankFusion` path is OPT-IN
|
|
@@ -234,19 +257,94 @@ module Parse
|
|
|
234
257
|
# native AND the cluster supports it. Native still falls back to
|
|
235
258
|
# the client path on any execution error.
|
|
236
259
|
if method == :rrf_native && rank_fusion_supported?(collection_name)
|
|
237
|
-
|
|
260
|
+
# The native pipeline enforces ACL AFTER fusion, so its
|
|
261
|
+
# per-branch limit is the pre-ACL depth: the candidate
|
|
262
|
+
# window, not the fusion depth.
|
|
263
|
+
fused = run_native(collection_name, lex, vec, candidate_window,
|
|
238
264
|
k_constant: k_constant, weights: weights, scope_opts: scope_opts)
|
|
239
|
-
|
|
265
|
+
if fused
|
|
266
|
+
trimmed = fused.first(k_int)
|
|
267
|
+
# Native retains the full candidate window per branch,
|
|
268
|
+
# NOT the client path's fusion depth: its ACL `$match`
|
|
269
|
+
# runs after `$rankFusion`, so the branch limit and the
|
|
270
|
+
# pre-ACL window are necessarily the same number. Report
|
|
271
|
+
# what actually executed rather than the client figure.
|
|
272
|
+
emit_hybrid_stats(collection_name: collection_name, k: k_int,
|
|
273
|
+
method: :rrf_native, branch_depth: candidate_window,
|
|
274
|
+
candidate_window: candidate_window,
|
|
275
|
+
post_filter_count: fused.length,
|
|
276
|
+
returned_count: trimmed.length)
|
|
277
|
+
return trimmed
|
|
278
|
+
end
|
|
240
279
|
end
|
|
241
280
|
|
|
242
|
-
lexical_rows = run_lexical(collection_name, lex,
|
|
243
|
-
vector_rows = run_vector(collection_name, vec,
|
|
244
|
-
rrf({ lexical: lexical_rows, vector: vector_rows },
|
|
245
|
-
|
|
281
|
+
lexical_rows = run_lexical(collection_name, lex, fusion_depth, scope_opts)
|
|
282
|
+
vector_rows = run_vector(collection_name, vec, fusion_depth, candidate_window, scope_opts)
|
|
283
|
+
fused = rrf({ lexical: lexical_rows, vector: vector_rows },
|
|
284
|
+
k_constant: k_constant, weights: weights)
|
|
285
|
+
trimmed = fused.first(k_int)
|
|
286
|
+
emit_hybrid_stats(collection_name: collection_name, k: k_int,
|
|
287
|
+
method: :rrf_client, branch_depth: fusion_depth,
|
|
288
|
+
candidate_window: candidate_window,
|
|
289
|
+
post_filter_count: fused.length,
|
|
290
|
+
returned_count: trimmed.length)
|
|
291
|
+
trimmed
|
|
246
292
|
end
|
|
247
293
|
|
|
248
294
|
private
|
|
249
295
|
|
|
296
|
+
# Resolve the two windows.
|
|
297
|
+
#
|
|
298
|
+
# * `candidate_window` — rows Atlas considers before ACL. Never
|
|
299
|
+
# narrower than the plain search's window, so opting into
|
|
300
|
+
# hybrid cannot make ACL underfill worse than a straight
|
|
301
|
+
# vector search. Bounded by Atlas's 10_000 ceiling.
|
|
302
|
+
# * `fusion_depth` — rows each branch retains for RRF. This
|
|
303
|
+
# becomes a branch `k`, so it is additionally bounded by
|
|
304
|
+
# VectorSearch::MAX_K; without that bound a hybrid `k` above
|
|
305
|
+
# 100 produces a branch `k` the plain search refuses outright.
|
|
306
|
+
#
|
|
307
|
+
# An out-of-range explicit `candidate_limit` is REFUSED rather
|
|
308
|
+
# than clamped: silently shrinking a caller's stated window
|
|
309
|
+
# would hide the very underfill they were trying to avoid.
|
|
310
|
+
#
|
|
311
|
+
# @return [Array(Integer, Integer)] `[fusion_depth, candidate_window]`
|
|
312
|
+
def resolve_windows(k_int, candidate_limit)
|
|
313
|
+
window =
|
|
314
|
+
if candidate_limit
|
|
315
|
+
limit = Integer(candidate_limit)
|
|
316
|
+
if limit < k_int
|
|
317
|
+
raise ArgumentError,
|
|
318
|
+
"hybrid search: vector[:candidate_limit] (#{limit}) must be >= k (#{k_int})."
|
|
319
|
+
end
|
|
320
|
+
if limit > Parse::VectorSearch::MAX_CANDIDATE_LIMIT
|
|
321
|
+
raise ArgumentError,
|
|
322
|
+
"hybrid search: vector[:candidate_limit] (#{limit}) exceeds the Atlas " \
|
|
323
|
+
"ceiling (#{Parse::VectorSearch::MAX_CANDIDATE_LIMIT})."
|
|
324
|
+
end
|
|
325
|
+
limit
|
|
326
|
+
else
|
|
327
|
+
multiplier = [DEFAULT_OVERSAMPLE_MULTIPLIER,
|
|
328
|
+
Parse::VectorSearch::DEFAULT_CANDIDATE_MULTIPLIER].max
|
|
329
|
+
[[k_int * multiplier, k_int].max,
|
|
330
|
+
Parse::VectorSearch::MAX_CANDIDATE_LIMIT].min
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
depth = [[window, Parse::VectorSearch::MAX_K].min, k_int].max
|
|
334
|
+
[depth, window]
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Mirrors the plain search's attrition telemetry. `post_filter_count`
|
|
338
|
+
# is the fused row count after each branch has enforced ACL/CLP;
|
|
339
|
+
# `underfilled` means the caller received fewer than `k`.
|
|
340
|
+
def emit_hybrid_stats(**payload)
|
|
341
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
342
|
+
|
|
343
|
+
payload[:underfilled] = payload[:returned_count] < payload[:k]
|
|
344
|
+
ActiveSupport::Notifications.instrument(AS_NOTIFICATION_NAME, payload)
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
|
|
250
348
|
# -- client-side branch execution --------------------------------
|
|
251
349
|
|
|
252
350
|
def run_lexical(collection_name, lex, oversample, scope_opts)
|
|
@@ -263,12 +361,16 @@ module Parse
|
|
|
263
361
|
)
|
|
264
362
|
end
|
|
265
363
|
|
|
266
|
-
def run_vector(collection_name, vec,
|
|
364
|
+
def run_vector(collection_name, vec, fusion_depth, candidate_window, scope_opts)
|
|
267
365
|
Parse::VectorSearch.search(
|
|
268
366
|
collection_name,
|
|
269
367
|
field: vec[:field],
|
|
270
368
|
query_vector: vec[:query_vector],
|
|
271
|
-
k
|
|
369
|
+
# `k` is what this branch keeps; `candidate_limit` is the
|
|
370
|
+
# pre-ACL window. Passing the window as `k` and letting the
|
|
371
|
+
# plain search derive its own would multiply it twice.
|
|
372
|
+
k: fusion_depth,
|
|
373
|
+
candidate_limit: candidate_window,
|
|
272
374
|
num_candidates: vec[:num_candidates],
|
|
273
375
|
filter: vec[:filter],
|
|
274
376
|
vector_filter: vec[:vector_filter],
|
|
@@ -312,11 +414,17 @@ module Parse
|
|
|
312
414
|
fusion = symbolize(fusion || {})
|
|
313
415
|
lex = symbolize(lexical || {})
|
|
314
416
|
vec = symbolize(vector || {})
|
|
315
|
-
|
|
417
|
+
# Same resolution the live path uses, so the shape this
|
|
418
|
+
# returns is the shape that actually executes.
|
|
419
|
+
_depth, candidate_window = resolve_windows(Integer(k), vec[:candidate_limit])
|
|
316
420
|
resolution = Parse::ACLScope.resolve!(scope_opts.dup, method_name: :"VectorSearch::Hybrid.search")
|
|
317
|
-
|
|
421
|
+
# `limit:` must match {#run_native}'s — the final `$limit` runs
|
|
422
|
+
# AFTER the ACL `$match`, so trimming to `k` there would
|
|
423
|
+
# reintroduce the underfill the window exists to prevent. The
|
|
424
|
+
# trim to `k` happens client-side once enforcement is done.
|
|
425
|
+
native_pipeline_for(lex, vec, candidate_window, resolution,
|
|
318
426
|
k_constant: fusion[:k_constant] || DEFAULT_K_CONSTANT,
|
|
319
|
-
weights: fusion[:weights], limit:
|
|
427
|
+
weights: fusion[:weights], limit: candidate_window)
|
|
320
428
|
end
|
|
321
429
|
|
|
322
430
|
def native_pipeline_for(lex, vec, oversample, resolution, k_constant:, weights:, limit:)
|
data/lib/parse/vector_search.rb
CHANGED
|
@@ -95,6 +95,44 @@ module Parse
|
|
|
95
95
|
# one. Atlas's guidance: numCandidates ≥ 10 × limit, ≤ 10_000.
|
|
96
96
|
DEFAULT_NUM_CANDIDATES_MULTIPLIER = 20
|
|
97
97
|
|
|
98
|
+
# How far past `k` the `$vectorSearch.limit` is raised when
|
|
99
|
+
# something downstream can drop rows (ACL enforcement, a
|
|
100
|
+
# caller-supplied `filter`, `protectedFields`, or pointer-field
|
|
101
|
+
# filtering). Atlas applies `limit` BEFORE any of those run, so
|
|
102
|
+
# without overfetching a caller who can read 2 of the top 10 asks
|
|
103
|
+
# for 10 and receives 2 — even when hundreds of readable matches
|
|
104
|
+
# exist further down the ranking.
|
|
105
|
+
#
|
|
106
|
+
# This is a mitigation, not a guarantee: a sufficiently selective
|
|
107
|
+
# ACL can still exhaust any finite candidate window. Deterministic
|
|
108
|
+
# fill would require `_rperm` declared as `type: "filter"` in the
|
|
109
|
+
# Atlas index (so the prefilter enforces visibility) or iterative
|
|
110
|
+
# candidate expansion. The instrumentation emitted by {.search}
|
|
111
|
+
# exists so an underfill is observable rather than silent.
|
|
112
|
+
DEFAULT_CANDIDATE_MULTIPLIER = 10
|
|
113
|
+
|
|
114
|
+
# Ceiling on the internally-raised candidate window. Atlas caps
|
|
115
|
+
# `numCandidates` at 10_000 and `numCandidates` must be >= `limit`,
|
|
116
|
+
# so the candidate window cannot usefully exceed that.
|
|
117
|
+
MAX_CANDIDATE_LIMIT = 10_000
|
|
118
|
+
|
|
119
|
+
# Emitted once per {.search}. The counts are deliberately named for
|
|
120
|
+
# where they are actually measured — there is no cheap way to learn
|
|
121
|
+
# how many rows `$vectorSearch` emitted before the server-side
|
|
122
|
+
# `$match` stages ran, so no field claims to be that number:
|
|
123
|
+
#
|
|
124
|
+
# * `candidate_limit` / `num_candidates` — the requested window.
|
|
125
|
+
# * `post_filter_count` — rows returned by the pipeline, i.e. after
|
|
126
|
+
# the server-side ACL `$match` and any caller `filter`.
|
|
127
|
+
# * `post_pointer_count` — rows left after client-side redaction and
|
|
128
|
+
# pointer-field filtering.
|
|
129
|
+
# * `returned_count` — rows handed back, after trimming to `k`.
|
|
130
|
+
# * `underfilled` — the caller received fewer than `k`.
|
|
131
|
+
#
|
|
132
|
+
# Obtaining a true pre-`$match` count would require a `$facet`, at
|
|
133
|
+
# the cost of a second pass over the candidate set.
|
|
134
|
+
AS_NOTIFICATION_NAME = "parse.vector_search.search"
|
|
135
|
+
|
|
98
136
|
# Accepted {.index_drift_policy} values.
|
|
99
137
|
INDEX_DRIFT_POLICIES = %i[warn raise ignore].freeze
|
|
100
138
|
|
|
@@ -165,8 +203,9 @@ module Parse
|
|
|
165
203
|
# `_vscore` rather than `_score` so hybrid pipelines with
|
|
166
204
|
# Atlas Search don't collide on the same key).
|
|
167
205
|
def search(collection_name, field:, query_vector:, k: 10,
|
|
168
|
-
num_candidates: nil,
|
|
169
|
-
index: nil, max_time_ms: nil, **scope_opts)
|
|
206
|
+
num_candidates: nil, candidate_limit: nil, filter: nil,
|
|
207
|
+
vector_filter: nil, index: nil, max_time_ms: nil, **scope_opts)
|
|
208
|
+
candidate_limit_override = candidate_limit
|
|
170
209
|
require_available!
|
|
171
210
|
index_name = (index || @default_index)
|
|
172
211
|
if index_name.nil? || index_name.to_s.empty?
|
|
@@ -195,12 +234,62 @@ module Parse
|
|
|
195
234
|
raise ArgumentError, "k must be in 1..#{MAX_K} (got #{k_int})."
|
|
196
235
|
end
|
|
197
236
|
|
|
198
|
-
|
|
237
|
+
# Anything that can drop rows AFTER Atlas has already applied
|
|
238
|
+
# `limit` forces an overfetch, otherwise the caller silently
|
|
239
|
+
# receives fewer than `k`. Master mode with no caller filter
|
|
240
|
+
# has no attrition, so it keeps the old one-for-one cost.
|
|
241
|
+
attrition_possible = !resolution.master? || !(filter.nil? || filter.empty?)
|
|
242
|
+
candidate_limit =
|
|
243
|
+
if candidate_limit_override
|
|
244
|
+
Integer(candidate_limit_override)
|
|
245
|
+
elsif attrition_possible
|
|
246
|
+
[k_int * DEFAULT_CANDIDATE_MULTIPLIER, MAX_CANDIDATE_LIMIT].min
|
|
247
|
+
else
|
|
248
|
+
k_int
|
|
249
|
+
end
|
|
250
|
+
if candidate_limit < k_int
|
|
251
|
+
raise ArgumentError,
|
|
252
|
+
"candidate_limit (#{candidate_limit}) must be >= k (#{k_int})."
|
|
253
|
+
end
|
|
254
|
+
if candidate_limit > MAX_CANDIDATE_LIMIT
|
|
255
|
+
raise ArgumentError,
|
|
256
|
+
"candidate_limit capped at #{MAX_CANDIDATE_LIMIT} by Atlas (got #{candidate_limit})."
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ANN width stays anchored to `k`, NOT to the raised window.
|
|
260
|
+
# Deriving it from `candidate_limit` would multiply twice and
|
|
261
|
+
# silently widen the HNSW search by the candidate multiplier —
|
|
262
|
+
# a scoped k=10 would jump from 200 to 2000 candidates. The
|
|
263
|
+
# window only needs numCandidates to be at least as large as
|
|
264
|
+
# `limit`, so take whichever is greater.
|
|
265
|
+
derived_num_candidates =
|
|
266
|
+
[k_int * DEFAULT_NUM_CANDIDATES_MULTIPLIER, candidate_limit].max
|
|
267
|
+
num_candidates_int = (num_candidates || derived_num_candidates).to_i
|
|
268
|
+
num_candidates_int = MAX_CANDIDATE_LIMIT if num_candidates_int > MAX_CANDIDATE_LIMIT &&
|
|
269
|
+
num_candidates.nil?
|
|
270
|
+
|
|
271
|
+
# A caller-supplied num_candidates that predates the candidate
|
|
272
|
+
# window used to be valid whenever it was >= k. Clamping the
|
|
273
|
+
# implicit window down to it keeps those calls working rather
|
|
274
|
+
# than turning them into a new ArgumentError. An EXPLICIT
|
|
275
|
+
# candidate_limit still conflicts loudly — that combination can
|
|
276
|
+
# only be a mistake.
|
|
277
|
+
if num_candidates && num_candidates_int < candidate_limit
|
|
278
|
+
if candidate_limit_override
|
|
279
|
+
raise ArgumentError,
|
|
280
|
+
"num_candidates (#{num_candidates_int}) must be >= candidate_limit " \
|
|
281
|
+
"(#{candidate_limit})."
|
|
282
|
+
end
|
|
283
|
+
candidate_limit = [num_candidates_int, k_int].max
|
|
284
|
+
end
|
|
285
|
+
|
|
199
286
|
if num_candidates_int < k_int
|
|
200
|
-
raise ArgumentError,
|
|
287
|
+
raise ArgumentError,
|
|
288
|
+
"num_candidates (#{num_candidates_int}) must be >= k (#{k_int})."
|
|
201
289
|
end
|
|
202
|
-
if num_candidates_int >
|
|
203
|
-
raise ArgumentError,
|
|
290
|
+
if num_candidates_int > MAX_CANDIDATE_LIMIT
|
|
291
|
+
raise ArgumentError,
|
|
292
|
+
"num_candidates capped at #{MAX_CANDIDATE_LIMIT} by Atlas (got #{num_candidates_int})."
|
|
204
293
|
end
|
|
205
294
|
|
|
206
295
|
validated_vector = validate_query_vector!(query_vector)
|
|
@@ -223,7 +312,9 @@ module Parse
|
|
|
223
312
|
"path" => path,
|
|
224
313
|
"queryVector" => validated_vector,
|
|
225
314
|
"numCandidates" => num_candidates_int,
|
|
226
|
-
|
|
315
|
+
# Deliberately the raised candidate window, not `k` — the
|
|
316
|
+
# result set is trimmed to `k` after enforcement runs.
|
|
317
|
+
"limit" => candidate_limit,
|
|
227
318
|
}
|
|
228
319
|
vs_stage["filter"] = vector_filter if vector_filter && !vector_filter.empty?
|
|
229
320
|
pipeline = [{ "$vectorSearch" => vs_stage }]
|
|
@@ -248,6 +339,9 @@ module Parse
|
|
|
248
339
|
pipeline << { "$match" => filter } if filter
|
|
249
340
|
|
|
250
341
|
raw_results = run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms)
|
|
342
|
+
# Already past the server-side ACL `$match` and any caller
|
|
343
|
+
# `filter` — NOT the number $vectorSearch emitted.
|
|
344
|
+
post_filter_count = raw_results.length
|
|
251
345
|
|
|
252
346
|
# Post-fetch enforcement: walk the rows the same way
|
|
253
347
|
# Parse::MongoDB.aggregate would. Master mode skips every
|
|
@@ -266,9 +360,40 @@ module Parse
|
|
|
266
360
|
# every mode, master included, so `_hashed_password` /
|
|
267
361
|
# `_session_token` can never surface through this entry point.
|
|
268
362
|
raw_results.map! { |doc| Parse::PipelineSecurity.strip_internal_fields(doc) }
|
|
363
|
+
|
|
364
|
+
# Trim only AFTER every enforcement layer has run — that
|
|
365
|
+
# ordering is the whole point of the raised candidate window.
|
|
366
|
+
post_pointer_count = raw_results.length
|
|
367
|
+
raw_results = raw_results.first(k_int) if post_pointer_count > k_int
|
|
368
|
+
|
|
369
|
+
emit_search_stats(
|
|
370
|
+
collection_name: collection_name, k: k_int,
|
|
371
|
+
candidate_limit: candidate_limit, num_candidates: num_candidates_int,
|
|
372
|
+
post_filter_count: post_filter_count, post_pointer_count: post_pointer_count,
|
|
373
|
+
returned_count: raw_results.length, master: resolution.master?,
|
|
374
|
+
)
|
|
375
|
+
|
|
269
376
|
raw_results
|
|
270
377
|
end
|
|
271
378
|
|
|
379
|
+
# @!visibility private
|
|
380
|
+
# Emit per-search counts so ACL/filter attrition is observable.
|
|
381
|
+
# `underfilled` means the caller received fewer rows than they
|
|
382
|
+
# asked for — the signal worth alerting on, since it means either
|
|
383
|
+
# the candidate window was too small for this principal or the
|
|
384
|
+
# collection simply ran out of matches. Distinguishing those two
|
|
385
|
+
# would need a pre-`$match` count, which this deliberately does
|
|
386
|
+
# not fabricate.
|
|
387
|
+
def emit_search_stats(**payload)
|
|
388
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
389
|
+
|
|
390
|
+
payload[:pointer_attrition] =
|
|
391
|
+
payload[:post_filter_count] - payload[:post_pointer_count]
|
|
392
|
+
payload[:underfilled] = payload[:returned_count] < payload[:k]
|
|
393
|
+
ActiveSupport::Notifications.instrument(AS_NOTIFICATION_NAME, payload)
|
|
394
|
+
nil
|
|
395
|
+
end
|
|
396
|
+
|
|
272
397
|
# Validate a query vector. Public so callers (and tests) can
|
|
273
398
|
# invoke it independently of {.search}.
|
|
274
399
|
#
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: parse-stack-next
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Adrian Curtin
|
|
@@ -301,16 +301,20 @@ files:
|
|
|
301
301
|
- lib/parse/console.rb
|
|
302
302
|
- lib/parse/embeddings.rb
|
|
303
303
|
- lib/parse/embeddings/batch_embedder.rb
|
|
304
|
+
- lib/parse/embeddings/binding_audit.rb
|
|
304
305
|
- lib/parse/embeddings/cache.rb
|
|
305
306
|
- lib/parse/embeddings/cohere.rb
|
|
306
307
|
- lib/parse/embeddings/fixture.rb
|
|
307
308
|
- lib/parse/embeddings/image_fetch.rb
|
|
308
309
|
- lib/parse/embeddings/jina.rb
|
|
309
310
|
- lib/parse/embeddings/local_http.rb
|
|
311
|
+
- lib/parse/embeddings/media_file.rb
|
|
310
312
|
- lib/parse/embeddings/openai.rb
|
|
311
313
|
- lib/parse/embeddings/provider.rb
|
|
312
314
|
- lib/parse/embeddings/qwen.rb
|
|
313
315
|
- lib/parse/embeddings/spend_cap.rb
|
|
316
|
+
- lib/parse/embeddings/streaming_body.rb
|
|
317
|
+
- lib/parse/embeddings/video_source.rb
|
|
314
318
|
- lib/parse/embeddings/voyage.rb
|
|
315
319
|
- lib/parse/graphql.rb
|
|
316
320
|
- lib/parse/graphql/scalars.rb
|