<?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: Serhiy Kucherenko</title>
    <description>The latest articles on DEV Community by Serhiy Kucherenko (@skucherenko).</description>
    <link>https://dev.to/skucherenko</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%2F4048232%2F93791092-fa66-4f4d-be33-57839c5696df.jpg</url>
      <title>DEV Community: Serhiy Kucherenko</title>
      <link>https://dev.to/skucherenko</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/skucherenko"/>
    <language>en</language>
    <item>
      <title>How to hurt yourself with AI</title>
      <dc:creator>Serhiy Kucherenko</dc:creator>
      <pubDate>Tue, 25 Aug 2026 14:56:39 +0000</pubDate>
      <link>https://dev.to/skucherenko/how-to-hurt-yourself-with-ai-4jp1</link>
      <guid>https://dev.to/skucherenko/how-to-hurt-yourself-with-ai-4jp1</guid>
      <description>&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;34x more work than it should have been.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timeline
&lt;/h2&gt;

&lt;p&gt;I write technical articles, and I like experimenting with something new in each one. For one of them, the experiment was an AI pipeline: it would draft the piece and pass it through several rounds of automated review before I saw it.&lt;/p&gt;

&lt;p&gt;That pipeline produced &lt;strong&gt;1,948&lt;/strong&gt; words while processed &lt;strong&gt;65,930&lt;/strong&gt; in total which is &lt;strong&gt;~34x&lt;/strong&gt; more.&lt;/p&gt;

&lt;p&gt;The idea was to base the writing on careful review and a feedback loop. So I ended up reviewing in person and wasting 34x words/tokens more than what I could actually produce.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause
&lt;/h2&gt;

&lt;p&gt;I am not sure what the exact cause is. There are several candidate explanations, and I do not think any one of them is complete on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Not enough context to judge whether a cut was a real improvement.&lt;/li&gt;
&lt;li&gt;The AI outputs what is locally coherent, what "sounds right," with no guarantee that coherent and applicable are the same thing.&lt;/li&gt;
&lt;li&gt;The AI was built to fill in gaps rather than flag them, and what it fills in with is often an assumption, usually one that turns out false.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What a human has that the AI does not is a felt sense of consequence: what a decision actually costs or breaks, in a specific world, learned from having been wrong before. "Experience" is the word for that, and it is overused to the point of meaning nothing, but I do not have a better one.&lt;/p&gt;

&lt;p&gt;If I compress all of that into one line: &lt;strong&gt;the pipeline optimized for the wrong thing.&lt;/strong&gt; It optimized for passing a check. It could not optimize for "is this piece actually good," because nothing in it could feel the cost of being wrong.&lt;/p&gt;

&lt;p&gt;The aim behind building it was sound and I would keep it: automate whatever a computer can do so a human does not have to. What failed was assuming editorial judgment was one of those things.&lt;/p&gt;

&lt;h2&gt;
  
  
  The debt
&lt;/h2&gt;

&lt;p&gt;Engineers already track several kinds of debt that are not financial.&lt;br&gt;
One of them is getting real attention now: &lt;strong&gt;cognitive debt&lt;/strong&gt;, which is what you take on when you outsource tracking why your own system does what it does to an LLM. Two recent sources define it the same way: a&lt;br&gt;
&lt;a href="https://arxiv.org/abs/2506.08872" rel="noopener noreferrer"&gt;MIT Media Lab study&lt;/a&gt; on AI-assisted writing, and &lt;a href="https://getdx.com/blog/cognitive-debt-the-hidden-risk-in-ai-driven-software-development/" rel="noopener noreferrer"&gt;getdx.com's framing&lt;/a&gt; for software teams specifically.&lt;/p&gt;

&lt;p&gt;This pipeline is a worked example. I could tell you the ratio, the gate verdicts, the timeline. For a while I could not have told you, from memory, what any single piece actually argued.&lt;/p&gt;

&lt;p&gt;The more AI gets pushed into everything, the more this particular kind of debt matters. On a prototype, nobody bothers tracking it. On something meant to hold up, not understanding what you built is a deal-breaker,&lt;br&gt;
and "just ship it, iterate later" is the same old fallacy wearing an&lt;br&gt;
AI-shaped coat: speed and understanding traded off as if they were opposites, when the trade was optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes
&lt;/h2&gt;

&lt;p&gt;The intent behind the pipeline was to be the reviewer: read a draft,&lt;br&gt;
judge it, hand back a correction, and only step in myself when the correction failed too. In practice, I was correcting constantly, sentence by sentence, round after round, and each correction only produced more issues.&lt;/p&gt;

&lt;p&gt;That does not work, and it should not. Scale it up and the failure gets clear: imagine ten thousand articles like this shipping out daily, all with a human correcting sentence by sentence, all costing that person a 34x reading tax.&lt;/p&gt;

&lt;p&gt;Going forward, AI in this workflow does small, bounded jobs: formatting, a lookup, a narrow research question with a checkable answer. Things that do not load much cognitively.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question for you
&lt;/h2&gt;

&lt;p&gt;What have you noticed, using AI or watching it get used badly? Two data points I found while researching this piece:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.livescience.com/technology/artificial-intelligence/i-violated-every-principle-i-was-given-ai-agent-deletes-companys-entire-database-in-9-seconds-then-confesses" rel="noopener noreferrer"&gt;A coding agent deleted a production database and its backups in nine seconds after it decided, on its own, to work around a credential mismatch it hit mid-task. Its own log afterward: "I violated every principle I was given."&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.damiencharlotin.com/hallucinations/" rel="noopener noreferrer"&gt;A public tracker has now logged over 1,200 cases of lawyers submitting AI-hallucinated fake case citations to real courts, adding new cases at roughly five to six a day.&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Different domain, same shape as this piece: a check that looked like it was working, until someone read closely enough to notice it wasn't.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Judge strictly to avoid hallucinations</title>
      <dc:creator>Serhiy Kucherenko</dc:creator>
      <pubDate>Fri, 14 Aug 2026 13:13:20 +0000</pubDate>
      <link>https://dev.to/skucherenko/judge-strictly-to-avoid-hallucinations-7kd</link>
      <guid>https://dev.to/skucherenko/judge-strictly-to-avoid-hallucinations-7kd</guid>
      <description>&lt;p&gt;Ten questions over the SEPA rulebooks. A second model grades each answer against a reference, zero to a hundred, and the run came back healthy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6k2g0q2y7drnzudw9ber.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6k2g0q2y7drnzudw9ber.png" alt="Ten questions graded 0 to 100. Nine cluster between 80 and 100. Question five sits flat at zero." width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Except for question five, which asked which currency SCT Inst payments are executed in. The reference answer is one word long.&lt;/p&gt;

&lt;p&gt;A zero on a one-word question reads like a system that does not know the first thing about its own subject matter. That is not what happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  It was doing what it was told
&lt;/h2&gt;

&lt;p&gt;The production prompt is explicit about the case where retrieval comes up short:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Answer the question using ONLY the sources below. If they do not contain the&lt;br&gt;
answer, say so.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Retrieval never put the euro page in the top five results. So the sources handed to the model genuinely did not contain the answer, and the model was under written instruction to say exactly that.&lt;/p&gt;

&lt;p&gt;The grader took that response, compared it to the word "Euro", and returned a zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The grader cannot see the difference
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg4qr3kcl9i0rukzxkszr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg4qr3kcl9i0rukzxkszr.png" alt="The judge receives question, reference answer and answer text, and returns one score. It never receives the retrieved sources, so a wrong guess and a correct refusal both land at zero." width="799" height="361"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The judge receives the question, the reference answer and the answer text. It does not receive the sources, and that is deliberate: a grader holding the retrieved chunks would end up grading the retrieval instead of the answer.&lt;/p&gt;

&lt;p&gt;That choice has a cost. "Correct, given what it was handed" is not a judgement this grader can make. It has one axis, factual match against the reference, and everything that fails to match lands at the bottom together.&lt;/p&gt;

&lt;p&gt;Which means a fabricated answer about euro payments would have scored exactly what the refusal scored. Zero, either way. The eval could not tell the two apart.&lt;/p&gt;

&lt;p&gt;The saved run does not help either. Each question is stored as an id, a score and a one-line critique. The answer text itself is not kept, so nothing downstream can recover the distinction the grader could not draw.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure was upstream
&lt;/h2&gt;

&lt;p&gt;If the refusal had been the problem, changing the answering model would have moved the number. It was not, and it did not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcr4uhva22q7p3rhk1zfp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcr4uhva22q7p3rhk1zfp.png" alt="The same question under three retrieval configurations. Dense vectors miss the euro page and the answer scores 0. Hybrid keyword search retrieves it but leaves recall@5 unchanged at 0.60, trading away a question the vectors had right. The eval-only reranker retrieves it and lifts recall@5 to 0.70." width="800" height="372"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two separate experiments recovered that question, and neither one touched generation. Keyword search found the euro page instantly, because the page contains the actual word. Vector search matches on meaning rather than&lt;br&gt;
words, and it ranked that page outside the top five. A cross-encoder reranker, kept to the eval path only, found it as well and lifted recall@5 from 0.60 to 0.70.&lt;/p&gt;

&lt;p&gt;Neither is a free fix. Leaving keyword search on permanently left recall@5 exactly where it started, at 0.60, because it dropped a question the vectors had been getting right. The blind spot moved. It did not close. The reranker is the only arm that moved the number, and it is not in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make a wrong answer cost something
&lt;/h2&gt;

&lt;p&gt;Retrieval will keep missing pages. Improving that is a separate job, so what matters here is how the eval reacts when it happens.&lt;/p&gt;

&lt;p&gt;The grader was not being harsh. My rubric just stops at zero, so both failures pile up there together. One of them wastes the reader's time. The other sends them into a payments rulebook holding a rule that does not exist. My eval scored those the same.&lt;/p&gt;

&lt;p&gt;So the strictness belongs on the wrong answer. &lt;a href="https://arxiv.org/abs/2406.04744" rel="noopener noreferrer"&gt;Meta's CRAG&lt;br&gt;
benchmark&lt;/a&gt;, which I only found after this run, grades four outcomes instead of one axis: a correct answer scores 1, a useful answer with minor errors 0.5, a &lt;strong&gt;missing&lt;/strong&gt; answer 0, and an incorrect answer &lt;strong&gt;minus one&lt;/strong&gt;. "Missing" is defined there as the system replying "I don't know".&lt;/p&gt;

&lt;p&gt;That minus sign is the part I was missing. Saying nothing costs you nothing. Being wrong costs you a point.&lt;/p&gt;

&lt;p&gt;Nothing was fabricated in this run. The system declined, as instructed, and the number punished it for that. But the rubric that could not credit that refusal is the same rubric that would not have charged for a made-up answer, and a made-up answer is the one that reaches the reader.&lt;/p&gt;

&lt;p&gt;Charge the model for being wrong. Let it say nothing for free.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The illusion of improvement</title>
      <dc:creator>Serhiy Kucherenko</dc:creator>
      <pubDate>Sun, 09 Aug 2026 19:24:47 +0000</pubDate>
      <link>https://dev.to/skucherenko/the-illusion-of-improvement-1j54</link>
      <guid>https://dev.to/skucherenko/the-illusion-of-improvement-1j54</guid>
      <description>&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://rag.serhiykucherenko.dev" rel="noopener noreferrer"&gt;My RAG&lt;/a&gt; answers questions about SEPA payment rulebooks. Every page of those PDFs carries the same header and footer, and all of it lands inside the chunks that get embedded.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fluuhdq4hlyxxny15ouyc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fluuhdq4hlyxxny15ouyc.png" alt="One chunk, as the embedder saw it" width="800" height="194"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two lines of letterhead on every one of 290 pages, sitting in the same vector as the sentence that actually answers something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hypothesis
&lt;/h2&gt;

&lt;p&gt;Identical text in every chunk means a shared component in every embedding. I believed that component was flattening the similarity signal: distances on my test query all clustered tightly around 0.34 instead of spreading out.&lt;/p&gt;

&lt;p&gt;Strip the repeated lines, and while I am in there, stop splitting mid-sentence. Sharper chunks, sharper vectors, better ranking.&lt;/p&gt;

&lt;p&gt;It is a mechanism, and it is checkable. I would have bet on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What improved
&lt;/h2&gt;

&lt;p&gt;The cleanup worked exactly as designed. Any line repeating on at least half a document's pages gets stripped, with digits masked first so page numbers cannot disguise a repeat. "&lt;a href="http://www.epc-cep.eu" rel="noopener noreferrer"&gt;www.epc-cep.eu&lt;/a&gt; 35" on one page and "&lt;a href="http://www.epc-cep.eu" rel="noopener noreferrer"&gt;www.epc-cep.eu&lt;/a&gt; 36" on the next both normalise to "&lt;a href="http://www.epc-cep.eu" rel="noopener noreferrer"&gt;www.epc-cep.eu&lt;/a&gt; #", counted once per page. That line recurs on all 290 pages, so it goes. The rulebook title and the "Date issued" line collapse the same way and go with it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Boilerplate in chunk text:&lt;/strong&gt; every chunk, to none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Words on a typical page:&lt;/strong&gt; 464, to 448.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunks in the corpus:&lt;/strong&gt; 495, to 484.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunk boundaries:&lt;/strong&gt; cut mid-sentence, to whole sentences.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Eleven chunks disappeared, and the reason is duller than it sounds. No chunk was pure boilerplate. The junk was about sixteen words per page, spread across every chunk on that page. Chunking runs per page at a 300-word target, so the only pages whose count changed were the eleven sitting just above that line: 315 words became 299, 306 became 290, and two chunks became one.&lt;/p&gt;

&lt;p&gt;Everywhere else the text simply got cleaner without changing shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reality check
&lt;/h2&gt;

&lt;p&gt;Then I re-ran the query I had been watching. &lt;strong&gt;Same top pages, same order.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The one thing I could have seen with my own eyes did not happen. Nothing reordered.&lt;/p&gt;

&lt;p&gt;That is the entire result, and it is worth being blunt about how weak an observation it is. There was no evaluation set yet. The test was me reading one query's results and forming an impression, and an impression cannot separate "no effect" from "an effect too small to notice".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the mechanism was wrong
&lt;/h2&gt;

&lt;p&gt;Ranking does not compare chunks to each other. It compares each chunk to the query and sorts by that angle. The boilerplate was only ever in the chunks; the question you type carries none of it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F337jc5aap914rowxyvgy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F337jc5aap914rowxyvgy.png" alt="The chunks moved. The order they came back in did not." width="800" height="431"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I have two explanations and they both fit. The junk was identical in every chunk, so removing it nudged them all in a similar direction instead of spreading them apart. It was also only about 3% of a page's words, which is not much of a shove to begin with. Either way, I predicted a reshuffle and got a nudge.&lt;/p&gt;

&lt;p&gt;What I cannot do is tell those two apart. Separating them needs the per-result distances to more than two decimal places, and I never wrote them down. Even a 3% shift should have moved something in the third decimal. Whether it did is a question my own logs cannot answer.&lt;/p&gt;

&lt;p&gt;The 0.34 floor was not noise the boilerplate added either. That is what a dense, homogeneous legal corpus looks like to an embedding model. The chunks really are that similar to each other, because rulebook pages really are that similar.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I kept, and what I changed
&lt;/h2&gt;

&lt;p&gt;The cleanup stayed. Clean chunks are what a citation displays against an exact page, and what the prompt feeds the model. The payoff is real. It is just not the payoff I built it for.&lt;/p&gt;

&lt;p&gt;What actually changed was my process. Three days later I built a golden set of questions with verified answers, and it produced a retrieval baseline of &lt;strong&gt;recall@5 = 0.60&lt;/strong&gt;, the number every retrieval change has had to argue with since.&lt;/p&gt;

&lt;p&gt;One detail I have left alone deliberately. The comment at the top of the cleanup module still states the dead hypothesis as fact: boilerplate "drags every embedding toward the same noise, which flattens the similarity signal". It is wrong, it is still there, and it is a better reminder than anything I would write on purpose.&lt;/p&gt;

&lt;p&gt;The hypothesis was reasonable, the mechanism was checkable, and the check said no. The next fix that sounds this obviously right gets measured before it gets believed.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Three small lessons from building a RAG by hand</title>
      <dc:creator>Serhiy Kucherenko</dc:creator>
      <pubDate>Sun, 02 Aug 2026 19:16:41 +0000</pubDate>
      <link>https://dev.to/skucherenko/three-small-lessons-from-building-a-rag-by-hand-43n0</link>
      <guid>https://dev.to/skucherenko/three-small-lessons-from-building-a-rag-by-hand-43n0</guid>
      <description>&lt;p&gt;A retrieval system over the SEPA payment rulebooks. Plain Python, Postgres, two LLM vendors. Three independent decisions from it are worth writing down, because in each case the decision was cheap and the thing it taught was not the thing it was made for.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The vector database never got installed
&lt;/h2&gt;

&lt;p&gt;Every RAG guide starts in the same place. &lt;a href="https://docs.langchain.com/oss/python/langchain/rag" rel="noopener noreferrer"&gt;LangChain's own documentation&lt;/a&gt;&lt;br&gt;
puts it plainly: to build RAG, you first need to create a vector store. Then the shortlist writes itself: Pinecone, Qdrant, Weaviate, Chroma.&lt;/p&gt;

&lt;p&gt;The vectors live in Postgres instead, through pgvector.&lt;/p&gt;

&lt;p&gt;Not because Postgres is faster. Because a new datastore is a new failure surface, with its own consistency model, its own operational habits and its own way of going wrong at 2am. Postgres was already there holding the documents, and I already knew how to debug it. Scaling was never the plan for a corpus of one rulebook.&lt;/p&gt;

&lt;p&gt;The whole of the retrieval query is this:&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&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;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;chunks&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;distance&lt;/span&gt; &lt;span class="k"&gt;ASC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;=&amp;gt;&lt;/code&gt; is cosine distance: 0 is identical, 2 is opposite. The &lt;code&gt;::vector&lt;/code&gt; cast is not decoration. A bare Python list arrives as &lt;code&gt;double precision[]&lt;/code&gt;, which that operator refuses, so the vector travels as a text literal and is cast on arrival.&lt;/p&gt;

&lt;p&gt;One operator, one cast, one index. That is the entire integration.&lt;/p&gt;

&lt;p&gt;Convenience is not proof, though. Would the real vector database have bought back time worth having?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe0l3semumf4xjuvgep0z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe0l3semumf4xjuvgep0z.png" alt="Where one question's 2,823 ms actually goes: generation 2,598 ms, embedding API 219 ms, vector search 4.9 ms" width="800" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One question, end to end, takes about &lt;strong&gt;2,823 ms&lt;/strong&gt;. The vector search inside it takes &lt;strong&gt;4.9 ms&lt;/strong&gt;. Replacing pgvector with something infinitely fast would return 0.17% of the wait.&lt;/p&gt;

&lt;p&gt;None of this says a vector database is wrong. It says the unfamiliar one would have been paid for in operations and returned a line nobody was waiting on.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. You can only measure what you own
&lt;/h2&gt;

&lt;p&gt;No LangChain, no LlamaIndex either. The pipeline is plain Python calling two LLM vendors and a Postgres.&lt;/p&gt;

&lt;p&gt;The usual defence is YAGNI, and it holds: the flow is five fixed steps with no branching. Embed the question, search, build a prompt, call the model, parse the answer and its citations. Orchestrating five fixed steps is the easy part. A framework earns its keep on branching, retries and swappable backends, and none of those were in play.&lt;/p&gt;

&lt;p&gt;But that is not the reason worth giving.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2b23ndaquxjrmj3rxypm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2b23ndaquxjrmj3rxypm.png" alt="Five fixed steps; the three I wrote are the three that produced findings" width="799" height="361"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every result worth publishing from this project came out of a layer a framework would have owned.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I wrote the chunker, so the repeated headers could be stripped from every page and the effect measured: retrieval did not move at all. I wrote the rank fusion, so it was visible that hybrid search scored exactly the same as dense-only, and that the corpus was too small to show a difference rather than the technique being wrong. I wrote the database call, so it could be timed alone and found to be 4.9 ms.&lt;/p&gt;

&lt;p&gt;Three findings, all negative, all only visible from inside.&lt;/p&gt;

&lt;p&gt;The cost is real and there is a file listing it: mature libraries exist for the chunker, the fusion, the metrics, the eval harness. That file is the off-ramp for when this stops being worth it.&lt;/p&gt;

&lt;p&gt;You cannot instrument an abstraction you did not build.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Two dependencies called "model", two very different bills
&lt;/h2&gt;

&lt;p&gt;The model this system was planned around was retired before the first commit. The decision record replacing it carries the same date as that commit. The project lost its LLM before it had a second file.&lt;/p&gt;

&lt;p&gt;The swap itself was trivial. The responder is one environment variable:&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;LLM_MODEL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LLM_MODEL&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;claude-haiku-4-5&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One line, one deploy, done. Now the other model.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffdqqwkgdhmm9aexi7nb0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffdqqwkgdhmm9aexi7nb0.png" alt="Blast radius: swapping the responder touches one config value; swapping the embedding model touches every stored vector" width="800" height="1183"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The embedding model cannot move like that. The table declares its vectors as 1536 dimensions and an insert guard rejects anything else, because vectors from different embedding models do not share a space. Changing it means re-embedding the entire corpus before a single query works again. An afternoon at this size. A migration at a real one.&lt;/p&gt;

&lt;p&gt;Same word, "model". One is a config value. The other is infrastructure wearing a config value's clothes.&lt;/p&gt;

&lt;p&gt;The vendor's deprecation schedule does not read your roadmap. Decide which of your dependencies is which before it decides for you.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The localhost trap: a 10-second database connection on Windows</title>
      <dc:creator>Serhiy Kucherenko</dc:creator>
      <pubDate>Mon, 27 Jul 2026 15:41:36 +0000</pubDate>
      <link>https://dev.to/skucherenko/the-localhost-trap-a-10-second-database-connection-on-windows-3le7</link>
      <guid>https://dev.to/skucherenko/the-localhost-trap-a-10-second-database-connection-on-windows-3le7</guid>
      <description>&lt;p&gt;Every question in the app took about 15 seconds. The model was fast. Retrieval was fast. Ten of those seconds were hiding somewhere else, and for days I couldn't say where.&lt;/p&gt;

&lt;p&gt;The app is payments-rag, a RAG system I built over the SEPA payment rulebooks. You ask a question in plain English, it retrieves the relevant passages from Postgres + pgvector, sends them to Claude, and returns an answer with the exact rulebook page cited. Fast is not a goal; under 5 seconds is. On my Windows machine it was taking three times that, every single query, and the answers were correct, which somehow made it worse. Correct but slow doesn't scream "bug." It whispers "maybe that's just how it is."&lt;/p&gt;

&lt;p&gt;For days I told myself it was just slow on Windows. Docker overhead, antivirus, something environmental, whatever. I checked the model latency three times before I thought to check the connection. That was the wrong order, and it cost me the better part of a week.&lt;/p&gt;

&lt;p&gt;There's a reason this class of bug survives. An LLM app carries a built-in excuse: models are slow, everyone knows models are slow, so a 15-second answer doesn't trigger the same alarm a 15-second SQL query would. The LLM had a good alibi for the slowness. I believed it too early.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring instead of guessing
&lt;/h2&gt;

&lt;p&gt;Two cheap pieces of observability ended the mystery in minutes.&lt;/p&gt;

&lt;p&gt;The first was a per-stage timer. I split each request into connect, retrieval, and generation, and logged the breakdown on every answer. The line read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;retrieval 2.1s · generation 3.4s · connect + overhead 10.2s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Retrieval plus generation was about 5.5 seconds. The other ten were sitting around the database connection, not in the model, not in the vector search.&lt;/p&gt;

&lt;p&gt;The second was a health check: a small panel that pings each dependency and shows the round-trip time. For the database it read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB reachable · 10137 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unambiguous. The connection itself took 10.1 seconds. Not the query, not the embedding, the TCP connect. The bug had been there for days behind a vague "it's just slow." The moment we measured, it took one screenshot to locate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually happening
&lt;/h2&gt;

&lt;p&gt;The connection string said &lt;code&gt;localhost&lt;/code&gt;. That one word triggered a chain of events.&lt;/p&gt;

&lt;p&gt;On Windows, &lt;code&gt;localhost&lt;/code&gt; is dual-stack: it resolves to both IPv6 &lt;code&gt;::1&lt;/code&gt; and IPv4 &lt;code&gt;127.0.0.1&lt;/code&gt;, and the OS prefers IPv6. So the Postgres client first tried to connect to &lt;code&gt;::1:5433&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Nothing was listening there. The Postgres in this setup runs in Docker, and the published port was bound on IPv4 only. The IPv4 loopback answers; &lt;code&gt;::1&lt;/code&gt; does not.&lt;/p&gt;

&lt;p&gt;And because no &lt;code&gt;connect_timeout&lt;/code&gt; was set, the client sat on the dead IPv6 route until the OS gave up on its own schedule and retried over IPv4. That giving-up takes about 10 seconds. Then the IPv4 connection succeeds in milliseconds, the query runs fine, and the answer comes back correct. Every query paid the toll again.&lt;/p&gt;

&lt;p&gt;Three things had to line up to make it hurt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;localhost&lt;/code&gt; is dual-stack, and Windows tries IPv6 first.&lt;/li&gt;
&lt;li&gt;Docker published the container port on IPv4 only, so there was nothing on &lt;code&gt;::1&lt;/code&gt; to answer.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;connect_timeout&lt;/code&gt;, so instead of failing fast and loud, the client waited out the full OS-level fallback on every single connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Remove any one of the three and the problem disappears, which is exactly why it survived for days. On Linux and macOS colleagues' setups the same code was quick.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;One word. &lt;code&gt;localhost&lt;/code&gt; becomes &lt;code&gt;127.0.0.1&lt;/code&gt;, which skips DNS ambiguity entirely and goes straight to the IPv4 loopback that Docker actually publishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgresql://user:pass@127.0.0.1:5433/mydb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a timeout, so this class of bug can never hide as a silent hang again:&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;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;connect_timeout&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hang is worse than an error. An error tells you where it hurts; a hang just eats your latency budget and says nothing.&lt;/p&gt;

&lt;p&gt;The before and after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;host&lt;/th&gt;
&lt;th&gt;connect latency&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;localhost&lt;/code&gt; (IPv6 detour)&lt;/td&gt;
&lt;td&gt;10,137 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt; (direct IPv4)&lt;/td&gt;
&lt;td&gt;27 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;About 375× faster, one word changed.&lt;/p&gt;

&lt;p&gt;One caveat so nobody over-applies this: the trap needs the specific combination above. Windows resolution order, a container port published on IPv4 only, no connect timeout. If your stack differs, your ten seconds are hiding somewhere else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I keep from this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;localhost&lt;/code&gt; is not &lt;code&gt;127.0.0.1&lt;/code&gt; once IPv6 is in play. For a container whose port is mapped to IPv4, prefer the literal address; it behaves identically on Linux and macOS, where loopback is loopback anyway.&lt;/li&gt;
&lt;li&gt;Always set a connect timeout. Fail fast and loud.&lt;/li&gt;
&lt;li&gt;Cheap observability pays for itself. A per-stage timer and a one-line health check are maybe an hour of work combined, and they turned a multi-day "it's just slow" into a five-minute fix.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The per-stage timer and the health check both stayed in the project permanently. They've earned their place: the same health view now checks all five dependencies on demand and every 10 minutes, so the next silent hang, wherever it comes from, gets a number attached to it before it gets a story.&lt;/p&gt;




&lt;p&gt;Serhiy Kucherenko builds backend systems and LLM tooling. The project this story comes from, payments-rag, is open source: &lt;a href="https://github.com/KucherenkoSerhiy/payments-rag" rel="noopener noreferrer"&gt;github.com/KucherenkoSerhiy/payments-rag&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>python</category>
    </item>
    <item>
      <title>When 60% recall meant 90% accuracy: a RAG measurement story</title>
      <dc:creator>Serhiy Kucherenko</dc:creator>
      <pubDate>Sun, 26 Jul 2026 21:49:28 +0000</pubDate>
      <link>https://dev.to/skucherenko/when-60-recall-meant-90-accuracy-a-rag-measurement-story-ei0</link>
      <guid>https://dev.to/skucherenko/when-60-recall-meant-90-accuracy-a-rag-measurement-story-ei0</guid>
      <description>&lt;p&gt;Retrieval recall sat at 0.60 and would not move. Hybrid search did nothing. A reranker rescued exactly one question, and cost seconds per query to do it. The number that finally explained everything came from neither technique. It came from reading what the retriever had actually pulled back.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg4qf9hu3qo052tsmep9n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg4qf9hu3qo052tsmep9n.png" alt="The story in four moves: the paradox (recall 6/10 versus answers 9/10), two fixes with no lift (hybrid 0.60, reranker 0.70 eval-only), the reframe (3 of 4 misses sat on another retrieved page), and the lesson (count the fact in any chunk and retrieval scores 9/10, matching the answer pass rate)" width="799" height="253"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two numbers that shouldn't coexist
&lt;/h2&gt;

&lt;p&gt;payments-rag is a small RAG over two SEPA payment rulebooks. One eval checks whether the labelled page lands in the top 5 retrieved chunks. Another checks whether the final answer is correct.&lt;/p&gt;

&lt;p&gt;The two evals disagreed. Recall@5 read 0.60, six of ten. The answer eval read 84.8 out of 100, a 90% pass rate. If the right page misses the top 5 four times in ten, how do nine answers in ten come out fine? That gap was the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hybrid search: no lift
&lt;/h2&gt;

&lt;p&gt;Hybrid retrieval was the obvious first move: vector similarity plus keyword search, combined. &lt;em&gt;(The first run looked broken for a dumb reason: Postgres's plainto_tsquery ANDs every query word by default. Fixed the operator before the real measurement.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The fair result was 0.60 to 0.60. Zero. Vector stayed the default.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reranker hits its ceiling
&lt;/h2&gt;

&lt;p&gt;A reranker was the fashionable next move, and it did move the number: 0.60 to 0.70. But 0.70 was not a random landing spot.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz87gcm8d5zc43v2gkff8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz87gcm8d5zc43v2gkff8.png" alt="A reranker re-scores the top-20 vector fanout and keeps the best 5, lifting recall from 0.60 to 0.70. A page ranked beyond 20 was never in the fanout and can never be promoted, so recall@20 = 0.70 is the hard ceiling" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It promoted every fetchable page into the top 5. The remaining misses were never retrieved in the first place, so no amount of reordering could reach them. Worth building to understand the mechanism, but it costs seconds per query and stays eval-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  The misses weren't misses
&lt;/h2&gt;

&lt;p&gt;A recall miss hides two very different failures: the content was fetched but ranked low, or it was never fetched at all. Reranking and hybrid only reorder what was fetched, so neither could touch the second kind. To tell them apart, I re-ran retrieval on each of the four misses and searched the full text of every retrieved chunk for the answer fact itself. Not the labelled page. The fact.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Favrm53gnf1hpoum00wa2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Favrm53gnf1hpoum00wa2.png" alt="Why the metric lied: for the remittance-length question, the labelled page 21 held no answer, but pages 22, 64, and 148 in the same top-5 all carried " width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three of the four had the fact sitting in a retrieved chunk, on a page I had never labelled. My golden set pointed at one correct page among several, and recall@5 punished the retriever for finding a different correct one. I spent a Tuesday convinced it was broken. It wasn't.&lt;/p&gt;

&lt;p&gt;Count "fact present in any retrieved chunk" instead of "labelled page in the top-k" and retrieval scores 9/10, exactly the answer eval's pass rate. The two numbers reconcile.&lt;/p&gt;

&lt;p&gt;The fourth miss, currency, is the real one. "Euro" appeared in none of the retrieved chunks, and currency is the single question that scored 0 on the answer eval. Every LLM knows SEPA runs in euro, yet the model refused to answer without a grounded chunk. For a citation tool, failing out loud beats papering over a gap with training knowledge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coarse chunks: why the page ranks low
&lt;/h2&gt;

&lt;p&gt;Why did the right page rank low in the first place? Each page-sized chunk embeds hundreds of words, averaging the answer together with the legal scaffolding around it. The further your wording drifts from the specification's own, the lower that chunk ranks. One fact on page 26, retrieved under three wordings:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0ricrumaxhw2xcl7h1r.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz0ricrumaxhw2xcl7h1r.png" alt="The same page-26 fact retrieved at rank 1 for the spec's own wording, but rank 8 and rank 9 for two natural-language phrasings, at k=10" width="800" height="448"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same fact. The rank tracks how far your phrasing sits from the spec's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What carries over
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Split "ranked low" from "never fetched" before you tune. Reranking and hybrid reorder the fetched set; they cannot conjure a missing page. Both were aimed at the wrong problem here.&lt;/li&gt;
&lt;li&gt;Literature numbers don't transfer. Big-corpus hybrid gains say nothing about a 2-document, 10-question set. Small corpora behave differently, and 10 questions is a noisy ruler.&lt;/li&gt;
&lt;li&gt;Label your golden set to the fact, not to one page. A single "correct" page turns real hits into phantom misses, which is the whole reason these two numbers ever disagreed.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;em&gt;One caveat: that soft recall, fact-present-in-any-chunk, is still a manual check, not a built eval. For now I ship the strict 6/10.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I started the week thinking the retriever found the right answer 60% of the time. It was finding it 90% of the time. The metric was wrong, not the retriever.&lt;/p&gt;




&lt;p&gt;Serhiy Kucherenko builds backend systems and LLM tooling. Try payments-rag live at &lt;a href="https://rag.serhiykucherenko.dev" rel="noopener noreferrer"&gt;rag.serhiykucherenko.dev&lt;/a&gt;. Full code and evals in the repo: &lt;a href="https://github.com/KucherenkoSerhiy/payments-rag" rel="noopener noreferrer"&gt;github.com/KucherenkoSerhiy/payments-rag&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>python</category>
    </item>
  </channel>
</rss>
