yc_
cd ../projects
Research 2024 → 2026 Solo — rebuild of my own 2024 attempt

LLM4Subjects

A 2024 course project rebuilt from scratch in 2026 — retrieval over a 79,427-entry subject vocabulary instead of classification into a label set, and what that changed.

Official R@10

0.6299

Top published row

0.57 — RUC Team

Vocabulary

79,427 GND labels

GPU spent

4.7 h, two LoRA runs

What I believed in 2024

This was the first real training project of my NLP master's, in the autumn of 2024. I believed the strongest available model wins and the job is to fine-tune it well, so I built a classifier: multilingual BERT with a dense head over the 14,607 subject labels that appear in the training split, fifteen epochs on an A100. It was not competitive, and the compute it wanted was wildly out of proportion to what the task actually asks for.

Two years later I came back and rebuilt it, starting from the data instead of the model. The first thing I found was that my old numbers were not wrong so much as meaningless: 142 records in the training set were also in the gold test split, and 8,966 titles had been pulled from the wrong track. Re-measured on a clean split, the 2024 classifier scores 0.0667 micro recall@10. The rebuilt pipeline scores 0.6044 on the same dev split.

The damaging part was not the headline. It was the band breakdown. That classifier scores exactly 0.0000 on the tail and on unseen labels, at every cutoff out to 50, and it emits 78 distinct codes out of 14,607 anywhere in 5,354 × 50 prediction slots. It had quietly learned to answer every record out of the same few dozen common headings.

That is not a training failure, it is a shape failure. 19.1% of the gold labels in the test split never appear in training at all, and a closed-vocabulary head has no output column for them. No amount of fine-tuning creates one. So the rebuild treats the problem as retrieval over a vocabulary rather than classification into a label set, and every decision after that follows from wanting to reach labels the training data never showed me.

systemmicro R@10headtorsotailzero-shot
2024 classifier — mBERT, 14,607-way head15 epochs, 1.25 h on an A1000.06670.43110.00000.00000.0000
kNN over 20 nearest documents8.8 s of retrieval against a warm cache0.30230.69230.36390.10890.0000
dense label towerno document index at all0.12240.05380.09000.16280.2623
BM25 over label surface strings7.3 s end to end, most of it loading an unused encoder0.15580.18860.14450.14560.1925
all three, fused0.39640.70720.45110.22620.1871
The 2024 classifier against the three retrieval mechanisms it was replaced by. Dev split, micro recall@10, all on the same frozen frequency bands.The classifier's head-band 0.4311 is the whole of its score, and 97.4% of its top-50 slots go to head-band labels — a recall and a slot-occupancy, two views of the same collapse.

Where the labels actually live

Before measuring anything I split the vocabulary by how often a label appears in the training records and froze that split in a file. Every number in the project reads the bands from there and never recomputes them, so a row measured in the first week is comparable to one measured in the last.

The shape is the whole problem. Sixty-five headings carry 16% of the gold assignments in the test split. Nearly thirteen thousand headings appear between one and nine times each and carry 30.7%. For the neighbour harvest I started from, a single averaged recall number hides a factor of six between the top band and the tail: 0.69 against 0.11. My 2024 classifier hid something worse behind its own average — 0.4311 on the head and 0.0000 on the tail outright.

bandtraining occurrenceslabelsshare of test goldfinal R@10
head> 1006516.0%0.7215
torso10 – 1001,54344.5%0.6297
tail1 – 912,99930.7%0.5353
zero-shotnever seen—8.9%0.3547
The frozen frequency bands, their share of the test split's 11,798 gold assignments, and what the final system reaches on each.Band membership is fixed against tib-core training counts and never recomputed — so growing the index later changes what a band scores, never which labels are in it.

Three retrievers that are wrong about different things

The winning system on this task harvested subjects from a document's nearest neighbours, so I started there rather than treating it as a warm-up. Encode 8,000 training documents, encode the record, take the subjects of the twenty nearest documents. It is fast and it is good at exactly what you would expect: 0.6923 on the head band.

It also scores 0.0000 on labels that appear in no indexed document, at every cutoff and at every index size. That is not a tuning result, it is arithmetic — a heading you cannot find on a neighbour cannot be copied off one. On dev that band holds 18.9% of the distinct gold labels, and 8.5% of the assignments recall is actually counted over.

So I built two retrievers that never look at a document at all. The first embeds all 79,427 vocabulary entries as text and scores them directly against the record; the second is BM25 over the labels' own surface strings, 150,984 of them once synonyms are counted. Both are worse than the neighbour harvest on aggregate, and both reach the band it cannot.

Their band profiles are what made the rest of the project obvious. kNN falls monotonically as labels get rarer. The label tower rises monotonically — it is best exactly where kNN is worst. BM25 is flat across all four bands, because whether a heading's name turns up in a title has nothing to do with how often that heading was assigned.

Three mechanisms fail in three directions

Micro recall@10 by label-frequency band, rung 1 — one retrieval pass, one encoder, one 8,000-document index, German-only label text. kNN falls monotonically down the bands and is structurally 0.0000 where a label appears in no indexed document; the label tower runs the other way; BM25 is flat because whether a heading's name shows up in a title has nothing to do with how often it was assigned.

  • kNN20 nearest documents
  • label towerdocument → label text
  • BM25label surface strings
  • RRF fusedall three

head

65 labels · >100 training documents
kNN
0.6923
label tower
0.0538
BM25
0.1886
RRF fused
0.7072

torso

10–100 training documents
kNN
0.3639
label tower
0.0900
BM25
0.1445
RRF fused
0.4511

tail

12,999 labels · 1–9 documents · 30.7% of test gold
kNN
0.1089
label tower
0.1628
BM25
0.1456
RRF fused
0.2262

zero-shot

never seen in training
kNN
0.0000
label tower
0.2623
BM25
0.1925
RRF fused
0.1871
micro R@10
0.000.190.380.560.75

Fusing all three, and what it costs

Three retrievers that fail in three directions is an argument for combining them rather than choosing between them. The obstacle is that their outputs are not in the same units: a cosine similarity, a BM25 term score and a count of neighbour votes cannot be averaged without inventing a conversion rate between them. Reciprocal rank fusion sidesteps that by throwing the scores away at the boundary and keeping only each retriever's ordering.

I tuned the three weights on dev with a 256-combination sweep, scored from a single retrieval pass so the sweep costs one run rather than 256. The constant k came out at the conventional 60, which I record as "the default was not beaten" rather than as a tuned parameter; the weights themselves are worth +0.0259 over equal weighting.

Reciprocal rank fusion

Three retrievers, three incomparable units. A cosine similarity, a BM25 term score and a count of neighbour votes cannot be added, averaged or normalised into each other without inventing a conversion rate. Their ranks can.

  • kNNw = 1.5

    scores in votes among 20 nearest documents

    head 0.6923 · zero 0.0000

    → 100 labels, ranked

  • label towerw = 1.0

    scores in cosine against 79,427 label texts

    head 0.0538 · zero 0.2623

    → 100 labels, ranked

  • BM25w = 1.0

    scores in term score over 150,984 label strings

    head 0.1886 · zero 0.1925

    → 100 labels, ranked

score(label) = Σr wr / (k + rankr(label))

w = 1.5 kNN · 1.0 label tower · 1.0 BM25  ·  k = 60

k is the flattener.A large k shrinks the gap between rank 1 and rank 20, so a label that one retriever is confident about cannot win on its own; a small k lets a single first place dominate the sum. 60 is the conventional default, and a 256-combination sweep over the weights and rrf_k did not beat it — the k = 30 and k = 100 rows both land below it in the sweep’s own top five. Worth recording as the default was not beatenrather than as a tuned parameter. The weights were tuned: they are worth +0.0259 over equal weighting.

fused candidates

micro R@10 0.3964 · +0.0942 over the best single retriever · ceiling R@100 0.6242

A fused candidate carries the fused score and the rank each retriever gave it — never the retrievers’ own scores. That is the whole point of using ranks, and it is what lets a fourth retriever be added later without renormalising the other three.

Every pair, and then all three

I ran every combination from one retrieval pass. Every pair beats both of its members, and all three beat every pair, so nothing in the set is dead weight — the third retriever is worth +0.0530 on top of the best pair and +0.1865 on top of the weakest one.

The honest version of the headline is narrower than it looks. Fusion's +0.0942 over the best single retriever is measured with weights tuned on the same dev split the row is reported on, and the single retrievers are untuned by construction because they have nothing to fuse. The untuned equal-weight figure, +0.0682, is the lower bound I actually trust.

It also costs something specific. The weight sweep selects on micro recall@10, an aggregate that is 59.5% head-and-torso, so it was never asked to protect unseen labels — and it did not. The label tower alone reaches 0.2623 there; fused, the band drops to 0.1871. The clearest illustration is the kNN + BM25 pair, which scores 0.0000 on a band where BM25 alone scores 0.1925: kNN is structurally empty there, and giving it weight 1.5 pushes every unseen candidate the other retrievers found out of the top ten. In the committed three-retriever configuration those labels are demoted rather than lost — at the hundred-candidate ceiling the band still holds 0.4288, against the label tower's own 0.4333 — and 0.1871 is the number the reranking stage was later built to beat.

retrieversR@5R@10R@100 (ceiling)official R@10
dense label tower0.09310.12240.25640.1260
kNN0.23190.30230.50910.3948
BM250.11310.15580.28640.1416
dense + kNN0.25550.32980.57940.4534
dense + BM250.16390.20990.37630.1736
kNN + BM250.27310.34340.58360.4239
all threethe committed configuration0.30520.39640.62420.5340
Every retriever alone, every pair, and all three — one retrieval pass, tuned weights, 5,354 dev records.The official column is a macro over record-type × language cells, which is what the leaderboard is quoted on. It is reported beside the micro figure and never instead of it: at rung 1 the two diverge by +0.1376, the widest gap anywhere in the project.

Every label is German; most of the documents are not

Every entry in the GND vocabulary has a German name, and 58.7% of the gold assignments in this dataset belong to English documents. That is the largest single mismatch in the pipeline, and it is why BM25 over label names was so much better on German records than English ones — German ran 2.07× English at the hundred-candidate mark before anything was done about it.

So I machine-translated all 79,224 distinct German label strings once, with Helsinki-NLP/opus-mt-de-en, in 122 seconds, and rendered every label in both languages. 23.8% come back identical to their source — proper nouns, formulae, loanwords — and render in one language rather than two.

The verdict splits. BM25 gains +0.0223 micro recall@10 and its German-to-English ratio falls from 2.07× to 1.26×, the largest single language correction in the project. The dense label tower loses 0.0075: every German label vector moved, and German gave up nearly four times as much recall as English picked up. Fused, the net is positive, so bilingual label text stays on.

It also moves the baseline. Every figure from the encoder screen onward is measured against bilingual label text, so the like-for-like fused reference is 0.4149 rather than the German-only 0.3964 in the ablation table above. The two numbers are the same configuration reading different label text, and mixing them would credit the encoders with a gain the translation had already paid for.

Which encoder deserved the GPU

Everything above runs on one encoder, and swapping it is a config change that costs no training at all, so I screened four multilingual models before spending any GPU time. Every candidate had to predate 2025-01-31, the close of the shared task's evaluation window, and every one is pinned to a specific hub revision — both E5 checkpoints had commits landed on them in April 2026, and a config naming a model without a revision quietly resolves to a 2026 state of a 2023 model while every date in the claim stays true.

The screen runs on a quarter of the training split: 8,000 documents sampled stratified by record type and language, with every cell above 200 documents inside 0.002 of its share of the full corpus and every smaller cell given at least one document, because an index missing a language outright would be a different experiment rather than a smaller one. Everything else is held fixed, and the BM25 column is the control — it reads no vectors, so it is identical to four decimals across all four rows.

Then I grew the index to the whole 32,043-document split with nothing else changed, and the ranking did not survive. The top two swapped: one adjacent transposition of four, Spearman ρ 0.800. A cheap screen generalises for shortlist membership, not for order.

Two models had already been carried forward rather than one, for a reason that had nothing to do with the aggregate: bge-m3 had the strongest label tower of the four and took the unseen-label band even while losing the screen. The flip is what turned that hedge from caution into the right call. The pair sat 0.0022 apart at the screen and 0.0039 apart at the full index, against a table spread of 0.0411, so picking one on the screen alone would have been a coin flip dressed as a decision.

The other result in that table is that scale within a family did not carry. multilingual-e5-large loses to its own base model at both index sizes, and the ablation says why: it has the better document-to-document tower and the worse document-to-label one. The two towers are not one capability, and the retriever I needed most was the one it was worse at.

The screen picked the shortlist, not the winner

Fused dev micro recall@10 per encoder, at the 8,000-document sample and at the whole 32,043-document training split. Everything except index size is identical between the two passes. The top two swap — one adjacent transposition of four, Spearman ρ 0.800 — and the larger checkpoint inside the E5 family stays the worse retriever at both sizes.

  • 8,000 documents (a quarter)
  • 32,043 documents (all of core_train)
  • BAAI/bge-m3568M · 1024drank #2then#1
    0.47020.5337
  • Alibaba-NLP/gte-multilingual-base305M · 768drank #1then#2
    0.47240.5298
  • intfloat/multilingual-e5-base278M · 768d · the incumbentrank #3then#3
    0.41490.4961
  • intfloat/multilingual-e5-large560M · 1024drank #4then#4
    0.40630.4926
dev micro R@100.380.430.470.520.56
encoderparamsdensekNNfused @ 8,000fused @ 32,043rank
BAAI/bge-m3best unseen-label band of the four, and the strongest label tower568M0.23750.34730.47020.53372 → 1
Alibaba-NLP/gte-multilingual-basewon the screen at 305M, against a 568M runner-up305M0.19990.36950.47240.52981 → 2
intfloat/multilingual-e5-basethe incumbent the fusion weights were tuned for278M0.11490.30230.41490.49613 → 3
intfloat/multilingual-e5-largebetter neighbours, worse label matching — loses to its own base560M0.08520.31930.40630.49264 → 4
The screen, in full. Fused dev micro recall@10 at both index sizes, with the per-retriever split that explains the E5 result.All four rows are on bilingual label text, so the e5-base row reads 0.4149 rather than the ablation table's German-only 0.3964. The dense and kNN columns are from the 8,000-document screen. The dense retriever scores label text and never reads an indexed document, so it is unchanged to four decimals when the index grows — only the neighbour retriever moves, which is the check that the difference between the two index columns is the index and nothing else.

More documents, or more GPU

The last stage grows the index again — from the tib-core training split to the all-subjects split, 70,579 documents after dropping nine records that leak into dev and merging 45 duplicated ids — and contrastively fine-tunes both shortlisted encoders with LoRA. Running each encoder off the shelf and fine-tuned at the same index is what separates the corpus effect from the training effect instead of reporting their sum.

The answer was lopsided. Doubling the corpus buys under a hundredth of a point; the GPU buys roughly seven times that. Index size is spent, and any budget left belongs in training or in the stages after retrieval, not in more documents.

The training itself ran under a constraint worth admitting to: a vLLM process held 74.7 GB of the shared A100's 80 GB, so the run that finished used batch size 8 with two mined negatives instead of the defaults of 32 and 4, after three OOM crashes cost about an hour. In-batch negatives are most of the contrastive signal, so batch 8 is the one place this is knowingly under-powered and the +0.05 / +0.07 should be read as a floor.

gte-multilingual-base starts 0.0035 behind and finishes 0.0135 ahead, gaining half again as much from training on 2.9M trainable parameters against 7.1M, in 87 GPU-minutes against 197. The cheaper model was also the more trainable one, which is not a result I would have predicted in 2024.

encoder32,043 docs70,579 docs+ LoRAindex addstraining addsGPU min
Alibaba-NLP/gte-multilingual-base0.52980.53760.6044+0.0079+0.066886.6
BAAI/bge-m30.53370.54110.5909+0.0073+0.0498196.7
Corpus growth and training, separated. Dev micro recall@10 at each step, both encoders.Everything except the named variable is held fixed at each step, including the fusion weights — which are still the ones tuned for multilingual-e5-base at rung 1 and were never retuned. The tower is now nearly twice as strong as those weights assume, so 0.6044 is a floor.

The test split, opened once

The configuration was frozen and committed one commit before the gold split was read, and the split was read exactly once: fine-tuned gte-multilingual-base over the 70,579-document index, a hundred fused candidates reordered by an off-the-shelf cross-encoder and cut to fifty. 4,910 records and 11,798 gold assignments, two and a half hours on a laptop. The organisers' own scorer and my evaluator agree to 1.1e-16.

It lands above every published row on recall and in the middle of the field on precision. I want to be precise about what that does and does not license: this is a reconstruction scored after the fact against numbers published by teams who competed in January 2025, not an entry that competed against them. Every model it loads predates the evaluation window's close, so the comparison is at least not flattered by a year of model progress — but it had their published scores to aim at, and they did not have mine.

Above the field on recall, mid-field on precision

tib-core-subjects test split, the organisers’ own scorer over a submission tree. Two measures, two charts — putting them on one pair of axes would invent a relationship the numbers do not have.

Recall@10

higher is better · this run 0.6299

  • this run
    0.6299
  • RUC Team
    0.57
  • Annif
    0.54
  • DUTIR831
    0.54
  • LA2I2F
    0.49

Precision@10

higher is better · this run 0.1346

  • RUC Team
    0.16
  • Annif
    0.14
  • this run
    0.1346
  • DUTIR831
    0.13
  • LA2I2F
    0.13

The four published rows are quoted at two decimals, so the precision comparison is coarse on their side: 0.1346 sits inside the rounding of the two rows published at 0.13. Below the top two, not below the field.

systemP@5R@5P@10R@10Avg R@k
this run0.20680.50560.13460.62990.7550
RUC Team0.250.480.160.570.66
Annif0.230.480.140.540.59
DUTIR8310.230.490.130.540.56
LA2I2F0.200.410.130.490.58
The full leaderboard row, tib-core-subjects test split, the organisers' aggregation.Published rows are quoted at the two decimals they were published at. Precision at a fixed cutoff is capped by how many headings a record actually has: at 2.40 gold labels per book a perfect system tops out at 0.4543 P@5 and 0.2394 P@10, so this run is taking 46% of the achievable precision at k=5 and 56% at k=10 — shares a raw figure never shows.

The number moves more with the average than with the method

One set of predictions, scored three ways: 0.5910 averaging over records, 0.7156 giving one vote to each record-type × language cell, and 0.6299 from the organisers' script. The gap between the first two is 0.1245. The gap between the best and worst published leaderboard row is 0.08.

The reason is cell sizes. Nine of twenty cells carry more than half of the macro figure between them, and seven of those nine hold nine records or fewer — a cell of two conference papers in Spanish weighs more than 1,465 German books. The organisers' script also cannot read eleven of the twenty cells at all: its reader seeds a dictionary with de and en and then subscripts it by directory name, so the 28 records in French, Spanish, Czech, Turkish, Dutch, Japanese and blank-language cells raise a KeyError inside their own code.

I report both aggregations everywhere and select models on the micro one, because a benchmark figure that moves further with the choice of average than with the choice of method is not a figure to tune against.

The long tail, and the bound underneath it

The tail is where the whole project happens. It holds 30.7% of the gold assignments, it is where the four encoders actually differ, and it is the band an averaged score hides best.

What buys the tail is candidates, not cleverness. Growing the index fourfold lifted tail recall by +0.12 to +0.14 for every encoder while moving the head of the top two by +0.008 and −0.012 — the head was already near its ceiling whatever the pipeline did. Fusion is the other lever: on the tail the fused row reaches 0.2262 against the label tower's 0.1628 and kNN's 0.1089.

Underneath the tail is a bound that no model reaches. Of the 992 headings in the test split that no training record carries, 812 are carried by no document in any corpus the shared task releases. They hold 851 gold assignments, 7.2% of everything the split asks for. That figure comes from three committed files and no run at all — it is a property of the benchmark, and every system that ranks by document similarity scores 0.000 on those assignments by construction. This one scores 0.323, because it also reads the labels' own text.

The zero-shot band, stage by stage

Recall@10 on gold headings no training record carries. Two of the seven rows are zero for a structural reason rather than a measured one: a closed-vocabulary classifier has no output column for the label, and a neighbour harvest has no document to harvest it from. Everything above zero is label text being read.

  • 2024 classifierno output column exists for the label
    0.0000structural
  • kNN alonerung 1 · 8,000 docs · dev
    0.0000structural
  • BM25 over label namesrung 1 · no index · dev
    0.1925
  • label tower alonerung 1 · no index · dev
    0.2623
  • RRF fused, tuned weightsrung 1 · 8,000 docs · dev
    0.1871
  • after LoRA fine-tunerung 3 · 70,579 docs · dev
    0.2167
  • final test run70,579 docs · + cross-encoder · test
    0.3547
R@10, zero band
0.000.130.250.380.50

▏the rule at 0.4288 is the rung-1 fused candidate ceiling on this band — the share of zero-shot gold that survives anywhere in the 100 candidates retrieval hands downstream. Nothing after retrieval can beat it without a different candidate generator, which is why the fused row’s dip to 0.1871 is a demotion rather than a loss.

The least comfortable table in the project

Fine-tuning is monotonic in label frequency. It gives the most where recall was already highest and takes from the band with no training signal to learn from — which is the exact opposite of the reason I chose retrieval over classification in the first place.

On aggregate the trade is worth taking: +0.067 fused against −0.023 on a band carrying 8.9% of assignments. But it means the retrieval design's own selling point now rests on the reranker and the adjudicator rather than on the tower — and the adjudication rows never ran, because the environment had no API credential, so on the test run it rests on the reranker alone. The version of this project that cared most about unseen labels would have stopped before the GPU.

The corpus tells a related story. Growing to 70,579 documents reached 173 of the 1,053 unseen dev headings — and all 173 land in the tail band, appearing in one to nine documents each. Reaching a label is not retrieving it: the +0.02 the zero-shot band gained is what 173 barely-present labels are worth.

Fine-tuning is monotonic in label frequency

Change in dev micro recall@10 from a LoRA contrastive fine-tune, per band, at a fixed 70,579-document index — so the only thing moving is the weights. Both encoders gain most where recall was already highest and lose on the band that has no training signal to gain from.

  • training helped
  • training cost recall
  • headgte-multilingual-base
    +0.1279
  • headbge-m3
    +0.0968
  • torsogte-multilingual-base
    +0.0876
  • torsobge-m3
    +0.0784
  • tailgte-multilingual-base
    +0.0326
  • tailbge-m3
    +0.0160
  • zero-shotgte-multilingual-base
    −0.0233
  • zero-shotbge-m3
    −0.0564
reranker modeheadtorsotailzero-shot
fused candidates, no rerankerthe baseline both rows are measured against0.70230.49530.29000.2203
replace the fused orderhead −0.2825 · unseen +0.15260.41980.41120.34500.3729
mix with the fused orderthe committed setting · head −0.0458 · tail +0.08000.65650.52960.37000.2712
The cross-encoder, reading the same hundred candidates two ways. Replacing the fused order versus mixing with it, by band.bge-reranker-base on a 300-record stratified dev sample — 131 head, 321 torso, 200 tail and 59 zero-shot assignments — because a full dev pass at 100 candidates a record is 535,400 pairs and 3.9 hours. Four decimals off 59 assignments is a direction, not a measurement, and these rows are not comparable with the full-dev figures elsewhere on this page. The shape is what mattered: replacement is a tail-and-unseen instrument and a head disaster, and mixing keeps most of what replacement bought while giving back most of what it cost.

Measured and not kept

Roughly half the work in the repository is things that did not make the final configuration. They are in it because a negative result measured on the same split as everything else is worth more than an untested idea.

  • A 66-way subject-area prior, one logistic regression per classification group over frozen document vectors, applied after fusion. Worth +0.0053 on a like-for-like baseline, +0.0158 on the head and −0.0018 on unseen labels. It is a head-band instrument and the head was not the problem, so it is off.
  • The larger cross-encoder, bge-reranker-v2-m3 at 568M. On its own 100-record sample it landed 0.076 R@10 below the fused candidates it was given, and ran at 6 pairs a second against the smaller model's 42. A full dev pass with it would have been 25 hours on the laptop.
  • Fine-tuning the cross-encoder. The untrained model in mixing mode already takes 16% of the headroom left in the candidate set for free, which was not enough of a case to spend a fourth GPU run.
  • A graph refiner over the label hierarchy, which I had assumed existed. The release contains zero skos:broader triples, and the edges I could actually build covered 130 of 14,607 labels — 65 disconnected two-node components.
  • LLM adjudication of the least-confident fifth of records. Built, validated against a closed candidate list so the model can never author a code, and never measured: the environment has no API credential. Dev bounds the best case at +0.046 recall@10, against the +0.034 the free cross-encoder already takes.
  • Abstention. The submission format is fifty fixed slots with no way to decline a record, so the official metric cannot reward it. I measured it anyway: declining the least-confident half buys +0.0424 P@5 and leaves 46.6% of the gold assignments unanswered. The curve is smooth with no elbow, so there is no discovered operating point — only a review-capacity policy.

What I took from the rebuild

  • Fuse mechanisms that are right about different inputs instead of ranking them and keeping the winner. The best single retriever here scores 0.3023 and the three together score 0.3964, and the ones that lose on aggregate are the only ones that reach the fifth of the split's gold labels no training record carries.
  • Measure by subgroup before trusting an average. My 2024 classifier's headline was bad; re-run on a clean split, its band breakdown was disqualifying — and only one of those two numbers told me what to build next.
  • A cheap screen is valid for membership, not for order. Screening on a quarter of the index picked the right pair of encoders and the wrong winner of the two.
  • Spend the config change before the GPU. Swapping the encoder was worth +0.0575 at the 8,000-document screen and +0.0337 once the index was full; the LoRA run was worth +0.0668. One of those cost 87 GPU-minutes and the other cost a line in a YAML file.
  • Make the discipline executable rather than intended. The test split opens once and the code refuses a second read without a justification; the evaluation plan is digested and committed before the split is touched; the frequency bands are read from a file and never recomputed. I wrote those rules after finding contamination in my own 2024 numbers, and a rule that only lives in a README would not have survived the week.

Stack

PyTorchTransformersLoRABM25Reciprocal rank fusionbge-rerankerApple MPSA100