[feature] Index-first Lucene search: ft:query-scope (live nodes) and ft:search-scope (ES-shaped map)#6455
Open
joewiz wants to merge 7 commits into
Open
[feature] Index-first Lucene search: ft:query-scope (live nodes) and ft:search-scope (ES-shaped map)#6455joewiz wants to merge 7 commits into
joewiz wants to merge 7 commits into
Conversation
ft:search-index($scope, $query, $options?) queries the Lucene index directly over the
documents in $scope and returns ALL matching nodes — of any indexed element type — with
their Lucene scores and match highlighting attached, exactly as ft:query results carry
them. Unlike ft:query it does not evaluate relative to an XPath context node set, so:
- relevance is correct for every hit regardless of how deeply the matched element is
nested (it avoids the //* descendant-wildcard ft:score-loss artifact by never using an
XPath node set as the query unit), and
- it is element-name independent — no need to enumerate or union the contributing element
types, so content producers stay decoupled from the search aggregator.
The result is an ordinary node set, so ft:score, ft:facets, ft:field and
ft:highlight-field-matches compose on it as usual. This is the focused native primitive
underpinning the field-first ("eXlasticSearch") search design; the ES _search-style result
map (hits/fields/facets/highlights/live-node) is assembled in XQuery on top of this node set.
Implementation reuses the existing scored XML-field search path: it builds a DocumentSet
from the scope collections and calls LuceneIndexWorker.query(...) with a null contextSet
(index-first, no descendant-of constraint) and null qnames (all defined indexes).
Tests (ft-search-index.xqm): searchable content in NESTED elements (para/caption) — the
case where //* loses ft:score — proving search-index finds them across element types,
scores each > 0, is name-independent, composes with ft:facets/ft:field/ft:score/
ft:highlight-field-matches, returns live nodes, sorts by score, and matches all on an
empty query.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address review feedback on the ft:search-index draft: - Add the missing LGPL license header to LuceneSearchIndexTests.java so the org CI RAT/license check passes (the sibling LuceneAnalyzersTests has it). - Cover the 3-argument $options form, which was advertised but untested: facet drill-down (OPTION_FACETS, restricting "content:(array)" hits to the para vs caption facet value) and default-operator (flipping eXist's AND default to OR widens "array map" from 2 hits to 3, proving the options arg passes through). A 2-arg control documents the AND default. - Comment SearchIndex.eval to explain that options is positionally the 3rd argument and parseOptions short-circuits to defaults when argCount < 3, so the 2-arg form never dereferences a missing argument. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… companion Rename the index-first live-node function ft:search-index -> ft:query-scope (class SearchIndex -> QueryScope, with the test module/runner renamed to match). The name places it in the ft:query family it actually belongs to: same LuceneIndexWorker.query() path, live nodes, composes with ft:score/ ft:field/ft:facets/ft:highlight-field-matches. "search-index" misread from an Elasticsearch mindset, where "index" is the corpus, not part of the verb. Add ft-search-scope-map.xqm: the executable spec for an ES _search-shaped, map-returning companion (proposed native ft:search-scope), assembled in XQuery over ft:query-scope. It returns total/max-score/hits[]/facets, where each hit carries uri, node-id, score, a "source" map (requested stored fields), and an optional "highlight" snippet. Hit granularity defaults to the indexed element (honest to the index); a collapse option gives the ES-faithful one-hit-per-document view (group by document URI, best-scoring element), modeling the element-vs-document count discrepancy seen in /api/search. 10 tests pin the shape and both granularities; 23 tests total across the query-scope suite, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…panion
Replace the XQuery reference module with a native ft:search-scope function,
so the ES _search-shaped, map-returning companion lives in the ft: namespace
alongside ft:query-scope. It returns map { total, max-score, hits[], facets },
where each hit carries uri, node-id, score, and a "source" map of requested
stored fields. The $options map shapes the result: fields, facets (dimensions
to aggregate), collapse, limit.
Hit granularity defaults to the indexed element; collapse=true() groups to
one-hit-per-document (best-scoring element, total = distinct documents),
modeling the element-vs-document count discrepancy. Score is summed from the
node's Lucene matches (as ft:score does); fields come from the worker's
stored-field lookup; facets from each match's FacetsCollector, merged across
queries (as ft:facets does). Highlighting and a stored-fields-only fast path
(no node materialization) are noted follow-ups.
Factor the shared scope-resolution and index-first query execution out of
QueryScope into LuceneScope, used by both functions. 26 XQSuite tests across
the suite (13 query-scope + 13 search-scope), all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Address the two blockers from the existdb-openapi trial against a real corpus (223 docs / 2637 indexed function elements): - "highlight" option (xs:string*): adds a per-hit "highlight" map whose values are the exist:field/exist:match nodes produced by the existing ft:highlight-field-matches engine. ft:search-scope already materializes the live node internally, so it highlights before detaching to the map. Field.highlightMatches is made package-private static for reuse. - "offset" option (alias "from"): pages the ranked hits as ranked[offset, offset+limit). limit alone capped only the first page; total still reports the full count, so APIs can page past page 1. Naming and element-default granularity were confirmed by the same trial. The stored-fields-only fast path (the map form is currently the slowest of the three options) remains the documented follow-up. 31 XQSuite tests across the suite (13 query-scope + 18 search-scope), all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion
Thread a facet drill-down into the search so callers can restrict by a facet
value (e.g. a "section" within an app). The "filter" option is a
map { dimension: value(s) } that becomes a Lucene DrillDownQuery on the
search -- the ES post-filter analog. This must live in the query rather than
be applied caller-side: filtering here keeps total/limit/paging consistent,
which post-hoc filtering of the hit list cannot.
"filter" restricts the query; the other options (fields/highlight/facets/
collapse/offset/limit) still shape the result. Facet aggregation continues to
run over the (now filtered) match set. Other Lucene query options
(default-operator, ...) are not yet threaded -- a follow-up.
Tests: drill-down restricts total and the hits array (kind=para drops the
caption hit, 3 -> 2; kind=caption keeps 1). 34 XQSuite tests across the suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…issions Pin the document-level security guarantee that existdb-openapi's field-permission model relies on: neither scope function may return nodes or hits from documents the caller cannot read. They resolve scope through broker.allDocs(...) and materialize hits as persistent nodes through the broker, both of which enforce read permissions -- the same guarantee any collection()//x query honors. scope-dls.xqm stores a public doc (world-readable) and a secret doc (rw-------) both matching a shared term, then queries as guest vs admin via system:as-user: a guest gets only the public hit (count 1, total 1), admin gets both (2); a term indexed only in the secret doc is unreachable to the guest (0) but visible to admin (1); the guest's single search-scope hit is always the public document. Mirrors the visibility checks ft-search-binary.xqm makes for legacy ft:search. 43 XQSuite tests across the suite, all green -- DLS confirmed, not assumed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5 tasks
4 tasks
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.
[This PR was co-authored with Claude Code. -Joe]
Summary
Two new Lucene functions for index-first, name-independent full-text search over a collection scope:
ft:query-scope($scope, $query, $options?)→ live nodes. The index-first sibling offt:query: it searches the Lucene index directly over$scope(collection/document URIs, recursive) and returns every matching indexed element — of any element type — as a live node carrying its score and matches.ft:score/ft:field/ft:facets/ft:highlight-field-matchescompose on the result as usual.ft:search-scope($scope, $query, $options?)→ one Elasticsearch_search-shapedmap(*). The detached, map-returning companion for API builders:{ total, max-score, hits[], facets }, each hit{ uri, node-id, score, source, highlight }. Plain result data, no node-set to walk and re-serialize.Motivation
Two recurring needs:
Index-first, name-independent search. To search "all indexed content under a collection regardless of element name" today, you write
collection(...)//*[ft:query(., 'field:(…)')]. The descendant-wildcard form losesft:scorefor field queries (a known artifact), and it forces callers to enumerate the contributing element names.ft:query-scopequeries the index directly over the scope and returns every matching indexed node with correct relevance — no wildcard, no element-name union to maintain.An Elasticsearch
_search-style result for API builders. For something like existdb-openapi's/api/search, what you want is plain result data —total,hitswith fields,facets— not a node-set to walk and re-serialize.ft:search-scopereturns exactly that, assembled natively.Why two functions (and the naming)
It helps to place these against the existing functions on two axes — detached vs live result, and XML vs map output:
ft:search(legacy report)ft:search-scope(new)ft:query(context-scoped),ft:query-scope(scope-scoped, new)LuceneIndexWorkermethods. Thequery/searchpair mirrors eXist's existing split (ft:queryreturns live nodes; the legacyft:searchreturns a detached report).-scopesuffix signals "scoped by a collection, not by an XPath context node-set." I avoidedft:search-index: from an Elasticsearch mindset "index" is the corpus you search (a collection here), not part of a verb, so it reads ambiguously.Hit granularity (the one decision worth calling out)
"Document" means three things in this stack, and conflating them produces real count bugs:
$scopenames;intro.xml), an XmldbURI;<text qname="…">), so one document with two<para>and one<caption>yields three Lucene documents.So
$scopefilters at eXist-document granularity, but hits/counts/facets are at indexed-element granularity. Elasticsearch guarantees one Lucene document per ES document (1:1); eXist does not.ft:search-scopetherefore defaults to indexed-element granularity (honest to the index, sub-document precision), with acollapseoption for the ES-faithful one-hit-per-document view (group by document, best-scoring element,total= distinct documents) — the analog of ES field-collapse /top_hits.ft:search-scopeoptions$optionscarries afilterthat restricts the query, plus keys that shape the result:"filter"{ dimension: value(s) }restricting the search (ES post-filter analog; keepstotal/paging consistent)"fields"source(_source)"highlight"highlightmap ofexist:field/exist:matchnodes"facets""collapse"total= distinct documents)"offset"/"limit"Note:
ft:search-scopereturns a single envelopemap, so read the hit count from?total(orcount(?hits?*)) —count(ft:search-scope(...))is always1.What changed (
extensions/indexes/lucene)QueryScope.java(ft:query-scope) andSearchScope.java(ft:search-scope) — the two functions;SearchScopeassembles the ES map natively from the same index-first query, reading scores/fields/facets the wayft:score/ft:field/ft:facetsdo, and reusing theft:highlight-field-matchesengine forhighlight.LuceneScope.java— shared scope-resolution + index-first query execution, used by both functions (no duplication).LuceneModule.java— registers the four signatures.Field.java—highlightMatchesmade package-privatestaticsoft:search-scopecan reuse it on the live node it materializes; no behavior change toft:highlight-field-matches.ft-query-scope.xqm,ft-search-scope.xqm,scope-dls.xqm, and theLuceneQueryScopeTestsrunner.Companion fix (faceted highlighting)
ft:search-scope'shighlightworks on its own for unfaceted queries. Combiningfilter(facet drill-down) withhighlightadditionally requires a small fix to a pre-existing defect — a facetDrillDownQuerysilently disabledft:highlight-field-matchesterm extraction — submitted as a separate bugfix PR, #6454 (it affectsft:queryfaceted highlighting independent of these functions, so it's based ondevelop, not this branch). This PR doesn't depend on it except for that combined path.Test plan
ft:query-scope(name-independence, nested-element scoring, composition withft:score/ft:field/ft:facets/ft:highlight-field-matches, live nodes, sortable-by-score);ft:search-scope(envelope shape, element vscollapsegranularity,source/facets/highlight/filter/offset/limit).scope-dls.xqm): a guest never gets nodes or hits from documents it cannot read (verified viasystem:as-useragainst mixed read permissions) — the restricted document is absent from bothhitsandtotal, so the count does not leak its existence./api/search-style endpoint: identical hit set to thecollection(...)//el[ft:query(...)]approach withft:scorepreserved; faceted highlighting end-to-end (with [bugfix] Preserve ft:highlight-field-matches under facet drill-down #6454 in place).