Retrieval 2 - Fine-tuning a small embedding model gives surprising results
I fine-tuned bge-base-en-v1.5, a 110 million parameter embedding model, on real Amazon shopping queries. One epoch, 8 minutes on a single GPU, $0.38.
It scores 0.460 ± 0.002 recall@20 across three training seeds, against 0.455 for Qwen3-Embedding-8B off the shelf. So it matches or narrowly beats the 8B model on recall@20, and clearly beats it on recall@10, at 1/70 of the parameters and 1/25 of the encode time.
I did not want to claim that on the strength of one run, so there are two checks behind it. The first is a paired bootstrap, which redraws the 8,955 test queries 2,000 times with replacement and scores every system on the same redraw. The fine-tuned model is ahead on recall@20 in 98.8% of resamples - +0.006, with a 95% interval of +0.001 to +0.011, just above zero. On recall@10 it is ahead in 100% of resamples, +0.015, interval +0.011 to +0.020. A narrow but real edge at k = 20, and a comfortable one at k = 10.
This is the second of three posts. The first built the benchmark and the baselines. The third puts a reranker on top.
The bar
From the previous post, on the same frozen 8,955 product-disjoint test queries over the same 482,105-product corpus. The Shopping Queries Dataset (ESCI) labels products Exact, Substitute, Complement or Irrelevant, and recall@20 is the share of a query's Exact products inside the 20 candidates stage 1 hands to the reranker.
| System | Params | recall@20 |
|---|---|---|
| BM25, lexical, no model | 0 | 0.383 |
| bge-base-en-v1.5, off the shelf | 110M | 0.421 |
| Hybrid: BM25 + bge-base | – | 0.437 |
| bge-large-en-v1.5, off the shelf | 335M | 0.436 |
| Qwen3-Embedding-8B, off the shelf | 7.6B | 0.455 |
0.421 is where the 110M model starts. 0.455 is the best off-the-shelf number. 0.437 is what hybrid fusion gives for free.
The five negative strategies
Full fine-tune with MultipleNegativesRankingLoss. One epoch over 54,880 rows, which is 20,039 product-disjoint training queries times up to 3 Exact products each, at batch 64, learning rate 2e-5, 256 tokens, one A10G per run. 858 steps, about 8 minutes.
The question I wanted answered was which negatives teach the model most. So five runs differ in exactly one thing - which negative is paired with each row.
--negatives | The negative is | What it tests |
|---|---|---|
labelled | A Substitute, Complement or Irrelevant judged for the same query | Human-confirmed hard negatives, the ESCI-specific asset |
substitutes | A Substitute only, the closest misses | Whether the hardest labelled negatives are better or just noisier |
mined | One of the base model's top-30 for the query that is not labelled Exact | The standard recipe when no labels exist |
random | A random product from the catalogue | The control: one more negative, no information in it |
none | No third column, only the other products in the batch push back | The floor |
When a query has no negative of the requested kind, the row falls back to a random product. I report that count, so the comparison is honest about how often it happens.
if negatives == "labelled":
pool = [p for p in g.loc[g["esci_label"].isin(["S", "C", "I"]), "product_id"] if p in text]
elif negatives == "substitutes":
pool = [p for p in g.loc[g["esci_label"] == "S", "product_id"] if p in text]
elif negatives == "mined":
pool = [p for p in (mined or {}).get(int(qid), []) if p in text]
else:
pool = []
...
for p in exact[:max_pos]:
row = {"anchor": q, "positive": text[p]}
if negatives != "none":
if pool:
neg = pool[rng.integers(len(pool))]
else:
fallback += 1 # reported in the run's report.json
neg = all_ids[rng.integers(len(all_ids))]
One training row shows the difficulty. For the query "awnmower tires without rims" the Exact product is a MARASTAR 15x6.00-6 front tire assembly for a Craftsman mower. The Substitute pool contains a "2PK 13x5.00-6 Turf Mower Tractor Tire with Gray Rim" - almost every word shared with the positive, separated only by "with rim", which is the one part the customer cares about. BM25 cannot see that. The base model barely can.
Two implementation details mattered more than they look.
loss = losses.MultipleNegativesRankingLoss(model)
targs = SentenceTransformerTrainingArguments(
per_device_train_batch_size=64,
learning_rate=2e-5,
warmup_ratio=0.1,
lr_scheduler_type="cosine",
batch_sampler=BatchSamplers.NO_DUPLICATES, # two rows of one query in a batch would make
# its other Exact product a false negative
prompts={"anchor": prompt}, # bge's query prefix, on the anchor column only
)
MultipleNegativesRankingLoss scores every query in a batch of 64 against all 128 passages in it, and trains a 128-way classification whose right answer is the query's own positive. Every other passage, including the 64 hard negatives, pushes back.
With several Exact products per query, NO_DUPLICATES is not optional. Two rows of the same query in one batch would train the model to push a genuine Exact product away. And bge's prefix goes on queries only, at training and at search time. Encoding passages with it, or queries without it, costs points silently.
Results
| System | Params | recall@10 | recall@20 | recall@50 | hit@20 | MRR@20 | nDCG@20 |
|---|---|---|---|---|---|---|---|
| bge-base, off the shelf | 110M | 0.320 | 0.421 | 0.544 | 0.794 | 0.510 | 0.370 |
| Hybrid: BM25 + bge-base | – | 0.331 | 0.437 | 0.570 | 0.803 | 0.525 | 0.385 |
| bge-large, off the shelf | 335M | 0.332 | 0.436 | 0.563 | 0.801 | 0.520 | 0.384 |
| Qwen3-Embedding-8B, off the shelf | 7.6B | 0.341 | 0.455 | 0.595 | 0.817 | 0.532 | 0.401 |
| Fine-tuned, pairs only | 110M | 0.333 | 0.439 | 0.567 | 0.800 | 0.525 | 0.390 |
| Fine-tuned, random negatives | 110M | 0.336 | 0.440 | 0.571 | 0.802 | 0.526 | 0.391 |
| Fine-tuned, mined negatives | 110M | 0.323 | 0.424 | 0.542 | 0.776 | 0.501 | 0.372 |
| Fine-tuned, Substitute negatives | 110M | 0.356 | 0.461 | 0.587 | 0.815 | 0.549 | 0.410 |
| Fine-tuned, labelled S/C/I negatives | 110M | 0.356 | 0.461 | 0.585 | 0.810 | 0.549 | 0.411 |
| Hybrid: BM25 + fine-tuned, Substitute run | – | 0.353 | 0.464 | 0.600 | 0.825 | 0.550 | 0.410 |

The comparison between those five runs is the main finding of the whole project.
The bootstrap puts that far outside noise. Labelled over random is +0.021, interval +0.018 to +0.024, ahead in 100% of resamples. Labelled over pairs-only is +0.022, also 100%.
Substitutes alone, at 0.461, ties with the full S/C/I mix - the difference is −0.000, interval −0.003 to +0.002, labelled ahead in only 38% of resamples. So the Substitutes are doing the work, and the Complements and Irrelevants add little. That is worth noting, because 15.6% of the Substitutes-only rows fell back to a random negative when the query had no Substitute at all.
Mined negatives hurt. The standard recipe when you have no labels is to take the base model's top ranked non-Exact products. That lands at 0.424, which is below pairs-only at 0.439, and against the untrained 0.421 it is a tie - +0.003, interval −0.002 to +0.008, ahead in 88% of resamples. Eight minutes of training for nothing measurable.
Hybrid still adds a little on top, but not separably. Fusing BM25 with the fine-tuned model gives 0.464 recall@20, 0.600 recall@50 and 0.825 hit@20, the best stage-1 numbers in the project. But the gain over the fine-tuned model alone is +0.002, interval −0.002 to +0.006, ahead in 85% of resamples, so it is within noise. Fusion added +0.016 to the base model and nothing measurable to the fine-tuned one, because the dense side now covers what BM25 used to contribute.
One guard rail I would copy. The before row recomputed inside the labelled run reproduced the baseline bge-base numbers exactly, 0.421 / 0.544 / 0.794. Same harness, not a reimplementation.

The left panel caught me out at first. Random and none converge to 0.3 to 0.45, the hard-negative runs sit at 0.9 to 1.0, and the hard runs are the better models. Hard negatives make the 128-way batch classification harder by design, so training loss is not comparable across negative kinds. Only the dev and test metrics rank the runs.
Dev recall@20 ranked them the same way as the test set - labelled 0.784, substitutes 0.785, random 0.775, none 0.775, mined 0.758. Different level, same order.
Where each model wins
Aggregate parity hides disagreement. Per query against Qwen3-8B on recall@20, the fine-tuned model is better on 30.8%, worse on 28.0% and equal on 41.2%. The two are close in the mean and disagree on most of the hard queries.
Against its own base model it is better on 32.8% and worse on 16.6%, and the share of test queries with no Exact product in the top 20 falls from 20.5% to 18.5%. Qwen3-8B is at 18.3% and BM25 at 25.2%.
The three groups make sense when you read them. The gains are constraint-heavy queries the base model fumbled, often going from 0.0 to 1.0 - ".5 inch suction cups without hooks", "10x3 cheesecake boxes without windows", "1/4 plastic ball valve", "50 ton porta power". The losses are single-product queries that flip, usually model numbers or exact titles - "bt-705", "batman dvd", "4 inch non marking caster wheel". Those are what BM25 or a reranker over the union recovers, which is why I kept both. And the queries Qwen3-8B still wins need world knowledge that one epoch on 20k shopping queries will not teach - "2 iron golf club", "alexa bedframe", "an appeal to heaven flag".

Splitting by how many Exact products a query has sharpens it further.
| Exact products | Queries | bge-base | Fine-tuned | Qwen3-8B |
|---|---|---|---|---|
| 1–2 | 1,271 | 0.488 | 0.523 | 0.505 |
| 3–5 | 2,039 | 0.506 | 0.554 | 0.534 |
| 6–10 | 2,810 | 0.433 | 0.477 | 0.471 |
| 11–20 | 2,163 | 0.341 | 0.377 | 0.387 |
| 21+ | 672 | 0.240 | 0.267 | 0.274 |
The fine-tuned model wins on queries with 1 to 10 Exact products. Qwen3-8B keeps its edge on 11 or more, and on recall@50 overall, 0.595 against 0.587. If your product surface shows three results, the first three rows are the ones that matter.
How sure I am
Two sources of noise, measured separately. Neither touches the official test split, so the numbers stay comparable with published ESCI results.
The first is which queries ended up in the test set. A single system carries an interval of about ±0.007 and a paired difference about ±0.005.
| Comparison, a − b | Diff | 95% interval | a ahead |
|---|---|---|---|
| Fine-tuned labelled − Qwen3-8B, recall@20 | +0.006 | +0.001 to +0.011 | 98.8% |
| Fine-tuned labelled − Qwen3-8B, recall@10 | +0.015 | +0.011 to +0.020 | 100% |
| Hybrid + fine-tuned − Qwen3-8B, recall@20 | +0.008 | +0.003 to +0.014 | 99.8% |
| Fine-tuned labelled − bge-base off the shelf | +0.040 | +0.036 to +0.044 | 100% |
| Fine-tuned labelled − hybrid BM25 + bge-base | +0.024 | +0.019 to +0.028 | 100% |
| Fine-tuned labelled − fine-tuned random | +0.021 | +0.018 to +0.024 | 100% |
| Fine-tuned labelled − fine-tuned mined | +0.037 | +0.034 to +0.041 | 100% |
| Fine-tuned labelled − fine-tuned Substitutes | −0.000 | −0.003 to +0.002 | 38%, a tie |
| Fine-tuned mined − bge-base off the shelf | +0.003 | −0.002 to +0.008 | 88%, a tie |
| Hybrid + fine-tuned − fine-tuned Substitutes | +0.002 | −0.002 to +0.006 | 85%, a tie |
| Hybrid BM25 + bge-base − bge-large off the shelf | +0.001 | −0.003 to +0.005 | 70%, a tie |
The second is which random choices the training run made. The seed decides which 3 Exact products each query keeps, which negative each row gets, the batch order and dropout. So I ran two more labelled runs with seeds 43 and 44, same data and settings, scored on the same frozen test set.
| Seed | recall@10 | recall@20 | recall@50 | hit@20 | nDCG@20 |
|---|---|---|---|---|---|
| 42 | 0.3561 | 0.4610 | 0.5851 | 0.8101 | 0.4109 |
| 43 | 0.3540 | 0.4597 | 0.5845 | 0.8114 | 0.4089 |
| 44 | 0.3539 | 0.4580 | 0.5843 | 0.8119 | 0.4083 |
| Mean ± std | 0.3547 ± 0.0012 | 0.4596 ± 0.0015 | 0.5846 ± 0.0004 |
Training-side noise is small, a spread of 0.003 in recall@20, which is smaller than the test-side interval. All three seeds are above Qwen3-8B's 0.4551 on recall@20, but the lowest margin is +0.003, and on its own that sits inside the paired interval. On recall@10 every seed is at least +0.013 ahead, well outside it.
The seed-42 run that the tables quote is the best of the three by 0.001 to 0.003, so the number to quote is the mean, 0.460. That is why I say matching or narrowly beating at k = 20 and clearly beating at k = 10, rather than just beats.
Serving, and what it cost
The fine-tuned model is a 440 MB sentence-transformers folder, served exactly like the base model. Encode the catalogue once, about 6 minutes on an A10G, store 768-float vectors, encode each query at request time with the bge prefix.
Against the 8B alternative: 768 floats per product in the vector store rather than 4,096, a model that fits on a CPU box, and a catalogue re-encode in minutes rather than 2.5 hours. That last one comes round again every time, because any fine-tune invalidates every stored product vector. Re-encode cost is what decides whether you can afford to iterate at all.
| Run | Training | Container | Estimated cost |
|---|---|---|---|
| Smoke, 20 steps on the smoke slice | 29 s | 88 s | $0.03 |
| Labelled, with before-eval | 489 s | 1,245 s | $0.38 |
| Mined, with before-eval and mining | 502 s | 1,301 s | $0.40 |
| Substitutes | 485 s | 881 s | $0.27 |
| Random | 484 s | 875 s | $0.27 |
| Pairs only, two columns, faster | 290 s | 673 s | $0.21 |
| Hybrid with the best run, CPU | – | 76 s | $0.01 |
| Total | about $1.57 |
Plus $0.54 for the two extra seeds, and nothing for the bootstrap, which runs locally on the saved per-query scores. The five-way negatives experiment that this whole post rests on cost $1.57.
Four things I would keep
| Which negative beats how many negatives | +0.001 for adding a random negative, +0.021 for making it a human-labelled near miss |
| Sparse judgements break negative mining | With 20 of 482,105 products judged per query, the model's near misses are mostly unjudged right answers |
| Loss curves cannot rank runs with different negative difficulty | The worst-looking loss curves were the best models |
| Dev and test disagree in level, not in order | Use the dev set to compare runs, never to quote a number |
Stage 2, the cross-encoder reranker over those 20 candidates, is in the next post.