https://labs.brunosquared.com/finals

I built a semantic search where you can look for moments from various champions leagues finals by describing them. Finals covers all 70 European Cup and Champions League finals from 1956, and it runs entirely in your browser. No server or API keys needed.

The naive version

The first attempt was the obvious one. Pull the Wikipedia article for every final, chop each one into chunks of roughly 200 words, run every chunk through a small embedding model, and rank by cosine similarity against the query. Then run BM25 keyword search alongside it and fuse the two rankings together to catch football terms that the model may not be so aware of.

It half-worked, but it could be better. Half of the results provided a moment which might've been relevant to a few words that were part of the query, but not the whole sentence itself.

The model was never the problem

Searching "comeback" returned 1959's Real Madrid 2–0 Reims which is not really a comeback.

The initial cause was due to just pulling in anything Wikipedia had to say. A Wikipedia article about a final also contains a background section, a "route to the final" section, and a rundown of previous meetings between the two clubs. Which was all being treated as one document, so a paragraph describing a completely different match inherited the year and the fixture of the article it happened to sit inside.

I cut those three section types out of the corpus entirely. That was the single biggest improvement of the entire project.

Bale's bicycle kick

Searching "bicycle kick" would not return Gareth Bale's goal in the 2018 final. Which I had happened to try as my first test.

The sentence describing the goal, averaged into its surrounding 200-word chunk scored 0.433, which put it below finals that also mentioned other types of kicks. One vector per chunk meant one average meaning per chunk, and a single vivid sentence gets diluted by all the ordinary text sitting around it.

The fix was to index individual sentences as well as windows, so a single moment can win on its own terms instead of being averaged into mediocrity.

The words the articles never use

I ran into a problem when trying to describe moments using more football-specific terminology.

The 1999 article mentions two injury-time goals but never says comeback. The 2018 article says bicycle kick and never overhead kick. So it makes a lot of moments unfindable depending on how you are describing them - some may type "comeback" and the other "bottled it".

So there's was one paid step, run once by me and not part of the build, where I threw the existing data at an LLM to write two things per final:

  • A paragraph on how that final is remembered in the language people actually use
  • A list of the other names for what happened in it.

Both get indexed next to the article. The output is committed to the repo along with the rest of the information, so I only have to do this once.

What didn't help

Every improvement that stuck came from the shape of the data or the way the two rankings were combined. Not one came from a bigger model, which I didn't want anyway to keep things fast.

The result

Every result shows its own scores, so a bad answer is diagnosable.

A search takes 40–80ms across 4,029 indexed passages, on your own device. The only annoying part is the first visit, where the 50 MB download of the model is needed. Which the page will ask you for before proceeding

Shoutout for the support: Opus 5

It still gets things wrong, due to the model comparing how things are described, not what actually happened. Situations worded alike are genuinely hard for it to tell apart. Seeing how far that gets you in a browser alone was the entire point.