Skip to content
AI360XpertBeta

Cross-Attention

One decoder token scores its query against every encoder key, then blends encoder values by those weights, so generation reads a different sequence without merging the two.

One decoder query scores three encoder keys, softens to weights that sum to one, and blends three encoder values into one output
One decoder query scores three encoder keys, softens to weights that sum to one, and blends three encoder values into one output

Why Does This Exist?

Self-attention pins queries, keys, and values to the same sequence. Every token reads the sequence it belongs to. That is exactly right when you build a picture of one passage. It is the wrong tool when a French decoder must read an English source sentence, a genuinely different sequence with its own length and its own job.

The older fix for this sat inside sequence-to-sequence models. An encoder squeezed a whole sentence into one fixed vector, and quality fell as sentences grew because one container cannot hold an unbounded sentence. The attention mechanism stopped the single squeeze and let the decoder score every encoder state at every step. Cross-attention is that same idea rebuilt on query-key-value machinery, so it slots straight into transformer blocks.

This page covers the split-source version: where each matrix comes from, how scaling and softmax turn scores into shares, and one hand-worked blend you can check. Self-attention is the sibling contrast with all three matrices from one sequence. Attention mechanism is the prerequisite score, weigh, and blend idea. Transformers show the full stack where cross-attention lives. If the bottleneck is new to you, read sequence-to-sequence first.

Think of It Like This

Describing a photo while you write

You write a caption with the photo propped beside the page. For each word you glance at the patch that word needs, linger where the detail sits, skim the rest, and write. Each glance differs because each word asks something different.

Your half-written line is the query, each photo patch is a key plus the detail it holds, your linger time is the weight, and the word you write is the blend. The page and the photo never merge into one object.

Where it stops: looking cannot add what the photo lacks. If the lens blurred a street sign, attention faithfully reads the blur. It routes the right patch to the right word, it does not repair the picture.

How It Actually Works

Cross-attention runs the familiar attention math with an unfamiliar split. The query asks from the target side. The keys and values answer from the source side.

Where do Q, K, and V come from?

Call the decoder states XdecX_{dec}, with mm rows for mm target positions, and the encoder outputs XencX_{enc}, with nn rows for nn source positions. Here a row means one token vector, and mm and nn rarely match in translation. Three learned matrices WQW_Q, WKW_K, and WVW_V project both sides into one shared width dkd_k, where dkd_k (read dee-kay) is the key dimension:

Q=XdecWQ,K=XencWK,V=XencWVQ = X_{dec} W_Q, \qquad K = X_{enc} W_K, \qquad V = X_{enc} W_V

So QQ holds mm queries from the decoder, KK holds nn keys from the encoder, and VV holds nn values from the encoder. A query says what the current target position needs. A key says what a source position offers. A value carries the source content that flows forward when its key matches. Because KK and VV come from fully computed encoder output, the decoder reads that output without editing it in place.

Score, scale, soften, blend

Every query scores against every key in one matrix multiply, then scales, softens, and blends:

CrossAttention(Q,K,V)=softmax ⁣(QK⊤dk)V\text{CrossAttention}(Q, K, V) = \text{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right) V

QK⊤QK^\top is mm by nn, one row per decoder position and one column per encoder position, so the table need not be square. Dividing by dk\sqrt{d_k} keeps the scores near unit variance when query and key parts start with zero mean and unit variance, which stops early softmax spikes. Softmax turns each row into weights α\alpha that sit between 0 and 1 and sum to exactly 1. Multiplying by VV mixes the encoder values by those shares. The diagram above shows this for one row: one query, three keys, three weights, one blended output.

Worked example: one query against three keys

Take dk=4d_k = 4, so dk=2\sqrt{d_k} = 2, with decoder query q=[1,1,0,0]q = [1, 1, 0, 0] and three encoder keys k1=[2,2,0,0]k_1 = [2, 2, 0, 0], k2=[1,0,0,0]k_2 = [1, 0, 0, 0], and k3=[−1,−1,0,0]k_3 = [-1, -1, 0, 0]. Raw dot products give:

q⋅k1=4,q⋅k2=1,q⋅k3=−2q \cdot k_1 = 4, \qquad q \cdot k_2 = 1, \qquad q \cdot k_3 = -2

Scale by 2:

[4,1,−2]/2=[2.0,0.5,−1.0][4, 1, -2] / 2 = [2.0, 0.5, -1.0]

Exponentiate for softmax: e2.0≈7.39e^{2.0} \approx 7.39, e0.5≈1.65e^{0.5} \approx 1.65, e−1.0≈0.37e^{-1.0} \approx 0.37. Add them: 7.39+1.65+0.37=9.417.39 + 1.65 + 0.37 = 9.41. Divide each by the sum:

α≈[7.39/9.41,  1.65/9.41,  0.37/9.41]≈[0.786,0.175,0.039]\alpha \approx [7.39 / 9.41, \; 1.65 / 9.41, \; 0.37 / 9.41] \approx [0.786, 0.175, 0.039]

The shares sum to 1.0001.000. The leader takes most of the mass without taking all of it, which is why the output stays a blend.

Give those positions scalar stand-in values [4.0,2.0,0.0][4.0, 2.0, 0.0] and mix:

c=0.786×4.0+0.175×2.0+0.039×0.0=3.144+0.350+0.000≈3.49c = 0.786 \times 4.0 + 0.175 \times 2.0 + 0.039 \times 0.0 = 3.144 + 0.350 + 0.000 \approx 3.49

One number out, 3.493.49, leaning hard toward the first encoder state yet still touched by the second. The picture draws these same numbers: scaled scores [2.0,0.5,−1.0][2.0, 0.5, -1.0], weights [0.786,0.175,0.039][0.786, 0.175, 0.039], values [4.0,2.0,0.0][4.0, 2.0, 0.0], output 3.493.49.

Code

Same step, run for real: soften the scaled scores, check they sum to one, mix the values.

import numpy as np

def softmax(z: np.ndarray) -> np.ndarray:    e: np.ndarray = np.exp(z - np.max(z))    return e / np.sum(e)

scores: np.ndarray = np.array([2.0, 0.5, -1.0])weights: np.ndarray = softmax(scores)values: np.ndarray = np.array([4.0, 2.0, 0.0])context: float = float(weights @ values)
print(f"{weights[0]:.3f} {weights[1]:.3f} {weights[2]:.3f}")  # -> 0.786 0.175 0.039print(f"{float(np.sum(weights)):.1f}")  # -> 1.0print(f"{context:.2f}")  # -> 3.49

The printout matches the hand computation: weights 0.7860.786, 0.1750.175, 0.0390.039 summing to 1.01.0, and a blended context of 3.493.49.

Watch Out For

Letting padding soak up the weights

Source batches pad short sentences to equal length, and those pad positions still score unless you stop them. Our [0.786,0.175,0.039][0.786, 0.175, 0.039] would flatten toward the pads, and the 3.493.49 would drift toward whatever vector the pads carry, often zeros that drag every context down.

Symptom: short sentences translate worse than long ones in the same batch, and weight plots show mass sitting on trailing pad columns. Fix: add a pad mask of negative infinity to padded scores before the softmax, so pads get exactly 00 and the live weights still sum to 11.

Building the query from the wrong side

In custom code it is easy to wire QQ from the encoder and KK, VV from the decoder, since the call looks symmetric. The shapes still multiply when lengths differ, but the output then has one row per source token instead of one per target token, and generation conditions on itself while ignoring the source it should read.

Symptom: output length tracks the source length, or loss drops while translations read fluently yet miss source content. Fix: keep the direction fixed, QQ always from the querying target sequence and KK, VV always from the read source sequence, and assert the output has mm rows for mm decoder positions.

The Quick Version

  • Cross-attention keeps the score, softmax, and blend formula but takes queries from the decoder while keys and values come from the encoder.
  • Raw dots [4, 1, -2] scale by 2 into [2.0, 0.5, -1.0], soften into [0.786, 0.175, 0.039], and blend values [4.0, 2.0, 0.0] into 3.49.
  • The score table spans m target rows by n source columns, so source and target lengths never need to match.
  • Encoder keys and values get built once and reused, while each new decoder token builds a fresh query.
  • Padding positions need a mask before the softmax, or the shares leak onto tokens that carry no meaning.