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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -0
  3. data/README.md +67 -25
  4. data/Rakefile +1 -1
  5. data/docs/ARCHITECTURE.md +54 -34
  6. data/docs/LIMITATIONS.md +12 -6
  7. data/docs/MODEL_AUDIT.md +195 -52
  8. data/ext/static_embeddings/se_embed.c +2 -1
  9. data/ext/static_embeddings/se_format.c +41 -4
  10. data/ext/static_embeddings/se_internal.h +17 -5
  11. data/ext/static_embeddings/se_tokenizer.c +155 -18
  12. data/ext/static_embeddings/se_unicode.c +1 -1
  13. data/ext/static_embeddings/static_embeddings.c +32 -2
  14. data/lib/models/demo.semb +0 -0
  15. data/lib/static_embeddings/bert_wordpiece.rb +191 -0
  16. data/lib/static_embeddings/canonical.rb +50 -0
  17. data/lib/static_embeddings/cli.rb +88 -62
  18. data/lib/static_embeddings/codec.rb +45 -0
  19. data/lib/static_embeddings/conversion.rb +58 -0
  20. data/lib/static_embeddings/errors.rb +2 -2
  21. data/lib/static_embeddings/format/constants.rb +109 -0
  22. data/lib/static_embeddings/format/hash_table.rb +69 -0
  23. data/lib/static_embeddings/format/trie.rb +78 -0
  24. data/lib/static_embeddings/format/verifier.rb +41 -0
  25. data/lib/static_embeddings/format/writer.rb +131 -0
  26. data/lib/static_embeddings/format.rb +3 -300
  27. data/lib/static_embeddings/importers/model2vec.rb +52 -0
  28. data/lib/static_embeddings/importers/sentence_transformers_static.rb +103 -0
  29. data/lib/static_embeddings/importers/support.rb +111 -0
  30. data/lib/static_embeddings/importers.rb +50 -0
  31. data/lib/static_embeddings/model.rb +35 -20
  32. data/lib/static_embeddings/paths.rb +17 -4
  33. data/lib/static_embeddings/provenance.rb +58 -0
  34. data/lib/static_embeddings/reference.rb +90 -33
  35. data/lib/static_embeddings/row_prefix_payload.rb +59 -0
  36. data/lib/static_embeddings/safetensors.rb +178 -34
  37. data/lib/static_embeddings/version.rb +1 -1
  38. data/lib/static_embeddings.rb +29 -57
  39. data/static_embeddings.gemspec +2 -2
  40. data/tools/check_model2vec_parity.rb +89 -54
  41. data/tools/check_st_parity.rb +125 -0
  42. data/tools/eval_retrieval.rb +58 -0
  43. metadata +24 -6
  44. data/lib/static_embeddings/converter.rb +0 -284
@@ -0,0 +1,50 @@
1
+ require "static_embeddings/errors"
2
+ require "static_embeddings/bert_wordpiece"
3
+ require "static_embeddings/canonical"
4
+ require "static_embeddings/importers/support"
5
+ require "static_embeddings/importers/model2vec"
6
+ require "static_embeddings/importers/sentence_transformers_static"
7
+
8
+ module StaticEmbeddings
9
+ module Importers
10
+ module_function
11
+
12
+ def import(source_dir, **options)
13
+ root = source_root(source_dir)
14
+ case detect(root)
15
+ when :model2vec then Model2Vec.call(root, **options)
16
+ when :sentence_transformers_static then SentenceTransformersStatic.call(root, **options)
17
+ end
18
+ end
19
+
20
+ def detect(root)
21
+ config = Support.load_object(File.join(root, "config.json"), optional: true) || {}
22
+ tokenizer = File.join(root, "tokenizer.json")
23
+ weights = File.join(root, "model.safetensors")
24
+ modules = File.join(root, "modules.json")
25
+
26
+ return :model2vec if model2vec_config?(config) && File.file?(tokenizer) && File.file?(weights)
27
+ return :sentence_transformers_static if File.file?(modules)
28
+
29
+ if File.file?(tokenizer) && File.file?(weights)
30
+ raise InvalidSourceError,
31
+ "cannot prove source family in #{root}: config.json is not Model2Vec and modules.json is absent"
32
+ end
33
+
34
+ raise InvalidSourceError,
35
+ "cannot detect a static embedding source in #{root}: need a Model2Vec config with " \
36
+ "tokenizer.json + model.safetensors, or modules.json for Sentence Transformers StaticEmbedding"
37
+ end
38
+
39
+ def source_root(source_dir)
40
+ root = File.expand_path(source_dir.to_s)
41
+ raise InvalidSourceError, "source directory #{root} does not exist" unless File.directory?(root)
42
+
43
+ root
44
+ end
45
+
46
+ def model2vec_config?(config)
47
+ config["model_type"].to_s == "model2vec" || Array(config["architectures"]).include?("StaticModel")
48
+ end
49
+ end
50
+ end
@@ -4,39 +4,41 @@ module StaticEmbeddings
4
4
  class Model
5
5
  attr_reader :path
6
6
 
7
- def provenance
8
- raw = provenance_json
9
- return {} if raw.nil?
7
+ if method_defined?(:max_tokens) && !method_defined?(:max_tokens_limit)
8
+ alias_method :max_tokens_limit, :max_tokens
10
9
 
11
- JSON.parse(raw)
12
- rescue JSON::ParserError, EncodingError => e
13
- raise InvalidModelError, "invalid provenance JSON: #{e.message}"
10
+ def max_tokens
11
+ limit = max_tokens_limit
12
+ limit.zero? ? false : limit
13
+ end
14
+ end
15
+
16
+ def provenance
17
+ @provenance ||= parse_provenance.freeze
14
18
  end
15
19
 
16
20
  def model_id
17
21
  provenance["source_model_id"]
18
22
  end
19
23
 
20
- def embed_array(text, **opts)
21
- format = opts.key?(:format) ? opts[:format] : :f32
22
- StaticEmbeddings.unpack(embed(text, **opts), dim, format: format).first
24
+ def embed_array(text, **options)
25
+ format = options.fetch(:format, :f32)
26
+ StaticEmbeddings.unpack(embed(text, **options), dim, format: format).first
23
27
  end
24
28
 
25
- def embed_batch_arrays(texts, **opts)
26
- format = opts.key?(:format) ? opts[:format] : :f32
27
- StaticEmbeddings.unpack(embed_batch(texts, **opts), dim, format: format)
29
+ def embed_batch_arrays(texts, **options)
30
+ format = options.fetch(:format, :f32)
31
+ StaticEmbeddings.unpack(embed_batch(texts, **options), dim, format: format)
28
32
  end
29
33
 
30
- def cosine_top_k(query_blob, matrix_blob, k, **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
+ def cosine_top_k(query_blob, matrix_blob, k, **options)
35
+ reject_runtime_dim!(options)
36
+ StaticEmbeddings.cosine_top_k(query_blob, matrix_blob, k, **options.merge(dim: dim))
34
37
  end
35
38
 
36
- def dot_top_k(query_blob, matrix_blob, k, **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))
39
+ def dot_top_k(query_blob, matrix_blob, k, **options)
40
+ reject_runtime_dim!(options)
41
+ StaticEmbeddings.dot_top_k(query_blob, matrix_blob, k, **options.merge(dim: dim))
40
42
  end
41
43
 
42
44
  def to_s
@@ -44,5 +46,18 @@ module StaticEmbeddings
44
46
  end
45
47
 
46
48
  alias inspect to_s
49
+
50
+ private
51
+
52
+ def parse_provenance
53
+ raw = provenance_json
54
+ raw.nil? ? {} : JSON.parse(raw)
55
+ rescue JSON::ParserError, EncodingError => e
56
+ raise InvalidModelError, "invalid provenance JSON: #{e.message}"
57
+ end
58
+
59
+ def reject_runtime_dim!(options)
60
+ raise InvalidOptionError, "dim: is set by the model" if options.key?(:dim)
61
+ end
47
62
  end
48
63
  end
@@ -11,13 +11,26 @@ module StaticEmbeddings
11
11
  end
12
12
 
13
13
  def model_path(model_id, env = ENV)
14
- File.join(cache_dir(env), "models", "#{model_id}.semb")
14
+ id = model_id.to_s
15
+ raise InvalidOptionError, "model_id must not be empty" if id.empty?
16
+ raise InvalidOptionError, "model_id contains a NUL byte" if id.include?("\0")
17
+
18
+ normalized = id.tr("\\", "/")
19
+ parts = normalized.split("/", -1)
20
+ invalid = normalized.start_with?("/") ||
21
+ parts.any? { |part| part.empty? || part == "." || part == ".." || part.include?(":") }
22
+ raise InvalidOptionError, "model_id must be a relative slash-separated identifier" if invalid
23
+
24
+ base = File.expand_path(File.join(cache_dir(env), "models"))
25
+ path = File.expand_path(File.join(base, "#{normalized}.semb"))
26
+ prefix = base.end_with?(File::SEPARATOR) ? base : "#{base}#{File::SEPARATOR}"
27
+ raise InvalidOptionError, "model_id escapes the model cache" unless path.start_with?(prefix)
28
+
29
+ path
15
30
  end
16
31
 
17
32
  def builtin_path(name)
18
- file = BUILTIN_MODELS.fetch(name) do
19
- raise ModelNotFound, "unknown builtin model #{name.inspect}"
20
- end
33
+ file = BUILTIN_MODELS.fetch(name) { raise ModelNotFound, "unknown builtin model #{name.inspect}" }
21
34
  File.join(BUILTIN_DIR, file)
22
35
  end
23
36
 
@@ -0,0 +1,58 @@
1
+ require "json"
2
+ require "static_embeddings/format/constants"
3
+ require "static_embeddings/bert_wordpiece"
4
+
5
+ module StaticEmbeddings
6
+ module Provenance
7
+ MODEL2VEC_VERSION = "0.9.0"
8
+ TOKENIZERS_VERSION = "0.23.1"
9
+ UNICODE_CATEGORIES_VERSION = "0.1.1"
10
+ NOTES = "Vectors are only reference-compatible if docs/MODEL_AUDIT.md records a passing oracle run for this source revision."
11
+
12
+ module_function
13
+
14
+ def json(model, unicode_source:)
15
+ JSON.generate(payload(model, unicode_source: unicode_source).sort.to_h)
16
+ end
17
+
18
+ def payload(model, unicode_source:)
19
+ dimensions = model.dimensions
20
+ runtime = model.runtime
21
+ source = model.source
22
+ tokenizer = model.tokenizer
23
+
24
+ data = {
25
+ "add_special_tokens" => runtime.add_special_tokens,
26
+ "config_seq_length" => source.config_seq_length,
27
+ "converter_version" => StaticEmbeddings::VERSION,
28
+ "dim" => dimensions.output,
29
+ "format_version" => Format::VERSION,
30
+ "native_dim" => dimensions.native,
31
+ "normalize" => runtime.normalization == Format::NORMALIZATION_L2,
32
+ "notes" => NOTES,
33
+ "oracle" => source.oracle,
34
+ "output_dim" => dimensions.output,
35
+ "reference_impl" => source.oracle,
36
+ "reference_max_tokens" => runtime.max_tokens,
37
+ "reference_tokenizers_version" => TOKENIZERS_VERSION,
38
+ "source_family" => source.family,
39
+ "source_files_sha256" => source.files_sha256,
40
+ "source_model_id" => source.model,
41
+ "source_revision" => source.revision,
42
+ "tokenizer_class" => source.tokenizer_class,
43
+ "tokenizer_profile" => tokenizer.fetch(:tokenizer_profile, BertWordPiece::TOKENIZER_PROFILE),
44
+ "unicode_source" => unicode_source,
45
+ "unk_policy" => runtime.unk_policy == Format::UNK_DROP ? "drop" : "include",
46
+ "vocab_size" => model.tokens.length
47
+ }
48
+ data["trained_mrl_dims"] = dimensions.trained if dimensions.trained
49
+ add_model2vec_versions(data) if source.family == "model2vec"
50
+ data
51
+ end
52
+
53
+ def add_model2vec_versions(data)
54
+ data["reference_model2vec_version"] = MODEL2VEC_VERSION
55
+ data["reference_unicode_categories_version"] = UNICODE_CATEGORIES_VERSION
56
+ end
57
+ end
58
+ end
@@ -1,14 +1,17 @@
1
+ require "static_embeddings/importers"
2
+ require "static_embeddings/format/constants"
3
+
1
4
  module StaticEmbeddings
2
5
  class Reference
3
6
  CJK_RANGES = [
4
7
  0x4E00..0x9FFF, 0x3400..0x4DBF, 0x20000..0x2A6DF, 0x2A700..0x2B73F,
5
- 0x2B740..0x2B81F, 0x2B820..0x2CEAF, 0xF900..0xFAFF, 0x2F800..0x2FA1F
8
+ 0x2B740..0x2B81F, 0x2B920..0x2CEAF, 0xF900..0xFAFF, 0x2F800..0x2FA1F
6
9
  ].freeze
7
10
 
8
11
  ASCII_PUNCT = [33..47, 58..64, 91..96, 123..126].freeze
9
12
  ASCII_SPACES = [" ", "\t", "\n", "\r"].freeze
10
13
  RE_PUNCT = /\A\p{P}\z/
11
- RE_CONTROL = /\A(?:\p{Cc}|\p{Cf}|\p{Co}|\p{Cs})\z/
14
+ RE_CONTROL = /\A(?:\p{Cc}|\p{Cf}|\p{Co})\z/
12
15
  RE_MN = /\A\p{Mn}\z/
13
16
  RE_WHITESPACE = /\A(?:\p{Zs}|[\u0085\u2028\u2029])\z/
14
17
 
@@ -22,41 +25,55 @@ module StaticEmbeddings
22
25
  @dim = meta.fetch(:dim)
23
26
  end
24
27
 
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"] || {}
28
+ def self.from_source_dir(dir, max_tokens: nil, dimensions: nil)
29
+ from_canonical(Importers.import(dir, max_tokens: max_tokens, dimensions: dimensions))
30
+ end
34
31
 
35
- new(tokens: tokens,
36
- matrix: Array.new(rows) { |i| floats[i * dim, dim] },
37
- meta: meta_from(tokenizer, config, normalizer, dim, max_tokens))
32
+ def self.from_canonical(model)
33
+ dim = model.dimensions.output
34
+ floats = matrix_bytes(model.matrix).unpack("e*")
35
+ rows = model.tokens.length
36
+ new(
37
+ tokens: model.tokens,
38
+ matrix: Array.new(rows) { |index| floats[index * dim, dim] },
39
+ meta: meta_from_canonical(model)
40
+ )
38
41
  end
39
42
 
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
+ def self.matrix_bytes(matrix)
44
+ return matrix unless matrix.respond_to?(:each_chunk)
45
+
46
+ matrix.each_chunk.each_with_object(Format.binary_string) { |chunk, packed| packed << chunk }
43
47
  end
44
48
 
45
- def self.meta_from(tokenizer, config, normalizer, dim, max_tokens)
46
- lowercase = normalizer.fetch("lowercase", true)
49
+ def self.meta_from_canonical(model)
50
+ tokenizer = model.tokenizer
51
+ runtime = model.runtime
47
52
  {
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
53
+ dim: model.dimensions.output,
54
+ lowercase: tokenizer.fetch(:do_lower_case),
55
+ strip_accents: tokenizer.fetch(:strip_accents),
56
+ clean_text: tokenizer.fetch(:clean_text),
57
+ handle_chinese_chars: tokenizer.fetch(:handle_chinese_chars),
58
+ max_input_chars_per_word: tokenizer.fetch(:max_input_chars_per_word),
59
+ unk_token: model.tokens.fetch(tokenizer.fetch(:unk_id)),
60
+ normalize: runtime.normalization == Format::NORMALIZATION_L2,
61
+ max_tokens: runtime.max_tokens,
62
+ unk_policy: runtime.unk_policy,
63
+ added_tokens: added_tokens(model)
57
64
  }
58
65
  end
59
66
 
67
+ def self.added_tokens(model)
68
+ mask = model.tokenizer.fetch(:added_token_mask)
69
+ BertWordPiece::STANDARD_SPECIAL_TOKENS.each_with_object({}) do |(content, bit), tokens|
70
+ next if (mask & bit).zero?
71
+
72
+ id = model.tokens.index(content)
73
+ tokens[content] = id unless id.nil?
74
+ end
75
+ end
76
+
60
77
  def normalize_text(text)
61
78
  chars = text.chars
62
79
  chars = clean_chars(chars) if @meta[:clean_text]
@@ -71,21 +88,61 @@ module StaticEmbeddings
71
88
  end
72
89
 
73
90
  def tokenize(text, max_tokens: @meta[:max_tokens])
74
- ids = []
75
- pre_tokenize(normalize_text(text)).each { |word| ids.concat(wordpiece(word)) }
91
+ ids = tokenize_with_added_tokens(text)
76
92
  max_tokens && max_tokens.positive? && ids.length > max_tokens ? ids.first(max_tokens) : ids
77
93
  end
78
94
 
79
95
  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?
96
+ ids = apply_unk_policy(tokenize(text, max_tokens: false))
97
+ ids = ids.first(max_tokens) if max_tokens && max_tokens.positive? && ids.length > max_tokens
98
+ return Array.new(@dim, 0.0) if ids.empty?
82
99
 
83
- vector = pooled(used)
100
+ vector = pooled(ids)
84
101
  @meta[:normalize] ? l2_normalize(vector) : vector
85
102
  end
86
103
 
104
+ def apply_unk_policy(ids)
105
+ case @meta.fetch(:unk_policy)
106
+ when Format::UNK_DROP then ids.reject { |id| id == unk_id }
107
+ when Format::UNK_INCLUDE then ids
108
+ else raise InvalidModelError, "unknown unk_policy #{@meta[:unk_policy].inspect}"
109
+ end
110
+ end
111
+
87
112
  private
88
113
 
114
+ def tokenize_with_added_tokens(text)
115
+ added = @meta[:added_tokens]
116
+ return tokenize_plain(text) if added.empty?
117
+
118
+ ids = []
119
+ cursor = 0
120
+ binary = text.b
121
+ while cursor < text.bytesize
122
+ match = added.keys.filter_map do |literal|
123
+ index = binary.index(literal.b, cursor)
124
+ index && [index, -literal.bytesize, literal]
125
+ end.min
126
+
127
+ unless match
128
+ ids.concat(tokenize_plain(text.byteslice(cursor, text.bytesize - cursor)))
129
+ break
130
+ end
131
+
132
+ index, _, literal = match
133
+ ids.concat(tokenize_plain(text.byteslice(cursor, index - cursor))) if index > cursor
134
+ ids << added.fetch(literal)
135
+ cursor = index + literal.bytesize
136
+ end
137
+ ids
138
+ end
139
+
140
+ def tokenize_plain(text)
141
+ return [] if text.nil? || text.empty?
142
+
143
+ pre_tokenize(normalize_text(text)).flat_map { |word| wordpiece(word) }
144
+ end
145
+
89
146
  def clean_chars(chars)
90
147
  chars.filter_map do |ch|
91
148
  cp = ch.ord
@@ -0,0 +1,59 @@
1
+ require "static_embeddings/errors"
2
+ require "static_embeddings/format/constants"
3
+
4
+ module StaticEmbeddings
5
+ class RowPrefixPayload
6
+ FLOAT_BYTES = 4
7
+
8
+ attr_reader :bytesize
9
+
10
+ def initialize(inner, native_dim:, output_dim:)
11
+ raise InvalidOptionError, "native_dim must be positive" unless native_dim.positive?
12
+ unless output_dim.between?(1, native_dim)
13
+ raise InvalidOptionError, "output_dim must be between 1 and #{native_dim}"
14
+ end
15
+
16
+ @inner = inner
17
+ @source_row_bytes = native_dim * FLOAT_BYTES
18
+ @output_row_bytes = output_dim * FLOAT_BYTES
19
+ unless (inner.bytesize % @source_row_bytes).zero?
20
+ raise ConversionError, "embedding payload is not a whole number of #{native_dim}-d rows"
21
+ end
22
+ @bytesize = (inner.bytesize / @source_row_bytes) * @output_row_bytes
23
+ end
24
+
25
+ def each_chunk
26
+ return enum_for(__method__) unless block_given?
27
+
28
+ remainder = Format.binary_string
29
+ @inner.each_chunk do |chunk|
30
+ data = remainder.empty? ? chunk : remainder + chunk
31
+ complete_rows, remainder = split_complete_rows(data)
32
+ yield slice_rows(complete_rows) unless complete_rows.empty?
33
+ end
34
+
35
+ raise ConversionError, "truncated embedding matrix while slicing dimensions" unless remainder.empty?
36
+ end
37
+
38
+ private
39
+
40
+ def split_complete_rows(data)
41
+ bytes = (data.bytesize / @source_row_bytes) * @source_row_bytes
42
+ complete = data.byteslice(0, bytes) || Format.binary_string
43
+ remainder = data.byteslice(bytes, data.bytesize - bytes) || Format.binary_string
44
+ remainder.force_encoding(Encoding::BINARY)
45
+ [complete, remainder]
46
+ end
47
+
48
+ def slice_rows(data)
49
+ rows = data.bytesize / @source_row_bytes
50
+ out = Format.binary_string(rows * @output_row_bytes)
51
+ offset = 0
52
+ rows.times do
53
+ out << data.byteslice(offset, @output_row_bytes)
54
+ offset += @source_row_bytes
55
+ end
56
+ out
57
+ end
58
+ end
59
+ end