Class: Parse::Embeddings::Voyage
- 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 family —
voyage-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 family —
voyage-3-large,voyage-3.5,voyage-3.5-lite,voyage-3,voyage-3-lite. - code models —
voyage-code-3,voyage-code-2(1536-dim). - domain models —
voyage-finance-2,voyage-law-2. - multimodal —
voyage-multimodal-3(text+image) andvoyage-multimodal-3.5(text+image+video). Unified vector space at the network boundary: text routes to/v1/multimodalembeddingswith 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 viaallow_faraday_proxy: true. Env-proxy autodiscovery (HTTPS_PROXYetc.) is suppressed by default. #inspect(inherited from Provider) never surfaces@api_key.AuthorizationandVoyage-Api-Keyare in Middleware::BodyBuilder::REDACTED_HEADERS.
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-3in 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.5is 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_dimensionis omitted from the request.NOTE: the whole v4 family defaults to 1024, NOT to a per-tier width.
voyage-4-largereaches 2048 andvoyage-4-litereaches 512 only by explicitly requesting them viaoutput_dimension(the constructor'sdimensions: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_dimensionoverride 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, andvoyage-code-3all accept the full 256/512/1024/2048 ladder, andvoyage-multimodal-3.5accepts it too whilevoyage-multimodal-3does 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_dimensionother 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/multimodalembeddingswith 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-3rejects 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-nanoships 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 explicitbase_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/:clusteringmap tonil(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
- MEDIA_MODALITIES =
Everything modality-specific about a non-text input, in one table.
modelsnames the constant gating which models accept the modality;url/base64are the wire content-type keys.Adding a modality (audio, when Voyage ships it) is a row here plus a MediaFile constructor and a one-line
embed_audiodelegating to #embed_media — the streaming body, row builder, batching, URL validation, and instrumentation are all modality-agnostic already. { image: { url: "image_url", base64: "image_base64", models: :MULTIMODAL_MODELS, noun: "image" }, video: { url: "video_url", base64: "video_base64", models: :VIDEO_MODELS, noun: "video" }, }.freeze
Constants inherited from Provider
Provider::AS_NOTIFICATION_NAME
Instance Method Summary collapse
-
#apply_output_dimension!(body) ⇒ Object
protected
Forward
output_dimensiononly when the configured width differs from the model's native default. -
#atlas? ⇒ Boolean
True when routed through ATLAS_BASE_URL.
- #backoff_seconds(attempt) ⇒ Object protected
- #build_connection ⇒ Object protected
-
#build_media_rows(sources, kind:, allow_insecure:, caller_name:) ⇒ Array<Hash>
protected
Build
inputs[].content[]row descriptors for a batch of media sources, accepting URL Strings, in-memory ImageFetch::FetchedImage wrappers, and file-backed MediaFile wrappers. -
#build_multimodal_body(strings, wire_input_type) ⇒ Object
protected
Build the wire body for
/v1/multimodalembeddingsfor TEXT inputs: each string wraps as a single{type: "text", text:}content row. -
#build_request_body(rows, wire_input_type) ⇒ Object
protected
Assemble the request body for a set of row descriptors.
-
#build_text_body(strings, wire_input_type) ⇒ Object
protected
Build the wire body for the standard
/v1/embeddingsendpoint (text-only models). - #dimensions ⇒ Object
-
#dispatch_media(rows, input_type, wire_input_type, kind) ⇒ Object
protected
Issue one multimodal request for a set of same-representation rows and return the vectors in row order.
- #embed_batch_size ⇒ Object
-
#embed_image(sources, input_type: :search_document, allow_insecure: false) ⇒ Array<Array<Float>>
Embed a batch of images through Voyage's
/v1/multimodalembeddingsendpoint. -
#embed_media(sources, kind:, input_type:, allow_insecure:) ⇒ Array<Array<Float>>
protected
Shared implementation behind #embed_image and #embed_video.
-
#embed_text(strings, input_type: :search_document) ⇒ Array<Array<Float>>
Vectors aligned 1:1 with
strings. -
#embed_video(sources, input_type: :search_document, allow_insecure: false) ⇒ Array<Array<Float>>
Embed a batch of videos through
/v1/multimodalembeddings. -
#endpoint ⇒ Symbol
:atlaswhen this provider targets MongoDB's Atlas Embedding and Reranking API,:voyagewhen it targets Voyage's own API,:customfor any other host. -
#enforce_media_size!(bytes, index, caller_name, path = nil) ⇒ Object
protected
Refuse a payload Voyage will reject anyway.
-
#extract_vectors!(payload, input_count) ⇒ Object
protected
Voyage's response shape mirrors OpenAI:.
-
#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
constructor
A new instance of Voyage.
- #inspect_attrs ⇒ Object
-
#json_string_prefix(str) ⇒ Object
protected
A JSON string literal with its opening quote and escaped contents but no closing quote, so a streamed payload can be appended before the string is closed.
- #max_input_tokens ⇒ Object
-
#modalities ⇒ Array<Symbol>
[:text, :image, :video]forvoyage-multimodal-3.5,[:text, :image]forvoyage-multimodal-3, and[:text]for text-only models. - #model_name ⇒ Object
- #normalize? ⇒ Boolean
- #parse_json_body!(body) ⇒ Object protected
- #post_embeddings(body, path: "embeddings") ⇒ Object protected
- #retry_after_seconds(response) ⇒ Object protected
-
#row_representation(row) ⇒ Object
protected
Which wire representation a row descriptor uses.
- #supports_input_type? ⇒ Boolean
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.
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, ) if > 128 raise ArgumentError, "Parse::Embeddings::Voyage: 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 = @truncation = truncation @allow_faraday_proxy = allow_faraday_proxy @connection = connection || build_connection end |
Instance Method Details
#apply_output_dimension!(body) ⇒ Object (protected)
Forward output_dimension only when the configured width
differs from the model's native default. Sending it to a model
with a single supported width is a 400, and sending the native
width needlessly is redundant — so both are omitted.
The constructor has already rejected any width outside MODEL_SUPPORTED_DIMENSIONS, so no re-validation is needed.
571 572 573 574 575 |
# File 'lib/parse/embeddings/voyage.rb', line 571 def apply_output_dimension!(body) return body if @dimensions == MODEL_DEFAULT_DIMENSIONS.fetch(@model) body[:output_dimension] = @dimensions body end |
#atlas? ⇒ Boolean
Returns true when routed through ATLAS_BASE_URL.
352 353 354 |
# File 'lib/parse/embeddings/voyage.rb', line 352 def atlas? @endpoint == :atlas end |
#backoff_seconds(attempt) ⇒ Object (protected)
908 909 910 |
# File 'lib/parse/embeddings/voyage.rb', line 908 def backoff_seconds(attempt) [0.5 * (2 ** (attempt - 1)), 30.0].min end |
#build_connection ⇒ Object (protected)
509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 |
# File 'lib/parse/embeddings/voyage.rb', line 509 def build_connection headers = { "Authorization" => "Bearer #{@api_key}", "Content-Type" => "application/json", "Accept" => "application/json", "User-Agent" => "parse-stack-embeddings/#{user_agent_version}", } faraday_opts = { url: @base_url, headers: headers } faraday_opts[:proxy] = nil unless @allow_faraday_proxy conn = Faraday.new(**faraday_opts) do |f| f..timeout = @timeout f..open_timeout = @open_timeout f.adapter Faraday.default_adapter end conn.proxy = nil if !@allow_faraday_proxy && conn.respond_to?(:proxy=) conn end |
#build_media_rows(sources, kind:, allow_insecure:, caller_name:) ⇒ Array<Hash> (protected)
Build inputs[].content[] row descriptors for a batch of media
sources, accepting URL Strings, in-memory
ImageFetch::FetchedImage wrappers, and file-backed
MediaFile wrappers.
Every URL is validated up-front so a malformed entry in slot N cannot get past validation while slots 0..N-1 are already in the wire body — no partial forwarding.
Returns descriptors rather than finished Hashes so
#build_request_body can assemble the JSON structurally. A
streamed row is { stream: MediaFile, key: String }; every
other row is a ready-to-serialize Hash.
685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 |
# File 'lib/parse/embeddings/voyage.rb', line 685 def build_media_rows(sources, kind:, allow_insecure:, caller_name:) spec = MEDIA_MODALITIES.fetch(kind) url_key = spec[:url] b64_key = spec[:base64] sources.each_with_index.map do |src, i| case src when Parse::Embeddings::MediaFile unless src.kind == kind raise ArgumentError, "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] is a " \ "#{src.kind} MediaFile; expected #{kind}." end enforce_media_size!(src.byte_size, i, caller_name, src.path) { stream: src, key: b64_key } when Parse::Embeddings::ImageFetch::FetchedImage # The only in-memory wrapper the SDK ships is for images. unless kind == :image raise ArgumentError, "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] is a FetchedImage; " \ "wrap #{spec[:noun]} sources with Parse::Embeddings::MediaFile.#{kind}." end enforce_media_size!(src.bytes.bytesize, i, caller_name) { content: [{ type: b64_key, b64_key => src.to_data_uri }] } when String canonical = Parse::Embeddings.validate_image_url!(src, allow_insecure: allow_insecure) { content: [{ type: url_key, url_key => canonical }] } else raise ArgumentError, "Parse::Embeddings::Voyage##{caller_name} sources[#{i}] must be a URL String " \ "or a Parse::Embeddings::MediaFile (got #{src.class})." end end end |
#build_multimodal_body(strings, wire_input_type) ⇒ Object (protected)
Build the wire body for /v1/multimodalembeddings for TEXT
inputs: each string wraps as a single {type: "text", text:}
content row. Image inputs build their content rows inline in
#embed_image (image_url / image_base64) and do not pass
through here.
550 551 552 553 554 555 556 557 558 559 560 561 562 |
# File 'lib/parse/embeddings/voyage.rb', line 550 def build_multimodal_body(strings, wire_input_type) body = { inputs: strings.map { |s| { content: [{ type: "text", text: s }] } }, model: @model, } body[:input_type] = wire_input_type if wire_input_type # `truncation` is documented for the multimodal endpoint too — # forward it for parity with the text path so callers get the # same fail-on-overlength behavior across models. body[:truncation] = @truncation apply_output_dimension!(body) body end |
#build_request_body(rows, wire_input_type) ⇒ Object (protected)
Assemble the request body for a set of row descriptors.
With no streamed rows this returns a plain Hash, serialized later by #post_embeddings. With them it returns a StreamingBody whose JSON is built structurally — each fragment is serialized independently and concatenated in order, so the file payloads are spliced by position rather than by searching the serialized document.
Building it structurally is a correctness requirement, not a
style preference: an earlier version emitted a sentinel token
and located it with String#split, which let a caller-supplied
URL containing that token capture a local file's bytes and ship
them to the provider as a URL to fetch.
756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 |
# File 'lib/parse/embeddings/voyage.rb', line 756 def build_request_body(rows, wire_input_type) trailer = { model: @model, truncation: @truncation } trailer[:input_type] = wire_input_type if wire_input_type apply_output_dimension!(trailer) return { inputs: rows }.merge(trailer) unless rows.any? { |r| r[:stream] } segments = [+'{"inputs":['] rows.each_with_index do |row, i| segments << "," if i.positive? if (media = row[:stream]) key = row[:key] # Open the JSON string, emit the data: prefix, stream the # payload, then close it. Base64 needs no escaping, and the # prefix is escaped by #to_json before its closing quote is # trimmed. segments << %({"content":[{"type":#{key.to_json},#{key.to_json}:) segments << json_string_prefix(media.data_uri_prefix) segments << media.stream_segment segments << %("}]}) else segments << row.to_json end end segments << "]" trailer.each { |k, v| segments << ",#{k.to_s.to_json}:#{v.to_json}" } segments << "}" Parse::Embeddings::StreamingBody.new(segments) end |
#build_text_body(strings, wire_input_type) ⇒ Object (protected)
Build the wire body for the standard /v1/embeddings endpoint
(text-only models).
531 532 533 534 535 536 537 538 539 540 541 542 543 |
# File 'lib/parse/embeddings/voyage.rb', line 531 def build_text_body(strings, wire_input_type) body = { input: strings, model: @model, truncation: @truncation, } # Only forward input_type when it has a wire value. Voyage # treats absent and `null` identically (unconditioned head), # but absent is the spec-correct form for non-retrieval intent. body[:input_type] = wire_input_type if wire_input_type apply_output_dimension!(body) body end |
#dimensions ⇒ Object
336 337 338 |
# File 'lib/parse/embeddings/voyage.rb', line 336 def dimensions @dimensions end |
#dispatch_media(rows, input_type, wire_input_type, kind) ⇒ Object (protected)
Issue one multimodal request for a set of same-representation rows and return the vectors in row order.
656 657 658 659 660 661 662 663 664 665 666 667 668 |
# File 'lib/parse/embeddings/voyage.rb', line 656 def dispatch_media(rows, input_type, wire_input_type, kind) body = build_request_body(rows, wire_input_type) (rows.length, input_type, modality: kind) do |emit_payload| payload = (body, path: "multimodalembeddings") 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, rows.length) validate_response!(rows.length, vectors) end end |
#embed_batch_size ⇒ Object
356 357 358 |
# File 'lib/parse/embeddings/voyage.rb', line 356 def @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 thetrust_provider_url_fetchsentinel 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.
468 469 470 471 |
# File 'lib/parse/embeddings/voyage.rb', line 468 def (sources, input_type: :search_document, allow_insecure: false) (sources, kind: :image, input_type: input_type, allow_insecure: allow_insecure) end |
#embed_media(sources, kind:, input_type:, allow_insecure:) ⇒ Array<Array<Float>> (protected)
Shared implementation behind #embed_image and #embed_video.
597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 |
# File 'lib/parse/embeddings/voyage.rb', line 597 def (sources, kind:, input_type:, allow_insecure:) spec = MEDIA_MODALITIES.fetch(kind) capable = self.class.const_get(spec[:models]) caller_name = "embed_#{kind}" unless capable.include?(@model) raise BadRequestError, "Parse::Embeddings::Voyage##{caller_name}: model #{@model.inspect} does not " \ "accept #{spec[:noun]} inputs. Configure the provider with a capable model " \ "(supported: #{capable.inspect})." end unless sources.is_a?(Array) raise ArgumentError, "Parse::Embeddings::Voyage##{caller_name} expects Array of #{spec[:noun]} " \ "URLs (got #{sources.class})." end return [] if sources.empty? unless INPUT_TYPE_WIRE_VALUES.key?(input_type) raise ArgumentError, "Parse::Embeddings::Voyage##{caller_name} input_type #{input_type.inspect} " \ "not in #{INPUT_TYPE_WIRE_VALUES.keys.inspect}." end # Voyage caps multimodal requests at the same per-request size # as the text endpoint. The text path chunks automatically; the # media path has no chunker (every directive is a single # source), so guard the direct-API caller against a silent 400. if sources.length > @embed_batch_size raise ArgumentError, "Parse::Embeddings::Voyage##{caller_name}: batch size #{sources.length} " \ "exceeds the configured cap #{@embed_batch_size} (Voyage per-request max: " \ "128). Split the input and call #{caller_name} once per chunk." end rows = build_media_rows( sources, kind: kind, allow_insecure: allow_insecure, caller_name: caller_name, ) wire_input_type = INPUT_TYPE_WIRE_VALUES[input_type] # Voyage requires a single representation per request: "each # request should use either image_base64/video_base64 or # image_url/video_url exclusively, not both." A mixed batch is # therefore split into one request per representation and # reassembled in the caller's original order, so the 1:1 # alignment this method promises still holds. groups = rows.each_with_index.group_by { |row, _i| row_representation(row) } return dispatch_media(rows, input_type, wire_input_type, kind) if groups.size == 1 results = Array.new(rows.length) groups.each_value do |pairs| subset = pairs.map(&:first) vectors = dispatch_media(subset, input_type, wire_input_type, kind) pairs.each_with_index { |(_row, original_index), n| results[original_index] = vectors[n] } end results end |
#embed_text(strings, input_type: :search_document) ⇒ Array<Array<Float>>
Returns 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 |
# File 'lib/parse/embeddings/voyage.rb', line 376 def (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" (strings.length, input_type) do |emit_payload| payload = (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 thetrust_provider_url_fetchsentinel 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.
498 499 500 501 |
# File 'lib/parse/embeddings/voyage.rb', line 498 def (sources, input_type: :search_document, allow_insecure: false) (sources, kind: :video, input_type: input_type, allow_insecure: allow_insecure) end |
#endpoint ⇒ Symbol
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.
347 348 349 |
# File 'lib/parse/embeddings/voyage.rb', line 347 def endpoint @endpoint end |
#enforce_media_size!(bytes, index, caller_name, path = nil) ⇒ Object (protected)
Refuse a payload Voyage will reject anyway. Enforced here rather than relying on Parse::Embeddings.max_media_bytes, which callers may legitimately raise for other providers.
723 724 725 726 727 728 729 730 731 |
# File 'lib/parse/embeddings/voyage.rb', line 723 def enforce_media_size!(bytes, index, caller_name, path = nil) return if bytes <= MAX_MEDIA_BYTES where = path ? " (#{path})" : "" raise BadRequestError, "Parse::Embeddings::Voyage##{caller_name} sources[#{index}]#{where} is " \ "#{bytes} bytes, over Voyage's #{MAX_MEDIA_BYTES}-byte per-file limit. " \ "Downscale or re-encode before embedding." end |
#extract_vectors!(payload, input_count) ⇒ Object (protected)
Voyage's response shape mirrors OpenAI:
{ "object": "list", "data": [ { "object": "embedding", "embedding": [...], "index": 0 }, ... ], "model": "voyage-3", "usage": { "total_tokens": N } }
874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 |
# File 'lib/parse/embeddings/voyage.rb', line 874 def extract_vectors!(payload, input_count) unless payload.is_a?(Hash) raise InvalidResponseError, "Parse::Embeddings::Voyage: response body is not a JSON object." end data = payload["data"] unless data.is_a?(Array) raise InvalidResponseError, "Parse::Embeddings::Voyage: response.data is not an Array." end if data.length != input_count raise InvalidResponseError, "Parse::Embeddings::Voyage: response.data.length #{data.length} != input count #{input_count}." end sorted = data.each_with_index.map do |entry, i| unless entry.is_a?(Hash) raise InvalidResponseError, "Parse::Embeddings::Voyage: response.data[#{i}] is not a JSON object." end idx = entry["index"] unless idx.is_a?(Integer) && idx >= 0 && idx < input_count raise InvalidResponseError, "Parse::Embeddings::Voyage: response.data[#{i}].index #{idx.inspect} out of range." end [idx, entry["embedding"]] end indices = sorted.map(&:first) if indices.uniq.length != indices.length raise InvalidResponseError, "Parse::Embeddings::Voyage: duplicate index in response.data." end sorted.sort_by(&:first).map(&:last) end |
#inspect_attrs ⇒ Object
503 504 505 |
# File 'lib/parse/embeddings/voyage.rb', line 503 def inspect_attrs super.merge(base: safe_base_host, endpoint: @endpoint, retries: @max_retries) end |
#json_string_prefix(str) ⇒ Object (protected)
A JSON string literal with its opening quote and escaped contents but no closing quote, so a streamed payload can be appended before the string is closed.
790 791 792 793 |
# File 'lib/parse/embeddings/voyage.rb', line 790 def json_string_prefix(str) encoded = str.to_json encoded[0...-1] end |
#max_input_tokens ⇒ Object
360 361 362 |
# File 'lib/parse/embeddings/voyage.rb', line 360 def max_input_tokens MODEL_MAX_INPUT_TOKENS[@model] end |
#modalities ⇒ Array<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.
427 428 429 430 |
# File 'lib/parse/embeddings/voyage.rb', line 427 def modalities return [:text] unless MULTIMODAL_MODELS.include?(@model) VIDEO_MODELS.include?(@model) ? %i[text image video] : %i[text image] end |
#model_name ⇒ Object
340 341 342 |
# File 'lib/parse/embeddings/voyage.rb', line 340 def model_name @model end |
#normalize? ⇒ 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 |
#parse_json_body!(body) ⇒ Object (protected)
850 851 852 853 854 855 856 857 858 859 860 861 |
# File 'lib/parse/embeddings/voyage.rb', line 850 def parse_json_body!(body) s = body.to_s if s.bytesize > MAX_RESPONSE_BYTES raise InvalidResponseError, "Parse::Embeddings::Voyage: response body exceeds #{MAX_RESPONSE_BYTES} bytes " \ "(#{s.bytesize}). Refusing to parse." end JSON.parse(s, max_nesting: 32) rescue JSON::ParserError => e raise InvalidResponseError, "Parse::Embeddings::Voyage: response is not valid JSON (#{e.})." end |
#post_embeddings(body, path: "embeddings") ⇒ Object (protected)
795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 |
# File 'lib/parse/embeddings/voyage.rb', line 795 def (body, path: "embeddings") attempts = 0 loop do attempts += 1 begin response = @connection.post(path) do |req| if body.is_a?(Parse::Embeddings::StreamingBody) # Faraday's net_http adapter routes an IO-shaped body # to Net::HTTP#body_stream. Rewind so a retry replays # from the start, and set Content-Length explicitly — # without it Net::HTTP falls back to chunked transfer # encoding, which some API gateways reject. body.rewind req.headers["Content-Length"] = body.size.to_s req.body = body else req.body = body.to_json end end rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e if attempts > @max_retries raise TransientError, "Parse::Embeddings::Voyage: #{e.class} after #{attempts} attempt(s)." end sleep(backoff_seconds(attempts)) next end status = response.status return parse_json_body!(response.body) if status >= 200 && status < 300 if status == 401 raise AuthenticationError, "Parse::Embeddings::Voyage: 401 Unauthorized — check api_key." end if status == 429 if attempts > @max_retries raise RateLimitError, "Parse::Embeddings::Voyage: 429 rate limited after #{attempts} attempt(s)." end sleep(retry_after_seconds(response) || backoff_seconds(attempts)) next end if status >= 500 if attempts > @max_retries raise TransientError, "Parse::Embeddings::Voyage: #{status} after #{attempts} attempt(s)." end sleep(backoff_seconds(attempts)) next end raise BadRequestError, "Parse::Embeddings::Voyage: #{status} from POST /#{path}." end end |
#retry_after_seconds(response) ⇒ Object (protected)
912 913 914 915 916 917 |
# File 'lib/parse/embeddings/voyage.rb', line 912 def retry_after_seconds(response) ra = response.respond_to?(:headers) ? response.headers["retry-after"] || response.headers["Retry-After"] : nil return nil unless ra v = ra.to_f v.positive? ? [v, 60.0].min : nil end |
#row_representation(row) ⇒ Object (protected)
Which wire representation a row descriptor uses. Voyage requires a single representation per request, so this is what #embed_media partitions on.
736 737 738 739 740 |
# File 'lib/parse/embeddings/voyage.rb', line 736 def row_representation(row) return :base64 if row[:stream] type = row.dig(:content, 0, :type).to_s type.end_with?("_base64") ? :base64 : :url end |
#supports_input_type? ⇒ Boolean
369 370 371 |
# File 'lib/parse/embeddings/voyage.rb', line 369 def supports_input_type? true end |