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.