static_embeddings 0.1.5 → 1.5.7
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 +60 -0
- data/README.md +17 -2
- data/docs/ARCHITECTURE.md +14 -5
- data/docs/MODEL_AUDIT.md +110 -0
- data/ext/static_embeddings/se_alloc_stats.c +5 -6
- data/ext/static_embeddings/se_f16.c +1 -1
- data/ext/static_embeddings/se_tokenizer.c +6 -0
- data/ext/static_embeddings/se_topk.c +1 -2
- data/ext/static_embeddings/static_embeddings.c +60 -10
- 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
|
@@ -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."
|
|
@@ -80,7 +80,11 @@ rows.each_with_index do |row, i|
|
|
|
80
80
|
|
|
81
81
|
full_raw = model.tokenize(text, max_tokens: false)
|
|
82
82
|
expected_usable = row.fetch("static_usable_token_ids")
|
|
83
|
-
got_usable =
|
|
83
|
+
got_usable = if reference["unk_token_id"].nil?
|
|
84
|
+
full_raw.first(max_length)
|
|
85
|
+
else
|
|
86
|
+
full_raw.reject { |id| id == model.unk_id }.first(max_length)
|
|
87
|
+
end
|
|
84
88
|
if got_usable != expected_usable
|
|
85
89
|
usable_failures << i
|
|
86
90
|
first = got_usable.zip(expected_usable).index { |a, b| a != b } || [got_usable.length, expected_usable.length].min
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
require "static_embeddings"
|
|
4
|
+
|
|
5
|
+
options = {
|
|
6
|
+
min_cosine: 1.0 - 1e-6,
|
|
7
|
+
max_abs: 1e-5
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
parser = OptionParser.new do |opts|
|
|
11
|
+
opts.on("--model PATH") { |value| options[:model] = value }
|
|
12
|
+
opts.on("--oracle PATH") { |value| options[:oracle] = value }
|
|
13
|
+
opts.on("--min-cosine N", Float) { |value| options[:min_cosine] = value }
|
|
14
|
+
opts.on("--max-abs N", Float) { |value| options[:max_abs] = value }
|
|
15
|
+
end
|
|
16
|
+
parser.parse!(ARGV)
|
|
17
|
+
unless options[:model] && options[:oracle]
|
|
18
|
+
abort "usage: ruby -Ilib tools/check_st_parity.rb --model MODEL.semb --oracle oracle.json"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
model = StaticEmbeddings.load(options[:model], verify: true)
|
|
22
|
+
payload = JSON.parse(File.read(options[:oracle], encoding: "UTF-8"))
|
|
23
|
+
abort "unsupported oracle schema #{payload["schema_version"].inspect}" unless payload["schema_version"] == 1
|
|
24
|
+
|
|
25
|
+
reference = payload.fetch("reference")
|
|
26
|
+
abort "ST oracle must use add_special_tokens=false" unless reference["add_special_tokens"] == false
|
|
27
|
+
abort "ST oracle must use normalize_embeddings=false" unless reference["normalize_embeddings"] == false
|
|
28
|
+
if model.normalized?
|
|
29
|
+
abort "ST source-faithful .semb must not L2-normalize; got normalized?=true"
|
|
30
|
+
end
|
|
31
|
+
unless model.max_tokens == false
|
|
32
|
+
abort "ST source-faithful .semb must bake unlimited tokens; got max_tokens=#{model.max_tokens.inspect}"
|
|
33
|
+
end
|
|
34
|
+
if model.provenance["unk_policy"] != "include"
|
|
35
|
+
abort "ST source-faithful .semb must use UNK_INCLUDE; got #{model.provenance["unk_policy"].inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def prefix_dims(vector, dim)
|
|
39
|
+
return vector if vector.length == dim
|
|
40
|
+
abort "oracle vector shorter than model dim (#{vector.length} < #{dim})" if vector.length < dim
|
|
41
|
+
|
|
42
|
+
vector.first(dim)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def dot(a, b)
|
|
46
|
+
a.zip(b).sum { |x, y| x * y }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def norm(a)
|
|
50
|
+
Math.sqrt(a.sum { |x| x * x })
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def vector_metrics(reference, got)
|
|
54
|
+
max_abs = reference.zip(got).map { |a, b| (a - b).abs }.max || 0.0
|
|
55
|
+
ref_zero = reference.all?(&:zero?)
|
|
56
|
+
got_zero = got.all?(&:zero?)
|
|
57
|
+
cosine =
|
|
58
|
+
if ref_zero && got_zero
|
|
59
|
+
1.0
|
|
60
|
+
elsif ref_zero || got_zero
|
|
61
|
+
0.0
|
|
62
|
+
else
|
|
63
|
+
dot(reference, got) / (norm(reference) * norm(got))
|
|
64
|
+
end
|
|
65
|
+
[cosine, max_abs]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
raw_failures = []
|
|
69
|
+
vector_failures = []
|
|
70
|
+
diag_failures = []
|
|
71
|
+
min_cosine = 1.0
|
|
72
|
+
max_abs_all = 0.0
|
|
73
|
+
vectors_checked = 0
|
|
74
|
+
|
|
75
|
+
payload.fetch("rows").each_with_index do |row, i|
|
|
76
|
+
text = row.fetch("text")
|
|
77
|
+
label = row.fetch("label", i.to_s)
|
|
78
|
+
problems = []
|
|
79
|
+
|
|
80
|
+
expected_raw = row.fetch("hf_raw_token_ids")
|
|
81
|
+
got_raw = model.tokenize(text, max_tokens: false)
|
|
82
|
+
if got_raw != expected_raw
|
|
83
|
+
raw_failures << i
|
|
84
|
+
first = got_raw.zip(expected_raw).index { |a, b| a != b } || [got_raw.length, expected_raw.length].min
|
|
85
|
+
problems << "raw ids differ at #{first} (got #{got_raw.length}, ref #{expected_raw.length})"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
got_vector = model.embed_array(text)
|
|
89
|
+
reference_vector = prefix_dims(row.fetch("st_encode_vector"), got_vector.length)
|
|
90
|
+
cosine, max_abs = vector_metrics(reference_vector, got_vector)
|
|
91
|
+
vectors_checked += 1
|
|
92
|
+
min_cosine = [min_cosine, cosine].min
|
|
93
|
+
max_abs_all = [max_abs_all, max_abs].max
|
|
94
|
+
unless cosine >= options[:min_cosine] && max_abs <= options[:max_abs]
|
|
95
|
+
vector_failures << i
|
|
96
|
+
problems << format("encode vector out of tolerance cos=%.10f max_abs=%.8g", cosine, max_abs)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
if row.key?("static_embedding_vector")
|
|
100
|
+
diag = prefix_dims(row.fetch("static_embedding_vector"), got_vector.length)
|
|
101
|
+
dcos, dabs = vector_metrics(diag, got_vector)
|
|
102
|
+
unless dcos >= options[:min_cosine] && dabs <= options[:max_abs]
|
|
103
|
+
diag_failures << i
|
|
104
|
+
problems << format("StaticEmbedding diagnostic out of tolerance cos=%.10f max_abs=%.8g", dcos, dabs)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
status = problems.empty? ? "ok" : "FAIL"
|
|
109
|
+
puts "#{status} idx=#{format('%03d', i)} label=#{label.inspect} bytes=#{text.bytesize}" +
|
|
110
|
+
(problems.empty? ? "" : " [#{problems.join('; ')}]")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
puts "rows=#{payload.fetch("rows").length}"
|
|
114
|
+
puts "vectors_checked=#{vectors_checked}"
|
|
115
|
+
puts "min_cosine=#{min_cosine}"
|
|
116
|
+
puts "max_abs_all=#{max_abs_all}"
|
|
117
|
+
puts "raw_token_id_failures=#{raw_failures.inspect}"
|
|
118
|
+
puts "vector_failures=#{vector_failures.inspect}"
|
|
119
|
+
puts "static_embedding_failures=#{diag_failures.inspect}"
|
|
120
|
+
|
|
121
|
+
unless raw_failures.empty? && vector_failures.empty? && diag_failures.empty?
|
|
122
|
+
abort "ST parity failed"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
puts "ST corpus parity OK (#{payload.fetch("rows").length}/#{payload.fetch("rows").length})"
|