<?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: Lev Riabov</title>
    <description>The latest articles on DEV Community by Lev Riabov (@lev_riabov_e6f2883d44b3ab).</description>
    <link>https://dev.to/lev_riabov_e6f2883d44b3ab</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%2F4036586%2F64269b79-d027-4664-8bc5-f6eb80e7822a.jpg</url>
      <title>DEV Community: Lev Riabov</title>
      <link>https://dev.to/lev_riabov_e6f2883d44b3ab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lev_riabov_e6f2883d44b3ab"/>
    <language>en</language>
    <item>
      <title>I measured the RAG technique menu on 46,000 chunks. Four things mattered.</title>
      <dc:creator>Lev Riabov</dc:creator>
      <pubDate>Sun, 02 Aug 2026 19:16:41 +0000</pubDate>
      <link>https://dev.to/lev_riabov_e6f2883d44b3ab/i-measured-the-rag-technique-menu-on-46000-chunks-four-things-mattered-2266</link>
      <guid>https://dev.to/lev_riabov_e6f2883d44b3ab/i-measured-the-rag-technique-menu-on-46000-chunks-four-things-mattered-2266</guid>
      <description>&lt;p&gt;Search "advanced RAG techniques" and you'll get a list of twenty things: hybrid search, reranking, HyDE, query decomposition, contextual retrieval, RAPTOR, GraphRAG, parent-child chunking, multi-vector retrieval, semantic chunking. They're presented as a menu you work through, each one making your system a bit better.&lt;/p&gt;

&lt;p&gt;They don't compose like that. Techniques at the same pipeline stage mostly &lt;em&gt;compete&lt;/em&gt; — they fix the same failure, so the second one you add finds nothing left to fix. And the only way to know which one your system actually needs is to measure on your own corpus.&lt;/p&gt;

&lt;p&gt;I built a RAG system over 62 ancient-history books (~46,000 chunks) and put every retrieval technique I could through the same door: implement, measure against a fixed 161-question test set, keep or reject, write down the number. This article is the retrieval half of that ledger — what shipped, what got rejected, and what the rejections taught me. My production system has &lt;strong&gt;no hybrid search and no reranker&lt;/strong&gt;, and that's a result, not a shortcut.&lt;/p&gt;

&lt;h2&gt;
  
  
  The map: four stages, two ledgers
&lt;/h2&gt;

&lt;p&gt;Everything before the LLM writes a word lives in one of four stages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A. INGEST  →  B. QUERY TRANSFORM  →  C. RETRIEVAL CORE  →  D. POST-RETRIEVAL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same stage → the techniques compete. Different stages → they compose. That one rule kills most of the listicle's implied ordering.&lt;/p&gt;

&lt;p&gt;The second rule is about money, and it's asymmetric:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ingest-time cost is paid once&lt;/strong&gt;, offline, on hardware you already have. One clever pass over the corpus is cheap forever.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query-time cost is paid on every request, in latency and tokens, for the life of the system.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So query-side cleverness has to clear a far higher bar than ingest-side cleverness. A technique that buys +1.7 points for a paid API call on every query is a worse deal than the same +1.7 for one overnight batch job — even though the leaderboard row looks identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One more thing, and it's the part nobody labels.&lt;/strong&gt; Evidence comes in three grades, and mixing them is how bad advice spreads:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measured here&lt;/strong&gt; — on this corpus, 46k chunks, 161 questions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measured smaller&lt;/strong&gt; — on my predecessor project (4 books, 950 chunks, 50 questions). Weaker. Re-tested whenever the finding looked scale-sensitive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoned away&lt;/strong&gt; — never measured. A cost/benefit judgment, and I'll say so when it is one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the whole retrieval menu with verdicts. The rest of the article explains the interesting rows.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Technique&lt;/th&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;th&gt;Evidence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strong embedding model&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Ship&lt;/strong&gt; — +18 recall@5&lt;/td&gt;
&lt;td&gt;measured here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contextual chunk notes + heading path&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Ship&lt;/strong&gt; — synthesis +18.2&lt;/td&gt;
&lt;td&gt;measured here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structure-aware chunking, canonical locators&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Ship&lt;/strong&gt; — architecture&lt;/td&gt;
&lt;td&gt;measured smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dedup, lost-in-the-middle reordering&lt;/td&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Ship&lt;/strong&gt; — free, one line each&lt;/td&gt;
&lt;td&gt;not worth measuring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metadata-filtered search&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Ship&lt;/strong&gt; — enables source isolation&lt;/td&gt;
&lt;td&gt;measured here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-encoder reranking&lt;/td&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;Kept, then &lt;strong&gt;dropped&lt;/strong&gt; — +1.7, paid per query&lt;/td&gt;
&lt;td&gt;measured here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-query expansion&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Off by default&lt;/strong&gt; — +2.1&lt;/td&gt;
&lt;td&gt;measured smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hybrid BM25 + RRF&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;C&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Rejected&lt;/strong&gt; — zero, byte-identical&lt;/td&gt;
&lt;td&gt;measured here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;HyDE&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;B&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Rejected&lt;/strong&gt; — −9.7&lt;/td&gt;
&lt;td&gt;measured smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parent-child / small-to-big&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Rejected&lt;/strong&gt; — completeness 3.22 → 2.67&lt;/td&gt;
&lt;td&gt;measured smaller&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contextual compression&lt;/td&gt;
&lt;td&gt;D&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Rejected&lt;/strong&gt; — prompt caching already won&lt;/td&gt;
&lt;td&gt;reasoned away&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-vector (ColBERT), SPLADE&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Rejected&lt;/strong&gt; — 10–50× storage&lt;/td&gt;
&lt;td&gt;reasoned away&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Semantic chunking, step-back, embed-summaries&lt;/td&gt;
&lt;td&gt;A/B&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Rejected&lt;/strong&gt; — no expected ROI&lt;/td&gt;
&lt;td&gt;reasoned away&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The baseline everything is measured against: naive dense retrieval, 500-token chunks, top-5 — &lt;strong&gt;recall@5 = 35.2%&lt;/strong&gt;. Two out of three questions never reached the model with the right passage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage A — ingest, the stage that pays
&lt;/h2&gt;

&lt;p&gt;At four books you can eyeball every quirk. At sixty-two you can't, and running one splitter over raw Gutenberg text means page headers, tables of contents and translator footnotes all leak into your chunks and get embedded as if they were content.&lt;/p&gt;

&lt;p&gt;The architecture that scales puts a hard interface in the middle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw book ──(per-format parser)──▶ normalized document tree ──(ONE uniform chunker)──▶ chunks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All per-book weirdness lives in parser adapters. The chunker is a single well-tested function that never learns which book it's processing: split on structural boundaries in priority order (section → paragraph → sentence), pack greedily to ~500 tokens, never merge across a section wall, never cut mid-sentence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chunking at scale is a parsing problem, not a token-count problem.&lt;/strong&gt; The size discussions get all the attention and are the least interesting decision in the stage.&lt;/p&gt;

&lt;p&gt;Two things I'd insist on again. Every chunk carries character offsets into the normalized text — which makes test-set gold spans &lt;em&gt;chunking-invariant&lt;/em&gt;, so I could change chunking strategy without rewriting the test set. And every chunk carries its canonical citation locator (&lt;code&gt;Caesar, Gallic War 4.25&lt;/code&gt;), because classical texts have stable reference schemes that survive every edition. That bought professional citations in answers and mechanical test-set authoring, for the cost of teaching the parsers to recognize book/chapter numbering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Contextual retrieval: shipped, and instructive about how to read a number
&lt;/h3&gt;

&lt;p&gt;A raw slice from the middle of chapter 12 reads "he then marched north" — the embedder has no idea who "he" is or which war this is. The fix is an ingest-time pass: a cheap local LLM writes 1–2 sentences situating each chunk, and the embedding covers &lt;code&gt;context_note + heading_path + chunk_text&lt;/code&gt; instead of bare text. 46,159 of 46,170 chunks enriched in one local batch pass on a consumer GPU, &lt;strong&gt;zero query-time cost&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The headline result was +1.7 &lt;a href="mailto:recall@5"&gt;recall@5&lt;/a&gt;. Noise. The internals were not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;category&lt;/th&gt;
&lt;th&gt;Δ recall@5&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;synthesis&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+18.2&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;literal&lt;/td&gt;
&lt;td&gt;+8.7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;multi-hop&lt;/td&gt;
&lt;td&gt;+2.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;synonym&lt;/td&gt;
&lt;td&gt;+0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;contradiction&lt;/td&gt;
&lt;td&gt;−5.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cross-book&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−9.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Synthesis — the worst category in the project — transformed. Cross-book got worse. Overall ranking sharpened well above noise (recall@1 +5.8, MRR +0.060). &lt;strong&gt;A flat headline hiding ±18-point internals is the normal case, not the exception.&lt;/strong&gt; If I'd only looked at the aggregate I'd have called this a no-op and moved on.&lt;/p&gt;

&lt;p&gt;The generation-side number was even more misleading. Answer completeness appeared to &lt;em&gt;drop&lt;/em&gt;, 4.45 → 4.30. It hadn't. On the 113 questions both runs answered, completeness was flat (4.46 → 4.40). What actually happened: contextual retrieval converted &lt;strong&gt;12 previously-refused questions into answered ones&lt;/strong&gt;, and those 12 — the retrieval-starved hard ones — scored 3.42, dragging the mean down while every prior answer held. In-scope false refusals fell from 15.6% to 7.4%.&lt;/p&gt;

&lt;p&gt;A win wearing the disguise of a regression. Any change that converts refusals into answers will do this to you, and the only defense is to always compute the metric on the set of questions both runs answered.&lt;/p&gt;

&lt;p&gt;The rest of the stage was cheap to reject. &lt;strong&gt;Parent-child retrieval&lt;/strong&gt; (embed small chunks, hand the LLM their parent section) regressed completeness 3.22 → 2.67 on my predecessor project — re-testable, but I'd want evidence of context starvation first. &lt;strong&gt;ColBERT-style multi-vector&lt;/strong&gt; costs 10–50× vector storage; rejected on the storage budget without measuring, and I'll call that what it is. &lt;strong&gt;Semantic chunking&lt;/strong&gt; and &lt;strong&gt;embedding summaries instead of chunks&lt;/strong&gt; were skipped on expected ROI — the latter is subsumed by contextual notes, which keep the original text &lt;em&gt;and&lt;/em&gt; add the context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage B — query transforms, the cheapest stage to skip
&lt;/h2&gt;

&lt;p&gt;Every technique here costs an extra LLM call before you've even searched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HyDE&lt;/strong&gt; — have the model write a hypothetical answer and embed &lt;em&gt;that&lt;/em&gt; instead of the question — scored &lt;strong&gt;−9.7 recall@5&lt;/strong&gt; on my predecessor. The mechanism is worth understanding because it generalizes: HyDE &lt;em&gt;replaces&lt;/em&gt; your query, discarding the discriminative terms the user actually gave you. It's built for a query/document vocabulary mismatch. If your embedder is strong enough not to have that mismatch, you're throwing away signal and paying a second of latency for the privilege.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-query expansion&lt;/strong&gt; (paraphrase the question n ways, union the results) measured +2.1 — real but marginal, and it's an extra call plus n searches on every request. Left in as an off-by-default flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query decomposition&lt;/strong&gt; and &lt;strong&gt;step-back prompting&lt;/strong&gt; I skipped as standalone techniques for a different reason: a retrieval loop that can search more than once does both of these adaptively, driven by what it actually found. Don't hand-build a static version of a behavior a loop gives you for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage C — the embedder is the whole ballgame
&lt;/h2&gt;

&lt;p&gt;This is the one that mattered most, and it's one line of configuration.&lt;/p&gt;

&lt;p&gt;Swapping the default embedder for a strong one (&lt;code&gt;qwen3-embedding-8b&lt;/code&gt;, hosted) took recall@5 from &lt;strong&gt;35.2% to 53%&lt;/strong&gt;. The hardest-hit category — modern-English questions against Victorian translation prose — gained &lt;strong&gt;+41.7 points&lt;/strong&gt;. Nothing else in this article comes close.&lt;/p&gt;

&lt;p&gt;How I picked it is the transferable part: shortlist by &lt;em&gt;constraints&lt;/em&gt;, decide by &lt;em&gt;ablation&lt;/em&gt;. The constraints were concrete — can it serve queries on a cheap CPU container or does it have to be an API, what's the license, does it need instruction prefixes, does the context window fit contextual notes. That produced four candidates. The leaderboard never got a vote in the final decision, because no leaderboard contains Victorian translation prose. Your corpus is the only leaderboard that counts.&lt;/p&gt;

&lt;p&gt;Two footguns cost real projects real quality here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Prefix parity.&lt;/strong&gt; Most modern embedders want different instruction prefixes for queries versus documents, and hosted APIs serving open-weight models generally &lt;em&gt;don't&lt;/em&gt; inject them for you. Corpus embedded with prefixes, queries without, is a silent multi-point loss that looks like nothing. Wrap embedding in one module that owns the prefix policy and never call the model from two places.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same model ≠ same vectors.&lt;/strong&gt; Runtime differences (sentence-transformers vs llama.cpp vs a hosted API), fp16 vs fp32, and quantization all shift vectors. The cheap defense: embed 20 fixed sentences on both stacks and assert cosine ≥ 0.999 before trusting them as one index.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Hybrid BM25 + RRF: rejected, and it's the best receipt in the project
&lt;/h3&gt;

&lt;p&gt;The standard 2024-era advice is that hybrid search — keyword BM25 fused with vector search — always wins at scale, especially for rare proper nouns. My corpus is &lt;em&gt;full&lt;/em&gt; of rare proper nouns (Vercingetorix, Pharsalus) in inconsistent Victorian spellings. I had rejected hybrid once already at 950 chunks, and I wrote down a prediction before running it: at 46k chunks this flips to a win.&lt;/p&gt;

&lt;p&gt;It didn't flip. It returned nothing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;metric&lt;/th&gt;
&lt;th&gt;dense&lt;/th&gt;
&lt;th&gt;hybrid&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;recall@1&lt;/td&gt;
&lt;td&gt;32.5&lt;/td&gt;
&lt;td&gt;30.7&lt;/td&gt;
&lt;td&gt;−1.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;recall@5&lt;/td&gt;
&lt;td&gt;56.7&lt;/td&gt;
&lt;td&gt;56.3&lt;/td&gt;
&lt;td&gt;−0.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;recall@50 (pool)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;82.4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;82.4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MRR&lt;/td&gt;
&lt;td&gt;0.580&lt;/td&gt;
&lt;td&gt;0.561&lt;/td&gt;
&lt;td&gt;−0.019&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not "roughly the same" — &lt;strong&gt;byte-identical pool recall, category by category.&lt;/strong&gt; Every answer BM25 could find by exact token match, the 8B embedder already had. And fusion made the top ranks slightly &lt;em&gt;worse&lt;/em&gt;, because RRF injects keyword-noise chunks that displace well-ranked dense hits.&lt;/p&gt;

&lt;p&gt;The mechanism is the finding: &lt;strong&gt;where a strong dense retriever misses, the answer is distributed, not keyword-findable&lt;/strong&gt; — so BM25 can't reach it either. The hybrid-always-wins advice assumes a weak lexical first stage. With a modern 8B embedder that assumption is just false on this corpus.&lt;/p&gt;

&lt;p&gt;Notice what made that result readable at all: &lt;code&gt;recall@50&lt;/code&gt;, treated as &lt;em&gt;pool recall&lt;/em&gt;. Recall@5 alone would have shown −0.4 and left me guessing whether BM25 had contributed new candidates that fusion then mis-ranked. A metric designed to separate "widened the pool" from "reordered the pool" turned an ambiguous wash into a clean rejection. Design your metrics to distinguish mechanisms, not just to score outcomes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage D — the reranker that failed its own rationale
&lt;/h2&gt;

&lt;p&gt;A cross-encoder rescores your top-50 and returns the best 5. It's the most-recommended technique in RAG, and I measured five of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;reranker&lt;/th&gt;
&lt;th&gt;host&lt;/th&gt;
&lt;th&gt;recall@5 vs no-rerank&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;qwen3-reranker-0.6b&lt;/td&gt;
&lt;td&gt;local&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−3.0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bge-reranker-v2-m3&lt;/td&gt;
&lt;td&gt;local&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−2.1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cohere/rerank-v3.5&lt;/td&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;+0.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cohere/rerank-4-pro&lt;/td&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+1.7&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;This is the exact inverse of the embedder gate.&lt;/strong&gt; There, the component was so weak that anything better was a huge win. Here the embedder is so strong that a 0.6B cross-encoder is &lt;em&gt;worse than the 8B embedder's own ranking&lt;/em&gt; — it adds noise. Only a state-of-the-art hosted reranker helps at all, which means shipping reranking means shipping a paid per-query dependency, forever.&lt;/p&gt;

&lt;p&gt;One architectural law came out of this, and it's free to obey: &lt;strong&gt;rerank the same text you embedded.&lt;/strong&gt; Scoring the bare chunk text while the index holds contextualized text made the reranker fight the retriever and undid the contextual gains outright (47.9% vs 51.6% on my predecessor). Retrieval and rerank must share a representation.&lt;/p&gt;

&lt;p&gt;The interesting part is why I dropped it. The reranker was kept provisionally for a &lt;em&gt;specific stated reason&lt;/em&gt;: contextual retrieval had cost me 9 points on cross-book questions, and reranking the top-50 was supposed to recover them. It didn't — cross-book landed at 26.0 against a 34.4 floor, and &lt;strong&gt;no reranker recovered it&lt;/strong&gt;, though the pool demonstrably held the answers. Cross-book was a candidate-&lt;em&gt;pool&lt;/em&gt; problem, not an ordering problem, and reranking cannot surface what isn't in the pool.&lt;/p&gt;

&lt;p&gt;So: the reranker helped a little, everywhere except the place it was hired to help. Marginal, paid, per-query, forever, and falsified in its own rationale. When a stronger generator arrived later it came out of the pipeline entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One honest caveat.&lt;/strong&gt; The model swap and the reranker drop happened in the same run, so that drop was never cleanly isolated — I skipped the arm that would have separated them, for cost. It's a receipt gap in an otherwise complete ledger, and I'd rather name it than let the table imply more rigor than it has.&lt;/p&gt;

&lt;p&gt;The rest of the stage: &lt;strong&gt;dedup&lt;/strong&gt; and &lt;strong&gt;lost-in-the-middle reordering&lt;/strong&gt; are one line each, free, and I shipped them without measuring. &lt;strong&gt;Contextual compression&lt;/strong&gt; — LLM-summarize the retrieved chunks before stuffing them — I rejected by reasoning: prompt caching already makes raw chunks cheap, compression adds latency, and a summary can silently delete the exact sentence you were going to cite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things mattered
&lt;/h2&gt;

&lt;p&gt;After all of it, here's what actually determined retrieval quality on this corpus:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The embedding model.&lt;/strong&gt; +18 points from one config line. Everything else in this article combined is smaller. Don't inherit the tutorial's embedder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What goes inside the chunk.&lt;/strong&gt; Not how big it is — what it &lt;em&gt;contains&lt;/em&gt;. An ingest-time context pass transformed the worst category by +18.2 and costs nothing at query time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The parsing layer.&lt;/strong&gt; Where boundaries fall and what metadata rides along. Unglamorous, decided before any technique gets a vote, and expensive to change later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The metrics themselves.&lt;/strong&gt; Two metric redesigns changed my conclusions more than any technique except the embedder: pool recall, which turned the hybrid result from ambiguous into decisive, and a recall metric that I had to rewrite after discovering it &lt;em&gt;penalized&lt;/em&gt; questions with corroborating sources across multiple books — scoring 0.2 for retrieving one of five passages that each fully answered the question. A metric that punishes the exact diligence your system is for will quietly steer you wrong for months.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And what didn't matter: hybrid search (zero), four of five rerankers (negative or nil), HyDE (−9.7), and every fashionable ingest architecture I skipped. Not because they're bad techniques — because on &lt;em&gt;this&lt;/em&gt; corpus the binding constraint was somewhere else. That's the pattern underneath all of it: &lt;strong&gt;at any moment exactly one thing is the binding constraint, and every technique aimed anywhere else returns noise.&lt;/strong&gt; The embedder was binding, so fixing it paid 18 points. Once it wasn't, contextual retrieval was marginal, reranking was marginal-and-paid, and hybrid was nothing at all.&lt;/p&gt;

&lt;p&gt;The shipped retrieval stack is boring: contextual dense embeddings, a strong embedder, top-k, metadata filters. No hybrid. No reranker. &lt;strong&gt;recall@5 = 56.7%.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Which raises the obvious question, and it's the reason there's a second half to this story: the finished system answers &lt;strong&gt;100% of in-scope questions&lt;/strong&gt; and refuses 96% of the unanswerable ones, on a retriever that finds the right passage 56.7% of the time in a single shot.&lt;/p&gt;

&lt;p&gt;It manages that because it doesn't do a single shot. Once retrieval was closed, the remaining headroom turned out to be architectural — and that's the next article.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build RAG and LLM-evaluation systems, and I'm available for contract work. Everything above is open: the code, the 161-question golden set, and the full append-only eval log with every run record behind these numbers.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://historian.loroplanner.com" rel="noopener noreferrer"&gt;historian.loroplanner.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code + case study + eval log:&lt;/strong&gt; &lt;a href="https://github.com/LevRiabov/antic-historian" rel="noopener noreferrer"&gt;github.com/LevRiabov/antic-historian&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Previous article:&lt;/strong&gt; &lt;a href="https://dev.to/lev_riabov_e6f2883d44b3ab/rag-for-developers-who-arent-ai-engineers-what-actually-matters-3h7g"&gt;RAG for developers who aren't AI engineers&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;If your team is trying to make an LLM answer reliably from your own data — or trying to figure out whether the one you built already can be trusted — reach out: &lt;a href="mailto:levriabov@zohomail.eu"&gt;levriabov@zohomail.eu&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>rag</category>
      <category>search</category>
    </item>
    <item>
      <title>RAG for developers who aren't AI engineers: what actually matters</title>
      <dc:creator>Lev Riabov</dc:creator>
      <pubDate>Sun, 19 Jul 2026 13:53:54 +0000</pubDate>
      <link>https://dev.to/lev_riabov_e6f2883d44b3ab/rag-for-developers-who-arent-ai-engineers-what-actually-matters-3h7g</link>
      <guid>https://dev.to/lev_riabov_e6f2883d44b3ab/rag-for-developers-who-arent-ai-engineers-what-actually-matters-3h7g</guid>
      <description>&lt;h1&gt;
  
  
  RAG for developers who aren't AI engineers: what actually matters
&lt;/h1&gt;

&lt;p&gt;Most non-AI developers have a mental model of RAG that is either wrong or dangerously incomplete. Not because they're bad engineers — because almost everything written about RAG is either a 10-minute framework tutorial that hides every real decision, or a research paper aimed at people who already do this for a living. There's very little in between.&lt;/p&gt;

&lt;p&gt;This article is the in-between. I recently built a production RAG system over 62 ancient-history books (~46,000 chunks) and measured every design decision against a fixed test set — including the decisions that &lt;em&gt;didn't&lt;/em&gt; work. I'm going to skip the framework marketing and tell you the small number of things that actually determine whether your RAG system is trustworthy, with the numbers to back them up.&lt;/p&gt;

&lt;p&gt;You don't need ML knowledge to follow this. If you can build a REST API and query Postgres, you can build everything described here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 60-second mental model
&lt;/h2&gt;

&lt;p&gt;RAG (Retrieval-Augmented Generation) is just this:&lt;/p&gt;

&lt;p&gt;question → find relevant text in your corpus → paste it into the LLM prompt → "answer ONLY from this text"&lt;/p&gt;

&lt;p&gt;That's the whole idea. A search engine bolted onto an LLM, with instructions to answer from the search results instead of from its training data.&lt;/p&gt;

&lt;p&gt;The only genuinely new component for a typical backend developer is &lt;strong&gt;embeddings&lt;/strong&gt;. An embedding model turns text into a vector (an array of ~1,000 floats) where &lt;em&gt;similar meaning produces nearby vectors&lt;/em&gt;. So "find relevant text" becomes nearest-neighbor search over vectors: embed all your document chunks once at ingest time, embed the user's question at query time, fetch the k closest chunks.&lt;/p&gt;

&lt;p&gt;If you know Elasticsearch: it's like that, but matching on meaning instead of keywords. "Why did the ship sink" will match a paragraph about "the vessel foundered in the storm" even though they share zero words.&lt;/p&gt;

&lt;p&gt;Everything else — chunking, retrieval, prompting — is ordinary engineering. Which is exactly why regular developers can and should build these systems. What's missing from most tutorials isn't skill, it's knowing where the traps are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: naive RAG works in the demo and lies in production
&lt;/h2&gt;

&lt;p&gt;Here's the part that shocks people.&lt;/p&gt;

&lt;p&gt;The standard tutorial setup — split documents into fixed 500-token chunks, embed with whatever the tutorial used, retrieve top-5, feed to the model — &lt;em&gt;appears&lt;/em&gt; to work immediately. You ask a question, you get a fluent, confident, well-structured answer with a citation. Demo done, ship it, right?&lt;/p&gt;

&lt;p&gt;I measured that exact baseline on my corpus with a 135-question test set where I knew which passages contained each answer. Result: &lt;strong&gt;recall@5 = 35.2%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Read that again. For roughly &lt;strong&gt;two out of three questions, the correct passage never reached the model at all.&lt;/strong&gt; And here's the dangerous part: the model answered anyway. Fluently. With the same confident tone as when the retrieval worked.&lt;/p&gt;

&lt;p&gt;This is the core thing non-AI developers get wrong about RAG. The failure mode is not "no results found" like a search engine, and not an exception like an API call. The failure mode is a &lt;strong&gt;confident, plausible fabrication that is indistinguishable from a correct answer unless you already know the answer.&lt;/strong&gt; A system that's right 65% of the time and silently makes things up the other 35% is worse than useless — because you can't tell which answer you're looking at.&lt;/p&gt;

&lt;p&gt;Nothing about the demo tells you this is happening. The only way to know is to measure. Which brings us to the single most important piece of advice in this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you improve anything: build a test set
&lt;/h2&gt;

&lt;p&gt;This is the step every beginner tutorial skips, and it's worth more than any technique you'll ever add.&lt;/p&gt;

&lt;p&gt;Before touching chunk sizes, embedders, rerankers, or agent frameworks, do this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write &lt;strong&gt;30–50 real questions&lt;/strong&gt; about your corpus — the kind your actual users will ask, not the kind your documents happen to answer neatly.&lt;/li&gt;
&lt;li&gt;For each question, record &lt;strong&gt;where the answer lives&lt;/strong&gt; (which document, roughly which passage).&lt;/li&gt;
&lt;li&gt;Crucially, include &lt;strong&gt;5–10 questions your corpus cannot answer.&lt;/strong&gt; These are your hallucination traps. The correct behavior is a refusal; anything else is fabrication you'd otherwise never see.&lt;/li&gt;
&lt;li&gt;Measure two things &lt;strong&gt;separately&lt;/strong&gt;:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval:&lt;/strong&gt; is the right passage in the top-k results? (Pure code, no LLM judging needed — this is the number to optimize first.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation:&lt;/strong&gt; given the right passages, is the answer grounded in them? Does the system refuse when it should?&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This costs one or two days and it changes everything, because RAG advice on the internet is wildly corpus-dependent. Things that transformed one system do nothing on another. Without your own test set, every blog post — including this one — is a coin flip. With it, you can check any technique against your data in an hour.&lt;/p&gt;

&lt;p&gt;Concrete example of why this matters: the standard 2024-era advice says "hybrid search (keyword BM25 + vector) always beats pure vector search at scale." I believed it. I expected it to win on my corpus and pre-registered that prediction. When I actually ran it, hybrid retrieval was &lt;strong&gt;byte-identical&lt;/strong&gt; to plain dense retrieval — same recall, category by category, not one result changed. I only know that because I measured. (The full write-up of that negative result is its own story.)&lt;/p&gt;

&lt;p&gt;Your test set is not test infrastructure you write once and forget. It's the steering wheel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moved the needle (ranked)
&lt;/h2&gt;

&lt;p&gt;With a test set in place, here's what mattered on my corpus, in order of impact.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The embedding model — the biggest single lever
&lt;/h3&gt;

&lt;p&gt;Swapping the default embedder for a strong one (qwen3-embedding-8b, via a hosted API) took recall@5 from &lt;strong&gt;35% to 53%&lt;/strong&gt; — an 18-point jump from changing one line of configuration. The hardest-hit category improved by &lt;strong&gt;+41.7 points&lt;/strong&gt;: questions phrased in modern English against text written in formal Victorian translation. The weak embedder simply couldn't bridge the vocabulary gap; the strong one could.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;don't inherit the tutorial's embedder.&lt;/strong&gt; It's the component doing the actual "understanding" in your retrieval, models differ enormously, and swapping it is trivial. Check the &lt;a href="https://huggingface.co/spaces/mteb/leaderboard" rel="noopener noreferrer"&gt;MTEB retrieval leaderboard&lt;/a&gt;, pick a few strong candidates, and measure them on &lt;em&gt;your&lt;/em&gt; test set.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. What you put inside a chunk
&lt;/h3&gt;

&lt;p&gt;Chunking discussions usually obsess over token counts. The bigger win for me was &lt;strong&gt;what the chunk contains&lt;/strong&gt;. A raw 500-token slice from the middle of chapter 12 loses all context — the embedder sees "he then marched north" with no idea who "he" is or which war this is.&lt;/p&gt;

&lt;p&gt;The fix: at ingest time, prepend context to each chunk before embedding it — the book title, the chapter heading path, a one-line note about what this section covers. On my worst-performing question category (questions requiring synthesis across a section), this transformed results: &lt;strong&gt;+18.2 recall points&lt;/strong&gt;. Chunking is about meaning-per-chunk, not arithmetic.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Things that did NOT help (measure before adding)
&lt;/h3&gt;

&lt;p&gt;Two techniques that every "advanced RAG" listicle recommends:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid BM25 + vector search:&lt;/strong&gt; as described above — zero change on my corpus. A strong embedder already found everything keyword search could find.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-encoder reranking:&lt;/strong&gt; I measured five rerankers. Only the most expensive hosted one beat the no-reranker baseline at all, by ~1.7 points — for a permanent paid dependency on every query. Dropped.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is not "never use these." On your corpus they might win. The point is that both add cost, latency, and complexity, and the &lt;em&gt;only&lt;/em&gt; way to know whether they pay for themselves is your test set. "Fashionable" is not evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it refuse instead of hallucinate
&lt;/h2&gt;

&lt;p&gt;Retrieval quality gets the right text in front of the model. The second half of trustworthiness is what the model does when the text &lt;em&gt;isn't&lt;/em&gt; there — and this you have to engineer deliberately.&lt;/p&gt;

&lt;p&gt;Three parts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompt for grounded answering.&lt;/strong&gt; Instruct the model to answer only from the provided sources, cite which source supports each claim, and explicitly refuse when the sources don't contain the answer. This is necessary but nowhere near sufficient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test refusal like a feature.&lt;/strong&gt; This is what the out-of-scope traps in your test set are for. My favorite trap type: a question about a &lt;em&gt;named work the corpus doesn't contain but does mention&lt;/em&gt;. For example, my corpus includes a footnote about the poet Sappho but none of her actual poetry. Ask "what does Sappho's poetry express?" and a weak setup confidently answers from the footnote, sounding completely authoritative. The correct behavior is: "the corpus mentions this work but does not contain it," with the source of the mention. That distinction — between &lt;em&gt;having&lt;/em&gt; information and &lt;em&gt;having a reference to&lt;/em&gt; information — is exactly the kind of failure you'll never notice without deliberately testing for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know that model choice matters here too.&lt;/strong&gt; In my measurements, honest-refusal ability varied dramatically between LLMs — swapping the generator model took out-of-scope refusal from 73% to 100% with the same prompt. Prompting alone had a ceiling; I iterated the prompt through five versions and watched edits slide along a fixed trade-off curve without moving it.&lt;/p&gt;

&lt;p&gt;End state on my system, measured on the full test set: &lt;strong&gt;0% false refusals&lt;/strong&gt; (it answers every answerable question) and &lt;strong&gt;96% honest refusals&lt;/strong&gt; on the trap questions. Those two numbers together are what "reliable" means for RAG — and both are ordinary integration-test numbers you can put in a CI run, not vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A concrete starting path for a TypeScript developer
&lt;/h2&gt;

&lt;p&gt;You do not need an AI framework, a vector-database vendor, or Python to start. Here's the stack I'd hand a TS developer today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Postgres + pgvector&lt;/strong&gt; — vector search inside the database you already run and know how to operate. At hundreds of thousands of chunks it's more than enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hosted embedding API&lt;/strong&gt; — one HTTP call per chunk/query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any decent LLM API&lt;/strong&gt; for generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~200 lines of TypeScript&lt;/strong&gt; before you even consider a framework. Writing the pipeline by hand first is the best way to actually understand it — frameworks make far more sense once you know what they're abstracting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The five steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingest:&lt;/strong&gt; split documents into chunks along natural boundaries (sections, paragraphs), prepending title/heading context to each chunk's text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed and store:&lt;/strong&gt; one API call per chunk, insert into a table with a &lt;code&gt;vector&lt;/code&gt; column, add an index.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieve:&lt;/strong&gt; embed the incoming question, &lt;code&gt;SELECT ... ORDER BY embedding &amp;lt;=&amp;gt; $1 LIMIT 10&lt;/code&gt;. That operator is a cosine-distance nearest-neighbor search — that's the whole "vector database."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generate:&lt;/strong&gt; build a prompt with the retrieved chunks (labeled with their sources), the grounding-and-refusal instructions, and the question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build the test set and iterate.&lt;/strong&gt; Measure retrieval first. Fix retrieval before blaming the model. Re-run on every change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1–4 are a weekend. Step 5 is what separates a demo from a system.&lt;/p&gt;

&lt;p&gt;If you want a reference implementation of the full production version — the eval harness, the measured ablations including the failed ones, cost controls, prompt-injection defenses — the entire project this article is based on is open:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://historian.loroplanner.com" rel="noopener noreferrer"&gt;historian.loroplanner.com&lt;/a&gt; (try asking it something the corpus can't answer — that's the interesting part)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code + case study:&lt;/strong&gt; &lt;a href="https://github.com/LevRiabov/antic-historian" rel="noopener noreferrer"&gt;github.com/LevRiabov/antic-historian&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's Python, but nothing about the architecture is Python-specific — the structure maps one-to-one onto the TypeScript stack above.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build RAG and LLM-evaluation systems, and I'm available for contract work. If your team is trying to make an LLM answer reliably from your own data — or trying to figure out whether the one you built already can be trusted — my case study above shows how I approach it, with published numbers. Reach out: &lt;a href="mailto:levriabov@zohomail.eu"&gt;levriabov@zohomail.eu&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>llm</category>
      <category>rag</category>
    </item>
  </channel>
</rss>
