static_embeddings 0.1.1
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 +136 -0
- data/LICENSE.txt +21 -0
- data/README.md +439 -0
- data/docs/ARCHITECTURE.md +278 -0
- data/docs/MODEL_AUDIT.md +61 -0
- data/docs/PERFORMANCE.md +60 -0
- data/exe/static_embeddings +6 -0
- data/ext/static_embeddings/extconf.rb +44 -0
- data/ext/static_embeddings/se_embed.c +192 -0
- data/ext/static_embeddings/se_format.c +762 -0
- data/ext/static_embeddings/se_internal.h +266 -0
- data/ext/static_embeddings/se_tokenizer.c +506 -0
- data/ext/static_embeddings/se_unicode.c +120 -0
- data/ext/static_embeddings/static_embeddings.c +2070 -0
- data/lib/static_embeddings/cli.rb +178 -0
- data/lib/static_embeddings/converter.rb +284 -0
- data/lib/static_embeddings/errors.rb +6 -0
- data/lib/static_embeddings/format.rb +289 -0
- data/lib/static_embeddings/model.rb +48 -0
- data/lib/static_embeddings/paths.rb +29 -0
- data/lib/static_embeddings/reference.rb +191 -0
- data/lib/static_embeddings/safetensors.rb +87 -0
- data/lib/static_embeddings/unicode_tables.rb +127 -0
- data/lib/static_embeddings/version.rb +3 -0
- data/lib/static_embeddings.rb +118 -0
- data/static_embeddings.gemspec +45 -0
- data/tools/benchmark.rb +38 -0
- data/tools/build_demo_model.rb +16 -0
- data/tools/check_model2vec_parity.rb +107 -0
- data/tools/make_fixture_model.rb +165 -0
- metadata +134 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module StaticEmbeddings
|
|
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
|
+
module_function
|
|
74
|
+
|
|
75
|
+
def hash_bytes(str, seed = HASH_SEED)
|
|
76
|
+
str.each_byte.reduce(seed) { |h, b| ((h ^ b) * 16_777_619) & 0xFFFFFFFF }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def next_power_of_two(n)
|
|
80
|
+
1 << (n - 1).bit_length
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_hash_table(tokens)
|
|
84
|
+
size = next_power_of_two((tokens.length / LOAD_FACTOR).ceil + 1)
|
|
85
|
+
slots = Array.new(size)
|
|
86
|
+
strings = binary_string
|
|
87
|
+
max_probe = 0
|
|
88
|
+
|
|
89
|
+
tokens.each_with_index do |token, id|
|
|
90
|
+
bytes = token.b
|
|
91
|
+
offset = strings.bytesize
|
|
92
|
+
strings << bytes
|
|
93
|
+
probe = insert_slot!(slots, tokens, size, bytes, hash_bytes(bytes), offset, id)
|
|
94
|
+
max_probe = probe if probe > max_probe
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
[size, strings, pack_slots(slots), max_probe]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def write(path:, meta:, tokens:, matrix:, norm_tables:, provenance:)
|
|
101
|
+
hash_size, strings, hash_blob, max_probe = build_hash_table(tokens)
|
|
102
|
+
root_trie, continuation_trie = build_wordpiece_tries(tokens, meta.fetch(:subword_prefix))
|
|
103
|
+
sections, body = build_body(
|
|
104
|
+
vocab_strings: strings,
|
|
105
|
+
vocab_hash: hash_blob,
|
|
106
|
+
embeddings: matrix,
|
|
107
|
+
norm_tables: norm_tables,
|
|
108
|
+
provenance: provenance,
|
|
109
|
+
root_trie: root_trie,
|
|
110
|
+
continuation_trie: continuation_trie
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
header = build_header(meta, tokens.length, hash_size, max_probe, sections)
|
|
114
|
+
file = header << body
|
|
115
|
+
digest = Digest::SHA256.digest(file)
|
|
116
|
+
file[CHECKSUM_OFFSET, CHECKSUM_SIZE] = digest
|
|
117
|
+
|
|
118
|
+
File.binwrite(path, file)
|
|
119
|
+
{ bytes: file.bytesize, sha256: digest.unpack1("H*"), hash_table_size: hash_size }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def verify(path)
|
|
123
|
+
data = File.binread(path)
|
|
124
|
+
raise InvalidModelError, "file too small" if data.bytesize < HEADER_SIZE
|
|
125
|
+
raise InvalidModelError, "bad magic" unless data.byteslice(0, 8) == MAGIC.b
|
|
126
|
+
|
|
127
|
+
stored = data.byteslice(CHECKSUM_OFFSET, CHECKSUM_SIZE)
|
|
128
|
+
actual = checksum_for_verify(data)
|
|
129
|
+
|
|
130
|
+
{ ok: stored == actual, expected: actual.unpack1("H*"), stored: stored.unpack1("H*") }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def binary_string(capacity = nil)
|
|
134
|
+
str = capacity ? String.new(capacity: capacity) : +""
|
|
135
|
+
str.force_encoding(Encoding::BINARY)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def insert_slot!(slots, tokens, size, bytes, hash, offset, id)
|
|
139
|
+
pos = hash & (size - 1)
|
|
140
|
+
probe = 1
|
|
141
|
+
loop do
|
|
142
|
+
slot = slots[pos]
|
|
143
|
+
unless slot
|
|
144
|
+
slots[pos] = [hash, offset, bytes.bytesize, id]
|
|
145
|
+
return probe
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if slot[0] == hash && slot[2] == bytes.bytesize && tokens.fetch(slot[3]).b == bytes
|
|
149
|
+
raise ArgumentError, "duplicate token in vocabulary: #{tokens.fetch(slot[3]).inspect}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
pos = (pos + 1) & (size - 1)
|
|
153
|
+
probe += 1
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def pack_slots(slots)
|
|
158
|
+
empty = [0, 0, 0, SLOT_EMPTY].pack("V4")
|
|
159
|
+
packed = binary_string(slots.length * 16)
|
|
160
|
+
slots.each { |slot| packed << (slot ? slot.pack("V4") : empty) }
|
|
161
|
+
packed
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def build_wordpiece_tries(tokens, prefix)
|
|
165
|
+
root = TrieBuilder.new
|
|
166
|
+
continuation = TrieBuilder.new
|
|
167
|
+
prefix_bytes = prefix.b
|
|
168
|
+
|
|
169
|
+
tokens.each_with_index do |token, id|
|
|
170
|
+
bytes = token.b
|
|
171
|
+
if !prefix_bytes.empty? && bytes.start_with?(prefix_bytes) && bytes.bytesize > prefix_bytes.bytesize
|
|
172
|
+
continuation.insert(bytes.byteslice(prefix_bytes.bytesize, bytes.bytesize - prefix_bytes.bytesize), id)
|
|
173
|
+
else
|
|
174
|
+
root.insert(bytes, id)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
[root.pack, continuation.pack]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
class TrieBuilder
|
|
182
|
+
Node = Struct.new(:terminal, :children, keyword_init: true)
|
|
183
|
+
|
|
184
|
+
def initialize
|
|
185
|
+
@nodes = [Node.new(terminal: SLOT_EMPTY, children: {})]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def insert(bytes, id)
|
|
189
|
+
return if bytes.empty?
|
|
190
|
+
|
|
191
|
+
node_index = 0
|
|
192
|
+
bytes.each_byte do |byte|
|
|
193
|
+
node = @nodes[node_index]
|
|
194
|
+
child = node.children[byte]
|
|
195
|
+
unless child
|
|
196
|
+
child = @nodes.length
|
|
197
|
+
node.children[byte] = child
|
|
198
|
+
@nodes << Node.new(terminal: SLOT_EMPTY, children: {})
|
|
199
|
+
end
|
|
200
|
+
node_index = child
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
node = @nodes[node_index]
|
|
204
|
+
raise ArgumentError, "duplicate trie key for token id #{id}" unless node.terminal == SLOT_EMPTY
|
|
205
|
+
|
|
206
|
+
node.terminal = id
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def pack
|
|
210
|
+
edges = []
|
|
211
|
+
node_records = @nodes.map do |node|
|
|
212
|
+
start = edges.length
|
|
213
|
+
node.children.sort_by { |byte, _| byte }.each do |byte, child|
|
|
214
|
+
edges << [byte, child]
|
|
215
|
+
end
|
|
216
|
+
[start, node.children.length, node.terminal, 0]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
packed = Format.binary_string(16 + node_records.length * 16 + edges.length * 8)
|
|
220
|
+
packed << [node_records.length, edges.length, 0, 0].pack("V4")
|
|
221
|
+
node_records.each { |record| packed << record.pack("V4") }
|
|
222
|
+
edges.each { |edge| packed << edge.pack("V2") }
|
|
223
|
+
packed
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def build_body(payloads)
|
|
228
|
+
body = binary_string
|
|
229
|
+
sections = {}
|
|
230
|
+
payloads.each do |name, payload|
|
|
231
|
+
align_body!(body)
|
|
232
|
+
sections[name] = [HEADER_SIZE + body.bytesize, payload.bytesize]
|
|
233
|
+
body << payload
|
|
234
|
+
end
|
|
235
|
+
[sections, body]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def align_body!(body)
|
|
239
|
+
padding = (ALIGNMENT - ((HEADER_SIZE + body.bytesize) % ALIGNMENT)) % ALIGNMENT
|
|
240
|
+
body << "\0".b * padding if padding.positive?
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def build_header(meta, vocab_size, hash_size, max_probe, sections)
|
|
244
|
+
header = "\0".b * HEADER_SIZE
|
|
245
|
+
header[0, 8] = MAGIC.b
|
|
246
|
+
|
|
247
|
+
HEADER_U32.each { |offset, value| put_u32(header, offset, value) }
|
|
248
|
+
META_U32.each { |offset, key| put_u32(header, offset, meta.fetch(key)) }
|
|
249
|
+
META_BOOL.each { |offset, key| put_u32(header, offset, meta.fetch(key) ? 1 : 0) }
|
|
250
|
+
|
|
251
|
+
put_u32(header, 24, vocab_size)
|
|
252
|
+
put_u32(header, 104, hash_size)
|
|
253
|
+
put_u32(header, MAX_TOKEN_CHARS_OFFSET, meta.fetch(:max_token_chars))
|
|
254
|
+
put_u32(header, MAX_PROBE_OFFSET, max_probe)
|
|
255
|
+
put_prefix(header, meta.fetch(:subword_prefix))
|
|
256
|
+
SECTION_FIELDS.each { |name, field| put_section(header, field, sections.fetch(name)) }
|
|
257
|
+
|
|
258
|
+
header
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def put_prefix(header, prefix)
|
|
262
|
+
bytes = prefix.b
|
|
263
|
+
raise ArgumentError, "subword prefix too long" if bytes.bytesize > 8
|
|
264
|
+
|
|
265
|
+
put_u32(header, 112, bytes.bytesize)
|
|
266
|
+
header[116, 8] = bytes.ljust(8, "\0")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def put_section(header, offset, section)
|
|
270
|
+
off, size = section
|
|
271
|
+
put_u64(header, offset, off)
|
|
272
|
+
put_u64(header, offset + 8, size)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def put_u32(buffer, offset, value)
|
|
276
|
+
buffer[offset, 4] = [value].pack("V")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def put_u64(buffer, offset, value)
|
|
280
|
+
buffer[offset, 8] = [value & 0xFFFFFFFF, value >> 32].pack("V2")
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def checksum_for_verify(data)
|
|
284
|
+
zeroed = data.dup
|
|
285
|
+
zeroed[CHECKSUM_OFFSET, CHECKSUM_SIZE] = "\0".b * CHECKSUM_SIZE
|
|
286
|
+
Digest::SHA256.digest(zeroed)
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module StaticEmbeddings
|
|
4
|
+
class Model
|
|
5
|
+
attr_reader :path
|
|
6
|
+
|
|
7
|
+
def provenance
|
|
8
|
+
raw = provenance_json
|
|
9
|
+
return {} if raw.nil?
|
|
10
|
+
|
|
11
|
+
JSON.parse(raw)
|
|
12
|
+
rescue JSON::ParserError, EncodingError => e
|
|
13
|
+
raise InvalidModelError, "invalid provenance JSON: #{e.message}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def model_id
|
|
17
|
+
provenance["source_model_id"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def embed_array(text, **opts)
|
|
21
|
+
format = opts.key?(:format) ? opts[:format] : :f32
|
|
22
|
+
StaticEmbeddings.unpack(embed(text, **opts), dim, format: format).first
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def embed_batch_arrays(texts, **opts)
|
|
26
|
+
format = opts.key?(:format) ? opts[:format] : :f32
|
|
27
|
+
StaticEmbeddings.unpack(embed_batch(texts, **opts), dim, format: format)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def cosine_top_k(query_blob, matrix_blob, k, **opts)
|
|
31
|
+
raise ArgumentError, "dim: is set by the model" if opts.key?(:dim)
|
|
32
|
+
|
|
33
|
+
StaticEmbeddings.cosine_top_k(query_blob, matrix_blob, k, **opts.merge(dim: dim))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def dot_top_k(query_blob, matrix_blob, k, **opts)
|
|
37
|
+
raise ArgumentError, "dim: is set by the model" if opts.key?(:dim)
|
|
38
|
+
|
|
39
|
+
StaticEmbeddings.dot_top_k(query_blob, matrix_blob, k, **opts.merge(dim: dim))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_s
|
|
43
|
+
"#<StaticEmbeddings::Model #{model_id || path} dim=#{dim} vocab=#{vocab_size}>"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
alias inspect to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module StaticEmbeddings
|
|
2
|
+
module Paths
|
|
3
|
+
BUILTIN_DIR = File.expand_path("../models", __dir__)
|
|
4
|
+
BUILTIN_MODELS = { demo: "demo.semb" }.freeze
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def cache_dir(env = ENV)
|
|
9
|
+
env["STATIC_EMBEDDINGS_CACHE"] ||
|
|
10
|
+
File.join(env["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache"), "static_embeddings")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def model_path(model_id, env = ENV)
|
|
14
|
+
File.join(cache_dir(env), "models", "#{model_id}.semb")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def builtin_path(name)
|
|
18
|
+
file = BUILTIN_MODELS.fetch(name) do
|
|
19
|
+
raise ModelNotFound, "unknown builtin model #{name.inspect}"
|
|
20
|
+
end
|
|
21
|
+
File.join(BUILTIN_DIR, file)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def builtin_available?(name = :demo)
|
|
25
|
+
file = BUILTIN_MODELS[name]
|
|
26
|
+
!file.nil? && File.file?(File.join(BUILTIN_DIR, file))
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
module StaticEmbeddings
|
|
2
|
+
class Reference
|
|
3
|
+
CJK_RANGES = [
|
|
4
|
+
0x4E00..0x9FFF, 0x3400..0x4DBF, 0x20000..0x2A6DF, 0x2A700..0x2B73F,
|
|
5
|
+
0x2B740..0x2B81F, 0x2B820..0x2CEAF, 0xF900..0xFAFF, 0x2F800..0x2FA1F
|
|
6
|
+
].freeze
|
|
7
|
+
|
|
8
|
+
ASCII_PUNCT = [33..47, 58..64, 91..96, 123..126].freeze
|
|
9
|
+
ASCII_SPACES = [" ", "\t", "\n", "\r"].freeze
|
|
10
|
+
RE_PUNCT = /\A\p{P}\z/
|
|
11
|
+
RE_CONTROL = /\A(?:\p{Cc}|\p{Cf}|\p{Co}|\p{Cs})\z/
|
|
12
|
+
RE_MN = /\A\p{Mn}\z/
|
|
13
|
+
RE_WHITESPACE = /\A(?:\p{Zs}|[\u0085\u2028\u2029])\z/
|
|
14
|
+
|
|
15
|
+
attr_reader :meta
|
|
16
|
+
|
|
17
|
+
def initialize(tokens:, matrix:, meta:)
|
|
18
|
+
@tokens = tokens
|
|
19
|
+
@vocab = tokens.each_with_index.to_h
|
|
20
|
+
@matrix = matrix
|
|
21
|
+
@meta = meta
|
|
22
|
+
@dim = meta.fetch(:dim)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_source_dir(dir, max_tokens: Converter::REFERENCE_MAX_TOKENS)
|
|
26
|
+
tokenizer = JSON.parse(File.binread(File.join(dir, "tokenizer.json")))
|
|
27
|
+
config_path = File.join(dir, "config.json")
|
|
28
|
+
config = File.file?(config_path) ? JSON.parse(File.binread(config_path)) : {}
|
|
29
|
+
tokens = tokens_from(tokenizer)
|
|
30
|
+
tensor = Safetensors.read(File.join(dir, "model.safetensors"))[:tensors].values.first
|
|
31
|
+
rows, dim = tensor[:shape]
|
|
32
|
+
floats = tensor[:bytes].unpack("e*")
|
|
33
|
+
normalizer = tokenizer["normalizer"] || {}
|
|
34
|
+
|
|
35
|
+
new(tokens: tokens,
|
|
36
|
+
matrix: Array.new(rows) { |i| floats[i * dim, dim] },
|
|
37
|
+
meta: meta_from(tokenizer, config, normalizer, dim, max_tokens))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.tokens_from(tokenizer)
|
|
41
|
+
vocab = tokenizer.dig("model", "vocab")
|
|
42
|
+
vocab.each_with_object(Array.new(vocab.length)) { |(token, id), tokens| tokens[id] = token }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.meta_from(tokenizer, config, normalizer, dim, max_tokens)
|
|
46
|
+
lowercase = normalizer.fetch("lowercase", true)
|
|
47
|
+
{
|
|
48
|
+
dim: dim,
|
|
49
|
+
lowercase: lowercase,
|
|
50
|
+
strip_accents: normalizer["strip_accents"].nil? ? lowercase : normalizer["strip_accents"],
|
|
51
|
+
clean_text: normalizer.fetch("clean_text", true),
|
|
52
|
+
handle_chinese_chars: normalizer.fetch("handle_chinese_chars", true),
|
|
53
|
+
max_input_chars_per_word: tokenizer.dig("model", "max_input_chars_per_word") || 100,
|
|
54
|
+
unk_token: tokenizer.dig("model", "unk_token") || "[UNK]",
|
|
55
|
+
normalize: config.key?("normalize") ? config["normalize"] : true,
|
|
56
|
+
max_tokens: max_tokens
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def normalize_text(text)
|
|
61
|
+
chars = text.chars
|
|
62
|
+
chars = clean_chars(chars) if @meta[:clean_text]
|
|
63
|
+
chars = split_chinese(chars) if @meta[:handle_chinese_chars]
|
|
64
|
+
chars = strip_accents(chars) if @meta[:strip_accents]
|
|
65
|
+
chars = lowercase(chars) if @meta[:lowercase]
|
|
66
|
+
chars.join
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def pre_tokenize(normalized)
|
|
70
|
+
normalized.split(" ").flat_map { |word| split_punctuation(word) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def tokenize(text, max_tokens: @meta[:max_tokens])
|
|
74
|
+
ids = []
|
|
75
|
+
pre_tokenize(normalize_text(text)).each { |word| ids.concat(wordpiece(word)) }
|
|
76
|
+
max_tokens && max_tokens.positive? && ids.length > max_tokens ? ids.first(max_tokens) : ids
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def embed(text, max_tokens: @meta[:max_tokens])
|
|
80
|
+
used = tokenize(text, max_tokens: max_tokens).reject { |id| id == unk_id }
|
|
81
|
+
return Array.new(@dim, 0.0) if used.empty?
|
|
82
|
+
|
|
83
|
+
vector = pooled(used)
|
|
84
|
+
@meta[:normalize] ? l2_normalize(vector) : vector
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def clean_chars(chars)
|
|
90
|
+
chars.filter_map do |ch|
|
|
91
|
+
cp = ch.ord
|
|
92
|
+
next nil if cp.zero? || cp == 0xFFFD || control?(ch)
|
|
93
|
+
|
|
94
|
+
whitespace?(ch) ? " " : ch
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def split_chinese(chars)
|
|
99
|
+
chars.flat_map { |ch| cjk?(ch.ord) ? [" ", ch, " "] : [ch] }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def strip_accents(chars)
|
|
103
|
+
chars.flat_map { |ch| ch.unicode_normalize(:nfd).chars }.reject { |ch| RE_MN.match?(ch) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def lowercase(chars)
|
|
107
|
+
chars.flat_map { |ch| ch.downcase.chars }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def split_punctuation(word)
|
|
111
|
+
out = []
|
|
112
|
+
current = +""
|
|
113
|
+
word.each_char do |ch|
|
|
114
|
+
if punctuation?(ch)
|
|
115
|
+
out << current unless current.empty?
|
|
116
|
+
out << ch
|
|
117
|
+
current = +""
|
|
118
|
+
else
|
|
119
|
+
current << ch
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
out << current unless current.empty?
|
|
123
|
+
out
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def wordpiece(word)
|
|
127
|
+
chars = word.chars
|
|
128
|
+
return [unk_id] if chars.length > @meta[:max_input_chars_per_word]
|
|
129
|
+
|
|
130
|
+
ids = []
|
|
131
|
+
start = 0
|
|
132
|
+
while start < chars.length
|
|
133
|
+
found, finish = longest_piece(chars, start)
|
|
134
|
+
return [unk_id] if found.nil?
|
|
135
|
+
|
|
136
|
+
ids << found
|
|
137
|
+
start = finish
|
|
138
|
+
end
|
|
139
|
+
ids
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def longest_piece(chars, start)
|
|
143
|
+
finish = chars.length
|
|
144
|
+
while start < finish
|
|
145
|
+
piece = chars[start...finish].join
|
|
146
|
+
piece = "###{piece}" if start.positive?
|
|
147
|
+
id = @vocab[piece]
|
|
148
|
+
return [id, finish] unless id.nil?
|
|
149
|
+
|
|
150
|
+
finish -= 1
|
|
151
|
+
end
|
|
152
|
+
[nil, nil]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def pooled(ids)
|
|
156
|
+
acc = Array.new(@dim, 0.0)
|
|
157
|
+
ids.each do |id|
|
|
158
|
+
row = @matrix[id]
|
|
159
|
+
@dim.times { |i| acc[i] += row[i] }
|
|
160
|
+
end
|
|
161
|
+
scale = 1.0 / ids.length
|
|
162
|
+
acc.map { |v| v * scale }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def l2_normalize(vec)
|
|
166
|
+
norm = Math.sqrt(vec.sum { |v| v * v })
|
|
167
|
+
norm.positive? ? vec.map { |v| v / norm } : vec
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def unk_id
|
|
171
|
+
@vocab.fetch(@meta[:unk_token])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def cjk?(codepoint)
|
|
175
|
+
CJK_RANGES.any? { |range| range.cover?(codepoint) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def punctuation?(char)
|
|
179
|
+
cp = char.ord
|
|
180
|
+
ASCII_PUNCT.any? { |range| range.cover?(cp) } || RE_PUNCT.match?(char)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def control?(char)
|
|
184
|
+
!ASCII_SPACES.include?(char) && RE_CONTROL.match?(char)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def whitespace?(char)
|
|
188
|
+
ASCII_SPACES.include?(char) || RE_WHITESPACE.match?(char)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module StaticEmbeddings
|
|
4
|
+
module Safetensors
|
|
5
|
+
MAX_HEADER_BYTES = 100 * 1024 * 1024
|
|
6
|
+
SUPPORTED_DTYPES = { "F32" => 4 }.freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def read(path)
|
|
11
|
+
data = File.binread(path)
|
|
12
|
+
header, body_offset = parse_header(data)
|
|
13
|
+
body_size = data.bytesize - body_offset
|
|
14
|
+
|
|
15
|
+
tensors = header.each_with_object({}) do |(name, spec), acc|
|
|
16
|
+
next if name == "__metadata__"
|
|
17
|
+
|
|
18
|
+
acc[name] = tensor_from(data, body_offset, body_size, name, spec)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
{ metadata: header["__metadata__"] || {}, tensors: tensors }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def write(path, name, shape, floats)
|
|
25
|
+
body = floats.pack("e*")
|
|
26
|
+
header = JSON.generate(name => { "dtype" => "F32", "shape" => shape, "data_offsets" => [0, body.bytesize] })
|
|
27
|
+
padded = header << (" " * ((8 - (header.bytesize % 8)) % 8))
|
|
28
|
+
File.binwrite(path, [padded.bytesize].pack("Q<") << padded << body)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse_header(data)
|
|
32
|
+
raise ConversionError, "safetensors file is shorter than its length prefix" if data.bytesize < 8
|
|
33
|
+
|
|
34
|
+
header_len = data.byteslice(0, 8).unpack1("Q<")
|
|
35
|
+
unless header_len.positive? && header_len <= MAX_HEADER_BYTES
|
|
36
|
+
raise ConversionError, "implausible safetensors header length #{header_len}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
body_offset = 8 + header_len
|
|
40
|
+
raise ConversionError, "safetensors header runs past end of file" if body_offset > data.bytesize
|
|
41
|
+
|
|
42
|
+
raw_header = data.byteslice(8, header_len)
|
|
43
|
+
raise ConversionError, "safetensors header is not a JSON object" unless raw_header.lstrip.start_with?("{")
|
|
44
|
+
|
|
45
|
+
[JSON.parse(raw_header), body_offset]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tensor_from(data, body_offset, body_size, name, spec)
|
|
49
|
+
dtype = spec["dtype"]
|
|
50
|
+
shape = spec["shape"]
|
|
51
|
+
begin_off, end_off = checked_offsets(name, spec["data_offsets"], body_size)
|
|
52
|
+
expected = checked_tensor_bytes(name, dtype, shape)
|
|
53
|
+
actual = end_off - begin_off
|
|
54
|
+
|
|
55
|
+
if actual != expected
|
|
56
|
+
raise ConversionError,
|
|
57
|
+
"tensor #{name}: shape #{shape.inspect} implies #{expected} bytes, offsets span #{actual}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
{ dtype: dtype, shape: shape, bytes: data.byteslice(body_offset + begin_off, actual) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def checked_offsets(name, offsets, body_size)
|
|
64
|
+
unless offsets.is_a?(Array) && offsets.length == 2
|
|
65
|
+
raise ConversionError, "tensor #{name}: malformed data_offsets"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
begin_off, end_off = offsets
|
|
69
|
+
unless begin_off.is_a?(Integer) && end_off.is_a?(Integer) && begin_off >= 0 &&
|
|
70
|
+
end_off >= begin_off && end_off <= body_size
|
|
71
|
+
raise ConversionError, "tensor #{name}: data_offsets out of bounds"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
[begin_off, end_off]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def checked_tensor_bytes(name, dtype, shape)
|
|
78
|
+
element_size = SUPPORTED_DTYPES[dtype]
|
|
79
|
+
raise ConversionError, "tensor #{name}: dtype #{dtype} is not supported (F32 only)" unless element_size
|
|
80
|
+
unless shape.is_a?(Array) && shape.all? { |dim| dim.is_a?(Integer) && dim >= 0 }
|
|
81
|
+
raise ConversionError, "tensor #{name}: malformed shape #{shape.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
shape.reduce(1, :*) * element_size
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|