<?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: Swapnanil Saha</title>
    <description>The latest articles on DEV Community by Swapnanil Saha (@swapnanilsaha).</description>
    <link>https://dev.to/swapnanilsaha</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%2F3939906%2F9f37b94e-be6e-42b9-a63e-34b65dca3522.jpeg</url>
      <title>DEV Community: Swapnanil Saha</title>
      <link>https://dev.to/swapnanilsaha</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/swapnanilsaha"/>
    <language>en</language>
    <item>
      <title>Embedding Dilution: Why Semantic Code Search Misses the Answer</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Mon, 20 Jul 2026 18:12:46 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/embedding-dilution-why-semantic-code-search-misses-the-answer-5b9i</link>
      <guid>https://dev.to/swapnanilsaha/embedding-dilution-why-semantic-code-search-misses-the-answer-5b9i</guid>
      <description>&lt;p&gt;I had a query that should have been boring. "Get a single object from the database." In Django, that is &lt;code&gt;QuerySet.get&lt;/code&gt; — the method whose entire job is to fetch exactly one row matching your lookup, or raise. Its docstring reads, almost verbatim: &lt;em&gt;"Perform the query and return a single object matching the given keyword arguments."&lt;/em&gt; That is not a loose match to my query. It is nearly a paraphrase of it.&lt;/p&gt;

&lt;p&gt;The chunk was indexed. I checked. The embedding was computed and stored like every other chunk in the corpus. And when I ran the search, the method was not in the top result, not at rank fifty — it was not in the top two hundred candidates at all. It never made it far enough into the pipeline to be judged. Two hundred other chunks, none of which described getting a single object from the database, beat it into the pool.&lt;/p&gt;

&lt;p&gt;That failure bothered me enough to take apart the whole retrieval path and measure where the answer died. This post is the post-mortem. The system under test is &lt;a href="https://github.com/swapnanil/vectr" rel="noopener noreferrer"&gt;vectr&lt;/a&gt;, a semantic code-search and working-memory tool I build; the corpus is Django, used purely as a public witness. But the mechanism I found is not specific to code, and it is not specific to my tool. It is a property of how a single embedding vector has to summarize a long, mixed passage — and it quietly limits recall in a lot of retrieval systems that look like they are working. If you have never read the embeddings foundations, my earlier &lt;a href="https://swapnanilsaha.com/blog/text-embeddings-llms-rag-complete-guide/" rel="noopener noreferrer"&gt;complete guide to text embeddings and RAG&lt;/a&gt; is the primer; this is the failure that guide's happy path hides.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A note on terms.&lt;/strong&gt; An &lt;em&gt;embedding&lt;/em&gt; is a list of numbers (a vector) that captures a passage's meaning, so passages with similar meaning get similar numbers. A &lt;em&gt;chunk&lt;/em&gt; is one indexed piece of the codebase — roughly one method plus a little context. A &lt;em&gt;docstring&lt;/em&gt; is the documentation written inside a function. &lt;em&gt;Cosine similarity&lt;/em&gt; measures the angle between two vectors: 1.0 is identical direction, 0 is unrelated.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 1 · The Miss
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Pipeline and the Query That Broke It
&lt;/h3&gt;

&lt;p&gt;Before the failure makes sense, you need the shape of the pipeline it happened in, because the shape is where the whole story turns. At measurement time, vectr retrieved in the way most production semantic-search systems do — a two-stage funnel.&lt;/p&gt;

&lt;p&gt;Stage one is &lt;strong&gt;hybrid retrieval&lt;/strong&gt;. It runs two searches in parallel and merges them. One leg is &lt;strong&gt;dense retrieval&lt;/strong&gt;: encode the query into a vector, encode every chunk into a vector, and rank chunks by cosine similarity — so it can match on meaning even with no shared words. The other leg is &lt;strong&gt;BM25&lt;/strong&gt;, a keyword-scoring function that rewards exact term overlap. Together they produce a &lt;strong&gt;candidate pool&lt;/strong&gt; of the top 200 chunks.&lt;/p&gt;

&lt;p&gt;Stage two reranks that pool. A &lt;strong&gt;cross-encoder reranker&lt;/strong&gt; — &lt;code&gt;bge-reranker-base&lt;/code&gt; — reads the query alongside each of the 200 pool members and re-scores them properly, followed by a quality pass. That reranker is the smart part of the system. It is also the expensive part, which is exactly why it only ever sees 200 candidates instead of all 40,538.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The one number that decides everything.&lt;/strong&gt; The reranker, the importance priors, the quality scores — every clever thing downstream operates &lt;em&gt;only on the 200 chunks in the pool&lt;/em&gt;. A chunk that is not in the pool is invisible to all of it. So the first question for any retrieval miss is never "why did the reranker score it low." It is "was it even in the pool to be scored." Recall gates everything after it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now the pieces that matter for the failure. The dense embedder was &lt;strong&gt;snowflake-arctic-embed-m-v1.5&lt;/strong&gt; — a general text embedder, not a code-specialized one. Hold onto that; it is not the villain, but it shapes the numbers. The corpus was a Django checkout from June 2026: &lt;strong&gt;4,129 files, 40,538 indexed chunks&lt;/strong&gt;. And the chunk for &lt;code&gt;QuerySet.get&lt;/code&gt; was, by any reasonable standard, ideal. It carried a class marker (&lt;code&gt;QuerySet&lt;/code&gt;), the full method signature, and that near-perfect docstring — followed by roughly thirty lines of mechanical implementation.&lt;/p&gt;

&lt;p&gt;That last clause is the whole problem in embryo. But to see why, we first have to pin down &lt;em&gt;where&lt;/em&gt; the miss happened, because the fix depends entirely on that.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Miss Is at Pool Entry, Not Ranking
&lt;/h3&gt;

&lt;p&gt;My first instinct was the wrong one, and it is probably yours too: the reranker must have mis-scored it. Bump the reranker, add an importance prior for well-known symbols, tune the quality pass. Every one of those fixes operates on the pool. So I checked the pool directly, leg by leg, and the reranker turned out to be innocent — it never got the chance to be guilty.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dense leg.&lt;/strong&gt; For every natural-language phrasing I tried — "get a single object from the database," "fetch one row matching criteria," "retrieve a single record by lookup" — &lt;code&gt;QuerySet.get&lt;/code&gt; was &lt;strong&gt;absent from the top 200 dense results&lt;/strong&gt;. Not low-ranked. Absent. The only phrasing that got it into the dense pool at all was a deliberately ORM-flavored control, written in Django's own vocabulary, and even that reached only &lt;strong&gt;#123 of 200&lt;/strong&gt; — barely inside a pool it should have topped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The keyword leg.&lt;/strong&gt; BM25 was all over the place, which is its nature: it lives and dies on exact term overlap. Phrase the query as "return exactly one matching object or raise…" — words that literally appear near the method — and BM25 ranked it &lt;strong&gt;#1&lt;/strong&gt;. Phrase it as "fetch one row matching criteria" and the same method fell to &lt;strong&gt;#127&lt;/strong&gt;. Other phrasings missed entirely. BM25 wasn't a safety net; it was a coin whose bias depended on whether I happened to echo the source text.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Where the answer actually died.&lt;/strong&gt; The reranker never saw &lt;code&gt;QuerySet.get&lt;/code&gt; for the natural-language queries, because &lt;code&gt;QuerySet.get&lt;/code&gt; was never in the 200 it was handed. This is the structural point the rest of the post builds on: &lt;strong&gt;if the right chunk never enters the pool, nothing downstream can save it.&lt;/strong&gt; A brilliant reranker on an incomplete pool is a brilliant answer to the wrong question.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is also the moment most retrieval dashboards lie to you by omission. They display the reranked top-k — the final, polished output — which looks fine because the reranker did a competent job on the pool it received. The miss is one layer up, invisible on that screen. You have to instrument pool entry itself to see it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trace the chunk's fate through the funnel&lt;/strong&gt; (three real scenarios from this run):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phrasing&lt;/th&gt;
&lt;th&gt;Dense top-200&lt;/th&gt;
&lt;th&gt;BM25 top-200&lt;/th&gt;
&lt;th&gt;Fused pool&lt;/th&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A · "get a single object from the database"&lt;/td&gt;
&lt;td&gt;absent (&amp;gt;200)&lt;/td&gt;
&lt;td&gt;missed&lt;/td&gt;
&lt;td&gt;not in pool&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;miss&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;B · ORM-vocabulary control&lt;/td&gt;
&lt;td&gt;#123 of 200&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;enters (weak)&lt;/td&gt;
&lt;td&gt;reaches reranker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;C · "return exactly one matching object or raise…"&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;#1&lt;/td&gt;
&lt;td&gt;dropped&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;miss&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Scenario C is the case where BM25 ranked the target first, yet the dense-dominated fusion dropped it before the returned top-60 — more on that in Part 4. A dash means that leg's rank was not separately recorded for that phrasing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2 · The Cause
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Dilution, Measured
&lt;/h3&gt;

&lt;p&gt;So the dense leg failed to rank a chunk whose docstring paraphrases the query. Why? The lazy answer is "the embedder isn't good enough, throw a bigger model at it." That answer is wrong, and I can show it is wrong with one micro-experiment.&lt;/p&gt;

&lt;p&gt;I took the exact same embedder and embedded two things. First, the full chunk: class marker, signature, perfect docstring, plus the ~30 lines of body — the &lt;code&gt;combinator&lt;/code&gt; handling, the &lt;code&gt;_chain()&lt;/code&gt; call, the &lt;code&gt;select_for_update&lt;/code&gt; checks, the &lt;code&gt;NotSupportedError&lt;/code&gt; raises. Second, just the &lt;strong&gt;signature and docstring alone&lt;/strong&gt; — call it the "purpose-only" version, the part that says &lt;em&gt;what this is for&lt;/em&gt; with none of the machinery that says &lt;em&gt;how it does it&lt;/em&gt;. Then I measured cosine similarity from each version to four query phrasings.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Full chunk&lt;/th&gt;
&lt;th&gt;Purpose-only&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;get a single object from the database&lt;/td&gt;
&lt;td&gt;0.601&lt;/td&gt;
&lt;td&gt;0.706&lt;/td&gt;
&lt;td&gt;+0.105&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;fetch one row matching criteria&lt;/td&gt;
&lt;td&gt;0.511&lt;/td&gt;
&lt;td&gt;0.606&lt;/td&gt;
&lt;td&gt;+0.095&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;retrieve a single record by lookup&lt;/td&gt;
&lt;td&gt;0.529&lt;/td&gt;
&lt;td&gt;0.625&lt;/td&gt;
&lt;td&gt;+0.096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;return exactly one matching object or raise…&lt;/td&gt;
&lt;td&gt;0.617&lt;/td&gt;
&lt;td&gt;0.678&lt;/td&gt;
&lt;td&gt;+0.061&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Purpose-only is closer to the query on every single phrasing, by &lt;strong&gt;+0.06 to +0.10 cosine&lt;/strong&gt;. Same embedder, same docstring, same query. The only thing I removed was the implementation body — and the chunk got measurably &lt;em&gt;more&lt;/em&gt; relevant to the thing it does. The signal that answers the query was in the chunk the whole time. The body was burying it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why a longer chunk drifts away from its own purpose.&lt;/strong&gt; An encoder turns a passage into one fixed-size vector. Cosine similarity then compares directions:&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cos(q, d) = (q · d) / (‖q‖ · ‖d‖)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The catch is &lt;em&gt;d&lt;/em&gt;. Whether the model builds it by literally averaging its token vectors (mean pooling) or by a summary token that attends across all tokens, the result is one point that must stand in for the whole passage. For the literal mean-pooling case it is just an average:&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;d ≈ (1/N) · Σ eᵢ
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A CLS- or attention-pooled encoder weights the tokens unevenly instead of averaging them flat, but the consequence is the same. Add thirty lines of body and you pour in dozens of token vectors pointing toward "loop, chain, raise, check." They pull &lt;em&gt;d&lt;/em&gt; toward the body's center of mass and away from the docstring's direction. The docstring's contribution does not vanish — it gets outvoted. That is dilution: not a missing signal, a &lt;strong&gt;drowned&lt;/strong&gt; one.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Now the calibration that turns this from a curiosity into a recall failure. In this embedding space, for the "get a single object" query, the weakest chunk that made it into the 200-deep pool sat at a cosine of about &lt;strong&gt;0.697&lt;/strong&gt;. Purpose-only scored &lt;strong&gt;0.706&lt;/strong&gt; — over the line, into the pool, in front of the reranker. The full chunk scored &lt;strong&gt;0.601&lt;/strong&gt; — under the line, out of the pool, invisible. The entire difference between "the reranker gets a shot at the right answer" and "the right answer is never considered" is that &lt;strong&gt;+0.10 of cosine the body ate.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A caveat on that floor: 0.697 was calibrated on the first query only, and the pool floor is a per-query quantity — treat it as a reference for that query, not a universal threshold. The general lesson is the recovered delta, which is positive for all four.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The instinct this kills: "just enrich the input."&lt;/strong&gt; The reflex when recall is bad is to feed the embedder more context — add the class body, the surrounding file, richer metadata. Here that makes it worse. The purpose signal is &lt;em&gt;already present&lt;/em&gt;; enriching the chunk only adds more body tokens to average against it. You cannot fix a drowning by adding water. The problem is the pooling of a long, mixed chunk into one vector — so the fix has to change what gets pooled, not what gets added.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Phrasing Doesn't Rescue It — and the Symbol Index Proves Why
&lt;/h3&gt;

&lt;p&gt;There is an obvious objection here: maybe I just phrased the queries badly. Maybe the right words would have pulled the chunk in. So I ran a &lt;strong&gt;60-query sweep&lt;/strong&gt; — 10 topics, 6 phrasings each — to give phrasing every chance to matter.&lt;/p&gt;

&lt;p&gt;It didn't. Rephrasing shuffled which wrong answers came back; it did not surface the right ones. Ask for a "signal dispatcher implementation" and the top results were &lt;strong&gt;1-to-6-line re-export stubs&lt;/strong&gt; — the little shim modules that just re-expose a name — while the real &lt;code&gt;Signal&lt;/code&gt; class, the thing that actually implements dispatch, was absent from the top three. Across the sweep, conceptual queries kept returning wrong or incomplete top-5 sets no matter how I said them. Phrasing is a knob on the query side. Dilution is a problem on the document side. Turning the query knob cannot un-average a document vector.&lt;/p&gt;

&lt;p&gt;Then came the control that settled it. vectr also keeps a deterministic &lt;strong&gt;symbol graph&lt;/strong&gt;: a plain lookup from a name to its definition site, no embeddings involved. For &lt;em&gt;every&lt;/em&gt; canonical symbol that semantic search had just missed, the deterministic lookup resolved it exactly and instantly — &lt;code&gt;Signal&lt;/code&gt;, &lt;code&gt;BaseCache&lt;/code&gt;, &lt;code&gt;Query&lt;/code&gt;, &lt;code&gt;SQLCompiler&lt;/code&gt;, &lt;code&gt;QuerySet.get&lt;/code&gt;, each to the correct file and line.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;This is the control that localizes the bug.&lt;/strong&gt; Same corpus, same index build. The symbol table resolves the target perfectly; semantic search cannot find it. That gap is not the parser's fault, not the chunker's fault, not a missing document. &lt;strong&gt;The index and the symbol table were correct. The failure is purely in the embedding and search layer.&lt;/strong&gt; When two views of the same index disagree this cleanly, the broken one tells you exactly where to look.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;One more result, and it is the one that made me stop trusting cold semantic search on its own. I ran a round of "famous symbols" — targets every Django developer knows by heart. Cold semantic search was roughly a &lt;strong&gt;coin flip&lt;/strong&gt; even there. &lt;code&gt;get_object_or_404&lt;/code&gt;, &lt;code&gt;QuerySet.get&lt;/code&gt;, and &lt;code&gt;reverse&lt;/code&gt; were all absent from the top-5; &lt;code&gt;ForeignKey&lt;/code&gt; came in at #2 and &lt;code&gt;Paginator&lt;/code&gt; at #4, each sitting behind look-alikes with more generic wording. If a system cannot reliably surface &lt;code&gt;get_object_or_404&lt;/code&gt;, the failure is not exotic. It is the common case wearing a docstring.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3 · A Trap in the Scores
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Normalized Scores Lie About Confidence
&lt;/h3&gt;

&lt;p&gt;This one is a short aside, but it burned me while I was debugging the above, so it earns its place. While hunting the dilution bug I ran control queries for concepts that &lt;em&gt;do not exist&lt;/em&gt; in Django core — CORS handling, for instance, which Django leaves to middleware and third-party packages. A search for something absent should come back empty, or at least visibly unsure.&lt;/p&gt;

&lt;p&gt;It came back with five hits scored between &lt;strong&gt;0.77 and 1.0&lt;/strong&gt;, looking every bit as confident as a real match. Nothing in the corpus answered the query, and the system reported near-certainty anyway.&lt;/p&gt;

&lt;p&gt;The reason is a modeling choice that is easy to make and easy to forget. The displayed score was the reranker's output after &lt;strong&gt;per-query normalization&lt;/strong&gt; — rescaled so the best result of &lt;em&gt;this&lt;/em&gt; query becomes ≈1.0. That rescaling throws away the only thing you needed: how good the top match is in absolute terms.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The top score is always ≈1.0 by construction.&lt;/strong&gt; A per-query-normalized score can't tell you "nothing here matches," because it is defined to make the best available result look like a perfect one — even when the best available result is garbage. The number describes rank within the query, not relevance to the world. If you surface it as confidence, your UI will radiate certainty at the exact moment it has found nothing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The practical rule that fell out of this: never show a per-query-normalized score as if it were confidence. Keep a non-normalized signal alongside it — a raw cosine, or a BM25 floor — so the system retains an honest way to say "nothing here is actually close." Recall failures like the dilution one are already invisible enough; a score that reads 0.99 over an empty result set makes them worse, because it converts a silent miss into a confident wrong answer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4 · The Fix That Shipped
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Fix I Shipped: Dual-Vector Indexing
&lt;/h3&gt;

&lt;p&gt;The measurement points at its own fix. If purpose-only embeddings score +0.06 to +0.10 higher — enough, in the calibrated case, to clear the pool floor — then the answer is not to throw away the body vector. It is to &lt;em&gt;also&lt;/em&gt; keep a purpose vector, and let a query match whichever one fits it.&lt;/p&gt;

&lt;p&gt;That is &lt;strong&gt;dual-vector indexing&lt;/strong&gt;. At index time, store two vectors per symbol: a &lt;strong&gt;purpose vector&lt;/strong&gt; built from the qualified signature and docstring with the body stripped out, alongside the existing &lt;strong&gt;full-body vector&lt;/strong&gt;. At query time, retrieve over both, and blend or take the max of the two similarities for pool entry. Nothing else in the pipeline changes.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy · The book and its spine.&lt;/strong&gt; A full-body embedding is like shelving a book by blending every word in it into one average color. Two books with very different covers but similar bulk end up the same muddy shade, and you can't find either by its subject. The purpose vector is the printed spine: title and one-line description, nothing else. You keep the whole book on the shelf — you just also write a legible spine, so someone looking for the &lt;em&gt;subject&lt;/em&gt; can find it without reading all 300 pages first. Dual-vector indexing shelves every symbol with both: the full text, and a spine.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;What I like about this shape is that it does not privilege one kind of query. Intent-shaped queries — "get a single object from the database" — land on the purpose vector, where the docstring is undiluted. Implementation-detail queries — "where is &lt;code&gt;select_for_update&lt;/code&gt; checked" — still land on the body vector, because that string only exists in the body. You are not trading one failure mode for its mirror image; you are giving each query the surface it needs.&lt;/p&gt;

&lt;p&gt;Two properties make it safe to apply blindly across a whole corpus:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Undocumented symbols degrade gracefully.&lt;/strong&gt; No docstring? The purpose vector is just the qualified signature. It never gets worse than the name itself, and the name is often enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is a uniform structural transform.&lt;/strong&gt; The same body-stripping rule applies to every symbol, at index time, with &lt;em&gt;no query-side special-casing&lt;/em&gt; — no keyword lists, no "if the query looks conceptual, reroute it." Query-side heuristics are the thing I have spent a long time deleting from this system; they are brittle, they compound, and they never generalize. A transform on the index side has none of those failure modes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is not free, though, and I would rather name the costs than let you find them. Storing a second vector per symbol roughly doubles the number of vectors in the index — reason enough to confirm dilution is actually your problem before you spend on it. And it does not stand alone: dual-vector &lt;em&gt;composes&lt;/em&gt; with structural ranking signals like symbol importance rather than replacing them. A diluted docstring and an under-weighted call graph are different failure classes; fixing one leaves the other exactly where it was.&lt;/p&gt;

&lt;p&gt;The honest sequence, stated plainly: I measured the cause first, then shipped the fix. The spike proved the mechanism — purpose-only embeddings clear the pool floor where full-chunk embeddings do not — and that measurement, not a hunch, is why dual-vector indexing &lt;strong&gt;shipped in vectr v1.0.0&lt;/strong&gt; on 8 July 2026. If someone tells you a retrieval change "should work," ask them for the cosine table. This one had one before a line of the fix was written.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fusion bug hiding underneath.&lt;/strong&gt; While validating the direction I tripped over a second, separate problem worth its own paragraph, because it will bite anyone running hybrid retrieval. Remember that BM25 ranked the target &lt;strong&gt;#1&lt;/strong&gt; for one phrasing. You would assume a #1 in either leg guarantees pool entry. It did not: the fused final top-60 &lt;em&gt;did not contain the target&lt;/em&gt; even though BM25 had ranked it first. The fusion was dense-dominated, and the dense leg's absence outvoted the keyword leg's #1.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Check that your fusion can't discard a leg's #1.&lt;/strong&gt; Hybrid retrieval is supposed to be a safety net: if one leg misses, the other catches. That promise only holds if your fusion actually lets a strong single-leg result survive. A dense-dominated blend can throw away the exact result BM25 nailed. Before you trust hybrid search, feed it a query where you &lt;em&gt;know&lt;/em&gt; one leg ranks the answer #1, and confirm the answer is still in the fused output. Mine wasn't.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The Same Dilution Shows Up Far Beyond Code
&lt;/h3&gt;

&lt;p&gt;I found this in code search, but nothing about the mechanism is about code. Dilution appears in any corpus where a document mixes "what this is for" with "how it works" or with plain boilerplate. The purpose is a small fraction of the tokens; the pooled vector drifts toward the bulk; a query written in terms of purpose lands short.&lt;/p&gt;

&lt;p&gt;You have almost certainly hit it without naming it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API reference pages&lt;/strong&gt; where a one-line summary sits on top of exhaustive parameter tables and examples. Search for what the endpoint &lt;em&gt;does&lt;/em&gt; and the parameter soup dominates the vector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legal clauses&lt;/strong&gt; buried inside pages of recitals and boilerplate. The operative sentence is three lines; the surrounding scaffolding is three hundred.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Product descriptions&lt;/strong&gt; embedded in spec sheets, where the one line a buyer would search for is outweighed by dimensions, SKUs, and compliance notices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two mitigations generalize as cleanly as the problem does. First, &lt;strong&gt;embed a purpose or summary field separately from the full text, and retrieve over both&lt;/strong&gt; — the dual-vector idea, minus the word "symbol." A short, curated summary vector per document is often the single highest-leverage change you can make to recall, precisely because it is immune to dilution by construction. Second, and this is the cheaper habit to build: &lt;strong&gt;audit recall at the pool level, not just the final ranking.&lt;/strong&gt; Most RAG dashboards show you the reranked top-k and nothing else, which means a pool-entry miss is completely invisible on the screen you are staring at. The failure that started this whole post would never have shown up on a top-k view. I only found it because I went looking one layer up.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Choosing the embedder is a real lever — measure it, don't assume it.&lt;/strong&gt; These deltas came from one general text embedder on one corpus. A code-specialized model would move the numbers; the CoIR benchmark evaluates nine retrieval models across ten code datasets and eight tasks and finds even state-of-the-art systems struggle with code retrieval, which is exactly why the embedder is not a detail. But a better embedder does not repeal dilution — it raises the whole curve, floor included, and a long mixed chunk still averages its purpose away. Dual-vector composes with a better model; it does not compete with one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 5 · Takeaways
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What To Actually Do With This
&lt;/h3&gt;

&lt;p&gt;Go back to the opening. A function whose docstring paraphrased the query ranked below two hundred chunks that didn't. You now know the chain underneath that sentence. The docstring's signal was real and present. The thirty lines of body around it pulled the pooled vector away — enough to cost about a tenth of a cosine point. That tenth was the difference between clearing the pool floor and never entering the pool at all — between reaching the reranker and never being considered. The reranker never failed, because the reranker never saw it. And a normalized score would have happily reported confidence over whatever wrong answers did make the pool.&lt;/p&gt;

&lt;p&gt;If you build retrieval, here is the short version to carry out of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure recall at pool entry, not at the reranked top-k.&lt;/strong&gt; The reranker can only be as good as its pool. The miss that matters most is the one that never reaches the screen you monitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When recall is bad, test purpose-only against full-chunk.&lt;/strong&gt; Embed a summary or signature alone, measure its cosine to the query, and compare. A large positive delta names your problem: dilution, not a weak model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index a separate purpose vector, and retrieve over both.&lt;/strong&gt; Keep the full text for detail queries; add a body-stripped summary vector for intent queries. It is a structural transform on the index, so it needs no query-side heuristics to work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm your fusion can't drop a leg's #1&lt;/strong&gt;, and never surface a per-query-normalized score as confidence. Keep a raw, absolute signal for an honest no-match.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The dual-vector fix shipped in vectr v1.0.0 on the strength of the cosine table above rather than a hunch. The boring query that started this — get a single object from the database — is exactly the case it was built to catch: the method whose docstring says precisely that, given a surface where its own body can no longer outvote it. The signal was never missing. It just needed somewhere to be read on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links &amp;amp; Further Reading
&lt;/h2&gt;

&lt;p&gt;Every external claim in this post was confirmed against the source it points to; where a source did not confirm a specific number, that number is not stated here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/swapnanil/vectr" rel="noopener noreferrer"&gt;vectr&lt;/a&gt; — the semantic code-search and working-memory tool used as the system under test, and the instrument that produced these measurements.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/text-embeddings-llms-rag-complete-guide/" rel="noopener noreferrer"&gt;The Complete Guide to Text Embeddings, Vector Databases &amp;amp; LLMs&lt;/a&gt; — the primer this post assumes: tokenization, pooling, cosine similarity, and how a RAG pipeline fits together.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2407.02883" rel="noopener noreferrer"&gt;CoIR: A Comprehensive Benchmark for Code Information Retrieval Models&lt;/a&gt; — a benchmark of ten code datasets across eight retrieval tasks and seven domains; it evaluates nine retrieval models and finds significant difficulty with code retrieval even for state-of-the-art systems. arXiv:2407.02883&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2507.02107" rel="noopener noreferrer"&gt;Structural Code Search using Natural Language Queries&lt;/a&gt; — reports that a natural-language-driven structural search outperforms baselines based on semantic code search by up to 57% F1; embeddings alone under-serve structural queries. arXiv:2507.02107&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Numbers here are from one embedder (arctic-embed-m-v1.5) on one corpus (Django, June 2026 checkout). The deltas are model-specific; the mechanism is general. Cosine thresholds like the ~0.697 pool floor are corpus- and index-specific calibration points, not universal constants.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>embeddings</category>
      <category>semanticsearch</category>
      <category>rag</category>
      <category>coderetrieval</category>
    </item>
    <item>
      <title>The Four Families of Context Relief for LLM Coding Agents</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Fri, 17 Jul 2026 13:50:58 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/the-four-families-of-context-relief-for-llm-coding-agents-5e6o</link>
      <guid>https://dev.to/swapnanilsaha/the-four-families-of-context-relief-for-llm-coding-agents-5e6o</guid>
      <description>&lt;p&gt;Run a coding agent on anything bigger than a toy repo and you hit the same wall. The context window fills up. Not with the answer — with the &lt;em&gt;search for&lt;/em&gt; the answer. Twelve file reads, four grep results, a stack trace, the output of a test run that failed for an unrelated reason. By the time the agent is ready to write the fix, half its working memory is archaeology it will never look at again.&lt;/p&gt;

&lt;p&gt;I've spent the last few months building a semantic-search-plus-working-memory MCP server (I'll call it &lt;strong&gt;vectr&lt;/strong&gt; throughout — it's the running example, not the point of the post), and the single most clarifying thing I did early on was stop treating "the context is full" as one problem. It's four problems wearing a trench coat. Each has its own mechanism, its own cost, its own failure mode, and — this is the part people miss — they only work when you compose them correctly. Get the composition wrong and you don't get relief; you get a subtle new class of bug where the agent confidently reasons over information it no longer has.&lt;/p&gt;

&lt;p&gt;So here's the map I wish someone had handed me. Four families of context relief: what each one actually buys you, where each one bites, and how they fit together.&lt;/p&gt;

&lt;p&gt;A few definitions first, because the jargon is dense. A &lt;strong&gt;token&lt;/strong&gt; is the unit an LLM reads and bills by — roughly three-quarters of a word. The &lt;strong&gt;context window&lt;/strong&gt; is the fixed number of tokens the model can attend to at once (a million, on the current Claude models). &lt;strong&gt;Prompt caching&lt;/strong&gt; lets you pay a reduced rate to re-send an identical prefix instead of reprocessing it. And &lt;strong&gt;MCP&lt;/strong&gt; (Model Context Protocol) is the standard interface an agent uses to call external tools. Keep those four in your head and the rest follows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Family&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Fails when…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 · Eviction&lt;/td&gt;
&lt;td&gt;Delete stale context, leave a placeholder&lt;/td&gt;
&lt;td&gt;Removes&lt;/td&gt;
&lt;td&gt;You threw away what you can't cheaply restore&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 · Offload &amp;amp; recall&lt;/td&gt;
&lt;td&gt;Write findings to a durable store, fetch on demand&lt;/td&gt;
&lt;td&gt;Restores&lt;/td&gt;
&lt;td&gt;Recall isn't automatic — the model forgets to look&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 · Retrieval&lt;/td&gt;
&lt;td&gt;Fetch the exact function, not the whole file&lt;/td&gt;
&lt;td&gt;Restores&lt;/td&gt;
&lt;td&gt;Used on structural questions (call graphs)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 · Subagents&lt;/td&gt;
&lt;td&gt;Burn messy work in a separate window&lt;/td&gt;
&lt;td&gt;Removes&lt;/td&gt;
&lt;td&gt;No shared memory — children re-derive everything&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Family 1 — Eviction: throw it away, but keep a receipt
&lt;/h2&gt;

&lt;p&gt;Eviction is the most literal answer to a full context: delete the stale stuff. The tool result from twenty turns ago, the file you read and already edited, the thinking block from a reasoning step that's now resolved — drop it out of the live window so the model stops paying to carry it.&lt;/p&gt;

&lt;p&gt;The harness can do this for you, and increasingly it does. Claude Code calls this &lt;strong&gt;compaction&lt;/strong&gt;: as a session approaches its context limit, it clears the oldest tool outputs first and only summarizes the rest of the conversation if that isn't enough on its own. Recent tool results stay inline so you can keep reasoning over them; older ones get cleared first.&lt;/p&gt;

&lt;p&gt;At the API level there's a more configurable version. Anthropic's &lt;strong&gt;context editing&lt;/strong&gt; feature exposes a strategy with the delightfully machine-generated name &lt;code&gt;clear_tool_uses_20250919&lt;/code&gt;. You turn it on with a beta header (&lt;code&gt;anthropic-beta: context-management-2025-06-27&lt;/code&gt;) and it watches your accumulating tool results. Once input tokens cross a threshold — the default &lt;code&gt;trigger&lt;/code&gt; is 100,000 input tokens — it clears the oldest tool results in chronological order, keeping the most recent few (&lt;code&gt;keep&lt;/code&gt; defaults to 3 tool uses).&lt;/p&gt;

&lt;p&gt;Here's the detail that matters more than any of the parameters: &lt;strong&gt;each cleared result is replaced with placeholder text so the model knows it was removed.&lt;/strong&gt; The agent doesn't silently lose a tool result and then hallucinate what was in it. It sees a tombstone — "this tool result was cleared" — which is a very different thing from a gap.&lt;/p&gt;

&lt;p&gt;The config below overrides the defaults to make the behaviour easy to see — I've set &lt;code&gt;trigger&lt;/code&gt; to 30,000 tokens rather than the stock 100,000 so it fires early, and pinned &lt;code&gt;clear_at_least&lt;/code&gt; so each pass removes a real chunk. In production you'd leave &lt;code&gt;trigger&lt;/code&gt; higher.&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="n"&gt;context_management&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;edits&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="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;clear_tool_uses_20250919&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;trigger&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;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;input_tokens&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keep&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;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;tool_uses&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clear_at_least&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;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;input_tokens&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;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exclude_tools&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;web_search&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="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What it saves
&lt;/h3&gt;

&lt;p&gt;Straightforwardly, input tokens — though the headline figure Anthropic has published for this feature is a task-performance number, not a token count: on an internal agentic-search evaluation, context editing alone lifted performance 29% over baseline, rising to 39% when paired with a memory tool. Hold onto that second number. It's the whole thesis of this post hiding in a benchmark.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it costs
&lt;/h3&gt;

&lt;p&gt;Two things, and the second is non-obvious. The first is the risk that you evict something the model actually needed — mitigated by the placeholder tombstone and by &lt;code&gt;exclude_tools&lt;/code&gt;, which lets you mark, say, your search tool's results as never-clearable. The second cost is about prompt caching, and it's where a lot of naive eviction setups quietly lose money.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The cache-invalidation math.&lt;/strong&gt; Prompt caching bills a cached prefix at &lt;strong&gt;0.1×&lt;/strong&gt; the base input rate on a read, but a cache &lt;em&gt;write&lt;/em&gt; costs &lt;strong&gt;1.25×&lt;/strong&gt; the base rate for the default 5-minute TTL (2× for the 1-hour TTL). The catch is invalidation: the cache key is a cumulative hash of everything up to and including your cache breakpoint, so changing any block at or before the breakpoint produces a different hash and a full cache miss.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read those two facts together and the tension jumps out. Eviction &lt;em&gt;edits the middle of your conversation&lt;/em&gt;. Every time it fires, it changes the prefix, which invalidates the cache from that point forward, which means your next request pays the 1.25× write cost to re-cache the new prefix. Evict a little bit, often, and you can spend more on cache churn than you saved on the evicted tokens. This is exactly why the &lt;code&gt;clear_at_least&lt;/code&gt; parameter exists: it forces each clearing pass to remove a worthwhile chunk of tokens so the cache invalidation is amortized against a real saving, not a rounding error. If you take one operational lesson from this whole family, make it that one — evict in big, infrequent passes, never in a trickle.&lt;/p&gt;

&lt;h3&gt;
  
  
  When it fails
&lt;/h3&gt;

&lt;p&gt;Eviction fails the moment the model needs something you threw away and &lt;em&gt;can't cheaply get it back&lt;/em&gt;. A tombstone that says "tool result cleared" is honest, but honesty doesn't reconstruct the file. If the only copy of that information lived in the evicted tool result, the agent is now stuck: it has to re-run the tool, re-read the file, re-derive the thing. You've converted a token cost into a latency-and-tool-call cost — and if the underlying state has changed in the meantime, possibly into a correctness bug.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The governing rule of eviction.&lt;/strong&gt; You can only safely evict what you can cheaply restore. Eviction on its own is not a memory strategy — it's a bet that restoration is cheap. That bet is only good if something else in your system guarantees it. Which is the entire reason the other three families exist.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Family 2 — Offload &amp;amp; recall: write it down where it survives
&lt;/h2&gt;

&lt;p&gt;If eviction is "throw it away and hope you don't need it," offload-and-recall is "write it down somewhere durable &lt;em&gt;before&lt;/em&gt; you throw it away, and fetch it back on demand." The agent, mid-session, notices it has learned something worth keeping — a function signature, a gotcha, a decision, a partial result — and commits it to an external store. Later, instead of carrying that finding in the context window the whole time, it recalls it in a single cheap call exactly when it's relevant.&lt;/p&gt;

&lt;p&gt;This is the working-memory pattern, and it's the half of vectr I care about most. When the agent discovers that, say, a workspace lock is acquired at &lt;code&gt;resolver.rs:214&lt;/code&gt; and released on scope exit, it doesn't keep that fact parked in context for forty turns. It stores a note. The note sits in a local store keyed to the workspace, and a &lt;code&gt;recall&lt;/code&gt; call pulls it back — in my case in under 50 milliseconds — whenever the agent's current task touches locking.&lt;/p&gt;

&lt;p&gt;The reason this is a distinct family, and not just "eviction with extra steps," is &lt;em&gt;what it survives&lt;/em&gt;. A finding in the live context window dies three deaths. It costs tokens the entire time it sits there. It gets mangled or dropped when the conversation is compacted into a summary — &lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-2-working-memory-compact-survival/" rel="noopener noreferrer"&gt;compaction preserves the gist and loses the exact line number&lt;/a&gt;. And it vanishes completely when the session ends. A note in an external store survives all three. It's there after &lt;code&gt;/compact&lt;/code&gt;. It's there in tomorrow's session. It costs nothing until you ask for it.&lt;/p&gt;

&lt;p&gt;Anthropic's own memory tool works on this principle, and it's the reason for that 39%-versus-29% gap I told you to hold onto. Context editing &lt;em&gt;alone&lt;/em&gt; improves performance 29% over baseline on Anthropic's internal agentic-search evaluation. Context editing &lt;em&gt;plus a memory tool&lt;/em&gt; gets you 39% — because the agent writes the important bits to memory before the eviction pass clears them, so clearing becomes safe instead of lossy. That extra ten points isn't a second independent optimization stacked on the first. It's the same optimization made &lt;em&gt;safe to run harder&lt;/em&gt;, because family 2 backs it up.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it saves, what it costs
&lt;/h3&gt;

&lt;p&gt;It saves the standing token cost of carrying a finding you only need occasionally. And, less measurably but more importantly, it saves &lt;em&gt;re-derivation&lt;/em&gt; — the agent doesn't have to re-read the file and re-reason to the same conclusion next time.&lt;/p&gt;

&lt;p&gt;Against that, three real costs. The agent has to decide what's worth remembering, which is a judgment call it will sometimes get wrong — store noise and your recall gets diluted. The recall has to actually be relevant when it fires, which is a retrieval-quality problem in miniature. And there's a token cost to the recall itself, a fact I had to make peace with: store terse one-line notes and recall is cheap but thin; store full code blocks and recall is rich but heavier. There's no free lunch. There's a dial.&lt;/p&gt;

&lt;h3&gt;
  
  
  When it fails
&lt;/h3&gt;

&lt;p&gt;It fails when recall isn't &lt;em&gt;automatic&lt;/em&gt;. If your architecture depends on the model choosing, of its own accord, to call the recall tool at the right moment, it will frequently just… not. The model has no reliable sense of what it stored three sessions ago. The fix is to stop relying on the model's initiative and inject the relevant notes into context deterministically — which, in Claude Code, means &lt;a href="https://swapnanilsaha.com/blog/claude-code-hooks-deterministic-agent-memory/" rel="noopener noreferrer"&gt;hooks, and which is a whole post of its own&lt;/a&gt;. The short version: an offload store the agent forgets to read is a filing cabinet in a locked room.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The note-taking engineer.&lt;/strong&gt; Working memory is the difference between an engineer who takes notes and one who doesn't. The note-taker doesn't hold the whole system in their head at once — they hold a pointer to where they wrote it down, and the act of writing it down is cheap insurance against the cost of re-discovering it. The catch is the same for both: a note you never look at again is just slower forgetting.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Family 3 — Retrieval over stuffing: fetch the 40 lines, not the file
&lt;/h2&gt;

&lt;p&gt;The third family attacks a different waste. The first two are about getting rid of information you already loaded. This one is about &lt;em&gt;never over-loading it in the first place&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The default way an agent explores an unfamiliar codebase is grep-and-read. Grep for a likely keyword, get forty hits, read the six files that look plausible, discard five of them. Every one of those reads lands the &lt;em&gt;entire file&lt;/em&gt; in context — a 400-line module of which the agent needed one function. The signal-to-noise ratio is brutal, and unlike a human skimming, the model pays full token price for every line whether it was useful or not.&lt;/p&gt;

&lt;p&gt;Retrieval-over-stuffing replaces the blunt read with a targeted fetch. Instead of loading whole files and letting the model sift, you run a ranked retrieval — semantic search over the codebase, ideally chunked at function and class boundaries so each result is a self-contained unit of meaning — and hand back the forty lines that actually match the query. "JWT validation logic" returns the &lt;code&gt;verify_token&lt;/code&gt; function directly, even though neither word appears in it, and it returns &lt;em&gt;that function&lt;/em&gt;, not the 400-line file it lives in.&lt;/p&gt;

&lt;p&gt;This is &lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-1-semantic-code-search/" rel="noopener noreferrer"&gt;the search half of vectr&lt;/a&gt;, and the payoff on unfamiliar code is large: on a big Java codebase in &lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-3-benchmark-methodology-results/" rel="noopener noreferrer"&gt;my own benchmarks&lt;/a&gt;, ranked retrieval cut the read-and-grep calls before the first edit by roughly three-quarters compared to the grep-and-read baseline. The mechanism is boring — embeddings plus a keyword index, merged — but the discipline is the point: &lt;strong&gt;the unit you put in context should be the unit of meaning, not the unit of storage.&lt;/strong&gt; A file is a storage unit. A function is a meaning unit.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it saves, what it costs
&lt;/h3&gt;

&lt;p&gt;It saves the bulk of exploratory token spend, and the turns that go with it. A search that returns the right function in one call replaces a grep-plus-four-reads sequence. In exchange, you need an index — which means an indexing step and the machinery to keep it fresh as files change — and retrieval quality becomes a first-class concern.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Confident-wrong retrieval is worse than a miss.&lt;/strong&gt; A search that returns the wrong forty lines is more dangerous than a grep that returns nothing, because the agent &lt;em&gt;trusts&lt;/em&gt; it more. I've watched an agent build a wrong mental model off a top-ranked result that was subtly off-topic, then reason confidently from that bad premise for a dozen turns. An honest empty result would have sent it looking again; a plausible wrong one didn't.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  When it fails
&lt;/h3&gt;

&lt;p&gt;It fails on questions retrieval is the wrong tool for. "Who calls this function?" is not a similarity question — the callers don't contain the callee's body, they contain a reference to it &lt;em&gt;by name&lt;/em&gt;. That's a graph traversal, not a search. Reach for semantic retrieval there and you'll get plausible-looking garbage. Part of doing this family well is knowing which questions are retrieval questions (concepts, patterns, "how does X work") and which are structural ones (definitions, call graphs) that want an exact lookup instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  Family 4 — Subagent isolation: burn the tokens in someone else's window
&lt;/h2&gt;

&lt;p&gt;The fourth family is the cleverest and the easiest to get subtly wrong. The idea: when a subtask is going to generate a pile of context you'll never reference again — a research spike, a log-diving expedition, a broad search — you don't do it in your main conversation. You spawn a &lt;strong&gt;subagent&lt;/strong&gt;, let it do the messy work &lt;em&gt;in its own context window&lt;/em&gt;, and take back only the distilled answer.&lt;/p&gt;

&lt;p&gt;Claude Code's subagents work exactly this way. Each one runs in its own context window with a custom system prompt, does its work independently, and returns only the result — the docs frame it as keeping exploration and implementation out of your main conversation. The parent agent spends, say, 800 tokens receiving a clean summary of an investigation that cost the subagent 40,000 tokens of reading and reasoning. Those 40,000 tokens are burned in a window that gets discarded. The parent's context stays clean.&lt;/p&gt;

&lt;p&gt;There's a nice secondary benefit. Because a subagent has its own tool permissions and its own system prompt, you can also use it to &lt;em&gt;constrain&lt;/em&gt; work — a read-only research agent that literally cannot write files — and to route cheap work to a cheaper, faster model. Context isolation and cost control fall out of the same mechanism.&lt;/p&gt;

&lt;h3&gt;
  
  
  What it saves, what it costs
&lt;/h3&gt;

&lt;p&gt;It saves the largest single chunk of exploratory context there is. A well-scoped subagent is the difference between your main window holding a conclusion and holding the entire messy derivation of that conclusion. The cost is a framing-and-parsing tax at the boundary: you have to specify the subtask well enough that the subagent can run without hand-holding, and you have to trust the summary it returns without seeing its work. If the summary is lossy in exactly the way that matters, the parent proceeds on a bad abstraction — and it can't tell, because the detail that would have flagged the problem got left behind in the discarded window.&lt;/p&gt;

&lt;h3&gt;
  
  
  When it fails
&lt;/h3&gt;

&lt;p&gt;Here's the failure mode nobody warns you about. Subagent isolation with &lt;em&gt;no shared memory&lt;/em&gt; means every subagent starts cold. It re-derives context the parent already had and the last subagent already found. Spawn three subagents to investigate three corners of the same system and, without a shared store between them, each one re-reads the same core files, re-learns the same architecture, and re-discovers the same gotcha — three times, in three separate windows, at full price each. You've isolated the context so well that you've also isolated the &lt;em&gt;learning&lt;/em&gt;. The isolation that saves the parent's window quietly taxes every child.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Isolation without a shared bus is a false economy.&lt;/strong&gt; Subagent isolation without a shared memory store is a false economy at scale. You save the parent's context by making the children re-derive everything from scratch. The fix is to give the subagents the same durable store from family 2 — so the first subagent's findings are recalled by the next instead of rediscovered. Isolation controls what flows &lt;em&gt;up&lt;/em&gt;; shared memory controls what flows &lt;em&gt;sideways&lt;/em&gt;. You want both.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The families compose — that's the whole point
&lt;/h2&gt;

&lt;p&gt;I've been dropping the composition hints deliberately, so let me make them explicit, because treating these four as a menu you pick one item from is the mistake I most want to talk you out of.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eviction (1) is only safe on top of offload (2) or retrieval (3).&lt;/strong&gt; This is the load-bearing relationship. You can only throw information away cheaply if you can get it back cheaply — and "getting it back cheaply" is precisely what families 2 and 3 provide. Evict a tool result whose contents you already wrote to a memory note: safe, because recall restores it. Evict a file you can re-fetch with one targeted search: safe, because retrieval restores it. Evict something that exists nowhere else and you've just planted a bug that will surface three turns later as confident nonsense. The 29% → 39% jump from adding a memory tool to context editing &lt;em&gt;is&lt;/em&gt; this relationship, quantified — the memory tool is what makes the eviction safe to run harder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrieval (3) keeps the working set small enough that eviction (1) rarely has to fire.&lt;/strong&gt; If you never stuffed the whole file in, there's less to evict later. The two attack the same waste from opposite ends — one at load time, one at cleanup time — and a system with good retrieval needs less aggressive eviction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subagents (4) need a shared store (2) or they re-derive context.&lt;/strong&gt; Covered above, but it's the composition people skip most often, because subagents &lt;em&gt;feel&lt;/em&gt; self-contained. They're self-contained in their context, not in their knowledge. Wire them to the same working-memory store and the isolation stops being a re-derivation tax.&lt;/p&gt;

&lt;p&gt;The unifying idea is almost embarrassingly simple once you see it: &lt;strong&gt;cheap restoration is the license to be aggressive about relief.&lt;/strong&gt; Every family is either a way to remove context (1, 4) or a way to make removal safe by guaranteeing you can get the important parts back (2, 3). Build only the removal half and you get an agent that forgets things it needed. Build only the restoration half and you get an agent that never frees anything and grinds to a halt at the context limit. You need the pair.&lt;/p&gt;

&lt;p&gt;This is also why I stopped thinking of vectr as "a search tool" or "a memory tool." It's families 2 and 3 in one MCP server, deliberately, because on their own each is half a solution. Search without memory re-explores every session. Memory without search has nothing good to store. And both of them exist, in the end, to make the harness's eviction — family 1, which I don't even own — safe to run.&lt;/p&gt;




&lt;h2&gt;
  
  
  A short field guide
&lt;/h2&gt;

&lt;p&gt;If you operate a coding agent and want to actually apply this, here's the compressed version I'd give a colleague over coffee.&lt;/p&gt;

&lt;p&gt;Start with &lt;strong&gt;retrieval (3)&lt;/strong&gt;, because it's the one that prevents the mess instead of cleaning it up, and it pays off immediately on any codebase you don't have memorized. Add &lt;strong&gt;offload-and-recall (2)&lt;/strong&gt; next, and make the recall &lt;em&gt;automatic&lt;/em&gt; rather than something the model has to remember to do — a store the agent forgets to read is worthless. Let the harness handle &lt;strong&gt;eviction (1)&lt;/strong&gt;, but check that it's evicting in big infrequent passes (mind the cache-invalidation math) and that everything it evicts is backed by 2 or 3. Reach for &lt;strong&gt;subagent isolation (4)&lt;/strong&gt; on genuinely large exploratory subtasks, and if you use more than one subagent on related work, give them a shared memory bus or accept that each is paying full freight to learn what the last one already knew.&lt;/p&gt;

&lt;p&gt;None of these is exotic. The compaction and context-editing pieces ship in the tools already. The retrieval and memory pieces are a weekend to prototype — I wrote up &lt;a href="https://swapnanilsaha.com/blog/vectr-v1-release-gate-honest-numbers/" rel="noopener noreferrer"&gt;the honest numbers on how far mine actually got&lt;/a&gt; if you want the unvarnished version. What's rare is treating them as one system with a single governing rule — restore-ability licenses removal — instead of four disconnected tricks. Get the rule right and the context window stops being the thing you fight and starts being the thing you manage.&lt;/p&gt;

&lt;p&gt;Four problems in a trench coat, then — not one. And once you've split them apart, the thing that surprised me is how little the individual tricks matter next to the relationship between them. Any single family, run on its own, either forgets something it needed or refuses to let go of anything. What actually works is the pair: a way to remove context sitting on top of a guarantee that you can get the important parts back. Cheap restoration is what buys you the right to be ruthless. Wire that in and the context window quietly changes from the wall you keep hitting into a budget you spend on purpose.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code — Context window and compaction. &lt;a href="https://code.claude.com/docs/en/context-window" rel="noopener noreferrer"&gt;https://code.claude.com/docs/en/context-window&lt;/a&gt; (accessed 2026-07-07)&lt;/li&gt;
&lt;li&gt;Anthropic — Context editing (&lt;code&gt;clear_tool_uses_20250919&lt;/code&gt;, &lt;code&gt;clear_at_least&lt;/code&gt;, &lt;code&gt;keep&lt;/code&gt;, &lt;code&gt;trigger&lt;/code&gt;, &lt;code&gt;exclude_tools&lt;/code&gt;). &lt;a href="https://platform.claude.com/docs/en/build-with-claude/context-editing" rel="noopener noreferrer"&gt;https://platform.claude.com/docs/en/build-with-claude/context-editing&lt;/a&gt; (accessed 2026-07-07)&lt;/li&gt;
&lt;li&gt;Anthropic — Managing context on the Claude Developer Platform (29% / 39% performance figures for context editing alone vs. context editing plus the memory tool). &lt;a href="https://www.anthropic.com/news/context-management" rel="noopener noreferrer"&gt;https://www.anthropic.com/news/context-management&lt;/a&gt; (accessed 2026-07-07)&lt;/li&gt;
&lt;li&gt;Anthropic — Prompt caching (5-minute / 1-hour TTL, 1.25× / 2× write, 0.1× read, cumulative-hash invalidation). &lt;a href="https://platform.claude.com/docs/en/build-with-claude/prompt-caching" rel="noopener noreferrer"&gt;https://platform.claude.com/docs/en/build-with-claude/prompt-caching&lt;/a&gt; (accessed 2026-07-07)&lt;/li&gt;
&lt;li&gt;Claude Code — Subagents (per-subagent context window, returns only the summary). &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;https://code.claude.com/docs/en/sub-agents&lt;/a&gt; (accessed 2026-07-07)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llmagents</category>
      <category>contextwindow</category>
      <category>promptcaching</category>
      <category>agentarchitecture</category>
    </item>
    <item>
      <title>Claude Code Hooks: A Practical Deep-Dive on Deterministic Agent Behavior</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Sun, 12 Jul 2026 22:36:47 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/claude-code-hooks-a-practical-deep-dive-on-deterministic-agent-behavior-2i7d</link>
      <guid>https://dev.to/swapnanilsaha/claude-code-hooks-a-practical-deep-dive-on-deterministic-agent-behavior-2i7d</guid>
      <description>&lt;p&gt;Here's a thing that took me embarrassingly long to accept about coding agents: you cannot instruct your way to reliability.&lt;/p&gt;

&lt;p&gt;I had a working-memory system — a semantic-search-plus-notes MCP (Model Context Protocol) server I've been building, and it's the case study for this whole post — and it worked beautifully in demos. The agent would discover something, store a note, recall it later, save itself a re-read. Then I'd watch a real session and the agent would just... not recall. It had notes sitting right there, one tool call away, verbatim, and it would instead re-read the same file it had already read two sessions ago, because nothing &lt;em&gt;made&lt;/em&gt; it check. My &lt;code&gt;CLAUDE.md&lt;/code&gt; said "call recall at the start of every task." The model read that instruction and ignored it, the way it ignores roughly anything that competes with the task actually in front of it.&lt;/p&gt;

&lt;p&gt;The lesson generalizes past my project. Any behavior you need to happen &lt;em&gt;every single time&lt;/em&gt; — inject context, run a linter, block a dangerous command, snapshot state before it's destroyed — cannot depend on the model deciding to do it. The model is a probabilistic thing optimizing for the current turn. You need something outside the model, in the harness, that fires deterministically. In Claude Code, that thing is &lt;strong&gt;hooks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is the practical guide I wanted when I started: what the events are and what each is genuinely good for, the exact configuration and I/O contract (which is fiddlier than the docs make it look), and then a real production hook pipeline — mine — walked through end to end, including the design calls and the parts that bit me.&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem hooks actually solve
&lt;/h2&gt;

&lt;p&gt;Before the plumbing, the point. There is a whole class of things you want an agent to do that instructions are simply the wrong tool for. Not because the instruction is badly worded — because instructions target the model, and the model is the part of the system you don't control.&lt;/p&gt;

&lt;p&gt;Think about what "the model complies with an instruction" actually means. On any given turn there's some probability the behavior happens, and that probability is well short of 1. It drops when the task gets absorbing, when the context is long, when an unusual prompt pulls attention elsewhere. That's fine for a preference — "prefer functional style," "keep commits small." It is a disaster for a guarantee. If the only thing standing between your agent and an &lt;code&gt;rm -rf&lt;/code&gt; on the wrong directory is a politely worded line in a config file, you don't have a control. You have a hope.&lt;/p&gt;

&lt;p&gt;Hooks move the decision out of the model and into the harness. The harness is deterministic: it runs code on a schedule, whether or not the model would have thought to. That single relocation — from "the model should" to "the harness will" — is the entire idea, and everything below is mechanics in service of it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The core distinction.&lt;/strong&gt; &lt;code&gt;CLAUDE.md&lt;/code&gt; is where you put things the model should &lt;em&gt;tend&lt;/em&gt; to do. Hooks are where you put things that must &lt;em&gt;deterministically&lt;/em&gt; happen. Confusing the two — trying to instruction-engineer a guarantee — is how you end up with a system that works in the demo and flakes in production.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What a hook actually is
&lt;/h2&gt;

&lt;p&gt;A hook is a shell command — or, increasingly, an HTTP call or an MCP tool invocation — that Claude Code runs automatically when a specific event fires in the session lifecycle. The event hands your command a JSON blob on stdin describing what's happening. Your command does whatever it wants and communicates back through two channels: its &lt;strong&gt;exit code&lt;/strong&gt; and its &lt;strong&gt;stdout&lt;/strong&gt;. That's the entire model. It's Unix-plumbing simple, which is exactly why it's reliable — there's no LLM in the loop deciding whether to honor it.&lt;/p&gt;

&lt;p&gt;The events cover the session from birth to death. When I first wrote my pipeline the list was short; by mid-2026 it has grown considerably — the reference now documents around thirty event types spanning session lifecycle, per-turn, per-tool-call, permissions, subagents, worktrees, and MCP elicitation. Most of them you'll never touch. The workhorses — the ones worth learning cold — are these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Fires&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionStart&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session begins or resumes (matchers: &lt;code&gt;startup&lt;/code&gt;, &lt;code&gt;resume&lt;/code&gt;, &lt;code&gt;clear&lt;/code&gt;, &lt;code&gt;compact&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Inject state the agent needs before turn 1 — branch info, environment, recalled memory&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before Claude processes each user prompt&lt;/td&gt;
&lt;td&gt;Inject per-turn context keyed to what the user just asked; can also block the prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before a tool call runs (matches on tool name)&lt;/td&gt;
&lt;td&gt;Block dangerous calls, rewrite arguments, or surface a warning tied to the specific action&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PostToolUse&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;After a tool call succeeds&lt;/td&gt;
&lt;td&gt;React to results, replace tool output, add follow-up context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PreCompact&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Before context compaction (matchers: &lt;code&gt;manual&lt;/code&gt;, &lt;code&gt;auto&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Persist anything that's about to be summarized away&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Stop&lt;/code&gt; / &lt;code&gt;SubagentStop&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;When the agent (or a subagent) finishes a turn&lt;/td&gt;
&lt;td&gt;Enforce "you're not done yet" — block the stop and send it back to work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SessionEnd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Session terminates&lt;/td&gt;
&lt;td&gt;Cleanup, flush, teardown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Notification&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude Code emits a notification (permission prompt, idle, etc.)&lt;/td&gt;
&lt;td&gt;Route notifications to your own channels&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mental split that helps: &lt;strong&gt;session-scoped&lt;/strong&gt; events (&lt;code&gt;SessionStart&lt;/code&gt;, &lt;code&gt;SessionEnd&lt;/code&gt;) bracket the whole thing; &lt;strong&gt;per-turn&lt;/strong&gt; events (&lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;) fire once per user exchange; &lt;strong&gt;per-tool&lt;/strong&gt; events (&lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;) fire around individual tool calls, potentially dozens of times a turn. Match the cadence of your hook to the cadence of the thing it's reacting to, or you'll either miss events or fire far too often.&lt;/p&gt;




&lt;h2&gt;
  
  
  The configuration surface
&lt;/h2&gt;

&lt;p&gt;Hooks live in &lt;code&gt;settings.json&lt;/code&gt;. There are three tiers, and the tier decides who the hook applies to and whether it's shared:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.claude/settings.json&lt;/code&gt; — all your projects, never checked in.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.claude/settings.json&lt;/code&gt; — one project, checked in and shared with the team.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.claude/settings.local.json&lt;/code&gt; — one project, gitignored, personal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The shape is nested and, honestly, a little awkward until it clicks. Under a top-level &lt;code&gt;hooks&lt;/code&gt; key, each event name maps to a &lt;em&gt;list of groups&lt;/em&gt;. Each group has an optional &lt;code&gt;matcher&lt;/code&gt; and a list of &lt;code&gt;hooks&lt;/code&gt; to run:&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;"hooks"&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;"PreToolUse"&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;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${CLAUDE_PROJECT_DIR}/.claude/hooks/guard.sh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&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;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;The &lt;code&gt;matcher&lt;/code&gt; is the filter. For tool events it matches against the tool name — &lt;code&gt;"Bash"&lt;/code&gt;, &lt;code&gt;"Edit|Write"&lt;/code&gt; for either, or a regex like &lt;code&gt;"mcp__memory__.*"&lt;/code&gt; for a whole MCP server's tools. For non-tool events it matches against the event's reason: &lt;code&gt;SessionStart&lt;/code&gt; takes &lt;code&gt;startup|resume|clear|compact&lt;/code&gt;, &lt;code&gt;PreCompact&lt;/code&gt; takes &lt;code&gt;manual|auto&lt;/code&gt;. Omit the matcher (or use &lt;code&gt;"*"&lt;/code&gt;) to fire on everything.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;type&lt;/code&gt; used to be implicitly "command." It's now explicit and there are several — &lt;code&gt;command&lt;/code&gt; (shell), &lt;code&gt;http&lt;/code&gt; (POST to a URL), &lt;code&gt;mcp_tool&lt;/code&gt; (invoke an already-connected MCP tool), and two LLM-in-the-loop types (&lt;code&gt;prompt&lt;/code&gt;, and the experimental &lt;code&gt;agent&lt;/code&gt;) that let a hook ask a model to make a yes/no call. For anything latency-sensitive you want &lt;code&gt;command&lt;/code&gt;, because it's a local process with no network round trip. The useful knobs on a command hook are &lt;code&gt;timeout&lt;/code&gt; (seconds; the default is generous but &lt;code&gt;UserPromptSubmit&lt;/code&gt; is capped lower because it's on the critical path of every turn), and the &lt;code&gt;${CLAUDE_PROJECT_DIR}&lt;/code&gt; placeholder so your command path survives the user's working directory changing.&lt;/p&gt;




&lt;h2&gt;
  
  
  The I/O contract, which is where people trip
&lt;/h2&gt;

&lt;p&gt;This is the part the quickstart glosses and the part that determines whether your hook works. A hook talks back through exit code and stdout, and the two are read differently depending on the code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit code 0&lt;/strong&gt; — success. Claude Code parses stdout looking for JSON. If it's JSON, the fields are honored; if it's not, no decision is taken and the session proceeds normally. (Two events, &lt;code&gt;SessionStart&lt;/code&gt; and &lt;code&gt;UserPromptSubmit&lt;/code&gt;, are more generous: they also fold plain, non-JSON stdout straight into the context. For every other event, if you want to inject something you emit the JSON form.) This is the channel you use to &lt;em&gt;inject context&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit code 2&lt;/strong&gt; — blocking error. stdout's JSON is ignored; instead, stderr is read as an error message, and for blockable events (&lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;/&lt;code&gt;SubagentStop&lt;/code&gt;) &lt;strong&gt;the action is blocked.&lt;/strong&gt; This is how a &lt;code&gt;PreToolUse&lt;/code&gt; hook vetoes an &lt;code&gt;rm -rf&lt;/code&gt;. (For &lt;code&gt;PreToolUse&lt;/code&gt; specifically there's now a cleaner path too: emit &lt;code&gt;permissionDecision&lt;/code&gt; — &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;deny&lt;/code&gt;, or &lt;code&gt;ask&lt;/code&gt; — inside &lt;code&gt;hookSpecificOutput&lt;/code&gt; on exit 0, which is more expressive than the blunt exit-2 veto and lets you attach a reason the model reads. I still reach for exit 2 when I just want a hard no.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Any other exit code&lt;/strong&gt; — non-blocking error. The session continues; the failure is surfaced in the transcript and logged, but nothing is blocked.&lt;/p&gt;

&lt;p&gt;The JSON you emit on stdout (with exit 0) has a couple of shapes. There's a set of top-level universal fields — &lt;code&gt;continue&lt;/code&gt; (set false to stop Claude entirely), &lt;code&gt;stopReason&lt;/code&gt;, &lt;code&gt;suppressOutput&lt;/code&gt;, &lt;code&gt;systemMessage&lt;/code&gt;. And there's an event-specific envelope, &lt;code&gt;hookSpecificOutput&lt;/code&gt;, which is where the good stuff lives. The single field I use most is &lt;code&gt;additionalContext&lt;/code&gt;: a string that Claude Code injects into the model's context at the point the hook fired.&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;"hookSpecificOutput"&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;"hookEventName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"additionalContext"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Current branch: main&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;Uncommitted: auth.ts, config.py"&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;That's the whole trick behind hook-injected memory. &lt;code&gt;additionalContext&lt;/code&gt; on a &lt;code&gt;SessionStart&lt;/code&gt; hook lands at the start of the conversation. The same field on &lt;code&gt;UserPromptSubmit&lt;/code&gt; lands next to the prompt the user just submitted. On &lt;code&gt;PreToolUse&lt;/code&gt;/&lt;code&gt;PostToolUse&lt;/code&gt; it lands next to the tool result. The docs render it as a system reminder and advise writing it as plain factual statements rather than instructions — the model treats "the deployment target is production" better than "remember to be careful about production." There's a cap on how much you can push through it (on the order of ten thousand characters), which is less a limit than a hint: injection is not a place to dump files.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The one rule that fails silently.&lt;/strong&gt; If you emit JSON on stdout with exit 0, stdout must contain &lt;em&gt;only&lt;/em&gt; that JSON. A stray &lt;code&gt;echo&lt;/code&gt; from your shell profile, a debug print, a warning from a Python import — any of it corrupts the JSON and your injection silently does nothing. More than one of my early hooks failed for exactly this reason and gave no error, because a malformed stdout on exit 0 just means "no decision," not "error." Nothing tells you. The session simply proceeds as if the hook weren't there.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;In the interactive version of this post there's a small explorer where you pick an event, an exit code, and a stdout shape and see exactly what Claude Code does — including how the "stray echo" case turns an injection into a silent no-op. It's on &lt;a href="https://swapnanilsaha.com/blog/claude-code-hooks-deterministic-agent-memory/" rel="noopener noreferrer"&gt;swapnanilsaha.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  A real pipeline: injecting working memory
&lt;/h2&gt;

&lt;p&gt;Now the case study. My tool is a working-memory MCP server: the agent stores notes during a session and recalls them later. The problem from the intro was that recall is a &lt;em&gt;tool the model has to choose to call&lt;/em&gt;, and it wouldn't, reliably. Hooks are how I took the choice away from the model and made recall happen deterministically.&lt;/p&gt;

&lt;p&gt;When you run &lt;code&gt;vectr init --hooks&lt;/code&gt;, the tool writes four hook groups into the project's &lt;code&gt;.claude/settings.json&lt;/code&gt;. Every one of them calls back into the same CLI — &lt;code&gt;vectr hook &amp;lt;event&amp;gt;&lt;/code&gt; — which owns the output contract so the settings file stays a thin, stable pointer. Here's the shape it writes (the install code is idempotent — re-running never duplicates entries and leaves any hooks you added yourself untouched):&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;"hooks"&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;"SessionStart"&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;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"startup|resume|clear|compact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vectr hook session-start"&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;span class="nl"&gt;"UserPromptSubmit"&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;"hooks"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vectr hook user-prompt-submit"&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;span class="nl"&gt;"PreToolUse"&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;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Edit|Write"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vectr hook pre-tool-use"&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;span class="nl"&gt;"PreCompact"&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;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"manual|auto"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"vectr hook pre-compact"&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;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;Four events, four jobs. Walk through why each one is the event it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  SessionStart — the boot set
&lt;/h3&gt;

&lt;p&gt;Before the agent's first turn, &lt;code&gt;vectr hook session-start&lt;/code&gt; fires. It resolves which of my running daemons serves this workspace, asks it for the &lt;em&gt;boot set&lt;/em&gt; — the must-see notes, meaning standing directives plus high-priority task context — and emits them as &lt;code&gt;additionalContext&lt;/code&gt;. This is the &lt;code&gt;MEMORY.md&lt;/code&gt;-equivalent: the handful of things that should be true in the agent's head from turn one, present with zero model agency. The matcher &lt;code&gt;startup|resume|clear|compact&lt;/code&gt; means it fires not just on a fresh start but also after a &lt;code&gt;/compact&lt;/code&gt; and after a &lt;code&gt;/clear&lt;/code&gt; — precisely the moments when the agent has just &lt;em&gt;lost&lt;/em&gt; its context and most needs the boot set re-injected.&lt;/p&gt;

&lt;h3&gt;
  
  
  UserPromptSubmit — per-turn recall
&lt;/h3&gt;

&lt;p&gt;This is the one that fixed the original problem. Every time the user submits a prompt, &lt;code&gt;vectr hook user-prompt-submit&lt;/code&gt; reads the prompt text off stdin, runs a semantic recall against the note store &lt;em&gt;keyed to that specific prompt&lt;/em&gt;, and injects the top matches next to the prompt before the model ever sees it. Ask about workspace locking and the locking notes are already there. The agent doesn't decide to recall — recall already happened, invisibly, on the way in.&lt;/p&gt;

&lt;p&gt;The tuning here matters because this hook is on the hot path of every single turn. I cap it hard: at most 3 notes, with a relevance floor (a minimum similarity of 0.35) so an off-topic prompt injects &lt;em&gt;nothing&lt;/em&gt; rather than dragging in vaguely-related noise. And it injects the terse one-line index form of each note, not the full body — enough for the model to know the note exists and decide whether to expand it, without spending a paragraph of tokens on every turn. An injection that fires every turn has to be miserly or it becomes the context bloat it was meant to prevent.&lt;/p&gt;

&lt;h3&gt;
  
  
  PreToolUse (Edit|Write) — the gotcha at the moment of the edit
&lt;/h3&gt;

&lt;p&gt;This one I'm quietly proud of. When the agent is about to edit or write a file, &lt;code&gt;vectr hook pre-tool-use&lt;/code&gt; pulls the &lt;code&gt;file_path&lt;/code&gt; out of the tool input and recalls any &lt;em&gt;gotcha&lt;/em&gt; recorded against that exact file — then injects it right there, at the instant of the edit. "This file's config is regenerated; edit &lt;code&gt;schema.ts&lt;/code&gt; instead." "This function looks unrelated but changing it breaks the lock invariant." Static path-scoped rules can't do this, because the gotcha is something an earlier session &lt;em&gt;learned and wrote down&lt;/em&gt;, and it surfaces exactly when it's actionable rather than sitting in a rules file the agent skimmed once.&lt;/p&gt;

&lt;h3&gt;
  
  
  PreCompact — save it before it's gone
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;/compact&lt;/code&gt; replaces the conversation with a summary and, in doing so, throws away exact detail. So right before it runs, &lt;code&gt;vectr hook pre-compact&lt;/code&gt; snapshots the working-memory store — sealing the current notes as a named checkpoint. Notably this hook injects &lt;em&gt;nothing&lt;/em&gt; into context; compaction is about to discard context anyway, so there's no point. Its whole job is the side effect of persisting state, and the boot set gets re-injected on the other side by the &lt;code&gt;SessionStart&lt;/code&gt; &lt;code&gt;compact&lt;/code&gt; matcher. The two hooks are a matched pair around the compaction event: one saves, the other restores.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why hook-injected memory beats a recall tool
&lt;/h2&gt;

&lt;p&gt;Let me make the central design argument sharp, because it's the reason the pipeline exists in this shape.&lt;/p&gt;

&lt;p&gt;A recall &lt;em&gt;tool&lt;/em&gt; and a recall &lt;em&gt;hook&lt;/em&gt; retrieve the exact same notes from the exact same store. The only difference is who pulls the trigger. With a tool, the model decides — and "the model decides" means a probability, well short of 1, that it happens on any given turn, dropping further as the task gets absorbing. With a hook, the harness decides, and the harness is deterministic. It fires every time, on schedule, whether or not the model would have thought to.&lt;/p&gt;

&lt;p&gt;For a capability whose entire value proposition is &lt;em&gt;reliability across sessions&lt;/em&gt;, a probabilistic trigger is a contradiction in terms. Working memory you recall 60% of the time isn't 60% as good as working memory you recall always — it's worse than that, because the times it fails are unpredictable and the agent has no way to know it's operating on a stale or empty picture. Moving the trigger from the model into the harness is the difference between a feature that demos well and one that holds up.&lt;/p&gt;

&lt;p&gt;The arithmetic makes it concrete. If a recall tool fires with probability &lt;code&gt;p&lt;/code&gt; on each turn, the chance it fires on &lt;em&gt;every&lt;/em&gt; turn of an &lt;code&gt;N&lt;/code&gt;-turn session is &lt;code&gt;p^N&lt;/code&gt;. At &lt;code&gt;p = 0.60&lt;/code&gt; over 20 turns that's about 0.004% — a clean session is essentially impossible. Even a very obedient &lt;code&gt;p = 0.95&lt;/code&gt; gives you only about 36%. A hook is &lt;code&gt;p = 1&lt;/code&gt;, so &lt;code&gt;p^N = 1&lt;/code&gt;, every session, forever.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The general principle.&lt;/strong&gt; Anything that must happen every time belongs in a hook, not in an instruction. A guarantee cannot be prompt-engineered, because the thing you'd be prompting is the exact thing you don't control. Relocate the trigger, not the words.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's a subtle second-order bug that falls out of doing this, and it's worth telling because it's the kind of thing you only find in real transcripts. Once &lt;code&gt;SessionStart&lt;/code&gt; and &lt;code&gt;UserPromptSubmit&lt;/code&gt; are auto-injecting notes, the model — which has &lt;em&gt;also&lt;/em&gt; been told in &lt;code&gt;CLAUDE.md&lt;/code&gt; to recall notes — will sometimes call the recall tool &lt;em&gt;on top of&lt;/em&gt; the injection, paying for the same memory twice. I caught this in an eval transcript: the agent got its notes injected by the hook and then immediately called &lt;code&gt;recall&lt;/code&gt; for the same thing. The fix is a one-line notice prepended to the injected context: &lt;em&gt;"Your working-memory notes are auto-injected below — do not call recall to re-fetch them; call it only for something not shown here."&lt;/em&gt; It resolves the double-dip cleanly, but I'd never have known to write it without watching the failure happen.&lt;/p&gt;




&lt;h2&gt;
  
  
  The three things that will hurt you
&lt;/h2&gt;

&lt;p&gt;Hooks are simple to write and easy to write &lt;em&gt;dangerously&lt;/em&gt;. Three concerns dominate, and they're all about what happens when a hook misbehaves.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A hook must never break the session
&lt;/h3&gt;

&lt;p&gt;This is the rule I hold most rigidly, and it shapes every line of my hook code. A hook runs on the critical path — &lt;code&gt;UserPromptSubmit&lt;/code&gt; fires before &lt;em&gt;every&lt;/em&gt; prompt the user sends. If that hook throws, hangs, or crashes, it degrades or breaks the user's session. So the hook code is paranoid by construction:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The recall function that feeds the injection catches &lt;em&gt;every&lt;/em&gt; exception and returns an empty string on any failure. Daemon down, slow, error, malformed response — doesn't matter, it yields nothing and the session proceeds.&lt;/li&gt;
&lt;li&gt;The top-level hook handler wraps its entire body in a try/except and &lt;strong&gt;always exits 0.&lt;/strong&gt; There is no code path where my hook returns a non-zero exit and accidentally blocks a prompt or a tool call.&lt;/li&gt;
&lt;li&gt;If there's no memory to inject — a brand-new workspace with zero notes — the hook emits &lt;em&gt;nothing at all&lt;/em&gt;, not an empty JSON envelope. A fresh project should feel exactly like no hook is installed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design stance is that the memory injection is a &lt;em&gt;bonus&lt;/em&gt;, never a &lt;em&gt;dependency&lt;/em&gt;. The session must work identically whether the daemon is up, down, or on fire. If your hook can make the agent worse when it fails, you've built a liability, not a feature.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Latency is a tax on every turn
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;UserPromptSubmit&lt;/code&gt; sits between the user hitting enter and the model starting to think. Whatever your hook spends there, the user waits. Claude Code caps this event's hook timeout lower than others for exactly this reason, but a timeout is a backstop, not a budget — you want to be &lt;em&gt;nowhere near&lt;/em&gt; it. My recall is designed to return in well under 50 milliseconds, and the hook does the absolute minimum: read stdin, one local HTTP call to an already-running daemon, print, exit. No model loading, no indexing, no network beyond localhost. If your per-turn hook does anything that can take a second, move it off the hot path — make it &lt;code&gt;async&lt;/code&gt;, or attach it to a less frequent event.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Hooks run arbitrary shell — treat them as such
&lt;/h3&gt;

&lt;p&gt;The official docs are blunt about this and they're right: &lt;strong&gt;hooks execute arbitrary shell commands with your full user permissions, automatically.&lt;/strong&gt; They can read your files and your environment variables. A malicious or careless hook in a shared &lt;code&gt;.claude/settings.json&lt;/code&gt; is a genuine attack surface — someone commits a hook, you pull the repo, and now their command runs on your machine the next time you start a session.&lt;/p&gt;

&lt;p&gt;Practical defenses: review hook configs before committing to shared repos, exactly as you'd review a &lt;code&gt;Makefile&lt;/code&gt; or a git hook; keep personal hooks in the gitignored &lt;code&gt;settings.local.json&lt;/code&gt; so they can't leak; and know that enterprise setups can lock this down with &lt;code&gt;allowManagedHooksOnly&lt;/code&gt;. In my own design I lean on a smaller mitigation — the settings file never contains logic, only &lt;code&gt;vectr hook &amp;lt;event&amp;gt;&lt;/code&gt;, a call into a versioned, inspectable CLI. There's no shell one-liner in the JSON to audit; the behavior lives in code you can read. And the CLI only ever talks to a localhost daemon, so a hook firing in the wrong directory can't reach across to another workspace's memory. That last point is deliberate: the resolver walks up from the current directory to find the daemon that serves &lt;em&gt;this&lt;/em&gt; workspace and refuses to fall back to a default — because a default port could belong to an unrelated project and leak its notes into your session.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest limitations
&lt;/h2&gt;

&lt;p&gt;A few things I've hit that the enthusiastic tutorials leave out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Injected context is still context.&lt;/strong&gt; Every note a hook injects costs tokens, every turn, forever. The &lt;code&gt;UserPromptSubmit&lt;/code&gt; hook is genuinely helpful &lt;em&gt;because&lt;/em&gt; it's disciplined — 3 notes, relevance floor, terse index form. An undisciplined version that injected ten full notes per turn would reintroduce the exact context bloat the memory system exists to fight. Hook injection is a budget you're spending; spend it like one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debugging is opaque by design.&lt;/strong&gt; Because a malformed stdout on exit 0 means "no decision" rather than "error," a broken hook fails &lt;em&gt;silently&lt;/em&gt;. The session just proceeds as if the hook weren't there. When an injection isn't landing, my first move is always to run the exact command by hand, pipe a sample event JSON into its stdin, and stare at stdout for the one stray character breaking the JSON. There's no substitute; the harness won't tell you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's Claude Code-shaped.&lt;/strong&gt; This whole mechanism is specific to one harness. My pipeline's determinism comes from Claude Code's hook system, and other agent environments have different injection points, or none. If you want the same deterministic-injection behavior elsewhere, you're re-implementing against a different (or absent) surface, and in the worst case you fall back to the very thing hooks let you escape — hoping the model calls the tool. That portability gap is real and I don't have a clean answer to it yet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compaction timing isn't fully in your hands.&lt;/strong&gt; &lt;code&gt;PreCompact&lt;/code&gt; fires before compaction, which is great, but auto-compaction triggers on the harness's schedule, near the context limit — not necessarily at a clean task boundary. My snapshot is a safety net, not a substitute for the agent proactively writing important findings to memory as it goes. The hook catches what the agent forgot to save; it works best when there's little to catch.&lt;/p&gt;




&lt;h2&gt;
  
  
  What hooks are really for
&lt;/h2&gt;

&lt;p&gt;Strip away the specifics and hooks are one idea: &lt;strong&gt;a place to put behavior that must not depend on the model's cooperation.&lt;/strong&gt; Injection, enforcement, persistence, cleanup — anything where "usually" isn't good enough and you need "always." The model is brilliant at the open-ended, judgment-heavy work in the middle of a turn. It is not the thing you want deciding whether the guardrail runs.&lt;/p&gt;

&lt;p&gt;For my working-memory tool, hooks are what turned a good idea that demoed well into something that actually holds across sessions. The notes were always there. What was missing was a guarantee that the agent would &lt;em&gt;look&lt;/em&gt; — and that guarantee cannot come from the agent. It comes from four small shell commands wired into the right four events, each one paranoid about never breaking the session, each one doing exactly one deterministic job. That's the whole art of it: not clever hooks, but reliable ones.&lt;/p&gt;




&lt;h2&gt;
  
  
  Companion posts
&lt;/h2&gt;

&lt;p&gt;This post is part of a series on the working-memory tool the pipeline is built on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-1-semantic-code-search/" rel="noopener noreferrer"&gt;Building vectr, Part 1: Semantic Code Search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-2-working-memory-compact-survival/" rel="noopener noreferrer"&gt;Building vectr, Part 2: Working Memory &amp;amp; Compact Survival&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-3-benchmark-methodology-results/" rel="noopener noreferrer"&gt;Building vectr, Part 3: Benchmark Methodology &amp;amp; Results&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://swapnanilsaha.com/blog/four-families-llm-context-relief-eviction/" rel="noopener noreferrer"&gt;Four Families of LLM Context Relief&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://swapnanilsaha.com/blog/vectr-v1-release-gate-honest-numbers/" rel="noopener noreferrer"&gt;vectr v1 Release Gate: The Honest Numbers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code — &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;Hooks reference&lt;/a&gt; (event list, &lt;code&gt;settings.json&lt;/code&gt; structure, matchers, exit-code protocol, &lt;code&gt;hookSpecificOutput&lt;/code&gt;/&lt;code&gt;additionalContext&lt;/code&gt;, security warning, &lt;code&gt;allowManagedHooksOnly&lt;/code&gt;). Accessed 2026-07-07.&lt;/li&gt;
&lt;li&gt;Claude Code — &lt;a href="https://code.claude.com/docs/en/hooks-guide" rel="noopener noreferrer"&gt;Automate actions with hooks&lt;/a&gt; (worked configuration examples).&lt;/li&gt;
&lt;li&gt;Claude Code — &lt;a href="https://code.claude.com/docs/en/context-window" rel="noopener noreferrer"&gt;Context window, compaction, and &lt;code&gt;/compact&lt;/code&gt;&lt;/a&gt;. Accessed 2026-07-07.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>claudecode</category>
      <category>hooks</category>
      <category>llmagents</category>
      <category>agentmemory</category>
    </item>
    <item>
      <title>Building Vectr, Part 2: What /compact Destroys and How to Survive It</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Tue, 16 Jun 2026 13:28:51 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/building-vectr-part-2-what-compact-destroys-and-how-to-survive-it-hml</link>
      <guid>https://dev.to/swapnanilsaha/building-vectr-part-2-what-compact-destroys-and-how-to-survive-it-hml</guid>
      <description>&lt;p&gt;Session three of a bug hunt in CPython's garbage collector. Two sessions in, I had what felt like a solid map: the exact call chain from &lt;code&gt;PyObject_GC_Del&lt;/code&gt; through the generational collector, the non-obvious invariant around finalizer ordering, the three files where the relevant logic lived. Then &lt;code&gt;/compact&lt;/code&gt; fired.&lt;/p&gt;

&lt;p&gt;The summary said something like: "we were investigating CPython's garbage collector, specifically the interaction between finalizers and the generational GC." Accurate. Useless. The exact function signatures were gone. The specific line numbers were gone. The invariant that took two sessions to understand — compressed to one sentence that had lost all the nuance. The next 20 minutes: re-reading files to rebuild what I already knew.&lt;/p&gt;

&lt;p&gt;This post is about what I learned from that, and from the working memory system I built to prevent it. &lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-1-semantic-code-search/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt; covered the indexing layer — how &lt;a href="https://swapnanilsaha.com/tools/vectr/" rel="noopener noreferrer"&gt;Vectr&lt;/a&gt; finds things in a codebase semantically. This part covers what happens after you find something: how to keep the knowledge alive across session boundaries, why my initial design was wrong in a fundamental way, and what actually works.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The Problem With /compact
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What /compact Actually Destroys
&lt;/h3&gt;

&lt;p&gt;Most people treat &lt;code&gt;/compact&lt;/code&gt; as "clear the context to keep going." That framing is roughly correct but understates the damage. The issue isn't just that context gets shorter — it's that the compression is lossy in exactly the cases where being wrong is most expensive.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/compact&lt;/code&gt; works by asking the AI to summarize the current conversation, then replacing the full history with that summary. Token count drops from (say) 180,000 to 12,000. Here's what the summary doesn't preserve:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exact function signatures.&lt;/strong&gt; A summary might say "the function takes a path and a flag." The conversation had &lt;code&gt;def process_workspace_changes(path: Path, db: Database, *, force: bool = False) -&amp;gt; list[ChangeResult]&lt;/code&gt;. The difference between those two descriptions is the difference between a valid call site and a runtime error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Specific line numbers.&lt;/strong&gt; "The resolver module" and &lt;code&gt;/src/workspace/resolver.rs:214&lt;/code&gt; are not the same precision. You can reconstruct the file path, but it costs you a tool call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-obvious behavioral invariants.&lt;/strong&gt; If you spent three turns establishing that &lt;code&gt;acquire_lock()&lt;/code&gt; must be called &lt;em&gt;before&lt;/em&gt; touching workspace metadata because there's a race condition with the filesystem watcher, that three-turn understanding might survive as "be careful with locking." The exact invariant — the one that matters when you're writing the code — is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reasoning chain.&lt;/strong&gt; Sometimes the value of an exploration session isn't the final answer but the chain of observations that produced it. Summaries discard chains. They keep endpoints.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key insight:&lt;/strong&gt; Summaries are fine for preserving topics and general direction. They fail specifically at exact signatures, line numbers, and subtle behavioral invariants — which is also where being wrong is most expensive. A summary of "be careful with locking" covers the topic. It doesn't tell you which function must be called first, or why, or what breaks if you get it wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In the CPython scenario, re-establishing the finalizer ordering invariant from scratch means re-reading several files and re-following a non-obvious call chain — roughly 15–20 minutes of work that was already done. A note stored at the end of session two takes about a minute to write and ten milliseconds to retrieve.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why You Can't Tell the AI to Just Forget Things
&lt;/h3&gt;

&lt;p&gt;When I started building Vectr's memory layer, I had a clean model: the AI finds something useful, stores it with &lt;code&gt;vectr_remember&lt;/code&gt;, then &lt;em&gt;drops the file from its context window&lt;/em&gt;. The note is 50 tokens. The file was 800 tokens. Net gain: 750 tokens freed for new content. I called this "context offload."&lt;/p&gt;

&lt;p&gt;I built it this way. I wrote documentation describing it this way. I designed &lt;code&gt;vectr_evict_hint&lt;/code&gt; entirely around it.&lt;/p&gt;

&lt;p&gt;It doesn't work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The KV cache is append-only.&lt;/strong&gt; Think of the transformer's memory as a lookup table it builds as it reads each token. For each token it processes, it computes a key-value representation that gets stored at each attention layer. Every subsequent token attends back to every previous token through these cached representations — that's how earlier context influences later output.&lt;/p&gt;

&lt;p&gt;Once a token's representation is computed and cached, it stays until the context is cleared. There is no mechanism to evict specific tokens by instruction. "You can drop chunk X from your context window" is itself processed as tokens — added to the cache, not used to remove other entries from it.&lt;/p&gt;

&lt;p&gt;A subtlety worth naming: the KV cache is maintained server-side by the inference provider. What you see as "context window usage" is a count of tokens in the current conversation, not a direct readout of GPU memory. The principle holds regardless: every token in the conversation occupies a slot in the cache, and you cannot remove individual tokens from a running session without ending or compressing the whole thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The KV cache memory cost formula:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KV cache size = 2 × L × n_heads × d_head × T × bytes_per_float
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a representative mid-size model: L=32 layers, n_heads=32, d_head=128, T=50,000 tokens at fp16 (2 bytes):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2 × 32 × 32 × 128 × 50,000 × 2 = 13.1 GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cache grows &lt;em&gt;linearly&lt;/em&gt; with sequence length T. No selective removal. The operations that genuinely reduce context are: end the session (total loss), use /compact (precision loss), or rely on provider-side prefix caching — which stores stable prefix representations like system prompts to avoid recomputing them, but doesn't remove anything from your active context budget.&lt;/p&gt;

&lt;p&gt;I measured context window usage before and after sequences of &lt;code&gt;vectr_remember&lt;/code&gt; + &lt;code&gt;vectr_evict_hint&lt;/code&gt; calls: essentially unchanged. The hint was adding tokens to the cache while accomplishing nothing at the context management level. In some cases it made things marginally worse.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; Any tool or documentation claiming "store to external memory to free context budget" is describing something the system cannot deliver. Tokens in a live context window cannot be selectively evicted. Working memory tools are genuinely valuable — but not for freeing active context. Building around that claim confuses your benchmarks and misleads anyone using the tool.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 2: What Working Memory Actually Does
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Three Tiers of Value
&lt;/h3&gt;

&lt;p&gt;Once I dropped the context-offload framing, the actual value of &lt;code&gt;vectr_remember&lt;/code&gt; became clear. It operates on three time horizons:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1 — In-session re-read avoidance.&lt;/strong&gt; Within a single session, before any /compact: recalling a stored note costs ~50 tokens instead of re-reading the original file at ~600 tokens. Real savings, but the file is still sitting in your context window anyway. Genuinely useful, but not the reason to build this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2 — /compact survival.&lt;/strong&gt; When /compact compresses the conversation, notes stored on disk (SQLite + ChromaDB) are untouched. Exact signatures and behavioral invariants survive verbatim. The session resumes from actual precision. This is where the system earns its cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3 — Cross-session persistence.&lt;/strong&gt; Between separate sessions — the editor closed and reopened — the AI starts with nothing. Notes survive. A new session calling &lt;code&gt;vectr_status()&lt;/code&gt; + &lt;code&gt;vectr_recall()&lt;/code&gt; recovers findings from sessions ago without re-reading a single file. Each session builds on the ones before it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy — The surgeon's notes:&lt;/strong&gt; A surgeon takes detailed notes before starting a complex procedure. Halfway through, an emergency calls them away for two hours. When they return: (a) their notes are on the desk — exact measurements, named vessels, where they left off; or (b) a colleague wrote a summary: "patient is partially through a vascular procedure, some complications noted." Option (b) is dangerous. Option (a) lets you continue precisely. &lt;code&gt;vectr_remember&lt;/code&gt; is option (a). /compact without notes is option (b).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Tier 3 compounds in a way that's easy to underestimate. The first session on a complex codebase pays the discovery cost. The second benefits from the first session's notes. By the tenth session, a well-maintained note store is a persistent model of the codebase that makes every session faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to Store and How
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Don't store file pointers.&lt;/strong&gt; "See &lt;code&gt;resolver.rs:214&lt;/code&gt; for the lock implementation" is a bad note. File paths change during refactoring. Line numbers drift with every edit. A pointer hasn't captured what you &lt;em&gt;learned&lt;/em&gt; — it's a reference. When you recall it, you still have to read the file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Store the finding itself:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight rust"&gt;&lt;code&gt;&lt;span class="n"&gt;WorkspaceLock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;defined&lt;/span&gt; &lt;span class="n"&gt;at&lt;/span&gt; &lt;span class="n"&gt;resolver&lt;/span&gt;&lt;span class="py"&gt;.rs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;214&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;of&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;06&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;08&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;blocks&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="py"&gt;.vectr_lock&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;writes&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="n"&gt;PID&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;validates&lt;/span&gt; &lt;span class="n"&gt;PID&lt;/span&gt; &lt;span class="k"&gt;match&lt;/span&gt; &lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="n"&gt;deleting&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="nf"&gt;file&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;Err&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;mismatch&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="n"&gt;this&lt;/span&gt; &lt;span class="n"&gt;is&lt;/span&gt; &lt;span class="n"&gt;intentional&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="n"&gt;bug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;CRITICAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;acquire&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;must&lt;/span&gt; &lt;span class="n"&gt;be&lt;/span&gt; &lt;span class="n"&gt;called&lt;/span&gt; &lt;span class="n"&gt;BEFORE&lt;/span&gt; &lt;span class="n"&gt;touching&lt;/span&gt; &lt;span class="n"&gt;workspace&lt;/span&gt;
  &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="py"&gt;. The&lt;/span&gt; &lt;span class="n"&gt;filesystem&lt;/span&gt; &lt;span class="n"&gt;watcher&lt;/span&gt; &lt;span class="n"&gt;reads&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;touching&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;
  &lt;span class="n"&gt;without&lt;/span&gt; &lt;span class="n"&gt;holding&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt; &lt;span class="n"&gt;fires&lt;/span&gt; &lt;span class="n"&gt;an&lt;/span&gt; &lt;span class="n"&gt;invalid&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="py"&gt;.
  This&lt;/span&gt; &lt;span class="n"&gt;caused&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;race&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;issue&lt;/span&gt; &lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="mf"&gt;1247.&lt;/span&gt;

&lt;span class="n"&gt;Key&lt;/span&gt; &lt;span class="n"&gt;callsites&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;workspace&lt;/span&gt;&lt;span class="py"&gt;.rs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;89&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;init&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="py"&gt;.rs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;203&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shutdown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This note is ~120 tokens. Reading the relevant files to reconstruct this knowledge would cost 600+ tokens plus two turns. The note captures the actual insight — the non-obvious invariant about lock order — not just a pointer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Priority and tags are not cosmetic.&lt;/strong&gt; &lt;code&gt;priority&lt;/code&gt; affects recall ordering: high-priority notes rank higher when multiple notes match a query with similar scores. &lt;code&gt;tags&lt;/code&gt; enable filtered recall — &lt;code&gt;vectr_recall(query="locking", tags=["concurrency"])&lt;/code&gt; returns only notes tagged with "concurrency" that semantically match the query. In a large note store accumulated over months, filtering by subsystem makes recall precise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: The Bugs That Shaped the Design
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The B9 Bug: When Recall Doesn't Recall
&lt;/h3&gt;

&lt;p&gt;For several early benchmark runs, &lt;code&gt;vectr_recall&lt;/code&gt; was firing in implementation sessions but returning nothing useful — 0 relevant results across 5 separate sessions on CPython tasks, even though the research session had stored detailed notes about exactly the functions being modified.&lt;/p&gt;

&lt;p&gt;Root cause: recall was using SQL LIKE queries, not semantic search.&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="c1"&gt;# The broken implementation (pre-B9)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Note&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM notes WHERE content LIKE ? LIMIT 20&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;%&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;%&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="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQL LIKE is substring matching. &lt;code&gt;vectr_recall("garbage collector finalizer ordering")&lt;/code&gt; would only return notes containing that exact string. A note about &lt;code&gt;PyObject_GC_Del&lt;/code&gt; describing finalizer behavior — stored with different wording in a different session — wouldn't match.&lt;/p&gt;

&lt;p&gt;The fix: use the ChromaDB vector store for recall. Notes are embedded when stored, retrieved by semantic similarity when recalled.&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="c1"&gt;# The correct implementation (post-B9)
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;recall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&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="n"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&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="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;chroma_collection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;query_texts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;n_results&lt;/span&gt;&lt;span class="o"&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;where&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;tags&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;$in&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tags&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;tags&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="bp"&gt;None&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="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_chroma&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Impact was immediate: &lt;code&gt;vectr_recall&lt;/code&gt; fired with relevant results in 4 of 6 implementation sessions in the CPython re-run, compared to 0 of 6 before. This bug sat undetected because the initial benchmark design didn't make empty recalls visible. Per-tool logging — "vectr_recall called 5 times, 5 empty responses" — made it obvious.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; SQL LIKE requires the query string to be a literal substring of the stored content. For anything more than exact-match lookup, it's not just suboptimal — it's functionally broken for most real queries.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  vectr_evict_hint: What It Actually Does After the Reframe
&lt;/h3&gt;

&lt;p&gt;After fixing the context-offload misconception, I kept &lt;code&gt;vectr_evict_hint&lt;/code&gt; but reframed it completely. What it actually does: it tracks the cumulative token cost of all code chunks Vectr has retrieved in the current session. When this cost crosses a threshold (40K tokens &lt;em&gt;or&lt;/em&gt; 20 tool calls — whichever fires first), it appends a hint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[vectr_evict_hint] You've retrieved ~42,000 tokens of indexed chunks
this session. The following chunks are fully indexed and re-retrievable
in &amp;lt;50ms — no need to re-read these files later:

  - resolver.rs:214  WorkspaceLock::acquire  (retrieved 8 turns ago)
  - resolver.rs:267  WorkspaceLock::release  (retrieved 8 turns ago)
  - workspace.rs:89  init call site          (retrieved 5 turns ago)

Consider calling vectr_remember now if you have key findings you
haven't stored yet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The word is "re-retrievable," not "droppable." The hint doesn't claim to free tokens. It tells the AI: these files are in the index, you can get them back in under 50ms if you need them — don't re-read out of caution when you already have what you need or could re-search instantly. It's a behavioral nudge, not a memory management operation.&lt;/p&gt;

&lt;p&gt;The threshold values come from MemGPT (arXiv:2310.08560), which found models begin exhibiting "lost in the middle" degradation at roughly 70% context fill. Using a disjunction (first threshold reached triggers the hint) keeps it from firing too late on sessions that accumulate few large files but many small searches.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Lost in the middle:&lt;/strong&gt; LLM performance on retrieval tasks follows a U-shaped curve over context position — accuracy highest at the beginning and end, degrading for content in the middle. The evict_hint threshold is set to fire before relevant information drifts into that degraded zone.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 4: The Mechanics of Actually Using It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Save-Moment Problem
&lt;/h3&gt;

&lt;p&gt;Knowing notes are valuable doesn't make the AI store them. In early sessions, &lt;code&gt;vectr_remember&lt;/code&gt; call rates were low — not because the AI couldn't see the tool, but because there was no clear trigger for "now is the moment to save this."&lt;/p&gt;

&lt;p&gt;Saving notes is a habit humans develop from experiencing loss. An AI editor in session 1 has never lost anything to /compact here — it's optimizing for the task in front of it, not a compression event that might happen three hours from now.&lt;/p&gt;

&lt;p&gt;The solution: making the save-moment explicit and concrete in the &lt;code&gt;CLAUDE.md&lt;/code&gt; template that Vectr writes into a workspace.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gs"&gt;**The moment you find a key definition, pattern, or non-obvious detail:**&lt;/span&gt;
call vectr_remember(content, tags=[...], priority="high"|"medium"|"low")
— store the actual code block or finding, not a file pointer.

Treat every vectr_search or vectr_locate call as a &lt;span class="gs"&gt;**pair**&lt;/span&gt;: search,
then immediately save the key finding before your next retrieval.

If /compact runs later, the conversation summary loses exact signatures
and line numbers — your note does not.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Pair every search with a save" turned out to be the most effective framing. Not "save when it feels important" (too vague), but "pair every retrieval with a note" (concrete, immediate trigger). Sessions that stored the most notes also had the lowest re-discovery costs in subsequent tasks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When not to search: the SR-RAG finding.&lt;/strong&gt; The pair pattern addresses when to save. There's a complementary question that ended up in the same CLAUDE.md template: when to search at all. Before calling &lt;code&gt;vectr_search&lt;/code&gt; on a well-known API or framework, the AI should first write out what it already knows and only search if genuine gaps remain.&lt;/p&gt;

&lt;p&gt;This comes from SR-RAG (arXiv:2504.01018). The finding: models often retrieve information already baked in from training, adding token cost without improving answer quality. Writing out what you already know before searching reduces unnecessary calls by 26–40% on familiar codebases. On an unfamiliar codebase, the AI's training knowledge rarely applies — every search turns up something new. On well-known frameworks, training knowledge is often more accurate than indexed documentation. The verbalization step surfaces which situation you're actually in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Snapshots: Checkpointing an Investigation
&lt;/h3&gt;

&lt;p&gt;Beyond individual notes, there's a use case for checkpointing entire session states. &lt;code&gt;vectr_snapshot("lock-subsystem-mapped")&lt;/code&gt; seals the current note set under a named label with a timestamp. &lt;code&gt;vectr_snapshot_list()&lt;/code&gt; at session start shows all checkpoints.&lt;/p&gt;

&lt;p&gt;Typical multi-session workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exploration sessions:&lt;/strong&gt; explore, call &lt;code&gt;vectr_remember&lt;/code&gt; on each key finding. Pair every search with a save.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exploration complete:&lt;/strong&gt; &lt;code&gt;vectr_snapshot("exploration-complete")&lt;/code&gt;. Seals the note state for this phase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementation sessions:&lt;/strong&gt; &lt;code&gt;vectr_status()&lt;/code&gt; → &lt;code&gt;vectr_recall(query)&lt;/code&gt; → build on the snapshot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implementation done:&lt;/strong&gt; &lt;code&gt;vectr_snapshot("implementation-done")&lt;/code&gt;. Two named checkpoints marking the arc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revisiting months later:&lt;/strong&gt; &lt;code&gt;vectr_snapshot_list()&lt;/code&gt; shows the investigation history. The snapshot timestamp tells you which notes were established before a given change.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  When Notes Are Wrong: vectr_forget
&lt;/h3&gt;

&lt;p&gt;Notes can be wrong. A note about function behavior written before a refactor may describe the old behavior. Stale notes are worse than no notes — false confidence in outdated information.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vectr_forget(note_id)&lt;/code&gt; deletes it. Every &lt;code&gt;vectr_recall&lt;/code&gt; response includes note IDs alongside the content so you can act on them inline. The workflow: recall → verify against current code → forget the stale note → store the updated one.&lt;/p&gt;

&lt;p&gt;Vectr also appends a &lt;code&gt;[STALE]&lt;/code&gt; marker automatically when a file path extracted from a note's content no longer exists in the workspace. The extraction is a regex scan for path-like strings — when those paths disappear from the file tree, the note gets flagged. It only catches path-level staleness, not behavioral changes in files that kept their names.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; The [STALE] marker fires when a referenced file path disappears. It does NOT fire when file content changes. A note about function behavior after a refactor that renamed the file gets flagged; a note about function behavior after a refactor that changed the logic without renaming gets no warning. Always verify behavioral notes against current code before acting on them for implementation work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The Design Principle I'd Rephrase
&lt;/h3&gt;

&lt;p&gt;Looking back at the original Vectr documentation for working memory, almost every sentence led with the wrong framing. "Store to vectr, then drop from context." "Offload findings to free context budget." "Context offload layer." Every one of these is technically false, and I shipped all of them.&lt;/p&gt;

&lt;p&gt;The correct version is shorter: store findings now so you can recall them precisely later. Through /compact. Through a new session. Through however many turns separate the discovery from the moment you need to use it. The value is in the later. The storing is cheap. The recalling is where you get the hours back.&lt;/p&gt;

&lt;p&gt;If I were writing the documentation from scratch I'd lead with the /compact scenario — with the specific moment when a detailed understanding of a complex system compresses into a three-sentence summary that can't be acted on. That's the moment where a stored note is worth exactly what it cost to write it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The part I haven't answered yet: does any of this actually save time? Not in the abstract — in real benchmarks, on real codebases, compared against an AI editor with no indexing and no memory. The number I care about is not total session cost (which includes upfront research overhead that inflates the naive comparison) but re-discovery cost per task across repeated sessions on the same codebase.&lt;/p&gt;

&lt;p&gt;Part 3 covers that measurement — including why the total sprint cost comparison is almost exactly the wrong metric to report, and what the data from CPython, Django, and Apache Camel actually showed once I separated research overhead from implementation savings.&lt;/p&gt;

&lt;p&gt;If you want to try &lt;a href="https://swapnanilsaha.com/tools/vectr/" rel="noopener noreferrer"&gt;Vectr&lt;/a&gt; now, the tool page has setup instructions. The full working memory layer — &lt;code&gt;vectr_remember&lt;/code&gt;, &lt;code&gt;vectr_recall&lt;/code&gt;, &lt;code&gt;vectr_snapshot&lt;/code&gt;, &lt;code&gt;vectr_forget&lt;/code&gt; — is in the current release alongside the semantic search tools from &lt;a href="https://swapnanilsaha.com/blog/building-vectr-part-1-semantic-code-search/" rel="noopener noreferrer"&gt;Part 1&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Packer et al., &lt;em&gt;MemGPT: Towards LLMs as Operating Systems&lt;/em&gt;, arXiv:2310.08560, 2023&lt;/li&gt;
&lt;li&gt;Liu et al., &lt;em&gt;Lost in the Middle: How Language Models Use Long Contexts&lt;/em&gt;, arXiv:2307.03172, 2023&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Self-Routing RAG: Binding Selective Retrieval with Knowledge Verbalization&lt;/em&gt;, arXiv:2504.01018, 2025&lt;/li&gt;
&lt;li&gt;Vaswani et al., &lt;em&gt;Attention Is All You Need&lt;/em&gt;, NeurIPS 2017&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;A Survey on LLM Acceleration Based on KV Cache Management&lt;/em&gt;, arXiv:2412.19442, 2024&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llmtools</category>
      <category>mcp</category>
      <category>developertools</category>
      <category>workingmemory</category>
    </item>
    <item>
      <title>Building Vectr, Part 1: Why grep Fails When You Don't Know the Keywords</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Tue, 09 Jun 2026 14:10:06 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/building-vectr-part-1-why-grep-fails-when-you-dont-know-the-keywords-17e7</link>
      <guid>https://dev.to/swapnanilsaha/building-vectr-part-1-why-grep-fails-when-you-dont-know-the-keywords-17e7</guid>
      <description>&lt;p&gt;&lt;em&gt;This is Part 1 of the Building Vectr series (1 of 3).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You get dropped into an unfamiliar codebase. Not a toy project — real production code, 8,000 files, three years of accumulated complexity and clever abstractions. Your job is to fix a bug in the request validation pipeline. What does an AI code editor do next?&lt;/p&gt;

&lt;p&gt;This post is about a problem I kept running into, a tax I kept paying, and the indexing system I built to eliminate it. It covers the technical decisions behind &lt;a href="https://swapnanilsaha.com/tools/vectr/" rel="noopener noreferrer"&gt;Vectr&lt;/a&gt;'s search layer: why naive chunking produces bad embeddings, how tree-sitter solves the code-parsing problem, what BM25 does that vector search can't, and why you need a symbol graph for questions that text search cannot answer at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1 — The Problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Re-discovery Tax
&lt;/h3&gt;

&lt;p&gt;If you're a human engineer navigating an unfamiliar codebase, here's what you probably do: you ask someone who knows it, or you grep for the error message, or you open the entry point and follow imports until you find the thing. Your brain does semantic compression the whole way — building a model of the system, discarding noise, following intuitions about where complexity tends to live. By the time you've read 20 files, you have a rough map that persists across days and sessions.&lt;/p&gt;

&lt;p&gt;An AI code editor has the same tools — read files, run shell commands, grep — but completely different economics. Every &lt;code&gt;Read&lt;/code&gt; call costs tokens. Every &lt;code&gt;Bash&lt;/code&gt; call for grep costs a turn. Unlike a human who can skim-read at 1,000 words per minute and discard irrelevant content almost for free, an AI editor pays full price for every character it reads: it sits in the context window whether or not it was useful. Read the wrong 500-line file and you've burned context that could have held the answer.&lt;/p&gt;

&lt;p&gt;The result, on unfamiliar codebases, is what I started calling the &lt;strong&gt;re-discovery tax&lt;/strong&gt;: a cluster of navigation calls at the start of every session, before any actual implementation begins, spent on figuring out where things are. And because AI editors have no persistent memory between sessions, they pay this tax again and again — every session, on the same codebase.&lt;/p&gt;

&lt;p&gt;In benchmarks I ran against real open-source codebases (more detail in Part 3), the re-discovery tax on CPython internals ranged from &lt;strong&gt;6 to 23 tool calls per task&lt;/strong&gt; before the first file write. Some sessions spent more turns navigating than implementing.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key observation:&lt;/strong&gt; The re-discovery tax is paid every session, not once. A human engineer's mental map of a codebase accumulates and compounds. An AI editor's map is fully rebuilt from scratch at the start of each session. The economic gap widens as the codebase grows.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Why grep Fails at the Boundary of Your Knowledge
&lt;/h3&gt;

&lt;p&gt;Before explaining what I built, I want to be precise about where grep breaks down — because "just use grep" is the natural reaction, and it's not obviously wrong until you try to use it systematically on unfamiliar code.&lt;/p&gt;

&lt;p&gt;grep is a brilliant tool for confirming hypotheses you already have. If you know what you're looking for, it's nearly perfect. The problem is the case that isn't really an edge case: &lt;em&gt;you don't know what you're looking for.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Say you're trying to understand how a Django application validates incoming JSON payloads before they hit the ORM layer. You might grep for &lt;code&gt;validate&lt;/code&gt;. You'll get 200 results across 40 files — field validators, form validators, configuration validators, test fixtures. None of them are obviously the thing you want. You grep for &lt;code&gt;json.loads&lt;/code&gt;. You get 30 results. You grep for &lt;code&gt;request.data&lt;/code&gt;. That gets you closer, maybe. But you spent four greps and 15 minutes before you found the right file.&lt;/p&gt;

&lt;p&gt;The deeper problem: grep requires you to already have a mental model of the codebase's naming conventions. An AI editor running on an unfamiliar codebase doesn't know whether payload validation is called &lt;code&gt;validate_payload&lt;/code&gt;, &lt;code&gt;check_request&lt;/code&gt;, &lt;code&gt;parse_input&lt;/code&gt;, or &lt;code&gt;_pre_process&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy:&lt;/strong&gt; Think of keyword search as asking for directions by street name in a city you've never visited. "Where is Maple Street?" gets a precise answer. But "where is the street with the good coffee shop near the park?" — keyword search has nothing to offer. You need a different kind of index: one that understands &lt;em&gt;what places are for&lt;/em&gt;, not just what they're called.&lt;/p&gt;

&lt;p&gt;Semantic search inverts this. It maps your query and every code chunk into the same high-dimensional vector space, then finds the chunks closest to your query by meaning — regardless of whether they share any words. "JWT validation logic" finds &lt;code&gt;verify_token&lt;/code&gt; even if neither of those words appears in the function body.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 2 — Building the Index
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Chunking Problem: Why Line Windows Break on Code
&lt;/h3&gt;

&lt;p&gt;Prose text has a natural unit of meaning: the paragraph. You can split a Wikipedia article into 200-word chunks, embed each one, and get a reasonable search system. Code doesn't work this way.&lt;/p&gt;

&lt;p&gt;The standard naive approach for code indexing is the same line-window strategy borrowed from document search: take a sliding window of N lines with M lines of overlap, create a chunk, embed it, move the window. A common default might be 150-line windows with 50 lines of overlap. Simple, language-agnostic, works on any file format.&lt;/p&gt;

&lt;p&gt;The problem is what happens at the window boundaries. Consider this function:&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process_workspace_changes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;force&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ChangeResult&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Process all pending changes in a workspace, optionally forcing re-indexing.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_pending_changes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;force&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&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;change&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pending&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;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ChangeKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DELETED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove_chunks_for_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChangeResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;removed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ChangeKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MODIFIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ChangeKind&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CREATED&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chunk_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;language_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ChangeResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;indexed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mark_changes_processed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&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;results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a 150-line window happens to cut through this function, neither resulting chunk is independently meaningful. The chunk with just the body is missing the parameter names and return type. The chunk with just the signature has no implementation context. The embedding of a half-function is significantly worse than the embedding of the complete thing.&lt;/p&gt;

&lt;p&gt;The fix: split at semantic boundaries. Functions should be complete units. Classes should contain their methods, or each method should be its own chunk with the class header prepended for context.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why completeness matters:&lt;/strong&gt; An embedding model compresses everything in its context into a single fixed-size vector. A complete function gives the model everything it needs to capture the function's purpose, parameters, return behavior, and side effects in that vector. A half-function forces the model to compress an ambiguous fragment — the resulting vector is a blurred average of possible interpretations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Parsing Code with tree-sitter
&lt;/h3&gt;

&lt;p&gt;tree-sitter is a parser library that produces concrete syntax trees for source code — every construct in the language has a named node with exact byte boundaries in the source. Unlike a regex-based approach, tree-sitter actually parses the grammar and handles edge cases correctly: nested functions, decorators on multiple lines, multiline function signatures, arrow functions in JavaScript, generic bounds in Rust.&lt;/p&gt;

&lt;p&gt;For Python, the tree-sitter query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scheme"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;function_definition&lt;/span&gt;
  &lt;span class="nv"&gt;name:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@name&lt;/span&gt;
  &lt;span class="nv"&gt;parameters:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parameters&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@params&lt;/span&gt;
  &lt;span class="nv"&gt;body:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@function&lt;/span&gt;

&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;class_definition&lt;/span&gt;
  &lt;span class="nv"&gt;name:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@name&lt;/span&gt;
  &lt;span class="nv"&gt;superclasses:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;argument_list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="nv"&gt;?&lt;/span&gt; &lt;span class="nv"&gt;@bases&lt;/span&gt;
  &lt;span class="nv"&gt;body:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;block&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;@class&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matches any function or class definition anywhere in the file and captures the name, parameters, and body as named nodes with precise byte-range positions. You can then slice the original source file at those byte positions to extract complete, syntactically valid chunks.&lt;/p&gt;

&lt;p&gt;For classes, Vectr attaches the full class signature — including the base class list captured by &lt;code&gt;@bases&lt;/code&gt; — as a header to each method chunk. So the chunk for &lt;code&gt;WorkspaceLock.acquire()&lt;/code&gt; includes its inheritance context. A method of &lt;code&gt;AuthenticatedView(LoginRequiredMixin, View)&lt;/code&gt; has a meaningfully different semantic context than a method of a plain &lt;code&gt;View&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A subtlety: very large functions.&lt;/strong&gt; AST-aware chunking breaks down for functions that are genuinely enormous — 500+ lines. Vectr handles this by further splitting large functions at their major control-flow boundaries (default threshold: 200 lines). The resulting sub-chunks each include the function signature as a header to preserve context. Their embedding quality is better than one giant embedding, though still lower than a naturally small function.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Code-Specific Embeddings Running Locally
&lt;/h3&gt;

&lt;p&gt;Not all embedding models are equally good at code. Models trained primarily on prose text have learned representations of natural language semantics. Code has different regularities: symbol names, type signatures, control flow patterns, API call chains. Code-aware models routinely outperform general-purpose models by 10–20% on tasks like "find the function that handles X."&lt;/p&gt;

&lt;p&gt;Vectr uses &lt;code&gt;Snowflake/snowflake-arctic-embed-m-v1.5&lt;/code&gt;, a 110-million-parameter model that produces 768-dimensional embedding vectors and runs in under 100ms per batch on a modern laptop CPU.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why local inference instead of an API?&lt;/strong&gt; Two practical constraints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cost: a tool that fires 20–50 search calls per session would accumulate non-trivial API costs quickly. Local inference is free at query time after the one-time model download.&lt;/li&gt;
&lt;li&gt;Data privacy: many codebases cannot be sent to third-party APIs. Internal tools, proprietary algorithms, customer data models — many organizations have policies or contractual obligations that prohibit sending source code to external services.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tradeoff: the model weighs roughly 440MB and needs to be downloaded on first run. This is a real friction point.&lt;/p&gt;

&lt;p&gt;One critical detail: queries and chunks are embedded with different input prefixes. Queries use &lt;code&gt;Represent this query for searching relevant code:&lt;/code&gt;, chunks use &lt;code&gt;Represent this code snippet:&lt;/code&gt;. arctic-embed-m is a single encoder, but it was trained with different prefixes for query-side and document-side inputs. Using the wrong prefix reduces the cosine similarity between semantically related query-chunk pairs — the vectors for "user authentication" and &lt;code&gt;verify_token&lt;/code&gt; end up further apart in embedding space than they should be. Getting this wrong costs 5–15% in retrieval quality.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3 — The Search Layer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Hybrid Search: Why BM25 and Vector Search Need Each Other
&lt;/h3&gt;

&lt;p&gt;Vector search handles concept queries well. But if you search for &lt;code&gt;_handle_workspace_lock_conflict&lt;/code&gt; — an exact function name — a vector search might not rank it first. The embedding is just one point in a crowded neighborhood of similar-looking function names. BM25, on the other hand, will find it immediately: exact string matches get the highest possible score.&lt;/p&gt;

&lt;p&gt;The inverse is also true: BM25 cannot find "retry logic with exponential backoff" if the function is called &lt;code&gt;_schedule_attempt_with_delay&lt;/code&gt; and its docstring says nothing about backoff. Zero keyword overlap means zero BM25 score. Vector search finds it because the semantic cluster it belongs to is close to the query in embedding space.&lt;/p&gt;

&lt;p&gt;The right system uses both. Every query in Vectr runs both a vector search and a BM25 search in parallel, then combines the two ranked lists using a weighted formula.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BM25 scoring formula:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score(D, Q) = Σᵢ IDF(qᵢ) · [ tf(qᵢ, D) · (k₁ + 1) ] / [ tf(qᵢ, D) + k₁ · (1 − b + b · |D| / avgdl) ]

IDF(qᵢ) = log( (N − nᵢ + 0.5) / (nᵢ + 0.5) )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tf(qᵢ, D)&lt;/code&gt; — term frequency of qᵢ in document D&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;N&lt;/code&gt; — total documents; &lt;code&gt;nᵢ&lt;/code&gt; — documents containing qᵢ&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;|D|&lt;/code&gt; — document length in tokens; &lt;code&gt;avgdl&lt;/code&gt; — average document length&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;k₁ = 1.5&lt;/code&gt; (term-frequency saturation), &lt;code&gt;b = 0.75&lt;/code&gt; (length normalization)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the Robertson–Sparck Jones variant. Some implementations add +1 inside the IDF log to prevent negative values for very common terms.&lt;/p&gt;

&lt;p&gt;The weight assigned to each approach depends on codebase familiarity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;BM25 weight&lt;/th&gt;
&lt;th&gt;Vector weight&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Large unfamiliar codebase&lt;/td&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Small familiar codebase&lt;/td&gt;
&lt;td&gt;0.7&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Explicit symbol name in query&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Natural language concept query&lt;/td&gt;
&lt;td&gt;0.2&lt;/td&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These weights are the actual values used in Vectr's implementation, tuned against the benchmark dataset.&lt;/p&gt;

&lt;p&gt;The benchmark on Apache Camel (58,000+ Java files) showed a &lt;strong&gt;73% reduction in Read+Bash navigation calls&lt;/strong&gt; compared to the baseline AI editor with no index.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Symbol Graph: What Text Search Cannot Answer
&lt;/h3&gt;

&lt;p&gt;Semantic search and BM25 handle "find me the code for this concept" well. But there's a different navigation pattern that neither handles: "find me everything that calls this function."&lt;/p&gt;

&lt;p&gt;Vectr builds a symbol graph during indexing. For each file, tree-sitter extracts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Definitions&lt;/strong&gt; — every function, class, method, and module-level constant with name and line number&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call edges&lt;/strong&gt; — every call site, mapping callee name to the calling function's context&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import edges&lt;/strong&gt; — every import statement, mapping the imported symbol to its likely source module&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP routes&lt;/strong&gt; — Flask/FastAPI &lt;code&gt;@router.get()&lt;/code&gt;, Express &lt;code&gt;app.post()&lt;/code&gt;, Spring &lt;code&gt;@GetMapping&lt;/code&gt; — extracted as named symbols&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The resulting graph enables exact lookups. &lt;code&gt;vectr_locate("WorkspaceLock")&lt;/code&gt; returns a file path and line number in under 10ms — no embedding, no ranking, pure symbol table lookup. &lt;code&gt;vectr_trace("acquire_lock")&lt;/code&gt; returns all callers and all callees in one round-trip. These are not search results — they are graph traversals, and they produce exact answers rather than relevance rankings.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Text search vs. graph traversal:&lt;/strong&gt; These are not competing approaches — they answer different questions. "Find code that does X" is a search problem. "Find who calls Y" or "find where Z is defined" is a graph traversal problem. Relying only on text search for definition lookups is like looking up a phone number by describing the person rather than looking them up by name.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Six Fallback Strategies in vectr_locate
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;vectr_locate&lt;/code&gt; runs six fallback strategies in sequence, stopping at the first match:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exact match&lt;/strong&gt; — direct lookup in the symbol table. Sub-millisecond. Highest confidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suffix match&lt;/strong&gt; — &lt;code&gt;Lock&lt;/code&gt; matches &lt;code&gt;WorkspaceLock&lt;/code&gt;, &lt;code&gt;AcquireLock&lt;/code&gt;, &lt;code&gt;LockManager&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same-module priority&lt;/strong&gt; — if a caller file is provided, search definitions within the same module first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique name&lt;/strong&gt; — if there is exactly one symbol across the entire codebase whose name contains your query string, return it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import chain follow&lt;/strong&gt; — follow import statements from a given file to find where the name likely comes from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuzzy (Levenshtein ≤ 2)&lt;/strong&gt; — edit distance ≤ 2 across all symbol names. Catches typos. Lowest confidence.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each strategy produces a &lt;code&gt;LocateResult&lt;/code&gt; with a &lt;code&gt;resolution_strategy&lt;/code&gt; field. An exact match means you can act on the result immediately. A fuzzy match with edit distance 2 means you should verify before relying on it. A silent wrong navigation is worse than no navigation at all.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4 — The Runtime Layer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  mtime Cache and Incremental Re-indexing
&lt;/h3&gt;

&lt;p&gt;The first time you run &lt;code&gt;vectr start&lt;/code&gt; on a large codebase, indexing takes time. CPython's 4,000+ files: about 8 minutes. Django's ~1,800 Python files: about 2 minutes. Apache Camel's 58,000+ Java files: closer to 45 minutes.&lt;/p&gt;

&lt;p&gt;During initial indexing, Vectr writes a file at &lt;code&gt;~/.cache/vectr/{hash}/index_cache.json&lt;/code&gt; that stores the modification timestamp of every indexed file. The &lt;code&gt;{hash}&lt;/code&gt; is a short SHA-256 hash of the absolute workspace root path. On subsequent runs, only files whose mtime has changed are re-indexed. On a typical active session where you've modified 5–10 files, subsequent re-indexing takes under 5 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Handling deletions:&lt;/strong&gt; Vectr also stores the complete set of indexed file paths. At startup, it diffs this set against the current file tree and removes all chunks belonging to deleted files before re-indexing modified ones. Process deletions first, then updates, then new files — this prevents a renamed file from leaving orphaned chunks in the index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The watchdog listener:&lt;/strong&gt; During an active session, Vectr runs a watchdog filesystem listener on the workspace root. When a file is saved, the listener queues it for re-indexing in the background. Events are debounced at 300ms — only the last write in a burst counts. Without debouncing, a single save in a project using aggressive auto-formatting would trigger 3–5 redundant re-index operations.&lt;/p&gt;

&lt;h3&gt;
  
  
  .vectrignore: Keeping the Index Clean
&lt;/h3&gt;

&lt;p&gt;Vectr reads a &lt;code&gt;.vectrignore&lt;/code&gt; file from the workspace root using glob patterns. The syntax follows &lt;code&gt;.gitignore&lt;/code&gt; conventions — trailing slash for directories, &lt;code&gt;*&lt;/code&gt; for single-level wildcard, &lt;code&gt;**&lt;/code&gt; for recursive match (via Python's &lt;code&gt;pathlib.Path.match()&lt;/code&gt;) — but Vectr does not implement the full gitignore specification: the &lt;code&gt;!&lt;/code&gt; negation prefix is not supported.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vendor/
node_modules/
dist/
*.pb.go        # generated protobuf Go files
*.min.js       # minified JavaScript
__pycache__/
.venv/
coverage/
*.snap         # Jest snapshots
migrations/    # Django database migrations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A codebase with &lt;code&gt;node_modules/&lt;/code&gt; will typically contain 5–20x more code from installed packages than from the project itself. Excluding vendor directories before the initial index run is the single most impactful configuration change most users can make.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Actually Happens When You Call vectr_search
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Query string is embedded using arctic-embed-m with query prefix
   → 768-dimensional float vector, ~15ms on CPU

2. Vector similarity search against ChromaDB store
   → Top-20 chunks by cosine similarity, with scores

3. Same query runs through BM25 index (rank-bm25, in-memory)
   → Top-20 chunks by BM25 score, with scores

4. Two ranked lists are merged
   → Weight BM25/vector based on codebase characterization
   → Normalized scores combined; top-N results selected (default N=5)

5. Symbol names in the query are detected (camelCase, snake_case, PascalCase)
   → If found: also run vectr_locate as a side channel
   → Merge symbol lookup results into final output if relevant

6. Final top-N chunks returned with:
   file path, start line, end line, matched text, search method
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result for &lt;code&gt;vectr_search("workspace lock acquisition and release")&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[1] resolver.rs:214 — WorkspaceLock::acquire()
    Acquires the workspace-scoped lock. Blocks if another process holds it.

[2] resolver.rs:267 — WorkspaceLock::release()
    Releases the workspace-scoped lock. Validates that the current process
    holds the lock before releasing (returns Err if not held).

[3] workspace.py:89 — _acquire_workspace_lock(path)
    Context manager: acquires, yields, releases on exit.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of reading 15 files to find these three functions, the AI editor reads one search result.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5 — Design Decisions I'd Make Differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Python 3.14 requirement.&lt;/strong&gt; The codebase uses &lt;code&gt;match/case&lt;/code&gt; pattern matching extensively and some &lt;code&gt;asyncio&lt;/code&gt; patterns that behave differently in earlier versions. In retrospect, 3.11 would probably work with a few hours of refactoring. The 3.14 requirement has been the single biggest adoption friction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ChromaDB as the vector store.&lt;/strong&gt; A vector store handles embedding persistence and similarity search. ChromaDB works, but the full HNSW index with persistence, the Python client layer, and the inter-process communication overhead add about 200ms specifically to ChromaDB's startup contribution — not total Vectr startup (~280ms including mtime diffing and watchdog initialization). For v2, I'd consider a lighter in-process option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The BM25 implementation.&lt;/strong&gt; The &lt;code&gt;rank-bm25&lt;/code&gt; library is pure Python and fast enough for codebases under 50,000 chunks. Beyond that, it starts to show latency. The right long-term solution is integrating BM25 scoring directly into the vector store query pipeline. For current use cases (most codebases are under 20K chunks), it's fine.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The indexing layer is the foundation, not the product. What it enables is an AI code editor that can navigate a large unfamiliar codebase as efficiently as a human engineer who has worked in it for months — finding the right functions in one or two calls instead of fifteen.&lt;/p&gt;

&lt;p&gt;But the index tells you &lt;em&gt;where things are&lt;/em&gt;. It doesn't tell you &lt;em&gt;why things are the way they are&lt;/em&gt; — the non-obvious invariants, the patterns that emerge from reading 50 files, the bugs that were fixed by changing two lines in a place that looks completely unrelated.&lt;/p&gt;

&lt;p&gt;That's what Part 2 addresses: a note store where an AI editor can save findings in structured, tagged form — "the lock acquisition logic is at resolver.rs:214, and it acquires an exclusive file lock using fcntl.flock, not a threading primitive" — and retrieve them in under 50ms at the start of any future session. When &lt;code&gt;/compact&lt;/code&gt; runs and replaces the conversation with a summary, exact signatures and line numbers evaporate — but notes don't. The indexer tells you where to look. The working memory layer tells you what you already know about what you found.&lt;/p&gt;

&lt;h3&gt;
  
  
  Summary of core decisions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Rationale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AST-aware chunking via tree-sitter&lt;/td&gt;
&lt;td&gt;Complete functions as the unit of meaning. Biggest quality improvement over naive line windows.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local embeddings (arctic-embed-m)&lt;/td&gt;
&lt;td&gt;No API cost, no data leaving the machine. One-time 440MB download.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hybrid BM25 + vector search&lt;/td&gt;
&lt;td&gt;Concept queries route to vector. Exact symbol names route to BM25.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Symbol graph&lt;/td&gt;
&lt;td&gt;Definitions, call edges, import edges, HTTP routes — exact graph traversal for questions text search cannot answer.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Six fallback strategies in vectr_locate&lt;/td&gt;
&lt;td&gt;Exact → suffix → same_module → unique_name → import_chain → fuzzy. Each result carries its resolution strategy.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mtime cache + watchdog&lt;/td&gt;
&lt;td&gt;Sub-5-second re-indexing on subsequent runs. In-session saves trigger background re-indexing automatically.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>mcp</category>
      <category>semanticsearch</category>
      <category>developertools</category>
      <category>codeindexer</category>
    </item>
    <item>
      <title>LLM Context Window Token Budget: Why Your Window Fills Up Fast</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Tue, 26 May 2026 19:59:18 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/llm-context-window-token-budget-why-your-window-fills-up-fast-4c05</link>
      <guid>https://dev.to/swapnanilsaha/llm-context-window-token-budget-why-your-window-fills-up-fast-4c05</guid>
      <description>&lt;p&gt;You build something with GPT-4o. The model supports 128,000 tokens. You think: that's enough for a full novel. Then, four or five conversation turns in, the model starts forgetting things that were said earlier. Eight turns in, you hit an error. You check the token count — you've used over 100,000 tokens, and you've typed maybe 400 words.&lt;/p&gt;

&lt;p&gt;This isn't a bug. It's the predictable consequence of not accounting for where those tokens actually go. A context window isn't blank space waiting to be filled with your words. By the time the first user message arrives, it is already partially consumed — by system instructions, by tool definitions, by retrieved documents, by the tokens the model itself generated in earlier turns. In a production AI agent, 30–60% of the context window is gone before a user types anything.&lt;/p&gt;

&lt;p&gt;What follows is a precise accounting of where those tokens go — the four layers that consume the window before users say anything, why the effective limit is substantially lower than the advertised one, what happens to response quality as the window approaches capacity, and which engineering patterns actually manage it at production scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The Problem
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The Illusion of Abundance
&lt;/h3&gt;

&lt;p&gt;GPT-4o supports 128K tokens. Claude 3.5 supports 200K. Gemini 1.5 Pro has been demonstrated at a million tokens — roughly 750,000 words, about ten average novels. The numbers sound absurdly generous. How could you possibly run out?&lt;/p&gt;

&lt;p&gt;Start with a calibration exercise. What is 128,000 tokens, actually?&lt;/p&gt;

&lt;p&gt;In English prose, one token is roughly four characters — about three-quarters of a word. A 1,000-word article runs to around 1,300 tokens, so 128K tokens can hold close to 96,000 words of clean text. That genuinely is a lot.&lt;/p&gt;

&lt;p&gt;But text in an LLM application is rarely clean English prose. It is JSON payloads from tool calls. It is API responses full of structured data. It is code. It is URLs. It is conversation history with speaker labels, timestamps, and formatting. All of these serialize into tokens at rates much higher than 4 characters per token.&lt;/p&gt;

&lt;p&gt;Then there is the question of performance. The advertised number represents a technical limit — the longest sequence the model can physically process. It does not represent the length at which the model operates at peak accuracy. Research has repeatedly found a significant gap between the two. Long-context benchmarks like RULER (2024) and HELMET (2024) found that in adversarial multi-document tasks, most frontier LLMs showed accuracy drops well before 32K tokens — GPT-4o fell from near-perfect baseline scores to the high-60s percentage range at 32K in some configurations. The technical limit says 128K. The accuracy cliff arrives much earlier.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Effective Limit Is Not the Advertised Limit&lt;/strong&gt;&lt;br&gt;
Models claiming 200K context windows show measurable quality degradation around 130K tokens in practice. Treating the advertised number as your operating budget is how production systems quietly degrade without triggering any explicit error.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cost is the third angle. Every token in the context is a token billed. At GPT-4o's pricing, 128K tokens of input costs several dollars per call — and agents often make dozens of calls per session, each with the full accumulated context. The monthly bill from a badly-managed context window can surprise you well before any error appears in the logs.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. How Tokens Are Counted — and Why the Count Surprises You
&lt;/h3&gt;

&lt;p&gt;An LLM does not read text. It reads a sequence of integers. Before any word reaches the model, it passes through a tokenizer that converts characters into integer IDs from a vocabulary of roughly 50,000–200,000 entries. The tokenizer used by GPT-4 and GPT-4o is called &lt;code&gt;cl100k_base&lt;/code&gt;; it has about 100,000 vocabulary entries. OpenAI's newer models use &lt;code&gt;o200k_base&lt;/code&gt;, with about 200,000.&lt;/p&gt;

&lt;p&gt;The vocabulary is built using &lt;strong&gt;BPE&lt;/strong&gt; — Byte Pair Encoding. The name comes from the construction: you start with individual characters, then repeatedly merge the pair of adjacent symbols that appears most often in your training corpus, replacing each occurrence of that pair with a new combined token. Do this enough times and common English words end up as single tokens. The algorithm learns what to merge entirely from what was common in the training text — mostly English prose on the internet. That's why "the", "is", "running" each become a single token, while "tokenization" becomes &lt;code&gt;["token", "ization"]&lt;/code&gt; — less common as a whole word, so BPE never fully merged it. Characters and raw bytes are the fallback for anything the vocabulary doesn't cover. The consequence is simple: anything that wasn't well-represented in training data — JSON brackets, URL slashes, code indentation — never got merged aggressively, so those sequences remain expensive in tokens relative to the characters they contain.&lt;/p&gt;

&lt;p&gt;The rule-of-thumb of 1 token ≈ 4 characters holds for clean English prose — decent enough for napkin estimates. It falls apart under several conditions that appear constantly in real applications:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Numbers tokenize unexpectedly.&lt;/strong&gt; BPE learns tokens from frequency in training data. The number "2023" is common in training data — it became a single token. But "2026" is less common, and "19847" is rare — these get split into per-digit or per-pair tokens. The price "USD 1,234,567.89" produces approximately 10–12 tokens, because the commas, period, digits, and currency symbol may each claim separate tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;URLs are disproportionately expensive.&lt;/strong&gt; A URL like &lt;code&gt;https://api.example.com/v2/users/12345&lt;/code&gt; looks compact — 38 characters, which by the prose rule should be about 9–10 tokens. In practice it is closer to 15–20 tokens. Slashes, dots, hyphens, underscores, and alphanumeric path segments each claim their own tokens or merge into small fragments, because URLs are structurally uncommon in prose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;JSON and structured data use roughly 2x the token count of plain text.&lt;/strong&gt; Consider:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plain text: The user's name is Alice, she is 28 years old, and her account is active.
JSON:       {"user": {"name": "Alice", "age": 28, "status": "active"}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plain text version: approximately 18 tokens. The JSON version: approximately 22 tokens — and this is a trivially small object. Real API responses with deeply nested keys, repeated field names, and verbose formatting can be far more expensive. Every brace, colon, and comma is a token or part of a token. A 500-word JSON payload can use 800+ tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code tokenizes inefficiently in some languages.&lt;/strong&gt; Research found that Python uses roughly 46% more tokens than equivalent Haskell to express the same computational idea. Python's indentation-based structure requires whitespace tokens, and Python's identifiers were less densely represented in the pre-GPT-4 training corpora.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy: The Luggage Weight Problem&lt;/strong&gt;&lt;br&gt;
Think of the context window as checked baggage with a weight limit, not a size limit. A suitcase full of dense sweaters weighs less than one with foam packing material filling the same volume. Plain prose is the dense sweaters — you pack a lot of meaning into few tokens. JSON, URLs, and code are the foam — structurally bulky, meaning-sparse, yet they count toward the same limit.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 2: The Consumers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3. The Four Layers That Eat Your Context Window
&lt;/h3&gt;

&lt;p&gt;Every LLM API call is a full context payload assembled from four distinct layers. Most developers think about only one: the user's current message. The other three arrive already loaded — silent costs that accumulate before the user types anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: The System Prompt&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The system prompt is the foundational layer. It is always present, on every API call. A minimal system prompt — "You are a helpful assistant" — costs about 7 tokens. But real production system prompts are not minimal.&lt;/p&gt;

&lt;p&gt;A typical customer-facing chatbot system prompt contains: the model's persona and tone guidelines, a list of topics it should and should not address, instructions about response format, domain-specific knowledge, legal disclaimers, and formatting instructions. Measured in practice, these range from 800 to 2,500 tokens. They are charged on every single API call. A 1,500-token system prompt running 1,000 calls per day costs you 1.5 million input tokens per day before a user says anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Tool Schemas&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When you give an LLM access to external tools, you must describe each tool to the model in the context window. These descriptions are written in JSON and can be verbose. A single moderately documented tool schema costs roughly 200 tokens. An agent with five tools carries around 1,000 tokens of tool descriptions on every call, before any user input. The JSON structure alone — all those braces, colons, and quoted keys — is part of why the token cost is higher than reading the description would suggest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: Retrieved Context (RAG)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Many production LLM applications retrieve relevant documents from a database and inject them as supporting material. A typical RAG retrieval returns 3–8 document chunks, each 300–600 tokens. Three chunks at 400 tokens each: 1,200 tokens. Eight chunks at 500 tokens each: 4,000 tokens. In a research assistant with a generous retrieval budget, you might inject 8,000–12,000 tokens of context per query.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The Hidden Fixed Cost&lt;/strong&gt;&lt;br&gt;
System prompt + tool schemas is your fixed cost floor. It doesn't change turn-to-turn. It can easily reach 2,000–4,000 tokens in a real agent — charged on every single API call in your fleet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Layer 4: Conversation History&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The model has no persistent memory. You create the illusion of memory by re-sending the full conversation history on every API call. Every turn appends two new entries (a user message and a model response) to a history that is re-sent in its entirety. Model responses can be long — a detailed answer with a code snippet might be 600–800 tokens. After ten exchanges, the conversation history alone can be 8,000–12,000 tokens.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. Context Creep — Watching the Window Fill
&lt;/h3&gt;

&lt;p&gt;The process by which a context window fills over a conversation has a name in production systems: &lt;strong&gt;context creep&lt;/strong&gt;. Consider a realistic customer support agent: 1,200-token system prompt, three tool schemas totaling 600 tokens, RAG retrieval returning two chunks (~800 tokens per turn), user messages averaging 60 tokens, model responses averaging 350 tokens.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Context budget:
  Fixed overhead: 1,200 + 600 = 1,800 tokens
  Per-turn RAG:   800 tokens
  Per-turn history growth: 60 (user) + 350 (model) = 410 tokens

  Turns until 80% of 128K:
    (1,800 + n × 800 + n × 410) ≥ 102,400
    n × 1,210 ≥ 100,600
    n ≈ 84 turns

  If model reply averages 800 tokens instead:
    Per-turn growth: 60 + 800 = 860
    n × 1,660 ≥ 100,600
    n ≈ 60 turns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change the model reply length to 800 tokens — a detailed-answer agent — and the window hits 80% around turn 60 rather than 84. Quality degradation begins before you hit the hard limit.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: The Physics
&lt;/h2&gt;

&lt;h3&gt;
  
  
  5. KV Cache Memory — Why Context Has a Physical Cost
&lt;/h3&gt;

&lt;p&gt;The context window limit is not an arbitrary policy. It is enforced by physics — GPU memory.&lt;/p&gt;

&lt;p&gt;The transformer's attention mechanism works by comparing every token in the context with every other token. For each token, the model creates a query ("what am I looking for?"), and every other token offers a key ("what do I contain?"). A third vector — the value — carries the actual information that gets passed when attention is high. Assembled across all tokens, these become the matrices &lt;strong&gt;Q&lt;/strong&gt;, &lt;strong&gt;K&lt;/strong&gt;, and &lt;strong&gt;V&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attention(Q, K, V) = softmax(QKᵀ / √d_k) · V&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The QKᵀ product is an n × n matrix where n is the sequence length. Doubling n quadruples this computation.&lt;/p&gt;

&lt;p&gt;There are two distinct computational phases in LLM inference. &lt;strong&gt;Prefill&lt;/strong&gt; processes the entire input prompt at once — O(n²) per attention layer. Implementations like FlashAttention reduce the memory bandwidth pressure dramatically via tiled computation, but the asymptotic complexity doesn't change. &lt;strong&gt;Decode&lt;/strong&gt; generates one token at a time, attending only to the current token against the cached history — O(n) per step with the KV cache. Without caching, decode would also be O(n²). The KV cache converts decode from O(n²) to O(n) at the cost of memory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KV Cache Memory Formula (Multi-Head Attention):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;KV_memory = 2 × n_layers × n_heads × d_head × seq_len × bytes_per_param
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a 7B-parameter model with standard MHA (32 layers, 32 heads, head_dim 128) at bfloat16 (2 bytes):&lt;/p&gt;

&lt;p&gt;KV_memory per token ≈ 2 × 32 × 32 × 128 × 1 × 2 = 524,288 bytes ≈ 0.5 MB&lt;/p&gt;

&lt;p&gt;At 128K context: 0.5 MB × 128,000 = &lt;strong&gt;64 GB&lt;/strong&gt; of KV cache alone — more than the model weights at bfloat16 (~14 GB).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note on GQA and MLA:&lt;/strong&gt; Most modern models (Llama 3, Mistral, GPT-4o) use Grouped-Query Attention (GQA), which reduces the KV cache by sharing key-value heads across groups of query heads. A model with 32 query heads and 8 KV heads (4× reduction) brings the per-token cache from ~0.5 MB to ~0.125 MB — about 16 GB at 128K context. Still the dominant memory consumer at long contexts. DeepSeek-class models use Multi-head Latent Attention (MLA), which compresses the K and V projections into a low-rank latent space before storing them, achieving 5–10× memory reduction over standard MHA.&lt;/p&gt;

&lt;p&gt;A 70B MHA model (80 layers, 64 heads, head_dim 128, bfloat16) runs to roughly &lt;strong&gt;2.5 MB per token&lt;/strong&gt;: 2 × 80 × 64 × 128 × 2 bytes = 2,621,440 bytes. At 128K context that's ~320 GB — which is why providers either cap context length aggressively for large models, or charge steeply for long-context calls. GQA with 8 KV heads drops it to ~40 GB, still substantial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt caching&lt;/strong&gt; (available from OpenAI, Anthropic, Google) caches the computed KV activations for repeated prompt prefixes. Subsequent calls beginning with the same prefix pay 50–75% less for those cached tokens and benefit from lower latency because the prefill phase for the cached portion is skipped. A stable system prompt is an ideal caching candidate. One practical constraint: both OpenAI and Anthropic require a minimum prefix length of at least 1,024 tokens before caching activates. A 200-token system prompt won't benefit — another reason to consolidate instructions into one substantial block rather than spreading them across multiple small messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KV cache quantization&lt;/strong&gt; is an active area of production optimization: storing the K and V tensors in lower-precision formats (int8 or int4) cuts KV cache memory by 2–4× with modest accuracy penalties. Research like KVQuant explores going to 2-bit precision for certain layers while targeting 10M-token contexts on commodity hardware.&lt;/p&gt;




&lt;h3&gt;
  
  
  6. Lost in the Middle — Why Performance Collapses Before You Hit the Limit
&lt;/h3&gt;

&lt;p&gt;Memory is the first constraint. Attention quality is the second — and it bites you even when your window is half-empty.&lt;/p&gt;

&lt;p&gt;In 2023, researchers at Stanford and UC Berkeley published "Lost in the Middle." They gave LLMs a task requiring them to find a specific document from a set of twenty documents, all injected into the context window. The position of the relevant document was varied systematically.&lt;/p&gt;

&lt;p&gt;When the relevant document was first or last, models retrieved it accurately. When it was in the middle positions, accuracy dropped by more than 30%. Newer models — Claude 3.5, GPT-4o — have partially mitigated this bias through long-context fine-tuning. "Partially" is doing a lot of work there: independent evaluations continue to find meaningful position-dependent performance gaps in all current models, even at lengths well within their advertised limits.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy: The Lecture Hall Effect&lt;/strong&gt;&lt;br&gt;
Students reliably remember a lecture's opening and closing. What happened in the middle of hour one is murky. LLMs have an analogous concentration pattern: strong attention to the beginning and end of the context, with a trough in the middle.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The mechanism is structural. RoPE (Rotary Position Embedding), used in most modern architectures, encodes position as a rotation applied to query and key vectors. The mathematical property of this rotation is that the similarity score between two vectors naturally decreases as the distance between their positions increases. At short contexts, the decay is a feature. At long contexts, it becomes a bug: tokens in the middle of a 100K-token window are thousands of positions away from both the beginning and from where the model is currently generating, so their similarity scores are systematically suppressed.&lt;/p&gt;

&lt;p&gt;A separate effect, &lt;strong&gt;context dilution&lt;/strong&gt;, compounds this: longer surrounding irrelevant context degrades performance even when the relevant content is guaranteed present. The model's attention distributes across noise, reducing effective attention for the signal — like finding one red marble in a bag of ten thousand, even knowing it's there.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A Subtle RAG Bug&lt;/strong&gt;&lt;br&gt;
If your RAG system retrieves 8 documents and inserts them in the middle of a long conversation history, the most relevant chunks may be in the attention trough. The model generates a response, you see no error, but the answer doesn't reflect those documents. The failure is silent.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Part 4: Solutions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  7. Token Budget Math — Calculating Your Real Available Space
&lt;/h3&gt;

&lt;p&gt;Every LLM application needs an explicit token budget with five zones:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Zone&lt;/th&gt;
&lt;th&gt;Typical token range&lt;/th&gt;
&lt;th&gt;Fixed or variable?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;System Prompt&lt;/td&gt;
&lt;td&gt;500–2,500&lt;/td&gt;
&lt;td&gt;Fixed per application&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool Schemas&lt;/td&gt;
&lt;td&gt;200–400 per tool&lt;/td&gt;
&lt;td&gt;Fixed per agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG Context&lt;/td&gt;
&lt;td&gt;0–12,000&lt;/td&gt;
&lt;td&gt;Variable per turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conversation History&lt;/td&gt;
&lt;td&gt;0 → grows&lt;/td&gt;
&lt;td&gt;Grows each turn&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generation Reserve&lt;/td&gt;
&lt;td&gt;500–2,000&lt;/td&gt;
&lt;td&gt;Reserved explicitly&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The generation reserve must be reserved explicitly — if your prompt consumes the entire window, the model either generates nothing or truncates its response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A worked example.&lt;/strong&gt; Customer support agent, GPT-4o (128K):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Total window:          128,000 tokens
System prompt:          -1,400 tokens  (measured)
Tool schemas (4 tools):   -800 tokens  (measured)
Generation reserve:     -1,500 tokens  (set by us)
─────────────────────────────────────────
Available for dynamic:  124,300 tokens

  Of that:
    RAG budget:           20,000 tokens  (5 chunks × 4,000 avg)
    History budget:       ~104,300 tokens (fills over time)

  ─────────────────────────────────────────
  Turns until 80% full:
    80% of 128K = 102,400 prompt tokens
    Fixed overhead = 1,400 + 800 = 2,200
    Per-turn RAG = 800
    Per-turn growth = user avg (60) + model avg (350) = 410
    Turns until (2,200 + n × 800 + n × 410) ≥ 102,400
    n × 1,210 ≥ 100,200
    n ≈ 82 turns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;82 turns sounds comfortable. But this assumes constant 350-token model replies. A user who triggers several detailed answers can double the history growth rate, cutting that to ~41 turns before the 80% threshold.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Measure, Don't Estimate&lt;/strong&gt;&lt;br&gt;
The system prompt and tool schema token counts must be measured with the actual tokenizer, not estimated from character counts. Log &lt;code&gt;prompt_tokens&lt;/code&gt; and &lt;code&gt;completion_tokens&lt;/code&gt; from every API response. The distribution of &lt;code&gt;prompt_tokens&lt;/code&gt; over time is your context growth curve.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  8. Four Strategies for Managing Context Window Limits
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Strategy 1: Sliding Window&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep only the most recent turns of conversation verbatim. In production, truncate by token count, not turn count — a 5-turn history could range from 500 to 8,000 tokens depending on response lengths.&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="c1"&gt;# Turn-count version — simple, good enough for prototyping
&lt;/span&gt;&lt;span class="n"&gt;MAX_HISTORY_TURNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;trimmed_history&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;MAX_HISTORY_TURNS&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;system&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="n"&gt;system_prompt&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;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;context_block&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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;system&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Context:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;context_block&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;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed_history&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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="n"&gt;new_message&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;messages&lt;/span&gt;

&lt;span class="c1"&gt;# Production version — truncate by token count, not turn count
# HISTORY_TOKEN_BUDGET = context_limit - fixed_costs - generation_reserve
# Example for 128K window: 128000 - 2200 (sys+tools) - 1500 (reserve) - 20000 (RAG) ≈ 104000
&lt;/span&gt;&lt;span class="n"&gt;HISTORY_TOKEN_BUDGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40_000&lt;/span&gt;  &lt;span class="c1"&gt;# adjust for your application
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_messages_token_bounded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;fixed_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;new_msg_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HISTORY_TOKEN_BUDGET&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;fixed_tokens&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;new_msg_tokens&lt;/span&gt;

    &lt;span class="c1"&gt;# Walk history from newest to oldest, keep what fits
&lt;/span&gt;    &lt;span class="n"&gt;trimmed_rev&lt;/span&gt; &lt;span class="o"&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;turn&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;turn_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;count_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;turn_tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;trimmed_rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;remaining&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;turn_tokens&lt;/span&gt;
    &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed_rev&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;system&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="n"&gt;system_prompt&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;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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;system&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;Context:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rag_chunks&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&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="n"&gt;new_message&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;messages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The drawback of the sliding window is abrupt forgetting: when turn 1 drops, any fact established there is simply gone. For short-lived task-completion agents, this is fine. For long-running conversational assistants, it creates visible gaps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strategy 2: Hierarchical Summarization&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keep recent turns verbatim; compress older turns into a rolling summary.&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;maybe_compress_history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;buffer_size&lt;/span&gt;&lt;span class="o"&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;verbatim_turns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;buffer_size&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="n"&gt;turns_to_summarize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;buffer_size&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;turns_to_summarize&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;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;

    &lt;span class="n"&gt;new_summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&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;Existing summary: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&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;New exchanges to incorporate:&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;format_turns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;turns_to_summarize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Update the summary to include these exchanges. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Preserve all concrete facts, decisions, and commitments. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Drop conversational filler. Be dense. Max ~400 tokens.&lt;/span&gt;&lt;span class="sh"&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;verbatim_turns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_summary&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cap the summary at 200–400 tokens. Run summarization asynchronously — don't make the user wait for the compression cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strategy 3: Token Compression (LLMLingua)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use a compression model to identify and remove low-entropy tokens from prompts, achieving 2–3× compression with minor accuracy loss. The most effective targets are verbose system prompts, RAG context chunks, and few-shot examples.&lt;/p&gt;

&lt;p&gt;Never apply compression to the current user message — compressing user input changes their meaning before the model sees it. Test in your specific domain for tasks where precision matters (legal, medical, code).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strategy 4: Embedding-based Retrieval Over History&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Store each conversation turn as a dense vector. At each new turn, embed the current user message and retrieve the most relevant prior turns by similarity. Concretely: as each turn completes, embed the user + assistant text and store it in a vector store alongside the full text. On the next user message, embed it, search for top-k similar turns, inject those into context. Keep only 2–3 verbatim recent turns for coherence.&lt;/p&gt;

&lt;p&gt;The effect: only the conversation history relevant to the current question enters the context window. A user asking "what was the budget we discussed?" triggers retrieval of those turns — even if they happened fifty exchanges ago. This requires an embedding model, a vector store, and a retrieval call per user message (adding roughly 50–150ms round-trip with a managed API, under 10ms with a self-hosted model).&lt;/p&gt;

&lt;p&gt;The four strategies are not mutually exclusive. Production systems often combine them: a sliding window of 5–8 verbatim turns + rolling summary + retrieval from older history covers all distance scales simultaneously.&lt;/p&gt;




&lt;h3&gt;
  
  
  9. The Practical Playbook
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Short task-completion agents (under 20 turns):&lt;/strong&gt; Use a sliding window of 10–15 turns. Reserve optimization effort for fixed-cost reduction: audit your system prompt for redundant language, consider dynamic tool registration (load only the tools relevant to the current turn).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long-running conversational assistants:&lt;/strong&gt; Implement hierarchical summarization with 8–12 verbatim turns. Cap summaries at 400 tokens. Run asynchronously. Periodically audit system prompt size — prompt creep through edits is real. A prompt that started at 600 tokens can quietly grow to 3,000 across six months of product changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Document-heavy research assistants (heavy RAG):&lt;/strong&gt; Limit retrieval to 3–5 top chunks. Apply token compression to chunks before injection. Sort retrieved chunks so the most relevant appears last in the injected block — adjacent to the user question, within the recency attention peak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production agents with many tools:&lt;/strong&gt; Use dynamic tool registration. A routing classifier (even a keyword matcher) identifies which tools are needed before the main model call and includes only those schemas — reducing 2,000 tokens of tool overhead to ~400 on most turns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context ordering (exploit the attention curve):&lt;/strong&gt; Instead of the framework default (system → history → RAG → user), use: system → recent history (most-recent last) → RAG chunks (most relevant last, adjacent to the user message) → current user message. The most relevant content sits at the end of the context, within the recency attention peak. Older history — the least relevant content — occupies the lower-attention middle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to monitor:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;prompt_tokens / context_limit&lt;/code&gt; — alert above 70%, act above 80%&lt;/li&gt;
&lt;li&gt;Token count by zone per call — when total grows, know which zone is responsible&lt;/li&gt;
&lt;li&gt;Quality signals segmented by context utilization — you may find degradation starts at 60% in your application&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Conclusion: The Window Is a System Resource
&lt;/h2&gt;

&lt;p&gt;A context window isn't a document store you fill until it overflows. It's a compute and memory resource with hard physical limits, a quality curve that degrades well before those limits, and an inference cost that grows with every token you put in it.&lt;/p&gt;

&lt;p&gt;In a typical agent, the window is 30–60% consumed before the first user message lands. The fix isn't a bigger context window, though headroom helps. It's building a real budget: measure each zone with an actual tokenizer, set hard limits per zone, implement a context manager that enforces those limits on every call, and track utilization in production dashboards the same way you'd track memory or CPU.&lt;/p&gt;

&lt;p&gt;The attention degradation problem — "lost in the middle" — adds a second dimension: even when your window is not full, quality depends on where in the window the important information sits. The primacy bias and recency bias are real, measurable effects that application design can exploit or fall victim to.&lt;/p&gt;

&lt;p&gt;The four strategies aren't competitors — most production systems end up combining them. Sliding window for the recent turns, rolling summary for the older ones, compression for the RAG chunks, and retrieval for anything that needs to survive beyond the window. Start with the simplest thing that doesn't break your use case, and add layers as your traffic and conversation length grow.&lt;/p&gt;

&lt;p&gt;Context engineering doesn't have the glamour of prompt engineering, but it's where most production LLM failures actually live. Missed retrievals, incoherent multi-turn conversations, bloated inference bills — these trace back to context mismanagement more often than they trace back to the wrong model. It fails silently, which is exactly why it's easy to ignore until you can't.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Research Papers&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Liu et al., &lt;a href="https://arxiv.org/abs/2307.03172" rel="noopener noreferrer"&gt;"Lost in the Middle: How Language Models Use Long Contexts"&lt;/a&gt; — Stanford / Berkeley / Samaya AI, 2023. The original paper quantifying the U-shaped attention bias across context positions.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2406.16008" rel="noopener noreferrer"&gt;"Found in the Middle: Calibrating Positional Attention Bias"&lt;/a&gt; — 2024. Proposes an architectural fix to the lost-in-the-middle problem, recovering up to 15pp accuracy.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2510.05381" rel="noopener noreferrer"&gt;"Context Length Alone Hurts LLM Performance Despite Perfect Retrieval"&lt;/a&gt; — 2025. Demonstrates context dilution: longer irrelevant context degrades performance even when the relevant content is guaranteed present.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://arxiv.org/abs/2401.18079" rel="noopener noreferrer"&gt;"KVQuant: Towards 10M Context Length LLM Inference with KV Cache Quantization"&lt;/a&gt; — 2024. Explores per-channel quantization of the KV cache to enable extreme context lengths on commodity hardware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Technical References&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://platform.openai.com/docs/guides/conversation-state" rel="noopener noreferrer"&gt;OpenAI — Managing Conversation State&lt;/a&gt; — Official docs on conversation history management and token counting.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/context-windows" rel="noopener noreferrer"&gt;Anthropic — Context Window Documentation&lt;/a&gt; — Claude context limits, caching strategies, and best practices.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/microsoft/LLMLingua" rel="noopener noreferrer"&gt;LLMLingua — Prompt Compression&lt;/a&gt; — Microsoft Research open-source project for token-level prompt compression.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://mbrenndoerfer.com/writing/kv-cache-memory-calculation-llm-inference-gpu" rel="noopener noreferrer"&gt;KV Cache Memory: Calculating GPU Requirements for LLM Inference&lt;/a&gt; — Interactive calculator for KV cache memory requirements given model architecture parameters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Background Reading&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://tianpan.co/blog/2025-11-11-managing-token-budgets-production-llm-systems" rel="noopener noreferrer"&gt;The Hidden Costs of Context: Managing Token Budgets in Production LLM Systems&lt;/a&gt; — TianPan.co, 2025. Production-focused survey of context management challenges.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://redis.io/blog/context-window-management-llm-apps-developer-guide/" rel="noopener noreferrer"&gt;Context Window Management for LLM Apps: Developer Guide&lt;/a&gt; — Redis, 2025. Practical implementation patterns for context management in production.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://swapnanilsaha.com/blog/text-embeddings-llms-rag-complete-guide/" rel="noopener noreferrer"&gt;The Complete Guide to Text Embeddings, Vector Databases &amp;amp; LLMs&lt;/a&gt; — Swapnanil Saha, 2026. Deep background on tokenization, BPE, transformer attention, and RAG pipelines referenced throughout this post.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
    </item>
    <item>
      <title>Why AI Code Assistants Waste Context — and How RAG Fixes It</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Tue, 26 May 2026 19:25:33 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/why-ai-code-assistants-waste-context-and-how-rag-fixes-it-1gej</link>
      <guid>https://dev.to/swapnanilsaha/why-ai-code-assistants-waste-context-and-how-rag-fixes-it-1gej</guid>
      <description>&lt;p&gt;Open a large file in your AI code assistant and ask it to refactor a function buried three hundred lines down. Watch it confidently produce something plausible but wrong — using an interface that was deprecated last sprint, calling a helper that doesn't exist in this service, ignoring a constraint in the module-level docstring that it technically "saw." The model didn't forget. The information was technically present in the prompt, but the transformer's attention mechanism never meaningfully focused on it. That's a different kind of failure, and it doesn't get better with a bigger context window.&lt;/p&gt;

&lt;p&gt;There's a persistent intuition in this industry that more context is always better. Send the whole file. Send the whole codebase. This intuition breaks in a specific and measurable way. The mechanism is called &lt;strong&gt;attention dilution&lt;/strong&gt; — softmax normalization means that every token in the context competes for a fixed budget of attention weight, and as the sequence grows longer, any given piece of information gets a smaller share of that budget.&lt;/p&gt;

&lt;p&gt;This post walks through the transformer attention math to explain exactly why the naive approach fails, then covers how RAG (Retrieval-Augmented Generation) addresses it — by retrieving only the specific code chunks relevant to the current task and injecting those into the context window instead of dumping everything.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The Problem with Stuffing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The Naive Approach: Just Send Everything
&lt;/h3&gt;

&lt;p&gt;The first instinct when building a code assistant is to send as much context as possible. Your project has a utility module? Include it. There's a shared type definitions file? Throw that in too. If the model's context window is 128,000 tokens, fill it to the brim — more information has to be better, right?&lt;/p&gt;

&lt;p&gt;This is called &lt;strong&gt;context window stuffing&lt;/strong&gt;. Three things go wrong with it, and each gets worse as the codebase grows. The first is attention dilution — the focus of this section. The second is position bias (Section 3). The third is raw cost (Section 4). To understand why these happen, you need a concrete model of how a transformer actually reads a prompt.&lt;/p&gt;

&lt;p&gt;A transformer does not read a prompt sequentially, the way a human reads a page from left to right. Instead, it processes all tokens &lt;em&gt;simultaneously&lt;/em&gt;, and every token attends to every other token in the sequence. The attention mechanism is the machine that computes how much each token should "look at" every other token when forming its representation.&lt;/p&gt;

&lt;p&gt;The output of attention for a single token is a weighted average of all the other tokens' value vectors. The weights are computed by comparing the current token's &lt;em&gt;query&lt;/em&gt; vector against every other token's &lt;em&gt;key&lt;/em&gt; vector. When you add more tokens to the context, you are not adding more information to a receptive mind — you are adding more competitors for a fixed budget of attention weight.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Analogy:&lt;/strong&gt; Imagine you are in a room full of people, all talking at once. You can only pay 100 percent of your attention total — it does not grow with the number of people. With 5 people in the room, each gets roughly 20% of your focus. With 500, each gets 0.2%. When the relevant person finally says something, their share of your attention has collapsed to noise. That is what happens to code buried in a long prompt.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  2. Why Attention Dilutes: The Math
&lt;/h3&gt;

&lt;p&gt;The attention mechanism was introduced in the paper "Attention Is All You Need" (Vaswani et al., 2017). Its core computation is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attention(Q, K, V) = softmax( QK^T / √d_k ) · V
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Q&lt;/code&gt; — the query matrix (what each token is "asking for")&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;K&lt;/code&gt; — the key matrix (what each token "offers" for comparison)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;V&lt;/code&gt; — the value matrix (the actual content passed forward if selected)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;d_k&lt;/code&gt; — the dimension of the key vectors (scales to prevent extreme dot products)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;softmax&lt;/code&gt; — converts a vector of raw scores into a probability distribution that sums to 1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The notation &lt;code&gt;QK^T&lt;/code&gt; means: for each token, compute a dot product between its query vector and every other token's key vector. The dot product is large when two vectors point in the same direction (high relevance between the pair), and near zero when they are orthogonal (unrelated). Multiplying by the transposed key matrix &lt;code&gt;K^T&lt;/code&gt; does all N×N such comparisons in a single matrix operation. The result is a matrix of raw relevance scores. Dividing by &lt;code&gt;√d_k&lt;/code&gt; prevents those scores from becoming so large that softmax saturates.&lt;/p&gt;

&lt;p&gt;The softmax step is the dilution mechanism. Because softmax always outputs a probability distribution — all values sum to exactly 1 — attention weights are a zero-sum resource. When there are N tokens in the context, the &lt;em&gt;average&lt;/em&gt; attention weight is 1/N, regardless of what any individual token does. The total budget is fixed at 1.0.&lt;/p&gt;

&lt;p&gt;This does not mean every token gets exactly equal attention — the model can still concentrate on a small subset if the dot-product scores separate those tokens sharply from the rest. Softmax is non-linear and can be quite aggressive when there is a large score gap between relevant and irrelevant tokens. But in a real codebase, that gap is rarely clean. Hundreds of unrelated function definitions produce hundreds of tokens with moderately non-zero dot products — they're not completely irrelevant, they just aren't what you need right now. These tokens collectively consume most of the softmax budget. The useful signal must compete against this crowd, and as N grows, the signal's share degrades continuously. It isn't a cliff; it's a steady erosion that compounds with each additional file you stuff in.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Key Insight:&lt;/strong&gt; The context window limit is not just a practical engineering constraint — it reflects a genuine quality degradation. The problem is not that the model &lt;em&gt;cannot read&lt;/em&gt; long inputs. It is that as context grows, every individual piece of information receives proportionally less attention weight. More input does not mean more comprehension; it means each fact competes harder for finite attentional resources.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  3. Lost in the Middle: Position Bias
&lt;/h3&gt;

&lt;p&gt;Attention dilution is one problem. A second, independent problem compounds it: &lt;strong&gt;position bias&lt;/strong&gt;. Modern language models do not attend to all positions in their context with equal reliability. They preferentially attend to tokens at the beginning and end of the sequence, and perform significantly worse on information placed in the middle.&lt;/p&gt;

&lt;p&gt;This phenomenon was studied in a 2023 paper by Nelson Liu et al. titled &lt;em&gt;Lost in the Middle: How Language Models Use Long Contexts&lt;/em&gt;. The researchers tested models on multi-document question answering, varying the position of the document containing the answer. When the answer document was at position 1 or last, accuracy was high. When it was at position 10 of 20 documents, accuracy dropped by more than 30 percentage points — even though the information was technically within the model's context window.&lt;/p&gt;

&lt;p&gt;Two mechanisms contribute. The first is &lt;strong&gt;RoPE&lt;/strong&gt; (Rotary Position Embeddings), the positional encoding scheme in most modern open-source language models (LLaMA, Mistral, GPT-NeoX). RoPE encodes position by rotating the query and key vectors by angles proportional to their positions. The dot product between a query at position &lt;em&gt;m&lt;/em&gt; and a key at position &lt;em&gt;n&lt;/em&gt; includes a term that decays with relative distance (m−n) — semantically relevant tokens far from the query position must overcome a rotational penalty to receive attention weight. Tokens near the start of the sequence are close to almost every other position, giving them a structural advantage.&lt;/p&gt;

&lt;p&gt;The second mechanism is &lt;strong&gt;causal training recency bias&lt;/strong&gt;. Language models are trained to predict the next token given all previous tokens. This reward signal pushes models to weight recent tokens heavily — the immediately preceding context is almost always the most relevant signal for next-token prediction during training. The middle of a long context rarely dominated training gradients, so models systematically underweight it. This effect was documented in GPT-3.5 era models well before RoPE became standard — it isn't purely an artifact of positional encoding, it's baked into causal pretraining. Both effects run in the same direction: the middle of a long context is structurally disadvantaged.&lt;/p&gt;

&lt;p&gt;A 2024 paper from UW, MIT, and Google (&lt;em&gt;Found in the Middle&lt;/em&gt;) demonstrated that this bias can be partially corrected by calibrating attention weights at inference time — but this requires modifying the model's internals, which is not available when calling an API.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Common Mistake:&lt;/strong&gt; Many teams inject retrieved chunks at the &lt;em&gt;end&lt;/em&gt; of the prompt, after a long system prompt and conversation history. This lands retrieved content in a position that gets the worst of both worlds: far from the beginning (losing the primacy advantage) and not at the very end (which is reserved for the generation target itself). The safest placement for retrieved code context is &lt;strong&gt;immediately before the user's specific question&lt;/strong&gt;, near the end but not buried in the middle of a long history.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  4. The Quadratic Cost Problem
&lt;/h3&gt;

&lt;p&gt;Even if you were willing to accept degraded attention quality, there is a third reason not to stuff context: the compute cost of attention scales &lt;strong&gt;quadratically&lt;/strong&gt; with sequence length.&lt;/p&gt;

&lt;p&gt;To compute the full attention matrix, the model must compare every token's query against every other token's key. If your sequence has N tokens, this requires N × N comparisons. Doubling the context length quadruples the compute required for attention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Time complexity of full self-attention: O(N² · d)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 4× increase in context length → 16× increase in attention compute. A 10× increase → 100×.&lt;/p&gt;

&lt;p&gt;FlashAttention (Dao et al., 2022) improves the &lt;em&gt;memory&lt;/em&gt; profile to O(N) via tiling — it never writes the full N×N matrix to GPU memory. But the number of floating-point operations is still O(N²). Latency and cost still scale quadratically with sequence length.&lt;/p&gt;

&lt;p&gt;In production, a code assistant filling 100,000 tokens of context is not just 10× slower than one filling 10,000 tokens — it is closer to 100× more expensive in attention compute alone. You are paying more to get worse results.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: How RAG Fixes It
&lt;/h2&gt;

&lt;h3&gt;
  
  
  5. RAG at a Glance: The Core Idea
&lt;/h3&gt;

&lt;p&gt;Retrieval-Augmented Generation reframes the problem. Instead of asking "how can we give the model the whole codebase?", it asks: "how do we figure out which parts of the codebase are relevant to this specific completion request, and send only those?"&lt;/p&gt;

&lt;p&gt;The answer has two phases. First, an &lt;strong&gt;offline indexing phase&lt;/strong&gt; where the codebase is processed, divided into chunks, and each chunk is converted into a vector representation (an embedding) that captures its semantic meaning. These vectors are stored in an index optimized for fast similarity search. Second, an &lt;strong&gt;online retrieval phase&lt;/strong&gt; that happens at query time: the developer's current context is converted into a query vector, and the most similar chunks from the index are retrieved and injected into the prompt.&lt;/p&gt;

&lt;p&gt;The model then receives a context window that is not a random cross-section of the codebase — it is the small set of pieces most likely to be relevant to the task at hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pipeline:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Parse &amp;amp; Chunk&lt;/strong&gt; — split at function/class boundaries, not arbitrary token counts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed Chunks&lt;/strong&gt; — convert each chunk to a vector with a code embedding model&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build Search Index&lt;/strong&gt; — ANN index for dense retrieval + BM25 index for lexical retrieval&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed the Query&lt;/strong&gt; — convert current cursor context to a query vector&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieve Top-k&lt;/strong&gt; — run hybrid search (dense + BM25), fuse results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inject &amp;amp; Generate&lt;/strong&gt; — inject top 3–5 chunks into the LLM prompt, immediately before the user's request&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1–3 happen once (or on incremental file changes). Steps 4–6 happen on every completion request. The parts where most implementations go wrong: chunking (using fixed-size splits instead of AST boundaries), retrieval (using only dense search and missing exact identifier queries), and injection order (burying retrieved context in the middle of the prompt).&lt;/p&gt;




&lt;h3&gt;
  
  
  6. Chunking for Code: Why Fixed-Size Fails
&lt;/h3&gt;

&lt;p&gt;Code has structure that text does not. A function is a unit of meaning. &lt;strong&gt;Fixed-size chunking&lt;/strong&gt; — splitting every file every 256 tokens — splits in the middle of functions, destroying logical units.&lt;/p&gt;

&lt;p&gt;Consider a Python function that is 80 lines long. With a 50-token chunk size, it gets split into chunks that look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Chunk A: def process_payment(order_id, amount, currency="USD"):
    """Process a payment..."""
    conn = get_db_connection()
    try:
        txn = conn.begin_transaction(

Chunk B:   order_id=order_id,
  amount=amount,
  currency=currency
)    except DatabaseError as e:
        log_error(e)
        raise PaymentError(str(e))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither chunk represents the function accurately. The embedding of Chunk A does not represent "a payment processing function" — it represents a truncated fragment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AST-based chunking&lt;/strong&gt; uses tree-sitter to parse each file and extract logical units at language-defined boundaries: function definitions, class bodies, method groups. Each chunk's metadata includes file path, start line, end line, and node type. This metadata is as important as the chunk text itself — it tells the retrieval system where in the codebase this chunk lives.&lt;/p&gt;

&lt;p&gt;One practical addition: each chunk can be augmented with a small surrounding context for embedding purposes — the preceding import block, the class it belongs to, or the file's module-level docstring. This gives the embedding model enough context to produce a vector that reflects the chunk's role in the larger structure. The key is that this surrounding context is used only for embedding, not retrieved as part of the chunk text.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The overlap trap in code:&lt;/strong&gt; Sliding window overlap (copying N tokens from one chunk into the next) is useful in prose. In code it often makes things worse: the overlap introduces duplicate logic into separate chunks, making embedding space crowded with near-identical vectors. For code, the recommended approach is to store a "parent context" chunk separately — always inject the enclosing class signature alongside any function chunk, rather than copying the previous function's body into the current chunk. The &lt;code&gt;Continue&lt;/code&gt; open-source IDE extension uses this approach.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  7. Retrieval Strategies: Dense, Sparse, and Why Code Needs Both
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Dense retrieval&lt;/strong&gt; converts query and each chunk to vectors, then finds the most similar by cosine similarity. It can match meaning even when exact words differ — "how do we handle rate limit errors?" surfaces functions named &lt;code&gt;throttle_on_429&lt;/code&gt; or &lt;code&gt;backoff_retry&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The embedding model used matters significantly. Code-specialized models like &lt;code&gt;voyage-code-3&lt;/code&gt; — purpose-built for code retrieval, top-ranked on code retrieval benchmarks (2025) — produce substantially better representations for function bodies, type signatures, and API calls than general-purpose models. &lt;code&gt;text-embedding-3-large&lt;/code&gt; is a strong general-purpose embedding model suited for mixed code + documentation retrieval, but it wasn't specifically designed around code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BM25 (lexical/keyword retrieval)&lt;/strong&gt; counts words. It excels at exact matches — a developer looking for &lt;code&gt;PaymentGateway.process_refund&lt;/code&gt; will find it immediately. Error codes, configuration key names, and exact API method names are better retrieved lexically than semantically. For code, the asymmetry is important: queries for exact identifiers favor BM25. Queries for concepts and behaviors favor dense retrieval. The right system runs both.&lt;/p&gt;




&lt;h3&gt;
  
  
  8. Hybrid Search and Reciprocal Rank Fusion
&lt;/h3&gt;

&lt;p&gt;Running both methods produces two ranked lists that need combining. BM25 scores and cosine similarity scores live in completely different numerical ranges — you cannot add them directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt; avoids the normalization problem entirely by ignoring raw scores and working only with ranks. The word "reciprocal" means 1/x — the score assigned to a document is the reciprocal of its rank in each list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RRF_score(d) = Σ_{r ∈ R} 1 / (k + rank_r(d))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;R&lt;/code&gt; = set of ranked lists (BM25 list, dense list)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;rank_r(d)&lt;/code&gt; = position of document &lt;code&gt;d&lt;/code&gt; in list &lt;code&gt;r&lt;/code&gt; (1-indexed)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;k&lt;/code&gt; = smoothing constant (default 60, from Cormack, Clarke &amp;amp; Buettcher 2009 — empirically robust across many retrieval tasks). Increasing k makes the formula more conservative, rewarding consistent mid-rank appearances over a single strong rank.&lt;/li&gt;
&lt;li&gt;If a document does not appear in a list, its contribution from that list is 0&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A document ranked #1 in both lists scores ≈ 0.033. A document ranked #1 in one list but #100 in the other scores ≈ 0.022. Candidates that both BM25 and semantic search agree on float to the top.&lt;/p&gt;




&lt;h3&gt;
  
  
  9. Reranking: The Final Sorting Pass
&lt;/h3&gt;

&lt;p&gt;After hybrid search and RRF, you have ~20 candidate chunks. A &lt;strong&gt;cross-encoder reranker&lt;/strong&gt; takes both the query and a candidate chunk as a single concatenated input and produces a relevance score. Because both texts pass through the model together, the model can attend to query-document relationships that a bi-encoder cannot — query and document never interact during bi-encoder encoding.&lt;/p&gt;

&lt;p&gt;The practical architecture: use fast bi-encoder retrieval (dense + BM25 + RRF) to get the top 20 candidates, then run a cross-encoder on those 20 for final ordering. The top 5 go into the prompt.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Cross-encoder context window limits:&lt;/strong&gt; Cross-encoders are themselves transformer models with context window limits. General-purpose reranker models like &lt;code&gt;ms-marco-MiniLM-L-12-v2&lt;/code&gt; support 512 subword tokens — which is often enough for a single short function, but not for large class bodies. For retrieval pipelines that surface larger chunks, use a reranker with a larger window: Cohere Rerank 3 supports 4,096 tokens; voyage-rerank-2 supports 16K. If the combined chunk+query still exceeds the limit, truncate the chunk from the bottom — the function signature and docstring are more informative for reranking than the implementation tail.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For code with strong AST chunking and a good code embedding model, hybrid bi-encoder retrieval is often sufficient for most queries. Reranking becomes most valuable when queries are ambiguous or when the codebase has many semantically similar functions. It adds 50–200ms of latency, so benchmark before committing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: In Production
&lt;/h2&gt;

&lt;h3&gt;
  
  
  10. How Cursor Does It: A Reference Architecture
&lt;/h3&gt;

&lt;p&gt;When you open a project in Cursor, it chunks local files and sends them to its servers, where they are embedded (via OpenAI's API or a custom model) and stored in &lt;strong&gt;Turbopuffer&lt;/strong&gt; — its vector store of choice. File paths are obfuscated client-side before any data leaves your machine. Embeddings are cached by chunk hash, making incremental re-indexing fast.&lt;/p&gt;

&lt;p&gt;At query time, Cursor monitors the active cursor position and constructs a composite signal: the current file's surrounding code, any open editor tabs, and recent edit history. This signal is embedded into a query vector, sent to Turbopuffer for ANN search, and the top-k results are retrieved. The actual code is read from local disk; the model only sees the retrieved text.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;@Codebase&lt;/code&gt; in Cursor's chat is the explicit trigger for a full retrieval pass over the indexed codebase. Without it, Cursor uses a lighter heuristic based on open tabs and file imports. &lt;code&gt;@Docs&lt;/code&gt; and &lt;code&gt;@Web&lt;/code&gt; extend the same pipeline beyond the local codebase.&lt;/p&gt;

&lt;p&gt;One important architectural note: the embedding model used to index the codebase is separate from the generative model used to produce completions. Cursor uses a lightweight, fast embedding model for indexing (optimized for latency and throughput over millions of chunks) and a larger, slower generative model for the actual completion. When building a similar system, these two components have independent optimization concerns — do not assume the same model serves both roles.&lt;/p&gt;

&lt;p&gt;GitHub Copilot's context construction follows a similar pattern. For inline completion, it uses the current file content around the cursor plus a Jaccard similarity heuristic to find other open tabs that share significant token overlap with the current file. The &lt;code&gt;@workspace&lt;/code&gt; symbol in VS Code triggers a more thorough indexing-based search, analogous to Cursor's &lt;code&gt;@Codebase&lt;/code&gt;. Copilot's default inline completion mode is a fast, low-latency path that does not run full vector retrieval on every keystroke — full retrieval is reserved for explicit chat interactions.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. Tradeoffs and Limits of Code RAG
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;RAG Behavior&lt;/th&gt;
&lt;th&gt;Mitigation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cross-file dependency reasoning&lt;/td&gt;
&lt;td&gt;Each retrieved chunk is a fragment; the model may not understand how three retrieved functions compose at the call site&lt;/td&gt;
&lt;td&gt;Include file path + line range metadata; retrieve parent class or module-level imports alongside function bodies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Newly created files not yet indexed&lt;/td&gt;
&lt;td&gt;Invisible to retrieval until the index is rebuilt&lt;/td&gt;
&lt;td&gt;Incremental indexing on file-save events; maintain a pending index queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query is too vague&lt;/td&gt;
&lt;td&gt;"fix the bug" → retrieves generic results&lt;/td&gt;
&lt;td&gt;Use cursor position + surrounding error message as primary query signal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minified or generated code&lt;/td&gt;
&lt;td&gt;Lock files, protobuf generated code pollute the index&lt;/td&gt;
&lt;td&gt;Maintain a .gitignore-style exclude list for the RAG indexer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Very large monorepos&lt;/td&gt;
&lt;td&gt;Recall degrades; indexing is slow&lt;/td&gt;
&lt;td&gt;Scope index to current working subdirectory or per-service sub-indices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema/type changes&lt;/td&gt;
&lt;td&gt;Stale embeddings give the model outdated type signatures&lt;/td&gt;
&lt;td&gt;Invalidate embeddings on file write by chunk content hash&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Does a larger context window make RAG obsolete?&lt;/strong&gt; As context windows grow to 1M and beyond — Llama 4 Scout hit 10M tokens in 2025, Gemini 1.5 Pro supported 1M — this question keeps coming up. The practical answer is no, though the reasoning matters. A 200,000-line Python codebase easily exceeds 2 million tokens. Most production monorepos are far larger. More importantly, the attention quality degradation described in Sections 2 and 3 doesn't disappear with a larger nominal window. Those long-context models achieve their range through techniques like NTK-aware RoPE scaling (which extends the effective frequency range of positional encodings) and sparse attention patterns (which skip computation on distant token pairs) — these help with extrapolation but don't eliminate the position bias at extremely long ranges. And practically: a 1M-token prompt is expensive and slow even on state-of-the-art hardware. For interactive code assistance, stuffing the full codebase is off the table regardless of window size.&lt;/p&gt;

&lt;p&gt;Large context windows and RAG do different jobs. RAG decides &lt;em&gt;what&lt;/em&gt; deserves to be in the context window. The context window determines &lt;em&gt;how much&lt;/em&gt; you can fit once you've been selective. A well-tuned system retrieves the right 5,000 tokens from a 10M-token codebase and puts them in a 128K window with room left for conversation history and tool outputs.&lt;/p&gt;

&lt;h3&gt;
  
  
  12. Building Your Own Code RAG Pipeline
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Parsing:&lt;/strong&gt; Use tree-sitter with a recursive AST walk — iterating only over &lt;code&gt;root_node.children&lt;/code&gt; misses deeply nested functions and class methods.&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="c1"&gt;# Pseudo-code: AST chunk extraction with tree-sitter (v0.21+ API)
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;tree_sitter_python&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;tspython&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;tree_sitter&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Parser&lt;/span&gt;

&lt;span class="n"&gt;PY_LANGUAGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Language&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tspython&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;language&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;parser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Parser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PY_LANGUAGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;TARGET_TYPES&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;function_definition&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;class_definition&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;walk_tree&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="n"&gt;source_code&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="n"&gt;file_path&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="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Recursively walk the AST to catch nested definitions
    (methods inside classes, functions inside functions, etc.)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TARGET_TYPES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;chunk_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;source_code&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="n"&gt;start_byte&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="n"&gt;end_byte&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;chunk_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;start_line&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="n"&gt;start_point&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;end_line&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="n"&gt;end_point&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="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="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="c1"&gt;# For class_definition, continue recursing to capture methods.
&lt;/span&gt;        &lt;span class="c1"&gt;# For function_definition, stop — we want the whole function,
&lt;/span&gt;        &lt;span class="c1"&gt;# not its nested helpers as separate chunks.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;class_definition&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;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;walk_tree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&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;for&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;walk_tree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&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;extract_chunks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_code&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="n"&gt;file_path&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;tree&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="nf"&gt;walk_tree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tree&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root_node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;file_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunks&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;chunks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Embedding models:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Context window&lt;/th&gt;
&lt;th&gt;Strengths&lt;/th&gt;
&lt;th&gt;When to use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;voyage-code-3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;16K tokens&lt;/td&gt;
&lt;td&gt;Purpose-built for code; top-ranked on code retrieval benchmarks (2025)&lt;/td&gt;
&lt;td&gt;Production code assistant, maximum retrieval quality&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;text-embedding-3-large&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8K tokens&lt;/td&gt;
&lt;td&gt;Strong general performance; well-supported; large community&lt;/td&gt;
&lt;td&gt;Mixed code + documentation retrieval; existing OpenAI integrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nomic-embed-code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8K tokens&lt;/td&gt;
&lt;td&gt;Open-weight; can run locally; no API cost&lt;/td&gt;
&lt;td&gt;Air-gapped environments; cost-sensitive deployments; on-prem&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Vector store:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pgvector in Postgres — sufficient for single-developer or small-team tools&lt;/li&gt;
&lt;li&gt;Qdrant — supports both dense and sparse vectors in a single collection, enabling native hybrid search without maintaining two separate stores&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prompt injection template:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are a coding assistant for this codebase.

## Relevant context from the codebase:

### [payments/gateway.py · lines 42–87]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
{chunk_1_text}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
### [payments/exceptions.py · lines 1–24]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
{chunk_2_text}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
### [payments/models.py · lines 88–112]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
python&lt;br&gt;
{chunk_3_text}&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
## Current task:
{user_request}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Include file path and line numbers in each chunk header. These cost very few tokens but give the model the module structure needed to generate correct imports and references.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Do not retrieve more than you need.&lt;/strong&gt; It is tempting to inject 10–15 chunks to "give the model more information." Resist this. Each additional chunk increases context size (paying the quadratic cost from Section 4), increases attention dilution, and reduces the proportion of the context that is highly relevant. In practice, 3–5 high-quality chunks typically outperform 15 lower-quality ones. Invest in retrieval quality, not retrieval quantity.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Through-Line
&lt;/h2&gt;

&lt;p&gt;The surprising thing about attention dilution is that it isn't a bug you can patch. It's a structural property of softmax normalization — the total attention weight sums to 1.0 regardless of sequence length, so every token you add is competing with every other for a share of that budget. More context doesn't mean more understanding; it means each fact gets a smaller slice. The lost-in-the-middle position bias makes it worse: code injected into the middle of a long prompt is structurally disadvantaged by both RoPE's distance decay and the recency bias that causal pretraining instills. Knowing this changes how you think about the whole problem.&lt;/p&gt;

&lt;p&gt;RAG doesn't solve attention dilution — it sidesteps it. Instead of sending everything and hoping the model finds what's relevant, it figures out what's relevant first and sends only that. The context window ends up containing what actually matters for the task: the right type definitions, the right helper functions, the right error handling patterns.&lt;/p&gt;

&lt;p&gt;In practice: below roughly 3,000–5,000 lines, context stuffing usually works well enough. Above that, the problems stack up fast. At 50,000+ lines, naive stuffing reliably hurts. At 500,000+ lines, AST chunking, hybrid BM25 + dense retrieval, RRF fusion, and careful prompt injection aren't premature optimization — they're the baseline.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Foundational Papers&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vaswani et al. (2017) — &lt;em&gt;Attention Is All You Need&lt;/em&gt;. NeurIPS. The original transformer paper introducing scaled dot-product attention.&lt;/li&gt;
&lt;li&gt;Liu et al. (2023) — &lt;a href="https://arxiv.org/abs/2307.03172" rel="noopener noreferrer"&gt;&lt;em&gt;Lost in the Middle: How Language Models Use Long Contexts&lt;/em&gt;&lt;/a&gt;. Stanford / Berkeley. Empirical study of U-shaped attention bias and the 30% accuracy drop at mid-context positions.&lt;/li&gt;
&lt;li&gt;He et al. (2024) — &lt;a href="https://arxiv.org/abs/2406.16008" rel="noopener noreferrer"&gt;&lt;em&gt;Found in the Middle: Calibrating Positional Attention Bias&lt;/em&gt;&lt;/a&gt;. UW / MIT / Google. Proposed calibration method that partially corrects RoPE position bias at inference time.&lt;/li&gt;
&lt;li&gt;Survey (2025) — &lt;a href="https://arxiv.org/abs/2510.04905" rel="noopener noreferrer"&gt;&lt;em&gt;Retrieval-Augmented Code Generation: A Survey&lt;/em&gt;&lt;/a&gt;. Comprehensive survey of RAG approaches specifically for code generation and repository-level tasks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;RAG &amp;amp; Retrieval&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cormack, Clarke &amp;amp; Buettcher (2009) — &lt;em&gt;Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Dao et al. (2022) — &lt;em&gt;FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://redis.io/blog/rag-vs-large-context-window-ai-apps/" rel="noopener noreferrer"&gt;RAG vs Large Context Window: Real Trade-offs for AI Apps&lt;/a&gt; — Redis Engineering Blog&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.shaped.ai/blog/context-window-optimization-why-ranking-not-stuffing-is-the-scaling-law-for-agents" rel="noopener noreferrer"&gt;Context Window Optimization: Why Ranking, Not Stuffing, Is the Scaling Law for Agents&lt;/a&gt; — Shaped AI&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://medium.com/@vishnudhat/rag-for-llm-code-generation-using-ast-based-chunking-for-codebase-c55bbd60836e" rel="noopener noreferrer"&gt;RAG for LLM Code Generation using AST-Based Chunking&lt;/a&gt; — Vishnudhat Natarajan&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://sderosiaux.substack.com/p/better-retrieval-beats-better-models" rel="noopener noreferrer"&gt;Better Retrieval Beats Better Models for Large Codebases&lt;/a&gt; — Stéphane Derosiaux&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Code Assistants &amp;amp; Architecture&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://towardsdatascience.com/how-cursor-actually-indexes-your-codebase/" rel="noopener noreferrer"&gt;How Cursor Actually Indexes Your Codebase&lt;/a&gt; — Towards Data Science&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://blog.quastor.org/p/github-copilot-works" rel="noopener noreferrer"&gt;How GitHub Copilot Works&lt;/a&gt; — Quastor Engineering&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.blog/ai-and-ml/generative-ai/what-is-retrieval-augmented-generation-and-what-does-it-do-for-generative-ai/" rel="noopener noreferrer"&gt;What is Retrieval-Augmented Generation?&lt;/a&gt; — GitHub Blog&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hybrid Search &amp;amp; Ranking&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://ranjankumar.in/bm25-vs-dense-retrieval-for-rag-engineers" rel="noopener noreferrer"&gt;BM25 vs Dense Retrieval for RAG: What Actually Breaks in Production&lt;/a&gt; — Ranjan Kumar&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://mbrenndoerfer.com/writing/hybrid-search-bm25-dense-retrieval-fusion" rel="noopener noreferrer"&gt;Hybrid Search: BM25 and Dense Retrieval Combined&lt;/a&gt; — Michael Brenndoerfer&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>codeassistants</category>
      <category>llm</category>
      <category>rag</category>
      <category>productivity</category>
    </item>
    <item>
      <title>India's DPDP Act 2023 Explained — And How AI Handles Data Principal Requests at Scale</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Thu, 21 May 2026 21:46:43 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/indias-dpdp-act-2023-explained-and-how-ai-handles-data-principal-requests-at-scale-38ib</link>
      <guid>https://dev.to/swapnanilsaha/indias-dpdp-act-2023-explained-and-how-ai-handles-data-principal-requests-at-scale-38ib</guid>
      <description>&lt;p&gt;&lt;em&gt;This post is for informational purposes only and does not constitute legal advice. The DPDP Act 2023 and its implementing Rules 2025 are relatively new — requirements may evolve through further notifications or guidance. Verify the current position with a qualified data protection lawyer before making compliance decisions.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Your company just received this email:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I would like to know all personal data your organisation holds about me. This is a formal request under the DPDP Act."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It lands in a shared &lt;code&gt;privacy@yourcompany.com&lt;/code&gt; inbox. Someone reads it. Forwards it to legal. Legal forwards it to engineering. Engineering says they need to check three databases. Nobody notes the date it arrived. Three weeks pass. When someone finally circles back, there are nine days left on the 30-day window the DPDP Rules require. Not enough time to locate the data, get legal sign-off, draft a response in the right language, and send it.&lt;/p&gt;

&lt;p&gt;On day 32, you're in violation.&lt;/p&gt;

&lt;p&gt;That scenario is the default for most Indian companies right now. Not because they're careless — but because nobody built the infrastructure for it.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;DPDP Copilot&lt;/strong&gt; to close that gap: a self-hosted operator tool that accepts public data requests, classifies them with Claude, drafts compliant multilingual replies, tracks every action as immutable evidence, and monitors SLA status in real time.&lt;/p&gt;

&lt;p&gt;But before the tool, you need to understand what you're actually dealing with. Let's start with the law.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://swapnanilsaha.com/tools/dpdp-copilot/" rel="noopener noreferrer"&gt;→ Full tool page and live demo&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: What the DPDP Act 2023 Actually Requires
&lt;/h2&gt;

&lt;p&gt;The Digital Personal Data Protection Act 2023 received presidential assent on 11 August 2023 and represents India's first comprehensive data protection legislation. Its structure borrows from GDPR while adapting to India's specific context — a 1.4 billion-person population, 22 scheduled languages, deep mobile penetration, and a digital public infrastructure layer (UPI, Aadhaar, DigiLocker) that most jurisdictions don't have.&lt;/p&gt;

&lt;p&gt;The implementing Rules — the Digital Personal Data Protection Rules 2025 — were notified on 13 November 2025, giving the Act its operational teeth.&lt;/p&gt;

&lt;p&gt;Here's what the law actually mandates, stripped of legalese, focusing on the parts most engineering and compliance teams get wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Four Rights Every Data Principal Has
&lt;/h3&gt;

&lt;p&gt;The Act grants every "data principal" — the person whose data is being processed — four actionable rights. When someone exercises any of these, your organisation (as the "data fiduciary") has a legal obligation to respond.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right of Access (Section 11)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any person can ask you: what personal data do you hold about me, and for what purpose? You must provide a summary of the data being processed, the processing activities, and the identities of any other data fiduciaries or processors with whom their data has been shared. The Act doesn't specify a format, but silence is not sufficient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right of Correction and Completion (Section 12(a))&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a person believes data you hold is inaccurate, incomplete, or misleading, they can demand you correct or complete it. You must either act on the request or explain in writing why you're not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right of Erasure (Section 12(b))&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A person can request deletion of their personal data from your systems. There are exceptions — data held for legal obligations, fraud prevention, pending litigation — but these exceptions have to be documented and justified, not just asserted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Right to Grievance Redressal (Section 13)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any person can file a grievance if they believe their rights under the Act have been violated. You must provide a mechanism to receive and respond to grievances. The Rules 2025 specify this mechanism must be genuinely accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Response Timelines
&lt;/h3&gt;

&lt;p&gt;The DPDP Rules 2025 (notified November 2025) set specific mandatory windows for responding to data principal requests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access, Correction, and Erasure requests (Sections 11–12)&lt;/strong&gt;: Data fiduciaries must respond within &lt;strong&gt;30 days&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grievance Redressal (Section 13)&lt;/strong&gt;: Grievances must be resolved within a maximum of &lt;strong&gt;90 days&lt;/strong&gt; from receipt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are calendar days. For reference: GDPR (EU) also requires responses within one month for most data subject requests; California's CCPA gives 45 days. India's framework is broadly comparable to GDPR in its demands — but applies at the scale of 1.4 billion people, across 22 scheduled languages. That's where the operational challenge is categorically harder.&lt;/p&gt;

&lt;p&gt;30 days sounds like a lot. For a company with no structured process, it evaporates fast. A request that lands in a shared inbox on a Friday, takes three days to be noticed, gets forwarded twice, waits a week for a legal review, and then requires manual drafting in the data principal's language — you're out of time before anyone writes the first sentence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A note on the DPDP Copilot tool's SLA default&lt;/strong&gt;: The tool's internal SLA clock defaults to 7 days — intentionally more conservative than the 30-day legal window. Most mature compliance programmes target internal deadlines that are significantly tighter than the regulatory maximum, so that normal delays (review cycles, approvals, language checks) don't push you to the edge. The 7-day default is configurable via &lt;code&gt;orgs.sla_days&lt;/code&gt;. When the Rules are read by your legal team and a specific target is agreed, you set it once in the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  What "Evidence" Actually Means Under DPDP
&lt;/h3&gt;

&lt;p&gt;The Act and the Rules create a documentation burden that most organisations underestimate. You need to be able to prove:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;That the request was received on a specific date&lt;/li&gt;
&lt;li&gt;That it was handled (classified and routed) in a timely manner&lt;/li&gt;
&lt;li&gt;What response you gave and when&lt;/li&gt;
&lt;li&gt;Whether the response fulfilled the request or why it couldn't&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is audit evidence. If the Data Protection Board investigates a complaint, you need to produce this trail. A forwarded email chain is not audit evidence. A Slack thread is not audit evidence. An append-only timestamped log — with the original message, the classification, the drafted response, and the send event — is audit evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Financial Exposure
&lt;/h3&gt;

&lt;p&gt;The Act's First Schedule specifies penalties by category of failure. The two most operationally relevant:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;₹250 crore (~$30M USD)&lt;/strong&gt; — Failure to implement &lt;strong&gt;reasonable security safeguards&lt;/strong&gt; to prevent personal data breaches (Section 8(5)). This is the preventive obligation — having security measures in place. The penalty applies even where a breach subsequently occurs and the fiduciary claims they didn't anticipate it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;₹200 crore&lt;/strong&gt; — Failure to &lt;strong&gt;notify the Data Protection Board and affected data principals&lt;/strong&gt; when a personal data breach does occur (Section 8(6)). The notification obligation is separate from the security obligation — you can get penalised for both.&lt;/p&gt;

&lt;p&gt;Other penalty tiers: ₹200 crore for violations related to children's personal data (Section 9); ₹150 crore for Significant Data Fiduciary obligation failures; ₹50 crore for other provision breaches.&lt;/p&gt;

&lt;p&gt;The Data Protection Board, once fully constituted, will have adjudicatory powers to investigate and levy these penalties. Failing to acknowledge or respond to a data principal request, if that person escalates to the Board, creates a documented paper trail of non-compliance before any investigation begins.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: Why Your Current Process Fails (And Why That's the Default)
&lt;/h2&gt;

&lt;p&gt;Let me describe the most common setup I've seen when talking to Indian companies dealing with DPDP requests:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;privacy@&lt;/code&gt; email address that gets checked sporadically&lt;/li&gt;
&lt;li&gt;No clock tracking — the 30-day window doesn't appear anywhere visible until it's almost gone&lt;/li&gt;
&lt;li&gt;No classification — the person who reads it decides manually whether it's an access request, deletion request, or complaint&lt;/li&gt;
&lt;li&gt;Reply drafted manually, from scratch, in English, by whoever processes it that week&lt;/li&gt;
&lt;li&gt;No audit trail beyond the email itself, which may be deleted if an inbox is cleaned&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't negligence. It's the logical outcome of a process designed before the Act existed. The process was "email us with your concern" — and it worked fine when data requests were rare. The DPDP Act changes the legal weight of those requests, but most companies haven't updated their infrastructure to match.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Three Ways Manual Processes Break
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. The deadline blind spot&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a request lands in an email inbox, the 30-day clock doesn't appear anywhere. Nobody stamps the receipt date. Nobody sends an automatic acknowledgement. The request sits until someone opens the inbox. If that takes a week — completely normal for a low-traffic shared inbox — you've already used 23% of your response window without touching the request. Legal review, data location, and drafting will eat most of what's left.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Classification inconsistency&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Please delete my data" is an erasure request. "I never gave you permission to use my data" is a grievance. "I want to update my phone number" is a correction request. "Can you send me everything you have on me" is an access request. A trained compliance professional can distinguish these consistently. Your Monday-morning on-call engineer who reads the shared inbox probably cannot — especially for requests written in Hindi, Bengali, or Tamil.&lt;/p&gt;

&lt;p&gt;When requests are misclassified, they get routed to the wrong person, get the wrong response template, and sometimes get the wrong legal treatment. An erasure request handled as a grievance will likely produce a response that doesn't fulfil the legal obligation under Section 12(b), even if it sounds polite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Evidence that can't survive audit&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An auditor asks: "On what date did you receive and process this erasure request?" If your answer is "let me check the email thread," you have a problem. Email is mutable, searchable by keyword but not by event type, and has no integrity guarantees. An auditor looking for "REQUEST_CREATED at timestamp T" followed by "REPLY_SENT at timestamp T+22 days" needs a structured log, not an inbox.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 3: The Role of AI in DPDP Compliance
&lt;/h2&gt;

&lt;p&gt;When I was designing DPDP Copilot, the central question was: &lt;strong&gt;where does AI actually add value, and where does it introduce risk?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;DPDP compliance has two types of tasks: tasks that require human judgment about legal gray areas, and tasks that require consistent application of known rules to varied inputs. AI is well-suited to the second category and badly suited to the first.&lt;/p&gt;

&lt;p&gt;Deciding whether your company has a legal obligation to retain data for a pending investigation? That's human judgment. Classifying an incoming message as an Access request vs. an Erasure request? That's pattern recognition on natural language — exactly what a well-prompted LLM is built for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Classification: Where LLMs Outperform Rules
&lt;/h3&gt;

&lt;p&gt;Naive rule-based classification for DPDP requests fails quickly. "Please delete my account" is an erasure request. "I want my data removed from your marketing list" is also an erasure request but uses entirely different vocabulary. "Remove me" submitted in a support ticket might be an erasure request or might just be asking to be unsubscribed from emails — context determines which.&lt;/p&gt;

&lt;p&gt;A rules-based system that catches "delete my data" literally will miss most real-world submissions. People write in fragments, in their native language, with emotional context, in ways that don't follow a template.&lt;/p&gt;

&lt;p&gt;An LLM with a well-structured prompt classifies these correctly without needing exhaustive keyword lists. The DPDP Copilot classification prompt:&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="err"&gt;Classify&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;this&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;into&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;exactly&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;one&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;of:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Grievance,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Access,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Rectification,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Deletion.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Respond&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;classification&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;Message:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="err"&gt;text&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;The system prompt establishes the legal framework — "You are a DPDP compliance assistant classifying data principal requests under India's DPDP Act 2023." The model maps the message to the correct legal category.&lt;/p&gt;

&lt;p&gt;The output is constrained to a JSON object with a single key. The application validates that &lt;code&gt;type&lt;/code&gt; is one of the four legal categories. If the model returns something outside those four values, it's rejected and retried — the system never persists a classification it can't validate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Multilingual Reply Drafting: Where AI Eliminates Weeks of Work
&lt;/h3&gt;

&lt;p&gt;This is where AI creates the most leverage in the Indian compliance context.&lt;/p&gt;

&lt;p&gt;India has 22 scheduled languages. The DPDP Act creates a right to grievance redressal — and for that mechanism to be genuinely accessible (which the Rules 2025 require), you need to respond in a language the person can understand.&lt;/p&gt;

&lt;p&gt;Without AI, producing compliant response templates in Hindi, Bengali, Tamil, and Marathi means hiring translators, reviewing legal language, maintaining version parity across languages, and updating all templates whenever requirements change. That's a significant operational cost — one that most companies defer indefinitely, defaulting to English-only responses that disadvantage non-English speakers.&lt;/p&gt;

&lt;p&gt;With a well-prompted LLM, drafting happens at response time. The model understands DPDP legal obligations and drafts a response that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Acknowledges the specific request type (not a generic "thank you for reaching out")&lt;/li&gt;
&lt;li&gt;Confirms receipt and logging with a reference number&lt;/li&gt;
&lt;li&gt;States the applicable response timeline&lt;/li&gt;
&lt;li&gt;Explains the next step the data principal should expect&lt;/li&gt;
&lt;li&gt;Is written in the language they chose&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The system prompt for drafting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are a DPDP compliance officer drafting replies to data principal requests under 
India's Digital Personal Data Protection Act 2023. Write professional, empathetic 
replies that: acknowledge the request type, confirm receipt and logging, state the 
applicable response timeline, and explain the next step. Keep the tone formal but accessible.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user message to the model specifies the request type and target language:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Draft a DPDP-compliant reply in ${language} for a ${type} request.

Customer message:
${text}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These are &lt;strong&gt;suggested&lt;/strong&gt; replies — an operator reviews them before sending. The human stays in the loop for all final communications.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prompt Caching: Making AI Cost-Efficient at Scale
&lt;/h3&gt;

&lt;p&gt;The system prompts for both classification and drafting use &lt;code&gt;cache_control: { type: 'ephemeral' }&lt;/code&gt; via the Anthropic SDK, enabling prompt caching.&lt;/p&gt;

&lt;p&gt;If you're processing dozens of data principal requests per day, the system prompt — which is identical for every request — gets cached by Anthropic's API after the first call. Subsequent calls are billed at a fraction of the full input token cost. At scale, prompt caching reduces API costs by 50–80% for the classification and drafting steps.&lt;/p&gt;

&lt;p&gt;This is a small architectural detail that has no effect on the first request and compounding positive effect on the hundredth. If you're building compliance tooling that processes high volumes, prompt caching is the difference between a sustainable per-request cost and one that makes the tool impractical at production scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Retry Logic: Resilience Against Transient Failures
&lt;/h3&gt;

&lt;p&gt;The LLM calls use exponential backoff retry logic:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;callWithRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;attempt&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="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_RETRIES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="o"&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;try&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;await&lt;/span&gt; &lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&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="nx"&gt;err&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;isRetryable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
        &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RateLimitError&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;InternalServerError&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;isRetryable&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;MAX_RETRIES&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="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&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;Only rate limit errors and server errors trigger retries — not client errors (bad API key, invalid request format). The delay doubles with each attempt: 1 second, then 2, then 4. Three attempts total. A transient API hiccup doesn't fail the entire processing pipeline for a data principal's submission.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 4: DPDP Copilot — The Tool in Detail
&lt;/h2&gt;

&lt;p&gt;With the legal and AI context established, here's how DPDP Copilot works end to end.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Public Request Form
&lt;/h3&gt;

&lt;p&gt;The entry point for data principals is &lt;code&gt;/grievance&lt;/code&gt; — no login required. Requiring a login to submit a data rights request is a barrier that conflicts with the spirit of the Act. If someone can't easily submit an erasure request, the mechanism isn't truly accessible.&lt;/p&gt;

&lt;p&gt;The form collects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The request message (free text — people write what they mean in their own words)&lt;/li&gt;
&lt;li&gt;Preferred response language (English, Hindi, Bengali, Tamil, Marathi)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no account creation, no verification code, no CAPTCHA wall. Data principals submit and receive an acknowledgement. The contact information is embedded in the message body — a known limitation of the current implementation, and a deliberate choice for the initial version: forcing a structured contact field requires more UI complexity and doesn't add meaningful compliance value until outbound email delivery is implemented.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Happens in the Background on Submission
&lt;/h3&gt;

&lt;p&gt;When the form is submitted, a single API call to &lt;code&gt;POST /api/public/requests&lt;/code&gt; triggers a multi-step synchronous pipeline:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Request creation&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The system creates a database record with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A UUID as the request ID&lt;/li&gt;
&lt;li&gt;The raw message text&lt;/li&gt;
&lt;li&gt;The chosen language&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type: 'PENDING'&lt;/code&gt; — not yet classified&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sla_due_at: now() + 7 days&lt;/code&gt; — the internal SLA clock starts at submission. This 7-day default is configurable via &lt;code&gt;orgs.sla_days&lt;/code&gt; and is intentionally conservative relative to the 30-day legal window.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;org_id&lt;/code&gt; from the active organisation configuration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Evidence logging — REQUEST_CREATED&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;evidence_events&lt;/code&gt; record is written immediately after creation:&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;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REQUEST_CREATED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_data"&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;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public_form"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hindi"&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;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-05-25T10:00:00.000Z"&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;This is the legal timestamp of receipt. The moment the request hits the database, it's on record. The evidence log is append-only at the application level — there are no delete or update operations on &lt;code&gt;evidence_events&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: AI classification&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The message text goes to Claude for classification. The model returns a JSON object. The application parses it and validates that &lt;code&gt;type&lt;/code&gt; is one of &lt;code&gt;{ Grievance, Access, Rectification, Deletion }&lt;/code&gt;. Any other value throws an error. The request record is updated with the validated type.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: Evidence logging — REQUEST_CLASSIFIED&lt;/strong&gt;&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;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REQUEST_CLASSIFIED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_data"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deletion"&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;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-05-25T10:00:01.342Z"&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;The classification result and timestamp are immutable facts in the evidence record from this point forward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: AI reply drafting&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Claude drafts a response in the data principal's chosen language, using the classified request type and the original message as context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6: Evidence logging — REPLY_SUGGESTED&lt;/strong&gt;&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;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REPLY_SUGGESTED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_data"&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;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hindi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-sonnet-4-6"&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;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-05-25T10:00:02.891Z"&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;The entire pipeline — creation, classification, drafting — runs in under 5 seconds for a typical request. By the time an operator opens the inbox, the request is already classified, a draft reply exists, and the SLA clock has been running since submission.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Operator Inbox
&lt;/h3&gt;

&lt;p&gt;The inbox at &lt;code&gt;/&lt;/code&gt; is protected by authentication. It shows all requests for the active organisation, each with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request type (Grievance, Access, Rectification, Deletion, or PENDING if classification failed)&lt;/li&gt;
&lt;li&gt;Message preview&lt;/li&gt;
&lt;li&gt;Live SLA status (Within SLA / Due Soon / Overdue)&lt;/li&gt;
&lt;li&gt;Creation timestamp&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The SLA status is computed at read time — not stored as a cached value. The &lt;code&gt;computeSlaStatus&lt;/code&gt; function runs on every page load:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeSlaStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slaDueAt&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;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&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;due&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slaDueAt&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;diffHours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;due&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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="nx"&gt;diffHours&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OVERDUE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diffHours&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DUE_SOON&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WITHIN_SLA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The status shown in the inbox reflects the current moment — not the status at the last time the record was updated. A request that was &lt;code&gt;WITHIN_SLA&lt;/code&gt; yesterday is automatically &lt;code&gt;DUE_SOON&lt;/code&gt; or &lt;code&gt;OVERDUE&lt;/code&gt; today without any scheduled job or background worker.&lt;/p&gt;

&lt;p&gt;The inbox is sorted by SLA urgency by default, so operators see the most at-risk requests first.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Request Detail Page
&lt;/h3&gt;

&lt;p&gt;Clicking into any request shows everything an operator needs to review, respond, and close:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The original message&lt;/strong&gt; — verbatim, exactly as submitted. No interpretation layer between the operator and what the data principal actually wrote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The AI-drafted reply&lt;/strong&gt; — pre-populated with DPDP-compliant language in the data principal's chosen language. The operator can read it, edit it in the text area, and send it. The draft is a starting point, not a cage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The resolution checklist&lt;/strong&gt; — structured prompts for the operator to work through before closing the request:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Has the relevant data been located?&lt;/li&gt;
&lt;li&gt;Has the requested action (access/correction/deletion) been taken?&lt;/li&gt;
&lt;li&gt;Has the data principal been notified?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The evidence timeline&lt;/strong&gt; — every event in chronological order with timestamps, event types, and metadata.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The export controls&lt;/strong&gt; — one click to download the full evidence trail as PDF or CSV.&lt;/p&gt;

&lt;h3&gt;
  
  
  Marking a Reply as Sent
&lt;/h3&gt;

&lt;p&gt;When an operator sends the response (currently: manually via email or another channel, then clicks "Mark as Sent" in the tool), the system:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Updates the request &lt;code&gt;status&lt;/code&gt; to &lt;code&gt;CLOSED&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Logs &lt;code&gt;REPLY_SENT&lt;/code&gt; to the evidence table:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&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;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REPLY_SENT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
     &lt;/span&gt;&lt;span class="nl"&gt;"event_data"&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;"operator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"manual"&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;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2025-05-27T14:22:00.000Z"&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;The gap between &lt;code&gt;REQUEST_CREATED&lt;/code&gt; and &lt;code&gt;REPLY_SENT&lt;/code&gt; timestamps is the documented response time. If an auditor asks "how long did you take to respond to this erasure request?" — the answer is computable from the evidence log to the second.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 5: The Evidence Architecture
&lt;/h2&gt;

&lt;p&gt;The evidence design is the most important part of DPDP Copilot from a compliance standpoint. Everything else is workflow tooling. The evidence log is what you use when the Data Protection Board comes calling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Append-Only by Design
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;evidence_events&lt;/code&gt; table has no update or delete paths in the application. Once an event is written, it stays. There's no "edit evidence" API, no admin panel for removing events, no soft-delete flag.&lt;/p&gt;

&lt;p&gt;Audit evidence that can be modified isn't evidence; it's a story you're telling. An append-only log where every event has a database-generated timestamp (not an application-provided one) is as close to tamper-evident as you can get in a PostgreSQL-backed application.&lt;/p&gt;

&lt;p&gt;The schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;evidence_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_id&lt;/span&gt;  &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_data&lt;/span&gt;  &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;org_id&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;orgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;The &lt;code&gt;created_at&lt;/code&gt; field uses &lt;code&gt;DEFAULT now()&lt;/code&gt; — the database server's timestamp, not the application's &lt;code&gt;Date.now()&lt;/code&gt;. Database server clocks in a managed PostgreSQL instance are NTP-synchronized and authoritative. Application clocks can drift.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Four Event Types
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;REQUEST_CREATED&lt;/strong&gt; — logged at the moment of database insertion, before any processing. This is the legal timestamp of receipt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;REQUEST_CLASSIFIED&lt;/strong&gt; — logged immediately after the AI classification succeeds and the type is validated. Contains the classified type in &lt;code&gt;event_data&lt;/code&gt;. If classification fails and retries are exhausted, this event is not logged — the absence of this event tells you classification failed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;REPLY_SUGGESTED&lt;/strong&gt; — logged when the AI draft is written to the request record. Contains the language and model used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;REPLY_SENT&lt;/strong&gt; — logged when an operator marks the reply as sent. Contains the operator identity and channel. This closes the request lifecycle in the evidence log.&lt;/p&gt;

&lt;p&gt;The presence of all four events, in order, within the applicable window means the request was handled correctly from intake to response. An auditor reviewing the CSV export can verify this in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Organisation Scoping
&lt;/h3&gt;

&lt;p&gt;Every evidence event carries an &lt;code&gt;org_id&lt;/code&gt;. Every query on &lt;code&gt;evidence_events&lt;/code&gt; is scoped to the active organisation. A single deployment can serve multiple organisations, and their evidence trails are strictly isolated.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;org_id&lt;/code&gt; in evidence events is written by the application using the resolved organisation context — not passed in by the caller. A data principal submitting a request cannot specify or forge the organisation context; it's resolved server-side from the environment configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the Export Looks Like
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;CSV export&lt;/strong&gt; for a complete request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;event&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="k"&gt;created&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;at&lt;/span&gt;
&lt;span class="k"&gt;REQUEST&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;CREATED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="ld"&gt;2025-05-25T10:00:00&lt;/span&gt;&lt;span class="mf"&gt;.000&lt;/span&gt;&lt;span class="k"&gt;Z&lt;/span&gt;
&lt;span class="k"&gt;REQUEST&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;CLASSIFIED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="ld"&gt;2025-05-25T10:00:01&lt;/span&gt;&lt;span class="mf"&gt;.342&lt;/span&gt;&lt;span class="k"&gt;Z&lt;/span&gt;
&lt;span class="k"&gt;REPLY&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;SUGGESTED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="ld"&gt;2025-05-25T10:00:02&lt;/span&gt;&lt;span class="mf"&gt;.891&lt;/span&gt;&lt;span class="k"&gt;Z&lt;/span&gt;
&lt;span class="k"&gt;REPLY&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;SENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="ld"&gt;2025-05-27T14:22:00&lt;/span&gt;&lt;span class="mf"&gt;.000&lt;/span&gt;&lt;span class="k"&gt;Z&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four rows. Auditor reads it: request received Sunday 10:00 AM, responded Tuesday 2:22 PM — 52 hours, well within any reasonable response window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDF export&lt;/strong&gt; includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Organisation name and request ID&lt;/li&gt;
&lt;li&gt;Request type and creation timestamp&lt;/li&gt;
&lt;li&gt;Original message (verbatim)&lt;/li&gt;
&lt;li&gt;Suggested reply (the draft that was reviewed and sent)&lt;/li&gt;
&lt;li&gt;Full evidence timeline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The PDF is generated server-side using Puppeteer with Chromium. The HTML template is a known XSS risk in the current implementation (user-provided message text is interpolated directly into HTML) — the fix is explicit HTML escaping before interpolation, which is on the roadmap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 6: SLA Architecture — The Compliance Clock
&lt;/h2&gt;

&lt;p&gt;SLA management is where most compliance tools fail. They either track SLA status as a static database field (which becomes stale the moment the clock ticks past the deadline) or they rely on background jobs (which can fail silently and leave the status indicator wrong).&lt;/p&gt;

&lt;p&gt;DPDP Copilot takes a third approach: &lt;strong&gt;compute SLA status at read time, every time.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How the Internal SLA Clock Works
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;sla_due_at&lt;/code&gt; timestamp is written once, at request creation: &lt;code&gt;now() + sla_days&lt;/code&gt;. The default is 7 days — more conservative than the 30-day legal window, so normal review and approval cycles don't consume the entire legal budget. That's the only mutation to this field — it never changes after the request is created.&lt;/p&gt;

&lt;p&gt;On every inbox load, every request detail page load, the &lt;code&gt;computeSlaStatus(slaDueAt)&lt;/code&gt; function runs in the API layer:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;diffHours&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slaDueAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&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="nx"&gt;diffHours&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OVERDUE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diffHours&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DUE_SOON&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
                     &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;WITHIN_SLA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No database update. No background worker. No scheduled job. The status shown to the operator is always accurate as of the current server time.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;DUE_SOON&lt;/code&gt; triggers at 24 hours remaining — a one-day warning before the internal deadline. This gives operators a meaningful heads-up without creating false urgency days in advance.&lt;/p&gt;

&lt;h3&gt;
  
  
  Setting the Right Internal SLA for Your Organisation
&lt;/h3&gt;

&lt;p&gt;The DPDP Rules 2025 set a 30-day legal maximum for access/correction/erasure responses. How you set your internal target depends on your process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A startup where one person handles requests end-to-end: 7–10 days is achievable and leaves buffer&lt;/li&gt;
&lt;li&gt;A mid-size company where requests go through legal review and data lookup across multiple systems: 14–21 days as the internal target, with the legal 30-day window as the backstop&lt;/li&gt;
&lt;li&gt;A large enterprise with formal approval workflows: set the SLA to match your internal SLA policy; use the evidence log to track compliance with your own commitments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The configurable &lt;code&gt;orgs.sla_days&lt;/code&gt; field in the database — not yet wired to request creation in the current version, but in the roadmap — will let each organisation set its own target without changing code.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Status vs. SLA Distinction
&lt;/h3&gt;

&lt;p&gt;Early versions of DPDP Copilot conflated two concepts in a single field: the workflow status of the request (open, closed?) and the computed SLA urgency (within deadline?). The second database migration separates these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- migration 002_split_status_from_sla.sql&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'OPEN'&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt;
  &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;sla_status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'CLOSED'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'CLOSED'&lt;/span&gt;
  &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'OPEN'&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After this migration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;status&lt;/code&gt; is the workflow state: &lt;code&gt;OPEN&lt;/code&gt; or &lt;code&gt;CLOSED&lt;/code&gt;. Closed means a reply was sent and the request is resolved.&lt;/li&gt;
&lt;li&gt;The live SLA urgency is always computed by &lt;code&gt;computeSlaStatus&lt;/code&gt; at read time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This matters for reporting. You want to answer: "Of all requests that were open during the last month, what percentage were responded to within the internal SLA?" That question requires separating workflow state from deadline state.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 7: Multilingual Compliance at Scale
&lt;/h2&gt;

&lt;p&gt;The multilingual capability deserves more attention than it typically gets in discussions of DPDP tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Language Matters for DPDP
&lt;/h3&gt;

&lt;p&gt;India's 2011 census (the most recent with detailed language data) recorded 19,569 raw mother tongue entries from respondents — often cited as "over 19,500 languages spoken in some capacity" — which consolidate into 121 languages with more than 10,000 speakers each. The DPDP Act and Rules 2025 require that grievance mechanisms be accessible, which practically means: if your users write to you in Hindi, a response mechanism that only understands English is not accessible.&lt;/p&gt;

&lt;p&gt;DPDP Copilot supports five languages in the current version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;English&lt;/strong&gt; — the default, always available&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hindi&lt;/strong&gt; — 528 million speakers (2011 census)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bengali&lt;/strong&gt; — 97 million speakers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tamil&lt;/strong&gt; — 69 million speakers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marathi&lt;/strong&gt; — 83 million speakers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The public request form shows these as radio button options. The selection flows into the API request, through the drafting call, and into the AI prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the AI Draft Looks Like in Practice
&lt;/h3&gt;

&lt;p&gt;Here's the same erasure request processed in two languages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Input (English):&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I never gave consent for you to sell my data. Please delete everything you have about me immediately."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Suggested reply in English:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Dear Data Principal,&lt;/p&gt;

&lt;p&gt;Thank you for your request submitted on 25 May 2025. We have received and logged your request for erasure of personal data under Section 12(b) of the Digital Personal Data Protection Act, 2023.&lt;/p&gt;

&lt;p&gt;Your request has been assigned reference number [REF-ID]. Our compliance team will review your request, locate the relevant data, and initiate the erasure process in accordance with applicable legal requirements. We will respond with the outcome within the timeframe required under the DPDP Act.&lt;/p&gt;

&lt;p&gt;Please retain this acknowledgement for your records.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Suggested reply in Hindi:&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;प्रिय डेटा प्रिंसिपल,&lt;/p&gt;

&lt;p&gt;25 मई 2025 को प्रस्तुत आपके अनुरोध के लिए धन्यवाद। हमने डिजिटल व्यक्तिगत डेटा संरक्षण अधिनियम, 2023 की धारा 12(ख) के अंतर्गत आपके व्यक्तिगत डेटा के विलोपन के अनुरोध को प्राप्त कर दर्ज किया है।&lt;/p&gt;

&lt;p&gt;आपके अनुरोध को संदर्भ संख्या [REF-ID] दी गई है। हमारी अनुपालन टीम आपके अनुरोध की समीक्षा करेगी, संबंधित डेटा का पता लगाएगी और लागू कानूनी आवश्यकताओं के अनुसार विलोपन प्रक्रिया शुरू करेगी। हम डीपीडीपी अधिनियम के तहत निर्धारित समय-सीमा के भीतर आपको परिणाम की सूचना देंगे।&lt;/p&gt;

&lt;p&gt;कृपया इस पावती को अपने रिकॉर्ड के लिए सुरक्षित रखें।&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The structure is identical. The legal references are consistent. The tone is professional but accessible. An operator who reviews the Hindi draft can run it through a translation tool to verify quality before sending — the AI draft is a starting point, not a blindly trusted final output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Separate Prompts Per Language Matter
&lt;/h3&gt;

&lt;p&gt;A naive approach would translate a fixed English template into other languages once, then serve those static translations. This works for simple acknowledgements but fails for personalised responses that need to reference the specific request content.&lt;/p&gt;

&lt;p&gt;Because DPDP Copilot drafts replies by passing the original message to the model, the suggested reply can acknowledge specific details the data principal mentioned — not just their request type. If someone writes "I asked you to stop sending me SMS messages three months ago and you're still doing it," a good response acknowledges that history. A static template can't.&lt;/p&gt;

&lt;p&gt;The LLM approach generates a response that's contextually appropriate in the data principal's language — which is a qualitatively different outcome from translation.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 8: The Data Architecture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Schema Design for Compliance
&lt;/h3&gt;

&lt;p&gt;The database schema is designed around compliance requirements first, application convenience second.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Three tables, three responsibilities&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orgs&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;name&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;sla_days&lt;/span&gt;   &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;message&lt;/span&gt;         &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;type&lt;/span&gt;            &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt;          &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="s1"&gt;'OPEN'&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;suggested_reply&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;sla_due_at&lt;/span&gt;      &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;org_id&lt;/span&gt;          &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;orgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;      &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;evidence_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;          &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;request_id&lt;/span&gt;  &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;event_type&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;event_data&lt;/span&gt;  &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;org_id&lt;/span&gt;      &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;orgs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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;The &lt;code&gt;orgs.sla_days&lt;/code&gt; field exists and is populated but not yet wired to request creation — the 7-day hardcode is the current implementation. When that field is connected, different organisations can run different internal SLA targets. The schema is ready for that; the application code isn't yet.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;evidence_events.event_data&lt;/code&gt; field is &lt;code&gt;jsonb&lt;/code&gt; — flexible enough to store different metadata per event type without schema changes. As the tool evolves (new event types, operator attribution, channel tracking), existing rows aren't invalidated.&lt;/p&gt;

&lt;h3&gt;
  
  
  Index Strategy
&lt;/h3&gt;

&lt;p&gt;Two composite indexes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;requests_org_created_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;evidence_events_request_org_created_idx&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;evidence_events&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first index supports the inbox query: "give me all requests for this org, sorted by most recent." The second supports the request detail query: "give me all evidence events for this request in this org, in chronological order."&lt;/p&gt;

&lt;p&gt;Both indexes include &lt;code&gt;org_id&lt;/code&gt; as the leading column because every query in the application is org-scoped. An index that starts with &lt;code&gt;org_id&lt;/code&gt; is used by the query planner even for queries that also filter by &lt;code&gt;request_id&lt;/code&gt; — the org scope eliminates most of the table before the planner looks at other columns.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 9: Deployment Architecture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Self-Hosted by Design
&lt;/h3&gt;

&lt;p&gt;DPDP Copilot is self-hosted. That's a deliberate product decision, not an oversight.&lt;/p&gt;

&lt;p&gt;DPDP requests often contain sensitive personal data — names, contact details, account information, and sometimes sensitive categories of data like health information or financial details. The organisation processing these requests is the data fiduciary. Routing that data through a third-party SaaS for classification and storage creates its own compliance risk: you're a data processor, processing data principal requests by sending them to another data processor, with all the consent and data transfer implications that entails.&lt;/p&gt;

&lt;p&gt;Running the tool in your own infrastructure — whether on-premises or in a cloud account you control — keeps the data principal's message in your trust boundary. The only data that leaves your environment is the message text sent to Anthropic's API for classification and drafting. That's a single, scoped, auditable data transfer that you control.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker Compose Quickstart
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Clone and configure&lt;/span&gt;
git clone https://github.com/swapnanil/dpdp-copilot
&lt;span class="nb"&gt;cd &lt;/span&gt;dpdp-copilot
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env

&lt;span class="c"&gt;# .env minimum required:&lt;/span&gt;
&lt;span class="c"&gt;# ANTHROPIC_API_KEY=sk-ant-...&lt;/span&gt;
&lt;span class="c"&gt;# DATABASE_URL=postgresql://user:pass@db:5432/dpdp&lt;/span&gt;
&lt;span class="c"&gt;# ADMIN_USER=compliance_admin&lt;/span&gt;
&lt;span class="c"&gt;# ADMIN_PASS=your_secure_password&lt;/span&gt;
&lt;span class="c"&gt;# DEFAULT_ORG_ID=          # fill after running seed&lt;/span&gt;
&lt;span class="c"&gt;# ADMIN_SESSION_SECRET=    # openssl rand -hex 32&lt;/span&gt;

&lt;span class="c"&gt;# Start database&lt;/span&gt;
docker compose up db &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# Run migrations&lt;/span&gt;
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; migrate

&lt;span class="c"&gt;# Seed initial org (note the UUID it prints)&lt;/span&gt;
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; seed

&lt;span class="c"&gt;# Start the application&lt;/span&gt;
docker compose up app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://localhost:3000&lt;/code&gt; for the operator inbox.&lt;br&gt;
Open &lt;code&gt;http://localhost:3000/grievance&lt;/code&gt; for the public form.&lt;/p&gt;
&lt;h3&gt;
  
  
  Environment Configuration Reference
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variable&lt;/th&gt;
&lt;th&gt;Required&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;&lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Your Anthropic API key for Claude&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DATABASE_URL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;PostgreSQL connection string&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADMIN_USER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Operator login username&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADMIN_PASS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Operator login password&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEFAULT_ORG_ID&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;UUID of the active organisation (from seed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADMIN_SESSION_SECRET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;td&gt;Signs session cookies — &lt;code&gt;openssl rand -hex 32&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MODEL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Claude model (default: &lt;code&gt;claude-sonnet-4-6&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MAX_TOKENS&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Reply draft length (default: 1024)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PUPPETEER_EXECUTABLE_PATH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;td&gt;Chromium path — set automatically in Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h3&gt;
  
  
  Production Considerations
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Session signing&lt;/strong&gt;: Generate &lt;code&gt;ADMIN_SESSION_SECRET&lt;/code&gt; with &lt;code&gt;openssl rand -hex 32&lt;/code&gt;. In development you can skip this; in production the session cookie must be signed or it's trivially forgeable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database&lt;/strong&gt;: The Docker Compose setup runs Postgres in a container. For production, use a managed database (AWS RDS, Google Cloud SQL, Supabase) with automated backups. The evidence table is your legal record — you want it on infrastructure with point-in-time recovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTPS&lt;/strong&gt;: Run behind a reverse proxy (nginx, Caddy) that terminates TLS. Session cookies should have &lt;code&gt;Secure&lt;/code&gt; and &lt;code&gt;SameSite=Strict&lt;/code&gt; — these aren't set in the current implementation but are straightforward to add in a production nginx config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limiting&lt;/strong&gt;: The public &lt;code&gt;/grievance&lt;/code&gt; form has no rate limiting in the current version. A reverse proxy rate limit on the public intake endpoint prevents abuse without touching the application code.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 10: Known Limitations and What's Next
&lt;/h2&gt;

&lt;p&gt;Honesty about limitations is part of useful tooling documentation. Here's what DPDP Copilot currently doesn't do and what the roadmap looks like.&lt;/p&gt;
&lt;h3&gt;
  
  
  Current Limitations
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;No outbound delivery&lt;/strong&gt;: The "send reply" workflow doesn't actually send anything. It marks the reply as sent in the evidence log and sets the request to &lt;code&gt;CLOSED&lt;/code&gt;. The operator is responsible for actually sending the drafted reply via their existing channel (email, portal, etc.). This is a limitation of the MVP, not the design — real outbound email delivery is the obvious next step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single-admin authentication&lt;/strong&gt;: The current auth model is a single username/password pair from environment variables. There's no user table, no role model, no per-operator audit trail. Multiple operators can't be tracked individually. This is fine for a team of one; it's a problem for a compliance team of five.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static org configuration&lt;/strong&gt;: The active organisation is selected via &lt;code&gt;DEFAULT_ORG_ID&lt;/code&gt; in the environment. There's no UI for switching organisations or a multi-tenant router. The database schema supports multiple orgs; the application routing doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No structured contact data&lt;/strong&gt;: Contact information is embedded in the free-form message. There's no &lt;code&gt;contact_email&lt;/code&gt; or &lt;code&gt;contact_phone&lt;/code&gt; field. This means there's no reliable way to programmatically address the data principal in the reply or route the response to them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDF XSS risk&lt;/strong&gt;: The PDF template interpolates user-provided text directly into HTML without escaping. A malicious actor could potentially inject HTML into the generated PDF. This is a known issue and is the highest-priority security fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No notifications&lt;/strong&gt;: Operators have no way to be alerted when a new request comes in or when a request is approaching its internal SLA deadline. Checking the inbox manually is the only current mechanism.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Roadmap
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Outbound reply delivery&lt;/strong&gt;: Send the drafted reply via email (SendGrid, AWS SES, or SMTP) directly from the tool. Logs the delivery event to the evidence table. The operator reviews the draft, edits if needed, and clicks Send — not "Copy this and email it manually."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SLA alerts&lt;/strong&gt;: Email or Slack notification when a request enters &lt;code&gt;DUE_SOON&lt;/code&gt; status. Optional daily digest of all open requests with their current SLA status.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-operator support&lt;/strong&gt;: A &lt;code&gt;users&lt;/code&gt; table, per-operator login, and role assignment (reviewer vs. approver). Evidence events attributed to specific operators. Audit trail for who touched what.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured contact fields&lt;/strong&gt;: Separate &lt;code&gt;contact_email&lt;/code&gt; from the message body at intake. Validate format. Apply retention controls — contact data should be deletable when the request is closed without deleting the evidence trail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configurable SLA&lt;/strong&gt;: Wire &lt;code&gt;orgs.sla_days&lt;/code&gt; to request creation. Different organisations have different internal SLA commitments — the schema already supports this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Approval workflow&lt;/strong&gt;: A draft reply requires supervisor approval before it can be sent. The evidence log records who approved and when. This is an operational pattern for organisations where a junior compliance analyst drafts but a senior officer approves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analytics dashboard&lt;/strong&gt;: How many requests per week? What types? Average response time? What percentage are within the internal SLA? This is a reporting requirement for any compliance programme worth its name.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 11: How DPDP Copilot Fits Into a Broader Compliance Programme
&lt;/h2&gt;

&lt;p&gt;DPDP Copilot handles the data principal rights workflow. That's one piece of a complete DPDP compliance programme. Here's how it fits:&lt;/p&gt;
&lt;h3&gt;
  
  
  What DPDP Copilot Covers
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Receiving data principal requests (Access, Rectification, Deletion, Grievance)&lt;/li&gt;
&lt;li&gt;Classifying them correctly and consistently&lt;/li&gt;
&lt;li&gt;Drafting multilingual responses&lt;/li&gt;
&lt;li&gt;Tracking internal SLA deadlines&lt;/li&gt;
&lt;li&gt;Generating the audit evidence trail&lt;/li&gt;
&lt;li&gt;Exporting evidence for regulatory review&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  What It Doesn't Cover
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data discovery&lt;/strong&gt;: Finding where a person's data actually lives across your systems. DPDP Copilot receives and tracks the request but doesn't automate the underlying data lookup. That's a data catalogue problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consent management&lt;/strong&gt;: Recording and tracking what data was collected under what consent. That's a separate consent registry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy notices&lt;/strong&gt;: Generating or maintaining the notice required under Section 5 of the Act. That's a legal document workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data breach notification&lt;/strong&gt;: Section 8(6) requires prompt notification of significant breaches to the Data Protection Board and affected persons. That's a separate incident response workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-border transfer compliance&lt;/strong&gt;: The Act restricts transfers of personal data to certain countries. That's a data governance and infrastructure question.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A full DPDP compliance programme needs all of these. DPDP Copilot handles the rights management piece — the part that creates the most immediate operational urgency because it has a hard deadline on individual transactions and a direct escalation path to the Data Protection Board.&lt;/p&gt;
&lt;h3&gt;
  
  
  The Risk Reduction Calculation
&lt;/h3&gt;

&lt;p&gt;Before DPDP Copilot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Time to acknowledge a request: hours to days (depends on inbox monitoring)&lt;/li&gt;
&lt;li&gt;Time to classify a request: manual, inconsistent, language-dependent&lt;/li&gt;
&lt;li&gt;Time to draft a response: hours (finding a template, adapting it, translating it)&lt;/li&gt;
&lt;li&gt;Deadline tracking: none — someone has to remember&lt;/li&gt;
&lt;li&gt;Evidence: none — email threads that can be deleted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After DPDP Copilot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Time to acknowledge: seconds (the evidence log records receipt immediately on submission)&lt;/li&gt;
&lt;li&gt;Time to classify: 1–2 seconds (LLM call)&lt;/li&gt;
&lt;li&gt;Time to draft a response: 2–3 seconds (LLM call)&lt;/li&gt;
&lt;li&gt;Deadline tracking: automatic, live-computed, visible in the operator inbox&lt;/li&gt;
&lt;li&gt;Evidence: append-only database log, exportable as PDF or CSV in one click&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reduction in time-to-first-action is the most important improvement. The legal clock starts when the request is submitted — not when someone reads it. DPDP Copilot ensures that classification and drafting are done before any human even opens the inbox. The operator's job is review and send, not receive-classify-draft-send.&lt;/p&gt;


&lt;h2&gt;
  
  
  Part 12: Who Should Use This
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Compliance and legal teams at Indian companies&lt;/strong&gt; processing personal data of Indian residents under the DPDP Act. If you're a data fiduciary — collecting or processing personal data — you have obligations under this Act. If you don't have a structured process for handling data principal requests, you need one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Engineering teams building privacy infrastructure&lt;/strong&gt; who need a reference implementation of DPDP request handling. The codebase is open-source. The data model, the API structure, the evidence logging pattern, the SLA computation logic — all of it is readable, runnable, and adaptable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Startups at the early compliance stage&lt;/strong&gt; who don't yet have a dedicated compliance team. The tool runs on a single machine. Configuration is a &lt;code&gt;.env&lt;/code&gt; file. The public form can be linked from your privacy policy. You don't need a compliance department to run it — you need someone who checks the inbox.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Organisations handling multilingual Indian user bases&lt;/strong&gt; where an English-only inbox isn't accessible to all the people it's supposed to serve. If your users write to you in Hindi and Tamil, they deserve responses in Hindi and Tamil — and the time cost of manual translation has historically made that impractical. It isn't anymore.&lt;/p&gt;


&lt;h2&gt;
  
  
  A Complete Example Walkthrough
&lt;/h2&gt;

&lt;p&gt;Let me walk through a real scenario end-to-end, using the tool as a data principal and then as an operator.&lt;/p&gt;
&lt;h3&gt;
  
  
  As the Data Principal
&lt;/h3&gt;

&lt;p&gt;You purchased something from a company. You're now getting SMS marketing messages you didn't opt in to. You want to file an erasure request and a grievance.&lt;/p&gt;

&lt;p&gt;You go to &lt;code&gt;https://yourcompany.com/grievance&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You write:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I never gave you permission to send me SMS promotions. I want you to delete my phone number and all data you hold about me. I also want to formally complain about this."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You select &lt;strong&gt;Hindi&lt;/strong&gt; as your preferred language and submit.&lt;/p&gt;

&lt;p&gt;You receive an acknowledgement: "Your request has been received and logged. Reference: [UUID]. Our compliance team will be in touch with the outcome."&lt;/p&gt;
&lt;h3&gt;
  
  
  As the Compliance Operator
&lt;/h3&gt;

&lt;p&gt;You open the operator inbox the next morning. You see a new request, classified as &lt;strong&gt;Grievance&lt;/strong&gt; (the model detected the formal complaint language alongside the deletion request), with &lt;code&gt;WITHIN_SLA&lt;/code&gt; status.&lt;/p&gt;

&lt;p&gt;You click into the request. You read the original message. The suggested reply in Hindi is already drafted. You read it — it acknowledges the complaint, confirms the erasure request has been noted, and explains next steps in Hindi.&lt;/p&gt;

&lt;p&gt;You make a small edit to reference your company's specific erasure process. You click "Send Reply" — which in the current version means you copy the draft, send it via your email system, and then click "Mark as Sent" in the tool.&lt;/p&gt;

&lt;p&gt;The evidence timeline now shows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;REQUEST_CREATED     2025-05-25 10:00:00
REQUEST_CLASSIFIED  2025-05-25 10:00:01  (Grievance)
REPLY_SUGGESTED     2025-05-25 10:00:02
REPLY_SENT          2025-05-26 09:15:00
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Total response time: 23 hours. Well within any reasonable SLA window. The CSV export documents this. If the data principal escalates to the Data Protection Board, you have a timestamped, exportable record of the complete interaction.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick Reference
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Public form&lt;/strong&gt;: &lt;code&gt;GET /grievance&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API Endpoints&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Auth&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;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/public/requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Submit a data principal request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/login&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Operator login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/logout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Operator logout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/requests&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Operator&lt;/td&gt;
&lt;td&gt;List all requests with live SLA status&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/requests/:id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Operator&lt;/td&gt;
&lt;td&gt;Request detail + evidence timeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;POST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/requests/:id/send-reply&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Operator&lt;/td&gt;
&lt;td&gt;Mark reply sent, close request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/requests/:id/export/pdf&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Operator&lt;/td&gt;
&lt;td&gt;Download PDF evidence report&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/api/requests/:id/export/csv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Operator&lt;/td&gt;
&lt;td&gt;Download CSV evidence export&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Request lifecycle&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Public form submission
  → Internal SLA clock starts (configurable, default 7 days)
  → REQUEST_CREATED logged
  → AI classification (Grievance / Access / Rectification / Deletion)
  → REQUEST_CLASSIFIED logged
  → AI reply drafted in chosen language
  → REPLY_SUGGESTED logged
  → Operator reviews in inbox
  → Operator marks reply sent
  → REPLY_SENT logged
  → Request status: CLOSED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Legal response windows under DPDP Rules 2025&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request type&lt;/th&gt;
&lt;th&gt;Section&lt;/th&gt;
&lt;th&gt;Legal window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Access&lt;/td&gt;
&lt;td&gt;Section 11&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Correction / Erasure&lt;/td&gt;
&lt;td&gt;Section 12&lt;/td&gt;
&lt;td&gt;30 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Grievance&lt;/td&gt;
&lt;td&gt;Section 13&lt;/td&gt;
&lt;td&gt;90 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Final Thought
&lt;/h2&gt;

&lt;p&gt;The DPDP Act's data principal rights framework isn't complicated. Four rights, two response windows, one evidence requirement. The complexity is operational — handling a high-variance stream of natural language requests, in multiple languages, against a hard time constraint, with an audit trail that has to survive regulatory scrutiny.&lt;/p&gt;

&lt;p&gt;Manual processes fail under those conditions not because of negligence but because the requirements are genuinely hard to satisfy with shared inboxes and email chains.&lt;/p&gt;

&lt;p&gt;DPDP Copilot automates the classification and drafting — the two tasks that are the most time-consuming and the most error-prone. It makes the internal SLA clock visible before it expires. It generates the audit evidence as a byproduct of normal operation, not as a separate reporting task.&lt;/p&gt;

&lt;p&gt;The tool is open-source, self-hosted, and runs on a single Docker Compose command. If you're an Indian company with DPDP obligations and no structured data rights workflow, this is where to start.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://swapnanilsaha.com/tools/dpdp-copilot/" rel="noopener noreferrer"&gt;→ View the full tool page, docs, live demo, and GitHub repo&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built by Swapnanil Saha — &lt;a href="https://swapnanilsaha.com" rel="noopener noreferrer"&gt;swapnanilsaha.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dpdp</category>
      <category>compliance</category>
      <category>dataprotection</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to Stop Evaluating LLM Outputs by Gut Feel</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Thu, 21 May 2026 05:25:31 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/how-to-stop-evaluating-llm-outputs-by-gut-feel-ml9</link>
      <guid>https://dev.to/swapnanilsaha/how-to-stop-evaluating-llm-outputs-by-gut-feel-ml9</guid>
      <description>&lt;p&gt;The standard workflow for evaluating LLM output quality goes something like this: someone reads Response A, reads Response B, and says "I think A is better." Everyone nods. The prompt ships.&lt;/p&gt;

&lt;p&gt;This is a problem for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't scale.&lt;/strong&gt; You can't manually review 500 eval pairs after every prompt change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's inconsistent.&lt;/strong&gt; The same person evaluating the same pair on different days produces different results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't tell you why.&lt;/strong&gt; "Response A is better" doesn't tell you what to fix when Response B becomes the baseline.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I built &lt;strong&gt;LLM Eval Suite&lt;/strong&gt; to replace gut feel with structured, evidence-backed scoring — for any task type, with CI integration.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://swapnanilsaha.com/tools/llm-eval-suite/" rel="noopener noreferrer"&gt;→ Full tool page&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Core Insight: Evidence, Not Opinion
&lt;/h2&gt;

&lt;p&gt;Every score in LLM Eval Suite is accompanied by a verbatim quote from the response being evaluated. Not "this response has poor faithfulness" — but:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Faithfulness: 1.0/10
Quote: "30-day return policy, no questions asked"
Reasoning: "Source document specifies 14 days. This is a clear hallucination, not an interpretation."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This changes what you can do with the output. You can show it to a stakeholder. You can track it over time. You can build a regression test from it. You can tell the model what specifically went wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  Six Evaluation Capabilities
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Multi-Dimensional Scoring
&lt;/h3&gt;

&lt;p&gt;Ten task presets — QA, summarisation, RAG, code generation, creative writing, classification, translation, and more. Each preset activates the dimensions that matter for that task:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task Type&lt;/th&gt;
&lt;th&gt;Key Dimensions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;qa&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Faithfulness, Completeness, Conciseness, Relevance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;summarisation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Coverage, Compression, Accuracy, Readability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rag&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Faithfulness, Answer Relevancy, Context Precision, Context Recall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Correctness, Efficiency, Readability, Security&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every dimension score comes with verbatim evidence from the response text.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose run cli &lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; examples/eval_qa.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mode&lt;/span&gt; compare &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; markdown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Regression Testing
&lt;/h3&gt;

&lt;p&gt;Save any eval report as a named baseline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose run cli regression save results.json &lt;span class="nt"&gt;--id&lt;/span&gt; prod-baseline
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run future evals against it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose run cli regression run results.json &lt;span class="nt"&gt;--id&lt;/span&gt; prod-baseline &lt;span class="nt"&gt;--format&lt;/span&gt; markdown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-dimension deltas are compared against configurable thresholds. &lt;strong&gt;Exit code 1 when scores drop below your floor.&lt;/strong&gt; This is the feature that makes the tool useful in CI.&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Actions Integration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run LLM eval&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;docker-compose run cli eval \&lt;/span&gt;
      &lt;span class="s"&gt;--file evals/suite.json \&lt;/span&gt;
      &lt;span class="s"&gt;--mode rank \&lt;/span&gt;
      &lt;span class="s"&gt;--format junit \&lt;/span&gt;
      &lt;span class="s"&gt;--output results.xml&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mikepenz/action-junit-report@v3&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;report_paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;results.xml&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Regression check&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;docker-compose run cli regression run \&lt;/span&gt;
      &lt;span class="s"&gt;results.json --id prod-baseline&lt;/span&gt;
    &lt;span class="s"&gt;# exits 1 if any dimension drops beyond threshold&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gates model upgrades, prompt changes, and fine-tune releases automatically. The JUnit XML output integrates with any CI system that understands test reports.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hallucination Detection
&lt;/h3&gt;

&lt;p&gt;Claim-level analysis against a source document. Each claim in the response is classified as supported or unsupported — binary, not "mostly faithful."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose run cli hallucination &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--response&lt;/span&gt; output.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source&lt;/span&gt; source.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; markdown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Risk levels: none / low / moderate / high / critical, with a &lt;code&gt;safe_to_use&lt;/code&gt; boolean for downstream gating. This is what you run before using LLM output in a production pipeline where accuracy matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Example output:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;hallucination_risk&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;span class="na"&gt;safe_to_use&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;Claim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30-day&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;return&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;policy"&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unsupported&lt;/span&gt;
  &lt;span class="na"&gt;evidence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Source&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;specifies&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;14&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;days"&lt;/span&gt;
  &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;

&lt;span class="na"&gt;Claim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;questions&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;asked"&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unsupported&lt;/span&gt;
  &lt;span class="na"&gt;evidence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Source&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;makes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mention&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;of&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;return&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;conditions"&lt;/span&gt;
  &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Prompt Sensitivity Analysis
&lt;/h3&gt;

&lt;p&gt;Test 2–5 prompt variants against a fixed response. Per-dimension variance tells you which dimensions are fragile across phrasings and which are stable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose run cli sensitivity &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; examples/prompt_variants.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; markdown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Know which prompt phrasings shift your scores before you deploy. High-variance dimensions across prompts signal that your evaluation isn't measuring the response — it's measuring the prompt wording.&lt;/p&gt;

&lt;h3&gt;
  
  
  Panel Evaluation
&lt;/h3&gt;

&lt;p&gt;Run N independent judge passes on the same evaluation. Mean and variance per dimension expose where judges agree and where they disagree.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker-compose run cli panel &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; examples/eval_qa.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--judges&lt;/span&gt; 5 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt; markdown
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;High-variance dimensions are flagged for human review automatically. The panel mode is the right choice when you're evaluating subjective tasks like creative writing where a single judge's opinion is insufficient signal.&lt;/p&gt;

&lt;h3&gt;
  
  
  RAGAS-Compatible RAG Preset
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;rag&lt;/code&gt; task type maps the four RAGAS metrics — &lt;strong&gt;faithfulness, answer relevancy, context precision, context recall&lt;/strong&gt; — as first-class evaluation dimensions with equal weighting. The output is compatible with RAGAS reporting conventions, so you can integrate this into existing RAGAS workflows or use it as a drop-in alternative.&lt;/p&gt;




&lt;h2&gt;
  
  
  Example: Two Responses In, Clear Winner Out
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Input:&lt;/strong&gt;&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;"task_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"qa"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"eval_mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"compare"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Refunds are accepted within 14 days if the item is unused."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"responses"&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;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Response A"&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;"You can get a refund within 14 days if the item hasn't been used."&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;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Response B"&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;"Our 30-day return policy means no questions asked."&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;&lt;strong&gt;Output:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;winner&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Response A&lt;/span&gt;
&lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;clear&lt;/span&gt;

&lt;span class="s"&gt;Response B — Faithfulness&lt;/span&gt;
  &lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.0/10&lt;/span&gt;
  &lt;span class="s"&gt;quote&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;30-day&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;return&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;policy,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;questions&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;asked"&lt;/span&gt;
  &lt;span class="na"&gt;reasoning&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Source&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;specifies&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;14&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;days.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;'No&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;questions&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;asked'&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;not&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;source.&lt;/span&gt;
              &lt;span class="s"&gt;Two&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;distinct&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hallucinations&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;one&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sentence."&lt;/span&gt;

&lt;span class="s"&gt;Response A — Faithfulness&lt;/span&gt;
  &lt;span class="s"&gt;score&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;9.5/10&lt;/span&gt;
  &lt;span class="s"&gt;quote&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;within&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;14&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;days&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;if&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;item&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;hasn't&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;been&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;used"&lt;/span&gt;
  &lt;span class="na"&gt;reasoning&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accurately&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;paraphrases&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;the&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;with&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;additions."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why This Matters in Production
&lt;/h2&gt;

&lt;p&gt;LLM evaluation is usually treated as a one-time concern — you evaluate before you ship. But models change, prompts drift, data distributions shift, and retrieval quality fluctuates. A system that was 90% faithful in January may be 75% faithful in April because the upstream data changed.&lt;/p&gt;

&lt;p&gt;The regression testing and CI integration in LLM Eval Suite are designed for this reality. You run evals continuously, not just at release time. The baseline is the floor — if you drop below it, the pipeline stops.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://swapnanilsaha.com/tools/llm-eval-suite/" rel="noopener noreferrer"&gt;→ View the full tool page, docs, and GitHub repo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>testing</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Stop Getting 'It Depends' Answers About RAG Architecture</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Thu, 21 May 2026 05:09:30 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/stop-getting-it-depends-answers-about-rag-architecture-1em7</link>
      <guid>https://dev.to/swapnanilsaha/stop-getting-it-depends-answers-about-rag-architecture-1em7</guid>
      <description>&lt;p&gt;Ask five AI engineers which vector database to use for your RAG system. You'll get five different answers, and they'll all start with "it depends."&lt;/p&gt;

&lt;p&gt;It depends on your data volume. It depends on your query patterns. It depends on whether you need GDPR compliance. It depends on your team's infra maturity. It depends on your budget. It depends on whether you're doing hybrid search.&lt;/p&gt;

&lt;p&gt;The "it depends" answer is technically correct and operationally useless. It turns an architecture decision into an unbounded research project.&lt;/p&gt;

&lt;p&gt;I built &lt;strong&gt;RAG Readiness&lt;/strong&gt; to make one specific recommendation per component — and explain why.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://swapnanilsaha.com/tools/rag-readiness/" rel="noopener noreferrer"&gt;→ Full tool page&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The Design Principle: Opinions, Not Options
&lt;/h2&gt;

&lt;p&gt;Most RAG tooling and documentation presents you with a comparison table. Pinecone vs. Weaviate vs. Qdrant vs. Chroma. BM25 vs. dense vs. hybrid. ada-002 vs. text-embedding-3-large.&lt;/p&gt;

&lt;p&gt;Comparison tables are useful if you already know which dimensions matter for your use case. They're paralyzing if you don't.&lt;/p&gt;

&lt;p&gt;RAG Readiness is opinionated by design. You describe your use case, your data, your constraints. The tool returns &lt;strong&gt;one choice per component&lt;/strong&gt; — with full reasoning.&lt;/p&gt;

&lt;p&gt;If GDPR applies, managed cloud vector databases are eliminated from consideration before the LLM is even called. That's a rule, not an LLM judgment. The recommendation you receive is already constraint-filtered.&lt;/p&gt;




&lt;h2&gt;
  
  
  Six Modes, One Tool
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Architecture Recommendation
&lt;/h3&gt;

&lt;p&gt;The core mode. Answer a structured set of questions about your use case — document types, query patterns, scale, compliance requirements, team capabilities. Get back:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vector database&lt;/strong&gt;: one specific choice with rationale&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedding model&lt;/strong&gt;: one specific choice&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunking strategy&lt;/strong&gt;: one specific approach with parameters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval method&lt;/strong&gt;: dense / BM25 / hybrid — one answer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reranker&lt;/strong&gt;: whether you need one and which
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py audit &lt;span class="nt"&gt;--interactive&lt;/span&gt;
&lt;span class="c"&gt;# or from file:&lt;/span&gt;
python main.py audit &lt;span class="nt"&gt;--file&lt;/span&gt; examples/usecase_legal_contracts.json &lt;span class="nt"&gt;--with-cost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Architecture Diagnosis
&lt;/h3&gt;

&lt;p&gt;You already have a RAG system. It's not working. This mode takes your existing architecture and the problems you're seeing, and returns a root-cause analysis per component with severity levels and one specific fix.&lt;/p&gt;

&lt;p&gt;Not "improve your chunking" — "switch from fixed 512-token chunks to parent-child hierarchical chunking with 512-token child nodes. Your documents have multi-clause structure that fixed chunks split mid-sentence."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py diagnose &lt;span class="nt"&gt;--file&lt;/span&gt; examples/diagnosis_pinecone_fixed.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Example output:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;overall_severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;critical&lt;/span&gt;

&lt;span class="s"&gt;chunking_strategy — critical&lt;/span&gt;
  &lt;span class="s"&gt;"Fixed 512-token chunks split mid-clause in long legal documents"&lt;/span&gt;
  &lt;span class="s"&gt;Fix&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Parent-child hierarchical chunking, 512-token child nodes&lt;/span&gt;

&lt;span class="s"&gt;retrieval_method — high&lt;/span&gt;
  &lt;span class="s"&gt;"Dense-only misses exact terms like dollar amounts and clause references"&lt;/span&gt;
  &lt;span class="s"&gt;Fix&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Hybrid BM25 + dense with RRF fusion&lt;/span&gt;

&lt;span class="na"&gt;quick_fix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Enable 10% token overlap today. Takes 20 minutes, reduces&lt;/span&gt;
           &lt;span class="s"&gt;the worst failures while you implement the full fix.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Multi-Use-Case Session
&lt;/h3&gt;

&lt;p&gt;Run up to 5 parallel audits in a single request — useful when you're scoping a RAG platform that needs to serve multiple internal teams.&lt;/p&gt;

&lt;p&gt;The output includes cross-cutting insights: which components can be shared across use cases, where requirements conflict (the legal team needs GDPR-compliant storage; the sales team wants managed cloud), and which use case to build first for the highest return on the shared infrastructure investment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Implementation Bundle
&lt;/h3&gt;

&lt;p&gt;Once you have an architecture you trust, generate a complete implementation starter kit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py bundle &amp;lt;session-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output: a &lt;code&gt;requirements.txt&lt;/code&gt;, &lt;code&gt;docker-compose.yml&lt;/code&gt;, &lt;code&gt;.env.example&lt;/code&gt;, and migration guide tailored to the recommended architecture. If you have an existing stack, you get ordered migration steps with rollback notes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cost Estimation
&lt;/h3&gt;

&lt;p&gt;Rule-based monthly cost breakdown per component — &lt;strong&gt;no LLM call&lt;/strong&gt;. Lookup tables for vector DB pricing tiers, embedding API costs, reranker inference, and LLM costs at your estimated query volume.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py cost &amp;lt;session-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Returns a line-item breakdown, optimization tips (e.g., "switching to a self-hosted embedding model saves ~$800/month at this query volume"), and a hosting model classification (managed vs. self-hosted trade-off at your scale).&lt;/p&gt;

&lt;h3&gt;
  
  
  RAGAS Eval Dataset Generation
&lt;/h3&gt;

&lt;p&gt;Generate evaluation questions grounded in your actual use case and query patterns — not generic retrieval questions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py eval-dataset &amp;lt;session-id&amp;gt; &lt;span class="nt"&gt;--num-questions&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output includes easy/medium/hard distribution, RAGAS metric mapping (which questions test faithfulness vs. answer relevancy vs. context precision), an annotation guide, and a time estimate for human review.&lt;/p&gt;




&lt;h2&gt;
  
  
  Session Persistence and Refinement
&lt;/h2&gt;

&lt;p&gt;Every audit persists to SQLite. You can refine against new constraints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py refine &amp;lt;session-id&amp;gt; &lt;span class="nt"&gt;--feedback&lt;/span&gt; &lt;span class="s2"&gt;"Qdrant was too heavy for our infra team"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tool re-runs with the feedback as an additional constraint. Refinement history is tracked — you can see how the recommendation evolved across iterations.&lt;/p&gt;




&lt;h2&gt;
  
  
  A Complete Quickstart
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/swapnanil/rag-readiness
&lt;span class="nb"&gt;cd &lt;/span&gt;rag-readiness
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env  &lt;span class="c"&gt;# add your ANTHROPIC_API_KEY&lt;/span&gt;
docker-compose up api

&lt;span class="c"&gt;# New architecture audit (interactive)&lt;/span&gt;
python main.py audit &lt;span class="nt"&gt;--interactive&lt;/span&gt;

&lt;span class="c"&gt;# Diagnose a broken stack&lt;/span&gt;
python main.py diagnose &lt;span class="nt"&gt;--interactive&lt;/span&gt;

&lt;span class="c"&gt;# Multi-use-case session&lt;/span&gt;
python main.py multi-audit examples/multi_usecase_lexvault.json

&lt;span class="c"&gt;# List sessions and refine&lt;/span&gt;
python main.py sessions
python main.py refine &amp;lt;session-id&amp;gt; &lt;span class="nt"&gt;--feedback&lt;/span&gt; &lt;span class="s2"&gt;"need self-hosted only"&lt;/span&gt;

&lt;span class="c"&gt;# Cost breakdown and eval dataset&lt;/span&gt;
python main.py cost &amp;lt;session-id&amp;gt;
python main.py eval-dataset &amp;lt;session-id&amp;gt; &lt;span class="nt"&gt;--num-questions&lt;/span&gt; 20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The Pre-Scoring Layer
&lt;/h2&gt;

&lt;p&gt;Before any LLM call, a rule-based pre-scorer computes a complexity score (1–10) from the use case inputs. This has two effects:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It calibrates the LLM prompt — a complexity-1 use case gets a simpler, more direct recommendation; a complexity-9 use case gets a recommendation with more explicit trade-off reasoning.&lt;/li&gt;
&lt;li&gt;It runs conflict detection — if your inputs contain contradictory constraints (e.g., "GDPR compliant" + "use Pinecone"), the conflict is flagged before the LLM is called, not discovered in the output.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI engineers&lt;/strong&gt; starting a new RAG project who want a structured starting point rather than a blank page&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engineering leads&lt;/strong&gt; who need to scope a RAG system for a business use case and justify the architecture choices to non-technical stakeholders&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teams with an existing RAG system&lt;/strong&gt; that isn't performing as expected and need a systematic diagnosis, not a hunch&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tool is open-source, runs locally, and persists everything to SQLite. Your use case details don't leave your environment beyond the single LLM API call per audit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://swapnanilsaha.com/tools/rag-readiness/" rel="noopener noreferrer"&gt;→ View the full tool page, docs, and GitHub repo&lt;/a&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>ai</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building Distributed Systems, Backend Infrastructure &amp; AI Platforms — My Engineering Journey</title>
      <dc:creator>Swapnanil Saha</dc:creator>
      <pubDate>Tue, 19 May 2026 09:34:58 +0000</pubDate>
      <link>https://dev.to/swapnanilsaha/building-distributed-systems-backend-infrastructure-ai-platforms-my-engineering-journey-54p3</link>
      <guid>https://dev.to/swapnanilsaha/building-distributed-systems-backend-infrastructure-ai-platforms-my-engineering-journey-54p3</guid>
      <description>&lt;p&gt;Hey everyone 👋&lt;/p&gt;

&lt;p&gt;I’m &lt;a href="https://swapnanilsaha.com" rel="noopener noreferrer"&gt;Swapnanil Saha&lt;/a&gt;, a backend and distributed systems engineer from Mumbai, India with 9+ years of experience building high-performance infrastructure systems, backend platforms, optimization pipelines, and AI-driven architectures.&lt;/p&gt;

&lt;p&gt;🌐 Website: &lt;a href="https://swapnanilsaha.com" rel="noopener noreferrer"&gt;swapnanilsaha.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;💻 GitHub: &lt;a href="https://github.com/swapnanil" rel="noopener noreferrer"&gt;github.com/swapnanil&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🔗 LinkedIn: &lt;a href="https://www.linkedin.com/in/swapnanil/" rel="noopener noreferrer"&gt;linkedin.com/in/swapnanil&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Work On
&lt;/h2&gt;

&lt;p&gt;Over the years, I’ve worked on systems involving:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Large-scale distributed backend systems&lt;/li&gt;
&lt;li&gt;Low-latency request processing&lt;/li&gt;
&lt;li&gt;High-QPS APIs&lt;/li&gt;
&lt;li&gt;Real-time optimization systems&lt;/li&gt;
&lt;li&gt;ML/CVR prediction pipelines&lt;/li&gt;
&lt;li&gt;AI infrastructure and automation tooling&lt;/li&gt;
&lt;li&gt;Performance engineering and infra optimization&lt;/li&gt;
&lt;li&gt;Data-intensive backend architectures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of my work has focused on solving problems where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;scalability matters,&lt;/li&gt;
&lt;li&gt;latency matters,&lt;/li&gt;
&lt;li&gt;reliability matters,&lt;/li&gt;
&lt;li&gt;and infrastructure efficiency matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I enjoy working on systems that operate under real production constraints and require practical engineering tradeoffs at scale.&lt;/p&gt;




&lt;h1&gt;
  
  
  Areas I’m Currently Exploring
&lt;/h1&gt;

&lt;p&gt;Lately, I’ve been spending more time exploring the intersection of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI systems&lt;/li&gt;
&lt;li&gt;distributed infrastructure&lt;/li&gt;
&lt;li&gt;backend optimization&lt;/li&gt;
&lt;li&gt;and production-scale ML platforms.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some areas I’m currently interested in:&lt;/p&gt;

&lt;h2&gt;
  
  
  AI Infrastructure
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;inference systems&lt;/li&gt;
&lt;li&gt;orchestration&lt;/li&gt;
&lt;li&gt;model serving&lt;/li&gt;
&lt;li&gt;prompt pipelines&lt;/li&gt;
&lt;li&gt;scalable AI tooling&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  ML &amp;amp; Prediction Systems
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;CVR prediction systems&lt;/li&gt;
&lt;li&gt;feature pipelines&lt;/li&gt;
&lt;li&gt;production inference flows&lt;/li&gt;
&lt;li&gt;experimentation frameworks&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Backend &amp;amp; Distributed Systems
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;concurrency&lt;/li&gt;
&lt;li&gt;caching systems&lt;/li&gt;
&lt;li&gt;event-driven architectures&lt;/li&gt;
&lt;li&gt;reliability engineering&lt;/li&gt;
&lt;li&gt;observability&lt;/li&gt;
&lt;li&gt;scaling strategies&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Tech Stack
&lt;/h1&gt;

&lt;p&gt;Some technologies I frequently work with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python&lt;/li&gt;
&lt;li&gt;Java&lt;/li&gt;
&lt;li&gt;Redis&lt;/li&gt;
&lt;li&gt;SQL&lt;/li&gt;
&lt;li&gt;Linux systems&lt;/li&gt;
&lt;li&gt;REST/gRPC architectures&lt;/li&gt;
&lt;li&gt;Distributed caching systems&lt;/li&gt;
&lt;li&gt;Cloud infrastructure&lt;/li&gt;
&lt;li&gt;Performance optimization tooling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Currently spending more time improving my:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Go&lt;/li&gt;
&lt;li&gt;Rust&lt;/li&gt;
&lt;li&gt;AI systems engineering skills&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Why I Built My Portfolio Website
&lt;/h1&gt;

&lt;p&gt;I recently launched my personal website:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://swapnanilsaha.com" rel="noopener noreferrer"&gt;https://swapnanilsaha.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The goal is to create a central place for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;projects&lt;/li&gt;
&lt;li&gt;engineering writeups&lt;/li&gt;
&lt;li&gt;architecture ideas&lt;/li&gt;
&lt;li&gt;experiments&lt;/li&gt;
&lt;li&gt;future AI infrastructure work&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also wanted a clean place to document lessons from building production systems and exploring modern AI infrastructure.&lt;/p&gt;




&lt;h1&gt;
  
  
  Topics I’ll Be Writing About
&lt;/h1&gt;

&lt;p&gt;Going forward, I plan to write about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;scaling backend systems&lt;/li&gt;
&lt;li&gt;low-latency engineering&lt;/li&gt;
&lt;li&gt;distributed systems architecture&lt;/li&gt;
&lt;li&gt;AI infra tooling&lt;/li&gt;
&lt;li&gt;production ML systems&lt;/li&gt;
&lt;li&gt;performance optimization&lt;/li&gt;
&lt;li&gt;backend engineering patterns&lt;/li&gt;
&lt;li&gt;practical engineering lessons from real systems&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Open to Connecting
&lt;/h1&gt;

&lt;p&gt;I enjoy discussing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;distributed systems&lt;/li&gt;
&lt;li&gt;backend architecture&lt;/li&gt;
&lt;li&gt;AI infrastructure&lt;/li&gt;
&lt;li&gt;performance engineering&lt;/li&gt;
&lt;li&gt;scalable systems design&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feel free to connect through my website or social profiles.&lt;/p&gt;

&lt;p&gt;🌐 Website: &lt;a href="https://swapnanilsaha.com" rel="noopener noreferrer"&gt;swapnanilsaha.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;💻 GitHub: &lt;a href="https://github.com/swapnanil" rel="noopener noreferrer"&gt;github.com/swapnanil&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🔗 LinkedIn: &lt;a href="https://www.linkedin.com/in/swapnanil/" rel="noopener noreferrer"&gt;linkedin.com/in/swapnanil&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Thanks for reading 🚀&lt;/p&gt;

</description>
      <category>ai</category>
      <category>backend</category>
      <category>distributedsystems</category>
      <category>infrastructure</category>
    </item>
  </channel>
</rss>
