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.
@@ -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: opts[: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, max_time_ms: nil, raw: false,
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 = vector_properties.keys
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
@@ -6,6 +6,6 @@ module Parse
6
6
  # The Parse Server SDK for Ruby
7
7
  module Stack
8
8
  # The current version.
9
- VERSION = "5.5.6"
9
+ VERSION = "5.6.0"
10
10
  end
11
11
  end
@@ -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
- oversample = [k_int * DEFAULT_OVERSAMPLE_MULTIPLIER, k_int].max
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
- fused = run_native(collection_name, lex, vec, oversample,
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
- return fused.first(k_int) if fused
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, oversample, scope_opts)
243
- vector_rows = run_vector(collection_name, vec, oversample, scope_opts)
244
- rrf({ lexical: lexical_rows, vector: vector_rows },
245
- k_constant: k_constant, weights: weights).first(k_int)
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, oversample, scope_opts)
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: oversample,
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
- oversample = [Integer(k) * DEFAULT_OVERSAMPLE_MULTIPLIER, Integer(k)].max
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
- native_pipeline_for(lex, vec, oversample, resolution,
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: Integer(k))
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:)
@@ -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, filter: nil, vector_filter: 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
- num_candidates_int = (num_candidates || (k_int * DEFAULT_NUM_CANDIDATES_MULTIPLIER)).to_i
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, "num_candidates (#{num_candidates_int}) must be >= k (#{k_int})."
287
+ raise ArgumentError,
288
+ "num_candidates (#{num_candidates_int}) must be >= k (#{k_int})."
201
289
  end
202
- if num_candidates_int > 10_000
203
- raise ArgumentError, "num_candidates capped at 10000 by Atlas (got #{num_candidates_int})."
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
- "limit" => k_int,
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.5.6
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