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 +4 -4
- data/CHANGELOG.md +59 -0
- data/README.md +55 -25
- data/Rakefile +1 -1
- data/docs/ARCHITECTURE.md +11 -9
- data/docs/LIMITATIONS.md +8 -0
- data/docs/MODEL_AUDIT.md +41 -0
- data/ext/static_embeddings/se_embed.c +2 -2
- data/ext/static_embeddings/se_format.c +2 -1
- data/ext/static_embeddings/se_internal.h +7 -0
- data/ext/static_embeddings/static_embeddings.c +9 -1
- data/lib/static_embeddings/canonical.rb +9 -6
- data/lib/static_embeddings/cli.rb +7 -1
- data/lib/static_embeddings/conversion.rb +2 -0
- data/lib/static_embeddings/format/constants.rb +6 -1
- data/lib/static_embeddings/importers/model2vec.rb +20 -4
- data/lib/static_embeddings/importers/sentence_transformers_static.rb +3 -2
- data/lib/static_embeddings/importers/support.rb +60 -6
- data/lib/static_embeddings/provenance.rb +16 -5
- data/lib/static_embeddings/reference.rb +55 -48
- data/lib/static_embeddings/safetensors.rb +31 -2
- data/lib/static_embeddings/version.rb +1 -1
- data/lib/static_embeddings/weighted_payload.rb +73 -0
- data/lib/static_embeddings.rb +3 -2
- data/tools/check_model2vec_parity.rb +13 -6
- data/tools/eval_retrieval.rb +91 -23
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2de25b005846ea72148d725e56b30e25e1f0a3a8aace72cdada2865e0ec625f2
|
|
4
|
+
data.tar.gz: aeae810542ca9734a9fc6e1edf700950e856e054c6f2b1595d5553d5756ee2e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
153
|
-
|
|
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`
|
|
211
|
-
tokenized prefix to a few kilobytes even when
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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.
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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.
|
|
335
|
+
Pinned upstream: `model2vec 0.10.0`, `tokenizers 0.23.3`.
|
|
307
336
|
|
|
308
|
-
- `tokenize` matches the pinned Hugging Face `tokenizers
|
|
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
|
|
312
|
-
`
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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.
|
|
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
|
|
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
|
|
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]
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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.
|
|
8
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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 =
|
|
202
|
+
element_size = ELEMENT_SIZES[dtype]
|
|
175
203
|
unless element_size
|
|
176
|
-
raise ConversionError,
|
|
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}"
|
|
@@ -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
|
data/lib/static_embeddings.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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.
|
|
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 << "
|
|
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)
|
data/tools/eval_retrieval.rb
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
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.
|
|
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
|