ruby_llm-voyage
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:
= RubyLLM.(
'Ruby is optimized for programmer happiness.',
model: 'voyage-4',
provider: :voyage
)
.vectors # => [0.018, -0.032, ...]
.input_tokens # => 9
Pass an array to embed a batch in one request, and dimensions: to control vector size:
RubyLLM.(%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.(['Reset your password from account settings.'])
RubyLLM::Voyage.('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:
= RubyLLM::Voyage.(
'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. = '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
digest = Digest::SHA256.hexdigest(content)
return if == digest
result = RubyLLM::Voyage.(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.
end
Then search with a query embedding:
class ArticlesController < ApplicationController
def search
query_vector = RubyLLM::Voyage.(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.(batch_size: 100)
where(embedding: nil).find_in_batches(batch_size: batch_size) do |batch|
result = RubyLLM::Voyage.(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.
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.
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.(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.(
[
['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.(
[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.
)
end
With auto_chunk, store chunk.text alongside each vector, and query the
index with the same contextualized model.
Giving a chatbot search
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.(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. = '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_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.
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.