Skip to content

v5.6.0 - Atlas Embeddings, Streamed Media, Retrieval Fixes

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 26 Jul 23:49
· 16 commits to main since this release

Feature Release

An embeddings and retrieval release: the Voyage provider now targets MongoDB's Atlas Embedding and Reranking API alongside Voyage's own, adds video embedding and four new models, and streams local media into requests instead of buffering it. Validating that work against the live API surfaced three retrieval defects, all of which are fixed here.

Breaking Changes

  • BREAKING: The Voyage provider's default model moves from voyage-3 to voyage-3.5. voyage-3 is retired from the Atlas endpoint, so the previous default caused an Atlas key to fail at construction whenever no model was named. Vectors from the two models are not comparable. Migration: code that relied on the default should pin model: "voyage-3" explicitly to keep existing embeddings valid, or re-embed against the new default. A :vector property that declares model: is unaffected, because the new binding audit catches the mismatch before any request rather than letting the two mix silently.

Changes

Voyage models are reachable through MongoDB's Atlas endpoint

  • NEW: The same Voyage models are served by Voyage's own API and by MongoDB's Atlas Embedding and Reranking API. The wire contract is identical but the credentials are not interchangeable, and each host rejects the other's key. An Atlas model API key is recognized by its prefix and routed to https://ai.mongodb.com/v1 automatically. Pass endpoint: :atlas or endpoint: :voyage to be explicit, or a base_url: to override both. A named endpoint that contradicts an explicit base_url is refused rather than silently reconciled, so a credential is never sent to a host the caller did not intend. #endpoint and #atlas? report the resolved target.
  • NEW: Added voyage-3.5, voyage-3.5-lite, voyage-code-2 (1536-dim), and voyage-multimodal-3.5.
  • NEW: Models the Atlas endpoint does not serve are refused at construction with a current replacement named. They remain valid against Voyage's own API.
  • NEW: voyage-4-nano is open-weight and served by neither hosted endpoint. It is tracked separately in SELF_HOSTED_ONLY_MODELS and refused against both hosts with a message pointing at a self-hosted base_url: or Parse::Embeddings::LocalHTTP, rather than failing as an opaque provider error.

Video embedding, and local media that never loads into memory

  • NEW: Parse::Embeddings::Voyage#embed_video embeds video through voyage-multimodal-3.5, the only model that accepts it. Text, image, and video vectors share one space, so a stored text vector is comparable against a video vector without re-embedding. #modalities reports [:text, :image, :video] for that model and [:text, :image] for voyage-multimodal-3.
  • NEW: Parse::Embeddings::Provider#embed_video joins #embed_image in the base protocol with the same NotImplementedError default, so video is a declared capability rather than one adapter's method. #supports_modality? answers the capability question without rescuing.
  • NEW: Parse::Embeddings::MediaFile wraps a local image or video and streams it into the request body. Serializing media with to_json costs roughly 2.4 times the file size in resident memory, counting the raw bytes, the base64 copy, and the serialized document. That is enough to exhaust a small dyno on a single moderate video. MediaFile reads only a 16-byte header at construction, and Parse::Embeddings::StreamingBody base64-encodes the payload into the socket in fixed-size chunks, so peak memory is bounded by the chunk size no matter how large the file is and nothing spills to disk. Content-Length is computed exactly, avoiding chunked transfer encoding, and the body replays byte-identically on retry. Passing a URL instead keeps the SDK out of the transfer entirely.

The model dimension table was wrong for the v4 family

  • FIXED: The entire v4 family defaults to 1024. voyage-4-large's 2048 and voyage-4-lite's 512 were recorded as native widths when they are Matryoshka options reached only by requesting them. Because the provider validates the returned vector width against the declared one, both models raised Parse::Embeddings::InvalidResponseError on every call. voyage-4-nano is 1024, and voyage-finance-2 carries a 32,000-token context rather than 16,000.
  • CHANGED: The coarse "Matryoshka-capable models" gate is replaced by per-model MODEL_SUPPORTED_DIMENSIONS. Any width on a model's ladder is accepted, including one wider than its default, which the old rule rejected as exceeding the native width. A width off the ladder is refused with the supported set named. output_dimension is sent whenever the configured width differs from the model's default, so voyage-4-lite at 512 and voyage-4-large at 2048 both work.
  • FIXED: Video validation no longer accepts containers the provider rejects. MP4 is the only format Voyage supports, and WebM and QuickTime payloads are refused by the API, so both are out of the default allowlist. An ftyp box no longer implies MP4 on its own, because QuickTime and the audio-only profiles share the ISO base media container, so major brands are matched explicitly and an unrecognized brand is refused rather than assumed. Apple's audio-only M4A brand is excluded, closing a type confusion in which an audio file passed as video.
  • NEW: Parse::Embeddings.max_media_bytes caps streamed media per file, defaulting to the 20 MB Voyage documents. Streaming already prevents an oversized file from exhausting memory, but the provider still rejects it, so failing locally turns a wasted upload into an immediate error. The Voyage adapter enforces the 20 MB ceiling independently, so raising the global knob for another provider cannot push an oversized payload onto Voyage.

Vector search no longer returns fewer results than requested

  • FIXED: $vectorSearch set its limit to k, but Atlas applies that limit before the SDK's ACL $match, protectedFields redaction, pointer-field filtering, and any caller-supplied filter. A scoped caller who could read 2 of the top 10 documents asked for 10 and received 2, even when hundreds of readable matches existed further down the ranking. The search now requests a wider internal candidate window, applies every enforcement layer, and only then trims to k. The window is raised only when something can actually drop rows, so a master-key call with no filter keeps its previous one-for-one cost. A candidate_limit: option on VectorSearch.search and find_similar tunes the window for principals whose visibility is unusually narrow. HNSW width stays anchored to k, so the wider window does not widen the ANN search.
  • FIXED: Hybrid search separates the rows each branch retains for fusion from the rows Atlas considers before ACL. Conflating them meant the branch limit was passed as the vector branch's k and then multiplied a second time by the plain search's own window derivation. A hybrid k: 10 asked Atlas for 1,000 rows instead of the intended 100, an explicit candidate_limit: 250 became 2,500 on the client path while staying 250 on the native path, and any k above 100 produced a branch k beyond VectorSearch::MAX_K that failed outright. The fusion depth is now bounded by MAX_K and the candidate window by Atlas's 10,000 ceiling, with both paths using the same window. A candidate_limit outside that range is refused rather than clamped.
  • FIXED: The native $rankFusion pipeline trimmed to k in a $limit stage that runs after its ACL $match, reintroducing the underfill the candidate window exists to prevent. It now limits to the candidate window and trims to k once enforcement has run.
  • NEW: parse.vector_search.search and parse.vector_search.hybrid notifications report the candidate window, attrition, and returned counts, so an underfill is observable rather than silent. The counts are named for where they are measured, because obtaining a true pre-$match count would require a $facet.

Vector properties are checked against the provider actually registered

  • FIXED: A :vector property's model: was recorded and never enforced. Because models in the same family usually share a width (voyage-3 and voyage-3.5 are both 1024), swapping the registered provider's model silently mixed incomparable vectors into one index. There was no error, just degraded recall, repairable only by re-embedding. dimensions: was verified, but only against a vector the provider had already returned and billed for. Both are now checked by Parse::Embeddings::BindingAudit before any request is issued, on the managed-write path and the query-embedding path alike. The audit runs ahead of the digest short-circuit so an unchanged record still surfaces a drifted binding, and it fails closed: a provider that cannot report #model_name or #dimensions is refused rather than skipped, because a declaration that cannot be verified is not a declaration that has been satisfied.
  • NEW: Parse::Embeddings::BindingAudit.audit_all! and .audit_all_or_raise! check every declared binding at once, for a boot-time or CI gate rather than waiting for the first save that happens to touch one.
  • NEW: :vector properties validate similarity: against the functions Atlas accepts (euclidean, cosine, dotProduct) at declaration time instead of surfacing a typo as an index error later.
  • NEW: A :vector property wider than the Atlas vectorSearch index cap is refused unless it declares searchable: false. Parse::Vector tolerates up to 16384 dimensions while an Atlas index caps at 8192, so such a property was previously declarable, storable, and permanently unsearchable, with the failure appearing only at query time. searchable: false now makes a property genuinely storage-only: excluded from find_similar and hybrid_search field resolution, refused with an explanation when named directly, and rejected by agent_searchable at class load.

Live provider contract tests

  • NEW: rake test:contract runs live provider contract tests that pin request routing, native dimensions, the Matryoshka ladder, accepted media, size limits, model availability, and response shape. They skip unless VOYAGE_CONTRACT_KEY is set and are excluded from both rake test and rake test:unit, so no ordinary run becomes billable because a key happens to be exported. Probes that assert a refusal issue raw requests rather than going through the SDK, since a local guard asserted against itself proves nothing about the contract it encodes. Infrastructure conditions are classified rather than guessed at: a bad credential fails, while rate limiting and 5xx responses skip as "contract not determined". VOYAGE_CONTRACT_RPM paces the suite per model for low-quota keys.

Behavior Notes

  • Audio is not offered by any Voyage model, and neither PDF nor DOCX is accepted as a content type. Render document pages to images and embed those. The SDK does not perform that conversion.
  • The candidate-window overfetch is a mitigation, not a completeness guarantee. Atlas caps the candidate window, so a sufficiently selective ACL can still underfill. Deterministic fill would require the authorization predicate to run inside the Atlas prefilter rather than after it. The post-search $match remains the enforcement boundary regardless.
  • Scoped hybrid calls now fetch a wider per-branch window than before. This is correct given the underfill it fixes, but it is a real cost increase worth measuring.
  • Mocked tests assert what the SDK believes the API does, so they cannot detect provider drift. Every dimension and media-format correction in this release was invisible to a fully green mocked suite.

Code Example

# Endpoint inferred from the key prefix, so no base_url is needed.
provider = Parse::Embeddings::Voyage.new(
  api_key: ENV.fetch("ATLAS_MODEL_API_KEY"),
  model: "voyage-multimodal-3.5",
)
provider.endpoint    # => :atlas
provider.modalities  # => [:text, :image, :video]

# Local media streams into the request; the bytes are never resident.
provider.embed_image([Parse::Embeddings::MediaFile.image("page.png")])
provider.embed_video([Parse::Embeddings::MediaFile.video("demo.mp4")])

# A URL keeps the SDK out of the transfer entirely, since the provider fetches it.
provider.embed_image(["https://cdn.example.com/page.png"])

# Widen the candidate window for a principal that can read only a small
# fraction of the class, so ACL attrition does not return fewer than k.
Post.find_similar(text: "quarterly planning", k: 10, candidate_limit: 500)

Commit: 3a925e2
Author: Adrian Curtin
Date: July 26, 2026