ADR 071 · Accepted
Public Keyword-Search Facade over the Retrieval Cascade
Context
ADR-049 built the site-search retrieval cascade (ServiceRetrievalRetrievalService over the tagged nr_llm.retrieval_backend implementations: Solr, ke_search, indexed_search, database LIKE fallback). Every class in that namespace is private — the only public faces of the capability are the LLM tools site_rag_query / site_fetch_source. Yet ADR-050 explicitly promises keyword content-finding to downstream extensions ("a consumer that only needs keyword content-finding uses nr_llm alone"), and ADR-028 / ADR-065 require cross-extension consumption to go through the documented, audited public surface.
That gap has a demonstrated cost: nr_ai_search's hybrid retrieval binds its sparse arm to the private concrete service ServiceRetrievalSolrSearchBackend via an @-reference in its own Services.yaml and imports four non-contract classes (RetrievalQuery including its bounds constants, AccessContext, SearchBackendInterface, EvidenceSource). A private service id carries no semver signal: any rename or restructuring inside nr_llm breaks the consumer's container compile silently, across the consumer's whole version constraint.
Decision
Close the gap with a deliberately narrow public facade instead of exposing the retrieval internals.
The contract
NetresearchNrLlmServiceRetrievalKeywordSearchInterface with exactly two methods:
public function search(string $query, int $limit, ?int $languageId = null): array; // list<KeywordHit>
public function isAvailable(): bool;
KeywordHit is a final readonly DTO carrying sourceId, title, url, excerpt, languageId (int), score (?float, backend-native, not comparable across backends) and pageUid (?int) — the fields of the internal EvidenceSource minus the backend label, so cascade internals can evolve without touching the contract.
Semantics
Clamp, never throw. The query is trimmed and truncated to RetrievalQuery::MAX_QUERY_LENGTH; a query shorter than RetrievalQuery::MIN_QUERY_LENGTH returns an empty list; the limit is clamped to 1..RetrievalQuery::MAX_SOURCES; a negative language id is clamped to 0. Out-of-range input is a normal call, not an exception.
Public-only. The facade always searches with AccessContext::publicOnly() — hits are what the anonymous visitor could read.
Degrade to empty. Any backend failure yields an empty list; the facade never throws. Cascade order, first-available-wins, URL deduplication and the result cap are ADR-049 semantics, reused unchanged (the implementation composes RetrievalService).
The pinning question: index-backed-only mode
A hybrid dense+sparse consumer (nr_ai_search's RRF fusion) pins an index-backed engine and must treat "index unavailable" as an empty sparse arm — fusing hits from the priority-0 database LIKE fallback would silently mix engines of incomparable relevance into the fusion. The facade therefore ships in two container registrations of the same implementation class:
The KeywordSearchInterface alias resolves the full cascade including the database fallback — the right default for "find the page about X" consumers.
The named service nr_llm.keyword_search.index_backed resolves a variant constructed with $indexBackedOnly: true that excludes the priority-0 tier, per the SearchBackendInterface::getPriority() contract ("the always-available database fallback uses 0, index-backed engines use higher values"). Its isAvailable() answers for index-backed engines only.
A named service variant was chosen over a second interface method so the contract stays at two methods and each variant gives a coherent isAvailable() answer for its own mode. Consumers wire it as a named argument, e.g.:
Vendor\Ext\Search\SparseArm:
arguments:
$keywordSearch: '@nr_llm.keyword_search.index_backed'
Both registrations are public: true and part of the audited, semver-guarded surface.
Consequences
Public-service count: 26 → 28. The interface alias and the named index-backed variant join ADR-065's Category A (documented downstream contract). This ADR is the new count authority, superseding ADR-069's count exactly as ADR-069 superseded ADR-065's. The breakdown is now 16 + 5 + 1 + 4 + 2 = 28. The TestsUnitConfigurationPublicServicesPolicyTest EXPECTED_PUBLIC_TRUE_COUNT constant and this ADR are the audit trail; ADR-065's Category A list is amended in place.
The facade constrains Retrieval refactors. The clamping, public-only and degrade-to-empty semantics plus the getPriority() > 0 index-backed distinction are now contract; cascade internals (backends, RetrievalService, EvidenceSource) remain private and free to change as long as the facade is preserved.
Consumers drop private references. nr_ai_search can replace its @Netresearch\NrLlm\Service\Retrieval\SolrSearchBackend binding and its RetrievalQuery / AccessContext / EvidenceSource imports with the interface (or the named variant) and KeywordHit, guarded by a normal version constraint bump.
Documentation. The facade is documented in Documentation/Api/KeywordSearch.rst.
Alternatives considered
Publish the internals (SearchBackendInterface, RetrievalService, RetrievalQuery, ...). Rejected: far wider surface, freezes the cascade's internal shape pre-1.0, and repeats the coupling this ADR removes one level down.
A second interface method (searchIndexBacked()). Rejected: the mode would double every future method and leave isAvailable() ambiguous about which mode it answers for.
Constructor flag without a named registration. Rejected: a consumer could only reach the index-backed variant by redefining the service in its own container configuration — reintroducing exactly the unversioned wiring knowledge this facade exists to remove.