Class: Parse::Embeddings::Voyage

Inherits:
Provider
  • Object
show all
Defined in:
lib/parse/embeddings/voyage.rb

Overview

Voyage AI embeddings provider. Wraps POST /v1/embeddings for text-only models and POST /v1/multimodalembeddings for the multimodal text+image models (text via #embed_text, images via #embed_image).

Supported models:

  • v4 familyvoyage-4-large, voyage-4, voyage-4-lite, voyage-4-nano (Apache 2.0, open-weight on Hugging Face — also runnable through LocalHTTP when self-hosted on vLLM / Ollama / llama.cpp).
  • v3 familyvoyage-3-large, voyage-3.5, voyage-3.5-lite, voyage-3, voyage-3-lite.
  • code modelsvoyage-code-3, voyage-code-2 (1536-dim).
  • domain modelsvoyage-finance-2, voyage-law-2.
  • multimodalvoyage-multimodal-3 (text+image) and voyage-multimodal-3.5 (text+image+video). Unified vector space at the network boundary: text routes to /v1/multimodalembeddings with a { inputs: [{ content: [{ type: "text", text: … }] }] } envelope, images go through #embed_image, video through #embed_video. All three share the same space, so stored text vectors are comparable against image and video vectors without re-embedding.

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 instead.

Most models expose a Matryoshka ladder (MODEL_SUPPORTED_DIMENSIONS); note that the whole v4 family DEFAULTS to 1024 and reaches 2048 or 256 only when dimensions: asks for it.

Endpoints

The same models are served by Voyage's own API and by MongoDB's Atlas Embedding and Reranking API. The wire contract is identical; the credentials are not interchangeable, and Voyage returns a 403 explaining as much if they are crossed. An Atlas model API key is recognized by its ATLAS_KEY_PREFIX and routes to ATLAS_BASE_URL automatically — pass endpoint: to be explicit. A few older models are absent from Atlas; see ATLAS_UNAVAILABLE_MODELS.

Memory

Local images and video should be wrapped with MediaFile, which streams the file into the request body StreamingBody::READ_CHUNK bytes at a time. Passing a URL instead keeps the SDK out of the transfer entirely — the provider does the fetch. Only ImageFetch::FetchedImage holds a payload in memory, so prefer it for small images only.

Asymmetric input types

Voyage's input_type field accepts "query" or "document" (mapped from the SDK-canonical :search_query / :search_document Symbols). The values are functionally analogous to Cohere's search_query / search_document — they're encoded by separately tuned heads, so re-using one type for both sides of a retrieval pair measurably degrades recall.

Voyage also accepts null (omit the field), which Voyage's docs recommend for "general purpose" embeddings unrelated to retrieval. We translate the absent / non-retrieval cases to null rather than picking a default — Voyage's training depends on the asymmetry, so guessing on the caller's behalf would be worse than passing-through.

Security

  • The Faraday connection refuses proxy: unless the caller opts in via allow_faraday_proxy: true. Env-proxy autodiscovery (HTTPS_PROXY etc.) is suppressed by default.
  • #inspect (inherited from Provider) never surfaces @api_key.
  • Authorization and Voyage-Api-Key are in Middleware::BodyBuilder::REDACTED_HEADERS.

Examples:

registration

Parse::Embeddings.register(:voyage,
  Parse::Embeddings::Voyage.new(
    api_key: ENV.fetch("VOYAGE_API_KEY"),
    model:   "voyage-3.5",
  ))

Atlas model API key (endpoint inferred from the prefix)

Parse::Embeddings.register(:voyage,
  Parse::Embeddings::Voyage.new(
    api_key: ENV.fetch("ATLAS_MODEL_API_KEY"),  # "al-…"
    model:   "voyage-multimodal-3.5",
  ))

streaming local media

provider.embed_image([Parse::Embeddings::MediaFile.image("page.png")])
provider.embed_video([Parse::Embeddings::MediaFile.video("demo.mp4")])

Defined Under Namespace

Classes: AuthenticationError, BadRequestError, RateLimitError, TransientError

Constant Summary collapse

DEFAULT_BASE_URL =
"https://api.voyageai.com/v1"
ATLAS_BASE_URL =

MongoDB's Atlas Embedding and Reranking API re-exposes the same Voyage models under a MongoDB-operated host. The wire contract (request envelopes, response envelopes, error shapes) is identical — only the host and the credential differ.

"https://ai.mongodb.com/v1"
DEFAULT_MODEL =

Bumped from voyage-3 in 5.6.0: that model is retired from the Atlas endpoint, so an Atlas key used without naming a model failed at construction. voyage-3.5 is served by both endpoints and shares the 1024 native width.

"voyage-3.5"
DEFAULT_TIMEOUT =
30
DEFAULT_OPEN_TIMEOUT =
5
DEFAULT_MAX_RETRIES =
3
DEFAULT_BATCH_SIZE =

Voyage's documented per-request cap is 128 inputs.

128
MAX_RESPONSE_BYTES =
16 * 1024 * 1024
MODEL_DEFAULT_DIMENSIONS =

Default (native) vector width per model — the width returned when output_dimension is omitted from the request.

NOTE: the whole v4 family defaults to 1024, NOT to a per-tier width. voyage-4-large reaches 2048 and voyage-4-lite reaches 512 only by explicitly requesting them via output_dimension (the constructor's dimensions: override) — those are Matryoshka options, not native widths. Verified against the live API for every model reachable through ATLAS_BASE_URL; see MODEL_SUPPORTED_DIMENSIONS.

{
  "voyage-4-large"        => 1024,
  "voyage-4"              => 1024,
  "voyage-4-lite"         => 1024,
  "voyage-4-nano"         => 1024,
  "voyage-3-large"        => 1024,
  "voyage-3.5"            => 1024,
  "voyage-3.5-lite"       => 1024,
  "voyage-3"              => 1024,
  "voyage-3-lite"         => 512,
  "voyage-code-3"         => 1024,
  "voyage-code-2"         => 1536,
  "voyage-finance-2"      => 1024,
  "voyage-law-2"          => 1024,
  "voyage-multimodal-3"   => 1024,
  "voyage-multimodal-3.5" => 1024,
}.freeze
MODEL_SUPPORTED_DIMENSIONS =

Every width a model's Matryoshka head will actually return. A model whose list has a single entry accepts no output_dimension override at all — requesting one is a 400.

This replaces the older "Matryoshka-capable models" boolean gate, which was too coarse: the v4 family, voyage-3-large, the v3.5 family, and voyage-code-3 all accept the full 256/512/1024/2048 ladder, and voyage-multimodal-3.5 accepts it too while voyage-multimodal-3 does not.

{
  "voyage-4-large"        => [256, 512, 1024, 2048],
  "voyage-4"              => [256, 512, 1024, 2048],
  "voyage-4-lite"         => [256, 512, 1024, 2048],
  "voyage-4-nano"         => [256, 512, 1024, 2048],
  "voyage-3-large"        => [256, 512, 1024, 2048],
  "voyage-3.5"            => [256, 512, 1024, 2048],
  "voyage-3.5-lite"       => [256, 512, 1024, 2048],
  "voyage-3"              => [1024],
  "voyage-3-lite"         => [512],
  "voyage-code-3"         => [256, 512, 1024, 2048],
  "voyage-code-2"         => [1536],
  "voyage-finance-2"      => [1024],
  "voyage-law-2"          => [1024],
  "voyage-multimodal-3"   => [1024],
  "voyage-multimodal-3.5" => [256, 512, 1024, 2048],
}.freeze
MATRYOSHKA_MODELS =

Back-compat alias: the set of models accepting any output_dimension other than their native width. Derived from MODEL_SUPPORTED_DIMENSIONS rather than hand-maintained.

MODEL_SUPPORTED_DIMENSIONS.select { |_m, dims| dims.length > 1 }.keys.freeze
MODEL_MAX_INPUT_TOKENS =
{
  "voyage-4-large"        => 32_000,
  "voyage-4"              => 32_000,
  "voyage-4-lite"         => 32_000,
  "voyage-4-nano"         => 32_000,
  "voyage-3-large"        => 32_000,
  "voyage-3.5"            => 32_000,
  "voyage-3.5-lite"       => 32_000,
  "voyage-3"              => 32_000,
  "voyage-3-lite"         => 32_000,
  "voyage-code-3"         => 32_000,
  "voyage-code-2"         => 16_000,
  "voyage-finance-2"      => 32_000,
  "voyage-law-2"          => 16_000,
  "voyage-multimodal-3"   => 32_000,
  "voyage-multimodal-3.5" => 32_000,
}.freeze
MULTIMODAL_MODELS =

Models that route to /v1/multimodalembeddings with the { inputs: [{ content: [...] }] } envelope rather than the standard /v1/embeddings { input: [String] } envelope. Text-only inputs from this provider are wrapped as { type: "text", text: s } content rows.

%w[voyage-multimodal-3 voyage-multimodal-3.5].freeze
MAX_MEDIA_BYTES =

Voyage's documented hard ceiling for a single image or video. Parse::Embeddings.max_media_bytes is a global convenience knob that may be lowered for any reason — but raising it above this cannot make Voyage accept a larger file, so the adapter enforces its own limit independently.

20 * 1024 * 1024
VIDEO_MODELS =

Multimodal models that additionally accept video content rows (video_url / video_base64) via #embed_video. voyage-multimodal-3 rejects video with an explicit "does not support video inputs" 400.

%w[voyage-multimodal-3.5].freeze
ATLAS_UNAVAILABLE_MODELS =

Models Voyage's hosted API serves but the Atlas Embedding and Reranking API does not. Verified against both endpoints.

%w[voyage-3 voyage-3-lite].freeze
SELF_HOSTED_ONLY_MODELS =

Open-weight models that NO hosted endpoint serves — neither Voyage's nor Atlas's. voyage-4-nano ships under Apache 2.0 on Hugging Face and is meant to be self-hosted (vLLM / Ollama / llama.cpp), reached either through LocalHTTP or through this provider with an explicit base_url: pointing at the local server. Naming one against a hosted endpoint is always a mistake, so it is refused there rather than failing as an opaque provider 400.

%w[voyage-4-nano].freeze
ATLAS_KEY_PREFIX =

Atlas model API keys carry this prefix and authenticate ONLY against ATLAS_BASE_URL; Voyage's own endpoint rejects them with a 403. Used to infer the endpoint when the caller does not name one explicitly.

"al-"
INPUT_TYPE_WIRE_VALUES =

Map SDK-canonical input_type symbols to Voyage wire strings. :classification / :clustering map to nil (omitted) since Voyage only distinguishes retrieval halves — other intents should receive the unconditioned vector.

{
  search_query:    "query",
  search_document: "document",
  classification:  nil,
  clustering:      nil,
}.freeze

Constants inherited from Provider

Provider::AS_NOTIFICATION_NAME

Instance Method Summary collapse

Methods inherited from Provider

#embed_text_batched, #inspect, #instrument_embed, #supports_modality?, #validate_response!

Constructor Details

#initialize(api_key:, model: DEFAULT_MODEL, endpoint: :auto, base_url: nil, timeout: DEFAULT_TIMEOUT, open_timeout: DEFAULT_OPEN_TIMEOUT, max_retries: DEFAULT_MAX_RETRIES, embed_batch_size: DEFAULT_BATCH_SIZE, dimensions: nil, truncation: true, allow_faraday_proxy: false, allow_insecure_base_url: false, connection: nil) ⇒ Voyage

Returns a new instance of Voyage.

Parameters:

  • api_key (String)

    required. Sent as Authorization: Bearer ….

  • model (String) (defaults to: DEFAULT_MODEL)
  • endpoint (Symbol) (defaults to: :auto)

    :auto (default), :voyage, or :atlas. Selects the default base_url and enables endpoint-specific model validation. :auto infers :atlas when api_key carries the ATLAS_KEY_PREFIX, else :voyage. An explicit base_url: always wins; the endpoint is then inferred from its host.

  • base_url (String, nil) (defaults to: nil)

    override. Must be HTTPS unless allow_insecure_base_url: true. Defaults to the resolved endpoint's host.

  • timeout (Integer) (defaults to: DEFAULT_TIMEOUT)

    read timeout, seconds.

  • open_timeout (Integer) (defaults to: DEFAULT_OPEN_TIMEOUT)

    connect timeout, seconds.

  • max_retries (Integer) (defaults to: DEFAULT_MAX_RETRIES)

    retry attempts on 429/5xx/timeouts.

  • embed_batch_size (Integer) (defaults to: DEFAULT_BATCH_SIZE)

    inputs per request (max 128).

  • dimensions (Integer, nil) (defaults to: nil)

    override output width via Voyage's output_dimension Matryoshka parameter. Only voyage-4-large accepts the field; for every other model the override must equal the native width or be omitted.

  • truncation (Boolean) (defaults to: true)

    forward Voyage's truncation: field. Defaults true to match Voyage's API default. Set false to force the API to reject over-length inputs rather than silently truncating (useful when you want explicit chunking errors).

  • allow_faraday_proxy (Boolean) (defaults to: false)

    opt in to proxy / env-proxy autodiscovery. Defaults false.

  • allow_insecure_base_url (Boolean) (defaults to: false)

    permit http:// base.

  • connection (Faraday::Connection, nil) (defaults to: nil)

    injection seam.



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
# File 'lib/parse/embeddings/voyage.rb', line 287

def initialize(
  api_key:,
  model: DEFAULT_MODEL,
  endpoint: :auto,
  base_url: nil,
  timeout: DEFAULT_TIMEOUT,
  open_timeout: DEFAULT_OPEN_TIMEOUT,
  max_retries: DEFAULT_MAX_RETRIES,
  embed_batch_size: DEFAULT_BATCH_SIZE,
  dimensions: nil,
  truncation: true,
  allow_faraday_proxy: false,
  allow_insecure_base_url: false,
  connection: nil
)
  validate_api_key!(api_key)
  validate_model!(model)
  resolved_endpoint = resolve_endpoint!(endpoint, api_key, base_url)
  base_url ||= resolved_endpoint == :atlas ? ATLAS_BASE_URL : DEFAULT_BASE_URL
  validate_model_for_endpoint!(model, resolved_endpoint)
  sanitized_base_url = validate_base_url!(base_url, allow_insecure_base_url)
  validate_positive_integer!(:timeout, timeout)
  validate_positive_integer!(:open_timeout, open_timeout)
  validate_non_negative_integer!(:max_retries, max_retries)
  validate_positive_integer!(:embed_batch_size, embed_batch_size)
  if embed_batch_size > 128
    raise ArgumentError,
          "Parse::Embeddings::Voyage: embed_batch_size #{embed_batch_size} exceeds Voyage's per-request cap (128)."
  end
  unless [true, false].include?(truncation)
    raise ArgumentError,
          "Parse::Embeddings::Voyage: truncation must be true or false (got #{truncation.inspect})."
  end
  validate_dimensions!(model, dimensions)

  @api_key = api_key
  @model = model
  @endpoint = resolved_endpoint
  @dimensions = dimensions || MODEL_DEFAULT_DIMENSIONS.fetch(model)
  @base_url = sanitized_base_url
  @timeout = timeout
  @open_timeout = open_timeout
  @max_retries = max_retries
  @embed_batch_size = embed_batch_size
  @truncation = truncation
  @allow_faraday_proxy = allow_faraday_proxy
  @connection = connection || build_connection
end

Instance Method Details

#atlas?Boolean

Returns true when routed through ATLAS_BASE_URL.

Returns:



352
353
354
# File 'lib/parse/embeddings/voyage.rb', line 352

def atlas?
  @endpoint == :atlas
end

#dimensionsObject



336
337
338
# File 'lib/parse/embeddings/voyage.rb', line 336

def dimensions
  @dimensions
end

#embed_batch_sizeObject



356
357
358
# File 'lib/parse/embeddings/voyage.rb', line 356

def embed_batch_size
  @embed_batch_size
end

#embed_image(sources, input_type: :search_document, allow_insecure: false) ⇒ Array<Array<Float>>

Embed a batch of images through Voyage's /v1/multimodalembeddings endpoint. Two source forms:

  • String URL (v5.1 path) — the provider receives a public URL and issues its own fetch. The SDK does NOT download the image; it validates the URL through Parse::Embeddings.validate_image_url! (CIDR / port / host allowlist, sentinel-gated egress opt-in) and forwards the canonicalized URL string in a { type: "image_url", image_url: ... } content row.
  • ImageFetch::FetchedImage (v5.5 bytes path) — bytes the SDK already downloaded through File.safe_open_url, magic-byte-verified, and EXIF-stripped. Forwarded as a { type: "image_base64", image_base64: "data:<mime>;base64,..." } content row. No URL validation runs (there is no provider-side fetch) and the trust_provider_url_fetch sentinel is NOT required.

Multimodal model required. Voyage's text-only models (voyage-3, voyage-4, etc.) do not accept image inputs; calling embed_image on a provider configured with one of those raises BadRequestError before any network call.

Parameters:

Returns:

  • (Array<Array<Float>>)

    vectors aligned 1:1 with sources.



469
470
471
472
# File 'lib/parse/embeddings/voyage.rb', line 469

def embed_image(sources, input_type: :search_document, allow_insecure: false)
  embed_media(sources, kind: :image, input_type: input_type,
                       allow_insecure: allow_insecure)
end

#embed_text(strings, input_type: :search_document) ⇒ Array<Array<Float>>

Returns vectors aligned 1:1 with strings.

Parameters:

Returns:

  • (Array<Array<Float>>)

    vectors aligned 1:1 with strings.



376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
# File 'lib/parse/embeddings/voyage.rb', line 376

def embed_text(strings, input_type: :search_document)
  unless strings.is_a?(Array)
    raise ArgumentError,
          "Parse::Embeddings::Voyage#embed_text expects Array<String> (got #{strings.class})."
  end
  return [] if strings.empty?
  strings.each_with_index do |s, i|
    unless s.is_a?(String)
      raise ArgumentError,
            "Parse::Embeddings::Voyage#embed_text strings[#{i}] is not a String (#{s.class})."
    end
    if s.empty?
      raise ArgumentError,
            "Parse::Embeddings::Voyage#embed_text strings[#{i}] is empty; Voyage rejects empty inputs."
    end
  end
  unless INPUT_TYPE_WIRE_VALUES.key?(input_type)
    raise ArgumentError,
          "Parse::Embeddings::Voyage#embed_text input_type #{input_type.inspect} not in " \
          "#{INPUT_TYPE_WIRE_VALUES.keys.inspect}."
  end
  wire_input_type = INPUT_TYPE_WIRE_VALUES[input_type]

  # Multimodal models route to a different endpoint with a
  # different request envelope. The response envelope shape is
  # the same (`{ data: [{ embedding, index }], usage: {...} }`)
  # so `extract_vectors!` is reused as-is.
  body =
    if MULTIMODAL_MODELS.include?(@model)
      build_multimodal_body(strings, wire_input_type)
    else
      build_text_body(strings, wire_input_type)
    end

  path = MULTIMODAL_MODELS.include?(@model) ? "multimodalembeddings" : "embeddings"

  instrument_embed(strings.length, input_type) do |emit_payload|
    payload = post_embeddings(body, path: path)
    # Voyage's response carries `usage: { total_tokens }`.
    if payload.is_a?(Hash) && payload["usage"].is_a?(Hash)
      tt = payload["usage"]["total_tokens"]
      emit_payload[:total_tokens] = tt if tt.is_a?(Integer) && tt >= 0
    end
    vectors = extract_vectors!(payload, strings.length)
    validate_response!(strings.length, vectors)
  end
end

#embed_video(sources, input_type: :search_document, allow_insecure: false) ⇒ Array<Array<Float>>

Embed a batch of videos through /v1/multimodalembeddings. Mirrors #embed_image's source forms and security posture exactly:

  • String URL — forwarded as a { type: "video_url", video_url: … } content row after Parse::Embeddings.validate_image_url! canonicalizes and screens it. The provider issues the fetch, so the trust_provider_url_fetch sentinel IS required and the SDK never downloads the video.
  • MediaFile — a local file, streamed into the request body as a { type: "video_base64", video_base64: "data:…" } row without ever being held in memory. No URL validation and no sentinel, because nothing is fetched.

Video-capable model required. Only VIDEO_MODELS accept video; voyage-multimodal-3 rejects it server-side, so this raises BadRequestError before any network call.

Parameters:

Returns:

  • (Array<Array<Float>>)

    vectors aligned 1:1 with sources.



499
500
501
502
# File 'lib/parse/embeddings/voyage.rb', line 499

def embed_video(sources, input_type: :search_document, allow_insecure: false)
  embed_media(sources, kind: :video, input_type: input_type,
                       allow_insecure: allow_insecure)
end

#endpointSymbol

Returns :atlas when this provider targets MongoDB's Atlas Embedding and Reranking API, :voyage when it targets Voyage's own API, :custom for any other host.

Returns:

  • (Symbol)

    :atlas when this provider targets MongoDB's Atlas Embedding and Reranking API, :voyage when it targets Voyage's own API, :custom for any other host.



347
348
349
# File 'lib/parse/embeddings/voyage.rb', line 347

def endpoint
  @endpoint
end

#inspect_attrsObject



504
505
506
# File 'lib/parse/embeddings/voyage.rb', line 504

def inspect_attrs
  super.merge(base: safe_base_host, endpoint: @endpoint, retries: @max_retries)
end

#max_input_tokensObject



360
361
362
# File 'lib/parse/embeddings/voyage.rb', line 360

def max_input_tokens
  MODEL_MAX_INPUT_TOKENS[@model]
end

#modalitiesArray<Symbol>

Returns [:text, :image, :video] for voyage-multimodal-3.5, [:text, :image] for voyage-multimodal-3, and [:text] for text-only models. Audio is not offered by any Voyage model.

Returns:

  • (Array<Symbol>)

    [:text, :image, :video] for voyage-multimodal-3.5, [:text, :image] for voyage-multimodal-3, and [:text] for text-only models. Audio is not offered by any Voyage model.



428
429
430
431
# File 'lib/parse/embeddings/voyage.rb', line 428

def modalities
  return [:text] unless MULTIMODAL_MODELS.include?(@model)
  VIDEO_MODELS.include?(@model) ? %i[text image video] : %i[text image]
end

#model_nameObject



340
341
342
# File 'lib/parse/embeddings/voyage.rb', line 340

def model_name
  @model
end

#normalize?Boolean

Returns:

  • (Boolean)


364
365
366
367
# File 'lib/parse/embeddings/voyage.rb', line 364

def normalize?
  # Voyage's v3 embeddings are documented unit-normalized.
  true
end

#supports_input_type?Boolean

Returns:

  • (Boolean)


369
370
371
# File 'lib/parse/embeddings/voyage.rb', line 369

def supports_input_type?
  true
end