<?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://snehal.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://snehal.dev/" rel="alternate" type="text/html" /><updated>2026-04-17T23:54:38+00:00</updated><id>https://snehal.dev/feed.xml</id><title type="html">Snehal Patel</title><subtitle>I love to build things ✨</subtitle><entry><title type="html">27,000 Tokens Before Hello: The Agent Harness Tax</title><link href="https://snehal.dev/agent-harness/" rel="alternate" type="text/html" title="27,000 Tokens Before Hello: The Agent Harness Tax" /><published>2026-04-14T00:00:00+00:00</published><updated>2026-04-14T00:00:00+00:00</updated><id>https://snehal.dev/agent-harness</id><content type="html" xml:base="https://snehal.dev/agent-harness/"><![CDATA[<p align="center">
  <img src="/images/agent_harness_hero-compressed.jpg" width="800" alt="agent_harness_hero" />
</p>

<p>If you have used Claude Code or Codex for more than a few sessions, you have probably noticed that first-turn pause. That brief hang before the agent does anything useful. I finally looked into why. Someone set up a proxy to count the tokens flowing between a coding agent and the API on each request. The number that came back was hard to believe. Before the agent had written a single line of code, before it had read a single file, it had already consumed 27,000 input tokens. On every single request.</p>

<p>The breakdown tells the story. System prompt: a few thousand tokens. Tool definitions: another several thousand. Memory files loaded at startup: more thousands. By the time the model saw the user’s actual task, over a quarter of a typical context budget was already spent on the agent talking to itself.</p>

<p>This is the central tension of building with LLMs today, and it has a name: the <strong>agent harness</strong>. The LangChain team put it cleanly: <em>“If you’re not the model, you’re the harness.”</em> Everything that wraps around the LLM to make it behave like an agent is the harness: the orchestration loop, the tools, the memory system, the context management, the state persistence, the error handling, the guardrails, the prompt assembly, the output parsing, the verification loops, the subagent coordination. The model is a component. The harness is the product.</p>

<hr />

<h2 id="the-von-neumann-analogy">The Von Neumann Analogy</h2>

<p>The best mental model for understanding a harness comes from computer architecture. A researcher framed it this way: a raw LLM is like a CPU with no RAM, no disk, and no I/O. Technically powerful. Practically useless on its own.</p>

<p>Map it out:</p>

<table>
  <thead>
    <tr>
      <th>Computer Component</th>
      <th>Agent Equivalent</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>CPU</td>
      <td>The LLM</td>
    </tr>
    <tr>
      <td>RAM (fast, limited, expensive)</td>
      <td>Context window</td>
    </tr>
    <tr>
      <td>Disk (slow, large, cheap)</td>
      <td>External files, databases, vector stores</td>
    </tr>
    <tr>
      <td>Device drivers</td>
      <td>Tool definitions and implementations</td>
    </tr>
    <tr>
      <td>Operating system</td>
      <td>The agent harness</td>
    </tr>
  </tbody>
</table>

<p>Every time you build an agent, you are reinventing the operating system. The orchestration loop is your scheduler. Context management is your memory manager. Tool execution is your I/O subsystem. Safety checks are your kernel-level permissions. These are not new problems. They are problems that OS designers solved in the 1970s and 1980s. The vocabulary has changed. The constraints are identical.</p>

<p>This is not a metaphor to be cute. It is a useful frame because it tells you what the hard problems actually are. Memory management in agents (what goes into the context window, when, and in what form) is as non-trivial as memory management in operating systems. Most of the bugs you will hit in production agent systems are not model bugs. They are OS bugs.</p>

<p align="center">
  <img src="/images/agent_harness_von_neumann-compressed.jpg" width="800" alt="agent_harness_von_neumann" />
</p>

<hr />

<h2 id="the-harness-tax">The Harness Tax</h2>

<p>Every agent carries what I call the <strong>harness tax</strong>: the tokens the agent spends on itself before spending a single token on the user’s task. Someone benchmarked this directly across three coding agents running the same trivial task (write a Fibonacci script):</p>

<table>
  <thead>
    <tr>
      <th>Agent</th>
      <th>Harness overhead per request</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Pi / OpenClaw</td>
      <td>~2,600 input tokens</td>
    </tr>
    <tr>
      <td>Codex</td>
      <td>~15,000 input tokens</td>
    </tr>
    <tr>
      <td>Claude Code</td>
      <td>~27,000 input tokens</td>
    </tr>
  </tbody>
</table>

<p>A 40-turn session at Claude Code’s rate burns roughly 1.12 million input tokens. About half of that is harness overhead. That is real money, and it is also a real engineering constraint. Claude Code ships around 24 tool definitions on every single request. In any given session, most of those tools never get called. But each tool schema takes up tokens, and those tokens sit in the context window the whole time.</p>

<p>This is where it stops being just a cost problem and becomes a quality problem. Token position matters. Research from Stanford’s “Lost in the Middle” paper showed model performance degrades 30%+ when key content lands in the middle of the context window rather than at the start or end. Every token of harness overhead is a token of displacement. When your agent’s context fills up with tool schemas for tools it will never use, those tokens are physically pushing your user’s code and intent toward that degraded middle zone.</p>

<p align="center">
  <img src="/images/agent_harness_tax-compressed.jpg" width="800" alt="agent_harness_tax" />
</p>

<p>I call it <strong>context rot</strong>. The harness bloats the context, the context degrades model attention, model attention degradation hurts the quality of the actual task. A thick harness can make your agent dumber, not smarter.</p>

<p>The design tension is real: more harness capabilities mean more tokens, and more tokens mean worse attention distribution. Every tool you add to your agent is a tradeoff between what it can do and how well it does everything else.</p>

<hr />

<h2 id="anatomy-of-a-production-harness">Anatomy of a Production Harness</h2>

<p>Different practitioners slice the harness differently. One breakdown lists 12 components. An open-source mini coding agent uses 6. The territory is the same either way. Here is how I group it.</p>

<h3 id="the-loop-orchestration-and-state">The Loop: Orchestration and State</h3>

<p>Every agent harness has an <strong>orchestration loop</strong> at its core. You may know it as ReAct (Reasoning plus Acting) or the TAO cycle (Thought, Action, Observation). The structure is always the same:</p>

<ol>
  <li>Assemble the prompt (system instructions + tools + memory + conversation history + user message)</li>
  <li>Call the LLM</li>
  <li>Parse the output: is it a final answer, a tool call, or a handoff to another agent?</li>
  <li>If tool call: validate inputs, check permissions, execute in sandbox, capture output</li>
  <li>Package tool results as new messages</li>
  <li>Update context, trigger compaction if needed</li>
  <li>Loop back to step 1</li>
</ol>

<p>Anthropic’s Claude Agent SDK describes their implementation as a “dumb loop where all intelligence lives in the model.” That is the right way to think about it. The loop is plumbing. It should be boring.</p>

<p><strong>State management</strong> is the part that gets complicated. How does an agent resume after a context window fills? Claude Code uses git commits as checkpoints. Long-running tasks use progress files as scratchpads, and the filesystem provides continuity across context windows. LangGraph uses typed dictionaries with explicit checkpoint snapshots.</p>

<p><strong>Error handling</strong> in agents is sneaky. A 10-step task with 99% per-step success has only 90.4% end-to-end success. There are four failure types worth designing for: transient failures (retry), LLM-recoverable failures (the model can self-correct), user-fixable failures (stop and ask), and unexpected failures (fail loudly and log everything). Most agent failures in production are compound failures where the root cause is two steps back.</p>

<h3 id="tools-and-prompt-construction">Tools and Prompt Construction</h3>

<p><strong>Tools</strong> are how the agent reaches outside the context window. Each tool is a schema (name, description, input parameters, output format) injected into the prompt on every request. The harness handles registration, input validation, permission checking, sandboxed execution, and result capture.</p>

<p>Claude Code gates around 40 discrete tool capabilities independently. The permission model is three-staged: trust establishment (who is asking and in what context), permission check (is this action allowed for this trust level), and explicit user confirmation for high-impact operations.</p>

<p><strong>Prompt construction</strong> is more nuanced than most people realize. The hierarchical assembly order matters: system prompt, tool definitions, memory files, conversation history, current user message. Changing that order changes model behavior.</p>

<p>The key optimization here is splitting your prompt into a <strong>stable prefix</strong> and a <strong>changing suffix</strong>. The stable prefix (system instructions, tool definitions, workspace summary) rarely changes within a session. The changing suffix (recent conversation, memory notes, current task) updates every turn. Why does this split matter? Because of <strong>prompt caching</strong>.</p>

<p>Anthropic’s API lets you mark cache breakpoints on content blocks using <code class="language-plaintext highlighter-rouge">cache_control</code>. Everything from the start of the request up to that breakpoint gets cached server-side with a 5-minute TTL. Cache reads cost roughly 10% of normal input token pricing. Cache writes (the first time) cost 25% more. After that first call, every subsequent turn within the TTL window gets the prefix at a 90% discount.</p>

<p>Here is how an open-source coding agent (<code class="language-plaintext highlighter-rouge">mini-coding-agent</code>) structures this in practice:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">build_prefix</span><span class="p">(</span><span class="n">workspace_context</span><span class="p">):</span>
    <span class="s">"""Stable portion of the prompt. Cached across turns."""</span>
    <span class="k">return</span> <span class="p">{</span>
        <span class="s">"role"</span><span class="p">:</span> <span class="s">"system"</span><span class="p">,</span>
        <span class="s">"content"</span><span class="p">:</span> <span class="p">[</span>
            <span class="p">{</span><span class="s">"type"</span><span class="p">:</span> <span class="s">"text"</span><span class="p">,</span> <span class="s">"text"</span><span class="p">:</span> <span class="n">AGENT_INSTRUCTIONS</span><span class="p">},</span>
            <span class="p">{</span><span class="s">"type"</span><span class="p">:</span> <span class="s">"text"</span><span class="p">,</span> <span class="s">"text"</span><span class="p">:</span> <span class="n">workspace_context</span><span class="p">.</span><span class="n">summary</span><span class="p">()},</span>
            <span class="p">{</span><span class="s">"type"</span><span class="p">:</span> <span class="s">"text"</span><span class="p">,</span> <span class="s">"text"</span><span class="p">:</span> <span class="n">tool_descriptions</span><span class="p">(),</span>
             <span class="s">"cache_control"</span><span class="p">:</span> <span class="p">{</span><span class="s">"type"</span><span class="p">:</span> <span class="s">"ephemeral"</span><span class="p">}}</span>  <span class="c1"># &lt;-- cache breakpoint
</span>        <span class="p">]</span>
    <span class="p">}</span>

<span class="k">def</span> <span class="nf">build_prompt</span><span class="p">(</span><span class="n">prefix</span><span class="p">,</span> <span class="n">memory_notes</span><span class="p">,</span> <span class="n">history</span><span class="p">,</span> <span class="n">user_message</span><span class="p">):</span>
    <span class="s">"""Combines cached prefix with per-turn changing suffix."""</span>
    <span class="k">return</span> <span class="p">[</span><span class="n">prefix</span><span class="p">]</span> <span class="o">+</span> <span class="n">memory_notes</span> <span class="o">+</span> <span class="n">history</span> <span class="o">+</span> <span class="p">[</span>
        <span class="p">{</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"user"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="n">user_message</span><span class="p">}</span>
    <span class="p">]</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">build_prefix</code> function assembles the agent instructions, a workspace summary (git branch, repo root, project docs), and tool descriptions into a single block with a cache breakpoint at the end. Everything after that breakpoint (memory, transcript, user message) is the changing suffix that gets processed at full price each turn.</p>

<p>Claude Code follows the same principle but at a much larger scale. Its stable prefix runs about 27,000 tokens (system prompt + 24-40 tool schemas + memory index files). The math on caching that prefix is significant:</p>

<blockquote>
  <p><strong>Without caching</strong>: 40-turn session x 27k prefix = 1,080,000 prefix tokens at full price</p>

  <p><strong>With caching</strong>: first turn pays 1.25x write cost on 27k tokens, remaining 39 turns pay 0.1x read cost</p>

  <p><strong>Result</strong>: roughly <strong>87% savings</strong> on the stable prefix portion alone</p>
</blockquote>

<p>The 5-minute TTL resets on each cache hit, so as long as the agent makes at least one API call every few minutes (which is typical in interactive coding sessions), the cache stays warm. The tradeoff is architectural: anything you put in the prefix must be truly stable. If you change even one token in the cached block, the entire cache invalidates and you pay the full write cost again. This is one reason Claude Code loads memory files in a specific order and keeps tool definitions static across turns.</p>

<p><strong>Output parsing</strong> is where most brittle failures live. With native tool calling (structured <code class="language-plaintext highlighter-rouge">tool_calls</code> objects), you bypass a lot of free-text parsing pain. Before tool use was standardized, agents had to parse structured output out of prose, and models would use slightly different formats, omit fields, or add extra text. The lesson from Claude Code’s build of the <code class="language-plaintext highlighter-rouge">AskUserQuestion</code> tool: even a well-designed tool fails if the model does not understand when to call it. Tool design is as much about the model’s training distribution as it is about the schema.</p>

<h3 id="memory-and-context">Memory and Context</h3>

<p>Memory in agents has two layers.</p>

<p><strong>Short-term memory</strong> is the conversation history in the context window. It is fast and immediately available. It is also expensive and finite.</p>

<p><strong>Long-term memory</strong> is everything that persists beyond a single context window. This includes project-level knowledge files (CLAUDE.md, AGENTS.md), session transcripts, task histories, user preferences. Claude Code uses a three-tier approach: a lightweight index file (150 characters max per entry, always loaded), detailed topic files loaded on demand, and raw transcripts used only for search.</p>

<p>The LangChain team makes a point worth sitting with: <em>memory is not a module you plug into an agent harness</em>. The team at Letta put it this way: “Asking to plug memory into an agent harness is like asking to plug driving into a car.” The harness controls what gets loaded into context, what survives compaction, how the filesystem is exposed, and how metadata is presented. Memory and harness are deeply coupled. You cannot really separate them.</p>

<p>The implication is uncomfortable: if your memory lives inside a closed harness (OpenAI’s Responses API with server-side compaction, Anthropic’s Managed Agents), you do not own your agent’s accumulated knowledge. Switching models means losing threads. This is not an accident. Model providers have strong incentives to lock users in via memory, because model switching costs are near zero today, but agent switching costs (accumulated state, learned preferences, project context) are high.</p>

<p><strong>Context management</strong> is the harness’s most active responsibility during a long task:</p>

<ul>
  <li><strong>Compaction</strong>: when context fills, summarize old turns into a dense representation and drop the originals</li>
  <li><strong>Observation masking</strong>: verbose tool outputs (like a full file listing) get hidden from the active window after their immediate turn</li>
  <li><strong>Just-in-time retrieval</strong>: instead of loading all memory files at startup, load only what is relevant to the current task</li>
  <li><strong>Subagent delegation</strong>: offload context-heavy subtasks to subagents that run in their own context windows and return only 1,000-2,000 token summaries</li>
</ul>

<p>The right approach is to keep recent events rich while compressing older events aggressively. Recent context is almost always more relevant. Old context can usually be summarized or dropped.</p>

<h3 id="guardrails-and-verification">Guardrails and Verification</h3>

<p><strong>Verification loops</strong> are the highest-ROI investment in a production harness. The Claude Code team measured a 2-3x quality improvement from adding verification. There are three levels:</p>

<ol>
  <li>Rules-based: run tests, linters, type checkers after each change. Fast and deterministic.</li>
  <li>Visual: take a screenshot and confirm the UI looks correct. Works for frontend work where tests do not tell the full story.</li>
  <li>LLM-as-judge: have a separate model review the output for correctness, completeness, or policy compliance. Expensive but catches things rules miss.</li>
</ol>

<p><strong>Subagent orchestration</strong> lets a harness parallelize work across multiple agent instances. Claude Code supports three patterns:</p>

<ul>
  <li><strong>Fork</strong>: a byte-identical copy of the current agent context, for truly parallel independent tasks</li>
  <li><strong>Teammate</strong>: a separate agent in its own terminal pane with a file-based mailbox for coordination</li>
  <li><strong>Worktree</strong>: an agent with its own git worktree and isolated branch, for tasks that need to diverge from the main codebase without interference</li>
</ul>

<p align="center">
  <img src="/images/agent_harness_anatomy-compressed.jpg" width="800" alt="agent_harness_anatomy" />
</p>

<hr />

<h2 id="thin-harness-fat-skills">Thin Harness, Fat Skills</h2>

<p>Here is a principle that surprised me when I first encountered it: giving your agent fewer tools often makes it better.</p>

<p>Pi (the reasoning engine behind OpenClaw) uses exactly four tools: read file, write file, edit file, and shell command. That is it. The reasoning is straightforward. Models trained on millions of GitHub repos and shell sessions already know how to use <code class="language-plaintext highlighter-rouge">grep</code>, <code class="language-plaintext highlighter-rouge">find</code>, <code class="language-plaintext highlighter-rouge">ls</code>, <code class="language-plaintext highlighter-rouge">git log</code>, and a thousand other utilities. If you want to search files, you do not need a <code class="language-plaintext highlighter-rouge">search_files</code> tool. You can just run <code class="language-plaintext highlighter-rouge">grep -r</code>. The tool schema would consume tokens to define something the model can already do with a shell command. Thin harness, fat skills.</p>

<p>The evidence from model evolution makes this trend concrete. Community benchmarks tracked harness changes across three Claude model generations:</p>

<ul>
  <li><strong>Sonnet 4.5</strong>: the harness needed explicit context reset mechanisms. When the model sensed it was running out of context, the harness would trigger a structured wrap-up and restart.</li>
  <li><strong>Opus 4.5</strong>: the model handled context pressure better on its own. The explicit reset logic became unnecessary. It got removed.</li>
  <li><strong>Opus 4.6</strong>: the harness had been doing sprint decomposition, breaking large tasks into smaller subtasks with explicit planning steps. With Opus 4.6, removing that entirely improved performance. The model planned better without the scaffolding.</li>
</ul>

<p>Three model generations. Three layers of harness removed. What was load-bearing infrastructure in January was dead weight by March.</p>

<p>Manus (another coding agent) rebuilt their entire harness five times in six months. Each rebuild removed complexity. The team at Vercel reportedly removed 80% of the tools from v0 and got better results. Claude Code achieves 95% context reduction in some configurations via lazy loading: tools and context loaded only when needed, not upfront.</p>

<p>The pattern is consistent: as models improve, the complexity that compensates for their limitations becomes unnecessary. Harness code is temporary by nature. If you are writing harness logic that feels permanent, you are probably compensating for a model limitation that will be trained away within a generation or two.</p>

<p>There is a real tension here though. The “thin harness” principle applies to general-purpose capabilities. Task-specific harnesses tell a different story. One practitioner reported a +16 percentage point improvement by replacing a generic agent setup with a harness engineered specifically for financial data tasks. Generic harnesses give you acceptable performance across many tasks. Task-specific harnesses give you significantly better performance on the tasks that matter. Models are also “non-fungible in their harness”: dropping Codex into the Claude Code harness produces poor results because models are post-trained with specific harness assumptions baked in. The harness and the model form a unit.</p>

<hr />

<h2 id="the-meta-harness">The Meta-Harness</h2>

<p>The most interesting recent development in this space is work coming out of Stanford: automated harness optimization.</p>

<p>The setup is a five-step loop:</p>

<ol>
  <li><strong>Inspect</strong>: an LLM agent searches the filesystem for previous task results and execution traces</li>
  <li><strong>Diagnose</strong>: it reasons about what failure modes the traces reveal</li>
  <li><strong>Propose</strong>: it writes new harness code, typically a single-file Python change</li>
  <li><strong>Evaluate</strong>: run the modified harness on a task distribution, record the reward</li>
  <li><strong>Update</strong>: push results back to the filesystem, loop</li>
</ol>

<p>They used Claude Code as the proposer agent. After enough iterations of this loop, the automated system hit 76.4% pass rate on TerminalBench, surpassing hand-designed harnesses. The key finding: the system needs unrestricted access to all previous experiment history. Text optimization loops that only see reward scores and summaries underperform badly. The dependencies are long-horizon. You need the full trace to diagnose the root cause.</p>

<p>This connects to a concept researchers describe as the <strong>model-harness training loop</strong>: what the harness does today gets trained into the model tomorrow. Anthropic observes which harness primitives are most effective in production, and subsequent model versions are post-trained to be better at using them natively. Then the harness can shed that layer and push to the next frontier. The harness is, in a real sense, the model’s training data pipeline for the next generation.</p>

<p>The business dimension matters too. Agent framework developers have pointed out that model providers are strongly incentivized to build lock-in at the harness layer, specifically through memory. OpenAI generates encrypted compaction summaries that cannot be used outside their ecosystem. Anthropic’s Claude Managed Agents puts the entire agent runtime behind an API. Switching the underlying model is nearly free today. Switching the harness, with all the accumulated memory and learned preferences it holds, is not.</p>

<p>If you do not own your harness, you do not own your agent.</p>

<hr />

<h2 id="closing-thoughts">Closing Thoughts</h2>

<p>The thing I keep coming back to is how much engineering surface area lives outside the model. When an agent fails in production, the first instinct is to blame the LLM. Usually the problem is elsewhere: a tool schema that ambiguously describes when a tool should be called, a compaction strategy that lost a key piece of project context, a verification loop that was never implemented, an error type that was not classified and got silently swallowed.</p>

<p>A few principles that have crystallized for me after going deep on this:</p>

<p><strong>Measure your harness tax first.</strong> Before optimizing anything else, count the tokens your agent spends before the user’s task begins. You may be surprised. Many teams have never looked at this number.</p>

<p><strong>Build for removal.</strong> Every piece of harness logic should have a mental annotation: “this becomes unnecessary when the model can do X natively.” When that model generation ships, delete the code. Treat harness complexity as technical debt with a known expiration date.</p>

<p><strong>Invest in verification loops.</strong> The data is consistent: 2-3x quality improvement from good verification, for relatively low implementation cost. Rules-based verification (linters, tests, type checkers) is the easiest starting point and often sufficient for 80% of the quality gain.</p>

<p><strong>Be deliberate about memory architecture.</strong> Memory is the stickiest part of the stack. The decisions you make about where memory lives and in what format determine what vendor you are locked into and how much of your agent’s accumulated knowledge you actually own. Design this intentionally, not by accident.</p>

<p>The harness is where most of the interesting engineering in agentic AI lives right now. The model is improving fast. The harness is where the real systems thinking happens. And as the LangChain team put it: if you’re not the model, you’re the harness.</p>]]></content><author><name></name></author><category term="Agent Harness" /><category term="Agentic AI" /><category term="LLM Infrastructure" /><category term="Agent Architecture" /><category term="Context Engineering" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Gemma 4: Everything You Need to Know About Google’s Most Capable Open Model</title><link href="https://snehal.dev/gemma4/" rel="alternate" type="text/html" title="Gemma 4: Everything You Need to Know About Google’s Most Capable Open Model" /><published>2026-04-06T00:00:00+00:00</published><updated>2026-04-06T00:00:00+00:00</updated><id>https://snehal.dev/gemma4</id><content type="html" xml:base="https://snehal.dev/gemma4/"><![CDATA[<p align="center">
  <img src="/images/gemma4.jpg" width="800" alt="embeddings_are_beautiful" />
</p>

<blockquote>
  <p><strong><em>Running a frontier-tier model locally now takes only two commands—a significant shift from the complex setups required just a year ago.</em></strong></p>
</blockquote>

<p>On April 2, 2026, Google DeepMind released <strong>Gemma 4</strong>, a family of open-weight models distilled from Gemini 3. These models are designed to run on consumer hardware, reaching performance parity with models like GPT-5-mini on coding benchmarks, while offering native multimodal support for images and audio. Crucially, the release uses the <strong>Apache 2.0 license</strong>, enabling unrestricted commercial deployment. Following its launch, the consensus across developer communities is that this is a milestone release, though it comes with specific architectural trade-offs.</p>

<hr />

<h2 id="what-is-gemma-4">What Is Gemma 4?</h2>

<p>Gemma 4 is a family of four models ranging from an edge-optimized 2B-class system to a 31B dense model that competes with closed-source frontier models. Every variant is trained on over 140 languages, and features a common architectural core with per-model specializations.</p>

<p>Three key factors define this release:</p>

<ul>
  <li><strong>Apache 2.0 Licensing:</strong> Previous Gemma models utilized a custom license that created ambiguity for certain commercial agentic systems. Gemma 4 is fully Apache 2.0, allowing users to modify, deploy, and build products without restriction.</li>
  <li><strong>Multimodal Input:</strong> All four models can process text and images. The two “Edge” variants (E2B and E4B) also support native audio input. Context windows reach up to <strong>256K tokens</strong> for the larger models and <strong>128K tokens</strong> for the edge variants.</li>
  <li><strong>Local Inference Performance:</strong> The 26B Mixture of Experts (MoE) variant can achieve speeds of approximately 120–150 tokens/second on an RTX 4090 and up to 400 tokens/second on M5 MacBook Pro systems, thanks to its low active parameter count (~3.8B) during inference.</li>
</ul>

<p><strong>Note:</strong> Gemma 4 is an input-multimodal model; it processes text, images, and audio but outputs only text.</p>

<hr />

<h2 id="the-model-lineup-pick-your-fighter">The Model Lineup; Pick Your Fighter</h2>

<p>Gemma 4 ships in four variants. Understanding the naming is critical because Google’s choices here generated genuine controversy.</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>Total Params</th>
      <th>Active (Inference)</th>
      <th>Memory (Base)</th>
      <th>Modalities</th>
      <th>Best For</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>E2B</strong></td>
      <td>~5.1B</td>
      <td>~2.3B</td>
      <td>~5.1 GB</td>
      <td>Text, Image, Audio</td>
      <td>Mobile, IoT, Raspberry Pi</td>
    </tr>
    <tr>
      <td><strong>E4B</strong></td>
      <td>~8.0B</td>
      <td>~4.5B</td>
      <td>~8.0 GB</td>
      <td>Text, Image, Audio</td>
      <td>Laptops, Voice Assistants</td>
    </tr>
    <tr>
      <td><strong>26B-A4B</strong></td>
      <td>~25.2B</td>
      <td>~3.8B (MoE)</td>
      <td>~25.2 GB</td>
      <td>Text, Image</td>
      <td>Local Agents, 24GB VRAM</td>
    </tr>
    <tr>
      <td><strong>31B</strong></td>
      <td>30.7B</td>
      <td>30.7B (Dense)</td>
      <td>~31 GB</td>
      <td>Text, Image</td>
      <td>Reasoning, Coding, 32GB+ VRAM</td>
    </tr>
  </tbody>
</table>

<p>HuggingFace model cards:</p>
<ul>
  <li>E2B: <code class="language-plaintext highlighter-rouge">google/gemma-4-e2b-it</code></li>
  <li>E4B: <code class="language-plaintext highlighter-rouge">google/gemma-4-e4b-it</code></li>
  <li>26B-A4B: <code class="language-plaintext highlighter-rouge">google/gemma-4-26B-A4B-it</code></li>
  <li>31B: <code class="language-plaintext highlighter-rouge">google/gemma-4-31b-it</code></li>
</ul>

<h3 id="the-e-naming-controversy">The “E” Naming Controversy</h3>

<p>The “E” in E2B and E4B stands for <strong>Effective parameters</strong>; the compute budget during a forward pass, not the weight count. E4B runs like a 4B model computationally, but it has 8 billion weights sitting in RAM. You are not saving memory. You are saving compute.</p>

<p><em>“E4B is 8B weights marketed as 4B… classic bait and switch.”</em> Multiple Hacker News users echoed this. The practical impact: if you’re GPU-constrained at 8GB VRAM, E4B fits (barely). But if you expected Raspberry Pi-level memory footprints from E2B based on the name, you’d be disappointed.</p>

<p>The naming logic is internally consistent; “effective” accurately describes the computational budget, not the storage cost; but it inverts the intuition most people have from model naming conventions where the number refers to parameters in memory.</p>

<p>Also notable: <strong>there is no 12B model.</strong> When a Gemma team member (canyon289) was asked about this on Hacker News, they acknowledged the gap but didn’t explain it. E4B appears to be the intended replacement for the 12B niche, though its benchmark performance sits meaningfully below what a 12B dense model would achieve.</p>

<hr />

<p align="center">
  <img src="/images/gemma4_model_family_comp.jpg" width="800" alt="gemma4_model_family_comp" />
</p>

<hr />

<h2 id="architecture-deep-dive">Architecture Deep Dive</h2>

<p>Gemma 4’s architecture is more interesting than it first appears. This section covers the seven innovations worth understanding.</p>

<h3 id="attention-local-and-global-interleaved">Attention: Local and Global, Interleaved</h3>

<p>Every Gemma 4 model alternates between two types of attention layers:</p>

<ul>
  <li><strong>Local (Sliding Window) Attention</strong>: each token only attends to the nearest N tokens within a sliding window. Efficient, O(n×w) complexity.</li>
  <li><strong>Global Attention</strong>: full attention over the entire context. Expensive, O(n²) complexity, but necessary for long-range coherence.</li>
</ul>

<p>The ratio:</p>
<ul>
  <li><strong>E2B</strong>: 4 local : 1 global</li>
  <li><strong>E4B, 26B-A4B, 31B</strong>: 5 local : 1 global</li>
</ul>

<p>Sliding window sizes: 512 tokens for E2B/E4B, 1024 tokens for the larger variants.</p>

<p><strong>Key change from Gemma 3:</strong> The last layer of every Gemma 4 model is always a global attention layer. Gemma 3’s 4B model had a local attention layer as its final layer, which created a bottleneck for tasks requiring full-sequence summarization. That’s fixed.</p>

<p>Why does this matter? For a 256K token context, you’d be doing global attention only once every 5 or 6 layers; most of the compute stays cheap. The local layers do the heavy lifting for nearby relationships; the global layers integrate across the full context.</p>

<h3 id="the-global-attention-efficiency-trio">The Global Attention Efficiency Trio</h3>

<p>Running global attention over 256K tokens naively would be prohibitively expensive. Gemma 4 applies three orthogonal optimizations to global attention layers specifically. Together they make long-context global attention fast enough to be practical.</p>

<h4 id="grouped-query-attention-gqa">Grouped Query Attention (GQA)</h4>

<p>Standard multi-head attention has one Key and one Value per Query head, so the KV cache scales linearly with the number of heads. GQA groups multiple Query heads to share a single KV pair.</p>

<p>Gemma 4’s grouping is asymmetric:</p>
<ul>
  <li>Local attention: <strong>2 Query heads per KV head</strong> (modest grouping, fine at local scale)</li>
  <li>Global attention: <strong>8 Query heads per KV head</strong> (aggressive grouping, critical for long contexts)</li>
</ul>

<p>To compensate for the information loss from aggressive grouping, the <strong>Key dimensions are doubled</strong> in global attention layers. More information per KV pair, fewer KV pairs total.</p>

<h4 id="key-value-tying-kv">Key-Value Tying (K=V)</h4>

<p>Applied only in global attention layers of the 31B and 26B-A4B variants. Instead of learning separate Key and Value projections, the model sets Keys equal to Values: <strong>K = V</strong>.</p>

<p>This means the model only needs to store one set of vectors for global attention layers instead of two; effectively halving the KV cache for those layers. A separate RMSNorm is applied to the projection for Keys vs. Values even when they’re tied, which preserves the ability to apply different normalizations to the same underlying vectors.</p>

<p>The practical effect: the “KV cache” for global attention layers becomes a “K-cache only”; a meaningful memory reduction at long contexts where global layer KV contributions would otherwise dominate.</p>

<h4 id="p-rope-partial-rotary-positional-encoding">p-RoPE: Partial Rotary Positional Encoding</h4>

<p>Standard RoPE (Rotary Positional Encoding) applies a rotation to every pair of dimensions in the attention head to encode position. At very long contexts, the lower-frequency rotation pairs accumulate noise; small rotations compound across 256K positions into drift that corrupts semantic information.</p>

<p>Gemma 4’s solution: <strong>only rotate the first 25% of dimension pairs</strong> (p=0.25). The other 75% have their rotations zeroed out entirely. Those high-frequency pairs preserve their semantic content without positional noise accumulation, while the 25% that do rotate handle positional discrimination efficiently.</p>

<p>p-RoPE is applied only to global attention layers, where long-context stability is most critical. Local attention layers use standard RoPE (they only see 512-1024 tokens, so noise accumulation isn’t a problem).</p>

<h3 id="per-layer-embeddings-what-e-actually-means">Per-Layer Embeddings; What “E” Actually Means</h3>

<p>The E2B and E4B models use <strong>Per-Layer Embeddings (PLE)</strong>; an additional lookup table for every transformer layer. This is the core innovation that makes the “E” naming meaningful.</p>

<p>Here’s how they work:</p>

<p><strong>Standard embedding:</strong> when you feed a token into the model, it looks up one vector from a single embedding table at the input. That vector flows through all layers.</p>

<p><strong>With PLE:</strong> the model maintains an additional embedding table per layer. At inference start, it does one lookup per token per layer; done once upfront, not repeated. Between each decoder block, a gating function weighs the per-layer embedding for the current token, projects it up to match the model’s hidden size (256-dim → 1,536-dim for E2B, → 2,560-dim for E4B), normalizes it, and adds it to the decoder’s output.</p>

<p>The effect: the model is constantly “reminded” of each token’s identity as information flows through the layers. Deep layers in a transformer can “forget” what they were originally processing; PLE counters this.</p>

<p><strong>The flash storage trick:</strong> PLE tables are stored in flash memory, not (V)RAM. They’re streamed in once at inference start and discarded. This is why E2B’s “effective” parameters are only 2B; the PLE tables exist in flash, not in the GPU’s working memory. The idea is similar to the DeepSeek Engram approach.</p>

<p><strong>KV cache sharing (E4B):</strong> in E4B, the last 18 of 42 transformer layers reuse the KV cache from the previous global attention layer instead of computing fresh K and V projections. The parameters saved from not needing those 18 K/V projections are reinvested in E2B into doubling the widths of the later MLP layers; trading attention memory for feedforward capacity.</p>

<p>PLE tables for E2B:</p>
<ul>
  <li>Main vocabulary embedding: 262,144 tokens × 1,536 dimensions</li>
  <li>Per-layer embeddings: 262,144 tokens × 256 dimensions × 35 layers</li>
</ul>

<p>That’s a lot of lookup tables. But because they live in flash and are accessed once, they don’t contribute to VRAM usage or per-token compute.</p>

<h3 id="mixture-of-experts-the-26b-a4b">Mixture of Experts; The 26B-A4B</h3>

<p>The 26B-A4B is the practical sweet spot for most local deployments. It runs at dense-4B speed while having the knowledge capacity of a 26B model. Here’s how.</p>

<p><strong>The MoE setup:</strong></p>
<ul>
  <li>128 experts total per MoE layer</li>
  <li>8 routed experts selected per token via softmax + top-k</li>
  <li>1 shared expert always activated (3× the size of a regular expert)</li>
  <li>Each routed expert is 1/3 the size of a standard MLP layer</li>
</ul>

<p>So for any given token: 8 small routed experts + 1 large shared expert = effectively ~4B parameters active. The other 120 experts sit idle.</p>

<p><strong>Key architectural decision; MoE as separate layers:</strong></p>

<p>This is where Gemma 4 diverges from competitors. DeepSeek and Qwen run their shared experts <em>in parallel</em> with routed experts; one forward pass activates both simultaneously. Gemma 4 adds MoE blocks as <strong>sequential separate layers</strong>:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Token → Attention → MLP → MoE → next Attention → ...
</code></pre></div></div>

<p>The MoE layer is additive on top of the normal MLP, not a replacement for it. This is a deliberate architectural choice that differs from what DeepSeek-V2/V3 and Qwen-MoE do. The tradeoff: more compute per token (you still run the full MLP), but potentially better specialization since the MoE layer is a pure addition.</p>

<p>Gemma 4 also does not use AltUp or hyperconnections; simpler stack, easier to optimize and quantize.</p>

<p><strong>Performance in practice:</strong> on an RTX 4090, the 26B-A4B runs at ~150 tokens/second generation speed. Qwen 3.5-35B-A3B (similar concept, 3B active) runs at ~100 tokens/second on the same hardware. The 50% speed advantage for comparable quality is the 26B-A4B’s headline case.</p>

<h3 id="vision-encoder">Vision Encoder</h3>

<p>All Gemma 4 models are vision-capable. The vision encoder sits outside the LLM stack; it processes images into soft tokens that the language model consumes alongside text.</p>

<p><strong>Two encoder sizes:</strong></p>
<ul>
  <li>E2B / E4B: <strong>150M parameter ViT</strong></li>
  <li>26B-A4B / 31B: <strong>550M parameter ViT</strong></li>
</ul>

<p>Both use 16×16 pixel patches.</p>

<p><strong>Variable aspect ratio with 2D RoPE:</strong> instead of squishing all images into a fixed square, Gemma 4 splits the positional embedding in half; one half encodes horizontal position, the other encodes vertical position. Images are adaptively resized to preserve their original aspect ratio, with padding as needed. This prevents distortion artifacts that occur when forcing a 16:9 landscape photo into a square grid.</p>

<p><strong>Soft token budget:</strong> images are represented in the LLM’s embedding space at one of five resolution levels; 70, 140, 280, 560, or 1120 tokens. The resolution must be a multiple of 48 pixels (3 patches of 16 pixels, merged via 3×3 average pooling into a single embedding). A 280-token budget means up to 9 × 280 = 2,520 patches before pooling. You trade resolution for context budget depending on your use case.</p>

<p><strong>Projection to LLM space:</strong> ViT patch embeddings are projected via a linear layer + RMSNorm to match the LLM’s hidden dimension.</p>

<h3 id="audio-encoder-e2be4b-only">Audio Encoder (E2B/E4B Only)</h3>

<p>The two smallest Gemma 4 models can process audio. The 26B-A4B and 31B cannot.</p>

<p>The audio encoder is <strong>Conformer-based</strong>; a Transformer encoder augmented with a convolutional module that’s well-suited to the local-then-global structure of speech signals.</p>

<p>Processing pipeline:</p>
<ol>
  <li>Raw audio waveform</li>
  <li>Mel-spectrogram extraction (convert to frequency representation)</li>
  <li>Chunking into fixed-length segments</li>
  <li>2D convolutional downsampling (reduce temporal resolution)</li>
  <li>Conformer encoder (Transformer + convolution)</li>
  <li>Linear projection to LLM embedding space</li>
</ol>

<p>The output is soft tokens; continuous embeddings, not discrete tokens; that the LLM processes alongside text and vision tokens.</p>

<p><em>“The smaller models have an audio thing glued onto them, it’s not native to the model like photo/video.”</em> The architecture supports audio, but it wasn’t trained end-to-end from day one the way the vision encoder was. For production voice applications, this matters; the audio understanding may be shallower than the image understanding.</p>

<h3 id="the-260k-vocabulary-problem">The 260K Vocabulary Problem</h3>

<p>All Gemma 4 models; E2B, E4B, 26B-A4B, 31B; use a vocabulary of <strong>262,144 tokens</strong> (2^18, or ~260K). This is inherited from Gemini 3 via distillation, and it’s the same vocabulary used in Gemma 3 and Gemma 3n.</p>

<p>For context: Llama uses ~32K tokens. Mistral uses ~32K. Qwen 3.5 uses ~150K. Gemma 4 is at 260K; roughly 8× larger than typical open models.</p>

<p>Why does this matter?</p>

<blockquote>
  <p><em>“Such a giant vocab is strange for a 2B model, since the output embedding matrix consumes most of the parameters.”</em></p>
</blockquote>

<p>For E2B, the main embedding table alone is: 262,144 tokens × 1,536 dimensions ≈ <strong>400M parameters</strong>; a disproportionate chunk of the “2B effective” budget. The PLE tables add more: 262,144 × 256 × 35 layers ≈ another <strong>2.4 billion values</strong> (though stored in flash). This is parameter budget that, in a model with a smaller vocabulary, would go into the transformer layers themselves.</p>

<p><strong>Upside:</strong> the large vocabulary enables excellent tokenization efficiency across 140+ languages. Code, scientific notation, and multilingual text get compressed more efficiently; fewer tokens per character, more context for the same length text.</p>

<p><strong>Downside for quantization:</strong> a 260K embedding table is harder to quantize cleanly than a 32K one. Standard Q4_K_M applies relatively uniform quantization pressure. Unsloth’s Dynamic 2.0 addresses this by analyzing each layer’s sensitivity individually and applying higher precision where it matters; including the embedding table. This is one reason the Unsloth GGUF variants (prefixed <code class="language-plaintext highlighter-rouge">UD-</code>) are worth preferring over standard <code class="language-plaintext highlighter-rouge">Q4_K_M</code> for Gemma 4 specifically.</p>

<hr />

<h2 id="benchmarks-where-it-wins-where-it-doesnt">Benchmarks; Where It Wins, Where It Doesn’t</h2>

<p>Here is the full benchmark comparison from HuggingFace model cards, compiled by the community:</p>

<table>
  <thead>
    <tr>
      <th>Model</th>
      <th>MMLU-Pro</th>
      <th>GPQA Diamond</th>
      <th>LiveCodeBench v6</th>
      <th>Codeforces ELO</th>
      <th>TAU2-Bench</th>
      <th>MMMLU</th>
      <th>HLE (no tools)</th>
      <th>HLE (tools)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Gemma 4 31B</strong></td>
      <td>85.2%</td>
      <td>84.3%</td>
      <td>80.0%</td>
      <td><strong>2150</strong></td>
      <td>76.9%</td>
      <td>88.4%</td>
      <td>19.5%</td>
      <td>26.5%</td>
    </tr>
    <tr>
      <td><strong>Gemma 4 26B-A4B</strong></td>
      <td>82.6%</td>
      <td>82.3%</td>
      <td>77.1%</td>
      <td>1718</td>
      <td>68.2%</td>
      <td>86.3%</td>
      <td>8.7%</td>
      <td>17.2%</td>
    </tr>
    <tr>
      <td><strong>Gemma 4 E4B</strong></td>
      <td>69.4%</td>
      <td>58.6%</td>
      <td>52.0%</td>
      <td>940</td>
      <td>42.2%</td>
      <td>76.6%</td>
      <td>;</td>
      <td>;</td>
    </tr>
    <tr>
      <td><strong>Gemma 4 E2B</strong></td>
      <td>60.0%</td>
      <td>43.4%</td>
      <td>44.0%</td>
      <td>633</td>
      <td>24.5%</td>
      <td>67.4%</td>
      <td>;</td>
      <td>;</td>
    </tr>
    <tr>
      <td>Gemma 3 27B (no think)</td>
      <td>67.6%</td>
      <td>42.4%</td>
      <td>29.1%</td>
      <td>110</td>
      <td>16.2%</td>
      <td>70.7%</td>
      <td>;</td>
      <td>;</td>
    </tr>
    <tr>
      <td>GPT-5-mini</td>
      <td>83.7%</td>
      <td>82.8%</td>
      <td>80.5%</td>
      <td>2160</td>
      <td>69.8%</td>
      <td>86.2%</td>
      <td>19.4%</td>
      <td>35.8%</td>
    </tr>
    <tr>
      <td>Qwen 3.5-122B-A10B</td>
      <td><strong>86.7%</strong></td>
      <td><strong>86.6%</strong></td>
      <td>78.9%</td>
      <td>2100</td>
      <td>79.5%</td>
      <td><strong>86.7%</strong></td>
      <td>25.3%</td>
      <td><strong>47.5%</strong></td>
    </tr>
    <tr>
      <td>Qwen 3.5-27B</td>
      <td>86.1%</td>
      <td>85.5%</td>
      <td><strong>80.7%</strong></td>
      <td>1899</td>
      <td><strong>79.0%</strong></td>
      <td>85.9%</td>
      <td><strong>24.3%</strong></td>
      <td>48.5%</td>
    </tr>
    <tr>
      <td>Qwen 3.5-35B-A3B</td>
      <td>85.3%</td>
      <td>84.2%</td>
      <td>74.6%</td>
      <td>2028</td>
      <td>81.2%</td>
      <td>85.2%</td>
      <td>22.4%</td>
      <td>47.4%</td>
    </tr>
  </tbody>
</table>

<h3 id="what-the-numbers-say">What the numbers say</h3>

<p><strong>Gemma 4 31B</strong> is competitive with GPT-5-mini across the board. On Codeforces ELO (competitive programming), it actually ties GPT-5-mini at 2150. This is the most striking result: an open-weight model running locally matching a closed-source frontier on coding.</p>

<p><strong>Gemma 4 E4B</strong> beats Gemma 3 27B on <em>every single benchmark</em>; dramatically in some cases (52% vs 29% on LiveCodeBench). This is a real generational improvement for small models.</p>

<p><strong>The Qwen problem:</strong> Gemma 4 consistently underperforms Qwen 3.5 variants on MMLU-Pro, GPQA Diamond, and especially HLE-with-tools. Qwen 3.5-27B outperforms even Gemma 4 31B on GPQA Diamond (85.5% vs 84.3%). The smaller Gemma models are outpaced by Qwen models with similar or fewer total parameters.</p>

<h3 id="the-benchmaxxing-debate">The “benchmaxxing” debate</h3>

<p>Developer community: <em>“Featuring the ELO score as the main benchmark is VERY misleading. The big dense Gemma 4 model does not seem to reach Qwen 3.5 27B dense model in most benchmarks. The release is a bit disappointing.”</em></p>

<p>The counter: <em>“You can use this model for about 5 seconds and realize its reasoning is in a league well above any Qwen model, but instead people assume benchmarks that are openly getting used for training are still relevant.”</em></p>

<p>This is a genuine tension. Chinese model vendors have known access to public benchmark test sets, and training on or near these distributions is a reasonable suspicion. Subjective quality impressions from practitioners often diverge from benchmark rankings. Simon Willison’s informal SVG generation test (a “pelican swimming in a pond”) produced what he called the best output he’d seen from a model running on his laptop (128GB M5). Benchmark numbers and practical impressions don’t always point the same direction.</p>

<h3 id="sql-benchmark">SQL benchmark</h3>

<p>On an independent SQL generation benchmark (sql-benchmark.nichlothian.com):</p>
<ul>
  <li><strong>E4B</strong>: 15/25; competitive with Qwen 3.5-9B in thinking mode</li>
  <li><strong>E2B (4-bit quantized)</strong>: 12/25; tied with NVIDIA Nemotron-3-Nano-4B, best 4B model tested</li>
</ul>

<p>A 12/25 score from 4-bit quantized E2B on SQL generation is genuinely useful for edge deployments.</p>

<h2 id="running-gemma-4-locally-two-commands-away">Running Gemma 4 Locally; Two Commands Away</h2>

<p>The fastest path to running the recommended model (26B-A4B at Q4_K_M quantization):</p>

<h3 id="llamacpp">llama.cpp</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>llama.cpp <span class="nt">--HEAD</span>
llama-server <span class="nt">-hf</span> ggml-org/gemma-4-26B-A4B-it-GGUF:Q4_K_M
</code></pre></div></div>

<p>That’s it. The first command installs llama.cpp from HEAD (required for Gemma 4 support). The second downloads the quantized model from HuggingFace and starts an OpenAI-compatible server on <code class="language-plaintext highlighter-rouge">localhost:8080</code>. No Python environment, no CUDA setup, no Docker.</p>

<p>To disable thinking/reasoning mode: append <code class="language-plaintext highlighter-rouge">--reasoning off</code>.</p>

<h3 id="ollama">Ollama</h3>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ollama pull gemma4:26b-a4b-it-q4_K_M
ollama run gemma4:26b-a4b-it-q4_K_M
</code></pre></div></div>

<h3 id="inference-speed-by-hardware">Inference speed by hardware</h3>

<table>
  <thead>
    <tr>
      <th>Hardware</th>
      <th>Model</th>
      <th>Quantization</th>
      <th>Generation Speed</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>RTX 4090 (24GB)</td>
      <td>26B-A4B</td>
      <td>UD-Q4_K_XL</td>
      <td>~150 tok/s</td>
    </tr>
    <tr>
      <td>RTX 4090 (24GB)</td>
      <td>31B</td>
      <td>UD-Q4_K_XL</td>
      <td>~5 tok/s <em>(CPU offload)</em></td>
    </tr>
    <tr>
      <td>RX 7900 XTX (24GB)</td>
      <td>26B-A4B</td>
      <td>UD-Q4_K_XL</td>
      <td>120 tok/s @ 1K ctx</td>
    </tr>
    <tr>
      <td>RX 7900 XTX (24GB)</td>
      <td>26B-A4B</td>
      <td>UD-Q4_K_XL</td>
      <td>71 tok/s @ 128K ctx</td>
    </tr>
    <tr>
      <td>M5 MacBook Pro (128GB)</td>
      <td>26B-A4B</td>
      <td>Q4_K_M</td>
      <td>400 tok/s</td>
    </tr>
    <tr>
      <td>M4 Mac Mini (24GB)</td>
      <td>26B-A4B</td>
      <td>Q4_K_M</td>
      <td>28 tok/s</td>
    </tr>
    <tr>
      <td>M1 Max (64GB)</td>
      <td>26B-A4B</td>
      <td>Q4_K_M</td>
      <td>16–50 tok/s</td>
    </tr>
    <tr>
      <td>M3 Max (36GB)</td>
      <td>26B-A4B</td>
      <td>Q4</td>
      <td>smooth</td>
    </tr>
    <tr>
      <td>M1 MacBook (16GB)</td>
      <td>26B-A4B</td>
      <td>Q4_K_M</td>
      <td>8 tok/s</td>
    </tr>
  </tbody>
</table>

<p><strong>The sweet spot</strong>: 26B-A4B at Q4_K_M on a 24GB VRAM card. The model loads in ~18GB, leaving ~6GB headroom for context and KV cache. You get ~120-150 tok/s on NVIDIA, comparable speed on AMD with ROCm.</p>

<p><strong>The 31B dense problem</strong>: 31B at any useful quantization exceeds 24GB VRAM once you add KV cache. The static SWA (Sliding Window Attention) KV cache alone costs 3.6GB. IQ4_XS quantization brings weights to 15.2GB, but you’re CPU-offloading and paying ~5 tok/s. For 31B quality on a 24GB card, wait for QAT (quantization-aware training) versions. For now, 31B really wants 32GB+ VRAM.</p>

<h3 id="recommended-inference-settings">Recommended inference settings</h3>

<p>From Unsloth (Daniel Han, who trained the model’s calibration sets):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>temperature: 1.0
top_p: 0.95
top_k: 64
EOS token: &lt;turn|&gt;
Thinking trace token: &lt;|channel&gt;thought\n
</code></pre></div></div>

<p>Note: Google recommends temperature 1.0 for benchmark reproducibility. Many practitioners prefer 0.7-0.8 for creative or conversational use. To disable thinking entirely in llama.cpp: <code class="language-plaintext highlighter-rouge">--reasoning off</code>.</p>

<h3 id="unsloth-dynamic-20-why-it-matters-for-gemma-4">Unsloth Dynamic 2.0; why it matters for Gemma 4</h3>

<p>Unsloth’s <code class="language-plaintext highlighter-rouge">UD-</code> quantization variants are different from standard GGUF quants. Dynamic 2.0 does per-layer sensitivity analysis using a &gt;1.5M token calibration dataset. For each layer, it determines the minimum quantization level that preserves quality. The result: embedding layers and attention projections that are sensitive to precision get quantized less aggressively, while feedforward layers that are more robust get quantized more.</p>

<p>For Gemma 4 specifically, this matters because of the 260K vocabulary embedding table. A standard Q4_K_M quantizes the embedding table at the same bit depth as everything else. Unsloth’s approach can keep embedding rows at higher precision where token frequency distributions make it worthwhile. The <code class="language-plaintext highlighter-rouge">UD-Q4_K_XL</code> variant is the recommended Unsloth pick for 24GB systems.</p>

<h2 id="what-the-community-actually-thinks">What the Community Actually Thinks</h2>

<h3 id="the-positives">The positives</h3>

<p><strong>On Apache 2.0 licensing:</strong><br />
<em>“Apache 2.0 is a big shift here. Previous Gemma licenses made it a legal gray zone for agent deployments, especially BYOK setups. Now it’s genuinely free to deploy commercially.”</em></p>

<p><em>“Finally open weights that don’t make us slaves to their garbage APIs.”</em></p>

<p><strong>On local performance:</strong><br />
<em>“Two commands to run a frontier-tier model locally. A year ago this would have been a 20 step setup guide with CUDA driver hell.”</em></p>

<p><em>“You can use this model for about 5 seconds and realize its reasoning is in a league well above any Qwen model.”</em></p>

<p><strong>On the 26B-A4B practical value:</strong><br />
One user ran Gemma 4 26B-A4B and Qwen 3.5-35B-A3B side-by-side on an RTX 4090 as Claude Code agent backends. Gemma 4: ~40 tok/s for a 4B-active model. Qwen 3.5-35B-A3B: ~12 tok/s for a 3B-active model. The inference speed advantage is real and compounds over long agentic sessions.</p>

<p><strong>On the E4B model specifically:</strong><br />
<em>“Good enough that I can see it replacing Claude.ai for some things”</em>; at just 8GB VRAM.</p>

<h3 id="the-negatives">The negatives</h3>

<p><strong>Tool calling was broken at launch.</strong> Multiple users reported failures with function calling. A chat template bug in llama.cpp was patched via PR #21326 within days. The underlying model supports function calling, but the inference infrastructure wasn’t fully ready on day one. For agentic workflows, tool reliability is table stakes; this matters.</p>

<p><strong>The “E” naming controversy.</strong> Already covered, but worth re-emphasizing: this created genuine confusion. If you’re recommending Gemma 4 to non-technical users, explain that “E2B needs 5GB of RAM, not 2GB.”</p>

<p><strong>No 12B model.</strong> There’s a well-populated tier of 9-12B models (Qwen 3.5-9B, Llama 3.1-8B) that’s ideal for mid-range laptops with 8-16GB VRAM. E4B sort of fits this niche but is architecturally unusual (PLE, KV cache sharing) in ways that may affect some applications.</p>

<p><strong>Smaller models underperform Qwen.</strong> On MMLU-Pro, GPQA Diamond, and HLE, Qwen 3.5-27B beats Gemma 4 31B. E4B (8B weights) is meaningfully below Qwen 3.5-9B. If your primary metric is knowledge benchmarks, Qwen is the current leader at most size classes.</p>

<p><strong>The agentic coding gap.</strong> At least one Hacker News user ran a structured Rust project test through OpenCode with multiple models. Qwen 3.5-27B “significantly outperformed” Gemma 4 26B-A4B. For coding-heavy agentic workflows specifically, Qwen maintains an advantage.</p>

<h3 id="the-debates">The debates</h3>

<p><strong>Benchmark gaming accusations:</strong> The Codeforces ELO (2150 for G4 31B) was specifically called out as misleading, since ELO-style scores on competitive programming can be gamed by training strategy more easily than raw correctness benchmarks. This is a live debate in the community.</p>

<p><strong>MoE “sqrt rule”:</strong> Some HN users applied the rule of thumb <code class="language-plaintext highlighter-rouge">sqrt(total_params × active_params)</code> to estimate “effective intelligence”; giving 26B-A4B a score of ~sqrt(26B × 4B) ≈ 10B dense equivalent. Others argued this rule is outdated and doesn’t apply to modern MoE architectures. No consensus.</p>

<p><strong>Thinking trace reliability:</strong> Users specifically flagged that Gemma 4’s thinking traces can produce convincing-looking but wrong reasoning chains. The model “hallucinated tool use and verification steps, producing wrong answers while appearing to reason correctly.” Long thinking traces don’t guarantee correctness.</p>

<h3 id="what-people-want-next-from-the-gemma-team">What people want next (from the Gemma team)</h3>

<p>Community requests logged by canyon289 (Gemma team member on HN):</p>
<ol>
  <li><strong>QAT (Quantization-Aware Training) versions</strong>; multiple urgent requests for models trained to be quantized, not just post-hoc quantized</li>
  <li><strong>Larger models</strong>; 60B-200B range to compete with Qwen 3.5-122B-A10B</li>
  <li><strong>Audio for larger models</strong>; E-series audio support but not 26B/31B</li>
  <li><strong>Audio output</strong>; no Gemma variant generates audio</li>
  <li><strong>Improved tool calling</strong>; functional from day one, not patched later</li>
  <li><strong>Qualcomm NPU support</strong>; <code class="language-plaintext highlighter-rouge">.litertlm</code> files for on-device Qualcomm hardware</li>
</ol>

<p>One unverified claim making rounds: Jeff Dean’s slides apparently hinted at a 124B MoE variant that was not included in this release. Nothing confirmed.</p>

<hr />

<h2 id="whats-missing-and-whats-next">What’s Missing and What’s Next</h2>

<p><strong>The model family gaps:</strong></p>

<p>Gemma 4 has a dumbbell shape; two tiny E-series models and two large models, with nothing in the 12-20B range. This leaves a gap that Qwen 3.5-9B and Qwen 3.5-27B fill comfortably. E4B at 8B weights is the closest, but its compute behavior (2-4B effective) makes it behave smaller than its weight count suggests.</p>

<p><strong>Multimodal gaps:</strong></p>

<p>The 26B-A4B and 31B models don’t support audio. If you need audio + large model, that combination doesn’t exist in the Gemma 4 family yet. And no Gemma 4 model generates images, audio, or video; text output only, full stop.</p>

<p><strong>Tool calling reliability:</strong></p>

<p>The infrastructure-level tool calling bugs at launch are patched, but the model itself was reportedly unreliable on complex multi-step tool use compared to the best closed models. Agentic coding and orchestration workflows still favor Qwen 3.5 or GPT-5-mini for tool-heavy tasks.</p>

<p><strong>The 260K vocab tradeoff:</strong></p>

<p>Inheriting Gemini’s tokenizer gives Gemma 4 excellent multilingual coverage, but it’s architecturally awkward for edge-scale models. Standard quantization is less effective on large embedding tables. QAT versions would help significantly; and are among the most-requested additions from the community.</p>

<p><strong>What’s next:</strong></p>

<ul>
  <li>Apple is reportedly distilling Google models for Siri’s next update</li>
  <li>Modular MAX published a day-zero implementation claiming “fastest open-source performance for Gemma 4 on NVIDIA Blackwell and AMD MI355”</li>
  <li>MLX-optimized versions for Apple Silicon appeared within days of launch</li>
  <li>The llama.cpp tool calling issue was patched within days; the toolchain moves fast</li>
</ul>

<hr />

<h2 id="tldr-quick-reference">TL;DR; Quick Reference</h2>

<h3 id="which-model-should-you-use">Which model should you use?</h3>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Do you have ≤8GB VRAM or need audio input?
  → E4B (voice assistant, mobile, laptop)
  
Do you have ≤8GB VRAM and need the smallest possible model?
  → E2B (Raspberry Pi, edge deployment)

Do you have 18-24GB VRAM and want the best local agent?
  → 26B-A4B at Q4_K_M or UD-Q4_K_XL (fastest, most capable per VRAM GB)

Do you have 32GB+ VRAM and want maximum quality?
  → 31B (best reasoning, best coding, closest to GPT-5-mini)

Do you need tools/agents to work reliably today?
  → Consider Qwen 3.5-27B or wait for Gemma 4 QAT versions
</code></pre></div></div>

<h3 id="one-liners">One-liners</h3>

<p><strong>llama.cpp (recommended for fine control):</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>brew <span class="nb">install </span>llama.cpp <span class="nt">--HEAD</span> <span class="o">&amp;&amp;</span> llama-server <span class="nt">-hf</span> ggml-org/gemma-4-26B-A4B-it-GGUF:Q4_K_M
</code></pre></div></div>

<p><strong>Ollama (easiest):</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ollama run gemma4:26b-a4b-it-q4_K_M
</code></pre></div></div>

<p><strong>llama.cpp without thinking:</strong></p>
<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>llama-server <span class="nt">-hf</span> ggml-org/gemma-4-26B-A4B-it-GGUF:Q4_K_M <span class="nt">--reasoning</span> off
</code></pre></div></div>]]></content><author><name></name></author><category term="Gemma 4" /><category term="Mixture of Experts" /><category term="Local LLM Inference" /><category term="Multimodal AI" /><category term="Open Source AI" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Embeddings are beautiful.</title><link href="https://snehal.dev/embeddings-are-beautiful/" rel="alternate" type="text/html" title="Embeddings are beautiful." /><published>2026-03-26T00:00:00+00:00</published><updated>2026-03-26T00:00:00+00:00</updated><id>https://snehal.dev/embeddings-are-beautiful</id><content type="html" xml:base="https://snehal.dev/embeddings-are-beautiful/"><![CDATA[<p align="center">
  <img src="/images/embeddings_are_beautiful.jpg" width="800" alt="embeddings_are_beautiful" />
</p>

<p>You have text. A product review, a support ticket, a search query. Your model needs to understand it but models do not read English. They read numbers.</p>

<p>So you tokenize it. Words become integer IDs from a dictionary. But integer 4,317 is not closer to 4,318 in meaning. You turned language into math that knows nothing about language.</p>

<p>So you try one-hot encoding. Each word becomes a sparse vector as long as your vocabulary. But “coffee” and “espresso” are just as far apart as “coffee” and “refrigerator”. You have structure but no meaning.</p>

<p>So you use Word2Vec. Words that appear in similar contexts get similar vectors. “Coffee” and “espresso” land near each other in a dense 300-dimensional space. <code class="language-plaintext highlighter-rouge">king - man + woman ≈ queen</code>. The geometry encodes relationships nobody programmed in.</p>

<p>But “bank” near a river and “bank” near a loan produce the same vector. One embedding per word, no matter the context.</p>

<p>So you use contextual embeddings. BERT reads the whole sentence before deciding what each word means. “Bank” near “deposit” points one direction. “Bank” near “river” points another. Context is no longer ignored. It is the entire point.</p>

<p>But now you need to compare whole sentences. Someone searches “how to return a broken item” and your knowledge base says “steps for processing a damaged product refund”. Same meaning, zero shared words.</p>

<p>So you use sentence embeddings. Models like Sentence-BERT encode entire passages into single dense vectors. Two sentences that mean the same thing land near each other regardless of vocabulary. You compare meaning with cosine similarity. Small angle, similar meaning.</p>

<p>But you have 10 million documents. Comparing your query to every single one takes seconds. Users expect milliseconds.</p>

<p>So you use approximate nearest neighbor search. HNSW, IVF, ScaNN. You sacrifice a tiny fraction of accuracy for orders of magnitude in speed. Instead of checking 10 million vectors you check a few thousand. The right answer is almost always in there.</p>

<p>But each vector is 1024 floats at 32 bits each. Multiply that by 100 million documents and your index needs hundreds of gigabytes of RAM just to exist.</p>

<p>So you use quantization. Compress each float from 32 bits to 8 bits or even 1 bit. Your index shrinks by 4x to 32x. Retrieval quality barely moves. You cut your infrastructure bill without cutting relevance.</p>

<p>But now you need 1024 dimensions for your detailed search and 128 dimensions for your fast mobile endpoint. Training and hosting two separate models for different use cases is wasteful.</p>

<p>So you use Matryoshka embeddings. One model, one training run, but the embedding is designed so the first 64, 128, 256 or 512 dimensions are useful on their own. Need speed, use fewer dimensions. Need precision, use all of them. One model serves every latency and cost constraint.</p>

<p>But now you need somewhere to store and query all these vectors at scale. A Postgres float array column is not going to cut it at 100 million rows.</p>

<p>So you use a vector database. Pinecone, Qdrant, Milvus, pgvector. Purpose-built for storing, indexing, and querying high-dimensional vectors with metadata filtering and hybrid search.</p>

<p>Your generic embedding model works on Wikipedia-style text. But your documents are grocery descriptions, legal contracts, medical notes. Off-the-shelf embeddings do not understand your domain.</p>

<p>So you fine-tune. Contrastive learning on your own pairs. “Organic dark roast whole bean” pulls closer to “fair trade arabica coffee beans”. Same architecture, dramatically better retrieval in your world.</p>

<p>Your embedding search returns the right neighborhood but not the best result. Similarity got you close. It did not find the best match.</p>

<p>So you add a reranker. A cross-encoder scores each query-candidate pair together. Too expensive for a million documents but perfect for re-ordering your top 100. Retrieval gets you recall. Reranking gets you precision.</p>

<p>Users search with text and your catalog is images. “Red running shoes” and your database is 5 million photos with sparse metadata.</p>

<p>So you use multimodal embeddings. CLIP, SigLIP. One shared space for text and images. You search images with words and words with images. Modality becomes irrelevant. Meaning is all that matters.</p>

<p>You build a chatbot on your knowledge base. The LLM is brilliant but confidently hallucinates a return policy that does not exist.</p>

<p>So you use RAG. Embed the query, search your vector store, pull the top chunks, feed them to the LLM as context. The model answers from your documents, not its imagination.</p>

<p>But your documents are 40-page PDFs embedded as one giant chunk. The answer is in paragraph 37 and the embedding represents a blurry average of everything.</p>

<p>So you chunk strategically. Split by section, by paragraph, by semantic boundary. Each chunk gets its own vector that represents what it actually says.</p>

<p>Your search still misses things. “Laptop” versus “notebook computer” works with vectors. But “model XPS-9530” is a keyword problem. Semantics alone cannot solve it.</p>

<p>So you use hybrid search. BM25 for exact lexical matching plus vector search for semantic understanding. Two retrieval paths, one merged result. Your system no longer fails on either axis.</p>]]></content><author><name></name></author><category term="Tokenization" /><category term="Word2Vec" /><category term="BERT" /><category term="Sentence Embeddings" /><category term="ANN Search" /><category term="Quantization" /><category term="Matryoshka" /><category term="Fine-Tuning" /><category term="RAG" /><category term="Hybrid Search" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">TurboQuant: The cheat sheet that ate your GPU (and how Google fixed it)</title><link href="https://snehal.dev/turboquant/" rel="alternate" type="text/html" title="TurboQuant: The cheat sheet that ate your GPU (and how Google fixed it)" /><published>2026-03-25T00:00:00+00:00</published><updated>2026-03-25T00:00:00+00:00</updated><id>https://snehal.dev/turboquant</id><content type="html" xml:base="https://snehal.dev/turboquant/"><![CDATA[<p align="center">
  <img src="/images/tq_header.jpg" width="800" alt="TurboQuant" />
</p>

<p><em>How a clever math trick from the 1980s is about to change the economics of running AI, from hyperscale data centers to the Mac Mini on your desk</em></p>

<hr />

<p>If you watched Silicon Valley, you remember Pied Piper: a scrappy startup that invented a revolutionary compression algorithm so good it would upend everything from file storage to video streaming to, presumably, the entire internet. The punchline was that nobody could explain <em>why</em> it worked so well, and the fictional Silicon Valley elite kept dismissing it as too good to be true.</p>

<p>When Google Research published a blog post on March 24, 2026 describing <strong>TurboQuant</strong>, a compression algorithm that reduces the memory footprint of large language models by at least 6x, delivers up to 8x inference speedup, and does so with <strong>zero accuracy loss</strong>, the Pied Piper comparisons appeared almost immediately in online discussions. No retraining. No fine-tuning. Drop it in and go.</p>

<p>The difference is that this one is real, mathematically provable, peer-reviewed, and being presented at ICLR 2026, one of the most competitive machine learning conferences in the world. Google also open-sourced the whole thing, which raises its own fascinating questions we’ll get to at the end.</p>

<p>But first: let’s understand <em>why</em> this matters, because if you don’t know what a KV cache is, the headline number of “6x compression” might not mean very much to you. It should. It means a lot.</p>

<hr />

<h2 id="the-problem-nobody-talks-about-at-dinner">The problem nobody talks about at dinner</h2>

<p>When you send a message to an AI chatbot (ChatGPT, Claude, Gemini, take your pick) something you probably don’t think about is happening behind the scenes. The model isn’t just “thinking” about your latest message. It’s re-reading <em>everything</em> you’ve ever said in this conversation, every single time, to figure out what to say next.</p>

<p>That’s expensive. So engineers invented a trick: instead of re-reading your entire conversation each turn, the model computes a set of summary vectors for every token (word/subword) it has seen and <em>caches</em> them. This is called the <strong>KV cache</strong>, short for Key-Value cache. Think of it as the model’s working memory, or more precisely, its cheat sheet: a running set of compressed notes on “who said what, and what did it mean?”</p>

<p>It’s clever. It’s also enormous.</p>

<hr />

<p align="center">
  <img src="/images/tq_kv_cache.jpg" width="800" alt="TurboQuant KV Cache" />
</p>

<hr />

<p>Here’s the brutal arithmetic. Take Llama 70B, a large but widely-used open-source model. Run a long conversation of around 100,000 tokens (not unusual for agentic workflows or document analysis). The KV cache for that single user session consumes roughly <strong>40 GB of GPU memory</strong>. An H100 GPU, the crown jewel of Nvidia’s data center lineup at around $30,000, has 80 GB of HBM3 memory total. That means one user’s conversation chews through <em>half a flagship GPU chip</em>.</p>

<p>And that’s before the model’s own weights, which for a 70B-parameter model require another 35-70 GB depending on precision. You can see where this is going. The KV cache isn’t just a cost center. It’s often the <em>primary</em> reason you can’t fit more users, longer contexts, or bigger models onto the hardware you have.</p>

<p>This is what engineers mean when they talk about the “memory wall.” You can have the fastest GPUs in the world and still be stuck waiting on memory bandwidth. As one engineer put it in an online thread: “The real AI bottleneck is often not the model. It is the memory wall.”</p>

<hr />

<h2 id="the-scale-that-makes-this-matter">The scale that makes this matter</h2>

<p>AI inference, which means actually <em>running</em> models for real users as opposed to training them, now accounts for <strong>55% of all AI compute spending</strong>. Hyperscalers are pouring nearly <strong>$700 billion</strong> into AI infrastructure in 2026. The KV cache sits right at the top of the memory bottleneck in all of that.</p>

<p>At cloud rates of $2-3 per hour per H100 GPU, the KV cache is often the difference between profitable and unprofitable AI deployment. When GPU memory fills up with KV caches, the system literally cannot take on new users. You either evict older conversations (losing context) or spin up more hardware (losing money).</p>

<p>6x compression on the KV cache means the same hardware handles roughly 6x more simultaneous conversations. Or 6x longer context windows. Or some mix of both. This is not a minor efficiency tweak; it’s a structural shift in the unit economics of serving AI at scale.</p>

<hr />

<h2 id="wait-wasnt-this-already-solved">Wait, wasn’t this already solved?</h2>

<p>Sort of. Quantization, the practice of rounding model weights or activations from high-precision floats (16-bit or 32-bit) to lower-precision integers (8-bit, 4-bit), has been around for years. You’ve probably seen tools like GPTQ, AWQ, or llama.cpp’s GGUF format. These all compress model <em>weights</em> to make them fit on consumer hardware.</p>

<p>But there’s a subtle and important distinction: <strong>TurboQuant doesn’t touch model weights at all.</strong> It compresses the KV <em>cache</em>, the intermediate attention scores computed fresh at inference time, not the static parameters. The practical consequence is significant: you can run a 4-bit quantized Llama model <em>and then</em> apply TurboQuant on top, getting compression benefits from both independently. They’re not competing; they’re complementary.</p>

<p>Previous KV cache quantization methods (the leading baseline is called KIVI) also tried to quantize the cache, but they introduced their own memory overhead by needing to store quantization constants alongside the compressed data, which partially undermined the savings. TurboQuant’s core innovation is eliminating that overhead almost entirely.</p>

<hr />

<h2 id="the-math-is-beautiful-even-if-you-hated-linear-algebra">The math is beautiful (even if you hated linear algebra)</h2>

<p>Here’s where it gets genuinely elegant. The specific theorem at the heart of this algorithm is the <strong>Johnson-Lindenstrauss Lemma</strong>, proved in 1984.</p>

<p>The JL Lemma says something remarkable: you can take a set of <em>n</em> points in high-dimensional space and project them down to a much lower-dimensional space (roughly proportional to log(n)) while <em>preserving the pairwise distances</em> between those points. You don’t lose the geometry; you just fold it into a smaller container.</p>

<p>This isn’t just a nice theoretical curiosity. It’s the mathematical foundation for why random projections (multiplying your data by a random matrix) don’t destroy the structure of your information. The structure is preserved in expectation, provably, with quantifiable error bounds.</p>

<p>Before we get to how TurboQuant uses this, let’s make sure the core mechanics of quantization itself are clear, because the intuition matters and it’s simpler than most explanations make it sound.</p>

<h3 id="quantization-in-plain-english-its-just-putting-numbers-in-bins">Quantization in plain English: it’s just putting numbers in bins</h3>

<p>At its heart, quantization is about putting data into “bins” so you can represent it with fewer bits. Think of it like rounding: 3.14159 becomes 3. The challenge is that some datasets have wildly uneven distributions that make bin assignment wasteful.</p>

<p>Consider a concrete example. Take this set of numbers: <code class="language-plaintext highlighter-rouge">[3.11, 4.43, 5.78, 12.33, 34.32]</code>. Using a simple floor function, these map to <code class="language-plaintext highlighter-rouge">[3, 4, 5, 12, 34]</code>. The problem is the outlier: that single value at 34.32 forces you to use 6 bits of information to cover the full range (since 2^6 = 64), even though four of your five values are tightly clustered between 3 and 6. Most of your bit budget is being spent on a single outlier.</p>

<p>This is exactly the problem random rotation solves. A rotation matrix is an orthogonal matrix in linear algebra terms: when you multiply your vector by it, you aren’t changing the “amount” of data (the vector length stays the same), but you are recalculating every single number as a weighted sum of the originals. By the Central Limit Theorem, when you sum up many random things, the result starts looking like a bell curve.</p>

<p>TurboQuant relies on exactly this: it doesn’t know what your data looks like, but it <em>does</em> know that after the random rotation, the coordinates must follow a predictable Beta distribution (well-approximated by a bell curve). After that rotation, the original <code class="language-plaintext highlighter-rouge">[3.11, 4.43, 5.78, 12.33, 34.32]</code> might become something like <code class="language-plaintext highlighter-rouge">[8.12, 8.65, 9.25, 10.53, 12.86]</code>: tightly packed, predictably distributed, no outliers. Now your bins can be placed tightly around that bell curve shape, giving you much higher precision with far fewer bits.</p>

<p>To find the most optimal bin placement, TurboQuant uses the <strong>Lloyd-Max algorithm</strong>, the gold standard for 1D quantization, which finds the best boundaries and reconstruction values to minimize mean squared error.</p>

<hr />

<p align="center">
  <img src="/images/tq_rotation.jpg" width="800" alt="TurboQuant Random Rotation" />
</p>

<hr />

<h3 id="why-thats-not-quite-enough-and-what-fixes-it">Why that’s not quite enough, and what fixes it</h3>

<p>After Lloyd-Max quantization, you have compressed data, but there’s still a small residual error. In isolation it looks negligible. The problem is that the attention mechanism in transformers is built entirely on <strong>dot products</strong> (inner products) between query and key vectors. Small quantization errors introduce a bias that accumulates as dot products are computed across long sequences. Left uncorrected, this compounds into real degradation at 100,000+ token context lengths.</p>

<p>The QJL stage is the error-correction step. It takes the quantization residual (the leftover error after step 1) and encodes it using just <strong>1 bit per dimension</strong>. That single bit doesn’t represent the original data; it represents the bias introduced by quantization, and it’s enough to mathematically cancel out all the accumulated bias in the dot product estimates.</p>

<p>The way to think about it: it’s a “1-bit note” that allows you to perfectly cancel out all the bias terms your quantization algorithm produces, making the interactions (inner products) extremely accurate again even after compressing the original data. The key insight is that high reconstruction error is fine, because TurboQuant doesn’t need accurate vector reconstruction. It needs accurate <strong>attention scores</strong>. The QJL correction ensures those are unbiased with variance proportional to 1/d, where d is the head dimension (typically 128). The model’s attention distribution over tokens is preserved even when individual vectors look quite different from their originals.</p>

<hr />

<h2 id="how-turboquant-actually-works-the-full-pipeline">How TurboQuant actually works: the full pipeline</h2>

<p>Now that the intuitions are in place, here’s the complete architecture in four steps:</p>

<h3 id="step-1-random-rotation-change-of-basis">Step 1: Random rotation (change of basis)</h3>

<p>Each KV vector gets multiplied by a random orthogonal matrix: a random rotation in high-dimensional space. This preserves dot products (critical for attention), transforms coordinates into a predictable bell-curve distribution, and eliminates the outlier problem. Bins can now be placed optimally.</p>

<h3 id="step-2-polarquant-compression-most-of-the-bits">Step 2: PolarQuant compression (most of the bits)</h3>

<p>Rather than quantizing in standard Cartesian coordinates, <strong>PolarQuant</strong> converts vectors into polar coordinates: each pair of values is represented as a radius (how strong is the signal?) and an angle (what direction is it pointing?). After the rotation step, these angles follow a highly predictable distribution on a fixed circular grid. The quantizer doesn’t need to dynamically figure out the boundaries; they’re already known. This is how PolarQuant eliminates the memory overhead that plagued earlier methods. This stage uses most of the available bits (roughly 2.5 bits of a 3.5-bit total budget) to capture the main signal.</p>

<h3 id="step-3-qjl-residual-correction-1-bit">Step 3: QJL residual correction (1 bit)</h3>

<p>The <strong>Quantized Johnson-Lindenstrauss</strong> transform takes the residual error from step 2, projects it through a random Gaussian matrix, and stores just the sign (+1 or -1) of each projection. Exactly 1 bit per dimension. This is enough, provably, to produce an unbiased dot product estimate. The combined inner product estimator is:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>&lt;q, k&gt; ≈ &lt;q, k_stage1&gt; + ||residual|| × √(π/2)/m × &lt;S·q, sign(S·residual)&gt;
</code></pre></div></div>

<p>The first term is the coarse approximation. The second term is the 1-bit stochastic correction. Together they cancel the accumulated bias with zero memory overhead from normalization constants.</p>

<h3 id="step-4-fast-gpu-execution">Step 4: Fast GPU execution</h3>

<p>TurboQuant is specifically engineered around the Hadamard transform, which GPUs execute extremely efficiently. On H100 accelerators, 4-bit TurboQuant achieves up to <strong>8x speedup</strong> in computing attention logits compared to 32-bit uncompressed keys. Not just less memory: actually faster.</p>

<hr />

<p align="center">
  <img src="https://storage.googleapis.com/gweb-research2023-media/images/Quantization-3.width-1250.png" width="800" alt="TurboQuant Google 1" />
</p>

<p align="center">
  <img src="https://storage.googleapis.com/gweb-research2023-media/images/Quantization-2.width-1250.png" width="800" alt="TurboQuant Google 2" />
</p>

<hr />

<h2 id="what-the-numbers-actually-look-like">What the numbers actually look like</h2>

<p>A community PyTorch implementation validated the paper’s claims on real hardware (RTX 3060, Qwen2.5-3B-Instruct):</p>

<table>
  <thead>
    <tr>
      <th>Configuration</th>
      <th>KV cache size (8K context)</th>
      <th>Compression</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>FP16 baseline</td>
      <td>289 MB</td>
      <td>1.0x</td>
    </tr>
    <tr>
      <td>TurboQuant 4-bit</td>
      <td>76 MB</td>
      <td><strong>3.8x</strong></td>
    </tr>
    <tr>
      <td>TurboQuant 3-bit</td>
      <td>58 MB</td>
      <td><strong>5.0x</strong></td>
    </tr>
    <tr>
      <td>TurboQuant 2-bit</td>
      <td>40 MB</td>
      <td><strong>7.3x</strong></td>
    </tr>
  </tbody>
</table>

<p>More importantly, the <em>accuracy</em> of attention scores at 3-bit, measured as cosine similarity between compressed and original attention patterns, comes in at <strong>0.9945-0.9961</strong>: essentially 99.5% fidelity. The model sees the same tokens, in essentially the same order of importance, that it would have seen with full precision.</p>

<p>The <strong>3-bit configuration is the practical sweet spot</strong>: 5x compression, 99.5% attention fidelity. At 2-bit you start seeing degradation in which tokens the model attends to. At 3.5 bits, the paper claims near-lossless results across all tested benchmarks, including perfect scores on Needle-in-a-Haystack tests across context lengths up to 104,000 tokens. That benchmark, finding one specific fact buried deep in a massive document, is the most direct proxy for whether TurboQuant makes the model forget things. It doesn’t.</p>

<hr />

<h2 id="the-real-world-impact-from-data-centers-to-your-desk">The real-world impact: from data centers to your desk</h2>

<p><strong>For the hyperscalers</strong> (Google, Microsoft, Amazon and friends): 6x KV cache compression translates to 6x more users per GPU, or 6x longer context windows for the same hardware bill. At $2-3 per GPU-hour and billions of API calls per day, this is a nine-figure annual savings number. The arXiv preprint came out in April 2025, a full year before the public blog post. The biggest labs have almost certainly been using TurboQuant-class techniques internally for a while.</p>

<p><strong>For the AI inference startup ecosystem</strong>: The unit economics just shifted structurally. If you can run a 70B model locally with reasonable latency, you stop paying for cloud API subscriptions and start building a private, local-first stack. The moat of mid-tier SaaS wrappers around foundation models just got meaningfully thinner.</p>

<p><strong>For local AI enthusiasts</strong>: This is genuinely transformative. Top-tier models in the 128B parameter range could theoretically run at full quality on 128 GB of RAM, the kind of configuration available in a maxed-out Mac Studio or a well-equipped workstation. With TurboQuant stacked on top of 4-bit weight quantization, the gap between “local open-source AI” and “$200/month cloud subscription” just got meaningfully smaller.</p>

<p><strong>For vector search</strong>: TurboQuant isn’t only a KV cache trick. It also works as a standalone improvement to vector similarity search, the technology that powers how search engines and recommendation systems find similar items across billions of entries. Google runs billions of these searches daily. TurboQuant outperforms state-of-the-art baselines on recall benchmarks while requiring no dataset-specific tuning or large codebooks. Same algorithm, two massive application areas.</p>

<hr />

<h2 id="a-note-on-prior-art-the-question-the-research-community-raised">A note on prior art: the question the research community raised</h2>

<p>Not everyone was purely celebratory. In technical discussions, a researcher flagged something worth paying attention to: the foundational technique of applying a geometric rotation prior to extreme quantization, specifically for managing high-dimensional geometry and enabling proper bias correction, was introduced in a NeurIPS 2021 paper called <strong>DRIVE</strong>, which tackled optimal distributed mean estimation using exactly this rotational approach and a similar bias correction mechanism. The researcher noted having presented this work in a private invited talk at Google shortly after publication, and expressed hope that the camera-ready version of the TurboQuant paper would acknowledge this prior art.</p>

<p>The discussion that followed was illuminating. When someone asked whether the “rotation” was essentially diagonalization (storing a diagonal matrix plus new basis vectors for compactness), the response clarified: not quite. The rotation isn’t about finding a compact diagonal representation. Its purpose is to spread energy across dimensions and ensure predictable coordinate distributions, making coordinate-wise quantization computationally efficient. The trade-off is that it throws away any learnable structure in the original vectors. As someone in the thread summarized: “Intuitively it’s like minimizing the error when replacing values with a well-known distribution. All you need to carry along is the rotation and the assumption that there is some amount of loss.”</p>

<p>This is worth flagging for two reasons. First, attribution matters in research, and the community is right to expect it. Second, it underscores the power of the underlying idea: the rotation-then-quantize paradigm was independently useful enough that multiple research groups converged on it from different directions.</p>

<hr />

<h2 id="the-question-everyone-keeps-asking-why-did-google-just-give-this-away">The question everyone keeps asking: why did Google just give this away?</h2>

<p>When a company with Google’s resources publishes an algorithm that could reshape the economics of the entire AI industry, people get suspicious. The discussions in online communities clustered around a few theories:</p>

<p><strong>Theory 1: They already have something better.</strong> Probably the safest assumption. The arXiv preprint is from April 2025, a full year before the blog post. Publishing it now gets credit and goodwill without revealing the actual current state of internal tooling.</p>

<p><strong>Theory 2: Talent acquisition.</strong> Top ML researchers won’t join a company where they can’t publish. Letting researchers publish, even work that includes valuable techniques, is often a better trade than the attrition that comes from not letting them.</p>

<p><strong>Theory 3: Ecosystem pressure on memory.</strong> If TurboQuant reduces industry-wide demand for high-bandwidth memory, HBM prices moderate. Google runs its own TPU infrastructure, designed in-house. Reducing the whole industry’s memory pressure has asymmetric benefits for vertically integrated compute players.</p>

<p><strong>Theory 4: It’s just how science works.</strong> Google Research has a long history of publishing foundational work, including “Attention Is All You Need,” the Transformer paper that spawned the entire current LLM era. Sometimes researchers at well-resourced companies genuinely want to share what they figured out. As one commenter put it: “If there is one overwhelming instinct in tech folk, one constantly in conflict with the business side, it is the desire to share the latest clever idea.”</p>

<p>All four theories are probably partially true. The most honest answer is that ideas like TurboQuant are genuinely not rare enough to hoard for long. Given similar incentives and the same mathematical foundations, other labs were converging on similar conclusions. Getting there first, in public, is worth more as a reputational and recruiting asset than as a kept secret.</p>

<hr />

<h2 id="try-it-yourself">Try it yourself</h2>

<p>A clean from-scratch PyTorch implementation is available at <a href="https://github.com/tonbistudio/turboquant-pytorch">tonbistudio/turboquant-pytorch</a>. It includes a synthetic validation suite that tests the algorithm against the paper’s theoretical bounds, a real model validation script using Qwen2.5-3B-Instruct that you can run on any CUDA GPU with at least 6 GB VRAM, and readable implementations of all three components: Lloyd-Max codebook solver, TurboQuant MSE stage, and QJL residual correction.</p>

<p>Community implementations are already appearing in MLX for Apple Silicon, and it’s a matter of time before this lands in vLLM, llama.cpp, and the other major inference frameworks.</p>

<p>For the more visually inclined, someone turned the paper into an interactive Marimo notebook where you can drag sliders and watch the math happen in real time, exploring how random rotations, Beta distributions, and quantization interact. It’s the best way to build intuition before diving into the code.</p>

<hr />

<h2 id="the-part-nobody-is-pricing-in">The part nobody is pricing in</h2>

<p>TurboQuant is a <em>systems</em> innovation, not a <em>model</em> innovation. It doesn’t make the underlying model smarter. It makes the memory required to <em>run</em> a smart model dramatically smaller. And this category of innovation (inference efficiency, memory compression, hardware-software co-design) is where a disproportionate share of real-world AI progress is happening right now, mostly out of the spotlight.</p>

<p>The benchmark numbers are clean. Production is always messier: adversarial inputs, unusual token distributions, edge cases the paper didn’t test. The “zero accuracy loss” claim deserves healthy skepticism at scale. But the theoretical foundations here are solid. These results are provably near theoretical lower bounds for distortion, not just empirically observed on a handful of benchmarks.</p>

<p>And this is not an isolated breakthrough. It’s one piece of a larger picture in which better quantization, smarter memory management, more efficient attention mechanisms, and cheaper hardware are all improving simultaneously. The open-source models available today on consumer hardware are genuinely capable. The hardware you already own is meaningfully more powerful than it was 12 months ago. The trajectory is clear, and it’s accelerating.</p>

<hr />

<h2 id="closing-thought">Closing thought</h2>

<p>The fictional Pied Piper algorithm was a joke about Silicon Valley hubris: a solution looking for a problem, run by founders who couldn’t quite grasp what they’d built.</p>

<p>TurboQuant is the opposite story. Researchers who knew exactly what problem they were solving, grounded the solution in 40-year-old mathematics, proved it rigorously, tested it on real hardware, and then gave it away. Not because they had to. Because that’s what you do with good science.</p>

<p>The KV cache is the most expensive cheat sheet in the history of computing. It just got a lot cheaper.</p>

<hr />

<p><em>Sources:</em></p>

<ul>
  <li><a href="https://research.google/blog/turboquant-redefining-ai-efficiency-with-extreme-compression/">Google Research Blog</a></li>
  <li><a href="https://arxiv.org/abs/2504.19874">TurboQuant paper (arXiv)</a></li>
  <li><a href="https://github.com/tonbistudio/turboquant-pytorch">PyTorch implementation</a></li>
  <li><a href="https://arxiv.org/abs/2406.03482">QJL paper</a></li>
  <li><a href="https://arxiv.org/abs/2502.02617">PolarQuant paper</a></li>
</ul>]]></content><author><name></name></author><category term="KV Cache" /><category term="Vector Quantization" /><category term="LLM Inference" /><category term="Johnson-Lindenstrauss" /><category term="Transformer Attention" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Doc-to-LoRA &amp;amp; Text-to-LoRA: How Sakana is teaching LLMs to learn instantly</title><link href="https://snehal.dev/doc2lora/" rel="alternate" type="text/html" title="Doc-to-LoRA &amp;amp; Text-to-LoRA: How Sakana is teaching LLMs to learn instantly" /><published>2026-03-01T00:00:00+00:00</published><updated>2026-03-01T00:00:00+00:00</updated><id>https://snehal.dev/doc2lora</id><content type="html" xml:base="https://snehal.dev/doc2lora/"><![CDATA[<p align="center">
  <img src="/images/d2l.jpg" width="800" alt="BlinkThink Logo" />
</p>

<p>I’ve been spending a lot of time lately thinking about the “context window problem.” We’ve all been there: you have a massive 200-page document or a complex coding feature to implement, and you’re forced to choose between a slow, memory-hungry long-context prompt or an expensive, multi-hour fine-tuning session. It’s a frustrating trade-off.</p>

<p>But last week, I dug into some fascinating new research from Sakana AI that basically says: <em>“Why not both?”</em></p>

<p>They’ve introduced two new frameworks; <strong>Doc-to-LoRA (D2L)</strong> and <strong>Text-to-LoRA (T2L)</strong>; that use something called hypernetworks to instantly internalize information. We’re talking about moving from “reading” a document to “knowing” it in less than a second.</p>

<hr />

<h2 id="the-problem-with-standard-approaches">The Problem with Standard Approaches</h2>

<p>Passing long documents into an LLM prompt is the path of least resistance, but it hits three distinct walls at scale:</p>

<p><strong>High Latency:</strong> Attention cost scales quadratically with context length. Every token you add increases time-to-first-byte geometrically; a 200-page PDF that takes 2 seconds at 10K tokens takes over 80 seconds at 200K.</p>

<p><strong>Memory Spikes:</strong> A 128K-token document consumes roughly <strong>12GB of VRAM</strong> just for the KV cache. That’s before you’ve run a single inference. Concurrent users per GPU collapses fast.</p>

<p><strong>RAG Blindspots:</strong> Chunking breaks holistic understanding. RAG will find local facts but miss cross-document synthesis; the kind of reasoning that requires holding two distant paragraphs in mind simultaneously. The retriever returns the right chunks, but no single chunk contains the cross-regional signal you actually needed.</p>

<hr />

<h2 id="under-the-hood-amortized-adaptation">Under the Hood: Amortized Adaptation</h2>

<p>If you’re like me and want to know what’s actually happening in the code, the key word is <strong>amortization</strong>.</p>

<p>Traditionally, if you wanted to “distill” a document into a model’s parameters, you’d use <code class="language-plaintext highlighter-rouge">Context Distillation</code> (CD). You’d train a student model to mimic a teacher model that has the full context. The problem? It takes 40 to 100 seconds per document. Sakana AI bypasses this by paying a “one-time fee” during a meta-training phase to train a <strong>hypernetwork</strong>.</p>

<h3 id="the-hypernetwork-architecture">The Hypernetwork Architecture</h3>

<p>The hypernetwork ($H_\phi$) isn’t just a simple MLP. It uses a <strong>Perceiver-based backbone</strong>.</p>

<ol>
  <li><strong>Input:</strong> It takes the internal token activations from a frozen base LLM (like Gemma) as it “reads” the document.</li>
  <li><strong>Processing:</strong> The Perceiver architecture allows it to handle variable-length inputs and map them into fixed-shape representations.</li>
  <li><strong>Output:</strong> It predicts the $A$ and $B$ matrices for a <strong>Low-Rank Adaptation (LoRA)</strong> module.</li>
</ol>

<p>Mathematically, the goal is to minimize the KL divergence between the teacher (with context $c$) and the student (with generated weights $\Delta W_c$):</p>

\[\min_{\phi} \mathbb{E}_{c} [KL[p_\theta(y | x, c) \parallel p_{\theta+H_\phi(c)}(y | x)]]\]

<p>By training on thousands of diverse contexts, the hypernetwork learns the “rule” for how weights should change to represent new info.</p>

<h3 id="the-chunking-mechanism">The Chunking Mechanism</h3>

<p>How do you handle a document that’s 4x longer than the model’s native window? D2L uses a <strong>chunking mechanism</strong>. It breaks the document into 1,024-token pieces, generates a rank-8 LoRA for each, and then concatenates them. This allows the “parametric memory” to scale alongside the document length while maintaining near-perfect accuracy on “Needle-in-a-Haystack” tests.</p>

<p>The result is a dramatically different resource profile compared to the alternatives:</p>

<table>
  <thead>
    <tr>
      <th>Approach</th>
      <th>VRAM (128K doc)</th>
      <th>Update Latency</th>
      <th>Cross-doc Synthesis</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Long Context (KV cache)</td>
      <td>~12 GB</td>
      <td>;</td>
      <td>✓</td>
    </tr>
    <tr>
      <td>Traditional Context Distillation</td>
      <td>low</td>
      <td>40–100s</td>
      <td>✓</td>
    </tr>
    <tr>
      <td><strong>Doc-to-LoRA</strong></td>
      <td><strong>&lt;50 MB</strong></td>
      <td><strong>&lt;1s</strong></td>
      <td><strong>✓</strong></td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="real-world-example-megamarts-weekly-brief">Real-World Example: MegaMart’s Weekly Brief</h2>

<p>I always find these things easier to visualize with a domain example.</p>

<p>“MegaMart” HQ sends a <strong>200-page weekly PDF</strong> to 500 store managers every Monday. It contains complex, interconnected data: regional supply chain disruptions, dynamic pricing models, localized competitor analysis, and fresh produce shelf-life projections.</p>

<p><strong>The old way (RAG or long-context prompting):</strong> Managers query an AI using RAG. Because the data is chunked, the AI misses that an avocado shortage in Region A means pushing Guacamole kits in Region B; the cross-regional signal spans two sections of the document that never land in the same retrieval chunk. The alternative, passing the full 200 pages into the prompt, takes 15 seconds to load and costs <strong>$1.50 per question</strong>. With 500 managers asking dozens of questions daily, that’s tens of thousands of dollars a week in inference cost alone.</p>

<p><strong>The Doc-to-LoRA way:</strong> HQ passes the PDF through the Hypernetwork <strong>once</strong>. It generates a “Week 42 Strategy LoRA”; a compact adapter under 50MB. Every store manager loads this adapter. They now query the AI with <strong>zero input context tokens</strong>. The model inherently knows the entire document, synthesizes cross-regional strategies correctly (Region A avocado shortage → push Guacamole kits in Region B), and answers for <strong>fractions of a cent per query</strong>. The adapter is shared once; the context cost is never paid again.</p>

<hr />

<h2 id="why-this-matters">Why This Matters</h2>

<p>The efficiency gains here are honestly staggering:</p>

<ul>
  <li><strong>VRAM:</strong> For a 128K-token doc, a standard model needs <strong>12GB</strong> of VRAM for the KV cache. Doc-to-LoRA needs less than <strong>50MB</strong>.</li>
  <li><strong>Latency:</strong> Update times drop from <strong>~100 seconds</strong> to <strong>&lt;1 second</strong>.</li>
  <li><strong>Accuracy:</strong> <strong>98.5% Needle-in-a-Haystack</strong> retrieval success at 4x the model’s native context limit.</li>
</ul>

<p>We’re moving toward a world where LLMs aren’t static blocks of weights. With hypernetworks, we can have “living” models that adapt to new information or specialized tasks as fast as we can describe them.</p>

<p>While there are still challenges; like the “accuracy gap” compared to slow, traditional distillation or potential interference between multiple adapters; this is a massive leap for on-device intelligence and privacy-first personalization.</p>

<p>I’m excited to see where this goes. If you want to dive into the code yourself, Sakana has released the weights and the implementation on GitHub. It’s definitely worth a weekend project.</p>

<p><strong>References:</strong></p>
<ol>
  <li><a href="https://pub.sakana.ai/doc-to-lora/">Sakana pub: Instant LLM Updates with Doc-to-LoRA and Text-to-LoRA</a></li>
  <li><a href="https://github.com/SakanaAI/text-to-lora">GitHub: Doc-to-LoRA and Text-to-LoRA</a></li>
  <li><a href="https://arxiv.org/abs/2602.15902">Arxiv: Doc-to-LoRA: Learning to Instantly Internalize Contexts</a></li>
  <li><a href="https://arxiv.org/abs/2506.06105">Arxiv: Text-to-LoRA: Instant Transformer Adaption</a></li>
</ol>]]></content><author><name></name></author><category term="LLM" /><category term="LoRA" /><category term="Hypernetworks" /><category term="Fine-Tuning" /><category term="AI Research" /><category term="RAG" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">BlinkThink: Self-Hosted Camera Snapshots with FastAPI and Gemini</title><link href="https://snehal.dev/blinkthink/" rel="alternate" type="text/html" title="BlinkThink: Self-Hosted Camera Snapshots with FastAPI and Gemini" /><published>2026-02-26T00:00:00+00:00</published><updated>2026-02-26T00:00:00+00:00</updated><id>https://snehal.dev/blinkthink</id><content type="html" xml:base="https://snehal.dev/blinkthink/"><![CDATA[<p align="center">
  <img src="https://raw.githubusercontent.com/spate141/BlinkThink/refs/heads/master/static/logo.png" width="200" alt="BlinkThink Logo" />
</p>

<p>If you own a Blink camera, you have accepted a certain set of implicit terms: your footage lives on Amazon’s servers, you pay a subscription to access more than a handful of clips, and the moment their service has a bad afternoon your security feed goes dark. For casual use, this is fine. But if you want to build any kind of custom workflow around your cameras: scheduled snapshots, programmatic triggers, automated analysis: the Blink app gives you nothing. There is no API, no export, no hooks. Just a mobile interface and a cloud you do not control.</p>

<p><strong><code class="language-plaintext highlighter-rouge">BlinkThink</code></strong> is my answer to that constraint: a lightweight, self-hosted Python web app that wraps the Blink camera API in a FastAPI server, stores snapshots locally, and optionally runs them through Gemini for structured image analysis. You own the stack. You own the data.</p>

<hr />

<h2 id="the-problem-with-cloud-dependent-cameras">The Problem with Cloud-Dependent Cameras</h2>

<p>The frustration with cloud cameras is not really about the cameras themselves: Blink hardware is fine. The frustration is the dependency surface. When Amazon has an S3 outage, your security footage is unavailable. When Blink changes their subscription tiers, features you relied on disappear behind a paywall. When their app gets a forced update, the interface you had memorized changes.</p>

<p>More fundamentally: you have no way to know how long your footage is retained, who can access it under what legal circumstances, or what happens to historical clips if you cancel your subscription. These are not paranoid concerns: they are straightforward consequences of putting your data in someone else’s system.</p>

<p>The Blink app is adequate for checking in on a camera from your phone. It is useless if you want to build anything on top of it. The open-source <strong><code class="language-plaintext highlighter-rouge">blinkpy</code></strong> library solves exactly this: it reverse-engineers the Blink API and exposes it as a Python client, giving you programmatic access to authentication, camera metadata, and snapshot capture. BlinkThink builds the rest of the stack on top of it.</p>

<hr />

<h2 id="what-i-built">What I Built</h2>

<p><a href="https://github.com/spate141/BlinkThink"><code class="language-plaintext highlighter-rouge">BlinkThink</code></a> is a self-hosted FastAPI application that connects to your Blink account, captures snapshots from any of your cameras on demand, stores them locally as JPEGs, and serves a web gallery for browsing them. MFA is supported. Multiple cameras work. The gallery filters by camera. An optional Gemini integration can analyze any snapshot and return structured scene descriptions.</p>

<p>Starting the server is a single command:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>uv run uvicorn main:app <span class="nt">--reload</span>
</code></pre></div></div>

<p>No database. No external dependencies beyond the Blink API and an optional Gemini API key. A few hundred lines of Python.</p>

<hr />

<h2 id="under-the-hood-the-architecture">Under the Hood: The Architecture</h2>

<p>The app has three distinct layers that are worth walking through separately.</p>

<h3 id="layer-1-fastapi-backend-mainpy">Layer 1: FastAPI Backend (<code class="language-plaintext highlighter-rouge">main.py</code>)</h3>

<p>The entry point uses FastAPI’s <strong>lifespan context manager</strong> to handle startup tasks: creating the local snapshot directory and attempting auto-login from persisted credentials before the server starts accepting requests. If auto-login fails, the server starts anyway and waits for a manual login through the UI.</p>

<p>The REST API is cleanly divided by concern:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">/api/auth/login</code> and <code class="language-plaintext highlighter-rouge">/api/auth/verify-mfa</code> handle the two-step authentication flow</li>
  <li><code class="language-plaintext highlighter-rouge">/api/cameras</code> returns the list of available cameras from the active Blink session</li>
  <li><code class="language-plaintext highlighter-rouge">/api/snapshot/{camera_id}</code> triggers a snapshot capture and writes the JPEG to disk</li>
  <li><code class="language-plaintext highlighter-rouge">/api/analyze</code> accepts image bytes and an optional prompt, returns structured Gemini analysis</li>
</ul>

<p><strong>Pydantic models</strong> (<code class="language-plaintext highlighter-rouge">LoginRequest</code>, <code class="language-plaintext highlighter-rouge">MfaRequest</code>) validate all inputs at the API boundary. CORS is restricted to configured origins: never a wildcard. The intent is that this runs on your local network, not exposed to the internet, but that is not an excuse for sloppy defaults.</p>

<h3 id="layer-2-blink-client-clientsblink_clientpy">Layer 2: Blink Client (<code class="language-plaintext highlighter-rouge">clients/blink_client.py</code>)</h3>

<p>The Blink client is a <strong>singleton</strong>: instantiated once at module level and shared across all requests. This matters because <code class="language-plaintext highlighter-rouge">blinkpy</code> session state is not cheap to recreate. Re-authenticating on every request would be slow, fragile, and likely to trigger rate limiting. The singleton pattern keeps a single live session for the application’s lifetime.</p>

<p>The authentication flow mirrors Blink’s two-step OAuth handshake:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Step 1: initiate login
</span><span class="k">await</span> <span class="n">client</span><span class="p">.</span><span class="n">start_login</span><span class="p">(</span><span class="n">email</span><span class="p">,</span> <span class="n">password</span><span class="p">)</span>
<span class="c1"># Raises BlinkTwoFARequiredError if MFA is needed
</span>
<span class="c1"># Step 2: complete MFA
</span><span class="k">await</span> <span class="n">client</span><span class="p">.</span><span class="n">verify_mfa</span><span class="p">(</span><span class="n">pin</span><span class="p">)</span>
<span class="c1"># Persists credentials to blink_credentials.json
</span></code></pre></div></div>

<p>After a successful <code class="language-plaintext highlighter-rouge">verify_mfa()</code>, credentials are written to disk. On the next server restart, the lifespan hook picks them up and auto-logs in. The token chain stays alive across restarts without requiring the user to re-authenticate each time.</p>

<p>Snapshot capture works by calling <code class="language-plaintext highlighter-rouge">snap_picture()</code> on the camera object, then reading <code class="language-plaintext highlighter-rouge">camera._cached_image</code>: the bytes that <code class="language-plaintext highlighter-rouge">blinkpy</code> holds in memory after the snap. The method is <code class="language-plaintext highlighter-rouge">get_snapshot_bytes()</code> and it returns raw JPEG bytes that the endpoint then passes to the filesystem layer.</p>

<h3 id="layer-3-filesystem-utils">Layer 3: Filesystem (<code class="language-plaintext highlighter-rouge">utils/</code>)</h3>

<p>Two small utilities do the work of keeping the snapshot directory clean and navigable.</p>

<p><code class="language-plaintext highlighter-rouge">sanitize_path_segment()</code> converts camera names into safe directory names. A camera named “Front Door” becomes <code class="language-plaintext highlighter-rouge">Front_Door</code>. This is a necessary step: camera names can contain spaces, slashes, and other characters that would break filesystem paths or URL routing.</p>

<p><code class="language-plaintext highlighter-rouge">snapshot_timestamp()</code> returns the current time as <code class="language-plaintext highlighter-rouge">YYYYMMDD_HHMMSS</code>. Combined with the camera name, this gives you filenames like <code class="language-plaintext highlighter-rouge">20260226_143022.jpg</code> that sort chronologically without any tooling.</p>

<p>The resulting storage layout looks like this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>snapshots/
├── Front_Door/
│   └── 20260226_143022.jpg
└── Back_Patio/
    └── 20260226_144001.jpg
</code></pre></div></div>

<p>Every file is human-readable and can be browsed with any file manager or copied off the machine without any export step. This is the point.</p>

<p>All I/O across all three layers: Blink API calls, file writes, Gemini requests: is <strong>async throughout</strong>, using <code class="language-plaintext highlighter-rouge">asyncio</code> and <code class="language-plaintext highlighter-rouge">aiofiles</code>. Nothing blocks the event loop.</p>

<hr />

<h2 id="three-design-decisions-worth-calling-out">Three Design Decisions Worth Calling Out</h2>

<h3 id="singleton-client-with-persistent-auth-state">Singleton client with persistent auth state</h3>

<p>The alternative to a singleton: re-authenticating on each request: would technically work but would be wrong in several ways. Blink’s authentication involves network calls, credential exchange, and session initialization. Running that on every snapshot request adds latency, burns rate limits, and creates a failure mode where a temporary network blip mid-request leaves you in a half-authenticated state.</p>

<p>The singleton avoids all of this. One auth, one session, persistent for the process lifetime. The lifespan hook handles the startup case gracefully: try auto-login from disk, succeed silently, or fall back to manual login without crashing. Credentials are refreshed and re-persisted after each successful auto-login, so the <code class="language-plaintext highlighter-rouge">blink_credentials.json</code> on disk stays current.</p>

<h3 id="filesystem-first-snapshot-storage">Filesystem-first snapshot storage</h3>

<p>BlinkThink has no database. Every snapshot write is a direct <code class="language-plaintext highlighter-rouge">aiofiles.open()</code> call to a predictable path. The gallery endpoint reads the filesystem: no metadata index, no ORM, no query. This is a deliberate choice, not an oversight.</p>

<p>Databases are the right tool for structured queries across large datasets with complex relationships. A gallery of camera snapshots does not have complex relationships. The data is flat: camera name, timestamp, JPEG bytes. A directory tree encodes all of that naturally, and any file browser, <code class="language-plaintext highlighter-rouge">rsync</code> command, or Python <code class="language-plaintext highlighter-rouge">os.walk()</code> can work with it directly. The total moving-parts count stays low. There is nothing to migrate, nothing to corrupt, nothing to back up separately from the images themselves.</p>

<h3 id="structured-ai-prompting-not-freeform">Structured AI prompting, not freeform</h3>

<p>The Gemini integration uses a constrained system prompt rather than asking the model to describe images freely. The output is structured into four specific fields: Scene, Subjects, Activity, and Flags. The instruction is explicit: facts only, no filler, no speculation beyond what is visible in the frame.</p>

<p>This matters in practice. Freeform image descriptions from large models tend to hedge. They add phrases like “it appears that” and “the image seems to show” and “upon closer inspection.” This hedging makes sense for uncertain inputs, but for a camera image with a clear scene it is just noise. The structured prompt forces the model to commit to concrete observations and surfaces the useful signal: an unusual vehicle in the driveway, a person at the door, an animal in the yard: without padding.</p>

<hr />

<h2 id="the-gemini-layer-making-cameras-think">The Gemini Layer: Making Cameras Think</h2>

<p><strong><code class="language-plaintext highlighter-rouge">GeminiClient</code></strong> wraps the <code class="language-plaintext highlighter-rouge">google-genai</code> SDK with async support and rate-limit handling. The default model is <code class="language-plaintext highlighter-rouge">gemini-2.5-flash</code>, configurable via the <code class="language-plaintext highlighter-rouge">GEMINI_MODEL</code> environment variable.</p>

<p>Extended thinking is enabled with a 2048-token budget. For a camera snapshot, this is overkill most of the time: but for edge cases where the scene is ambiguous or partially occluded, the extra reasoning budget consistently produces better structured output than greedy decoding.</p>

<p>Rate limiting is handled with an asyncio semaphore. When the API returns a 429, the client waits 30 seconds before retrying. This is simple and effective. If the API is unavailable for any reason, the <code class="language-plaintext highlighter-rouge">/api/analyze</code> endpoint returns a clean error response rather than propagating an exception to the UI. The snapshot workflow: capture, store, display: works entirely independently of Gemini.</p>

<p>This is worth stating directly: <strong>AI analysis is optional and opt-in</strong>. You do not need a Gemini API key to run BlinkThink. The core functionality: logging in, capturing snapshots, browsing the local gallery: works without any AI configuration. Gemini is an enhancement, not a dependency.</p>

<hr />

<h2 id="closing-thoughts">Closing Thoughts</h2>

<p>The interesting engineering problem in BlinkThink turned out not to be the Gemini integration. That part was straightforward: initialize the client, pass image bytes, parse structured output. The harder problem was building a clean authentication flow around <code class="language-plaintext highlighter-rouge">blinkpy</code>, handling the two-step MFA handshake, deciding when and how to persist credentials, and making the whole thing work reliably across server restarts without requiring the user to re-authenticate every time.</p>

<p>Credential persistence without a database sounds simple and mostly is, but the edge cases: expired tokens, failed auto-login, partial auth state: each need a defined behavior. “Fall back gracefully” is easy to say and takes some care to actually implement.</p>

<p>The broader point: owning your data does not have to mean complexity. The whole app is a few hundred lines of Python. You get a running web server, a local gallery, multi-camera support, and optional AI analysis. No cloud subscription, no third-party retention policy, no dependency on someone else’s uptime.</p>

<p>If you run Blink cameras and want to do more with them than the app allows, <a href="https://github.com/spate141/BlinkThink">BlinkThink</a> is a reasonable starting point. Contributions welcome: obvious directions include motion detection triggers, scheduled snapshot captures, and support for additional camera backends.</p>]]></content><author><name></name></author><category term="Python" /><category term="FastAPI" /><category term="Gemini" /><category term="blinkpy" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">Building a Browser Agent with Gemini and Playwright</title><link href="https://snehal.dev/building-a-browser-agent/" rel="alternate" type="text/html" title="Building a Browser Agent with Gemini and Playwright" /><published>2026-02-15T00:00:00+00:00</published><updated>2026-02-15T00:00:00+00:00</updated><id>https://snehal.dev/building-a-browser-agent</id><content type="html" xml:base="https://snehal.dev/building-a-browser-agent/"><![CDATA[<p align="center">
  <img src="https://raw.githubusercontent.com/spate141/browser-agent/refs/heads/main/logo.png" width="200" alt="Browser Agent Logo" />
</p>

<p>If you have written Selenium or Playwright scripts for more than a few months, you know the ritual. You spend an afternoon automating some workflow, everything works perfectly, and then the site ships a new build and your carefully crafted XPath dissolves into a <code class="language-plaintext highlighter-rouge">NoSuchElementException</code>. You patch it. A week later it breaks again. At some point the maintenance cost quietly exceeds the value of the automation itself.</p>

<p>The deeper problem is that traditional browser automation is <strong>imperative</strong>: you are encoding a specific sequence of actions against a specific DOM structure. Any change to the site’s structure invalidates your script. For one-off tasks or fast-moving targets, this is a bad trade. What you actually want is to describe <em>intent</em>: “go to this site and do this thing”: and have something else figure out the mechanics.</p>

<p>That is the premise behind <strong><code class="language-plaintext highlighter-rouge">browser-agent</code></strong>: a lightweight Python tool that wires Gemini’s function-calling API to a live Playwright browser, turning plain-English instructions into real browser actions.</p>

<hr />

<h2 id="the-problem-with-traditional-browser-automation">The Problem with Traditional Browser Automation</h2>

<p>CSS selectors and XPaths are brittle by design. They are references into a tree structure that nobody promised would stay stable. When developers refactor markup, add a wrapper <code class="language-plaintext highlighter-rouge">div</code>, or switch from IDs to data attributes for better test hygiene, your automation silently breaks.</p>

<p>Beyond fragility, scripts are <strong>task-specific</strong>. The script that logs into your dashboard and exports a CSV cannot be repurposed for a different site without a full rewrite. Each new workflow requires a fresh encoding of element locations and action sequences. For any organization running a dozen automations, this becomes a part-time maintenance job.</p>

<p>The combination of <strong>LLMs and tool-use</strong> changes this equation. A model that can read a snapshot of the current page and decide which element to click next does not care what the site looked like last month. It reasons about current state, not encoded assumptions. The script stops being a rigid plan and becomes a responsive loop.</p>

<hr />

<h2 id="what-i-built">What I Built</h2>

<p><a href="https://github.com/spate141/browser-agent"><code class="language-plaintext highlighter-rouge">browser-agent</code></a> is an open-source Python project that gives Gemini eight browser tools and a Playwright Chromium session, then steps back and lets the model drive.</p>

<p>It supports: navigating to URLs, capturing page snapshots, clicking elements, typing into fields, exporting pages as PDFs, going back in history, and waiting. There is both a Python API and a CLI. The one-liner that motivated the whole thing:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">&gt;&gt;</span> browser-agent <span class="s2">"Go to Hacker News and save the front page as a PDF"</span>
</code></pre></div></div>

<p>No selectors. No page objects. Just the task.</p>

<hr />

<h2 id="under-the-hood-the-agentic-loop">Under the Hood: The Agentic Loop</h2>

<p>The core architecture is a tight loop between Gemini and a live browser. Here is how a single run unfolds:</p>

<p><strong>1. Task input.</strong> The user provides a plain-English instruction, either via CLI argument or the Python <code class="language-plaintext highlighter-rouge">run_browser_agent()</code> function.</p>

<p><strong>2. Task refinement (optional).</strong> Before the main loop starts, <code class="language-plaintext highlighter-rouge">task_generator.py</code> makes a separate Gemini call at temperature 0.3. It converts open-ended prompts into 3–10 numbered steps. This decouples <em>intent clarification</em> from <em>execution</em>: the main loop receives a concrete plan rather than an ambiguous request. If the generator fails for any reason, the original input passes through unchanged. Graceful degradation, not a hard error.</p>

<p><strong>3. Browser and client initialization.</strong> <code class="language-plaintext highlighter-rouge">run_browser_agent()</code> launches a Playwright Chromium instance with anti-bot configuration applied (more on this below), initializes the Gemini client, and prepares the tool schema for all eight browser functions.</p>

<p><strong>4. The loop (≤ 50 iterations).</strong> Each iteration follows the same pattern:</p>
<ul>
  <li>Send the full conversation history plus the eight-function tool schema to Gemini</li>
  <li>Gemini returns a <code class="language-plaintext highlighter-rouge">function_call</code> response naming one of the eight tools and its arguments</li>
  <li>Execute that tool call against the live browser</li>
  <li>Append the result to conversation history</li>
  <li>Repeat</li>
</ul>

<p><strong>5. Exit conditions.</strong> The loop exits when Gemini calls <code class="language-plaintext highlighter-rouge">task_complete()</code> (success) or <code class="language-plaintext highlighter-rouge">task_failed()</code> (the model has determined it cannot finish the task). The 50-iteration cap is a safety rail against runaway loops.</p>

<p>The eight tools available to the model:</p>

<table>
  <thead>
    <tr>
      <th>Tool</th>
      <th>What it does</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_navigate(url)</code></td>
      <td>Load a URL</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_snapshot()</code></td>
      <td>Capture interactive elements + page text</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_click(index)</code></td>
      <td>Click the nth element</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_type(index, text, submit)</code></td>
      <td>Fill a field</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_pdf(filename)</code></td>
      <td>Export page as PDF</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_back()</code></td>
      <td>Go back in history</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">browser_wait(seconds)</code></td>
      <td>Pause</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">task_complete / task_failed</code></td>
      <td>Signal outcome</td>
    </tr>
  </tbody>
</table>

<p>The whole thing is stateless across runs but stateful within one: Gemini sees the full history of what it has done at every step.</p>

<hr />

<h2 id="three-design-decisions-worth-calling-out">Three Design Decisions Worth Calling Out</h2>

<h3 id="indexed-element-references-instead-of-raw-dom">Indexed element references instead of raw DOM</h3>

<p>The most important implementation choice is what the model actually sees when it reads a page. Serializing a full DOM tree into the prompt is expensive in tokens and noisy in signal. Most of the DOM is irrelevant to any given action.</p>

<p><code class="language-plaintext highlighter-rouge">Browser.snapshot()</code> takes a different approach: it runs a single JavaScript evaluation that finds all <strong>visible interactive elements</strong> on the page: links, buttons, inputs, selects: assigns each an integer index starting from 0, and returns a compact JSON map of index → element description. The prompt stays small. Gemini only needs to say <code class="language-plaintext highlighter-rouge">browser_click(index=7)</code> to click the eighth interactive element on the page. It never has to reason about XPaths, CSS selectors, or DOM hierarchy.</p>

<p>This also means the index space is stable within a single snapshot. The model can call <code class="language-plaintext highlighter-rouge">browser_snapshot()</code>, read the result, and immediately reference any element by its index in the next step. Deterministic and cheap.</p>

<h3 id="task-generator-for-vague-inputs">Task generator for vague inputs</h3>

<p>Vague prompts are the hardest input for an action loop. “Summarize the top stories on Hacker News” could mean five different things depending on how you interpret “top” and “summarize.” If that ambiguity hits the main loop directly, the model has to resolve it under execution pressure: while also managing browser state.</p>

<p>Separating the <em>clarification step</em> from the <em>execution step</em> keeps both simpler. The task generator makes one focused call: take this open-ended request, return numbered steps. Temperature 0.3 keeps it deterministic. The output: a clean numbered list: is what the main loop actually runs against.</p>

<p>The fallback matters: if the generator call fails or returns garbage, the original prompt passes through. The system degrades gracefully rather than halting on a pre-processing failure.</p>

<h3 id="anti-bot-configuration-without-third-party-packages">Anti-bot configuration without third-party packages</h3>

<p>Browser fingerprinting is a real concern for any automation tool. The typical response in the Selenium ecosystem is to reach for a stealth library: a package that patches browser internals to hide automation signals. These libraries tend to be fragile, lag behind browser releases, and add dependency surface area.</p>

<p><code class="language-plaintext highlighter-rouge">browser-agent</code> takes a lighter approach: a custom User-Agent string, the <code class="language-plaintext highlighter-rouge">--disable-blink-features=AutomationControlled</code> Chromium launch flag, and a one-line JavaScript snippet on page load that deletes <code class="language-plaintext highlighter-rouge">navigator.webdriver</code>. Three lines of configuration, no extra package, minimal maintenance burden. The README is honest that this won’t defeat every detection system: but for most casual use cases, it is sufficient and far easier to maintain than a full stealth wrapper.</p>

<hr />

<h2 id="a-quick-walkthrough">A Quick Walkthrough</h2>

<p>Here is a concrete example: “Go to DuckDuckGo, search for ‘best Python libraries 2025’, and save the results as a PDF.”</p>

<p>After the task generator breaks this into steps, the main loop produces something like:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Step 1 → browser_navigate("https://duckduckgo.com")
Step 2 → browser_snapshot()          # finds search box at index 4
Step 3 → browser_type(4, "best Python libraries 2025", submit=True)
Step 4 → browser_wait(2)
Step 5 → browser_pdf("ddg_results")
Step 6 → task_complete("Saved results to ddg_results.pdf")
</code></pre></div></div>

<p>Gemini decided every one of those steps. The code just executed them, captured results, and fed them back. The <code class="language-plaintext highlighter-rouge">browser_snapshot()</code> call in step 2 is what gave the model enough information to know the search box was at index 4: it read the element map and made an inference.</p>

<p>Six steps, zero selectors written by hand.</p>

<hr />

<h2 id="closing-thoughts">Closing Thoughts</h2>

<p>The interesting lesson from building <code class="language-plaintext highlighter-rouge">browser-agent</code> is that the LLM integration was not the hard part. Gemini’s function-calling API is well-designed; once you define the tool schema, the model uses it reliably. The hard part was building a <strong>clean, token-efficient interface</strong> between the model and the browser.</p>

<p>Indexed snapshots instead of raw DOM. Graceful fallback on pre-processing failures. Simple anti-bot configuration that does not require external packages. None of those are AI problems: they are interface design problems. Getting that layer right made everything else fall into place.</p>

<p>If you want to try it, extend it with your own tools, or just poke at the internals, the code is on GitHub: <a href="https://github.com/spate141/browser-agent">github.com/spate141/browser-agent</a>. Stars and PRs welcome.</p>]]></content><author><name></name></author><category term="Python" /><category term="Gemini" /><category term="Playwright" /><category term="Browser Automation" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">OpenClaw: 98% Plumbing, 2% Revolution</title><link href="https://snehal.dev/openclaw-98-revolution/" rel="alternate" type="text/html" title="OpenClaw: 98% Plumbing, 2% Revolution" /><published>2026-02-08T00:00:00+00:00</published><updated>2026-02-08T00:00:00+00:00</updated><id>https://snehal.dev/openclaw-98-revolution</id><content type="html" xml:base="https://snehal.dev/openclaw-98-revolution/"><![CDATA[<p><img src="https://miro.medium.com/v2/resize:fit:700/1*Djis1yxPYMJdPt-o8X3E1w.png" alt="" /></p>

<p>The hype cycle for agentic AI reached a fever pitch over the last couple of weeks, and at the center of the storm sits <strong>OpenClaw (aka Moltbot aka Clawdbot)</strong>. Depending on which corner of internet you inhabit, it is either the <em>“iPhone moment”</em> for personal AI agents or a sophisticated exercise in <em>“marketed obfuscation.”</em></p>

<hr />

<h2 id="anatomy-of-the-bot">Anatomy of the Bot</h2>

<p>At its core, OpenClaw is an orchestration layer. It doesn’t ship its own model weights or a proprietary vision model. Instead, it glues together three mature technologies into a single <em>“heartbeat”</em> loop:</p>

<ul>
  <li><strong>The Browser Interface (Playwright):</strong> OpenClaw utilizes Microsoft’s Playwright library to programmatically navigate the web. The <em>“magic”</em> of its web navigation isn’t a breakthrough in spatial reasoning; it relies on Playwright’s built-in vision model to convert DOM elements and screenshots into textual descriptions that an LLM can parse.</li>
  <li><strong>The Reasoning Engine (Frontier LLMs):</strong> Whether it’s Claude 3.5 Sonnet, Opus, or GPT-4o, the <em>“intelligence”</em> is outsourced. The agent blindly dispatches user prompts <em>(e.g., “Buy me banana from Kroger”)</em> to these models, which then decide which tool — like Playwright or a terminal — to call.</li>
  <li><strong>The Memory Layer (Grep &amp; Text):</strong> Perhaps the most polarizing technical detail is its memory. Rather than a complex vector database (RAG) for everything, the system often relies on appending conversation history to text files and using standard grep commands controlled by the LLM to retrieve past context.</li>
</ul>

<hr />

<h2 id="2-innovation">2% Innovation?</h2>

<p>Critics argue that OpenClaw is 98% hype and 2% unoriginal plumbing. The argument is simple: if you are a PhD-level researcher or a senior engineer, you’ve likely been cobbling together LLM tool-calling with browser automation for years. From this perspective, calling OpenClaw <em>“revolutionary”</em> is like calling a shell script that runs rsync a breakthrough in cloud storage.</p>

<p>The dismantling of its workflow reveals a high level of <em>“blind dispatching.”</em> The LLM decides the URL, Playwright returns the text, and OpenClaw simply passes the messages back and forth.</p>

<hr />

<h2 id="so-then-why-is-everyone-so-obsessed">So Then Why Is Everyone So Obsessed?</h2>

<p>However, focusing solely on the lack of novel code misses the point of <strong>consumer-facing architecture.</strong> The brilliance of OpenClaw — and the reason it has achieved critical mass — lies in its <strong>Unified Gateway.</strong></p>

<ul>
  <li><strong>The Always-On Heartbeat:</strong> Unlike standard <em>“prompt-and-respond”</em> interfaces, OpenClaw introduces a persistent execution loop. It supports cron jobs and autonomous background cycles, allowing it to perform tasks like <em>“audit my emails every 4 hours”</em> without human intervention.</li>
  <li><strong>Zero-Guardrail Flexibility:</strong> By running on local hardware (like the ubiquitous Mac Mini home-lab setup), it bypasses the <em>“moralizing”</em> guardrails of enterprise wrappers. It can edit its own source code, build new <em>“skills”</em> (sub-agents), and coordinate across multiple sessions.</li>
  <li><strong>The Packaging Win:</strong> It reduces the friction of agentic deployment. What used to require a complex LangGraph setup or custom Python environments can now be spun up with a single command, integrating Telegram, WhatsApp, and Discord as the primary UI.</li>
</ul>

<hr />

<h2 id="ux-innovation-is-still-kind-of-innovation-though">UX Innovation Is Still Kind of Innovation Though!</h2>

<p>The whole debate over OpenClaw highlights a recurring theme in software history. Just as Dropbox was dismissed as <em>“rsync + SFTP”</em> and the iPhone as a <em>“phone + iPod + browser”</em>, OpenClaw is being criticized for its reliance on existing components.</p>

<p>The takeaway is clear: the innovation here isn’t in the <strong><em>model</em></strong>, but in the <strong><em>harness</em></strong>. OpenClaw provides a clean, malleable architectural design for gateways and channels that allows agents to interact with the <em>“dirty”</em> web and local OS APIs in a way that feels seamless to the end user.</p>

<p>It may be <em>“just plumbing”</em> — but in a world of fragmented AI tools, the person who connects the pipes is the one who controls the flow. Whether OpenClaw is a <em>“hobby project”</em> or the foundation of a new agentic era depends entirely on whether you value the elegance of the algorithm or the utility of the system.</p>]]></content><author><name></name></author><category term="Python" /><category term="Playwright" /><category term="Claude" /><category term="Agentic AI" /><summary type="html"><![CDATA[]]></summary></entry><entry><title type="html">The 10-Million Token Paradox: Decoding the Logic of Recursive Language Models</title><link href="https://snehal.dev/the-10-million-token-paradox-rlm/" rel="alternate" type="text/html" title="The 10-Million Token Paradox: Decoding the Logic of Recursive Language Models" /><published>2026-02-08T00:00:00+00:00</published><updated>2026-02-08T00:00:00+00:00</updated><id>https://snehal.dev/the-10-million-token-paradox-rlm</id><content type="html" xml:base="https://snehal.dev/the-10-million-token-paradox-rlm/"><![CDATA[<blockquote>
  <ul>
    <li>Context windows are getting bigger.</li>
    <li>Reasoning is getting worse.</li>
    <li>And somewhere, a Transformer is crying softly.</li>
  </ul>
</blockquote>

<hr />

<h2 id="context-rot">Context Rot</h2>

<blockquote>
  <p>We thought million-token prompts would make models smarter. Instead, they made them confidently wrong at industrial scale.</p>
</blockquote>

<p>The landscape of large language models is hitting a fundamental wall: <strong>Context Rot</strong>. Even as context windows expand into the millions, the quality of reasoning degrades steeply as prompts grow longer. We are moving past the era of “neural attention only” and into the era of <strong>Inference-Time Scaling</strong>.</p>

<p>The combination of the <strong>Recursive Language Model (RLM)</strong> paradigm and Google’s <strong>Agent Development Kit (ADK)</strong> represents the most significant architectural shift of 2026. This isn’t just about “more tokens”: it’s about treating the prompt as a programmatically accessible environment rather than a static input string.</p>

<hr />

<h2 id="prompts-as-environments">Prompts as Environments</h2>

<p>Traditional LLMs ingest tokens directly into their neural network. RLMs invert this. An RLM treats a massive user prompt as an external environment accessed via a <strong>Read-Eval-Print Loop (REPL)</strong>. Instead of feeding tokens into a Transformer, the RLM initializes a persistent programming environment where the prompt is stored as a variable. The model then writes Python code to “peek” into specific slices of this data.</p>

<p><strong>LLMs:</strong> <em>“Here’s 800,000 tokens. Good luck, soldier.”</em></p>

<p><strong>RLMs:</strong> <em>“No ❤️”</em></p>

<hr />

<h2 id="google-adk-and-the-recursive-loop">Google ADK and the Recursive Loop</h2>

<p>The RLM loop works in four stages:</p>

<ol>
  <li><strong>Metadata Initialization</strong>: The root model (<code class="language-plaintext highlighter-rouge">depth=0</code>) receives only constant-size metadata (length, prefix) about the prompt.</li>
  <li><strong>Symbolic Decomposition</strong>: The agent writes code to partition the context into manageable chunks (e.g., splitting a book by chapters).</li>
  <li><strong>Recursive Invocation</strong>: The agent calls <code class="language-plaintext highlighter-rouge">rlm_agent(query, context)</code>, spawning child agents (<code class="language-plaintext highlighter-rouge">depth=1+</code>) to process those chunks.</li>
  <li><strong>Aggregation</strong>: The root model collects results from sub-agents and builds the final answer in the REPL environment.</li>
</ol>

<p>While the original MIT paper proved the theoretical scaling of RLMs to 10M+ tokens, Google’s <strong>Agent Development Kit (ADK)</strong> provides the infrastructure required for industrial application.</p>

<h3 id="beyond-the-mit-implementation">Beyond the MIT Implementation</h3>

<p>The ADK implementation introduces several critical technical extensions:</p>

<ul>
  <li><strong>Massive Parallelism</strong>: The original RLM sub-agents were run sequentially to avoid quota limits. ADK enables concurrent task execution with configurable limits, drastically reducing latency for information-dense tasks.</li>
  <li><strong>Connected Data Systems</strong>: While research RLMs viewed the prompt as a simple string, ADK uses <code class="language-plaintext highlighter-rouge">Path</code> objects. This allows the agent to invoke methods on data residing in <strong>Google Cloud Storage (GCS)</strong> buckets or local filesystems without ever loading the full content into the model’s primary window.</li>
  <li><strong>Real-Time UI Visualization</strong>: Long-running recursive tasks can be opaque. ADK streams events in real-time, allowing developers to visualize the recursion tree and validate the agent’s decomposition strategy as it happens.</li>
</ul>

<h3 id="context-was-never-the-problem">Context Was Never the Problem!</h3>

<p>We thought the bottleneck was: <em>“Not enough tokens.”</em> It wasn’t.</p>

<p>The real bottleneck was: <strong>reasoning over too much stuff at once.</strong></p>

<p>RLMs + ADK flip the script:</p>
<ul>
  <li>Context lives <strong>outside</strong> the model</li>
  <li>Reasoning happens <strong>symbolically</strong></li>
  <li>Scale comes from <strong>structure</strong>, not brute force</li>
</ul>

<h3 id="engineering-challenges-alignment-and-cost">Engineering Challenges: Alignment and Cost</h3>

<p>Recursion is powerful, but it can spread small mistakes and hidden spend across the system.</p>

<ul>
  <li><strong>Cost Variance</strong>: RLM inference remains comparable to base LM calls on average, but tail-end costs can spike if an agent enters a complex decomposition loop.</li>
  <li><strong>Stopping Conditions</strong>: A critical missing layer is structural stop conditions: knowing when more comparison no longer yields more correctness.</li>
</ul>

<h3 id="symbolic-recursion-vs-neural-attention">Symbolic Recursion vs. Neural Attention</h3>

<blockquote>
  <ul>
    <li><strong>Neural attention:</strong> I will softly weight everything and hope for the best.</li>
    <li><strong>Symbolic recursion:</strong> I will write a loop. And then another loop. And then a nested loop.</li>
  </ul>
</blockquote>

<p>The technical “secret sauce” of RLMs is <strong>Symbolic Recursion</strong>. In traditional scaffolds, agents verbalize sub-calls (autoregressive delegation), which limits output length and precision. RLMs generate sub-calls <strong>programmatically</strong>.</p>

<p>An RLM can write a loop that launches parallel agents to analyze pairs of chunks: handling tasks like <strong>OOLONG-Pairs</strong>, which requires quadratic processing complexity, a task where even vanilla GPT-5 fails catastrophically.</p>

<h3 id="performance-benchmarks">Performance Benchmarks</h3>

<p><strong>Vanilla models choke. RLMs breathe.</strong></p>

<p><img src="https://miro.medium.com/v2/resize:fit:700/1*qdQ8HpqKJF-03r1pLO48rA.png" alt="RLM vs LLM performance benchmarks" /></p>

<hr />

<h2 id="why-the-rlm-is-more-than-just-a-grep-sub-agent">Why the RLM is More Than Just a “grep” Sub-agent</h2>

<p>The fundamental shift of RLMs lies in treating the sub-agent as a native function within a stateful REPL environment rather than a disconnected tool. While traditional coding agents use an external controller to orchestrate independent tools like <code class="language-plaintext highlighter-rouge">grep</code>, the RLM defines a unified <strong>LM ↔ REPL + Prompt</strong> interface.</p>

<p>In this paradigm, the massive user prompt is not a static token stream to be compacted into a narrow window: it’s a programmatically accessible variable within the REPL. This integration allows the model to programmatically examine and decompose contexts, launching recursive sub-agents as internal algorithmic steps. This moves AI toward a unified neurosymbolic execution where symbolic code manages precise data retrieval while neural logic handles fuzzy reasoning, effectively decoupling the reasoning process from the physical constraints of any single neural LM call.</p>

<p>By defining sub-agents as functions inside the REPL, the RLM operates as a dynamic corporate hierarchy of stateless LLMs. Instead of a single “CEO” model attempting to ingest 10,000 pages at once: leading to the inevitable “context rot”: the system scripts a custom organization on the fly to handle specific data slices. This allows the system to scale to enormous contexts, up to two orders of magnitude beyond the model’s native limit, as the neural logic only ever interacts with the symbolic handles managed by the REPL. A singular, fixed-context-window LM can solve arbitrarily large problems by recursively calling itself within a controlled loop: creating a task-agnostic framework capable of navigating massive datasets with programmatic precision.</p>

<hr />

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

<ul>
  <li><a href="https://arxiv.org/abs/2512.24601">Recursive Language Models (RLM)</a></li>
  <li><a href="https://github.com/LiamConnell/adk-python/tree/main/contributing/samples/rlm">Google ADK: RLM Implementation</a></li>
  <li><a href="https://github.com/alexzhang13/rlm">Original MIT RLM Code</a></li>
  <li><a href="https://arxiv.org/abs/2511.02817">OOLONG: Evaluating Long-Context Reasoning</a></li>
  <li><a href="https://arxiv.org/abs/2508.06600">BrowseComp-Plus: Evaluation Benchmark for Deep-Research Agents</a></li>
  <li><a href="https://arxiv.org/abs/2412.15204">LongBench-v2: Deeper Understanding on Realistic Long-Context Tasks</a></li>
</ul>]]></content><author><name></name></author><category term="LLMs" /><category term="Transformers" /><category term="ADK" /><category term="Inference Scaling" /><summary type="html"><![CDATA[Context windows are getting bigger. Reasoning is getting worse. And somewhere, a Transformer is crying softly.]]></summary></entry><entry><title type="html">VerbalVista: Talking to Your Own Data with RAG, FAISS, and a Bit of Stubbornness</title><link href="https://snehal.dev/verbalvista/" rel="alternate" type="text/html" title="VerbalVista: Talking to Your Own Data with RAG, FAISS, and a Bit of Stubbornness" /><published>2025-02-15T00:00:00+00:00</published><updated>2025-02-15T00:00:00+00:00</updated><id>https://snehal.dev/verbalvista</id><content type="html" xml:base="https://snehal.dev/verbalvista/"><![CDATA[<p align="center"><img src="https://i.ibb.co/6FQPs5C/verbal-vista-blue-transparent.png" width="200" alt="VerbalVista logo" /></p>

<p>There’s a specific frustration that sets in the first time you try to ask ChatGPT about something it cannot know: a meeting transcript, a proprietary PDF, an internal wiki page, an audio recording you made last Tuesday. The model is fluent and confident and completely useless for your actual question. It knows everything about the world and nothing about <em>your</em> work.</p>

<p>The obvious workaround is to paste the document into the context. That works; until it doesn’t. A few pages is fine. A 200-page technical spec, a 6-hour podcast, or an entire Git repository is not. You hit the context limit, or you hit the cost ceiling, or you discover that models quietly deprioritize content buried deep in a long prompt. The problem isn’t access to a capable LLM. The problem is retrieval: getting the <em>right</em> slice of your data in front of the model at query time.</p>

<p>RAG: Retrieval-Augmented Generation: is the standard answer, and there are plenty of frameworks that package it up for you. But the interesting questions only surface when you build it yourself: what chunk size actually works? When does BM25 beat cosine similarity? How do you keep embedding costs reasonable when you have tens of thousands of chunks? I wanted those answers for myself, so I built <strong><a href="https://github.com/spate141/VerbalVista">VerbalVista</a></strong>: a full-stack RAG platform that accepts eight different input types, chunks and indexes them into FAISS, and answers questions using GPT-4 or Claude.</p>

<hr />

<h2 id="the-problem-with-context-windows">The Problem with Context Windows</h2>

<p>LLMs have no persistent memory of your private data. Every query starts cold. The model knows what you put in the prompt and nothing else. For most tasks that’s fine: but for document intelligence, it’s the entire problem.</p>

<p>The naive fix is to stuff everything into context. Upload the document, prepend it to your question, send it to the API. For a short document this is perfectly reasonable. For anything longer, you run into three compounding issues. First, most models have a hard token limit, so large documents simply don’t fit. Second, cost scales linearly with context length: sending a 100,000-token document with every query gets expensive fast. Third, and subtlest: attention is not uniform. Models tend to recall content near the beginning and end of a long context more reliably than content in the middle. If your answer lives in paragraph 47 of a 200-page spec, the model may not find it even if it’s technically in the prompt.</p>

<p>RAG sidesteps all three problems. Instead of sending everything, you send only the relevant chunks: a few hundred tokens retrieved from an index rather than tens of thousands retrieved from nowhere. The index does the heavy lifting so the model doesn’t have to.</p>

<p>What RAG frameworks hide from you are the choices that actually determine quality: chunk size and overlap, embedding model, distance metric, whether to run lexical retrieval alongside semantic retrieval, how to merge results, how many chunks to include before you exceed the prompt budget. Getting those right is the actual engineering work. Building VerbalVista was mostly an excuse to make those decisions deliberately rather than accepting a framework’s defaults.</p>

<hr />

<h2 id="what-i-built">What I Built</h2>

<p><strong>VerbalVista</strong> is a full-stack RAG platform with a Streamlit front-end, a FastAPI + Ray Serve backend, and a FAISS + BM25 retrieval layer. It accepts PDFs, DOCX files, plain text, email (<code class="language-plaintext highlighter-rouge">.eml</code>), audio/video files, URLs, YouTube videos, and code repositories. Everything gets transcribed or parsed into text, chunked, embedded, and indexed. At query time, it runs both semantic and lexical retrieval, merges the results, and streams a response from GPT-4 or Claude: including a per-query cost estimate.</p>

<p>The project is at <a href="https://github.com/spate141/VerbalVista">github.com/spate141/VerbalVista</a>. To run it locally:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>streamlit run app.py
</code></pre></div></div>

<hr />

<h2 id="under-the-hood-the-rag-pipeline">Under the Hood: The RAG Pipeline</h2>

<p>The full pipeline has five stages. Each one is straightforward in isolation; the interesting parts are the seams between them.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Input sources
  │
  ▼
[Ingestion] ──► .data.txt files
  │
  ▼
[Chunking] ──► text chunks + metadata
  │
  ▼
[Embedding &amp; Indexing] ──► FAISS (semantic) + BM25 (lexical)
  │
  ▼
[Retrieval] ──► top-k chunks, merged + deduplicated
  │
  ▼
[Generation] ──► streamed answer + token counts + cost
</code></pre></div></div>

<p><strong>1. Ingestion: the wide funnel</strong></p>

<p>The most underestimated part of any RAG system is getting content in. VerbalVista handles eight source types:</p>

<ul>
  <li><strong>Audio/video</strong> → Whisper transcription → text</li>
  <li><strong>PDF/DOCX/TXT/EML</strong> → <code class="language-plaintext highlighter-rouge">document_parser.py</code> → text</li>
  <li><strong>URLs</strong> → Selenium-based <code class="language-plaintext highlighter-rouge">url_parser.py</code> → text</li>
  <li><strong>YouTube</strong> → transcript API → text</li>
  <li><strong>Reddit / Hacker News / 4chan</strong> → specialized scrapers → text</li>
  <li><strong>Code repositories</strong> → <code class="language-plaintext highlighter-rouge">code_parser.py</code> (Python + Markdown files) → text</li>
</ul>

<p>Every path converges on the same output: a <code class="language-plaintext highlighter-rouge">.data.txt</code> file on disk. The FAISS index has no idea whether its chunks came from a podcast or a PDF, and that’s intentional.</p>

<p><strong>2. Chunking</strong></p>

<p>Text files are split with <code class="language-plaintext highlighter-rouge">RecursiveCharacterTextSplitter</code> at a configurable chunk size and overlap. Overlap is the key parameter most explanations skip past: without it, a sentence that straddles a chunk boundary gets split in two, and neither half carries enough context to be useful for retrieval. A 10–20% overlap ensures boundary content appears in full in at least one chunk.</p>

<p>Each chunk carries metadata: source filename, chunk index: that surfaces in the response so you know exactly where an answer came from.</p>

<p><strong>3. Embedding and indexing</strong></p>

<p>The <code class="language-plaintext highlighter-rouge">EmbedChunks</code> class calls the OpenAI embeddings API in batches, converting each chunk into a float vector. Vectors are L2-normalized to unit norm and stored in a FAISS <code class="language-plaintext highlighter-rouge">IndexFlatIP</code> (inner product). Normalized vectors make inner product equivalent to cosine similarity, which is the distance metric that works best for semantic text search.</p>

<p>In parallel, the same chunks go into a <code class="language-plaintext highlighter-rouge">rank_bm25</code> BM25 index for lexical retrieval. Both indices are persisted to disk: FAISS binary format for the vector index, pickle for the BM25 object and chunk metadata: so re-indexing is only needed when the source documents change.</p>

<p><strong>4. Retrieval: dual strategy</strong></p>

<p>At query time, both indices run in parallel:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Semantic search
</span><span class="k">def</span> <span class="nf">do_semantic_search</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mi">10</span><span class="p">):</span>
    <span class="n">query_vec</span> <span class="o">=</span> <span class="n">embed</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
    <span class="n">query_vec</span> <span class="o">=</span> <span class="n">query_vec</span> <span class="o">/</span> <span class="n">np</span><span class="p">.</span><span class="n">linalg</span><span class="p">.</span><span class="n">norm</span><span class="p">(</span><span class="n">query_vec</span><span class="p">)</span>
    <span class="n">scores</span><span class="p">,</span> <span class="n">indices</span> <span class="o">=</span> <span class="n">faiss_index</span><span class="p">.</span><span class="n">search</span><span class="p">(</span><span class="n">query_vec</span><span class="p">,</span> <span class="n">k</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">[(</span><span class="n">chunks</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="n">scores</span><span class="p">[</span><span class="mi">0</span><span class="p">][</span><span class="n">j</span><span class="p">])</span> <span class="k">for</span> <span class="n">j</span><span class="p">,</span> <span class="n">i</span> <span class="ow">in</span> <span class="nb">enumerate</span><span class="p">(</span><span class="n">indices</span><span class="p">[</span><span class="mi">0</span><span class="p">])]</span>

<span class="c1"># Lexical search
</span><span class="k">def</span> <span class="nf">do_lexical_search</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">k</span><span class="o">=</span><span class="mi">10</span><span class="p">):</span>
    <span class="n">tokens</span> <span class="o">=</span> <span class="n">query</span><span class="p">.</span><span class="n">lower</span><span class="p">().</span><span class="n">split</span><span class="p">()</span>
    <span class="n">scores</span> <span class="o">=</span> <span class="n">bm25</span><span class="p">.</span><span class="n">get_scores</span><span class="p">(</span><span class="n">tokens</span><span class="p">)</span>
    <span class="n">top_k</span> <span class="o">=</span> <span class="n">np</span><span class="p">.</span><span class="n">argsort</span><span class="p">(</span><span class="n">scores</span><span class="p">)[::</span><span class="o">-</span><span class="mi">1</span><span class="p">][:</span><span class="n">k</span><span class="p">]</span>
    <span class="k">return</span> <span class="p">[(</span><span class="n">chunks</span><span class="p">[</span><span class="n">i</span><span class="p">],</span> <span class="n">scores</span><span class="p">[</span><span class="n">i</span><span class="p">])</span> <span class="k">for</span> <span class="n">i</span> <span class="ow">in</span> <span class="n">top_k</span><span class="p">]</span>
</code></pre></div></div>

<p>Results from both searches are merged, deduplicated by chunk ID, and trimmed to fit within the prompt token budget. The merged set becomes the context for the LLM.</p>

<p><strong>5. Generation</strong></p>

<p><code class="language-plaintext highlighter-rouge">GPTAgent</code> and <code class="language-plaintext highlighter-rouge">ClaudeAgent</code> wrap the respective APIs with a consistent interface. Retrieved chunks are formatted into a system context. Responses stream token by token back to the Streamlit UI. Each response includes prompt token count, completion token count, and an estimated USD cost calculated from current API pricing.</p>

<hr />

<h2 id="three-design-decisions-worth-calling-out">Three Design Decisions Worth Calling Out</h2>

<h3 id="hybrid-retrieval-semantic--lexical-not-eitheror">Hybrid retrieval: semantic + lexical, not either/or</h3>

<p>Pure semantic search: embed the query, find the nearest vectors: handles paraphrase and conceptual synonymy well. Ask about “authentication failures” and you’ll surface chunks that talk about “login errors” or “credential issues,” because the embeddings are close in vector space.</p>

<p>But semantic search struggles with specificity. If your document contains version numbers, error codes, product names, or precise technical identifiers, embedding similarity often lets you down. <code class="language-plaintext highlighter-rouge">AttributeError: 'NoneType' object has no attribute 'shape'</code> and “null pointer dereference” are conceptually related, but their embeddings are not that close. BM25 finds exact keyword matches that cosine similarity misses.</p>

<p>Running both and merging the top-k from each is not complicated to implement: both indices are built at index time and queried at retrieval time: but the quality difference on technical content is significant. Exact-match queries get answered better. Conceptual queries get answered better. Neither index alone covers the full retrieval space.</p>

<h3 id="everything-is-text-first">Everything is text first</h3>

<p>The ingestion layer looks simple from the outside: “just parse the document.” In practice it’s the part that takes the longest to get right and breaks the most often. Audio files need Whisper, which needs GPU time or API calls. PDFs have scanned pages, embedded images, inconsistent encoding. Email threads have quoted replies, HTML, attachments. URLs have JavaScript-rendered content, login walls, navigation noise.</p>

<p>The architectural decision that makes all of this manageable is committing to a single intermediate format: plain text files on disk. Every parser’s job is to produce a <code class="language-plaintext highlighter-rouge">.data.txt</code> file and nothing else. The chunker, embedder, and FAISS index never see the original source format. This decouples ingestion from retrieval completely. Adding a new source type means writing one new parser; nothing downstream changes.</p>

<h3 id="cost-tracking-as-a-first-class-concern">Cost tracking as a first-class concern</h3>

<p>Every query in VerbalVista returns the prompt token count, the completion token count, and the estimated USD cost. These accumulate in Streamlit session state so you can see the running total for the session.</p>

<p>This sounds like a minor UI feature. It isn’t. When you’re running 50 exploratory queries a day during development: trying different phrasings, different retrieval depths, different models: the costs add up faster than intuition suggests. GPT-4 at scale is not cheap. Building the cost counter in from the start rather than adding it later means the number is always in front of you when you’re deciding whether to run another experiment. It keeps iteration honest.</p>

<hr />

<h2 id="closing-thoughts">Closing Thoughts</h2>

<p>The most educational part of building VerbalVista wasn’t the LLM integration: that part is relatively mechanical once you have the retrieval working. It was the retrieval layer itself: the realization that chunk size and overlap aren’t hyperparameters to tune once and forget, that BM25 and cosine similarity are complements not substitutes, and that the amount of context you give the model matters as much as the model you choose.</p>

<p>The project grew in ways that reflect how RAG systems evolve in practice. You start with PDFs. Someone asks if you can index an audio recording. Then a YouTube video. Then a GitHub repository. The ingestion layer ends up being the majority of the codebase, and the LLM calls end up being a thin wrapper at the end of a much longer pipeline. VerbalVista went through 42 releases and 262 commits because the interesting problems kept accumulating at the input end, not the output end.</p>

<p>If you’re building something similar or curious about the implementation details, the full source is at <a href="https://github.com/spate141/VerbalVista">github.com/spate141/VerbalVista</a>.</p>]]></content><author><name></name></author><category term="Python" /><category term="RAG" /><category term="FAISS" /><category term="LLM" /><category term="Streamlit" /><category term="FastAPI" /><summary type="html"><![CDATA[]]></summary></entry></feed>