ADR-074: Reciprocal Rank Fusion as a hosted utility
- Status
-
Accepted
- Date
-
2026-07-17
- Authors
-
Netresearch DTT GmbH
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 inConfiguration/Services.yamland adds nopublic: trueoverride, 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:
RetrievalServicedoes not fan out across backends and does not merge their results.ReciprocalRankFusionis 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 importsNetresearch\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.