ADR 074 · Accepted
Reciprocal Rank Fusion as a Hosted Utility
Context
nr_ai_search fuses its dense (embedding) and sparse (keyword) retrieval arms with Reciprocal Rank Fusion (Cormack et al., 2009): rank-only fusion combines rankings whose scores live on incomparable scales — dense cosine similarity versus BM25 — without any score normalization. Its implementation is 55 lines, pure and dependency-free, and the sparse arm it fuses is nr_llm's own keyword-search facade (ADR-071).
The 2026-07 downstream-extraction analysis flagged the class as genuinely portable but deferred hosting it here: ADR-049 decided the retrieval cascade is first-available-wins with no cross-backend merging, so RRF would have been public API with zero callers inside nr_llm. The revisit trigger was a second consumer of rank fusion, or a fan-out-and-merge retrieval mode superseding ADR-049.
Every hybrid consumer that pairs its own dense arm with the ADR-071 sparse facade needs this same fusion step, and each copy re-derives the identical math. The maintainer pulled the second-consumer trigger forward: host the utility now so hybrid consumers share one implementation instead of each carrying a private copy.
Decision
Host the class as Netresearch\NrLlm\Service\Retrieval\ReciprocalRankFusion with the signature identical to the nr_ai_search original — fuse(array $rankedKeyLists, int $k = 60, array $weights = []): array — so a consumer migrates by swapping the namespace import, nothing else. As in the original, PHP array-key coercion applies to the fused result: numeric-string keys (e.g. '42') come back as int.
Pure final class, no interface. The math has exactly one correct implementation; an interface would add an abstraction with nothing to substitute.
Newable, not a DI service. The class is stateless with a no-argument constructor; consumers instantiate it with new. It is excluded from container autoconfiguration in Configuration/Services.yaml and adds no public: true override, so the audited public-service count (ADR-028 / ADR-065 / ADR-071) is unchanged.
ADR-049 is unchanged. nr_llm's own retrieval cascade remains first-available-wins: RetrievalService does not fan out across backends and does not merge their results. ReciprocalRankFusion is a consumer-facing utility; no nr_llm code path calls it.
Consequences
nr_ai_search (and any later hybrid consumer) deletes its own copy and imports Netresearch\NrLlm\Service\Retrieval\ReciprocalRankFusion; behaviour is bit-identical.
The class is supported public API surface: changing fuse()'s signature or tie-breaking semantics is a breaking change for consumers and belongs in a release note.
Should ADR-049's cascade ever gain a fan-out-and-merge mode, the fusion math is already in place; adopting it there would be a new ADR superseding ADR-049, not a change to this one.