static_embeddings 0.1.1 → 0.1.2
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 +121 -0
- data/README.md +167 -276
- data/docs/ARCHITECTURE.md +56 -6
- data/docs/MODEL_AUDIT.md +21 -8
- data/docs/PERFORMANCE.md +104 -36
- data/ext/static_embeddings/extconf.rb +4 -0
- data/ext/static_embeddings/se_alloc_stats.c +244 -0
- data/ext/static_embeddings/se_f16.c +378 -0
- data/ext/static_embeddings/se_format.c +235 -148
- data/ext/static_embeddings/se_internal.h +159 -0
- data/ext/static_embeddings/se_tokenizer.c +144 -41
- data/ext/static_embeddings/se_topk.c +236 -0
- data/ext/static_embeddings/static_embeddings.c +225 -744
- data/lib/static_embeddings/format.rb +22 -11
- data/lib/static_embeddings/version.rb +1 -1
- data/tools/benchmark.rb +13 -4
- metadata +4 -1
data/README.md
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
# static_embeddings
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
A Ruby runtime for converted Model2Vec / potion static embedding models. A model
|
|
4
|
+
is converted once into a local `.semb` file and loaded through a small C
|
|
5
|
+
extension. No ONNX Runtime, no Rust or Python at runtime, no network access, one
|
|
6
|
+
mmap-able file, binary float32 output.
|
|
5
7
|
|
|
6
|
-
|
|
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.
|
|
8
|
+
The current production target is `minishlab/potion-retrieval-32M`.
|
|
14
9
|
|
|
15
10
|
```ruby
|
|
16
11
|
require "static_embeddings"
|
|
@@ -24,99 +19,57 @@ batch = model.embed_batch([
|
|
|
24
19
|
"postgres pipeline mode in Ruby",
|
|
25
20
|
"local static embeddings without ONNX Runtime"
|
|
26
21
|
])
|
|
27
|
-
|
|
28
|
-
batch.bytesize == 2 * model.dim * 4
|
|
29
22
|
```
|
|
30
23
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
The runtime does not load arbitrary HuggingFace models. It loads only `.semb` files produced by this repository's converter.
|
|
24
|
+
New to the gem? Start with `GET_STARTED.md`. Design rationale is in
|
|
25
|
+
`docs/ARCHITECTURE.md`.
|
|
34
26
|
|
|
35
|
-
|
|
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.
|
|
27
|
+
## Runtime contract
|
|
43
28
|
|
|
44
|
-
The
|
|
29
|
+
The runtime loads only `.semb` files produced by this repository's converter,
|
|
30
|
+
never arbitrary HuggingFace models. A `.semb` file carries validated metadata, a
|
|
31
|
+
BERT WordPiece tokenizer profile, an mmap-ready vocabulary lookup, float32
|
|
32
|
+
embedding rows, and provenance plus a checksum.
|
|
45
33
|
|
|
46
|
-
|
|
34
|
+
The only supported tokenizer profile is `BERT_WORDPIECE_V1`. The converter
|
|
35
|
+
rejects unsupported tokenizer features rather than approximating them.
|
|
47
36
|
|
|
48
|
-
|
|
37
|
+
## Converting a model
|
|
49
38
|
|
|
50
39
|
```bash
|
|
51
40
|
git lfs install
|
|
52
41
|
git clone https://huggingface.co/minishlab/potion-retrieval-32M
|
|
53
42
|
|
|
54
43
|
bundle exec rake compile
|
|
55
|
-
|
|
56
44
|
bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
|
|
57
45
|
--id potion-retrieval-32m
|
|
58
46
|
```
|
|
59
47
|
|
|
60
|
-
The
|
|
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:
|
|
48
|
+
The result lands in `~/.cache/static_embeddings/models/potion-retrieval-32m.semb`.
|
|
49
|
+
Inspect or verify it with the `inspect` and `verify` subcommands, then load it:
|
|
77
50
|
|
|
78
51
|
```ruby
|
|
79
52
|
model = StaticEmbeddings.load_model("potion-retrieval-32m")
|
|
53
|
+
model = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL")) # explicit path
|
|
80
54
|
```
|
|
81
55
|
|
|
82
|
-
|
|
56
|
+
Convert during image build or deploy preparation; production should only ever
|
|
57
|
+
see `.semb` files.
|
|
83
58
|
|
|
84
|
-
|
|
85
|
-
model = StaticEmbeddings.load(
|
|
86
|
-
File.expand_path("~/.cache/static_embeddings/models/potion-retrieval-32m.semb")
|
|
87
|
-
)
|
|
88
|
-
```
|
|
59
|
+
### Verify once, not on every boot
|
|
89
60
|
|
|
90
|
-
`load` does not
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
61
|
+
`load` does not check the SHA-256 by default: hashing a 135 MB file at boot
|
|
62
|
+
would undo the point of mmapping it lazily. `verify` streams the file, so it
|
|
63
|
+
costs one chunk of memory rather than a copy of the model. Structural validation always runs,
|
|
64
|
+
so a malformed container is rejected, but a bit flip inside the matrix is not
|
|
65
|
+
detected and silently changes vectors. Verify where the artifact enters your
|
|
66
|
+
system:
|
|
96
67
|
|
|
97
68
|
```ruby
|
|
98
69
|
StaticEmbeddings.load(path, verify: true) # image build / CI
|
|
99
70
|
StaticEmbeddings.load(path) # hot path, artifact already trusted
|
|
100
71
|
```
|
|
101
72
|
|
|
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
73
|
## API
|
|
121
74
|
|
|
122
75
|
```ruby
|
|
@@ -133,6 +86,7 @@ model.model_id
|
|
|
133
86
|
|
|
134
87
|
vector_blob = model.embed("postgres pipeline mode in Ruby") # f32 by default
|
|
135
88
|
small_blob = model.embed("postgres pipeline mode in Ruby", format: :f16) # half-size storage
|
|
89
|
+
bounded = model.embed(huge_text, validate_encoding: :prefix) # see "Large inputs"
|
|
136
90
|
array = model.embed_array("postgres pipeline mode in Ruby")
|
|
137
91
|
|
|
138
92
|
batch_blob = model.embed_batch(texts)
|
|
@@ -145,8 +99,7 @@ stats[:token_count]
|
|
|
145
99
|
stats[:unk_count]
|
|
146
100
|
stats[:truncated]
|
|
147
101
|
|
|
148
|
-
ids
|
|
149
|
-
|
|
102
|
+
ids = model.tokenize("postgres pipeline mode in Ruby")
|
|
150
103
|
vector_blob = model.embed_token_ids(ids)
|
|
151
104
|
stats = model.embed_token_ids_with_stats(ids)
|
|
152
105
|
|
|
@@ -154,218 +107,138 @@ model.cosine_top_k(query_blob, matrix_blob, 10)
|
|
|
154
107
|
model.dot_top_k(query_blob, matrix_blob, 10)
|
|
155
108
|
|
|
156
109
|
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
110
|
StaticEmbeddings.pack(rows, format: :f16) # Array(s) of Float -> blob
|
|
160
111
|
StaticEmbeddings.unpack(blob, model.dim) # blob -> Array of Array(Float)
|
|
161
112
|
```
|
|
162
113
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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.
|
|
114
|
+
Output is a binary `String` of little-endian float32 values in row-major order.
|
|
115
|
+
`embed_array` and `embed_batch_arrays` decode that into Ruby `Float` objects and
|
|
116
|
+
exist for debugging and application code, not for the hot path.
|
|
194
117
|
|
|
195
|
-
|
|
118
|
+
`embed_token_ids` pools ids you supply, applying the same `max_tokens`
|
|
119
|
+
truncation as `embed`, so `embed_token_ids(model.tokenize(text))` equals
|
|
120
|
+
`embed(text)`. Pass `max_tokens: false` to pool every id. It is for reusing a
|
|
121
|
+
cached tokenization and for benchmarking pooling in isolation; it is not a
|
|
122
|
+
faster path for ordinary text.
|
|
196
123
|
|
|
197
|
-
`
|
|
198
|
-
`[-1, 1]`. It raises `ArgumentError` on a zero-norm query and scores zero-norm
|
|
199
|
-
rows as `0.0`.
|
|
124
|
+
### `format: :f16`
|
|
200
125
|
|
|
201
|
-
`
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
126
|
+
`format:` selects the returned storage encoding only. The model always computes
|
|
127
|
+
in float32. For a 512-dimensional model one vector goes from 2048 to 1024 bytes.
|
|
128
|
+
It is accepted by `embed`, `embed_batch`, `embed_with_stats`, `embed_token_ids`,
|
|
129
|
+
`embed_token_ids_with_stats`, `pack`, `unpack`, and the top-k helpers.
|
|
205
130
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
```
|
|
131
|
+
Choose `f16` for the RAM and storage it saves, not for speed. It halves the
|
|
132
|
+
bytes a top-k scan streams, but every row still has to be decoded before
|
|
133
|
+
scoring, and which effect wins is a property of the machine — this repository's
|
|
134
|
+
own samples show `f16` winning on x86-64 F16C and losing on M1 Pro. See
|
|
135
|
+
`docs/PERFORMANCE.md` before assuming either.
|
|
230
136
|
|
|
231
|
-
`
|
|
232
|
-
the
|
|
137
|
+
`StaticEmbeddings.simd_backend` reports the live kernel: `"neon-fp16"`,
|
|
138
|
+
`"f16c"`, or `"lut"` for the lookup-table fallback, which is several-fold
|
|
139
|
+
slower. Check it before drawing any conclusion from an `f16` benchmark.
|
|
233
140
|
|
|
234
|
-
|
|
141
|
+
### Similarity helpers
|
|
235
142
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
143
|
+
`cosine_top_k` divides by both norms and returns similarity in `[-1, 1]`. It
|
|
144
|
+
raises `ArgumentError` on a zero-norm query and scores zero-norm rows as `0.0`.
|
|
145
|
+
`dot_top_k` returns the raw dot product; use it when your vectors are already
|
|
146
|
+
unit length, including output from a model whose `normalized?` is `true`.
|
|
147
|
+
|
|
148
|
+
Both skip rows scoring `NaN` rather than admitting them to the result, so a
|
|
149
|
+
corrupt row is never reported as if it were merely empty.
|
|
150
|
+
|
|
151
|
+
Three contracts, each of which raises rather than guessing:
|
|
152
|
+
|
|
153
|
+
- **`dim:` is required on the module-level form.** A blob is bare bytes with no
|
|
154
|
+
dimension and no format tag, so an f32 query against an f16 matrix would
|
|
155
|
+
otherwise divide out to a plausible row count and return nonsense.
|
|
156
|
+
`model.cosine_top_k` and `model.dot_top_k` fill `dim:` in and are the
|
|
157
|
+
recommended form.
|
|
158
|
+
- **A matrix at or above 1 MiB must be frozen.** It is scanned with the GVL
|
|
159
|
+
released so other threads keep running, and a frozen `String` cannot be
|
|
160
|
+
mutated mid-scan. `allow_unfrozen: true` scans while holding the GVL: correct,
|
|
161
|
+
but it blocks every other thread for the duration.
|
|
162
|
+
- **A large `format: :f32` matrix must be 4-byte aligned.** Anything from
|
|
163
|
+
`pack`, `embed_batch` or `File.binread` is; a `byteslice` at an odd offset may
|
|
164
|
+
not be.
|
|
241
165
|
|
|
242
166
|
```ruby
|
|
243
167
|
MATRIX = model.embed_batch(corpus).freeze
|
|
244
|
-
|
|
245
168
|
model.dot_top_k(query, MATRIX, 10) # lock-free, concurrent, GVL released
|
|
246
169
|
```
|
|
247
170
|
|
|
248
|
-
|
|
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
|
|
171
|
+
## Large inputs
|
|
257
172
|
|
|
258
|
-
|
|
173
|
+
When truncation is active the runtime does not copy the whole string into C
|
|
174
|
+
memory. It copies a leading slice sized from `max_tokens`, cut on a word
|
|
175
|
+
boundary, and grows the budget only if that slice did not reach `max_tokens`.
|
|
176
|
+
The result is identical to tokenizing the whole document, because WordPiece
|
|
177
|
+
segmentation is word-local, but the bytes the tokenizer reads stop growing with
|
|
178
|
+
input size: with `max_tokens: 512` the window is a few kilobytes whether the
|
|
179
|
+
document is 10 KB or 3 MB.
|
|
259
180
|
|
|
260
|
-
|
|
181
|
+
That bounds the tokenizer, not the whole call. `embed` also has to establish
|
|
182
|
+
that the Ruby `String` is valid UTF-8, and when Ruby has not computed the
|
|
183
|
+
string's coderange yet that scan is O(total bytes) and happens before any
|
|
184
|
+
prefix is chosen:
|
|
261
185
|
|
|
262
186
|
```ruby
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
warn "high [UNK] ratio: #{ratio.round(3)}" if ratio > 0.3
|
|
187
|
+
text = File.read(path) # coderange unknown
|
|
188
|
+
model.embed(text) # full UTF-8 scan, then a few KB of tokenizing
|
|
189
|
+
model.embed(text) # coderange cached: prefix window only
|
|
267
190
|
```
|
|
268
191
|
|
|
269
|
-
|
|
192
|
+
Three ways out, in order of preference. Reuse the `String`, since Ruby caches
|
|
193
|
+
the coderange after the first scan. Force the scan once outside the hot path
|
|
194
|
+
with `text.valid_encoding?`. Or ask for validation to be bounded the same way
|
|
195
|
+
tokenizing is:
|
|
270
196
|
|
|
271
|
-
```
|
|
272
|
-
|
|
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
|
|
197
|
+
```ruby
|
|
198
|
+
model.embed(text, validate_encoding: :prefix)
|
|
311
199
|
```
|
|
312
200
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
```
|
|
201
|
+
`:prefix` skips the up-front scan and lets the tokenizer validate the bytes it
|
|
202
|
+
actually reads; malformed UTF-8 inside the window still raises. On a 3 MB
|
|
203
|
+
document that turned 442 µs into 78 µs, against 71 µs for the same string with
|
|
204
|
+
its coderange already cached.
|
|
333
205
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
206
|
+
The trade is real and worth stating: under `:prefix`, invalid bytes *past* the
|
|
207
|
+
truncation window are never looked at and no longer raise. `:full` is the
|
|
208
|
+
default and keeps the whole-string guarantee. A coderange Ruby has already
|
|
209
|
+
computed is honoured either way, so a `String` already known to be broken
|
|
210
|
+
raises in both modes, and one already known to be valid costs nothing in
|
|
211
|
+
either. The option is accepted by `embed`, `embed_batch`, `embed_with_stats`
|
|
212
|
+
and `tokenize`.
|
|
340
213
|
|
|
341
|
-
|
|
342
|
-
|
|
214
|
+
Text with no legal cut anywhere in the scan window — one enormous word, a run of
|
|
215
|
+
combining marks — falls back to copying the whole input. With
|
|
216
|
+
`max_tokens: false` there is nothing to truncate, so the whole input is copied.
|
|
217
|
+
`docs/ARCHITECTURE.md` explains what makes a cut legal.
|
|
343
218
|
|
|
344
219
|
`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
|
|
346
|
-
|
|
220
|
+
`String`, so peak memory for one call is about twice the returned blob. Chunk
|
|
221
|
+
very large corpora.
|
|
347
222
|
|
|
348
223
|
## Concurrency
|
|
349
224
|
|
|
350
|
-
`embed_batch`
|
|
351
|
-
|
|
352
|
-
|
|
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.
|
|
225
|
+
`embed_batch` snapshots its input, then releases the GVL for the C work, so one
|
|
226
|
+
request can compute embeddings while other Puma threads keep serving. Because
|
|
227
|
+
the snapshot is taken up front, mutating the Array afterwards does not corrupt
|
|
228
|
+
anything but is ignored.
|
|
358
229
|
|
|
359
230
|
`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
|
|
361
|
-
|
|
231
|
+
number of threads can search one shared corpus at once.
|
|
232
|
+
|
|
233
|
+
The runtime does not expose internal parallelism through `threads:`. For offline
|
|
234
|
+
indexing, split work at the job level and run multiple Ruby workers.
|
|
362
235
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
236
|
+
With a `Fiber::Scheduler` installed and the current fiber non-blocking, work
|
|
237
|
+
above 2 KB is handed to a real thread the fiber joins, so the scheduler keeps
|
|
238
|
+
running other fibers. That is one OS thread per call: under high concurrency,
|
|
239
|
+
prefer batching over many small `embed` calls.
|
|
367
240
|
|
|
368
|
-
For Puma with `preload_app!`, load and warm
|
|
241
|
+
For Puma with `preload_app!`, load and warm before workers fork:
|
|
369
242
|
|
|
370
243
|
```ruby
|
|
371
244
|
preload_app!
|
|
@@ -376,52 +249,70 @@ before_fork do
|
|
|
376
249
|
end
|
|
377
250
|
```
|
|
378
251
|
|
|
379
|
-
##
|
|
252
|
+
## Language safety
|
|
380
253
|
|
|
381
|
-
|
|
254
|
+
`potion-retrieval-32M` is an English retrieval model. Russian text can produce a
|
|
255
|
+
high `[UNK]` ratio while still returning a valid vector, which is the most
|
|
256
|
+
likely way to end up with a silently bad index. Check the ratio before trusting
|
|
257
|
+
a corpus:
|
|
382
258
|
|
|
383
|
-
```
|
|
384
|
-
|
|
385
|
-
|
|
259
|
+
```ruby
|
|
260
|
+
stats = model.embed_with_stats(text)
|
|
261
|
+
ratio = stats[:token_count].zero? ? 0.0 : stats[:unk_count].to_f / stats[:token_count]
|
|
262
|
+
|
|
263
|
+
warn "high [UNK] ratio: #{ratio.round(3)}" if ratio > 0.3
|
|
386
264
|
```
|
|
387
265
|
|
|
388
|
-
|
|
266
|
+
Out-of-vocabulary text is also slower, because every word falls through to
|
|
267
|
+
subword splitting instead of hitting the vocabulary directly.
|
|
389
268
|
|
|
390
|
-
|
|
391
|
-
export EMBEDDING_MODEL=/root/.cache/static_embeddings/models/potion-retrieval-32m.semb
|
|
392
|
-
```
|
|
269
|
+
## Correctness contract
|
|
393
270
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
271
|
+
The reference implementation is `model2vec.StaticModel`. The converter records
|
|
272
|
+
its decisions in the `.semb` file: truncation at 512 tokens by default, applied
|
|
273
|
+
after tokenization and before pooling; `[UNK]` tokens dropped; a zero vector for
|
|
274
|
+
input with no usable tokens; L2 normalization when the source model requires it.
|
|
397
275
|
|
|
398
|
-
|
|
276
|
+
Token ids must match the reference exactly. Vectors are compared with tolerance,
|
|
277
|
+
because floating-point addition order is not bit-stable across implementations:
|
|
399
278
|
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
bundle exec rake test
|
|
279
|
+
```text
|
|
280
|
+
cosine >= 1 - 1e-6
|
|
281
|
+
max_abs_diff < 1e-5
|
|
404
282
|
```
|
|
405
283
|
|
|
406
|
-
|
|
284
|
+
`docs/MODEL_AUDIT.md` records the parity result, digests and edge-case
|
|
285
|
+
behaviour for every trusted conversion, and the command to reproduce it. A new
|
|
286
|
+
conversion is not trusted until it has its own record.
|
|
287
|
+
|
|
288
|
+
## Development
|
|
407
289
|
|
|
408
290
|
```bash
|
|
409
|
-
|
|
291
|
+
bundle install
|
|
292
|
+
bundle exec rake # compile + fixtures + test
|
|
293
|
+
bundle exec rake demo_model # tiny synthetic model for smoke tests
|
|
410
294
|
```
|
|
411
295
|
|
|
412
|
-
|
|
296
|
+
`StaticEmbeddings.load_builtin` loads that demo model. It only works inside a
|
|
297
|
+
checkout, it is not shipped in the published gem, and
|
|
298
|
+
`StaticEmbeddings.builtin_available?` returns `false` when it is missing. It is
|
|
299
|
+
a test fixture and API demo, not a retrieval quality baseline.
|
|
413
300
|
|
|
414
301
|
```bash
|
|
415
|
-
ruby tools/benchmark.rb
|
|
302
|
+
ruby tools/benchmark.rb # normalised performance budget
|
|
303
|
+
bundle exec rake cancellation_timing # timing-sensitive, excluded from rake test
|
|
304
|
+
samples/run_all.sh # native hot-path probes; see samples/README.md
|
|
416
305
|
```
|
|
417
306
|
|
|
418
|
-
|
|
307
|
+
The C memory smoke binary runs without Ruby:
|
|
419
308
|
|
|
420
309
|
```bash
|
|
421
310
|
ruby tools/make_fixture_model.rb
|
|
422
311
|
ruby -Ilib -e 'require "static_embeddings"; StaticEmbeddings.convert("test/fixtures/tiny-wordpiece", output_path: "tmp/test-tiny.semb", model_id: "fixture/tiny-wordpiece")'
|
|
423
312
|
cc -O2 -std=c99 -Wall -Wextra -Iext/static_embeddings \
|
|
424
313
|
tools/memory_smoke.c \
|
|
314
|
+
ext/static_embeddings/se_f16.c \
|
|
315
|
+
ext/static_embeddings/se_topk.c \
|
|
425
316
|
ext/static_embeddings/se_format.c \
|
|
426
317
|
ext/static_embeddings/se_unicode.c \
|
|
427
318
|
ext/static_embeddings/se_tokenizer.c \
|
|
@@ -430,9 +321,9 @@ cc -O2 -std=c99 -Wall -Wextra -Iext/static_embeddings \
|
|
|
430
321
|
./tmp/memory_smoke tmp/test-tiny.semb
|
|
431
322
|
```
|
|
432
323
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
324
|
+
The runtime is intentionally narrow. If a model needs unsupported tokenizer
|
|
325
|
+
behaviour, fix the converter whitelist and audit it; do not make the C runtime
|
|
326
|
+
guess.
|
|
436
327
|
|
|
437
328
|
## License
|
|
438
329
|
|