All projects
NLP · Dense RetrievalUC Berkeley DATASCI 266 — NLP with Deep Learning/2026

Two-Tower Retrieval

Bidirectional coach–athlete matching for college swimming recruiting, built on a knowledge graph and contrastive fine-tuning.

College recruiting is a matching problem disguised as an inbox problem. Athletes email hundreds of coaches who run programs that do not fit them; coaches read thousands of profiles to find a handful of viable recruits. This project encodes both sides into one embedding space so the search runs in either direction — and tests what actually drives retrieval quality: contrastive fine-tuning with hard negatives, or LLM normalization of the inputs.

0.909
Forward MRR
up from 0.304 pre-trained — swimmer → coach
83.6%
Top-1 hit rate
184 of 220 held-out swimmers, perfect MRR
4.5×
Reverse MRR gain
0.045 → 0.202 — the harder direction
150K
Knowledge graph
swimmer profiles, 19,369 coaches, 295 benchmarks

Why embeddings alone fail here

A swimmer profile reads “100 free 51.2, looking for D1.” A general-purpose NLP system does not know that is a 100-yard freestyle in 51.2 seconds, or that it implies a competitive tier. The vocabulary is compressed, numeric, and domain-specific in a way that pre-trained sentence embeddings simply do not encode.

Worse, the relationships that decide a match — conference affiliation, division eligibility, event-specific time standards per school — are structural. They live in a graph, not in prose, and no amount of text similarity recovers them.

So the system draws on a Neo4j subgraph from the uSport.ai platform: 150,000 synthetic swimmer profiles, 19,369 coaches, and 295 per-school event benchmarks across 39 schools and 28 events. A universal event normalizer reconciles the formats — “200 Butterfly” and “200 fly” resolve to the same event — and times are parsed to seconds so they can be compared numerically.

Ground truth from benchmarks, not from clicks

There is no click log for recruiting, so positives are constructed from competitive reality: a swimmer matches a coach if their best time in any event falls inside that school's competitive range — at or below the school's slowest benchmark, or within 5% of the median. That yields 22,974 positive pairs across 731 swimmers and 72 coaches.

The same benchmarks generate the hard negatives, which is where the signal is. A random negative is a coach at a school that swims different events entirely — trivially separable. A hard negative is a coach whose school swims exactly the swimmer's events, but where the swimmer is not competitive. Same vocabulary, same domain, wrong answer.

Splitting is by swimmer, 70/30, with a fixed seed: 511 train and 220 test, fully disjoint. Triplets come only from train swimmers, every metric is computed only on test swimmers, and no swimmer appears on both sides.

What the experiment was actually testing

Both towers share one encoder — BGE-large-en-v1.5, chosen off the MTEB leaderboard for top-10 English retrieval at a workable 1024 dimensions. One tower serializes the swimmer; the other serializes the coach enriched with their school's benchmarks. Cosine similarity at inference gives a ranked list in either direction.

The experimental question was whether a decoder LLM normalizing both sides into semi-structured JSON before embedding beats embedding the text directly. This is a deliberate departure from HyDE and Query2Doc, which expand the query side; here both sides get normalized into one consistent schema.

The answer: normalization helps, but it is not what matters. Contrastive fine-tuning lifted forward MRR from 0.304 to 0.883 — a 2.9× gain. Normalization on top of that added 0.026, reaching 0.909, with bootstrap confidence intervals that overlap the fine-tuned baseline. Trending, not conclusive. The fine-tuning is the whole story; the normalization is a rounding error dressed as a treatment.

The direction that did not work

Reverse retrieval — coach searching for swimmers — improved 4.5×, from 0.045 to 0.202 MRR, and 0.202 is still bad. Pre-trained P@1 was exactly zero.

The reason is informational, not architectural: a coach profile is a name, a title, a school, and a team gender. There is very little distinctive text to embed. A swimmer profile carries events, times, a power index, and a bio. Symmetric architecture, deeply asymmetric information — and no amount of contrastive training manufactures signal that was never in the input.

The one place normalization earned its keep was here: at P@10 the treatment reached 0.106 against the baseline's 0.085, suggesting cleaner representations help surface relevant swimmers further down the ranking even when the top of it stays noisy.

Where it breaks

Of 220 test swimmers, 184 — 83.6% — retrieve a correct coach at rank one. The failures are informative: swimmers with only a single relevant coach in the entire candidate set, and swimmers from underrepresented countries such as Estonia and Luxembourg, whose profiles sit outside the US-centric training distribution.

Negative-pair rejection is honest about its own weakness. Fine-tuning widened the similarity gap between positives and negatives 5.7× — 0.015 to 0.086 — but AUC-ROC only reaches 0.633–0.666. That ceiling reflects the coarseness of the negative signal: two swimming profiles with zero event overlap still share a great deal of swimming language.

The T5 explanation generator is labelled a proof of concept because it is one. Six hand-written training pairs, two held out, ROUGE around 0.53 on n=2 — illustrative and nothing more. It produces fluent, correctly grounded output (“Carleton College develops distance swimmers… your sprint freestyle times fit their team needs”) and it occasionally conflates stroke specializations. A production version needs hundreds of procedurally generated examples.

Forward retrieval — swimmer → coach

Held-out test set of 220 swimmers, disjoint from the 511 used for training.

ApproachP@1P@10MRRnDCG@10
Baseline (pre-trained)0.1730.1100.3040.120
Baseline (fine-tuned)0.8360.6050.8830.664
Treatment (LLM-norm)0.8730.5960.9090.659
Bootstrap 95% CI: treatment [0.872–0.940] overlaps fine-tuned baseline [0.846–0.918] — a trend, not a conclusive win.

Reverse retrieval — coach → swimmer

The same models, 72 held-out coaches. Everything improves; nothing gets good. Coach profiles carry too little distinctive text.

ApproachP@1P@10MRR
Baseline (pre-trained)0.0000.0070.045
Baseline (fine-tuned)0.0830.0850.202
Treatment (LLM-norm)0.0690.1060.203

Negative-pair rejection

Can the model tell a genuine mismatch from a match? Separation improves 5.7×, but the ceiling is low.

ApproachPositive simNegative simSeparationAUC-ROC
Pre-trained0.5520.5370.0150.633
Fine-tuned0.4580.3710.0860.654
Treatment0.4700.3840.0860.666

Experimental setup

Encoder
BAAI/bge-large-en-v1.5 — 1024-d, 512 tokens
Loss
MultipleNegativesRankingLoss, 3 epochs
Training triplets
1,196, built from train swimmers only
Split
70/30 by swimmer — 511 train / 220 test, disjoint
Ground truth
22,974 benchmark-derived pairs, 731 swimmers × 72 coaches
Explanations
T5-small, 6 training pairs, 2 held out

Stack

PythonPyTorchsentence-transformersBGE-large-en-v1.5T5-smallNeo4jscikit-learnHugging FaceJupyter