<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://ccomkhj.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ccomkhj.github.io/" rel="alternate" type="text/html" /><updated>2026-08-12T20:24:32+00:00</updated><id>https://ccomkhj.github.io/feed.xml</id><title type="html">pile of thoughts</title><subtitle>Huijo Kim&apos;s personal site — machine learning, computer vision, MLOps, and reflections.</subtitle><author><name>Huijo Kim</name><email>ccomkhj@gmail.com</email></author><entry><title type="html">How Modern LLMs Rebuilt Attention</title><link href="https://ccomkhj.github.io/VariationOfMHA/" rel="alternate" type="text/html" title="How Modern LLMs Rebuilt Attention" /><published>2026-08-12T00:00:00+00:00</published><updated>2026-08-12T00:00:00+00:00</updated><id>https://ccomkhj.github.io/VariationOfMHA</id><content type="html" xml:base="https://ccomkhj.github.io/VariationOfMHA/"><![CDATA[<p>I firmly believe I’ve become a power user of AI/agents/LLMs (whatever we call them). Most of what I know about how these things behave comes from: trial and error, every day, at work and at home. 
Running the same prompt through models from different providers — and through different models from the <em>same</em> provider — slowly gave me a feel for the machinery underneath.</p>

<p>So this post is me going one level down. For each of the big architectural tricks of the last few years, I want to answer the same two questions: <strong>what is it, and which model actually shipped it?</strong> 
That second question matters more than it sounds, because for the closed models (OpenAI, Anthropic) we mostly can’t know — the open-weight releases are where the architecture is legible.</p>

<p>My learning and this writing based on <a href="https://www.amazon.de/dp/1633437167">Build a Large Language Model (From Scratch)</a>.
If my writing lits your interest, I highly recommend reading this book!</p>

<hr />

<h2 id="gqa--llama-23-qwen3-gemma-3">GQA — Llama 2/3, Qwen3, Gemma 3</h2>

<p>Grouped-Query Attention is the boring default now. If you pick an open-weight model at random today, it’s probably using GQA as a more compute- and parameter-efficient drop-in for Multi-Head Attention (MHA). It’s not new either — it goes back to the 2023 paper <a href="https://arxiv.org/abs/2305.13245">GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints</a>, and even the larger variants in the good old Llama 2 series used it.</p>

<p><img src="https://sebastianraschka.com/images/LLMs-from-scratch-images/bonus/gqa-memory/1.webp?1" alt="GQA" /></p>

<p>Sharing keys and values reduces the total number of key and value computations, which leads to lower memory usage and improved efficiency.</p>

<p>So, to summarize, the core idea behind GQA is to reduce the number of key and value heads by sharing them across multiple query heads. This (1) lowers the model’s parameter count and (2) reduces the memory bandwidth usage for key and value tensors during inference, since fewer keys and values need to be stored and retrieved from the KV cache.</p>

<p>While GQA is mainly a computational-efficiency workaround for MHA, ablation studies (such as those in the <a href="https://arxiv.org/abs/2305.13245">original GQA paper</a> and the <a href="https://arxiv.org/abs/2307.09288">Llama 2 paper</a>) show it performs comparably to standard MHA in terms of LLM modeling performance.</p>

<p>However, this assumes the number of key-value groups is chosen carefully. In the extreme case where all attention heads share a single key-value group — known as multi-query attention — memory usage drops even more drastically, but modeling performance can suffer. And on the other extreme, if we set the number of key-value groups equal to the number of query heads, we’re back at standard multi-head attention.</p>

<blockquote>
  <p><strong>My take:</strong> GQA is the one on this list I never notice as a user, which is exactly the point — it’s a free lunch that everyone quietly took.</p>
</blockquote>

<hr />

<h2 id="mla--deepseek-v2-v3-r1">MLA — DeepSeek V2, V3, R1</h2>

<p>Multi-Head Latent Attention, used in <a href="https://arxiv.org/abs/2412.19437">DeepSeek V2, V3, and R1</a>, offers a different memory-saving strategy that also pairs particularly well with KV caching. Instead of sharing key and value heads like GQA, MLA compresses the key and value tensors into a lower-dimensional space before storing them in the KV cache.</p>

<p>At inference time, these compressed tensors are projected back to their original size before being used, as shown in the figure below. This adds an extra matrix multiplication but reduces memory usage.</p>

<p> </p>

<p><img src="https://sebastianraschka.com/images/LLMs-from-scratch-images/bonus/mla-memory/1.webp" alt="MLA" /></p>

<p> </p>

<p>(As a side note, the queries are also compressed, but only during training, not inference.)</p>

<p> </p>

<p><img src="https://sebastianraschka.com/images/LLMs-from-scratch-images/bonus/mla-memory/2.webp" alt="MHA vs GQA vs MLA modeling performance" width="500px" /></p>

<p> </p>

<p>As shown in the figure above, GQA appears to perform worse than MHA, whereas MLA offers <em>better</em> modeling performance than MHA — which is likely why the DeepSeek team chose MLA over GQA. (It would have been interesting to see the “KV Cache per Token” savings comparison between MLA and GQA as well.)</p>

<p>So: MLA is a clever trick to reduce KV cache memory use while even slightly outperforming MHA in terms of modeling performance. Not a tradeoff — a win on both axes.</p>

<blockquote>
  <p><strong>My take:</strong> this is the piece that made DeepSeek’s pricing make sense to me. When a provider is an order of magnitude cheaper per token on long inputs..</p>
</blockquote>

<hr />

<h2 id="swa--gemma-2-gemma-3">SWA — Gemma 2, Gemma 3</h2>

<p>What is sliding window attention (SWA)? If we think of regular self-attention as a <em>global</em> mechanism, since each sequence element can access every other sequence element, then SWA is <em>local</em> attention: we restrict the context size around the current query position. This is illustrated in the figure below.</p>

<p><img src="https://sebastianraschka.com/images/LLMs-from-scratch-images/bonus/swa-memory/1.webp?2" alt="Sliding Window Attention" width="500px" /></p>

<p>Instead of attending to all previous tokens, each token only attends to a fixed-size local window around its position. This localized attention lowers the size of the KV cache substantially.</p>

<p>Sliding window attention was originally introduced in the <a href="https://arxiv.org/abs/2004.05150">LongFormer paper in 2020</a>, but the reason to focus on Google’s Gemma models is that they’re very good open-weight models showing that SWA is a genuinely feasible approach in recent, capable models — not just a research curiosity.</p>

<p><a href="https://arxiv.org/abs/2408.00118">Gemma 2</a> used a hybrid approach that combined local (sliding window) and global attention layers in a 1:1 ratio, with each token able to attend to a context window of 4k tokens. The reason for the 1:1 split is that it strikes a balance between efficiency and global context modeling — an LLM using <em>only</em> local attention can be too restrictive.</p>

<p><a href="https://arxiv.org/abs/2503.19786">Gemma 3</a> then pushed further toward efficiency: a 5:1 ratio between sliding window and full attention layers, meaning for every five local attention layers there’s one global layer. The window itself also shrank, from 4096 tokens in Gemma 2 to 1024 in Gemma 3.</p>

<blockquote>
  <p><strong>My take:</strong> One global layer holding the line for five local ones suggests most attention work really is local, and that most of the quadratic cost we pay is insurance.</p>
</blockquote>

<hr />

<h2 id="moe--deepseek-v3-qwen3-moe">MoE — DeepSeek-V3, Qwen3-MoE</h2>

<p>Mixture-of-Experts is the odd one out here: it isn’t an attention variant at all, it’s a feed-forward variant. The core idea is to replace each feed-forward module in a transformer block with multiple expert layers, where each expert is itself a feed-forward module. So one feed-forward block becomes many, as illustrated below.</p>

<p> </p>

<p><img src="https://sebastianraschka.com/images/LLMs-from-scratch-images/bonus/moe-memory/1.webp" alt="Mixture-of-Experts feed-forward module" width="800px" /></p>

<p>When DeepSeek rattled the stock market in early 2025, a lot of the media described MoE as a panel of specialist AIs conferring with each other — a coding AI, a medical AI, a math AI, voting on an answer. Look at the figure again: an “expert” is a feed-forward network. That’s it. The router is a small learned gate that picks which of those blocks to run for this one token, and whatever specialization exists is whatever training nudged into them — nobody assigned job titles.</p>

<p>Because only a few experts are active at a time, MoE modules are often called <em>sparse</em>, in contrast to <em>dense</em> modules that always use the full parameter set. The large total parameter count increases the capacity of the LLM — it can absorb more knowledge during training — while the sparsity keeps inference efficient, since we don’t use all the parameters at once.</p>

<p>For example, DeepSeek-V3 has 256 experts per MoE module and 671 billion parameters in total. Yet during inference, only 9 experts are active at a time (1 shared expert plus 8 selected by the router). That’s just 37 billion parameters per token inference step instead of all 671 billion.</p>

<p>One notable feature of DeepSeek-V3’s MoE design is the <strong>shared expert</strong>: an expert that is always active for every token. The idea isn’t new — it was already introduced in the <a href="https://arxiv.org/abs/2201.05596">2022 DeepSpeed-MoE</a> and <a href="https://arxiv.org/abs/2401.06066">2024 DeepSeek MoE</a> papers.</p>

<p> </p>

<p><img src="https://sebastianraschka.com/images/LLMs-from-scratch-images/bonus/moe-memory/3.webp?1" alt="MoE shared expert" width="500px" /></p>

<p>(An annotated figure from the <a href="https://arxiv.org/abs/2401.06066">DeepSeekMoE: Towards Ultimate Expert Specialization in Mixture-of-Experts Language Models</a> paper.)</p>

<p> </p>

<p>The benefit of a shared expert was first noted in the <a href="https://arxiv.org/abs/2201.05596">DeepSpeed-MoE paper</a>, where they found it boosts overall modeling performance compared to having no shared expert. The likely reason: common or repeated patterns don’t have to be relearned by every individual expert, which leaves each of them more room for specialized patterns.</p>

<blockquote>
  <p><strong>My take:</strong> What MoE really breaks is the assumption that capacity and compute are the same quantity. the shared expert is the honest admission that some of it isn’t.</p>
</blockquote>

<hr />

<h2 id="gated-deltanet--qwen3-next-kimi-linear">Gated DeltaNet — Qwen3-Next, Kimi Linear</h2>

<p>Recently, <a href="https://qwen.ai/blog?id=4074cca80393150c248e508aa62983f9cb7d27cd&amp;from=research.latest-advancements-list">Qwen3-Next</a> and <a href="https://arxiv.org/abs/2510.26692">Kimi Linear</a> proposed hybrid transformers that swap in alternatives to the attention mechanism which scale <em>linearly</em> instead of quadratically with context length.</p>

<p>Both use a 3:1 ratio: for every three transformer blocks employing the linear Gated DeltaNet variant, there’s one block using full attention, as shown in the figure below.</p>

<p>So what is Gated DeltaNet? It’s short for <em>Gated Delta Network</em>, Qwen3-Next’s linear-attention layer, intended as an alternative to standard softmax attention. It was adopted from the <a href="https://arxiv.org/abs/2412.06464">Gated Delta Networks: Improving Mamba2 with Delta Rule</a> paper.</p>

<p>Gated DeltaNet was originally proposed as an improved version of Mamba2, combining Mamba2’s gated decay mechanism with a delta rule. Mamba itself is a state-space model — an alternative to transformers, and a big enough topic that it deserves its own post someday.</p>

<p>The “delta rule” part refers to computing the difference (delta, Δ) between new and predicted values to update a hidden state that’s used as a memory state.</p>

<p><img src="https://sebastianraschka.com/images/LLMs-from-scratch-images/bonus/gated_deltanet/01.webp" alt="Qwen3-Next versus Kimi Linear" /></p>

<blockquote>
  <p><strong>My take:</strong> I’ve been following Mamba for years as <em>the</em> candidate to replace the transformer, and for most of that time state-space models were fascinating but never quite competitive. So the recent wow moment — these models landing in the same conversation as the state-of-the-art from OpenAI and Anthropic — is genuinely satisfying to watch. What I didn’t expect is <em>how</em> they got there: not by winning the argument, but by refusing to have it. Three linear blocks, one full-attention block, both mechanisms in the same stack. And notice the pattern repeating from Gemma — nobody is willing to drop quadratic attention entirely. Whatever those few full layers are doing, it isn’t replaceable yet.</p>
</blockquote>

<hr />

<h2 id="dsa--deepseek-v32">DSA — DeepSeek V3.2</h2>

<p>The DeepSeek-V3.2 model uses Multi-Head Latent Attention <em>alongside</em> DeepSeek Sparse Attention (DSA), with the indexer queries derived from the shared compressed latent representation rather than the raw input. Two techniques from this post, stacked.</p>

<p>What’s DSA? In one line: a cheap learned dot-product scorer that limits each query to the most relevant tokens before the attention softmax.</p>

<p>Standard causal self-attention attends to all previous tokens for each query, yielding O(L²) compute and O(L) KV-cache growth with sequence length L. Sliding Window Attention already showed above that restricting attention to a fixed local window substantially reduces this cost — in SWA, each query token attends only to a local span of nearby previous tokens.</p>

<p>DSA uses the same broad idea of attending to only a subset of previous tokens, but it replaces the <em>fixed</em> window with a <em>learned</em> selection mechanism. For each query token, the model scores candidate past tokens and keeps only the most relevant ones.</p>

<p> </p>

<p><img src="https://sebastianraschka.com/images/blog/2025/technical-deepseek/10.png" alt="DeepSeek Sparse Attention selected-token pattern" width="800px" /></p>

<p><em>DeepSeek Sparse Attention selects a learned subset of past tokens for each query token.</em></p>

<p>DSA adds two components on top of standard attention.</p>

<p><strong>1. Lightning Indexer</strong></p>

<p>For each query token $t$ and every candidate past token $s$, the indexer computes a scalar relevance score. This implementation makes the scale factors from the reference code explicit:</p>

\[I_{t,s} = \sum_{j=1}^{H_I} \frac{w_{t,j}}{\sqrt{H_I}} \cdot \text{ReLU}\left(\frac{q_{t,j} \cdot k_s}{\sqrt{d_I}}\right)\]

<p>where:</p>
<ul>
  <li>$H_I$ is the number of lightweight index heads,</li>
  <li>$q_{t,j}$ is the indexer query vector for token $t$ and head $j$,</li>
  <li>$k_s$ is a shared indexer key vector for past token $s$,</li>
  <li>$w_{t,j}$ is a learned per-head gate scaled by $1 / \sqrt{H_I}$.</li>
</ul>

<p>The ReLU zeroes out negative dot-product contributions, and the gated sum aggregates across index heads into a single relevance score per past token.</p>

<p><strong>2. Token Selector</strong></p>

<p>After computing all indexer scores, only the top-K highest-scoring positions are kept. All other positions are masked to −∞ <em>before</em> the standard softmax, so the model effectively attends to only $k \ll L$ tokens.</p>

<p>The ReLU in the indexer is not where the final sparsity comes from. Since the scores are summed over multiple index heads, most final scores can still be nonzero. The token selector is what creates the sparse pattern, by keeping only the top-K positions.</p>

<p>In a fused production implementation, this can lower attention compute from O(L²) to O(L·k). The book’s reference implementation keeps the standard dense attention score matrix and applies the DSA-selected top-K mask before softmax — which makes the selection logic easy to inspect, but doesn’t deliver the fused-kernel compute savings.</p>

<p>The figure below summarizes the flow: the lightning indexer scores candidate tokens, the selector keeps top-K positions, and the resulting mask restricts the usual attention softmax.</p>

<p> </p>

<p><img src="https://sebastianraschka.com/images/blog/2025/technical-deepseek/11.png" alt="DeepSeek Sparse Attention flowchart" width="700px" /></p>

<p><em>DSA first scores candidate tokens, then keeps the top-K tokens for the final attention mask.</em></p>

<blockquote>
  <p><strong>My take:</strong> SWA guesses that relevance is nearby; DSA learns where relevance actually is — the more honest bet for long documents, where the token you need is often thousands of positions back. What struck me most is how much this resembles sparse retrieval in RAG: the lightning indexer is a cheap first-stage scorer over every candidate, top-K is the retrieval cutoff, and the expensive attention only ever sees the shortlist. Same two-stage shape I keep building <em>around</em> models with a retriever and a reranker — except here it lives inside the model and gets trained end to end instead of bolted on outside.</p>
</blockquote>

<hr />

<h2 id="cross-layer-kv-sharing--gemma-4-e2b-e4b">Cross-layer KV sharing — Gemma 4 E2B, E4B</h2>

<p>We discussed GQA above, where several query heads share the same key and value heads. Cross-layer KV sharing applies a related idea <em>across transformer layers</em> instead of within one.</p>

<p>Instead of computing a fresh key and value projection in every layer, later layers reuse K/V tensors from an earlier layer. They still compute their own queries, so each layer can form its own attention pattern. The main memory saving comes from storing fewer K/V tensors in the cache.</p>

<p>This idea is also called cross-layer attention, and it’s described in Brandon <em>et al.</em>, <a href="https://arxiv.org/abs/2405.12981">Reducing Transformer Key-Value Cache Size with Cross-Layer Attention</a>. Gemma 4 E2B and E4B use a related shared KV-cache scheme, which makes it a natural addition to the GQA, MLA, and SWA techniques above.</p>

<p> </p>

<p><img src="https://raw.githubusercontent.com/rasbt/LLMs-from-scratch/main/ch04/10_kv-sharing/gemma4-kv-sharing.webp" alt="Cross-layer KV sharing" width="800px" /></p>

<p> </p>

<p>In <a href="https://github.com/rasbt/LLMs-from-scratch/tree/main/ch05/17_gemma4">Gemma 4</a>, KV sharing is combined with GQA or MQA <em>and</em> sliding window attention — three of the techniques from this post in one model. The book’s simplified GPT example implements only the cross-layer KV-sharing part, so the code stays focused on the main mechanism.</p>

<p>The simplified rule it uses is:</p>

<ol>
  <li>Early layers compute and cache their own K/V tensors.</li>
  <li>Later layers reuse the most recent K/V tensors from an earlier producing layer.</li>
  <li>All layers still compute their own query projections.</li>
</ol>

<p>This reduces the number of K/V caches that grow with context length. The tradeoff is reduced model capacity, because some layers no longer get their own K/V projections.</p>

<blockquote>
  <p><strong>My take:</strong> stacking GQA + MQA + SWA + cross-layer sharing in one model tells you these tricks are mostly orthogonal — each attacks a different axis of the same cache. Which also means the next efficiency jump probably won’t come from one clever idea, but from someone stacking a fourth.</p>
</blockquote>]]></content><author><name>Huijo</name></author><category term="Machine Learning" /><category term="Reading" /><summary type="html"><![CDATA[GQA, MLA, SWA, MoE, Gated DeltaNet, DSA, KV sharing — and which model actually ships each one.]]></summary></entry><entry><title type="html">AI is everywhere, even in interview</title><link href="https://ccomkhj.github.io/AIInInterview/" rel="alternate" type="text/html" title="AI is everywhere, even in interview" /><published>2026-08-11T00:00:00+00:00</published><updated>2026-08-11T00:00:00+00:00</updated><id>https://ccomkhj.github.io/AIInInterview</id><content type="html" xml:base="https://ccomkhj.github.io/AIInInterview/"><![CDATA[<p>In our everyday life, use of AI has become the new normal.
I heavily use AI in personal life as well as professionally, so it plays a very close role in my life.
However, I notice that as its usage becomes prevalent, some people can’t stop using it.
Here’s the story I just faced TODAY.</p>

<p>In my work, I have been interviewing for a potential teammate.
As this position carries technical responsibility (data engineer), it’s required to utilize AI during the technical interview.
The <strong>live</strong> technical assignment I use has both an AI-supported programming task and an AI-prohibited algorithmic task.
As I highlighted <strong>live</strong>, it’s expected to be impossible to cheat.
However, it happened today: so-called AI-cheating, in person.</p>

<p>As I wanted to assess how the candidate utilizes AI, I started with the AI-assisted problem-solving case.
The candidate had prepared a repository claiming to have helper functions.
I consider this a positive sign, as the candidate had prepared something for this interview.</p>

<p>The candidate starts writing a prompt, and I ask what the intention behind it is.
The candidate writes a prompt full of comprehensive keywords. Okay, it’s a good sign.
It looks a bit unnatural, injecting only keywords, but okay, maybe the candidate is considering how token embedding works by focusing on keywords.</p>

<p>Initially, the candidate couldn’t understand the purpose of the problem; however, in a short time, the candidate provided better context.
I regarded this as fast learning from the problem. Okay, it’s a good sign.</p>

<p>The issue starts with the non-AI-assisted case.
I offered a short 2-minute restroom break before the next phase.
A brief breath of fresh air, then on to the next task.
The candidate read through the problem in detail and understood the case very well.
I heard a few typing sounds, so my suspicion started, but I let the candidate continue.
First, I didn’t want to offend the candidate. Second, if it’s cheating, it’s better to detect it than to give a chance to stop.
It’s better because cheating during an interview means any serious cheating could happen once working together.</p>

<p>Anyhow, the candidate completed the interview quite well. A bit of guidance and support from me as an interviewer was provided, but it was well done. Okay, it’s a good sign.
Time to work on the next problem.
Again, it’s supposed to be a non-AI-assisted problem.
This time, I was sitting next to the candidate, discussing how to solve it.
The brilliant candidate becomes silent.
Nothing progressed, for one reason only: I’m next to the candidate.
Suddenly, a Claude Code artifact pops up during the interview.
Okay, it’s apparent that another agent session is running in parallel with the non-AI-assisted challenge.
Initially, the candidate switches the tab, but when I ask to open it, the candidate is speechless.</p>

<p>I gently ask the candidate to open the running terminal.
It has the whole history of cheating.
Interesting.
The candidate’s brilliant answers in all phases came from this.
The session shows <code class="language-plaintext highlighter-rouge">/rc</code>, which means remote control of the session.
Not 100% sure, but it’s suspicious that the candidate could read the non-AI-assisted problems’ content through the Claude Code app.
The excuse for cheating is apparently: “it’s for my personal learning, I don’t cheat.”
Okay, it’s expected. A quick acknowledgment and apology would have been ideal at the end of this.
However, it’s hard to admit. Denial is easy.</p>

<p>So, what’s my take?
People get dumber by delegating <strong>thinking</strong> to AI.
It’s a great channel to explore knowledge and sharpen ideas.
However, as soon as we give up thinking for ourselves, it becomes a counterproductive tool.
It is easy to make fun of others, but I should observe myself to see if I do the same.</p>]]></content><author><name>Huijo</name></author><category term="Agents" /><category term="Decision-Making" /><summary type="html"><![CDATA[It is expected to use AI in the technical interview, but it is even used when not allowed.]]></summary></entry><entry><title type="html">Attention has four matrices, not three</title><link href="https://ccomkhj.github.io/AttentionHasFourMatrices/" rel="alternate" type="text/html" title="Attention has four matrices, not three" /><published>2026-08-02T00:00:00+00:00</published><updated>2026-08-02T00:00:00+00:00</updated><id>https://ccomkhj.github.io/AttentionHasFourMatrices</id><content type="html" xml:base="https://ccomkhj.github.io/AttentionHasFourMatrices/"><![CDATA[<p>In 2022 I wrote a paper review of <em>Attention Is All You Need</em>. Rereading it, I find one sentence carrying the whole explanation:</p>

<blockquote>
  <p>An attention function can be described as mapping a query and a set of key-value pairs to an output.</p>
</blockquote>

<p>The sentence is the paper’s own and it is accurate. It also explains nothing. It does not say why there are three projections instead of one, what each of them costs when the model is served, or which claims about attention maps survive scrutiny.</p>

<p>This post covers what I skipped. It also corrects the name. We say Q, K, V. The mechanism has four weight matrices and two circuits, and the one the name omits is tied for the largest.</p>

<h2 id="1-the-same-input-different-weights">1. The same input, different weights</h2>

<p>In self-attention, Q, K and V are not three pieces of information about a token. They are one vector passed through three learned matrices.</p>

\[q = xW_Q, \qquad k = xW_K, \qquad v = xW_V\]

<p>Here $x$ is one row of the residual stream, the token’s current representation at this layer. Nothing else enters. No extra data is fetched and no side channel is read. The three outputs differ only because the weights differ.</p>

<p>That changes where to look when you want to know what a head does. The answer is in the matrices, not in the token.</p>

<p>The mechanism built on them is one line:</p>

\[\mathrm{Attention}(Q,K,V) = \mathrm{softmax}\!\left(\frac{QK^\top}{\sqrt{d_k}}\right)V\]

<p>The $\sqrt{d_k}$ divisor is a gradient fix. I used to read it as a normalisation of similarity, which it is not. The paper’s reason is that for large $d_k$, “the dot products grow large in magnitude, pushing the softmax function into regions where it has extremely small gradients.”</p>

<h2 id="2-q-and-k-need-separate-matrices">2. Q and K need separate matrices</h2>

<p>If Q and K are both $x$ times a weight, why not use one weight and halve the parameters? The roles already differ by position, since $i$ asks and $j$ is asked.</p>

<p>Set $W_Q = W_K = W$ and the score becomes:</p>

\[\mathrm{score}(i,j) = x_i W W^\top x_j^\top\]

<p>Swapping $i$ and $j$ gives the same number. The affinity matrix is forced symmetric.</p>

<p>The easy version of this argument overstates what that means, and I nearly published it. Symmetric scores are not symmetric attention weights. Softmax normalises row by row, and under a causal mask each row sums over a different set of positions, so $\alpha_{ij}$ and $\alpha_{ji}$ differ either way. What a shared matrix removes is the freedom to set the two affinities independently.</p>

<p>It removes more than symmetry. $WW^\top$ is positive semi-definite, so the diagonal is pinned at $\mathrm{score}(i,i) = \lVert x_i W \rVert^2 \ge 0$, and Cauchy-Schwarz gives:</p>

\[\mathrm{score}(i,j) \le \frac{1}{2}\left(\mathrm{score}(i,i) + \mathrm{score}(j,j)\right)\]

<p>No token can score another above its own self-score unless that other token has a much larger projected norm. A head built on a shared matrix is biased toward attending to itself, which is a poor property for a mechanism whose job is moving information between positions.</p>

<p>Two matrices give a general bilinear form:</p>

\[\mathrm{score}(i,j) = x_i W_Q W_K^\top x_j^\top\]

<p>$W_Q W_K^\top$ carries no symmetry constraint and no definiteness constraint. The relation it encodes is directed, and a token is free to find itself uninteresting. Q is what a position looks for. K is what a position offers as a match target. Both come from the same $x$, so a token’s search and its findability are separate learned functions of identical input.</p>

<p>Those two results are derivations rather than citations. I am not quoting anyone here, and the algebra is short enough to check.</p>

<h2 id="3-what-v-adds-that-k-does-not">3. What V adds that K does not</h2>

<p>The third matrix asks a third question: what gets handed over once a match happens.</p>

<p>K decides how a token gets found. V decides what it contributes. Separating the two lets a token be a strong match target while contributing something unrelated to its own identity.</p>

<p>I held two wrong ideas here until recently, and I suspect they are common.</p>

<p>The first was that V holds the token’s content and must therefore be wider than K. It is not wider. The paper introduces “queries and keys of dimension $d_k$, and values of dimension $d_v$”, which allows them to differ, then sets $d_k = d_v = d_{model}/h = 64$. Standard implementations follow.</p>

<p>The second was that V is the token’s data. It is a learned projection. Nothing requires $v_j = x_j W_V$ to resemble token $j$ at all. That assumption is what makes “the head copies the token” feel true when it usually is not.</p>

<h2 id="4-the-matrix-the-name-leaves-out">4. The matrix the name leaves out</h2>

<p>After the $h$ heads each produce a $d_{head}$-wide result, the paper concatenates them and projects back to model width:</p>

\[\mathrm{MultiHead}(Q,K,V) = \mathrm{Concat}(\mathrm{head}_1, \ldots, \mathrm{head}_h)\, W_O\]

<p>$W_O$ is the fourth matrix. In Llama 2 70B it ties with $W_Q$ as the largest of the four, and the Q, K, V name gives it no billing.</p>

<p>Block matrix multiplication makes concatenate-then-project identical to each head owning its own slice of $W_O$:</p>

\[\mathrm{Concat}(h_1 \ldots h_h)\, W_O = \sum_i h_i W_O^{(i)}\]

<p>with each $W_O^{(i)}$ of shape $d_{head} \times d_{model}$. The concatenation is notation. The structure underneath is per-head and additive, which is why Anthropic’s circuits framework describes heads as “independent operations, each outputting a result which is added into the residual stream”, and why an individual head can be attributed, ablated, or pruned at all.</p>

<p>The ordering matters. $W_V$ maps a source token down into the head’s private $d_{head}$ subspace. $W_O$ maps that payload back up into $d_{model}$ and chooses which directions of the residual stream the head writes into.</p>

<p>V on its own is not in the residual basis. In Llama 2 70B it is 128 numbers in a subspace no other layer reads. $W_O$ is what makes them legible. So what a head writes is $W_O W_V x$, never V alone.</p>

<h2 id="5-four-matrices-two-circuits">5. Four matrices, two circuits</h2>

<p>The four matrices pair up. Anthropic’s circuits framework splits a head into a QK circuit that computes the attention pattern and an OV circuit that computes how each token affects the output if attended to.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                    x  (residual stream)
                    |
        +-----------+-----------+
        |           |           |
      x W_Q       x W_K       x W_V
        |           |           |
        q           k           v
        |           |           |
        +-----+-----+           x W_O
              |                  |
          QK circuit         OV circuit
         W_Q W_K^T            W_O W_V
       "where to look"     "what to write"
</code></pre></div></div>

<p>Every claim about an attention head is one of two kinds, and the two need different evidence.</p>

<table>
  <thead>
    <tr>
      <th>Claim type</th>
      <th>Example</th>
      <th>Evidence required</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>QK</td>
      <td>“This head attends from a pronoun to its antecedent”</td>
      <td>The attention pattern</td>
    </tr>
    <tr>
      <td>OV</td>
      <td>“This head copies the subject forward”</td>
      <td>Measured effect on the output</td>
    </tr>
  </tbody>
</table>

<p>Conflating them is a common failure in writing about attention. “This head handles negation” is two claims stacked: the head finds the negation token, which is QK, and what it writes flips something downstream, which is OV. An attention map supports the first claim only.</p>

<h2 id="6-the-two-circuits-get-equal-budgets">6. The two circuits get equal budgets</h2>

<p>I assumed the value path must be the smaller one, since Q, K, V reads like three peers and $W_V$ is one of them. Then I looked at a real configuration. Llama 2 70B uses <code>hidden_size 8192</code>, <code>num_attention_heads 64</code>, and <code>num_key_value_heads 8</code>, so $d_{head}$ is 128.</p>

<table>
  <thead>
    <tr>
      <th>Matrix</th>
      <th>Shape</th>
      <th>Parameters</th>
      <th>Path</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>$W_Q$</td>
      <td>8192 x 8192</td>
      <td>67.1M</td>
      <td>QK</td>
    </tr>
    <tr>
      <td>$W_K$</td>
      <td>8192 x 1024</td>
      <td>8.4M</td>
      <td>QK</td>
    </tr>
    <tr>
      <td>$W_V$</td>
      <td>8192 x 1024</td>
      <td>8.4M</td>
      <td>OV</td>
    </tr>
    <tr>
      <td>$W_O$</td>
      <td>8192 x 8192</td>
      <td>67.1M</td>
      <td>OV</td>
    </tr>
  </tbody>
</table>

<p>$W_V$ is one eighth the size of $W_Q$. That is grouped-query attention, which I come back to below. The paths still add up to the same total:</p>

\[W_Q + W_K = 75.5\text{M} = W_V + W_O\]

<p>Each path is one wide matrix paired with one narrow one, mirrored. It holds under plain multi-head attention too, where all four are $d_{model} \times d_{model}$. Per head, both circuits are $d_{model} \times d_{model}$ maps of rank at most $d_{head}$.</p>

<p>The architecture spends the same on where to look and on what to write. Nothing in it treats the content path as needing more capacity.</p>

<h2 id="7-only-two-of-the-four-cost-anything-at-inference">7. Only two of the four cost anything at inference</h2>

<p>The four are symmetric in budget and completely asymmetric in behaviour during generation.</p>

<p>In a decoder-only model the causal mask means position $t$ attends only to positions up to $t$. So $x_j$ at every layer depends only on tokens up to $j$. Append a token and nothing about position 3 changes, at any depth. Its $k$ and $v$ are frozen the moment it is in the past. Its $q$ is dead weight, because position 3 already did its lookup and will never repeat it.</p>

<p>That is the derivation of the KV cache. K and V are stored because they are re-read at every future step. Q is discarded because it is used once. Causality does the work here rather than the naming. In a bidirectional encoder, deeper-layer keys for past positions do change when a token is appended, because the layers below attended to it, and caching would be invalid.</p>

<p>The cost of what is kept:</p>

\[\text{KV bytes} \approx 2 \cdot n_{layers} \cdot n_{kv} \cdot d_{head} \cdot \text{seq len} \cdot \text{batch} \cdot \text{bytes}\]

<p>Q does not appear in it. Neither does $W_O$. Only two of the four matrices produce anything that has to be stored.</p>

<p>Storing it is the bottleneck. Training parallelises across the sequence and incremental decoding cannot. Shazeer’s multi-query attention paper puts it plainly: decoding is “often slow, due to the memory-bandwidth cost of repeatedly loading the large ‘keys’ and ‘values’ tensors.” The constraint is moving the cache rather than arithmetic.</p>

<p>That formula has exactly one term an architect can cut without touching depth, width, or context length, and it is $n_{kv}$. Multi-query attention sets it to 1. Grouped-query attention sets it to some $g$ between 1 and $h$, using “an intermediate (more than one, less than number of query heads) number of key-value heads” to reach “quality close to multi-head attention with comparable speed to MQA.” Llama 2’s 34B and 70B use it, with 8 key-value heads against 64 query heads.</p>

<p>Query heads stay at $h$ in all three variants. That is why $W_V$ is eight times smaller than $W_Q$ in the table above.</p>

<p>Sharing keys and values does not collapse the heads. Eight query heads over two key-value heads still produce eight distinct attention patterns, because $W_Q$ differs per head even where $W_K$ is shared. What grouped-query attention gives up is memory, not the diversity that multi-head exists for.</p>

<h2 id="8-heads-partition-the-model-they-do-not-add-to-it">8. Heads partition the model, they do not add to it</h2>

<p>Multi-head attention does not widen the layer.</p>

<p>The original model uses $h = 8$ with $d_k = d_v = d_{model}/h = 64$ and $d_{model} = 512$. Eight heads is 512 divided eight ways, not eight copies of 512. The paper is explicit that “due to the reduced dimension of each head, the total computational cost is similar to that of single-head attention with full dimensionality.”</p>

<p>At fixed $d_{model}$, adding heads is a trade rather than an upgrade. You get more simultaneous lookups, each in a narrower subspace.</p>

<p>What the trade buys is stated in one sentence: multi-head attention “allows the model to jointly attend to information from different representation subspaces at different positions. With a single attention head, averaging inhibits this.” The operative word is averaging. One softmax produces one distribution per position, and a position usually needs several unrelated things at once, such as its syntactic parent, its antecedent, the previous token, and the matching bracket. One distribution cannot peak in four places without smearing.</p>

<p>Whether all those heads earn their place is a separate question, and the evidence has a boundary I have to mark. Voita et al. pruned 38 of 48 encoder heads on an English to Russian WMT model for a drop of 0.15 BLEU, and found that “the most important and confident heads play consistent and often linguistically-interpretable roles” and prune last. Michel et al. found many heads removable at test time, with some layers reduced to a single head.</p>

<p>Both studies used 2019-era encoder-decoder NMT models and BERT. Neither says anything direct about a modern decoder-only LLM at scale. “Most heads in GPT-class models are redundant” gets repeated constantly and does not follow from these papers. I am not making that claim.</p>

<h2 id="9-what-an-attention-map-cannot-tell-you">9. What an attention map cannot tell you</h2>

<p>The tempting move with all of this is to screenshot a heatmap and point at the bright cell.</p>

<p>The mechanical reason not to comes straight out of the two circuits. An attention map is a QK object. It records where a head looked. It never contains V or $W_O$, so it cannot record what the head wrote. The contribution of a source token is $\alpha_{ij} W_O W_V x_j$, and if that lands near zero, or in directions no downstream layer reads, a large $\alpha_{ij}$ moves nothing. Per head that map has rank at most 128 out of 8192 dimensions, which leaves room to write nothing at all.</p>

<p>The empirical literature agrees and remains unresolved. Jain and Wallace found attention weights “frequently uncorrelated with gradient-based measures of feature importance”, with very different attention distributions producing equivalent predictions. Wiegreffe and Pinter replied that the conclusion depends on the definition of explanation, and proposed four concrete tests. Cite both or neither.</p>

<p>There is also the blunt fact of attention sinks. Models assign strong attention to initial tokens “even if they are not semantically important.” A head putting 0.6 of its mass on token 0 is usually reporting nothing.</p>

<p>The position I would defend is neither that attention maps explain the model nor that they are meaningless. They are QK evidence. They constrain where a head looked, and any claim about what it did needs the other circuit.</p>

<h2 id="what-i-check-now">What I check now</h2>

<table>
  <thead>
    <tr>
      <th>Matrix</th>
      <th>Computed from</th>
      <th>Role</th>
      <th>Cached at decode</th>
      <th>Shrinks under GQA</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>$W_Q$</td>
      <td>$x$</td>
      <td>What this position seeks</td>
      <td>No, used once</td>
      <td>No</td>
    </tr>
    <tr>
      <td>$W_K$</td>
      <td>$x$</td>
      <td>What this position offers</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>$W_V$</td>
      <td>$x$</td>
      <td>What it hands over</td>
      <td>Yes</td>
      <td>Yes</td>
    </tr>
    <tr>
      <td>$W_O$</td>
      <td>head output</td>
      <td>Where it writes in the residual stream</td>
      <td>Not applicable</td>
      <td>No</td>
    </tr>
  </tbody>
</table>

<p>The first three take the same input. Different weights give them different jobs, different lifetimes, and different bills.</p>

<p>Reading an attention implementation now, I check four things: which of the four matrices are shared across heads, which of them are cached, what rank each circuit has, and whether a claim being made about a head is QK or OV. My 2022 post said that attention maps a query and a set of key-value pairs to an output. That is still true. The difference is that I can now say what each of those words costs.</p>

<h2 id="references">References</h2>

<ul>
  <li>Vaswani et al., <a href="https://arxiv.org/abs/1706.03762"><em>Attention Is All You Need</em></a>.</li>
  <li>Elhage et al., <a href="https://transformer-circuits.pub/2021/framework/index.html"><em>A Mathematical Framework for Transformer Circuits</em></a>.</li>
  <li>Shazeer, <a href="https://arxiv.org/abs/1911.02150"><em>Fast Transformer Decoding: One Write-Head is All You Need</em></a>.</li>
  <li>Ainslie et al., <a href="https://aclanthology.org/2023.emnlp-main.298/"><em>GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints</em></a>.</li>
  <li>Touvron et al., <a href="https://arxiv.org/abs/2307.09288"><em>Llama 2: Open Foundation and Fine-Tuned Chat Models</em></a>.</li>
  <li>Voita et al., <a href="https://aclanthology.org/P19-1580/"><em>Analyzing Multi-Head Self-Attention: Specialized Heads Do the Heavy Lifting, the Rest Can Be Pruned</em></a>.</li>
  <li>Michel et al., <a href="https://proceedings.neurips.cc/paper_files/paper/2019/file/2c601ad9d2ff9bc8b282670cdd54f69f-Paper.pdf"><em>Are Sixteen Heads Really Better than One?</em></a>.</li>
  <li>Jain and Wallace, <a href="https://aclanthology.org/N19-1357/"><em>Attention is not Explanation</em></a>.</li>
  <li>Wiegreffe and Pinter, <a href="https://aclanthology.org/D19-1002/"><em>Attention is not not Explanation</em></a>.</li>
  <li>Xiao et al., <a href="https://arxiv.org/abs/2309.17453"><em>Efficient Streaming Language Models with Attention Sinks</em></a>.</li>
  <li>3Blue1Brown, <a href="https://www.3blue1brown.com/lessons/attention/"><em>Attention in transformers, step-by-step</em></a>.</li>
</ul>]]></content><author><name>Huijo</name></author><category term="Machine Learning" /><category term="Engineering" /><summary type="html"><![CDATA[Q, K, V, and the output projection the name leaves out. What each weight does to the same input, and why the differences between them decide what an inference server has to cache.]]></summary></entry><entry><title type="html">What’s inside that token? JWT explained with a boarding pass</title><link href="https://ccomkhj.github.io/JWTForMCP/" rel="alternate" type="text/html" title="What’s inside that token? JWT explained with a boarding pass" /><published>2026-07-30T00:00:00+00:00</published><updated>2026-07-30T00:00:00+00:00</updated><id>https://ccomkhj.github.io/JWTForMCP</id><content type="html" xml:base="https://ccomkhj.github.io/JWTForMCP/"><![CDATA[<p>I recently built an OAuth 2.0 server using <a href="https://www.keycloak.org/">Keycloak</a> so that our MCP (Model Context Protocol) tools can check <em>who</em> is calling them and <em>what</em> that caller is allowed to do. The heart of that system is a small piece of text called a <strong>JWT</strong> (JSON Web Token, pronounced “jot”).</p>

<p>This post explains what a JWT is, what it carries, and how you can put your own information inside it — like which environment (prod or dev), which customer, and which exact permissions a caller has. No security background needed.</p>

<h2 id="the-boarding-pass-analogy">The boarding pass analogy</h2>

<p>A JWT is a boarding pass for software.</p>

<p>When you fly, you first go to the check-in counter, prove who you are with your passport, and receive a boarding pass. From then on, nobody at the gate calls the check-in counter to ask “is this person really allowed on this flight?” They just look at the pass. It says who you are, which flight you may board, which seat class you get, and it has security features so nobody can forge it.</p>

<p>The same flow happens in our system:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> You (or an AI agent)          Keycloak                 MCP server
        |                  (check-in counter)         (boarding gate)
        |                          |                        |
        |--- 1. "Here is my  -----&gt;|                        |
        |     username/password"   |                        |
        |                          |                        |
        |&lt;-- 2. Boarding pass -----|                        |
        |     (the JWT)            |                        |
        |                          |                        |
        |--- 3. "I want to read the database. Here is -----&gt;|
        |        my JWT."                                   |
        |                                                   |
        |&lt;-- 4. Gate checks the pass. Valid? Come in. ------|
</code></pre></div></div>

<p>Keycloak is the check-in counter: it verifies your identity once and hands you a signed pass. The MCP server is the gate: it never needs to see your password. It only reads the pass and checks the security seal.</p>

<h2 id="a-jwt-has-three-parts">A JWT has three parts</h2>

<p>If you look at a raw JWT it seems like gibberish:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>eyJhbGciOi...  .  eyJpc3MiOi...  .  SflKxwRJSM...
   HEADER            PAYLOAD          SIGNATURE
</code></pre></div></div>

<p>Three chunks of text separated by dots. Each has one job:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+----------------------------------------------------------+
|  HEADER      "What kind of pass is this?"                 |
|              e.g. { "alg": "RS256", "typ": "JWT" }        |
|              (which sealing method was used)              |
+----------------------------------------------------------+
|  PAYLOAD     "The actual facts" (called *claims*)         |
|              who you are, who issued it, what you may do  |
+----------------------------------------------------------+
|  SIGNATURE   "The tamper-proof seal"                      |
|              proves Keycloak wrote it and nobody          |
|              changed a single letter afterwards           |
+----------------------------------------------------------+
</code></pre></div></div>

<p>One thing surprises everyone: <strong>the payload is not encrypted.</strong> Anyone who holds the token can read it, just like anyone who picks up your boarding pass can read your name and seat. What they <em>cannot</em> do is change it — if even one letter of the payload is altered, the signature no longer matches and the gate rejects the pass. So the rule is simple:</p>

<blockquote>
  <p>Put facts in a JWT, never secrets. Names, roles, permissions: yes. Passwords, API keys: never.</p>
</blockquote>

<h2 id="the-facts-inside-claims">The facts inside: claims</h2>

<p>Each fact in the payload is called a <strong>claim</strong>. Some claims are standard — every JWT system in the world understands them:</p>

<table>
  <thead>
    <tr>
      <th>Claim</th>
      <th>Boarding pass equivalent</th>
      <th>What it means</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">iss</code> (issuer)</td>
      <td>The airline that printed the pass</td>
      <td>Which server created this token — e.g. your Keycloak URL. The gate only trusts passes from airlines it knows.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">aud</code> (audience)</td>
      <td>The flight number</td>
      <td>Who this token is <em>for</em> — e.g. <code class="language-plaintext highlighter-rouge">mcp-server</code>. A pass for flight LH123 doesn’t get you onto LH456, and a token issued for one API shouldn’t work on another.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sub</code> (subject)</td>
      <td>The passenger name</td>
      <td>Who the token is about — the user or service ID.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">exp</code> (expiry)</td>
      <td>The departure time</td>
      <td>When the pass stops working. JWTs are short-lived on purpose: a stolen pass is only useful for minutes, not forever.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">roles</code></td>
      <td>Economy / Business class</td>
      <td>Broad groups the user belongs to, like <code class="language-plaintext highlighter-rouge">admin</code> or <code class="language-plaintext highlighter-rouge">analyst</code>.</td>
    </tr>
  </tbody>
</table>

<p>A minimal payload from Keycloak looks like this:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"iss"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://auth.example.com/realms/mcp"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"aud"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mcp-server"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"sub"</span><span class="p">:</span><span class="w"> </span><span class="s2">"user-42"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"exp"</span><span class="p">:</span><span class="w"> </span><span class="mi">1767100000</span><span class="p">,</span><span class="w">
  </span><span class="nl">"realm_access"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"roles"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"analyst"</span><span class="p">]</span><span class="w"> </span><span class="p">}</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<h2 id="adding-your-own-facts-custom-claims">Adding your own facts: custom claims</h2>

<p>Standard claims answer “who are you and who issued this?” But real systems need more context. In our case the MCP server needs to know three extra things before it does anything:</p>

<ol>
  <li><strong>Which environment</strong> is this token for — production or development?</li>
  <li><strong>Which customer’s data</strong> may this caller touch — customer A or B?</li>
  <li><strong>Exactly which actions</strong> are allowed — read the database? read a sheet? write to it?</li>
</ol>

<p>All three fit naturally into the payload as <strong>custom claims</strong>:</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"iss"</span><span class="p">:</span><span class="w"> </span><span class="s2">"https://auth.example.com/realms/mcp"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"aud"</span><span class="p">:</span><span class="w"> </span><span class="s2">"mcp-server"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"sub"</span><span class="p">:</span><span class="w"> </span><span class="s2">"user-42"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"exp"</span><span class="p">:</span><span class="w"> </span><span class="mi">1767100000</span><span class="p">,</span><span class="w">
  </span><span class="nl">"realm_access"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nl">"roles"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"analyst"</span><span class="p">]</span><span class="w"> </span><span class="p">},</span><span class="w">

  </span><span class="nl">"env"</span><span class="p">:</span><span class="w"> </span><span class="s2">"prod"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"customer"</span><span class="p">:</span><span class="w"> </span><span class="s2">"A"</span><span class="p">,</span><span class="w">
  </span><span class="nl">"permissions"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"db:read"</span><span class="p">,</span><span class="w"> </span><span class="s2">"sheet:read"</span><span class="p">,</span><span class="w"> </span><span class="s2">"sheet:write"</span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Think of these as extra stamps on the boarding pass: “Lounge access: yes. Priority boarding: no.”</p>

<h3 id="why-the-resourceaction-pattern-is-worth-copying">Why the <code class="language-plaintext highlighter-rouge">resource:action</code> pattern is worth copying</h3>

<p>Notice the shape of the permissions: <code class="language-plaintext highlighter-rouge">db:read</code>, <code class="language-plaintext highlighter-rouge">sheet:write</code>. That’s <em>thing colon verb</em>. It stays readable as the system grows, and the check on the server side becomes one line of logic:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Request: "write row 5 to the sheet"
Check:   does the token's permissions list contain "sheet:write"?
         yes -&gt; do it
         no  -&gt; refuse (403 Forbidden)
</code></pre></div></div>

<p>Roles and permissions work together but answer different questions. A <strong>role</strong> is a job title (“analyst”); a <strong>permission</strong> is a concrete allowance (“may write to sheets”). In Keycloak you define roles, attach permissions to them, and Keycloak expands everything into the token automatically. Users are managed by role; the MCP server only ever checks permissions.</p>

<h3 id="how-to-add-custom-claims-in-keycloak">How to add custom claims in Keycloak</h3>

<p>Keycloak calls this feature <strong>protocol mappers</strong> (in newer versions you’ll find it under <strong>client scopes</strong>). No code required:</p>

<ol>
  <li>Store the fact on the user or client — e.g. a user attribute <code class="language-plaintext highlighter-rouge">customer = A</code> (Users → your user → Attributes).</li>
  <li>Create a mapper that copies the attribute into the token (Clients → your client → Client scopes → Add mapper → <em>User Attribute</em>, set the claim name to <code class="language-plaintext highlighter-rouge">customer</code>).</li>
  <li>That’s it. Every new token for that user now contains <code class="language-plaintext highlighter-rouge">"customer": "A"</code>.</li>
</ol>

<p>The <code class="language-plaintext highlighter-rouge">env</code> claim is often simplest to handle differently: run one Keycloak realm (or client) per environment, and hard-code the claim with a <em>hardcoded claim</em> mapper — <code class="language-plaintext highlighter-rouge">env: prod</code> in the prod realm, <code class="language-plaintext highlighter-rouge">env: dev</code> in the dev realm. Then a dev token can <em>never</em> claim to be a prod token, because the prod issuer never signed it.</p>

<h2 id="the-full-journey-of-one-request">The full journey of one request</h2>

<p>Here is everything together — what happens when an AI agent asks our MCP server to write to a spreadsheet:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code> AI agent                                        MCP server
    |                                                |
    |  "Append this row to the sheet."               |
    |  + JWT                                         |
    |-----------------------------------------------&gt;|
    |                                                |
    |                     The gatekeeper checklist:  |
    |                                                |
    |          1. Signature valid?  (not forged)     |
    |          2. iss = our Keycloak?  (right issuer)|
    |          3. aud = mcp-server?  (meant for us)  |
    |          4. exp in the future?  (not expired)  |
    |          5. env = prod?  (right environment)   |
    |          6. customer = A?  (right data scope)  |
    |          7. "sheet:write" in permissions?      |
    |                                                |
    |     ALL seven pass -&gt; row is written           |
    |     ANY one fails  -&gt; request refused          |
    |&lt;-----------------------------------------------|
</code></pre></div></div>

<p>Steps 1–4 are the standard checks every JWT system does. Steps 5–7 are ours — and they only exist because we put <code class="language-plaintext highlighter-rouge">env</code>, <code class="language-plaintext highlighter-rouge">customer</code>, and <code class="language-plaintext highlighter-rouge">permissions</code> into the token as custom claims.</p>

<p>The elegant part: the MCP server made all seven decisions <strong>without contacting Keycloak</strong> and <strong>without storing any user data</strong>. Everything it needed was on the pass. That’s why JWTs are the default choice for connecting many small services — each gate can verify passes on its own, as long as it knows the issuer’s public key.</p>

<h2 id="takeaways">Takeaways</h2>

<ul>
  <li>A <strong>JWT</strong> is a signed, readable, short-lived boarding pass: header (seal type), payload (facts), signature (the seal).</li>
  <li><strong>Readable ≠ forgeable.</strong> Anyone can read it; nobody can change it. So: facts in, secrets out.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">iss</code></strong> says who printed the pass, <strong><code class="language-plaintext highlighter-rouge">aud</code></strong> says which gate it’s for, <strong><code class="language-plaintext highlighter-rouge">exp</code></strong> says when it dies. Check all three, always.</li>
  <li><strong>Custom claims</strong> let you carry your own facts — <code class="language-plaintext highlighter-rouge">env</code>, <code class="language-plaintext highlighter-rouge">customer</code>, <code class="language-plaintext highlighter-rouge">permissions</code> — and Keycloak adds them with mappers, no code.</li>
  <li>The <strong><code class="language-plaintext highlighter-rouge">resource:action</code></strong> permission format (<code class="language-plaintext highlighter-rouge">db:read</code>, <code class="language-plaintext highlighter-rouge">sheet:write</code>) keeps authorization checks one line long, forever.</li>
</ul>]]></content><author><name>Huijo</name></author><category term="Engineering" /><category term="Agents" /><summary type="html"><![CDATA[I built an OAuth 2.0 server with Keycloak for our MCP setup. Here is what a JWT actually carries — issuer, audience, roles — and how we pack our own facts into it: environment, customer, and fine-grained permissions.]]></summary></entry><entry><title type="html">My tips for improving an agent-driven work environment</title><link href="https://ccomkhj.github.io/AgentDrivenWorkEnvironment/" rel="alternate" type="text/html" title="My tips for improving an agent-driven work environment" /><published>2026-07-28T00:00:00+00:00</published><updated>2026-07-28T00:00:00+00:00</updated><id>https://ccomkhj.github.io/AgentDrivenWorkEnvironment</id><content type="html" xml:base="https://ccomkhj.github.io/AgentDrivenWorkEnvironment/"><![CDATA[<p>I ran a session on this at my team’s AI lunch. Here’s the summary.</p>

<h2 id="too-many-agents-at-once">Too many agents at once</h2>

<p>How many agents are you running right now?</p>

<p>One day, I had 3 quite complex linear tickets.
I created three worktrees and start running agent sessions.
Some data issue was reported, so I added one more agent to analyze and debug the problem.
Some model traininig was requested, so I added another agent session.
Switching among multiple sessions is not free. You can tell it’s just switching tab, however, your brain can’t switch that fast.
It’s not only draining, but also quality of your decision plumet.</p>

<h3 id="agents-scale-your-review-capacity-does-not">Agents scale. Your review capacity does not.</h3>

<p>Somewhere past two concurrent diffs, you stop reviewing and start approving. Nothing warns you when you cross that line. The output still looks like work getting done, and the pull request still gets a thumbs up.</p>

<p>I call this the <strong>orchestration tax</strong>. You pay it in the only currency you cannot mint more of. Opening a sixth agent does not add a sixth unit of throughput. It takes review time away from the other five.</p>

<p>So the ceiling is not a number I can give you. Run as many as you can verify. If you cannot say what would make you reject a diff before you open it, you are not reviewing it.</p>

<p>Part of that ceiling is a tooling problem. A terminal or a VS Code window is built for one session at a time. Once you go past a couple of agents, switching between them costs more than the work does, and you start approving out of navigation fatigue. A tool that shows parallel sessions at a glance genuinely raises the number you can verify. It will not make you a better reviewer, but it gives you back the time you were spending on managing.</p>

<p>There are couple of solutions, but <a href="http://superset.sh">Superset</a> is my favorite. I love their minimal setup.</p>

<h2 id="one-session-until-auto-compact">One session, until auto-compact?</h2>

<p>Do you know what auto-compact means?</p>

<p>If you use Claude Code all day you have seen the symptoms:</p>

<ul>
  <li>It re-reads a file it already read.</li>
  <li>It re-proposes the approach you killed an hour ago.</li>
  <li>It edits against a file state that no longer exists.</li>
</ul>

<h3 id="if-auto-compact-fired-you-were-already-too-late">If auto-compact fired, you were already too late</h3>

<p>Auto-compact is not a feature you use. It is the system telling you it is about to throw away detail on your behalf, and it does not ask which details you cared about. By the time you see the notice, the choice of what survives has gone to a summarizer that never saw you wince at one particular line.</p>

<h3 id="use-the-agent-at-its-smartest-state">Use the agent at its smartest state</h3>

<p>Lossy summarization is only half of the problem. Long contexts are not uniformly useful in the first place.</p>

<p><img src="/img/agentenv-lost-in-middle.png" alt="Accuracy versus the position of the document containing the answer: high at the first position, dropping through the middle, rising again at the end" width="520" /></p>

<p><em>From <a href="https://arxiv.org/pdf/2307.03172">Lost in the Middle: How Language Models Use Long Contexts</a>.</em></p>

<p>Accuracy is highest when the answer sits at the start or the end of the context and sags in the middle. In that experiment the middle positions drop to roughly the closed-book baseline, which means the model did about as well as if you had given it nothing. Retrieving the right document into the window does not guarantee the model uses it.</p>

<p>Why the curve has that shape:</p>

<ul>
  <li>The end performs well because of recency. Generated answer tokens sit closest to the final prompt tokens, and many attention patterns favour nearby information.</li>
  <li>The beginning performs well because of primacy. Early tokens usually carry the instructions and the framing, they influence many later representations, and they tend to get disproportionate attention.</li>
  <li>The middle performs worse because of competition. Relevant tokens are surrounded by distractors, they receive weaker attention, and models are not trained to use every position in a long context equally well.</li>
</ul>

<p>A bigger window does not fix this. It just moves the sag further out.</p>

<p><img src="/img/agentenv-1m-context-bench.png" alt="Benchmark table of coding LLMs across 32K to 1M context on LongCodeQA and LongSWE-Bench, showing scores that flatten or fall as context grows" width="820" /></p>

<p><em>From <a href="https://arxiv.org/pdf/2505.07897">Evaluating Coding LLMs at 1M Context Windows</a>.</em></p>

<p>Read this one across the columns rather than down them. Scores do not climb as the window grows. On the code-editing side they sit near the floor almost everywhere, and several models peak well before their maximum context. The advertised window is a capacity limit, not a working range.</p>

<p>So do not use the whole 1M just because you have it. Watch your context continuously. <code class="language-plaintext highlighter-rouge">/statusline</code> turns it into something you can see instead of something you find out about later.</p>

<p>My own rule: every time a session passes 200k, I stop and decide. Either I <code class="language-plaintext highlighter-rouge">/compact</code> deliberately, or I hand off and <code class="language-plaintext highlighter-rouge">/clear</code>. What matters is that I pick what survives instead of letting the trigger pick.</p>

<p>Treat your context like your baby. Feed it only what it needs. And <code class="language-plaintext highlighter-rouge">/rewind</code> is not an emergency exit you use once a month. It is how you undo a bad turn before it contaminates everything after it.</p>

<h3 id="the-tokens-you-paid-before-you-typed-anything">The tokens you paid before you typed anything</h3>

<p>Everything above is about the session you are already in. Some of your context is gone before that. Every skill you install puts a <code class="language-plaintext highlighter-rouge">- name: description</code> line into a listing that goes out with every turn, whether you use the skill or not. Plugins and MCP servers cost you the same way.</p>

<p>That listing sits at the very front of the window, which is the position the graph above says matters most. So if you have a hundred skills installed and use four, your best context is spent describing ninety-six things that will never run. The unused ones also give the agent more wrong answers to pick from.</p>

<p>I built <a href="https://github.com/ccomkhj/context-saver">context-saver</a> because I wanted to see the number. It is a local single-file UI that shows what each skill, plugin and MCP server costs you in always-paid tokens, and lets you switch off what you do not use. You click, review the diff, and it writes <code class="language-plaintext highlighter-rouge">~/.claude/settings.json</code> with a backup. You can also set a skill to name-only or user-invocable-only, so it stops advertising itself but still works when you ask for it.</p>

<p>Context management is the lowest hanging fruit in getting more out of an agent. It costs nothing except discipline.</p>

<h2 id="save-your-attention">Save your attention</h2>

<p>You type “make it better”, the agent works, and then you eyeball the diff. Then you do it again on the next completion, and the one after that. Every completion bills your attention, whether the change was any good or not.</p>

<p><img src="/img/agentenv-loop-engineering.png" alt="Two loops side by side. Left: ME sits inside the loop with the thinking agents. Right: ME defines a goal for an orchestrator agent, which sits inside the loop instead" width="820" /></p>

<!-- TODO(story): the loop that ate my attention. The specific task, how many
     round trips before you stepped back, and what you changed. -->

<p>The default setup is on the left. You are inside the loop. Every iteration has to pass through you, so it can only run as fast as you can look at it, and it stops the moment you go to lunch.</p>

<p>Loop-engineering is on the right. You are not in the loop. You define the goal the loop runs against, and an orchestrator agent takes the seat you used to sit in. Iteration continues without you. You spend attention once, on stating the target, rather than once per completion.</p>

<p>That only works if the orchestrator can tell whether the goal was met, which is where the design gets interesting.</p>

<p><img src="/img/agentenv-goal-orchestrator.png" alt="An orchestrator on a small model dispatches work to workers on larger models; workers report output back, and the orchestrator measures it against a goal statement" width="700" /></p>

<p>Notice the split. The orchestrator does not have to be the strongest model in the room. It dispatches work, reads the report, and checks it against the goal statement, and a cheap fast model handles that fine. The expensive models do the actual work. Checking is the easy half. Writing a goal that can be checked is the part you have to think about.</p>

<p>Which is why I keep the condition strict. The goal has to be measurable quantitatively, not qualitatively. Two real ones from my own work:</p>

<ul>
  <li>MCP authentication: does it support dual mode for an admin user?</li>
  <li>Shopify intra-market extraction: extract every market without blowing up the row count on the dataframe merge.</li>
</ul>

<p>Both of those have an answer a command can produce. “Make the auth cleaner” does not.</p>

<p>So when you write the goal, name the command that proves it, and pin down how it runs. Which interpreter, which environment, which tree. If it only passes on your machine, you have not specified a goal yet, you have described a feeling.</p>

<h2 id="where-this-leaves-me">Where this leaves me</h2>

<p>The three tips are really one tip. Agents are abundant and my attention is not, so everything else follows from that.</p>

<p>Cap concurrency at what I can verify. Curate the context before the system curates it for me. Turn measurable tasks into loops that run without me.</p>

<p>None of this needs a better model. It needs me to decide what done looks like before the work starts, which is the part I still get wrong more often than I would like.</p>]]></content><author><name>Huijo</name></author><category term="Agents" /><summary type="html"><![CDATA[Three limits decide how much you get out of coding agents: how many diffs you can verify, how much context you feed them, and how much attention each loop costs you.]]></summary></entry><entry><title type="html">Designing a RAG retrieval system: from ingestion to grounded generation</title><link href="https://ccomkhj.github.io/DesigningRAGRetrievalSystems/" rel="alternate" type="text/html" title="Designing a RAG retrieval system: from ingestion to grounded generation" /><published>2026-07-19T00:00:00+00:00</published><updated>2026-07-19T00:00:00+00:00</updated><id>https://ccomkhj.github.io/DesigningRAGRetrievalSystems</id><content type="html" xml:base="https://ccomkhj.github.io/DesigningRAGRetrievalSystems/"><![CDATA[<p>I used to explain RAG as documents, a vector database, and an LLM. That is enough for a diagram, but it does not help much when an answer is wrong.</p>

<p>The failure may have started during parsing. Dense search may have missed an error code. Fusion may have dropped a useful candidate, or the reranker may never have received it. Even correct retrieval can fail when context packing removes a prerequisite.</p>

<p>I now debug RAG by following the evidence. At each stage I ask where the required source text disappeared.</p>

<p>The example below is a simplified composite. The product and error code are fictional, but the failure pattern is common in operational documentation.</p>

<h2 id="a-small-case-with-several-retrieval-problems">A small case with several retrieval problems</h2>

<p>Imagine a gateway runbook containing these facts:</p>

<blockquote>
  <p>Event E-4317 means that majority quorum is unavailable. A replacement gateway joins as a non-voter and becomes a voter only after synchronization. The standby takes over after three missed heartbeats, but automatic takeover is blocked until quorum returns.</p>
</blockquote>

<p>Users may ask:</p>

<ul>
  <li>“What does E-4317 mean?”</li>
  <li>“How does the backup gateway take over?”</li>
  <li>“How do I diagnose E-4317 after replacing a gateway?”</li>
</ul>

<p>Dense search handles the second query well because it can connect “backup” with “standby” and “take over” with “assumes active duty.” BM25 has the clearer advantage on the exact error code. The mixed query needs both signals and more than one source span.</p>

<p>The dangerous result is an answer that explains failover but omits that the replacement is still a non-voter. The text sounds relevant, yet it is incomplete. Looking only at the final LLM call hides the real failure.</p>

<p>The pipeline is a series of cutoffs:</p>

<blockquote>
  <p>parse and version, create citable spans, build dense and sparse representations, filter eligible points, retrieve candidates, fuse, rerank, pack context, then generate.</p>
</blockquote>

<p>Once evidence falls outside a cutoff, a later stage cannot recover it:</p>

\[\operatorname{EvidenceRecall}(\text{after reranking})
\le
\operatorname{EvidenceRecall}(\text{candidate union}).\]

<h2 id="1-ingestion-sets-the-limits">1. Ingestion sets the limits</h2>

<p>I keep the source text separate from the text used for retrieval.</p>

<table>
  <thead>
    <tr>
      <th>Object</th>
      <th>Purpose</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Source document</td>
      <td>Audit, reprocessing, and version history</td>
    </tr>
    <tr>
      <td>Source span</td>
      <td>Exact citation with page or byte offsets</td>
    </tr>
    <tr>
      <td>Index text</td>
      <td>Source span plus title, heading path, or retrieval context</td>
    </tr>
    <tr>
      <td>Answer payload</td>
      <td>Exact source text and any approved neighbouring context</td>
    </tr>
  </tbody>
</table>

<p>A sentence such as “the replacement initially joins as a non-voter” is a clean citation but a weak standalone search document. Prefixing its title and heading can improve retrieval without pretending that the prefix appeared in the source.</p>

<p>Qdrant can store <a href="https://qdrant.tech/documentation/manage-data/vectors/#named-vectors">named dense and sparse vectors on the same point</a>. I use the point ID as the alignment boundary:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">client</span><span class="p">.</span><span class="n">create_collection</span><span class="p">(</span>
    <span class="n">collection_name</span><span class="o">=</span><span class="s">"runbook_chunks"</span><span class="p">,</span>
    <span class="n">vectors_config</span><span class="o">=</span><span class="p">{</span>
        <span class="s">"dense"</span><span class="p">:</span> <span class="n">models</span><span class="p">.</span><span class="n">VectorParams</span><span class="p">(</span>
            <span class="n">size</span><span class="o">=</span><span class="n">DENSE_DIMENSION</span><span class="p">,</span>
            <span class="n">distance</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">Distance</span><span class="p">.</span><span class="n">COSINE</span><span class="p">,</span>
        <span class="p">)</span>
    <span class="p">},</span>
    <span class="n">sparse_vectors_config</span><span class="o">=</span><span class="p">{</span>
        <span class="s">"bm25"</span><span class="p">:</span> <span class="n">models</span><span class="p">.</span><span class="n">SparseVectorParams</span><span class="p">(</span><span class="n">modifier</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">Modifier</span><span class="p">.</span><span class="n">IDF</span><span class="p">)</span>
    <span class="p">},</span>
<span class="p">)</span>
</code></pre></div></div>

<p>The payload carries <code>tenant_id</code>, <code>document_id</code>, source and pipeline versions, offsets, exact source text, index text, authorization groups, and an <code>is_current</code> flag. The dense and sparse branches therefore refer to the same span and permissions.</p>

<p>FastEmbed’s <code>Qdrant/bm25</code> model expects Qdrant to apply inverse document frequency, which is why the sparse vector uses <code>Modifier.IDF</code>.</p>

<p>I derive deterministic point IDs from tenant, document, source version, pipeline version, and source offsets. Retrying ingestion then overwrites the same points. This makes the final state idempotent, but it does not make repeated writes free.</p>

<p>Deterministic IDs also leave stale points behind when a document changes from ten chunks to eight. An ingestion manifest must reconcile old IDs after a successful run. Qdrant’s point <code>version</code> is an internal operation version, so the application still needs its own <code>source_version</code>.</p>

<p>Rechunking requires a full rebuild of the affected dense and sparse indexes. Boundaries change the embeddings, BM25 document lengths, average length, document frequency, and candidate population. In production I build a new collection, validate it, and switch an alias.</p>

<h2 id="2-dense-and-bm25-scores-mean-different-things">2. Dense and BM25 scores mean different things</h2>

<p>For query vector $q$ and document vector $d$, cosine similarity is:</p>

\[\cos(q,d)=\frac{q\cdot d}{\lVert q\rVert\lVert d\rVert}.\]

<p>This score says that an encoder placed two texts in similar directions. It does not prove that the chunk contains an exact identifier, supports a complete answer, or belongs to the current authorized corpus.</p>

<p>Some models use different prompts for queries and passages. FastEmbed exposes <code>query_embed(...)</code> and <code>passage_embed(...)</code> for that reason. Using one generic embedding path can bypass the setup an asymmetric model was trained to use.</p>

<p>BM25 keeps lexical evidence. Its score is:</p>

\[\operatorname{BM25}(q,d)=
\sum_{t\in q}
\operatorname{IDF}(t)
\frac{f(t,d)(k_1+1)}
{f(t,d)+k_1\left(1-b+b\frac{|d|}{\operatorname{avgdl}}\right)}.\]

<p>Term frequency $f(t,d)$ rewards matches but saturates through $k_1$. IDF rewards rare terms. The parameter $b$ adjusts for document length. FastEmbed’s Qdrant BM25 defaults are <code>k1=1.2</code>, <code>b=0.75</code>, and <code>avg_len=256</code>, but tokenization and identifier handling often matter more than small parameter changes.</p>

<p>I choose the first baseline from the query distribution:</p>

<table>
  <thead>
    <tr>
      <th>Query pattern</th>
      <th>Baseline</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Error codes, SKUs, names, quotations</td>
      <td>BM25</td>
    </tr>
    <tr>
      <td>Paraphrases and conceptual questions</td>
      <td>Dense</td>
    </tr>
    <tr>
      <td>Exact identifier plus semantic intent</td>
      <td>Hybrid</td>
    </tr>
    <tr>
      <td>Stable query class with strict latency</td>
      <td>Best measured single retriever</td>
    </tr>
  </tbody>
</table>

<p>Hybrid search earns its extra cost only when the union improves candidate recall.</p>

<h2 id="3-fusion-can-only-reorder-candidates">3. Fusion can only reorder candidates</h2>

<p>Let the dense and sparse top-$n$ sets be $D_n(q)$ and $S_n(q)$. Their candidate union is:</p>

\[U_n(q)=D_n(q)\cup S_n(q).\]

<p>Before tuning fusion, I check whether the required evidence exists in $U_n(q)$. If it is missing, I inspect chunk boundaries, index text, filters, approximate search, thresholds, and branch limits.</p>

<p>Query rewriting can damage this stage. Replacing <code>E-4317</code> with “quorum problem” removes the strongest sparse feature, so I preserve the literal query for BM25.</p>

<p>Dense cosine scores and BM25 scores have no shared scale. Reciprocal Rank Fusion avoids adding them directly:</p>

\[\operatorname{RRF}(d)=
\sum_{r_d\in R(d)}\frac{1}{k+r_d}.\]

<p>RRF uses rank positions. A point near the top of both branches receives a contribution from each. The original <a href="https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf">RRF paper</a> used $k=60$. Qdrant uses zero-based ranks and defaults to $k=2$. Those settings produce different behavior, so experiment logs should include the engine, rank convention, $k$, branch limits, and weights.</p>

<p>Weighted RRF can prefer one branch, but the weights map to branch positions rather than raw scores. I leave them equal until a labeled evaluation set justifies changing them.</p>

<p>Qdrant also supports Distribution-Based Score Fusion. DBSF normalizes scores using the mean and standard deviation of each returned branch, then adds them. It is worth testing when score magnitude within a retriever is stable. RRF is a safer starting point when score scales move between queries.</p>

<p>The compact Qdrant query below shows the important part:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">eligible</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="n">Filter</span><span class="p">(</span>
    <span class="n">must</span><span class="o">=</span><span class="p">[</span><span class="n">tenant_condition</span><span class="p">,</span> <span class="n">acl_condition</span><span class="p">,</span> <span class="n">current_version_condition</span><span class="p">]</span>
<span class="p">)</span>

<span class="n">points</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">query_points</span><span class="p">(</span>
    <span class="n">collection_name</span><span class="o">=</span><span class="s">"runbook_chunks"</span><span class="p">,</span>
    <span class="n">prefetch</span><span class="o">=</span><span class="p">[</span>
        <span class="n">models</span><span class="p">.</span><span class="n">Prefetch</span><span class="p">(</span>
            <span class="n">query</span><span class="o">=</span><span class="n">dense_query</span><span class="p">,</span> <span class="n">using</span><span class="o">=</span><span class="s">"dense"</span><span class="p">,</span>
            <span class="nb">filter</span><span class="o">=</span><span class="n">eligible</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">candidate_limit</span><span class="p">,</span>
        <span class="p">),</span>
        <span class="n">models</span><span class="p">.</span><span class="n">Prefetch</span><span class="p">(</span>
            <span class="n">query</span><span class="o">=</span><span class="n">sparse_query</span><span class="p">,</span> <span class="n">using</span><span class="o">=</span><span class="s">"bm25"</span><span class="p">,</span>
            <span class="nb">filter</span><span class="o">=</span><span class="n">eligible</span><span class="p">,</span> <span class="n">limit</span><span class="o">=</span><span class="n">candidate_limit</span><span class="p">,</span>
        <span class="p">),</span>
    <span class="p">],</span>
    <span class="n">query</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">RrfQuery</span><span class="p">(</span><span class="n">rrf</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">Rrf</span><span class="p">()),</span>
    <span class="n">query_filter</span><span class="o">=</span><span class="n">eligible</span><span class="p">,</span>
    <span class="n">limit</span><span class="o">=</span><span class="n">rerank_pool</span><span class="p">,</span>
    <span class="n">with_payload</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="p">).</span><span class="n">points</span>
</code></pre></div></div>

<p><code>candidate_limit</code> is a recall ceiling. A point outside both prefetch lists is invisible to fusion and reranking. I plot candidate-union recall against latency instead of copying a default such as 30.</p>

<p>The same eligibility filter belongs on every branch. Filtering a global top-k afterward is unsafe and wastes candidate capacity on points the user cannot access. Mandatory tenant and ACL filters should come from authenticated server-side identity. Frequently filtered payload fields also need indexes.</p>

<p>Authorization checks continue during context expansion. An authorized anchor does not make an unauthorized neighbour safe to attach.</p>

<h2 id="4-reranking-and-context-construction-solve-different-problems">4. Reranking and context construction solve different problems</h2>

<p>A cross-encoder reads the query and candidate together. This allows deeper token interaction than a dual encoder, but it is too expensive to run over the full corpus.</p>

<p>The reranker is limited by its input:</p>

<ul>
  <li>It cannot recover evidence missing from the candidate union.</li>
  <li>Long candidate text may be truncated at the useful passage.</li>
  <li>A model trained on web passages may fit code or internal terminology poorly.</li>
</ul>

<p>Model licenses matter too. A strong benchmark result does not grant commercial usage.</p>

<p>I test several rerank pool sizes. For each one I record union recall, final nDCG or evidence recall, latency, and reranked tokens. If evidence often appears around rank 35, a pool of 20 has already lost it. If recall saturates at 20, scoring 100 wastes time.</p>

<p>After reranking, the problem changes from ranking chunks to assembling evidence. The context builder may add a prerequisite, include the next warning, merge overlapping offsets, or replace several children with a parent section. Expansion should happen after reranking and must respect document, version, tenant, and ACL boundaries.</p>

<p>For the gateway query, the packed context needs three facts: what <code>E-4317</code> means, why the replacement is a non-voter, and why failover remains blocked without quorum. Returning only the heartbeat sentence produces an unsafe answer even though that sentence is relevant.</p>

<p>I send exact source text to the generator and keep retrieval prefixes in <code>index_text</code>. Each source block carries an ID and offsets. The generation prompt requires citations, forbids invented procedures, and asks the model to abstain when the sources are incomplete or contradictory. The application can at least verify that every cited ID exists in the supplied context.</p>

<p>Longer context is not automatically better. <a href="https://aclanthology.org/2024.tacl-1.9/">Lost in the Middle</a> showed that evidence position can affect model performance. I log packed order, unique source coverage, redundancy, and token count.</p>

<h2 id="5-evaluation-should-locate-the-failure">5. Evaluation should locate the failure</h2>

<p>An end-to-end answer score is useful for release decisions. Diagnosis needs a frozen corpus, source-level evidence labels, and traces from every stage.</p>

<p>The query set should include exact identifiers, paraphrases, mixed queries, rules with exceptions, procedures, stale versions, authorization boundaries, and unanswerable questions. A single average can hide a retriever that improves natural-language queries while breaking error codes.</p>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>Main check</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Ingestion</td>
      <td>Does a complete, citable evidence span exist?</td>
    </tr>
    <tr>
      <td>Eligibility</td>
      <td>Is gold evidence visible only to the correct principal?</td>
    </tr>
    <tr>
      <td>Dense and BM25 branches</td>
      <td>Evidence recall at candidate-k by query type</td>
    </tr>
    <tr>
      <td>Candidate union</td>
      <td>Recall ceiling for fusion and reranking</td>
    </tr>
    <tr>
      <td>Fusion</td>
      <td>nDCG, MRR, and branch contribution</td>
    </tr>
    <tr>
      <td>Reranking</td>
      <td>Quality change at final k, latency, and truncation</td>
    </tr>
    <tr>
      <td>Packed context</td>
      <td>Coverage, precision, redundancy, order, and tokens</td>
    </tr>
    <tr>
      <td>Answer</td>
      <td>Correctness, citation support, faithfulness, and abstention</td>
    </tr>
  </tbody>
</table>

<p>ANN recall and retrieval relevance are separate checks. I compare Qdrant’s approximate dense results with <code>exact=True</code> on a representative sample. If exact search misses the evidence, I inspect the embedding and index text. If exact search finds it but HNSW does not, I inspect <code>hnsw_ef</code> and index settings.</p>

<p>Recall@k measures candidate coverage. MRR is useful when the first correct hit dominates, while nDCG handles graded relevance across several positions. The measured $k$ should match the number of candidates consumed by the next stage.</p>

<p>Fusion weights, thresholds, candidate limits, and rerank pool size are tuned on one split and reported on another. Otherwise the evaluation mostly measures how well the system fit its test questions.</p>

<h3 id="a-short-failure-analysis-loop">A short failure-analysis loop</h3>

<table>
  <thead>
    <tr>
      <th>Symptom</th>
      <th>First place to inspect</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>No chunk contains the full rule or warning</td>
      <td>Parsing and boundaries</td>
    </tr>
    <tr>
      <td>Exact codes miss</td>
      <td>Sparse tokens, IDF, and BM25 rank</td>
    </tr>
    <tr>
      <td>Paraphrases miss</td>
      <td>Query and passage encoding, index text, and exact vector search</td>
    </tr>
    <tr>
      <td>Both branches find evidence but hybrid misses</td>
      <td>Union membership, limits, thresholds, and RRF settings</td>
    </tr>
    <tr>
      <td>Reranker does not help</td>
      <td>Candidate recall, truncation, and model fit</td>
    </tr>
    <tr>
      <td>Good anchors produce an incomplete answer</td>
      <td>Expansion, deduplication, packing order, and budget</td>
    </tr>
    <tr>
      <td>Stale or unauthorized text appears</td>
      <td>Eligibility and expansion filters</td>
    </tr>
  </tbody>
</table>

<p>I change one layer at a time. Adding BM25 can fix exact-code recall. Fusion can improve the mixed candidate set. Reranking can improve order inside that set. Context expansion can restore a prerequisite. None of them can repair a source span that ingestion never created.</p>

<h2 id="what-i-would-build-first">What I would build first</h2>

<p>My first production version would use structure-aware parsing, stable source offsets, heading-prefixed index text, and named dense and BM25 vectors on the same Qdrant point. Every retrieval branch would receive server-derived authorization and version filters.</p>

<p>I would start with equal-weight RRF, measure the candidate limit, rerank only the surviving union, then expand neighbours or parents under the same eligibility rules. The generator would receive exact source blocks with IDs and would cite or abstain.</p>

<p>The trace would record branch IDs and scores, union overlap, fusion settings, reranker scores, expanded offsets, packed order, citations, and per-stage latency. Without that trace, “retrieval failed” is only a description.</p>

<p>The useful design question is:</p>

<blockquote>
  <p>For this query distribution, which evidence does each stage preserve, where can it disappear, and what trace would prove it?</p>
</blockquote>

<p>That question has been more useful to me than debating dense, sparse, or hybrid retrieval in isolation.</p>

<h2 id="references">References</h2>

<ul>
  <li>Karpukhin et al., <a href="https://aclanthology.org/2020.emnlp-main.550/"><em>Dense Passage Retrieval for Open-Domain Question Answering</em></a>.</li>
  <li>Robertson and Zaragoza, <a href="https://www.staff.city.ac.uk/~sbrp622/papers/foundations_bm25_review.pdf"><em>The Probabilistic Relevance Framework: BM25 and Beyond</em></a>.</li>
  <li>Cormack et al., <a href="https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf"><em>Reciprocal Rank Fusion</em></a>.</li>
  <li>Nogueira and Cho, <a href="https://arxiv.org/abs/1901.04085"><em>Passage Re-ranking with BERT</em></a>.</li>
  <li>Liu et al., <a href="https://aclanthology.org/2024.tacl-1.9/"><em>Lost in the Middle</em></a>.</li>
  <li>Ru et al., <a href="https://proceedings.neurips.cc/paper_files/paper/2024/hash/27245589131d17368cccdfa990cbf16e-Abstract-Datasets_and_Benchmarks_Track.html"><em>RAGChecker</em></a>.</li>
  <li>Qdrant, <a href="https://qdrant.tech/documentation/search/hybrid-queries/"><em>Hybrid and Multi-Stage Queries</em></a>, <a href="https://qdrant.tech/documentation/search/filtering/"><em>Filtering</em></a>, and <a href="https://qdrant.tech/documentation/fastembed/fastembed-rerankers/"><em>Reranking with FastEmbed</em></a>.</li>
</ul>]]></content><author><name>Huijo</name></author><category term="Agents" /><summary type="html"><![CDATA[How to trace evidence through dense search, BM25, fusion, filtering, reranking, context construction, and evaluation.]]></summary></entry><entry><title type="html">What Claude Code taught me about compaction</title><link href="https://ccomkhj.github.io/CompactionIsStateTransfer/" rel="alternate" type="text/html" title="What Claude Code taught me about compaction" /><published>2026-07-18T00:00:00+00:00</published><updated>2026-07-18T00:00:00+00:00</updated><id>https://ccomkhj.github.io/CompactionIsStateTransfer</id><content type="html" xml:base="https://ccomkhj.github.io/CompactionIsStateTransfer/"><![CDATA[<p>During a technical discussion, someone asked me:</p>

<blockquote>
  <p>What is a good compact strategy for a long coding session?</p>
</blockquote>

<p>I answered quickly. Keep the objective, decisions, changed files, test state, unresolved problems, and next action. Do not spend summary tokens copying a file that the agent can read again.</p>

<p>Afterwards, I wondered how close that answer was to what an actual coding agent does. I had seen a reconstructed Claude Code repository, so I went through its compaction code.</p>

<p>The repository was widely called a source leak. More precisely, people reconstructed it from a sourcemap published in an npm package. I do not treat it as an official API or as documentation of the current release. Some paths depend on feature flags, and parts have already changed. For example, the snapshot disabled thinking in one fallback summarization path. The current <a href="https://code.claude.com/docs/en/context-window">Claude Code context documentation</a> says that v2.1.198 inherits the session’s extended thinking setting during compaction.</p>

<p>Still, the snapshot answered my question. Claude Code does not rely on one perfect summary. It removes context that is cheap to recover, summarizes the remaining conversation, and then reloads selected state.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>context after compaction =
    persistent instructions
  + summary of decisions and progress
  + state reloaded from source
  + sometimes a recent verbatim tail
</code></pre></div></div>

<p>The summary is only one part of the new context. That was the part I had missed.</p>

<hr />

<h2 id="what-the-source-snapshot-shows">What the source snapshot shows</h2>

<p>The traditional path looks like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Structured conversation
        ↓
Keep messages after the latest compact boundary
        ↓
Optionally clear old tool results
        ↓
Append a summarization request
        ↓
Run one model turn with tools blocked
        ↓
Keep the summary and reload selected context
</code></pre></div></div>

<p>Claude Code starts after the latest compact boundary. It does not summarize raw history that was already compacted. Otherwise each new compact would process both the old conversation and the summary made from it.</p>

<p>Text after <code class="language-plaintext highlighter-rouge">/compact</code> becomes a custom focus:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/compact focus on database decisions and exact test failures
</code></pre></div></div>

<p>This tells the summarizer which details matter most. Current documentation also supports persistent <a href="https://code.claude.com/docs/en/how-claude-code-works">compact instructions in <code class="language-plaintext highlighter-rouge">CLAUDE.md</code></a>.</p>

<h3 id="old-tool-results-go-first">Old tool results go first</h3>

<p>Before full summarization, Claude Code can perform micro-compaction. It looks for large, old results from file reads, shell commands, search tools, web tools, and file edits. Those results can be replaced with a short cleared-content marker.</p>

<p>Parts of this behavior are feature-gated in the snapshot, so installations may differ. The general order is also documented officially: Claude Code clears older tool output first and summarizes the conversation when it needs more room.</p>

<p>That order makes sense. A file can be read again. A test can be rerun. A decision made after reading the file and discussing the failed test may exist only in the conversation.</p>

<p>I classify context by how I would recover it:</p>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>Example</th>
      <th>What to preserve</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Reconstructible</td>
      <td>File contents, current diff, branch state</td>
      <td>A path or pointer</td>
    </tr>
    <tr>
      <td>Reproducible</td>
      <td>Test output, search results</td>
      <td>The command and important result</td>
    </tr>
    <tr>
      <td>Irreplaceable</td>
      <td>User corrections, decisions, rejected approaches</td>
      <td>The meaning itself</td>
    </tr>
  </tbody>
</table>

<p>The third row needs most of the summary budget. Copying the first two rows in full usually wastes it.</p>

<h3 id="the-summarizer-sees-structured-messages">The summarizer sees structured messages</h3>

<p>I had assumed that compacting meant rendering the session as one long transcript and asking for a shorter string. The snapshot normally keeps the Claude API message structure. User messages, assistant messages, <code class="language-plaintext highlighter-rouge">tool_use</code> blocks, and <code class="language-plaintext highlighter-rouge">tool_result</code> blocks remain distinct. Claude Code appends a new user message that asks for the summary.</p>

<p>This distinction helps. A failed test returned by a tool is evidence. A user message that changes the requirement is an instruction. An assistant suggestion that the user rejected should not become the final decision.</p>

<p>The preferred implementation forks the existing conversation so it can reuse the prompt cache. If that path fails, Claude Code sends the active messages explicitly. The summarizer gets one turn and cannot call tools. In the inspected snapshot, compact output is capped at 20,000 tokens.</p>

<p>The compact prompt asks for a structured handoff. It covers the user’s intent, technical concepts, files, errors, corrections, pending tasks, current work, and the next step. It also asks for filenames, signatures, relevant code, and direct quotes when they constrain what should happen next.</p>

<p>The model temporarily produces an analysis and a summary. Claude Code discards the analysis and keeps the summary. It then wraps that summary in a synthetic user message explaining that the session is continuing from an earlier conversation.</p>

<h3 id="claude-code-reloads-state-afterwards">Claude Code reloads state afterwards</h3>

<p>The next context contains more than that synthetic summary. Claude Code can restore recent files, project instructions, memory, invoked skills, attachments, and context produced by hooks. The current documentation confirms that the system prompt remains loaded and that project-root <code class="language-plaintext highlighter-rouge">CLAUDE.md</code> and auto memory are re-injected.</p>

<p>Suppose Claude turns 150,000 tokens of conversation into a 6,000-token summary. The next request may still contain 40,000 or 60,000 tokens after the system prompt, project instructions, skills, and restored files return. These numbers are only illustrative. The point is that post-compact context is not measured by summary length alone.</p>

<p>A summary may record that <code class="language-plaintext highlighter-rouge">src/auth/session.ts</code> changed and explain why. The file system provides its exact current contents. It may record that one test failed on a race-condition assertion. The next turn can rerun the test if it needs the complete trace.</p>

<p>The full transcript also remains on disk as JSONL under <code class="language-plaintext highlighter-rouge">~/.claude/projects/</code>. It is available for inspection and recovery without occupying the active context on every request. The <a href="https://code.claude.com/docs/en/sessions">session documentation</a> warns that this file format is internal and may change.</p>

<h3 id="an-experimental-memory-path">An experimental memory path</h3>

<p>The snapshot contains another feature-gated path. If <code class="language-plaintext highlighter-rouge">/compact</code> has no custom instruction, Claude Code may reuse a structured session-memory file instead of requesting a fresh summary at compact time.</p>

<p>That memory tracks current state, task details, files, errors, decisions, results, and a worklog. The snapshot aims to keep it around 12,000 tokens, then combines it with a recent verbatim tail. This spreads summarization across the session rather than making every retention decision when the context is already full.</p>

<p>In this version, adding custom compact instructions skips that initial shortcut and favors a fresh focused summary. The exact branch may change, but it gives me another reason to add a focus when continuity matters.</p>

<hr />

<h2 id="how-i-compact-now">How I compact now</h2>

<p>Before reading the implementation, I concentrated on writing a better summary prompt. Now I pay more attention to what enters the session and where durable information lives.</p>

<p>Large research reads can happen in a separate agent context. A command that produces 20,000 lines can save the complete output to a file and return only the error or statistic I care about. Test commands, repository conventions, and architecture constraints belong in project instructions, skills, or hooks.</p>

<p>For important decisions, I want four pieces of information:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Decision: what we chose
Reason: why we chose it
Evidence: the relevant file, command, test, issue, or URL
Status: accepted, provisional, or rejected
</code></pre></div></div>

<p>Failed approaches belong in the summary too. Otherwise the next context can repeat an attractive dead end. I keep user corrections, newly discovered constraints, and unresolved contradictions.</p>

<p>I also prefer to compact between phases. Once exploration has produced a decision and evidence, I can compact before implementation starts. Compacting in the middle of debugging is riskier because observations are still changing and a recent tool result may contain a clue I have not understood yet.</p>

<p>After compaction, I read the generated summary once. I check that modified files are named, rejected ideas are not presented as decisions, removed evidence still has a path or reproduction command, and the next action is concrete.</p>

<h2 id="the-instruction-i-would-use">The instruction I would use</h2>

<p>For a long implementation task, I would run:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>/compact preserve:
- the exact objective and acceptance criteria
- user corrections and non-negotiable constraints
- decisions with their rationale
- modified files and important symbols
- reproduction commands and exact failing tests
- failed approaches and why they failed
- unresolved risks and the immediate next action

Prefer paths and commands over copied files or long logs that can
be recovered from the repository.
</code></pre></div></div>

<p>If this applies to every session, I would put a shorter version under <code class="language-plaintext highlighter-rouge"># Compact instructions</code> in <code class="language-plaintext highlighter-rouge">CLAUDE.md</code>.</p>

<p>Before reading the source, I thought a good compact strategy mostly meant asking for a better summary. My practice now starts earlier. I keep durable rules in files, avoid filling the main session with complete logs, and compact between phases when I can.</p>

<p>The summary still matters. I ask it to preserve why the work reached its current state and what should happen next. Files and commands can supply the exact details they already store better.</p>

<h2 id="references">References</h2>

<ul>
  <li>Anthropic, <a href="https://code.claude.com/docs/en/how-claude-code-works"><em>How Claude Code works</em></a>.</li>
  <li>Anthropic, <a href="https://code.claude.com/docs/en/context-window"><em>Explore the context window</em></a>.</li>
  <li>Anthropic, <a href="https://code.claude.com/docs/en/sessions"><em>Manage sessions</em></a>.</li>
  <li>Reconstructed source mirror, <a href="https://gitlawb.com/node/repos/z6MkgKkb/instructkr-claude-code?path=src%2Fservices%2Fcompact&amp;tab=code"><code class="language-plaintext highlighter-rouge">src/services/compact</code></a>. I used it as a historical snapshot, not as an official interface.</li>
</ul>]]></content><author><name>Huijo</name></author><category term="Agents" /><summary type="html"><![CDATA[Claude Code clears old tool output, summarizes the decisions that remain, and reloads working state. That suggests a practical compact strategy for long coding sessions.]]></summary></entry><entry><title type="html">Chunking in RAG: why 512 tokens is not a strategy</title><link href="https://ccomkhj.github.io/ChunkingInRAG/" rel="alternate" type="text/html" title="Chunking in RAG: why 512 tokens is not a strategy" /><published>2026-07-14T00:00:00+00:00</published><updated>2026-07-14T00:00:00+00:00</updated><id>https://ccomkhj.github.io/ChunkingInRAG</id><content type="html" xml:base="https://ccomkhj.github.io/ChunkingInRAG/"><![CDATA[<p>I used to set <code class="language-plaintext highlighter-rouge">chunk_size=512</code>, add some overlap, and move on. It felt like plumbing.</p>

<p>That worked often enough that I did not question it. Then I started looking more closely at retrieval failures. Some answers were split across chunks. Other chunks contained the answer but were too vague to match the query. Sometimes retrieval was fine and the LLM still got the wrong context.</p>

<p>“Chunk size” is too small an idea for all of those problems. I now separate chunking into four decisions:</p>

<ul>
  <li>B, boundary: which source spans can be retrieved?</li>
  <li>R, representation: what text or vectors does search use for each span?</li>
  <li>P, payload: what does the answer-writing LLM receive after retrieval?</li>
  <li>E, evaluation: does the test resemble the work the system will do?</li>
</ul>

<p>I write this as:</p>

<blockquote>
  <p>B × R × P, judged by E.</p>
</blockquote>

<p>It is a mnemonic, not an equation. An ambiguous representation can ruin a sensible boundary, and a bad final prompt can waste a perfectly good retrieval. That sounds obvious written down. It was less obvious when all four choices hid behind one <code class="language-plaintext highlighter-rouge">chunk_size</code> setting.</p>

<hr />

<h2 id="part-i-one-warranty-question-four-decisions">Part I: one warranty question, four decisions</h2>

<p>Consider these lines from a vehicle manual:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Traction-battery warranty

The traction battery is covered for eight years.
The coverage ends earlier when the vehicle reaches 160,000 km.
</code></pre></div></div>

<p>The user asks:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>When does the traction-battery warranty end?
</code></pre></div></div>

<p>The answer needs the subject, the eight-year limit, and the distance exception. Following those pieces through the pipeline makes B, R, P, and E much easier to distinguish.</p>

<h3 id="b-which-spans-exist">B: which spans exist?</h3>

<p>Suppose ingestion creates these chunks:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Chunk 1: Traction-battery warranty

Chunk 2: The traction battery is covered for eight years.

Chunk 3: The coverage ends earlier when the vehicle reaches 160,000 km.
</code></pre></div></div>

<p>Chunk 1 matches the subject but contains no answer. Chunk 2 has the time limit. Chunk 3 has the exception, though “the coverage” is unclear on its own.</p>

<p>A retriever may return all three. Still, the candidate set is awkward because none of its members states the complete rule. This failure happened before ranking. The splitter broke apart evidence that a reader needs together.</p>

<p>For this document, I would probably keep the rule and its exception in one chunk:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Chunk 1: Traction-battery warranty

Chunk 2:
The traction battery is covered for eight years.
The coverage ends earlier when the vehicle reaches 160,000 km.
</code></pre></div></div>

<p>I would not merge every adjacent sentence. I only want to preserve relationships that a reader actually needs. In manuals, that often means keeping table headers with rows, rules with exceptions, and procedure steps with their warnings or prerequisites. Headings and captions need similar care. Token limits still matter, but they are constraints on a boundary policy, not a definition of meaning.</p>

<p>The first check is simple: does the index contain a complete, citable span? If it does not, a better embedding model will not invent one. I need to change the parser or splitter and rebuild the affected indexes.</p>

<h3 id="r-what-does-search-see">R: what does search see?</h3>

<p>Now suppose I deliberately keep this short source span:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The coverage ends earlier when the vehicle reaches 160,000 km.
</code></pre></div></div>

<p>It is a good citation. It is also hard to retrieve by itself because “the coverage” could refer to almost anything. I can leave the source untouched and index a more descriptive version:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Document: 2026 Vehicle Warranty
Section: Traction-battery warranty
Context: This section defines the time and distance limits of battery coverage.

The coverage ends earlier when the vehicle reaches 160,000 km.
</code></pre></div></div>

<p>The title and heading make the sentence easier to match with a query about the traction battery. They do not need to appear in the citation as though they came from the manual.</p>

<p>At this point there are three different objects, which is where I kept getting confused:</p>

<table>
  <thead>
    <tr>
      <th>Object</th>
      <th>What it is for</th>
      <th>Example</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Source span</td>
      <td>Provenance and citation</td>
      <td>The exact sentence and source offsets</td>
    </tr>
    <tr>
      <td>Index representation</td>
      <td>Search</td>
      <td>Title, heading, context, and source span</td>
    </tr>
    <tr>
      <td>Answer payload</td>
      <td>Answer construction</td>
      <td>The retrieved span plus selected surrounding text</td>
    </tr>
  </tbody>
</table>

<p>The simplest representation is the chunk text with its existing title and heading path. <a href="https://www.anthropic.com/engineering/contextual-retrieval">Contextual Retrieval</a> goes further: an LLM writes a short, chunk-specific prefix before ingestion, and that prefix becomes part of the embedding and BM25 index. <a href="https://arxiv.org/abs/2409.04701">Late chunking</a> takes another route. It encodes a larger context first, then pools the token vectors that belong to the smaller span. A multivector index can also keep several local representations instead of forcing the whole chunk into one vector.</p>

<p>What matters is when and why the extra text is used. An LLM-generated prefix belongs to R because search indexes it. The label does not depend on whether an LLM wrote the text. And metadata sitting unused next to a vector will not improve similarity; search has to embed it, index it for sparse retrieval, filter on it, or otherwise include it in scoring.</p>

<p>So if a good source span exists but will not match the query, I keep its offsets fixed and experiment with its representation. That tells me whether R was the problem without quietly changing B at the same time.</p>

<h3 id="p-what-reaches-the-answer-writing-llm">P: what reaches the answer-writing LLM?</h3>

<p>Assume retrieval ranks this child span first:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The coverage ends earlier when the vehicle reaches 160,000 km.
</code></pre></div></div>

<p>Search has done its job. The final LLM still needs the preceding sentence to state the warranty correctly. At query time, the context builder can fetch the neighbour:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>The traction battery is covered for eight years.
The coverage ends earlier when the vehicle reaches 160,000 km.
</code></pre></div></div>

<p>That is P. Neither the stored boundary nor the similarity score changed. The system added context after it had chosen the anchor.</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>INGESTION
document
  → B: create source spans
  → R: create searchable representations
  → build indexes

QUERY TIME
question
  → retrieve and rerank using R
  → P: expand, merge, deduplicate, and pack context
  → answer-writing LLM
</code></pre></div></div>

<p>In practice, P might add a neighbouring span, replace several children with their parent section, or merge overlapping offsets. Procedures usually need their original order. Expansion must also stop at version and permission boundaries.</p>

<p>More context is not automatically safer. If every 100-token child expands into a 2,000-token parent, the prompt fills with the same noise that small retrieval units were meant to avoid. I prefer to rerank first, expand only the surviving anchors, remove overlap by source offset, and then pack the result into the token budget.</p>

<p>When a correct anchor ranks well but the answer is still incomplete, I leave the index alone at first. Replaying the same ranked anchors through another packing policy is a cleaner test.</p>

<h3 id="e-what-did-the-experiment-prove">E: what did the experiment prove?</h3>

<p>Suppose a fixed-window splitter and a structure-aware splitter both score 95% on a set of single-sentence fact questions. They are tied on that set. That is all I can safely say.</p>

<p>Production might contain tables, exceptions, comparisons, and multi-step procedures. The test has told me nothing about those cases. Structure-aware chunks may work better there. Fixed windows may be just as good and cheaper. Both may fail for unrelated reasons.</p>

<p>The papers do not give us one winner either. <a href="https://aclanthology.org/2024.emnlp-main.845/">Dense X Retrieval</a> reported strong proposition-level retrieval on its open-domain QA setup. <a href="https://aclanthology.org/2025.findings-naacl.114/">Is Semantic Chunking Worth the Computational Cost?</a> did not find consistent gains that paid for semantic chunking across its tasks. <a href="https://aclanthology.org/2026.acl-long.1372/">HiChunk</a> argues that evidence-sparse RAG benchmarks can hide differences between chunking policies.</p>

<p>Those findings apply to the policies and workloads that the papers tested. My own evaluation set needs local facts, rules with exceptions, tables, procedures, and comparisons. It also needs questions that draw on several evidence spans, plus section-level and document-level questions. Some questions should have no answer in the corpus so that abstention is testable.</p>

<p>Source annotations matter as much as reference answers. A correct answer can hide bad retrieval if the model already knows the fact. When an answer is wrong, source offsets help locate the failure in B, R, P, or generation.</p>

<p>While debugging, I use this table:</p>

<table>
  <thead>
    <tr>
      <th>Question</th>
      <th>Check</th>
      <th>First experiment</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Does a complete evidence span exist?</td>
      <td>B, boundary</td>
      <td>Change parsing or splitting, then rebuild dense and sparse indexes.</td>
    </tr>
    <tr>
      <td>Does its searchable form identify it?</td>
      <td>R, representation</td>
      <td>Keep source offsets fixed and change prefixes, embeddings, or scoring.</td>
    </tr>
    <tr>
      <td>Did the final LLM get usable context?</td>
      <td>P, payload</td>
      <td>Keep ranked anchors fixed and change expansion, deduplication, or packing.</td>
    </tr>
    <tr>
      <td>Did the test expose the real failure?</td>
      <td>E, evaluation</td>
      <td>Add realistic queries, source labels, system metrics, and cost logging.</td>
    </tr>
  </tbody>
</table>

<p>This sequence has saved me from treating every retrieval problem as a request for a different token count.</p>

<hr />

<h2 id="part-ii-one-equation-for-each-decision">Part II: one equation for each decision</h2>

<p>I keep one equation for each decision. Any more than that belongs in the papers.</p>

<h3 id="b-the-candidate-set">B: the candidate set</h3>

<p>Let a document be a token sequence $d=(x_1,\ldots,x_n)$. A boundary policy with configuration $\theta$ produces:</p>

\[\mathcal{C}_\theta(d)=\{c_i=x_{a_i:b_i}\}_{i=1}^{m}.\]

<p>Changing $\theta$ changes the candidates themselves. Candidate count, duplicated tokens, dense embeddings, BM25 length statistics, ingestion cost, and update cost change with it. A fair boundary experiment rebuilds every affected index.</p>

<p>For plain text, <a href="https://docs.langchain.com/oss/python/integrations/splitters/recursive_text_splitter">LangChain’s <code class="language-plaintext highlighter-rouge">RecursiveCharacterTextSplitter</code></a> is a reasonable baseline. <a href="https://docling-project.github.io/docling/concepts/chunking/">Docling’s <code class="language-plaintext highlighter-rouge">HybridChunker</code></a> is more interesting for PDFs and manuals because it uses document hierarchy, tokenizer limits, headings, captions, and repeated table headers. <a href="https://github.com/superlinear-ai/raglite">RAGLite</a> adds semantic boundaries over Markdown-oriented input. <a href="https://aclanthology.org/2026.acl-long.1372/">HiChunk</a> explores learned hierarchical boundaries and auto-merge retrieval.</p>

<p>Recursive or structure-aware splitting is my baseline. Semantic or learned splitting has to fix a measured boundary failure and earn back its extra ingestion cost.</p>

<h3 id="r-the-indexed-representation">R: the indexed representation</h3>

<p>Let $f$ be the embedding model. The representation can include context that is absent from the cited source span:</p>

\[s_R(q,c_i)=\cos\!\left(f(q),f([\text{title};\text{heading};\text{context};c_i])\right).\]

<p>The source span $c_i$ stays put while the searchable form changes. This is why I test boundaries and representations separately.</p>

<p><a href="https://www.anthropic.com/engineering/contextual-retrieval">Anthropic’s Contextual Retrieval</a> is a concrete recipe for adding chunk-specific prefixes to dense and BM25 indexes. <a href="https://github.com/superlinear-ai/raglite">RAGLite</a> implements segmented late chunking for supported local embedders and keeps multiple contextualized vectors for a chunk. Jina AI publishes a <a href="https://github.com/jina-ai/late-chunking">late-chunking reference implementation</a> for models that expose token-level outputs. If one pooled vector still loses too much local detail, <a href="https://aclanthology.org/2022.naacl-main.272/">ColBERTv2</a> shows the late-interaction end of the design space, at a higher storage cost.</p>

<p>With a pooled embedding API, deterministic title and heading prefixes come first; I do not pay an LLM to write context for every chunk until that baseline fails. For a local embedder that exposes token vectors, the clean comparison is ordinary embeddings versus late chunking over identical source spans.</p>

<h3 id="p-the-context-budget">P: the context budget</h3>

<p>Let $A$ be the reranked anchors and $p(c)$ the payload built around anchor $c$:</p>

\[S^*=\arg\max_{S\subseteq A}
\left[\sum_{c\in S}u(q,c)-\lambda\,\operatorname{Redundancy}(S)+\mu\,\operatorname{Coverage}(S)\right]
\quad\text{subject to}\quad
\sum_{c\in S}\operatorname{tokens}(p(c))\le B_{\mathrm{ctx}}.\]

<p>$B_{\mathrm{ctx}}$ is the generator’s context budget. The objective favors useful evidence and broader coverage while charging for repetition.</p>

<p><a href="https://developers.llamaindex.ai/python/framework/integrations/retrievers/auto_merging_retriever/">LlamaIndex’s <code class="language-plaintext highlighter-rouge">HierarchicalNodeParser</code> and <code class="language-plaintext highlighter-rouge">AutoMergingRetriever</code></a> implement the child-to-parent pattern directly. Leaf nodes are precise search anchors; related leaves can be replaced by a broader parent before synthesis. Its sentence-window and node-postprocessor patterns suit cases where a full parent is too much.</p>

<p>Whichever framework I use, I keep source offsets. They are far more dependable than string similarity for removing overlap, finding neighbours, preserving provenance, and enforcing version or permission boundaries.</p>

<h3 id="e-evidence-recall">E: evidence recall</h3>

<p>Let $E_q$ be the source positions required to answer query $q$, and $U_k(q)$ the unique source positions covered by the top $k$ results:</p>

\[\operatorname{EvidenceRecall}@k=\frac{|E_q\cap U_k(q)|}{|E_q|}.\]

<p>Evidence recall tells me whether retrieval covered the answer. It needs a companion metric for density or redundancy. Retrieving the whole document can achieve perfect recall while burying the answer in irrelevant text.</p>

<p><a href="https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/">Ragas</a> includes context precision, context recall, faithfulness, and other RAG metrics, though source-offset metrics may need custom code. <a href="https://docs.langchain.com/langsmith/evaluate-rag-tutorial">LangSmith</a> can store datasets, trace retrieval and generation, and compare experiments even when the application itself does not use LangChain. <a href="https://aclanthology.org/2026.acl-long.1372/">HiCBench</a> shows one way to build evidence-dense questions with hierarchical boundary annotations.</p>

<p>No single score settles a chunking experiment. I track evidence coverage and context precision, then inspect answer and citation correctness. I also log index size, ingestion cost, retrieved tokens, and latency. A quality gain that doubles the index or makes updates painfully slow may still be the wrong trade.</p>

<h3 id="my-starting-point">My starting point</h3>

<p>For a new documentation or manual corpus, my first version would look like this:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Source documents
      ↓
Structure-aware parsing
      ↓
Small child spans with stable source offsets
      ↓
Title + heading path in the indexed representation
      ↓
Dense + BM25 candidate retrieval
      ↓
Reranking
      ↓
Conditional neighbour or parent expansion
      ↓
Source-offset deduplication and budgeted packing
      ↓
Answer-writing LLM with explicit citations
</code></pre></div></div>

<p>I preserve tables, procedures, code units, headings, lists, and clauses before enforcing the token cap. I store the exact source span separately from its indexed representation. Metadata prefixes come first; generated context or late chunking only enter the experiment if that baseline has a representation problem.</p>

<p>At query time, I retrieve small anchors, rerank them, and expand the survivors. Expansion cannot cross a document version or permission boundary. Any change to boundaries or indexed text triggers a rebuild of both dense and sparse indexes.</p>

<p>I split evaluation results by query type and keep source-level evidence labels. An advanced chunker earns its place by improving the quality-cost trade-off on that workload, not by sounding more sophisticated.</p>

<p>When the system fails, I now work from left to right: B, R, P, then E. That usually tells me what to test next. It is slower than changing 512 to 768 and hoping, but at least I can explain what changed.</p>

<h2 id="references">References</h2>

<ul>
  <li>Lewis et al., <a href="https://arxiv.org/abs/2005.11401"><em>Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks</em></a>.</li>
  <li>Chen et al., <a href="https://aclanthology.org/2024.emnlp-main.845/"><em>Dense X Retrieval: What Retrieval Granularity Should We Use?</em></a>.</li>
  <li>Günther et al., <a href="https://arxiv.org/abs/2409.04701"><em>Late Chunking: Contextual Chunk Embeddings Using Long-Context Embedding Models</em></a>.</li>
  <li>Superlinear AI, <a href="https://github.com/superlinear-ai/raglite"><em>RAGLite</em></a>: an open-source RAG toolkit combining semantic boundaries, segmented late chunking, contextual headings, and multivector chunk retrieval.</li>
  <li>Pierse et al., <a href="https://weaviate.io/blog/late-chunking"><em>Late Chunking: Balancing Precision and Cost in Long Context Retrieval</em></a>.</li>
  <li>Anthropic, <a href="https://www.anthropic.com/engineering/contextual-retrieval"><em>Contextual Retrieval</em></a>.</li>
  <li>Sarthi et al., <a href="https://arxiv.org/abs/2401.18059"><em>RAPTOR: Recursive Abstractive Processing for Tree-Organized Retrieval</em></a>.</li>
  <li>Jiang et al., <a href="https://arxiv.org/abs/2406.15319"><em>LongRAG: Enhancing Retrieval-Augmented Generation with Long-context LLMs</em></a>.</li>
  <li>Liu et al., <a href="https://aclanthology.org/2024.tacl-1.9/"><em>Lost in the Middle: How Language Models Use Long Contexts</em></a>.</li>
  <li>Zhong et al., <a href="https://arxiv.org/abs/2406.00456"><em>Mix-of-Granularity: Optimize the Chunking Granularity for Retrieval-Augmented Generation</em></a>.</li>
  <li>Wang et al., <a href="https://aclanthology.org/2025.findings-acl.422/"><em>Document Segmentation Matters for Retrieval-Augmented Generation</em></a>.</li>
  <li>Lu et al., <a href="https://aclanthology.org/2026.acl-long.1372/"><em>HiChunk: Evaluating and Enhancing Retrieval Augmented Generation with Hierarchical Chunking</em></a>.</li>
  <li>de Moura Júnior et al., <a href="https://arxiv.org/abs/2603.25333"><em>Adaptive Chunking: Optimizing Chunking-Method Selection for RAG</em></a>.</li>
  <li>Santhanam et al., <a href="https://aclanthology.org/2022.naacl-main.272/"><em>ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction</em></a>.</li>
</ul>]]></content><author><name>Huijo</name></author><category term="Agents" /><summary type="html"><![CDATA[Chunking involves four separate decisions: which units exist, how search represents them, what context the LLM receives, and how the whole policy is tested.]]></summary></entry><entry><title type="html">KL Divergence, Practically — What I Got Wrong</title><link href="https://ccomkhj.github.io/KL/" rel="alternate" type="text/html" title="KL Divergence, Practically — What I Got Wrong" /><published>2026-01-02T00:00:00+00:00</published><updated>2026-01-02T00:00:00+00:00</updated><id>https://ccomkhj.github.io/KL</id><content type="html" xml:base="https://ccomkhj.github.io/KL/"><![CDATA[<p>I ran into a recurring “KL term” while reading <a href="http://proceedings.mlr.press/v37/rezende15.pdf">Rezende &amp; Mohamed (2015), Variational Inference with Normalizing Flows</a> and realized my mental model was slightly off. I used to treat “KL term” as basically “cross-entropy,” so I thought it was just another classification-like penalty. That belief is <strong>sometimes</strong> practically correct, but it is also <strong>misleading</strong> in the exact context where KL shows up most prominently: variational inference and normalizing flows. This note is my attempt to resolve that confusion.</p>

<p>(Primary motivation: Rezende &amp; Mohamed, 2015. :contentReference[oaicite:0]{index=0})</p>

<hr />

<h2 id="1-what-kl-divergence-actually-is">1. What KL Divergence Actually Is</h2>

<p>KL divergence measures how far one probability distribution is from another:</p>

\[D_{KL}(P\|Q) = \mathbb{E}_{x\sim P}\left[\log \frac{P(x)}{Q(x)}\right].\]

<p>A few properties I need to keep front-of-mind:</p>

<ul>
  <li><strong>Not symmetric</strong>: $D_{KL}(P|Q) \neq D_{KL}(Q|P)$.</li>
  <li><strong>Not a “distance” metric</strong> (no triangle inequality).</li>
  <li>It is an <strong>expectation under $P$</strong>, meaning the direction strongly affects behavior.</li>
</ul>

<p>The direction is not cosmetic: it changes optimization behavior (mode-covering vs mode-seeking).</p>

<hr />

<h2 id="2-where-my-confusion-came-from-kl-vs-cross-entropy">2. Where My Confusion Came From: KL vs Cross-Entropy</h2>

<p>I previously thought:</p>

<blockquote>
  <p>“KL term is basically cross-entropy.”</p>
</blockquote>

<p>This is <strong>only conditionally true</strong>.</p>

<p>The key identity is:</p>

\[D_{KL}(P\|Q) = H(P,Q) - H(P),\]

<p>where</p>

<ul>
  <li>cross-entropy: $H(P,Q) = -\mathbb{E}_{x\sim P}[\log Q(x)]$</li>
  <li>entropy: $H(P) = -\mathbb{E}_{x\sim P}[\log P(x)]$</li>
</ul>

<p>So if <strong>$P$ is fixed</strong>, then $H(P)$ is constant, and minimizing $D_{KL}(P|Q)$ is equivalent to minimizing cross-entropy $H(P,Q)$.</p>

<p>That is why in standard supervised classification with fixed labels, it <em>feels</em> like “cross-entropy = KL”.</p>

<h3 id="the-correction-to-my-belief">The correction to my belief</h3>
<p>The “KL term” is not <em>intrinsically</em> cross-entropy. Cross-entropy is what KL reduces to <strong>when the target distribution $P$ is fixed and we only optimize $Q$</strong>.</p>

<p>That “fixed target” assumption is exactly what breaks in many important ML objectives.</p>

<hr />

<h2 id="3-the-vae--variational-inference-setting-kl-is-not-a-label-loss">3. The VAE / Variational Inference Setting: KL is Not a “Label Loss”</h2>

<p>Rezende &amp; Mohamed frame variational inference as maximizing a lower bound on $\log p(x)$ (the evidence), because the true marginal likelihood is typically intractable. They write the ELBO as:</p>

\[\log p_\theta(x) \ge \mathbb{E}_{q_\phi(z \mid x)}[\log p_\theta(x \mid z)] - D_{KL}(q_\phi(z \mid x)\|p(z)).\]

<p>This is the core equation in the paper. :contentReference[oaicite:1]{index=1}</p>

<p>Here the KL is:</p>

\[D_{KL}(q_\phi(z \mid x)\|p(z)).\]

<p>This is the conceptual point I had wrong: <strong>this KL is not comparing “predictions vs labels.”</strong><br />
It is comparing:</p>

<ul>
  <li>$q_\phi(z \mid x)$: an <em>approximate posterior</em> produced by the inference network (encoder)</li>
  <li>$p(z)$: the prior</li>
</ul>

<p>So the KL term is a <strong>regularizer / information constraint</strong>: it prevents the inference network from encoding arbitrary information in $z$ and pushes the posterior toward the prior.</p>

<h3 id="why-my-cross-entropy-intuition-fails-here">Why my cross-entropy intuition fails here</h3>
<p>In classification, $P$ is typically fixed (true labels).<br />
In the ELBO, <strong>$q_\phi(z \mid x)$ is learned</strong> and changes during training.</p>

<p>So even though the identity $D_{KL} = H(P,Q) - H(P)$ always holds mathematically, the “$H(P)$ is constant” trick is not something I can rely on in intuition. The moving part $q_\phi$ is exactly what I’m optimizing.</p>

<hr />

<h2 id="4-another-important-subtlety-kl-direction-matters">4. Another Important Subtlety: KL Direction Matters</h2>

<p>The ELBO uses:</p>

\[D_{KL}(q_\phi(z \mid x)\|p(z)).\]

<p>This is the “reverse” direction relative to what I mentally associate with supervised learning (which usually resembles $D_{KL}(P|Q)$ with fixed $P$).</p>

<p>This direction has practical consequences:</p>

<ul>
  <li>$D_{KL}(P|Q)$ (“forward KL”) strongly penalizes putting low probability mass where $P$ has mass → tends to be <strong>mode-covering</strong>.</li>
  <li>$D_{KL}(Q|P)$ (“reverse KL”) strongly penalizes putting mass where $P$ has low mass → tends to be <strong>mode-seeking</strong>.</li>
</ul>

<p>In variational inference, this asymmetry is part of why approximate posteriors can miss modes (the “mode-seeking” behavior is a known limitation). Normalizing flows in Rezende &amp; Mohamed are partly motivated by making $q_\phi(z \mid x)$ flexible enough to reduce that approximation gap. :contentReference[oaicite:2]{index=2}</p>

<hr />

<h2 id="5-why-normalizing-flows-make-the-kl-term-even-more-central">5. Why Normalizing Flows Make the KL Term Even More Central</h2>

<p>Rezende &amp; Mohamed propose constructing the approximate posterior by transforming a simple base distribution through a sequence of invertible mappings (“normalizing flow”):</p>

<ul>
  <li>start: $z_0 \sim q_0(z_0 \mid x)$ (often Gaussian)</li>
  <li>transform: $z_k = f_k(z_{k-1})$ for invertible $f_k$</li>
  <li>end: $z_K$ has a more complex distribution $q_K(z_K \mid x)$</li>
</ul>

<p>Because the transform is invertible, the density changes via change-of-variables:</p>

\[\log q_K(z_K \mid x) = \log q_0(z_0 \mid x) - \sum_{k=1}^{K}\log\left|\det \frac{\partial f_k}{\partial z_{k-1}}\right|.\]

<p>This flexibility makes the approximate posterior richer, which tightens the variational bound. This is one of the main contributions of the paper. :contentReference[oaicite:3]{index=3}</p>

<h3 id="my-updated-intuition">My updated intuition</h3>
<p>The KL term is not an “annoying extra penalty.” It is the <strong>explicit mismatch measure</strong> between what my inference model can represent ($q_\phi$) and what the generative model assumes ($p$).<br />
Normalizing flows are an upgrade to $q_\phi$ so that this mismatch can be reduced without sacrificing scalability.</p>

<hr />

<h2 id="6-when-the-target-distribution-is-not-fixed-happens-in-practice">6. When the “Target Distribution is Not Fixed” Happens in Practice</h2>

<p>This directly answers my earlier confusion: <em>when is $P$ not fixed?</em></p>

<p>In variational inference and flows, the “target” distribution in a KL can involve learnable distributions such as \(q_\phi(z \mid x)\) or teacher/student distributions that change over time. Concrete examples:</p>

<ul>
  <li><strong>Variational inference / VAE</strong>: \(q_\phi(z \mid x)\) is learned.</li>
  <li><strong>Normalizing flows in VI</strong>: the whole posterior family is learned through transformations.</li>
  <li><strong>Online distillation / EMA teachers</strong>: teacher distributions evolve during training.</li>
  <li><strong>RL</strong>: policies and visitation distributions shift.</li>
</ul>

<p>This is the world where “KL term = cross-entropy” stops being a reliable mental shortcut.</p>

<hr />

<h2 id="7-the-corrected-takeaway-i-want-to-keep">7. The Corrected Takeaway I Want to Keep</h2>

<p>My previous belief:</p>
<ul>
  <li>“KL is basically cross-entropy.”</li>
</ul>

<p>What I now believe:</p>
<ul>
  <li>KL is a <strong>distribution mismatch measure</strong>.</li>
  <li>Cross-entropy is one <strong>special case view</strong> of KL when the target distribution is fixed.</li>
  <li>In variational inference (including normalizing flows), the KL term is the <em>core regularizer</em> that shapes the posterior approximation, and its direction matters.</li>
</ul>

<p>If I keep that in mind, the ELBO decomposition in Rezende &amp; Mohamed stops looking like “reconstruction loss + random KL penalty” and starts looking like what it really is:</p>

<blockquote>
  <p>a likelihood-fitting term plus an explicit constraint on how much the posterior is allowed to deviate from the prior, with flows making that posterior expressive enough to be useful. :contentReference[oaicite:4]{index=4}</p>
</blockquote>]]></content><author><name>Huijo</name></author><category term="Machine Learning" /><summary type="html"><![CDATA[Why treating KL as 'just cross-entropy' breaks down inside variational inference, and what its asymmetry actually does to optimization (mode-covering vs mode-seeking).]]></summary></entry><entry><title type="html">VAE, Practically — What I Got Wrong</title><link href="https://ccomkhj.github.io/VAE/" rel="alternate" type="text/html" title="VAE, Practically — What I Got Wrong" /><published>2026-01-02T00:00:00+00:00</published><updated>2026-01-02T00:00:00+00:00</updated><id>https://ccomkhj.github.io/VAE</id><content type="html" xml:base="https://ccomkhj.github.io/VAE/"><![CDATA[<p>While reading <a href="http://proceedings.mlr.press/v37/rezende15.pdf">Variational Inference with Normalizing Flows, Rezende &amp; Mohamed (2015)</a>, I noticed my own mental model of VAEs was slightly off. I was carrying an “engineering” intuition that worked for autoencoders, but it created confusion the moment I tried to interpret VAEs as <strong>variational inference</strong> and relate them to forecasting problems like strawberry yield prediction.</p>

<p>This post is my corrected summary, written as a set of “mis-beliefs → corrections,” grounded in the variational inference framing emphasized in the paper.</p>

<hr />

<h2 id="1-my-first-mis-belief-decoder-is-the-posterior">1) My first mis-belief: “Decoder is the posterior”</h2>

<h3 id="what-i-believed">What I believed</h3>
<blockquote>
  <p>The decoder is the posterior.</p>
</blockquote>

<h3 id="whats-actually-true">What’s actually true</h3>
<p>In VAE terminology:</p>

<ul>
  <li>The <strong>posterior</strong> is $p_\theta(z \mid x)$.</li>
  <li>But it is usually intractable in deep generative models.</li>
  <li>So VAEs introduce a tractable approximation $q_\phi(z \mid x)$.</li>
</ul>

<p>That means:</p>

<ul>
  <li>
    <p><strong>Encoder</strong> $\approx$ approximate posterior (inference network):<br />
$q_\phi(z \mid x) \approx p_\theta(z \mid x)$</p>
  </li>
  <li>
    <p><strong>Decoder</strong> $\approx$ likelihood / generative model:<br />
$p_\theta(x \mid z)$</p>
  </li>
</ul>

<p>So the decoder is not “the posterior.” The decoder is <em>one of the ingredients</em> used to define the posterior (via Bayes’ rule), but the direction is the opposite:</p>

<ul>
  <li>Decoder: $z \rightarrow x$ (generate)</li>
  <li>Encoder: $x \rightarrow z$ (infer)</li>
</ul>

<p>This mapping is consistent with the paper’s emphasis: <strong>variational inference replaces posterior inference with optimization over a variational family</strong>, where $q_\phi(z\mid x)$ is the variational distribution and is learned to match $p_\theta(z\mid x)$.</p>

<hr />

<h2 id="2-my-second-mis-belief-encoder-compresses-decoder-expands">2) My second mis-belief: “Encoder compresses, decoder expands”</h2>

<h3 id="what-i-believed-1">What I believed</h3>
<blockquote>
  <p>Encoder compresses a pipeline; decoder expands a pipeline. Therefore, VAE is a fancy compress–decompress trick.</p>
</blockquote>

<h3 id="whats-actually-true-and-why-this-confusion-happens">What’s actually true (and why this confusion happens)</h3>
<p>This “compress / expand” idea comes from image VAEs, where:</p>

<ul>
  <li>$z$ is low-dimensional,</li>
  <li>$x$ is a high-dimensional image,</li>
  <li>and the decoder visually looks like an upsampling network.</li>
</ul>

<p>But for forecasting (e.g., predicting yield as a scalar), “expand” is not a meaningful concept:</p>

<ul>
  <li>The target $y$ is often 1-dimensional.</li>
  <li>The decoder does not necessarily “expand”; it often maps $(x, z)$ to a scalar distribution.</li>
</ul>

<p>So the right mental model is:</p>

<blockquote>
  <p>The encoder and decoder are not defined by dimensionality changes.<br />
They are defined by <em>probabilistic roles</em> in variational inference.</p>
</blockquote>

<ul>
  <li>Encoder: a <strong>recognition model</strong> for approximate posterior inference.</li>
  <li>Decoder: a <strong>generative model</strong> defining the likelihood.</li>
</ul>

<hr />

<h2 id="3-what-vae-inference-really-means-and-why-generative-inference-confused-me">3) What “VAE inference” really means (and why “generative inference” confused me)</h2>

<h3 id="my-confusion">My confusion</h3>
<p>The phrase “generative inference” didn’t make sense to me, because inference should mean “estimate hidden state,” not “generate outputs.”</p>

<h3 id="the-correction">The correction</h3>
<p>In probabilistic modeling, “inference” usually means:<br />
<strong>compute or approximate the posterior over latent variables</strong>.</p>

<p>In a VAE, the true posterior is:</p>

\[p_\theta(z \mid x) = \frac{p_\theta(x \mid z)\,p(z)}{p_\theta(x)}.\]

<p>But VAEs <em>do not compute this directly</em>. Instead, they learn:</p>

\[q_\phi(z \mid x) \approx p_\theta(z \mid x).\]

<p>So:</p>

<ul>
  <li><strong>VAE inference</strong> = run the encoder to obtain $q_\phi(z \mid x)$ (or its parameters).</li>
  <li><strong>Generation / sampling</strong> = sample $z \sim p(z)$ (or a conditional prior) and decode $x \sim p_\theta(x\mid z)$.</li>
</ul>

<p>These are different operations. Inference is “backward reasoning.” Generation is “forward simulation.”</p>

<p>The paper’s message is basically:</p>
<blockquote>
  <p>we make inference scalable by turning it into an optimization problem, and then amortizing it with a neural network.</p>
</blockquote>

<hr />

<h2 id="4-strawberry-yield-forecasting-how-i-map-state-vs-observation-properly">4) Strawberry yield forecasting: how I map “state vs observation” properly</h2>

<p>Now I apply this to a practical problem:</p>

<ul>
  <li>I observe temperature and fruit count.</li>
  <li>I want to forecast strawberry yield.</li>
</ul>

<h3 id="observed-variables-measurements">Observed variables (measurements)</h3>
<p>Let:</p>

<ul>
  <li>$T_{1:t}$ = temperature history up to time $t$</li>
  <li>$C_{1:t}$ = fruit_count history up to time $t$</li>
  <li>$Y_t$ = yield (today, or at harvest)</li>
</ul>

<p>I’ll group the observed covariates as $x$:</p>

\[x = (T_{1:t}, C_{1:t}, \text{engineered features}).\]

<h3 id="latent-state-unobserved-but-important">Latent state (unobserved but important)</h3>
<p>A forecasting model often benefits from a hidden state representing things I <em>don’t measure well</em>:</p>

<ul>
  <li>plant vigor</li>
  <li>stress (heat / water / disease)</li>
  <li>phenological stage</li>
  <li>cultivar or management effects</li>
  <li>microclimate differences</li>
</ul>

<p>Call that latent crop condition $Z_t$.</p>

<p>So, in Bayesian terms, what I actually want is:</p>

\[p_\theta(Z_t \mid x),\]

<p>i.e., “given observed data, what crop states are plausible?”</p>

<p>That is exactly a posterior.</p>

<hr />

<h2 id="5-what-the-decoder-becomes-in-this-forecasting-case">5) What the decoder becomes in this forecasting case</h2>

<p>In a forecasting-oriented VAE (more precisely, a conditional VAE), the decoder is a probabilistic forecast model:</p>

\[p_\theta(Y \mid x, z).\]

<p>This is the “forward” story:</p>

<ul>
  <li>if the crop state is $z$</li>
  <li>and covariates are $x$</li>
  <li>then yield $Y$ follows some distribution.</li>
</ul>

<p>For example, the decoder might output $(\mu_\theta(x,z), \sigma_\theta(x,z))$ for a Gaussian yield distribution.</p>

<p>So yes: the decoder is the forecasting component.<br />
But it is not “expanding” by default—it is <strong>defining a likelihood</strong>.</p>

<hr />

<h2 id="6-then-what-is-the-encoder-in-this-forecasting-case">6) Then what is the encoder in this forecasting case?</h2>

<p>During training, I have both covariates and yield, so I can infer what latent crop state best explains the outcome:</p>

\[q_\phi(z \mid x, y).\]

<p>Interpretation:</p>
<blockquote>
  <p>“Given sensors and realized yield, what hidden crop condition must have been present?”</p>
</blockquote>

<p>That’s posterior inference (approximate), and it is the core meaning of “VAE inference.”</p>

<p>This is aligned with Rezende &amp; Mohamed’s viewpoint:<br />
the inference model $q_\phi$ is trained to approximate the true posterior while keeping optimization tractable.</p>

<hr />

<h2 id="7-the-forecasting-time-detail-i-initially-missed-i-need-a-conditional-prior">7) The forecasting-time detail I initially missed: I need a conditional prior</h2>

<p>A key practical point:</p>

<p>At prediction time, I do not know $y$, so I cannot directly use $q_\phi(z \mid x, y)$.</p>

<p>To forecast, I need a distribution over latent states given only covariates:</p>

\[p_\psi(z \mid x),\]

<p>sometimes called a <strong>conditional prior</strong>.</p>

<p>Then forecasting is:</p>

<p>1) Sample latent crop states: $z^{(k)} \sim p_\psi(z \mid x)$<br />
2) Decode yields: $y^{(k)} \sim p_\theta(y \mid x, z^{(k)})$<br />
3) Aggregate samples to get a predictive distribution.</p>

<p>This is how the model produces:</p>

<ul>
  <li>a mean forecast</li>
  <li>prediction intervals</li>
  <li>multi-modal outcomes (if relevant)</li>
</ul>

<hr />

<h2 id="8-comparing-this-to-lightgbm-the-baseline-that-keeps-me-honest">8) Comparing this to LightGBM (the baseline that keeps me honest)</h2>

<p>LightGBM is typically:</p>

\[\hat{y} = f_{\text{LGBM}}(x),\]

<p>a direct mapping from engineered covariates to yield.</p>

<p>A VAE-style forecast is instead:</p>

\[z \sim p_\psi(z \mid x), \quad y \sim p_\theta(y \mid x, z).\]

<p>This difference matters when:</p>

<ul>
  <li>there are hidden factors not captured by $x$,</li>
  <li>the same $x$ maps to multiple plausible yields,</li>
  <li>or uncertainty calibration is important (operations planning, labor scheduling, contracts).</li>
</ul>

<p>If none of those are true, LGBM is usually the better engineering choice.</p>

<hr />

<h2 id="9-my-corrected-takeaways-the-short-list">9) My corrected takeaways (the short list)</h2>

<h3 id="what-i-used-to-think">What I used to think</h3>
<ul>
  <li>Encoder compresses</li>
  <li>Decoder expands</li>
  <li>Decoder is the posterior</li>
  <li>“Generative inference” means predicting</li>
</ul>

<h3 id="what-i-now-think">What I now think</h3>
<ul>
  <li>Encoder is the <strong>approximate posterior</strong>: $q_\phi(z \mid \cdot)$</li>
  <li>Decoder is the <strong>likelihood / generative model</strong>: $p_\theta(\cdot \mid z)$</li>
  <li>In forecasting, decoder is best seen as a <strong>probabilistic forecaster</strong>, not an “expander”</li>
  <li>“VAE inference” means <strong>latent state inference</strong> via the encoder</li>
  <li>For forecasting, I often need a <strong>conditional prior</strong> $p_\psi(z \mid x)$ to sample latent states when $y$ is unknown</li>
</ul>

<hr />

<h2 id="10-one-sentence-that-finally-fixed-my-mental-model">10) One sentence that finally fixed my mental model</h2>

<p>A VAE-style forecaster for strawberry yield is:</p>

<blockquote>
  <p>a model that learns a latent crop-condition variable $z$ and uses variational inference (via an encoder) to approximate the posterior over $z$, while a decoder defines a probabilistic forecast $p_\theta(y \mid x, z)$ that can be sampled for uncertainty-aware predictions.</p>
</blockquote>

<p>This framing made the Rezende &amp; Mohamed (2015) motivation click:<br />
<strong>variational inference is the mathematical reason VAEs exist, and “encoder/decoder” are just neural parameterizations of the variational posterior and the likelihood.</strong></p>]]></content><author><name>Huijo</name></author><category term="Machine Learning" /><summary type="html"><![CDATA[A corrected mental model for VAEs, written as 'mis-belief → correction' notes after re-reading Rezende & Mohamed (2015) and reframing the encoder/decoder as variational inference rather than autoencoding.]]></summary></entry></feed>