ADR-071: Public keyword-search facade over the retrieval cascade
- Status
-
Accepted
- Date
-
2026-07-17
- Authors
-
Netresearch DTT GmbH
Context
ADR-049 built the site-search retrieval cascade
(Service 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
Service via an @-reference in
its own Services. and imports four non-contract classes
(Retrieval including its bounds constants,
Access, Search,
Evidence). 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
Netresearch with
exactly two methods:
public function search(string $query, int $limit, ?int $languageId = null): array; // list<KeywordHit>
public function isAvailable(): bool;
Keyword 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 Evidence
minus the backend label, so cascade internals can evolve without
touching the contract.
Semantics
- Clamp, never throw. The query is trimmed and truncated to
Retrieval; a query shorter thanQuery:: MAX_ QUERY_ LENGTH Retrievalreturns an empty list; the limit is clamped to 1..Query:: MIN_ QUERY_ LENGTH Retrieval; a negative language id is clamped to 0. Out-of-range input is a normal call, not an exception.Query:: MAX_ SOURCES - Public-only. The facade always searches with
Access— hits are what the anonymous visitor could read.Context:: public Only () - 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
Retrieval).Service
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
Keywordalias resolves the full cascade including the database fallback — the right default for "find the page about X" consumers.Search Interface - The named service
nr_llm.keyword_search.index_backedresolves a variant constructed with$indexBackedOnly: truethat excludes the priority-0 tier, per theSearchcontract ("the always-available database fallback uses 0, index-backed engines use higher values"). ItsBackend Interface:: get Priority () isanswers for index-backed engines only.Available ()
A named service variant was chosen over a second interface method so
the contract stays at two methods and each variant gives a coherent
is 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
TestsUnit Configuration Public Services Policy Test EXPECTED_PUBLIC_TRUE_COUNTconstant 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() > 0index-backed distinction are now contract; cascade internals (backends,Retrieval,Service Evidence) remain private and free to change as long as the facade is preserved.Source - Consumers drop private references. nr_ai_search can replace its
@Netresearch\NrLlm\Service\Retrieval\SolrSearchBackendbinding and itsRetrieval/Query Access/Context Evidenceimports with the interface (or the named variant) andSource Keyword, guarded by a normal version constraint bump.Hit - Documentation. The facade is documented in
Documentation/.Api/ Keyword Search. rst
Alternatives considered
- Publish the internals (
Search,Backend Interface Retrieval,Service Retrieval, ...). Rejected: far wider surface, freezes the cascade's internal shape pre-1.0, and repeats the coupling this ADR removes one level down.Query - A second interface method (
searchIndexBacked()). Rejected: the mode would double every future method and leaveisambiguous about which mode it answers for.Available () - 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.