Reading: Beyond RRF - How We Combined Vector Search With Learning To Rank
idealo on combining vector search with learning-to-rank, moving past plain RRF. Notes below also cover their MICES 2026 talk on how they fine-tuned the embedding model.
Source: idealo Tech Blog on Medium
Link: Beyond RRF: How We Combined Vector Search With Learning To Rank
Talk / slides: MICES 2026 — Hybrid Search at idealo (Gennady Shabanov, Atakan Filgöz)
Deep dive: contrastive learning pairing strategies
Notes on how idealo built positive/negative pairs from click-through data to fine-tune a sentence embedding model with contrastive learning — written to understand the underlying math well enough to reuse the idea, not just the slide bullet points.
Why contrastive learning at all?
An off-the-shelf embedding model (e.g. multilingual-e5-small) puts semantically similar text close together. But “semantically similar” is not the same as “relevant to my users.” A search for “phone with great camera” should embed close to a Google Pixel, not to a phone case that happens to mention “camera” in its description. Generic embeddings don’t know that — your own click data does.
Contrastive learning fine-tunes the embedding space using exactly that signal: pull the embeddings of things your users treat as relevant together, and push the embeddings of things they treat as irrelevant apart.
The core idea, formally
Every training example is a triplet:
— anchor, e.g. the search query “phone with great camera” — positive, an item considered relevant to — negative, an item considered irrelevant to
All three are mapped into the same embedding space by the same model
We measure closeness with cosine similarity:
The training objective says: make
The loss: MultipleNegativesRankingLoss (InfoNCE)
idealo used MultipleNegativesRankingLoss — the same loss known in the wider literature as InfoNCE. Instead of comparing one positive against one negative, it compares one positive against every other item in the training batch, treating them all as negatives “for free”:
where:
= number of negatives available for this anchor (batch size 1, if using in-batch negatives, plus any explicit hard negatives you added) = temperature, a small constant (e.g. 0.05) that sharpens the softmax — lower makes the model punish near-miss negatives harder
Read the formula as: softmax over similarity scores, then cross-entropy against the index of the true positive. The network is literally being trained to answer a multiple-choice question — “out of all these candidates, which one is the true positive?” — via gradient descent.
Worked mini-example
Say the anchor is “phone with great camera” and, after encoding, cosine similarities to 4 candidates are:
| item | similarity |
|---|---|
| Pixel 8a (positive) | 0.80 |
| Phone case (negative) | 0.40 |
| Bluetooth speaker (negative) | 0.10 |
| Laptop (negative) | 0.05 |
With
Loss is small because the positive already dominates. If the phone case had similarity 0.75 instead of 0.40 (a genuinely hard negative, almost as close as the true positive), the loss would spike — and so would the gradient pushing the case’s embedding away. This is exactly why hard negatives matter: an easy negative (laptop, similarity 0.05) contributes almost nothing to the loss or the gradient — the model already “knows” the answer, so there’s nothing to learn from it.
Toy example: pairs → batch → loss → backward, in PyTorch
The mini-example above is just one row of a real batch. Here’s a full, tiny, self-contained batch of 4 queries so the batching mechanics are concrete — no sentence-transformers/HF needed, an nn.Embedding stands in for the encoder so you can see every step:
1 | import torch |
Sample output (random init, so exact numbers vary run to run, but the shape of the curve is always this):
1 | step 0 | loss=6.1000 | true-positive sims=[-0.3 0.1 -0.2 0.0] # random -> no idea |
Map this straight back to the pipeline: candidates = cat([vp, vn]) is the “8 columns” matrix from the pairing-strategy section — 4 free in-batch negatives (other rows’ positives) plus 4 explicit hard negatives, all in one softmax per row. labels = arange(4) is the label trick from batching — the positive for row i was deliberately placed at column i, so no manual annotation is needed beyond the original CTR-based pairing decision. loss.backward() is where training actually happens: everything before it (encode → similarity → softmax → cross-entropy) is forward computation ending in one number, and that number is the thing gradient descent differentiates to update the encoder.
Where the pairing strategy comes in
InfoNCE needs
Example result list for a query, sorted by position:
| item | CTR |
|---|---|
| A | 0.42 |
| B | 0.18 |
| C | 0.07 |
| D | 0.00 |
| E | 0.00 |
1. First & Last — winner
One clean, unambiguous pair per query: the most-clicked item vs. the least-clicked item. Simple, and it produced the best NDCG in idealo’s experiments (with batch size 256).
2. All positives + last negative
Every clicked item (A, B, C) becomes its own positive example, all paired against the same single negative (E). More training pairs per query than strategy 1, but the negative is reused, so it adds less new negative information per pair.
3. Only positive — worst
No explicit negative at all — this strategy relies entirely on in-batch negatives from InfoNCE (other queries’ positives happening to land in the same batch). Since those are essentially random, they’re almost always easy negatives (as in the worked example above: similarity ≈ 0.05, contributing ≈ 0 gradient). Explains directly, via the loss formula, why this was the worst performer — the model rarely sees a genuinely hard, informative negative.
4. Expanded — full cross product
Every positive against every negative. Maximizes pair count (here:
5. Selective negative — different category
Deliberately picks a negative from an unrelated product category — trivially easy to distinguish, similar to the “laptop” example above (similarity ≈ 0.05). Weak gradient, hence the weakest result among strategies that at least have an explicit negative.
The false-negative trap
CTR-based negative mining has a subtle bug: CTR = 0 does not mean “irrelevant.” If a query returns six units of the same product in different pack sizes, only one gets clicked — the other five have 0 CTR but are still perfectly relevant. Training the loss above with such an item as
idealo’s fix: before mining hard negatives from CTR, ask an LLM to judge whether a zero-CTR candidate is actually irrelevant to the query. Only LLM-confirmed irrelevant items are kept as negatives (~25% of naive CTR-negatives got filtered out). This is orthogonal to the loss/pairing math above — it’s a data-cleaning step upstream of it, but it’s what made “First & Last”-style hard negatives safe to use at scale.
1 | def is_true_negative(llm, query: str, candidate_title: str) -> bool: |
They tried three variants of this idea; only filtering (not generating) negatives won:
| experiment | recipe | NDCG@5 |
|---|---|---|
| baseline | raw CTR positive/negative, no filtering | middle |
| Exp1 — LLM-filtered (shipped) | CTR pairs, ~25% of negatives LLM-rejected → replaced with a random negative | best |
| Exp2 — LLM-generated | LLM invents the negative text itself, no CTR pairs at all | worst |
| Exp3 — CTR + 5 LLM negatives | keep CTR negative, add 5 more LLM-written negatives per pair | middle |
Lesson: an LLM is better used as a filter over real user behavior than as a generator of synthetic negatives — generated negatives tend to be too obviously wrong (easy negatives again, same problem as strategy 5 above).
Training loop, end to end (sentence-transformers)
Putting the pairing strategy and the loss together, this is roughly what idealo’s fine-tuning loop looks like using sentence-transformers:
1 | from sentence_transformers import SentenceTransformer, InputExample, losses |
InputExample(texts=[a, p, n]) is exactly the MultipleNegativesRankingLoss then adds every other example’s positive in the batch as an in-batch negative for free, which is why batch_size=256 alone (with no other change) improved NDCG over batch_size=128: more free negatives per step.
After retrieval: fusing keyword + vector results
Fine-tuning the embedding model only solves half the problem — you still need to combine its ranked list with the existing keyword (BM25/Lucene) ranked list. This is the “Beyond RRF” part of the talk.
Reciprocal Rank Fusion (RRF) — a simple, training-free way to blend two ranked lists using only each item’s rank (position), not its raw score:
with
1 | def reciprocal_rank_fusion(ranked_lists, k=60): |
Learning to Rank (LTR) fusion — instead of a fixed formula, train a ranking model (e.g. gradient-boosted trees) on real features of both sources (keyword score, vector cosine similarity, CTR history, price, etc.) plus real click feedback, and let it learn how to weigh them jointly:
In idealo’s A/B test, LTR fusion beat RRF on every metric (CTR, exit rate, revenue/click) — RRF only looks at rank position and ignores how much better one match is than another, while LTR can learn that a cosine similarity of 0.9 should count for a lot more than a rank-3 position.
Rollout was staged and A/B-tested at each step rather than switched on all at once:
- Pilot — append vector results only to low-recall keyword searches (few/no keyword hits), A/B test.
- Learn to Rank — collect clicks on the mixed results, retrain the LTR model on them.
- Full rollout — vector results on every query, LTR decides final order, A/B test again.
Serving the vectors at scale
The last piece: once you have ~500M item embeddings, brute-force cosine similarity against all of them per query is too slow. idealo uses approximate nearest neighbor (ANN) indexes instead of exact search — trading a small amount of recall for large speedups.
- Products (~8M) → HNSW (graph-based index, high precision, fine at this scale).
- Offers (~500M) → FAISS IVF-PQ (inverted file + product quantization), sharded 10 ways.
IVF-PQ in one paragraph: cluster all vectors into nlist groups with k-means (“inverted file”); at query time, only search the few nearest clusters instead of all 500M vectors (“IVF” — this is why it’s approximate); additionally compress each vector into a short code (“PQ” — product quantization) so the whole index fits in memory.
1 | import faiss |
index.nprobe is the main recall/latency knob: scanning more clusters (nprobe) raises recall but also latency — idealo landed on a setting giving ~60ms average query latency across 10 shards, versus ~600-700ms they measured on a naive exact-search (S3 Vectors) trial.
Takeaways
- Hard negatives drive learning; easy negatives don’t.
- “First & Last” ≈ hard-negative mining from real result lists.
- Zero-CTR ≠ irrelevant — filter false negatives before training.
- Bigger batch = more free in-batch negatives.
- LLM as a filter > LLM as a generator for negatives.
- RRF blends by rank; LTR blends by learned features — LTR won.
- Exact search doesn’t scale — ANN (IVF-PQ/HNSW) trades recall for speed.
Reading: Beyond RRF - How We Combined Vector Search With Learning To Rank