<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Multigrid</title>
    <description>The latest articles on DEV Community by Multigrid (@multigrid).</description>
    <link>https://dev.to/multigrid</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4035895%2F376e2fa1-ea84-490e-a044-401a5dbeea9b.png</url>
      <title>DEV Community: Multigrid</title>
      <link>https://dev.to/multigrid</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/multigrid"/>
    <language>en</language>
    <item>
      <title>Evaluating Classical NLP vs LLM Approaches</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:22:36 +0000</pubDate>
      <link>https://dev.to/multigrid/evaluating-classical-nlp-vs-llm-approaches-58li</link>
      <guid>https://dev.to/multigrid/evaluating-classical-nlp-vs-llm-approaches-58li</guid>
      <description>&lt;p&gt;The reflexive answer is “use a model” and the contrarian answer is “classical methods are underrated”. Both are slogans. The decision is a calculation with four inputs, and once you write it down it usually answers itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four variables
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Volume.&lt;/strong&gt; Documents per month. This decides whether per-document cost is a rounding error or the entire budget, and it is the only variable that changes the answer by orders of magnitude.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Output space.&lt;/strong&gt; Closed (one of eight buckets, a span, a ranked list) or open (a sentence nobody wrote yet). Closed spaces are what classical methods were built for; open spaces are what models are for, and no amount of cleverness moves a task across this line.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Label availability.&lt;/strong&gt; Do you have, or can you cheaply get, a few thousand labelled examples? This is the fixed cost of the classical route and the reason the model route wins on day one.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Constraint profile.&lt;/strong&gt; Latency budget, determinism requirement, audit requirement, data-residency requirement. Any one of these can decide the question regardless of cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The decision table by task
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;format-defined extraction&lt;/td&gt;
&lt;td&gt;Regex, always. Dates, identifiers, log fields, anything with a written specification. Exact by construction, microseconds, no fee. A model here is slower, costlier and less correct. See the regex page.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fixed-taxonomy classification&lt;/td&gt;
&lt;td&gt;Local model at scale, hosted model below the break-even. A linear model is roughly three orders of magnitude cheaper per document and sub-millisecond; the crossover is set by your label budget rather than by quality.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;open-taxonomy classification&lt;/td&gt;
&lt;td&gt;Hosted model. If the categories change monthly or were invented this morning, there is nothing to train on and a prompt is the only option that ships.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;standard entity types&lt;/td&gt;
&lt;td&gt;Local NER pipeline. Person, organisation, location, dates and money in a common domain are well covered, and you get character offsets a generated list does not have.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bespoke entity types&lt;/td&gt;
&lt;td&gt;Hosted model, unless volume is very high — in which case use it to label a few thousand documents, review them, and train a local model on the result.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;lexical retrieval&lt;/td&gt;
&lt;td&gt;BM25. It is the baseline a dense system must beat, and BEIR (2021) found that out of domain it frequently is not beaten. Hybrid with embeddings; do not replace.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;semantic retrieval&lt;/td&gt;
&lt;td&gt;Embeddings. Nothing lexical relates 'cancel' to 'terminate', and no weighting scheme will.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;deduplication&lt;/td&gt;
&lt;td&gt;MinHash or SimHash. Detects exactly what deduplication means, indexable, and about a hundredth the cost of embedding the corpus.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;summarising, rewriting, explaining&lt;/td&gt;
&lt;td&gt;Hosted model. The output space is open. Extractive methods are a different product, not a cheaper version of this one.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;anything needing multi-step reasoning&lt;/td&gt;
&lt;td&gt;Hosted model. Classical pipelines do not do this at all, and pretending otherwise wastes a quarter.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The break-even volume, derived
&lt;/h2&gt;

&lt;p&gt;For the rows where both options are genuinely available, the crossover is arithmetic. Define:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;code&gt;V&lt;/code&gt; — documents per month.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;c&lt;/code&gt; — hosted cost per document, which is tokens per document × price per token, input and output.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;F&lt;/code&gt; — one-off cost of building the local system: engineering time plus labelling.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;m&lt;/code&gt; — monthly cost of running it: infrastructure plus maintenance.&lt;/li&gt;
&lt;li&gt;  &lt;code&gt;T&lt;/code&gt; — the horizon you are amortising over, in months.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Local is cheaper over &lt;code&gt;T&lt;/code&gt; months when &lt;code&gt;F + mT &amp;lt; cVT&lt;/code&gt;, which rearranges to &lt;code&gt;V &amp;gt; (F/T + m) / c&lt;/code&gt;. Everything about the decision is in that expression: the fixed cost divided by your horizon, plus the running cost, over the per-document price you avoid.&lt;/p&gt;

&lt;p&gt;A worked example with every input labelled as an assumption. &lt;strong&gt;Assume&lt;/strong&gt; classification at 300 input tokens per document and an assumed $0.20 per million tokens, so &lt;code&gt;c&lt;/code&gt; = $0.00006 per document. &lt;strong&gt;Assume&lt;/strong&gt; the local route costs two engineer-weeks at an assumed $6,000 per week plus three analyst-days of labelling at an assumed $400 per day, so &lt;code&gt;F&lt;/code&gt; = $13,200. &lt;strong&gt;Assume&lt;/strong&gt; $50 per month to run and maintain it, and a 24-month horizon.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;V &amp;gt; (13,200/24 + 50) / 0.00006&lt;/code&gt; = (550 + 50) / 0.00006 = &lt;strong&gt;10 million documents per month&lt;/strong&gt;. Below that, the hosted model is cheaper and you should not be building anything. Above it, the gap widens without limit, because one side of the comparison scales with volume and the other does not.&lt;/p&gt;

&lt;p&gt;Three sensitivities worth noticing before trusting any such number. Halve the horizon and the break-even nearly doubles — building for a product that might be cancelled is a bad trade. Raise the assumed token price fivefold, as a reasoning model or a much longer prompt would, and the break-even falls to about 2 million. And if &lt;code&gt;F&lt;/code&gt; is near zero because a maintained off-the-shelf model already covers your task — language ID, standard NER, deduplication — the break-even collapses to almost nothing and the local option wins at any volume. That last case is more common than it looks, and it is the one people skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that are not about cost
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Latency.&lt;/strong&gt; A local model answers in single-digit milliseconds; a hosted call is hundreds. If the result is needed inside a request a user is waiting on, that is not a cost difference, it is an architecture difference — one is a function call, the other needs a queue, timeouts, retries and a story for what the user sees meanwhile. At 10 ms you can afford to classify every document twice; at 400 ms you cannot afford it once, synchronously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Determinism.&lt;/strong&gt; A pinned local model returns the same answer forever. A hosted endpoint can change under you, and &lt;a href="https://multigrid.ai/learn/silent-model-updates" rel="noopener noreferrer"&gt;an update you did not initiate&lt;/a&gt; will move your outputs without moving your code. If a decision must be reproducible — a compliance classification, a scoring rule someone can appeal — the local option has a property the hosted one cannot offer at any price.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auditability.&lt;/strong&gt; A linear model’s decision decomposes into features and weights you can print. A generated explanation is text produced after the fact by the same process that produced the answer, and it is not evidence about how the answer was computed — the two can disagree without anything erroring. Where a person is affected and can appeal, this difference tends to be decisive on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hybrid that usually wins
&lt;/h2&gt;

&lt;p&gt;Almost every mature system in this space is a cascade rather than a choice, and the shape is the same across every task in this cluster: something cheap and deterministic runs on everything, and only the residue escalates.&lt;/p&gt;

&lt;p&gt;Regex finds candidates and a model adjudicates the ambiguous ones. A local classifier handles documents above a confidence threshold and escalates the rest. BM25 and embeddings each retrieve fifty documents, and a cross-encoder re-ranks the union. Take the arithmetic above with a 5% escalation rate and the effective per-document cost falls twentyfold while the hard cases still get the better treatment — which is a strictly better outcome than either pure option, at the price of one threshold you have to tune and monitor.&lt;/p&gt;

&lt;p&gt;The two mistakes to avoid are symmetrical. Do not send ten million documents to a model because it was faster to write, when the task has a specification and the specification is a regex. And do not spend a quarter building a classical pipeline for a task with an open output space, or for a volume the arithmetic above says will never repay it. Write down the four variables first; the answer is usually already in them.&lt;/p&gt;

&lt;p&gt;The one input in the break-even expression that is not a guess is &lt;code&gt;c&lt;/code&gt;, and it is worth taking from the source rather than from memory: per-token prices for classification, embedding and chat models sit side by side on the &lt;a href="https://multigrid.ai/pricing" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt;, so the crossover volume above is one substitution away from being your number. It will often say the hosted option is cheaper, and that is a real answer. It will just as often say the opposite — and where latency, determinism or auditability are in play, the arithmetic is not the thing deciding anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/what-is-nlp" rel="noopener noreferrer"&gt;Natural Language Processing: The Field Before and After LLMs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/text-classification" rel="noopener noreferrer"&gt;Text Classification Without an LLM&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/regex-for-text" rel="noopener noreferrer"&gt;Regular Expressions for Text Extraction: Still Undefeated&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>datascience</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Task-Specific Metrics: BLEU, ROUGE, and Their Limits</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:22:32 +0000</pubDate>
      <link>https://dev.to/multigrid/task-specific-metrics-bleu-rouge-and-their-limits-4c73</link>
      <guid>https://dev.to/multigrid/task-specific-metrics-bleu-rouge-and-their-limits-4c73</guid>
      <description>&lt;p&gt;BLEU is from 2002 and ROUGE from 2004. They are routinely dismissed as obsolete by people who then spend forty dollars of judge calls detecting a regression that a character-level F-score would have caught in eight milliseconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  What BLEU computes
&lt;/h2&gt;

&lt;p&gt;BLEU (Papineni et al., 2002) was built for machine translation. It is a modified n-gram precision: of the n-grams in the candidate, what fraction appear in the reference — with each reference n-gram usable only as many times as it occurs, so repeating a common word does not inflate the score. Precisions for n = 1 to 4 are combined as a geometric mean, and a brevity penalty punishes candidates shorter than the reference, since precision alone rewards saying almost nothing.&lt;/p&gt;

&lt;p&gt;Two structural facts follow. Because the mean is geometric, a zero at any n zeroes the whole score — which is why sentence-level BLEU needs smoothing and is unreliable regardless. And because it is a precision against a fixed reference, a correct translation that uses different words scores badly. BLEU was designed and validated as a &lt;em&gt;corpus-level, system-level&lt;/em&gt; statistic, and its authors said so.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ROUGE computes
&lt;/h2&gt;

&lt;p&gt;ROUGE (Lin, 2004) inverts the emphasis for summarisation: recall-oriented overlap with a reference summary. ROUGE-N is n-gram recall; ROUGE-L uses the longest common subsequence, which rewards in-order overlap without requiring contiguity; ROUGE-Lsum applies that per sentence and aggregates, and is the variant most summarisation papers actually report even when they write “ROUGE-L”.&lt;/p&gt;

&lt;p&gt;The recall orientation is the point: a summary that omits the main finding should score badly even if every word it does contain is impeccable. The cost is the mirror image of BLEU’s — a summary that copies large spans of the source scores well whether or not it summarised anything, which is why extractive baselines are stubbornly competitive on ROUGE and why ROUGE alone cannot detect a fluent fabrication.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reporting traps
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Tokenisation changes the number.&lt;/strong&gt; This is the big one. BLEU is computed over tokens, so a different tokeniser gives a different score for identical translations — which made published BLEU scores non-comparable across papers for years. Post’s 2018 paper, “A Call for Clarity in Reporting BLEU Scores”, is the standard citation and sacreBLEU is the standard fix: it takes detokenised text and applies one canonical tokenisation, and it emits a signature string identifying the exact configuration. If you report BLEU without that signature, you have reported a number nobody can reproduce, including you in six months.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Reference count changes the number.&lt;/strong&gt; More references means more ways to be right, so multi-reference scores are systematically higher. Comparing across datasets with different reference counts is meaningless.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Sentence-level use.&lt;/strong&gt; Both metrics are noisy on single instances. Use them on a corpus, or use them as a regression tripwire on a fixed corpus, never as a per-item grade.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Correlation with human judgement is task-dependent.&lt;/strong&gt; Reiter’s 2018 structured review of BLEU validity concluded that it is reasonable at the system level in machine translation and should not be relied on for evaluating natural language generation more broadly, or for individual outputs. That distinction — system level in MT, not general NLG — is the honest summary of the evidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where classic metrics beat a judge
&lt;/h2&gt;

&lt;p&gt;Not on judgement. On the properties a measurement instrument needs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;deterministic&lt;/td&gt;
&lt;td&gt;The same inputs give the same score forever. A judge gives a distribution, and comparing today's judged score with last quarter's requires that nothing about the judge changed — which you cannot guarantee for a hosted model.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;free and instant&lt;/td&gt;
&lt;td&gt;Microseconds per item and no API call. You can score every request in production rather than a 1% sample, which changes what questions you can ask.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no bias to correct&lt;/td&gt;
&lt;td&gt;n-gram overlap has no position bias, no verbosity preference and no self-preference. It has different problems, but they are stationary, which means you can characterise them once.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;no vendor&lt;/td&gt;
&lt;td&gt;Nothing to deprecate, rate-limit, or reprice. An eval pipeline whose baseline metric depends on a hosted model has a dependency that can invalidate its own history.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Which makes them the right tool for a specific set of jobs. &lt;strong&gt;Constrained transformation&lt;/strong&gt; — translating into a fixed house style, normalising addresses, converting one structured format to another — has a reference and a narrow space of correct outputs, which is exactly BLEU’s home ground, and chrF (character n-gram F-score) usually behaves better than BLEU on morphologically rich languages and on short strings. &lt;strong&gt;High-volume regression detection&lt;/strong&gt; — running a metric over every production output daily to catch the day the score drops — works because you only need the change to be detectable, not the absolute level to be meaningful. &lt;strong&gt;Extraction and structured output&lt;/strong&gt; should mostly use exact match, normalised edit distance or set F1, and reaching for a judge there is paying money to make a deterministic check stochastic.&lt;/p&gt;

&lt;p&gt;The rule of thumb: if there is a reference and the space of correct answers is narrow, use a classic metric. If the space of correct answers is wide, no reference metric can work and you need &lt;a href="https://multigrid.ai/learn/llm-as-a-judge" rel="noopener noreferrer"&gt;a judge with a rubric&lt;/a&gt; or a reference-free check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The learned metrics in between
&lt;/h2&gt;

&lt;p&gt;There is a middle tier worth knowing, between n-gram overlap and a general-purpose judge. BERTScore (Zhang et al., 2020) matches contextual embeddings instead of exact tokens, so a correct paraphrase is no longer punished. COMET (Rei et al., 2020) is trained on human quality ratings and has become the standard reference-based metric in machine translation evaluation, largely displacing BLEU for system comparison at the major shared tasks.&lt;/p&gt;

&lt;p&gt;These keep most of the operational advantages — a pinned model checkpoint you run locally, so the score is reproducible and free at the margin — while behaving far better on paraphrase. They cost you the ability to explain a score in one sentence, and they have their own training-distribution biases. For anything with a reference, this tier deserves consideration before a general judge does.&lt;/p&gt;

&lt;p&gt;However you use them, the operational pattern is the same and it is worth stating plainly: pick the metric, freeze the corpus, record the score every day, and set the alert on the &lt;em&gt;change&lt;/em&gt; rather than the level. You never have to defend what a chrF of 0.62 means. You only have to notice the morning it becomes 0.54, and for that job a deterministic metric on a fixed corpus is not merely adequate, it is strictly better than a judge — because a judge that drifts gives you the same alert for a reason that has nothing to do with your system.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/summarization-evaluation" rel="noopener noreferrer"&gt;Evaluating Summarisation Without Reference Summaries&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/llm-as-a-judge" rel="noopener noreferrer"&gt;LLM-as-a-Judge: Setting One Up That You Can Trust&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>datascience</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>A Next.js Route Handler That Calls a Model</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:21:57 +0000</pubDate>
      <link>https://dev.to/multigrid/a-nextjs-route-handler-that-calls-a-model-5fmf</link>
      <guid>https://dev.to/multigrid/a-nextjs-route-handler-that-calls-a-model-5fmf</guid>
      <description>&lt;p&gt;A route handler that calls a model is fifteen lines. A route handler that still streams once it is behind a CDN, on a serverless runtime, with users who close tabs, needs four more things — and each of them fails silently rather than loudly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The handler
&lt;/h2&gt;

&lt;p&gt;In the App Router a route handler is a file named &lt;code&gt;route.ts&lt;/code&gt; exporting a function per HTTP method. It receives a standard &lt;code&gt;Request&lt;/code&gt; and returns a standard &lt;code&gt;Response&lt;/code&gt;, which is the part that matters here: &lt;code&gt;Response&lt;/code&gt; accepts a &lt;code&gt;ReadableStream&lt;/code&gt; as its body, so streaming needs no framework feature at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/chat/route.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;runtime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nodejs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;force-dynamic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bad prompt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;upstream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.multigrid.ai/v1/chat/completions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LLM_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;openai/gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
      &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;upstream failed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;detail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/event-stream; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Cache-Control&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no-cache, no-transform&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Connection&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keep-alive&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Accel-Buffering&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dynamic = "force-dynamic"&lt;/code&gt; is there because a route handler that Next.js believes to be static gets evaluated at build time and served from a file. A POST handler is dynamic by default, so this is belt-and-braces — but on a GET streaming route it is the difference between a live stream and a cached snapshot of the first response anybody ever got.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass the stream through, do not rebuild it
&lt;/h2&gt;

&lt;p&gt;Note what the handler does &lt;em&gt;not&lt;/em&gt; do: it does not read the upstream body, parse it, and write a new stream. &lt;code&gt;upstream.body&lt;/code&gt; is already a &lt;code&gt;ReadableStream&lt;/code&gt; and handing it straight to &lt;code&gt;new Response()&lt;/code&gt; keeps the whole thing zero-copy and preserves backpressure end to end — if the browser stops reading, the pause propagates back to the upstream socket by itself.&lt;/p&gt;

&lt;p&gt;When you do need to touch the bytes — to count tokens, to inject your own event, to strip a field — use a &lt;code&gt;TransformStream&lt;/code&gt; rather than a read-and-rewrite loop. It keeps the same property.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Count characters as they pass, without buffering the answer.&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;meter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;TransformStream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Uint8Array&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;byteLength&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bytes streamed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// runs when upstream closes&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeThrough&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;meter&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The temptation to &lt;code&gt;await upstream.text()&lt;/code&gt; “just to log it” is how streaming routes become non-streaming routes. That one line buffers the entire answer before a single byte reaches the browser, and it turns a 400ms time-to-first-token into an eight-second wait while the endpoint still reports itself as streaming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge or Node
&lt;/h2&gt;

&lt;p&gt;Next.js lets a route handler declare &lt;code&gt;export const runtime&lt;/code&gt;. The two values are &lt;code&gt;"nodejs"&lt;/code&gt; (the default) and &lt;code&gt;"edge"&lt;/code&gt;. The choice is not about speed in the way it is usually described.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;Edge starts a V8 isolate, which is fast and cheap. Node starts a container. For a route that is called rarely and must feel instant, edge wins on the first request.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Available APIs&lt;/td&gt;
&lt;td&gt;Edge is web-standard only: fetch, ReadableStream, crypto.subtle. No fs, no net, no native modules. Most Postgres drivers, most Node crypto and most SDKs that touch the filesystem simply do not load.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming&lt;/td&gt;
&lt;td&gt;Both stream. Edge is built around it; on Node the runtime handles a ReadableStream response body natively. Neither is a reason to choose.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Duration limits&lt;/td&gt;
&lt;td&gt;Different ceilings on different platforms and plans, and they move — see deploying without timing out. Do not memorise a number; check the one your project is actually configured with.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The practical rule: if the handler only calls &lt;code&gt;fetch&lt;/code&gt;, edge is a reasonable default. The moment it touches a database over TCP, reads a file, or uses a library that has not been audited for edge compatibility, use Node and stop fighting it. Discovering this at deploy time rather than in development is common, because &lt;code&gt;next dev&lt;/code&gt; is more forgiving about which globals exist than the edge runtime is.&lt;/p&gt;

&lt;p&gt;Route segment options like &lt;code&gt;runtime&lt;/code&gt;, &lt;code&gt;dynamic&lt;/code&gt; and &lt;code&gt;maxDuration&lt;/code&gt; are Next.js conventions and their accepted values have changed across major versions — &lt;code&gt;edge&lt;/code&gt; in particular has been renamed and re-scoped more than once. Check the route segment config documentation for the exact version in your &lt;code&gt;package.json&lt;/code&gt; rather than copying a value from a blog post.&lt;/p&gt;

&lt;h2&gt;
  
  
  When streaming silently stops streaming
&lt;/h2&gt;

&lt;p&gt;The most confusing failure in this whole area: everything works locally, and in production the answer arrives all at once at the end. Nothing errored. The cause is almost always something between your function and the browser that buffers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;A reverse proxy.&lt;/strong&gt; nginx buffers proxied responses by default. The &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt; header in the handler above is the documented opt-out and nginx honours it; other proxies need their own configuration.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Compression.&lt;/strong&gt; A gzip layer that buffers to get a better ratio destroys streaming. &lt;code&gt;Cache-Control: no-transform&lt;/code&gt; asks intermediaries not to recompress, which is why it is in the header block rather than just &lt;code&gt;no-cache&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;A CDN.&lt;/strong&gt; Some edge caches will not stream a response they consider cacheable. A &lt;code&gt;Content-Type&lt;/code&gt; of &lt;code&gt;text/event-stream&lt;/code&gt; plus &lt;code&gt;no-cache&lt;/code&gt; is the signal that usually prevents it.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Your own logging.&lt;/strong&gt; Any &lt;code&gt;await res.text()&lt;/code&gt; or &lt;code&gt;res.clone().json()&lt;/code&gt; on the path buffers everything. Clone plus read is the sneaky one, because it looks side-effect-free.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Diagnose it with curl rather than the browser, which has its own buffering heuristics for small responses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://your-app.example/api/chat &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"prompt":"count slowly to twenty"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; line&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%T.%3N&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$line&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timestamps spread across seconds mean it streams. Timestamps all identical mean something buffered, and now you know it is not your React code.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the client goes away
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;request.signal&lt;/code&gt; aborts when the client disconnects, and passing it to the upstream &lt;code&gt;fetch&lt;/code&gt; is what makes a closed tab actually stop the generation instead of leaving your function billing tokens into a void for another thirty seconds. It is one property and it is left out of nearly every example.&lt;/p&gt;

&lt;p&gt;Two caveats. Serverless platforms differ in how reliably a client disconnect propagates to the function, so treat this as an optimisation rather than a guarantee: pair it with a sane &lt;code&gt;max_tokens&lt;/code&gt; so the worst case is bounded regardless. And if you need to run work &lt;em&gt;after&lt;/em&gt; responding — writing the finished answer to a database, say — that work must not depend on the request signal, or it will be cancelled along with the stream.&lt;/p&gt;

&lt;p&gt;The next two problems from here are the duration ceiling, in &lt;a href="https://multigrid.ai/learn/vercel-ai-timeouts" rel="noopener noreferrer"&gt;deploying an AI app without timing out&lt;/a&gt;, and whether any of this can be a Server Action instead, in &lt;a href="https://multigrid.ai/learn/server-actions-ai" rel="noopener noreferrer"&gt;Server Actions and AI&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/react-streaming-llm" rel="noopener noreferrer"&gt;Streaming Tokens Into a React UI&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/vercel-ai-timeouts" rel="noopener noreferrer"&gt;Deploying an AI App Without Timing Out&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/server-actions-ai" rel="noopener noreferrer"&gt;Server Actions and AI: What Fits and What Doesn't&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>llm</category>
    </item>
    <item>
      <title>Cadence, Volume and the Slop Problem</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:21:53 +0000</pubDate>
      <link>https://dev.to/multigrid/cadence-volume-and-the-slop-problem-5942</link>
      <guid>https://dev.to/multigrid/cadence-volume-and-the-slop-problem-5942</guid>
      <description>&lt;p&gt;The case against publishing more because it became cheaper is not a moral one. It is arithmetic: the cost of a send fell to nearly nothing while the cost of reading did not, unsubscribing is one-way, and additional sends add reach that saturates while adding churn that does not. All of that is worked below.&lt;/p&gt;

&lt;p&gt;Every figure on this page is a labelled assumption feeding an explicit calculation. Nothing here is a measurement from any particular publication, and the conclusions hold or fail on the arithmetic rather than on anybody’s reported results. Substitute your own numbers; the structure of the answer will not change.&lt;/p&gt;

&lt;h2&gt;
  
  
  The claim
&lt;/h2&gt;

&lt;p&gt;Language models made producing a plausible newsletter issue nearly free. The obvious response is to send more. The argument here is that for most publications this is a losing trade, that it is losing for reasons you can calculate rather than reasons you have to believe, and that the exceptions are identifiable in advance.&lt;/p&gt;

&lt;p&gt;The underlying asymmetry: what a newsletter provides is not text. It is selection — somebody deciding that these seven things are worth your attention and the four hundred others are not. Generation got cheap. Selection did not, because selection is judgement and judgement is the thing a model cannot supply about your subject and your readers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost curve moved on one side only
&lt;/h2&gt;

&lt;p&gt;Two costs govern this market. The cost of producing an issue, borne by the sender, and the cost of reading one, borne by every subscriber. The first collapsed. The second is unchanged and is measured in the only genuinely fixed resource in the system.&lt;/p&gt;

&lt;p&gt;When one side of a market gets much cheaper and the other does not, the scarce side becomes the one that sets value. Attention is now the binding constraint, and the operation that conserves it is refusal. A publication that increases output has increased its supply of the abundant thing while consuming more of the scarce one, which is the shape of a bad trade regardless of how good each individual issue is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cadence multiplies churn linearly
&lt;/h2&gt;

&lt;p&gt;Unsubscribing is monotonic. A subscriber can leave and cannot leave twice, and almost nobody resubscribes. That makes list size a stock depleted by a flow, and the flow is proportional to how often you send.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Assumptions, all substitutable:
  unsubscribe rate per send   u = 0.3%   (a normal, healthy figure)
  sends per month             s

Monthly retention = (1 − u)^s
Annual retention  = (1 − u)^(12s)

  s = 1  →  monthly 99.70%   annual (0.997)^12  = 96.5%
  s = 2  →  monthly 99.40%   annual (0.997)^24  = 93.0%
  s = 4  →  monthly 98.81%   annual (0.997)^48  = 86.6%
  s = 8  →  monthly 97.62%   annual (0.997)^96  = 74.9%

Annual loss, no new subscribers:
  monthly   3.5%
  weekly   13.4%
  twice a week 25.1%

On a list of 10,000, standing still requires gross additions of:
  monthly       ~350 a year
  twice a week  ~2,510 a year
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last two lines are the whole treadmill. Going from monthly to twice weekly means finding roughly seven times as many new subscribers every year simply to stay the same size — and acquisition is the expensive activity, which means the cadence decision quietly became an acquisition-budget decision.&lt;/p&gt;

&lt;p&gt;Two honest caveats. Unsubscribe rate is not constant across cadence: a publication that raises frequency without raising quality usually sees_u_ rise as well, which makes the table optimistic. And a small number of very engaged subscribers will tolerate a much higher frequency, which is exactly why the exception in a later section is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reach saturates
&lt;/h2&gt;

&lt;p&gt;The case for volume is reach: send more often and more of your list sees you at least once. That is true, and it has sharply diminishing returns while the churn above has none.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Assume a subscriber opens any given send with probability o = 0.35,
independently. Probability of seeing at least one send in a month:

  s = 1  →  1 − 0.65^1 = 35.0%
  s = 2  →  1 − 0.65^2 = 57.8%
  s = 4  →  1 − 0.65^4 = 82.1%
  s = 8  →  1 − 0.65^8 = 96.8%

Marginal reach per doubling, against annual list loss:

  1 → 2   +22.8 points of reach    3.5% → 7.0% annual loss
  2 → 4   +24.4 points of reach    7.0% → 13.4% annual loss
  4 → 8   +14.7 points of reach   13.4% → 25.1% annual loss

Reach is bounded above by 100%. Churn is not bounded below by anything.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The independence assumption is generous and the direction of the error matters. Opens are strongly correlated within a person — some subscribers open almost everything and some open almost nothing — so real incremental reach is lower than the binomial suggests. The calculation is therefore the best case for volume, and the best case still turns over somewhere around the fourth send.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a newsletter actually sells
&lt;/h2&gt;

&lt;p&gt;Consider what changes for a reader when a publication doubles its output with the same editorial effort. Each issue contains, on average, half as much of the thing they subscribed for. The rejection rate — the proportion of candidate material that did not make it — halved, and the rejection rate is the product.&lt;/p&gt;

&lt;p&gt;This is why the machine helps least exactly where publishers most want it to help. It can draft the write-up of an item you selected. It cannot tell you that the item everyone else is covering is not worth covering, because that judgement depends on knowing what your readers already know, and the most probable continuation is by construction the consensus one. That is the same division &lt;a href="https://multigrid.ai/learn/ai-writing-process" rel="noopener noreferrer"&gt;that runs through every writing stage&lt;/a&gt;: safe wherever the context already contains the answer, unsafe wherever something has to be supplied.&lt;/p&gt;

&lt;p&gt;There is a second-order effect worth naming. If drafting is cheap, the marginal item costs almost nothing to include, so the threshold for inclusion drifts down without anybody deciding to lower it. Nobody ever resolves to publish worse things; they just stop having a reason to say no.&lt;/p&gt;

&lt;p&gt;The same asymmetry shows up one level down, in the subject line. There is no way to select a subject line by experiment at ordinary list sizes, because &lt;a href="https://multigrid.ai/learn/ai-headlines" rel="noopener noreferrer"&gt;an adequately powered test needs thousands of impressions per variant&lt;/a&gt;. So even the smallest editorial decision in a newsletter is judgement exercised without evidence, which is a reason to have fewer of them rather than more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The feedback loop that punishes twice
&lt;/h2&gt;

&lt;p&gt;Beyond the reader there is an intermediary, and it makes the trade worse in a way that is not obvious from the arithmetic.&lt;/p&gt;

&lt;p&gt;Mailbox providers decide inbox placement partly from engagement signals — opens, replies, deletions without reading, complaints — aggregated per sender. The exact weightings are not published by any provider and change; the general mechanism is documented widely enough in sender guidance to plan around, and it is the mechanism rather than the weights that matters here.&lt;/p&gt;

&lt;p&gt;The loop runs like this. More sends at the same effort means lower average engagement per send. Lower engagement worsens placement for subsequent sends. Worse placement lowers engagement further. Volume therefore does not just cost subscribers directly; it degrades the delivery of the issues you still send to the people who still want them.&lt;/p&gt;

&lt;p&gt;The observable consequence for a publisher is a lag that makes the problem hard to see in time. Readers disengage well before they unsubscribe, so open rate falls first and list size looks healthy for months. By the time list size moves, the cause is a quarter old.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where volume genuinely works
&lt;/h2&gt;

&lt;p&gt;The argument above is about a publication whose value is selection. Not every newsletter is one, and the exceptions are specific.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Where more items means more selection.&lt;/strong&gt; A curated link roundup that goes from ten to twenty items per week has, if the candidate pool grew accordingly, raised its rejection rate rather than lowered it. The test is whether the pool grew with the output.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Where the content is a record rather than a judgement.&lt;/strong&gt; Changelogs, status notices, transactional summaries, market opens. Nobody subscribed for the editing, and completeness is the value.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Where every issue is requested.&lt;/strong&gt; Per-topic subscriptions and digest controls turn cadence into the reader’s decision, which removes the trade entirely.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Where the subject genuinely produces more.&lt;/strong&gt; A beat in a period of real activity supports more issues, because the material is there. This is the only exception that expires, and it expires quietly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The common structure: volume is safe when it tracks the supply of things worth saying, and unsafe when it tracks the supply of capacity to say things. Machine assistance moved the second and left the first alone, which is why it makes this mistake so much easier to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four signals you have crossed the line
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Nothing was rejected this month.&lt;/strong&gt; If everything considered was published, no selection occurred, and selection was the product.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Open rate is falling while list size is flat.&lt;/strong&gt; The leading indicator, and typically a quarter ahead of the lagging one.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;You could not say what any recent issue argued.&lt;/strong&gt; Issues that covered a subject rather than claiming something are issues a reader has no reason to remember.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The reply volume dropped faster than the open rate.&lt;/strong&gt; Replies are the strongest engagement signal available and the first to go when readers move from reading to skimming.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The remedy is unpopular and simple: publish less and reject more. Machine assistance is entirely compatible with that — use it for the write-up, the transcript, the structure, the checks — as long as it is not used to lower the bar for what gets in. What that bar looks like in a single piece is &lt;a href="https://multigrid.ai/learn/ai-slop" rel="noopener noreferrer"&gt;the subject of the next page&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/ai-slop" rel="noopener noreferrer"&gt;What "AI Slop" Means, and How Not to Make It&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/ai-headlines" rel="noopener noreferrer"&gt;Headlines, Titles and Subject Lines&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/ai-writing-process" rel="noopener noreferrer"&gt;Where AI Fits in a Real Writing Process&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>writing</category>
      <category>discuss</category>
      <category>llm</category>
    </item>
    <item>
      <title>Neuron-Level Explanations and Their Limits</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:21:18 +0000</pubDate>
      <link>https://dev.to/multigrid/neuron-level-explanations-and-their-limits-2oof</link>
      <guid>https://dev.to/multigrid/neuron-level-explanations-and-their-limits-2oof</guid>
      <description>&lt;p&gt;The obvious way to understand a neuron is to find the inputs that make it fire hardest and read them. It is also the way that produces confident, wrong explanations, and the fixes — automated explanation with automated scoring — are interesting precisely because of how they are scored.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this starts: max-activating examples
&lt;/h2&gt;

&lt;p&gt;Run a large corpus through the model, record one neuron’s activation at every token, sort, and look at the top few hundred. Sometimes the answer is immediate: a neuron that fires on quotation marks, or on tokens inside URLs, or on the word after “Dr”.&lt;/p&gt;

&lt;p&gt;The multimodal neuron work at OpenAI (Goh and colleagues, 2021) is the most striking result in this genre — units in a vision-and-language model that responded to a concept across photographs, drawings and the written word for it. That is a real finding about representation and it is why the approach stayed popular.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why top examples mislead
&lt;/h2&gt;

&lt;p&gt;Three distinct problems, and they compound.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You are reading the tail.&lt;/strong&gt; The top 0.01% of activations is not a description of a neuron’s behaviour. A unit can look like a “French text” neuron in its top hundred examples and fire moderately, across a much larger set of tokens, on something else entirely. Since contribution to the output is roughly activation times its outgoing weight, moderate activations on millions of tokens can matter more than extreme activations on a few.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Human pattern-matching is unreliable and unfalsifiable.&lt;/strong&gt; Shown twenty examples with any shared property, a person will find one. Without a held-out prediction, the explanation cannot be wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The interpretability illusion.&lt;/strong&gt; Bolukbasi and colleagues demonstrated in 2021 that the apparent meaning of a neuron can depend on which corpus you look at — the same unit yields different clean-looking explanations from different datasets. Neither explanation is a lie about its own data; both are incomplete, and nothing in the procedure tells you so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The automated pipeline
&lt;/h2&gt;

&lt;p&gt;OpenAI published the standard structure in 2023 (Bills and colleagues): use a language model to explain another language model’s neurons, and — this is the load-bearing part — use a language model to score the explanation.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Collect.&lt;/strong&gt; For the target neuron, gather text excerpts with per-token activations, including high-activating examples and a random sample across the range.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Explain.&lt;/strong&gt; Show an explainer model the excerpts with their activation values and ask for a short natural-language description of what makes this neuron fire.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Simulate.&lt;/strong&gt; Give a second model the explanation and a fresh, held-out excerpt &lt;em&gt;without&lt;/em&gt; activations, and ask it to predict the activation at each token.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Score.&lt;/strong&gt; Correlate the simulated activations with the real ones on the held-out text. That correlation is the explanation’s score.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The elegance is in step four. An explanation is now a &lt;em&gt;predictive hypothesis&lt;/em&gt; tested on data it was not derived from, which is exactly what a human reading twenty examples fails to provide. It also gives an automatic ceiling test: run the pipeline on random units, or with deliberately wrong explanations, and see what score noise achieves.&lt;/p&gt;

&lt;h2&gt;
  
  
  How an explanation is scored
&lt;/h2&gt;

&lt;p&gt;Worth dwelling on, because the scoring choices determine what the numbers mean and they are easy to get wrong.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;The held-out sample must span the activation range.&lt;/strong&gt; Score only on top-activating excerpts and every explanation looks good, because on those tokens almost anything predicts “high”. The discriminating cases are the tokens where the neuron does &lt;em&gt;not&lt;/em&gt; fire but a naive reading of the explanation says it should.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Correlation rewards getting the shape right, not the scale.&lt;/strong&gt; A simulator that predicts the right pattern with uniformly wrong magnitudes scores well. Whether that is acceptable depends on what you want the explanation for.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The simulator is a confound.&lt;/strong&gt; A better simulator raises every score without any explanation improving. Scores are comparable within one pipeline configuration and not across different ones, which makes cross-paper comparison of these numbers unsafe.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;A low score is ambiguous.&lt;/strong&gt; It can mean the explanation is wrong, or that the neuron is genuinely polysemantic and no short description exists. Those are very different findings and the score does not distinguish them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The other half: what the neuron writes
&lt;/h2&gt;

&lt;p&gt;Everything above describes a neuron by its &lt;em&gt;inputs&lt;/em&gt; — what makes it fire. That is only half of what a unit is. The other half is its output: the column of the MLP’s down-projection belonging to that unit, which is the vector it adds to the residual stream whenever it activates.&lt;/p&gt;

&lt;p&gt;That vector can be read directly. Push it through the final layer norm and the unembedding, exactly as &lt;a href="https://multigrid.ai/learn/logit-lens" rel="noopener noreferrer"&gt;the logit lens&lt;/a&gt; does, and you get the tokens this neuron promotes and suppresses when it fires. No corpus, no sampling, no explainer model — it is a property of the weights.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

&lt;span class="c1"&gt;# GPT-2: c_proj.weight has shape (d_mlp, d_model), one ROW per MLP unit
&lt;/span&gt;&lt;span class="n"&gt;W_out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transformer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;LAYER&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;mlp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;c_proj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;     &lt;span class="c1"&gt;# (d_mlp, d_model)
&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;W_out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;NEURON&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                                &lt;span class="c1"&gt;# what this unit writes
&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;no_grad&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lm_head&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transformer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ln_f&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;direction&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="n"&gt;top&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;topk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;bot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;topk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;promotes:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;top&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;suppresses:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indices&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two halves should agree. A unit whose max-activating examples are all legal text and whose output direction promotes legal vocabulary is a coherent story from both ends. A unit with a clean input story and an output direction that promotes nothing recognisable is telling you the description is incomplete — most likely it feeds a later computation rather than the output, in which case the honest description is about what it contributes to, not about what it means.&lt;/p&gt;

&lt;p&gt;Check the orientation of the weight matrix before trusting any of this. GPT-2 uses a &lt;code&gt;Conv1D&lt;/code&gt; layer whose weight is stored transposed relative to a standard &lt;code&gt;nn.Linear&lt;/code&gt;, so the axis indexing a unit differs between architectures. Verify by confirming that the vector you extracted has length &lt;code&gt;d_model&lt;/code&gt;, not &lt;code&gt;d_mlp&lt;/code&gt;. Getting this backwards produces a plausible-looking list of tokens for a vector that means nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three structural limits
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Polysemanticity.&lt;/strong&gt; If &lt;a href="https://multigrid.ai/learn/superposition" rel="noopener noreferrer"&gt;superposition&lt;/a&gt; is right, many neurons participate in several features and have no single meaning. An explanation pipeline will still produce a sentence for such a neuron, because it always produces a sentence. The low score is the only signal, and it is a weak one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Explanation is not causation.&lt;/strong&gt; A perfectly predictive description of when a neuron fires says nothing about what the model does with it. A neuron could fire reliably on a well-defined pattern and have almost no downstream effect. The causal question needs an ablation, and the two questions are routinely conflated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coverage.&lt;/strong&gt; A large model has hundreds of millions of MLP units across its layers. Explaining a sample tells you about the distribution of explainability; it does not give you a map. And the neurons you would most like explained — the ones implicated in some failure — are not the ones a random sample surfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  Features instead of neurons
&lt;/h2&gt;

&lt;p&gt;The field’s response has been to change the unit of analysis. If the neuron basis is the wrong basis, find a better one: train a sparse autoencoder on the layer’s activations and interpret its units instead. Reports from this line of work describe substantially more monosemantic units than raw neurons give.&lt;/p&gt;

&lt;p&gt;The explanation pipeline transfers unchanged — collect, explain, simulate, score — and the scoring is more important here, not less, because a sparse autoencoder produces its decomposition whether or not the model has one. A high explanation score on autoencoder features plus a causal check that intervening on a feature changes behaviour as its description predicts is the combination that means something. Either alone does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do if you must interpret a neuron
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt; Sample across the whole activation range, not just the top. Include the median and the zero region.&lt;/li&gt;
&lt;li&gt; Write the explanation as a prediction, then test it on held-out text: generate inputs your explanation says should fire and inputs it says should not, and check both.&lt;/li&gt;
&lt;li&gt; Check the outgoing weights. What does this unit write into the residual stream, and which tokens does that promote when passed through the unembedding? A neuron whose description and whose output direction disagree is telling you the description is incomplete.&lt;/li&gt;
&lt;li&gt; Ablate it — set it to its mean over a distribution rather than to zero — and measure what changes. If nothing does, your explanation may be correct and irrelevant.&lt;/li&gt;
&lt;li&gt; Report the score of a deliberately wrong explanation on the same neuron. That is your noise floor, and without it a number like 0.4 means nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/superposition" rel="noopener noreferrer"&gt;Superposition: Why One Neuron Means Several Things&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/probing-classifiers" rel="noopener noreferrer"&gt;Probing Classifiers: Finding What a Layer Knows&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/logit-lens" rel="noopener noreferrer"&gt;The Logit Lens: Reading Predictions From Middle Layers&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>Nested and Recursive Schemas: The Depth Limit Nobody Documents</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:21:15 +0000</pubDate>
      <link>https://dev.to/multigrid/nested-and-recursive-schemas-the-depth-limit-nobody-documents-32po</link>
      <guid>https://dev.to/multigrid/nested-and-recursive-schemas-the-depth-limit-nobody-documents-32po</guid>
      <description>&lt;p&gt;There are two ceilings on schema depth and they behave completely differently. One is published, enforced at request time and easy to discover. The other is not a limit at all — it is a gradual change in what a failure looks like, and it has no error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different limits
&lt;/h2&gt;

&lt;p&gt;The &lt;strong&gt;hard limit&lt;/strong&gt; is a documented cap in the provider implementation. OpenAI’s Structured Outputs guide has published caps on nesting depth, on total object properties across a schema, and on the combined character length of names and enum values. Those numbers exist, they are enforced with a 400, and they have been raised at least once since the feature launched in August 2024 — so any figure you read anywhere, including here, needs checking against the current docs before you design around it. That is why the section below is a probe rather than a table.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;soft limit&lt;/strong&gt; is the depth at which the model starts putting correct values in the wrong place. There is no announcement. Every response still validates. It is not a property of the provider at all; it is a property of the model, your schema and your document together, which is why nobody can publish a number for it that would mean anything for your case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why depth fails quietly
&lt;/h2&gt;

&lt;p&gt;This is the important idea in the page. Constrained decoding does not remove errors, it relocates them — and nesting is where the relocation is most visible.&lt;/p&gt;

&lt;p&gt;Without enforcement, a model that loses track of nesting emits unbalanced braces and your parser raises. Loud, immediate, traceable. With enforcement, unbalanced braces are unreachable: the mask will not emit &lt;code&gt;}&lt;/code&gt; where the grammar does not allow one. So the model, having drifted, produces the only thing still available to it — &lt;em&gt;a syntactically perfect document with the value in the wrong sub-object&lt;/em&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unconstrained, drifted:            constrained, drifted:

{ "parties": [                     { "parties": [
    { "name": "Acme",                  { "name": "Acme",
      "address": {                       "address": {
        "city": "Utrecht"                  "city": "Utrecht",
  }                                        "postcode": "1011AB" },   &amp;lt;-- belongs
                                         "role": "buyer" } ],            to party 2
  ^ JSONDecodeError                  "effective_date": "2026-01-04" }

  you find out in 3ms                ^ validates. you find out in Q3.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two consequences follow directly. First, schema validation is not a quality signal for nested output; a 100% validation rate tells you nothing about placement. Second, the invariants that catch this are cross-field ones you have to write yourself — the count of parties, a total that must equal a sum of children, an id that must appear exactly once. &lt;a href="https://multigrid.ai/learn/testing-structured-output" rel="noopener noreferrer"&gt;Those are the properties worth testing&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A probe for the hard limit
&lt;/h2&gt;

&lt;p&gt;Rather than trusting a number in any document, generate schemas of increasing depth and find where your endpoint stops accepting them. Runs in a few seconds, costs a handful of tokens, and is valid for the model and endpoint you actually use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BadRequestError&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MODEL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;nest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;An object nested to the given depth, with a string leaf at the bottom.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;additionalProperties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;level_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;level_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MODEL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Fill every level with the word deep.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
            &lt;span class="n"&gt;response_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;probe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;nest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;)}},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;BadRequestError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;depth &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; accepted -- no limit found below &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                            &lt;span class="c1"&gt;# bisect the first rejected depth
&lt;/span&gt;        &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt;
        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deepest accepted depth: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first rejection said: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;last_error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read the rejection text, not just the depth. It usually names which cap you hit — nesting, property count or character budget — and those three interact: a wide schema hits the property cap long before the nesting cap, so the depth this probe reports for a one-property-per-level schema is an upper bound on what your real schema will get.&lt;/p&gt;

&lt;p&gt;Run the same probe against every provider you route to. A schema that works on one model and 400s on another is a routine cause of intermittent failures in multi-provider setups, and the answer is usually to design to the tightest limit in your set rather than to branch.&lt;/p&gt;

&lt;p&gt;“Every provider you route to” is the operative phrase, and it is usually longer than people think once failover is counted — &lt;a href="https://multigrid.ai/providers" rel="noopener noreferrer"&gt;Multigrid’s provider pages&lt;/a&gt; are one way to enumerate that set before you run the probe against each member of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recursive schemas
&lt;/h2&gt;

&lt;p&gt;Trees, comment threads, nested clauses, org charts. JSON Schema expresses these with a self-reference — &lt;code&gt;"$ref": "#"&lt;/code&gt; for root recursion, or a named &lt;code&gt;$defs&lt;/code&gt; entry that refers to itself. Hosted strict modes have supported recursive schemas in some form since fairly early, with the same caveat about checking current docs.&lt;/p&gt;

&lt;p&gt;The failure mode is specific and worth anticipating: a grammar can always continue descending, so a model that starts producing nested nodes has no structural pressure to stop, and the generation runs into &lt;code&gt;max_tokens&lt;/code&gt;. You get &lt;code&gt;finish_reason: "length"&lt;/code&gt; and a truncated document. Bound it in the schema where you can — a &lt;code&gt;depth&lt;/code&gt; integer field with a small enum of allowed values, or an explicit non-recursive leaf variant in an &lt;code&gt;anyOf&lt;/code&gt; — and always check &lt;code&gt;finish_reason&lt;/code&gt; before parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flattening, and when not to
&lt;/h2&gt;

&lt;p&gt;The reliable answer to both limits is to stop nesting and emit a node list with parent references:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"nodes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parent_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clause"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parent_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"subclause"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"parent_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"n1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"subclause"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Depth becomes one level regardless of how deep the tree is, so both caps stop mattering. Truncation degrades gracefully: a cut-off list is a shorter list rather than a broken tree. And errors become checkable — a dangling &lt;code&gt;parent_id&lt;/code&gt;, a cycle, or a duplicate &lt;code&gt;id&lt;/code&gt; are all assertions you can write in ten lines, where “this object is nested under the wrong parent” is not.&lt;/p&gt;

&lt;p&gt;The cost is real, so do not do this reflexively. The model must now invent and track identifiers, which is a genuine burden on it, and you must rebuild the tree in code and handle the malformed cases. For two or three levels, nest. Flatten when the depth is unbounded, when the data is recursive by nature, or when the probe above tells you your natural schema does not fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/schema-edge-cases" rel="noopener noreferrer"&gt;Optional Fields, Nulls and Unions: Where Schemas Break&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/llm-friendly-schemas" rel="noopener noreferrer"&gt;Designing a JSON Schema an LLM Can Fill Correctly&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/structured-output-support" rel="noopener noreferrer"&gt;Structured Output Support: Test It Yourself&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Neural Radiance Fields and Gaussian Splatting</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:20:40 +0000</pubDate>
      <link>https://dev.to/multigrid/neural-radiance-fields-and-gaussian-splatting-3m4e</link>
      <guid>https://dev.to/multigrid/neural-radiance-fields-and-gaussian-splatting-3m4e</guid>
      <description>&lt;p&gt;A neural radiance field is a small neural network whose weights encode one specific scene — not a model of scenes in general, a model of your kitchen. Gaussian splatting keeps the idea of fitting a scene to photographs and throws away the network, which is why it renders in milliseconds instead of seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scene becomes weights, concretely
&lt;/h2&gt;

&lt;p&gt;The function being learned takes a point in space and a viewing direction, and returns a colour and a density:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;F(x, y, z, theta, phi) -&amp;gt; (r, g, b, sigma)

  5 numbers in, 4 numbers out.

Original NeRF (Mildenhall and colleagues, 2020):
  8 hidden layers of 256 units
  roughly 600,000 parameters
  about 2.4 MB in fp32

Training input: 50-100 photographs of ONE scene, with camera poses
                recovered by structure-from-motion.
Training time:  hours to about a day on one GPU, in the original work.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is what “a scene becomes weights” means. There is no mesh, no point cloud, no texture atlas. The geometry and the appearance of the room are stored as 600,000 floating-point numbers in a multi-layer perceptron, and the only way to find out what is at a point is to evaluate the network there.&lt;/p&gt;

&lt;p&gt;It also means the model generalises to nothing. Train on your kitchen and you have a kitchen; a different room requires training again from scratch. These are per-scene fits, which is a genuinely unusual thing for a neural network to be and is the source of most confusion about them.&lt;/p&gt;

&lt;p&gt;One detail is load-bearing: the input coordinates are not fed in directly. They are expanded with sines and cosines at many frequencies first — the same &lt;a href="https://multigrid.ai/learn/positional-encoding" rel="noopener noreferrer"&gt;Fourier positional encoding&lt;/a&gt; idea used in transformers. Without it the MLP produces a smooth blur, because a small network fed raw coordinates has a strong bias toward low-frequency functions. With it, sharp edges become representable. That one change is the difference between the technique working and not.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a pixel is produced
&lt;/h2&gt;

&lt;p&gt;There is no rasteriser. To find the colour of one pixel you cast a ray from the camera through it and integrate what the ray passes through:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sample points along the ray:  t_1 ... t_n
query the MLP at each:        colour c_i, density sigma_i

transmittance (how much light survives to sample i):
  T_i = exp( - sum over j&amp;lt;i of sigma_j * delta_j )

pixel colour:
  C = sum over i of  T_i * (1 - exp(-sigma_i * delta_i)) * c_i

delta_i = distance between consecutive samples.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole rendering equation is differentiable, which is the reason this works at all: you compare the rendered pixel to the real photograph and backpropagate the error into the weights. The scene is fitted by rendering it repeatedly and correcting.&lt;/p&gt;

&lt;p&gt;Sampling is done in two passes — a coarse network to find where the surfaces are, then a fine network concentrating samples there — because most of a ray is empty air and evaluating the MLP in empty air is wasted work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it was slow: count the evaluations
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;One 800 x 600 frame:

  rays            = 800 * 600            = 480,000
  samples per ray = 64 coarse + 128 fine = 192
  MLP evaluations = 480,000 * 192        = 92,160,000

  each evaluation: an 8-layer, 256-wide MLP
  roughly 600,000 MACs -&amp;gt; about 5.5 x 10^13 MACs per frame
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ninety-two million forward passes through a small network, for one image. At 30 frames per second that would be 2.8 billion network evaluations per second, sustained. This is not an implementation problem; it is what the representation demands, and it is the entire reason everything that came afterwards came afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first fix: put features in a grid
&lt;/h2&gt;

&lt;p&gt;The network is doing two jobs at once: storing the scene and computing the colour. Split them. Store learned feature vectors in a spatial data structure, look up the ones near the query point, and let a much smaller MLP turn the looked-up features into a colour.&lt;/p&gt;

&lt;p&gt;Instant-NGP (NVIDIA, 2022) is the well-known version, using a multiresolution hash table of feature vectors and an MLP of two or three tiny layers. Reported training times drop from hours to seconds or minutes and rendering becomes interactive.&lt;/p&gt;

&lt;p&gt;The trade is stated in one line, and it is the same trade as everywhere else in this cluster: &lt;strong&gt;memory for compute&lt;/strong&gt;. The 2.4 MB MLP becomes tens or hundreds of megabytes of hash tables, and in exchange the arithmetic per sample falls by an order of magnitude.&lt;/p&gt;

&lt;h2&gt;
  
  
  What splatting changed
&lt;/h2&gt;

&lt;p&gt;3D Gaussian splatting (Kerbl and colleagues, SIGGRAPH 2023) takes the next step and removes the network from the scene representation entirely. A scene is a large set of explicit 3D Gaussian blobs, each with its own parameters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;per Gaussian:
  position                  3 floats
  scale                     3
  rotation (quaternion)     4
  opacity                   1
  colour, spherical         3 (degree 0)  ... 48 (degree 3)
  harmonics
                          ----
                          ~59 floats at degree 3 = about 236 bytes

typical scene: 1 to 5 million Gaussians
             = 240 MB to 1.2 GB uncompressed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Spherical harmonics are how the colour is allowed to change with viewing angle, which is what preserves specular highlights and gloss — the thing that makes a reconstruction look like a photograph rather than a model.&lt;/p&gt;

&lt;p&gt;The change that matters is in how a frame is produced. Rendering is no longer “evaluate a network many times along each ray”. It is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Project each 3D Gaussian to the image plane, which gives a 2D Gaussian — a soft ellipse — by a linear transformation of its covariance.&lt;/li&gt;
&lt;li&gt; Assign the ellipses to screen tiles and sort them by depth.&lt;/li&gt;
&lt;li&gt; Alpha-blend front to back within each tile until the accumulated opacity saturates.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That is rasterisation: project, sort, blend. It is the operation graphics hardware has had dedicated paths for since the 1990s, and it is why the original paper reports real-time rendering at 1080p at frame rates above 100 — against seconds per frame for a comparable-quality NeRF. Nothing about the fitting got fundamentally smarter; the representation was changed into one the hardware already knew how to draw.&lt;/p&gt;

&lt;p&gt;Training is still gradient descent against photographs, and it still uses a differentiable renderer. The Gaussians are additionally cloned, split and pruned during optimisation, so the model adds detail where the reconstruction error is high rather than starting from a fixed budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a splat scene is actually fitted
&lt;/h2&gt;

&lt;p&gt;The optimisation is worth describing, because it explains something that otherwise looks arbitrary: nobody chooses how many Gaussians a scene has. The number is an outcome.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Initialise from the point cloud you already have.&lt;/strong&gt; Structure-from-motion produced camera poses, and it produced a sparse point cloud as a by-product — typically 50,000 to 200,000 points. Each becomes one Gaussian, small and roughly isotropic, with the point’s colour.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Render and compare.&lt;/strong&gt; Rasterise the current scene from a training camera and compute the loss against the real photograph. The loss is an L1 term plus a structural-similarity term rather than plain squared error, because pixel-mean error is a poor stand-in for how a photograph looks.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Backpropagate into everything.&lt;/strong&gt; Positions, scales, rotations, opacities and spherical-harmonic coefficients are all ordinary parameters with gradients. There is no network in the loop.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Adaptive density control, every hundred iterations or so.&lt;/strong&gt; Gaussians in regions the reconstruction is missing show large positional gradients. Small ones there are &lt;em&gt;cloned&lt;/em&gt;; large ones covering high-detail regions are &lt;em&gt;split&lt;/em&gt; into two smaller ones; and any Gaussian whose opacity has fallen below a threshold is deleted. Opacity is also periodically reset so that the pruning has something to bite on.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Stop after a fixed budget.&lt;/strong&gt; Around thirty thousand iterations in the original work, on the order of tens of minutes on one GPU for a typical scene, by which point the population has grown from a hundred thousand to a few million.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step four is the interesting one and it has no equivalent in ordinary neural network training: the model changes its own size during optimisation, adding capacity exactly where the error is. That is why detailed scenes end up with more Gaussians than empty ones, and why the file size of a splat scene is a measurement of how complicated the room was rather than a hyperparameter anybody set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What neither of them gives you
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Camera poses are an input&lt;/td&gt;
&lt;td&gt;Both need to know where each photograph was taken, typically from structure-from-motion. That preprocessing step fails on textureless surfaces, repeated patterns and small baselines, and when it fails nothing downstream works.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One scene, one fit&lt;/td&gt;
&lt;td&gt;Neither produces a model that generalises. Feed-forward variants that predict a scene from a few images in one pass exist and are moving quickly, but the classic pipeline is per-scene optimisation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lighting is baked in&lt;/td&gt;
&lt;td&gt;The appearance captured is the appearance under the lighting at capture time. Relighting, moving a light, or inserting the scene into another environment all need inverse rendering, which is a harder and less solved problem.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No usable geometry by default&lt;/td&gt;
&lt;td&gt;A NeRF is a density field and a splat scene is a cloud of blobs. Neither is a watertight mesh, so physics, collision and most content pipelines need a conversion step that is lossy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage versus compute&lt;/td&gt;
&lt;td&gt;The NeRF is megabytes and slow to render; the splat scene is hundreds of megabytes and fast. On a phone or over a network that difference is the deciding factor, and compressing splat scenes is an active area precisely because of it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This field moves faster than almost anything else in this cluster. Compression, dynamic and animated scenes, and feed-forward reconstruction from a handful of images are all active, and the numbers above will improve. The arithmetic that will not change is the reason for the shift: querying a network per sample per ray is a different order of work from projecting and blending primitives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/vae-explained" rel="noopener noreferrer"&gt;Variational Autoencoders&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/cnn-explained" rel="noopener noreferrer"&gt;Convolutional Networks, and Where They Still Win&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>Negative Prompts and How They Work Mechanically</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:20:36 +0000</pubDate>
      <link>https://dev.to/multigrid/negative-prompts-and-how-they-work-mechanically-709</link>
      <guid>https://dev.to/multigrid/negative-prompts-and-how-they-work-mechanically-709</guid>
      <description>&lt;p&gt;There is no negative-prompt mechanism in a diffusion model. There is a text field that gets encoded and put where the empty string normally goes — in the unconditional branch of classifier-free guidance — and every property of negative prompts, including the surprising ones, falls out of that one substitution.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;p&gt;Guidance evaluates the denoiser twice per step: once with your prompt, once with an empty prompt. The two predictions are combined by extrapolating away from the empty-prompt one. A negative prompt simply puts text in that second slot, so you are now extrapolating away from a specific prediction rather than a generic one.&lt;/p&gt;

&lt;p&gt;It is not a filter, not a constraint, and not a post-generation check. It is a direction in noise space that the sampler is told to move against.&lt;/p&gt;

&lt;h2&gt;
  
  
  The slot it occupies
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Standard classifier-free guidance, no negative prompt:

    eps_hat = eps("")        +  w · ( eps(prompt) − eps("") )

With a negative prompt, "" is replaced by the negative text:

    eps_hat = eps(negative)  +  w · ( eps(prompt) − eps(negative) )

Expanded into coefficients:

    eps_hat = w · eps(prompt)  +  (1 − w) · eps(negative)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The negative prompt is therefore weighted &lt;code&gt;1 − w&lt;/code&gt;, which is negative for any guidance scale above 1. That is the whole thing. Read off the coefficients at the usual settings, using the same table worked in &lt;a href="https://multigrid.ai/learn/classifier-free-guidance" rel="noopener noreferrer"&gt;the guidance page&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  w        weight on prompt     weight on negative prompt
-----------------------------------------------------------
  1.0            1.00                 0.00     no effect at all
  2.0            2.00                −1.00
  3.5            3.50                −2.50
  5.0            5.00                −4.00
  7.5            7.50                −6.50
 12.0           12.00               −11.00     very strong; expect damage
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why it does nothing at guidance scale 1
&lt;/h2&gt;

&lt;p&gt;At &lt;code&gt;w = 1&lt;/code&gt; the coefficient on the negative prompt is exactly zero. Substitute it into the equation and see it disappear:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;    eps_hat = 1 · eps(prompt)  +  (1 − 1) · eps(negative)
            = eps(prompt)

The negative prompt term is multiplied by zero. Not weakened. Removed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Most implementations go further and skip the second forward pass entirely when the scale is 1, since it would be multiplied out anyway. So the negative prompt field is not merely ineffective at that setting — the text is never encoded and the pass never runs.&lt;/p&gt;

&lt;p&gt;This has a practical corollary that catches people constantly: the strength of a negative prompt is not a separate dial. It is &lt;code&gt;w − 1&lt;/code&gt;. Raising the guidance scale strengthens the negative prompt at the same time as it strengthens the positive one, and lowering the guidance scale to fix oversaturation weakens the negative prompt as a side effect.&lt;/p&gt;

&lt;p&gt;Some interfaces expose a separate negative-prompt strength. That is not a change to this equation; it is an additional blend applied before or after it, implemented differently in each tool. Check what your tool does before assuming it maps onto the coefficients above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is free
&lt;/h2&gt;

&lt;p&gt;The second forward pass already existed. Guidance requires it whether the second prompt is your negative text or the empty string, so filling the field costs one extra text-encoder call — a few milliseconds, once, not per step — and nothing else.&lt;/p&gt;

&lt;p&gt;That is why negative prompts spread as a habit. They are the only quality lever in a generation UI with no compute cost attached, which also explains why enormous boilerplate negative prompts became normal: nobody was paying for them by the token.&lt;/p&gt;

&lt;p&gt;The corollary is that on models with no second pass — guidance-distilled checkpoints, and models that were trained without conditioning dropout — there is nowhere for the negative prompt to go. A UI may still show the field and silently ignore it, which is a documentation problem rather than a bug in the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it fails to remove things
&lt;/h2&gt;

&lt;p&gt;“I put &lt;em&gt;hat&lt;/em&gt; in the negative prompt and it still gave me a hat.” The mechanism explains why this is expected rather than broken.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;It is a direction, not a constraint.&lt;/strong&gt; The sampler moves against a vector at every step. Nothing checks the result. There is no stage at which the image is examined for hats.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Nouns in the negative are not object-level.&lt;/strong&gt; The text encoder produces a sequence of embeddings for the whole negative prompt, and the model attends to all of it. Putting “hat” there pushes away from the model’s general notion of hat-related imagery, which is entangled with heads, portraits, and whatever else co-occurs with hats in training data.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;The positive prompt usually wins.&lt;/strong&gt; The coefficient on the positive prompt is &lt;code&gt;w&lt;/code&gt; and on the negative is &lt;code&gt;1 − w&lt;/code&gt;, and the positive prompt is also the thing the model was conditioned to follow. If the composition needs a hat, guidance of 7.5 against it is a nudge.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Long negatives dilute each other.&lt;/strong&gt; One text encoding represents the whole negative string. Adding a twentieth term does not add a twentieth constraint; it changes one embedding slightly. Encoders with a 77-token limit will also silently truncate a long negative prompt, which is why the end of a boilerplate list often does nothing at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For actually removing an object, the reliable route is spatial rather than textual: mask it and regenerate that region, as covered in &lt;a href="https://multigrid.ai/learn/inpainting-outpainting" rel="noopener noreferrer"&gt;inpainting and outpainting&lt;/a&gt;, or constrain the composition with &lt;a href="https://multigrid.ai/learn/controlnet" rel="noopener noreferrer"&gt;structural conditioning&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing one that does something
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Start empty and add only what you observe.&lt;/strong&gt; A negative prompt copied from a forum is a set of directions tuned for somebody else’s model at somebody else’s guidance scale. On a modern checkpoint it frequently makes things worse.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Prefer quality and style words to object words.&lt;/strong&gt; Terms describing a rendering quality — blur, low resolution, compression artefacts, a medium you do not want — describe a direction in image space, which is what this mechanism can act on. Object nouns describe content, which it cannot reliably suppress.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Keep it inside the token limit.&lt;/strong&gt; If your text encoder truncates at 77 tokens, that is roughly 50 to 60 words. Anything past it is discarded silently.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Test with the seed fixed.&lt;/strong&gt; Generate with and without the negative prompt at the same seed, sampler and step count. Any other comparison is noise. See &lt;a href="https://multigrid.ai/learn/image-generation-seeds" rel="noopener noreferrer"&gt;seeds and determinism&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Re-tune it when you change the guidance scale.&lt;/strong&gt; The negative prompt’s strength is &lt;code&gt;w − 1&lt;/code&gt;. A negative prompt tuned at guidance 7.5 is running at less than half strength at guidance 3.5.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Beyond the single negative
&lt;/h2&gt;

&lt;p&gt;Two extensions exist and both are visible in the equation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple weighted conditions.&lt;/strong&gt; Nothing restricts the combination to two terms. Implementations exist that evaluate several conditionings and combine them with independent weights, which is how per-concept strengths are offered in some tools. The cost is one forward pass per condition, so a three-way combination triples the per-step compute rather than doubling it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Perpendicular negative guidance.&lt;/strong&gt; A known failure of the plain formulation is that the negative direction often overlaps with the positive one — negating “blurry” also pushes against parts of what you asked for. The fix is to project the negative direction onto the component orthogonal to the positive one and use only that, so the negative term cannot cancel anything the prompt asked for. It costs the same forward passes and only changes how the vectors are combined.&lt;/p&gt;

&lt;p&gt;Both are worth knowing about mainly as a way to read what a tool is doing. If a UI offers per-term negative weights, it is doing something other than the two-term equation above, and its numbers will not match this page’s coefficients.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/classifier-free-guidance" rel="noopener noreferrer"&gt;CFG Scale Explained: What Classifier-Free Guidance Does&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/how-diffusion-works" rel="noopener noreferrer"&gt;How Diffusion Models Generate an Image&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/image-model-text-hands" rel="noopener noreferrer"&gt;Why Image Models Struggle With Text and Hands&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>Negative Instructions: Why "Don't" Often Backfires</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:20:02 +0000</pubDate>
      <link>https://dev.to/multigrid/negative-instructions-why-dont-often-backfires-2704</link>
      <guid>https://dev.to/multigrid/negative-instructions-why-dont-often-backfires-2704</guid>
      <description>&lt;p&gt;Every prompt accumulates prohibitions, one incident at a time. They are the weakest instruction form available, for two reasons that have nothing to do with the model being contrary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two reasons a prohibition is weak
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A prohibition names an unbounded set.&lt;/strong&gt; “Do not be verbose” excludes one region of output space and specifies nothing about where to land instead. There are countless compliant continuations and the model picks by likelihood, not by your unstated preference. “Answer in at most two sentences” names one target, and a target is something the sampler can move toward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The forbidden thing is now in the context.&lt;/strong&gt; A model conditions on tokens; it does not evaluate a logical operator over them. “Never mention the competitor’s name” puts that name in the prompt, and the presence of a token in context raises the probability of related continuations. The instruction has to overcome the salience it created. This is the same mechanism that makes “do not apologise” produce an apology for apologising.&lt;/p&gt;

&lt;p&gt;There is a third, structural reason worth adding: prohibitions must hold at every one of the hundreds of sequential generation steps, while a positive format instruction only has to be satisfied once by the finished text. Compliance decays with length either way, but the negative form has more chances to fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ten rewrites
&lt;/h2&gt;

&lt;p&gt;Each row is a prohibition of the kind that ends up in a production system prompt, and the positive form that names a target instead. Nothing here is measured — these are rewrites for shape, and the argument for them is the mechanism above.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Instead of&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Don't be verbose&lt;/td&gt;
&lt;td&gt;Answer in at most three sentences. Stop after the recommendation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't make things up&lt;/td&gt;
&lt;td&gt;Every claim must quote a span from . If none supports it, reply exactly: NOT_FOUND&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't use markdown&lt;/td&gt;
&lt;td&gt;Output plain text only. The first character must be a letter or a digit.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't apologise&lt;/td&gt;
&lt;td&gt;Begin the reply with the answer itself, in the first sentence.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't mention prices&lt;/td&gt;
&lt;td&gt;Restrict the reply to features and availability. Refer pricing questions to the sales contact line verbatim.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't add explanations after the JSON&lt;/td&gt;
&lt;td&gt;Output exactly one JSON object. The final character must be }&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't guess the customer's intent&lt;/td&gt;
&lt;td&gt;Choose one of: refund, technical, billing, other. Use other when two are equally likely.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't use jargon&lt;/td&gt;
&lt;td&gt;Write for a reader with no industry background. Define any term that is not in everyday English on first use.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't repeat the question&lt;/td&gt;
&lt;td&gt;Start with the verb of the recommended action.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Don't give medical advice&lt;/td&gt;
&lt;td&gt;Describe what the document says and stop. Close with the referral sentence given in .&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice what changed apart from the polarity. Nine of the ten rewrites are now checkable by a function — a length test, an equality test, a membership test, a first-or-last character test. That is the real gain. A prohibition can only be audited by a human reading output; a positive constraint with a literal in it can fail a test in CI.&lt;/p&gt;

&lt;p&gt;The rewrites also expose something the prohibitions were hiding. Several of them turn out to be two decisions, not one: “don’t guess the customer’s intent” requires a fallback label, and nobody had chosen one. That is the usual experience. A prohibition can be written before you have decided what should happen instead; a positive instruction cannot, which is why rewriting them is a design exercise rather than a copy edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a negative is the right form
&lt;/h2&gt;

&lt;p&gt;Prohibitions are not banned, they are just narrow. Keep one when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;The excluded set is small and nameable.&lt;/strong&gt; “Do not use the words ‘delve’, ‘tapestry’ or ‘testament’” works because you can also test it.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;It is a genuine safety boundary.&lt;/strong&gt; Refusal scope belongs in the system prompt in explicit terms, and its value does not depend on being perfectly obeyed — it depends on being defensible and paired with an enforcement layer.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;It is paired with the positive alternative.&lt;/strong&gt; “Do not invent an order id; if it is missing, output &lt;code&gt;null&lt;/code&gt;” is a prohibition with a landing place, and that is the version that holds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where one survives, put it next to the thing it constrains rather than in a general rules block. “Do not invent an order id” belongs in the field description inside the output contract, ten tokens from where the model will write that field — not in a list of twelve policies two thousand tokens earlier, competing with eleven other rules for the same attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to enforce outside the prompt
&lt;/h2&gt;

&lt;p&gt;The general lesson is that a constraint you care about should not live only in prose. Three mechanisms, in order of reliability:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;A validator plus a retry.&lt;/strong&gt; Parse, test the constraint, and on failure re-request with the specific violation quoted back. This is the only approach that works on every model and it converts a soft instruction into a hard one.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Stop sequences.&lt;/strong&gt; If the model reliably starts its unwanted epilogue with a known string, a stop sequence removes it at the decoder rather than asking politely.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Token bias, where offered.&lt;/strong&gt; OpenAI-compatible APIs expose &lt;code&gt;logit_bias&lt;/code&gt;, a map of token id to a value in the range −100 to 100, where −100 is effectively a ban. It is exact and it is fiddly: bias applies per token id, so a word needs every casing and leading-space variant covered, and many providers and models do not support the parameter at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rank them by what happens when the model ignores you. A prohibition fails silently and reaches the user. A validator fails loudly and retries. That difference is worth more than any wording.&lt;/p&gt;

&lt;p&gt;One layer sits above all three, and it is the only one that survives an adversarial user: refuse to &lt;em&gt;act&lt;/em&gt; on the output rather than trying to prevent its production. If the model must not issue refunds above a threshold, the threshold belongs in the code that issues refunds. Every constraint that lives only in a prompt is a constraint somebody can talk their way past, and prohibitions are the weakest form of a mechanism that was already the wrong place for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/prompting-for-refusals" rel="noopener noreferrer"&gt;Prompting for Refusals: Making a Model Say "I Don't Know"&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/output-formatting" rel="noopener noreferrer"&gt;Output Formatting: Consistent Shapes Without JSON Mode&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/instruction-following-limits" rel="noopener noreferrer"&gt;Instruction Following: Why Models Ignore Rule 7 of 12&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
    <item>
      <title>Named Entity Recognition Then and Now</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:19:58 +0000</pubDate>
      <link>https://dev.to/multigrid/named-entity-recognition-then-and-now-2h76</link>
      <guid>https://dev.to/multigrid/named-entity-recognition-then-and-now-2h76</guid>
      <description>&lt;p&gt;Named entity recognition is span labelling: find the character offsets of every person, organisation and place in a text and tag each one. That framing — offsets, not a list — is where most comparisons between classical NER and LLM extraction quietly go wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The task, precisely
&lt;/h2&gt;

&lt;p&gt;A classical NER system consumes a tokenised sentence and emits one tag per token in a scheme such as BIO: &lt;code&gt;B-ORG&lt;/code&gt; for the first token of an organisation, &lt;code&gt;I-ORG&lt;/code&gt; for subsequent ones, &lt;code&gt;O&lt;/code&gt; for everything else. Two properties follow and both matter. Output is aligned to the input, so every entity comes with exact offsets you can highlight, store or redact. And output is structurally constrained: the system cannot return an entity that is not literally in the text, because it is labelling tokens rather than writing them.&lt;/p&gt;

&lt;p&gt;Ask a language model to “extract all organisations” and you get a list of strings. That is a different object. It has no offsets, it may normalise the surface form (&lt;code&gt;IBM&lt;/code&gt; for &lt;code&gt;International Business Machines Corp.&lt;/code&gt;), it may merge two mentions into one, and it can return something that never appeared — which is the ordinary &lt;a href="https://multigrid.ai/learn/why-llms-hallucinate" rel="noopener noreferrer"&gt;failure mode of generation&lt;/a&gt; applied to extraction. For redaction, highlighting or any use where you must point at the source, you need the offsets, and getting them back means string-matching the model’s output against the document, which fails whenever it normalised anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the published record shows
&lt;/h2&gt;

&lt;p&gt;NER has an unusually clean public record, so there is no need to invent numbers. The reference benchmark is the CoNLL-2003 shared task (Tjong Kim Sang and De Meulder), which defined four entity types over Reuters newswire and has been the comparison point ever since. The best systems at the 2003 workshop — Florian et al. among them — reported F1 in the high eighties on English, using classifier ensembles over hand-engineered features plus gazetteers.&lt;/p&gt;

&lt;p&gt;Twenty years of neural work moved that into the mid nineties on the same dataset, with entity-aware pre-trained encoders such as LUKE (Yamada et al., EMNLP 2020) among the systems reporting the highest figures. The important observation is the shape of the curve rather than any single number: two decades of architecture work bought roughly five to seven F1 points on newswire, which means the classical systems were already close to the ceiling &lt;em&gt;on that domain&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The catch, and it is the one that decides real projects, is that CoNLL-2003 is 1996 Reuters newswire. Performance on clinical notes, legal contracts, chat logs, product listings or non-English text is a different question with a different answer, and a system trained on newswire degrades sharply on all of them. Do not read a benchmark number as a prediction about your documents. For the model you actually install — spaCy, Stanza, Flair — read the accuracy figures published for that specific version and corpus in its own documentation, and then check them on a hundred of your own sentences, because that is the only number that describes your problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost arithmetic
&lt;/h2&gt;

&lt;p&gt;Nobody here has benchmarked either option, so what follows is arithmetic with every input labelled as an assumption. Substitute your own.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Assumption:&lt;/strong&gt; 10 million documents per month, averaging 300 tokens each — 3 billion input tokens.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Assumption:&lt;/strong&gt; a cheap hosted model at $0.10 per million input tokens, with extraction output small enough to ignore. That is 3,000 × $0.10 = &lt;strong&gt;$300 per month&lt;/strong&gt;, before retries.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Assumption:&lt;/strong&gt; a small CPU NER pipeline processes a 300-token document in 20 ms on one core. 10 million documents is 200,000 core-seconds, about 56 core-hours.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Assumption:&lt;/strong&gt; a 4-vCPU instance at $0.05 per hour run continuously is $36 per month and supplies roughly 2,900 core-hours, fifty times the requirement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the monthly ratio is roughly 8:1 in this example, and it widens with volume because one side scales and the other does not. But the ratio is not the interesting number — the &lt;em&gt;latency&lt;/em&gt; is. Twenty milliseconds locally against a few hundred for a network round trip is a factor of ten to twenty, and it is the difference between running extraction inline in a request handler and building a queue, a worker, a retry policy and a dead-letter path. That engineering is the real cost of the API option and it does not appear on the bill.&lt;/p&gt;

&lt;p&gt;Against that, the classical option has a fixed cost the API does not: if no off-the-shelf model covers your entity types you need labelled data, and annotation is measured in analyst-days. &lt;a href="https://multigrid.ai/learn/nlp-vs-llm" rel="noopener noreferrer"&gt;The break-even derivation&lt;/a&gt; treats that fixed cost properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where an LLM genuinely wins
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Entity types nobody has a model for.&lt;/strong&gt; “Contract termination clauses”, “mentions of a competitor’s pricing”, “symptoms with their onset”. A prompt describes these in a sentence; a trained model requires a labelled corpus that does not exist.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Extraction that needs reasoning.&lt;/strong&gt; Deciding whether an organisation mention is the buyer or the seller is not span labelling. Neither is resolving &lt;em&gt;the company&lt;/em&gt; back to a name mentioned two paragraphs earlier — see &lt;a href="https://multigrid.ai/learn/coreference-resolution" rel="noopener noreferrer"&gt;coreference&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Low volume, immediate need.&lt;/strong&gt; At ten thousand documents a month the API bill is a rounding error and the classical route costs a week. Volume is what flips this.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Long-tail languages.&lt;/strong&gt; Where no maintained NER model exists for the language at all, a multilingual model is the only option on the table.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The hybrid that keeps both
&lt;/h2&gt;

&lt;p&gt;The arrangement that tends to survive contact with production runs the cheap system on everything and the expensive one on the residue. A local NER model tags all ten million documents; a confidence threshold and a set of rules identify the few per cent that are ambiguous — no entities found in a document that should have some, overlapping spans, types the model handles badly — and only those go to a model. If five per cent escalate, the API cost in the arithmetic above falls to about $15 a month while the hard cases still get the better treatment.&lt;/p&gt;

&lt;p&gt;The same pattern also solves the offsets problem. Use the local model for spans, and use the language model only for the judgement it is genuinely better at: classifying a span you already found, or linking it to a canonical record.&lt;/p&gt;

&lt;p&gt;If you are testing whether escalation is worth it, both halves of the comparison want to be measurable on the same axis. Extraction models are billed per token like everything else, so you can put your own escalation rate into the arithmetic above and see the crossover — the &lt;a href="https://multigrid.ai/models" rel="noopener noreferrer"&gt;per-model token prices&lt;/a&gt; are the only input it needs. It is worth being blunt about the result, though: at high volume with common entity types and a domain a public model already covers, the local pipeline wins on cost, latency and offsets simultaneously, and the API is the wrong tool no matter how cheap the tokens get.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/pos-tagging" rel="noopener noreferrer"&gt;Part-of-Speech Tagging and Dependency Parsing&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/text-classification" rel="noopener noreferrer"&gt;Text Classification Without an LLM&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/nlp-vs-llm" rel="noopener noreferrer"&gt;Evaluating Classical NLP vs LLM Approaches&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>datascience</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Music and Sound Generation Models</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:19:23 +0000</pubDate>
      <link>https://dev.to/multigrid/music-and-sound-generation-models-2o9p</link>
      <guid>https://dev.to/multigrid/music-and-sound-generation-models-2o9p</guid>
      <description>&lt;p&gt;Three minutes of CD-quality stereo audio is about sixteen million numbers, arranged in a sequence where an error lasting one thousandth of a second is audible. That is the problem every approach on this page is trying to make smaller, and which representation it picks decides everything else about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short answer
&lt;/h2&gt;

&lt;p&gt;Audio generation works by not generating audio. Every practical system maps the waveform into a much smaller representation, generates in that representation, and maps back with a decoder. The three representations in use are time-frequency images, discrete tokens from a learned codec, and continuous latents — and the third is the same trick as &lt;a href="https://multigrid.ai/learn/latent-diffusion" rel="noopener noreferrer"&gt;latent diffusion&lt;/a&gt; for images, applied to a one-dimensional signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why audio was harder, in numbers
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Raw audio, CD quality:
    44,100 samples per second per channel × 2 channels = 88,200 values/s

    30 seconds  =  2,646,000 values
    3 minutes   = 15,876,000 values

Compare a 1024×1024 RGB image:
    1024 × 1024 × 3 = 3,145,728 values

So three minutes of audio is about 5× the raw values of a megapixel
image — and, unlike an image, it is a single sequence in which order is
the content.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The raw count is only half of it. Three further properties make audio less forgiving than a picture of the same size:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Errors are audible at a much finer scale.&lt;/strong&gt; A wrong pixel is invisible. A discontinuity lasting a millisecond is a click that everyone hears, and periodic artefacts at any level become a tone.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Phase matters and is hard to represent.&lt;/strong&gt; Two signals with identical frequency content and different phase can sound completely different. Most of the intuitive representations throw phase away.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Structure spans the whole sequence.&lt;/strong&gt; A chorus must return ninety seconds later in the same key at the same tempo. That is a dependency across millions of samples, which is far longer than any dependency an image model has to maintain.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Spectrograms, and the phase problem
&lt;/h2&gt;

&lt;p&gt;The oldest workable approach converts audio into a time-frequency image and reuses image machinery on it. A short-time Fourier transform produces a 2D array of magnitudes: time along one axis, frequency along the other. A mel scale compresses the frequency axis to match human hearing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;30 seconds of audio as a mel spectrogram:

    hop length 256 samples at 44.1 kHz  →  172 frames per second
    30 s × 172                          →  5,166 time steps
    80 mel bands

    5,166 × 80 = 413,280 values

    Down from 2,646,000 raw values: a 6.4× reduction, and now it is a
    2D array that a convolutional or transformer image model can eat.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch is in what the transform discarded. A magnitude spectrogram keeps how much energy is at each frequency and throws away the phase. Reconstructing a waveform therefore requires inventing phase, and the classical iterative algorithms for doing so produce the characteristic metallic, watery quality that early neural audio was known for.&lt;/p&gt;

&lt;p&gt;The fix was neural vocoders: networks trained to produce a waveform from a spectrogram directly, learning plausible phase rather than estimating it. That works well and it also means the vocoder becomes the quality ceiling for the whole system, in exactly the way the autoencoder is the ceiling for a latent image model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Neural codecs turn audio into tokens
&lt;/h2&gt;

&lt;p&gt;The change that made audio look like a language modelling problem was the neural audio codec: &lt;a href="https://multigrid.ai/learn/autoencoders" rel="noopener noreferrer"&gt;an autoencoder&lt;/a&gt; whose bottleneck is &lt;em&gt;discrete&lt;/em&gt;. The encoder produces a vector per frame, and residual vector quantisation snaps it to entries in a stack of learned codebooks — each codebook correcting the error left by the one before.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A codec with a 50 Hz frame rate and 8 codebooks of 1,024 entries:

    tokens per second = 50 frames × 8 codebooks = 400 tokens/s
    bits per second   = 400 × log2(1024) = 400 × 10 = 4,000 bits/s = 4 kbps

    30 seconds  =    12,000 tokens
    3 minutes   =    72,000 tokens

Versus 2,646,000 raw values for the same 30 seconds: a 220× reduction,
into a form a transformer can model autoregressively.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 12,000 tokens for thirty seconds, generation becomes next-token prediction over a vocabulary of 1,024 — structurally the same problem as text. The residual structure creates its own difficulty: the first codebook carries the coarse content and later ones carry fine detail, so the eight tokens for one frame are not interchangeable and the model has to be arranged to predict them in a sensible order. Different systems interleave them differently, and that ordering is one of the main design decisions in this family.&lt;/p&gt;

&lt;p&gt;The trade against the alternatives is the usual one for discrete representations: a hard ceiling set by the codec’s bitrate, against the ability to use every technique developed for autoregressive sequence models, including conditioning on text in exactly the way a language model does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Continuous latents and diffusion
&lt;/h2&gt;

&lt;p&gt;The third approach skips quantisation. Train a continuous autoencoder that compresses the waveform along the time axis, and run a diffusion or flow model in that latent space — the identical structure to image latent diffusion, with one spatial axis instead of two.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A continuous audio autoencoder at 1,000× temporal compression:

    44,100 samples/s  →  ~44 latent frames/s, each a vector of width 64

    30 seconds  =  1,320 latent positions × 64 channels
    3 minutes   =  7,920 latent positions × 64 channels

Attention over 7,920 positions is entirely ordinary for a transformer,
which is why this is the direction long-form generation went.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The advantages mirror the image case: the whole sequence is generated jointly rather than left to right, so global structure is available to the model at every step; there is no quantisation ceiling; and generation time is set by step count rather than by output length. The disadvantage also mirrors it: the autoencoder’s reconstruction quality is a floor nothing downstream can beat, and heavier temporal compression trades transient sharpness — drum hits, consonants, string attacks — for cheaper generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The long-range structure problem
&lt;/h2&gt;

&lt;p&gt;Even with a good representation, musical form is a distinct difficulty and it is worth separating from audio quality.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Repetition must be exact enough and varied enough.&lt;/strong&gt; A returning chorus is not a copy and not a new section. Nothing in a next-token or denoising objective represents “this is the same section as before, transposed”.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Tempo and key are global constraints on local decisions.&lt;/strong&gt; Like spelling in an image, they are properties that no local sample decision is penalised for violating, only their accumulation.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Long context is expensive in exactly the way video is.&lt;/strong&gt; Attention over a full track is quadratic in its length, so systems either use a heavily compressed representation, a windowed attention pattern, or a hierarchy that generates structure first and detail second.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Evaluation has no quick glance.&lt;/strong&gt; Judging a three-minute output requires listening to three minutes, which makes iteration slow and automated metrics unusually load-bearing — and the automated metrics for musical quality are not well established.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Different problems wearing the same name
&lt;/h2&gt;

&lt;p&gt;“AI audio generation” covers several tasks with very different difficulty profiles, and conflating them is the most common error in discussion of the field.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;speech synthesis&lt;/td&gt;
&lt;td&gt;Short, highly structured, with a clear correctness criterion and abundant paired data. The easiest of these problems and the one furthest along. Covered from the buyer's side in the text-to-speech guide.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sound effects and Foley&lt;/td&gt;
&lt;td&gt;Short, no long-range structure, and judged on plausibility rather than accuracy. Close in difficulty to short-clip video generation.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;instrumental music&lt;/td&gt;
&lt;td&gt;Long-range structure, harmonic constraints, and a listener who notices form. The hard case among purely audio outputs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;music with vocals&lt;/td&gt;
&lt;td&gt;Everything above, plus intelligible lyrics, plus a voice that stays the same person across three minutes. Combines the failure modes of speech and of music.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;stem separation&lt;/td&gt;
&lt;td&gt;Not generation at all — a discriminative task, splitting a mix into parts. Mentioned because it is often grouped with these and has a completely different structure and a well-defined ground truth.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pipeline reasoning transfers to speech as well; the latency and cost side of that is in &lt;a href="https://multigrid.ai/learn/text-to-speech-guide" rel="noopener noreferrer"&gt;the text-to-speech guide&lt;/a&gt;, and native audio input models are covered in &lt;a href="https://multigrid.ai/learn/audio-input-models" rel="noopener noreferrer"&gt;speech-to-text versus native audio&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This page does not state what any current system can do. Capability in this area is moving quickly and the honest position is that quality claims should be checked by listening to output you generated yourself, on your own prompts, rather than to a curated demo. The arithmetic above is what does not move: the sample counts, the compression ratios each representation achieves, and the quadratic cost of long context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/latent-diffusion" rel="noopener noreferrer"&gt;Latent Diffusion: Why It Runs on a Laptop&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/video-generation-internals" rel="noopener noreferrer"&gt;How Video Models Keep Frames Consistent&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/3d-generation-ai" rel="noopener noreferrer"&gt;3D Asset Generation&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>programming</category>
    </item>
    <item>
      <title>Multimodal RAG: Retrieving Over Images and Text</title>
      <dc:creator>Multigrid</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:19:20 +0000</pubDate>
      <link>https://dev.to/multigrid/multimodal-rag-retrieving-over-images-and-text-2mc7</link>
      <guid>https://dev.to/multigrid/multimodal-rag-retrieving-over-images-and-text-2mc7</guid>
      <description>&lt;p&gt;Retrieval over a text corpus is a solved shape: embed chunks, embed the query, take the nearest neighbours. The moment the corpus contains diagrams, screenshots, scanned pages or photographs, you have to decide what a picture is &lt;em&gt;indexed as&lt;/em&gt; — and the three available answers differ by orders of magnitude in storage and by entire categories in what they can find.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem in one sentence
&lt;/h2&gt;

&lt;p&gt;A vector index compares things in one space. A query is text. If the corpus is pixels, either the pixels have to enter the text space, the text has to enter a shared space with pixels, or the query has to be answered by something other than nearest-neighbour search.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three architectures
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1 · Caption then index
&lt;/h3&gt;

&lt;p&gt;Run every image through a vision model once at ingestion, store the generated description as text, and index it with your existing text pipeline. Nothing about your retrieval stack changes; you have simply added a preprocessing step.&lt;/p&gt;

&lt;p&gt;It is the cheapest to operate and the easiest to debug — you can read the index, which is not a small advantage. Its ceiling is set at ingestion: whatever the captioner did not mention is unfindable forever. A caption saying “a bar chart of quarterly revenue” cannot answer “which quarter dipped below €2m”. The fix is to caption for your queries rather than generically — extract the underlying values into the caption if that is what people will ask about.&lt;/p&gt;

&lt;h3&gt;
  
  
  2 · Joint embedding
&lt;/h3&gt;

&lt;p&gt;Use a model trained to put images and text in one shared space — the CLIP family, and its many successors. An image and its description land near each other, so a text query retrieves images directly with no caption in between.&lt;/p&gt;

&lt;p&gt;This is the right architecture for photograph-shaped corpora: product catalogues, stock libraries, media archives. It is a poor one for documents, because a contrastive image/caption objective was never asked to represent the sentence in paragraph four of a page.&lt;/p&gt;

&lt;h3&gt;
  
  
  3 · Retrieve over page images directly
&lt;/h3&gt;

&lt;p&gt;The newer approach, and for document corpora usually the strongest. ColPali (Faysse et al., 2024) skips text extraction entirely: it embeds each page image as a set of per-patch vectors using a vision-language model, embeds the query as per-token vectors, and scores with a late-interaction operator in the manner of ColBERT — every query token finds its best-matching patch and the scores are summed. Because the representation is per-patch, a match can be localised to a region of the page, and layout, figures and tables are represented natively because no flattening to text ever happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  The storage arithmetic
&lt;/h2&gt;

&lt;p&gt;This is the axis people miss until the bill arrives. The published ColPali configuration uses 128-dimensional vectors per image patch, with on the order of a thousand patches per page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;single dense vector per page
  1 x 1024 dims x 4 bytes (float32)        =   4.1 kB / page
  same at 2 bytes (float16)                =   2.0 kB / page

late interaction, per-patch
  1030 patches x 128 dims x 2 bytes        = 264 kB / page
                                             ~64x the dense index

100,000 pages
  dense, float16                           = 200 MB
  per-patch, float16                       =  26 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty-six gigabytes is not prohibitive, but it is a different class of infrastructure decision from two hundred megabytes, and the query cost differs too: late interaction scores many vectors per candidate rather than one, so it usually runs as a reranking stage over a cheap first-pass retrieval rather than as the only index. Binary quantisation and pooling reduce the multiplier substantially and are standard practice; budget for the engineering, not just the disk.&lt;/p&gt;

&lt;h2&gt;
  
  
  How each one fails
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Architecture&lt;/th&gt;
&lt;th&gt;Description&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;caption-then-index&lt;/td&gt;
&lt;td&gt;Silent omission. The answer was in the image and the caption did not mention it, so retrieval returns nothing and the model answers from elsewhere. Invisible in your logs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;joint embedding&lt;/td&gt;
&lt;td&gt;Text-in-image blindness. A screenshot whose content is entirely words retrieves on its visual gist, not on the words.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;page-image retrieval&lt;/td&gt;
&lt;td&gt;Cost and operational weight. Also weaker at pure keyword matching than a plain BM25 index, which is genuinely good at exact strings.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;all three&lt;/td&gt;
&lt;td&gt;Chunk-to-page mismatch: retrieving a whole page when the answer is one cell, then paying full image tokens to send it.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Choosing, and the hybrid
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Photographs, products, media.&lt;/strong&gt; Joint embedding. This is what it was built for.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Scanned or visually complex documents.&lt;/strong&gt; Page-image retrieval, with a cheaper first stage in front of it.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Mostly text with a few diagrams.&lt;/strong&gt; Caption-then-index. Do not rebuild a working pipeline for 4&amp;nbsp;% of the corpus; caption the diagrams into it.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Exact identifiers matter.&lt;/strong&gt; Keep a lexical index alongside whatever else you do. No embedding retrieves a part number as reliably as an inverted index does.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One implementation detail that applies to all of them: retrieval and generation do not have to consume the same representation. Retrieve over whatever works, then send the model the page image itself. The index exists to find the page; the model should see the real thing.&lt;/p&gt;

&lt;p&gt;Granularity is the decision that follows, and it is where multimodal RAG differs most sharply from the text case. In a text pipeline you chunk to a few hundred tokens and retrieve several chunks cheaply. A page image is indivisible in practice — you cannot send half a scanned page and expect the layout to make sense — and it costs on the order of a thousand tokens. So retrieving the top ten pages, which would be unremarkable in a text system, is a five-figure token prompt before the question is even asked. Retrieve fewer, rerank harder, and consider a two-stage generation: a cheap pass over extracted text from twenty candidate pages to decide which three matter, then the expensive pass over just those three as images. The cost curve rewards precision here in a way it does not for text.&lt;/p&gt;

&lt;p&gt;The generation half of a multimodal RAG system needs a model that accepts image input at a resolution high enough for your pages, which is a narrower set than the models that accept images at all — the &lt;a href="https://multigrid.ai/models" rel="noopener noreferrer"&gt;catalogue&lt;/a&gt; lists input modality and context length per model, the two constraints that bound how many retrieved pages you can put in one request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/document-understanding" rel="noopener noreferrer"&gt;Document Understanding: PDFs, Tables and Layout&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/how-vision-models-work" rel="noopener noreferrer"&gt;How a Multimodal Model Sees an Image&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;a href="https://multigrid.ai/learn/cheap-multimodal-pipeline" rel="noopener noreferrer"&gt;Building a Multimodal Pipeline That Doesn't Cost a Fortune&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>llm</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
