feat: rank keyword search with BM25 - #79
Merged
Merged
Conversation
`search unemployment` on Eurostat put UNE_RT_M — the monthly unemployment rate — at position 65 of 146. The old scorer summed raw token occurrences with no length denominator and weighed every query word the same, so a long title beat a relevant one and "di" counted like "disoccupazione". Ranking is now BM25: idf down-weights common terms, b normalises for document length. Field boosts (id, first 60 chars) survive, rescaled to the BM25 unit. Measured on two blind gold sets. A second one was written for Eurostat with the ISTAT protocol — 25 needs written with gold: null saved to disk before looking at the catalogue, gold filled in a second pass, one discarded as undecidable -> 24 needs, 48 queries. MRR Eurostat ES en ISTAT IS it before 0.086 0.172 0.073 0.133 after 0.166 0.330 0.169 0.326 semantic 0.241 0.308 0.327 0.343 In the provider's own language the keyword path now matches the semantic one, with no model, no server and no index build. Cross-language is untouched and is not fixable lexically: Italian queries on Eurostat score 0.000 before and 0.001 after; only the semantic arm scores at all. The candidate set is chosen exactly as before — AND over every token, OR only when that is empty. BM25 alone would score any document sharing one token, so "tasso di disoccupazione" would report 2405 matches on ISTAT instead of 28: correct at the top, useless as a total. Only the ordering changed, so result counts, pagination and --all behave as they did. Two details that would otherwise have made this silently worse. Tokenisation splits on the underscore: \w swallows it and would turn UNE_RT_M into one term, so no query could match an id exactly — an existing test caught it. And a token with no exact match expands to the terms it prefixes, at half weight, because whole-token BM25 kills prefix search outright (search comun -p istat: 605 results before, 0 with plain BM25). Prefix, not arbitrary substring. The index is built per call over the whole catalogue, never over the candidate set: idf is a property of the corpus, and a candidate set selected by the query contains its terms in every row. Cost 0.14 s on the largest catalogue, queries 1-18 ms, against a ~1.9 s Python import. No disk index, nothing to invalidate. No provider-specific branch: length normalisation is relative to each corpus, so the 5x spread in title length across providers needs no tuning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TekhaUTgxCgKVyiNEBCN36
|
| Filename | Overview |
|---|---|
| src/opensdmx/ranking.py | Introduces corpus-wide BM25 scoring, prefix expansion, and field boosts aligned with exact-or-prefix term matching. |
| src/opensdmx/discovery.py | Routes keyword-search candidates through BM25 while retaining AND/OR candidate selection and punctuation fallback behavior. |
| src/opensdmx/cli.py | Normalizes BM25 score presentation to two decimal places across supported output formats. |
| tests/test_discovery.py | Adds focused coverage for tokenization, BM25 weighting, prefix behavior, empty indexes, full-catalog IDF, and the prior substring-boost defect. |
| docs/search.md | Documents the new ranking model, measurements, limitations, and preserved search semantics. |
Reviews (2): Last reviewed commit: "fix: field boosts must match terms the w..." | Re-trigger Greptile
The id and head-of-title boosts tested plain containment (`t in term`) while the BM25 sum matches exact-or-prefix. So the query token "rt" boosted an id like EXPORT_ALPHA exactly as much as RT_ALPHA: an internal substring outranking a real token match, and two different matching rules inside one ranker. Expansions are now computed once per query and shared by both, so a term is recognised by the boosts precisely when it is recognised by the score. Measured cost, on the two gold sets: Eurostat 0.166 -> 0.161 (en 0.330 -> 0.320), ISTAT unchanged at 0.169 with one miss fewer and S@10 24.1% -> 25.9%. Inside the noise for a gold whose effective n is nearer 12 than 24. Published figures updated to what actually ships. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TekhaUTgxCgKVyiNEBCN36
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The default search path was the worst one, measurably.
search unemploymenton Eurostat putUNE_RT_M— the monthly unemployment rate — at position 65 of 146.search "gross domestic product"putNAMA_10_GDPat 16 of 29.The cause is the scorer, not the corpus: it summed raw token occurrences with no length denominator and weighed every query word the same. A long title beat a relevant one, and "di" counted like "disoccupazione". Handing it more text changed nothing — the extended-text arm scored 0.075 against the baseline 0.073, inside the noise.
Ranking is now BM25 (
src/opensdmx/ranking.py):idfdown-weights common terms,bnormalises for document length. The field boosts (id ×3, first 60 characters ×2) survive, rescaled to the BM25 unit.Measured on two blind gold sets
A second gold set was written for Eurostat with the same protocol as the ISTAT one: 25 information needs and their queries written with
gold: nullsaved to disk before looking at the catalogue, gold filled in a second pass, family gold, one need discarded as undecidable → 24 needs, 48 queries. Eurostat embeddings were built for the run — the first time the semantic arm has been measured on a provider without harvested prose.enitIn the provider's own language the keyword path now matches the semantic one — Eurostat 0.330 vs 0.308, ISTAT 0.326 vs 0.343 — with no model, no server and no index build. That is the case most users are in, on most providers.
Cross-language is untouched, and is not fixable lexically. Italian queries on Eurostat score 0.000 across 24 queries before this change and 0.001 after. Only the semantic arm scores at all (0.174). No term weighting invents words a document does not contain.
What it looks like on real queries
tasso di disoccupazione(ISTAT)...TAXDISOCCUMENS1_UNT2020— the series closed in 2020151_874— the current monthly seriespopolazione residente(ISTAT)164_346— intercensal reconstruction 197122_315— "Popolazione residente - bilancio"unemployment(Eurostat)LFSQ_UPGAL— top three all long-term unemploymentLFSA_URGAN— unemployment ratespopulation on 1 January(Eurostat)PROJ_19RP3— top three all projectionsTPS00001— the observationOf ten queries compared, four change clearly for the better, four change little or debatably, two are identical. The gain concentrates where the old scorer failed systematically; where the title was already short and specific, nothing moved. Full table in
docs/search.md.What deliberately did not change
The candidate set is chosen exactly as before — AND over every token, OR only when that is empty. BM25 alone would score any document sharing one token, so
"tasso di disoccupazione"would report 2405 matches on ISTAT instead of 28: correct at the top, useless as a total. Only the ordering changed, so result counts, the pagination title,--pageand--allbehave as they did.Two details that would otherwise have made this silently worse
Tokenisation splits on the underscore.
\wswallows it and would turnUNE_RT_Minto a single term, so no query could ever match an id exactly and the id boost would be dead. A pre-existing test caught this — it was the only failure in the suite.Prefix expansion. A query token with no exact match expands to the terms it prefixes, at half weight, so an exact hit always outranks a prefix hit. Without it, whole-token BM25 kills prefix search outright:
search comun -p istatgoes from 605 results to 0, and that behaviour ships in thesearch --helpexample. Prefix, not arbitrary substring —comunreaches "comuni" and "comunali", not "incomunicabile".Queries with no word characters (
search "[") fall back to literal substring matching.Cost
The index is built per call over the whole catalogue, never over the candidate set: idf is a property of the corpus, and a candidate set selected by the query contains its terms in every row, which would flatten the signal BM25 exists to provide.
Measured: 0.14 s build on the largest catalogue (Eurostat, 8150 dataflows), 28 ms on OECD, queries 1–18 ms — against a command whose Python import alone is ~1.9 s. No disk index, so nothing to invalidate when the dataflow or category cache refreshes.
Multi-provider
No provider-specific branch. Length normalisation is relative to each corpus, so the 5× spread in title length across providers (unicef averages 2.6 words per title, abs 13.7) needs no tuning. Verified live on eurostat, istat, oecd, ecb, ilo and abs — including providers with no category cache, where the corpus is title + id only.
Also
scoreis now a float on the BM25 scale, formatted to 2 decimals in table, JSON and CSV._score_resultsis off the search path but kept:eval/retrieval.pyuses it as the baseline arm, and deleting it would make the measurement that justified this change unreproducible.docs/search.mdrecords the measurement, its three declared limitations (the Eurostat gold pairs topics across registers so 24 needs are not 24 independent observations; its topics are easier than ISTAT's; two families are too narrow and were not retrofitted after seeing results), and the before/after table.Verification
ruff clean · mypy strict clean on 16 source files · 394 tests green, 9 new covering the tokenizer, idf, length normalisation, prefix expansion, the empty-corpus NaN guard, and the punctuation fallback.
🤖 Generated with Claude Code
https://claude.ai/code/session_01TekhaUTgxCgKVyiNEBCN36