<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Retrieval on Nikhil Adhikari</title><link>https://kai2055.github.io/tags/retrieval/</link><description>Recent content in Retrieval on Nikhil Adhikari</description><generator>Hugo -- gohugo.io</generator><language>en-US</language><lastBuildDate>Sun, 26 Jul 2026 00:00:00 +0200</lastBuildDate><atom:link href="https://kai2055.github.io/tags/retrieval/index.xml" rel="self" type="application/rss+xml"/><item><title>A Stale Vector Store Caused a Phantom Regression</title><link>https://kai2055.github.io/p/stale-vector-store-regression/</link><pubDate>Sun, 26 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/stale-vector-store-regression/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; Tests that had been passing suddenly started
failing, right after a code change — so the code change looked guilty. It wasn&amp;rsquo;t.
The real cause was a database of pre-computed data that had quietly gone stale: it
was excluded from version control, so the usual &amp;ldquo;has anything changed?&amp;rdquo; check
showed everything clean while the thing the system actually depends on was out of
date. The takeaway: &lt;em&gt;a clean version-control status tells you nothing about the
state of files it doesn&amp;rsquo;t track&lt;/em&gt; — and this is exactly the failure the project&amp;rsquo;s
automatic quality gate is built to prevent.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full finding below.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Surfaced by:&lt;/strong&gt; first Layer 2 runs while building the Layer 3 runner · &lt;strong&gt;Related:&lt;/strong&gt;
ADR-020 (Layer 3 gate policy)&lt;/p&gt;
&lt;h2 id="what-happened"&gt;What happened
&lt;/h2&gt;&lt;p&gt;Mid-session, Layer 2 entries that pass in the committed baseline started failing.
Two examples: &lt;strong&gt;L2-006&lt;/strong&gt; went from a correct top-1 hit to &lt;strong&gt;0 candidates, declined,
retrieved nothing&lt;/strong&gt;; &lt;strong&gt;L2-001&lt;/strong&gt; went from the correct incident in its candidate set
to 3 candidates, all wrong.&lt;/p&gt;
&lt;p&gt;On the surface this looked like a regression — the system had gotten worse — and it
appeared right after a code refactor, so the refactor was the obvious suspect.&lt;/p&gt;
&lt;h2 id="why-it-was-not-the-obvious-cause"&gt;Why it was NOT the obvious cause
&lt;/h2&gt;&lt;p&gt;The refactor was innocent. It only moved a loop (&lt;code&gt;run_suite&lt;/code&gt; extracted from &lt;code&gt;main&lt;/code&gt;);
it touched no retrieval or graph code. Isolation confirmed this:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;ollama list&lt;/code&gt; — the embedder was loaded and fine.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;git status&lt;/code&gt; / &lt;code&gt;git log&lt;/code&gt; — corpus untouched since the baseline commit. No data
change.&lt;/li&gt;
&lt;li&gt;A &lt;em&gt;second&lt;/em&gt; entry also mis-retrieved, so it wasn&amp;rsquo;t one bad entry — it was systemic
to retrieval.&lt;/li&gt;
&lt;li&gt;Comparing to the committed baseline showed the affected entries &lt;strong&gt;used to pass&lt;/strong&gt;.
So the baseline was right and &lt;em&gt;current&lt;/em&gt; had drifted — pointing away from a bad
baseline and toward something environmental.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="root-cause"&gt;Root cause
&lt;/h2&gt;&lt;p&gt;The &lt;strong&gt;vector store on disk was stale.&lt;/strong&gt; The corpus had grown from 15 to 20 documents
in an earlier commit, but the ChromaDB index at &lt;code&gt;data/chromadb&lt;/code&gt; had never been
rebuilt after that growth. So retrieval was searching the &lt;strong&gt;old 15-doc index&lt;/strong&gt; while
the suite and baseline expected the &lt;strong&gt;current 20-doc / 107-chunk&lt;/strong&gt; corpus. Wrong
incidents came back, or none at all.&lt;/p&gt;
&lt;p&gt;The reason &lt;code&gt;git status&lt;/code&gt; gave no warning: &lt;strong&gt;&lt;code&gt;data/chromadb&lt;/code&gt; is gitignored&lt;/strong&gt; — it&amp;rsquo;s a
generated artifact, not tracked. So git reported a clean tree while the actual thing
retrieval depends on was silently out of date. &lt;strong&gt;A clean git tree says nothing about
the state of the index.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="the-fix"&gt;The fix
&lt;/h2&gt;&lt;p&gt;Re-index so the store matches the current corpus (&lt;code&gt;python -m src.embedding&lt;/code&gt;). After
re-indexing, L2-001 immediately found the expected incident again, and the full suite
reproduced documented behavior.&lt;/p&gt;
&lt;h2 id="why-this-is-load-bearing-for-the-quality-gate"&gt;Why this is load-bearing for the quality gate
&lt;/h2&gt;&lt;p&gt;This is exactly the failure the Layer 3 runner&amp;rsquo;s &lt;strong&gt;mandatory re-index step&lt;/strong&gt; prevents.
A gate that evaluated without re-indexing would compare a fresh baseline against a
possibly-stale store and report regressions that aren&amp;rsquo;t real — the false-alarm
failure mode ADR-020 warns about. The re-index isn&amp;rsquo;t hygiene; it&amp;rsquo;s a &lt;em&gt;correctness
precondition&lt;/em&gt;, now demonstrated rather than assumed.&lt;/p&gt;
&lt;h2 id="lessons"&gt;Lessons
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Gitignored artifacts have no version signal.&lt;/strong&gt; A clean tree can sit on top of a
stale generated dependency. Never infer store freshness from git.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diagnose environmental vs. logic failures before concluding.&lt;/strong&gt; The symptom framed
the refactor as guilty; the cause was a stale artifact. Isolation, not assumption,
found it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The baseline was the tool that cracked it.&lt;/strong&gt; Run current, diff against committed
baseline, find the flipped entry, isolate the cause — the same procedure the quality
gate automates, run here by hand.&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>Confident Matches on Vocabulary, Not Mechanism</title><link>https://kai2055.github.io/p/vocabulary-not-mechanism/</link><pubDate>Sat, 25 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/vocabulary-not-mechanism/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; The system finds past incidents by comparing
&lt;em&gt;meaning&lt;/em&gt;, but sometimes two very different failures use the same words —
&amp;ldquo;Cloudflare,&amp;rdquo; &amp;ldquo;edge,&amp;rdquo; &amp;ldquo;database&amp;rdquo; — and it confidently returns the wrong one. This
is the most dangerous kind of error: not a crash, not an obvious miss, but a
confident wrong answer that would send an engineer down the wrong path during a
live outage. Rather than hide it or fake a better score by deleting the test, I&amp;rsquo;ve
kept it visible and documented — because knowing exactly where a reliability
system fails &lt;em&gt;is&lt;/em&gt; the reliability work, and this particular fix is real design
work, not a quick tuning tweak.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full finding below.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Where it shows:&lt;/strong&gt; Layer 1 retrieval and Layer 2 diagnosis — the same failure,
twice. &lt;strong&gt;Status:&lt;/strong&gt; Understood, not fixed. Kept visible on purpose.&lt;/p&gt;
&lt;h2 id="the-short-version"&gt;The short version
&lt;/h2&gt;&lt;p&gt;The system sometimes matches on shared words rather than shared cause, and does it
with high confidence. A query and an incident can use the same vocabulary while
describing completely different failures. The embedding scores them as close, the
system returns the wrong incident, and nothing about the score signals that it&amp;rsquo;s
wrong. This is the failure the whole project exists to guard against: not a crash,
not an obvious miss, but a confident wrong answer that would mislead an on-call
engineer during a live outage.&lt;/p&gt;
&lt;h2 id="instance-1--layer-1-retrieval-the-ddos-probe"&gt;Instance 1 — Layer 1 retrieval: the DDoS probe
&lt;/h2&gt;&lt;p&gt;A no-match probe: &lt;em&gt;&amp;ldquo;DDoS attack overwhelmed our CDN edge nodes and caused a 12-hour
outage.&amp;rdquo;&lt;/em&gt; This is meant to retrieve nothing — the corpus has no DDoS incident.
Against the 15-document corpus it correctly declined. Against the 20-document corpus
it now matches a Cloudflare incident at distance &lt;strong&gt;0.236&lt;/strong&gt; — well inside the 0.30
threshold, a confident hit.&lt;/p&gt;
&lt;p&gt;But that Cloudflare incident is a &lt;strong&gt;configuration-error&lt;/strong&gt; incident: a database
access-control change that cascaded. It has nothing to do with a DDoS, which is a
volumetric attack. The match is on surface vocabulary — &amp;ldquo;Cloudflare,&amp;rdquo; &amp;ldquo;edge,&amp;rdquo;
&amp;ldquo;outage&amp;rdquo; — not on the failure mechanism. The probe stays classified as a no-match;
its continued matching is the finding, not something to reclassify away.&lt;/p&gt;
&lt;h2 id="instance-2--layer-2-diagnosis-the-xid-wraparound-attractor"&gt;Instance 2 — Layer 2 diagnosis: the XID-wraparound attractor
&lt;/h2&gt;&lt;p&gt;In the Layer 2 baseline, three descriptions produced a diagnosis of Postgres
transaction-ID (XID) wraparound. Two were correct (a real Sentry Postgres incident).
The third, L2-007, was &lt;strong&gt;wrong&lt;/strong&gt; — it&amp;rsquo;s Roblox&amp;rsquo;s service-registry cascade, nothing to
do with Postgres. But it shares symptom vocabulary — read-only, cascade, database —
with the Sentry incident, and the model reached for the specific,
authoritative-sounding failure it had seen before.&lt;/p&gt;
&lt;h2 id="why-these-are-the-same-failure"&gt;Why these are the same failure
&lt;/h2&gt;&lt;p&gt;Both are the system latching onto a &lt;strong&gt;specific, plausible, well-documented failure&lt;/strong&gt;
because the surface features match, while the actual mechanism does not. The danger
in both is the &lt;strong&gt;confidence&lt;/strong&gt;. A vague wrong answer is easy to distrust. &amp;ldquo;PostgreSQL
XID wraparound&amp;rdquo; and a 0.236 distance both look authoritative. During an incident,
that&amp;rsquo;s worse than silence — it&amp;rsquo;s a false lead delivered with conviction.&lt;/p&gt;
&lt;h2 id="why-it-is-being-kept-visible-rather-than-patched"&gt;Why it is being kept visible rather than patched
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;It is honest signal.&lt;/strong&gt; The Layer 1 decline rate is 0.500 — three of four no-match
probes decline correctly, and the DDoS probe is the one that doesn&amp;rsquo;t. Forcing that
number to look better by deleting the probe would hide a real property of the system.
The metric is more useful with the known failure inside it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix is not local.&lt;/strong&gt; This isn&amp;rsquo;t a threshold to nudge or a prompt line to add.
It&amp;rsquo;s a limitation of matching on embedding similarity and symptom vocabulary.
Addressing it properly means giving the system more to discriminate on — richer
context at retrieval time, or a verification step that checks whether the mechanism
actually fits before returning a confident answer. That&amp;rsquo;s real design work, recorded
here as the direction rather than attempted as a patch.&lt;/p&gt;
&lt;h2 id="what-would-actually-address-it"&gt;What would actually address it
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Retrieval:&lt;/strong&gt; search richer text so the match rests on more than a few shared
nouns. More context gives the embedding more to separate genuinely-similar
incidents from merely-similarly-worded ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Diagnosis:&lt;/strong&gt; a mechanism-check before a candidate is returned with high
confidence — does the cited incident&amp;rsquo;s actual failure mode match the symptoms, or
only their vocabulary? This is a grounding step one level deeper than
citation-checking: not &amp;ldquo;is this incident real and retrieved,&amp;rdquo; but &amp;ldquo;does this
incident actually &lt;em&gt;explain&lt;/em&gt; what was reported.&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both are deferred. Both are the honest fix. Neither is a tuning change.&lt;/p&gt;</description></item><item><title>Why Layer 2 Needed Its Own Threshold</title><link>https://kai2055.github.io/p/layer2-threshold/</link><pubDate>Fri, 24 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/layer2-threshold/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; The diagnostic layer breaks an incident into
short symptom fragments and searches for each. But it was reusing a
&amp;ldquo;closeness&amp;rdquo; cutoff that had been tuned for full, richly-worded questions — and
short fragments always score as less close, so &lt;strong&gt;25 of 27 symptoms found the right
incident and then had it thrown away&lt;/strong&gt;. Giving Layer 2 its own, looser cutoff fixed
most of it. But the honest conclusion isn&amp;rsquo;t &amp;ldquo;0.36 is the right number&amp;rdquo; — it&amp;rsquo;s that
&lt;em&gt;a closeness score alone can&amp;rsquo;t cleanly separate signal from noise on short
fragments&lt;/em&gt;, because in the data the two genuinely overlap. Naming that limit is
the point.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full finding below.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Measured on:&lt;/strong&gt; 15 incident descriptions, 27 frozen symptoms, 5 no-match symptoms.&lt;/p&gt;
&lt;h2 id="the-problem-in-one-line"&gt;The problem in one line
&lt;/h2&gt;&lt;p&gt;Layer 2 was using Layer 1&amp;rsquo;s threshold. Layer 1&amp;rsquo;s threshold was tuned on complete
questions. Layer 2 sends symptom fragments. Fragments score worse, so everything was
thrown away.&lt;/p&gt;
&lt;h2 id="why-fragments-score-worse"&gt;Why fragments score worse
&lt;/h2&gt;&lt;p&gt;A post-mortem chunk describes a whole incident — trigger, failure, cascade, recovery
— many concepts in one paragraph. A complete question matches that richness. A
fragment matches one small part of it, so the distance is worse even though it
describes the same event. &lt;strong&gt;The whole description is closer to the document than any
of its parts.&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Input type&lt;/th&gt;
 &lt;th&gt;Typical distance&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Layer 1 complete questions&lt;/td&gt;
 &lt;td&gt;0.20 – 0.27&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Layer 2 symptom fragments&lt;/td&gt;
 &lt;td&gt;0.32 – 0.41&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;The threshold both were using&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;0.30&lt;/strong&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The cutoff sits in the gap. Layer 1 clears it every time. Layer 2 never does.&lt;/p&gt;
&lt;h2 id="what-was-actually-failing"&gt;What was actually failing
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Verdict&lt;/th&gt;
 &lt;th&gt;Count&lt;/th&gt;
 &lt;th&gt;Meaning&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;PASS&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;found, kept, visible to the agent&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;THRESHOLD&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;25&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;found the right document, then discarded&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;RANK&lt;/td&gt;
 &lt;td&gt;0&lt;/td&gt;
 &lt;td&gt;found but ranked too deep&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;MISS&lt;/td&gt;
 &lt;td&gt;2&lt;/td&gt;
 &lt;td&gt;never found at all&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;25 of 27 symptoms found the correct document and had it thrown away.&lt;/strong&gt; Retrieval
was working. Filtering was calibrated for the wrong input — the correct document was
often ranked &lt;em&gt;first&lt;/em&gt;, at distances like 0.32–0.33, just above the 0.30 cutoff.&lt;/p&gt;
&lt;h2 id="the-sweep-and-the-decision"&gt;The sweep, and the decision
&lt;/h2&gt;&lt;p&gt;Each threshold was tested against the same frozen symptoms. The usable range came out
to &lt;strong&gt;0.34–0.36&lt;/strong&gt;: below it, entries get no evidence; above 0.38 junk starts leaking
badly, and at 0.40 &lt;em&gt;every&lt;/em&gt; no-match probe leaks — decline behaviour collapses
entirely. &lt;strong&gt;0.36&lt;/strong&gt; is the best point: 9 of 13 entries get evidence, junk still only
1 of 5. Decision: &lt;code&gt;LAYER2_THRESHOLD = 0.36&lt;/code&gt;, while Layer 1 keeps 0.30.&lt;/p&gt;
&lt;h2 id="what-this-does-not-fix"&gt;What this does &lt;em&gt;not&lt;/em&gt; fix
&lt;/h2&gt;&lt;p&gt;Stated plainly, because 0.36 is not a solution:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;4 of 13 descriptions still get nothing&lt;/strong&gt; — the agent still fails on those.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;4 symptoms return the wrong document&lt;/strong&gt; — the agent reasons over a wrong incident,
which during an outage is worse than returning nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1 junk symptom leaks&lt;/strong&gt; — a description with nothing matching gets treated as if it
matched.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Layer 2 goes from never working to working roughly two-thirds of the time, with some
false leads.&lt;/p&gt;
&lt;h2 id="the-overlap-problem"&gt;The overlap problem
&lt;/h2&gt;&lt;p&gt;The reason no threshold is clean: the distance bands &lt;em&gt;interleave&lt;/em&gt;. A junk symptom
(&amp;ldquo;air conditioning failed&amp;rdquo;, 0.362) can score worse than a real one, but another junk
symptom (&amp;ldquo;machines shut themselves down&amp;rdquo;, 0.334) scores &lt;em&gt;better&lt;/em&gt; than a real hit
(&amp;ldquo;servers dropping everything&amp;rdquo;, 0.344). &lt;strong&gt;No cutoff separates them, because the
separation doesn&amp;rsquo;t exist in the data.&lt;/strong&gt; More samples would describe the overlap more
precisely; they wouldn&amp;rsquo;t create a boundary.&lt;/p&gt;
&lt;p&gt;So the honest conclusion is not &amp;ldquo;0.36 is the right number.&amp;rdquo; It&amp;rsquo;s: &lt;strong&gt;a distance score
alone cannot separate signal from noise on short symptom fragments.&lt;/strong&gt; 0.36 is a round
number picked from where the table turns — a working setting that makes the component
functional, to be revisited when the suite grows. Confidence in the exact number is
low, deliberately so.&lt;/p&gt;
&lt;h2 id="what-to-try-next"&gt;What to try next
&lt;/h2&gt;&lt;p&gt;The threshold fix treats the symptom; the cause is that fragments carry less signal
than whole descriptions. The real direction: &lt;strong&gt;search the full description alongside
each symptom&lt;/strong&gt; and merge the results — matching rich text against rich chunks, the
comparison the embedding model is actually good at. Evidence: the same content as
complete descriptions scored 0.870 at threshold 0.30, while split into fragments it
scores zero at the same threshold. That would widen the gap between signal and noise
rather than moving a line through the middle of it.&lt;/p&gt;</description></item><item><title>Chasing a Bug That Didn't Exist</title><link>https://kai2055.github.io/p/chasing-a-bug-that-didnt-exist/</link><pubDate>Thu, 23 Jul 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/chasing-a-bug-that-didnt-exist/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; A search system was returning &amp;ldquo;nothing
found&amp;rdquo; — while the correct answer sat in its database, ranked first. Nothing had
crashed; every function did exactly what it was written to do. This is the story
of discovering that &lt;em&gt;the code was fine and the system was still broken&lt;/em&gt; — because
the test suite had been unknowingly grading the system on easy questions, and a
hidden limit was throwing away correct answers before anyone looked at them. It&amp;rsquo;s
a good example of the kind of failure that doesn&amp;rsquo;t show up as an error message —
the most dangerous kind.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Below is the full technical investigation. Skip it freely — the summary above is
the point.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Layer:&lt;/strong&gt; 1 (retrieval) · &lt;strong&gt;Outcome:&lt;/strong&gt; No defect found in application code. Two
real problems found anyway.&lt;/p&gt;
&lt;h2 id="where-it-started"&gt;Where it started
&lt;/h2&gt;&lt;p&gt;Layer 2 was blocked. A retrieval probe returned zero results against a store
holding 83 chunks. No error, no exception — just an empty list.&lt;/p&gt;
&lt;p&gt;The working theory was environmental: the embedding model wasn&amp;rsquo;t loaded in Ollama.
That theory mattered, because Layer 2&amp;rsquo;s &amp;ldquo;the agent declines honestly&amp;rdquo; framing
depended on knowing whether the empty result was a genuine no-match or a broken
pipe.&lt;/p&gt;
&lt;p&gt;First command of the day killed it. &lt;code&gt;ollama list&lt;/code&gt; showed &lt;code&gt;nomic-embed-text&lt;/code&gt;,
274 MB, installed four weeks earlier. The model was fine. So the bug was real, and
somewhere in code I&amp;rsquo;d written.&lt;/p&gt;
&lt;h2 id="the-elimination"&gt;The elimination
&lt;/h2&gt;&lt;p&gt;Rather than guess, I listed every link in the chain and tested each in order. Six
suspects.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Suspect&lt;/th&gt;
 &lt;th&gt;Result&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Model not installed&lt;/td&gt;
 &lt;td&gt;Present&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Wrong database path&lt;/td&gt;
 &lt;td&gt;App points at &lt;code&gt;data/chromadb&lt;/code&gt; — correct&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Store empty&lt;/td&gt;
 &lt;td&gt;83 chunks&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Wrong distance measure&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;hnsw:space: cosine&lt;/code&gt;, set explicitly&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Embedding prefix mismatch&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;search_document:&lt;/code&gt; / &lt;code&gt;search_query:&lt;/code&gt; correctly paired&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Threshold comparison inverted&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;distance &amp;lt;= threshold&lt;/code&gt; — right direction&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Every single one came back clean. Every function I checked did exactly what it was
written to do. That was the first real finding: &lt;strong&gt;the system was correct and still
useless.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="three-false-leads-all-self-inflicted"&gt;Three false leads, all self-inflicted
&lt;/h2&gt;&lt;p&gt;The diagnostic script lied to me three times, and each time for the same reason.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It hardcoded the database path.&lt;/strong&gt; Pointed at &lt;code&gt;data/chroma&lt;/code&gt; instead of
&lt;code&gt;data/chromadb&lt;/code&gt;. ChromaDB doesn&amp;rsquo;t error on an empty folder — it silently creates a
blank database. So the script made an empty store and correctly reported it was
empty. Cost: half an hour convinced the corpus had vanished.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It reimplemented the embedding call.&lt;/strong&gt; Called Ollama directly with plain text,
no prefix. The corpus was embedded with &lt;code&gt;search_document:&lt;/code&gt;. So it was comparing
unlabelled queries against labelled documents — a mismatched measurement that
produced a real-looking number I then reasoned from.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It used a candidate depth the system doesn&amp;rsquo;t use.&lt;/strong&gt; I set 15 to see where
documents ranked. The system uses 5. That made the diagnostic disagree with the
sweep, and reconciling the two is what exposed the actual bug.&lt;/p&gt;
&lt;p&gt;The lesson generalises: &lt;strong&gt;a diagnostic that restates what the system defines will
eventually disagree with it, and it will disagree quietly.&lt;/strong&gt; Every fix was the same
— import the real code instead of copying it.&lt;/p&gt;
&lt;h2 id="what-was-actually-wrong"&gt;What was actually wrong
&lt;/h2&gt;&lt;p&gt;Two things, neither a broken function.&lt;/p&gt;
&lt;h3 id="the-evaluation-suite-was-easier-than-reality"&gt;The evaluation suite was easier than reality
&lt;/h3&gt;&lt;p&gt;The 31-query suite was written after reading and normalising all 15 post-mortems.
So it reuses the documents&amp;rsquo; own vocabulary without meaning to. It scored the system
1.000 while plain-English questions were failing.&lt;/p&gt;
&lt;p&gt;To measure this instead of asserting it, I built a paired suite: same entries, same
&lt;code&gt;expected_doc_id&lt;/code&gt;, same difficulty, only the query text rewritten in plain English.
No-match probes and filter queries held constant as a control.&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Suite&lt;/th&gt;
 &lt;th&gt;Hit rate @ 0.30&lt;/th&gt;
 &lt;th&gt;MRR&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Original wording&lt;/td&gt;
 &lt;td&gt;1.000&lt;/td&gt;
 &lt;td&gt;0.949&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Plain English&lt;/td&gt;
 &lt;td&gt;0.826&lt;/td&gt;
 &lt;td&gt;0.783&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The control held — decline rate identical across both suites at every threshold. So
the gap came from wording and nothing else.&lt;/p&gt;
&lt;p&gt;The clearest single illustration:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Query&lt;/th&gt;
 &lt;th&gt;Distance&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&amp;ldquo;BGP route leak&amp;rdquo; — the document&amp;rsquo;s own words&lt;/td&gt;
 &lt;td&gt;0.1996&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&amp;ldquo;database ran out of connections&amp;rdquo; — same kind of event, plain words&lt;/td&gt;
 &lt;td&gt;0.3035&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The distance was largely measuring word overlap. The threshold was acting as a
jargon filter.&lt;/p&gt;
&lt;h3 id="top_k-was-discarding-correct-answers-before-checking-them"&gt;&lt;code&gt;top_k&lt;/code&gt; was discarding correct answers before checking them
&lt;/h3&gt;&lt;p&gt;The plain-English curve flattened at 0.913 and never moved, even at threshold 0.50
where the filter does nothing. That meant some failures weren&amp;rsquo;t threshold failures
at all.&lt;/p&gt;
&lt;p&gt;A per-query diagnostic found the reason: one query&amp;rsquo;s correct document sat at &lt;strong&gt;rank
6, distance 0.2888&lt;/strong&gt; — comfortably inside the 0.30 threshold, never looked at,
because &lt;code&gt;top_k=5&lt;/code&gt; truncated the list first.&lt;/p&gt;
&lt;p&gt;The candidate count was overriding the relevance rule. Raising it to 10:&lt;/p&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;&lt;/th&gt;
 &lt;th&gt;Before&lt;/th&gt;
 &lt;th&gt;After&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;Hit rate @ 0.30&lt;/td&gt;
 &lt;td&gt;0.826&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;0.870&lt;/strong&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Ceiling&lt;/td&gt;
 &lt;td&gt;0.913&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;0.957&lt;/strong&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Decline rate&lt;/td&gt;
 &lt;td&gt;0.600&lt;/td&gt;
 &lt;td&gt;0.600&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Real answers gained, no noise admitted. The same number turned out to be hardcoded
in four places — &lt;code&gt;retrieve()&lt;/code&gt;, &lt;code&gt;run_sweep&lt;/code&gt;, &lt;code&gt;score_filter_query&lt;/code&gt;, and the agent&amp;rsquo;s
retrieve node at 3. It&amp;rsquo;s now one constant, &lt;code&gt;DEFAULT_TOP_K&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Worth noting the agent was retrieving &lt;strong&gt;three&lt;/strong&gt; candidates per symptom while Layer 1
used five. The component doing the harder work had the least evidence.&lt;/p&gt;
&lt;h2 id="the-decision-i-didnt-make"&gt;The decision I didn&amp;rsquo;t make
&lt;/h2&gt;&lt;p&gt;Two queries still fail at 0.30, missing by 0.0055 and 0.0092. Moving the threshold
to 0.35 would recover both. I left it alone.&lt;/p&gt;
&lt;p&gt;The distance bands overlap — a junk probe scored 0.215, closer than four of five
real queries. There is no value that separates good from bad, so any number is a
tradeoff, and one tuned to clear 23 specific queries is fitting the sample rather
than the problem. 0.35 would also drop decline rate from 0.600 to 0.200 — triple the
noise to recover two borderline cases.&lt;/p&gt;
&lt;p&gt;And the failure modes aren&amp;rsquo;t equal. Returning nothing costs an engineer time.
Returning the wrong past incident during a live outage sends them after the wrong
root cause. Strict is the right side to fail on.&lt;/p&gt;
&lt;h2 id="what-this-is-actually-a-story-about"&gt;What this is actually a story about
&lt;/h2&gt;&lt;p&gt;Not a bug hunt. Every function was correct. It&amp;rsquo;s about a system that stays up,
throws no errors, and quietly returns nothing while holding the answer. During a
live outage it would tell an engineer that nothing similar has ever happened — with
the matching post-mortem in hand, ranked first.&lt;/p&gt;
&lt;p&gt;And it&amp;rsquo;s about the evaluation framework marking its own homework. The suite said
1.000. The system was at 0.826 for anyone who hadn&amp;rsquo;t read the corpus. The
measurement was wrong in the flattering direction, which is the direction you don&amp;rsquo;t
check.&lt;/p&gt;
&lt;p&gt;The fix for that isn&amp;rsquo;t a better threshold. It&amp;rsquo;s a regression gate — re-running these
measurements on every corpus change and reporting when the numbers move. You don&amp;rsquo;t
calibrate once; you build the thing that notices when calibration has gone stale.&lt;/p&gt;</description></item><item><title>BUG-001 — A Threshold Applied Where It Didn't Belong</title><link>https://kai2055.github.io/p/bug-001-distance-threshold/</link><pubDate>Mon, 29 Jun 2026 00:00:00 +0200</pubDate><guid>https://kai2055.github.io/p/bug-001-distance-threshold/</guid><description>
 &lt;blockquote&gt;
 &lt;p&gt;&lt;strong&gt;The short version, for anyone:&lt;/strong&gt; The system had two different jobs that
happened to share one piece of code: &lt;em&gt;ranking&lt;/em&gt; results by closeness, and
&lt;em&gt;fetching an exact set&lt;/em&gt; of records that match a filter. A cutoff meant only for
the first job was silently being applied to the second — but it stayed harmless
as long as a temporary setting was loose enough to never cut anything. The moment
that setting was tightened to a realistic value, filter accuracy collapsed from
100% to 33%. The bug had been there since the first line of code; changing one
config value exposed it. The lesson: &lt;em&gt;tests passing at a permissive setting are
not proof of correctness.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full bug report below.&lt;/em&gt;&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;&lt;/th&gt;
 &lt;th&gt;&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;ID&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;BUG-001&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Component&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;&lt;code&gt;retrieve()&lt;/code&gt; — &lt;code&gt;src/embedding.py&lt;/code&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Severity&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Major · &lt;strong&gt;Priority&lt;/strong&gt; High&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Status&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;Closed — fixed and verified&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Related&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;TP-001, ADR-013&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="what-happened"&gt;What happened
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;RELEVANCE_THRESHOLD&lt;/code&gt; was lowered from 1.0 to 0.30. Semantic queries improved.
Filter queries collapsed. Filter precision, recall, and exact-match all dropped from
1.000 to 0.333 — one of three queries passing. The same value across three runs. Not
noise. A logic bug.&lt;/p&gt;
&lt;p&gt;The defect was already there. At 1.0, nothing ever got cut, so the bad path never
ran. A config value changed and the latent bug surfaced.&lt;/p&gt;
&lt;h2 id="evidence"&gt;Evidence
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;Metric&lt;/th&gt;
 &lt;th&gt;t = 1.0&lt;/th&gt;
 &lt;th&gt;t = 0.30&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;Hit rate@5&lt;/td&gt;
 &lt;td&gt;1.000&lt;/td&gt;
 &lt;td&gt;1.000&lt;/td&gt;
 &lt;td&gt;—&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;MRR&lt;/td&gt;
 &lt;td&gt;0.949&lt;/td&gt;
 &lt;td&gt;0.949&lt;/td&gt;
 &lt;td&gt;—&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Section accuracy&lt;/td&gt;
 &lt;td&gt;0.435&lt;/td&gt;
 &lt;td&gt;0.435&lt;/td&gt;
 &lt;td&gt;—&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;Decline rate&lt;/td&gt;
 &lt;td&gt;0.000&lt;/td&gt;
 &lt;td&gt;0.600&lt;/td&gt;
 &lt;td&gt;+0.600&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;&lt;strong&gt;Filter precision / recall / exact&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;1.000&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;0.333&lt;/strong&gt;&lt;/td&gt;
 &lt;td&gt;&lt;strong&gt;−0.667&lt;/strong&gt;&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Regression is isolated. Everything else held.&lt;/p&gt;
&lt;h2 id="root-cause"&gt;Root cause
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;retrieve()&lt;/code&gt; applied the cosine-distance threshold to &lt;em&gt;every&lt;/em&gt; query — including
metadata-filtered ones:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#75715e"&gt;# Keep only results close enough to be relevant&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;relevant &lt;span style="color:#f92672"&gt;=&lt;/span&gt; [r &lt;span style="color:#66d9ef"&gt;for&lt;/span&gt; r &lt;span style="color:#f92672"&gt;in&lt;/span&gt; results &lt;span style="color:#66d9ef"&gt;if&lt;/span&gt; r[&lt;span style="color:#e6db74"&gt;&amp;#34;distance&amp;#34;&lt;/span&gt;] &lt;span style="color:#f92672"&gt;&amp;lt;=&lt;/span&gt; threshold] &lt;span style="color:#75715e"&gt;# applied unconditionally&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Two things are wrong. The list comprehension has no condition — every caller gets
the distance cutoff, so semantic ranking and metadata set-retrieval share one path.
And the docstring states it as a flat rule, with no sign that a metadata query might
need different treatment. The defect is in the design, not a slip in the code.&lt;/p&gt;
&lt;p&gt;A filter query like &amp;ldquo;minor-severity incidents&amp;rdquo; is a &lt;strong&gt;set question&lt;/strong&gt;. The right
answer is &lt;em&gt;every&lt;/em&gt; document matching the filter — it doesn&amp;rsquo;t matter how semantically
close the query phrase sits to the chunk text; the metadata already decided. At 1.0,
nothing was ever discarded, so the wrong logic produced correct output by accident.
At 0.30, documents that matched the filter correctly but sat far in cosine distance
got silently dropped. Filter scoring is exact set-match — lose one document, lose
the query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The real defect:&lt;/strong&gt; two retrieval modes — semantic ranking and metadata
set-retrieval — forced through one code path, with a parameter meant for one mode
leaking into the other.&lt;/p&gt;
&lt;h2 id="fix"&gt;Fix
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;retrieve()&lt;/code&gt; now accepts &lt;code&gt;threshold=None&lt;/code&gt; — no distance cutoff, return all
metadata-matched results, ranked. The call site for filter scoring passes
&lt;code&gt;threshold=None&lt;/code&gt;; semantic queries keep the real threshold. The two modes are now
distinguished at the call site.&lt;/p&gt;
&lt;p&gt;Fixed at the source — &lt;code&gt;retrieve()&lt;/code&gt;, not the eval harness — so every downstream
caller inherits it, including the Layer 2 diagnostic agent.&lt;/p&gt;
&lt;h2 id="what-was-missed"&gt;What was missed
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;score_filter_query&lt;/code&gt; passes &lt;code&gt;top_k=5&lt;/code&gt;. If a metadata filter matches more than 5
documents, the vector store returns only the top 5 by distance, and set-match scoring
counts the rest as missing. Harmless now — no filter in the 15-document corpus exceeds
5 — but the same defect class: a semantic parameter constraining a metadata query.
Tracked separately, to fix before the corpus grows.&lt;/p&gt;
&lt;h2 id="lessons-learned"&gt;Lessons learned
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;1. Passing tests at a permissive setting are not evidence of correctness.&lt;/strong&gt; The bug
existed from the first line of code. &lt;code&gt;RELEVANCE_THRESHOLD = 1.0&lt;/code&gt; meant the faulty
branch never discarded anything. The placeholder was even flagged in the source
(&lt;code&gt;# loose placeholder&lt;/code&gt;). Knowing a setting is temporary is not the same as testing
what happens when it changes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Partial metric reporting hides regressions.&lt;/strong&gt; The threshold sweep tracked 3 of 5
metrics — hit rate, MRR, decline rate. It &amp;ldquo;confirmed&amp;rdquo; 0.30 as optimal while silently
breaking a metric it did not watch. Caught only on the confirming run that reported
the full set. Fix: full metrics on every run, enforced by exit criteria.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Determinism separates bugs from noise.&lt;/strong&gt; The first thought was jitter. Three
identical runs at 0.333 reclassified it as logic, worth investigating.&lt;/p&gt;</description></item></channel></rss>