You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(search): relevance scoring, shared parser probe and accent-safe filters (#536)
* fix(scoring): score by share of terms, drop Italian stopwords, fix accented 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
* fix(search): same parser probe for find_relevant_datasets, accent-safe 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
* fix(search): acronyms survive stopwords, NFC matching, unbounded tag 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
* perf(tags): bound the tag facet, widen it only when the filter comes 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
* fix(tags): let an escalation failure surface, order imports
- 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
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copy file name to clipboardExpand all lines: LOG.md
+53Lines changed: 53 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,6 +2,59 @@
2
2
3
3
## 2026-09-05
4
4
5
+
### Relevance scoring: three defects that only became visible once search worked
6
+
7
+
Testing v0.4.122 through an MCP client, not curl, showed `ckan_find_relevant_datasets`
8
+
answering its own documented example badly. `defibrillatori Comune di Lecce` on
9
+
dati.gov.it used to return the catalog's only Lecce dataset — because the search
10
+
returned exactly one result. With recall restored it returns 679, and the top three
11
+
became Trento, Martina Franca and Desio while the Lecce dataset fell out of the window.
12
+
13
+
The wrapping fix did not cause this; it removed the cover. Three defects, all older:
14
+
15
+
-`scoreTextField` awarded the **whole** field weight when **any** query term matched.
16
+
"Comune di Martina Franca" and "Comune di Lecce" both scored a full holder match, so
17
+
the right dataset could not outrank the wrong ones. It now scores the share of terms
18
+
the field carries.
19
+
- the stopword list was English-only, so `di` counted as a term: "Provincia Autonoma
20
+
**di** Trento" earned a full holder match on a query asking for Lecce. Italian
21
+
stopwords added.
22
+
-`\b` is ASCII-only in JavaScript, so a term ending in an accented letter never found
23
+
its word boundary: `mobilità` scored 0 against "mobilità urbana", `qualità` against
24
+
"qualità dell'aria". On a catalog that is mostly not in English this silently sank
25
+
every accented query. Replaced with Unicode lookarounds.
26
+
27
+
Looking for more of the same family turned up three more, all cases of a local filter
28
+
running over a truncated or wrongly-normalised set:
29
+
30
+
-`ckan_find_relevant_datasets` never called the parser probe. `portals.json` used to
31
+
cover it; removing `force_text_field` left it sending boolean queries to the parser that
32
+
ignores them. On dati.comune.milano.it `aria OR acqua` returned 0 there against 87 from
33
+
`ckan_package_search`. Same probe now applies to both.
34
+
-`ckan_organization_search` builds a Solr wildcard, which bypasses the analysis chain, so
35
+
the pattern must be pre-normalised the way CKAN builds a name slug. It lowercased but did
36
+
not fold accents: `città` returned 0 while `citta` matched 135 datasets.
37
+
-`ckan_tag_list` applied `tag_query` after faceting, with `facet.limit` set to the
38
+
caller's `limit`. On dati.gov.it 53 tags contain "citta" and none is in the top 100, so
39
+
the filter answered "no tags" while they existed. The facet is now widened when a filter
40
+
is given.
41
+
42
+
`openspec/specs/ckan-search/spec.md` described the parser as a property of
43
+
`ckan_package_search` alone, which is what let the second caller go unnoticed — and after
44
+
yesterday it was also wrong, still describing the per-portal default that was removed.
45
+
Rewritten as a property of the query-building path, naming every tool that shares it.
46
+
47
+
Also raised the candidate window to at least 50: the local ranking only sees what Solr
48
+
returns first, and `limit: 3` shrank it to 15 — enough when a search returned a handful
49
+
of results, not enough now.
50
+
51
+
`defibrillatori Comune di Lecce` puts the Lecce dataset first again. 532 tests, 5 added.
52
+
53
+
How it was missed: yesterday's verification checked result **counts** through
54
+
`ckan_package_search`, never the ranked output of `ckan_find_relevant_datasets` — the
55
+
third most used tool in the telemetry, and the second caller of `resolveSearchQuery`.
56
+
Counting results proves recall, not usefulness.
57
+
5
58
### v0.4.122 - Solr parser fix
6
59
7
60
Ships #534: the `text:(...)` wrapper is reserved for the queries dismax cannot serve, the escaping preserves unary operators and balanced grouping, and the parser probe measures two terms taken from the catalog on every portal. `force_text_field` is gone from `portals.json`.
Copy file name to clipboardExpand all lines: openspec/specs/ckan-search/spec.md
+59-8Lines changed: 59 additions & 8 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,18 +1,69 @@
1
1
# ckan-search Specification
2
2
3
3
## Purpose
4
-
TBD - created by archiving change update-search-parser-config. Update Purpose after archive.
5
-
## Requirements
6
-
### Requirement: Package search parser override
7
-
The system SHALL support a per-portal default and a per-request override to force package search queries through the `text` field when needed, and SHALL escape Solr/Lucene special characters when wrapping queries in `text:(...)`.
4
+
How this server turns a caller's query into a CKAN `package_search` request: which Solr
5
+
parser it reaches, how the query is escaped, and how results are ranked.
8
6
9
-
#### Scenario: Portal default applies
10
-
-**WHEN** a portal is configured to force the text-field parser
11
-
-**THEN**`ckan_package_search` uses `text:(...)` for non-fielded queries by default with escaped query content
7
+
## Requirements
8
+
### Requirement: Solr parser selection
9
+
10
+
Every tool that builds a Solr query for `package_search` SHALL resolve the parser through
11
+
`resolveSearchQuery`, and SHALL use the same portal probe when the query may need wrapping.
12
+
This is a property of the query-building path, not of one tool: today the path is shared by
13
+
`ckan_package_search` and `ckan_find_relevant_datasets`, any tool added later belongs on
14
+
that list, and a change touching the parser SHALL be verified against every tool on it.
15
+
16
+
Background: CKAN sends a colon-free query to Solr's dismax parser with `q.op=AND`,
17
+
`mm='2<-1 5<80%'` and `qf='name^4 title^4 tags^2 groups^2 text'`. dismax has no boolean
18
+
syntax, so `A OR B` collapses into `A AND B`; a colon takes the query off dismax, which is
19
+
what `text:(...)` exploits. The same switch discards the `qf` boosts and ANDs every term on
20
+
one field, so the wrapper helps a boolean query and harms every other shape.
21
+
22
+
#### Scenario: Boolean query on a portal that ignores booleans
23
+
-**WHEN** a query carries `AND`, `OR` or `NOT`, or punctuation inside a word, and the
24
+
portal probe finds the default parser does not honour a disjunction
25
+
-**THEN** the query is wrapped in `text:(...)` with its content escaped
26
+
-**AND** the wrapper is applied identically by `ckan_package_search` and
27
+
`ckan_find_relevant_datasets`
28
+
29
+
#### Scenario: Plain keyword query
30
+
-**WHEN** a query carries no boolean operator, which is the shape an LLM client generates
31
+
from a user's request
32
+
-**THEN** the query reaches the portal's own parser unwrapped, keeping the `qf` boosts
33
+
and `mm`, and no probe is issued
34
+
35
+
#### Scenario: Unary operator
36
+
-**WHEN** a query carries a `+`, `-` or `!` in operator position
37
+
-**THEN** the character survives the escaping, because dismax honours it natively and
38
+
escaping it inverts the caller's intent
12
39
13
40
#### Scenario: Request override applies
14
41
-**WHEN** a client explicitly requests the text-field parser
15
-
-**THEN**`ckan_package_search` uses `text:(...)` regardless of portal defaults with escaped query content
42
+
-**THEN** the wrapper is applied regardless of what the probe found
43
+
44
+
#### Scenario: Portal where the wrapper does not work
45
+
-**WHEN** the probe finds the wrapped form returns fewer results than the plain one
46
+
-**THEN** no wrapping is applied on that portal, and the verdict is cached only if it was
47
+
actually measured
48
+
49
+
### Requirement: Relevance ranking
50
+
51
+
`ckan_find_relevant_datasets` ranks locally over the candidates `package_search` returns
52
+
first, so its answer depends on both the recall of the query and the size of the candidate
53
+
window. The tool SHALL score a field by the share of query terms it carries, SHALL match
54
+
terms on Unicode word boundaries, and SHALL score at least 50 candidates whatever the
55
+
requested limit.
56
+
57
+
#### Scenario: Field scoring
58
+
-**WHEN** a field contains some of the query's terms
59
+
-**THEN** it scores in proportion to the share it carries, never the full weight for a
60
+
single term
61
+
62
+
#### Scenario: Non-English text
63
+
-**WHEN** a query term ends in an accented letter, or is a stopword of the catalog's
64
+
language
65
+
-**THEN** term matching respects Unicode word boundaries, and the stopword does not
0 commit comments