static_embeddings
static_embeddings is a Ruby runtime for converted Model2Vec / potion static embedding models.
The current production target is minishlab/potion-retrieval-32M converted once into a local .semb file and loaded through a small C extension.
Runtime goals:
- no ONNX Runtime;
- no Rust or Python dependency at runtime;
- no runtime network access;
- no HuggingFace parsing in the hot path;
- one mmap-able model file;
- binary float32 output suitable for storing or passing to a vector index.
require "static_embeddings"
model = StaticEmbeddings.load_model("potion-retrieval-32m")
blob = model.("postgres pipeline mode in Ruby")
blob.bytesize == model.dim * 4
batch = model.([
"postgres pipeline mode in Ruby",
"local static embeddings without ONNX Runtime"
])
batch.bytesize == 2 * model.dim * 4
Runtime contract
The runtime does not load arbitrary HuggingFace models. It loads only .semb files produced by this repository's converter.
A .semb file contains:
- validated model metadata;
- a BERT WordPiece tokenizer profile;
- a mmap-ready vocabulary lookup table;
- float32 embedding rows;
- provenance and checksum data;
- the Model2Vec inference decisions needed by the C runtime.
The first supported tokenizer profile is BERT_WORDPIECE_V1. The converter must reject unsupported tokenizer features instead of approximating them.
Current model workflow
The expected workflow for potion-retrieval-32M is explicit.
git lfs install
git clone https://huggingface.co/minishlab/potion-retrieval-32M
bundle exec rake compile
bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
--id potion-retrieval-32m
The converted model is written to:
~/.cache/static_embeddings/models/potion-retrieval-32m.semb
Inspect and verify it:
bundle exec ruby -Ilib exe/static_embeddings inspect \
~/.cache/static_embeddings/models/potion-retrieval-32m.semb
bundle exec ruby -Ilib exe/static_embeddings verify \
~/.cache/static_embeddings/models/potion-retrieval-32m.semb
Load it from Ruby:
model = StaticEmbeddings.load_model("potion-retrieval-32m")
Or load the file directly:
model = StaticEmbeddings.load(
File.("~/.cache/static_embeddings/models/potion-retrieval-32m.semb")
)
Demo model
A tiny demo model can be generated for smoke tests without network access or external model files.
bundle exec rake demo_model
model = StaticEmbeddings.load_builtin
Treat it only as a test fixture and API demo. Do not use it as a retrieval quality baseline.
API
model.path
model.dim
model.vocab_size
model.max_tokens
model.normalized?
model.lowercase?
model.unk_id
model.mapped_bytes
model.provenance
model.model_id
vector_blob = model.("postgres pipeline mode in Ruby") # f32 by default
small_blob = model.("postgres pipeline mode in Ruby", format: :f16) # half-size storage
array = model.("postgres pipeline mode in Ruby")
batch_blob = model.(texts)
small_batch = model.(texts, format: :f16)
arrays = model.(texts)
stats = model.("postgres pipeline mode in Ruby")
stats[:vector]
stats[:token_count]
stats[:unk_count]
stats[:truncated]
ids = model.tokenize("postgres pipeline mode in Ruby")
vector_blob = model.(ids)
stats = model.(ids)
StaticEmbeddings.cosine_top_k(query_blob, matrix_blob, 10)
StaticEmbeddings.dot_top_k(query_blob, matrix_blob, 10)
StaticEmbeddings.cosine_top_k(query_f16, matrix_f16, 10, format: :f16)
Default embedding output is a binary String containing little-endian float32 values in row-major order.
Pass format: :f16 to embed, embed_batch, embed_with_stats, embed_token_ids,
or embed_token_ids_with_stats when you want IEEE float16 storage instead. For a
512-dimensional model this changes one vector from 512 * 4 = 2048 bytes to
512 * 2 = 1024 bytes. The model still computes in float32; format: only
controls the returned storage encoding.
embed_array and embed_batch_arrays are convenience methods and allocate Ruby Float objects.
They accept the same format: option and decode the returned storage format for debugging or application code.
embed_token_ids skips the tokenizer and pools the rows you give it. It applies
the same max_tokens truncation as embed, so embed_token_ids(model.tokenize(text))
equals embed(text). Pass max_tokens: false to pool every id. It exists for
debugging, for reusing a cached tokenization, and for benchmarking the pooling
loop in isolation; it is not a faster path for ordinary text.
Similarity helpers
cosine_top_k divides by both vector norms and returns cosine similarity in
[-1, 1]. It raises ArgumentError on a zero-norm query and scores zero-norm
rows as 0.0. Pass format: :f16 when both the query and matrix blobs are
float16-encoded.
dot_top_k returns the raw dot product with no normalization. Use it when your
vectors are already unit length — including output from a model whose
normalized? is true — because then the two agree and the dot product is
cheaper. Use cosine_top_k in every other case.
Both skip rows whose score is NaN rather than letting them into the result.
if model.normalized?
StaticEmbeddings.dot_top_k(query_blob, matrix_blob, 10)
else
StaticEmbeddings.cosine_top_k(query_blob, matrix_blob, 10)
end
Language safety
potion-retrieval-32M is an English retrieval model. Russian text can produce a high [UNK] ratio while still returning a valid vector.
Check unknown-token pressure before trusting a corpus:
stats = model.(text)
ratio = stats[:token_count].zero? ? 0.0 : stats[:unk_count].to_f / stats[:token_count]
warn "high [UNK] ratio: #{ratio.round(3)}" if ratio > 0.3
Use tokenize when vector quality looks wrong:
bundle exec ruby -Ilib exe/static_embeddings tokenize \
~/.cache/static_embeddings/models/potion-retrieval-32m.semb \
"postgres pipeline mode in Ruby"
Correctness contract
The reference implementation is model2vec.StaticModel.
The converter records these decisions in the .semb file:
- default truncation is 512 tokens;
- truncation happens after tokenization and before pooling;
[UNK]tokens are dropped;- input with no usable tokens returns a zero vector;
- vectors are L2-normalized when the source model requires it.
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.
Run the parity check with:
python3 -m venv .venv-model2vec && . .venv-model2vec/bin/activate
pip install -U model2vec numpy
python tools/model2vec_oracle.py minishlab/potion-retrieval-32M --out tmp/model2vec_oracle.json
bundle exec rake parity \
MODEL="$HOME/.cache/static_embeddings/models/potion-retrieval-32m.semb" \
ORACLE=tmp/model2vec_oracle.json
The oracle records token ids as well as vectors, and includes inputs on both sides of the large-input prefix window.
Token ids must match the reference exactly. Float vectors are checked with tolerance because floating-point addition order is not bit-stable across implementations.
Accepted vector tolerance:
cosine >= 1 - 1e-6
max_abs_diff < 1e-5
Large inputs
Ruby strings have to be copied into C-owned memory before the GVL can be
released, and that copy is charged to the calling thread while it still holds
the GVL. When truncation is active the runtime therefore does not copy the whole
string: it copies a leading slice sized from max_tokens, cut immediately after
an ASCII byte that ends a word, and only accepts the result once that slice has
actually reached max_tokens. If it has not, the budget doubles and the text is
tokenized again.
The result is identical to tokenizing the whole document — WordPiece
segmentation is word-local, and the cut is always on both a codepoint and a word
boundary — but the cost of embed stops growing with input size:
10 KB 43-47 us
100 KB 43-47 us
1 MB 43-47 us
3 MB 43-47 us
embed_batch applies the same strategy per text. With max_tokens: false there
is nothing to truncate, so the whole input is copied and processed.
Concurrency
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.
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.
When a Fiber::Scheduler is installed and the current fiber is non-blocking,
work above 2 KB is handed to a real thread that the fiber joins, so the
scheduler keeps running other fibers. Note that this creates one OS thread per
call: under high concurrency prefer batching over many small embed calls.
For Puma with preload_app!, load and warm the model before workers fork:
preload_app!
before_fork do
MODEL = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL"))
MODEL.warmup!
end
Docker / production
Convert models during image build or deploy preparation. Production runtime should only see .semb files.
RUN bundle exec ruby -Ilib exe/static_embeddings convert ./potion-retrieval-32M \
--id potion-retrieval-32m
Then set the path explicitly:
export EMBEDDING_MODEL=/root/.cache/static_embeddings/models/potion-retrieval-32m.semb
model = StaticEmbeddings.load(ENV.fetch("EMBEDDING_MODEL"))
Development
bundle install
bundle exec rake clean compile
bundle exec rake test
Build the synthetic fixture model:
ruby tools/make_fixture_model.rb
Run the benchmark:
ruby tools/benchmark.rb
Run the C memory smoke binary locally after building a fixture:
ruby tools/make_fixture_model.rb
ruby -Ilib -e 'require "static_embeddings"; StaticEmbeddings.convert("test/fixtures/tiny-wordpiece", output_path: "tmp/test-tiny.semb", model_id: "fixture/tiny-wordpiece")'
cc -O2 -std=c99 -Wall -Wextra -Iext/static_embeddings \
tools/memory_smoke.c \
ext/static_embeddings/se_format.c \
ext/static_embeddings/se_unicode.c \
ext/static_embeddings/se_tokenizer.c \
ext/static_embeddings/se_embed.c \
-lm -o tmp/memory_smoke
./tmp/memory_smoke tmp/test-tiny.semb
Notes
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.
License
MIT. See LICENSE.txt.