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,127 @@
|
|
|
1
|
+
module StaticEmbeddings
|
|
2
|
+
|
|
3
|
+
module UnicodeTables
|
|
4
|
+
|
|
5
|
+
MAP_SCAN_LIMIT = 0x2FFFF
|
|
6
|
+
CODESPACE_LIMIT = 0x10FFFF
|
|
7
|
+
SURROGATES = (0xD800..0xDFFF)
|
|
8
|
+
|
|
9
|
+
ENTRY_PACK = "V6"
|
|
10
|
+
|
|
11
|
+
RE_MN = /\A\p{Mn}\z/
|
|
12
|
+
RE_PUNCT = /\A\p{P}\z/
|
|
13
|
+
RE_CONTROL = /\A(?:\p{Cc}|\p{Cf}|\p{Co})\z/
|
|
14
|
+
RE_WHITESPACE = /\A(?:\p{Zs}|[\u0085\u2028\u2029])\z/
|
|
15
|
+
|
|
16
|
+
class RangeAccumulator
|
|
17
|
+
def initialize
|
|
18
|
+
@ranges = []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add(codepoint)
|
|
22
|
+
last = @ranges.last
|
|
23
|
+
if last && last[1] + 1 == codepoint
|
|
24
|
+
last[1] = codepoint
|
|
25
|
+
else
|
|
26
|
+
@ranges << [codepoint, codepoint]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_a
|
|
31
|
+
@ranges
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
def build
|
|
38
|
+
lower = []
|
|
39
|
+
nfd = []
|
|
40
|
+
mn = RangeAccumulator.new
|
|
41
|
+
punct = RangeAccumulator.new
|
|
42
|
+
control = RangeAccumulator.new
|
|
43
|
+
whitespace = RangeAccumulator.new
|
|
44
|
+
|
|
45
|
+
(0..CODESPACE_LIMIT).each do |cp|
|
|
46
|
+
if SURROGATES.cover?(cp)
|
|
47
|
+
control.add(cp)
|
|
48
|
+
next
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
ch = begin
|
|
52
|
+
cp.chr(Encoding::UTF_8)
|
|
53
|
+
rescue RangeError
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
mn.add(cp) if RE_MN.match?(ch)
|
|
58
|
+
punct.add(cp) if RE_PUNCT.match?(ch)
|
|
59
|
+
control.add(cp) if RE_CONTROL.match?(ch)
|
|
60
|
+
whitespace.add(cp) if RE_WHITESPACE.match?(ch)
|
|
61
|
+
|
|
62
|
+
next if cp > MAP_SCAN_LIMIT
|
|
63
|
+
|
|
64
|
+
down = ch.downcase
|
|
65
|
+
lower << [cp, down.codepoints] if down != ch
|
|
66
|
+
|
|
67
|
+
decomposed = ch.unicode_normalize(:nfd)
|
|
68
|
+
nfd << [cp, decomposed.codepoints] if decomposed != ch
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
{ "lowercase" => lower, "NFD" => nfd }.each do |name, table|
|
|
72
|
+
long = table.find { |(_, out)| out.length > 4 }
|
|
73
|
+
next unless long
|
|
74
|
+
|
|
75
|
+
raise "#{name} mapping for U+#{format('%04X', long[0])} needs #{long[1].length} slots"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
lower: lower,
|
|
80
|
+
nfd: nfd,
|
|
81
|
+
mn: mn.to_a,
|
|
82
|
+
punct: punct.to_a,
|
|
83
|
+
control: control.to_a,
|
|
84
|
+
whitespace: whitespace.to_a
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def pack(tables)
|
|
89
|
+
blob = +""
|
|
90
|
+
blob.force_encoding(Encoding::BINARY)
|
|
91
|
+
blob << [
|
|
92
|
+
tables[:lower].length,
|
|
93
|
+
tables[:nfd].length,
|
|
94
|
+
tables[:mn].length,
|
|
95
|
+
tables[:punct].length,
|
|
96
|
+
tables[:control].length,
|
|
97
|
+
tables[:whitespace].length,
|
|
98
|
+
0, 0
|
|
99
|
+
].pack("V8")
|
|
100
|
+
|
|
101
|
+
%i[lower nfd].each do |key|
|
|
102
|
+
tables[key].sort_by(&:first).each do |(cp, out)|
|
|
103
|
+
padded = out + [0] * (4 - out.length)
|
|
104
|
+
blob << ([cp, out.length] + padded).pack(ENTRY_PACK)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
%i[mn punct control whitespace].each do |key|
|
|
109
|
+
tables[key].sort_by(&:first).each { |(lo, hi)| blob << [lo, hi].pack("V2") }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
blob
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def packed
|
|
116
|
+
@packed ||= pack(build)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def reset!
|
|
120
|
+
@packed = nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def source_stamp
|
|
124
|
+
"ruby-#{RUBY_VERSION}p#{RUBY_PATCHLEVEL}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "static_embeddings/version"
|
|
3
|
+
|
|
4
|
+
begin
|
|
5
|
+
require "static_embeddings/static_embeddings"
|
|
6
|
+
rescue LoadError
|
|
7
|
+
ext_dir = File.expand_path("static_embeddings", __dir__)
|
|
8
|
+
so_path = %w[.so .bundle].lazy.map { |ext| File.join(ext_dir, "static_embeddings#{ext}") }
|
|
9
|
+
.find { |path| File.file?(path) }
|
|
10
|
+
unless so_path
|
|
11
|
+
raise LoadError, "Could not find the compiled StaticEmbeddings extension. " \
|
|
12
|
+
"Run: bundle exec rake compile"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
require so_path
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
require "static_embeddings/errors"
|
|
19
|
+
require "static_embeddings/paths"
|
|
20
|
+
require "static_embeddings/format"
|
|
21
|
+
require "static_embeddings/unicode_tables"
|
|
22
|
+
require "static_embeddings/safetensors"
|
|
23
|
+
require "static_embeddings/converter"
|
|
24
|
+
require "static_embeddings/reference"
|
|
25
|
+
require "static_embeddings/model"
|
|
26
|
+
|
|
27
|
+
module StaticEmbeddings
|
|
28
|
+
class << self
|
|
29
|
+
def load(path, verify: false)
|
|
30
|
+
expanded = File.expand_path(path.to_s)
|
|
31
|
+
raise ModelNotFound, "no model at #{expanded}" unless File.file?(expanded)
|
|
32
|
+
|
|
33
|
+
if verify
|
|
34
|
+
result = Format.verify(expanded)
|
|
35
|
+
raise InvalidModelError, "checksum mismatch for #{expanded}" unless result[:ok]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Model.new(expanded)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def load_builtin(name = :demo, verify: false)
|
|
42
|
+
load(Paths.builtin_path(name), verify: verify)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def builtin_available?(name = :demo)
|
|
46
|
+
Paths.builtin_available?(name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def cache_dir
|
|
50
|
+
Paths.cache_dir
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def model_path(model_id)
|
|
54
|
+
Paths.model_path(model_id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def load_model(model_id, verify: false)
|
|
58
|
+
path = model_path(model_id)
|
|
59
|
+
unless File.file?(path)
|
|
60
|
+
raise ModelNotFound,
|
|
61
|
+
"model #{model_id.inspect} is not installed. " \
|
|
62
|
+
"Convert it first: static_embeddings convert <hf-dir> --id #{model_id}"
|
|
63
|
+
end
|
|
64
|
+
load(path, verify: verify)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def convert(source_dir, output_path:, model_id: nil, max_tokens: Converter::REFERENCE_MAX_TOKENS)
|
|
68
|
+
converter = Converter.new(source_dir)
|
|
69
|
+
converter.convert(output_path: output_path, model_id: model_id, max_tokens: max_tokens)
|
|
70
|
+
converter.report
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def verify(path)
|
|
74
|
+
Format.verify(File.expand_path(path.to_s))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def unpack(blob, dim, format: :f32)
|
|
78
|
+
raise ArgumentError, "dim must be positive" unless dim.to_i.positive?
|
|
79
|
+
|
|
80
|
+
floats =
|
|
81
|
+
case normalize_format(format)
|
|
82
|
+
when :f32
|
|
83
|
+
raise ArgumentError, "f32 blob byte size must be a multiple of 4" unless (blob.bytesize % 4).zero?
|
|
84
|
+
|
|
85
|
+
blob.unpack("e*")
|
|
86
|
+
when :f16
|
|
87
|
+
raise ArgumentError, "f16 blob byte size must be a multiple of 2" unless (blob.bytesize % 2).zero?
|
|
88
|
+
|
|
89
|
+
decode_f16(blob)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
raise ArgumentError, "blob is not a multiple of dim" unless (floats.length % dim).zero?
|
|
93
|
+
|
|
94
|
+
floats.each_slice(dim).to_a
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def pack(rows, format: :f32)
|
|
98
|
+
flat = rows.first.is_a?(Array) ? rows.flatten(1) : rows
|
|
99
|
+
|
|
100
|
+
case normalize_format(format)
|
|
101
|
+
when :f32 then flat.map(&:to_f).pack("e*")
|
|
102
|
+
when :f16 then encode_f16(flat.map(&:to_f))
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_format(format)
|
|
107
|
+
case format&.to_sym
|
|
108
|
+
when nil, :f32, :float32
|
|
109
|
+
:f32
|
|
110
|
+
when :f16, :float16
|
|
111
|
+
:f16
|
|
112
|
+
else
|
|
113
|
+
raise ArgumentError, "unsupported embedding format #{format.inspect} (expected :f32 or :f16)"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require_relative "lib/static_embeddings/version"
|
|
2
|
+
|
|
3
|
+
Gem::Specification.new do |spec|
|
|
4
|
+
spec.name = "static_embeddings"
|
|
5
|
+
spec.version = StaticEmbeddings::VERSION
|
|
6
|
+
spec.authors = ["Roman Haydarov"]
|
|
7
|
+
spec.email = ["romnhajdarov@gmail.com"]
|
|
8
|
+
|
|
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-style static embedding " \
|
|
11
|
+
"models. Models are converted offline into a flat mmap-able .semb " \
|
|
12
|
+
"file; at runtime the gem tokenizes (BERT WordPiece), looks up rows " \
|
|
13
|
+
"and mean-pools them. Releases the GVL on large native work, rejects internal " \
|
|
14
|
+
"thread fan-out, and links nothing but libc."
|
|
15
|
+
spec.homepage = "https://github.com/roman-haidarov/static_embeddings"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
spec.required_ruby_version = ">= 3.1.0"
|
|
18
|
+
|
|
19
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
20
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
22
|
+
|
|
23
|
+
spec.files = Dir[
|
|
24
|
+
"lib/**/*.rb",
|
|
25
|
+
"lib/models/*.semb",
|
|
26
|
+
"exe/*",
|
|
27
|
+
"ext/**/*.{c,h,rb}",
|
|
28
|
+
"docs/**/*.md",
|
|
29
|
+
"tools/*.rb",
|
|
30
|
+
"static_embeddings.gemspec",
|
|
31
|
+
"README.md",
|
|
32
|
+
"CHANGELOG.md",
|
|
33
|
+
"LICENSE.txt"
|
|
34
|
+
].reject { |path| File.basename(path) == ".DS_Store" }
|
|
35
|
+
|
|
36
|
+
spec.bindir = "exe"
|
|
37
|
+
spec.executables = ["static_embeddings"]
|
|
38
|
+
spec.require_paths = ["lib"]
|
|
39
|
+
spec.extensions = ["ext/static_embeddings/extconf.rb"]
|
|
40
|
+
|
|
41
|
+
spec.add_development_dependency "bundler", "~> 2.0"
|
|
42
|
+
spec.add_development_dependency "minitest", "~> 5.0"
|
|
43
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
44
|
+
spec.add_development_dependency "rake-compiler", "~> 1.2"
|
|
45
|
+
end
|
data/tools/benchmark.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
4
|
+
require "static_embeddings"
|
|
5
|
+
require "benchmark"
|
|
6
|
+
|
|
7
|
+
path = ARGV[0] || File.expand_path("../lib/models/demo.semb", __dir__)
|
|
8
|
+
model = StaticEmbeddings.load(path)
|
|
9
|
+
model.warmup!
|
|
10
|
+
|
|
11
|
+
CORPUS = (1..5_000).map do |i|
|
|
12
|
+
"локальный поиск по тексту номер #{i} hello world static embedding vector search"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
tokens = CORPUS.sum { |t| model.tokenize(t).length }
|
|
16
|
+
bytes = CORPUS.sum(&:bytesize)
|
|
17
|
+
|
|
18
|
+
def timed(label, iterations)
|
|
19
|
+
elapsed = Benchmark.realtime { iterations.times { yield } }
|
|
20
|
+
[label, elapsed]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
single = Benchmark.realtime { 2_000.times { model.embed(CORPUS.first) } }
|
|
24
|
+
batch1 = Benchmark.realtime { model.embed_batch(CORPUS) }
|
|
25
|
+
tok_only = Benchmark.realtime { CORPUS.each { |t| model.tokenize(t) } }
|
|
26
|
+
|
|
27
|
+
per_token_ns = (batch1 / tokens) * 1e9
|
|
28
|
+
per_token_per_100dim = per_token_ns / (model.dim / 100.0)
|
|
29
|
+
|
|
30
|
+
puts "model #{model.model_id} dim=#{model.dim} vocab=#{model.vocab_size}"
|
|
31
|
+
puts "corpus #{CORPUS.length} texts, #{tokens} tokens, #{bytes} bytes"
|
|
32
|
+
puts
|
|
33
|
+
puts "single embed #{((single / 2_000) * 1e6).round(1)} us/call"
|
|
34
|
+
puts "batch #{(CORPUS.length / batch1).round} texts/s, #{(tokens / batch1).round} tokens/s"
|
|
35
|
+
puts "tokenize only #{((tok_only / bytes) * 1e9).round(1)} ns/input byte"
|
|
36
|
+
puts
|
|
37
|
+
puts "normalised #{per_token_ns.round(1)} ns/token"
|
|
38
|
+
puts " #{per_token_per_100dim.round(1)} ns/token/100dim"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
4
|
+
require "static_embeddings"
|
|
5
|
+
require_relative "make_fixture_model"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
|
|
9
|
+
target = File.expand_path("../lib/models/demo.semb", __dir__)
|
|
10
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
11
|
+
|
|
12
|
+
Dir.mktmpdir do |dir|
|
|
13
|
+
FixtureModel.write(dir)
|
|
14
|
+
report = StaticEmbeddings.convert(dir, output_path: target, model_id: "demo/tiny-wordpiece")
|
|
15
|
+
puts "wrote #{target} (#{report[:bytes]} bytes, vocab #{report[:vocab_size]}, dim #{report[:dim]})"
|
|
16
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
ids: true
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
parser = OptionParser.new do |opts|
|
|
12
|
+
opts.on("--model PATH") { |value| options[:model] = value }
|
|
13
|
+
opts.on("--oracle PATH") { |value| options[:oracle] = value }
|
|
14
|
+
opts.on("--min-cosine N", Float) { |value| options[:min_cosine] = value }
|
|
15
|
+
opts.on("--max-abs N", Float) { |value| options[:max_abs] = value }
|
|
16
|
+
opts.on("--[no-]ids") { |value| options[:ids] = value }
|
|
17
|
+
end
|
|
18
|
+
parser.parse!(ARGV)
|
|
19
|
+
unless options[:model] && options[:oracle]
|
|
20
|
+
abort "usage: ruby -Ilib tools/check_model2vec_parity.rb --model MODEL.semb --oracle oracle.json"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
model = StaticEmbeddings.load(options[:model], verify: true)
|
|
24
|
+
payload = JSON.parse(File.read(options[:oracle], encoding: "UTF-8"))
|
|
25
|
+
rows = payload.is_a?(Hash) ? payload.fetch("rows") : payload
|
|
26
|
+
oracle_max_length = payload.is_a?(Hash) ? payload["max_length"] : nil
|
|
27
|
+
|
|
28
|
+
if oracle_max_length && oracle_max_length != model.max_tokens
|
|
29
|
+
warn "WARNING oracle max_length=#{oracle_max_length} but model.max_tokens=#{model.max_tokens}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def dot(a, b)
|
|
33
|
+
a.zip(b).sum { |x, y| x * y }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def norm(a)
|
|
37
|
+
Math.sqrt(a.sum { |x| x * x })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
min_cosine = 1.0
|
|
41
|
+
max_abs_all = 0.0
|
|
42
|
+
vector_failures = []
|
|
43
|
+
id_failures = []
|
|
44
|
+
id_rows_checked = 0
|
|
45
|
+
|
|
46
|
+
rows.each_with_index do |row, i|
|
|
47
|
+
text = row.fetch("text")
|
|
48
|
+
ref = row.fetch("vector")
|
|
49
|
+
ref_ids = row["token_ids"]
|
|
50
|
+
|
|
51
|
+
problems = []
|
|
52
|
+
|
|
53
|
+
if options[:ids] && ref_ids
|
|
54
|
+
id_rows_checked += 1
|
|
55
|
+
got_ids = model.tokenize(text)
|
|
56
|
+
if got_ids != ref_ids
|
|
57
|
+
id_failures << i
|
|
58
|
+
first = got_ids.zip(ref_ids).index { |a, b| a != b } || [got_ids.length, ref_ids.length].min
|
|
59
|
+
problems << "ids differ at #{first} (got #{got_ids.length}, ref #{ref_ids.length})"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
got = model.embed(text).unpack("e*")
|
|
64
|
+
max_abs = ref.zip(got).map { |a, b| (a - b).abs }.max || 0.0
|
|
65
|
+
ref_zero = ref.all?(&:zero?)
|
|
66
|
+
got_zero = got.all?(&:zero?)
|
|
67
|
+
cosine =
|
|
68
|
+
if ref_zero && got_zero
|
|
69
|
+
1.0
|
|
70
|
+
elsif ref_zero || got_zero
|
|
71
|
+
0.0
|
|
72
|
+
else
|
|
73
|
+
dot(ref, got) / (norm(ref) * norm(got))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
min_cosine = [min_cosine, cosine].min
|
|
77
|
+
max_abs_all = [max_abs_all, max_abs].max
|
|
78
|
+
|
|
79
|
+
unless cosine >= options[:min_cosine] && max_abs <= options[:max_abs]
|
|
80
|
+
vector_failures << i
|
|
81
|
+
problems << "vector out of tolerance"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
status = problems.empty? ? "ok" : "FAIL"
|
|
85
|
+
label = text.bytesize > 64 ? "#{text[0, 32].inspect}...(#{text.bytesize}B)" : text.inspect
|
|
86
|
+
line = format("%s idx=%02d cos=%.10f max_abs=%.8g bytes=%d text=%s",
|
|
87
|
+
status, i, cosine, max_abs, text.bytesize, label)
|
|
88
|
+
line += " [#{problems.join('; ')}]" unless problems.empty?
|
|
89
|
+
puts line
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
puts "rows=#{rows.length}"
|
|
93
|
+
puts "id_rows_checked=#{id_rows_checked}"
|
|
94
|
+
puts "min_cosine=#{min_cosine}"
|
|
95
|
+
puts "max_abs_all=#{max_abs_all}"
|
|
96
|
+
puts "token_id_failures=#{id_failures.inspect}"
|
|
97
|
+
puts "vector_failures=#{vector_failures.inspect}"
|
|
98
|
+
|
|
99
|
+
if id_rows_checked.zero? && options[:ids]
|
|
100
|
+
warn "WARNING oracle has no token_ids; regenerate it with the current tools/model2vec_oracle.py"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
unless id_failures.empty? && vector_failures.empty?
|
|
104
|
+
abort "parity failed: token ids #{id_failures.inspect}, vectors #{vector_failures.inspect}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
puts "parity OK"
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
|
7
|
+
require "static_embeddings/version"
|
|
8
|
+
require "static_embeddings/safetensors"
|
|
9
|
+
|
|
10
|
+
module FixtureModel
|
|
11
|
+
DIM = 8
|
|
12
|
+
SPECIALS = ["[PAD]", "[UNK]", "[CLS]", "[SEP]", "[MASK]"].freeze
|
|
13
|
+
|
|
14
|
+
WORDS = %w[
|
|
15
|
+
hello world ruby gem static embedding vector search text token
|
|
16
|
+
the a of and for local fast model cafe naive
|
|
17
|
+
привет мир поиск текст вектор модель локальный быстрый
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
PUNCTUATION = %w[. , ! ? - : ; ' " ( ) /].freeze
|
|
21
|
+
SUBWORDS = %w[##ing ##ed ##s ##er ##ик ##ов ##ый].freeze
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def vocab_tokens
|
|
26
|
+
[SPECIALS, ascii_letters, digits, cyrillic_letters, ascii_subwords,
|
|
27
|
+
cyrillic_subwords, PUNCTUATION, WORDS, SUBWORDS].each_with_object([]) do |group, tokens|
|
|
28
|
+
group.each { |token| tokens << token }
|
|
29
|
+
end.uniq
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def matrix_floats(vocab_size)
|
|
33
|
+
state = 0x12345678
|
|
34
|
+
Array.new(vocab_size * DIM) do
|
|
35
|
+
state = (state * 1_103_515_245 + 12_345) & 0x7FFFFFFF
|
|
36
|
+
((state % 20_000) - 10_000) / 10_000.0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def write(dir)
|
|
41
|
+
FileUtils.mkdir_p(dir)
|
|
42
|
+
tokens = vocab_tokens
|
|
43
|
+
write_tokenizer(dir, tokens)
|
|
44
|
+
write_config(dir)
|
|
45
|
+
write_tokenizer_config(dir)
|
|
46
|
+
write_tensor(dir, tokens.length)
|
|
47
|
+
{ dir: dir, vocab_size: tokens.length, dim: DIM }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ascii_letters
|
|
51
|
+
("a".."z").to_a
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def digits
|
|
55
|
+
("0".."9").to_a
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cyrillic_letters
|
|
59
|
+
("а".."я").to_a
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def ascii_subwords
|
|
63
|
+
ascii_letters.map { |char| "###{char}" }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def cyrillic_subwords
|
|
67
|
+
cyrillic_letters.map { |char| "###{char}" }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def write_tokenizer(dir, tokens)
|
|
71
|
+
File.write(File.join(dir, "tokenizer.json"), JSON.pretty_generate(tokenizer_payload(tokens)))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def tokenizer_payload(tokens)
|
|
75
|
+
{
|
|
76
|
+
"version" => "1.0",
|
|
77
|
+
"truncation" => nil,
|
|
78
|
+
"padding" => nil,
|
|
79
|
+
"added_tokens" => added_tokens,
|
|
80
|
+
"normalizer" => normalizer,
|
|
81
|
+
"pre_tokenizer" => { "type" => "BertPreTokenizer" },
|
|
82
|
+
"post_processor" => nil,
|
|
83
|
+
"decoder" => nil,
|
|
84
|
+
"model" => wordpiece_model(tokens)
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def added_tokens
|
|
89
|
+
SPECIALS.each_with_index.map do |content, index|
|
|
90
|
+
{
|
|
91
|
+
"id" => index,
|
|
92
|
+
"content" => content,
|
|
93
|
+
"single_word" => false,
|
|
94
|
+
"lstrip" => false,
|
|
95
|
+
"rstrip" => false,
|
|
96
|
+
"normalized" => false,
|
|
97
|
+
"special" => true
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def normalizer
|
|
103
|
+
{
|
|
104
|
+
"type" => "BertNormalizer",
|
|
105
|
+
"clean_text" => true,
|
|
106
|
+
"handle_chinese_chars" => true,
|
|
107
|
+
"strip_accents" => nil,
|
|
108
|
+
"lowercase" => true
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def wordpiece_model(tokens)
|
|
113
|
+
{
|
|
114
|
+
"type" => "WordPiece",
|
|
115
|
+
"unk_token" => "[UNK]",
|
|
116
|
+
"continuing_subword_prefix" => "##",
|
|
117
|
+
"max_input_chars_per_word" => 100,
|
|
118
|
+
"vocab" => tokens.each_with_index.to_h
|
|
119
|
+
}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def write_config(dir)
|
|
123
|
+
File.write(File.join(dir, "config.json"), JSON.pretty_generate(config_payload))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def config_payload
|
|
127
|
+
{
|
|
128
|
+
"model_type" => "model2vec",
|
|
129
|
+
"hidden_dim" => DIM,
|
|
130
|
+
"seq_length" => 1_000_000,
|
|
131
|
+
"normalize" => true,
|
|
132
|
+
"tokenizer_name" => "fixture/tiny-wordpiece"
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def write_tokenizer_config(dir)
|
|
137
|
+
File.write(File.join(dir, "tokenizer_config.json"), JSON.pretty_generate(tokenizer_config_payload))
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def tokenizer_config_payload
|
|
141
|
+
{
|
|
142
|
+
"tokenizer_class" => "BertTokenizer",
|
|
143
|
+
"do_lower_case" => true,
|
|
144
|
+
"strip_accents" => nil,
|
|
145
|
+
"tokenize_chinese_chars" => true,
|
|
146
|
+
"model_max_length" => 1_000_000,
|
|
147
|
+
"unk_token" => "[UNK]"
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def write_tensor(dir, vocab_size)
|
|
152
|
+
StaticEmbeddings::Safetensors.write(
|
|
153
|
+
File.join(dir, "model.safetensors"),
|
|
154
|
+
"embeddings",
|
|
155
|
+
[vocab_size, DIM],
|
|
156
|
+
matrix_floats(vocab_size)
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
if $PROGRAM_NAME == __FILE__
|
|
162
|
+
target = ARGV[0] || File.expand_path("../test/fixtures/tiny-wordpiece", __dir__)
|
|
163
|
+
info = FixtureModel.write(target)
|
|
164
|
+
puts "wrote #{info[:vocab_size]} tokens, dim #{info[:dim]} -> #{info[:dir]}"
|
|
165
|
+
end
|