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+.
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')
endVoyage 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 # => 9Pass 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
)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
endThen the vector column:
bin/rails generate neighbor:vector
bin/rails db:migrateclass 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
endEmbed 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!
endThen 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
endThe 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!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 recordsreorder 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.
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
endOr 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
)
endWith 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.
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.
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'
endRequest-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 |
- 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_tokensfrom 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.
bin/setup
bundle exec rake test # offline suite, no API key needed
bundle exec rubocopAn opt-in live suite runs against the real Voyage API:
VOYAGE_API_KEY=your-key bundle exec rake test:liveSee CONTRIBUTING.md for development guidelines and live test options, and docs/RELEASING.md for the release process.
The gem is available as open source under the terms of the MIT License.