Skip to content

Static embeddings for Ukrainian retrieval: 32.8 MB, about 0.1 ms per query on a CPU

ukr-static-m3-64k is a Model2Vec model distilled from BAAI/bge-m3. On three Ukrainian retrieval sets it scores 0.07–0.12 nDCG@10 higher than potion-multilingual-128M, with weights one-eighth the size at the same precision. Encoding a text is a table lookup and an average, so it needs no GPU. It does not replace BM25 for keyword-style questions: see when BM25 is better.

Ukrainian · 64,003 tokens × 256 dimensions · MIT

pip install model2vec

Also loads with sentence-transformers: example.

Encode Ukrainian text

python
from model2vec import StaticModel

model = StaticModel.from_pretrained("SyntaxSpirits/ukr-static-m3-64k")
embeddings = model.encode(["Київ — столиця України.", "Як оформити закордонний паспорт?"])

With sentence-transformers:

python
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("SyntaxSpirits/ukr-static-m3-64k").float()
embeddings = model.encode(["Київ — столиця України."])

Keep the .float(): the weights are stored in FP16, and without it sentence-transformers returns NaN for inputs that contain no known tokens, such as an empty string.

Embeddings are L2-normalised, so a dot product is the cosine similarity. Inputs that leave no known tokens, such as an empty string or "!!!", get a zero vector. Both loaders cut texts at 512 tokens, and Model2Vec by default also cuts at 3,584 characters before tokenizing. To embed whole texts with Model2Vec, pass max_length=None to model.encode; otherwise split long documents into chunks.

Retrieval quality

nDCG@10 for question → passage retrieval.

ModelWeightsBelebele ukWebFAQ ukUA-SQuAD val
ukr-static-m3-64k32.8 MB (FP16)0.7760.4340.684
potion-multilingual-128M512 MB (FP32)0.6520.3440.613
static-similarity-mrl-multilingual-v1434 MB (FP32)0.6170.352–
BM25–0.6930.4900.761
ukr-static-m3-64k + BM25–0.7700.5040.765
potion-multilingual-128M + BM25–0.7160.4620.734
BAAI/bge-m3 (teacher)2.27 GB (FP32)0.937––

The full evaluation notes, including overlap checks against the training text, are on the model card.

When BM25 is the better choice

On Belebele the model alone scores 0.776 against BM25's 0.693, and fusing the two does not improve on it (0.770). On WebFAQ and UA-SQuAD it is the other way round: BM25 alone scores higher (0.490 and 0.761, against 0.434 and 0.684), and fusing the two adds only +0.014 and +0.005 nDCG@10 over BM25 alone. UA-SQuAD is a Ukrainian translation of the SQuAD 2.0 dev set, so its questions share many words with their passages, which favours BM25.

For keyword-style questions, use the model alongside BM25, not instead of it. A minimal hybrid search with rank-bm25-rs and the same reciprocal-rank fusion as in the table, k = 60 over the top 100 of each ranking (pip install model2vec rank-bm25-rs):

python
import re

import numpy as np
from model2vec import StaticModel
from rank_bm25_rs import BM25Okapi

docs = [
    "Київ — столиця і найбільше місто України.",
    "Закордонний паспорт можна оформити в ЦНАП або в Державній міграційній службі.",
    "Дніпро — найдовша річка, що протікає Україною.",
]
query = "Де зробити закордонний паспорт?"


def words(text):
    return re.findall(r"\w+", text.lower())


def rrf(*scores, k=60, top=100):
    fused = np.zeros(len(docs))
    for s in scores:
        for rank, i in enumerate(np.argsort(-s, kind="stable")[:top], start=1):
            fused[i] += 1 / (k + rank)
    return np.argsort(-fused, kind="stable")


model = StaticModel.from_pretrained("SyntaxSpirits/ukr-static-m3-64k")
dense = model.encode(docs) @ model.encode([query])[0]
sparse = BM25Okapi([words(d) for d in docs]).get_scores(words(query))

docs[rrf(dense, sparse)[0]]
# 'Закордонний паспорт можна оформити в ЦНАП або в Державній міграційній службі.'

Size, speed and memory

At the same FP16 precision, POTION's weights would be 256 MB, 7.8 times this model's. Encoding the 900 Belebele questions one at a time after warm-up, on an Apple M1 Pro CPU:

ukr-static-m3-64kpotion-multilingual-128M
p95 time per query0.11 ms0.12–0.14 ms
Load time0.3 s3 s
Peak memory of the Python processabout 360 MiBabout 2.0 GiB

About 150 MiB of the peak memory is Python and the datasets library.

How it was built

  1. Vocabulary. The 32,000 most frequent lowercased Ukrainian words in 60 M characters of Ukrainian Wikipedia and FineWeb-2, plus the 32,000 BGE-M3 subword tokens most frequent in the same text, so that unseen word forms still get vectors. The tokenizer prefers whole words over subword splits.
  2. Distillation. Each token was embedded by BGE-M3, reduced to 256 dimensions with PCA and weighted by corpus frequency.
  3. Tokenlearn. The model was then trained with Tokenlearn to reproduce BGE-M3's mean token embedding on 119,430 chunks from the same sources. Chunks that share an 8-word sequence with Belebele or WebFAQ were removed.

Limitations

How to cite

Oleksandr Kholodniak (ORCID 0009-0000-1012-7361).

bibtex
@misc{kholodniak2026ukrstatic,
  author       = {Kholodniak, Oleksandr},
  title        = {ukr-static-m3-64k: a static embedding model for Ukrainian retrieval},
  year         = {2026},
  howpublished = {\url{https://huggingface.co/SyntaxSpirits/ukr-static-m3-64k}}
}

MIT, like BGE-M3

pip install model2vec

Model card and files: huggingface.co/SyntaxSpirits/ukr-static-m3-64k. Derived from BAAI/bge-m3 and its XLM-RoBERTa tokenizer (both MIT); trained on Ukrainian Wikipedia (CC BY-SA 4.0) and FineWeb-2 (ODC-By 1.0) text. The release contains token vectors and a vocabulary, no training passages. Questions and results that differ: open a discussion. An independent project, not affiliated with BAAI or MinishLab.