Module: Parse::VectorSearch
- Defined in:
- lib/parse/vector_search.rb,
lib/parse/vector_search/hybrid.rb
Overview
Atlas Vector Search entry point. Routes through Parse::MongoDB
rather than Parse Server's REST aggregate (REST aggregate is master-
key-only and bypasses ACL/CLP — see CLAUDE.md).
v5.0 ships the low-level surface only:
Parse::VectorSearch.search(
"WikiArticle",
field: :embedding,
query_vector: vec,
k: 10,
index: "WikiArticle_embedding_voyage_multimodal_3_1024_idx",
session_token: token,
)
The high-level Class.find_similar(text: …) wrapper and the
:vector property type land later in the v5.0 cycle. This module
is callable today against any collection that has a queryable
vectorSearch index — including the vector_prototype.Movie
fixture in scripts/vector_prototype/.
Stage 0 invariant
Atlas refuses any pipeline whose stage 0 is not $vectorSearch,
$search, or $searchMeta. The module therefore bypasses
Parse::MongoDB.aggregate (which prepends an ACL $match at
stage 0) and reproduces the SDK-side enforcement chain inline —
ACL $match is appended AFTER $vectorSearch, mirroring
Parse::AtlasSearch.search.
ACL / CLP enforcement
Identity is resolved through ACLScope.resolve!, so the
same kwargs accepted by mongo-direct paths are honored here:
session_token:, master: true, acl_user:, acl_role:. The
resolution drives:
- CLP
findboundary check — refuses calls the equivalent REST find would refuse. - Optional
pointerFieldspost-filter — drops rows that don't name the current user_id in the configured pointer fields. - Post-
$vectorSearchACL$matchinjection (Parse Server's_rpermpredicate). - Post-fetch
protectedFieldsredaction.
master: true bypasses ACL/CLP injection (matches the standard
mongo-direct semantics). The unconditional
PipelineSecurity.strip_internal_fields pass runs on
every result row regardless of mode, so _hashed_password and
friends never appear in returned documents.
Defined Under Namespace
Modules: Hybrid Classes: ConstraintNotSupported, InvalidQueryVector, NotAvailable
Constant Summary collapse
- MAX_DIMENSIONS =
Hard cap on query-vector dimensions to bound validator work and to refuse obvious garbage (the largest production-grade model today, Voyage
voyage-multimodal-3, is 1024-dim; OpenAItext-embedding-3-largeis 3072-dim). 8192- MAX_K =
Hard cap on
limit(k). Atlas itself caps$vectorSearch.limitat 10_000 but practical RAG workloads stay well below that; tighter cap here keeps a runaway caller from materializing a huge result set client-side. 1000- DEFAULT_NUM_CANDIDATES_MULTIPLIER =
Default
numCandidatesmultiplier when the caller doesn't pass one. Atlas's guidance: numCandidates ≥ 10 × limit, ≤ 10_000. 20- DEFAULT_CANDIDATE_MULTIPLIER =
How far past
kthe$vectorSearch.limitis raised when something downstream can drop rows (ACL enforcement, a caller-suppliedfilter,protectedFields, or pointer-field filtering). Atlas applieslimitBEFORE any of those run, so without overfetching a caller who can read 2 of the top 10 asks for 10 and receives 2 — even when hundreds of readable matches exist further down the ranking.This is a mitigation, not a guarantee: a sufficiently selective ACL can still exhaust any finite candidate window. Deterministic fill would require
_rpermdeclared astype: "filter"in the Atlas index (so the prefilter enforces visibility) or iterative candidate expansion. The instrumentation emitted by search exists so an underfill is observable rather than silent. 10- MAX_CANDIDATE_LIMIT =
Ceiling on the internally-raised candidate window. Atlas caps
numCandidatesat 10_000 andnumCandidatesmust be >=limit, so the candidate window cannot usefully exceed that. 10_000- AS_NOTIFICATION_NAME =
Emitted once per search. The counts are deliberately named for where they are actually measured — there is no cheap way to learn how many rows
$vectorSearchemitted before the server-side$matchstages ran, so no field claims to be that number:candidate_limit/num_candidates— the requested window.post_filter_count— rows returned by the pipeline, i.e. after the server-side ACL$matchand any callerfilter.post_pointer_count— rows left after client-side redaction and pointer-field filtering.returned_count— rows handed back, after trimming tok.underfilled— the caller received fewer thank.
Obtaining a true pre-
$matchcount would require a$facet, at the cost of a second pass over the candidate set. "parse.vector_search.search"- INDEX_DRIFT_POLICIES =
Accepted index_drift_policy values.
%i[warn raise ignore].freeze
Class Attribute Summary collapse
-
.default_index ⇒ String?
Optional fallback for VectorSearch.search's
index:keyword.
Class Method Summary collapse
-
.index_drift_policy ⇒ Symbol
Current drift policy (default
:warn). -
.index_drift_policy=(value) ⇒ Symbol
Policy applied when first-query index verification (see Core::VectorSearchable) finds the deployed Atlas vectorSearch index disagreeing with the model declaration — wrong
numDimensions, wrongsimilarity, or a tenant-scope field missing from the index'sfilterpaths. -
.search(collection_name, field:, query_vector:, k: 10, num_candidates: nil, candidate_limit: nil, filter: nil, vector_filter: nil, index: nil, max_time_ms: nil, **scope_opts) ⇒ Array<Hash>
Low-level
$vectorSearchentry point. -
.validate_query_vector!(vec, dimensions: nil) ⇒ Array<Float>
Validate a query vector.
Class Attribute Details
Class Method Details
.index_drift_policy ⇒ Symbol
Returns current drift policy (default :warn).
168 169 170 |
# File 'lib/parse/vector_search.rb', line 168 def index_drift_policy @index_drift_policy ||= :warn end |
.index_drift_policy=(value) ⇒ Symbol
Policy applied when first-query index verification (see
Core::VectorSearchable) finds the deployed Atlas
vectorSearch index disagreeing with the model declaration —
wrong numDimensions, wrong similarity, or a tenant-scope
field missing from the index's filter paths.
:warn(default) — emit a[Parse::VectorSearch:DRIFT]warning once per (class, field, index) and continue. Drift usually means the index predates a model change; queries still run but return degraded or wrongly-scoped results.:raise— fail the query with Core::VectorSearchable::IndexDriftError. Strict mode for deployments that treat drift as a release blocker.:ignore— skip verification entirely.
157 158 159 160 161 162 163 164 165 |
# File 'lib/parse/vector_search.rb', line 157 def index_drift_policy=(value) v = value.respond_to?(:to_sym) ? value.to_sym : nil unless v && INDEX_DRIFT_POLICIES.include?(v) raise ArgumentError, "Parse::VectorSearch.index_drift_policy must be one of " \ "#{INDEX_DRIFT_POLICIES.inspect} (got #{value.inspect})." end @index_drift_policy = v end |
.search(collection_name, field:, query_vector:, k: 10, num_candidates: nil, candidate_limit: nil, filter: nil, vector_filter: nil, index: nil, max_time_ms: nil, **scope_opts) ⇒ Array<Hash>
Low-level $vectorSearch entry point.
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 |
# File 'lib/parse/vector_search.rb', line 205 def search(collection_name, field:, query_vector:, k: 10, num_candidates: nil, candidate_limit: nil, filter: nil, vector_filter: nil, index: nil, max_time_ms: nil, **scope_opts) candidate_limit_override = candidate_limit require_available! index_name = (index || @default_index) if index_name.nil? || index_name.to_s.empty? raise ArgumentError, "Parse::VectorSearch.search requires index: (or set Parse::VectorSearch.default_index)." end # `Parse::ACLScope.resolve!` mutates the options hash by deleting # auth kwargs. Pass a fresh hash so we don't accidentally drop # caller kwargs and so `resolve!` can refuse 2-of-N combinations. resolution = Parse::ACLScope.resolve!(scope_opts, method_name: :"VectorSearch.search") path = field.to_s if path.empty? || path.start_with?("$") || path.include?(".") raise ArgumentError, "field: must be a non-empty, non-$-prefixed, non-dotted field name." end if Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST.include?(path) || path.start_with?("_auth_data_") raise ArgumentError, "field: refuses internal/sensitive field path #{path.inspect}." end k_int = Integer(k) if k_int <= 0 || k_int > MAX_K raise ArgumentError, "k must be in 1..#{MAX_K} (got #{k_int})." end # Anything that can drop rows AFTER Atlas has already applied # `limit` forces an overfetch, otherwise the caller silently # receives fewer than `k`. Master mode with no caller filter # has no attrition, so it keeps the old one-for-one cost. attrition_possible = !resolution.master? || !(filter.nil? || filter.empty?) candidate_limit = if candidate_limit_override Integer(candidate_limit_override) elsif attrition_possible [k_int * DEFAULT_CANDIDATE_MULTIPLIER, MAX_CANDIDATE_LIMIT].min else k_int end if candidate_limit < k_int raise ArgumentError, "candidate_limit (#{candidate_limit}) must be >= k (#{k_int})." end if candidate_limit > MAX_CANDIDATE_LIMIT raise ArgumentError, "candidate_limit capped at #{MAX_CANDIDATE_LIMIT} by Atlas (got #{candidate_limit})." end # ANN width stays anchored to `k`, NOT to the raised window. # Deriving it from `candidate_limit` would multiply twice and # silently widen the HNSW search by the candidate multiplier — # a scoped k=10 would jump from 200 to 2000 candidates. The # window only needs numCandidates to be at least as large as # `limit`, so take whichever is greater. derived_num_candidates = [k_int * DEFAULT_NUM_CANDIDATES_MULTIPLIER, candidate_limit].max num_candidates_int = (num_candidates || derived_num_candidates).to_i num_candidates_int = MAX_CANDIDATE_LIMIT if num_candidates_int > MAX_CANDIDATE_LIMIT && num_candidates.nil? # A caller-supplied num_candidates that predates the candidate # window used to be valid whenever it was >= k. Clamping the # implicit window down to it keeps those calls working rather # than turning them into a new ArgumentError. An EXPLICIT # candidate_limit still conflicts loudly — that combination can # only be a mistake. if num_candidates && num_candidates_int < candidate_limit if candidate_limit_override raise ArgumentError, "num_candidates (#{num_candidates_int}) must be >= candidate_limit " \ "(#{candidate_limit})." end candidate_limit = [num_candidates_int, k_int].max end if num_candidates_int < k_int raise ArgumentError, "num_candidates (#{num_candidates_int}) must be >= k (#{k_int})." end if num_candidates_int > MAX_CANDIDATE_LIMIT raise ArgumentError, "num_candidates capped at #{MAX_CANDIDATE_LIMIT} by Atlas (got #{num_candidates_int})." end validated_vector = validate_query_vector!(query_vector) Parse::PipelineSecurity.validate_filter!(filter) if filter Parse::PipelineSecurity.validate_filter!(vector_filter) if vector_filter # CLP `find` boundary + pointerFields. Mirrors # `Parse::AtlasSearch.search` — without this, a scoped caller # could issue $vectorSearch against a collection whose CLP # would refuse them on the equivalent REST find. assert_clp_find!(collection_name, resolution) pointer_fields = resolve_pointer_fields!(collection_name, resolution) protected_fields = Parse::CLPScope.protected_fields_for( collection_name, resolution., ) vs_stage = { "index" => index_name.to_s, "path" => path, "queryVector" => validated_vector, "numCandidates" => num_candidates_int, # Deliberately the raised candidate window, not `k` — the # result set is trimmed to `k` after enforcement runs. "limit" => candidate_limit, } vs_stage["filter"] = vector_filter if vector_filter && !vector_filter.empty? pipeline = [{ "$vectorSearch" => vs_stage }] pipeline << { "$addFields" => { "_vscore" => { "$meta" => "vectorSearchScore" } }, } # Inject ACL $match AFTER $vectorSearch + the score projection # but BEFORE the caller-supplied filter, so the user-controlled # filter cannot exfiltrate restricted documents that passed the # $vectorSearch operator. NOTE: Atlas's `$vectorSearch.filter` # (the pre-filter) cannot enforce ACL here because `_rperm` # would need to be declared as `type: "filter"` in the index # definition — out of scope at the SDK layer. The post-stage # `$match` is the enforcement boundary. unless resolution.master? acl_match = Parse::ACLScope.match_stage_for(resolution) pipeline << acl_match if acl_match end pipeline << { "$match" => filter } if filter raw_results = run_pipeline!(collection_name, pipeline, max_time_ms: max_time_ms) # Already past the server-side ACL `$match` and any caller # `filter` — NOT the number $vectorSearch emitted. post_filter_count = raw_results.length # Post-fetch enforcement: walk the rows the same way # Parse::MongoDB.aggregate would. Master mode skips every # redaction layer (matches the helper's behavior). unless resolution.master? Parse::ACLScope.redact_results!(raw_results, resolution) Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any? if pointer_fields raw_results = Parse::CLPScope.filter_by_pointer_fields( raw_results, pointer_fields, resolution.user_id, ) end end # Internal-fields denylist is the process-level floor: runs in # every mode, master included, so `_hashed_password` / # `_session_token` can never surface through this entry point. raw_results.map! { |doc| Parse::PipelineSecurity.strip_internal_fields(doc) } # Trim only AFTER every enforcement layer has run — that # ordering is the whole point of the raised candidate window. post_pointer_count = raw_results.length raw_results = raw_results.first(k_int) if post_pointer_count > k_int emit_search_stats( collection_name: collection_name, k: k_int, candidate_limit: candidate_limit, num_candidates: num_candidates_int, post_filter_count: post_filter_count, post_pointer_count: post_pointer_count, returned_count: raw_results.length, master: resolution.master?, ) raw_results end |
.validate_query_vector!(vec, dimensions: nil) ⇒ Array<Float>
Validate a query vector. Public so callers (and tests) can invoke it independently of search.
407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 |
# File 'lib/parse/vector_search.rb', line 407 def validate_query_vector!(vec, dimensions: nil) unless vec.is_a?(Array) raise InvalidQueryVector, "query_vector must be an Array (got #{vec.class})." end if vec.empty? raise InvalidQueryVector, "query_vector cannot be empty." end if vec.length > MAX_DIMENSIONS raise InvalidQueryVector, "query_vector length #{vec.length} exceeds MAX_DIMENSIONS=#{MAX_DIMENSIONS}." end if dimensions && vec.length != dimensions raise InvalidQueryVector, "query_vector length #{vec.length} != declared dimensions #{dimensions}." end out = Array.new(vec.length) vec.each_with_index do |v, i| unless v.is_a?(Numeric) raise InvalidQueryVector, "query_vector[#{i}] is not numeric (#{v.class})." end f = v.to_f unless f.finite? raise InvalidQueryVector, "query_vector[#{i}] is not finite (#{v.inspect})." end out[i] = f end out.freeze end |