File size: 12,472 Bytes
588581a d9d575a 588581a d9d575a 588581a d9d575a 588581a d9d575a 588581a d9d575a 4380add 588581a d9d575a 588581a d9d575a 588581a d9d575a 588581a d9d575a 588581a d9d575a fb8e5c5 d9d575a fb8e5c5 d9d575a fb8e5c5 d9d575a 588581a d9d575a 588581a d9d575a 588581a d9d575a 74f228a d9d575a 74f228a d9d575a 74f228a d9d575a 74f228a d9d575a 74f228a d9d575a 74f228a d9d575a 74f228a d9d575a 74f228a d9d575a 588581a d9d575a 588581a d9d575a f547998 d9d575a f547998 d9d575a 588581a d9d575a 588581a d9d575a 588581a d9d575a 588581a d9d575a 6d58742 588581a d9d575a 588581a d9d575a 588581a 6d58742 588581a d9d575a 588581a d9d575a 588581a d9d575a 4380add d9d575a fb8e5c5 4380add d9d575a 588581a d9d575a 588581a d9d575a 588581a d9d575a 588581a d9d575a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 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 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | ---
license: mit
language: en
library_name: fastembed
tags:
- fastembed
- qdrant
- onnx
- retrieval
- asymmetric-dual-encoder
- edge
base_model: NovaSearch/stella_en_400M_v5
pipeline_tag: feature-extraction
---
# constella-zero
The query side of an **asymmetric dual encoder**: documents are indexed once, in the cloud, by a
large frozen encoder; queries are encoded on the device by **a lookup table**.
There is no transformer here. The model is 30,522 Γ 1024 int8 rows and one pooling rule β
encoding a query is a gather and a weighted sum. The query asset is **31.8 MB**, and the reference
implementation encodes a query end to end, tokenization included, in **0.38 ms** on one CPU core
(the ONNX graph alone runs an 8-token query in 0.047 ms β see [Costs](#costs)).
It was distilled from [`stella_en_400M_v5`](https://huggingface.co/NovaSearch/stella_en_400M_v5)
so that its output lands in that model's document space. The matching document encoder is
published as [`stella-en-400M-v5-doc-onnx`](https://huggingface.co/DylanCouzon/stella-en-400M-v5-doc-onnx);
the two are only meaningful together.
*constella = constellation + stella: navigate by fixed stars, no engine.*
> **Research preview.** It is a bag of tokens and behaves like one. Read
> [Results](#results) and [Limits](#limits) first.
## Usage
The snippets in this section run in order, sharing state.
```python
from fastembed import TextEmbedding
NAME = "DylanCouzon/constella-zero"
query_model = TextEmbedding(NAME)
q = next(iter(query_model.embed(["how do mrna vaccines work?"]))) # (1024,), L2-normalized
```
Not in a FastEmbed release yet. Until it is:
pip install "fastembed @ git+https://github.com/Dylancouzon/fastembed@add-constella-models"
FastEmbed fetches only `model.onnx` and the tokenizer β about 31 MB, not the whole repo. Pooling
and L2 normalization happen inside the graph.
### The document side
```python
DOC_NAME = "DylanCouzon/stella-en-400M-v5-doc-onnx"
doc_model = TextEmbedding(DOC_NAME) # 1.75 GB, runs in the cloud, once per document
docs = [
"mRNA vaccines deliver a strand of messenger RNA encoding a viral antigen.",
"The Treaty of Westphalia ended the Thirty Years' War in 1648.",
]
D = list(doc_model.embed(docs))
```
That asymmetry is the point: `doc_model` is a 400M-parameter transformer that runs once per
document. `query_model` runs on every query, on the device, and costs almost nothing.
### With Qdrant
```python
from qdrant_client import QdrantClient, models
client = QdrantClient(":memory:") # or your cluster
client.create_collection("docs", vectors_config=models.VectorParams(
size=1024, distance=models.Distance.COSINE))
client.upsert("docs", points=[
models.PointStruct(id=i, vector=D[i].tolist(), payload={"text": t})
for i, t in enumerate(docs)])
hits = client.query_points("docs", query=q.tolist(), limit=5).points
print(hits[0].payload["text"])
```
Qdrant implements cosine as a dot product β it normalizes on upsert and compares with dot β so
`COSINE` costs the same as `DOT` here without assuming the caller preserved unit norm.
The table itself can also live in Qdrant, as a retrieve-by-id collection of one point per vocab
row (`hnsw_config=models.HnswConfigDiff(m=0)` β indexing it is pure waste), so the query path holds
no model weights at all.
### Without FastEmbed
`zero_encoder.py` is the reference implementation β 93 lines, `numpy` and `tokenizers`, no torch.
This downloads the whole repo, not just the 31 MB graph.
```python
from huggingface_hub import snapshot_download
import sys, numpy as np
d = snapshot_download("DylanCouzon/constella-zero")
sys.path.insert(0, d)
from zero_encoder import ZeroQueryEncoder
enc = ZeroQueryEncoder(d, variant="int8") # or "fp16"
q_np = enc.encode(["how do mrna vaccines work?"]) # (1, 1024), L2-normalized
assert np.abs(q_np[0] - q).max() < 1e-5 # the vector FastEmbed just produced
```
## How it works
Tokenize (WordPiece, special tokens on, truncate at 512, no padding, no prefix). A token appearing
`c` times carries **total weight `sqrt(c)`** β repetition saturates. Sum the rows, divide by the
weight sum, L2-normalize. An empty or near-zero-norm bag falls back to the normalized `[CLS]` row
(id 101). Per-token learned weights are folded into the rows, so the artifact is self-contained.
Because pooling is not a masked mean, it is done inside the ONNX graph rather than by the caller.
`config.json` carries the rule and its fingerprint (`adb24fb2e8cad66f`).
`int8` is the variant every number below was measured on; it is loss-free against `fp16` to within
0.00013 nDCG@10.
## Files
You need exactly one of these three.
| file | for | size |
|---|---|---|
| `model.onnx` | FastEmbed, or any ONNX runtime β pooled and normalized, `(b, 1024)` | 31 MB |
| `model_tokens.onnx` | pipelines that insist on pooling themselves, `(b, s, 1024)` | 31 MB |
| `model.npz` | the numpy reference path | 94 MB |
Both graphs are opset 17, standard operators only, carrying the table as an int8 initializer with a
per-row fp32 scale dequantized in-graph.
The bundled tokenizer files are stella's, with `model_max_length`/`max_length` set to **512** and
`padding` to **null** β the rule the document index was built with. stella ships 32768/8000 and
fixed-512 padding, which any loader honouring those fields would otherwise apply.
`config.json` records the originals under `tokenizer_deviation_from_teacher`.
## Results
nDCG@10 on six BEIR datasets, exact search so ANN recall is not a confound. Measured once, on the
table shipped here (sha `a7007b1aβ¦`).
| system | arguana | fiqa | nfcorpus | scidocs | scifact | trec-covid | **average** |
|---|---|---|---|---|---|---|---|
| **constella-zero (int8)** | 0.5916 | 0.3728 | 0.3124 | 0.1677 | 0.6101 | 0.5490 | **0.4339** |
| **+ BM25, Qdrant `Fusion.DBSF`, prefetch 100** | 0.5800 | 0.3872 | 0.3442 | 0.1850 | 0.7173 | 0.7184 | **0.4887** |
| + BM25, convex fusion (not runnable in Qdrant) | 0.5975 | 0.4026 | 0.3497 | 0.1881 | 0.7068 | 0.7018 | **0.4911** |
| BM25 alone | 0.4878 | 0.2532 | 0.3180 | 0.1565 | 0.6791 | 0.6099 | 0.4174 |
| the teacher, used on both sides | 0.6369 | 0.5536 | 0.4134 | 0.2395 | 0.7796 | 0.8234 | 0.5744 |
A lookup table retains **75.5%** of the teacher's quality (0.4339 / 0.5744), with a query side
that does no matrix multiplication at all.
### Fusing with BM25 in Qdrant
**The recommended fused system is `Fusion.DBSF` with a prefetch limit of 100** β the row in bold
above. DBSF has **no fitted fusion weights**; the prefetch limit of 100 was chosen from where DBSF
saturates on our development set, plus a deployability criterion, so the configuration is
development-informed even though the operator itself fits nothing.
Fusion needs **named** vectors, so hybrid search gets its own collection:
```python
# The sparse side is whatever lexical model you use -- FastEmbed's `Qdrant/bm25`, or your own.
# Placeholder sparse vectors here, so this snippet runs with no extra download.
client.create_collection(
"hybrid",
vectors_config={"dense": models.VectorParams(size=1024, distance=models.Distance.COSINE)},
sparse_vectors_config={"bm25": models.SparseVectorParams()},
)
client.upsert("hybrid", points=[
models.PointStruct(
id=i,
vector={"dense": D[i].tolist(),
"bm25": models.SparseVector(indices=[i], values=[1.0])},
payload={"text": t})
for i, t in enumerate(docs)])
hits = client.query_points(
"hybrid",
prefetch=[
models.Prefetch(query=q.tolist(), using="dense", limit=100),
models.Prefetch(query=models.SparseVector(indices=[0], values=[1.0]),
using="bm25", limit=100),
],
query=models.FusionQuery(fusion=models.Fusion.DBSF),
limit=10,
).points
print(hits[0].payload["text"])
```
**On the four datasets with no disclosed teacher overlap** (see Limits), DBSF at prefetch 100 scores
**0.4912** against convex fusion's 0.4866; across all six, 0.4887 vs 0.4911. Both differences are
inside the ~0.005 band we treat as noise, and we computed no confidence interval for them, so read
this as **no measured quality difference in either direction** β not as DBSF being better. The
reason to prefer it is that it *runs in the product*, needs no 1000-deep prefetch, and removes a
tuned weight from the system.
The `convex fusion` row is retained for continuity: it was the operator of record when this model
was released. It is `0.8 Γ dense + 0.2 Γ BM25`, each channel divided by its per-query maximum, at
prefetch depth 1000 β **Qdrant does not implement it**, and a 1000-deep prefetch to return 10
results is not a realistic configuration.
`Fusion.RRF` is the weaker choice. We swept it fairly β `k` from 1 to 101 in Qdrant's units (best
`k=3`), and 24 weighted configurations (best `k=2, weights=[2, 1]`) β and its best point lands
below DBSF on our development set. An earlier version of this card said only that RRF "will not
reproduce" the fused row; that was true, but rested on an unweighted, badly-ranged comparison,
which has since been redone.
**Caveats.** Numbers use `bm25s` (lucene defaults), not Qdrant's own BM25, which has a fixed
`avg_len` and its own tokenizer; DBSF normalises over the returned scores, so a different lexical
implementation shifts its inputs.
Our evaluation excludes each query's own document *before* truncating to 100, so the numbers
describe a prefetch with a **self-exclusion filter** (`must_not` on the point id). Without one, a
plain `limit: 100` spends a slot on the self-match. This matters only where queries are also
documents β ArguAna (1,298 of 1,406 queries) and FiQA (55); the other four datasets have none, so
the clean-4 figures are unaffected either way.
## Limits
- **Teacher contamination.** stella discloses **ArguAna** and **FiQA** in its training data β
two of the six above, and ArguAna is its second-highest score. On the four sets with no
disclosed overlap it averages **0.4098 against BM25's 0.4409** β below BM25. Weight the
average accordingly.
- **It is a bag of tokens.** Word order, negation and syntax are not represented: "dog bites man"
and "man bites dog" give the same vector.
- **Out of domain it drops.** Training was Wikipedia- and e-commerce-shaped, and the six sets
above are further from that than the data it was fitted on.
- **English only**, 512 wordpieces, 30,522-token WordPiece vocab. Out-of-vocabulary terms degrade
to subword rows.
- **The document side is not cheap** β 2.05 GB per 1M documents at 1024-d fp16. The whole trade is
on the query side.
## Costs
| | |
|---|---|
| query asset (int8 rows + scales + tokenizer) | 31.8 MB |
| `model.onnx` graph execution, batch 1, one thread, 8-token query | 0.047 ms |
| `model.onnx` graph execution, batch 1, one thread, 512-token query | 1.22 ms |
| `zero_encoder.py` end to end, batch 1, one CPU core, incl. tokenization | 0.38 ms |
| hydration (cold load to first query) | 0.22 s |
| document vectors, 1024-d fp16 / int8 | 2.05 / 1.02 GB per 1M β raw payload, before index overhead |
The graph rows exclude tokenization; `zero_encoder.py`'s 0.38 ms is the end-to-end figure and the
honest one to compare against another encoder. No end-to-end FastEmbed timing is published here.
The graph derives token counts from an all-pairs comparison, so cost grows with the **square** of
sequence length β 26x from an 8-token query to a 512-token one. Real queries sit at the short end
(median 13 wordpieces).
## Training
L2 regression of the table's pooled output onto the teacher's query embeddings, over 340,850
pairs plus 220,632 query-text-only rows, from **Amazon ESCI**, **FEVER**, **HotpotQA**, **SQuAD**,
**NQ-open**, **TriviaQA** and **Mr. TyDi (en)**. No MS MARCO.
Attribution: NQ, SQuAD, HotpotQA, FEVER and Mr. TyDi are Wikipedia-derived and **CC BY-SA**
(3.0/4.0); Amazon ESCI and TriviaQA are Apache-2.0; the teacher is MIT.
## Provenance
```
run_id p35w-2m-s2500
table sha256 a7007b1a6af120b976f093fd69ddcb5001996ec0b84b5864b4fd25d7af878abf
teacher NovaSearch/stella_en_400M_v5 @ ffeb2b7ee715c226d4ffe5e4619f7dbb48624c20
preproc prefix="" Β· add_special_tokens Β· max_length=512 Β· pool_mode=sqrt
preproc fingerprint adb24fb2e8cad66f
```
Published as `zero-query-encoder-v1` and renamed on 2026-09-03; the old URL redirects.
|