LlamaIndex hybrid retrieval — QueryFusionRetriever score normalization and RRF errors (2026) — Fix Guide (2026) | AI Error Hub
Home Providers LlamaIndex Hybrid retrieval fusion
LlamaIndex Retrieval · Hybrid Severity: Medium HTTP n/a

LlamaIndex QueryFusionRetriever — fusion errors and BM25/vector imbalance

Hybrid retrieval combines multiple retrievers (typically BM25 for exact-match + vector for semantic) via fusion. When the fusion is wrong, one retriever dominates and hybrid loses to plain vector.

By Ahmed R. · Senior AI Infrastructure Engineer Published 2026-07-26 Verified 2026-07-26

Quick fix (TL;DR)

Resolution: QueryFusionRetriever combines results from multiple retrievers. Fusion modes: reciprocal_rerank (RRF — rank-based, robust); relative_score (score-based, sensitive to scale); simple (concat + dedupe). Fix by (a) using RRF as the default, (b) normalizing scores when mixing retrievers with different scoring scales, and (c) generating multiple queries via num_queries for query expansion.

Real error messages you'll see

These are the exact strings returned by the LlamaIndex framework and its integrations when this error occurs. Copy-paste-searching any of them should land on this page.

BM25 results dominate — scores incomparable
# BM25 scores: 40-80 (Okapi BM25 raw scores)
# Vector cosine scores: 0.3-0.9
# fusion_mode="relative_score" gives BM25 all top spots
RRF still favors one retriever
# BM25 top-2 both make top-5 fused
# Vector top-1 does not
# Cause: k parameter in RRF too low; small rank diffs dominate
QueryFusion with num_queries=1
# Just running one query through multiple retrievers,
# no query expansion benefit

Reference

Fusion modes

ModeHow it fusesBest for
reciprocal_rerankRRF: sum of 1/(k+rank) across retrieversRecommended default — robust to score scales
relative_scoreNormalized scores added with weightsWhen scores are comparable across retrievers
dist_based_scoreDistance-based scoringFor cosine-only retrievers
simpleJust concat + dedupeDebug / baseline

Root causes, ranked by frequency

Based on developer reports across LlamaIndex forums, GitHub issues, and Discord community during 2025–2026.

  • 25%
    Score scales incompatible for relative_score mode. BM25 raw scores vs cosine similarity in 0-1 range.
  • 18%
    Weights not set for retrievers. Default equal weights suboptimal when one retriever is stronger.
  • 14%
    k parameter in RRF too low. Small k emphasizes top-1 differences; large k smooths.
  • 10%
    num_queries=1 loses query expansion benefit. Fusion of one query per retriever is just merging.
  • 8%
    BM25 not tokenized for the corpus language. English tokenizer on non-English text; BM25 useless.
  • 7%
    Vector retriever using wrong embedding. Stale index; drift in cosine scores.
  • 10%
    Retriever similarity_top_k too small. BM25 top-3 + vector top-3 = 6 nodes to fuse; not enough diversity.
  • 8%
    Dedup not fusing scores. Duplicate nodes retain first-seen score; better one thrown away.

Fixes — copy-paste solutions

Fix #1

Use RRF as the default fusion mode

RRF is robust to score-scale differences between retrievers.

Reciprocal Rank Fusion is the standard for hybrid IR. Rank-based, not score-based, so scale differences between BM25 and cosine do not matter.

rrf_hybrid.py
from llama_index.core.retrievers import QueryFusionRetriever
from llama_index.retrievers.bm25 import BM25Retriever
from llama_index.core import VectorStoreIndex

# ... build vector_index and get nodes ready ...

# --- Vector retriever ---
vector_retriever = vector_index.as_retriever(similarity_top_k=20)

# --- BM25 retriever ---
bm25_retriever = BM25Retriever.from_defaults(
    nodes=all_nodes,
    similarity_top_k=20,
)

# --- Fusion retriever ---
fusion_retriever = QueryFusionRetriever(
    retrievers=[vector_retriever, bm25_retriever],
    similarity_top_k=10,          # final top-k after fusion
    num_queries=1,                # no query expansion (see next fix)
    mode="reciprocal_rerank",     # RRF — robust to score scale differences
    use_async=True,
    verbose=True,
)

# Use it in a query engine
from llama_index.core.query_engine import RetrieverQueryEngine
query_engine = RetrieverQueryEngine.from_args(fusion_retriever, llm=llm)

response = query_engine.query("What is the return window for damaged items?")
print(response)

# --- Inspect fusion behaviour ---
retrieved = fusion_retriever.retrieve("What is the return window?")
for i, node in enumerate(retrieved):
    print(f"{i}: score={node.score:.3f} source={node.metadata.get('source')}")

# RRF scores are always in the [0,1] range because they are computed as
#   sum over retrievers of 1 / (k + rank)
# with k typically 60. Small values but comparable across queries.
The k parameter in RRF (default 60) controls how much rank-1 vs rank-2 differ. Higher k smooths; lower k gives top-1 more weight. Rarely need to change from default.
Fix #2

Use num_queries > 1 for query expansion + hybrid benefit

The real power of QueryFusionRetriever is query expansion.

Set num_queries=4 to have the LLM generate paraphrases, then run each through each retriever, and fuse everything. Substantial recall lift.

query_expansion_fusion.py
from llama_index.core.retrievers import QueryFusionRetriever
from llama_index.llms.openai import OpenAI

llm = OpenAI(model="gpt-4o-mini")

# --- Query expansion + hybrid fusion ---
fusion_retriever = QueryFusionRetriever(
    retrievers=[vector_retriever, bm25_retriever],
    similarity_top_k=10,
    num_queries=4,               # LLM generates 4 query variants
    mode="reciprocal_rerank",
    llm=llm,                     # for query generation
    verbose=True,                # print generated queries
    query_gen_prompt=(
        # Optional custom prompt — default is decent
        "You are helpful. Given the user's query, generate {num_queries} "
        "paraphrased versions that capture different phrasing of the same intent.\n"
        "Original query: {query}\n\n"
        "Generate {num_queries} paraphrases (one per line):"
    ),
    use_async=True,               # crucial — otherwise VERY slow
)

# Execution: 4 queries × 2 retrievers = 8 parallel retrieval calls,
# fused via RRF into top-10

response = query_engine.query("How can I return a damaged product?")
# LLM generates variants like:
#   "What is the procedure for returning a damaged item?"
#   "How do I initiate a return for a broken product?"
#   "Return process for defective goods?"

# Each variant is retrieved separately, boosting recall on paraphrased queries
Query expansion is one of the highest-leverage recall improvements — often 15-25% hit rate lift on real queries. Cost: 1 extra LLM call per query for the generation.
Fix #3

When scores must be used, normalize per-retriever

relative_score mode needs comparable score distributions.

If you must use score-based fusion (e.g. for weighting), normalize each retriever's scores to the same range and apply per-retriever weights.

weighted_score_fusion.py
from llama_index.core.retrievers import QueryFusionRetriever
from llama_index.core.schema import NodeWithScore
from typing import List

class NormalizingRetriever:
    """Wraps a retriever and normalizes its scores to [0, 1]."""
    def __init__(self, retriever, weight: float = 1.0):
        self.retriever = retriever
        self.weight = weight

    async def aretrieve(self, query: str) -> List[NodeWithScore]:
        results = await self.retriever.aretrieve(query)
        if not results:
            return results
        max_score = max(n.score or 0 for n in results)
        min_score = min(n.score or 0 for n in results)
        rng = max_score - min_score
        if rng > 0:
            for n in results:
                # Normalize to [0,1], then apply weight
                normalized = (n.score - min_score) / rng
                n.score = normalized * self.weight
        return results

# --- Weighted fusion favouring vector over BM25 ---
weighted_vector = NormalizingRetriever(vector_retriever, weight=0.7)
weighted_bm25 = NormalizingRetriever(bm25_retriever, weight=0.3)

fusion = QueryFusionRetriever(
    retrievers=[weighted_vector, weighted_bm25],
    similarity_top_k=10,
    mode="relative_score",         # score-based since we've normalized
    use_async=True,
)

# --- Simpler alternative: post-hoc reranking with a cross-encoder ---
# Often better than any fusion tuning — rerank fused top-30 to top-5

from llama_index.postprocessor.cohere_rerank import CohereRerank

fusion_broad = QueryFusionRetriever(
    retrievers=[vector_retriever, bm25_retriever],
    similarity_top_k=30,       # broad candidate pool
    mode="reciprocal_rerank",
    use_async=True,
)

reranker = CohereRerank(top_n=5, model="rerank-english-v3.0", api_key="...")

from llama_index.core.query_engine import RetrieverQueryEngine
query_engine = RetrieverQueryEngine.from_args(
    fusion_broad,
    node_postprocessors=[reranker],
    llm=llm,
)
For most cases, RRF + cross-encoder rerank at the end outperforms elaborate weighted fusion. The rerank step is where you get the quality; fusion just assembles the candidate pool.

Prevention checklist

Ship these seven safeguards once and this error stops appearing in your logs.

  • Default to reciprocal_rerank (RRF) — robust to score-scale differences.
  • Set num_queries=3-5 for query expansion; substantial recall lift.
  • Set use_async=True — sequential fusion is very slow.
  • When mixing BM25 and vector, ensure BM25 uses the correct tokenizer for your corpus language.
  • Set retriever similarity_top_k generously (20-30) for diverse candidates before fusion.
  • For weighted scoring, normalize per-retriever first — raw score comparison is meaningless.
  • Combine fusion with a cross-encoder reranker at the end; more quality than fusion tuning alone.

Frequently asked questions

RRF is more robust because it does not depend on score comparability. relative_score can outperform when scores are truly comparable and you have good weights. Start with RRF; move to relative_score only if evaluation shows benefit.
For domain-specific corpora with exact-match terms (product SKUs, error codes, names), yes — BM25 catches what embeddings miss. For general prose, vector alone is often enough.
Native hybrid runs BOTH searches at the store level and returns pre-fused results. QueryFusionRetriever fuses at the client. Native is faster; client-side is more flexible. If your store has native hybrid, use it.
Yes — pass a list of any length. Common pattern: vector + BM25 + a metadata-filter retriever. Cost grows linearly with retriever count.
Yes — one LLM call for the expansion, then N × M retrieval calls (N queries × M retrievers). For latency-sensitive apps, keep num_queries modest (2-4).

Get the weekly AI-error digest

New fixes, provider status recaps, and one deep tutorial — every Tuesday. 8,400+ engineers.