---
title: "ADR-074: Reciprocal Rank Fusion as a hosted utility"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-074@0.35"
source: "Adr/Adr074ReciprocalRankFusionUtility.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# 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 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.
