ruby_llm-voyage

CI Gem Version

Voyage AI builds embedding and reranking models for search and retrieval. This gem adds them to RubyLLM: document and query embeddings, reranking, contextualized chunk embeddings, and the Files API.

Requires Ruby 3.1.3+ and RubyLLM 1.16+.

Installation

Add the gem to your Gemfile:

gem 'ruby_llm-voyage'

Then configure your API key (created in the Voyage dashboard):

RubyLLM.configure do |config|
  config.voyage_api_key = ENV.fetch('VOYAGE_API_KEY')
end

Your first embedding

Voyage works through the standard RubyLLM embedding interface:

embedding = RubyLLM.embed(
  'Ruby is optimized for programmer happiness.',
  model: 'voyage-4',
  provider: :voyage
)

embedding.vectors      # => [0.018, -0.032, ...]
embedding.input_tokens # => 9

Pass an array to embed a batch in one request, and dimensions: to control vector size:

RubyLLM.embed(%w[Ruby Rails], model: 'voyage-4-lite', provider: :voyage, dimensions: 512)

Voyage tunes vectors differently for the text you store (document) and the text people search with (query), and using both sides improves retrieval quality. The gem gives each side its own method:

RubyLLM::Voyage.embed_documents(['Reset your password from account settings.'])
RubyLLM::Voyage.embed_query('how do I reset my password')

Both return the same RubyLLM::Embedding object and use config.default_embedding_model when model: is omitted.

For the remaining options — truncation, quantized output, an explicit nil input type — use RubyLLM::Voyage.embed:

embedding = RubyLLM::Voyage.embed(
  'How do I reset my password?',
  model: 'voyage-4-lite',
  input_type: 'query',
  truncation: false,
  dimensions: 512
)

Semantic search in Rails

Here's a complete search feature for a help center, using Neighbor and PostgreSQL's pgvector extension.

Set up the gems and configure the embedding defaults once:

# Gemfile
gem 'ruby_llm-voyage'
gem 'neighbor'
# config/initializers/ruby_llm.rb
RubyLLM.configure do |config|
  config.voyage_api_key = Rails.credentials.voyage_api_key
  config.default_embedding_model = 'voyage-4-lite'
  config.voyage_default_output_dimension = 512
end

Then the vector column:

bin/rails generate neighbor:vector
bin/rails db:migrate
class AddEmbeddingToArticles < ActiveRecord::Migration[8.0]
  def change
    add_column :articles, :embedding, :vector, limit: 512

    add_column :articles, :embedding_model, :string   # Identifies rows that need re-embedding when you upgrade models
    add_column :articles, :embedding_digest, :string  # Digest of the embedded data to track changes to the field
    add_column :articles, :embedded_at, :datetime     # Identifies when the data last had its embedding created

    add_index :articles, :embedding, using: :hnsw, opclass: :vector_cosine_ops
    add_index :articles, :embedding_model
  end
end

Embed in the background as articles are created or updated:

class Article < ApplicationRecord
  has_neighbors :embedding

  after_save_commit -> { EmbedArticleJob.perform_later(self) }, if: :saved_change_to_content?

  def refresh_embedding!
    digest = Digest::SHA256.hexdigest(content)
    return if embedding_digest == digest

    result = RubyLLM::Voyage.embed_documents(content)

    update_columns(
      embedding: result.vectors,
      embedding_model: result.model,
      embedding_digest: digest,
      embedded_at: Time.zone.current
    )
  end
end

class EmbedArticleJob < ApplicationJob
  def perform(article) = article.refresh_embedding!
end

Then search with a query embedding:

class ArticlesController < ApplicationController
  def search
    query_vector = RubyLLM::Voyage.embed_query(params[:q]).vectors

    @articles = Article.nearest_neighbors(
      :embedding, query_vector, distance: 'cosine'
    ).first(10)
  end
end

Embedding an entire table

The per-record job above handles ongoing writes, but backfilling existing data one row at a time wastes API calls. Embed whole tables in batches instead — each batch is one request, and Voyage accepts up to 1,000 texts per request (current limits):

class Article < ApplicationRecord
  def self.embed_missing!(batch_size: 100)
    where(embedding: nil).find_in_batches(batch_size: batch_size) do |batch|
      result = RubyLLM::Voyage.embed_documents(batch.map(&:content))

      batch.zip(result.vectors) do |(article, vector)|
        article.update_columns(
          embedding: vector,
          embedding_model: result.model,
          embedding_digest: Digest::SHA256.hexdigest(article.content),
          embedded_at: Time.current
        )
      end
    end
  end
end

Article.embed_missing!

The provenance columns make model upgrades a repeat of the same procedure: clear the vectors built with the old model, backfill, and switch the query side once nothing is left to embed.

Article.where.not(embedding_model: 'voyage-4').update_all(embedding: nil)
Article.embed_missing!

Better results with reranking

Vector search is fast but approximate. When result order matters, fetch a generous candidate set with vector search, then let a Voyage reranker put the best matches first:

question = 'How do I configure SAML SSO?'
query_vector = RubyLLM::Voyage.embed_query(question).vectors

candidates = Article.nearest_neighbors(
  :embedding, query_vector, distance: 'cosine'
).first(25)

reranking = RubyLLM::Voyage.rerank(
  query: question,
  documents: candidates.map(&:content),
  model: 'rerank-2.5-lite',
  top_k: 5
)

best_articles = reranking.reorder(candidates) # the 5 most relevant records

reorder maps the reranked positions back onto the collection you passed as documents:. Each result also carries the original index, a relevance_score, and (with return_documents: true) the document text.

Rerankers score every candidate against the query, so rerank a small candidate set rather than the whole corpus.

Embedding long documents with context

Chunking a long document strips each chunk of its surroundings: a handbook chunk that reads "this does not apply during the probation period" no longer says which policy it belongs to. Voyage's contextualized models embed each chunk with information from the rest of its document.

Pass pre-chunked documents as nested arrays:

result = RubyLLM::Voyage.contextualized_embed(
  [
    ['Vacation policy overview', 'Accrual rates by tenure', 'Carryover rules'],
    ['Remote work policy', 'Equipment stipend', 'Home office requirements']
  ],
  model: 'voyage-context-4',
  input_type: :document,
  dimensions: 512
)

result.results.each do |document|
  document.chunks.each do |chunk|
    # document.index, chunk.index, chunk.embedding
  end
end

Or let Voyage do the chunking server-side:

result = RubyLLM::Voyage.contextualized_embed(
  [handbook.full_text],
  model: 'voyage-context-4',
  input_type: :document,
  dimensions: 512,
  auto_chunk: true,
  chunk_size: 512,
  chunk_overlap: 0
)

result.results.first.chunks.each do |chunk|
  HandbookChunk.create!(
    position: chunk.index,
    content: chunk.text,   # the exact text this vector represents
    embedding: chunk.embedding
  )
end

With auto_chunk, store chunk.text alongside each vector, and query the index with the same contextualized model.

A RubyLLM tool can embed the model's search query with Voyage and pull matching articles out of your database:

class SearchKnowledgeBase < RubyLLM::Tool
  desc 'Search the help center for articles relevant to a question'

  param :query, desc: 'A concise search query'

  def execute(query:)
    query_vector = RubyLLM::Voyage.embed_query(query).vectors

    Article.nearest_neighbors(:embedding, query_vector, distance: 'cosine')
           .first(5)
           .map { |article| { id: article.id, content: article.content } }
           .to_json
  end
end

chat = RubyLLM.chat(model: 'claude-sonnet-4-5')
chat.with_instructions('Answer using the knowledge base. Cite article IDs.')
chat.with_tool(SearchKnowledgeBase)

chat.ask('How do I set up single sign-on?')

If articles are scoped per account, filter the nearest_neighbors query by the current user's permissions before returning results to the model.

Batch workflows with the Files API

Voyage's asynchronous batch workflows exchange JSONL files. This gem covers the full file lifecycle:

uploaded = RubyLLM::Voyage.upload_file('requests.jsonl')

RubyLLM::Voyage.retrieve_file(uploaded.id)   # metadata
RubyLLM::Voyage.list_files(purpose: 'batch') # cursor-paginated listing
RubyLLM::Voyage.file_content(uploaded.id)    # raw JSONL back out

RubyLLM::Voyage.delete_files([uploaded.id])

find_file and download_file are aliases of retrieve_file and file_content, matching RubyLLM's own file-method naming.

Downloads follow Voyage's redirect to a signed storage URL without forwarding your API key, and refuse non-HTTPS redirects.

Configuration reference

Every Voyage option can be set globally, per RubyLLM.context, or per request:

RubyLLM.configure do |config|
  config.voyage_api_key = ENV.fetch('VOYAGE_API_KEY')
  config.default_embedding_model = 'voyage-4-lite'  # when model: is omitted
  config.voyage_default_output_dimension = 512      # when dimensions: is omitted
  config.voyage_default_input_type = 'document'
  config.voyage_default_truncation = false          # error instead of truncating
  config.voyage_default_output_dtype = 'float'
  config.voyage_default_encoding_format = 'base64'
  config.voyage_api_base = 'https://api.voyageai.com/v1'
end

Request-level options on RubyLLM::Voyage.embed (and on embed_query / embed_documents, which fix input_type for you):

Option Voyage field Values
model: model Any Voyage embedding model ID
input_type: input_type query, document, or nil
truncation: truncation true or false
dimensions: output_dimension Model-dependent
output_dtype: output_dtype float, int8, uint8, binary, ubinary
encoding_format: encoding_format base64 or nil
provider_options: merged into request Fields the gem doesn't have a keyword for yet

Not supported

  • Multimodal embeddings (voyage-multimodal-3.5) — text inputs only.
  • Batch jobs — the Files API is covered, but creating and managing asynchronous batch jobs is not.
  • Token counting — Voyage's official SDKs tokenize locally; this gem reports input_tokens from API responses instead.
  • Request splitting — one call sends one API request. Collections over Voyage's 1,000-text limit must be batched by the caller, as in the backfill example above.

New Voyage request fields usually work before the gem names them: pass them through provider_options:. For anything else, open an issue.

Development

bin/setup
bundle exec rake test     # offline suite, no API key needed
bundle exec rubocop

An opt-in live suite runs against the real Voyage API:

VOYAGE_API_KEY=your-key bundle exec rake test:live

See CONTRIBUTING.md for development guidelines and live test options, and docs/RELEASING.md for the release process.

License

The gem is available as open source under the terms of the MIT License.