# ADR-005 : Fusion RRF v2 (pondération question×2 + blending position-aware)

# ADR-005 : Fusion RRF v2 (pondération question×2 + blending position-aware)

> _Date : 2026-Q1 — Statut : accepté_

## Contexte

Le retrieval combine plusieurs sources :
- Vecteur dense de la question brute (TEI bge-m3)
- Vecteur dense du passage HyDE (Haiku-généré)
- Sparse BM25 sur la question brute
- (Selon intent) chunks META, chunks synergy

Toutes les sources retournent un top-K avec rank et score. Il faut les fusionner en une seule liste classée.

Premier essai (RRF v1, classique) : `score = Σ 1/(60 + rank)` pour chaque chunk présent dans une passe. Inspiré de la littérature standard.

Problèmes constatés :
- **HyDE dilue la précision** : les exact-matches de la question brute sont noyés par les paraphrases HyDE. Si rank 1 dans question + rank 50 dans HyDE → score combiné moyen, pas top-1.
- **Reranker peu confiant** sur du contenu technique abstrait (notes de mécaniques, conditions de victoire) → score reranker ~0 → enterre des bons chunks.

## Décision

**Fusion RRF v2** avec deux modifications :

### 1. Pondération question × 2

```typescript
score = 2 × (1 / (60 + rank_question + 1))
      + 1 × (1 / (60 + rank_hyde + 1))
      + 1 × (1 / (60 + rank_bm25 + 1))
```

La question brute pèse double — sa précision compte plus que celle du HyDE qui est une paraphrase.

### 2. Top-rank bonus

```typescript
if (rank === 0) score += 0.05;
else if (rank === 1 || rank === 2) score += 0.02;
```

Préserve les exact-matches contre la dilution.

### 3. Blending position-aware (post-rerank)

Au lieu d'écraser le score RRF avec le score reranker :

```typescript
const rrf_position = candidate.rrf_rank;
let weight_rrf, weight_rerank;
if (rrf_position <= 2)        { weight_rrf = 0.75; weight_rerank = 0.25; }
else if (rrf_position <= 9)   { weight_rrf = 0.60; weight_rerank = 0.40; }
else                          { weight_rrf = 0.40; weight_rerank = 0.60; }

final_score = weight_rrf * rrf_score + weight_rerank * rerank_score;
```

Si la fusion RRF a déjà classé un chunk dans le top, on garde son signal. Si le reranker est confiant et qu'il détrône un mauvais top, son signal compte plus.

Activable / désactivable via `RAG_FUSION_V2_ENABLED` (défaut `true`).

## Conséquences

### Bonnes
- Les exact-matches de la question survivent au passage HyDE
- Le reranker peu confiant sur du contenu abstrait n'enterre plus systématiquement les bons chunks
- Kill-switch via env var pour A/B test si nécessaire
- Inspiré de [tobi/qmd](https://github.com/tobi/qmd), pattern documenté

### Mauvaises
- **Magic numbers** : 0.05, 0.02, 75/25, 60/40, 40/60. Les ratios ont été tunés empiriquement, pas théoriquement.
- **Plus de paramètres à comprendre** pour un dev qui débarque
- Toute modification doit être validée sur le banc d'éval RAG (sinon régression silencieuse)

## Tuning

Si tu veux ajuster :
- **Ratios blending** : monter à 85/15 sur top-3 si tu veux encore plus protéger le signal RRF
- **Top-rank bonus** : monter à +0.10 sur rank=0 si exact-matches cruciaux

Toujours `npm run test:rag` avant et après pour comparer la qualité.

## Source d'inspiration

- [RRF original paper](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf) (Cormack 2009)
- [tobi/qmd](https://github.com/tobi/qmd) — implémentation référence pour les ratios pondérés
- Banc d'éval RAG interne (boardgame-referee `tests/rag/`)