static_embeddings 1.5.8 → 1.5.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbb2b16d03e01ba91c8c856890e98537ff81c4b4961a4d4172dc2e8a27fb46e3
4
- data.tar.gz: d182451276da091e5410fa5dcbf93ec08e773bfa51cc6226f0c244db132e3656
3
+ metadata.gz: 2de25b005846ea72148d725e56b30e25e1f0a3a8aace72cdada2865e0ec625f2
4
+ data.tar.gz: aeae810542ca9734a9fc6e1edf700950e856e054c6f2b1595d5553d5756ee2e4
5
5
  SHA512:
6
- metadata.gz: 54d96917d4a2fd738787ee12bd57a63f3793893a4abe3e59a9b860efe8508a895cb4d1cbd865f4f5692af1b2aa46eea21b17b55ed090b98a9af8df59946feadc
7
- data.tar.gz: da73a4ead8792691b1e8433ecb7487f121e1dda88c61054e56f0f05b56c43f922a49643f1668da4c34be1258f654f134975cae5db875075f2a99e9bd2ea5256a
6
+ metadata.gz: 9747f27eeaaec7272099bf4789afacda8b76017850aa4cc4d16a596bcb3b43d9b23e2686cb9a6b3a9431bfc88468a605a903ad9f9fd0eac6e1019746da69be58
7
+ data.tar.gz: d112d3b2381929555dad88e0b85b16a759a0ba0ddcf1b64cd8c3c44b90c122a9f56b616d6af8d8560d2ec6408bc61ee8f83340af2bf00435fda9f14f9a9d70b6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,64 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.9
4
+
5
+ Pins the upstream contract to **model2vec 0.10.0 / tokenizers 0.23.3** and
6
+ closes a fail-open hole in the converter. The `.semb` format stays v3; files
7
+ written by earlier versions load and embed exactly as before.
8
+
9
+ ### Fixed
10
+
11
+ - **Model2Vec `weights` were silently ignored.** The importer picked the only 2-D
12
+ tensor in `model.safetensors` and skipped every 1-D tensor, so a model with
13
+ per-token `weights` converted without error and produced wrong vectors
14
+ (cosine 0.71–0.98 against `StaticModel` on the test fixture). Vocabulary-
15
+ quantized models (`embeddings` K×d + `weights` + `mapping`) failed with a
16
+ misleading "rows != tokens" error. Both are now baked into ordinary float32
17
+ rows, `row[i] = embeddings[mapping[i]] * weights[i]`, which is exactly what
18
+ `StaticModel` pools; the C runtime is unchanged. Weighted, quantized and
19
+ F64/I64 side-tensor variants match model2vec 0.10.0 within `max_abs 1.2e-7`.
20
+ Provenance records `baked_token_weights` / `baked_vocabulary_quantization`.
21
+ - **Unknown safetensors tensors are rejected** instead of ignored.
22
+ - **`tools/eval_retrieval.rb` no longer reports trivial metrics as results.**
23
+ With `k >= documents` Hit@k is 1.0 for every model by construction (the
24
+ bundled Russian FAQ set has 10 documents and used k=10). The tool now prints
25
+ full-ranking MRR, nDCG/recall/hit at k, a random-ranking baseline, bootstrap
26
+ 95% intervals over queries, and warnings for trivial `k`, small query sets and
27
+ zero-vector queries.
28
+
29
+ ### Changed
30
+
31
+ - **Model2Vec truncation follows 0.10.0 by default.** model2vec 0.10.0 moved
32
+ truncation into the tokenizer: `max_length` now counts raw ids including
33
+ `[UNK]`, and `[UNK]` is dropped afterwards. 0.9.0 dropped `[UNK]` first. On a
34
+ text whose `[UNK]` tokens precede the cap the two versions disagree (cosine
35
+ 0.59 on a synthetic case). New header value `truncation_policy = 3`
36
+ (`raw_ids`) is the Model2Vec default; `--truncation usable-ids` (`2`) keeps the
37
+ 0.9.0 contract for indexes built with it. Files written before 1.5.9 carry
38
+ `2` and are unaffected. `Model#truncation` reports `:raw_ids` / `:usable_ids`.
39
+ Sentence Transformers conversions are unchanged (with `UNK_INCLUDE` both
40
+ policies select the same ids).
41
+ - **`config.json` `max_length` sets the default token cap**, as in 0.10.0
42
+ (`null` means unlimited; absent still means 512).
43
+ - Oracle schema 3 (`tools/model2vec_oracle.py`) records the truncation policy
44
+ and supports both upstream versions; the checker refuses to compare a model
45
+ against an oracle of the other policy.
46
+ - CI: `upstream_parity` and `potion_audit` pin 0.10.0 / 0.23.3 / numpy 2.5.4; new
47
+ `upstream_parity_usable_ids` keeps the 0.9.0 contract tested; new
48
+ non-blocking `upstream_canary` runs the corpus against unpinned PyPI releases
49
+ so the next upstream drift is reported instead of discovered in production.
50
+ - README "Language safety" no longer presents the `[UNK]` ratio as sufficient:
51
+ text spelled out letter by letter has almost no `[UNK]` and is just as broken.
52
+
53
+ ### Upgrade notes
54
+
55
+ Reconverting a Model2Vec source with 1.5.9 changes vectors only for texts with
56
+ `[UNK]` before the token cap. If you keep an existing index, either keep the
57
+ old `.semb` or reconvert with `--truncation usable-ids`; do not mix files of
58
+ both policies in one index. Real-model audits in `docs/MODEL_AUDIT.md` were
59
+ recorded under 0.9.0 and must be re-run (the `potion_audit` CI job does this
60
+ for potion-retrieval-32M).
61
+
3
62
  ## 1.5.8
4
63
 
5
64
  ### Fixed
data/README.md CHANGED
@@ -33,7 +33,7 @@ BERT WordPiece tokenizer profile, an mmap-ready vocabulary lookup, float32
33
33
  embedding rows, and provenance plus a checksum.
34
34
 
35
35
  The only supported tokenizer profile is `BERT_WORDPIECE_V1`, pinned for
36
- compatibility to `tokenizers 0.23.1`. The converter rejects unsupported
36
+ compatibility to `tokenizers 0.23.3`. The converter rejects unsupported
37
37
  tokenizer features rather than approximating them. `.semb` format v3 also
38
38
  records which of the five standard BERT added tokens are active. Files produced
39
39
  by 0.1.4 and earlier use format v2 and must be reconverted.
@@ -60,6 +60,20 @@ bundle exec ruby -Ilib exe/static_embeddings convert ./static-retrieval-mrl-en-v
60
60
  --trained-mrl-dims 1024,512,256,128,64,32
61
61
  ```
62
62
 
63
+ Model2Vec conversions follow the pinned `model2vec 0.10.0` contract by default:
64
+ the token cap counts raw tokenizer ids (with `[UNK]`), then `[UNK]` is dropped.
65
+ An index built with model2vec 0.9.0, or with this gem before 1.5.9, used the
66
+ opposite order; keep it consistent with `--truncation usable-ids`. The policy is
67
+ recorded in the file (`model.truncation` is `:raw_ids` or `:usable_ids`), so old
68
+ `.semb` files keep their behaviour without reconversion. A `max_length` in the
69
+ source `config.json` (0.10.0 writes it; `null` means unlimited) becomes the
70
+ default `max_tokens`.
71
+
72
+ Model2Vec side tensors are baked into the rows at convert time: per-token
73
+ `weights` and the vocabulary-quantization `mapping` produce
74
+ `row[i] = embeddings[mapping[i]] * weights[i]`, exactly what `StaticModel`
75
+ pools. Any other tensor in `model.safetensors` is rejected rather than ignored.
76
+
63
77
  `--max-tokens unlimited` bakes no token cap (Sentence Transformers default).
64
78
  `--max-tokens 0` is a convert-time alias; `embed(max_tokens: 0)` is still
65
79
  rejected. Query and documents must use the same loaded `.semb`.
@@ -139,18 +153,20 @@ Output is a binary `String` of little-endian float32 values in row-major order.
139
153
  exist for debugging and application code, not for the hot path.
140
154
 
141
155
  `tokenize` exposes raw WordPiece ids, including `[UNK]`, and its own
142
- `max_tokens` cap is therefore a raw-id cap. Embedding has a different, upstream
143
- pooling rule: for `UNK_DROP`, `[UNK]` is removed first and `max_tokens` is then
144
- applied to usable ids. To cache tokenization without changing the embedding,
145
- cache it unbounded:
156
+ `max_tokens` cap is therefore a raw-id cap. Embedding applies the model's
157
+ recorded truncation policy: with `:raw_ids` (model2vec 0.10.0, the default for
158
+ new Model2Vec conversions) the cap counts raw ids and `[UNK]` is dropped
159
+ afterwards; with `:usable_ids` (model2vec 0.9.0) `[UNK]` is dropped first and the
160
+ cap counts the remaining ids. To cache tokenization without changing the
161
+ embedding, cache it unbounded:
146
162
 
147
163
  ```ruby
148
164
  ids = model.tokenize(text, max_tokens: false)
149
165
  model.embed_token_ids(ids) == model.embed(text)
150
166
  ```
151
167
 
152
- `embed_token_ids` applies that same usable-id cap. This distinction matters only
153
- when `[UNK]` appears around the truncation boundary.
168
+ `embed_token_ids` applies the same policy. The two policies differ only when
169
+ `[UNK]` appears before the truncation boundary.
154
170
 
155
171
  ### `format: :f16`
156
172
 
@@ -207,11 +223,11 @@ model.dot_top_k(query, MATRIX, 10) # lock-free, concurrent, GVL release
207
223
 
208
224
  When truncation is active the runtime starts with a leading slice sized from
209
225
  `max_tokens`, cut on a word boundary, and grows the budget until it has reached
210
- `max_tokens` **usable** ids. For ordinary in-vocabulary text this keeps the
211
- tokenized prefix to a few kilobytes even when the document is megabytes long.
212
- For OOV-heavy input it may scan farther — or the whole string — because `[UNK]`
213
- no longer consumes the usable-token budget. That is required for the corrected
214
- UNK-before-truncate contract.
226
+ `max_tokens` ids as counted by the model's truncation policy. For ordinary
227
+ in-vocabulary text this keeps the tokenized prefix to a few kilobytes even when
228
+ the document is megabytes long. Under `:usable_ids`, OOV-heavy input may scan
229
+ farther — or the whole string — because `[UNK]` does not consume the budget;
230
+ under `:raw_ids` it does, so the scan stays bounded.
215
231
 
216
232
  That bounds the tokenizer, not the whole call. `embed` also has to establish
217
233
  that the Ruby `String` is valid UTF-8, and when Ruby has not computed the
@@ -286,35 +302,49 @@ end
286
302
 
287
303
  ## Language safety
288
304
 
289
- `potion-retrieval-32M` is an English retrieval model. Russian text can produce a
290
- high `[UNK]` ratio while still returning a valid vector, which is the most
291
- likely way to end up with a silently bad index. Check the ratio before trusting
292
- a corpus:
305
+ `potion-retrieval-32M` is an English retrieval model. Foreign-language text still
306
+ returns a valid vector, which is the most likely way to end up with a silently
307
+ bad index. It fails in two different ways, and the `[UNK]` ratio sees only one:
308
+
309
+ - words the vocabulary cannot spell become `[UNK]` (high `unk_count`);
310
+ - words it *can* spell letter by letter (English BERT-derived vocabularies
311
+ usually contain single Cyrillic letters) become chains of one-character
312
+ pieces: `[UNK]` stays near zero and the vector is still close to meaningless.
313
+
314
+ Check both before trusting a corpus:
293
315
 
294
316
  ```ruby
295
317
  stats = model.embed_with_stats(text)
296
318
  ratio = stats[:token_count].zero? ? 0.0 : stats[:unk_count].to_f / stats[:token_count]
319
+ words = text.split.size
320
+ pieces_per_word = words.zero? ? 0.0 : stats[:token_count].to_f / words
297
321
 
298
322
  warn "high [UNK] ratio: #{ratio.round(3)}" if ratio > 0.3
323
+ warn "text is spelled out in fragments: #{pieces_per_word.round(1)} pieces/word" if pieces_per_word > 2.5
299
324
  ```
300
325
 
326
+ In-vocabulary English is typically 1.1–1.4 pieces per word. `text.split` is a
327
+ rough word count (it ignores punctuation splitting), which is enough for this
328
+ check.
329
+
301
330
  Out-of-vocabulary text is also slower, because every word falls through to
302
331
  subword splitting instead of hitting the vocabulary directly.
303
332
 
304
333
  ## Correctness contract
305
334
 
306
- 0.1.5 separates contracts that 0.1.4 accidentally mixed together:
335
+ Pinned upstream: `model2vec 0.10.0`, `tokenizers 0.23.3`.
307
336
 
308
- - `tokenize` matches the pinned Hugging Face `tokenizers 0.23.1` raw
337
+ - `tokenize` matches the pinned Hugging Face `tokenizers` raw
309
338
  `BertNormalizer + BertPreTokenizer + WordPiece` ids, including the supported
310
339
  `normalized: false` standard added tokens and `[UNK]`.
311
- - Embedding drops `[UNK]` when the model records `UNK_DROP`, then applies
312
- `max_tokens` to the remaining usable ids, mean-pools, and performs source-model
313
- L2 normalization.
314
- - The gem intentionally does **not** reproduce Model2Vec 0.9.0's character
315
- pre-cut (`max_length * median_token_length`). Here `max_tokens` means actual
316
- usable tokenizer ids. The oracle reports rows affected by this difference
317
- separately instead of calling them parity.
340
+ - Embedding applies the recorded truncation policy (`:raw_ids` caps raw ids and
341
+ then drops `[UNK]`, as model2vec 0.10.0 does; `:usable_ids` drops `[UNK]` and
342
+ then caps, as 0.9.0 did), mean-pools, and performs source-model L2
343
+ normalization.
344
+ - The gem intentionally does **not** reproduce Model2Vec's character pre-cut
345
+ (`max_length * median_token_length`, still present in 0.10.0). Here
346
+ `max_tokens` means actual tokenizer ids. The oracle reports rows affected by
347
+ this difference separately instead of calling them parity.
318
348
 
319
349
  Where the execution contracts are the same, vectors are compared to
320
350
  `model2vec.StaticModel` with:
data/Rakefile CHANGED
@@ -44,7 +44,7 @@ task parity: :compile do
44
44
  Generate it first (needs Python and the model2vec package):
45
45
 
46
46
  python3 -m venv .venv-model2vec && . .venv-model2vec/bin/activate
47
- pip install "model2vec==0.9.0" "tokenizers==0.23.1" numpy
47
+ pip install "model2vec==0.10.0" "tokenizers==0.23.3" "numpy==2.5.4"
48
48
  python tools/model2vec_oracle.py /path/to/source-model --out #{oracle}
49
49
  MSG
50
50
  end
data/docs/ARCHITECTURE.md CHANGED
@@ -89,7 +89,7 @@ already has a slot for I8; there is no dead I8 path in C.
89
89
  ### Why the semantics live in the header
90
90
 
91
91
  `max_tokens_default`, `unk_policy`, `empty_policy` and the normalizer flags
92
- are properties of the *model*, not options the caller guesses. The embedding contract caps the first 512 usable token ids by default;
92
+ are properties of the *model*, not options the caller guesses. So is `truncation_policy`: `3` caps raw ids and then drops `[UNK]` (model2vec 0.10.0, the default for new Model2Vec conversions), `2` drops `[UNK]` first and caps the rest (model2vec 0.9.0, every file written before 1.5.9). The embedding contract caps the first 512 token ids by default;
93
93
  a model card's `model_max_length` of 1,000,000 is a different number entirely, and
94
94
  confusing them silently changes every vector for long chunks. Model2Vec 0.9.0
95
95
  also applies a character pre-cut before tokenization; this runtime intentionally
@@ -159,7 +159,7 @@ picked the wrong one would produce plausible, slightly wrong vectors forever.
159
159
  The Ruby build that generated the tables is stamped into provenance, so a
160
160
  model file and its embedded normalizer tables can never drift apart unnoticed.
161
161
  That stamp is not, by itself, proof of Hugging Face parity: 0.1.5 therefore
162
- checks a systematic boundary corpus against pinned `tokenizers 0.23.1` in CI.
162
+ checks a systematic boundary corpus against pinned `tokenizers` (0.23.3 since 1.5.9) in CI.
163
163
  One subtle example is `Cn`: `bert.rs` says `is_other()` includes unassigned
164
164
  codepoints, while the `unicode_categories 0.1.1` implementation actually tests
165
165
  Cc/Cf/Co. The runtime follows the executable upstream behavior, not the comment.
@@ -168,9 +168,10 @@ Cc/Cf/Co. The runtime follows the executable upstream behavior, not the comment.
168
168
 
169
169
  ```
170
170
  tokenize -> raw ids (including [UNK])
171
- drop [UNK] if unk_policy = DROP
172
- truncate usable ids to max_tokens
173
- acc[j] += row[j] for each usable id, in token order
171
+ policy 3 (raw_ids): keep the first max_tokens raw ids, then drop [UNK]*
172
+ policy 2 (usable_ids): drop [UNK]* first, then keep the first max_tokens ids
173
+ (* only when unk_policy = DROP; both coincide otherwise)
174
+ acc[j] += row[j] for each kept id, in token order
174
175
  divide by the number of pooled rows
175
176
  L2 normalize if the model says so
176
177
  ```
@@ -226,10 +227,11 @@ therefore serialised the process on work that the tokenizer would discard after
226
227
  `embed` and `embed_batch` instead copy a leading slice. The budget starts at
227
228
  `max_tokens * 16` bytes clamped to `[4096, 65536]`, and the slice is cut at a
228
229
  position where the tokenizer would have started a fresh word. A result is accepted only when that slice actually reached `max_tokens`
229
- usable ids; `[UNK]` does not consume the embedding budget. Otherwise the budget
230
- doubles and the text is tokenized again. Growth is geometric. OOV-heavy input
231
- may therefore reach the full document: deciding that fewer than `max_tokens`
232
- usable ids exist requires reading it.
230
+ ids as counted by the truncation policy. Under `usable_ids`, `[UNK]` does not
231
+ consume the embedding budget, so OOV-heavy input may reach the full document:
232
+ deciding that fewer than `max_tokens` usable ids exist requires reading it.
233
+ Under `raw_ids` every id counts and the window stays bounded. Otherwise the
234
+ budget doubles and the text is tokenized again. Growth is geometric.
233
235
 
234
236
  Three properties make this exact rather than approximate:
235
237
 
data/docs/LIMITATIONS.md CHANGED
@@ -23,6 +23,14 @@ the entry says whether that is a decision or just unfinished work.
23
23
  context; a word contributes the same row wherever it appears. If you need
24
24
  contextual embeddings, this is the wrong tool.
25
25
 
26
+ - **Two upstream truncation contracts, chosen at convert time.** `.semb` files
27
+ record `raw_ids` (model2vec 0.10.0) or `usable_ids` (0.9.0); there is no
28
+ runtime switch, because mixing them inside one index silently changes
29
+ vectors. Model2Vec's character pre-cut is reproduced by neither.
30
+ - **Model2Vec `weights`/`mapping` are expanded, not stored.** A
31
+ vocabulary-quantized source becomes a full-vocabulary `.semb`; the file does
32
+ not get smaller. int8 embedding tensors are rejected.
33
+
26
34
  ## Runtime
27
35
 
28
36
  - **No internal parallelism.** `threads:` is rejected rather than ignored. Split
data/docs/MODEL_AUDIT.md CHANGED
@@ -229,3 +229,44 @@ arm64-darwin24, 1.5.6):
229
229
  Hit@10 was 1.0 for every model on this tiny labeled set. That is a domain sanity
230
230
  check, not a public retrieval benchmark, and it does not make the similarity
231
231
  model a hard-coded Russian default.
232
+
233
+ ## 1.5.9 record: model2vec 0.10.0 contract
234
+
235
+ Recorded locally (x86_64-linux, Ruby 3.2.3, Python 3.12.3), deterministic
236
+ `tiny-wordpiece` fixture, 434-row corpus from `tools/parity_cases.py`, oracle
237
+ schema 3.
238
+
239
+ `model2vec 0.10.0` / `tokenizers 0.23.3` / `numpy 2.5.4`, default conversion
240
+ (`truncation=raw_ids`):
241
+
242
+ ```text
243
+ rows=434
244
+ vectors_checked=432
245
+ intentional_character_pretruncate_deviations=2
246
+ min_cosine=0.9999999999999982
247
+ max_abs_all=1.1920928955078125e-07
248
+ raw_token_id_failures=[] usable_token_id_failures=[] embed_invariant_failures=[] vector_failures=[]
249
+ corpus parity OK (434/434)
250
+ ```
251
+
252
+ `model2vec 0.9.0` / `tokenizers 0.23.3`, `--truncation usable-ids`
253
+ (`--allow-version-mismatch`, since the oracle now pins 0.10.0):
254
+
255
+ ```text
256
+ rows=434 vectors_checked=429 intentional_character_pretruncate_deviations=5
257
+ min_cosine=0.9999999999999988 max_abs_all=5.960464477539063e-08
258
+ corpus parity OK (434/434)
259
+ ```
260
+
261
+ Negative control: the same `usable_ids` file against the 0.10.0 oracle, with the
262
+ checker's policy guard disabled, fails on rows 426–429 and 432, so the corpus
263
+ does detect the truncation-order change.
264
+
265
+ Side tensors (`weights`, `weights + mapping` from `quantize_model(...,
266
+ vocabulary_quantization=32)`, and an F64/I64 variant) on the same fixture:
267
+ `max_abs` 6.0e-8, 6.0e-8 and 1.2e-7 against `StaticModel.encode`.
268
+
269
+ **Not yet re-run under 0.10.0:** potion-retrieval-32M, potion-base-8M,
270
+ potion-science-32M. Their records above are 0.9.0 / `usable_ids` results. The
271
+ `potion_audit` CI job regenerates the potion-retrieval-32M record; append it
272
+ here before trusting a 1.5.9 conversion of that model.
@@ -197,8 +197,8 @@ se_status_t se_embed_ids(const se_model_t *model, se_scratch_t *sc, const uint32
197
197
  se_status_t se_embed_one(const se_model_t *model, se_scratch_t *sc, const uint8_t *input,
198
198
  size_t input_len, uint32_t max_tokens, float *out, se_token_stats_t *stats,
199
199
  se_error_t *err, se_cancel_flag_t *cancelled) {
200
- se_status_t rc = se_tokenize(model, sc, input, input_len, max_tokens, SE_TOKEN_LIMIT_USABLE,
201
- stats, err, cancelled);
200
+ se_status_t rc = se_tokenize(model, sc, input, input_len, max_tokens,
201
+ se_embed_limit_mode(model), stats, err, cancelled);
202
202
  if (rc != SE_OK)
203
203
  return rc;
204
204
  return embed_ids_core(model, sc, sc->ids, stats->token_count, out, err, cancelled);
@@ -695,7 +695,8 @@ static se_status_t validate(se_model_t *model, se_error_t *err) {
695
695
  m->normalization_type);
696
696
  return SE_ERR_INVALID_FORMAT;
697
697
  }
698
- if (m->truncation_policy != SE_TRUNCATE_USABLE_IDS_BEFORE_POOLING) {
698
+ if (m->truncation_policy != SE_TRUNCATE_USABLE_IDS_BEFORE_POOLING &&
699
+ m->truncation_policy != SE_TRUNCATE_RAW_IDS_BEFORE_UNK_DROP) {
699
700
  se_error_set(err, SE_ERR_INVALID_FORMAT, "truncation policy %u is not supported",
700
701
  m->truncation_policy);
701
702
  return SE_ERR_INVALID_FORMAT;
@@ -28,6 +28,7 @@ static inline void se_cancel_request(se_cancel_flag_t *flag) {
28
28
  #define SE_NORMALIZATION_L2 1u
29
29
 
30
30
  #define SE_TRUNCATE_USABLE_IDS_BEFORE_POOLING 2u
31
+ #define SE_TRUNCATE_RAW_IDS_BEFORE_UNK_DROP 3u
31
32
 
32
33
  #define SE_UNK_INCLUDE 0u
33
34
  #define SE_UNK_DROP 1u
@@ -422,6 +423,12 @@ typedef struct {
422
423
 
423
424
  typedef enum { SE_TOKEN_LIMIT_RAW = 0, SE_TOKEN_LIMIT_USABLE = 1 } se_token_limit_t;
424
425
 
426
+ static inline se_token_limit_t se_embed_limit_mode(const se_model_t *model) {
427
+ return model->meta.truncation_policy == SE_TRUNCATE_RAW_IDS_BEFORE_UNK_DROP
428
+ ? SE_TOKEN_LIMIT_RAW
429
+ : SE_TOKEN_LIMIT_USABLE;
430
+ }
431
+
425
432
  se_status_t se_tokenize(const se_model_t *model, se_scratch_t *scratch, const uint8_t *input,
426
433
  size_t input_len, uint32_t max_tokens, se_token_limit_t limit_mode,
427
434
  se_token_stats_t *stats, se_error_t *err, se_cancel_flag_t *cancelled);
@@ -463,6 +463,12 @@ static VALUE model_max_tokens(VALUE self) {
463
463
  return UINT2NUM(get_model(self)->model.meta.max_tokens_default);
464
464
  }
465
465
 
466
+ static VALUE model_truncation(VALUE self) {
467
+ return se_embed_limit_mode(&get_model(self)->model) == SE_TOKEN_LIMIT_RAW
468
+ ? ID2SYM(rb_intern("raw_ids"))
469
+ : ID2SYM(rb_intern("usable_ids"));
470
+ }
471
+
466
472
  static VALUE model_normalized_p(VALUE self) {
467
473
  return get_model(self)->model.meta.normalization_type == SE_NORMALIZATION_L2 ? Qtrue : Qfalse;
468
474
  }
@@ -1290,7 +1296,8 @@ static VALUE embed_token_ids_value(VALUE self, VALUE ids_value, VALUE max_tokens
1290
1296
  size_t n = (size_t)n_long;
1291
1297
  int truncated = 0;
1292
1298
 
1293
- if (max_tokens != 0 && w->model.meta.unk_policy == SE_UNK_DROP) {
1299
+ if (max_tokens != 0 && w->model.meta.unk_policy == SE_UNK_DROP &&
1300
+ se_embed_limit_mode(&w->model) == SE_TOKEN_LIMIT_USABLE) {
1294
1301
  size_t usable = 0;
1295
1302
  size_t cutoff = n;
1296
1303
  for (size_t i = 0; i < n; i++) {
@@ -1710,6 +1717,7 @@ RUBY_FUNC_EXPORTED void Init_static_embeddings(void) {
1710
1717
  rb_define_method(cModel, "dim", model_dim, 0);
1711
1718
  rb_define_method(cModel, "vocab_size", model_vocab_size, 0);
1712
1719
  rb_define_method(cModel, "max_tokens", model_max_tokens, 0);
1720
+ rb_define_method(cModel, "truncation", model_truncation, 0);
1713
1721
  rb_define_method(cModel, "normalized?", model_normalized_p, 0);
1714
1722
  rb_define_method(cModel, "lowercase?", model_lowercase_p, 0);
1715
1723
  rb_define_method(cModel, "unk_id", model_unk_id, 0);
@@ -2,9 +2,9 @@ module StaticEmbeddings
2
2
  module Canonical
3
3
  Dimensions = Struct.new(:native, :output, :trained, keyword_init: true)
4
4
  Runtime = Struct.new(:normalization, :unk_policy, :empty_policy, :max_tokens, :add_special_tokens,
5
- keyword_init: true)
5
+ :truncation, keyword_init: true)
6
6
  Source = Struct.new(:family, :model, :revision, :oracle, :files_sha256, :tokenizer_class,
7
- :config_seq_length, keyword_init: true)
7
+ :config_seq_length, :baked, keyword_init: true)
8
8
  Model = Struct.new(:tokens, :matrix, :dimensions, :runtime, :tokenizer, :source, keyword_init: true)
9
9
 
10
10
  module_function
@@ -13,18 +13,20 @@ module StaticEmbeddings
13
13
  Dimensions.new(native: native, output: output, trained: trained&.freeze).freeze
14
14
  end
15
15
 
16
- def runtime(normalization:, unk_policy:, empty_policy:, max_tokens:, add_special_tokens: false)
16
+ def runtime(normalization:, unk_policy:, empty_policy:, max_tokens:, add_special_tokens: false,
17
+ truncation: Format::TRUNCATE_USABLE_IDS_BEFORE_POOLING)
17
18
  Runtime.new(
18
19
  normalization: normalization,
19
20
  unk_policy: unk_policy,
20
21
  empty_policy: empty_policy,
21
22
  max_tokens: max_tokens,
22
- add_special_tokens: add_special_tokens
23
+ add_special_tokens: add_special_tokens,
24
+ truncation: truncation
23
25
  ).freeze
24
26
  end
25
27
 
26
28
  def source(family:, model:, oracle:, files_sha256:, revision: nil, tokenizer_class: nil,
27
- config_seq_length: nil)
29
+ config_seq_length: nil, baked: nil)
28
30
  Source.new(
29
31
  family: family,
30
32
  model: model,
@@ -32,7 +34,8 @@ module StaticEmbeddings
32
34
  oracle: oracle,
33
35
  files_sha256: files_sha256.freeze,
34
36
  tokenizer_class: tokenizer_class,
35
- config_seq_length: config_seq_length
37
+ config_seq_length: config_seq_length,
38
+ baked: baked&.freeze
36
39
  ).freeze
37
40
  end
38
41
 
@@ -26,6 +26,9 @@ module StaticEmbeddings
26
26
  --max-tokens N Model2Vec default: 512; Sentence Transformers default: unlimited
27
27
  --max-tokens unlimited
28
28
  --dimensions N Keep the first N dims (Matryoshka prefix slice)
29
+ --truncation raw-ids|usable-ids
30
+ Model2Vec default raw-ids (model2vec 0.10.0);
31
+ usable-ids reproduces model2vec 0.9.0
29
32
  --revision SHA Source revision recorded in provenance
30
33
  --trained-mrl-dims 1024,512,256,... training dims recorded in provenance
31
34
 
@@ -85,12 +88,13 @@ module StaticEmbeddings
85
88
  parser.on("--dimensions N", Integer) { |value| options[:dimensions] = value }
86
89
  parser.on("--revision SHA") { |value| options[:source_revision] = value }
87
90
  parser.on("--trained-mrl-dims LIST") { |value| options[:trained_mrl_dims] = value }
91
+ parser.on("--truncation POLICY", %w[raw-ids usable-ids]) { |value| options[:truncation] = value }
88
92
  end.parse!(argv)
89
93
  end
90
94
  end
91
95
 
92
96
  def conversion_options(options)
93
- options.select { |key, _| %i[max_tokens dimensions source_revision trained_mrl_dims].include?(key) }
97
+ options.select { |key, _| %i[max_tokens dimensions source_revision trained_mrl_dims truncation].include?(key) }
94
98
  end
95
99
 
96
100
  def parse_max_tokens(value)
@@ -111,6 +115,7 @@ module StaticEmbeddings
111
115
  " bytes #{report[:bytes]}",
112
116
  " sha256 #{report[:sha256]}",
113
117
  " max_tokens #{max_tokens}",
118
+ " truncation #{report[:truncation]}",
114
119
  "",
115
120
  "Record this conversion in docs/MODEL_AUDIT.md before trusting the vectors."
116
121
  ].join("\n")
@@ -143,6 +148,7 @@ module StaticEmbeddings
143
148
  "dim" => model.dim,
144
149
  "vocab_size" => model.vocab_size,
145
150
  "max_tokens" => model.max_tokens,
151
+ "truncation" => model.truncation,
146
152
  "normalized" => model.normalized?,
147
153
  "lowercase" => model.lowercase?,
148
154
  "unk_id" => model.unk_id,
@@ -25,6 +25,7 @@ module StaticEmbeddings
25
25
  dim: model.dimensions.output,
26
26
  native_dim: model.dimensions.native,
27
27
  max_tokens: model.runtime.max_tokens,
28
+ truncation: Format::TRUNCATION_POLICIES.key(model.runtime.truncation),
28
29
  provenance: JSON.parse(provenance)
29
30
  )
30
31
  end
@@ -36,6 +37,7 @@ module StaticEmbeddings
36
37
  dim: model.dimensions.output,
37
38
  normalization_type: runtime.normalization,
38
39
  max_tokens_default: runtime.max_tokens,
40
+ truncation_policy: runtime.truncation,
39
41
  add_special_tokens: runtime.add_special_tokens,
40
42
  unk_policy: runtime.unk_policy,
41
43
  empty_policy: runtime.empty_policy,
@@ -12,6 +12,11 @@ module StaticEmbeddings
12
12
  NORMALIZATION_NONE = 0
13
13
  NORMALIZATION_L2 = 1
14
14
  TRUNCATE_USABLE_IDS_BEFORE_POOLING = 2
15
+ TRUNCATE_RAW_IDS_BEFORE_UNK_DROP = 3
16
+ TRUNCATION_POLICIES = {
17
+ raw_ids: TRUNCATE_RAW_IDS_BEFORE_UNK_DROP,
18
+ usable_ids: TRUNCATE_USABLE_IDS_BEFORE_POOLING
19
+ }.freeze
15
20
  UNK_INCLUDE = 0
16
21
  UNK_DROP = 1
17
22
  EMPTY_ZERO_VECTOR = 0
@@ -53,7 +58,6 @@ module StaticEmbeddings
53
58
  28 => TOKENIZER_BERT_WORDPIECE_V1,
54
59
  32 => DTYPE_F32,
55
60
  36 => POOLING_MEAN,
56
- 48 => TRUNCATE_USABLE_IDS_BEFORE_POOLING,
57
61
  108 => HASH_SEED
58
62
  }.freeze
59
63
 
@@ -61,6 +65,7 @@ module StaticEmbeddings
61
65
  20 => :dim,
62
66
  40 => :normalization_type,
63
67
  44 => :max_tokens_default,
68
+ 48 => :truncation_policy,
64
69
  56 => :unk_policy,
65
70
  60 => :empty_policy,
66
71
  80 => :max_input_chars_per_word,
@@ -3,18 +3,20 @@ module StaticEmbeddings
3
3
  module Model2Vec
4
4
  SOURCE_FILES = %w[tokenizer.json config.json tokenizer_config.json model.safetensors].freeze
5
5
  DEFAULT_MAX_TOKENS = 512
6
+ DEFAULT_TRUNCATION = :raw_ids
6
7
  FAMILY = "model2vec"
7
8
  ORACLE = "model2vec.StaticModel"
8
9
 
9
10
  module_function
10
11
 
11
12
  def call(root, model_id: nil, max_tokens: nil, dimensions: nil,
12
- source_revision: nil, trained_mrl_dims: nil)
13
+ source_revision: nil, trained_mrl_dims: nil, truncation: nil)
13
14
  tokenizer = Support.load_object(File.join(root, "tokenizer.json"))
14
15
  config = Support.load_object(File.join(root, "config.json"), optional: true) || {}
15
16
  tokenizer_config = Support.load_object(File.join(root, "tokenizer_config.json"), optional: true) || {}
16
17
  profile, tokens = BertWordPiece.compile(tokenizer, tokenizer_config)
17
- payload, native_dim = Support.extract_matrix(File.join(root, "model.safetensors"), tokens.length)
18
+ payload, native_dim, baked = Support.extract_matrix(File.join(root, "model.safetensors"), tokens.length,
19
+ side_tensors: Support::MODEL2VEC_SIDE_TENSORS)
18
20
  output_dim = Support.resolve_dimensions(dimensions, native_dim)
19
21
 
20
22
  Canonical.model(
@@ -29,7 +31,8 @@ module StaticEmbeddings
29
31
  normalization: normalization(config),
30
32
  unk_policy: Format::UNK_DROP,
31
33
  empty_policy: Format::EMPTY_ZERO_VECTOR,
32
- max_tokens: Support.resolve_max_tokens(max_tokens, DEFAULT_MAX_TOKENS)
34
+ max_tokens: Support.resolve_max_tokens(max_tokens, default_max_tokens(config)),
35
+ truncation: Support.resolve_truncation(truncation, DEFAULT_TRUNCATION)
33
36
  ),
34
37
  tokenizer: BertWordPiece.runtime_meta(profile, tokens),
35
38
  source: Canonical.source(
@@ -39,11 +42,24 @@ module StaticEmbeddings
39
42
  oracle: ORACLE,
40
43
  files_sha256: Support.digest_files(SOURCE_FILES.map { |name| File.join(root, name) }),
41
44
  tokenizer_class: profile[:tokenizer_class],
42
- config_seq_length: config["seq_length"]
45
+ config_seq_length: config["seq_length"],
46
+ baked: baked
43
47
  )
44
48
  )
45
49
  end
46
50
 
51
+ def default_max_tokens(config)
52
+ return DEFAULT_MAX_TOKENS unless config.key?("max_length")
53
+
54
+ value = config["max_length"]
55
+ return 0 if value.nil?
56
+ unless value.is_a?(Integer) && value.between?(1, Format::UINT32_MAX)
57
+ raise InvalidSourceError,
58
+ "config.json max_length must be an integer in 1..#{Format::UINT32_MAX} or null, got #{value.inspect}"
59
+ end
60
+ value
61
+ end
62
+
47
63
  def normalization(config)
48
64
  config.fetch("normalize", false) ? Format::NORMALIZATION_L2 : Format::NORMALIZATION_NONE
49
65
  end
@@ -11,7 +11,7 @@ module StaticEmbeddings
11
11
  module_function
12
12
 
13
13
  def call(root, model_id: nil, max_tokens: nil, dimensions: nil,
14
- source_revision: nil, trained_mrl_dims: nil)
14
+ source_revision: nil, trained_mrl_dims: nil, truncation: nil)
15
15
  module_dir = module_directory(root)
16
16
  tokenizer = Support.load_object(File.join(module_dir, "tokenizer.json"))
17
17
  tokenizer_config = Support.load_object(File.join(module_dir, "tokenizer_config.json"), optional: true) || {}
@@ -32,7 +32,8 @@ module StaticEmbeddings
32
32
  normalization: Format::NORMALIZATION_NONE,
33
33
  unk_policy: Format::UNK_INCLUDE,
34
34
  empty_policy: Format::EMPTY_ZERO_VECTOR,
35
- max_tokens: Support.resolve_max_tokens(max_tokens, DEFAULT_MAX_TOKENS)
35
+ max_tokens: Support.resolve_max_tokens(max_tokens, DEFAULT_MAX_TOKENS),
36
+ truncation: Support.resolve_truncation(truncation, :usable_ids)
36
37
  ),
37
38
  tokenizer: BertWordPiece.runtime_meta(profile, tokens),
38
39
  source: Canonical.source(
@@ -4,6 +4,7 @@ require "static_embeddings/errors"
4
4
  require "static_embeddings/format/constants"
5
5
  require "static_embeddings/safetensors"
6
6
  require "static_embeddings/row_prefix_payload"
7
+ require "static_embeddings/weighted_payload"
7
8
 
8
9
  module StaticEmbeddings
9
10
  module Importers
@@ -35,20 +36,65 @@ module StaticEmbeddings
35
36
  end
36
37
  end
37
38
 
38
- def extract_matrix(path, vocab_size)
39
+ MODEL2VEC_SIDE_TENSORS = %w[weights mapping].freeze
40
+
41
+ def extract_matrix(path, vocab_size, side_tensors: [])
39
42
  raise InvalidSourceError, "missing model.safetensors in #{File.dirname(path)}" unless File.file?(path)
40
43
 
41
- name, tensor = sole_matrix_tensor(Safetensors.describe(path).fetch(:tensors))
44
+ tensors = Safetensors.describe(path).fetch(:tensors)
45
+ name, tensor = sole_matrix_tensor(tensors)
46
+ reject_unknown_tensors(tensors, name, side_tensors)
42
47
  rows, dim = tensor.fetch(:shape)
43
- unless rows == vocab_size
48
+ unless dim.positive? && dim <= Format::UINT32_MAX
49
+ raise ConversionError, "embedding matrix #{name} has unsupported dimension #{dim}"
50
+ end
51
+
52
+ payload = Safetensors.f32_payload(path, tensor)
53
+ weights = side_vector(path, tensors, "weights", vocab_size, side_tensors)
54
+ mapping = side_vector(path, tensors, "mapping", vocab_size, side_tensors)
55
+
56
+ if mapping
57
+ validate_mapping(mapping, rows)
58
+ elsif rows != vocab_size
44
59
  raise ConversionError,
45
60
  "embedding matrix #{name} has #{rows} rows but the tokenizer has #{vocab_size} tokens"
46
61
  end
47
- unless dim.positive? && dim <= Format::UINT32_MAX
48
- raise ConversionError, "embedding matrix #{name} has unsupported dimension #{dim}"
62
+ validate_weights(weights) if weights
63
+
64
+ if weights || mapping
65
+ payload = WeightedPayload.new(payload, dim: dim, source_rows: rows, output_rows: vocab_size,
66
+ weights: weights, mapping: mapping)
67
+ end
68
+ [payload, dim, { weights: !weights.nil?, mapping_clusters: mapping ? rows : nil }]
69
+ end
70
+
71
+ def reject_unknown_tensors(tensors, matrix_name, side_tensors)
72
+ unknown = tensors.keys - [matrix_name] - side_tensors
73
+ return if unknown.empty?
74
+
75
+ raise ConversionError,
76
+ "model.safetensors contains tensors this converter does not understand: #{unknown.inspect}; " \
77
+ "refusing to ignore them because that would silently change vectors"
78
+ end
79
+
80
+ def side_vector(path, tensors, key, vocab_size, side_tensors)
81
+ return nil unless side_tensors.include?(key) && tensors.key?(key)
82
+
83
+ tensor = tensors.fetch(key)
84
+ unless tensor.fetch(:shape) == [vocab_size]
85
+ raise ConversionError, "#{key} tensor has shape #{tensor[:shape].inspect}, expected [#{vocab_size}]"
86
+ end
87
+ Safetensors.read_vector(path, tensor)
88
+ end
89
+
90
+ def validate_mapping(mapping, rows)
91
+ unless mapping.all? { |v| v.is_a?(Integer) && v >= 0 && v < rows }
92
+ raise ConversionError, "mapping tensor must contain integer row indices in 0...#{rows}"
49
93
  end
94
+ end
50
95
 
51
- [Safetensors.f32_payload(path, tensor), dim]
96
+ def validate_weights(weights)
97
+ raise ConversionError, "weights tensor contains non-finite values" unless weights.all? { |w| w.to_f.finite? }
52
98
  end
53
99
 
54
100
  def resolve_max_tokens(requested, default)
@@ -62,6 +108,14 @@ module StaticEmbeddings
62
108
  value
63
109
  end
64
110
 
111
+ def resolve_truncation(requested, default)
112
+ key = (requested || default).to_s.tr("-", "_").to_sym
113
+ Format::TRUNCATION_POLICIES.fetch(key) do
114
+ raise InvalidOptionError,
115
+ "truncation must be one of #{Format::TRUNCATION_POLICIES.keys.inspect}, got #{requested.inspect}"
116
+ end
117
+ end
118
+
65
119
  def resolve_dimensions(requested, native_dim)
66
120
  return native_dim if requested.nil?
67
121
 
@@ -4,8 +4,9 @@ require "static_embeddings/bert_wordpiece"
4
4
 
5
5
  module StaticEmbeddings
6
6
  module Provenance
7
- MODEL2VEC_VERSION = "0.9.0"
8
- TOKENIZERS_VERSION = "0.23.1"
7
+ MODEL2VEC_VERSION = "0.10.0"
8
+ MODEL2VEC_USABLE_IDS_VERSION = "0.9.0"
9
+ TOKENIZERS_VERSION = "0.23.3"
9
10
  UNICODE_CATEGORIES_VERSION = "0.1.1"
10
11
  NOTES = "Vectors are only reference-compatible if docs/MODEL_AUDIT.md records a passing oracle run for this source revision."
11
12
 
@@ -35,6 +36,7 @@ module StaticEmbeddings
35
36
  "reference_impl" => source.oracle,
36
37
  "reference_max_tokens" => runtime.max_tokens,
37
38
  "reference_tokenizers_version" => TOKENIZERS_VERSION,
39
+ "truncation" => Format::TRUNCATION_POLICIES.key(runtime.truncation).to_s,
38
40
  "source_family" => source.family,
39
41
  "source_files_sha256" => source.files_sha256,
40
42
  "source_model_id" => source.model,
@@ -46,12 +48,21 @@ module StaticEmbeddings
46
48
  "vocab_size" => model.tokens.length
47
49
  }
48
50
  data["trained_mrl_dims"] = dimensions.trained if dimensions.trained
49
- add_model2vec_versions(data) if source.family == "model2vec"
51
+ add_baked_side_tensors(data, source.baked)
52
+ add_model2vec_versions(data, runtime) if source.family == "model2vec"
50
53
  data
51
54
  end
52
55
 
53
- def add_model2vec_versions(data)
54
- data["reference_model2vec_version"] = MODEL2VEC_VERSION
56
+ def add_baked_side_tensors(data, baked)
57
+ return unless baked && (baked[:weights] || baked[:mapping_clusters])
58
+
59
+ data["baked_token_weights"] = baked[:weights]
60
+ data["baked_vocabulary_quantization"] = baked[:mapping_clusters] if baked[:mapping_clusters]
61
+ end
62
+
63
+ def add_model2vec_versions(data, runtime)
64
+ data["reference_model2vec_version"] =
65
+ runtime.truncation == Format::TRUNCATE_RAW_IDS_BEFORE_UNK_DROP ? MODEL2VEC_VERSION : MODEL2VEC_USABLE_IDS_VERSION
55
66
  data["reference_unicode_categories_version"] = UNICODE_CATEGORIES_VERSION
56
67
  end
57
68
  end
@@ -25,52 +25,55 @@ module StaticEmbeddings
25
25
  @dim = meta.fetch(:dim)
26
26
  end
27
27
 
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
31
-
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
- )
41
- end
42
-
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 }
47
- end
48
-
49
- def self.meta_from_canonical(model)
50
- tokenizer = model.tokenizer
51
- runtime = model.runtime
52
- {
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)
64
- }
65
- end
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?
28
+ class << self
29
+ def from_source_dir(dir, max_tokens: nil, dimensions: nil, truncation: nil)
30
+ from_canonical(Importers.import(dir, max_tokens: max_tokens, dimensions: dimensions, truncation: truncation))
31
+ end
32
+
33
+ def from_canonical(model)
34
+ dim = model.dimensions.output
35
+ floats = matrix_bytes(model.matrix).unpack("e*")
36
+ rows = model.tokens.length
37
+ new(
38
+ tokens: model.tokens,
39
+ matrix: Array.new(rows) { |index| floats[index * dim, dim] },
40
+ meta: meta_from_canonical(model)
41
+ )
42
+ end
43
+
44
+ def matrix_bytes(matrix)
45
+ return matrix unless matrix.respond_to?(:each_chunk)
46
+
47
+ matrix.each_chunk.each_with_object(Format.binary_string) { |chunk, packed| packed << chunk }
48
+ end
49
+
50
+ def meta_from_canonical(model)
51
+ tokenizer = model.tokenizer
52
+ runtime = model.runtime
53
+ {
54
+ dim: model.dimensions.output,
55
+ lowercase: tokenizer.fetch(:do_lower_case),
56
+ strip_accents: tokenizer.fetch(:strip_accents),
57
+ clean_text: tokenizer.fetch(:clean_text),
58
+ handle_chinese_chars: tokenizer.fetch(:handle_chinese_chars),
59
+ max_input_chars_per_word: tokenizer.fetch(:max_input_chars_per_word),
60
+ unk_token: model.tokens.fetch(tokenizer.fetch(:unk_id)),
61
+ normalize: runtime.normalization == Format::NORMALIZATION_L2,
62
+ max_tokens: runtime.max_tokens,
63
+ unk_policy: runtime.unk_policy,
64
+ truncation: runtime.truncation,
65
+ added_tokens: added_tokens(model)
66
+ }
67
+ end
68
+
69
+ def added_tokens(model)
70
+ mask = model.tokenizer.fetch(:added_token_mask)
71
+ BertWordPiece::STANDARD_SPECIAL_TOKENS.each_with_object({}) do |(content, bit), tokens|
72
+ next if (mask & bit).zero?
73
+
74
+ id = model.tokens.index(content)
75
+ tokens[content] = id unless id.nil?
76
+ end
74
77
  end
75
78
  end
76
79
 
@@ -93,8 +96,12 @@ module StaticEmbeddings
93
96
  end
94
97
 
95
98
  def embed(text, max_tokens: @meta[:max_tokens])
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
99
+ if @meta.fetch(:truncation, Format::TRUNCATE_USABLE_IDS_BEFORE_POOLING) == Format::TRUNCATE_RAW_IDS_BEFORE_UNK_DROP
100
+ ids = apply_unk_policy(tokenize(text, max_tokens: max_tokens))
101
+ else
102
+ ids = apply_unk_policy(tokenize(text, max_tokens: false))
103
+ ids = ids.first(max_tokens) if max_tokens && max_tokens.positive? && ids.length > max_tokens
104
+ end
98
105
  return Array.new(@dim, 0.0) if ids.empty?
99
106
 
100
107
  vector = pooled(ids)
@@ -4,6 +4,8 @@ module StaticEmbeddings
4
4
  module Safetensors
5
5
  MAX_HEADER_BYTES = 100 * 1024 * 1024
6
6
  SUPPORTED_DTYPES = { "F32" => 4, "F16" => 2, "BF16" => 2 }.freeze
7
+ AUX_DTYPES = { "F64" => 8, "I64" => 8, "I32" => 4 }.freeze
8
+ ELEMENT_SIZES = SUPPORTED_DTYPES.merge(AUX_DTYPES).freeze
7
9
  CONVERT_CHUNK_ELEMENTS = 64 * 1024
8
10
  IO_CHUNK_BYTES = 1024 * 1024
9
11
 
@@ -96,9 +98,35 @@ module StaticEmbeddings
96
98
  end
97
99
 
98
100
  def f32_payload(path, tensor)
101
+ unless SUPPORTED_DTYPES.key?(tensor.fetch(:dtype))
102
+ raise ConversionError,
103
+ "embedding matrix dtype #{tensor.fetch(:dtype)} is not supported (expected F32, F16, or BF16)"
104
+ end
99
105
  F32Payload.new(path, tensor)
100
106
  end
101
107
 
108
+ def read_vector(path, tensor)
109
+ bytes = File.open(path, "rb") do |io|
110
+ io.seek(tensor.fetch(:absolute_offset), IO::SEEK_SET)
111
+ io.read(tensor.fetch(:source_bytes))
112
+ end
113
+ unless bytes&.bytesize == tensor.fetch(:source_bytes)
114
+ raise ConversionError, "tensor data is truncated while reading #{path}"
115
+ end
116
+ bytes.force_encoding(Encoding::BINARY)
117
+
118
+ case tensor.fetch(:dtype)
119
+ when "F32" then bytes.unpack("e*")
120
+ when "F64" then bytes.unpack("E*")
121
+ when "F16" then convert_f16_to_f32(bytes).unpack("e*")
122
+ when "BF16" then convert_bf16_to_f32(bytes).unpack("e*")
123
+ when "I64" then bytes.unpack("q<*")
124
+ when "I32" then bytes.unpack("l<*")
125
+ else
126
+ raise ConversionError, "tensor dtype #{tensor[:dtype]} is not supported"
127
+ end
128
+ end
129
+
102
130
  def write(path, name, shape, floats)
103
131
  body = floats.pack("e*")
104
132
  write_raw(path, name, shape, "F32", body)
@@ -171,9 +199,10 @@ module StaticEmbeddings
171
199
  end
172
200
 
173
201
  def checked_tensor_bytes(name, dtype, shape)
174
- element_size = SUPPORTED_DTYPES[dtype]
202
+ element_size = ELEMENT_SIZES[dtype]
175
203
  unless element_size
176
- raise ConversionError, "tensor #{name}: dtype #{dtype} is not supported (expected F32, F16, or BF16)"
204
+ raise ConversionError,
205
+ "tensor #{name}: dtype #{dtype} is not supported (expected #{ELEMENT_SIZES.keys.join(', ')})"
177
206
  end
178
207
  unless shape.is_a?(Array) && shape.all? { |dim| dim.is_a?(Integer) && dim >= 0 }
179
208
  raise ConversionError, "tensor #{name}: malformed shape #{shape.inspect}"
@@ -1,3 +1,3 @@
1
1
  module StaticEmbeddings
2
- VERSION = "1.5.8"
2
+ VERSION = "1.5.9"
3
3
  end
@@ -0,0 +1,73 @@
1
+ require "static_embeddings/errors"
2
+ require "static_embeddings/format/constants"
3
+
4
+ module StaticEmbeddings
5
+ class WeightedPayload
6
+ FLOAT_BYTES = 4
7
+ ROWS_PER_CHUNK = 1024
8
+
9
+ attr_reader :bytesize
10
+
11
+ def initialize(inner, dim:, source_rows:, output_rows:, weights: nil, mapping: nil)
12
+ raise InvalidOptionError, "weights or mapping is required" if weights.nil? && mapping.nil?
13
+ unless inner.bytesize == source_rows * dim * FLOAT_BYTES
14
+ raise ConversionError, "embedding payload does not hold #{source_rows} rows of #{dim} floats"
15
+ end
16
+
17
+ @inner = inner
18
+ @row_bytes = dim * FLOAT_BYTES
19
+ @output_rows = output_rows
20
+ @weights = weights
21
+ @mapping = mapping
22
+ @bytesize = output_rows * @row_bytes
23
+ end
24
+
25
+ def each_chunk(&block)
26
+ return enum_for(__method__) unless block_given?
27
+
28
+ @mapping ? each_mapped_chunk(&block) : each_streamed_chunk(&block)
29
+ end
30
+
31
+ private
32
+
33
+ def each_mapped_chunk
34
+ table = Format.binary_string(@inner.bytesize)
35
+ @inner.each_chunk { |chunk| table << chunk }
36
+
37
+ index = 0
38
+ while index < @output_rows
39
+ count = [ROWS_PER_CHUNK, @output_rows - index].min
40
+ out = Format.binary_string(count * @row_bytes)
41
+ count.times do
42
+ row = table.byteslice(@mapping[index] * @row_bytes, @row_bytes)
43
+ out << (@weights ? scale(row, @weights[index]) : row)
44
+ index += 1
45
+ end
46
+ yield out
47
+ end
48
+ end
49
+
50
+ def each_streamed_chunk
51
+ remainder = Format.binary_string
52
+ index = 0
53
+ @inner.each_chunk do |chunk|
54
+ data = remainder.empty? ? chunk : remainder + chunk
55
+ whole = (data.bytesize / @row_bytes) * @row_bytes
56
+ remainder = (data.byteslice(whole, data.bytesize - whole) || Format.binary_string).force_encoding(Encoding::BINARY)
57
+ next if whole.zero?
58
+
59
+ out = Format.binary_string(whole)
60
+ (whole / @row_bytes).times do |r|
61
+ out << scale(data.byteslice(r * @row_bytes, @row_bytes), @weights[index])
62
+ index += 1
63
+ end
64
+ yield out
65
+ end
66
+ raise ConversionError, "truncated embedding matrix while applying weights" unless remainder.empty?
67
+ end
68
+
69
+ def scale(row, weight)
70
+ row.unpack("e*").map! { |x| x * weight }.pack("e*")
71
+ end
72
+ end
73
+ end
@@ -54,7 +54,7 @@ module StaticEmbeddings
54
54
  end
55
55
 
56
56
  def convert(source_dir, output_path:, model_id: nil, max_tokens: nil, dimensions: nil,
57
- source_revision: nil, trained_mrl_dims: nil)
57
+ source_revision: nil, trained_mrl_dims: nil, truncation: nil)
58
58
  require "static_embeddings/conversion"
59
59
  Conversion.call(
60
60
  source_dir,
@@ -63,7 +63,8 @@ module StaticEmbeddings
63
63
  max_tokens: max_tokens,
64
64
  dimensions: dimensions,
65
65
  source_revision: source_revision,
66
- trained_mrl_dims: trained_mrl_dims
66
+ trained_mrl_dims: trained_mrl_dims,
67
+ truncation: truncation
67
68
  )
68
69
  end
69
70
 
@@ -20,9 +20,15 @@ end
20
20
 
21
21
  model = StaticEmbeddings.load(options[:model], verify: true)
22
22
  payload = JSON.parse(File.read(options[:oracle], encoding: "UTF-8"))
23
- abort "unsupported oracle schema #{payload["schema_version"].inspect}" unless payload["schema_version"] == 2
23
+ schema = payload["schema_version"]
24
+ abort "unsupported oracle schema #{schema.inspect}" unless [2, 3].include?(schema)
24
25
 
25
26
  reference = payload.fetch("reference")
27
+ oracle_truncation = (reference["truncation"] || "usable_ids").to_sym
28
+ if oracle_truncation != model.truncation
29
+ abort "oracle truncation=#{oracle_truncation} (model2vec #{reference['model2vec']}) " \
30
+ "but model.truncation=#{model.truncation}; reconvert with --truncation #{oracle_truncation.to_s.tr('_', '-')}"
31
+ end
26
32
  max_length = Integer(reference.fetch("max_length"))
27
33
  if max_length != model.max_tokens
28
34
  abort "oracle max_length=#{max_length} but model.max_tokens=#{model.max_tokens}"
@@ -79,16 +85,17 @@ rows.each_with_index do |row, i|
79
85
  end
80
86
 
81
87
  full_raw = model.tokenize(text, max_tokens: false)
82
- expected_usable = row.fetch("static_usable_token_ids")
83
- got_usable = if reference["unk_token_id"].nil?
84
- full_raw.first(max_length)
88
+ expected_usable = row["static_pooled_token_ids"] || row.fetch("static_usable_token_ids")
89
+ drop_unk = ->(ids) { reference["unk_token_id"].nil? ? ids : ids.reject { |id| id == model.unk_id } }
90
+ got_usable = if model.truncation == :raw_ids
91
+ drop_unk.call(full_raw.first(max_length))
85
92
  else
86
- full_raw.reject { |id| id == model.unk_id }.first(max_length)
93
+ drop_unk.call(full_raw).first(max_length)
87
94
  end
88
95
  if got_usable != expected_usable
89
96
  usable_failures << i
90
97
  first = got_usable.zip(expected_usable).index { |a, b| a != b } || [got_usable.length, expected_usable.length].min
91
- problems << "usable ids differ at #{first} (got #{got_usable.length}, ref #{expected_usable.length})"
98
+ problems << "pooled ids differ at #{first} (got #{got_usable.length}, ref #{expected_usable.length})"
92
99
  end
93
100
 
94
101
  got_vector_blob = model.embed(text)
@@ -2,11 +2,14 @@ require "json"
2
2
  require "optparse"
3
3
  require "static_embeddings"
4
4
 
5
- options = { k: 10 }
5
+ options = { k: 10, bootstrap: 2000, seed: 42, per_query: false }
6
6
  parser = OptionParser.new do |opts|
7
7
  opts.on("--model PATH") { |value| options[:model] = value }
8
8
  opts.on("--dataset PATH") { |value| options[:dataset] = value }
9
9
  opts.on("--k N", Integer) { |value| options[:k] = value }
10
+ opts.on("--bootstrap N", Integer, "bootstrap resamples (0 disables)") { |value| options[:bootstrap] = value }
11
+ opts.on("--seed N", Integer) { |value| options[:seed] = value }
12
+ opts.on("--per-query", "include each query's first relevant rank") { options[:per_query] = true }
10
13
  end
11
14
  parser.parse!(ARGV)
12
15
  abort "usage: ruby -Ilib tools/eval_retrieval.rb --model MODEL.semb --dataset dataset.json" unless options[:model] && options[:dataset]
@@ -15,44 +18,109 @@ payload = JSON.parse(File.read(options[:dataset], encoding: "UTF-8"))
15
18
  docs = payload.fetch("documents")
16
19
  queries = payload.fetch("queries")
17
20
  k = options[:k]
21
+ abort "--k must be positive" unless k.positive?
22
+ abort "--bootstrap must be nonnegative" if options[:bootstrap].negative?
23
+ abort "dataset has no documents" if docs.empty?
24
+ abort "dataset has no queries" if queries.empty?
18
25
 
19
- model = StaticEmbeddings.load(options[:model], verify: true)
20
- matrix = model.embed_batch(docs.map { |doc| doc.fetch("text") })
26
+ doc_ids = docs.map { |doc| doc.fetch("id").to_s }
27
+ unknown = queries.flat_map { |q| q.fetch("relevant").map(&:to_s) } - doc_ids
28
+ abort "queries reference unknown document ids: #{unknown.uniq.inspect}" unless unknown.empty?
21
29
 
22
30
  def dcg(gains)
23
31
  gains.each_with_index.sum { |gain, i| gain / Math.log2(i + 2) }
24
32
  end
25
33
 
26
- mrr = 0.0
27
- ndcg = 0.0
28
- hits = 0
34
+ def query_metrics(ranking, relevant, k)
35
+ first = ranking.index { |id| relevant.include?(id) }
36
+ top = ranking.first(k)
37
+ gains = top.map { |id| relevant.include?(id) ? 1.0 : 0.0 }
38
+ ideal = Array.new([relevant.length, top.length].min, 1.0)
39
+ ideal_dcg = dcg(ideal)
40
+ {
41
+ first_rank: first && first + 1,
42
+ rr: first ? 1.0 / (first + 1) : 0.0,
43
+ rr_at_k: first && first < k ? 1.0 / (first + 1) : 0.0,
44
+ ndcg_at_k: ideal_dcg.positive? ? dcg(gains) / ideal_dcg : 0.0,
45
+ recall_at_k: relevant.empty? ? 0.0 : gains.sum / relevant.length,
46
+ hit_at_k: first && first < k ? 1.0 : 0.0
47
+ }
48
+ end
49
+
50
+ METRICS = %i[rr rr_at_k ndcg_at_k recall_at_k hit_at_k].freeze
51
+ NAMES = { rr: "mrr", rr_at_k: "mrr_at_k", ndcg_at_k: "ndcg_at_k", recall_at_k: "recall_at_k", hit_at_k: "hit_at_k" }.freeze
52
+
53
+ def mean(values)
54
+ values.sum / values.length.to_f
55
+ end
56
+
57
+ def summarize(per_query)
58
+ METRICS.to_h { |m| [NAMES.fetch(m), mean(per_query.map { |q| q.fetch(m) })] }
59
+ end
60
+
61
+ def bootstrap(per_query, resamples, rng)
62
+ return nil if resamples.zero?
63
+
64
+ n = per_query.length
65
+ samples = METRICS.to_h { |m| [m, []] }
66
+ resamples.times do
67
+ draw = Array.new(n) { per_query[rng.rand(n)] }
68
+ METRICS.each { |m| samples[m] << mean(draw.map { |q| q.fetch(m) }) }
69
+ end
70
+ METRICS.to_h do |m|
71
+ sorted = samples[m].sort
72
+ [NAMES.fetch(m), [sorted[(0.025 * (resamples - 1)).round], sorted[(0.975 * (resamples - 1)).round]]]
73
+ end
74
+ end
75
+
76
+ model = StaticEmbeddings.load(options[:model], verify: true)
77
+ matrix = model.embed_batch(docs.map { |doc| doc.fetch("text") }).freeze
78
+ rng = Random.new(options[:seed])
29
79
 
30
- queries.each do |query|
80
+ zero_queries = []
81
+ per_query = queries.map do |query|
31
82
  relevant = query.fetch("relevant").map(&:to_s)
32
83
  blob = model.embed(query.fetch("text"))
33
- ranked = model.cosine_top_k(blob, matrix, [k, docs.length].min)
34
- ids = ranked.map { |index, _| docs[index].fetch("id").to_s }
35
- rank = ids.index { |id| relevant.include?(id) }
36
- mrr += rank ? 1.0 / (rank + 1) : 0.0
37
- hits += 1 if rank && rank < k
38
- gains = ids.map { |id| relevant.include?(id) ? 1.0 : 0.0 }
39
- ideal = [1.0] * [relevant.length, k].min + [0.0] * [k - relevant.length, 0].max
40
- ideal = ideal.first(gains.length)
41
- denom = dcg(ideal)
42
- ndcg += denom.positive? ? dcg(gains) / denom : 0.0
43
- end
44
-
45
- n = queries.length.to_f
84
+ ranking =
85
+ if blob.unpack("e*").all?(&:zero?)
86
+ zero_queries << query.fetch("id", query.fetch("text")).to_s
87
+ []
88
+ else
89
+ model.cosine_top_k(blob, matrix, docs.length).map { |index, _| doc_ids[index] }
90
+ end
91
+
92
+ query_metrics(ranking, relevant, k).merge(id: query.fetch("id", nil))
93
+ end
94
+
95
+ random_runs = 200
96
+ random = Array.new(random_runs) do
97
+ summarize(queries.map { |query| query_metrics(doc_ids.shuffle(random: rng), query.fetch("relevant").map(&:to_s), k) })
98
+ end
99
+ random_baseline = random.first.keys.to_h { |key| [key, mean(random.map { |run| run.fetch(key) })] }
100
+
101
+ warnings = []
102
+ if k >= docs.length
103
+ warnings << "k=#{k} >= documents=#{docs.length}: hit_at_k and recall_at_k cannot distinguish rankings when queries have relevant documents"
104
+ end
105
+ if queries.length < 50
106
+ warnings << "only #{queries.length} queries: compare models by the bootstrap intervals, not the point estimates"
107
+ end
108
+ warnings << "#{zero_queries.length} queries embedded to a zero vector: #{zero_queries.inspect}" unless zero_queries.empty?
109
+
46
110
  result = {
47
111
  "model_id" => model.model_id,
48
112
  "dim" => model.dim,
49
113
  "normalized" => model.normalized?,
114
+ "truncation" => model.truncation,
50
115
  "queries" => queries.length,
51
116
  "documents" => docs.length,
52
117
  "k" => k,
53
- "mrr" => mrr / n,
54
- "ndcg_at_k" => ndcg / n,
55
- "hit_at_k" => hits / n
118
+ "metrics" => summarize(per_query),
119
+ "bootstrap_95" => bootstrap(per_query, options[:bootstrap], rng),
120
+ "random_baseline" => random_baseline,
121
+ "warnings" => warnings
56
122
  }
123
+ result["per_query"] = per_query.map { |q| { "id" => q[:id], "first_relevant_rank" => q[:first_rank] } } if options[:per_query]
124
+ warnings.each { |warning| warn "warning: #{warning}" }
57
125
  puts JSON.pretty_generate(result)
58
126
  model.close
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: static_embeddings
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.8
4
+ version: 1.5.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Haydarov
@@ -127,6 +127,7 @@ files:
127
127
  - lib/static_embeddings/safetensors.rb
128
128
  - lib/static_embeddings/unicode_tables.rb
129
129
  - lib/static_embeddings/version.rb
130
+ - lib/static_embeddings/weighted_payload.rb
130
131
  - static_embeddings.gemspec
131
132
  - tools/benchmark.rb
132
133
  - tools/build_demo_model.rb