static_embeddings 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +136 -0
- data/LICENSE.txt +21 -0
- data/README.md +439 -0
- data/docs/ARCHITECTURE.md +278 -0
- data/docs/MODEL_AUDIT.md +61 -0
- data/docs/PERFORMANCE.md +60 -0
- data/exe/static_embeddings +6 -0
- data/ext/static_embeddings/extconf.rb +44 -0
- data/ext/static_embeddings/se_embed.c +192 -0
- data/ext/static_embeddings/se_format.c +762 -0
- data/ext/static_embeddings/se_internal.h +266 -0
- data/ext/static_embeddings/se_tokenizer.c +506 -0
- data/ext/static_embeddings/se_unicode.c +120 -0
- data/ext/static_embeddings/static_embeddings.c +2070 -0
- data/lib/static_embeddings/cli.rb +178 -0
- data/lib/static_embeddings/converter.rb +284 -0
- data/lib/static_embeddings/errors.rb +6 -0
- data/lib/static_embeddings/format.rb +289 -0
- data/lib/static_embeddings/model.rb +48 -0
- data/lib/static_embeddings/paths.rb +29 -0
- data/lib/static_embeddings/reference.rb +191 -0
- data/lib/static_embeddings/safetensors.rb +87 -0
- data/lib/static_embeddings/unicode_tables.rb +127 -0
- data/lib/static_embeddings/version.rb +3 -0
- data/lib/static_embeddings.rb +118 -0
- data/static_embeddings.gemspec +45 -0
- data/tools/benchmark.rb +38 -0
- data/tools/build_demo_model.rb +16 -0
- data/tools/check_model2vec_parity.rb +107 -0
- data/tools/make_fixture_model.rb +165 -0
- metadata +134 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1a6d1a316162408c80a3de7afce3e1bc1f6e7cce0707c58df8737c86e0748183
|
|
4
|
+
data.tar.gz: c6e117117e09740437da333ec7203b62695b53233d5f64caa8cd15cc16f82d7c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 274b82bff6ffbe3f650687b9e30a611c919ffeedea859d98864857d095100f0142f3ed56954b9420eddf17ab9aac1d879dcf7648539af9a852c4d4a08df150c3
|
|
7
|
+
data.tar.gz: e9f11243e8e73ea99ab215620fa024b7cf78ccae8c5e9b2bb1d06dd88e60862fadaa37b8d2fb56b2251740a474c5d556f262e827b35ec898b1dfeadac938fc3d
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1 (unreleased)
|
|
4
|
+
|
|
5
|
+
Safety and correctness release. Everything here was found by re-reviewing 0.1.0
|
|
6
|
+
against a built extension rather than against the source, and every item has a
|
|
7
|
+
regression test that fails on 0.1.0.
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Concurrent `top_k` on a shared matrix (blocker).** 0.1.0 wrapped the matrix
|
|
12
|
+
in `rb_str_locktmp` before releasing the GVL. That is an exclusive lock, so a
|
|
13
|
+
second thread searching the same corpus got
|
|
14
|
+
`RuntimeError: temporal locking already locked string` - 900 failures out of
|
|
15
|
+
1200 calls with four threads over a 51 MB matrix. The lock is gone. A matrix
|
|
16
|
+
at or above 1 MiB must now be `frozen`, which makes mutation impossible and
|
|
17
|
+
lock-free concurrent scanning safe. A large unfrozen matrix raises
|
|
18
|
+
`ArgumentError`; `allow_unfrozen: true` scans it while holding the GVL.
|
|
19
|
+
- **`embed_batch` read the caller's Array after releasing the GVL (blocker).**
|
|
20
|
+
Elements were validated once and then re-read on every round, so another
|
|
21
|
+
thread replacing an element with a non-String turned a later `RSTRING_LEN`
|
|
22
|
+
into undefined behaviour - a reproducible `[BUG] Segmentation fault` from
|
|
23
|
+
ordinary Ruby. Input is now snapshotted into a private Array before any GVL
|
|
24
|
+
release, and the caller's Array is never read again.
|
|
25
|
+
- **Silent `f32`/`f16` mixing.** An `f32` query against an `f16` matrix used to
|
|
26
|
+
divide out to a consistent row count and return plausible garbage.
|
|
27
|
+
`StaticEmbeddings.cosine_top_k` / `dot_top_k` now require `dim:` and check the
|
|
28
|
+
query length against it. `Model#cosine_top_k` / `Model#dot_top_k` supply
|
|
29
|
+
`dim:` automatically and are the recommended form.
|
|
30
|
+
- **Prefix cutting only worked on ASCII.** The cut had to land on an ASCII
|
|
31
|
+
whitespace or punctuation byte, so CJK and NBSP-separated documents grew the
|
|
32
|
+
budget to the full length and copied the whole input under the GVL - cost
|
|
33
|
+
scaled linearly with input size again. `se_prefix_boundary_len` now uses the
|
|
34
|
+
tokenizer's own predicates: ASCII and Unicode whitespace, ASCII and Unicode
|
|
35
|
+
punctuation, and either side of a CJK codepoint, skipping any codepoint the
|
|
36
|
+
normaliser rewrites. A 24 MB CJK document went from 2.24 ms to 0.10 ms and is
|
|
37
|
+
byte-identical to `embed_token_ids(tokenize(text))`.
|
|
38
|
+
- A large unaligned matrix is rejected instead of being silently copied in full
|
|
39
|
+
while holding the GVL.
|
|
40
|
+
- **`f16` top-k was scalar and unusably slow on large corpora.** Half-precision
|
|
41
|
+
rows are now decoded with AArch64 `FCVTL` / x86 `VCVTPH2PS` kernels when
|
|
42
|
+
available, and with a lookup-table fallback otherwise. Measured on x86 with
|
|
43
|
+
F16C at dim 512 over 20k rows: 55 -> 978 ops/s, which is also 1.8x the `f32`
|
|
44
|
+
scan since it moves half the bytes. The fallback alone is 220 ops/s.
|
|
45
|
+
- The AArch64 kernel was gated on `__ARM_FEATURE_FP16_VECTOR_ARITHMETIC`, which
|
|
46
|
+
is only defined with an explicit `-mcpu`. Default builds - including
|
|
47
|
+
`rake compile` on Apple silicon - therefore fell back to the lookup table.
|
|
48
|
+
`vcvt_f32_f16` is baseline AArch64, so the gate is now just `__aarch64__`.
|
|
49
|
+
- The half-precision lookup table (256 KB) is populated only when the fallback
|
|
50
|
+
kernel is selected; native builds never touch those pages.
|
|
51
|
+
- `cosine_top_k` scored a row containing `NaN` as `0.0`, making a corrupt row
|
|
52
|
+
indistinguishable from a zero-norm one, while `dot_top_k` dropped it. Both
|
|
53
|
+
now drop it.
|
|
54
|
+
- `cold_start` reported `cold_page_cache=likely` from a ratio that cannot tell a
|
|
55
|
+
page-cache read from a device read - it said "cold" while faulting 132 MB in
|
|
56
|
+
4.3 ms. It now reports `warmup_mapped_bytes_per_sec` and
|
|
57
|
+
`page_cache_state=unknown` with instructions for a real cold measurement.
|
|
58
|
+
- The valgrind job no longer passes `--undef-value-errors=no`. The harness is
|
|
59
|
+
pure C with no Ruby noise, so the flag only hid the class of bug most likely
|
|
60
|
+
in a parser reading an mmapped file.
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- `StaticEmbeddings.encode_f16` / `decode_f16`: the storage codec that backs
|
|
65
|
+
`format: :f16`, exposed directly and covered by table tests for zero, negative
|
|
66
|
+
zero, subnormals, the smallest normal, the largest finite half, overflow to
|
|
67
|
+
infinity, mantissa carry and NaN. `Ruby`'s duplicate half decoder in `unpack`
|
|
68
|
+
was deleted; there is now one implementation.
|
|
69
|
+
- `StaticEmbeddings.pack`, the inverse of `unpack`.
|
|
70
|
+
- `StaticEmbeddings.simd_backend`, reporting the live `f16` kernel.
|
|
71
|
+
- `Model#cosine_top_k`, `Model#dot_top_k`.
|
|
72
|
+
- `test/top_k_contract_test.rb`, `test/input_snapshot_test.rb`,
|
|
73
|
+
`test/f16_codec_test.rb`, and Unicode-boundary cases in
|
|
74
|
+
`test/prefix_chunking_test.rb`.
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
|
|
78
|
+
- `se_scratch_reserve` no longer takes an `input_bytes` argument it ignored.
|
|
79
|
+
- README documents the frozen-matrix contract, the required `dim:`, the
|
|
80
|
+
non-ASCII prefix fallback, `verify: true` at artifact-entry time, the
|
|
81
|
+
`embed_batch` peak-memory factor of two, and that `load_builtin` is a
|
|
82
|
+
checkout-only fixture.
|
|
83
|
+
|
|
84
|
+
## 0.1.0
|
|
85
|
+
|
|
86
|
+
First working cut of the runtime and the offline converter.
|
|
87
|
+
|
|
88
|
+
- `.semb` v2 container: 320-byte header, 64-byte aligned sections, SHA-256
|
|
89
|
+
over the file with the checksum field zeroed, mandatory provenance section.
|
|
90
|
+
- C runtime: mmap loader with full bounds validation, read-only
|
|
91
|
+
open-addressing vocabulary hash table straight out of the mapping,
|
|
92
|
+
`BERT_WORDPIECE_V1` tokenizer, f32 mean pooling with optional L2
|
|
93
|
+
normalisation, binary vector output with `format: :f32` by default and compact
|
|
94
|
+
`format: :f16` storage for single embeddings, batches, and token-id pooling.
|
|
95
|
+
- Model semantics (`max_tokens`, unk policy, empty policy, normalizer flags)
|
|
96
|
+
are header fields, not hardcoded assumptions. Default truncation is 512
|
|
97
|
+
tokens, matching `model2vec.StaticModel` rather than `model_max_length`.
|
|
98
|
+
- Unicode tables (NFD, simple lowercase, Mn/P/C/Zs ranges) are generated by
|
|
99
|
+
the converter and shipped inside the model file. No utf8proc, no vendored
|
|
100
|
+
dependency of any kind.
|
|
101
|
+
- Concurrency: GVL released for batches above 2 KB, real-thread hand-off when
|
|
102
|
+
a `Fiber::Scheduler` is installed; internal `threads:` fan-out is intentionally rejected.
|
|
103
|
+
- Large inputs are not copied whole. When truncation is active `embed` and
|
|
104
|
+
`embed_batch` copy a leading slice sized from `max_tokens`, cut on a word
|
|
105
|
+
boundary, and grow it only if that slice did not reach `max_tokens`. Cost per
|
|
106
|
+
call stops scaling with input size (3 MB behaves like 10 KB).
|
|
107
|
+
- `cosine_top_k` returns real cosine similarity; `dot_top_k` returns the raw dot
|
|
108
|
+
product. They are separate kernels, not aliases. Both skip `NaN` rows and
|
|
109
|
+
accept `format: :f16` when query and matrix blobs are half-precision encoded.
|
|
110
|
+
- `embed_token_ids` / `embed_token_ids_with_stats` pool a caller-supplied id
|
|
111
|
+
array, honouring `max_tokens` truncation.
|
|
112
|
+
- Strict fail-closed converter: refuses non-WordPiece models, unknown
|
|
113
|
+
normalizer or pre-tokenizer types, non-standard `added_tokens`, and any
|
|
114
|
+
vocabulary/matrix size mismatch.
|
|
115
|
+
- CLI: `convert`, `verify`, `inspect`, `tokenize`, `embed`, `cache-path`.
|
|
116
|
+
- Tiny demo model is generated by `bundle exec rake demo_model` for smoke tests.
|
|
117
|
+
- Test suite runs offline against a synthetic source model: format fuzzing over
|
|
118
|
+
the whole file, tokenizer parity and fuzzing against a pure-Ruby reference,
|
|
119
|
+
vector tolerance, converter determinism, batch/thread determinism, GVL
|
|
120
|
+
release, and the large-input prefix path (including a sweep over every
|
|
121
|
+
printable ASCII separator).
|
|
122
|
+
- `tools/model2vec_oracle.py` and `tools/check_model2vec_parity.rb` compare
|
|
123
|
+
token ids and vectors against Python `model2vec.StaticModel`. Run via
|
|
124
|
+
`rake parity`.
|
|
125
|
+
|
|
126
|
+
### Known gaps
|
|
127
|
+
|
|
128
|
+
- `format: :f16` is a storage encoding, not a new model. The runtime computes in
|
|
129
|
+
float32, converts the returned blob to IEEE float16, and decodes half-precision
|
|
130
|
+
top-k inputs on the fly. Use `format: :f32` when exact float32 parity is needed.
|
|
131
|
+
|
|
132
|
+
### Not in this release
|
|
133
|
+
|
|
134
|
+
- int8 weights (the format has the slot, the runtime has no path)
|
|
135
|
+
- SIMD distance kernels
|
|
136
|
+
- SentencePiece / Unigram tokenizers
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roman Haydarov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
# static_embeddings
|
|
2
|
+
|
|
3
|
+
`static_embeddings` is a Ruby runtime for converted Model2Vec / potion static embedding models.
|
|
4
|
+
The current production target is `minishlab/potion-retrieval-32M` converted once into a local `.semb` file and loaded through a small C extension.
|
|
5
|
+
|
|
6
|
+
Runtime goals:
|
|
7
|
+
|
|
8
|
+
- no ONNX Runtime;
|
|
9
|
+
- no Rust or Python dependency at runtime;
|
|
10
|
+
- no runtime network access;
|
|
11
|
+
- no HuggingFace parsing in the hot path;
|
|
12
|
+
- one mmap-able model file;
|
|
13
|
+
- binary float32 output suitable for storing or passing to a vector index.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "static_embeddings"
|
|
17
|
+
|
|
18
|
+
model = StaticEmbeddings.load_model("potion-retrieval-32m")
|
|
19
|
+
|
|
20
|
+
blob = model.embed("postgres pipeline mode in Ruby")
|
|
21
|
+
blob.bytesize == model.dim * 4
|
|
22
|
+
|
|
23
|
+
batch = model.embed_batch([
|
|
24
|
+
"postgres pipeline mode in Ruby",
|
|
25
|
+
"local static embeddings without ONNX Runtime"
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
batch.bytesize == 2 * model.dim * 4
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Runtime contract
|
|
32
|
+
|
|
33
|
+
The runtime does not load arbitrary HuggingFace models. It loads only `.semb` files produced by this repository's converter.
|
|
34
|
+
|
|
35
|
+
A `.semb` file contains:
|
|
36
|
+
|
|
37
|
+
- validated model metadata;
|
|
38
|
+
- a BERT WordPiece tokenizer profile;
|
|
39
|
+
- a mmap-ready vocabulary lookup table;
|
|
40
|
+
- float32 embedding rows;
|
|
41
|
+
- provenance and checksum data;
|
|
42
|
+
- the Model2Vec inference decisions needed by the C runtime.
|
|
43
|
+
|
|
44
|
+
The first supported tokenizer profile is `BERT_WORDPIECE_V1`. The converter must reject unsupported tokenizer features instead of approximating them.
|
|
45
|
+
|
|
46
|
+
## Current model workflow
|
|
47
|
+
|
|
48
|
+
The expected workflow for `potion-retrieval-32M` is explicit.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git lfs install
|
|
52
|
+
git clone https://huggingface.co/minishlab/potion-retrieval-32M
|
|
53
|
+
|
|
54
|
+
bundle exec rake compile
|
|
55
|
+
|
|
56
|
+
bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
|
|
57
|
+
--id potion-retrieval-32m
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The converted model is written to:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
~/.cache/static_embeddings/models/potion-retrieval-32m.semb
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Inspect and verify it:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
bundle exec ruby -Ilib exe/static_embeddings inspect \
|
|
70
|
+
~/.cache/static_embeddings/models/potion-retrieval-32m.semb
|
|
71
|
+
|
|
72
|
+
bundle exec ruby -Ilib exe/static_embeddings verify \
|
|
73
|
+
~/.cache/static_embeddings/models/potion-retrieval-32m.semb
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Load it from Ruby:
|
|
77
|
+
|
|
78
|
+
```ruby
|
|
79
|
+
model = StaticEmbeddings.load_model("potion-retrieval-32m")
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Or load the file directly:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
model = StaticEmbeddings.load(
|
|
86
|
+
File.expand_path("~/.cache/static_embeddings/models/potion-retrieval-32m.semb")
|
|
87
|
+
)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`load` does not verify the SHA-256 by default: hashing a 135 MB file on every
|
|
91
|
+
boot would undo the point of mmapping it lazily. Structural validation always
|
|
92
|
+
runs, so a malformed container is rejected, but a bit flip inside the matrix is
|
|
93
|
+
not detected and silently changes the vectors. Verify once where the artifact
|
|
94
|
+
enters your system - during the image build, in CI, or right after conversion -
|
|
95
|
+
and load without verification at runtime:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
StaticEmbeddings.load(path, verify: true) # image build / CI
|
|
99
|
+
StaticEmbeddings.load(path) # hot path, artifact already trusted
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Demo model
|
|
103
|
+
|
|
104
|
+
A tiny demo model can be generated for smoke tests without network access or external model files.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bundle exec rake demo_model
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
model = StaticEmbeddings.load_builtin
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`load_builtin` only works inside a checkout after `rake demo_model` has run. The
|
|
115
|
+
demo model is a build artifact, it is not shipped in the published gem, and
|
|
116
|
+
`StaticEmbeddings.builtin_available?` returns `false` when it is missing. Treat
|
|
117
|
+
it only as a test fixture and API demo. Do not use it as a retrieval quality
|
|
118
|
+
baseline.
|
|
119
|
+
|
|
120
|
+
## API
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
model.path
|
|
124
|
+
model.dim
|
|
125
|
+
model.vocab_size
|
|
126
|
+
model.max_tokens
|
|
127
|
+
model.normalized?
|
|
128
|
+
model.lowercase?
|
|
129
|
+
model.unk_id
|
|
130
|
+
model.mapped_bytes
|
|
131
|
+
model.provenance
|
|
132
|
+
model.model_id
|
|
133
|
+
|
|
134
|
+
vector_blob = model.embed("postgres pipeline mode in Ruby") # f32 by default
|
|
135
|
+
small_blob = model.embed("postgres pipeline mode in Ruby", format: :f16) # half-size storage
|
|
136
|
+
array = model.embed_array("postgres pipeline mode in Ruby")
|
|
137
|
+
|
|
138
|
+
batch_blob = model.embed_batch(texts)
|
|
139
|
+
small_batch = model.embed_batch(texts, format: :f16)
|
|
140
|
+
arrays = model.embed_batch_arrays(texts)
|
|
141
|
+
|
|
142
|
+
stats = model.embed_with_stats("postgres pipeline mode in Ruby")
|
|
143
|
+
stats[:vector]
|
|
144
|
+
stats[:token_count]
|
|
145
|
+
stats[:unk_count]
|
|
146
|
+
stats[:truncated]
|
|
147
|
+
|
|
148
|
+
ids = model.tokenize("postgres pipeline mode in Ruby")
|
|
149
|
+
|
|
150
|
+
vector_blob = model.embed_token_ids(ids)
|
|
151
|
+
stats = model.embed_token_ids_with_stats(ids)
|
|
152
|
+
|
|
153
|
+
model.cosine_top_k(query_blob, matrix_blob, 10)
|
|
154
|
+
model.dot_top_k(query_blob, matrix_blob, 10)
|
|
155
|
+
|
|
156
|
+
StaticEmbeddings.cosine_top_k(query_blob, matrix_blob, 10, dim: model.dim)
|
|
157
|
+
StaticEmbeddings.cosine_top_k(query_f16, matrix_f16, 10, dim: model.dim, format: :f16)
|
|
158
|
+
|
|
159
|
+
StaticEmbeddings.pack(rows, format: :f16) # Array(s) of Float -> blob
|
|
160
|
+
StaticEmbeddings.unpack(blob, model.dim) # blob -> Array of Array(Float)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Default embedding output is a binary `String` containing little-endian float32 values in row-major order.
|
|
164
|
+
Pass `format: :f16` to `embed`, `embed_batch`, `embed_with_stats`, `embed_token_ids`,
|
|
165
|
+
or `embed_token_ids_with_stats` when you want IEEE float16 storage instead. For a
|
|
166
|
+
512-dimensional model this changes one vector from `512 * 4 = 2048` bytes to
|
|
167
|
+
`512 * 2 = 1024` bytes. The model still computes in float32; `format:` only
|
|
168
|
+
controls the returned storage encoding.
|
|
169
|
+
|
|
170
|
+
`embed_array` and `embed_batch_arrays` are convenience methods and allocate Ruby `Float` objects.
|
|
171
|
+
They accept the same `format:` option and decode the returned storage format for debugging or application code.
|
|
172
|
+
|
|
173
|
+
`f16` top-k scans decode half rows with native conversion where the CPU has it:
|
|
174
|
+
`FCVTL` on AArch64 (baseline, no `-mcpu` needed) and `VCVTPH2PS` on x86 when
|
|
175
|
+
CPUID reports F16C with OS support for AVX state. Everything else falls back to
|
|
176
|
+
a 65,536-entry lookup table, which is populated only when that fallback is
|
|
177
|
+
actually selected.
|
|
178
|
+
|
|
179
|
+
`StaticEmbeddings.simd_backend` reports which kernel is live and returns
|
|
180
|
+
`"neon-fp16"`, `"f16c"` or `"lut"`. Check it before drawing conclusions from an
|
|
181
|
+
`f16` benchmark - the difference between the native and fallback kernels is
|
|
182
|
+
several-fold.
|
|
183
|
+
|
|
184
|
+
With a native kernel `f16` search can be faster on memory-bound corpora, but it
|
|
185
|
+
is not guaranteed: half rows still have to be decoded before scoring. Treat
|
|
186
|
+
`f16` primarily as a storage/RAM trade-off and benchmark on the target CPU. On
|
|
187
|
+
the lookup-table fallback it is usually slower than `f32`.
|
|
188
|
+
|
|
189
|
+
`embed_token_ids` skips the tokenizer and pools the rows you give it. It applies
|
|
190
|
+
the same `max_tokens` truncation as `embed`, so `embed_token_ids(model.tokenize(text))`
|
|
191
|
+
equals `embed(text)`. Pass `max_tokens: false` to pool every id. It exists for
|
|
192
|
+
debugging, for reusing a cached tokenization, and for benchmarking the pooling
|
|
193
|
+
loop in isolation; it is not a faster path for ordinary text.
|
|
194
|
+
|
|
195
|
+
### Similarity helpers
|
|
196
|
+
|
|
197
|
+
`cosine_top_k` divides by both vector norms and returns cosine similarity in
|
|
198
|
+
`[-1, 1]`. It raises `ArgumentError` on a zero-norm query and scores zero-norm
|
|
199
|
+
rows as `0.0`.
|
|
200
|
+
|
|
201
|
+
`dot_top_k` returns the raw dot product with no normalization. Use it when your
|
|
202
|
+
vectors are already unit length, including output from a model whose
|
|
203
|
+
`normalized?` is `true`, because then the two agree and the dot product is
|
|
204
|
+
cheaper. Use `cosine_top_k` in every other case.
|
|
205
|
+
|
|
206
|
+
Both skip rows whose score is `NaN` rather than letting them into the result.
|
|
207
|
+
That includes a row that itself contains `NaN`: `cosine_top_k` returns `0.0`
|
|
208
|
+
only for a genuinely zero-norm row, so a corrupt row is never reported as if it
|
|
209
|
+
were merely empty.
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
if model.normalized?
|
|
213
|
+
model.dot_top_k(query_blob, matrix_blob, 10)
|
|
214
|
+
else
|
|
215
|
+
model.cosine_top_k(query_blob, matrix_blob, 10)
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
#### `dim:` is required on the module-level form
|
|
220
|
+
|
|
221
|
+
An embedding blob is bare bytes. It carries no dimension and no format tag, so
|
|
222
|
+
a query and a matrix stored in different formats can divide out to a consistent
|
|
223
|
+
row count and produce plausible nonsense. The module-level helpers therefore
|
|
224
|
+
require `dim:` and check the query against it:
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
StaticEmbeddings.cosine_top_k(query_f32, matrix_f16, 10, dim: 512, format: :f16)
|
|
228
|
+
# ArgumentError: query is 2048 bytes but dim: 512 with format :f16 needs 1024 bytes
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`model.cosine_top_k` / `model.dot_top_k` fill `dim:` in from the model and are
|
|
232
|
+
the recommended form.
|
|
233
|
+
|
|
234
|
+
#### The matrix must be frozen
|
|
235
|
+
|
|
236
|
+
A matrix at or above 1 MiB is scanned with the GVL released, so other Ruby
|
|
237
|
+
threads keep running while the scan is in flight. Nothing stops one of them from
|
|
238
|
+
reallocating the blob mid-scan, and copying a corpus-sized matrix on every query
|
|
239
|
+
would defeat the point of the API. A frozen `String` cannot be mutated at all,
|
|
240
|
+
so freezing is the contract:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
MATRIX = model.embed_batch(corpus).freeze
|
|
244
|
+
|
|
245
|
+
model.dot_top_k(query, MATRIX, 10) # lock-free, concurrent, GVL released
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Passing a large unfrozen matrix raises `ArgumentError`. If you cannot freeze it,
|
|
249
|
+
`allow_unfrozen: true` scans it while holding the GVL: correct, but it blocks
|
|
250
|
+
every other thread in the process for the duration of the scan.
|
|
251
|
+
|
|
252
|
+
A large matrix must also be 4-byte aligned when `format: :f32`. A blob produced
|
|
253
|
+
by `pack`, `embed_batch` or `File.binread` always is; a `byteslice` at an odd
|
|
254
|
+
offset may not be, and is rejected rather than silently copied.
|
|
255
|
+
|
|
256
|
+
## Language safety
|
|
257
|
+
|
|
258
|
+
`potion-retrieval-32M` is an English retrieval model. Russian text can produce a high `[UNK]` ratio while still returning a valid vector.
|
|
259
|
+
|
|
260
|
+
Check unknown-token pressure before trusting a corpus:
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
stats = model.embed_with_stats(text)
|
|
264
|
+
ratio = stats[:token_count].zero? ? 0.0 : stats[:unk_count].to_f / stats[:token_count]
|
|
265
|
+
|
|
266
|
+
warn "high [UNK] ratio: #{ratio.round(3)}" if ratio > 0.3
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Use `tokenize` when vector quality looks wrong:
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
bundle exec ruby -Ilib exe/static_embeddings tokenize \
|
|
273
|
+
~/.cache/static_embeddings/models/potion-retrieval-32m.semb \
|
|
274
|
+
"postgres pipeline mode in Ruby"
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
## Correctness contract
|
|
278
|
+
|
|
279
|
+
The reference implementation is `model2vec.StaticModel`.
|
|
280
|
+
|
|
281
|
+
The converter records these decisions in the `.semb` file:
|
|
282
|
+
|
|
283
|
+
- default truncation is 512 tokens;
|
|
284
|
+
- truncation happens after tokenization and before pooling;
|
|
285
|
+
- `[UNK]` tokens are dropped;
|
|
286
|
+
- input with no usable tokens returns a zero vector;
|
|
287
|
+
- vectors are L2-normalized when the source model requires it.
|
|
288
|
+
|
|
289
|
+
`docs/MODEL_AUDIT.md` records the source model revision, file digests, tokenizer facts, added-token audit, edge-case behavior and reference parity result for every trusted converted model. The audited `potion-retrieval-32m` conversion is recorded there with `.semb` bytes, SHA256, token-id parity and vector parity. New conversions must add their own parity record before they are treated as trusted.
|
|
290
|
+
|
|
291
|
+
Run the parity check with:
|
|
292
|
+
|
|
293
|
+
```bash
|
|
294
|
+
python3 -m venv .venv-model2vec && . .venv-model2vec/bin/activate
|
|
295
|
+
pip install -U model2vec numpy
|
|
296
|
+
python tools/model2vec_oracle.py minishlab/potion-retrieval-32M --out tmp/model2vec_oracle.json
|
|
297
|
+
bundle exec rake parity \
|
|
298
|
+
MODEL="$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb" \
|
|
299
|
+
ORACLE=tmp/model2vec_oracle.json
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
The oracle records token ids as well as vectors, and includes inputs on both sides of the large-input prefix window.
|
|
303
|
+
|
|
304
|
+
Token ids must match the reference exactly. Float vectors are checked with tolerance because floating-point addition order is not bit-stable across implementations.
|
|
305
|
+
|
|
306
|
+
Accepted vector tolerance:
|
|
307
|
+
|
|
308
|
+
```text
|
|
309
|
+
cosine >= 1 - 1e-6
|
|
310
|
+
max_abs_diff < 1e-5
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
## Large inputs
|
|
314
|
+
|
|
315
|
+
Ruby strings have to be copied into C-owned memory before the GVL can be
|
|
316
|
+
released, and that copy is charged to the calling thread while it still holds
|
|
317
|
+
the GVL. When truncation is active the runtime therefore does not copy the whole
|
|
318
|
+
string: it copies a leading slice sized from `max_tokens`, cut immediately after
|
|
319
|
+
an ASCII byte that ends a word, and only accepts the result once that slice has
|
|
320
|
+
actually reached `max_tokens`. If it has not, the budget doubles and the text is
|
|
321
|
+
tokenized again.
|
|
322
|
+
|
|
323
|
+
The result is identical to tokenizing the whole document — WordPiece
|
|
324
|
+
segmentation is word-local, and the cut is always on both a codepoint and a word
|
|
325
|
+
boundary — but the cost of `embed` stops growing with input size:
|
|
326
|
+
|
|
327
|
+
```text
|
|
328
|
+
10 KB 43-47 us
|
|
329
|
+
100 KB 43-47 us
|
|
330
|
+
1 MB 43-47 us
|
|
331
|
+
3 MB 43-47 us
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
A cut is legal where the tokenizer would have started a fresh word: after
|
|
335
|
+
whitespace, after punctuation, and on either side of a CJK codepoint, using the
|
|
336
|
+
model's own Unicode tables rather than an ASCII-only rule. Text with no legal
|
|
337
|
+
cut anywhere in the scan window - one enormous word, or a run of combining marks
|
|
338
|
+
- falls back to copying the whole input, which is the only correct thing to do
|
|
339
|
+
there.
|
|
340
|
+
|
|
341
|
+
`embed_batch` applies the same strategy per text. With `max_tokens: false` there
|
|
342
|
+
is nothing to truncate, so the whole input is copied and processed.
|
|
343
|
+
|
|
344
|
+
`embed_batch` builds its result in C memory and then copies it into a Ruby
|
|
345
|
+
`String`, so peak memory for one call is about twice the size of the returned
|
|
346
|
+
blob. Split very large corpora into chunks if that matters.
|
|
347
|
+
|
|
348
|
+
## Concurrency
|
|
349
|
+
|
|
350
|
+
`embed_batch` copies Ruby input into C-owned memory, then releases the GVL for large C computations. That is the intended web-runtime behaviour: one request can run a CPU-bound embedding batch while other Puma threads in the same process continue serving work.
|
|
351
|
+
|
|
352
|
+
The runtime does not expose internal parallelism through `threads:`. For offline indexing, split work at the application/job level and run multiple Ruby workers explicitly.
|
|
353
|
+
|
|
354
|
+
Do not mutate an Array while it is being embedded. `embed_batch` snapshots the
|
|
355
|
+
elements it was given before it releases the GVL, so a concurrent mutation
|
|
356
|
+
cannot corrupt memory, but the call embeds the snapshot and ignores anything
|
|
357
|
+
that happened afterwards.
|
|
358
|
+
|
|
359
|
+
`cosine_top_k` and `dot_top_k` scan a frozen matrix without any lock, so any
|
|
360
|
+
number of threads can search one shared corpus at the same time. See the
|
|
361
|
+
similarity helpers section for the frozen-matrix contract.
|
|
362
|
+
|
|
363
|
+
When a `Fiber::Scheduler` is installed and the current fiber is non-blocking,
|
|
364
|
+
work above 2 KB is handed to a real thread that the fiber joins, so the
|
|
365
|
+
scheduler keeps running other fibers. Note that this creates one OS thread per
|
|
366
|
+
call: under high concurrency prefer batching over many small `embed` calls.
|
|
367
|
+
|
|
368
|
+
For Puma with `preload_app!`, load and warm the model before workers fork:
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
preload_app!
|
|
372
|
+
|
|
373
|
+
before_fork do
|
|
374
|
+
MODEL = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL"))
|
|
375
|
+
MODEL.warmup!
|
|
376
|
+
end
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
## Docker / production
|
|
380
|
+
|
|
381
|
+
Convert models during image build or deploy preparation. Production runtime should only see `.semb` files.
|
|
382
|
+
|
|
383
|
+
```dockerfile
|
|
384
|
+
RUN bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
|
|
385
|
+
--id potion-retrieval-32m
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
Then set the path explicitly:
|
|
389
|
+
|
|
390
|
+
```bash
|
|
391
|
+
export EMBEDDING_MODEL=/root/.cache/static_embeddings/models/potion-retrieval-32m.semb
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
model = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL"))
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
## Development
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
bundle install
|
|
402
|
+
bundle exec rake clean compile
|
|
403
|
+
bundle exec rake test
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
Build the synthetic fixture model:
|
|
407
|
+
|
|
408
|
+
```bash
|
|
409
|
+
ruby tools/make_fixture_model.rb
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Run the benchmark:
|
|
413
|
+
|
|
414
|
+
```bash
|
|
415
|
+
ruby tools/benchmark.rb
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
Run the C memory smoke binary locally after building a fixture:
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
ruby tools/make_fixture_model.rb
|
|
422
|
+
ruby -Ilib -e 'require "static_embeddings"; StaticEmbeddings.convert("test/fixtures/tiny-wordpiece", output_path: "tmp/test-tiny.semb", model_id: "fixture/tiny-wordpiece")'
|
|
423
|
+
cc -O2 -std=c99 -Wall -Wextra -Iext/static_embeddings \
|
|
424
|
+
tools/memory_smoke.c \
|
|
425
|
+
ext/static_embeddings/se_format.c \
|
|
426
|
+
ext/static_embeddings/se_unicode.c \
|
|
427
|
+
ext/static_embeddings/se_tokenizer.c \
|
|
428
|
+
ext/static_embeddings/se_embed.c \
|
|
429
|
+
-lm -o tmp/memory_smoke
|
|
430
|
+
./tmp/memory_smoke tmp/test-tiny.semb
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
## Notes
|
|
434
|
+
|
|
435
|
+
The runtime is intentionally narrow. If a model requires unsupported tokenizer behavior, fix the converter whitelist and audit first; do not make the C runtime guess.
|
|
436
|
+
|
|
437
|
+
## License
|
|
438
|
+
|
|
439
|
+
MIT. See `LICENSE.txt`.
|