Query, Key, Value › The QKV Mechanism: From Intuition to Formula
Language AI HandbookAll chapters Query, Key, Value › The QKV Mechanism: From Intuition to FormulaOne section · Sections in this chapter3 / 11 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Now that we understand why we need separate representations for querying, being queried, and contributing content, let's build up the mathematical machinery that makes this work. We'll develop each component step by step, showing how the formulas directly address the challenge of learning flexible attention patterns.
The journey from intuition to formula follows a natural progression:
- Project tokens into specialized QKV spaces
- Measure compatibility between queries and keys using dot products
- Scale the scores to prevent numerical instability
- Aggregate values according to the attention weights
Each step solves a specific problem that arises from the previous one. By the end, you'll see how these pieces combine into a single elegant formula that powers modern transformers.
Step 1: Projecting Into Specialized Spaces
The core insight is simple: if we want tokens to play different roles, we should give them different representations for each role. We accomplish this through learned linear projections, separate transformations that map each token's embedding into QKV spaces.
Why three separate projections? Consider what happens when a pronoun like "it" needs to find its antecedent. The pronoun's embedding encodes "third-person singular neuter pronoun," but that's not what it should search for. It should search for "noun phrase that could be referred to." Meanwhile, a noun like "cat" shouldn't advertise "I'm a cat" but rather "I'm a noun phrase available for reference." And when "cat" contributes information, it should provide its semantic content (the concept of a cat), not its grammatical role.
These are three fundamentally different jobs:
- Querying: What information am I looking for?
- Being queried: What information do I have to offer?
- Contributing: What content should I transmit?
A single embedding can't optimally serve all three purposes. So we learn three different transformations, each specialized for its role.
Given an input embedding for a single token, we compute:
where:
- : the input embedding vector (a row vector with dimensions)
- : the resulting query vector, expressing what this token is looking for
- : the resulting key vector, advertising what this token contains
- : the resulting value vector, carrying the content to be aggregated
- : the query projection matrix (learned during training)
- : the key projection matrix (learned during training)
- : the value projection matrix (learned during training)
- : the input embedding dimension
- : the dimension of queries and keys (must match for dot product compatibility)
- : the dimension of values (can differ from )
Each projection is a matrix multiplication, a linear transformation where each output dimension is a weighted combination of input dimensions. These weights are learned during training. The model doesn't know in advance which embedding features matter for querying versus being queried versus contributing content. Instead, gradient descent discovers these patterns from data:
- learns which combinations of embedding features make effective queries (what patterns indicate "I need information about X")
- learns which combinations make effective keys (what patterns indicate "I have information about X")
- learns which content is worth transmitting when attention flows
This learning happens implicitly through the training objective. If attending from a verb to its subject improves next-word prediction, the projection matrices gradually adjust to make verb queries match subject keys.
Scaling to sequences. For a complete sequence of tokens, we stack all embeddings into a matrix (where each row is one token) and project all tokens simultaneously:
The resulting matrices have shapes , , and . Row of each matrix is the query, key, or value vector for token .
def project_qkv(embeddings, W_q, W_k, W_v):
"""
Project input embeddings into query, key, and value spaces.
Args:
embeddings: Input matrix of shape (seq_len, embed_dim)
W_q: Query projection matrix of shape (embed_dim, d_k)
W_k: Key projection matrix of shape (embed_dim, d_k)
W_v: Value projection matrix of shape (embed_dim, d_v)
Returns:
Q, K, V matrices
"""
Q = embeddings @ W_q # (seq_len, d_k)
K = embeddings @ W_k # (seq_len, d_k)
V = embeddings @ W_v # (seq_len, d_v)
return Q, K, VWhy might these projections learn different things? Consider an embedding that encodes both syntactic information (part of speech, grammatical role) and semantic information (meaning, topic). The query projection might learn to emphasize syntactic features, helping verbs find their subjects by grammatical role rather than semantic similarity. Meanwhile, the value projection might preserve semantic content, so that when attention flows, it transfers meaning rather than grammatical markers.
The separation between keys and values is particularly powerful. A word's key determines which queries it matches (what attention it receives), while its value determines what information it contributes. This decoupling means a word can attract attention for one reason (syntactic role) while contributing entirely different information (semantic content).
Step 2: Measuring Compatibility with Dot Products
With queries and keys in hand, we face the next challenge: how do we measure whether a query matches a key? We need a scoring function that takes two vectors and returns a single number indicating compatibility. Higher numbers should mean better matches.
Several options exist, including additive attention, multiplicative attention, and others, but the dot product has become the standard choice for its simplicity and efficiency. The geometric interpretation is intuitive:
- Vectors pointing in the same direction → large positive dot product (strong match)
- Orthogonal vectors → zero dot product (no relationship)
- Vectors pointing in opposite directions → negative dot product (poor match)
This gives us exactly the ranking behavior we want: the most compatible keys get the highest scores, and training can learn arbitrary matching patterns by adjusting the projection matrices.
For positions and , the compatibility score is:
where:
- : the raw attention score measuring how well position 's query matches position 's key
- : the query vector for position
- : the key vector for position
- , : the -th components of these vectors
- : the dimension of query and key vectors (must match for the dot product to be defined)
The beauty of the dot product is that it's differentiable, fast to compute, and captures alignment in learned representation space. Because and come from learned projections, the model can discover arbitrary matching patterns during training.
To compute all scores at once (every query against every key), we use matrix multiplication:
where:
- : the score matrix containing all pairwise attention scores
- : the query matrix (row is the query for position )
- : the transposed key matrix (column is the key for position )
Entry is the dot product of row of with column of , which equals . This single matrix multiplication gives us all pairwise scores in one highly optimized operation, a key reason why attention scales well on modern hardware.
def compute_attention_scores(Q, K):
"""
Compute raw attention scores from queries and keys.
Args:
Q: Query matrix of shape (seq_len, d_k)
K: Key matrix of shape (seq_len, d_k)
Returns:
Scores matrix of shape (seq_len, seq_len)
"""
# Matrix multiplication: Q @ K.T gives all pairwise dot products
scores = Q @ K.T
return scoresThe power of learned projections. This is where QKV attention differs fundamentally from raw embedding similarity. In the previous chapter, tokens matched based on how similar their embeddings were, a fixed computation that couldn't adapt to context. Now they match based on how well their learned projections align.
Consider the implications: a verb's query can learn to match the key of a noun playing the subject role, even if "run" and "dog" have very different embeddings. The word "it" can learn a query that matches noun phrase keys, even though pronouns and nouns occupy different regions of embedding space. The projections transform the matching problem from "what words are similar?" to "what words are relevant?", and relevance is learned from data.
Step 3: The Scaling Problem and Its Solution
We have scores, but there's a subtle problem lurking in the mathematics. As the dimension grows, dot products tend to become larger in magnitude, not because the vectors are more aligned, but simply because we're summing more terms. This creates numerical instability that can derail training. To understand why and how we fix it, let's work through the statistics step by step.
Setting up the problem. Assume each component of and has zero mean and unit variance (a reasonable assumption for normalized embeddings). We want to understand how the variance of the dot product depends on dimension.
Step 1: Write the dot product as a sum. The dot product is:
where:
- : the -th component of the query vector
- : the -th component of the key vector
- : the dimension of both vectors
Step 2: Compute variance of each term. Each product is the product of two independent random variables with zero mean and unit variance. For independent random variables and with and :
So each term has variance 1.
Step 3: Sum the variances. For independent random variables, the variance of a sum equals the sum of variances:
Conclusion. The dot product has variance , so its standard deviation is . At (common in practice), scores have standard deviation 8. At , it's about 22.6. This growth in magnitude isn't a feature; it's a bug that we need to fix.
# Demonstrate how dot product variance grows with dimension
def measure_dot_product_variance(d_k, n_samples=10000):
"""Compute empirical variance of dot products for random unit-variance vectors."""
q = np.random.randn(n_samples, d_k)
k = np.random.randn(n_samples, d_k)
dot_products = np.sum(q * k, axis=1)
return dot_products.var()
dimensions = [4, 8, 16, 32, 64, 128, 256, 512]
empirical_variances = [measure_dot_product_variance(d) for d in dimensions]
theoretical_variances = dimensions # Variance equals d_k
The plot confirms our theoretical prediction: variance grows linearly with dimension. The empirical measurements (red squares) lie almost perfectly on the theoretical line (blue circles), validating our statistical analysis.
Why does this matter? The problem becomes clear when we consider what happens next: we apply softmax to convert scores into probability-like attention weights.
The softmax function converts a vector of real numbers into a probability distribution. For a vector of scores , softmax computes:
where:
- : the -th score (the raw attention score for position )
- : the exponential of . This keeps all values become positive
- : the sum of exponentials across all positions, serving as a normalizing constant
- : the number of positions (sequence length)
The exponential function amplifies differences between inputs: larger scores get exponentially larger outputs. When one score is much larger than others, softmax assigns nearly all probability mass to that element. The attention becomes "hard," focusing on essentially one position while ignoring all others.
More critically, this creates gradient problems. In the softmax function, elements with very low probability receive vanishingly small gradients. If attention is sharply focused due to large score magnitudes, the model struggles to learn that other positions might also be relevant. Training becomes slow and unstable.
# Demonstrate softmax saturation with different score magnitudes
def softmax(x):
exp_x = np.exp(x - x.max())
return exp_x / exp_x.sum()
# Same relative differences, different scales
scores_small = np.array([1.0, 0.8, 0.5, 0.3])
scores_medium = scores_small * 5
scores_large = scores_small * 20
weights_small = softmax(scores_small)
weights_medium = softmax(scores_medium)
weights_large = softmax(scores_large)


The visualization makes the problem viscerally clear. All three plots use scores with the same relative differences, where position 1's score is always 25% higher than position 2's, and so on. But scaling up the magnitudes completely changes the output distribution. In the rightmost plot, position 1 receives 99.9% of the weight, and the model can barely learn to attend elsewhere.
The elegant solution. Since the problem is that score variance grows with , we fix it by dividing scores by . This simple scaling operation normalizes the variance back to approximately 1, regardless of dimension:
- Original variance:
- After scaling by : variance becomes
The result is the complete scaled dot-product attention formula:
where:
- : the query matrix, with row containing the query vector for position
- : the key matrix, with row containing the key vector for position
- : the value matrix, with row containing the value vector for position
- : the raw score matrix, where entry is the dot product
- : the scaling factor that stabilizes score magnitudes
- : applied row-wise, converts each row of scaled scores into a probability distribution (attention weights)
- : the sequence length (number of tokens)
- : the query/key dimension
- : the value dimension (determines the output dimension)
The formula works in three stages. First, computes all pairwise query-key dot products. Second, dividing by and applying softmax converts these scores into attention weights. Third, multiplying by aggregates value vectors according to these weights, producing context-enriched output representations.
After scaling, scores have controlled variance regardless of , and softmax operates in a regime where gradients flow to all positions.
# Compare attention weights with and without scaling
def compute_attention_comparison(d_k, n_positions=8):
"""Compare attention distributions with and without scaling."""
np.random.seed(42)
Q = np.random.randn(1, d_k)
K = np.random.randn(n_positions, d_k)
# Unscaled scores
unscaled = (Q @ K.T).flatten()
# Scaled scores
scaled = unscaled / np.sqrt(d_k)
# Softmax
def softmax(x):
exp_x = np.exp(x - x.max())
return exp_x / exp_x.sum()
return unscaled, scaled, softmax(unscaled), softmax(scaled)
# High dimension where scaling matters most
d_k_demo = 64
unscaled_scores, scaled_scores, unscaled_weights, scaled_weights = (
compute_attention_comparison(d_k_demo)
)

The side-by-side comparison demonstrates scaling's importance. Without it (left), attention collapses to a near-hard distribution where one position dominates. With scaling (right), the model retains flexibility to attend broadly. Gradients then flow to all positions during training, allowing the model to learn attention patterns that spread weight across multiple positions.
def scaled_dot_product_attention(Q, K, V):
"""
Compute scaled dot-product attention.
Args:
Q: Query matrix of shape (seq_len, d_k)
K: Key matrix of shape (seq_len, d_k)
V: Value matrix of shape (seq_len, d_v)
Returns:
output: Attention output of shape (seq_len, d_v)
attention_weights: Attention weights of shape (seq_len, seq_len)
"""
d_k = Q.shape[-1]
# Compute scaled scores
scores = Q @ K.T / np.sqrt(d_k)
# Apply softmax (row-wise)
# Subtract max for numerical stability (doesn't change the result)
scores_stable = scores - scores.max(axis=1, keepdims=True)
exp_scores = np.exp(scores_stable)
attention_weights = exp_scores / exp_scores.sum(axis=1, keepdims=True)
# Aggregate values
output = attention_weights @ V
return output, attention_weightsLet's verify that the dimensions work out correctly by tracing through a concrete example:
# Example dimensions
seq_len = 4 # 4 tokens in the sequence
embed_dim = 8 # each token has 8-dimensional embedding
d_k = 6 # query and key dimension
d_v = 6 # value dimension
# Initialize random embeddings and projection matrices
np.random.seed(42)
X = np.random.randn(seq_len, embed_dim)
W_q = np.random.randn(embed_dim, d_k) * 0.1
W_k = np.random.randn(embed_dim, d_k) * 0.1
W_v = np.random.randn(embed_dim, d_v) * 0.1
# Project to Q, K, V
Q, K, V = project_qkv(X, W_q, W_k, W_v)
# Compute attention
output, attention_weights = scaled_dot_product_attention(Q, K, V)Shape analysis: Input X: (4, 8) (seq_len × embed_dim) Query projection: (8, 6) (embed_dim × d_k) Key projection: (8, 6) (embed_dim × d_k) Value projection: (8, 6) (embed_dim × d_v) Queries Q: (4, 6) (seq_len × d_k) Keys K: (4, 6) (seq_len × d_k) Values V: (4, 6) (seq_len × d_v) Attention weights: (4, 4) (seq_len × seq_len) Output: (4, 6) (seq_len × d_v)
The shapes confirm our understanding: input embeddings flow through projections to create QKV. The attention weight matrix is always because we compute all pairwise interactions. The output has the same sequence length as the input but takes on the value dimension . In practice, is often chosen to equal and the original embedding dimension, enabling residual connections.
Step 4: Aggregating Values
We've now computed attention weights, a probability distribution over positions for each query. But weights alone don't produce useful outputs. The final step uses these weights to aggregate value vectors, producing new representations enriched with contextual information.
This is where the separation of keys and values proves its worth. Keys determined which positions receive attention (through query-key matching). Values determine what content flows. A word might attract attention because of its syntactic role (captured in its key) while contributing semantic information (carried in its value).
For position , the output is a weighted sum of all value vectors:
where:
- : the output vector for position , now enriched with contextual information
- : the attention weight from position to position , indicating how much position attends to position
- : the value vector at position , carrying the content that can be transferred
- : the sequence length (total number of positions)
- : the value dimension
The attention weights come from applying softmax to the scaled scores, so they satisfy for each position . This constraint ensures the output is a proper weighted average.
Geometrically, this weighted average has an elegant interpretation. Position 's output is a blend of all value vectors, with weights determined by query-key compatibility. Positions with keys that matched 's query (high ) contribute more; positions with mismatched keys (low ) contribute less. Because the weights sum to 1, the output lies within the convex hull of the value vectors, a point "inside" the space spanned by all possible values.
The information flow paradigm. Self-attention can be understood as a message-passing system. Each position broadcasts its value vector, and each position receives a custom blend of all broadcasts, with the blend weights determined by query-key compatibility. Unlike recurrent networks that pass information sequentially, or convolutional networks that only see local neighborhoods, attention allows any position to directly receive information from any other position in a single step.
This direct, content-addressable communication is what makes transformers so effective. A pronoun can directly access its antecedent, regardless of distance. A verb can simultaneously gather information from its subject plus object and modifiers. The network doesn't need to learn complex routing through intermediate positions; it learns which positions are relevant and attends to them directly.
Let's visualize the complete attention flow for a simple sentence:
# Create example with interpretable structure
words = ["The", "quick", "fox", "jumps"]
n_words = len(words)
# Create embeddings that capture some semantic structure
# (In practice these would come from an embedding layer)
np.random.seed(123)
word_embeddings = np.random.randn(n_words, embed_dim)
# Initialize projection matrices
W_q = np.random.randn(embed_dim, d_k) * 0.2
W_k = np.random.randn(embed_dim, d_k) * 0.2
W_v = np.random.randn(embed_dim, d_v) * 0.2
# Compute QKV attention
Q, K, V = project_qkv(word_embeddings, W_q, W_k, W_v)
output, attn_weights = scaled_dot_product_attention(Q, K, V)
The heatmap shows how each word's query matches against all keys. Each row sums to 1.0 because softmax normalizes the weights. In this random initialization, the patterns aren't meaningful, but after training, we would see linguistically interpretable patterns: articles attending to their nouns, verbs attending to subjects and objects, and so on.