Causal Attribution for RAG: Which Retrieved Chunks Actually Moved the Answer?

Cosine similarity tells you what your retriever found. It does not tell you what your model used. Those are very different questions — and the gap between them is where most RAG systems silently fail.


The Uncomfortable Question

Every production RAG system I’ve reviewed in the last two years has the same blind spot. The team can show me retrieval metrics — recall@k, MRR, NDCG. They can show me generation metrics — faithfulness scores, answer relevance, maybe a LLM-as-judge rubric. They cannot show me the one thing that actually matters for debugging, cost optimization, and trust:

For any given generated answer, which of the retrieved chunks actually caused it?

This question sounds almost pedantic until you sit with it. A typical RAG pipeline retrieves the top 5–20 chunks per query. Those chunks get concatenated into the context window. The LLM generates an answer. The team assumes the answer is “grounded in” the retrieved context. But grounded how? In all of it? In the top-similarity chunk? In some weighted blend? In none of it, with the model quietly falling back on parametric memory?

You do not know. Your dashboards do not know. Your evaluators — most of which compare generated answers against the full retrieved context — systematically miss the case where the answer is “faithful” to a chunk that was retrieved but irrelevant, while the chunk that actually drove the generation was redundant with another one and got no credit.

This is a causal inference problem dressed up as an information retrieval problem. And like most problems at the intersection of two fields, almost nobody is solving it because each field assumes the other one will.

This article is my attempt to pull the problem into the open and lay out a practical framework for solving it. I’ll cover why similarity is not causality, how to formally frame chunk-level attribution as a causal estimand, four methods for estimating it in practice, a working Python implementation, and how to evaluate whether your attribution method is itself trustworthy.


Similarity Is Not Causality

Let me start by making the core confusion precise.

When your retriever returns chunk C with similarity score 0.87 to query Q, what that score tells you is that C is close to Q in the embedding space of whatever model produced the vectors. Nothing more. Specifically, it does not tell you:

  • Whether the LLM’s answer would change if C were removed from the context
  • Whether some other chunk C’ with similarity 0.42 contributed more to the answer
  • Whether C was redundant with another high-similarity chunk and neither was necessary
  • Whether the model generated the answer primarily from parametric memory and essentially ignored the context

The retrieval step ranks chunks by predicted relevance. The generation step is where causal influence actually happens. And the generation step is a stochastic function of the full context, the query, and a sampling process — not a weighted average of chunk-level similarities.

I see this fail in practice constantly. A well-tuned retriever finds C and C’, both high similarity, both discussing the topic. The LLM uses C’ and ignores C. The dashboard says “retrieval looks great, faithfulness looks great.” Nobody notices that you are paying to retrieve, embed, and stuff C into every single context window for no causal benefit. Multiply that across a few million queries a month and you have an optimization surface that nobody is measuring.

The core move is to stop asking “which chunks are similar to the query?” and start asking “which chunks, if removed, would change the answer?” That is a causal question, and it requires causal tools.


The Formal Framing

Let me set up the problem using potential outcomes notation, because it makes the rest of the article easier to reason about.

For a query Q and a retrieved set R = {C₁, C₂, …, Cₖ}, define:

  • A(S) = the answer generated by the LLM when given context subset S ⊆ R
  • Φ(A) = some evaluation of the answer (faithfulness, specificity, correctness, or distance to a reference)

The causal effect of chunk Cᵢ on the answer is the difference in answer quality when Cᵢ is present versus absent, holding everything else constant:

τᵢ = Φ(A(R)) − Φ(A(R \ {Cᵢ}))

This is the leave-one-out (LOO) causal effect. It’s the simplest possible estimand and it has two well-known problems.

First, interactions between chunks are invisible to LOO. Suppose C₁ and C₂ are near-duplicates. The LLM uses either one interchangeably. Remove C₁ and the answer stays identical because C₂ picks up the slack. LOO says C₁ had zero causal effect. It also says C₂ had zero causal effect. Both are wrong — together, they are essential.

Second, LOO ignores context-dependent activation. Sometimes chunk Cⱼ is only useful when Cᵢ is present to provide framing. Removing Cᵢ in isolation makes Cⱼ appear less useful too, and your attribution conflates the two.

The clean solution to both problems is the Shapley value, borrowed from cooperative game theory. Treat each chunk as a player, the answer quality as the coalition’s payoff, and distribute credit by averaging marginal contributions across all possible orderings of the players:

φᵢ = (1/k!) Σ_π [Φ(A(Sᵢ,π ∪ {Cᵢ})) − Φ(A(Sᵢ,π))]

where the sum is over all permutations π of the chunks and Sᵢ,π is the set of chunks that appear before Cᵢ in permutation π.

Shapley values are provably the unique attribution that satisfies four reasonable axioms (efficiency, symmetry, dummy, additivity). They handle redundancy and interactions correctly. They are also exponentially expensive — naive computation is O(2ᵏ) LLM calls, which is completely infeasible for k > 10 or so.

The practical path is Monte Carlo approximation of Shapley, with a few method-specific tricks to reduce variance. I’ll cover that in the implementation section.

One more framing worth making explicit: this is fundamentally a do-calculus question. We want P(A | do(Cᵢ = present)), not P(A | Cᵢ = present). In a clean experimental setup where we manipulate which chunks are in the context, those are the same — there are no unobserved confounders between “chunk was included” and “answer content” once we randomize the inclusion. That randomization is the thing LOO and Shapley methods give us for free, and it’s why this is one of the rare places in practical ML where we actually get clean causal identification without having to fight about confounders.


Four Methods, Ranked by What You Should Actually Use

Method 1: Leave-One-Out Ablation

The baseline. Generate the answer with all k chunks, then regenerate k times, each time dropping one chunk. Measure how much each regeneration diverges from the baseline.

Cost: k + 1 LLM calls per query. Interpretable. Misses interactions and redundancy.

Use when: k is small, chunks are heterogeneous (low redundancy), and you need a cheap first-pass attribution.

Method 2: Monte Carlo Shapley

The gold standard. Sample random permutations of chunks, build up the coalition one chunk at a time, and record each chunk’s marginal contribution at the moment it joins the coalition. Average across samples.

Cost: typically 100–500 LLM calls per query for stable attribution with k around 10. Expensive but tractable for offline analysis, evaluation runs, and retrieval optimization. Not for online serving.

Use when: you need attribution you can trust, especially for evaluation suites or retrieval model training.

Method 3: Token-Level Likelihood Attribution

Rather than regenerating answers, score each answer token under different context conditions. For each answer span t, compute:

Λᵢ(t) = log P(t | Q, R) − log P(t | Q, R \ {Cᵢ})

This is the log-likelihood ratio of the answer token with versus without chunk Cᵢ. Chunks with high Λᵢ for a given span are the ones that made that span more likely. Sum over spans to get chunk-level attribution.

Cost: k + 1 likelihood evaluations (much cheaper than full generation if your model exposes logprobs). Gives you per-claim attribution, not just per-answer — you can say “this specific sentence was driven primarily by chunk 3.”

Use when: your LLM exposes logprobs and you need claim-level attribution for compliance, source citation, or debugging hallucinations.

Method 4: Attention Flow Analysis

If you have white-box access to the model, trace attention patterns from answer tokens back to context tokens, and aggregate to chunk level using attention rollout or integrated gradients.

Cost: one forward pass. Extremely cheap.

Use when: you have open-weights models in the loop. Skip when: you don’t, or when you care about calibrated attribution — attention weights are well known to be unreliable causal signals (Jain and Wallace’s “Attention is not Explanation” settled this debate in 2019 and the subsequent literature has only reinforced it).

My practical recommendation: start with LOO for debugging, use Monte Carlo Shapley for any attribution you’ll publish or use to train retrievers, and use token-level likelihood when you need claim-level granularity. Treat attention-based attribution as a heuristic for exploratory work only.


A Working Implementation

Here is a minimal but functional attribution toolkit. It’s designed to be model-agnostic — plug in any LLM that returns text, and optionally a logprob interface for the likelihood method.

from dataclasses import dataclass
from itertools import combinations
from typing import Callable, Sequence
import numpy as np

@dataclass
class Chunk:
    id: str
    text: str
    similarity: float = 0.0

# --- Core interfaces ---

def generate(query: str, chunks: Sequence[Chunk], llm) -> str:
    """Call your LLM with query + concatenated chunk context."""
    context = "\n\n".join(f"[{c.id}] {c.text}" for c in chunks)
    prompt = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer:"
    return llm(prompt)

def divergence(a: str, b: str, embedder) -> float:
    """Semantic divergence between two answers. 0 = identical, 1 = orthogonal."""
    va, vb = embedder(a), embedder(b)
    cos = np.dot(va, vb) / (np.linalg.norm(va) * np.linalg.norm(vb))
    return 1.0 - cos

# --- Method 1: Leave-One-Out ---

def loo_attribution(
    query: str,
    chunks: Sequence[Chunk],
    llm: Callable,
    embedder: Callable,
) -> dict[str, float]:
    baseline = generate(query, chunks, llm)
    scores = {}
    for i, chunk in enumerate(chunks):
        perturbed = [c for j, c in enumerate(chunks) if j != i]
        counterfactual = generate(query, perturbed, llm)
        scores[chunk.id] = divergence(baseline, counterfactual, embedder)
    return scores

# --- Method 2: Monte Carlo Shapley ---

def shapley_attribution(
    query: str,
    chunks: Sequence[Chunk],
    llm: Callable,
    value_fn: Callable[[str], float],
    n_samples: int = 200,
    seed: int = 42,
) -> dict[str, float]:
    """
    Monte Carlo estimate of Shapley values.
    value_fn takes a generated answer and returns a scalar (higher = better).
    """
    rng = np.random.default_rng(seed)
    n = len(chunks)
    phi = np.zeros(n)
    counts = np.zeros(n)

    empty_value = value_fn(generate(query, [], llm))

    for _ in range(n_samples):
        perm = rng.permutation(n)
        coalition: list[Chunk] = []
        prev_value = empty_value
        for idx in perm:
            coalition.append(chunks[idx])
            answer = generate(query, coalition, llm)
            current_value = value_fn(answer)
            phi[idx] += current_value - prev_value
            counts[idx] += 1
            prev_value = current_value

    phi = phi / np.maximum(counts, 1)
    return {chunks[i].id: float(phi[i]) for i in range(n)}

# --- Method 3: Token-level likelihood attribution ---

def likelihood_attribution(
    query: str,
    chunks: Sequence[Chunk],
    answer: str,
    logprob_fn: Callable,
) -> dict[str, list[float]]:
    """
    For each chunk, return per-token log-likelihood ratios.
    logprob_fn(context, target) -> list[float] of token logprobs.
    """
    full_context = "\n\n".join(f"[{c.id}] {c.text}" for c in chunks)
    baseline_lp = logprob_fn(f"{full_context}\n\n{query}", answer)

    attributions = {}
    for i, chunk in enumerate(chunks):
        perturbed_context = "\n\n".join(
            f"[{c.id}] {c.text}" for j, c in enumerate(chunks) if j != i
        )
        perturbed_lp = logprob_fn(f"{perturbed_context}\n\n{query}", answer)
        # Log-likelihood ratio per token
        ratios = [b - p for b, p in zip(baseline_lp, perturbed_lp)]
        attributions[chunk.id] = ratios
    return attributions

Two practical notes on the Shapley implementation.

Choice of value function matters enormously. If you use “distance to full-context answer,” Shapley will tell you which chunks are necessary to reproduce the baseline. If you use “faithfulness to ground truth,” Shapley will tell you which chunks contribute to correctness, including penalizing chunks that introduce factual errors. These give you genuinely different attributions. Pick the one that matches the question you actually care about.

Permutation sampling has high variance. The simple Monte Carlo estimator above works, but for production-grade attribution consider antithetic sampling (pair each permutation with its reverse) or stratified sampling over coalition sizes. The SHAP library has mature implementations; adapting KernelExplainer to RAG is usually faster than rolling your own.


Evaluating the Attribution Itself

Attribution methods are themselves models that need to be validated. Two tests are especially useful.

Necessity test. Take the top-m chunks ranked by attribution score. Remove them. If attribution is trustworthy, the answer should degrade significantly. If the answer is essentially unchanged, your attribution is either wrong or the chunks were redundant in aggregate.

Sufficiency test. Keep only the top-m chunks ranked by attribution score. Regenerate. If attribution is trustworthy, the answer should be close to the baseline (full-context) answer. If the answer is much worse, your attribution method is missing chunks that mattered.

Plot necessity and sufficiency curves across m and across a held-out query set. A good attribution method shows sharp degradation in the necessity test and fast saturation in the sufficiency test. A bad one shows flat curves — it’s not distinguishing signal from noise.

There is also a clean synthetic test: inject a chunk you engineered to be either critical or decorative, and check whether your attribution method ranks it correctly. This is the RAG equivalent of the synthetic counterfactual benchmarks that the causal inference community has used for decades to validate estimators.


What You Actually Do With This

Attribution is not an end in itself. It unlocks four concrete optimizations.

Retrieval cost reduction. Run Shapley attribution offline over a representative query set. Identify the effective k — the smallest k such that the top-k attributed chunks recover most of the answer quality. Most RAG systems I’ve measured have an effective k well below their configured k. Cutting from retrieving 20 chunks to retrieving 8 can preserve 95%+ of answer quality at a fraction of the cost.

Retriever training signal. Attribution scores are a vastly better training signal for dense retrievers than click-through or similarity pseudo-labels. A retrieval model trained against Shapley-attributed “chunks that actually mattered” is learning to retrieve for causal contribution, not just semantic proximity. This is the RAG-native version of learning to rank.

Grounded citation for users. Claim-level likelihood attribution lets you annotate specific answer spans with their causal source chunks. That is the real infrastructure for trustworthy citation in LLM products — not the theater of “sources were included in the context.”

Hallucination detection. If no retrieved chunk has non-trivial attribution to a given claim, that claim is coming from parametric memory. It may still be correct, but it is not grounded. Flagging claims with low aggregate attribution is the most reliable hallucination detector I know of, and it’s built directly out of the causal machinery above.


Where Papilon Fits

This whole framework is a natural extension of the causal inference work I’ve been building into Papilon, my open-source platform for causal attribution and simulation.

The Shapley estimator above is a lightweight version of Papilon’s attribution machinery, which was originally designed for marketing mix modeling — chunks are to RAG what channels are to MMM, and the question “which channel drove the conversion” translates almost perfectly to “which chunk drove the answer.” The decomposition math, the coalition sampling, the sensitivity analysis, the counterfactual simulation — it all carries over.

There is a concrete integration I’m working toward: treating each query as an observational unit, each retrieved chunk as a treatment, and the answer quality as the outcome. You can then run Papilon’s causal discovery to identify which query-level features (intent, topic, complexity) interact with which chunk-level features (source, freshness, specificity) to drive attribution. That becomes a chunk-selection policy, not just an attribution report.

The reason I’m bullish on this bridge is that retrieval is one of the cleanest causal identification setups in applied ML. You control the treatment assignment (which chunks get retrieved). You can randomize it cheaply (ablation). You have no confounders between treatment and outcome once randomized. Every other part of ML would kill for those conditions. RAG gets them for free and most teams are throwing the opportunity away.


Closing Thought

Cosine similarity built the RAG industry. It also papered over the one question the industry most needs to answer: did the model actually use what we gave it?

That question is a causal question. Treating it as one unlocks cost optimization, retriever improvement, trustworthy citation, and hallucination detection — all from the same underlying framework. The methods are not new. Shapley values are 70 years old. Potential outcomes are 50. What’s new is recognizing that a RAG pipeline is a controlled experiment running millions of times a day, and that we’ve been ignoring the data it produces.

Causal attribution for RAG is not a luxury evaluation layer. For any system that generates answers people will act on, it is the difference between an LLM application and a trustworthy LLM application. The tools exist. The math is settled. The only thing left is to wire it in.


Brian Curry is a Kansas City–based AI Strategist, Marketing Economist, and Applied Researcher. He is the founder of Vector1 Research, an independent lab advancing marketing economics, causal inference, and cognitive AI systems. He has led data and analytics initiatives at enterprises from $4B to $125B, including Koch Industries, Tractor Supply, Vail Resorts, Hallmark, Garmin, and AT&T. He is the creator of Papilon (causal inference and simulation), MeaningFlow (semantic content modeling), PyCausalSim (causal discovery through simulation), and the Memory-Node Encapsulation (MNE) architecture for cognitive AI.

Contact: brian at vector1.ai | Vector1 Research | GitHub

Related research