Skip to content

fix(search): relevance scoring, shared parser probe and accent-safe filters - #536

Merged
aborruso merged 5 commits into
mainfrom
fix/relevance-scoring-dilution
Sep 5, 2026
Merged

fix(search): relevance scoring, shared parser probe and accent-safe filters#536
aborruso merged 5 commits into
mainfrom
fix/relevance-scoring-dilution

Conversation

@aborruso

@aborruso aborruso commented Sep 5, 2026

Copy link
Copy Markdown
Member

What happened

Testing v0.4.122 through an MCP client rather than curl showed ckan_find_relevant_datasets answering its own documented example badly.

v0.4.121 v0.4.122 this PR
total results 1 679 679
1st Defibrillatori DAE — Comune Cardioprotetto (Lecce) Defibrillatori (Trento) Defibrillatori DAE — Comune Cardioprotetto (Lecce)
2nd Martina Franca Elenco patrocini Comune di Lecce 2015
3rd Desio Elenco patrocini Comune di Lecce 2016

defibrillatori Comune di Lecce is the example the tool's own description gives for holder weighting. It used to work for the wrong reason: the search returned exactly one result, so there was nothing to rank.

The wrapping fix in #534 did not cause this. It removed the cover from defects that were already there — and looking for more of the same family turned up six in total.

Relevance scoring

Any term earned the whole field. scoreTextField returned the full weight if any query term matched, so "Comune di Martina Franca" and "Comune di Lecce" both scored a full holder match of 4 and the dataset actually held by Lecce could not outrank the others. It now scores the share of terms the field carries: 2 of 3 → 2.7, 1 of 3 → 1.3.

The stopword list was English-only. di counted as a query term, so "Provincia Autonoma di Trento" earned a full holder match on a query asking for Lecce. That is why nearly every candidate scored 13.

\b is ASCII-only in JavaScript. A term ending in an accented letter never found its word boundary: mobilità scored 0 against "mobilità urbana", qualità against "qualità dell'aria". On a catalog that is mostly not in English this silently sank every accented query. Replaced with Unicode lookarounds, which still reject immobilità for mobilità.

The candidate window was too small. Now at least 50 (Math.min(Math.max(limit * 5, 50), 100)): the local ranking only scores what Solr returns first, and limit: 3 shrank the window to 15.

Three more of the same family

ckan_find_relevant_datasets never called the parser probe. portals.json used to cover it; removing force_text_field in #534 left it sending boolean queries to the parser that ignores them.

query ckan_package_search ckan_find_relevant_datasets before after
aria OR acqua (Milano) 87 0 87
bandiera blu OR spiagge 7 1 7

ckan_organization_search did not fold accents. It builds a Solr wildcard, which bypasses the analysis chain, so the pattern has to be pre-normalised the way CKAN builds a name slug. It lowercased but stopped there: città returned 0 while citta matched citta-metropolitana-di-napoli and 134 others.

ckan_tag_list filtered a truncated list. tag_query is applied after faceting, with facet.limit set to the caller's limit. On dati.gov.it 53 tags contain "citta" and none is in the top 100, so the filter answered "no tags" while they existed. The facet is widened when a filter is given, and the limit applied to the filtered list.

The spec

openspec/specs/ckan-search/spec.md named ckan_package_search alone in both parser scenarios. That is what let the second caller go unnoticed: it described the parser as a property of one tool, when it is a property of the query-building path. After #534 it was also wrong, still describing the per-portal default that was removed.

Rewritten around the invariant — every tool that builds a Solr query for package_search resolves the parser through resolveSearchQuery and the same probe, today ckan_package_search and ckan_find_relevant_datasets, and a change touching the parser is verified against every tool on that list. Scenarios added for plain, boolean and unary shapes, for the portal where the wrapper does not work, and for relevance ranking. openspec validate --specs --strict passes.

Verification

  • 532 tests, 5 added, all anchored to the measured cases above
  • e2e through the MCP client and a local HTTP server: the Lecce dataset is first again; qualità dell'aria Milano returns air-quality monitoring datasets; the two search tools now agree on boolean queries; città finds 4 organizations and 5 tags

How it was missed

Yesterday's verification checked result counts through ckan_package_search, never the ranked output of ckan_find_relevant_datasets — the third most used tool in the telemetry, and the second caller of resolveSearchQuery, which I had listed and not exercised. Counting results proves recall, not usefulness.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2

…cented boundaries

Testing v0.4.122 through an MCP client rather than curl showed
ckan_find_relevant_datasets answering its own documented example badly.
`defibrillatori Comune di Lecce` on dati.gov.it used to return the catalog's only
Lecce dataset — because the search returned exactly one result. With recall
restored it returns 679, and the top three became Trento, Martina Franca and
Desio while the Lecce dataset fell out of the window.

The wrapping fix did not cause this, it removed the cover. Three older defects:

- scoreTextField awarded the whole field weight when any term matched, so "Comune
  di Martina Franca" and "Comune di Lecce" both scored a full holder match and the
  right dataset could not outrank the wrong ones. It now scores the share of terms
  the field carries.
- the stopword list was English-only, so `di` counted as a term and "Provincia
  Autonoma di Trento" earned a full holder match on a query asking for Lecce.
- `\b` is ASCII-only in JavaScript, so a term ending in an accented letter never
  found its word boundary: `mobilità` scored 0 against "mobilità urbana",
  `qualità` against "qualità dell'aria". On a mostly non-English catalog this sank
  every accented query. Replaced with Unicode lookarounds.

The candidate window is now at least 50: the local ranking only sees what Solr
returns first, and `limit: 3` shrank it to 15.

Verified: the Lecce dataset is first again, `qualità dell'aria Milano` returns air
quality monitoring datasets. 532 tests, 5 added.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
@greptile-apps

greptile-apps Bot commented Sep 5, 2026

Copy link
Copy Markdown

Greptile Summary

This PR improves CKAN search relevance and consistency across package, organization, and tag tools.

  • Scores text fields proportionally across matching query terms and handles accented text across Unicode normalization forms.
  • Applies shared parser probing to both package-search paths and expands the local ranking candidate window.
  • Accent-folds organization and tag filters.
  • Uses bounded tag-facet retrieval first, propagates exhaustive fallback failures, and restores count-based ordering locally.
  • Updates the search specification, regression tests, and change log.

Important Files Changed

Filename Overview
src/tools/package.ts Improves multilingual term extraction, proportional relevance scoring, Unicode matching, parser resolution, and candidate selection.
src/tools/tag.ts Adds accent-safe filtering, bounded-first facet retrieval, explicit fallback failure propagation, and deterministic count ordering.
src/tools/organization.ts Normalizes accented organization search patterns before constructing Solr wildcard queries.
tests/unit/package-scoring.test.ts Adds focused regressions for stopwords, acronyms, proportional scoring, boundaries, and Unicode normalization.
openspec/specs/ckan-search/spec.md Defines shared parser-selection and relevance-ranking invariants for package-search tools.

Reviews (5): Last reviewed commit: "fix(tags): let an escalation failure sur..." | Re-trigger Greptile

Comment thread src/tools/package.ts
Comment thread src/tools/package.ts
…e filters

Sweeping for defects of the same family as the scoring dilution turned up three
more, all local filters running over a truncated or wrongly-normalised set.

- ckan_find_relevant_datasets never called the parser probe. portals.json used to
  cover it, and removing force_text_field left it sending boolean queries to the
  parser that ignores them: on dati.comune.milano.it `aria OR acqua` returned 0
  there against 87 from ckan_package_search. Both tools now share the probe.
- ckan_organization_search builds a Solr wildcard, which bypasses the analysis
  chain, so the pattern has to be pre-normalised the way CKAN builds a name slug.
  It lowercased but did not fold accents: `città` returned 0 organizations while
  `citta` matched 135 datasets' worth.
- ckan_tag_list applied tag_query after faceting with facet.limit set to the
  caller's limit. On dati.gov.it 53 tags contain "citta" and none is in the top
  100, so the filter answered "no tags" while they existed.

openspec/specs/ckan-search/spec.md named ckan_package_search alone in both parser
scenarios, which is what let the second caller go unnoticed, and after #534 it was
also wrong — it still described the per-portal default that was removed. Rewritten
as a property of the query-building path, naming every tool that shares it, with
the invariant that a change touching the parser is verified against all of them.
`openspec validate --specs --strict` passes.

Verified e2e: find_relevant_datasets 87 and 7 on the two boolean queries, matching
package_search; `città` finds 4 organizations and 5 tags; the Lecce dataset stays
first. 532 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
@aborruso aborruso changed the title fix(scoring): score by share of terms, drop Italian stopwords, fix accented boundaries fix(search): relevance scoring, shared parser probe and accent-safe filters Sep 5, 2026
Comment thread src/tools/tag.ts Outdated
…facet

Three review findings, all confirmed:

- the Italian stopwords were applied to every portal, so `UN population` lost the
  `UN`. Removing the list is not an option — without it the Lecce query ranks
  Martina Franca first, since `di` inflates its title match — so an all-caps token
  now survives the list: an acronym is not an article.
- term matching compared raw Unicode, so an NFC query would miss NFD metadata.
  Both sides are normalised to NFC now.
- widening the tag facet to 1000 moved the false-negative boundary instead of
  removing it. `facet.limit: -1` returns every tag when a filter is given: 14138
  on dati.gov.it, against 3 from the tag_list action.

535 tests, 3 added. Verified e2e: the Lecce dataset stays first with `di` still
dropped, `UN population` keeps its acronym, `città` matches across the full tag set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
Comment thread src/tools/tag.ts Outdated
…up short

`facet.limit: -1` made every filtered tag query download the whole facet — 1.4 MB
and 0.5s on dati.gov.it, more on a larger catalog — however few tags the caller
asked for.

The filtering cannot move server-side: CKAN's parameter whitelist rejects Solr's
`facet.contains` with "Invalid search parameters". So the facet starts bounded at
max(limit * 20, 500), roughly 50 KB, and only a filter that came up short pays for
the full set. `citta` is answered by the bounded window, `zzzqwerty` escalates and
still returns nothing, which is the honest answer.

The escalation exposed one more thing: Solr sorts a facet by count only while
facet.limit is positive. Asked for -1 it returns index order, so the widened set
arrived reverse-alphabetical ("zuglio", "zucs", ...) and the first three matches
were arbitrary. `facet.sort` is rejected by CKAN as well, so the ordering the tool
documents is restored locally: `acqua` now returns acqua(154),
impianti-agricoli-e-di-acquacoltura(66), acqua-dolce(13).

535 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
Comment thread src/tools/tag.ts Outdated
Comment thread src/tools/tag.ts
- the exhaustive facet request swallowed its error with `.catch(() => null)` and
  returned the bounded set, answering "these are the matching tags" while hiding
  the ones it never looked at — the same silent truncation this PR is about. The
  error now goes to the tool's normal path.
- the stripAccents import sat after the type-only one, splitting the internal
  group. AGENTS.md orders imports external, internal, types.

535 tests. Verified: `acqua` still returns acqua(154),
impianti-agricoli-e-di-acquacoltura(66), acqua-dolce(13); a filter matching
nothing still returns nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
@aborruso
aborruso merged commit 244a6d8 into main Sep 5, 2026
4 checks passed
@aborruso
aborruso deleted the fix/relevance-scoring-dilution branch September 5, 2026 16:13
@aborruso aborruso mentioned this pull request Sep 5, 2026
aborruso added a commit that referenced this pull request Sep 5, 2026
Ships #536, the relevance and parser-probe fixes. Version bumped in package.json,
package-lock.json, manifest.json, server.json (both fields), src/server.ts and
src/worker.ts. No tools added or removed, so /health stays at 20.

535 tests pass.


Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
aborruso added a commit that referenced this pull request Sep 6, 2026
Review finding on #543: `"OR"` in double quotes is a literal search term to Solr,
and dropping every OR token after tokenisation had discarded the quotes left such
a query with no terms to score on. It had never scored — before #536 the `or`
stopword took it — so this is a step past the regression, not just its repair.

Quoted tokens are collected before filtering; an operator is dropped only when
it is bare. Test added; 545 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2
aborruso added a commit that referenced this pull request Sep 6, 2026
* fix(scoring): a Solr operator is not an acronym

The all-caps exception added in #536 so that `UN population` keeps its `UN` also
let `OR`, `AND` and `NOT` through: `aria OR acqua` scored on three terms, and a
title carrying one of them got 4 × 1/3 instead of 4 × 1/2. Found testing the
local MCP client after the merge — the term list in the response read
["aria","or","acqua"].

Solr's boolean keywords are dropped before the acronym rule. Lowercase `or`
was already a stopword and stays one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2

* fix(scoring): a quoted operator is a literal, and scores

Review finding on #543: `"OR"` in double quotes is a literal search term to Solr,
and dropping every OR token after tokenisation had discarded the quotes left such
a query with no terms to score on. It had never scored — before #536 the `or`
stopword took it — so this is a step past the regression, not just its repair.

Quoted tokens are collected before filtering; an operator is dropped only when
it is bare. Test added; 545 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2

* fix(scoring): an escaped quote does not end a quoted phrase

Review finding: the quoted-fragment regex stopped at a backslash-escaped quote,
so in `"OR\" AND"` only `OR` counted as quoted and the literal `AND` was dropped
as syntax. The regex now skips escaped characters inside the phrase. Test added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEpSWpAwuMaGkfnMpQdqK2

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant