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.

← All ADRs