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.
@@ -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,3 @@
1
+ module StaticEmbeddings
2
+ VERSION = "0.1.1"
3
+ 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
@@ -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