static_embeddings 0.1.5 → 1.5.6
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 +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +17 -2
- data/docs/ARCHITECTURE.md +14 -5
- data/docs/MODEL_AUDIT.md +110 -0
- data/lib/models/demo.semb +0 -0
- data/lib/static_embeddings/bert_wordpiece.rb +191 -0
- data/lib/static_embeddings/canonical.rb +50 -0
- data/lib/static_embeddings/cli.rb +88 -63
- data/lib/static_embeddings/codec.rb +45 -0
- data/lib/static_embeddings/conversion.rb +58 -0
- data/lib/static_embeddings/errors.rb +2 -2
- data/lib/static_embeddings/format/constants.rb +109 -0
- data/lib/static_embeddings/format/hash_table.rb +69 -0
- data/lib/static_embeddings/format/trie.rb +78 -0
- data/lib/static_embeddings/format/verifier.rb +41 -0
- data/lib/static_embeddings/format/writer.rb +131 -0
- data/lib/static_embeddings/format.rb +3 -338
- data/lib/static_embeddings/importers/model2vec.rb +52 -0
- data/lib/static_embeddings/importers/sentence_transformers_static.rb +103 -0
- data/lib/static_embeddings/importers/support.rb +111 -0
- data/lib/static_embeddings/importers.rb +50 -0
- data/lib/static_embeddings/model.rb +35 -20
- data/lib/static_embeddings/paths.rb +8 -12
- data/lib/static_embeddings/provenance.rb +58 -0
- data/lib/static_embeddings/reference.rb +50 -40
- data/lib/static_embeddings/row_prefix_payload.rb +59 -0
- data/lib/static_embeddings/version.rb +1 -1
- data/lib/static_embeddings.rb +29 -55
- data/static_embeddings.gemspec +2 -2
- data/tools/check_model2vec_parity.rb +5 -1
- data/tools/check_st_parity.rb +125 -0
- data/tools/eval_retrieval.rb +58 -0
- metadata +24 -6
- data/lib/static_embeddings/converter.rb +0 -328
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "json"
|
|
3
|
+
require "static_embeddings/errors"
|
|
4
|
+
require "static_embeddings/format/constants"
|
|
5
|
+
require "static_embeddings/safetensors"
|
|
6
|
+
require "static_embeddings/row_prefix_payload"
|
|
7
|
+
|
|
8
|
+
module StaticEmbeddings
|
|
9
|
+
module Importers
|
|
10
|
+
module Support
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def load_json(path, optional: false)
|
|
14
|
+
return nil if optional && !File.file?(path)
|
|
15
|
+
raise InvalidSourceError, "missing #{File.basename(path)} in #{File.dirname(path)}" unless File.file?(path)
|
|
16
|
+
|
|
17
|
+
JSON.parse(File.binread(path))
|
|
18
|
+
rescue JSON::ParserError => e
|
|
19
|
+
raise InvalidSourceError, "invalid JSON in #{path}: #{e.message}"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def load_object(path, optional: false)
|
|
23
|
+
value = load_json(path, optional: optional)
|
|
24
|
+
return nil if value.nil?
|
|
25
|
+
raise InvalidSourceError, "#{path} must contain a JSON object" unless value.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
value
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def digest_files(paths, root: nil)
|
|
31
|
+
paths.each_with_object({}) do |path, digests|
|
|
32
|
+
next unless File.file?(path)
|
|
33
|
+
|
|
34
|
+
digests[digest_key(path, root)] = Digest::SHA256.file(path).hexdigest
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def extract_matrix(path, vocab_size)
|
|
39
|
+
raise InvalidSourceError, "missing model.safetensors in #{File.dirname(path)}" unless File.file?(path)
|
|
40
|
+
|
|
41
|
+
name, tensor = sole_matrix_tensor(Safetensors.describe(path).fetch(:tensors))
|
|
42
|
+
rows, dim = tensor.fetch(:shape)
|
|
43
|
+
unless rows == vocab_size
|
|
44
|
+
raise ConversionError,
|
|
45
|
+
"embedding matrix #{name} has #{rows} rows but the tokenizer has #{vocab_size} tokens"
|
|
46
|
+
end
|
|
47
|
+
unless dim.positive? && dim <= Format::UINT32_MAX
|
|
48
|
+
raise ConversionError, "embedding matrix #{name} has unsupported dimension #{dim}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
[Safetensors.f32_payload(path, tensor), dim]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def resolve_max_tokens(requested, default)
|
|
55
|
+
return default if requested.nil?
|
|
56
|
+
return 0 if requested == false || requested == :unlimited || requested == 0
|
|
57
|
+
|
|
58
|
+
value = Integer(requested)
|
|
59
|
+
unless value.between?(1, Format::UINT32_MAX)
|
|
60
|
+
raise InvalidOptionError, "max_tokens must be 1..#{Format::UINT32_MAX}, false, or :unlimited"
|
|
61
|
+
end
|
|
62
|
+
value
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def resolve_dimensions(requested, native_dim)
|
|
66
|
+
return native_dim if requested.nil?
|
|
67
|
+
|
|
68
|
+
value = Integer(requested)
|
|
69
|
+
unless value.between?(1, native_dim)
|
|
70
|
+
raise InvalidOptionError, "dimensions must be between 1 and native_dim #{native_dim}, got #{value}"
|
|
71
|
+
end
|
|
72
|
+
value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def normalize_mrl_dims(value, native_dim:)
|
|
76
|
+
return nil if value.nil?
|
|
77
|
+
|
|
78
|
+
dims = value.is_a?(Array) ? value : value.to_s.split(",").map(&:strip)
|
|
79
|
+
dims = dims.map { |item| Integer(item) }
|
|
80
|
+
if dims.empty? || dims.any? { |dim| !dim.between?(1, native_dim) }
|
|
81
|
+
raise InvalidOptionError, "trained_mrl_dims must contain dimensions between 1 and #{native_dim}"
|
|
82
|
+
end
|
|
83
|
+
dims.uniq.freeze
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def slice_matrix(payload, native_dim, output_dim)
|
|
87
|
+
return payload if output_dim == native_dim
|
|
88
|
+
|
|
89
|
+
RowPrefixPayload.new(payload, native_dim: native_dim, output_dim: output_dim)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def sole_matrix_tensor(tensors)
|
|
93
|
+
matrices = tensors.select { |_, tensor| tensor.fetch(:shape).length == 2 }
|
|
94
|
+
raise ConversionError, "model.safetensors contains no 2-D tensor" if matrices.empty?
|
|
95
|
+
return matrices.first if matrices.length == 1
|
|
96
|
+
|
|
97
|
+
raise ConversionError,
|
|
98
|
+
"model.safetensors contains several 2-D tensors (#{matrices.keys.inspect}); expected exactly one"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def digest_key(path, root)
|
|
102
|
+
return File.basename(path) unless root
|
|
103
|
+
|
|
104
|
+
root_real = File.realpath(root)
|
|
105
|
+
real = File.realpath(path)
|
|
106
|
+
prefix = root_real.end_with?(File::SEPARATOR) ? root_real : "#{root_real}#{File::SEPARATOR}"
|
|
107
|
+
real.start_with?(prefix) ? real.delete_prefix(prefix) : File.basename(real)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "static_embeddings/errors"
|
|
2
|
+
require "static_embeddings/bert_wordpiece"
|
|
3
|
+
require "static_embeddings/canonical"
|
|
4
|
+
require "static_embeddings/importers/support"
|
|
5
|
+
require "static_embeddings/importers/model2vec"
|
|
6
|
+
require "static_embeddings/importers/sentence_transformers_static"
|
|
7
|
+
|
|
8
|
+
module StaticEmbeddings
|
|
9
|
+
module Importers
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def import(source_dir, **options)
|
|
13
|
+
root = source_root(source_dir)
|
|
14
|
+
case detect(root)
|
|
15
|
+
when :model2vec then Model2Vec.call(root, **options)
|
|
16
|
+
when :sentence_transformers_static then SentenceTransformersStatic.call(root, **options)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def detect(root)
|
|
21
|
+
config = Support.load_object(File.join(root, "config.json"), optional: true) || {}
|
|
22
|
+
tokenizer = File.join(root, "tokenizer.json")
|
|
23
|
+
weights = File.join(root, "model.safetensors")
|
|
24
|
+
modules = File.join(root, "modules.json")
|
|
25
|
+
|
|
26
|
+
return :model2vec if model2vec_config?(config) && File.file?(tokenizer) && File.file?(weights)
|
|
27
|
+
return :sentence_transformers_static if File.file?(modules)
|
|
28
|
+
|
|
29
|
+
if File.file?(tokenizer) && File.file?(weights)
|
|
30
|
+
raise InvalidSourceError,
|
|
31
|
+
"cannot prove source family in #{root}: config.json is not Model2Vec and modules.json is absent"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
raise InvalidSourceError,
|
|
35
|
+
"cannot detect a static embedding source in #{root}: need a Model2Vec config with " \
|
|
36
|
+
"tokenizer.json + model.safetensors, or modules.json for Sentence Transformers StaticEmbedding"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def source_root(source_dir)
|
|
40
|
+
root = File.expand_path(source_dir.to_s)
|
|
41
|
+
raise InvalidSourceError, "source directory #{root} does not exist" unless File.directory?(root)
|
|
42
|
+
|
|
43
|
+
root
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def model2vec_config?(config)
|
|
47
|
+
config["model_type"].to_s == "model2vec" || Array(config["architectures"]).include?("StaticModel")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -4,39 +4,41 @@ module StaticEmbeddings
|
|
|
4
4
|
class Model
|
|
5
5
|
attr_reader :path
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
return {} if raw.nil?
|
|
7
|
+
if method_defined?(:max_tokens) && !method_defined?(:max_tokens_limit)
|
|
8
|
+
alias_method :max_tokens_limit, :max_tokens
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
def max_tokens
|
|
11
|
+
limit = max_tokens_limit
|
|
12
|
+
limit.zero? ? false : limit
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def provenance
|
|
17
|
+
@provenance ||= parse_provenance.freeze
|
|
14
18
|
end
|
|
15
19
|
|
|
16
20
|
def model_id
|
|
17
21
|
provenance["source_model_id"]
|
|
18
22
|
end
|
|
19
23
|
|
|
20
|
-
def embed_array(text, **
|
|
21
|
-
format =
|
|
22
|
-
StaticEmbeddings.unpack(embed(text, **
|
|
24
|
+
def embed_array(text, **options)
|
|
25
|
+
format = options.fetch(:format, :f32)
|
|
26
|
+
StaticEmbeddings.unpack(embed(text, **options), dim, format: format).first
|
|
23
27
|
end
|
|
24
28
|
|
|
25
|
-
def embed_batch_arrays(texts, **
|
|
26
|
-
format =
|
|
27
|
-
StaticEmbeddings.unpack(embed_batch(texts, **
|
|
29
|
+
def embed_batch_arrays(texts, **options)
|
|
30
|
+
format = options.fetch(:format, :f32)
|
|
31
|
+
StaticEmbeddings.unpack(embed_batch(texts, **options), dim, format: format)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
def cosine_top_k(query_blob, matrix_blob, k, **
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
StaticEmbeddings.cosine_top_k(query_blob, matrix_blob, k, **opts.merge(dim: dim))
|
|
34
|
+
def cosine_top_k(query_blob, matrix_blob, k, **options)
|
|
35
|
+
reject_runtime_dim!(options)
|
|
36
|
+
StaticEmbeddings.cosine_top_k(query_blob, matrix_blob, k, **options.merge(dim: dim))
|
|
34
37
|
end
|
|
35
38
|
|
|
36
|
-
def dot_top_k(query_blob, matrix_blob, k, **
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
StaticEmbeddings.dot_top_k(query_blob, matrix_blob, k, **opts.merge(dim: dim))
|
|
39
|
+
def dot_top_k(query_blob, matrix_blob, k, **options)
|
|
40
|
+
reject_runtime_dim!(options)
|
|
41
|
+
StaticEmbeddings.dot_top_k(query_blob, matrix_blob, k, **options.merge(dim: dim))
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
def to_s
|
|
@@ -44,5 +46,18 @@ module StaticEmbeddings
|
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
alias inspect to_s
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def parse_provenance
|
|
53
|
+
raw = provenance_json
|
|
54
|
+
raw.nil? ? {} : JSON.parse(raw)
|
|
55
|
+
rescue JSON::ParserError, EncodingError => e
|
|
56
|
+
raise InvalidModelError, "invalid provenance JSON: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def reject_runtime_dim!(options)
|
|
60
|
+
raise InvalidOptionError, "dim: is set by the model" if options.key?(:dim)
|
|
61
|
+
end
|
|
47
62
|
end
|
|
48
63
|
end
|
|
@@ -12,29 +12,25 @@ module StaticEmbeddings
|
|
|
12
12
|
|
|
13
13
|
def model_path(model_id, env = ENV)
|
|
14
14
|
id = model_id.to_s
|
|
15
|
-
raise
|
|
16
|
-
raise
|
|
15
|
+
raise InvalidOptionError, "model_id must not be empty" if id.empty?
|
|
16
|
+
raise InvalidOptionError, "model_id contains a NUL byte" if id.include?("\0")
|
|
17
17
|
|
|
18
18
|
normalized = id.tr("\\", "/")
|
|
19
19
|
parts = normalized.split("/", -1)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
end
|
|
20
|
+
invalid = normalized.start_with?("/") ||
|
|
21
|
+
parts.any? { |part| part.empty? || part == "." || part == ".." || part.include?(":") }
|
|
22
|
+
raise InvalidOptionError, "model_id must be a relative slash-separated identifier" if invalid
|
|
24
23
|
|
|
25
24
|
base = File.expand_path(File.join(cache_dir(env), "models"))
|
|
26
25
|
path = File.expand_path(File.join(base, "#{normalized}.semb"))
|
|
27
26
|
prefix = base.end_with?(File::SEPARATOR) ? base : "#{base}#{File::SEPARATOR}"
|
|
28
|
-
unless path.start_with?(prefix)
|
|
29
|
-
|
|
30
|
-
end
|
|
27
|
+
raise InvalidOptionError, "model_id escapes the model cache" unless path.start_with?(prefix)
|
|
28
|
+
|
|
31
29
|
path
|
|
32
30
|
end
|
|
33
31
|
|
|
34
32
|
def builtin_path(name)
|
|
35
|
-
file = BUILTIN_MODELS.fetch(name)
|
|
36
|
-
raise ModelNotFound, "unknown builtin model #{name.inspect}"
|
|
37
|
-
end
|
|
33
|
+
file = BUILTIN_MODELS.fetch(name) { raise ModelNotFound, "unknown builtin model #{name.inspect}" }
|
|
38
34
|
File.join(BUILTIN_DIR, file)
|
|
39
35
|
end
|
|
40
36
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "static_embeddings/format/constants"
|
|
3
|
+
require "static_embeddings/bert_wordpiece"
|
|
4
|
+
|
|
5
|
+
module StaticEmbeddings
|
|
6
|
+
module Provenance
|
|
7
|
+
MODEL2VEC_VERSION = "0.9.0"
|
|
8
|
+
TOKENIZERS_VERSION = "0.23.1"
|
|
9
|
+
UNICODE_CATEGORIES_VERSION = "0.1.1"
|
|
10
|
+
NOTES = "Vectors are only reference-compatible if docs/MODEL_AUDIT.md records a passing oracle run for this source revision."
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def json(model, unicode_source:)
|
|
15
|
+
JSON.generate(payload(model, unicode_source: unicode_source).sort.to_h)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def payload(model, unicode_source:)
|
|
19
|
+
dimensions = model.dimensions
|
|
20
|
+
runtime = model.runtime
|
|
21
|
+
source = model.source
|
|
22
|
+
tokenizer = model.tokenizer
|
|
23
|
+
|
|
24
|
+
data = {
|
|
25
|
+
"add_special_tokens" => runtime.add_special_tokens,
|
|
26
|
+
"config_seq_length" => source.config_seq_length,
|
|
27
|
+
"converter_version" => StaticEmbeddings::VERSION,
|
|
28
|
+
"dim" => dimensions.output,
|
|
29
|
+
"format_version" => Format::VERSION,
|
|
30
|
+
"native_dim" => dimensions.native,
|
|
31
|
+
"normalize" => runtime.normalization == Format::NORMALIZATION_L2,
|
|
32
|
+
"notes" => NOTES,
|
|
33
|
+
"oracle" => source.oracle,
|
|
34
|
+
"output_dim" => dimensions.output,
|
|
35
|
+
"reference_impl" => source.oracle,
|
|
36
|
+
"reference_max_tokens" => runtime.max_tokens,
|
|
37
|
+
"reference_tokenizers_version" => TOKENIZERS_VERSION,
|
|
38
|
+
"source_family" => source.family,
|
|
39
|
+
"source_files_sha256" => source.files_sha256,
|
|
40
|
+
"source_model_id" => source.model,
|
|
41
|
+
"source_revision" => source.revision,
|
|
42
|
+
"tokenizer_class" => source.tokenizer_class,
|
|
43
|
+
"tokenizer_profile" => tokenizer.fetch(:tokenizer_profile, BertWordPiece::TOKENIZER_PROFILE),
|
|
44
|
+
"unicode_source" => unicode_source,
|
|
45
|
+
"unk_policy" => runtime.unk_policy == Format::UNK_DROP ? "drop" : "include",
|
|
46
|
+
"vocab_size" => model.tokens.length
|
|
47
|
+
}
|
|
48
|
+
data["trained_mrl_dims"] = dimensions.trained if dimensions.trained
|
|
49
|
+
add_model2vec_versions(data) if source.family == "model2vec"
|
|
50
|
+
data
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def add_model2vec_versions(data)
|
|
54
|
+
data["reference_model2vec_version"] = MODEL2VEC_VERSION
|
|
55
|
+
data["reference_unicode_categories_version"] = UNICODE_CATEGORIES_VERSION
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
require "
|
|
2
|
-
require "static_embeddings/
|
|
3
|
-
require "static_embeddings/safetensors"
|
|
1
|
+
require "static_embeddings/importers"
|
|
2
|
+
require "static_embeddings/format/constants"
|
|
4
3
|
|
|
5
4
|
module StaticEmbeddings
|
|
6
5
|
class Reference
|
|
@@ -26,49 +25,52 @@ module StaticEmbeddings
|
|
|
26
25
|
@dim = meta.fetch(:dim)
|
|
27
26
|
end
|
|
28
27
|
|
|
29
|
-
def self.from_source_dir(dir, max_tokens:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
config = File.file?(config_path) ? JSON.parse(File.binread(config_path)) : {}
|
|
33
|
-
tokens = tokens_from(tokenizer)
|
|
34
|
-
tensor = Safetensors.read(File.join(dir, "model.safetensors"))[:tensors].values.first
|
|
35
|
-
rows, dim = tensor[:shape]
|
|
36
|
-
floats = Safetensors.f32_bytes(tensor).unpack("e*")
|
|
37
|
-
normalizer = tokenizer["normalizer"] || {}
|
|
28
|
+
def self.from_source_dir(dir, max_tokens: nil, dimensions: nil)
|
|
29
|
+
from_canonical(Importers.import(dir, max_tokens: max_tokens, dimensions: dimensions))
|
|
30
|
+
end
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
32
|
+
def self.from_canonical(model)
|
|
33
|
+
dim = model.dimensions.output
|
|
34
|
+
floats = matrix_bytes(model.matrix).unpack("e*")
|
|
35
|
+
rows = model.tokens.length
|
|
36
|
+
new(
|
|
37
|
+
tokens: model.tokens,
|
|
38
|
+
matrix: Array.new(rows) { |index| floats[index * dim, dim] },
|
|
39
|
+
meta: meta_from_canonical(model)
|
|
40
|
+
)
|
|
42
41
|
end
|
|
43
42
|
|
|
44
|
-
def self.
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
def self.matrix_bytes(matrix)
|
|
44
|
+
return matrix unless matrix.respond_to?(:each_chunk)
|
|
45
|
+
|
|
46
|
+
matrix.each_chunk.each_with_object(Format.binary_string) { |chunk, packed| packed << chunk }
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
def self.
|
|
50
|
-
|
|
49
|
+
def self.meta_from_canonical(model)
|
|
50
|
+
tokenizer = model.tokenizer
|
|
51
|
+
runtime = model.runtime
|
|
51
52
|
{
|
|
52
|
-
dim:
|
|
53
|
-
lowercase:
|
|
54
|
-
strip_accents:
|
|
55
|
-
clean_text:
|
|
56
|
-
handle_chinese_chars:
|
|
57
|
-
max_input_chars_per_word: tokenizer.
|
|
58
|
-
unk_token: tokenizer.
|
|
59
|
-
normalize:
|
|
60
|
-
max_tokens: max_tokens,
|
|
61
|
-
|
|
53
|
+
dim: model.dimensions.output,
|
|
54
|
+
lowercase: tokenizer.fetch(:do_lower_case),
|
|
55
|
+
strip_accents: tokenizer.fetch(:strip_accents),
|
|
56
|
+
clean_text: tokenizer.fetch(:clean_text),
|
|
57
|
+
handle_chinese_chars: tokenizer.fetch(:handle_chinese_chars),
|
|
58
|
+
max_input_chars_per_word: tokenizer.fetch(:max_input_chars_per_word),
|
|
59
|
+
unk_token: model.tokens.fetch(tokenizer.fetch(:unk_id)),
|
|
60
|
+
normalize: runtime.normalization == Format::NORMALIZATION_L2,
|
|
61
|
+
max_tokens: runtime.max_tokens,
|
|
62
|
+
unk_policy: runtime.unk_policy,
|
|
63
|
+
added_tokens: added_tokens(model)
|
|
62
64
|
}
|
|
63
65
|
end
|
|
64
66
|
|
|
65
|
-
def self.
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
next unless token["special"] && allowed.key?(content) && token["normalized"] == false
|
|
67
|
+
def self.added_tokens(model)
|
|
68
|
+
mask = model.tokenizer.fetch(:added_token_mask)
|
|
69
|
+
BertWordPiece::STANDARD_SPECIAL_TOKENS.each_with_object({}) do |(content, bit), tokens|
|
|
70
|
+
next if (mask & bit).zero?
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
id = model.tokens.index(content)
|
|
73
|
+
tokens[content] = id unless id.nil?
|
|
72
74
|
end
|
|
73
75
|
end
|
|
74
76
|
|
|
@@ -91,14 +93,22 @@ module StaticEmbeddings
|
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
def embed(text, max_tokens: @meta[:max_tokens])
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
return Array.new(@dim, 0.0) if
|
|
96
|
+
ids = apply_unk_policy(tokenize(text, max_tokens: false))
|
|
97
|
+
ids = ids.first(max_tokens) if max_tokens && max_tokens.positive? && ids.length > max_tokens
|
|
98
|
+
return Array.new(@dim, 0.0) if ids.empty?
|
|
97
99
|
|
|
98
|
-
vector = pooled(
|
|
100
|
+
vector = pooled(ids)
|
|
99
101
|
@meta[:normalize] ? l2_normalize(vector) : vector
|
|
100
102
|
end
|
|
101
103
|
|
|
104
|
+
def apply_unk_policy(ids)
|
|
105
|
+
case @meta.fetch(:unk_policy)
|
|
106
|
+
when Format::UNK_DROP then ids.reject { |id| id == unk_id }
|
|
107
|
+
when Format::UNK_INCLUDE then ids
|
|
108
|
+
else raise InvalidModelError, "unknown unk_policy #{@meta[:unk_policy].inspect}"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
102
112
|
private
|
|
103
113
|
|
|
104
114
|
def tokenize_with_added_tokens(text)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "static_embeddings/errors"
|
|
2
|
+
require "static_embeddings/format/constants"
|
|
3
|
+
|
|
4
|
+
module StaticEmbeddings
|
|
5
|
+
class RowPrefixPayload
|
|
6
|
+
FLOAT_BYTES = 4
|
|
7
|
+
|
|
8
|
+
attr_reader :bytesize
|
|
9
|
+
|
|
10
|
+
def initialize(inner, native_dim:, output_dim:)
|
|
11
|
+
raise InvalidOptionError, "native_dim must be positive" unless native_dim.positive?
|
|
12
|
+
unless output_dim.between?(1, native_dim)
|
|
13
|
+
raise InvalidOptionError, "output_dim must be between 1 and #{native_dim}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
@inner = inner
|
|
17
|
+
@source_row_bytes = native_dim * FLOAT_BYTES
|
|
18
|
+
@output_row_bytes = output_dim * FLOAT_BYTES
|
|
19
|
+
unless (inner.bytesize % @source_row_bytes).zero?
|
|
20
|
+
raise ConversionError, "embedding payload is not a whole number of #{native_dim}-d rows"
|
|
21
|
+
end
|
|
22
|
+
@bytesize = (inner.bytesize / @source_row_bytes) * @output_row_bytes
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each_chunk
|
|
26
|
+
return enum_for(__method__) unless block_given?
|
|
27
|
+
|
|
28
|
+
remainder = Format.binary_string
|
|
29
|
+
@inner.each_chunk do |chunk|
|
|
30
|
+
data = remainder.empty? ? chunk : remainder + chunk
|
|
31
|
+
complete_rows, remainder = split_complete_rows(data)
|
|
32
|
+
yield slice_rows(complete_rows) unless complete_rows.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
raise ConversionError, "truncated embedding matrix while slicing dimensions" unless remainder.empty?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def split_complete_rows(data)
|
|
41
|
+
bytes = (data.bytesize / @source_row_bytes) * @source_row_bytes
|
|
42
|
+
complete = data.byteslice(0, bytes) || Format.binary_string
|
|
43
|
+
remainder = data.byteslice(bytes, data.bytesize - bytes) || Format.binary_string
|
|
44
|
+
remainder.force_encoding(Encoding::BINARY)
|
|
45
|
+
[complete, remainder]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def slice_rows(data)
|
|
49
|
+
rows = data.bytesize / @source_row_bytes
|
|
50
|
+
out = Format.binary_string(rows * @output_row_bytes)
|
|
51
|
+
offset = 0
|
|
52
|
+
rows.times do
|
|
53
|
+
out << data.byteslice(offset, @output_row_bytes)
|
|
54
|
+
offset += @source_row_bytes
|
|
55
|
+
end
|
|
56
|
+
out
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
data/lib/static_embeddings.rb
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
|
-
require "json"
|
|
2
1
|
require "static_embeddings/version"
|
|
3
2
|
|
|
4
3
|
begin
|
|
5
4
|
require "static_embeddings/static_embeddings"
|
|
6
5
|
rescue LoadError
|
|
7
6
|
ext_dir = File.expand_path("static_embeddings", __dir__)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
unless
|
|
11
|
-
raise LoadError, "Could not find the compiled StaticEmbeddings extension. "
|
|
12
|
-
"Run: bundle exec rake compile"
|
|
7
|
+
extension = %w[.so .bundle].lazy.map { |suffix| File.join(ext_dir, "static_embeddings#{suffix}") }
|
|
8
|
+
.find { |path| File.file?(path) }
|
|
9
|
+
unless extension
|
|
10
|
+
raise LoadError, "Could not find the compiled StaticEmbeddings extension. Run: bundle exec rake compile"
|
|
13
11
|
end
|
|
14
|
-
|
|
15
|
-
require so_path
|
|
12
|
+
require extension
|
|
16
13
|
end
|
|
17
14
|
|
|
18
15
|
require "static_embeddings/errors"
|
|
19
16
|
require "static_embeddings/paths"
|
|
20
|
-
require "static_embeddings/format"
|
|
17
|
+
require "static_embeddings/format/constants"
|
|
21
18
|
require "static_embeddings/model"
|
|
22
19
|
|
|
23
20
|
module StaticEmbeddings
|
|
@@ -25,11 +22,7 @@ module StaticEmbeddings
|
|
|
25
22
|
def load(path, verify: false)
|
|
26
23
|
expanded = File.expand_path(path.to_s)
|
|
27
24
|
raise ModelNotFound, "no model at #{expanded}" unless File.file?(expanded)
|
|
28
|
-
|
|
29
|
-
if verify
|
|
30
|
-
result = Format.verify(expanded)
|
|
31
|
-
raise InvalidModelError, "checksum mismatch for #{expanded}" unless result[:ok]
|
|
32
|
-
end
|
|
25
|
+
raise InvalidModelError, "checksum mismatch for #{expanded}" if verify && !self.verify(expanded)[:ok]
|
|
33
26
|
|
|
34
27
|
Model.new(expanded)
|
|
35
28
|
end
|
|
@@ -54,63 +47,44 @@ module StaticEmbeddings
|
|
|
54
47
|
path = model_path(model_id)
|
|
55
48
|
unless File.file?(path)
|
|
56
49
|
raise ModelNotFound,
|
|
57
|
-
"model #{model_id.inspect} is not installed. " \
|
|
58
|
-
"
|
|
50
|
+
"model #{model_id.inspect} is not installed. Convert it first: " \
|
|
51
|
+
"static_embeddings convert <hf-dir> --id #{model_id}"
|
|
59
52
|
end
|
|
60
53
|
load(path, verify: verify)
|
|
61
54
|
end
|
|
62
55
|
|
|
63
|
-
def convert(source_dir, output_path:, model_id: nil, max_tokens: nil
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
56
|
+
def convert(source_dir, output_path:, model_id: nil, max_tokens: nil, dimensions: nil,
|
|
57
|
+
source_revision: nil, trained_mrl_dims: nil)
|
|
58
|
+
require "static_embeddings/conversion"
|
|
59
|
+
Conversion.call(
|
|
60
|
+
source_dir,
|
|
61
|
+
output_path: output_path,
|
|
62
|
+
model_id: model_id,
|
|
63
|
+
max_tokens: max_tokens,
|
|
64
|
+
dimensions: dimensions,
|
|
65
|
+
source_revision: source_revision,
|
|
66
|
+
trained_mrl_dims: trained_mrl_dims
|
|
67
|
+
)
|
|
69
68
|
end
|
|
70
69
|
|
|
71
70
|
def verify(path)
|
|
72
|
-
|
|
71
|
+
require "static_embeddings/format/verifier"
|
|
72
|
+
Format::Verifier.call(File.expand_path(path.to_s))
|
|
73
73
|
end
|
|
74
74
|
|
|
75
75
|
def unpack(blob, dim, format: :f32)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
floats =
|
|
79
|
-
case normalize_format(format)
|
|
80
|
-
when :f32
|
|
81
|
-
raise ArgumentError, "f32 blob byte size must be a multiple of 4" unless (blob.bytesize % 4).zero?
|
|
82
|
-
|
|
83
|
-
blob.unpack("e*")
|
|
84
|
-
when :f16
|
|
85
|
-
raise ArgumentError, "f16 blob byte size must be a multiple of 2" unless (blob.bytesize % 2).zero?
|
|
86
|
-
|
|
87
|
-
decode_f16(blob)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
raise ArgumentError, "blob is not a multiple of dim" unless (floats.length % dim).zero?
|
|
91
|
-
|
|
92
|
-
floats.each_slice(dim).to_a
|
|
76
|
+
require "static_embeddings/codec"
|
|
77
|
+
Codec.unpack(blob, dim, format: format)
|
|
93
78
|
end
|
|
94
79
|
|
|
95
80
|
def pack(rows, format: :f32)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
case normalize_format(format)
|
|
99
|
-
when :f32 then flat.map(&:to_f).pack("e*")
|
|
100
|
-
when :f16 then encode_f16(flat.map(&:to_f))
|
|
101
|
-
end
|
|
81
|
+
require "static_embeddings/codec"
|
|
82
|
+
Codec.pack(rows, format: format)
|
|
102
83
|
end
|
|
103
84
|
|
|
104
85
|
def normalize_format(format)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
:f32
|
|
108
|
-
when :f16, :float16
|
|
109
|
-
:f16
|
|
110
|
-
else
|
|
111
|
-
raise ArgumentError, "unsupported embedding format #{format.inspect} (expected :f32 or :f16)"
|
|
112
|
-
end
|
|
86
|
+
require "static_embeddings/codec"
|
|
87
|
+
Codec.normalize_format(format)
|
|
113
88
|
end
|
|
114
|
-
|
|
115
89
|
end
|
|
116
90
|
end
|
data/static_embeddings.gemspec
CHANGED
|
@@ -7,8 +7,8 @@ Gem::Specification.new do |spec|
|
|
|
7
7
|
spec.email = ["romnhajdarov@gmail.com"]
|
|
8
8
|
|
|
9
9
|
spec.summary = "Fast local text embeddings for Ruby — no ONNX, no Rust, no network"
|
|
10
|
-
spec.description = "A small C-extension runtime for Model2Vec
|
|
11
|
-
"models. Models are converted offline into a flat mmap-able .semb " \
|
|
10
|
+
spec.description = "A small C-extension runtime for Model2Vec and Sentence Transformers static " \
|
|
11
|
+
"WordPiece embedding models. Models are converted offline into a flat mmap-able .semb " \
|
|
12
12
|
"file; at runtime the gem tokenizes (BERT WordPiece), looks up rows " \
|
|
13
13
|
"and mean-pools them. Releases the GVL on large native work, rejects internal " \
|
|
14
14
|
"thread fan-out, and links nothing but libc."
|