static_embeddings 0.1.4 → 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 +118 -0
- data/README.md +67 -25
- data/Rakefile +1 -1
- data/docs/ARCHITECTURE.md +54 -34
- data/docs/LIMITATIONS.md +12 -6
- data/docs/MODEL_AUDIT.md +195 -52
- data/ext/static_embeddings/se_embed.c +2 -1
- data/ext/static_embeddings/se_format.c +41 -4
- data/ext/static_embeddings/se_internal.h +17 -5
- data/ext/static_embeddings/se_tokenizer.c +155 -18
- data/ext/static_embeddings/se_unicode.c +1 -1
- data/ext/static_embeddings/static_embeddings.c +32 -2
- 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 -62
- 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 -300
- 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 +17 -4
- data/lib/static_embeddings/provenance.rb +58 -0
- data/lib/static_embeddings/reference.rb +90 -33
- data/lib/static_embeddings/row_prefix_payload.rb +59 -0
- data/lib/static_embeddings/safetensors.rb +178 -34
- data/lib/static_embeddings/version.rb +1 -1
- data/lib/static_embeddings.rb +29 -57
- data/static_embeddings.gemspec +2 -2
- data/tools/check_model2vec_parity.rb +89 -54
- 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 -284
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "static_embeddings/format/constants"
|
|
3
|
+
require "static_embeddings/format/hash_table"
|
|
4
|
+
require "static_embeddings/format/trie"
|
|
5
|
+
|
|
6
|
+
module StaticEmbeddings
|
|
7
|
+
module Format
|
|
8
|
+
module Writer
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(path:, meta:, tokens:, matrix:, norm_tables:, provenance:)
|
|
12
|
+
hash_size, strings, hash_blob, max_probe = HashTable.build(tokens)
|
|
13
|
+
root_trie, continuation_trie = WordPieceTrie.build(tokens, meta.fetch(:subword_prefix))
|
|
14
|
+
payloads = payloads(strings, hash_blob, matrix, norm_tables, provenance, root_trie, continuation_trie)
|
|
15
|
+
sections, file_size = layout(payloads)
|
|
16
|
+
header = build_header(meta, tokens.length, hash_size, max_probe, sections)
|
|
17
|
+
checksum = write_file(path, header, payloads, sections)
|
|
18
|
+
|
|
19
|
+
{ bytes: file_size, sha256: checksum.unpack1("H*"), hash_table_size: hash_size }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def payloads(strings, hash_blob, matrix, norm_tables, provenance, root_trie, continuation_trie)
|
|
23
|
+
{
|
|
24
|
+
vocab_strings: strings,
|
|
25
|
+
vocab_hash: hash_blob,
|
|
26
|
+
embeddings: matrix,
|
|
27
|
+
norm_tables: norm_tables,
|
|
28
|
+
provenance: provenance,
|
|
29
|
+
root_trie: root_trie,
|
|
30
|
+
continuation_trie: continuation_trie
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def layout(payloads)
|
|
35
|
+
offset = HEADER_SIZE
|
|
36
|
+
sections = payloads.each_with_object({}) do |(name, payload), out|
|
|
37
|
+
offset += (ALIGNMENT - (offset % ALIGNMENT)) % ALIGNMENT
|
|
38
|
+
out[name] = [offset, payload.bytesize]
|
|
39
|
+
offset += payload.bytesize
|
|
40
|
+
end
|
|
41
|
+
[sections, offset]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def write_file(path, header, payloads, sections)
|
|
45
|
+
digest = Digest::SHA256.new
|
|
46
|
+
File.open(path, "wb") do |io|
|
|
47
|
+
write_chunk(io, digest, header)
|
|
48
|
+
offset = HEADER_SIZE
|
|
49
|
+
|
|
50
|
+
payloads.each do |name, payload|
|
|
51
|
+
target = sections.fetch(name).first
|
|
52
|
+
padding = target - offset
|
|
53
|
+
write_chunk(io, digest, "\0".b * padding) if padding.positive?
|
|
54
|
+
write_payload(io, digest, payload)
|
|
55
|
+
offset = target + payload.bytesize
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
digest.digest.tap do |checksum|
|
|
60
|
+
File.open(path, "r+b") do |io|
|
|
61
|
+
io.seek(CHECKSUM_OFFSET, IO::SEEK_SET)
|
|
62
|
+
io.write(checksum)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def write_payload(io, digest, payload)
|
|
68
|
+
if payload.respond_to?(:each_chunk)
|
|
69
|
+
payload.each_chunk { |chunk| write_chunk(io, digest, chunk) }
|
|
70
|
+
else
|
|
71
|
+
write_chunk(io, digest, payload)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write_chunk(io, digest, chunk)
|
|
76
|
+
io.write(chunk)
|
|
77
|
+
digest << chunk
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_header(meta, vocab_size, hash_size, max_probe, sections)
|
|
81
|
+
header = "\0".b * HEADER_SIZE
|
|
82
|
+
header[0, 8] = MAGIC.b
|
|
83
|
+
|
|
84
|
+
HEADER_U32.each { |offset, value| put_u32(header, offset, value) }
|
|
85
|
+
META_U32.each { |offset, key| put_u32(header, offset, meta.fetch(key)) }
|
|
86
|
+
META_BOOL.each { |offset, key| put_u32(header, offset, meta.fetch(key) ? 1 : 0) }
|
|
87
|
+
put_u32(header, 24, vocab_size)
|
|
88
|
+
put_u32(header, 104, hash_size)
|
|
89
|
+
put_u32(header, MAX_TOKEN_CHARS_OFFSET, meta.fetch(:max_token_chars))
|
|
90
|
+
put_u32(header, MAX_PROBE_OFFSET, max_probe)
|
|
91
|
+
put_u32(header, ADDED_TOKEN_MASK_OFFSET, meta.fetch(:added_token_mask, 0))
|
|
92
|
+
put_prefix(header, meta.fetch(:subword_prefix))
|
|
93
|
+
SECTION_FIELDS.each { |name, field| put_section(header, field, sections.fetch(name)) }
|
|
94
|
+
header
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def put_prefix(header, prefix)
|
|
98
|
+
bytes = prefix.b
|
|
99
|
+
raise ArgumentError, "subword prefix too long" if bytes.bytesize > 8
|
|
100
|
+
|
|
101
|
+
put_u32(header, 112, bytes.bytesize)
|
|
102
|
+
header[116, 8] = bytes.ljust(8, "\0")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def put_section(header, offset, section)
|
|
106
|
+
put_u64(header, offset, section.fetch(0))
|
|
107
|
+
put_u64(header, offset + 8, section.fetch(1))
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def put_u32(buffer, offset, value)
|
|
111
|
+
integer = Integer(value)
|
|
112
|
+
unless integer.between?(0, UINT32_MAX)
|
|
113
|
+
raise ArgumentError, "u32 value out of range: #{integer}"
|
|
114
|
+
end
|
|
115
|
+
buffer[offset, 4] = [integer].pack("V")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def put_u64(buffer, offset, value)
|
|
119
|
+
integer = Integer(value)
|
|
120
|
+
unless integer.between?(0, UINT64_MAX)
|
|
121
|
+
raise ArgumentError, "u64 value out of range: #{integer}"
|
|
122
|
+
end
|
|
123
|
+
buffer[offset, 8] = [integer & UINT32_MAX, integer >> 32].pack("V2")
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.write(**kwargs)
|
|
128
|
+
Writer.call(**kwargs)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -1,300 +1,3 @@
|
|
|
1
|
-
require "
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
module Format
|
|
5
|
-
MAGIC = "SEMBv1\0\0"
|
|
6
|
-
VERSION = 2
|
|
7
|
-
HEADER_SIZE = 320
|
|
8
|
-
ALIGNMENT = 64
|
|
9
|
-
|
|
10
|
-
TOKENIZER_BERT_WORDPIECE_V1 = 1
|
|
11
|
-
DTYPE_F32 = 1
|
|
12
|
-
POOLING_MEAN = 1
|
|
13
|
-
NORMALIZATION_NONE = 0
|
|
14
|
-
NORMALIZATION_L2 = 1
|
|
15
|
-
TRUNCATE_IDS_BEFORE_POOLING = 1
|
|
16
|
-
UNK_INCLUDE = 0
|
|
17
|
-
UNK_DROP = 1
|
|
18
|
-
EMPTY_ZERO_VECTOR = 0
|
|
19
|
-
EMPTY_RAISE = 1
|
|
20
|
-
|
|
21
|
-
SLOT_EMPTY = 0xFFFFFFFF
|
|
22
|
-
HASH_SEED = 2_166_136_261
|
|
23
|
-
LOAD_FACTOR = 0.70
|
|
24
|
-
|
|
25
|
-
MAX_TOKEN_CHARS_OFFSET = 124
|
|
26
|
-
CHECKSUM_OFFSET = 240
|
|
27
|
-
CHECKSUM_SIZE = 32
|
|
28
|
-
MAX_PROBE_OFFSET = 304
|
|
29
|
-
|
|
30
|
-
SECTION_FIELDS = {
|
|
31
|
-
vocab_strings: 128,
|
|
32
|
-
vocab_hash: 144,
|
|
33
|
-
embeddings: 160,
|
|
34
|
-
norm_tables: 176,
|
|
35
|
-
provenance: 192,
|
|
36
|
-
root_trie: 208,
|
|
37
|
-
continuation_trie: 224
|
|
38
|
-
}.freeze
|
|
39
|
-
|
|
40
|
-
HEADER_U32 = {
|
|
41
|
-
8 => VERSION,
|
|
42
|
-
12 => HEADER_SIZE,
|
|
43
|
-
16 => 1,
|
|
44
|
-
28 => TOKENIZER_BERT_WORDPIECE_V1,
|
|
45
|
-
32 => DTYPE_F32,
|
|
46
|
-
36 => POOLING_MEAN,
|
|
47
|
-
48 => TRUNCATE_IDS_BEFORE_POOLING,
|
|
48
|
-
108 => HASH_SEED
|
|
49
|
-
}.freeze
|
|
50
|
-
|
|
51
|
-
META_U32 = {
|
|
52
|
-
20 => :dim,
|
|
53
|
-
40 => :normalization_type,
|
|
54
|
-
44 => :max_tokens_default,
|
|
55
|
-
56 => :unk_policy,
|
|
56
|
-
60 => :empty_policy,
|
|
57
|
-
80 => :max_input_chars_per_word,
|
|
58
|
-
84 => :pad_id,
|
|
59
|
-
88 => :unk_id,
|
|
60
|
-
92 => :cls_id,
|
|
61
|
-
96 => :sep_id,
|
|
62
|
-
100 => :mask_id
|
|
63
|
-
}.freeze
|
|
64
|
-
|
|
65
|
-
META_BOOL = {
|
|
66
|
-
52 => :add_special_tokens,
|
|
67
|
-
64 => :do_lower_case,
|
|
68
|
-
68 => :strip_accents,
|
|
69
|
-
72 => :handle_chinese_chars,
|
|
70
|
-
76 => :clean_text
|
|
71
|
-
}.freeze
|
|
72
|
-
|
|
73
|
-
VERIFY_CHUNK_BYTES = 1024 * 1024
|
|
74
|
-
|
|
75
|
-
module_function
|
|
76
|
-
|
|
77
|
-
def hash_bytes(str, seed = HASH_SEED)
|
|
78
|
-
str.each_byte.reduce(seed) { |h, b| ((h ^ b) * 16_777_619) & 0xFFFFFFFF }
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def next_power_of_two(n)
|
|
82
|
-
1 << (n - 1).bit_length
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def build_hash_table(tokens)
|
|
86
|
-
size = next_power_of_two((tokens.length / LOAD_FACTOR).ceil + 1)
|
|
87
|
-
slots = Array.new(size)
|
|
88
|
-
strings = binary_string
|
|
89
|
-
max_probe = 0
|
|
90
|
-
|
|
91
|
-
tokens.each_with_index do |token, id|
|
|
92
|
-
bytes = token.b
|
|
93
|
-
offset = strings.bytesize
|
|
94
|
-
strings << bytes
|
|
95
|
-
probe = insert_slot!(slots, tokens, size, bytes, hash_bytes(bytes), offset, id)
|
|
96
|
-
max_probe = probe if probe > max_probe
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
[size, strings, pack_slots(slots), max_probe]
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def write(path:, meta:, tokens:, matrix:, norm_tables:, provenance:)
|
|
103
|
-
hash_size, strings, hash_blob, max_probe = build_hash_table(tokens)
|
|
104
|
-
root_trie, continuation_trie = build_wordpiece_tries(tokens, meta.fetch(:subword_prefix))
|
|
105
|
-
sections, body = build_body(
|
|
106
|
-
vocab_strings: strings,
|
|
107
|
-
vocab_hash: hash_blob,
|
|
108
|
-
embeddings: matrix,
|
|
109
|
-
norm_tables: norm_tables,
|
|
110
|
-
provenance: provenance,
|
|
111
|
-
root_trie: root_trie,
|
|
112
|
-
continuation_trie: continuation_trie
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
header = build_header(meta, tokens.length, hash_size, max_probe, sections)
|
|
116
|
-
file = header << body
|
|
117
|
-
digest = Digest::SHA256.digest(file)
|
|
118
|
-
file[CHECKSUM_OFFSET, CHECKSUM_SIZE] = digest
|
|
119
|
-
|
|
120
|
-
File.binwrite(path, file)
|
|
121
|
-
{ bytes: file.bytesize, sha256: digest.unpack1("H*"), hash_table_size: hash_size }
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def verify(path)
|
|
125
|
-
raise InvalidModelError, "file too small" if File.size(path) < HEADER_SIZE
|
|
126
|
-
|
|
127
|
-
File.open(path, "rb") do |io|
|
|
128
|
-
header = io.read(HEADER_SIZE)
|
|
129
|
-
raise InvalidModelError, "bad magic" unless header.byteslice(0, 8) == MAGIC.b
|
|
130
|
-
|
|
131
|
-
stored = header.byteslice(CHECKSUM_OFFSET, CHECKSUM_SIZE)
|
|
132
|
-
actual = streaming_checksum(io, header)
|
|
133
|
-
|
|
134
|
-
{ ok: stored == actual, expected: actual.unpack1("H*"), stored: stored.unpack1("H*") }
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def streaming_checksum(io, header)
|
|
139
|
-
digest = Digest::SHA256.new
|
|
140
|
-
zeroed = header.dup
|
|
141
|
-
zeroed[CHECKSUM_OFFSET, CHECKSUM_SIZE] = "\0".b * CHECKSUM_SIZE
|
|
142
|
-
digest << zeroed
|
|
143
|
-
|
|
144
|
-
buffer = String.new(capacity: VERIFY_CHUNK_BYTES)
|
|
145
|
-
digest << buffer while io.read(VERIFY_CHUNK_BYTES, buffer)
|
|
146
|
-
digest.digest
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
def binary_string(capacity = nil)
|
|
150
|
-
str = capacity ? String.new(capacity: capacity) : +""
|
|
151
|
-
str.force_encoding(Encoding::BINARY)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def insert_slot!(slots, tokens, size, bytes, hash, offset, id)
|
|
155
|
-
pos = hash & (size - 1)
|
|
156
|
-
probe = 1
|
|
157
|
-
loop do
|
|
158
|
-
slot = slots[pos]
|
|
159
|
-
unless slot
|
|
160
|
-
slots[pos] = [hash, offset, bytes.bytesize, id]
|
|
161
|
-
return probe
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
if slot[0] == hash && slot[2] == bytes.bytesize && tokens.fetch(slot[3]).b == bytes
|
|
165
|
-
raise ArgumentError, "duplicate token in vocabulary: #{tokens.fetch(slot[3]).inspect}"
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
pos = (pos + 1) & (size - 1)
|
|
169
|
-
probe += 1
|
|
170
|
-
end
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
def pack_slots(slots)
|
|
174
|
-
empty = [0, 0, 0, SLOT_EMPTY].pack("V4")
|
|
175
|
-
packed = binary_string(slots.length * 16)
|
|
176
|
-
slots.each { |slot| packed << (slot ? slot.pack("V4") : empty) }
|
|
177
|
-
packed
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def build_wordpiece_tries(tokens, prefix)
|
|
181
|
-
root = TrieBuilder.new
|
|
182
|
-
continuation = TrieBuilder.new
|
|
183
|
-
prefix_bytes = prefix.b
|
|
184
|
-
|
|
185
|
-
tokens.each_with_index do |token, id|
|
|
186
|
-
bytes = token.b
|
|
187
|
-
if !prefix_bytes.empty? && bytes.start_with?(prefix_bytes) && bytes.bytesize > prefix_bytes.bytesize
|
|
188
|
-
continuation.insert(bytes.byteslice(prefix_bytes.bytesize, bytes.bytesize - prefix_bytes.bytesize), id)
|
|
189
|
-
else
|
|
190
|
-
root.insert(bytes, id)
|
|
191
|
-
end
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
[root.pack, continuation.pack]
|
|
195
|
-
end
|
|
196
|
-
|
|
197
|
-
class TrieBuilder
|
|
198
|
-
Node = Struct.new(:terminal, :children, keyword_init: true)
|
|
199
|
-
|
|
200
|
-
def initialize
|
|
201
|
-
@nodes = [Node.new(terminal: SLOT_EMPTY, children: {})]
|
|
202
|
-
end
|
|
203
|
-
|
|
204
|
-
def insert(bytes, id)
|
|
205
|
-
return if bytes.empty?
|
|
206
|
-
|
|
207
|
-
node_index = 0
|
|
208
|
-
bytes.each_byte do |byte|
|
|
209
|
-
node = @nodes[node_index]
|
|
210
|
-
child = node.children[byte]
|
|
211
|
-
unless child
|
|
212
|
-
child = @nodes.length
|
|
213
|
-
node.children[byte] = child
|
|
214
|
-
@nodes << Node.new(terminal: SLOT_EMPTY, children: {})
|
|
215
|
-
end
|
|
216
|
-
node_index = child
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
node = @nodes[node_index]
|
|
220
|
-
raise ArgumentError, "duplicate trie key for token id #{id}" unless node.terminal == SLOT_EMPTY
|
|
221
|
-
|
|
222
|
-
node.terminal = id
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
def pack
|
|
226
|
-
edges = []
|
|
227
|
-
node_records = @nodes.map do |node|
|
|
228
|
-
start = edges.length
|
|
229
|
-
node.children.sort_by { |byte, _| byte }.each do |byte, child|
|
|
230
|
-
edges << [byte, child]
|
|
231
|
-
end
|
|
232
|
-
[start, node.children.length, node.terminal, 0]
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
packed = Format.binary_string(16 + node_records.length * 16 + edges.length * 8)
|
|
236
|
-
packed << [node_records.length, edges.length, 0, 0].pack("V4")
|
|
237
|
-
node_records.each { |record| packed << record.pack("V4") }
|
|
238
|
-
edges.each { |edge| packed << edge.pack("V2") }
|
|
239
|
-
packed
|
|
240
|
-
end
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
def build_body(payloads)
|
|
244
|
-
body = binary_string
|
|
245
|
-
sections = {}
|
|
246
|
-
payloads.each do |name, payload|
|
|
247
|
-
align_body!(body)
|
|
248
|
-
sections[name] = [HEADER_SIZE + body.bytesize, payload.bytesize]
|
|
249
|
-
body << payload
|
|
250
|
-
end
|
|
251
|
-
[sections, body]
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def align_body!(body)
|
|
255
|
-
padding = (ALIGNMENT - ((HEADER_SIZE + body.bytesize) % ALIGNMENT)) % ALIGNMENT
|
|
256
|
-
body << "\0".b * padding if padding.positive?
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
def build_header(meta, vocab_size, hash_size, max_probe, sections)
|
|
260
|
-
header = "\0".b * HEADER_SIZE
|
|
261
|
-
header[0, 8] = MAGIC.b
|
|
262
|
-
|
|
263
|
-
HEADER_U32.each { |offset, value| put_u32(header, offset, value) }
|
|
264
|
-
META_U32.each { |offset, key| put_u32(header, offset, meta.fetch(key)) }
|
|
265
|
-
META_BOOL.each { |offset, key| put_u32(header, offset, meta.fetch(key) ? 1 : 0) }
|
|
266
|
-
|
|
267
|
-
put_u32(header, 24, vocab_size)
|
|
268
|
-
put_u32(header, 104, hash_size)
|
|
269
|
-
put_u32(header, MAX_TOKEN_CHARS_OFFSET, meta.fetch(:max_token_chars))
|
|
270
|
-
put_u32(header, MAX_PROBE_OFFSET, max_probe)
|
|
271
|
-
put_prefix(header, meta.fetch(:subword_prefix))
|
|
272
|
-
SECTION_FIELDS.each { |name, field| put_section(header, field, sections.fetch(name)) }
|
|
273
|
-
|
|
274
|
-
header
|
|
275
|
-
end
|
|
276
|
-
|
|
277
|
-
def put_prefix(header, prefix)
|
|
278
|
-
bytes = prefix.b
|
|
279
|
-
raise ArgumentError, "subword prefix too long" if bytes.bytesize > 8
|
|
280
|
-
|
|
281
|
-
put_u32(header, 112, bytes.bytesize)
|
|
282
|
-
header[116, 8] = bytes.ljust(8, "\0")
|
|
283
|
-
end
|
|
284
|
-
|
|
285
|
-
def put_section(header, offset, section)
|
|
286
|
-
off, size = section
|
|
287
|
-
put_u64(header, offset, off)
|
|
288
|
-
put_u64(header, offset + 8, size)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def put_u32(buffer, offset, value)
|
|
292
|
-
buffer[offset, 4] = [value].pack("V")
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def put_u64(buffer, offset, value)
|
|
296
|
-
buffer[offset, 8] = [value & 0xFFFFFFFF, value >> 32].pack("V2")
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
end
|
|
300
|
-
end
|
|
1
|
+
require "static_embeddings/format/constants"
|
|
2
|
+
require "static_embeddings/format/verifier"
|
|
3
|
+
require "static_embeddings/format/writer"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module StaticEmbeddings
|
|
2
|
+
module Importers
|
|
3
|
+
module Model2Vec
|
|
4
|
+
SOURCE_FILES = %w[tokenizer.json config.json tokenizer_config.json model.safetensors].freeze
|
|
5
|
+
DEFAULT_MAX_TOKENS = 512
|
|
6
|
+
FAMILY = "model2vec"
|
|
7
|
+
ORACLE = "model2vec.StaticModel"
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def call(root, model_id: nil, max_tokens: nil, dimensions: nil,
|
|
12
|
+
source_revision: nil, trained_mrl_dims: nil)
|
|
13
|
+
tokenizer = Support.load_object(File.join(root, "tokenizer.json"))
|
|
14
|
+
config = Support.load_object(File.join(root, "config.json"), optional: true) || {}
|
|
15
|
+
tokenizer_config = Support.load_object(File.join(root, "tokenizer_config.json"), optional: true) || {}
|
|
16
|
+
profile, tokens = BertWordPiece.compile(tokenizer, tokenizer_config)
|
|
17
|
+
payload, native_dim = Support.extract_matrix(File.join(root, "model.safetensors"), tokens.length)
|
|
18
|
+
output_dim = Support.resolve_dimensions(dimensions, native_dim)
|
|
19
|
+
|
|
20
|
+
Canonical.model(
|
|
21
|
+
tokens: tokens,
|
|
22
|
+
matrix: Support.slice_matrix(payload, native_dim, output_dim),
|
|
23
|
+
dimensions: Canonical.dimensions(
|
|
24
|
+
native: native_dim,
|
|
25
|
+
output: output_dim,
|
|
26
|
+
trained: Support.normalize_mrl_dims(trained_mrl_dims, native_dim: native_dim)
|
|
27
|
+
),
|
|
28
|
+
runtime: Canonical.runtime(
|
|
29
|
+
normalization: normalization(config),
|
|
30
|
+
unk_policy: Format::UNK_DROP,
|
|
31
|
+
empty_policy: Format::EMPTY_ZERO_VECTOR,
|
|
32
|
+
max_tokens: Support.resolve_max_tokens(max_tokens, DEFAULT_MAX_TOKENS)
|
|
33
|
+
),
|
|
34
|
+
tokenizer: BertWordPiece.runtime_meta(profile, tokens),
|
|
35
|
+
source: Canonical.source(
|
|
36
|
+
family: FAMILY,
|
|
37
|
+
model: model_id || File.basename(root),
|
|
38
|
+
revision: source_revision,
|
|
39
|
+
oracle: ORACLE,
|
|
40
|
+
files_sha256: Support.digest_files(SOURCE_FILES.map { |name| File.join(root, name) }),
|
|
41
|
+
tokenizer_class: profile[:tokenizer_class],
|
|
42
|
+
config_seq_length: config["seq_length"]
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalization(config)
|
|
48
|
+
config.fetch("normalize", false) ? Format::NORMALIZATION_L2 : Format::NORMALIZATION_NONE
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
module StaticEmbeddings
|
|
4
|
+
module Importers
|
|
5
|
+
module SentenceTransformersStatic
|
|
6
|
+
TYPE = "sentence_transformers.models.StaticEmbedding"
|
|
7
|
+
FAMILY = "sentence_transformers_static"
|
|
8
|
+
ORACLE = "sentence_transformers.SentenceTransformer.encode"
|
|
9
|
+
DEFAULT_MAX_TOKENS = 0
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(root, model_id: nil, max_tokens: nil, dimensions: nil,
|
|
14
|
+
source_revision: nil, trained_mrl_dims: nil)
|
|
15
|
+
module_dir = module_directory(root)
|
|
16
|
+
tokenizer = Support.load_object(File.join(module_dir, "tokenizer.json"))
|
|
17
|
+
tokenizer_config = Support.load_object(File.join(module_dir, "tokenizer_config.json"), optional: true) || {}
|
|
18
|
+
config = Support.load_object(File.join(module_dir, "config.json"), optional: true) || {}
|
|
19
|
+
profile, tokens = BertWordPiece.compile(tokenizer, tokenizer_config)
|
|
20
|
+
payload, native_dim = Support.extract_matrix(File.join(module_dir, "model.safetensors"), tokens.length)
|
|
21
|
+
output_dim = Support.resolve_dimensions(dimensions, native_dim)
|
|
22
|
+
|
|
23
|
+
Canonical.model(
|
|
24
|
+
tokens: tokens,
|
|
25
|
+
matrix: Support.slice_matrix(payload, native_dim, output_dim),
|
|
26
|
+
dimensions: Canonical.dimensions(
|
|
27
|
+
native: native_dim,
|
|
28
|
+
output: output_dim,
|
|
29
|
+
trained: Support.normalize_mrl_dims(trained_mrl_dims, native_dim: native_dim)
|
|
30
|
+
),
|
|
31
|
+
runtime: Canonical.runtime(
|
|
32
|
+
normalization: Format::NORMALIZATION_NONE,
|
|
33
|
+
unk_policy: Format::UNK_INCLUDE,
|
|
34
|
+
empty_policy: Format::EMPTY_ZERO_VECTOR,
|
|
35
|
+
max_tokens: Support.resolve_max_tokens(max_tokens, DEFAULT_MAX_TOKENS)
|
|
36
|
+
),
|
|
37
|
+
tokenizer: BertWordPiece.runtime_meta(profile, tokens),
|
|
38
|
+
source: Canonical.source(
|
|
39
|
+
family: FAMILY,
|
|
40
|
+
model: model_id || File.basename(root),
|
|
41
|
+
revision: source_revision,
|
|
42
|
+
oracle: ORACLE,
|
|
43
|
+
files_sha256: source_digests(root, module_dir),
|
|
44
|
+
tokenizer_class: profile[:tokenizer_class],
|
|
45
|
+
config_seq_length: config["seq_length"]
|
|
46
|
+
)
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def module_directory(root)
|
|
51
|
+
spec = module_spec(root)
|
|
52
|
+
contained_directory(root, spec.fetch("path"))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def module_spec(root)
|
|
56
|
+
modules = Support.load_json(File.join(root, "modules.json"))
|
|
57
|
+
reject_source("modules.json is not an array") unless modules.is_a?(Array)
|
|
58
|
+
reject_source("modules.json declares #{modules.length} modules, expected exactly one StaticEmbedding") unless modules.length == 1
|
|
59
|
+
|
|
60
|
+
spec = modules.first
|
|
61
|
+
reject_source("modules.json[0] is not an object") unless spec.is_a?(Hash)
|
|
62
|
+
reject_source("modules.json[0].type is #{spec['type'].inspect}, expected #{TYPE}") unless spec["type"] == TYPE
|
|
63
|
+
reject_source("modules.json[0] has no path") unless spec.key?("path")
|
|
64
|
+
reject_source("modules.json[0].path is not a string") unless spec["path"].is_a?(String)
|
|
65
|
+
spec
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def contained_directory(root, relative)
|
|
69
|
+
relative = relative.to_s
|
|
70
|
+
reject_source("module path contains a NUL byte") if relative.include?("\0")
|
|
71
|
+
reject_source("module path #{relative.inspect} is absolute") if Pathname.new(relative).absolute?
|
|
72
|
+
|
|
73
|
+
root_real = File.realpath(root)
|
|
74
|
+
candidate = relative.empty? ? root : File.expand_path(relative, root)
|
|
75
|
+
reject_source("module path #{relative.inspect} is not a directory") unless File.directory?(candidate)
|
|
76
|
+
|
|
77
|
+
real = File.realpath(candidate)
|
|
78
|
+
prefix = root_real.end_with?(File::SEPARATOR) ? root_real : "#{root_real}#{File::SEPARATOR}"
|
|
79
|
+
reject_source("module path #{relative.inspect} escapes the source directory") unless real == root_real || real.start_with?(prefix)
|
|
80
|
+
real
|
|
81
|
+
rescue Errno::ENOENT
|
|
82
|
+
reject_source("module path #{relative.inspect} does not exist")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def source_digests(root, module_dir)
|
|
86
|
+
paths = [
|
|
87
|
+
File.join(root, "modules.json"),
|
|
88
|
+
File.join(module_dir, "tokenizer.json"),
|
|
89
|
+
File.join(module_dir, "tokenizer_config.json"),
|
|
90
|
+
File.join(module_dir, "config.json"),
|
|
91
|
+
File.join(module_dir, "model.safetensors")
|
|
92
|
+
]
|
|
93
|
+
Support.digest_files(paths, root: root)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def reject_source(message)
|
|
97
|
+
raise UnsupportedModelError,
|
|
98
|
+
"#{message}. Sentence Transformers conversion accepts exactly one #{TYPE} module " \
|
|
99
|
+
"using #{BertWordPiece::TOKENIZER_PROFILE}."
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -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
|