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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class Voyage
|
|
6
|
+
# Voyage contextualized chunk embedding mapping.
|
|
7
|
+
# @api private
|
|
8
|
+
module ContextualizedEmbeddings
|
|
9
|
+
# Executes a contextualized chunk embedding request.
|
|
10
|
+
# @return [RubyLLM::Voyage::ContextualizedEmbedding]
|
|
11
|
+
def contextualized_embed(inputs, model:, input_type: RubyLLM::Voyage::UNSET, dimensions: nil,
|
|
12
|
+
output_dtype: RubyLLM::Voyage::UNSET,
|
|
13
|
+
encoding_format: RubyLLM::Voyage::UNSET, auto_chunk: nil, chunk_size: nil,
|
|
14
|
+
chunk_overlap: nil, provider_options: {})
|
|
15
|
+
payload = {
|
|
16
|
+
inputs: inputs,
|
|
17
|
+
model: model,
|
|
18
|
+
input_type: contextualized_option(input_type, :voyage_default_input_type),
|
|
19
|
+
output_dimension: dimensions || @config.voyage_default_output_dimension,
|
|
20
|
+
output_dtype: contextualized_option(output_dtype, :voyage_default_output_dtype),
|
|
21
|
+
encoding_format: contextualized_option(encoding_format, :voyage_default_encoding_format),
|
|
22
|
+
enable_auto_chunking: auto_chunk,
|
|
23
|
+
chunk_size: chunk_size,
|
|
24
|
+
chunk_overlap: chunk_overlap
|
|
25
|
+
}.compact.merge(provider_options.transform_keys(&:to_sym))
|
|
26
|
+
response = @connection.post('contextualizedembeddings', payload)
|
|
27
|
+
parse_contextualized_response(response, output_dtype: payload[:output_dtype])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def contextualized_option(value, config_key)
|
|
33
|
+
value = @config.public_send(config_key) if value.equal?(RubyLLM::Voyage::UNSET)
|
|
34
|
+
value&.to_s
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse_contextualized_response(response, output_dtype: nil)
|
|
38
|
+
body = response.body
|
|
39
|
+
results = body.fetch('data').map do |group|
|
|
40
|
+
chunks = group.fetch('data').map do |item|
|
|
41
|
+
vector = item.fetch('embedding')
|
|
42
|
+
vector = RubyLLM::Voyage::VectorDecoder.decode(vector, output_dtype) if vector.is_a?(String)
|
|
43
|
+
RubyLLM::Voyage::ContextualizedChunk.new(
|
|
44
|
+
embedding: vector,
|
|
45
|
+
index: item.fetch('index'),
|
|
46
|
+
text: item['text']
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
RubyLLM::Voyage::ContextualizedResult.new(chunks: chunks, index: group.fetch('index'))
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
RubyLLM::Voyage::ContextualizedEmbedding.new(
|
|
53
|
+
results: results,
|
|
54
|
+
model: body['model'],
|
|
55
|
+
input_tokens: body.dig('usage', 'total_tokens') || 0,
|
|
56
|
+
chunker_version: body['chunker_version']
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class Voyage
|
|
6
|
+
# Voyage embeddings request and response mapping.
|
|
7
|
+
# @api private
|
|
8
|
+
module Embeddings
|
|
9
|
+
# Returns the embeddings endpoint relative to the configured API base.
|
|
10
|
+
# @return [String]
|
|
11
|
+
# @api private
|
|
12
|
+
def embedding_url(...)
|
|
13
|
+
'embeddings'
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Converts RubyLLM embedding arguments to a Voyage request body.
|
|
17
|
+
# A +nil+ dimensions argument falls back to the configured
|
|
18
|
+
# +voyage_default_output_dimension+.
|
|
19
|
+
# @param text [String, Array<String>]
|
|
20
|
+
# @param model [String]
|
|
21
|
+
# @param dimensions [Integer, nil]
|
|
22
|
+
# @param input_type [String, Symbol, nil]
|
|
23
|
+
# @param truncation [Boolean, nil]
|
|
24
|
+
# @param output_dtype [String, Symbol, nil]
|
|
25
|
+
# @param encoding_format [String, Symbol, nil]
|
|
26
|
+
# @return [Hash]
|
|
27
|
+
# @api private
|
|
28
|
+
def render_embedding_payload(text, model:, dimensions: nil, input_type: RubyLLM::Voyage::UNSET,
|
|
29
|
+
truncation: RubyLLM::Voyage::UNSET, output_dtype: RubyLLM::Voyage::UNSET,
|
|
30
|
+
encoding_format: RubyLLM::Voyage::UNSET)
|
|
31
|
+
{
|
|
32
|
+
model: model,
|
|
33
|
+
input: text,
|
|
34
|
+
input_type: enum_option(input_type, :voyage_default_input_type),
|
|
35
|
+
truncation: configured_option(truncation, :voyage_default_truncation),
|
|
36
|
+
output_dimension: dimensions || @config.voyage_default_output_dimension,
|
|
37
|
+
output_dtype: enum_option(output_dtype, :voyage_default_output_dtype),
|
|
38
|
+
encoding_format: enum_option(encoding_format, :voyage_default_encoding_format)
|
|
39
|
+
}.compact
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Converts a Voyage response into a RubyLLM embedding result.
|
|
43
|
+
# @param response [Faraday::Response]
|
|
44
|
+
# @param model [String]
|
|
45
|
+
# @param text [String, Array<String>]
|
|
46
|
+
# @param output_dtype [String, Symbol, nil]
|
|
47
|
+
# @return [RubyLLM::Embedding]
|
|
48
|
+
# @raise [KeyError] when the response omits embedding data
|
|
49
|
+
# @raise [ArgumentError] when Base64 data or its output type is invalid
|
|
50
|
+
# @api private
|
|
51
|
+
def parse_embedding_response(response, model:, text:, output_dtype: nil)
|
|
52
|
+
data = response.body
|
|
53
|
+
vectors = data.fetch('data').map { |item| item.fetch('embedding') }
|
|
54
|
+
if vectors.first.is_a?(String)
|
|
55
|
+
vectors.map! do |vector|
|
|
56
|
+
RubyLLM::Voyage::VectorDecoder.decode(vector, output_dtype)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
vectors = vectors.first if vectors.length == 1 && !text.is_a?(Array)
|
|
60
|
+
|
|
61
|
+
Embedding.new(
|
|
62
|
+
vectors: vectors,
|
|
63
|
+
model: data['model'] || model,
|
|
64
|
+
input_tokens: data.dig('usage', 'total_tokens') || 0
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def configured_option(value, config_key)
|
|
71
|
+
value.equal?(RubyLLM::Voyage::UNSET) ? @config.public_send(config_key) : value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def enum_option(value, config_key)
|
|
75
|
+
configured_option(value, config_key)&.to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
6
|
+
module Providers
|
|
7
|
+
class Voyage
|
|
8
|
+
# Voyage Files API operations.
|
|
9
|
+
# @api private
|
|
10
|
+
module Files
|
|
11
|
+
# Maximum redirect hops followed when downloading file content.
|
|
12
|
+
MAX_DOWNLOAD_REDIRECTS = 3
|
|
13
|
+
private_constant :MAX_DOWNLOAD_REDIRECTS
|
|
14
|
+
|
|
15
|
+
# Uploads one JSONL file.
|
|
16
|
+
# @param path [String, Pathname]
|
|
17
|
+
# @param purpose [String]
|
|
18
|
+
# @return [RubyLLM::Voyage::VoyageFile]
|
|
19
|
+
def upload_file(path, purpose: 'batch')
|
|
20
|
+
expanded = File.expand_path(path)
|
|
21
|
+
unless File.extname(expanded).casecmp?('.jsonl')
|
|
22
|
+
raise ArgumentError, 'Voyage file uploads must use the .jsonl extension'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# RubyLLM's connection stack loads faraday-multipart and uses this
|
|
26
|
+
# same class for its own uploads; 1.x has no public helper for it.
|
|
27
|
+
part = Faraday::Multipart::FilePart.new(expanded, 'application/jsonl', File.basename(expanded))
|
|
28
|
+
response = @connection.post('files', { file: part, purpose: purpose })
|
|
29
|
+
parse_file(response.body)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Retrieves file metadata.
|
|
33
|
+
# @param file_id [String]
|
|
34
|
+
# @return [RubyLLM::Voyage::VoyageFile]
|
|
35
|
+
def retrieve_file(file_id)
|
|
36
|
+
parse_file(@connection.get("files/#{escape_id(file_id)}").body)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Lists file metadata with cursor pagination.
|
|
40
|
+
# @return [RubyLLM::Voyage::Page]
|
|
41
|
+
def list_files(purpose: nil, limit: nil, order: nil, after: nil)
|
|
42
|
+
query = URI.encode_www_form({ purpose: purpose, limit: limit, order: order, after: after }.compact)
|
|
43
|
+
path = query.empty? ? 'files' : "files?#{query}"
|
|
44
|
+
parse_page(@connection.get(path).body) { |item| parse_file(item) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Downloads raw file content.
|
|
48
|
+
# @param file_id [String]
|
|
49
|
+
# @return [String]
|
|
50
|
+
def file_content(file_id)
|
|
51
|
+
response = @connection.get("files/#{escape_id(file_id)}/content") do |request|
|
|
52
|
+
request.headers['Accept'] = 'text/plain'
|
|
53
|
+
end
|
|
54
|
+
return response.body unless response.status.between?(300, 399)
|
|
55
|
+
|
|
56
|
+
download_redirect(response.headers['location'])
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Atomically deletes one or more files. Voyage treats the operation
|
|
60
|
+
# as all-or-nothing and reports the outcome in a +success+ field,
|
|
61
|
+
# with +error_file_ids+ available in +raw+ on failure.
|
|
62
|
+
# @param file_ids [Array<String>]
|
|
63
|
+
# @return [RubyLLM::Voyage::FileDeletion]
|
|
64
|
+
def delete_files(file_ids)
|
|
65
|
+
body = @connection.post('files/delete', { file_ids: file_ids }).body
|
|
66
|
+
deleted = body.is_a?(Hash) && body.key?('success') ? body['success'] : true
|
|
67
|
+
RubyLLM::Voyage::FileDeletion.new(file_ids: file_ids, deleted: deleted, raw: body)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def parse_file(item)
|
|
73
|
+
RubyLLM::Voyage::VoyageFile.new(
|
|
74
|
+
id: item['id'], object: item['object'], purpose: item['purpose'], filename: item['filename'],
|
|
75
|
+
bytes: item['bytes'], created_at: item['created_at'], expires_at: item['expires_at'], raw: item
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_page(body, &)
|
|
80
|
+
RubyLLM::Voyage::Page.new(
|
|
81
|
+
data: body.fetch('data').map(&),
|
|
82
|
+
first_id: body['first_id'], last_id: body['last_id'], has_more: body['has_more'], raw: body
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def escape_id(id)
|
|
87
|
+
URI.encode_www_form_component(id.to_s)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def download_redirect(location)
|
|
91
|
+
connection = download_connection
|
|
92
|
+
|
|
93
|
+
MAX_DOWNLOAD_REDIRECTS.times do
|
|
94
|
+
uri = validated_redirect_uri(location)
|
|
95
|
+
response = connection.get(uri.to_s) { |request| request.headers['Accept'] = 'text/plain' }
|
|
96
|
+
return response.body unless response.status.between?(300, 399)
|
|
97
|
+
|
|
98
|
+
location = response.headers['location']
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
raise RubyLLM::Error, 'Voyage file download followed too many redirects'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validated_redirect_uri(location)
|
|
105
|
+
uri = URI(location.to_s)
|
|
106
|
+
unless uri.is_a?(URI::HTTPS) && uri.host && !uri.userinfo
|
|
107
|
+
raise RubyLLM::Error, 'Voyage returned an unsafe file download redirect'
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
uri
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Signed storage URLs must be fetched outside the provider connection
|
|
114
|
+
# so the Voyage Authorization header is not forwarded. Uses RubyLLM's
|
|
115
|
+
# own side-connection builder and honors its configured adapter and
|
|
116
|
+
# timeout.
|
|
117
|
+
def download_connection
|
|
118
|
+
RubyLLM::Connection.basic do |faraday|
|
|
119
|
+
faraday.options.timeout = @config.request_timeout
|
|
120
|
+
faraday.adapter(@config.faraday_adapter || :net_http)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Providers
|
|
5
|
+
class Voyage
|
|
6
|
+
# Voyage reranking request and response mapping.
|
|
7
|
+
# @api private
|
|
8
|
+
module Reranking
|
|
9
|
+
# Executes a Voyage reranking request.
|
|
10
|
+
# @return [RubyLLM::Voyage::Reranking]
|
|
11
|
+
def rerank(query:, documents:, model:, top_k: nil, return_documents: RubyLLM::Voyage::UNSET,
|
|
12
|
+
truncation: RubyLLM::Voyage::UNSET, provider_options: {})
|
|
13
|
+
payload = {
|
|
14
|
+
query: query,
|
|
15
|
+
documents: documents,
|
|
16
|
+
model: model,
|
|
17
|
+
top_k: top_k,
|
|
18
|
+
return_documents: return_documents,
|
|
19
|
+
truncation: truncation
|
|
20
|
+
}.reject { |_key, value| value.nil? || value.equal?(RubyLLM::Voyage::UNSET) }
|
|
21
|
+
.merge(provider_options.transform_keys(&:to_sym))
|
|
22
|
+
response = @connection.post('rerank', payload)
|
|
23
|
+
parse_rerank_response(response)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def parse_rerank_response(response)
|
|
29
|
+
body = response.body
|
|
30
|
+
results = body.fetch('data').map do |item|
|
|
31
|
+
RubyLLM::Voyage::RerankResult.new(
|
|
32
|
+
index: item.fetch('index'),
|
|
33
|
+
relevance_score: item.fetch('relevance_score'),
|
|
34
|
+
document: item['document']
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
RubyLLM::Voyage::Reranking.new(
|
|
39
|
+
results: results,
|
|
40
|
+
model: body['model'],
|
|
41
|
+
input_tokens: body.dig('usage', 'total_tokens') || 0
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
# Provider implementations registered with RubyLLM.
|
|
5
|
+
module Providers
|
|
6
|
+
# Voyage retrieval API provider for RubyLLM.
|
|
7
|
+
#
|
|
8
|
+
# Most applications should use +RubyLLM.embed+ or
|
|
9
|
+
# {RubyLLM::Voyage.embed} instead of instantiating this class directly.
|
|
10
|
+
class Voyage < Provider
|
|
11
|
+
require 'ruby_llm/providers/voyage/embeddings'
|
|
12
|
+
require 'ruby_llm/providers/voyage/reranking'
|
|
13
|
+
require 'ruby_llm/providers/voyage/contextualized_embeddings'
|
|
14
|
+
require 'ruby_llm/providers/voyage/files'
|
|
15
|
+
|
|
16
|
+
include Voyage::Embeddings
|
|
17
|
+
include Voyage::Reranking
|
|
18
|
+
include Voyage::ContextualizedEmbeddings
|
|
19
|
+
include Voyage::Files
|
|
20
|
+
|
|
21
|
+
# Returns the configured Voyage API base URL.
|
|
22
|
+
# @return [String]
|
|
23
|
+
def api_base
|
|
24
|
+
@config.voyage_api_base || 'https://api.voyageai.com/v1'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns authentication headers for Voyage requests.
|
|
28
|
+
# @return [Hash{String => String}]
|
|
29
|
+
def headers
|
|
30
|
+
{ 'Authorization' => "Bearer #{@config.voyage_api_key}" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# RubyLLM 1.x does not expose per-request provider options for embeddings,
|
|
34
|
+
# so the extension's facade calls this richer provider method directly.
|
|
35
|
+
#
|
|
36
|
+
# @api private
|
|
37
|
+
# @param text [String, Array<String>]
|
|
38
|
+
# @param model [String]
|
|
39
|
+
# @param dimensions [Integer, nil]
|
|
40
|
+
# @param provider_options [Hash]
|
|
41
|
+
# @return [RubyLLM::Embedding]
|
|
42
|
+
def embed(text, model:, dimensions: nil, provider_options: {}, **options)
|
|
43
|
+
payload = render_embedding_payload(text, model:, dimensions:, **options)
|
|
44
|
+
.merge(provider_options.transform_keys(&:to_sym))
|
|
45
|
+
response = @connection.post(embedding_url(model:), payload)
|
|
46
|
+
parse_embedding_response(response, model:, text:, output_dtype: payload[:output_dtype])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
class << self
|
|
50
|
+
# Lists configuration keys registered for the Voyage provider.
|
|
51
|
+
# @return [Array<Symbol>]
|
|
52
|
+
# @api private
|
|
53
|
+
def configuration_options
|
|
54
|
+
%i[
|
|
55
|
+
voyage_api_key
|
|
56
|
+
voyage_api_base
|
|
57
|
+
voyage_default_input_type
|
|
58
|
+
voyage_default_truncation
|
|
59
|
+
voyage_default_output_dimension
|
|
60
|
+
voyage_default_output_dtype
|
|
61
|
+
voyage_default_encoding_format
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Lists configuration keys required to use the Voyage provider.
|
|
66
|
+
# @return [Array<Symbol>]
|
|
67
|
+
# @api private
|
|
68
|
+
def configuration_requirements
|
|
69
|
+
%i[voyage_api_key]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Indicates that Voyage model IDs can be used without registry entries.
|
|
73
|
+
# @return [Boolean]
|
|
74
|
+
def assume_models_exist?
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Voyage
|
|
5
|
+
# One document returned by a reranking request.
|
|
6
|
+
RerankResult = Struct.new(:index, :relevance_score, :document, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
# Typed result returned by {Voyage.rerank}.
|
|
9
|
+
Reranking = Struct.new(:results, :model, :input_tokens, keyword_init: true) do
|
|
10
|
+
# Reorders the original candidate collection by relevance.
|
|
11
|
+
#
|
|
12
|
+
# Pass the same collection whose contents were sent as +documents:+.
|
|
13
|
+
# Returns the matching items in reranked order, truncated to +top_k+
|
|
14
|
+
# when one was requested.
|
|
15
|
+
#
|
|
16
|
+
# @param collection [Array, #to_a] candidates in their original order
|
|
17
|
+
# @return [Array] the reranked items
|
|
18
|
+
# @raise [IndexError] when the collection is smaller than the
|
|
19
|
+
# document list that was reranked
|
|
20
|
+
#
|
|
21
|
+
# @example
|
|
22
|
+
# reranking = RubyLLM::Voyage.rerank(
|
|
23
|
+
# query: query, documents: articles.map(&:content), top_k: 5
|
|
24
|
+
# )
|
|
25
|
+
# reranking.reorder(articles) # => the five most relevant articles
|
|
26
|
+
def reorder(collection)
|
|
27
|
+
items = collection.to_a
|
|
28
|
+
results.map { |result| items.fetch(result.index) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# One contextualized chunk and its vector.
|
|
33
|
+
ContextualizedChunk = Struct.new(:embedding, :index, :text, keyword_init: true) do
|
|
34
|
+
# Summarizes the vector so console output stays readable.
|
|
35
|
+
# @return [String]
|
|
36
|
+
def inspect
|
|
37
|
+
dimensions = embedding.respond_to?(:length) ? "[#{embedding.length} values]" : embedding.inspect
|
|
38
|
+
"#<RubyLLM::Voyage::ContextualizedChunk index=#{index} embedding=#{dimensions} " \
|
|
39
|
+
"text=#{text&.slice(0, 60).inspect}>"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Contextualized chunks belonging to one input document or query.
|
|
44
|
+
ContextualizedResult = Struct.new(:chunks, :index, keyword_init: true)
|
|
45
|
+
|
|
46
|
+
# Typed result returned by {Voyage.contextualized_embed}.
|
|
47
|
+
ContextualizedEmbedding = Struct.new(
|
|
48
|
+
:results,
|
|
49
|
+
:model,
|
|
50
|
+
:input_tokens,
|
|
51
|
+
:chunker_version,
|
|
52
|
+
keyword_init: true
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Metadata for a file managed by Voyage's Files API.
|
|
56
|
+
VoyageFile = Struct.new(
|
|
57
|
+
:id,
|
|
58
|
+
:object,
|
|
59
|
+
:purpose,
|
|
60
|
+
:filename,
|
|
61
|
+
:bytes,
|
|
62
|
+
:created_at,
|
|
63
|
+
:expires_at,
|
|
64
|
+
:raw,
|
|
65
|
+
keyword_init: true
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# A cursor-based page returned by a Voyage list endpoint.
|
|
69
|
+
Page = Struct.new(:data, :first_id, :last_id, :has_more, :raw, keyword_init: true)
|
|
70
|
+
|
|
71
|
+
# Result of an all-or-nothing bulk file deletion.
|
|
72
|
+
FileDeletion = Struct.new(:file_ids, :deleted, :raw, keyword_init: true)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Voyage
|
|
5
|
+
# Decodes Voyage Base64 vector representations.
|
|
6
|
+
#
|
|
7
|
+
# The gem decodes API responses automatically; use this directly when
|
|
8
|
+
# parsing embeddings out of Voyage batch result files, which contain the
|
|
9
|
+
# same Base64 encoding.
|
|
10
|
+
module VectorDecoder
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Decodes a Base64 vector for the requested Voyage output type.
|
|
14
|
+
# @param vector [String] Base64-encoded vector data
|
|
15
|
+
# @param output_dtype [String, Symbol, nil] the +output_dtype+ the
|
|
16
|
+
# embedding was requested with; +nil+ decodes as +float+
|
|
17
|
+
# @return [Array<Float>, Array<Integer>]
|
|
18
|
+
# @raise [ArgumentError] when the Base64 data or output type is invalid
|
|
19
|
+
#
|
|
20
|
+
# @example Decode an embedding from a batch results file
|
|
21
|
+
# line = JSON.parse(jsonl_line)
|
|
22
|
+
# encoded = line.dig('response', 'body', 'data', 0, 'embedding')
|
|
23
|
+
# RubyLLM::Voyage::VectorDecoder.decode(encoded, :float)
|
|
24
|
+
def decode(vector, output_dtype)
|
|
25
|
+
# unpack1('m0') is strict Base64: it raises ArgumentError on invalid
|
|
26
|
+
# input, matching Base64.strict_decode64 without requiring the
|
|
27
|
+
# base64 gem (no longer a default gem as of Ruby 3.5).
|
|
28
|
+
bytes = vector.unpack1('m0')
|
|
29
|
+
|
|
30
|
+
case (output_dtype || 'float').to_s
|
|
31
|
+
when 'float' then bytes.unpack('e*')
|
|
32
|
+
when 'int8', 'binary' then bytes.unpack('c*')
|
|
33
|
+
when 'uint8', 'ubinary' then bytes.unpack('C*')
|
|
34
|
+
else raise ArgumentError, "Unsupported Voyage output dtype: #{output_dtype.inspect}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
# Voyage retrieval integration for RubyLLM.
|
|
5
|
+
#
|
|
6
|
+
# Use {.embed_query} and {.embed_documents} for the two sides of retrieval,
|
|
7
|
+
# {.embed} for request-level Voyage options, or the conventional
|
|
8
|
+
# +RubyLLM.embed(..., provider: :voyage)+ interface.
|
|
9
|
+
module Voyage
|
|
10
|
+
# Current gem version.
|
|
11
|
+
VERSION = '0.1.0'
|
|
12
|
+
|
|
13
|
+
# Internal sentinel used to distinguish an omitted option from an explicit
|
|
14
|
+
# +nil+ value.
|
|
15
|
+
# @api private
|
|
16
|
+
UNSET = Object.new.freeze
|
|
17
|
+
end
|
|
18
|
+
end
|