ruby_llm-voyage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +45 -0
- data/CONTRIBUTING.md +72 -0
- data/LICENSE.txt +21 -0
- data/README.md +389 -0
- data/SECURITY.md +31 -0
- data/docs/RELEASING.md +91 -0
- data/lib/ruby_llm/providers/voyage/contextualized_embeddings.rb +62 -0
- data/lib/ruby_llm/providers/voyage/embeddings.rb +80 -0
- data/lib/ruby_llm/providers/voyage/files.rb +126 -0
- data/lib/ruby_llm/providers/voyage/reranking.rb +47 -0
- data/lib/ruby_llm/providers/voyage.rb +80 -0
- data/lib/ruby_llm/voyage/results.rb +74 -0
- data/lib/ruby_llm/voyage/vector_decoder.rb +39 -0
- data/lib/ruby_llm/voyage/version.rb +18 -0
- data/lib/ruby_llm/voyage.rb +258 -0
- data/lib/ruby_llm-voyage.rb +4 -0
- metadata +91 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8ff013c0d7cc147977b07d6bd229fe1417390abf857b3252cb9721969609c1bf
|
|
4
|
+
data.tar.gz: 9e6075faf6732a6dcb9f68a03f25a7302435f4fa12b89a253999d09ec8e98549
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ce3de774db04508201dcf029283c28d3c3c9dc1de6286cbf7dab209743f46692af300516439c23ba4bb49a9e654ee123479eeef29ff6ec7dfa35edaa57899f40
|
|
7
|
+
data.tar.gz: e0ba556b4ffa536441a2760c2190dec86ccc80894772702a5ca187f2db753241192e26a180ac9005530f59d1f0465e5b78f2596c98454cec991f944144673f6f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-07-11
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Voyage text embedding provider for RubyLLM 1.16, covering single and batch
|
|
15
|
+
inputs, query and document input types, truncation control, flexible
|
|
16
|
+
dimensions, quantized output types, and Base64 decoding.
|
|
17
|
+
- `RubyLLM::Voyage.embed_query` and `RubyLLM::Voyage.embed_documents` for the
|
|
18
|
+
two sides of retrieval, with `input_type` fixed per method.
|
|
19
|
+
- `RubyLLM::Voyage.embed` facade for request-level Voyage options, falling
|
|
20
|
+
back to `config.default_embedding_model` when `model:` is omitted.
|
|
21
|
+
- Reranking via `RubyLLM::Voyage.rerank`, with `Reranking#reorder` to map
|
|
22
|
+
results back onto the original candidate collection.
|
|
23
|
+
- Contextualized chunk embeddings via
|
|
24
|
+
`RubyLLM::Voyage.contextualized_embed`, including server-side auto
|
|
25
|
+
chunking.
|
|
26
|
+
- Files API support: upload, retrieve, list, download, and bulk delete, with
|
|
27
|
+
`find_file` and `download_file` aliases matching RubyLLM's provider file
|
|
28
|
+
naming. Downloads follow signed-URL redirects without forwarding the API
|
|
29
|
+
key and reject non-HTTPS redirects.
|
|
30
|
+
- Public `VectorDecoder` for decoding Base64 embeddings out of batch result
|
|
31
|
+
files.
|
|
32
|
+
- Configurable request defaults: `voyage_default_input_type`,
|
|
33
|
+
`voyage_default_truncation`, `voyage_default_output_dimension`,
|
|
34
|
+
`voyage_default_output_dtype`, and `voyage_default_encoding_format`.
|
|
35
|
+
- Fail-fast `ArgumentError` for nil or empty inputs before any network
|
|
36
|
+
request.
|
|
37
|
+
- Instrumentation events for embeddings, reranking, and contextualized
|
|
38
|
+
embeddings with result, response model, and token usage.
|
|
39
|
+
- Offline unit and HTTP contract tests, opt-in live API tests, and CI
|
|
40
|
+
coverage for Ruby 3.1 through 4.0.
|
|
41
|
+
- Trusted-publishing release workflow, SHA-pinned GitHub Actions, and
|
|
42
|
+
contributor, security, and release documentation.
|
|
43
|
+
|
|
44
|
+
[Unreleased]: https://github.com/dinsley/ruby_llm-voyage/compare/v0.1.0...HEAD
|
|
45
|
+
[0.1.0]: https://github.com/dinsley/ruby_llm-voyage/releases/tag/v0.1.0
|
data/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for helping improve `ruby_llm-voyage`.
|
|
4
|
+
|
|
5
|
+
## Development setup
|
|
6
|
+
|
|
7
|
+
The project supports Ruby 3.1.3 through Ruby 4.0 and RubyLLM 1.x beginning
|
|
8
|
+
with 1.16.
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
git clone https://github.com/dinsley/ruby_llm-voyage.git
|
|
12
|
+
cd ruby_llm-voyage
|
|
13
|
+
bin/setup
|
|
14
|
+
bundle exec rake test
|
|
15
|
+
bundle exec rubocop
|
|
16
|
+
bundle exec rake yard
|
|
17
|
+
bundle exec rake rdoc
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`bin/console` starts an IRB session with the gem loaded.
|
|
21
|
+
|
|
22
|
+
The default suite is network-isolated with WebMock. New behavior should have
|
|
23
|
+
offline coverage and must not require a Voyage API key.
|
|
24
|
+
|
|
25
|
+
## Live API tests
|
|
26
|
+
|
|
27
|
+
Live tests are manual, billable, and never run in CI:
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
VOYAGE_API_KEY=your-key bundle exec rake test:live
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The live suite covers single and batch embedding, context-based defaults,
|
|
34
|
+
input types, retrieval ranking, every flexible dimension and output dtype,
|
|
35
|
+
Base64 decoding, `provider_options` precedence, reranking, and contextualized
|
|
36
|
+
embeddings. WebMock allows connections only to `api.voyageai.com`, so an
|
|
37
|
+
accidental request to any other host still fails.
|
|
38
|
+
|
|
39
|
+
The suite defaults to `voyage-4-lite`, which supports the complete
|
|
40
|
+
dimension/dtype matrix. Environment variables adjust what runs:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
# Use a different primary model (must support flexible dimensions and dtypes)
|
|
44
|
+
VOYAGE_LIVE_MODEL=voyage-4 bundle exec rake test:live
|
|
45
|
+
|
|
46
|
+
# Smoke-test additional model families with their defaults
|
|
47
|
+
VOYAGE_LIVE_MODELS=voyage-4-large,voyage-code-3 bundle exec rake test:live
|
|
48
|
+
|
|
49
|
+
# Include the mutating Files API lifecycle test (uploads and deletes a file)
|
|
50
|
+
VOYAGE_LIVE_FILES=1 bundle exec rake test:live
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use short inputs and the default `voyage-4-lite` model unless a test
|
|
54
|
+
specifically requires another model. Never commit credentials, captured
|
|
55
|
+
authorization headers, or API responses containing private input.
|
|
56
|
+
|
|
57
|
+
## Pull requests
|
|
58
|
+
|
|
59
|
+
- Add or update tests for behavior changes.
|
|
60
|
+
- Update `README.md` for user-facing changes.
|
|
61
|
+
- Add an entry under `Unreleased` in `CHANGELOG.md`.
|
|
62
|
+
- Run `bundle exec rake` (tests and RuboCop) and
|
|
63
|
+
`gem build ruby_llm-voyage.gemspec`.
|
|
64
|
+
- Add YARD/RDoc comments for public API changes and verify both documentation
|
|
65
|
+
tasks complete without warnings.
|
|
66
|
+
- Keep live tests separate from the default suite.
|
|
67
|
+
|
|
68
|
+
## Compatibility changes
|
|
69
|
+
|
|
70
|
+
Changes to the minimum Ruby or RubyLLM version are user-facing compatibility
|
|
71
|
+
changes. Update the gemspec, CI matrix, the requirements note at the top of
|
|
72
|
+
the README, and the changelog together.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Insley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# ruby_llm-voyage
|
|
2
|
+
|
|
3
|
+
[](https://github.com/dinsley/ruby_llm-voyage/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/ruby_llm-voyage)
|
|
5
|
+
|
|
6
|
+
[Voyage AI](https://docs.voyageai.com/) builds embedding and reranking models
|
|
7
|
+
for search and retrieval. This gem adds them to
|
|
8
|
+
[RubyLLM](https://rubyllm.com/): document and query embeddings, reranking,
|
|
9
|
+
contextualized chunk embeddings, and the Files API.
|
|
10
|
+
|
|
11
|
+
Requires Ruby 3.1.3+ and RubyLLM 1.16+.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
Add the gem to your Gemfile:
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem 'ruby_llm-voyage'
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then configure your API key (created in the [Voyage dashboard](https://dashboard.voyageai.com/)):
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
RubyLLM.configure do |config|
|
|
25
|
+
config.voyage_api_key = ENV.fetch('VOYAGE_API_KEY')
|
|
26
|
+
end
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Your first embedding
|
|
30
|
+
|
|
31
|
+
Voyage works through the standard RubyLLM embedding interface:
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
embedding = RubyLLM.embed(
|
|
35
|
+
'Ruby is optimized for programmer happiness.',
|
|
36
|
+
model: 'voyage-4',
|
|
37
|
+
provider: :voyage
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
embedding.vectors # => [0.018, -0.032, ...]
|
|
41
|
+
embedding.input_tokens # => 9
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Pass an array to embed a batch in one request, and `dimensions:` to control vector size:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
RubyLLM.embed(%w[Ruby Rails], model: 'voyage-4-lite', provider: :voyage, dimensions: 512)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Voyage tunes vectors differently for the text you store (`document`) and the text people search with (`query`), and using
|
|
51
|
+
both sides improves retrieval quality. The gem gives each side its own method:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
RubyLLM::Voyage.embed_documents(['Reset your password from account settings.'])
|
|
55
|
+
RubyLLM::Voyage.embed_query('how do I reset my password')
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Both return the same `RubyLLM::Embedding` object and use `config.default_embedding_model` when `model:` is omitted.
|
|
59
|
+
|
|
60
|
+
For the remaining options — truncation, quantized output, an explicit `nil` input type — use `RubyLLM::Voyage.embed`:
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
embedding = RubyLLM::Voyage.embed(
|
|
64
|
+
'How do I reset my password?',
|
|
65
|
+
model: 'voyage-4-lite',
|
|
66
|
+
input_type: 'query',
|
|
67
|
+
truncation: false,
|
|
68
|
+
dimensions: 512
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Semantic search in Rails
|
|
73
|
+
|
|
74
|
+
Here's a complete search feature for a help center, using [Neighbor](https://github.com/ankane/neighbor) and PostgreSQL's pgvector
|
|
75
|
+
extension.
|
|
76
|
+
|
|
77
|
+
Set up the gems and configure the embedding defaults once:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# Gemfile
|
|
81
|
+
gem 'ruby_llm-voyage'
|
|
82
|
+
gem 'neighbor'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# config/initializers/ruby_llm.rb
|
|
87
|
+
RubyLLM.configure do |config|
|
|
88
|
+
config.voyage_api_key = Rails.credentials.voyage_api_key
|
|
89
|
+
config.default_embedding_model = 'voyage-4-lite'
|
|
90
|
+
config.voyage_default_output_dimension = 512
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Then the vector column:
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
bin/rails generate neighbor:vector
|
|
98
|
+
bin/rails db:migrate
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
class AddEmbeddingToArticles < ActiveRecord::Migration[8.0]
|
|
103
|
+
def change
|
|
104
|
+
add_column :articles, :embedding, :vector, limit: 512
|
|
105
|
+
|
|
106
|
+
add_column :articles, :embedding_model, :string # Identifies rows that need re-embedding when you upgrade models
|
|
107
|
+
add_column :articles, :embedding_digest, :string # Digest of the embedded data to track changes to the field
|
|
108
|
+
add_column :articles, :embedded_at, :datetime # Identifies when the data last had its embedding created
|
|
109
|
+
|
|
110
|
+
add_index :articles, :embedding, using: :hnsw, opclass: :vector_cosine_ops
|
|
111
|
+
add_index :articles, :embedding_model
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Embed in the background as articles are created or updated:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
class Article < ApplicationRecord
|
|
120
|
+
has_neighbors :embedding
|
|
121
|
+
|
|
122
|
+
after_save_commit -> { EmbedArticleJob.perform_later(self) }, if: :saved_change_to_content?
|
|
123
|
+
|
|
124
|
+
def refresh_embedding!
|
|
125
|
+
digest = Digest::SHA256.hexdigest(content)
|
|
126
|
+
return if embedding_digest == digest
|
|
127
|
+
|
|
128
|
+
result = RubyLLM::Voyage.embed_documents(content)
|
|
129
|
+
|
|
130
|
+
update_columns(
|
|
131
|
+
embedding: result.vectors,
|
|
132
|
+
embedding_model: result.model,
|
|
133
|
+
embedding_digest: digest,
|
|
134
|
+
embedded_at: Time.zone.current
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
class EmbedArticleJob < ApplicationJob
|
|
140
|
+
def perform(article) = article.refresh_embedding!
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Then search with a query embedding:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
class ArticlesController < ApplicationController
|
|
148
|
+
def search
|
|
149
|
+
query_vector = RubyLLM::Voyage.embed_query(params[:q]).vectors
|
|
150
|
+
|
|
151
|
+
@articles = Article.nearest_neighbors(
|
|
152
|
+
:embedding, query_vector, distance: 'cosine'
|
|
153
|
+
).first(10)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Embedding an entire table
|
|
159
|
+
|
|
160
|
+
The per-record job above handles ongoing writes, but backfilling existing data one row at a time wastes
|
|
161
|
+
API calls. Embed whole tables in batches instead — each batch is one request, and Voyage accepts up to
|
|
162
|
+
1,000 texts per request ([current limits](https://docs.voyageai.com/reference/embeddings-api)):
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
class Article < ApplicationRecord
|
|
166
|
+
def self.embed_missing!(batch_size: 100)
|
|
167
|
+
where(embedding: nil).find_in_batches(batch_size: batch_size) do |batch|
|
|
168
|
+
result = RubyLLM::Voyage.embed_documents(batch.map(&:content))
|
|
169
|
+
|
|
170
|
+
batch.zip(result.vectors) do |(article, vector)|
|
|
171
|
+
article.update_columns(
|
|
172
|
+
embedding: vector,
|
|
173
|
+
embedding_model: result.model,
|
|
174
|
+
embedding_digest: Digest::SHA256.hexdigest(article.content),
|
|
175
|
+
embedded_at: Time.current
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
Article.embed_missing!
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The provenance columns make model upgrades a repeat of the same procedure:
|
|
186
|
+
clear the vectors built with the old model, backfill, and switch the query
|
|
187
|
+
side once nothing is left to embed.
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
Article.where.not(embedding_model: 'voyage-4').update_all(embedding: nil)
|
|
191
|
+
Article.embed_missing!
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Better results with reranking
|
|
195
|
+
|
|
196
|
+
Vector search is fast but approximate. When result order matters, fetch a generous candidate
|
|
197
|
+
set with vector search, then let a [Voyage reranker](https://docs.voyageai.com/docs/reranker) put
|
|
198
|
+
the best matches first:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
question = 'How do I configure SAML SSO?'
|
|
202
|
+
query_vector = RubyLLM::Voyage.embed_query(question).vectors
|
|
203
|
+
|
|
204
|
+
candidates = Article.nearest_neighbors(
|
|
205
|
+
:embedding, query_vector, distance: 'cosine'
|
|
206
|
+
).first(25)
|
|
207
|
+
|
|
208
|
+
reranking = RubyLLM::Voyage.rerank(
|
|
209
|
+
query: question,
|
|
210
|
+
documents: candidates.map(&:content),
|
|
211
|
+
model: 'rerank-2.5-lite',
|
|
212
|
+
top_k: 5
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
best_articles = reranking.reorder(candidates) # the 5 most relevant records
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`reorder` maps the reranked positions back onto the collection you passed as
|
|
219
|
+
`documents:`. Each result also carries the original `index`, a
|
|
220
|
+
`relevance_score`, and (with `return_documents: true`) the document text.
|
|
221
|
+
|
|
222
|
+
Rerankers score every candidate against the query, so rerank a small candidate set rather than the whole corpus.
|
|
223
|
+
|
|
224
|
+
## Embedding long documents with context
|
|
225
|
+
|
|
226
|
+
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
|
|
227
|
+
says which policy it belongs to. Voyage's [contextualized models](https://docs.voyageai.com/docs/contextualized-chunk-embeddings) embed each chunk with
|
|
228
|
+
information from the rest of its document.
|
|
229
|
+
|
|
230
|
+
Pass pre-chunked documents as nested arrays:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
result = RubyLLM::Voyage.contextualized_embed(
|
|
234
|
+
[
|
|
235
|
+
['Vacation policy overview', 'Accrual rates by tenure', 'Carryover rules'],
|
|
236
|
+
['Remote work policy', 'Equipment stipend', 'Home office requirements']
|
|
237
|
+
],
|
|
238
|
+
model: 'voyage-context-4',
|
|
239
|
+
input_type: :document,
|
|
240
|
+
dimensions: 512
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
result.results.each do |document|
|
|
244
|
+
document.chunks.each do |chunk|
|
|
245
|
+
# document.index, chunk.index, chunk.embedding
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Or let Voyage do the chunking server-side:
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
result = RubyLLM::Voyage.contextualized_embed(
|
|
254
|
+
[handbook.full_text],
|
|
255
|
+
model: 'voyage-context-4',
|
|
256
|
+
input_type: :document,
|
|
257
|
+
dimensions: 512,
|
|
258
|
+
auto_chunk: true,
|
|
259
|
+
chunk_size: 512,
|
|
260
|
+
chunk_overlap: 0
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
result.results.first.chunks.each do |chunk|
|
|
264
|
+
HandbookChunk.create!(
|
|
265
|
+
position: chunk.index,
|
|
266
|
+
content: chunk.text, # the exact text this vector represents
|
|
267
|
+
embedding: chunk.embedding
|
|
268
|
+
)
|
|
269
|
+
end
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
With `auto_chunk`, store `chunk.text` alongside each vector, and query the
|
|
273
|
+
index with the same contextualized model.
|
|
274
|
+
|
|
275
|
+
## Giving a chatbot search
|
|
276
|
+
|
|
277
|
+
A RubyLLM tool can embed the model's search query with Voyage and pull
|
|
278
|
+
matching articles out of your database:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
class SearchKnowledgeBase < RubyLLM::Tool
|
|
282
|
+
desc 'Search the help center for articles relevant to a question'
|
|
283
|
+
|
|
284
|
+
param :query, desc: 'A concise search query'
|
|
285
|
+
|
|
286
|
+
def execute(query:)
|
|
287
|
+
query_vector = RubyLLM::Voyage.embed_query(query).vectors
|
|
288
|
+
|
|
289
|
+
Article.nearest_neighbors(:embedding, query_vector, distance: 'cosine')
|
|
290
|
+
.first(5)
|
|
291
|
+
.map { |article| { id: article.id, content: article.content } }
|
|
292
|
+
.to_json
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
chat = RubyLLM.chat(model: 'claude-sonnet-4-5')
|
|
297
|
+
chat.with_instructions('Answer using the knowledge base. Cite article IDs.')
|
|
298
|
+
chat.with_tool(SearchKnowledgeBase)
|
|
299
|
+
|
|
300
|
+
chat.ask('How do I set up single sign-on?')
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
If articles are scoped per account, filter the `nearest_neighbors` query by the current user's permissions before returning results to the model.
|
|
304
|
+
|
|
305
|
+
## Batch workflows with the Files API
|
|
306
|
+
|
|
307
|
+
Voyage's asynchronous [batch workflows](https://docs.voyageai.com/docs/batch-inference) exchange JSONL files. This gem covers the full file lifecycle:
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
uploaded = RubyLLM::Voyage.upload_file('requests.jsonl')
|
|
311
|
+
|
|
312
|
+
RubyLLM::Voyage.retrieve_file(uploaded.id) # metadata
|
|
313
|
+
RubyLLM::Voyage.list_files(purpose: 'batch') # cursor-paginated listing
|
|
314
|
+
RubyLLM::Voyage.file_content(uploaded.id) # raw JSONL back out
|
|
315
|
+
|
|
316
|
+
RubyLLM::Voyage.delete_files([uploaded.id])
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`find_file` and `download_file` are aliases of `retrieve_file` and `file_content`, matching RubyLLM's own file-method naming.
|
|
320
|
+
|
|
321
|
+
Downloads follow Voyage's redirect to a signed storage URL without forwarding your API key, and refuse non-HTTPS redirects.
|
|
322
|
+
|
|
323
|
+
## Configuration reference
|
|
324
|
+
|
|
325
|
+
Every Voyage option can be set globally, per `RubyLLM.context`, or per
|
|
326
|
+
request:
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
RubyLLM.configure do |config|
|
|
330
|
+
config.voyage_api_key = ENV.fetch('VOYAGE_API_KEY')
|
|
331
|
+
config.default_embedding_model = 'voyage-4-lite' # when model: is omitted
|
|
332
|
+
config.voyage_default_output_dimension = 512 # when dimensions: is omitted
|
|
333
|
+
config.voyage_default_input_type = 'document'
|
|
334
|
+
config.voyage_default_truncation = false # error instead of truncating
|
|
335
|
+
config.voyage_default_output_dtype = 'float'
|
|
336
|
+
config.voyage_default_encoding_format = 'base64'
|
|
337
|
+
config.voyage_api_base = 'https://api.voyageai.com/v1'
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Request-level options on `RubyLLM::Voyage.embed` (and on `embed_query` /
|
|
342
|
+
`embed_documents`, which fix `input_type` for you):
|
|
343
|
+
|
|
344
|
+
| Option | Voyage field | Values |
|
|
345
|
+
| --- | --- | --- |
|
|
346
|
+
| `model:` | `model` | Any Voyage embedding model ID |
|
|
347
|
+
| `input_type:` | `input_type` | `query`, `document`, or `nil` |
|
|
348
|
+
| `truncation:` | `truncation` | `true` or `false` |
|
|
349
|
+
| `dimensions:` | `output_dimension` | Model-dependent |
|
|
350
|
+
| `output_dtype:` | `output_dtype` | `float`, `int8`, `uint8`, `binary`, `ubinary` |
|
|
351
|
+
| `encoding_format:` | `encoding_format` | `base64` or `nil` |
|
|
352
|
+
| `provider_options:` | merged into request | Fields the gem doesn't have a keyword for yet |
|
|
353
|
+
|
|
354
|
+
## Not supported
|
|
355
|
+
|
|
356
|
+
- **Multimodal embeddings** (`voyage-multimodal-3.5`) — text inputs only.
|
|
357
|
+
- **Batch jobs** — the Files API is covered, but creating and managing
|
|
358
|
+
asynchronous batch jobs is not.
|
|
359
|
+
- **Token counting** — Voyage's official SDKs tokenize locally; this gem
|
|
360
|
+
reports `input_tokens` from API responses instead.
|
|
361
|
+
- **Request splitting** — one call sends one API request. Collections over
|
|
362
|
+
Voyage's 1,000-text limit must be batched by the caller, as in the
|
|
363
|
+
backfill example above.
|
|
364
|
+
|
|
365
|
+
New Voyage request fields usually work before the gem names them: pass them through `provider_options:`. For anything else, open an [issue](https://github.com/dinsley/ruby_llm-voyage/issues).
|
|
366
|
+
|
|
367
|
+
## Development
|
|
368
|
+
|
|
369
|
+
```sh
|
|
370
|
+
bin/setup
|
|
371
|
+
bundle exec rake test # offline suite, no API key needed
|
|
372
|
+
bundle exec rubocop
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
An opt-in live suite runs against the real Voyage API:
|
|
376
|
+
|
|
377
|
+
```sh
|
|
378
|
+
VOYAGE_API_KEY=your-key bundle exec rake test:live
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
See [CONTRIBUTING.md](https://github.com/dinsley/ruby_llm-voyage/blob/main/CONTRIBUTING.md)
|
|
382
|
+
for development guidelines and live test options, and
|
|
383
|
+
[docs/RELEASING.md](https://github.com/dinsley/ruby_llm-voyage/blob/main/docs/RELEASING.md)
|
|
384
|
+
for the release process.
|
|
385
|
+
|
|
386
|
+
## License
|
|
387
|
+
|
|
388
|
+
The gem is available as open source under the terms of the
|
|
389
|
+
[MIT License](https://github.com/dinsley/ruby_llm-voyage/blob/main/LICENSE.txt).
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Security fixes are provided for the latest released version of
|
|
6
|
+
`ruby_llm-voyage`.
|
|
7
|
+
|
|
8
|
+
## Reporting a vulnerability
|
|
9
|
+
|
|
10
|
+
Please do not open a public issue for a suspected vulnerability. Use
|
|
11
|
+
[GitHub private vulnerability reporting](https://github.com/dinsley/ruby_llm-voyage/security/advisories/new)
|
|
12
|
+
to report it privately.
|
|
13
|
+
|
|
14
|
+
Include:
|
|
15
|
+
|
|
16
|
+
- The affected version
|
|
17
|
+
- Reproduction steps or a minimal proof of concept
|
|
18
|
+
- The potential impact
|
|
19
|
+
- Any suggested mitigation
|
|
20
|
+
|
|
21
|
+
Do not include real Voyage API keys, customer embedding inputs, or production
|
|
22
|
+
responses. You should receive an acknowledgement within seven days.
|
|
23
|
+
|
|
24
|
+
## Credential and data handling
|
|
25
|
+
|
|
26
|
+
- Store `VOYAGE_API_KEY` in a secret manager or environment variable.
|
|
27
|
+
- Never expose the API key to browsers or other untrusted clients.
|
|
28
|
+
- Only configure `voyage_api_base` from trusted application configuration.
|
|
29
|
+
- Treat embedding inputs and vectors as potentially sensitive data.
|
|
30
|
+
- Review RubyLLM instrumentation exporters before sending event payloads to a
|
|
31
|
+
third party; events can contain embedding text or reranking queries.
|
data/docs/RELEASING.md
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
Releases are published by the `Release` GitHub Actions workflow using
|
|
4
|
+
[RubyGems trusted publishing](https://guides.rubygems.org/trusted-publishing/).
|
|
5
|
+
No RubyGems API key exists anywhere; the workflow authenticates with an OIDC
|
|
6
|
+
token scoped to this repository, the `release.yml` workflow file, and the
|
|
7
|
+
`release` environment.
|
|
8
|
+
|
|
9
|
+
## One-time setup
|
|
10
|
+
|
|
11
|
+
Already done for this repository, listed for reference:
|
|
12
|
+
|
|
13
|
+
- A trusted publisher on rubygems.org for `ruby_llm-voyage` pointing at
|
|
14
|
+
`dinsley/ruby_llm-voyage`, workflow `release.yml`, environment `release`.
|
|
15
|
+
(The first release used a pending publisher, which expires after about
|
|
16
|
+
12 hours if unused — re-register if it lapses before the first tag.)
|
|
17
|
+
- Private vulnerability reporting and Dependabot security updates enabled.
|
|
18
|
+
- Default branch protected, requiring the `CI` workflow to pass.
|
|
19
|
+
|
|
20
|
+
Optional hardening: add required reviewers to the `release` GitHub
|
|
21
|
+
environment so tag pushes pause for approval before publishing.
|
|
22
|
+
|
|
23
|
+
## Prepare the release
|
|
24
|
+
|
|
25
|
+
1. Confirm CI passes on every supported Ruby version.
|
|
26
|
+
2. Run the offline release checks:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
bundle exec rake
|
|
30
|
+
bundle exec rake yard
|
|
31
|
+
bundle exec rake rdoc
|
|
32
|
+
gem build ruby_llm-voyage.gemspec
|
|
33
|
+
gem specification ruby_llm-voyage-*.gem
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
3. Optionally run the billable live suite:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
VOYAGE_API_KEY=your-key bundle exec rake test:live
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
4. Update `RubyLLM::Voyage::VERSION` in
|
|
43
|
+
`lib/ruby_llm/voyage/version.rb`.
|
|
44
|
+
5. Move the `CHANGELOG.md` entries under `Unreleased` into a dated version
|
|
45
|
+
section and update its comparison links.
|
|
46
|
+
6. Commit the release changes and open a pull request.
|
|
47
|
+
|
|
48
|
+
## Publish
|
|
49
|
+
|
|
50
|
+
After the release commit is merged:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
git switch main
|
|
54
|
+
git pull --ff-only
|
|
55
|
+
git tag -s vVERSION -m "ruby_llm-voyage VERSION"
|
|
56
|
+
git push origin vVERSION
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The tag push triggers the `Release` workflow, which runs the test suite and
|
|
60
|
+
publishes the gem to RubyGems with provenance attestations. Watch it at:
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
gh run watch
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Then create a GitHub release from the tag using the matching changelog
|
|
67
|
+
section as the release notes:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
gh release create vVERSION --title "ruby_llm-voyage VERSION" --notes-file <(changelog section)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Verify
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
gem install ruby_llm-voyage -v VERSION
|
|
77
|
+
ruby -e 'require "ruby_llm-voyage"; puts RubyLLM::Voyage::VERSION'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Confirm that the RubyGems page links to the expected source, changelog,
|
|
81
|
+
documentation, and issue tracker. Do not reuse or move a published version
|
|
82
|
+
tag; release a new patch version for corrections.
|
|
83
|
+
|
|
84
|
+
## Manual fallback
|
|
85
|
+
|
|
86
|
+
If the workflow is unavailable, a gem owner with MFA can publish manually:
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
bundle exec rake build
|
|
90
|
+
gem push pkg/ruby_llm-voyage-VERSION.gem
|
|
91
|
+
```
|