<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<title>Coder Legion - Recent posts tagged rag</title>
<link>https://coderlegion.com/tag/rag</link>
<description>Powered by Question2Answer</description>
<item>
<title>Why Your RAG Chatbot Gives Wrong Answers (and How to Fix It)</title>
<link>https://coderlegion.com/32049/why-your-rag-chatbot-gives-wrong-answers-and-how-to-fix-it</link>
<description>&lt;p&gt;You built a chatbot on your own documents, and it still answers with confident nonsense. Customers get wrong prices, invented policies or a polite &quot;I don't know&quot; when the answer is on your website. This is the most common complaint I hear about &lt;strong&gt;RAG chatbot wrong answers&lt;/strong&gt;, and the good news is that most of the time the model is not the problem. The retrieval and the rules around it are.&lt;/p&gt;
&lt;p&gt;I build and tune these systems as part of &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/services/ai-integration-development&quot;&gt;AI integration and AI-powered applications&lt;/a&gt; work. This guide walks through why a retrieval-augmented chatbot gives bad answers and the fixes I try, in the order I try them.&lt;/p&gt;
&lt;h3 id=&quot;first-understand-how-a-rag-chatbot-answers&quot;&gt;First, understand how a RAG chatbot answers&lt;/h3&gt;
&lt;p&gt;RAG stands for retrieval-augmented generation. When a user asks a question, the system:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Searches your content for the passages most related to the question&lt;/li&gt;
&lt;li&gt;Passes those passages to the language model along with the question&lt;/li&gt;
&lt;li&gt;Asks the model to answer using that material&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That means a wrong answer can come from three places: &lt;strong&gt;the right passage was never found&lt;/strong&gt;, &lt;strong&gt;the passage was found but was poor or incomplete&lt;/strong&gt;, or &lt;strong&gt;the model ignored or went beyond the material it was given&lt;/strong&gt;. Fixing the wrong layer wastes weeks, so diagnose first.&lt;/p&gt;
&lt;h3 id=&quot;step-1-find-out-which-layer-is-failing&quot;&gt;Step 1: Find out which layer is failing&lt;/h3&gt;
&lt;p&gt;Take 20 to 30 real questions that the bot got wrong. For each one, log what was retrieved and look at it yourself.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Right answer not in the retrieved passages:&lt;/strong&gt; a retrieval problem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Right answer present, but the bot answered wrongly:&lt;/strong&gt; a prompt or generation problem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Right answer not in your content at all:&lt;/strong&gt; a content problem. No technique fixes missing information.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This simple audit usually shows that one layer causes most of the failures. Everything below maps to one of these three layers.&lt;/p&gt;
&lt;h3 id=&quot;fixing-retrieval-problems&quot;&gt;Fixing retrieval problems&lt;/h3&gt;
&lt;h4 id=&quot;chunk-by-meaning-not-by-character-count&quot;&gt;Chunk by meaning, not by character count&lt;/h4&gt;
&lt;p&gt;Splitting documents into fixed-size pieces often cuts a policy in half or separates a heading from its answer. Split on headings, paragraphs and list boundaries, keep a little overlap, and store the page title and section name with each chunk so the passage carries its own context.&lt;/p&gt;
&lt;h4 id=&quot;clean-the-source-content&quot;&gt;Clean the source content&lt;/h4&gt;
&lt;p&gt;Navigation menus, cookie banners, repeated footers and duplicated pages pollute search results. Strip boilerplate before indexing and remove outdated pages, so the bot doesn't find an old price list that outranks the new one.&lt;/p&gt;
&lt;h4 id=&quot;combine-keyword-and-semantic-search&quot;&gt;Combine keyword and semantic search&lt;/h4&gt;
&lt;p&gt;Semantic search finds related meaning, but it can miss exact terms such as product codes, names and error messages. Combining it with keyword search (hybrid search) and merging the results catches both kinds of question. If you store vectors in PostgreSQL, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/pgvector/pgvector&quot;&gt;pgvector extension&lt;/a&gt; works alongside normal full-text search, and my comparison of &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/pgvector-hnsw-vs-ivfflat-index&quot;&gt;HNSW and IVFFlat indexes&lt;/a&gt; covers the index choice.&lt;/p&gt;
&lt;h4 id=&quot;retrieve-more-then-rerank&quot;&gt;Retrieve more, then rerank&lt;/h4&gt;
&lt;p&gt;Fetch a wider set of candidates, then use a reranking step to pick the few most relevant passages for the model. This often improves answer quality without changing anything else.&lt;/p&gt;
&lt;h4 id=&quot;rewrite-follow-up-questions&quot;&gt;Rewrite follow-up questions&lt;/h4&gt;
&lt;p&gt;Users ask things like &quot;what about the cheaper one?&quot; A search for that phrase finds nothing useful. Before searching, rewrite follow-ups into standalone questions using the conversation so far.&lt;/p&gt;
&lt;h3 id=&quot;fixing-prompt-and-generation-problems&quot;&gt;Fixing prompt and generation problems&lt;/h3&gt;
&lt;h4 id=&quot;tell-the-model-to-stay-inside-the-material&quot;&gt;Tell the model to stay inside the material&lt;/h4&gt;
&lt;p&gt;Instruct the model to answer only from the supplied passages and to say so when they don't contain the answer. Anthropic's guidance on &lt;a rel=&quot;nofollow&quot; href=&quot;https://platform.claude.com/docs/claude/docs/minimizing-hallucinations&quot;&gt;minimizing hallucinations&lt;/a&gt; describes techniques such as allowing the model to say it doesn't know and asking it to ground claims in quotes from the source.&lt;/p&gt;
&lt;h4 id=&quot;require-citations&quot;&gt;Require citations&lt;/h4&gt;
&lt;p&gt;Make the bot show which page or document each answer came from. Citations let users verify, and they let you see at a glance when an answer is not supported by a source.&lt;/p&gt;
&lt;h4 id=&quot;use-a-no-good-match-threshold&quot;&gt;Use a &quot;no good match&quot; threshold&lt;/h4&gt;
&lt;p&gt;If the best retrieved passage is only weakly related, don't force an answer. Return a fallback such as offering to connect the user with a person. A bot that says &quot;I'm not sure&quot; at the right moment is more trusted than one that always answers.&lt;/p&gt;
&lt;h4 id=&quot;set-topic-limits-and-a-persona&quot;&gt;Set topic limits and a persona&lt;/h4&gt;
&lt;p&gt;Define what the bot may and may not discuss, and how it should speak. On the Henceforward platform I built, the admin suite included controls for brand persona, topic restrictions and AI guardrails, so non-developers could adjust behaviour without a deploy. See the &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/work/henceforward-ai-rag-chatbot-knowledge-base&quot;&gt;Henceforward RAG chatbot case study&lt;/a&gt; for how that was structured.&lt;/p&gt;
&lt;h3 id=&quot;fixing-content-problems&quot;&gt;Fixing content problems&lt;/h3&gt;
&lt;p&gt;If the answer isn't in your content, no setting will conjure it. Look at the questions the bot can't answer and treat them as a to-do list for your knowledge base:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Add pages for questions customers ask repeatedly&lt;/li&gt;
&lt;li&gt;Make pricing, policies and hours explicit and dated&lt;/li&gt;
&lt;li&gt;Remove contradictory versions of the same information&lt;/li&gt;
&lt;li&gt;Put the answer near the question it belongs to&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Many &quot;AI problems&quot; turn out to be documentation problems.&lt;/p&gt;
&lt;h3 id=&quot;build-a-small-evaluation-set&quot;&gt;Build a small evaluation set&lt;/h3&gt;
&lt;p&gt;The single most useful habit is a fixed list of test questions with the answers you expect. Run it after every change to chunking, search or prompts, and compare results. Without it, you fix one question and silently break three others.&lt;/p&gt;
&lt;p&gt;A good starter set:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;30 to 50 real customer questions, with the expected answer or source page&lt;/li&gt;
&lt;li&gt;A handful of questions the bot should refuse or hand off&lt;/li&gt;
&lt;li&gt;A few tricky cases: ambiguous wording, outdated information, follow-up questions&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Re-run it whenever you change anything, and add every new failure you find.&lt;/p&gt;
&lt;h3 id=&quot;watch-the-bot-in-production&quot;&gt;Watch the bot in production&lt;/h3&gt;
&lt;p&gt;Log each question, the passages retrieved, the answer and any user feedback. Review a sample every week. Thumbs-down ratings and &quot;I don't know&quot; responses point you straight to content gaps and retrieval failures. My article on &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/production-grade-ai-features&quot;&gt;making AI features production-ready&lt;/a&gt; covers the logging, cost controls and failure handling that keep a bot trustworthy after launch.&lt;/p&gt;
&lt;h3 id=&quot;a-realistic-order-of-work&quot;&gt;A realistic order of work&lt;/h3&gt;
&lt;p&gt;If you have limited time, this is the order I would follow, because each step is cheap and builds on the last:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Build the test question set and record the current score&lt;/li&gt;
&lt;li&gt;Clean and re-chunk the content, then re-run the test set&lt;/li&gt;
&lt;li&gt;Add hybrid search and reranking, then re-run&lt;/li&gt;
&lt;li&gt;Tighten the prompt, add citations and a no-match fallback, then re-run&lt;/li&gt;
&lt;li&gt;Fill content gaps found in the failed questions&lt;/li&gt;
&lt;li&gt;Start the weekly production review&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Changing one thing at a time, and measuring after each change, is what separates steady improvement from guesswork. It also gives you evidence to show stakeholders that answer quality is actually improving.&lt;/p&gt;
&lt;h3 id=&quot;when-it-s-still-wrong-check-the-basics&quot;&gt;When it's still wrong: check the basics&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Is the content actually indexed, or did ingestion fail silently?&lt;/li&gt;
&lt;li&gt;Are you searching the right version or language of the content?&lt;/li&gt;
&lt;li&gt;Is the context window being filled with too many weak passages?&lt;/li&gt;
&lt;li&gt;Did a prompt change accidentally loosen the rules?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you are planning a chatbot from scratch, my guide to &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/ai-chatbot-development-cost&quot;&gt;AI chatbot development cost&lt;/a&gt; explains what drives the budget, and &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/building-ai-powered-chatbot-laravel-openai-database&quot;&gt;building an AI chatbot with Laravel and OpenAI&lt;/a&gt; shows one implementation.&lt;/p&gt;
&lt;h3 id=&quot;key-takeaways&quot;&gt;Key takeaways&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Diagnose first: was the answer not retrieved, poorly retrieved or mishandled by the model?&lt;/li&gt;
&lt;li&gt;Better chunking, clean content, hybrid search and reranking fix most retrieval failures.&lt;/li&gt;
&lt;li&gt;Ground the model in the sources, require citations and allow &quot;I don't know&quot;.&lt;/li&gt;
&lt;li&gt;Missing content is a content problem, not an AI problem.&lt;/li&gt;
&lt;li&gt;A small test set and weekly log reviews keep quality from slipping.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If your chatbot is giving answers you can't trust, &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/book&quot;&gt;book a call&lt;/a&gt; and I will review how it retrieves and answers, and tell you what to fix first.&lt;/p&gt;
&lt;p&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://aqibjavaid.site/blog/rag-chatbot-wrong-answers-fix&quot;&gt;aqibjavaid.site&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32049/why-your-rag-chatbot-gives-wrong-answers-and-how-to-fix-it</guid>
<pubDate>Thu, 22 Oct 2026 08:46:53 +0000</pubDate>
</item>
<item>
<title>Legal Knowledge Bases on Premise: Compression, Tokens, and RAG Without Leaking Data</title>
<link>https://coderlegion.com/32025/legal-knowledge-bases-on-premise-compression-tokens-and-rag-without-leaking-data</link>
<description>&lt;p&gt;If you are an engineer asked to build &quot;a searchable legal knowledge base&quot; for a law firm or an in-house legal team, the easy part is the search UI. The hard part is everything underneath: hundreds of gigabytes of scanned contracts, stamped pages, exhibit photos, and counterparty PDFs that nobody can open quickly, that blow past any context window if you try to feed them to a model, and that absolutely cannot leave the building. This piece is the hands-on, engineering-minded version of that problem — the one that talks about compression math, token budgets, and pipeline topology rather than vendor brochures.&lt;/p&gt;
&lt;p&gt;The thesis is simple to state and annoying to implement: &lt;strong&gt;shrink the documents before you try to retrieve over them, keep the whole chain on hardware you control, and treat token cost as a first-class design constraint, not an afterthought.&lt;/strong&gt; Below we walk through why document size is really a token problem, what compression does at a conceptual level, how RAG over private legal data actually works, the token-cost math you should run before writing any code, and how to assemble an on-prem pipeline. We close with a selection framework so you can evaluate tools without trusting marketing.&lt;/p&gt;
&lt;h3 id=&quot;why-document-size-is-really-a-token-problem&quot;&gt;Why document size is really a token problem&lt;/h3&gt;
&lt;p&gt;Most engineers first meet this problem as a storage problem. &quot;Our case files are big.&quot; That framing is a trap, because storage is cheap and getting cheaper. The real cost shows up later, when you try to make the data useful to a language model. Three distinct cost centers all scale with the size and shape of the source documents:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Ingestion compute.&lt;/strong&gt; Every page you want to search must be parsed, extracted, and embedded. Uncompressed, low-quality scans force repeated OCR passes and waste embedding calls.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retrieval payload.&lt;/strong&gt; Whatever chunks you retrieve get stuffed into the model's context at query time. Big, poorly chunked documents mean more tokens per query, every single query.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transfer and storage tier.&lt;/strong&gt; Legal material is retained for years. A storage-industry analysis by Komprise found that 60–70% of files on corporate network storage had not been opened in over 90 days, yet they still occupy the most expensive storage tier. For legal teams this is not a one-time annoyance — it is a permanent tax, because you cannot delete and you cannot keep paying premium rates for files nobody opens.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So &quot;make it smaller&quot; is not about disk savings. It is about making the document &lt;em&gt;ingestible, retrievable, and transferable&lt;/em&gt; without a budget explosion. That is the through-line of this whole article.&lt;/p&gt;
&lt;h3 id=&quot;how-images-become-tokens-or-why-a-pdf-lies-to-you-&quot;&gt;How images become tokens (or why a &quot;PDF&quot; lies to you)&lt;/h3&gt;
&lt;p&gt;Here is the part junior pipeline code gets wrong. A 300-page &quot;contract PDF&quot; that a paralegal hands you is usually not text. It is 300 raster images of paper, possibly with a thin text layer that does not line up with the stamps. When you load it, you are not loading 300 pages of words; you are loading 300 photographs of words.&lt;/p&gt;
&lt;p&gt;There are two ways a model ends up paying for those pages:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Path A — vision tokens.&lt;/strong&gt; If you feed the page images directly to a multimodal model, each image is tiled and encoded into a grid of vision tokens. A single A4 scan at reasonable resolution can easily consume a few thousand vision tokens. Multiply by 300 pages and a few hundred exhibit photos and you are in the high hundreds of thousands of tokens &lt;em&gt;just to read the bundle once&lt;/em&gt;. And you re-read it every time you reprocess, re-embed, or re-index. This is the silent killer of naive &quot;just point the model at the folder&quot; approaches.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Path B — OCR to text.&lt;/strong&gt; The sane path is to run OCR, get real text, chunk the text, embed the chunks. Now a page is a few hundred to a thousand tokens of text instead of a few thousand vision tokens. The catch: OCR quality depends entirely on the clarity of the source image. If your &quot;compression&quot; blurred the stamp or the signature, OCR fails, entities get dropped, and your retrieval silently returns wrong answers. For legal data, a wrong answer is worse than no answer.&lt;/p&gt;
&lt;p&gt;This is the crux of why compression for legal material is not the same as compression for a photo gallery. You are optimizing for &lt;em&gt;perceptual fidelity of structured marks&lt;/em&gt; — stamps, signatures, seals, exhibit numbers — not for aesthetic smoothness. A generic image compressor that shaves bytes by softening edges will happily destroy the one pixel pattern that proves a document was executed. The engineering requirement is: &lt;strong&gt;compress hard enough to win the storage and transfer battle, but preserve the marks that carry legal meaning.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;compression-principles-at-a-conceptual-level&quot;&gt;Compression principles at a conceptual level&lt;/h3&gt;
&lt;p&gt;You do not need to be a codec engineer to design this pipeline, but you should understand the levers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lossless vs. lossy.&lt;/strong&gt; Lossless compression (think of it as a perfect repacking) shrinks without throwing anything away. For already-digital text PDFs and Office files it is the right default — there is no reason to lose a comma. But scanned pages and exhibit photos are inherently continuous-tone raster data; lossless only gets you so far because paper scans carry noise, dust, and background texture that do not compress well. There, a carefully tuned lossy step is where the real byte savings come from.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Perceptual vs. flat bitrate.&lt;/strong&gt; A good compressor for documents spends its bit budget where human-meaningful structure lives — sharp edges, high-contrast marks, fine text strokes — and spends less on smooth backgrounds. The failure mode is the opposite: spreading the bit budget evenly so that backgrounds look fine but the seal in the corner turns to mush. For legal work you want the perceptual model biased toward &lt;em&gt;marks&lt;/em&gt;, not toward &quot;pleasing photo.&quot;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Consistency across formats.&lt;/strong&gt; This is the part that quietly breaks pipelines. The same contract arrives as a PDF from one counterparty, a Word file from another, and a phone photo of a signed page from a third. If your compression behaves differently per format — clean on PDF, blurry on the photo — your downstream OCR and retrieval quality becomes a lottery determined by whoever sent the file. A recent product update from one vendor consolidated the image-compression core so that scanned pages, stamps, and exhibits compress under one consistent standard whether the source is PDF, Word, or a raw scan. The engineering payoff is not &quot;smaller files&quot; in the abstract; it is &lt;em&gt;predictable&lt;/em&gt; output, so the rest of the pipeline can assume a stable input quality instead of branching on format. Search the name and you'll find it if you want to look at that approach.&lt;/p&gt;
&lt;p&gt;The headline number that matters operationally: volumes can drop 60–80% on these bundles. That is not a compression-ratio bragging point. It is what makes an email attachment that would not send actually send, a multi-hundred-megabyte case folder sync cleanly to a branch office, and the preprocessing before indexing cheap enough to re-run whenever your chunking strategy improves.&lt;/p&gt;
&lt;h3 id=&quot;how-rag-over-private-legal-data-works&quot;&gt;How RAG over private legal data works&lt;/h3&gt;
&lt;p&gt;Retrieval-augmented generation, stripped of hype, is a six-stage pipeline. Each stage has an engineering decision that interacts with document size.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Ingest and normalize.&lt;/strong&gt; Pull the source files, normalize formats, and run the compression step described above. This is where 60–80% of the byte weight dies. Critically, do this &lt;em&gt;before&lt;/em&gt; anything leaves the machine.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Extract.&lt;/strong&gt; For digital files, pull the text layer. For scans, run OCR. For exhibits, decide whether the image itself carries information (a photograph of a defect) or just needs to be attached as evidence (a signed page). This decision drives your token budget later.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Chunk.&lt;/strong&gt; Split the extracted text into overlapping windows — say 400–800 tokens with a small overlap. For contracts, chunking by &lt;em&gt;section&lt;/em&gt; (recital, definitions, representations, covenants, signature block) beats blind fixed windows, because a clause is a semantic unit. A naive fixed window will split &quot;the liability cap is $X, except in cases of fraud&quot; right down the middle.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. Embed.&lt;/strong&gt; Each chunk becomes a vector via an embedding model. Embedding cost scales with total tokens ingested, which is why the compression-upfront discipline pays off: you embed less, and you embed cleaner text.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Index.&lt;/strong&gt; Store vectors in a vector store and, in parallel, keep a full-text (lexical) index — BM25-style — over the same chunks. Legal retrieval leans heavily on exact phrases (&quot;Section 7.2&quot;, &quot;Force Majeure&quot;, &quot;indemnification&quot;), which lexical search nails and pure semantic search often misses.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. Retrieve and generate.&lt;/strong&gt; At query time, run hybrid retrieval: combine the semantic nearest-neighbor hits with the lexical hits, rerank, take the top-k chunks, and inject them into the prompt with citations. The model never sees the whole document; it sees a few thousand tokens of the most relevant slices.&lt;/p&gt;
&lt;p&gt;The privacy property falls out of topology, not features: if every one of those six stages runs on hardware inside your network, with no stage calling an external API, then the data simply has no path to leave. That is the entire argument for on-prem RAG in legal. It is not &quot;we added encryption&quot;; it is &quot;there is no outbound call to intercept.&quot;&lt;/p&gt;
&lt;p&gt;A note on the embedding model itself, because engineers often agonize over which one to pick and then ignore the part that actually matters. Embeddings convert a chunk of text into a fixed-length vector — commonly 384 to 3072 dimensions depending on the model. Two properties drive your design: the vector's dimensionality sets how much memory the index consumes (dimensions × chunks × bytes), and the model's notion of &quot;similar&quot; determines whether &quot;indemnification&quot; retrieves &quot;hold harmless&quot; or retrieves a unrelated mention of &quot;insurance.&quot; For legal text, domain mismatch hurts: a general-purpose embedding trained on web text may treat &quot;consideration&quot; as a thoughtful opinion rather than a contract's exchanged value. If retrieval feels random, the embedding model is a more likely culprit than your chunk size. You do not need the largest model; you need one whose similarity space matches contract language, and you need to verify that with measurement rather than intuition.&lt;/p&gt;
&lt;h3 id=&quot;the-token-cost-math-you-should-run-before-coding&quot;&gt;The token-cost math you should run before coding&lt;/h3&gt;
&lt;p&gt;Do not estimate. Compute. Here is the spreadsheet logic, using a representative matter bundle:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Bundle:&lt;/strong&gt; a 300-page scanned contract PDF + 200 exhibit photos.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Uncompressed footprint:&lt;/strong&gt; ~650 MB (scanned raster + photos).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vision-only ingestion (Path A):&lt;/strong&gt; ~2,500–3,000 vision tokens per page/image. Call it 2,800. Bundle = 500 items × 2,800 = &lt;strong&gt;1.4 million tokens to read once.&lt;/strong&gt; Re-embed on every pipeline tweak and the cost multiplies.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OCR-to-text ingestion (Path B):&lt;/strong&gt; 300 pages × ~700 words ≈ 210,000 words ≈ &lt;strong&gt;280,000 tokens&lt;/strong&gt; of source text. Plus exhibits that carry text. So you dropped ingestion from ~1.4M to ~0.3M tokens — roughly a 5× reduction — by refusing to feed vision tokens.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chunking:&lt;/strong&gt; at 600 tokens/chunk with overlap, ~600 chunks. Embedding calls scale with chunks, not pages, so this is stable and cheap.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query payload:&lt;/strong&gt; top-8 chunks × ~500 tokens = &lt;strong&gt;4,000 tokens per query&lt;/strong&gt; into the model. This is the recurring cost, and compression does not change it much — chunk design does. That is the key insight: &lt;strong&gt;compression wins the ingestion and storage battle; chunk design wins the query battle.&lt;/strong&gt; Confusing the two is why teams over-spend.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now fold in storage. That 650 MB, uncompressed, sitting on the premium tier for years (recall the 60–70% untouched-files finding) is a recurring bill. At 60–80% reduction you are paying for a fraction of it, and the bundle becomes small enough to replicate to a branch office over a normal link instead of shipping disks.&lt;/p&gt;
&lt;p&gt;Cost formula to put in your design doc:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ingestion_cost ≈ total_source_tokens × embedding_rate
query_cost      ≈ avg_retrieved_tokens × generation_rate × queries_per_month
storage_cost    ≈ compressed_bytes × premium_tier_rate × retention_years
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The lever you control most is &lt;code&gt;total_source_tokens&lt;/code&gt; (via compression + OCR discipline) and &lt;code&gt;avg_retrieved_tokens&lt;/code&gt; (via chunk design). Everything else is someone else's price list.&lt;/p&gt;
&lt;h3 id=&quot;building-an-on-prem-knowledge-base-pipeline&quot;&gt;Building an on-prem knowledge base pipeline&lt;/h3&gt;
&lt;p&gt;You do not need a single monolith. You need five loosely coupled components, each of which can be a self-hosted service:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A. Compressor/normalizer.&lt;/strong&gt; Sits first in the line. Takes any format, applies consistent image compression, preserves marks. Output is normalized files plus extracted text where available. This is the component most teams underestimate; it is also the component whose quality determines whether stage B (OCR) succeeds on the first pass.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;B. Parser/OCR.&lt;/strong&gt; Turns normalized inputs into clean text. Keep it on-prem; cloud OCR means the document left. For exhibits that are pure images, decide per-file whether to OCR or to store the image as attached evidence with a caption.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;C. Chunker + embedder.&lt;/strong&gt; Applies your legal-aware chunking policy and produces vectors. The embedder is a model you host; pick one sized for your latency and privacy needs. Run embeddings in batches to keep GPU/CPU busy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;D. Hybrid store.&lt;/strong&gt; A vector index for semantics plus a lexical index for exact phrases, with a reranker on top. Keep both behind the same internal network boundary.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;E. Retrieval gateway.&lt;/strong&gt; The only component users touch. It builds the prompt from retrieved chunks, attaches citations, and calls your self-hosted generation model. It should also enforce authorization — a junior associate should not retrieve a partner-only matter just because they can type.&lt;/p&gt;
&lt;p&gt;Notice what is &lt;em&gt;not&lt;/em&gt; in the pipeline: any outbound network call. If your design doc has a line that says &quot;send to  for processing,&quot; you have defeated the purpose. The whole point is that the six RAG stages and the compressor all run where the disk lives.&lt;/p&gt;
&lt;p&gt;One more engineering note: redaction belongs in the pipeline, not in the prompt. Names, ID numbers, bank accounts, and privileged notes should be detected and masked &lt;em&gt;before&lt;/em&gt; indexing, so they never become retrievable chunks and never reach the generation model. Doing it in the prompt (&quot;please ignore sensitive data&quot;) is theater; the data is still in the index.&lt;/p&gt;
&lt;h3 id=&quot;measuring-retrieval-quality-before-you-trust-it&quot;&gt;Measuring retrieval quality before you trust it&lt;/h3&gt;
&lt;p&gt;A RAG pipeline that returns confident, well-cited answers can still be wrong, and in legal work a wrong citation is a liability. You should treat retrieval as a measurable subsystem with its own eval, independent of whether the final text &quot;looks right.&quot; The cheap version of this is a gold set: take fifty real questions a lawyer would ask — &quot;what is the cap on liability in the 2024 Acme agreement,&quot; &quot;which matters reference arbitration in London&quot; — and hand-label the chunks that should have been retrieved. Then measure precision@k and recall@k on your top-k window. If precision is low, your reranker or chunking is surfacing noise; if recall is low, your embedding or lexical index is missing the answer entirely.&lt;/p&gt;
&lt;p&gt;The subtler metric is &lt;em&gt;citation faithfulness&lt;/em&gt;: does the generated answer actually point at the chunk that supports the claim? A system can retrieve the right chunk and then ignore it. Log the retrieved chunks alongside the output during a pilot and sample them. None of this requires a fancy framework — a spreadsheet of question, expected chunk, retrieved chunk, and a pass/fail column will tell you more in an afternoon than a quarter of vibes. The point is to instrument the retrieval layer explicitly, because once the pipeline is in production nobody revisits it until something blows up.&lt;/p&gt;
&lt;h3 id=&quot;operational-failure-modes-worth-designing-against&quot;&gt;Operational failure modes worth designing against&lt;/h3&gt;
&lt;p&gt;A few problems show up only after real volume accumulates, and they are cheaper to prevent than to clean up.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Duplicate and near-duplicate matter files.&lt;/strong&gt; The same contract gets re-uploaded with a different filename, or a counterparty sends the same exhibit twice. Without deduplication, your index grows and retrieval returns two near-identical chunks, diluting the top-k. A content-hash or near-duplicate detection step before indexing is a small addition that pays for itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Version drift.&lt;/strong&gt; A contract is amended; the old version stays indexed. Now a query returns contradictory clauses from two versions. You need a versioning policy — index by matter-plus-version, and prefer the latest unless the query explicitly asks for history.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OCR drift on low-quality scans.&lt;/strong&gt; Even good compressors cannot save a source that was unreadable to begin with. Build a quality gate: if OCR confidence on a page falls below a threshold, flag it for human review instead of silently embedding garbage. Silent garbage is worse than a visible gap, because the gap at least gets noticed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Authorization leakage.&lt;/strong&gt; The retrieval gateway must enforce matter-level access. If chunks from a partner-only matter are reachable by a junior associate through a clever query, your &quot;private&quot; system has a privilege bug, not a privacy feature. Treat the gateway's authz layer as part of the security boundary, because it is.&lt;/p&gt;
&lt;p&gt;None of these are exotic. They are the difference between a demo that impresses and a system a firm will actually stake client confidences on.&lt;/p&gt;
&lt;h3 id=&quot;the-governance-risk-that-makes-on-prem-non-optional&quot;&gt;The governance risk that makes on-prem non-optional&lt;/h3&gt;
&lt;p&gt;This is not paranoia; it is the reported failure mode. IBM's 2025 Cost of a Data Breach study found that about one in five organizations had a breach caused by employees using AI tools on their own — so-called shadow AI — and those breaches cost on average $670,000 more than peers'. Among organizations that were breached, 63% had no AI governance policy at all.&lt;/p&gt;
&lt;p&gt;Read that against a legal team. The moment a paralegal pastes a confidential contract into a public chat window &quot;to summarize it,&quot; the data has left, silently, with no policy catching it. The engineering answer is not a training video; it is to remove the outbound path entirely. If the only way to use AI on the matter is through the on-prem gateway (component E above), then shadow AI cannot exfiltrate, because there is nowhere to paste. That is governance by architecture, which is the only kind that survives a busy Tuesday.&lt;/p&gt;
&lt;p&gt;There is also a quieter productivity argument, and it is worth putting in the same deck. A 2025 knowledge-worker report compared teams with and without knowledge management: those without spent 8.5 hours a week searching for things; those with a system dropped to 4.6 hours. Scaled to 1,000 employees, that swing frees the equivalent of 98 full-time roles. Legal time is expensive and billable; the reclaimed hours convert directly into client work, not into folder archaeology.&lt;/p&gt;
&lt;h3 id=&quot;what-to-check-when-choosing&quot;&gt;What to check when choosing&lt;/h3&gt;
&lt;p&gt;Whether you build the pipeline yourself or evaluate a tool, run every candidate through the same lens. This is a selection framework, not a vendor scorecard — fill it in per option and let the gaps speak.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Dimension &lt;/th&gt;&lt;th&gt;Why it matters &lt;/th&gt;&lt;th&gt;What good looks like&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Data boundary &lt;/td&gt;&lt;td&gt;Legal material cannot leave the network; an outbound call is a compliance event &lt;/td&gt;&lt;td&gt;Entire pipeline, including OCR and embedding, runs on-prem with no external API&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Compression consistency &lt;/td&gt;&lt;td&gt;Mixed PDF/Word/scan inputs must produce stable, predictable output quality &lt;/td&gt;&lt;td&gt;One compression standard across formats; marks and stamps preserved, not blurred&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Retrieval type &lt;/td&gt;&lt;td&gt;Lawyers search by clause, exhibit number, and exact phrase, not vague meaning &lt;/td&gt;&lt;td&gt;Hybrid semantic + full-text, with reranking and cited source snippets&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Sensitive-data handling &lt;/td&gt;&lt;td&gt;PII and privilege must not become retrievable or model-visible &lt;/td&gt;&lt;td&gt;Auto-detection and redaction before indexing; return only under authorization&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Chunking policy &lt;/td&gt;&lt;td&gt;Bad chunking splits clauses and returns half-answers &lt;/td&gt;&lt;td&gt;Section-aware chunking for contracts, not fixed windows&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Cost model &lt;/td&gt;&lt;td&gt;Token and storage bills compound over years of retention &lt;/td&gt;&lt;td&gt;Predictable ingestion/query math; compression reduces both storage tier and re-embed cost&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Scale path &lt;/td&gt;&lt;td&gt;Matter volume grows; the design must not require a rewrite &lt;/td&gt;&lt;td&gt;Batch embedding, replicable index, clean separation of compressor from retriever&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Any option that cannot check the first row should be disqualified before the demo starts. The rest of the table separates &quot;looks impressive in a slide&quot; from &quot;survives a real matter.&quot;&lt;/p&gt;
&lt;h3 id=&quot;closing-thoughts-for-the-engineer&quot;&gt;Closing thoughts for the engineer&lt;/h3&gt;
&lt;p&gt;The mistake is to treat this as a search problem with a legal skin. It is really a &lt;em&gt;data-shape&lt;/em&gt; problem: legal documents are big, image-heavy, format-fragmented, and toxic if they escape. Solve the shape first — compress consistently, OCR cleanly, chunk by meaning — and the retrieval almost designs itself. Solve it on hardware you control, and the compliance conversation becomes a one-liner instead of a fire drill.&lt;/p&gt;
&lt;p&gt;Run the token math before you write a line of code. Know your &lt;code&gt;total_source_tokens&lt;/code&gt; and your &lt;code&gt;avg_retrieved_tokens&lt;/code&gt;, and you will know your bill. Everything else in this space is variation on those two numbers and the single rule that the data never crosses the boundary.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/32025/legal-knowledge-bases-on-premise-compression-tokens-and-rag-without-leaking-data</guid>
<pubDate>Thu, 22 Oct 2026 04:20:55 +0000</pubDate>
</item>
<item>
<title>Building a Small RAG Index from Documentation Pages: Map First, Then Scrape</title>
<link>https://coderlegion.com/31890/building-a-small-rag-index-from-documentation-pages-map-first-then-scrape</link>
<description>&lt;p&gt;When you want an LLM to answer questions about a product's docs, the hard part is rarely the vector store. It is getting clean, current text out of the pages in the first place. Raw HTML is full of navigation, cookie banners and script tags that waste tokens and confuse retrieval. Here is the simple three-step workflow I use.&lt;/p&gt;
&lt;h3 id=&quot;1-map-the-section-you-care-about&quot;&gt;1. Map the section you care about&lt;/h3&gt;
&lt;p&gt;Before fetching anything, list the URLs under the docs path (for example docs.example.com/guides). A sitemap or a &quot;map&quot; endpoint gives you the full list in one call, so you can drop changelogs, blog posts or old version folders before you spend any requests on them.&lt;/p&gt;
&lt;h3 id=&quot;2-scrape-only-the-pages-you-kept-as-markdown&quot;&gt;2. Scrape only the pages you kept, as Markdown&lt;/h3&gt;
&lt;p&gt;Markdown keeps headings, lists and code blocks but drops the page chrome. It is usually several times smaller than the HTML and chunks naturally on headings, which makes retrieval more precise. Save each page with its source URL so answers can cite where they came from.&lt;/p&gt;
&lt;h3 id=&quot;3-chunk-on-headings-and-embed&quot;&gt;3. Chunk on headings and embed&lt;/h3&gt;
&lt;p&gt;Split each Markdown file on H2/H3 headings, keep chunks around 300 to 800 tokens, and store the URL plus heading as metadata. Re-run the map step on a schedule and only re-scrape pages that are new or changed.&lt;/p&gt;
&lt;h3 id=&quot;tools&quot;&gt;Tools&lt;/h3&gt;
&lt;p&gt;You can do steps 1 and 2 with your own fetcher plus an HTML-to-Markdown converter. If you would rather not maintain that, a hosted web data API does the same job: for example &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.xcrawl.ai/&quot;&gt;XCrawl&lt;/a&gt; has map, scrape, search and crawl endpoints that return Markdown or JSON, a Python/Node SDK, and an MCP server (npm package xcrawl-mcp) so Claude or Cursor can call it directly. New accounts start with 1,000 free credits, enough to index a mid-sized docs site and test the pipeline.&lt;/p&gt;
&lt;h3 id=&quot;a-few-rules-of-thumb&quot;&gt;A few rules of thumb&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Map before you crawl, so you never fetch pages you will throw away.&lt;/li&gt;
&lt;li&gt;Keep the source URL with every chunk.&lt;/li&gt;
&lt;li&gt;Respect robots.txt and each site's terms, and keep request rates gentle on small sites.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is the whole pipeline: map, scrape to Markdown, chunk on headings. It is boring, which is exactly what you want from data plumbing.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31890/building-a-small-rag-index-from-documentation-pages-map-first-then-scrape</guid>
<pubDate>Tue, 20 Oct 2026 21:15:41 +0000</pubDate>
</item>
<item>
<title>Building RAG on PostgreSQL with Pgvector and Ruby on rails</title>
<link>https://coderlegion.com/31432/building-rag-on-postgresql-with-pgvector-and-ruby-on-rails</link>
<description>&lt;p&gt;🧠 Your Database Is Already a Vector Database&lt;/p&gt;
&lt;p&gt;Most RAG tutorials start the same way: &quot;First, spin up a vector database.&quot;&lt;/p&gt;
&lt;p&gt;But Stop.&lt;/p&gt;
&lt;p&gt;If you're on Rails, you already have PostgreSQL. &lt;br&gt;
With the pgvector extension it can store and search embeddings  and for most applications, that's the whole answer.&lt;/p&gt;
&lt;p&gt;🐘 Setup is one migration&lt;/p&gt;
&lt;p&gt;`enable_extension &quot;vector&quot;&lt;/p&gt;
&lt;p&gt;add_column :document_chunks, :embedding, :vector, limit: 1536`&lt;/p&gt;
&lt;p&gt;💎 And one line in your model&lt;/p&gt;
&lt;h2 id=&quot;gemfile&quot;&gt;Gemfile&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;gem &quot;neighbor&quot;&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;class DocumentChunk &amp;lt; ApplicationRecord
  has_neighbors :embedding, dimensions: 1536, normalize: true
end&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;🔍 Searching is just ActiveRecord&lt;/p&gt;
&lt;p&gt;&lt;code&gt;DocumentChunk
  .where(tenant: Current.tenant)
  .nearest_neighbors(:embedding, query_vector, distance: &quot;cosine&quot;)
  .limit(6)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Scoped, chainable, joinable. A normal Rails query.&lt;/p&gt;
&lt;p&gt;🚀 Why this beats a separate vector DB&lt;/p&gt;
&lt;p&gt;1️⃣ No new infrastructure&lt;br&gt;
Nothing extra to deploy, monitor, back up, or upgrade.&lt;/p&gt;
&lt;p&gt;2️⃣ Transactional consistency&lt;br&gt;
Embeddings commit in the same transaction as the record. Two systems can't drift apart.&lt;/p&gt;
&lt;p&gt;3️⃣ Authorization you already trust&lt;br&gt;
This is the big one. Retrieval almost always needs scoping  by tenant, project, or permission. In Postgres that's a WHERE clause. In an external store it's metadata filtering you have to keep in sync with your auth model.&lt;br&gt;
Get it wrong, and one customer's documents land in another customer's LLM prompt.&lt;/p&gt;
&lt;p&gt;4️⃣ Real joins&lt;br&gt;
Chunks plus their documents, authors, and timestamps in one query.&lt;/p&gt;
&lt;p&gt;⚠️ Three things that will bite you&lt;/p&gt;
&lt;p&gt;❌ Index opclass mismatch&lt;br&gt;
vector_cosine_ops goes with cosine distance. Mismatch them and Postgres silently skips your index and sequential-scans the table.&lt;/p&gt;
&lt;p&gt;❌ No similarity floor&lt;br&gt;
Nearest neighbours are nearest, not relevant. Without a threshold, an unrelated question still returns chunks — which the model will happily use to hallucinate.&lt;/p&gt;
&lt;p&gt;❌ Pure vector search&lt;br&gt;
Embeddings are weak at exactly what users type literally: error codes, SKUs, invoice numbers. ERR_4012 and ERR_4013 are nearly identical in vector space.&lt;br&gt;
Fix: hybrid search — pgvector + Postgres full-text, fused with Reciprocal Rank Fusion.&lt;/p&gt;
&lt;p&gt;🎯 When to actually leave Postgres&lt;/p&gt;
&lt;p&gt;Past roughly 5–10 million chunks, or when the vector workload starves your transactional workload for memory.&lt;br&gt;
Until then, a dedicated vector database buys you one more service to operate and a second place for your authorization logic to be wrong.&lt;/p&gt;
&lt;p&gt;Your database stays the source of truth. The vector index is a lookup layer on top of it.&lt;/p&gt;
&lt;p&gt;💬 If you've shipped RAG in Rails — what pushed you off Postgres, if anything?&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31432/building-rag-on-postgresql-with-pgvector-and-ruby-on-rails</guid>
<pubDate>Fri, 16 Oct 2026 18:17:18 +0000</pubDate>
</item>
<item>
<title>Building a Private RAG Pipeline That Doesn't Leak Patient Data</title>
<link>https://coderlegion.com/31299/building-a-private-rag-pipeline-that-doesnt-leak-patient-data</link>
<description>&lt;p&gt;&lt;img src=&quot;http://&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;If you have ever wired up a retrieval-augmented generation system for a normal company, the healthcare version will feel familiar and then suddenly will not. The retrieval math is the same. The constraints are a different sport. This is a walk through how a private RAG pipeline for medical documents tends to get built, and where the engineering effort actually has to go — because the interesting problems are not in the vector store, they are upstream of it.&lt;/p&gt;
&lt;p&gt;A note before we start: I am describing an approach and the trade-offs, not a specific product. When I quote numbers, they are published benchmarks or third-party surveys, and I will say which is which.&lt;/p&gt;
&lt;h3 id=&quot;why-just-use-a-hosted-api-falls-over&quot;&gt;Why &quot;just use a hosted API&quot; falls over&lt;/h3&gt;
&lt;p&gt;The first instinct for a quick RAG demo is to push documents to a managed embedding and chat service. That works until the documents contain patient health information, unpublished trial data, or anything bound by rules about where data is allowed to live. For a hospital, a lot of material simply cannot step outside the intranet. So the entire pipeline — extraction, chunking, embedding, retrieval, and the model call — has to be able to run on machines you control.&lt;/p&gt;
&lt;p&gt;That single constraint reshapes the architecture. You are no longer picking a vendor endpoint. You are assembling a stack that can be air-gapped, audited, and proven to keep bytes inside the building.&lt;/p&gt;
&lt;h3 id=&quot;the-cold-data-problem-is-bigger-than-you-think&quot;&gt;The cold-data problem is bigger than you think&lt;/h3&gt;
&lt;p&gt;Before any of the AI work, there is a storage reality worth respecting. Komprise surveyed enterprise NAS environments and found that 60% to 70% of data had not been touched in over 90 days, yet it was still sitting on the most expensive primary storage tier. In a hospital this shows up as the imaging archive: once films are digitized, enormous volumes of DICOM sit almost never opened, while the storage bill climbs.&lt;/p&gt;
&lt;p&gt;If you sweep that cold, duplicated mass into a retrieval pipeline, two things happen. Retrieval gets slower because the index is bloated with near-dead material, and inference gets more expensive because you are paying to embed and re-read files nobody needed. The engineering lesson is mundane but important: a trim step before ingestion pays for itself many times over. Archive what should be archived, dedupe what is duplicated, and only then index.&lt;/p&gt;
&lt;h3 id=&quot;compression-is-a-first-class-stage-not-an-afterthought&quot;&gt;Compression is a first-class stage, not an afterthought&lt;/h3&gt;
&lt;p&gt;This is the part most RAG tutorials skip. Medical corpora are heavy — scanned pages, high-resolution images, long PDFs, OFD documents, audio, video. Feeding them raw means large embeddings, large context windows, and large bills.&lt;/p&gt;
&lt;p&gt;Document compression, run locally so nothing leaves the environment, changes the economics. A published benchmark I came across processed a 443-page OFD document end to end in 124 seconds, and measured image token cost down by 85.9% against the GPT-4o rate. The volume reductions in that class of tool tend to land in the 60% to 80% range across eight format families — PDF, images, video, audio, Office, OFD, scans, and similar. None of that touches the readability of the text; it is footprint, not content.&lt;/p&gt;
&lt;p&gt;The practical effect is twofold. First, retrieval gets cheaper because there is less to embed. Second, the model can actually read the file — a 443-page document that used to blow the context window becomes tractable. The &quot;compression&quot; label undersells it; what it really buys you is whether the model can read the thing at all, and whether you can afford to let it.&lt;/p&gt;
&lt;p&gt;One such pipeline I looked at, a RAG data engineering base, is built around a self-hosted engine rather than a call to someone else's API, which matters precisely because of the residency constraint above.&lt;/p&gt;
&lt;h3 id=&quot;the-six-stage-path-from-pile-to-trusted-knowledge&quot;&gt;The six-stage path from pile to trusted knowledge&lt;/h3&gt;
&lt;p&gt;The retrieval side is where the quiet engineering lives. A reasonable private RAG base breaks the work into stages rather than one giant &quot;ingest and hope&quot;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Cleaning&lt;/strong&gt; — strip boilerplate, fix encoding, normalize scanned pages.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chunking&lt;/strong&gt; — split by structure, not by arbitrary character counts, so a retrieved passage keeps its meaning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sensitive-field detection and masking&lt;/strong&gt; — flag personal and health information before it is ever indexed. The better implementations cover a meaningful number of categories; the ones built for healthcare tend to detect on the order of 13 classes of sensitive fields (names, ID numbers, record numbers, family history, and so on), with masking that can be reversible or irreversible depending on policy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deduplication&lt;/strong&gt; — collapse the near-duplicates that the cold-data problem created.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vectorization&lt;/strong&gt; — embed on local hardware.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retrieval with provenance&lt;/strong&gt; — return passages that point at a specific, version-correct source.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The reason this is staged rather than monolithic is that in medicine a confident answer citing the wrong version of a protocol is worse than no answer. Spending real effort on de-duplication, field-level masking, and provenance before anything reaches the vector store is what separates a system you can trust from a system that sounds convincing.&lt;/p&gt;
&lt;h3 id=&quot;why-masking-belongs-before-indexing&quot;&gt;Why masking belongs before indexing&lt;/h3&gt;
&lt;p&gt;A common mistake is to embed first and worry about privacy later. That inverts the risk. Once a sensitive field is inside an embedding or a chat context, &quot;removing&quot; it is fuzzy at best. The safer shape is to detect and mask during ingestion, so the index itself never contains what it should not. This is also where the audit trail matters: if a regulator asks what left the building, the honest answer should be &quot;nothing,&quot; backed by logs showing the masking step ran on every document.&lt;/p&gt;
&lt;h3 id=&quot;deployment-shape-and-the-domestic-os-wrinkle&quot;&gt;Deployment shape and the domestic-OS wrinkle&lt;/h3&gt;
&lt;p&gt;For hospitals already on a localized software track, deployment fit is a real gate. A pipeline that only runs on a narrow set of Western operating systems creates a second project just to get it installed. The private-deployment model that tends to work is one that drops the whole system inside the hospital network and supports domestic environments such as Kylin and UnionTech, so &quot;will it even run here&quot; stops being a meeting.&lt;/p&gt;
&lt;h3 id=&quot;a-framework-for-evaluating-any-such-solution&quot;&gt;A framework for evaluating any such solution&lt;/h3&gt;
&lt;p&gt;If you are comparing approaches — and you should compare approaches, not just vendors — these are the dimensions I would put on the table. None of this names a product; it is the checklist I wish more teams started from:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Dimension to evaluate &lt;/th&gt;&lt;th&gt;What to check&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Data residency and exit rules &lt;/td&gt;&lt;td&gt;Can the full pipeline run on-premise or air-gapped, with no byte leaving your network?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Sensitive-field handling &lt;/td&gt;&lt;td&gt;How many categories of PII/PHI can it detect and mask automatically before indexing?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Format coverage &lt;/td&gt;&lt;td&gt;Do your actual file types — scans, images, Office, OFD, DICOM, audio, video — work?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Volume reduction &lt;/td&gt;&lt;td&gt;What is the typical size reduction, and is legibility preserved?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Compute cost at scale &lt;/td&gt;&lt;td&gt;Embedding and retrieval cost per thousand pages as the corpus grows into the millions.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Retrieval accuracy &lt;/td&gt;&lt;td&gt;Is chunking structure-aware, and is provenance tracked so answers are traceable?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Auditability &lt;/td&gt;&lt;td&gt;Are ingestion, masking, and retrieval logged for compliance review?&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Deployment fit &lt;/td&gt;&lt;td&gt;On-prem, air-gapped, and compatible with the operating systems you already run?&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;I have kept the wording relative on purpose. &quot;Steadier,&quot; &quot;lower long-term,&quot; &quot;broader coverage&quot; — none of that is a verdict, it is what to verify for your own corpus.&lt;/p&gt;
&lt;h3 id=&quot;the-part-worth-taking-home&quot;&gt;The part worth taking home&lt;/h3&gt;
&lt;p&gt;The model is the easy, well-trodden part of medical RAG. The hard part is everything before it: respecting that the data cannot leave, trimming the cold mass, compressing so the model can read it, and masking so the index is safe to query. Get those upstream stages right and the retrieval layer becomes almost boring — which, for a system handling patient data, is exactly the compliment you want to give it.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/31299/building-a-private-rag-pipeline-that-doesnt-leak-patient-data</guid>
<pubDate>Fri, 16 Oct 2026 07:30:13 +0000</pubDate>
</item>
<item>
<title>Learning LangChain</title>
<link>https://coderlegion.com/30850/learning-langchain</link>
<description>&lt;p&gt;A while back I built Synapse, a small app that answers questions using my own notes. I did every part of it by hand: load the files, turn them into vectors, find the closest ones to the question, and hand those to an LLM. It worked, and I understood it, because I'd written every line myself.&lt;/p&gt;
&lt;p&gt;So when I started LangChain, a framework that promised to do all of that for me felt a little suspicious. My honest question was: what is this doing that I haven't already done?&lt;/p&gt;
&lt;p&gt;It turns out the answer is less &quot;new ideas&quot; and more &quot;a standard shape for the ones you already have.&quot; Let me walk through it the way I learned it.&lt;/p&gt;
&lt;h3 id=&quot;start-small-messages-and-templates&quot;&gt;Start small: messages and templates&lt;/h3&gt;
&lt;p&gt;A chat model doesn't take one big string. It takes a list of messages: a system message that sets the role, then human messages from you, then AI messages from the model. LangChain gives each of those its own class. Nothing magic, it's the same thing the raw API wants.&lt;/p&gt;
&lt;p&gt;Prompt templates were the next small step. Instead of building a string every time, you write the prompt once with a gap in it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from langchain_core.prompts import ChatPromptTemplate

template = ChatPromptTemplate.from_template(
    &quot;I've recently adopted a {pet}. Suggest three {pet} names.&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then an output parser takes the model's text reply and turns it into something you can actually use, like a Python list. The thing that stuck with me here is that the parser is only as good as your instructions. Vague instructions about the format gave me a messy list. Strict ones gave me a clean one.&lt;/p&gt;
&lt;h3 id=&quot;the-pipe&quot;&gt;The pipe&lt;/h3&gt;
&lt;p&gt;This is the bit where it clicked for me. LangChain lets you join those pieces with a &lt;code&gt;|&lt;/code&gt;, like a Unix pipe:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;chain = chat_template | chat | list_output_parser
chain.invoke({&quot;pet&quot;: &quot;dog&quot;})
# ['Max', 'Bella', 'Luna']
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A prompt goes in one end, the model sits in the middle, and a clean list comes out the other end. Each step's output is the next step's input. That's the whole idea.&lt;/p&gt;
&lt;p&gt;It even works for ordinary Python functions. Wrap them in &lt;code&gt;RunnableLambda&lt;/code&gt; and they snap in like any other piece:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from langchain_core.runnables import RunnableLambda

add_up = RunnableLambda(lambda x: sum(x))
square = RunnableLambda(lambda x: x**2)

(add_up | square).invoke([1, 2, 5])   # 8, then 64
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can also draw a chain. &lt;code&gt;chain.get_graph().print_ascii()&lt;/code&gt; prints the boxes and arrows, which is surprisingly handy when a chain gets long.&lt;/p&gt;
&lt;p&gt;Two more things I liked. &lt;code&gt;RunnableParallel&lt;/code&gt; runs two chains side by side and gives both results to the next step. And &lt;code&gt;batch&lt;/code&gt; is quicker than looping: two requests through &lt;code&gt;batch&lt;/code&gt; took 2.67 seconds in my notebook, while the same two as separate calls took about 7.5.&lt;/p&gt;
&lt;h3 id=&quot;now-rag-with-the-same-pieces&quot;&gt;Now RAG, with the same pieces&lt;/h3&gt;
&lt;p&gt;Remember my five hand-built steps? LangChain has a piece for each.&lt;/p&gt;
&lt;p&gt;For loading, there are loaders for different file types. I used one for PDFs and one for Word documents. Splitting was the new part, since Synapse never did any. I split a six-page document twice. First by its headings, so every chunk remembers which lecture it came from, then by size, about 500 characters with a little overlap. That left me with 20 chunks, each labelled with where it came from.&lt;/p&gt;
&lt;p&gt;Each chunk then became 1,536 numbers using OpenAI's embedding model. I wanted to see if it really worked, so I compared three pairs of chunks. They scored 0.88, 0.80 and 0.79, so related text does end up close together. The vectors all have a length of about 1, which is why a plain dot product works as a similarity score.&lt;/p&gt;
&lt;p&gt;Those vectors went into Chroma, which saves them to a folder on disk so I don't have to re-embed every time I open the notebook.&lt;/p&gt;
&lt;p&gt;Retrieval comes in two flavours. A plain similarity search gives you the closest chunks. MMR gives you close chunks that are also different from each other, so you don't get five copies of the same paragraph.&lt;/p&gt;
&lt;p&gt;And the last step is where the pipe comes back. The whole thing is one chain:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;chain = (
    {&quot;context&quot;: retriever, &quot;question&quot;: RunnablePassthrough()}
    | prompt_template
    | chat
    | StrOutputParser()
)

chain.invoke(&quot;What software do data scientists use?&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The retriever fills in the context, the question passes straight through, the prompt tells the model to answer only from that context and name the lecture it used, and the parser hands back plain text. The answer ended with:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;Resources: Programming Languages &amp;amp; Software Employed in Data Science - All the Tools You Need&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's the same &quot;only use these notes&quot; guard I wrote by hand in Synapse, plus the sources I'd been putting off adding.&lt;/p&gt;
&lt;h3 id=&quot;what-this-changed-for-me&quot;&gt;What this changed for me&lt;/h3&gt;
&lt;p&gt;LangChain didn't teach me a new idea. Everything in that pipeline is something I'd already done by hand: load, embed, find the closest, ask. What it gave me was a standard shape for each step, and the pipe to join them. Having built it the long way first is what made the short way make sense.&lt;/p&gt;
&lt;p&gt;Next up is LangGraph.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30850/learning-langchain</guid>
<pubDate>Mon, 12 Oct 2026 17:46:29 +0000</pubDate>
</item>
<item>
<title>Why Your RAG Chatbot Gives Wrong Answers (And How to Fix It)</title>
<link>https://coderlegion.com/30306/why-your-rag-chatbot-gives-wrong-answers-and-how-to-fix-it</link>
<description>&lt;p&gt;You built a &quot;chat with your documents&quot; app. You upload a PDF, ask a simple question, and the answer is wrong, half right, or completely made up.&lt;/p&gt;
&lt;p&gt;If this happened to you, do not blame the AI model yet. In most RAG apps, wrong answers come from a few simple mistakes in how the document is prepared and searched. They are easy to fix once you know where to look.&lt;/p&gt;
&lt;p&gt;This is part 2 of my RAG series. In part 1 we built a simple &quot;chat with your PDF&quot; app. Here, we fix the problems that show up when you test it with real documents.&lt;/p&gt;
&lt;h3 id=&quot;first-find-where-the-problem-is&quot;&gt;First, Find Where the Problem Is&lt;/h3&gt;
&lt;p&gt;A RAG app does two jobs:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Retrieval:&lt;/strong&gt; find the right pieces of the document for the question.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generation:&lt;/strong&gt; use those pieces to write the answer.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Most wrong answers are retrieval problems. The model never saw the right text, so it could not answer well. Before you change anything, print the pieces your app found:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;relevant_chunks = search(index, chunks, query_embedding, top_k=3)

for chunk in relevant_chunks:
    print(chunk[:200])
    print('---')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the answer is not in those pieces, fix retrieval (problems 1 to 4 and 7 below). If the answer is there but the reply is still wrong, fix the prompt (problems 5 and 6).&lt;/p&gt;
&lt;h3 id=&quot;problem-1-chunks-are-too-big-or-too-small&quot;&gt;Problem 1: Chunks Are Too Big or Too Small&lt;/h3&gt;
&lt;p&gt;If chunks are too big, one chunk holds many topics and the search gets confused. If they are too small, a chunk has no useful meaning on its own.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; start with about 300 to 500 words per chunk. Then test two or three sizes with your own questions and keep the one that works best.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;for size in [200, 400, 600]:
    chunks = chunk_text(text, chunk_size=size)
    print(size, 'words -&amp;gt;', len(chunks), 'chunks')
    # rebuild the index and test the same 5 questions
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;problem-2-no-overlap-between-chunks&quot;&gt;Problem 2: No Overlap Between Chunks&lt;/h3&gt;
&lt;p&gt;When you cut text at a fixed number of words, a sentence can be split in half. The first half sits in one chunk and the second half in another. Neither chunk gives the full answer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; let each chunk repeat the last 50 words or so of the one before it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def chunk_text(text, chunk_size=400, overlap=50):
    words = text.split()
    chunks = []
    step = chunk_size - overlap
    for i in range(0, len(words), step):
        chunk = ' '.join(words[i:i + chunk_size])
        if chunk:
            chunks.append(chunk)
    return chunks
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;problem-3-the-text-from-the-pdf-is-messy&quot;&gt;Problem 3: The Text From the PDF Is Messy&lt;/h3&gt;
&lt;p&gt;This one is very common, and people forget to check it. If the text you extract is bad, everything after it is bad too. Look out for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Scanned PDFs.&lt;/strong&gt; They are pictures, so the extractor returns empty text.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Headers and footers.&lt;/strong&gt; Lines like &quot;Company Name | Page 4&quot; repeat on every page and fill your chunks with noise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tables.&lt;/strong&gt; They often turn into broken lines of numbers.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; always print the extracted text and read it. Then clean it.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import pdfplumber
from collections import Counter

def extract_pages(pdf_path):
    pages = []
    with pdfplumber.open(pdf_path) as pdf:
        for page in pdf.pages:
            pages.append(page.extract_text() or '')
    return pages

def remove_repeated_lines(pages, min_pages=3):
    counts = Counter()
    for page in pages:
        for line in set(page.split('\n')):
            counts[line.strip()] += 1
    repeated = {line for line, n in counts.items() if line and n &amp;gt;= min_pages}
    cleaned = []
    for page in pages:
        lines = [l for l in page.split('\n') if l.strip() not in repeated]
        cleaned.append('\n'.join(lines))
    return cleaned

pages = extract_pages('manual.pdf')
empty = [i + 1 for i, p in enumerate(pages) if len(p.strip()) &amp;lt; 20]
if empty:
    print('No text on these pages (maybe scanned):', empty)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For scanned pages, use an OCR tool such as &lt;code&gt;pytesseract&lt;/code&gt; to turn the pictures into text first.&lt;/p&gt;
&lt;h3 id=&quot;problem-3-and-a-half-tables&quot;&gt;Problem 3 and a Half: Tables&lt;/h3&gt;
&lt;p&gt;If your documents are full of tables, plain text extraction will not be enough. Try &lt;code&gt;page.extract_tables()&lt;/code&gt; in pdfplumber and turn each row into a short sentence, like &quot;Plan: Basic, Price: 500&quot;. Search works much better on sentences than on broken columns.&lt;/p&gt;
&lt;h3 id=&quot;problem-4-wrong-number-of-chunks-top-k-&quot;&gt;Problem 4: Wrong Number of Chunks (top_k)&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;top_k&lt;/code&gt; is how many chunks you send to the model. If it is too low, you miss part of the answer. If it is too high, you fill the prompt with noise and the model gets distracted.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; start with 3 to 5. If your questions need facts from different parts of the document, go up. If answers look unfocused, go down.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;relevant_chunks = search(index, chunks, query_embedding, top_k=4)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;problem-5-the-prompt-lets-the-model-guess&quot;&gt;Problem 5: The Prompt Lets the Model Guess&lt;/h3&gt;
&lt;p&gt;If your prompt only says &quot;answer this question&quot;, the model will happily use what it learned in training and mix it with your document. That is how made-up answers happen.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; tell it clearly to use only the context, and what to say when the answer is missing.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;prompt = f'''Answer the question using ONLY the context below.
If the answer is not in the context, say: 'I could not find this in the document.'
Do not use any outside knowledge.

Context:
{context}

Question: {question}
Answer:'''
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;problem-6-no-i-don-t-know-path&quot;&gt;Problem 6: No &quot;I Don't Know&quot; Path&lt;/h3&gt;
&lt;p&gt;A vector search always returns something, even when nothing in the document is close to the question. Without a check, your app sends useless chunks to the model, and the model tries its best to answer anyway.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; add a distance cutoff. If no chunk is close enough, skip the model and say so.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def search_with_cutoff(index, chunks, query_embedding, top_k=4, max_distance=1.2):
    distances, indices = index.search(np.array([query_embedding]), top_k)
    results = []
    for dist, i in zip(distances[0], indices[0]):
        if i != -1 and dist &amp;lt;= max_distance:
            results.append(chunks[i])
    return results

relevant = search_with_cutoff(index, chunks, query_embedding)
if not relevant:
    print('I could not find this in the document.')
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The right &lt;code&gt;max_distance&lt;/code&gt; depends on your embedding model, so 1.2 is only a starting point. Print the distances for a few questions that the document answers and a few that it does not. Then pick a number between the two groups.&lt;/p&gt;
&lt;h3 id=&quot;problem-7-wrong-or-mixed-embedding-models&quot;&gt;Problem 7: Wrong or Mixed Embedding Models&lt;/h3&gt;
&lt;p&gt;There are two mistakes here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Using different models&lt;/strong&gt; for the document chunks and the questions. The numbers will not match, and search will give random results. This often happens when you change the model but forget to rebuild the index.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Using an English-only model for other languages.&lt;/strong&gt; If your documents are in Tamil, Hindi, or another language, an English model will search badly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; use one model for both, and rebuild the whole index if you change it. For other languages, try a multilingual model.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sentence_transformers import SentenceTransformer

model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

doc_vectors = model.encode(chunks)           # for the document
query_vector = model.encode([question])[0]   # for the question, same model
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;problem-8-no-sources-so-no-way-to-check&quot;&gt;Problem 8: No Sources, So No Way to Check&lt;/h3&gt;
&lt;p&gt;If your app only shows an answer, you cannot tell if it is right. And when it is wrong, you cannot tell why.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; save the file name and page number with every chunk, and show them with the answer.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;chunks_with_meta = [
    {'text': chunk_text_value, 'file': 'manual.pdf', 'page': 12},
    # one item per chunk
]

sources = [f&quot;{c['file']}, page {c['page']}&quot; for c in relevant]
print('Answer:', answer)
print('Sources:', ', '.join(sources))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sources help your users trust the answer, and they help you debug it faster.&lt;/p&gt;
&lt;h3 id=&quot;your-debugging-checklist&quot;&gt;Your Debugging Checklist&lt;/h3&gt;
&lt;p&gt;When your RAG chatbot gives a wrong answer, go through this list in order:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[ ] Print the extracted text. Does it look right?&lt;/li&gt;
&lt;li&gt;[ ] Print the retrieved chunks. Is the answer inside them?&lt;/li&gt;
&lt;li&gt;[ ] Is the chunk size around 300 to 500 words, with some overlap?&lt;/li&gt;
&lt;li&gt;[ ] Is &lt;code&gt;top_k&lt;/code&gt; between 3 and 5?&lt;/li&gt;
&lt;li&gt;[ ] Did you use the same embedding model for chunks and questions?&lt;/li&gt;
&lt;li&gt;[ ] Does your prompt say &quot;use only the context&quot; and give an &quot;I could not find this&quot; line?&lt;/li&gt;
&lt;li&gt;[ ] Do you have a distance cutoff?&lt;/li&gt;
&lt;li&gt;[ ] Are you showing sources with every answer?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One more tip: write 10 test questions with known answers and run them after every change. That way you will know if a fix helped or made things worse.&lt;/p&gt;
&lt;h3 id=&quot;wrap-up&quot;&gt;Wrap-Up&lt;/h3&gt;
&lt;p&gt;Most RAG problems are not about the AI model. They come from how the text is cleaned, split, searched, and passed to the model. Fix those, and your answers get much better.&lt;/p&gt;
&lt;p&gt;If you would like a working codebase to start from instead of fixing all of this yourself, I made a RAG chatbot source-code kit: &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dtechsolution.gumroad.com/l/rag-it&quot;&gt;RAG Source code&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Which of these problems hit you the most? Tell me in the comments, and I will try to cover it in the next part.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30306/why-your-rag-chatbot-gives-wrong-answers-and-how-to-fix-it</guid>
<pubDate>Thu, 08 Oct 2026 13:55:22 +0000</pubDate>
</item>
<item>
<title>How to Build a &quot;Chat With Your PDFs&quot; App: A Simple RAG Guide</title>
<link>https://coderlegion.com/30154/how-to-build-a-chat-with-your-pdfs-app-a-simple-rag-guide</link>
<description>&lt;p&gt;Ever wanted to upload a PDF and just ask it questions instead of scrolling through fifty pages? That is what a &quot;chat with your PDF&quot; app does, and it works using a technique called &lt;strong&gt;RAG (Retrieval-Augmented Generation)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In this guide, I will explain what RAG is in plain words, break down the pieces you need to build one, and walk through a simple working version step by step. By the end, you will understand exactly how these apps work under the hood, and you can build your own from here.&lt;/p&gt;
&lt;h3 id=&quot;what-is-rag-in-plain-words-&quot;&gt;What Is RAG, in Plain Words?&lt;/h3&gt;
&lt;p&gt;RAG stands for Retrieval-Augmented Generation. That sounds complex, but the idea is simple.&lt;/p&gt;
&lt;p&gt;Think of a smart friend who has read every page of your PDF but has a short memory. Every time you ask a question, your friend does not try to remember the whole document. Instead, they quickly flip to the exact pages that answer your question, read just those pages, and then answer you based on what they just read.&lt;/p&gt;
&lt;p&gt;That is exactly what a RAG app does:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Retrieval&lt;/strong&gt; — find the most relevant parts of the document for your question&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Augmented Generation&lt;/strong&gt; — hand those relevant parts to an AI model, which uses them to write a proper answer&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Without RAG, an AI model only knows what it learned during training — it has never seen your PDF. RAG lets you &quot;show&quot; it the right pages of your document, right when it needs them, so it can answer questions about content it was never trained on.&lt;/p&gt;
&lt;h3 id=&quot;the-building-blocks-of-a-rag-app&quot;&gt;The Building Blocks of a RAG App&lt;/h3&gt;
&lt;p&gt;A &quot;chat with your PDF&quot; app is made of five pieces, each doing one small job:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Step &lt;/th&gt;&lt;th&gt;What it does &lt;/th&gt;&lt;th&gt;Common tools&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;1. Extract text &lt;/td&gt;&lt;td&gt;Pulls the raw text out of the PDF &lt;/td&gt;&lt;td&gt;PyPDF2, pdfplumber&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2. Chunk the text &lt;/td&gt;&lt;td&gt;Splits the text into small pieces (a few hundred words each) &lt;/td&gt;&lt;td&gt;Plain Python splitting, LangChain text splitters&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;3. Create embeddings &lt;/td&gt;&lt;td&gt;Turns each chunk into a list of numbers that capture its meaning &lt;/td&gt;&lt;td&gt;OpenAI embeddings, sentence-transformers&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;4. Store in a vector database &lt;/td&gt;&lt;td&gt;Saves those embeddings so they can be searched fast &lt;/td&gt;&lt;td&gt;FAISS, ChromaDB, Pinecone&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;5. Retrieve and answer &lt;/td&gt;&lt;td&gt;Finds the closest matching chunks to your question, sends them to an LLM, returns the answer &lt;/td&gt;&lt;td&gt;OpenAI, Anthropic, or any LLM API&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Once you understand these five steps, you understand how every RAG app works. The fancy tools just do these steps for you automatically.&lt;/p&gt;
&lt;h3 id=&quot;building-a-simple-version-step-by-step&quot;&gt;Building a Simple Version, Step by Step&lt;/h3&gt;
&lt;p&gt;Here is a minimal working example in Python. It will not be production-ready, but it will actually work, and it shows exactly what is happening at each step.&lt;/p&gt;
&lt;h4 id=&quot;step-1-extract-text-from-the-pdf&quot;&gt;Step 1: Extract text from the PDF&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import pdfplumber

def extract_text(pdf_path):
    text = &quot;&quot;
    with pdfplumber.open(pdf_path) as pdf:
        for page in pdf.pages:
            text += page.extract_text() + &quot;\n&quot;
    return text
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-2-split-the-text-into-chunks&quot;&gt;Step 2: Split the text into chunks&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def chunk_text(text, chunk_size=500):
    words = text.split()
    chunks = []
    for i in range(0, len(words), chunk_size):
        chunk = &quot; &quot;.join(words[i:i + chunk_size])
        chunks.append(chunk)
    return chunks
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-3-turn-chunks-into-embeddings&quot;&gt;Step 3: Turn chunks into embeddings&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from sentence_transformers import SentenceTransformer

model = SentenceTransformer('all-MiniLM-L6-v2')

def embed_chunks(chunks):
    return model.encode(chunks)
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-4-store-and-search-with-a-vector-database&quot;&gt;Step 4: Store and search with a vector database&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import faiss
import numpy as np

def build_index(embeddings):
    dimension = embeddings.shape[1]
    index = faiss.IndexFlatL2(dimension)
    index.add(np.array(embeddings))
    return index

def search(index, chunks, query_embedding, top_k=3):
    distances, indices = index.search(np.array([query_embedding]), top_k)
    return [chunks[i] for i in indices[0]]
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-5-ask-the-question-and-get-an-answer&quot;&gt;Step 5: Ask the question and get an answer&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def ask_question(question, chunks, index):
    query_embedding = model.encode([question])[0]
    relevant_chunks = search(index, chunks, query_embedding)
    context = &quot;\n\n&quot;.join(relevant_chunks)

    prompt = f&quot;&quot;&quot;Answer the question using only the context below.

Context:
{context}

Question: {question}
Answer:&quot;&quot;&quot;

    # Send this prompt to any LLM API (OpenAI, Anthropic, etc.)
    # response = call_llm_api(prompt)
    # return response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Put these five pieces together — extract, chunk, embed, store, retrieve and answer — and you have a working &quot;chat with your PDF&quot; app.&lt;/p&gt;
&lt;h3 id=&quot;common-mistakes-when-you-build-this&quot;&gt;Common Mistakes When You Build This&lt;/h3&gt;
&lt;p&gt;A few things that trip up most people the first time:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Chunks too big or too small.&lt;/strong&gt; Too big, and the AI gets confused by irrelevant text mixed in. Too small, and it loses context. 300–500 words per chunk is a good starting point.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Forgetting to test with real questions.&lt;/strong&gt; Try questions the document does not actually answer — your app should say &quot;I don't know&quot; instead of making something up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not handling scanned PDFs.&lt;/strong&gt; If a PDF is a scanned image and not real text, &lt;code&gt;pdfplumber&lt;/code&gt; will return nothing. You will need OCR (like &lt;code&gt;pytesseract&lt;/code&gt;) for those.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Skipping overlap between chunks.&lt;/strong&gt; If a sentence gets cut in half between two chunks, the answer can be incomplete. Adding a small overlap (30–50 words) between chunks fixes most of this.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;wrap-up-1&quot;&gt;Wrap-Up&lt;/h3&gt;
&lt;p&gt;That is the whole idea behind &quot;chat with your PDF&quot; apps: extract, chunk, embed, store, retrieve, and answer. None of the individual steps are hard on their own — the skill is in connecting them properly and handling the messy real-world cases, like bad formatting or scanned pages.&lt;/p&gt;
&lt;p&gt;If you want to build your own version, the code above is a solid starting point. And if you would rather skip the setup and start from a working, tested codebase instead of debugging embeddings and vector search from scratch, I put together a ready-to-use RAG chatbot source-code kit: &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://dtechsolution.gumroad.com/l/rag-it&quot;&gt;RAG source code&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Either way, I hope this helped you understand what is actually happening inside these apps. Let me know in the comments if you get stuck anywhere!&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30154/how-to-build-a-chat-with-your-pdfs-app-a-simple-rag-guide</guid>
<pubDate>Wed, 07 Oct 2026 15:43:19 +0000</pubDate>
</item>
<item>
<title>RAG Retrieval Strategies on a 50k-Document Dataset: BM25 vs Dense vs Hybrid</title>
<link>https://coderlegion.com/30044/rag-retrieval-strategies-on-a-50k-document-dataset-bm25-vs-dense-vs-hybrid</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;On 50k documents, retrieval quality matters more than your choice of vector database. At this size you can run exact search and skip the infrastructure.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BM25&lt;/strong&gt; is great at exact terms (error codes, names, IDs) and weak at paraphrases. &lt;strong&gt;Dense&lt;/strong&gt; retrieval is the reverse.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hybrid&lt;/strong&gt; (BM25 + dense, merged with Reciprocal Rank Fusion) is the best default. It's cheap to build and covers both failure modes.&lt;/li&gt;
&lt;li&gt;Add a reranker only after you've measured that hybrid isn't enough. Build a small eval set first, because without one you're guessing.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;the-setup-nobody-tells-you-about&quot;&gt;The setup nobody tells you about&lt;/h3&gt;
&lt;p&gt;Say you've built a RAG app over 50,000 documents: support tickets, internal docs, maybe PDFs. The demo worked. Then a real user asked a real question, and the model confidently answered from the wrong paragraph.&lt;/p&gt;
&lt;p&gt;Nine times out of ten, the LLM isn't the problem. &lt;strong&gt;Retrieval is.&lt;/strong&gt; If the right chunk never reaches the prompt, no model can save you.&lt;/p&gt;
&lt;p&gt;One clarification before we start. &quot;50k documents&quot; usually means far more &lt;em&gt;chunks&lt;/em&gt;. If each document splits into about 10 chunks, you're searching roughly 500k pieces of text. That number matters for memory and latency, so keep it in mind below.&lt;/p&gt;
&lt;p&gt;I haven't run a fresh benchmark on your data, and I won't pretend to. This article uses published research and documented numbers to show what each approach does, where it breaks, and how to decide. If you want the background on what embeddings actually are, my post on &lt;a rel=&quot;nofollow&quot; href=&quot;https://ahmershah.dev/blogs/syedahmershah-the-anatomy-of-ai-deconstructing-the-brain-into-vectors-and-math&quot;&gt;the anatomy of AI&lt;/a&gt; covers vectors from the ground up.&lt;/p&gt;
&lt;h3 id=&quot;the-three-contenders-at-a-glance&quot;&gt;The three contenders at a glance&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;BM25 &lt;/th&gt;&lt;th&gt;Dense &lt;/th&gt;&lt;th&gt;Hybrid (RRF)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Matches on &lt;/td&gt;&lt;td&gt;Exact words &lt;/td&gt;&lt;td&gt;Meaning &lt;/td&gt;&lt;td&gt;Both&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Strong at &lt;/td&gt;&lt;td&gt;IDs, codes, names, jargon &lt;/td&gt;&lt;td&gt;Paraphrases, natural questions &lt;/td&gt;&lt;td&gt;Mixed real-world queries&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Weak at &lt;/td&gt;&lt;td&gt;Synonyms, vocabulary mismatch &lt;/td&gt;&lt;td&gt;Rare tokens, unseen domains &lt;/td&gt;&lt;td&gt;Slightly more moving parts&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Needs a model &lt;/td&gt;&lt;td&gt;No &lt;/td&gt;&lt;td&gt;Yes (embeddings) &lt;/td&gt;&lt;td&gt;Yes&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Build effort &lt;/td&gt;&lt;td&gt;Low &lt;/td&gt;&lt;td&gt;Low to medium &lt;/td&gt;&lt;td&gt;Low, once the other two exist&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;bm25-the-librarian-with-perfect-index-cards&quot;&gt;BM25: the librarian with perfect index cards&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;BM25&lt;/strong&gt; is a keyword-ranking function from the 1990s. It scores a chunk higher when:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;it contains your query words (term frequency),&lt;/li&gt;
&lt;li&gt;those words are rare across the corpus (inverse document frequency), and&lt;/li&gt;
&lt;li&gt;the chunk isn't just long and rambling (length normalization).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;em&gt;Analogy:&lt;/em&gt; imagine a librarian who never reads the books. She only keeps meticulous index cards of which words appear where. Ask for &quot;ERR-4012&quot; and she hands you the exact page in seconds. Ask &quot;why does my app crash on startup&quot; and she's stuck unless the page literally says &quot;crash&quot; and &quot;startup.&quot;&lt;/p&gt;
&lt;p&gt;Here's a minimal version using &lt;code&gt;rank_bm25&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import re
from rank_bm25 import BM25Okapi

def tokenize(text: str) -&amp;gt; list[str]:
    # keep dots, dashes and underscores *inside* a token (ERR-4012, user_id,
    # v2.4.1) but not at its edges, so &quot;startup.&quot; still matches &quot;startup&quot;
    return re.findall(r&quot;[a-z0-9]+(?:[._\-][a-z0-9]+)*&quot;, text.lower())

tokenized_chunks = [tokenize(c) for c in chunks]
bm25 = BM25Okapi(tokenized_chunks)

def bm25_search(query: str, k: int = 50):
    scores = bm25.get_scores(tokenize(query))
    top = scores.argsort()[::-1][:k]
    return [(int(i), float(scores[i])) for i in top]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two honest notes. First, &lt;code&gt;rank_bm25&lt;/code&gt; is fine for prototypes, but it scores every document per query. For production, use something with an inverted index like Elasticsearch, OpenSearch, Tantivy, or Postgres full-text search. Second, your &lt;em&gt;tokenizer&lt;/em&gt; is half the battle. The regex above keeps &lt;code&gt;ERR-4012&lt;/code&gt; as one token on purpose. A default tokenizer that splits on punctuation can quietly wreck exact-match queries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where BM25 wins:&lt;/strong&gt; product codes, function names, legal clause numbers, rare jargon, anything where the user types the exact words.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where it loses:&lt;/strong&gt; vocabulary mismatch. The user says &quot;refund,&quot; the doc says &quot;reimbursement.&quot; BM25 sees two unrelated strings.&lt;/p&gt;
&lt;h3 id=&quot;dense-retrieval-the-friend-who-gets-what-you-mean&quot;&gt;Dense retrieval: the friend who gets what you mean&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Dense retrieval&lt;/strong&gt; turns text into vectors (embeddings) using a neural model. Chunks with similar &lt;em&gt;meaning&lt;/em&gt; land close together in vector space, and you search by finding the nearest neighbors to your query vector.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Analogy:&lt;/em&gt; instead of index cards, you have a well-read friend. You describe a vague idea (&quot;that thing where the server keeps retrying and makes it worse&quot;) and they say &quot;oh, you want the doc on retry storms.&quot; They understand meaning, but they might blank on the exact serial number you asked for.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer(&quot;BAAI/bge-small-en-v1.5&quot;)

# 50k chunks x 384 float32 is about 77 MB. 500k chunks is about 770 MB.
doc_vecs = model.encode(chunks, normalize_embeddings=True, batch_size=64)

def dense_search(query: str, k: int = 50):
    q = model.encode([query], normalize_embeddings=True)[0]
    scores = doc_vecs @ q          # cosine similarity (vectors are normalized)
    top = np.argpartition(-scores, k)[:k]
    top = top[np.argsort(-scores[top])]
    return [(int(i), float(scores[i])) for i in top]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice what's missing: no vector database, no ANN index. That's deliberate.&lt;/p&gt;
&lt;h4 id=&quot;you-probably-don-t-need-an-ann-index-yet&quot;&gt;You probably don't need an ANN index yet&lt;/h4&gt;
&lt;p&gt;Approximate nearest neighbor indexes like HNSW trade a bit of recall for speed. At small scale, you may be paying that trade for nothing. pgvector, for example, does exact nearest neighbor search by default, which gives perfect recall. Plenty of teams run tens of thousands of vectors on a plain sequential scan, and skipping the index avoids build time, maintenance cost, and the recall loss of approximate search.&lt;/p&gt;
&lt;p&gt;If your 50k documents become 500k chunks, revisit that, but measure first. A brute-force matrix multiply over a few hundred thousand vectors is still quick on a decent machine; it's the memory (see the comment in the code above) you'll feel first. When you do need HNSW, here's the standard pgvector shape:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- higher ef_search = better recall, slower queries
SET hnsw.ef_search = 100;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Always compare ANN results against exact results on your own queries before trusting the index.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where dense wins:&lt;/strong&gt; paraphrases, natural-language questions, cross-vocabulary matches, multilingual queries.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where it loses:&lt;/strong&gt; exact identifiers, rare tokens, and anything where the embedding model has never seen your domain's vocabulary.&lt;/p&gt;
&lt;h3 id=&quot;what-the-research-says-and-what-it-doesn-t-&quot;&gt;What the research says (and what it doesn't)&lt;/h3&gt;
&lt;p&gt;The most cited evidence here is &lt;strong&gt;BEIR&lt;/strong&gt;, a benchmark covering zero-shot retrieval across a wide range of settings. Its headline finding surprised people at the time. Dense models beat BM25 by a clear margin on MS MARCO, the data they were trained on, yet BM25 held up as a robust baseline almost everywhere else. The authors found dense models do well when training and target data overlap heavily, but fall short on datasets with large domain shift.&lt;/p&gt;
&lt;p&gt;That's the key insight for your 50k-document corpus. Your data is probably &lt;em&gt;not&lt;/em&gt; MS MARCO. It has your company's acronyms, your product names, your weird internal vocabulary. A dense model trained on generic web text is, in effect, out-of-domain. That's exactly where BM25 refuses to embarrass itself.&lt;/p&gt;
&lt;p&gt;Two honest caveats:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;BEIR is from 2021.&lt;/strong&gt; Embedding models have improved a lot since. Don't read it as &quot;BM25 beats dense.&quot; Read it as &quot;dense models can fail unpredictably on unfamiliar domains, and lexical search is a safety net.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Benchmark averages hide your queries.&lt;/strong&gt; A model can win on average and still lose on the 20% of queries your users care about most.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;BEIR also showed the cost side. BM25 answers in milliseconds on a CPU, while a cross-encoder reranker on top of it was orders of magnitude slower per query in the paper's setup. Reranking helps, but it isn't free, and we'll come back to that.&lt;/p&gt;
&lt;h3 id=&quot;hybrid-two-witnesses-are-better-than-one&quot;&gt;Hybrid: two witnesses are better than one&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Analogy:&lt;/em&gt; picture two witnesses to a crime. One remembers exact details (the license plate), and the other remembers the overall impression (a dark sedan, driving erratically). Either alone can mislead you. Together, they converge on the right car.&lt;/p&gt;
&lt;p&gt;That's &lt;strong&gt;hybrid retrieval&lt;/strong&gt;: run BM25 and dense search in parallel, then merge the two ranked lists.&lt;/p&gt;
&lt;p&gt;The tricky part is the merge. BM25 scores and cosine similarities live on completely different scales, so you can't just add them. You &lt;em&gt;can&lt;/em&gt; normalize and weight them, but the weights drift as your data changes.&lt;/p&gt;
&lt;h4 id=&quot;reciprocal-rank-fusion-rrf-&quot;&gt;Reciprocal Rank Fusion (RRF)&lt;/h4&gt;
&lt;p&gt;RRF sidesteps the scale problem by ignoring scores entirely and using only &lt;strong&gt;rank positions&lt;/strong&gt;. It comes from a 2009 SIGIR paper by Cormack, Clarke, and Büttcher. The formula is one line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;RRF(d) = Σ over rankers  1 / (k + rank(d))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;with k = 60 as the usual default. The paper found that value near-optimal in a pilot study, and it has stuck around ever since. Because RRF is score-independent, using only ranks, one retriever with weird score ranges can't dominate the result.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from collections import defaultdict

def rrf(ranked_lists: list[list[int]], k: int = 60) -&amp;gt; list[tuple[int, float]]:
    fused = defaultdict(float)
    for ranking in ranked_lists:
        for rank, doc_id in enumerate(ranking, start=1):
            fused[doc_id] += 1.0 / (k + rank)
    return sorted(fused.items(), key=lambda x: x[1], reverse=True)

def hybrid_search(query: str, k: int = 10, pool: int = 50):
    bm25_ids  = [i for i, _ in bm25_search(query, pool)]
    dense_ids = [i for i, _ in dense_search(query, pool)]
    return rrf([bm25_ids, dense_ids])[:k]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;em&gt;Analogy:&lt;/em&gt; RRF is like a talent show with two judges, where each judge only gives rankings, never scores. A contestant who is 2nd on both lists beats one who is 1st on one list and 40th on the other. &lt;strong&gt;Consensus beats a single loud opinion.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Two practical tips: pull a generous candidate pool (50 or so) from each retriever before fusing, and treat &lt;code&gt;k = 60&lt;/code&gt; as a starting point rather than a law.&lt;/p&gt;
&lt;h3 id=&quot;a-real-world-data-point-on-hybrid&quot;&gt;A real-world data point on hybrid&lt;/h3&gt;
&lt;p&gt;Anthropic published numbers on this in their &lt;em&gt;Contextual Retrieval&lt;/em&gt; write-up. The headline result: combining contextual embeddings with contextual BM25 cut the top-20-chunk retrieval failure rate by 49%, from 5.7% to 2.9%. They also explain &lt;em&gt;why&lt;/em&gt; BM25 earns its place: embedding models can miss exact-match queries like unique identifiers, which BM25 handles well.&lt;/p&gt;
&lt;p&gt;A fair caution comes from critics of that post. The best number, a 67% reduction, comes from stacking &lt;em&gt;several&lt;/em&gt; techniques, including reranking, not from any single trick. One analysis pointed out that the headline figures measure only top-k retrieval failure rate, which may not match what &lt;em&gt;you&lt;/em&gt; care about. So don't copy their pipeline wholesale. Take the lesson: &lt;strong&gt;hybrid beats either alone, and each added layer needs its own measurement.&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;how-to-actually-decide-build-a-tiny-eval&quot;&gt;How to actually decide: build a tiny eval&lt;/h3&gt;
&lt;p&gt;This is the part most tutorials skip, and it's the part that matters. Before you pick a strategy, write down 50 to 100 real questions with the chunk IDs that &lt;em&gt;should&lt;/em&gt; answer them. Then measure &lt;strong&gt;recall@k&lt;/strong&gt;: what fraction of the time the right chunk shows up in the top k results.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def recall_at_k(search_fn, eval_set, k=10):
    hits = 0
    for query, relevant_ids in eval_set:
        retrieved = {i for i, _ in search_fn(query)[:k]}
        hits += bool(retrieved &amp;amp; set(relevant_ids))
    return hits / len(eval_set)

for name, fn in [(&quot;bm25&quot;, bm25_search),
                 (&quot;dense&quot;, dense_search),
                 (&quot;hybrid&quot;, hybrid_search)]:
    print(name, recall_at_k(fn, eval_set, k=10))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Pull your eval questions from real user logs if you have them, and include the ugly ones: typos, error codes, vague questions. Hand-label them yourself. It's boring, and it's the highest-leverage hour you'll spend on this project.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Look at the failures, not just the number.&lt;/strong&gt; If BM25 misses are paraphrase problems and dense misses are ID problems, you've just proven hybrid is justified for &lt;em&gt;your&lt;/em&gt; data, not someone else's.&lt;/p&gt;
&lt;h3 id=&quot;what-i-d-do-on-a-50k-document-corpus&quot;&gt;What I'd do on a 50k-document corpus&lt;/h3&gt;
&lt;p&gt;If I were handed this project tomorrow, here's the order I'd work in:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Start with BM25 alone.&lt;/strong&gt; It's fast to build and a strong baseline. If it already hits your recall target, you may be done.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add dense retrieval with a decent open embedding model&lt;/strong&gt;, brute-force, no vector DB. Compare the two on your eval set.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fuse with RRF.&lt;/strong&gt; In most real corpora with mixed vocabulary, this is where recall jumps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fix chunking before adding fancier models.&lt;/strong&gt; Chunks that lose their context (like a paragraph saying &quot;it increased 12%&quot; with no idea what &quot;it&quot; is) hurt &lt;em&gt;both&lt;/em&gt; retrievers. This is the problem Anthropic's contextual approach targets.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Add a reranker last&lt;/strong&gt;, and only if hybrid recall@50 is good but precision@5 is poor. That pattern means the right chunk is in the pool but ranked too low, which is exactly the reranker's job. Budget for the latency, since reranking can cost far more per query than the retrieval itself.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reach for HNSW only when measurements say so&lt;/strong&gt;, such as high query volume or a corpus that grew far past your current size.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id=&quot;common-mistakes-i-see&quot;&gt;Common mistakes I see&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Skipping evaluation&lt;/strong&gt; and trusting vibes from five hand-picked demo queries.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tokenizing BM25 carelessly&lt;/strong&gt;, so identifiers and code get shredded.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Normalizing and summing raw scores&lt;/strong&gt; from different retrievers instead of fusing ranks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Over-engineering infrastructure&lt;/strong&gt; (a managed vector DB, three services) for a dataset whose vectors fit in under a gigabyte of RAM.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blaming the LLM&lt;/strong&gt; for answers that were doomed by what retrieval handed it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ignoring metadata filters.&lt;/strong&gt; If users usually ask about one product or one date range, filtering before search often beats any clever ranking.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;final-thoughts&quot;&gt;Final thoughts&lt;/h3&gt;
&lt;p&gt;There's no universally best retriever. BM25 is the reliable, literal-minded librarian. Dense retrieval is the intuitive friend who sometimes gets overconfident. Hybrid puts them in the same room and lets them check each other's work.&lt;/p&gt;
&lt;p&gt;At 50k documents, you have the luxury of simplicity: brute-force vectors, a plain keyword index, and fusion code you can read in 15 seconds. Spend your effort on a good eval set and good chunking. Those two things will move your answer quality more than any model swap.&lt;/p&gt;
&lt;p&gt;The snippets above are illustrative, so test them on your own data before relying on them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;references&quot;&gt;References&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Thakur et al. (2021). &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/pdf/2104.08663&quot;&gt;BEIR: A Heterogeneous Benchmark for Zero-shot Evaluation of Information Retrieval Models&lt;/a&gt;. NeurIPS Datasets and Benchmarks.&lt;/li&gt;
&lt;li&gt;Cormack, Clarke, Büttcher (2009). &lt;a rel=&quot;nofollow&quot; href=&quot;https://dl.acm.org/doi/10.1145/1571941.1572114&quot;&gt;Reciprocal Rank Fusion outperforms Condorcet and individual rank learning methods&lt;/a&gt;. SIGIR '09.&lt;/li&gt;
&lt;li&gt;Anthropic. &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.anthropic.com/engineering/contextual-retrieval&quot;&gt;Introducing Contextual Retrieval&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Almond AI. &lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/almond-ai/responding-to-anthropics-contextual-retrieval-for-rag-apps-why-context-is-not-all-you-need-af503985aa55&quot;&gt;Responding to Anthropic's Contextual Retrieval: Why Context is NOT All You Need&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;pgvector. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/pgvector/pgvector&quot;&gt;pgvector on GitHub&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h4 id=&quot;find-me-across-the-web-&quot;&gt;&lt;strong&gt;Find me across the web:&lt;/strong&gt;&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Portfolio:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;http://ahmershah.dev&quot;&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.crunchbase.com/person/syed-ahmer-shah&quot;&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Crunchbase Company:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.crunchbase.com/organization/syedahmershah&quot;&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clutch:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://clutch.co/profile/syed-ahmer-shah&quot;&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tech Behemoth:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://techbehemoths.com/company/syed-ahmer-shah&quot;&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Design Rush:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.designrush.com/agency/profile/syed-ahmer-shah&quot;&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Edverise:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://edverise.com/profile/syed-ahmer-shah&quot;&gt;&lt;strong&gt;@syed-ahmer-shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trust Pilot:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.trustpilot.com/review/ahmershah.dev&quot;&gt;&lt;strong&gt;ahmershah.dev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/syedahmershah&quot;&gt;&lt;strong&gt;Syed Ahmer Shah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ahmershahdev&quot;&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AWS Builder Profile:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://builder.aws.com/community/syedahmershah&quot;&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;DEV:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://dev.to/syedahmershah&quot;&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Medium:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@syedahmershah&quot;&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hashnode:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://hashnode.com/@syedahmershah&quot;&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://substack.com/@syedahmershah&quot;&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;HackerNoon:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://hackernoon.com/u/syedahmershah&quot;&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Substack:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://syedahmershah.substack.com/&quot;&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Facebook:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.facebook.com/ahmershahdev&quot;&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Linkedin Page:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://linkedin.com/company/syedahmershah&quot;&gt;&lt;strong&gt;@syedahmershah&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;YouTube:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/@ahmershahdev&quot;&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Instagram:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.instagram.com/ahmershahdev/&quot;&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TikTok:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.tiktok.com/@ahmershahdev&quot;&gt;&lt;strong&gt;@ahmershahdev&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/30044/rag-retrieval-strategies-on-a-50k-document-dataset-bm25-vs-dense-vs-hybrid</guid>
<pubDate>Wed, 07 Oct 2026 02:43:05 +0000</pubDate>
</item>
<item>
<title>Is It Still RAG If There's No Retrieval?</title>
<link>https://coderlegion.com/29679/is-it-still-rag-if-theres-no-retrieval</link>
<description>&lt;p&gt;I've been shipping software for 28 years, 21 of them as a consultant working across over 100 different engagements. Past a certain point, a resume stops being a record and becomes a burdensome editing problem. Two pages can barely hold a half-dozen engagements, selected by hand for someone to read cold before I even get a chance to chat with them. Everything else is real experience that somebody, somewhere, might want to know.&lt;/p&gt;
&lt;p&gt;It bothered me that the odds are decent the one engagement a particular hiring manager is looking for didn't make the cut. Maybe the point-of-sale integration story that would have mattered to a retail-tech team got bumped for something that better fit the job description.&lt;/p&gt;
&lt;p&gt;So I built a (sigh, another) chatbot. As I put myself into the job market, an idea that had been sitting there since my last product shipped resurfaced: put my career history, skills, hobbies, and interests behind a single interface anyone can query, and ship it as an actual product rather than a demo. Two birds, one side quest.&lt;/p&gt;
&lt;p&gt;What I ended up with is a small Go and HTMX app with one curated Markdown file as its entire knowledge base: no embeddings, no vector store, no agent loop anywhere. It's live; you'll have to visit my LinkedIn profile or snag a copy of my resume for the link. Whether that even counts as RAG is a fair question, which I hope to answer in this article. What follows is how I constrained it, what I built, and where I had the most fun.&lt;/p&gt;
&lt;h3 id=&quot;the-design-brief&quot;&gt;The design brief&lt;/h3&gt;
&lt;p&gt;I set the constraints before writing any code as they would help shape the implementation and technology choices.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nothing I'm not allowed to say.&lt;/strong&gt; Twenty-one years of consulting means NDAs, confidentiality agreements, and client names all through my records. The source data had to be treated as a leak risk before it could become knowledge. Client names became anonymized descriptors (&quot;a national grocery chain&quot; instead of the real one), contact details and commercial terms were stripped, and anything under NDA was excluded outright. Then review passes, plural: an agentic review, a mechanical keyword sweep for every client name I could remember, a manual read of the whole file, and a regeneration. The entire knowledge boundary is one curated Markdown file. If it isn't in the file, the bot can't say it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Me, minus most of me.&lt;/strong&gt; No address, no phone, no email. The only personal details in the bundle are the ones I put there on purpose: location, availability, and hobbies and interests for the culture-fit questions. LinkedIn is the contact route, full stop. My personal information is in the hands of enough shady data brokers already.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Never invent.&lt;/strong&gt; The knowledge boundary dictates what the bot can answer; this rule sets how it answers: only facts and metrics that appear explicitly in the source data. No rounding up engagement counts, no inferring a skill from an adjacent one, no helpful guesses. When a question falls outside the bundle, the bot gently declines to answer. A chatbot that confabulates my experience is worse than the resume problem I started with.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No opinions.&lt;/strong&gt; The bot is a little more than a fact retriever. Asked &quot;is Jason a fit for this role?&quot;, it answers with the facts the bundle provides and suggests that would be a great thing to bring up in an interview. It doesn't editorialize about my strengths or volunteer a verdict. Judging fit is the hiring team's job; the bot's job is making sure they're judging complete information.&lt;/p&gt;
&lt;h3 id=&quot;it-is-not-an-agent&quot;&gt;It is not an agent&lt;/h3&gt;
&lt;p&gt;A key design decision came next: the bot has no tools, no ReAct loop, no reasoning cycle of any kind. It is just a query tool. You ask a question, it answers from the bundle, simple and done.&lt;/p&gt;
&lt;p&gt;An &quot;agent&quot; didn't make sense here, so everything shifted to pre-processing and bundle build time. Look things up? The entire knowledge base is in the prompt. Compute years of .NET experience? All aggregates were precomputed. Take an action? There is nothing for it to do: the product is a conversation.&lt;/p&gt;
&lt;p&gt;Stripping the agent loop streamlined things. There is no tool-calling code to write, no schema to maintain, no loop to debug when the model decides to call the wrong thing three times in a row. Requests are mini-sessions and responses are fast, and the failures are boring. It shrinks the security surface too: an agent that can call tools and take actions gives an attacker something to reach for, and this bot can only speak.&lt;/p&gt;
&lt;h3 id=&quot;to-rag-or-not-to-rag&quot;&gt;To &quot;RAG&quot; or not to RAG&lt;/h3&gt;
&lt;p&gt;Retrieval-augmented generation (RAG) is the standard answer when you want a model to answer from your data: chunk the documents, embed them, drop them in a vector store, and fetch the top matches for each question. My entire career knowledge base fits in a modern context window with room to spare, so at runtime there was nothing to retrieve. Every chunk is always &quot;retrieved.&quot;&lt;/p&gt;
&lt;p&gt;Instead of abandoning the acronym, I re-staged its three phases.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retrieval&lt;/strong&gt; happened offline, long before any user showed up, and before I even started the bot. It was a mining and cleansing process: extracting the history from client dossiers and notes, applying the curation gate, and staging the result as a single Markdown file. The retrieval question, what the bot should know, got answered once, reviewed and edited by hand, carefully.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Augmentation&lt;/strong&gt; happens at the start of every chat session, as part of the conversation: the entire bundle goes in as context. Nothing is fetched per-question because everything is already there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Generation&lt;/strong&gt; is the phase that's truer to the word. The model reads the question, infers which engagements, skills, and metrics answer the query, and composes an answer from the full corpus. That inference is the entire point, and it's the part a keyword search over my resume can't do.&lt;/p&gt;
&lt;p&gt;These tradeoffs create high-quality results. No chunking means no retrieval misses: nothing relevant can be missed because everything is present. There's no embeddings pipeline to maintain and no drift between what was indexed and what was written. The cost is context window spend on every session, which prompt caching brings under control later.&lt;/p&gt;
&lt;p&gt;This stops being viable the moment the corpus outgrows the window. Then chunking, embeddings, and vector search earn their keep, and I'd be doing actual RAG.&lt;/p&gt;
&lt;h3 id=&quot;reducing-confabulation&quot;&gt;Reducing confabulation&lt;/h3&gt;
&lt;p&gt;The never-invent rule from the design brief needed mechanical help, because a model that's been handed a pile of career facts will still helpfully invent a number when asked for one. Two decisions cover most of it.&lt;/p&gt;
&lt;p&gt;The model choice was important here. I picked Gemini 3.6 Flash, a modern instruction follower, over the flashier reasoning models: for this workload the job is faithful extraction from provided context, and instruction-following models are the ones that stay on script. I went with the slightly older 3.6 model for cost reasons, backed by a mini-evaluation harness to ensure it stayed on track.&lt;/p&gt;
&lt;p&gt;Math got removed from the model entirely. Years of experience by skill, engagement counts by industry, the aggregate tables useful to recruiters: all of it was precomputed (mechanically, not by an LLM) from structured dates and written into the bundle as an appendix the bot quotes verbatim. Overlapping consulting engagements make naive date arithmetic wrong in ways that look plausible, and a confidently wrong &quot;14 years of Go&quot; is worse than a table it can't misread.&lt;/p&gt;
&lt;h3 id=&quot;prompt-caching&quot;&gt;Prompt caching&lt;/h3&gt;
&lt;p&gt;With all the career data riding along in every session, input tokens became the cost of the product, especially as the conversation grows. Prompt caching fixes this: providers let you pay a fraction of the normal input price for tokens they've seen before, provided the prompt starts with exactly the same bytes every time.&lt;/p&gt;
&lt;p&gt;The stable parts, the system prompt and the full career bundle, go first; the conversation follows after. Anything per-request that lands in that prefix, a timestamp, a session ID, even a helpful &quot;today is,&quot; silently invalidates the cache and quietly turns every chat back into a full-price chat. I went out of my way to keep the prefix byte-identical and to manage sessions so each conversation maps to a consistent cache key.&lt;/p&gt;
&lt;p&gt;Then I verified it through a test harness and eyeballing the OpenRouter logs, tuning it until caching was consistent and supported across multiple sessions. When the bundle changes, the cache busts. That's fine; the bundle changes rarely.&lt;/p&gt;
&lt;h3 id=&quot;the-build-brutally-efficient-again&quot;&gt;The build: brutally efficient again&lt;/h3&gt;
&lt;p&gt;My last side project, &lt;a rel=&quot;nofollow&quot; href=&quot;https://dumbquestion.ai&quot;&gt;DumbQuestion.ai&lt;/a&gt;, taught me a build mantra I've kept: brutally efficient. So the bot is another Go binary with HTMX for the UI. No JavaScript framework, no client-side state, no build pipeline beyond Go's own. One tiny container, one process, and it renders GitHub-flavored Markdown straight into the chat (the aggregate tables from the bundle deserve to render as tables).&lt;/p&gt;
&lt;p&gt;I didn't start from zero. The Turnstile abuse gate, the prompt-guard library, the embedded asset pipeline: all of it came over from the DumbQuestion codebase. The bundle itself is baked straight into the container build. The original design pulled it from an R2 bucket at boot with a re-check loop, but the data changes rarely, so the bucket became a moving part I didn't need. Updating the career data is now just a rebuild and redeploy, and the whole thing ships as one artifact.&lt;/p&gt;
&lt;p&gt;OpenRouter carries the model traffic, one API surface behind the caching work from the last section. This allowed me to route Google model traffic to my GCP account to take advantage of the monthly credit courtesy of my Google AI Pro subscription. Deployment is straightforward: GitHub Actions builds the container and pushes it to Cloud Run for scale-to-zero and crazy fast cold start times.&lt;/p&gt;
&lt;h3 id=&quot;abuse-protection&quot;&gt;Abuse protection&lt;/h3&gt;
&lt;p&gt;A public chatbot backed by a metered LLM API bills me for strangers' curiosity, and if a stranger wants to know a little something about me, fine. But abuse protection wasn't optional. With the whole bundle riding in every session, an enthusiastic scraper could have turned &quot;cheap&quot; into a surprise bill overnight. I did at least remember to set a monthly budget on the model usage.&lt;/p&gt;
&lt;p&gt;The outer layer is Cloudflare: rate limits, bot protection, and Turnstile in front of the chat. The cheap rejections happen at the edge, where they cost me nothing, and proving you're human before you're allowed to interrogate my career filters out nearly everything casually automated.&lt;/p&gt;
&lt;p&gt;The inner layer is prompt-guard, the injection detection that came over from the DumbQuestion codebase. It classifies attempts to override instructions, reveal the system prompt, or otherwise jailbreak the bot. What the bot does when it catches one is the fun part, and that's next.&lt;/p&gt;
&lt;h3 id=&quot;personality-the-fun-part&quot;&gt;Personality: the fun part&lt;/h3&gt;
&lt;p&gt;The design brief said no opinions. It said nothing about character, and a chatbot's response to a jailbreak attempt is a product surface. So when prompt-guard flags one, the bot answers in character. I originally pulled in DumbQuestion's quirky personality, but overly aggressive satire wasn't quite a fit for a conversational career bot.&lt;/p&gt;
&lt;p&gt;Try to prompt inject and you get a parlay into the pitch: a security-first mindset, the same instinct behind the curation gate and the no-agent decision earlier in this article. Try to convince the bot to ignore its instructions and it suggests that would make a great interview question. Ask my age and it cheerfully answers with how long I've been shipping software instead.&lt;/p&gt;
&lt;p&gt;The real defense is data isolation, a single curated file with nothing sensitive behind it and a lack of tools to break out of that box. The personality is the UX of the refusal.&lt;/p&gt;
&lt;p&gt;Nothing is logged or tracked, so there are no war stories from the wild. The responses above are designed behaviors, and the demo is the bot itself.&lt;/p&gt;
&lt;p&gt;The resume problem I started with didn't have a resume-shaped fix. Twenty-eight years and 100-plus engagements won't compress into two pages, and they shouldn't have to. No hiring manager wants a 12-page CV. The bot holds all of it, answers honestly about what it knows, declines what it doesn't, and costs about nothing to keep running. I shipped another AI product along the way, which was the other half of the point.&lt;/p&gt;
&lt;p&gt;If your history doesn't fit the format either, the recipe is short: curate the data first, put the whole thing in context, and skip the vector store until you actually need one.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/29679/is-it-still-rag-if-theres-no-retrieval</guid>
<pubDate>Sun, 04 Oct 2026 01:48:59 +0000</pubDate>
</item>
<item>
<title>Engineering an Evidence-First Knowledge Graph: From GitHub Artifacts to On-Chain Proofs in MyZubster</title>
<link>https://coderlegion.com/28984/engineering-evidence-first-knowledge-graph-from-github-artifacts-chain-proofs-myzubster</link>
<description>&lt;p&gt;Certo. Per CoderLegion lo farei più tecnico del post DEV, con più attenzione ad architettura, data provenance, canonicalizzazione, hashing e al motivo per cui dopo il test con Nicola stiamo valutando standard esistenti invece di continuare ad aggiungere smart contract custom.&lt;br&gt;
Engineering an Evidence-First Knowledge Graph: From GitHub Artifacts to On-Chain Proofs in MyZubster&lt;br&gt;
Building and testing a complete provenance chain with a real contributor, canonical payloads, SHA-256, Ethereum Sepolia, RAG, and a Knowledge Graph&lt;br&gt;
Tonight we ran one of the most complete end-to-end experiments so far on MyZubster.&lt;br&gt;
The test involved a real contributor, Nicola / N4K48, rather than synthetic fixtures.&lt;br&gt;
What started as a relatively simple test of the MyZubster Knowledge Profile evolved into a much larger architecture experiment involving:&lt;br&gt;
Authentication&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Knowledge Profile&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Private Knowledge Cards&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Publication&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Real software contribution&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;GitHub evidence&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;RAG / Qdrant&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Knowledge Graph&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Canonical payload&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SHA-256&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ethereum Sepolia&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Publicly inspectable provenance&lt;/p&gt;
&lt;p&gt;The most important result was not the blockchain transaction.&lt;br&gt;
It was discovering where the boundaries of the MyZubster architecture should probably be.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The problem we are trying to solve&lt;br&gt;
Most professional profiles fundamentally contain claims.&lt;br&gt;
For example:&lt;br&gt;
{&lt;br&gt;
  &quot;person&quot;: &quot;N4K48&quot;,&lt;br&gt;
  &quot;skill&quot;: &quot;Docker&quot;&lt;br&gt;
}&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This tells us what somebody says about themselves.&lt;br&gt;
It tells us very little about why another person should trust that statement.&lt;br&gt;
We wanted to experiment with a different model:&lt;br&gt;
Person&lt;br&gt;
 ↓&lt;br&gt;
Claim&lt;br&gt;
 ↓&lt;br&gt;
Activity&lt;br&gt;
 ↓&lt;br&gt;
Artifact&lt;br&gt;
 ↓&lt;br&gt;
Evidence&lt;br&gt;
 ↓&lt;br&gt;
Cryptographic commitment&lt;br&gt;
 ↓&lt;br&gt;
Attestation&lt;/p&gt;
&lt;p&gt;The objective is not to magically transform a claim into truth.&lt;br&gt;
The objective is to make its provenance inspectable.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Testing with a real contributor&lt;br&gt;
Nicola created a MyZubster Knowledge Profile representing different parts of his real activity.&lt;br&gt;
We used three Knowledge Cards during the experiment:&lt;br&gt;
N4K48&lt;br&gt;
 │&lt;br&gt;
 ├── Truck driving and transport organization&lt;br&gt;
 │&lt;br&gt;
 ├── Docker / AI experiments on myzubster-mvp&lt;br&gt;
 │&lt;br&gt;
 └── Learning and collaboration in MyZubster&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This immediately forced us to distinguish two concepts that are easy to accidentally merge:&lt;br&gt;
CLAIM&lt;/p&gt;
&lt;p&gt;and:&lt;br&gt;
EVIDENCE&lt;/p&gt;
&lt;p&gt;A professional statement is not automatically evidence.&lt;br&gt;
A GitHub commit is not automatically proof of every professional statement either.&lt;br&gt;
Therefore MyZubster needs to preserve the semantic boundary between them.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Authentication failed before cryptography did&lt;br&gt;
The first failures had nothing to do with Ethereum.&lt;br&gt;
Nicola encountered an expired JWT during the onboarding flow.&lt;br&gt;
The UI indicated an authenticated state, but a subsequent request produced:&lt;br&gt;
jwt expired&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Another issue appeared in the conversational onboarding flow: information already supplied by the user was not always being reused correctly.&lt;br&gt;
We also discovered a routing issue where the Knowledge Profile Builder URL displayed the MyZubster homepage instead of the Builder.&lt;br&gt;
These problems were fixed before continuing.&lt;br&gt;
The authentication behavior was changed so that an expired session could trigger re-authentication while preserving the locally prepared onboarding information.&lt;br&gt;
This matters because provenance starts before hashing.&lt;br&gt;
If users lose their work because authentication expires, the cryptographic layer does not save the product.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Private-first Knowledge Cards&lt;br&gt;
The next test covered persistence.&lt;br&gt;
Nicola created three cards and saved them privately.&lt;br&gt;
The tested state transition was approximately:&lt;br&gt;
NEW&lt;br&gt;
 ↓&lt;br&gt;
PRIVATE DRAFT&lt;br&gt;
 ↓&lt;br&gt;
RELOAD&lt;br&gt;
 ↓&lt;br&gt;
EDIT&lt;br&gt;
 ↓&lt;br&gt;
PREVIEW&lt;br&gt;
 ↓&lt;br&gt;
PUBLISH&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The important property here is that creation does not imply publication.&lt;br&gt;
A Knowledge Card can exist privately while the owner reviews:&lt;br&gt;
claims&lt;br&gt;
activities&lt;br&gt;
sources&lt;br&gt;
evidence&lt;br&gt;
verification state&lt;/p&gt;
&lt;p&gt;Only an explicit publication action makes the card public.&lt;br&gt;
Published cards can also be withdrawn before being modified.&lt;br&gt;
That gives us a lifecycle closer to:&lt;br&gt;
DRAFT&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
PUBLIC&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
WITHDRAWN / DRAFT&lt;/p&gt;
&lt;p&gt;rather than treating the knowledge database as an append-only public feed.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Turning software work into evidence&lt;br&gt;
The next step was intentionally practical.&lt;br&gt;
Instead of adding another manually entered skill, Nicola performed a real change in his myzubster-mvp repository.&lt;br&gt;
The work involved:&lt;br&gt;
scripts/ingest_knowledge.py&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;and improvements to the chunking/knowledge-ingestion process.&lt;br&gt;
The resulting test reported:&lt;br&gt;
46 knowledge chunks loaded into Qdrant&lt;br&gt;
1 empty document skipped&lt;/p&gt;
&lt;p&gt;The Git commit associated with that work could then become a source attached to the relevant Knowledge Card.&lt;br&gt;
We now had something structurally different from:&lt;br&gt;
N4K48&lt;br&gt;
 ↓&lt;br&gt;
&quot;knows software development&quot;&lt;/p&gt;
&lt;p&gt;We had:&lt;br&gt;
N4K48&lt;br&gt;
 ↓&lt;br&gt;
Knowledge Card&lt;br&gt;
 ↓&lt;br&gt;
documented software activity&lt;br&gt;
 ↓&lt;br&gt;
GitHub repository&lt;br&gt;
 ↓&lt;br&gt;
specific commit&lt;br&gt;
 ↓&lt;br&gt;
inspectable code change&lt;/p&gt;
&lt;p&gt;This is an important distinction.&lt;br&gt;
MyZubster does not need to decide that the commit proves a particular competence.&lt;br&gt;
It can state something narrower:&lt;br&gt;
This artifact is evidence associated with this activity and this Knowledge Card.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;RAG became part of the evidence trail&lt;br&gt;
The test also exercised the knowledge retrieval stack.&lt;br&gt;
The local project involved:&lt;br&gt;
Docker Compose&lt;br&gt;
   │&lt;br&gt;
   ├── Ollama&lt;br&gt;
   │&lt;br&gt;
   ├── Qdrant&lt;br&gt;
   │&lt;br&gt;
   ├── API&lt;br&gt;
   │&lt;br&gt;
   └── Open WebUI&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;During retrieval testing, the context being returned was insufficient.&lt;br&gt;
The experiment therefore changed:&lt;br&gt;
AI_CONTEXT_LIMIT=1&lt;/p&gt;
&lt;p&gt;to:&lt;br&gt;
AI_CONTEXT_LIMIT=5&lt;/p&gt;
&lt;p&gt;and repeated the retrieval tests.&lt;br&gt;
That work itself became documentable evidence.&lt;br&gt;
This creates a useful recursive property.&lt;br&gt;
The system can represent knowledge about the process used to improve the system itself:&lt;br&gt;
engineering work&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Git artifact&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Knowledge Card&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  ↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Knowledge Graph&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Building the Knowledge Graph&lt;br&gt;
Once the cards were public, we started connecting them into a graph.&lt;br&gt;
Conceptually:&lt;pre&gt;&lt;code&gt;            N4K48
              │
 ┌────────────┼────────────┐
 │            │            │
 ▼            ▼            ▼
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Transport      Learning     Docker / AI&lt;br&gt;
   Card           Card          Card&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                             │
                             ▼
                         GitHub repo
                             │
                             ▼
                           commit
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Sources become traversable relationships instead of text buried inside a profile.&lt;br&gt;
During this phase we found another real-world infrastructure issue.&lt;br&gt;
The visual Knowledge Graph deployment was protected by Vercel, preventing Nicola from accessing it.&lt;br&gt;
After adjusting deployment accessibility, testing continued, including from mobile.&lt;br&gt;
We later corrected another graph/catalog connection that produced a &quot;Catalog unavailable&quot; result.&lt;br&gt;
These may look like small frontend/infrastructure bugs, but they matter for an evidence system.&lt;br&gt;
Evidence that technically exists but cannot be navigated is significantly less useful.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Proof v1: hashing the wrong thing&lt;br&gt;
Then we introduced Ethereum Sepolia.&lt;br&gt;
The first MyZubsterProof experiment anchored a SHA-256 digest related to the Knowledge Card.&lt;br&gt;
However, the digest represented the Knowledge Card URL.&lt;br&gt;
Conceptually:&lt;br&gt;
Knowledge Card URL&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; SHA-256&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; bytes32&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt; Ethereum Sepolia&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This works cryptographically.&lt;br&gt;
But semantically it proves much less than we wanted.&lt;br&gt;
Suppose:&lt;br&gt;
&lt;a rel=&quot;nofollow&quot; href=&quot;https://example.com/card/123&quot;&gt;https://example.com/card/123&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;continues to exist while the content served at that URL changes.&lt;br&gt;
Hashing the URL does not detect the content change.&lt;br&gt;
The first experiment therefore became useful precisely because it exposed this weakness.&lt;br&gt;
We explicitly documented it rather than treating an on-chain hash as stronger evidence than it actually was.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Proof v2: canonicalize before hashing&lt;br&gt;
Proof v2 changed the architecture.&lt;br&gt;
Instead of hashing a location, we created a deterministic representation of the Knowledge Card itself.&lt;br&gt;
Conceptually:&lt;br&gt;
Knowledge Card&lt;br&gt;
   ↓&lt;br&gt;
canonical representation&lt;br&gt;
   ↓&lt;br&gt;
exact bytes&lt;br&gt;
   ↓&lt;br&gt;
SHA-256&lt;br&gt;
   ↓&lt;br&gt;
bytes32&lt;br&gt;
   ↓&lt;br&gt;
Ethereum Sepolia&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For the tested card, the resulting digest was:&lt;br&gt;
6097e05866bafceec24663d2638cb1dae5742ac78284abbfd45cc9c3b0bfb845&lt;/p&gt;
&lt;p&gt;The corresponding on-chain value was:&lt;br&gt;
0x6097e05866bafceec24663d2638cb1dae5742ac78284abbfd45cc9c3b0bfb845&lt;/p&gt;
&lt;p&gt;Reading knowledgeHash() from the deployed MyZubsterProof contract returned that same value.&lt;br&gt;
The canonical payload and the verification procedure were also committed to the repository.&lt;br&gt;
That gives us a reproducible verification path:&lt;br&gt;
canonical payload&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;   │
   ▼
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SHA-256(payload)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;   │
   ▼
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;expected digest&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;   │
   ▼
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;on-chain knowledgeHash()&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;   │
   ▼
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;comparison&lt;/p&gt;
&lt;p&gt;If all values match, we can establish that the committed canonical representation corresponds to the digest stored by the contract.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Why canonicalization matters&lt;br&gt;
This is a subtle but critical engineering detail.&lt;br&gt;
Consider two JSON objects:&lt;br&gt;
{&lt;br&gt;
  &quot;name&quot;: &quot;N4K48&quot;,&lt;br&gt;
  &quot;activity&quot;: &quot;Docker&quot;&lt;br&gt;
}&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;and:&lt;br&gt;
{&lt;br&gt;
  &quot;activity&quot;: &quot;Docker&quot;,&lt;br&gt;
  &quot;name&quot;: &quot;N4K48&quot;&lt;br&gt;
}&lt;/p&gt;
&lt;p&gt;Semantically, an application may consider them equivalent.&lt;br&gt;
At the byte level, they are different.&lt;br&gt;
And therefore:&lt;br&gt;
SHA256(payloadA) != SHA256(payloadB)&lt;/p&gt;
&lt;p&gt;can occur even when both payloads represent the same logical information.&lt;br&gt;
The same issue appears with:&lt;br&gt;
whitespace&lt;br&gt;
field ordering&lt;br&gt;
Unicode normalization&lt;br&gt;
line endings&lt;br&gt;
optional values&lt;br&gt;
number serialization&lt;br&gt;
dates&lt;/p&gt;
&lt;p&gt;So an evidence system cannot simply say:&lt;br&gt;
hash(JSON.stringify(object))&lt;/p&gt;
&lt;p&gt;and assume the problem is solved forever.&lt;br&gt;
The canonicalization algorithm effectively becomes part of the protocol.&lt;br&gt;
A durable proof therefore needs something like:&lt;br&gt;
payload format&lt;br&gt;
canonicalization version&lt;br&gt;
hash algorithm&lt;br&gt;
digest&lt;br&gt;
attestation reference&lt;/p&gt;
&lt;p&gt;Proof v2 pushed us toward that realization.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Integrity is not truth&lt;br&gt;
This is probably the most important architectural rule from the experiment.&lt;br&gt;
Assume the canonical payload contains:&lt;br&gt;
{&lt;br&gt;
  &quot;claim&quot;: &quot;N4K48 is an expert in Docker&quot;&lt;br&gt;
}&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;and suppose we perfectly canonicalize it, hash it and anchor the digest on Ethereum.&lt;br&gt;
We have demonstrated something about the integrity of the statement.&lt;br&gt;
We have not demonstrated that the statement itself is true.&lt;br&gt;
Formally:&lt;br&gt;
integrity(claim) ≠ truth(claim)&lt;/p&gt;
&lt;p&gt;Likewise:&lt;br&gt;
existence(GitHub commit) ≠ proof(skill)&lt;/p&gt;
&lt;p&gt;and:&lt;br&gt;
blockchain timestamp ≠ professional certification&lt;/p&gt;
&lt;p&gt;Different evidence supports different propositions.&lt;br&gt;
MyZubster therefore needs to model not only evidence, but also what each piece of evidence is capable of establishing.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Provenance as a graph&lt;br&gt;
This leads to a more interesting representation.&lt;br&gt;
Instead of:&lt;br&gt;
Person → Skill&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;we can represent:&lt;br&gt;
Person&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Claim&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Activity&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Knowledge Card&lt;br&gt;
  │&lt;br&gt;
  ├──────────────► GitHub artifact&lt;br&gt;
  │&lt;br&gt;
  ├──────────────► external source&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Canonical Payload&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Digest&lt;br&gt;
  │&lt;br&gt;
  ▼&lt;br&gt;
Attestation&lt;/p&gt;
&lt;p&gt;Each edge has semantics.&lt;br&gt;
For example:&lt;br&gt;
Person --DECLARES--&amp;gt; Claim&lt;/p&gt;
&lt;p&gt;Claim --SUPPORTED_BY--&amp;gt; Evidence&lt;/p&gt;
&lt;p&gt;Activity --PRODUCED--&amp;gt; Artifact&lt;/p&gt;
&lt;p&gt;KnowledgeCard --CANONICALIZED_AS--&amp;gt; Payload&lt;/p&gt;
&lt;p&gt;Payload --HASHED_AS--&amp;gt; Digest&lt;/p&gt;
&lt;p&gt;Digest --ATTESTED_BY--&amp;gt; Attestation&lt;/p&gt;
&lt;p&gt;This is much more expressive than simply attaching a blockchain transaction ID to a user profile.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The architectural question that emerged&lt;br&gt;
After completing Proof v2, Nicola compared the experiment with existing systems and standards.&lt;br&gt;
The comparison included:&lt;ul&gt;
&lt;li&gt;Talent Protocol / Builder Score;&lt;/li&gt;
&lt;li&gt;Ethereum Attestation Service;&lt;/li&gt;
&lt;li&gt;cheqd;&lt;/li&gt;
&lt;li&gt;Open Badges 3.0 and Verifiable Credentials.&lt;br&gt;
That comparison raised a very useful question.&lt;br&gt;
Why should MyZubster implement every cryptographic primitive itself?&lt;br&gt;
The ecosystem already contains mature approaches for:&lt;br&gt;
identity&lt;br&gt;
credentials&lt;br&gt;
attestations&lt;br&gt;
reputation&lt;br&gt;
issuers&lt;br&gt;
verification&lt;br&gt;
revocation&lt;br&gt;
cryptographic proofs&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Creating:&lt;br&gt;
MyZubsterProofV3.sol&lt;br&gt;
MyZubsterProofV4.sol&lt;br&gt;
MyZubsterProofV5.sol&lt;/p&gt;
&lt;p&gt;may therefore be less valuable than making MyZubster interoperable with existing primitives.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Separating the layers&lt;br&gt;
The experiment suggests that the architecture could eventually be separated into several layers.&lt;br&gt;
Identity layer&lt;br&gt;
Person&lt;br&gt;
Wallet&lt;br&gt;
GitHub identity&lt;br&gt;
MyZubster account&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Knowledge layer&lt;br&gt;
Knowledge Card&lt;br&gt;
Claim&lt;br&gt;
Activity&lt;br&gt;
Skill&lt;br&gt;
Project&lt;/p&gt;
&lt;p&gt;Evidence layer&lt;br&gt;
GitHub commit&lt;br&gt;
Pull request&lt;br&gt;
Repository&lt;br&gt;
Document&lt;br&gt;
External source&lt;br&gt;
Artifact&lt;/p&gt;
&lt;p&gt;Integrity layer&lt;br&gt;
canonical payload&lt;br&gt;
hash algorithm&lt;br&gt;
digest&lt;br&gt;
version&lt;/p&gt;
&lt;p&gt;Attestation layer&lt;br&gt;
Potentially:&lt;br&gt;
Ethereum Attestation Service&lt;/p&gt;
&lt;p&gt;rather than a growing family of MyZubster-specific contracts.&lt;br&gt;
Credential layer&lt;br&gt;
Potential interoperability with:&lt;br&gt;
W3C Verifiable Credentials&lt;br&gt;
Open Badges&lt;/p&gt;
&lt;p&gt;Relationship layer&lt;br&gt;
This is where MyZubster's Knowledge Graph becomes particularly important.&lt;br&gt;
Person&lt;br&gt;
 ↓&lt;br&gt;
Claim&lt;br&gt;
 ↓&lt;br&gt;
Activity&lt;br&gt;
 ↓&lt;br&gt;
Evidence&lt;br&gt;
 ↓&lt;br&gt;
Artifact&lt;br&gt;
 ↓&lt;br&gt;
Attestation&lt;br&gt;
 ↓&lt;br&gt;
Credential&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;MyZubster may be the graph, not the protocol&lt;br&gt;
This was probably the biggest architectural insight of the evening.&lt;br&gt;
Originally it is tempting to think:&lt;br&gt;
MyZubster&lt;br&gt;
   =&lt;br&gt;
our smart contract&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;But after this experiment, another model looks more interesting:&lt;br&gt;
MyZubster&lt;br&gt;
   =&lt;br&gt;
evidence/provenance graph&lt;br&gt;
+&lt;br&gt;
workflow&lt;br&gt;
+&lt;br&gt;
verification semantics&lt;br&gt;
+&lt;br&gt;
human-readable navigation&lt;/p&gt;
&lt;p&gt;with standards handling lower-level primitives where appropriate.&lt;br&gt;
That means MyZubster could potentially consume:&lt;br&gt;
GitHub evidence&lt;br&gt;
EAS attestations&lt;br&gt;
Verifiable Credentials&lt;br&gt;
Open Badges&lt;br&gt;
blockchain transactions&lt;br&gt;
traditional documents&lt;br&gt;
other public evidence&lt;/p&gt;
&lt;p&gt;without pretending that all evidence has the same meaning.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;A possible future architecture&lt;br&gt;
The architecture we are now exploring looks closer to this:&lt;pre&gt;&lt;code&gt;               PERSON
                 │
                 ▼
               CLAIM
                 │
                 ▼
              ACTIVITY
                 │
                 ▼
          KNOWLEDGE CARD
                 │
    ┌────────────┴────────────┐
    │                         │
    ▼                         ▼
EVIDENCE                   ARTIFACT
    │                         │
    └────────────┬────────────┘
                 │
                 ▼
        CANONICAL PAYLOAD
                 │
                 ▼
              SHA-256
                 │
     ┌───────────┴───────────┐
     │                       │
     ▼                       ▼
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;   ATTESTATION              CREDENTIAL&lt;br&gt;
   e.g. EAS                VC / Badge&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;     │                       │
     └───────────┬───────────┘
                 ▼
         MYZUBSTER GRAPH
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Notice that the blockchain is no longer the center of the system.&lt;br&gt;
The relationships are.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Why this differs from a reputation score&lt;br&gt;
Consider a reputation system that produces:&lt;br&gt;
Score = 87&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That number may be useful for ranking or filtering.&lt;br&gt;
But a provenance graph asks a different question:&lt;br&gt;
Why?&lt;/p&gt;
&lt;p&gt;A MyZubster-style path could eventually allow someone to inspect:&lt;br&gt;
N4K48&lt;br&gt;
 ↓&lt;br&gt;
Docker-related Knowledge Card&lt;br&gt;
 ↓&lt;br&gt;
specific development activity&lt;br&gt;
 ↓&lt;br&gt;
GitHub commit&lt;br&gt;
 ↓&lt;br&gt;
source code&lt;br&gt;
 ↓&lt;br&gt;
canonical representation&lt;br&gt;
 ↓&lt;br&gt;
cryptographic digest&lt;br&gt;
 ↓&lt;br&gt;
attestation&lt;/p&gt;
&lt;p&gt;The user is not forced to trust only the final number.&lt;br&gt;
They can inspect the path that produced the evidence.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Evidence types should not collapse into one trust score&lt;br&gt;
This is another consequence of the experiment.&lt;br&gt;
Imagine three pieces of evidence:&lt;br&gt;
A: self-declared professional experience&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;B: public GitHub commit&lt;/p&gt;
&lt;p&gt;C: credential issued by a university&lt;/p&gt;
&lt;p&gt;All three may belong in the graph.&lt;br&gt;
But they should not automatically become:&lt;br&gt;
A = B = C&lt;/p&gt;
&lt;p&gt;Their semantics are different.&lt;br&gt;
A useful graph therefore needs metadata such as:&lt;br&gt;
evidence type&lt;br&gt;
issuer&lt;br&gt;
subject&lt;br&gt;
source&lt;br&gt;
verification method&lt;br&gt;
verification status&lt;br&gt;
created timestamp&lt;br&gt;
attestation reference&lt;br&gt;
revocation state&lt;/p&gt;
&lt;p&gt;The graph should preserve those differences rather than hide them behind one universal score.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What the experiment actually demonstrated&lt;br&gt;
By the end of the session we had exercised a chain approximately like this:&lt;br&gt;
Real contributor&lt;br&gt;
  ↓&lt;br&gt;
authenticated MyZubster account&lt;br&gt;
  ↓&lt;br&gt;
private Knowledge Cards&lt;br&gt;
  ↓&lt;br&gt;
public Knowledge Cards&lt;br&gt;
  ↓&lt;br&gt;
real software activity&lt;br&gt;
  ↓&lt;br&gt;
GitHub evidence&lt;br&gt;
  ↓&lt;br&gt;
RAG / Qdrant test&lt;br&gt;
  ↓&lt;br&gt;
Knowledge Graph&lt;br&gt;
  ↓&lt;br&gt;
canonical card payload&lt;br&gt;
  ↓&lt;br&gt;
SHA-256&lt;br&gt;
  ↓&lt;br&gt;
Sepolia Proof v2&lt;br&gt;
  ↓&lt;br&gt;
public verification information&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is more useful to us than an isolated smart-contract demo because every layer exposed different failure modes.&lt;br&gt;
We encountered:&lt;br&gt;
expired authentication&lt;br&gt;
routing problems&lt;br&gt;
deployment access restrictions&lt;br&gt;
catalog/navigation problems&lt;br&gt;
retrieval-context limitations&lt;br&gt;
evidence-modeling questions&lt;br&gt;
URL-vs-content hashing semantics&lt;br&gt;
canonicalization requirements&lt;br&gt;
truth-vs-integrity boundaries&lt;/p&gt;
&lt;p&gt;That is exactly what an end-to-end proof of concept should reveal.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What we deliberately did not prove&lt;br&gt;
It is equally important to document what the experiment does not establish.&lt;br&gt;
Proof v2 does not prove that every statement in a Knowledge Card is true.&lt;br&gt;
It does not automatically prove professional competence.&lt;br&gt;
It does not establish that GitHub activity is equivalent to mastery of a skill.&lt;br&gt;
It does not turn blockchain data into a trusted credential issuer.&lt;br&gt;
What it does establish is a mechanism for connecting an exact representation of information with a reproducible digest and a public cryptographic reference.&lt;br&gt;
That is an integrity primitive.&lt;br&gt;
Trust requires additional layers.&lt;/li&gt;
&lt;li&gt;The next engineering experiment&lt;br&gt;
Instead of immediately creating another custom proof contract, the next useful experiment is likely to take the exact Proof v2 data model and map it onto existing standards.&lt;br&gt;
Something like:&lt;br&gt;
Knowledge Card&lt;br&gt;
  ↓&lt;br&gt;
canonical payload&lt;br&gt;
  ↓&lt;br&gt;
current Proof v2&lt;br&gt;
  │&lt;br&gt;
  ├────► EAS schema mapping&lt;br&gt;
  │&lt;br&gt;
  └────► Open Badges / VC mapping&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Then we can compare:&lt;br&gt;
custom MyZubster contract&lt;br&gt;
vs.&lt;br&gt;
standardized attestation&lt;/p&gt;
&lt;p&gt;across:&lt;br&gt;
schema flexibility&lt;br&gt;
revocation&lt;br&gt;
references&lt;br&gt;
issuer semantics&lt;br&gt;
verification&lt;br&gt;
interoperability&lt;br&gt;
developer complexity&lt;br&gt;
gas cost&lt;br&gt;
long-term maintainability&lt;/p&gt;
&lt;p&gt;Only after that comparison should we decide what belongs inside MyZubster itself.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The broader engineering lesson&lt;br&gt;
Tonight's work started with a very ordinary product task:&lt;br&gt;
Can a contributor create a knowledge profile?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It progressed into:&lt;br&gt;
Can the profile contain inspectable evidence?&lt;/p&gt;
&lt;p&gt;Then:&lt;br&gt;
Can that evidence point to real engineering work?&lt;/p&gt;
&lt;p&gt;Then:&lt;br&gt;
Can the exact knowledge representation be cryptographically committed?&lt;/p&gt;
&lt;p&gt;And eventually:&lt;br&gt;
Should MyZubster even own the attestation protocol?&lt;/p&gt;
&lt;p&gt;That progression matters.&lt;br&gt;
A proof of concept should not merely prove that the original architecture works.&lt;br&gt;
Sometimes its most valuable outcome is discovering that part of the original architecture should not exist.&lt;br&gt;
For MyZubster, that may mean allowing established standards to handle credentials and attestations while concentrating our engineering effort on the part we actually care about:&lt;br&gt;
human claim&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;real activity&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;inspectable evidence&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;artifact&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;verification&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;↓
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;provenance&lt;/p&gt;
&lt;p&gt;and making that entire chain navigable.&lt;br&gt;
That's the direction we're exploring next.&lt;br&gt;
CoderLegion title&lt;br&gt;
Engineering an Evidence-First Knowledge Graph: GitHub, SHA-256, Ethereum and MyZubster&lt;br&gt;
Subtitle&lt;br&gt;
How a real contributor test evolved from Knowledge Cards and RAG into canonical payloads, on-chain proofs, provenance graphs, and a rethink of our attestation architecture.&lt;br&gt;
Short description / excerpt&lt;br&gt;
We tested MyZubster end-to-end with a real contributor: authentication, private and public Knowledge Cards, GitHub evidence, Qdrant/RAG, a Knowledge Graph, canonical payloads, SHA-256 and Ethereum Sepolia. The most important result wasn't the smart contract—it was realizing that MyZubster may be better positioned as an evidence and provenance graph built on interoperable attestation standards.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28984/engineering-evidence-first-knowledge-graph-from-github-artifacts-chain-proofs-myzubster</guid>
<pubDate>Tue, 29 Sep 2026 02:03:45 +0000</pubDate>
</item>
<item>
<title>Nine Days, 143 Commits: a Forensic History of the Unit-of-Return Experiment</title>
<link>https://coderlegion.com/28802/nine-days-143-commits-a-forensic-history-of-the-unit-of-return-experiment</link>
<description>&lt;p&gt;&lt;em&gt;Every SHA, every raw number and every recovered file below is traceable to the repo. Where something broke, the commit that fixed it is named. Where something is still open, it says so.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;prologue-a-table-in-the-comments&quot;&gt;Prologue: a Table in the Comments&lt;/h4&gt;
&lt;p&gt;On September 24, a commenter named Tom Jones dropped a table into the discussion under our bootstrap-pipeline article: identical retriever, store, query and ranking — only the returned unit varied. Ten chunks: 9/14. Whole document: 5/14. No retrieval: 6/14. And the one count that survived: his chunk ranker's top hit landed on the answer-bearing document 0 times out of 14.&lt;/p&gt;
&lt;p&gt;We ran his protocol on our index. This is the story of everything that had to break first — over nine days and 143 commits — before a single one of those numbers could be trusted.&lt;/p&gt;
&lt;h4 id=&quot;act-i-the-rollback-that-started-it-early-august-&quot;&gt;Act I. The Rollback That Started It (Early August)&lt;/h4&gt;
&lt;p&gt;Months before any of this, in the thread under Tom's &lt;em&gt;&quot;A stable aggregate is not a stable measurement&quot;&lt;/em&gt;, he told the story that set our methodology: a config change measured 3/4 before against 2/10 after, production was rolled back on it — and the rolled-back config then measured 4/10. It had been noise the whole way. His answer was a noise-floor gate that refuses &lt;em&gt;and states the n it would take&lt;/em&gt;, because &quot;3 of 4 against 2 of 10&quot; means nobody has measured anything yet. We adopted the rule wholesale: no claim below the noise floor, ever. Everything that follows was built to survive that gate — including, as you will see, our own published numbers.&lt;/p&gt;
&lt;h4 id=&quot;act-ii-linking-tests-to-functions-sept-15-22-&quot;&gt;Act II. Linking Tests to Functions (Sept 15–22)&lt;/h4&gt;
&lt;p&gt;The bootstrap pipeline needed one impossible edge: which function does a test &lt;em&gt;actually execute&lt;/em&gt;? Name matching died at &lt;strong&gt;0 out of 109&lt;/strong&gt; (&lt;code&gt;d74785d4&lt;/code&gt;). A custom &lt;code&gt;sys.settrace&lt;/code&gt; plugin traced all 1,727 tests: 1,551 (89.8%) execute ≥1 src function, 10.1 per test on average — &quot;1 test = 1 function&quot; was a myth, so edges became the full trace, no ranker (&lt;code&gt;8a94026d&lt;/code&gt;, Sept 16).&lt;/p&gt;
&lt;p&gt;Then the confounds queued up. Tarantula ranking as a selector: refuted (rank≤3 covered 22.6%). &lt;code&gt;coverage.py&lt;/code&gt; as a faster driver: refuted (+19.96% vs +13.6%). Static signals: 70% union recall, companion only. Portability held on foreign repos (97.3–100%). E17 wired TESTS edges into search (&lt;code&gt;c1fb2363&lt;/code&gt;, Sept 22, PR #39) — but the pilot first returned &lt;strong&gt;120/120 Error 500&lt;/strong&gt;, because methods are &lt;code&gt;Class.method&lt;/code&gt; and the naive matcher looked for &lt;code&gt;def name&lt;/code&gt;. Fixed with an AST extractor. Then the v1 judge's &quot;completeness B&amp;gt;A, p=0.0046&quot; turned out to be a judge-context artifact — the judge had seen each arm's own tests. Fix: identical reference across arms, effect erased to p=1.00, and a standing rule was born: &lt;em&gt;the judge's reference must be identical across arms&lt;/em&gt;.&lt;/p&gt;
&lt;h4 id=&quot;act-iii-september-burns-sept-19-20-&quot;&gt;Act III. September Burns (Sept 19–20)&lt;/h4&gt;
&lt;p&gt;E10 (full-text embeddings) was refuted on a clean reindex (hit@1 0/10 fast, 2/10 quality) — code reverted to HEAD the same day (&lt;code&gt;15ce15d7&lt;/code&gt;). E11 whispered a signal: +2 hit@5 at +6ms. E11 stayed a behind-flag candidate (A-prepend, N=10) — not shipped, not dead, awaiting a bigger panel. Then the crash: a full reindex died at 330K chunks after ~9 hours, losing everything, because &lt;code&gt;run()&lt;/code&gt; held 1.3 GB of embeddings for one final bulk write. Fix (&lt;code&gt;0fcb0f33&lt;/code&gt;): flush every 32 files, resume from known hashes, integrity check at start — and the rule that governs this project to this day: &lt;strong&gt;no experiment runs without a frozen baseline&lt;/strong&gt;. E12 confirmed the 8-hour reindex was physics, not a bug. E13 refuted doc-chunks (hit@5 12.5% vs code's 50%/40%) — the first sighting of the two populations.&lt;/p&gt;
&lt;h4 id=&quot;act-iv-the-week-everything-lied-sept-22-25-&quot;&gt;Act IV. The Week Everything Lied (Sept 22–25)&lt;/h4&gt;
&lt;p&gt;F4 said deepseek scored 0/3 and was &quot;invalid as a model.&quot; It was an undercooked reasoning budget. Pinned rerun: 3/3, verdict corrected, Pitfall #19 logged. Held-out control #14 (email spacing) fired ~100% — it was a semantic twin of a catalogue sentence; replaced with an out-of-domain Docker tag, validator scripted. The frozen 16-item symptom list lived in &lt;code&gt;%TEMP%&lt;/code&gt; and was &lt;strong&gt;deleted by a temp cleanup&lt;/strong&gt; — recovered from the session database (&lt;code&gt;opencode.db&lt;/code&gt;, write rid=75331, verified against read rid=75864), SHA256 &lt;code&gt;a6f719df…&lt;/code&gt;, frozen in-repo with a guard test so it can never happen again. Sept 25 brought a permanent reindex deadlock (fixed by splitting locks, ledger added, 858.5s completion) and a foreign MCP server squatting our fixed ports :8080/:8081 — hence the standing rule: чужое не трогаем.&lt;/p&gt;
&lt;h4 id=&quot;act-v-the-four-arms-sept-26-&quot;&gt;Act V. The Four Arms (Sept 26)&lt;/h4&gt;
&lt;p&gt;Pre-registered (&lt;code&gt;5f158ece&lt;/code&gt;), harnessed (&lt;code&gt;02b0781a&lt;/code&gt;), judged (&lt;code&gt;0a1c212f&lt;/code&gt;, 516 files, manifest with SHA256 per artifact). Frozen 16 (8+8, sha &lt;code&gt;e048aa12…&lt;/code&gt;), index snapshot 10106/716/14099, reader &lt;code&gt;longcat-2.0&lt;/code&gt;, blind judge &lt;code&gt;qwen3.7-plus&lt;/code&gt;, 10 trials, strict &amp;gt;50% majority:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Arm &lt;/th&gt;&lt;th&gt;Judged (n=160) &lt;/th&gt;&lt;th&gt;Code (n=80) &lt;/th&gt;&lt;th&gt;Prose (n=80)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;A top-k chunks&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;26 (16.3%, CI 0.11–0.23) &lt;/td&gt;&lt;td&gt;5 (6.3%, CI 2.7–13.9) &lt;/td&gt;&lt;td&gt;21 (26.3%, CI 17.9–36.8)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;B whole document&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;55 (34.4%, CI 0.27–0.42) &lt;/td&gt;&lt;td&gt;&lt;strong&gt;40 (50%, CI 39.3–60.7)&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;15 (18.8%, CI 11.7–28.7)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;C oracle&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;156 (97.5%) &lt;/td&gt;&lt;td&gt;76 (95%) &lt;/td&gt;&lt;td&gt;80 (100%)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;D closed book&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;0 (160× &quot;I don't know&quot;) &lt;/td&gt;&lt;td&gt;0 &lt;/td&gt;&lt;td&gt;0&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Objective half: A hit@1 and B top-1-is-gold both 5/16 — the unit did not move retrieval. Judged: code 8× on non-overlapping CIs (the unit acts on the reader); prose A&amp;gt;B as direction only (overlapping CIs, the +6 sits on two queries that flip between snapshots, majority a 2:2 tie) — but 18.8% vs closed-book 0% means the strong prose-collapse prediction did not reproduce here. Majority strict: A 2/16, B 5/16 (one B point rested on a 5/5 tie and was counted out — stated, not hidden), C 16/16, D 0/16. Judge non-unanimous 6/64; repro t5→t10 within noise.&lt;/p&gt;
&lt;h4 id=&quot;act-vi-side-quests-sept-27-&quot;&gt;Act VI. Side Quests (Sept 27)&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;NodeRAG&lt;/strong&gt; (&lt;code&gt;153cdc6d&lt;/code&gt;): frozen 10 rule-queries + 3 positive + 3 NONE controls against our two long diary files — chunked TF-IDF 8/10 at 302K tokens vs graph BFS 7/10 at 170K, all controls clean. Verdict: REFUTED. Every graph miss returned zero files — no seed symbol, no traversal.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The logits murder mystery.&lt;/strong&gt; A production-retriever run started losing targets at the reranker: MMR 10→10, reranker 10→2, raw scores like −6.88 and −0.99. Cause: llama.cpp returns raw logits (≈[−11,+11]) while &lt;code&gt;MIN_RERANK_SCORE=0.3&lt;/code&gt; was calibrated for [0,1] — the filter ate 70–97% of results. Fix: sigmoid in the llama_cpp branch only, threshold untouched, 7 regression tests plus a mutation check. Post-mortem sting: the P3 target normalizes to 0.271 &amp;lt; 0.3 — the model itself rates the true file at logit −0.99, so the threshold was never the root cause. P3 stays open: the sigmoid fixed the scale contract, not the recall — the model itself rates the true file below threshold, which is a ranking problem no filter fix can solve. Two traps logged: measuring the filter on survivors (P-002), and landing the sigmoid in the ONNX block first (P-003, caught by diff review).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The janitor work nobody photographs.&lt;/strong&gt; A cp1251 mojibake from a PowerShell write (lesson: never write Russian markdown through PS). A privacy scrub — 49 tracked files with usernames, 115 with local paths — before answering Tom (PR #49). A pre-commit hook that failed twice on xdist worker crashes while the suite was green, passing on the third attempt with zero code changes. A merge (&lt;code&gt;d86a4a45&lt;/code&gt;, PR #50) that conflicted in three diary files, silently dropped 7 origin entries in an earlier commit (restored), and needed a 429→204-line archival of known issues to satisfy the size gate. The lint commit (&lt;code&gt;0cec4a63&lt;/code&gt;) fixed 4 ruff errors, two of which — an unreachable &lt;code&gt;allowlist&lt;/code&gt; line after &lt;code&gt;return&lt;/code&gt;, a blind &lt;code&gt;except&lt;/code&gt; — had been sitting in HEAD blocking every commit.&lt;/p&gt;
&lt;h4 id=&quot;behind-the-scenes-three-days-one-laptop&quot;&gt;Behind the Scenes: Three Days, One Laptop&lt;/h4&gt;
&lt;p&gt;Three days, one personal Windows machine, a single live index serving everything — no separate lab copy. Harness through opencode CLI at 4–8 threads: 640 reader calls plus the same in judge calls, then the trials=5 pilot on top. Public repo, so nothing is rewritten — invalid runs and snapshots sit on disk next to the real ones. No history rewrites, no cleaned-up reruns presented as first attempts. 143 commits since September 1; every number above names its file.&lt;/p&gt;
&lt;h4 id=&quot;related-work-only-what-we-opened-&quot;&gt;Related Work (only what we opened)&lt;/h4&gt;
&lt;p&gt;Anthropic's Contextual Retrieval (2024) cut retrieval failures 49% (67% with rerank) on their evals including codebases, via per-chunk LLM context prepended before embedding — compatible with, not identical to, our arm-B reader effect. Jina's late chunking (2024) conditions chunk embeddings on full-document context with gains growing on longer docs in their BEIR eval — same direction, prose/long-doc only. Neither splits code vs prose; our split is ours, pilot-scale (n=16), reader-specific.&lt;/p&gt;
&lt;h4 id=&quot;red-team&quot;&gt;Red Team&lt;/h4&gt;
&lt;p&gt;E17: 10 threads × 100 calls, 0 errors; 234-test hub at 16.1ms median; empty queries → 0 rows (null-behavior checks); mid-call close → empty results, no errors. F5 self-attacks: the 5/5 tie restated strict (B prose 1/8, B ALL 5/16 — hidden tie-breaks are how published tables mislead); &quot;0 invalid&quot; true for t10, false for the t5 pilot (F5S-03/B); gold-as-document overstates prose miss (7/8 prose queries had gold in neither arm, yet the reader scored via duplicate translations); t5 retrieval contexts overwritten and unrecoverable — verdicts reproduce, contexts do not; cheap reader bounds our numbers, not readers.&lt;/p&gt;
&lt;h4 id=&quot;what-could-go-wrong&quot;&gt;What Could Go Wrong&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;n=16 pilot scale.&lt;/strong&gt; Prefer CIs over bare rates; every % above carries Wilson 95%.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Index snapshotted, not frozen.&lt;/strong&gt; Drift documented, not eliminated — freeze before the next reader run.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Judged noise band not re-run.&lt;/strong&gt; t5→t10 is reproducibility, not noise measurement.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No per-arm token costs.&lt;/strong&gt; Context chars only — no cost claims attached.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;graph_score = 0.4&lt;/code&gt; shipped as an operating point (MRR held); ablation vs BM25/reranker not run.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cap experiment on our own logs not run.&lt;/strong&gt; Tom's numbers stay his.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TESTS-signal limits carry over&lt;/strong&gt; (Python-only, +15.3% wide-panel overhead, test nodes without line numbers, 10.2% mock blindness).&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 id=&quot;acknowledgments&quot;&gt;Acknowledgments&lt;/h4&gt;
&lt;p&gt;A huge thank you to everyone who engages with these posts in the comments. Your feedback, real-world observations, counter-examples, and benchmark numbers directly shape these experiments. Tom Jones handed over the full 4-arm protocol with controls and a falsifiable prediction for our index. This kind of open technical critique is what keeps engineering honest.&lt;/p&gt;
&lt;h4 id=&quot;a-note-on-how-this-was-written&quot;&gt;A note on how this was written&lt;/h4&gt;
&lt;p&gt;Every experiment, bug, failure, and idea here is mine — I earned them the hard way, in production, in public. AI worked as my editor: it helped me structure thoughts and polish my English. It did not invent the facts, because it has none of its own.&lt;/p&gt;
&lt;p&gt;No AI detectors were consulted in the making of this disclosure. They have enough trouble agreeing on what I am.&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Disclaimer &amp;amp; Status:&lt;/strong&gt; draft (source-material for the article). This is not a &quot;feature advertisement&quot;, but an honest engineering story: figures are reproducible, weak points are named, and unaddressed risks are listed in the &quot;What Could Go Wrong&quot; section.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28802/nine-days-143-commits-a-forensic-history-of-the-unit-of-return-experiment</guid>
<pubDate>Sun, 27 Sep 2026 12:11:20 +0000</pubDate>
</item>
<item>
<title>GraphRAG: Why Vectors Aren't Enough and Your AI Needs a Knowledge Graph</title>
<link>https://coderlegion.com/28581/graphrag-why-vectors-arent-enough-and-your-ai-needs-a-knowledge-graph</link>
<description>&lt;p&gt;You ask your traditional RAG pipeline an apparently straightforward enterprise question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;“Analyze all 500 contracts and technical audits from the past two years and tell me the three recurring operational bottlenecks shared by our tier-1 suppliers, and which finished products would stall if one of them goes bankrupt.”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Your vector database immediately springs into action. It calculates the cosine similarity of the query embedding against hundreds of thousands of isolated text chunks, retrieves the fifteen most semantically adjacent paragraphs, and feeds them into the model's context window.&lt;/p&gt;
&lt;p&gt;The resulting answer is a predictable disappointment: a superficial, fragmented response that cites two isolated clauses, overlooks the transitive supplier dependencies, and hallucinates the rest.&lt;/p&gt;
&lt;p&gt;This failure is not the fault of the underlying foundation model, nor is it a matter of tuning embedding dimensions. &lt;strong&gt;It is an architectural limitation intrinsic to flat vector spaces&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;After breaking down production pitfalls in &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/rag_antipatterns/&quot;&gt;RAG: 7 Anti-Patterns&lt;/a&gt; and championing pragmatic simplicity in &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/pgvector_vs_vectordb/&quot;&gt;PostgreSQL with pgvector vs Vector DBs&lt;/a&gt;, the time has come to explore the most significant technical frontier of 2026: &lt;strong&gt;GraphRAG&lt;/strong&gt;. A breakthrough paradigm that unites &lt;strong&gt;Knowledge Graphs&lt;/strong&gt; with generative intelligence to supply Large Language Models with what vectors alone can never provide: &lt;strong&gt;structural comprehension and multi-hop relational reasoning&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/watch?v=c5qJHr3DnT4&quot;&gt;&lt;img src=&quot;https://img.youtube.com/vi/c5qJHr3DnT4/0.jpg&quot; alt=&quot;YouTube Video&quot;&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h4 id=&quot;the-semantic-blindness-of-flat-vector-spaces&quot;&gt;The Semantic Blindness of Flat Vector Spaces&lt;/h4&gt;
&lt;p&gt;To understand why vectors fall short, we must examine how standard vector retrieval operates:&lt;/p&gt;
&lt;p&gt;Embeddings map chunks of text into a high-dimensional continuous space. Chunks addressing similar conceptual themes land geometrically close together. This makes vector search remarkably effective at solving &lt;strong&gt;needle-in-a-haystack&lt;/strong&gt; queries:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;“What is our return policy for international deliveries?”&lt;/em&gt; ➔ Vector proximity locates with surgical precision the exact section describing that policy.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;However, human knowledge and industrial systems rarely exist as disconnected needles in a haystack. They operate as &lt;strong&gt;dense, interdependent networks&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Standard vector retrieval suffers from two critical, structural blind spots:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Inability to Perform Multi-hop Reasoning&lt;/strong&gt;: If answering a prompt requires traversing a chain from Entity A to Entity B via an intermediate Entity C that shares no immediate vocabulary with the original query, vector search will never bridge the gap. Vectors capture lexical-semantic similarity, but remain blind to causal logic, hierarchical containment, and transitive links.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inability to Achieve Global Sensemaking&lt;/strong&gt;: Queries such as &lt;em&gt;“What are the overarching themes across this legal corpus?”&lt;/em&gt; or &lt;em&gt;“What anomalous patterns emerge across customer incident logs?”&lt;/em&gt; cannot be answered by pinpointing a single chunk. They require synthesizing the dataset as a cohesive whole.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/graphrag/graphrag_vs_vector_rag.jpg&quot; alt=&quot;Technical comparison: Traditional Vector RAG versus GraphRAG's hierarchical architecture&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;the-graphrag-architecture-how-microsoft-broke-the-retrieval-barrier&quot;&gt;The GraphRAG Architecture: How Microsoft Broke the Retrieval Barrier&lt;/h4&gt;
&lt;p&gt;Pioneered by &lt;strong&gt;Microsoft Research&lt;/strong&gt; (Darren Edge, Jonathan Larson, et al.), GraphRAG re-engineers the ingestion and retrieval lifecycle through a four-stage pipeline:&lt;/p&gt;
&lt;h5 id=&quot;1-llm-guided-entity-and-relationship-extraction&quot;&gt;1. LLM-Guided Entity and Relationship Extraction&lt;/h5&gt;
&lt;p&gt;Rather than blindly slicing text into arbitrary token chunks, an LLM traverses source documents to extract domain entities (people, organizations, components, technologies, regulations) and the &lt;strong&gt;explicit relationships&lt;/strong&gt; binding them, outputting structured semantic triples &lt;code&gt;(Subject, Predicate, Object)&lt;/code&gt; alongside rich descriptive summaries.&lt;/p&gt;
&lt;h5 id=&quot;2-knowledge-graph-synthesis&quot;&gt;2. Knowledge Graph Synthesis&lt;/h5&gt;
&lt;p&gt;The extracted triples are assembled into a unified, clean relational graph, resolving entity co-references and deduplicating aliases across the entire document collection. Nodes represent real-world entities; edges represent documented interactions.&lt;/p&gt;
&lt;h5 id=&quot;3-hierarchical-community-detection-the-leiden-algorithm-&quot;&gt;3. Hierarchical Community Detection (The Leiden Algorithm)&lt;/h5&gt;
&lt;p&gt;Here lies the core innovation of GraphRAG: it applies advanced complex network partitioning — specifically the &lt;strong&gt;Leiden community detection algorithm&lt;/strong&gt; — to segment the graph into &lt;strong&gt;hierarchically nested clusters of tightly bound nodes&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;At the top level (macro), it identifies broad thematic domains.&lt;/li&gt;
&lt;li&gt;At intermediate levels, it isolates coherent sub-ecosystems.&lt;/li&gt;
&lt;li&gt;At the granular base (micro), it preserves detailed operational nodes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h5 id=&quot;4-precomputed-community-summaries&quot;&gt;4. Precomputed Community Summaries&lt;/h5&gt;
&lt;p&gt;For each detected community in the hierarchy, an LLM precomputes a comprehensive &lt;strong&gt;Community Report&lt;/strong&gt; summarizing the key entities, operational themes, tensions, and structural takeaways of that cluster.&lt;/p&gt;
&lt;p&gt;When a user submits a global exploratory prompt, GraphRAG bypasses millions of unindexed raw tokens: &lt;strong&gt;it queries the precomputed hierarchical community summaries in parallel&lt;/strong&gt;, delivering a structured, macro-level synthesis with dramatically reduced token overhead.&lt;/p&gt;
&lt;h4 id=&quot;local-search-vs-global-search&quot;&gt;Local Search vs. Global Search&lt;/h4&gt;
&lt;p&gt;This dual-retrieval mechanism equips AI systems to handle fundamentally different query categories with unmatched precision:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Query Mode &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;GraphRAG Mechanics &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Ideal Question Types&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Local Search&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Traverses the immediate subgraph of an extracted entity, pulling its direct neighbors, typed relations, and raw supporting text snippets. &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;em&gt;“What failure history and alternative suppliers are documented for component X?”&lt;/em&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Global Search&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Synthesizes high-level community reports generated across the Leiden partition hierarchy in parallel. &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;em&gt;“What are the top strategic operational risks documented across our entire enterprise this quarter?”&lt;/em&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;the-stepping-stone-to-agi-from-associative-memory-to-world-models&quot;&gt;The Stepping Stone to AGI: From Associative Memory to World Models&lt;/h4&gt;
&lt;p&gt;In our study of &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/alan_turing/&quot;&gt;Alan Turing&lt;/a&gt;, we reflected on how genuine cognition cannot be reduced to the statistical mimicry of adjacent tokens.&lt;/p&gt;
&lt;p&gt;Today's Large Language Models are marvels of &lt;strong&gt;associative pattern completion&lt;/strong&gt;, but they lack an intrinsic, verifiable &lt;strong&gt;world model&lt;/strong&gt;. When a model hallucinates, it does so because it completes probabilistic token sequences unconstrained by a ground truth of hard relational facts.&lt;/p&gt;
&lt;p&gt;GraphRAG represents a pivotal leap toward &lt;strong&gt;Artificial General Intelligence (AGI)&lt;/strong&gt; by functioning as the system's structured hippocampus and associative cortex:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Neuro-Symbolic Fusion&lt;/strong&gt;: It unites the flexible language mastery of deep neural networks with the deterministic, auditable rigor of graph theory.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Elimination of Relational Hallucinations&lt;/strong&gt;: If the knowledge graph specifies that Component A connects to Subsystem B which relies on Supplier C, an AI agent navigates that path with absolute mathematical fidelity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Native Regulatory Auditability&lt;/strong&gt;: Every synthesized claim can be mapped back to concrete edges, nodes, and source documents, satisfying the rigorous explainability and data governance mandates enforced by the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/eu_ai_act/&quot;&gt;EU AI Act&lt;/a&gt; (Article 13).&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;industrial-application-from-bill-of-materials-to-enterprise-operations&quot;&gt;Industrial Application: From Bill of Materials to Enterprise Operations&lt;/h4&gt;
&lt;p&gt;At Datalaria, we experience the transformative power of this approach firsthand. In our &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/obs_part5_radar/&quot;&gt;Obsolescence Radar series&lt;/a&gt;, we engineered autonomous systems to audit complex industrial Bills of Materials (BOM).&lt;/p&gt;
&lt;p&gt;A bill of materials is not an unstructured document; it is a &lt;strong&gt;Directed Acyclic Graph (DAG)&lt;/strong&gt;. Determining whether an obsolete microchip halts the assembly of a satellite or an electric vehicle cannot be resolved by vector similarity; it demands &lt;strong&gt;deterministic traversal across hierarchical assembly graphs&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;By arming autonomous agents (&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part1/&quot;&gt;CrewAI&lt;/a&gt;) and &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/mcp_protocol/&quot;&gt;Model Context Protocol (MCP)&lt;/a&gt; servers with GraphRAG architectures, AI transcends conversational assistants to become a &lt;strong&gt;resilient operational diagnostic engine&lt;/strong&gt; capable of tracing ripple effects across global enterprise operations.&lt;/p&gt;
&lt;h4 id=&quot;conclusion&quot;&gt;Conclusion&lt;/h4&gt;
&lt;p&gt;Vector embeddings taught artificial intelligence how to locate isolated data points across the digital expanse. Knowledge graphs teach it &lt;strong&gt;how those points interlock to build genuine understanding&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The future of production AI architecture does not require discarding vector search; it calls for orchestrating hybrid systems where vectors provide rapid semantic intuition, while knowledge graphs provide structure, multi-hop reasoning, and immutable truth.&lt;/p&gt;
&lt;p&gt;If your ambition is to build AI architectures that do not merely recite text, but genuinely reason across your organization's complex reality, the path forward is clear: &lt;strong&gt;stop treating enterprise data as a cloud of blind points and start treating it as the living graph it truly is&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h5 id=&quot;sources-of-interest-&quot;&gt;Sources of Interest:&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.microsoft.com/en-us/research/project/graphrag/&quot;&gt;&lt;strong&gt;Microsoft Research&lt;/strong&gt;: Project GraphRAG — Unlocking LLM Discovery on Complex Data&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2404.16130&quot;&gt;&lt;strong&gt;arXiv (2024)&lt;/strong&gt;: From Local to Global — A Graph RAG Approach to Query-Focused Summarization&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.youtube.com/watch?v=c5qJHr3DnT4&quot;&gt;&lt;strong&gt;YouTube&lt;/strong&gt;: GraphRAG Methods for Optimized LLM Context Windows (Jonathan Larson)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/microsoft/graphrag&quot;&gt;&lt;strong&gt;GitHub&lt;/strong&gt;: Microsoft GraphRAG Official Repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/rag_antipatterns/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: RAG in Production — 7 Anti-Patterns That Destroy Precision&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/pgvector_vs_vectordb/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: PostgreSQL with pgvector vs Dedicated Vector DBs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/alan_turing/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: Alan Turing — The Genius Who Asked if Machines Could Think&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/obs_part5_radar/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: Obsolescence Radar with BOM Component Graphs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/mcp_protocol/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: MCP Protocol — The Connection Standard for AI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/eu_ai_act/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: EU AI Act — Practical Guide to Governance and Explainability&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28581/graphrag-why-vectors-arent-enough-and-your-ai-needs-a-knowledge-graph</guid>
<pubDate>Fri, 25 Sep 2026 06:22:06 +0000</pubDate>
</item>
<item>
<title>FreshContext: inspecting context before an agent uses it</title>
<link>https://coderlegion.com/28440/freshcontext-inspecting-context-before-an-agent-uses-it</link>
<description>&lt;p&gt;The passage an agent retrieves can be relevant and still unsuitable for the job in front of it. An old document may be useful background. A recent-looking record may have no traceable source. A failed request should not become evidence just because its error text matched a query.&lt;/p&gt;
&lt;p&gt;FreshContext evaluates caller-provided context using source and intent profiles. It keeps the decision, reasons and handoff status visible instead of treating one rank score as a complete answer. It does not authenticate the underlying facts.&lt;/p&gt;
&lt;h3 id=&quot;five-cases-one-important-distinction&quot;&gt;Five cases, one important distinction&lt;/h3&gt;
&lt;p&gt;In a fixed-time, five-record synthetic run using Core 0.5.2, the results were:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Input &lt;/th&gt;&lt;th&gt;Decision &lt;/th&gt;&lt;th&gt;Handoff&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Recent, dated record &lt;/td&gt;&lt;td&gt;Use first &lt;/td&gt;&lt;td&gt;Allowed with label&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Historical record &lt;/td&gt;&lt;td&gt;Use as background &lt;/td&gt;&lt;td&gt;Allowed with label&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Undated record &lt;/td&gt;&lt;td&gt;Needs verification &lt;/td&gt;&lt;td&gt;Review required&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Failed source &lt;/td&gt;&lt;td&gt;Exclude &lt;/td&gt;&lt;td&gt;Review required&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Recent record with source recorded as unknown &lt;/td&gt;&lt;td&gt;Use first &lt;/td&gt;&lt;td&gt;Review required&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That last row matters. Looking only at the decision label would hide the handoff restriction. Integration should inspect the whole result, preserve warnings and test the caller's behavior when context is incomplete. The label itself is not an enforcement mechanism.&lt;/p&gt;
&lt;p&gt;A second synthetic input supplies a newly established publication date for the undated record. The changed record is identified for renewed review. That is a simulated evidence update, not permission to invent a date until a score improves.&lt;/p&gt;
&lt;h3 id=&quot;a-captured-result-is-not-a-live-source-check&quot;&gt;A captured result is not a live source check&lt;/h3&gt;
&lt;p&gt;This Core version's freshness is anchored at retrieval. A saved result is not a fresh check of the source whenever someone opens the report. The assessment runner records retrieval age separately so that distinction remains visible.&lt;/p&gt;
&lt;p&gt;The public &lt;a rel=&quot;nofollow&quot; href=&quot;https://freshcontext.dev/context-integrity-demo?utm_source=coderlegion&quot;&gt;FreshContext demo&lt;/a&gt; currently explains the mechanism with a static example and video links. The five-record run above is a separate local synthetic verification, not a customer case study or a live public console. The &lt;a rel=&quot;nofollow&quot; href=&quot;https://freshcontext.dev/sample-assessment&quot;&gt;sample assessment&lt;/a&gt; shows a bounded engagement structure and is explicitly fictional.&lt;/p&gt;
&lt;h3 id=&quot;from-demonstration-to-a-bounded-assessment&quot;&gt;From demonstration to a bounded assessment&lt;/h3&gt;
&lt;p&gt;The useful question is whether a specific workflow preserves uncertainty, makes the treatment inspectable, and behaves correctly when context is missing or unusable.&lt;/p&gt;
&lt;p&gt;For teams exploring that question, the &lt;a rel=&quot;nofollow&quot; href=&quot;https://freshcontext.dev/services?utm_source=coderlegion&quot;&gt;Context Integrity Assessment&lt;/a&gt; is a scoped service: a workflow risk map, bounded sample evaluation, fit recommendation and technical walkthrough. Scope and price are agreed before work; an enquiry is not a contract or payment. The current request path prepares an email for the sender to review and send.&lt;/p&gt;
&lt;p&gt;Disclosure: this article was generated by a coding agent from agent-executed synthetic tests. It does not claim independent human review, customer outcomes, legal certification or measured answer-quality gains.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28440/freshcontext-inspecting-context-before-an-agent-uses-it</guid>
<pubDate>Thu, 24 Sep 2026 08:07:13 +0000</pubDate>
</item>
<item>
<title>I Wanted a RAG System That Could Show Its Work, So I Built Alchimista</title>
<link>https://coderlegion.com/28173/i-wanted-a-rag-system-that-could-show-its-work-so-i-built-alchimista</link>
<description>&lt;p&gt;Most RAG demos stop at the answer.&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Daniele-Cangi/Alchimista&quot;&gt;Alchimista&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You upload a document, ask a question, get a plausible response, maybe see a few source chunks, and that's where the story ends.&lt;/p&gt;
&lt;p&gt;But I kept coming back to a different problem:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What happens when the answer itself isn't enough?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;What if I need to know which evidence was used, what happened to sensitive data before processing, which policy was active, whether two tenants could ever see each other's documents, and what the system actually did rather than what its architecture diagram claims it should do?&lt;/p&gt;
&lt;p&gt;That's where &lt;strong&gt;Alchimista&lt;/strong&gt; started.&lt;/p&gt;
&lt;h3 id=&quot;the-idea&quot;&gt;The idea&lt;/h3&gt;
&lt;p&gt;Alchimista is a self-hosted document intelligence and AI governance system built around a fairly simple principle:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;An AI answer should leave evidence behind.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The basic path looks like this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;document → privacy processing → ingestion → retrieval → cited answer → decision evidence → audit&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;That sounds straightforward. Making all of those boundaries agree with each other was considerably less straightforward.&lt;/p&gt;
&lt;h3 id=&quot;rag-wasn-t-the-difficult-part&quot;&gt;RAG wasn't the difficult part&lt;/h3&gt;
&lt;p&gt;Retrieving chunks and generating an answer is only one piece of the system.&lt;/p&gt;
&lt;p&gt;The more interesting questions appeared around it.&lt;/p&gt;
&lt;p&gt;A document belongs to a tenant. Retrieval has to respect that boundary.&lt;/p&gt;
&lt;p&gt;An answer needs citations. Those citations need to point back to actual indexed evidence.&lt;/p&gt;
&lt;p&gt;Sensitive information may need to be detected or pseudonymized before it reaches an external model.&lt;/p&gt;
&lt;p&gt;If privacy processing fails, silently continuing with the original text is not an acceptable fallback.&lt;/p&gt;
&lt;p&gt;And if the system records an AI decision, the audit trail shouldn't accidentally become another place where sensitive data leaks.&lt;/p&gt;
&lt;p&gt;So Alchimista gradually became less of a &quot;RAG application&quot; and more of an infrastructure layer around the complete evidence path.&lt;/p&gt;
&lt;h3 id=&quot;privacy-as-part-of-the-pipeline&quot;&gt;Privacy as part of the pipeline&lt;/h3&gt;
&lt;p&gt;Alchimista currently has several privacy modes.&lt;/p&gt;
&lt;p&gt;At the strictest level, sensitive values are pseudonymized &lt;strong&gt;before chunking and embedding&lt;/strong&gt;, meaning the normal retrieval index never receives their cleartext representation.&lt;/p&gt;
&lt;p&gt;Reversible mappings live separately in an AES-256-GCM encrypted vault.&lt;/p&gt;
&lt;p&gt;More importantly, enabled privacy policies fail closed. If the privacy service is unavailable or produces an invalid result, processing stops instead of quietly bypassing the protection.&lt;/p&gt;
&lt;p&gt;That behavior matters more to me than adding another checkbox labelled &quot;privacy&quot;.&lt;/p&gt;
&lt;h3 id=&quot;local-first&quot;&gt;Local first&lt;/h3&gt;
&lt;p&gt;An earlier version of the project leaned heavily on cloud infrastructure.&lt;/p&gt;
&lt;p&gt;That worked, but eventually I wanted the core system to be independently runnable and inspectable.&lt;/p&gt;
&lt;p&gt;The current baseline needs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Docker with Compose&lt;/li&gt;
&lt;li&gt;Python 3.11+&lt;/li&gt;
&lt;li&gt;PostgreSQL&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No GCP project, external vector database, Auth0 tenant, object store, or paid service is required.&lt;/p&gt;
&lt;p&gt;The cloud integrations still exist as optional adapters, but they are no longer prerequisites for proving that the system works.&lt;/p&gt;
&lt;p&gt;Starting it locally is intentionally boring:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git clone https://github.com/Daniele-Cangi/Alchimista.git
cd Alchimista
python scripts/init_local_env.py
docker compose up --detach --build --wait
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the application is available locally with Documents, Ask, Privacy, Audit, Governance, and System in the same interface.&lt;/p&gt;
&lt;h3 id=&quot;i-wanted-a-proof-not-just-a-demo&quot;&gt;I wanted a proof, not just a demo&lt;/h3&gt;
&lt;p&gt;One thing I've become increasingly skeptical of in my own projects is a green UI that proves very little about the system underneath it.&lt;/p&gt;
&lt;p&gt;So Alchimista has an end-to-end self-hosted smoke test.&lt;/p&gt;
&lt;p&gt;It doesn't just check that the containers started.&lt;/p&gt;
&lt;p&gt;It exercises ingestion, privacy detection, pseudonymization and restoration, protected persistence, SQL retrieval, citations, tenant isolation, AI decision evidence, audit metadata, the encrypted vault, cleanup behavior, and persistence across a PostgreSQL restart.&lt;/p&gt;
&lt;p&gt;The successful end state is deliberately unexciting:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;SELF_HOSTED_SMOKE_OK
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But that line means much more to me than a screenshot of a chatbot answering a document question.&lt;/p&gt;
&lt;h3 id=&quot;some-deliberate-limitations&quot;&gt;Some deliberate limitations&lt;/h3&gt;
&lt;p&gt;There are also things I don't want Alchimista to pretend.&lt;/p&gt;
&lt;p&gt;It implements technical controls; it does &lt;strong&gt;not&lt;/strong&gt; magically certify GDPR, the EU AI Act, or any other regulation.&lt;/p&gt;
&lt;p&gt;PII detection cannot promise to identify every possible sensitive value.&lt;/p&gt;
&lt;p&gt;The default local embedding path is designed to make the complete system independently runnable and test retrieval mechanics. It isn't a claim that a deterministic offline embedder is the best semantic retrieval system available.&lt;/p&gt;
&lt;p&gt;And stronger pseudonymization can affect retrieval quality, which means strict privacy modes have to be evaluated against the actual domain rather than assumed to be free.&lt;/p&gt;
&lt;p&gt;I think being explicit about those boundaries makes the system more useful, not less.&lt;/p&gt;
&lt;h3 id=&quot;what-s-next&quot;&gt;What's next&lt;/h3&gt;
&lt;p&gt;Alchimista is now at the point where I'm less interested in adding features for the sake of feature count and more interested in attacking the boundaries.&lt;/p&gt;
&lt;p&gt;What breaks under larger document collections?&lt;/p&gt;
&lt;p&gt;How much retrieval quality is lost under different privacy strategies?&lt;/p&gt;
&lt;p&gt;Which pieces of AI decision evidence are genuinely useful during an investigation months later?&lt;/p&gt;
&lt;p&gt;Where should the trust boundary actually sit when an external model is introduced?&lt;/p&gt;
&lt;p&gt;Those are the experiments I'm interested in next.&lt;/p&gt;
&lt;p&gt;This is also my first proper post on DEV, so I'll probably use this space less as a release feed and more as a place to document those experiments — including the ones that don't work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alchimista is open source and available on GitHub.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Feedback on the architecture, privacy model, or places you'd try to break it is very welcome.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28173/i-wanted-a-rag-system-that-could-show-its-work-so-i-built-alchimista</guid>
<pubDate>Tue, 22 Sep 2026 13:05:47 +0000</pubDate>
</item>
<item>
<title>From a Test-Suite Trace to a Search Signal: the Bootstrap Pipeline Story</title>
<link>https://coderlegion.com/28140/from-a-test-suite-trace-to-a-search-signal-the-bootstrap-pipeline-story</link>
<description>&lt;hr&gt;
&lt;h3 id=&quot;where-it-started&quot;&gt;Where It Started&lt;/h3&gt;
&lt;p&gt;The problem to which everything traces back: &lt;strong&gt;RAG search across an unfamiliar codebase sees the code, but misses the intent.&lt;/strong&gt; Embeddings find a chunk by keywords, PropertyGraph finds a symbol by name, but to the question &quot;where is the real business logic here, and what can I safely throw away?&quot; no model answers — because the answer simply doesn't exist in static code representation.&lt;/p&gt;
&lt;p&gt;Hence the &lt;strong&gt;bootstrap pipeline&lt;/strong&gt; was born for indexing a new project across 4 steps:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Entities&lt;/strong&gt; — types and data-classes as the core domain backbone;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Entry points&lt;/strong&gt; — decorators (&lt;code&gt;@mcp_app.tool&lt;/code&gt;) as the outer system boundary;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tests as ground truth&lt;/strong&gt; — tests as the only deterministic way to say &lt;em&gt;&quot;this function is part of live execution logic&quot;&lt;/em&gt;;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Git → ADR&lt;/strong&gt; — history of decisions extracted from commit logs.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Step 3 was the main battleground. The argument wasn't &lt;em&gt;&quot;are tests needed&quot;&lt;/em&gt;, but &lt;strong&gt;how to link a test to the exact function it actually executes&lt;/strong&gt;. Steps 1 and 2 relied on clean static analysis, whereas step 3 lacked an obvious static answer.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;first-clash-static-vs-dynamic-exp-7-&quot;&gt;First Clash: Static vs Dynamic (Exp 7)&lt;/h3&gt;
&lt;p&gt;Initial intuition: &quot;A test has a name, a function has a name, let's link by name.&quot;&lt;br&gt;
Measurement killed it instantly: &lt;strong&gt;0 out of 109&lt;/strong&gt; tests in the evaluation sample named the function they actually executed. Import-only (file-level match) gave 77.9% — but file level is coarse noise (a single file contains dozens of functions).&lt;/p&gt;
&lt;p&gt;We executed the entire suite (1,727 tests) using a custom &lt;code&gt;sys.settrace&lt;/code&gt; plugin:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;[dynamic_trace] total tests traced: 1727
[dynamic_trace] tests executing &amp;gt;=1 src function: 1551 (89.8%)
[dynamic_trace] unique src functions executed: 1212
[dynamic_trace] avg src functions per linked test: 10.1 (median 6, range 1-118)
tests with exact-name target hit in dynamic set: 47 (2.7%)
A/B same session: 174.8s vs 198.6s -&amp;gt; overhead +13.6%
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Takeaway: Dynamic tracing is the only deterministic linker, at the cost of a +13.6% one-time execution overhead.&lt;/strong&gt; And an uncomfortable truth surfaced immediately: a single test executes &lt;strong&gt;10.1 functions&lt;/strong&gt; on average. &quot;1 test = 1 function&quot; was a naive myth. Thus, edges required a &lt;strong&gt;ranker&lt;/strong&gt;: which of the 10 is the primary target?&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;ranking-failed-exp-7b-tarantula-&quot;&gt;Ranking Failed (Exp 7b, Tarantula)&lt;/h3&gt;
&lt;p&gt;We tested the Tarantula heuristic (&quot;a function called infrequently by many tests is the primary target&quot;).&lt;br&gt;
&lt;em&gt;Hypothesis:&lt;/em&gt; ≥60-70% of tests have an unambiguous candidate at rank≤3.&lt;br&gt;
&lt;em&gt;Reality:&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;rank≤3 applied to only &lt;strong&gt;22.6%&lt;/strong&gt; of tests (7.5% rank=1) — &lt;strong&gt;HYPOTHESIS REFUTED&lt;/strong&gt;;&lt;/li&gt;
&lt;li&gt;However, &lt;strong&gt;precision was high&lt;/strong&gt;: manually inspected candidates at rank=1..3 were all accurate targets;&lt;/li&gt;
&lt;li&gt;The main offender: &lt;strong&gt;shared utilities&lt;/strong&gt; like &lt;code&gt;safe_mkdir&lt;/code&gt; / &lt;code&gt;get_data_root&lt;/code&gt; (autouse fixtures, 234 callers each) and &lt;code&gt;project_hash&lt;/code&gt; (223 callers).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Tarantula is unsuitable for &lt;em&gt;selecting&lt;/em&gt; the main target, but works well as a &lt;em&gt;confidence annotation&lt;/em&gt; for ~16% of tests. &lt;code&gt;TESTS&lt;/code&gt; edges are thus built from the complete trace without truncation — they are correct by construction and do not require lossy ranking.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;driver-switch-failed-exp-8-sysmon-&quot;&gt;Driver Switch Failed (Exp 8, sysmon)&lt;/h3&gt;
&lt;p&gt;Could &lt;code&gt;coverage run&lt;/code&gt; (Python 3.14, &lt;code&gt;sys.monitoring&lt;/code&gt;) run faster than our custom &lt;code&gt;sys.settrace&lt;/code&gt; plugin?&lt;br&gt;
&lt;em&gt;Hypothesis:&lt;/em&gt; Overhead &amp;lt;5%.&lt;br&gt;
&lt;em&gt;Measurement:&lt;/em&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;baseline: 184.88s | coverage: 221.78s -&amp;gt; overhead +19.96% (target &amp;lt;5% REFUTED)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;coverage.py&lt;/code&gt; was &lt;strong&gt;~1.5x slower&lt;/strong&gt; than our lightweight plugin. &lt;code&gt;sys.monitoring&lt;/code&gt; remains a validation oracle for spot-checks, while &lt;code&gt;sys.settrace&lt;/code&gt; stays as the main execution driver.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;static-companion-not-a-replacement-exp-9-&quot;&gt;Static Companion — Not a Replacement (Exp 9)&lt;/h3&gt;
&lt;p&gt;We evaluated a full static score (AST L1 calls / L2 name tokens / L3 imports) against dynamic trace as ground truth.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 1em 0;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #ddd; padding: 8px; background-color: #4a90e2; color: white; text-align: left;&quot;&gt;Signal&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #ddd; padding: 8px; background-color: #4a90e2; color: white; text-align: left;&quot;&gt;Hit&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #ddd; padding: 8px; background-color: #4a90e2; color: white; text-align: left;&quot;&gt;Recall&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #ddd; padding: 8px; background-color: #4a90e2; color: white; text-align: left;&quot;&gt;Precision&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #ddd; padding: 8px; background-color: #4a90e2; color: white; text-align: left;&quot;&gt;Mean Candidates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;strong&gt;L1 (direct calls from test body)&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;88.4%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;30.3%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;68.0%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;2.9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;strong&gt;L2 (name tokens)&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;17.7%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;3.8%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;12.1%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;strong&gt;L3 (file imports)&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;91.6%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;72.0%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;21.8%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;41.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;strong&gt;Union (L1 + L2 + L3)&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;90.4%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;70.0%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;20.6%&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The initial assumption (&lt;em&gt;recall ≤ 30%&lt;/em&gt;) was wrong: &lt;strong&gt;union recall reached 70%&lt;/strong&gt;. Static signals were stronger than anticipated, but as a precise anchor L1 is narrow (precision 68%, 2.9 candidates), and as a wide net L3 is noisy (41.4 candidates).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Dynamic trace remains the edge driver; static analysis acts as a companion layer and candidate supplier for mock tests (which execute 0 real target functions dynamically).&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;portability-across-external-codebases-exp-16-&quot;&gt;Portability Across External Codebases (Exp 16)&lt;/h3&gt;
&lt;p&gt;Does this generalize beyond our own repo? We ran the tracer against external projects:&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 1em 0;&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style=&quot;border: 1px solid #ddd; padding: 8px; background-color: #4a90e2; color: white; text-align: left;&quot;&gt;Project&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #ddd; padding: 8px; background-color: #4a90e2; color: white; text-align: left;&quot;&gt;Language&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #ddd; padding: 8px; background-color: #4a90e2; color: white; text-align: left;&quot;&gt;Tests&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #ddd; padding: 8px; background-color: #4a90e2; color: white; text-align: left;&quot;&gt;Linked %&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #ddd; padding: 8px; background-color: #4a90e2; color: white; text-align: left;&quot;&gt;Overhead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;strong&gt;gemma_agent&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;Python&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;2882 (2874 pass)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;strong&gt;97.3%&lt;/strong&gt; (2805)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;strong&gt;+17.4%&lt;/strong&gt; (71.4s vs 60.8s)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;strong&gt;commit-&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;Python&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;27&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;strong&gt;100%&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;strong&gt;codebase-memory-mcp&lt;/strong&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;Go&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;27 test funcs&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;—&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #ddd; padding: 8px;&quot;&gt;&lt;code&gt;go test&lt;/code&gt;: 51.0% pkg / &lt;strong&gt;22.2% per-test&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Linked % on clean (non-mocked) third-party projects proved &lt;strong&gt;higher&lt;/strong&gt; than on our own codebase (which relies heavily on mocks). The limitation is honest: dynamic execution is currently &lt;strong&gt;Python-only&lt;/strong&gt;; Go and TS require per-test tooling (e.g., &lt;code&gt;go test -coverprofile&lt;/code&gt; across N executions). PropertyGraph itself is polyglot, but the edge builder remains Python-first.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;and-now-edges-met-the-consumer-e17-&quot;&gt;And Now: Edges Met the Consumer (E17)&lt;/h3&gt;
&lt;p&gt;Everything prior built &lt;code&gt;test ──TESTS──&amp;gt; function&lt;/code&gt; edges inside PropertyGraph without leveraging them during search. E17 closed the loop:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Data:&lt;/strong&gt; 1,727 tests → &lt;strong&gt;16,172 TESTS edges&lt;/strong&gt;, 1,595 Test nodes, 1,132 covered functions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Implementation:&lt;/strong&gt; &lt;code&gt;SymbolIndexAdapter.get_tests_for_symbol()&lt;/code&gt; (incoming &lt;code&gt;TESTS&lt;/code&gt; edges) + &lt;code&gt;Searcher._append_tests_signal()&lt;/code&gt;: appends up to 3 tests per function (capped at &lt;code&gt;min(len, 6)&lt;/code&gt; per query), &lt;code&gt;graph_score = 0.4&lt;/code&gt; vs 1.0 for definitions, sentinel &lt;code&gt;chunk_index = -(20_000_000 + line)&lt;/code&gt; avoiding collisions with code chunks in RRF ranking.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Toggle:&lt;/strong&gt; &lt;code&gt;MSCODEBASE_TESTS_SIGNAL&lt;/code&gt;, &lt;strong&gt;off by default&lt;/strong&gt; — production behavior remains bit-for-bit untouched.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A/B evaluation on a live PropertyGraph (7 target functions, true answers from trace):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;hit@1: off=7/7, on=7/7 | hit@3: 7/7 | MRR(function): off=1.000, on=1.000
TESTS-signal: 6/7 queries received relevant covering tests in the response
graph_stage avg dt: off=3.43ms, on=3.27ms (within noise floor)
RETRACTION: 0 broken links (all files verified on disk)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The core invariant holds — &lt;strong&gt;function definitions are never displaced by test results&lt;/strong&gt; (&lt;code&gt;MRR = 1.0&lt;/code&gt; in both arms): tests follow strictly as secondary context.&lt;/p&gt;
&lt;h4 id=&quot;wide-panel-35-queries-functions-with-most-tests-edges-&quot;&gt;Wide Panel (35 queries, functions with most TESTS edges)&lt;/h4&gt;
&lt;p&gt;To validate beyond the narrow 7-query panel, we ran a wide panel of 35 identifier queries (functions with the most TESTS edges):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;hit@1: off=33/35 (94.3%), on=33/35 (94.3%)
hit@3: off=34/35 (97.1%), on=34/35 (97.1%)
MRR(function): off=0.957, on=0.957
TESTS-signal: 34/35 queries received new covering tests (97.1%)
graph_stage avg dt: off=6.52ms, on=7.53ms (overhead +15.3%)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Critical finding:&lt;/strong&gt; TESTS-signal &lt;strong&gt;does not improve hit@1&lt;/strong&gt; (off=on). It only &lt;strong&gt;adds context&lt;/strong&gt; (tests) to already-found results: 97.1% of queries received new covering tests. This means TESTS-signal is &lt;strong&gt;context for LLM&lt;/strong&gt;, not a search improvement. If LLM doesn't use tests, the signal is useless.&lt;/p&gt;
&lt;h4 id=&quot;language-coverage-critical-limitation-&quot;&gt;Language Coverage (Critical Limitation)&lt;/h4&gt;
&lt;p&gt;TESTS-signal works &lt;strong&gt;only for Python&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Python:&lt;/strong&gt; 34.0% of functions covered by TESTS edges (1,108/3,256)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Other (Go, Rust, etc.):&lt;/strong&gt; 0% (716 functions without TESTS edges)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript:&lt;/strong&gt; 0% (11 functions without TESTS edges)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For non-Python projects, TESTS-signal &lt;strong&gt;does not work at all&lt;/strong&gt;. Dynamic trace (pytest + sys.settrace) collects edges only for Python. For Go/TS, a separate connector is needed (go test -coverprofile, Jest coverage), but this &lt;strong&gt;is not done&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;red-team-5-5-attacks-repelled&quot;&gt;Red Team: 5/5 Attacks Repelled&lt;/h4&gt;
&lt;p&gt;We tested TESTS-signal against 5 attack vectors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Concurrency:&lt;/strong&gt; 10 threads × 100 calls = 1,000 calls in 17.2s, 0 errors&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Boundaries:&lt;/strong&gt; function with 234 tests = 16.11ms (acceptable)&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Abuse:&lt;/strong&gt; query for nonexistent function = 0 results (graceful degradation)&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;TOCTOU:&lt;/strong&gt; graph closed between calls = graceful degradation&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Dependency failure:&lt;/strong&gt; PropertyGraph with nonexistent path = 0 results (graceful degradation)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Conclusion:&lt;/strong&gt; TESTS-signal is resilient to concurrency, boundaries, abuse, TOCTOU, and dependency failures.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-could-go-wrong-1&quot;&gt;What Could Go Wrong&lt;/h3&gt;
&lt;p&gt;A transparent list of risks and open validation items. (Full numbers for the A/B and red-team results are in the E17 section above — this list stays to the takeaways and the fixes.)&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Evaluation scope.&lt;/strong&gt; While expanded to a 35-query panel, evaluation is still performed on a single primary codebase without deep reranker interaction.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A/B did not improve hit@1.&lt;/strong&gt; See &quot;Wide Panel&quot; above — TESTS-signal &lt;strong&gt;does not help find the function&lt;/strong&gt;. It only &lt;strong&gt;adds context&lt;/strong&gt; to already-found results.&lt;br&gt;
→ &lt;strong&gt;Conclusion:&lt;/strong&gt; TESTS-signal is &lt;strong&gt;context for LLM&lt;/strong&gt;, not a search improvement.&lt;br&gt;
→ &lt;strong&gt;Risk:&lt;/strong&gt; if LLM doesn't use tests, the signal is useless.&lt;br&gt;
→ &lt;strong&gt;Fix:&lt;/strong&gt; verify on real LLM pipeline (not in this experiment).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Overhead +15.3% for wide panel&lt;/strong&gt; (6.52ms → 7.53ms avg graph_stage time). For bootstrap (one-time run) this is acceptable. For prod search — may be critical with many queries.&lt;br&gt;
→ &lt;strong&gt;Risk:&lt;/strong&gt; at 1000 queries/sec, overhead may be noticeable.&lt;br&gt;
→ &lt;strong&gt;Fix:&lt;/strong&gt; cache TESTS-signal (not done).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Language limitation.&lt;/strong&gt; Dynamic trace is currently Python-only (~34% function coverage in Python, 0% in JS/TS/Go).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;graph_score = 0.4&lt;/code&gt; is an empirical constant.&lt;/strong&gt; Chosen to stay strictly below function definitions, but unverified against BM25/reranker weight interactions.&lt;br&gt;
→ Verify on full pipeline; constant may become a parameter.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pointer &lt;code&gt;:0&lt;/code&gt;.&lt;/strong&gt; Test nodes from dynamic trace lack line numbers (&lt;code&gt;line=0&lt;/code&gt;). Indexers must resolve test decorator line positions before enabling in prod.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Hub-function noise.&lt;/strong&gt; &lt;code&gt;safe_mkdir&lt;/code&gt; linked to 234 tests yields low-signal noise. The cap of 3 tests prevents payload flooding but doesn't solve irrelevance.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dependence on a green test suite.&lt;/strong&gt; On broken test suites, trace degrades (failing tests = missing edges). Bootstrap applies to stable branches only.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Mock tests are blind.&lt;/strong&gt; 10.2% of tests execute 0 src functions; static companions cover 88 of 176, but not all.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Graph reindex drift.&lt;/strong&gt; Rebuilding the PropertyGraph may alter node ordering slightly.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;CI scale limit.&lt;/strong&gt; Running trace on 100k+ test suites may breach CI execution windows.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Clean-state status.&lt;/strong&gt; Verification was executed locally; clean CI state verification requires a PR merge.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;acknowledgments-1&quot;&gt;Acknowledgments&lt;/h3&gt;
&lt;p&gt;A huge thank you to everyone who engages with these posts in the comments. Your feedback, real-world observations, counter-examples, and benchmark numbers directly shape these experiments. This kind of open technical critique is what keeps engineering honest.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-note-on-how-this-was-written-1&quot;&gt;A note on how this was written&lt;/h3&gt;
&lt;p&gt;Every experiment, bug, failure, and idea here is mine — I earned them the hard way, in production, in public. AI worked as my editor: it helped me structure thoughts and polish my English. It did not invent the facts, because it has none of its own.&lt;/p&gt;
&lt;p&gt;No AI detectors were consulted in the making of this disclosure. They have enough trouble agreeing on what I am.&lt;/p&gt;
&lt;hr&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Disclaimer &amp;amp; Status:&lt;/strong&gt; draft (source-material for the article). This is not a &quot;feature advertisement&quot;, but an honest engineering story: figures are reproducible, weak points are named, and unaddressed risks are listed in the &quot;What Could Go Wrong&quot; section.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/28140/from-a-test-suite-trace-to-a-search-signal-the-bootstrap-pipeline-story</guid>
<pubDate>Tue, 22 Sep 2026 10:27:33 +0000</pubDate>
</item>
<item>
<title>Knowledge Graph Evaluation: Scoring 12 Real Decisions</title>
<link>https://coderlegion.com/27367/knowledge-graph-evaluation-scoring-12-real-decisions</link>
<description>&lt;p&gt;Three weeks ago I started scoring my knowledge graph against the answer I would have reached without it. It earns its place: 6 of 12 decisions came out sharper, 2 errors never reached real work, and 73 minutes of research time saved.&lt;/p&gt;
&lt;h3 id=&quot;the-metric-that-cannot-fail&quot;&gt;The Metric That Cannot Fail&lt;/h3&gt;
&lt;p&gt;Retrieval told me none of that. 21 of 21 queries, 100% recall, 8s median. Recall and latency describe the index, not the work. No input makes them come back bad.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://renezander.com/diagrams/diagram-kg-floor-vs-value.png&quot; alt=&quot;Two dials: retrieval pinned at the top of a narrow 90 to 100 percent scale, decision quality reading 6 of 12 on a full one&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;freeze-your-answer-first&quot;&gt;Freeze Your Answer First&lt;/h3&gt;
&lt;p&gt;None of that score exists unless the old answer is written down first: the options, the criteria, the recommendation, a confidence number. Then query. Write the baseline afterwards and you rebuild a past self who conveniently agreed with whatever came back.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://renezander.com/diagrams/diagram-kg-frozen-baseline.png&quot; alt=&quot;A locked answer with a measurable gap to the post-read position, against a recalled answer that slides to meet it&quot;&gt;&lt;/p&gt;
&lt;p&gt;If your retrieval has never contradicted you, nobody has checked.&lt;/p&gt;
&lt;p&gt;What would it take for your own retrieval layer to return an answer that changes your mind, and would you be able to prove it did?&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;I write field notes from real builds: AI integration, automation, and the parts that break in production. New posts every two weeks. Use the &lt;a rel=&quot;nofollow&quot; href=&quot;https://renezander.com/rag-requirements/?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=knowledge-graph-evaluation&quot;&gt;RAG requirements template&lt;/a&gt; to audit the source, access and action boundaries in your own context layer.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/27367/knowledge-graph-evaluation-scoring-12-real-decisions</guid>
<pubDate>Wed, 16 Sep 2026 08:07:33 +0000</pubDate>
</item>
<item>
<title>25 Years in Tech Taught Me What AI Still Can't Replace — Helkyn Coello's Developer Journey</title>
<link>https://coderlegion.com/26566/25-years-in-tech-taught-me-what-ai-still-cant-replace-helkyn-coellos-developer-journey</link>
<description>&lt;h2 id=&quot;25-years-in-tech-taught-me-what-ai-still-can-t-replace-helkyn-coello-s-developer-journey&quot;&gt;25 Years in Tech Taught Me What AI Still Can't Replace — Helkyn Coello's Developer Journey&lt;/h2&gt;
&lt;p&gt;Technology has changed dramatically over the past 25 years.&lt;/p&gt;
&lt;p&gt;Desktop applications became client-server systems. Client-server systems became web platforms. And now, AI is changing how software is designed, built, tested, and operated.&lt;/p&gt;
&lt;p&gt;For &lt;strong&gt;Helkyn Coello&lt;/strong&gt;, that transformation hasn't happened from the sidelines.&lt;/p&gt;
&lt;p&gt;He has spent 25 years building enterprise software, leading technology teams, modernizing legacy systems, and navigating the transition from traditional software development to AI-native systems.&lt;/p&gt;
&lt;p&gt;Today, Helkyn is the &lt;strong&gt;Chief AI Officer at AccountTECH&lt;/strong&gt;, where he leads company-wide AI adoption across products, operations, and team capabilities. His work spans enterprise RAG, AI governance, agentic AI, software architecture, and the challenge of bringing AI into production without breaking systems that already work.&lt;/p&gt;
&lt;p&gt;His CoderLegion profile captures the philosophy behind that experience:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;The best AI in your stack is still the mental model in your head.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;We spoke with Helkyn about 25 years of technology change, modernizing enterprise systems, AI in production, RAG, agentic AI, vibe coding, governance, and what developers need to understand in an AI-first world.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;25-years-of-technology-change&quot;&gt;25 Years of Technology Change&lt;/h3&gt;
&lt;p&gt;Looking back across 25 years of technology, Helkyn says the biggest change isn't simply that software can now be built faster.&lt;/p&gt;
&lt;p&gt;It's the distance between having an idea and having something running.&lt;/p&gt;
&lt;p&gt;In the client-server era, trying something new could mean weeks or months of development, installing software on every machine, dealing with hardware configurations, and sometimes even preparing training for the people who would use it.&lt;/p&gt;
&lt;p&gt;Today, a competent person can build and deploy something in an afternoon.&lt;/p&gt;
&lt;p&gt;And that changes more than development speed.&lt;/p&gt;
&lt;p&gt;It changes which ideas are worth trying because &lt;strong&gt;being wrong has become cheaper&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;But Helkyn believes one thing hasn't changed nearly as much:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;Deciding what to build was and still is the hard part.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Requirements are still vague. Users often don't know exactly what they need until they see something that isn't quite right. Integrations with systems nobody wants to touch are still where projects can go wrong. Maintenance still consumes budgets that nobody planned for.&lt;/p&gt;
&lt;p&gt;AI doesn't eliminate those problems.&lt;/p&gt;
&lt;p&gt;Instead, it changes where the bottleneck is.&lt;/p&gt;
&lt;p&gt;Coding is no longer necessarily the bottleneck. Producing the right specifications and giving AI enough context to build something correctly has become increasingly important.&lt;/p&gt;
&lt;p&gt;Poorly documented requirements can send an AI model in completely the wrong direction. Detailed specifications can produce a much more robust result.&lt;/p&gt;
&lt;p&gt;And Helkyn sees a broader shift happening:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Non-coders are becoming software analysts and developers are becoming just reviewers, whether they like it or not.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;modernizing-legacy-systems-without-breaking-the-business&quot;&gt;Modernizing Legacy Systems Without Breaking the Business&lt;/h3&gt;
&lt;p&gt;Helkyn has personally experienced major technology migrations, including moving an enterprise system from an Access-based legacy platform to .NET and later transitioning from client-server architecture to a fully web-based system.&lt;/p&gt;
&lt;p&gt;The biggest lesson wasn't technical.&lt;/p&gt;
&lt;p&gt;It was:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Think about the customer first, even if that means sacrificing technical aspects.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A business cannot simply pause while its technology is being modernized.&lt;/p&gt;
&lt;p&gt;That's why Helkyn favors incremental migration over clean-cut replacement.&lt;/p&gt;
&lt;p&gt;Build capabilities in the new system gradually. Move customers across in chunks. Keep the old system running in parallel until the new system has earned customers' trust.&lt;/p&gt;
&lt;p&gt;And much of the difficult work happens behind the scenes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Data migration schedules&lt;/li&gt;
&lt;li&gt;Daily releases&lt;/li&gt;
&lt;li&gt;Keeping old and new systems synchronized&lt;/li&gt;
&lt;li&gt;Handling edge cases&lt;/li&gt;
&lt;li&gt;Repurposing custom legacy code&lt;/li&gt;
&lt;li&gt;Maintaining continuity for existing customers&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The reason is simple:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Customers must not feel any interruption.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That philosophy also influences how Helkyn approaches AI.&lt;/p&gt;
&lt;p&gt;When someone proposes replacing an existing process with an AI-based one, his first question is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What keeps running while the new system proves itself?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The second is:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How do we roll back when things go wrong?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If there is no way to reverse the change, Helkyn doesn't consider it modernization.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;It's a bet without plan B.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's why he prefers putting AI into systems that already work and &lt;strong&gt;augmenting them rather than rebuilding everything around AI&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-experience-matters-more-in-the-ai-era&quot;&gt;Why Experience Matters More in the AI Era&lt;/h3&gt;
&lt;p&gt;AI-generated applications can look finished.&lt;/p&gt;
&lt;p&gt;The code can look plausible. The architecture can look reasonable. A fifty-page specification can look professional.&lt;/p&gt;
&lt;p&gt;And that's exactly what makes AI so tricky.&lt;/p&gt;
&lt;p&gt;Helkyn experienced this himself when a Business Manager gave him a detailed specification generated with AI. It was beautifully organized and confidently written, but it contained gaps that were only obvious if you understood the domain deeply.&lt;/p&gt;
&lt;p&gt;The lesson was important:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;Plausibility used to be a decent proxy for competence. But it isn't anymore.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;An experienced engineer doesn't only ask:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&quot;Does it run?&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;They ask:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What happens with 10,000 records instead of 10?&lt;/li&gt;
&lt;li&gt;What happens when two requests arrive simultaneously?&lt;/li&gt;
&lt;li&gt;What happens when a third-party API fails at the worst possible moment?&lt;/li&gt;
&lt;li&gt;Who will maintain this eight months from now?&lt;/li&gt;
&lt;li&gt;Why was this approach chosen when the existing codebase already solves a similar problem?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those questions aren't necessarily visible in an impressive AI-generated demo.&lt;/p&gt;
&lt;p&gt;That's why Helkyn believes experience is becoming &lt;strong&gt;more valuable&lt;/strong&gt;, not less.&lt;/p&gt;
&lt;p&gt;AI can write code quickly and even produce strong architectures when given the right architectural requirements.&lt;/p&gt;
&lt;p&gt;But it doesn't automatically understand the history and domain knowledge of a business.&lt;/p&gt;
&lt;p&gt;A developer who understands that domain and knows how to use AI as leverage has a significant advantage.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;AI is becoming a commodity. Domain experience is what will differentiate developers.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;from-ai-experiments-to-production&quot;&gt;From AI Experiments to Production&lt;/h3&gt;
&lt;p&gt;There's a huge difference between an AI demo and production AI.&lt;/p&gt;
&lt;p&gt;Helkyn puts it simply:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;The demo only has to work once, for you, on the input you picked. Production has to work for everybody, on inputs nobody imagined, on a day when your model provider is having problems.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In production, non-determinism becomes a serious concern.&lt;/p&gt;
&lt;p&gt;In a demo, slightly different answers can feel like intelligence.&lt;/p&gt;
&lt;p&gt;In a system of record, they can look like bugs.&lt;/p&gt;
&lt;p&gt;So part of the architectural work is deciding &lt;strong&gt;which parts of a process are actually allowed to vary&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Then comes the less exciting work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Permissions&lt;/li&gt;
&lt;li&gt;Logging&lt;/li&gt;
&lt;li&gt;Cost control&lt;/li&gt;
&lt;li&gt;Failure handling&lt;/li&gt;
&lt;li&gt;API outages&lt;/li&gt;
&lt;li&gt;Unexpected model responses&lt;/li&gt;
&lt;li&gt;User experience when something goes wrong&lt;/li&gt;
&lt;li&gt;Traceability&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Traceability is particularly important.&lt;/p&gt;
&lt;p&gt;Helkyn has seen AI systems that worked beautifully on day one but couldn't answer basic questions about their own outputs:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How much did this answer cost?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who produced it?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What input produced it?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why was the answer produced?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Without traceability, production AI becomes difficult to understand, audit, and improve.&lt;/p&gt;
&lt;p&gt;And as organizations consider AI governance and regulatory requirements, that becomes even more important.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;production-over-pilots&quot;&gt;Production Over Pilots&lt;/h3&gt;
&lt;p&gt;Helkyn's approach favors &lt;strong&gt;production over pilots&lt;/strong&gt; and integrating AI into systems that already work rather than rebuilding everything from scratch.&lt;/p&gt;
&lt;p&gt;So why do companies struggle to make that transition?&lt;/p&gt;
&lt;p&gt;Because pilots and production systems are judged by completely different standards.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;A pilot is graded on whether it impressed someone in a room. Production is graded on whether it survives next week.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;A pilot can succeed through speed, novelty, and a controlled demonstration.&lt;/p&gt;
&lt;p&gt;Production has to survive:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Security reviews&lt;/li&gt;
&lt;li&gt;Permissions&lt;/li&gt;
&lt;li&gt;Error handling&lt;/li&gt;
&lt;li&gt;Support&lt;/li&gt;
&lt;li&gt;Real users&lt;/li&gt;
&lt;li&gt;Messy data&lt;/li&gt;
&lt;li&gt;Real operational conditions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Another problem is that pilots often avoid the hardest parts.&lt;/p&gt;
&lt;p&gt;A company chooses a clean use case with tidy data because it wants to demonstrate a result.&lt;/p&gt;
&lt;p&gt;But the process that would actually create business value might involve data spread across six different systems.&lt;/p&gt;
&lt;p&gt;Helkyn also highlights another failure point: &lt;strong&gt;feedback that goes nowhere&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;When users repeatedly provide feedback and nothing changes, they eventually stop reporting problems.&lt;/p&gt;
&lt;p&gt;Silence then gets mistaken for success.&lt;/p&gt;
&lt;p&gt;His approach is different:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Feedback should either result in a shipped change or an answer.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For moving from experiment to production, his advice is straightforward:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Choose a process that is genuinely painful and genuinely messy.&lt;/li&gt;
&lt;li&gt;Build it small but end-to-end.&lt;/li&gt;
&lt;li&gt;Include permissions and logging from day one.&lt;/li&gt;
&lt;li&gt;Put it in front of real users early.&lt;/li&gt;
&lt;li&gt;Watch what users actually do instead of only asking what they think.&lt;/li&gt;
&lt;li&gt;Define what failure looks like before starting.&lt;/li&gt;
&lt;li&gt;Keep the old process running until the new one earns the traffic.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;It isn't particularly glamorous.&lt;/p&gt;
&lt;p&gt;But that's often what production engineering looks like.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;building-an-enterprise-rag-system&quot;&gt;Building an Enterprise RAG System&lt;/h3&gt;
&lt;p&gt;One of Helkyn's current projects is an enterprise RAG knowledge system built around training material, documentation, and video transcripts.&lt;/p&gt;
&lt;p&gt;The problem wasn't a lack of knowledge.&lt;/p&gt;
&lt;p&gt;The knowledge already existed.&lt;/p&gt;
&lt;p&gt;It was simply scattered across formats that people couldn't search consistently.&lt;/p&gt;
&lt;p&gt;So employees would ask other employees for answers.&lt;/p&gt;
&lt;p&gt;That worked, but it didn't scale.&lt;/p&gt;
&lt;p&gt;The goal of the RAG system is to make that knowledge easier to find for employees and customers.&lt;/p&gt;
&lt;p&gt;But building enterprise RAG exposed challenges that aren't obvious in tutorials or demos.&lt;/p&gt;
&lt;p&gt;The biggest warning?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Permissions.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A RAG system can become an extremely efficient way of leaking information if different audiences have access to different documents.&lt;/p&gt;
&lt;p&gt;Internal information, customer information, and information belonging to different clients cannot simply be placed into one index and protected with a prompt.&lt;/p&gt;
&lt;p&gt;Access control needs to happen &lt;strong&gt;before the model sees the document&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;That's an architectural problem, not a prompt-engineering problem.&lt;/p&gt;
&lt;p&gt;Another major lesson was understanding what the documents actually represent.&lt;/p&gt;
&lt;p&gt;A training video, customer call, support ticket, internal conversation, and written article aren't equivalent pieces of information.&lt;/p&gt;
&lt;p&gt;They differ in:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reliability&lt;/li&gt;
&lt;li&gt;Freshness&lt;/li&gt;
&lt;li&gt;Audience&lt;/li&gt;
&lt;li&gt;Tone&lt;/li&gt;
&lt;li&gt;Context&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That means representation matters enormously.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;Retrieval isn't the answer, representation is.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The way information is indexed, chunked, categorized, and enriched with metadata can matter more than simply choosing a different model.&lt;/p&gt;
&lt;p&gt;Helkyn is also clear that this project is still &lt;strong&gt;work in progress&lt;/strong&gt;. The lessons are real, but final outcome metrics aren't available yet.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;where-rag-stops-being-enough&quot;&gt;Where RAG Stops Being Enough&lt;/h3&gt;
&lt;p&gt;RAG is powerful, but it has boundaries.&lt;/p&gt;
&lt;p&gt;At its core, RAG retrieves relevant information and gives it to a model.&lt;/p&gt;
&lt;p&gt;But not every problem is a retrieval problem.&lt;/p&gt;
&lt;p&gt;If someone asks:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;How many of these happened since March?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's a database query.&lt;/p&gt;
&lt;p&gt;The answer should come from actual data, such as SQL, rather than asking a language model to perform calculations from retrieved documents.&lt;/p&gt;
&lt;p&gt;Relationships create another challenge.&lt;/p&gt;
&lt;p&gt;A document might explain a customer configuration.&lt;/p&gt;
&lt;p&gt;Another might describe a support ticket.&lt;/p&gt;
&lt;p&gt;Another might describe a release.&lt;/p&gt;
&lt;p&gt;The interesting answer may depend on the relationship between all three.&lt;/p&gt;
&lt;p&gt;That's where relationship graphs can become useful.&lt;/p&gt;
&lt;h4 id=&quot;so-when-should-you-introduce-agents-&quot;&gt;So when should you introduce agents?&lt;/h4&gt;
&lt;p&gt;Helkyn's test is practical:&lt;/p&gt;
&lt;p&gt;An agent makes sense when a task genuinely requires a sequence of steps that cannot be known in advance.&lt;/p&gt;
&lt;p&gt;But there's a second condition that matters even more:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;If you wouldn't give an intern unsupervised write access to that system, your agent shouldn't have it either.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;He also challenges the assumption that every AI workflow needs to become &quot;agentic.&quot;&lt;/p&gt;
&lt;p&gt;If a process has clear inputs, clear outputs, and fixed logic, deterministic code may be the better solution.&lt;/p&gt;
&lt;p&gt;His preferred architecture is often:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;AI at the edges. Code at the core.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Let the model interpret messy input and explain results, while deterministic code handles processes with a single correct answer.&lt;/p&gt;
&lt;p&gt;And when agents are involved, Helkyn prefers a model where:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The agent proposes. A human confirms.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;At least until the pattern has earned enough trust.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;vibe-coding-won-t-kill-developers-it-ll-kill-the-middle-&quot;&gt;Vibe Coding Won't Kill Developers. It'll Kill the Middle.&lt;/h3&gt;
&lt;p&gt;Helkyn recently wrote about the idea that &lt;strong&gt;&quot;Vibe Coding Won't Kill Developers. It'll Kill the Middle.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For him, &quot;the middle&quot; isn't a statement about people's ability or intelligence.&lt;/p&gt;
&lt;p&gt;It's about the market.&lt;/p&gt;
&lt;p&gt;AI is increasingly capable of handling implementation against a known specification:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;CRUD applications&lt;/li&gt;
&lt;li&gt;Simple integrations&lt;/li&gt;
&lt;li&gt;Brochure websites&lt;/li&gt;
&lt;li&gt;Standard internal tools&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is precisely the kind of work AI can perform cheaply.&lt;/p&gt;
&lt;p&gt;Helkyn compares it to photography.&lt;/p&gt;
&lt;p&gt;Cheap cameras didn't eliminate professional photography.&lt;/p&gt;
&lt;p&gt;They eliminated part of the market while making the high end more valuable.&lt;/p&gt;
&lt;p&gt;The same pattern could emerge in software.&lt;/p&gt;
&lt;p&gt;The uncomfortable question is what happens to developers whose careers traditionally depended on implementation against well-defined requirements.&lt;/p&gt;
&lt;p&gt;The skills that become more valuable are different:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Architectural judgment&lt;/li&gt;
&lt;li&gt;Security&lt;/li&gt;
&lt;li&gt;Performance&lt;/li&gt;
&lt;li&gt;Understanding what &lt;strong&gt;not&lt;/strong&gt; to build&lt;/li&gt;
&lt;li&gt;Deep domain expertise&lt;/li&gt;
&lt;li&gt;Understanding production failure modes&lt;/li&gt;
&lt;li&gt;Knowing how to use AI without becoming dependent on it&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And Helkyn identifies one particularly difficult question:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;What I don't have a good answer for is how somebody starting today gets those twenty years, when the work that used to build them is the work being automated.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That's one of the biggest unresolved questions of the AI era.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;ai-governance-without-killing-innovation&quot;&gt;AI Governance Without Killing Innovation&lt;/h3&gt;
&lt;p&gt;Governance can easily become bureaucracy.&lt;/p&gt;
&lt;p&gt;Helkyn's approach is to avoid treating governance as a document people are expected to remember.&lt;/p&gt;
&lt;p&gt;Instead:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;Make it a property of the system instead of a policy about the system.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Helkyn explains that AccountTECH is US-based, so the EU AI Act isn't a legal obligation for the company. Instead, they use it voluntarily as a reference framework for thinking about defensible AI systems.&lt;/p&gt;
&lt;p&gt;One practical approach is to make AI usage part of the architecture itself.&lt;/p&gt;
&lt;p&gt;For example, instead of maintaining a separate spreadsheet asking where AI is being used, AI usage can be represented directly alongside the application's existing screen and permission structure.&lt;/p&gt;
&lt;p&gt;Then the inventory becomes a natural side effect of building the feature.&lt;/p&gt;
&lt;p&gt;The second principle is:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;Gate for promotion, not for exploration.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;People should be free to experiment.&lt;/p&gt;
&lt;p&gt;The friction should appear when an experiment is ready to become production.&lt;/p&gt;
&lt;p&gt;That's where review, permissions, logging, ownership, and other safeguards become mandatory.&lt;/p&gt;
&lt;p&gt;As Helkyn puts it:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;Put the friction where the risk actually appears, and nowhere else.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-mental-model-in-your-head&quot;&gt;The Mental Model in Your Head&lt;/h3&gt;
&lt;p&gt;Helkyn's CoderLegion profile contains a line that perhaps summarizes his entire approach to AI:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;The best AI in your stack is still the mental model in your head.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;What does that mean in practice?&lt;/p&gt;
&lt;p&gt;AI tools give developers answers.&lt;/p&gt;
&lt;p&gt;But an answer is only valuable if you can understand and judge it.&lt;/p&gt;
&lt;p&gt;If you understand the architecture and structure of the software you're building, you can evaluate AI-generated code.&lt;/p&gt;
&lt;p&gt;If you don't, you risk outsourcing the architecture of something you're responsible for to a process you cannot properly audit.&lt;/p&gt;
&lt;p&gt;Helkyn has experienced this himself.&lt;/p&gt;
&lt;p&gt;He once spent an entire weekend trying to fix an AI-generated application that had suddenly stopped working.&lt;/p&gt;
&lt;p&gt;After several days of troubleshooting, he discovered that the root cause was an architectural decision the AI had made around credential storage in a Supabase database.&lt;/p&gt;
&lt;p&gt;It wasn't simply a coding mistake.&lt;/p&gt;
&lt;p&gt;It was an &lt;strong&gt;architectural decision&lt;/strong&gt; he hadn't realized the AI had made.&lt;/p&gt;
&lt;p&gt;That experience reinforced an important lesson:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Don't let AI make important architectural decisions without guidance.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;His advice to developers who rely heavily on AI is practical:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Understand the important parts of your application before using AI.&lt;/li&gt;
&lt;li&gt;Decide what you think the solution should look like.&lt;/li&gt;
&lt;li&gt;Ask the AI for its approach.&lt;/li&gt;
&lt;li&gt;Compare the two.&lt;/li&gt;
&lt;li&gt;Ask the AI to argue against its own solution.&lt;/li&gt;
&lt;li&gt;Judge the arguments yourself.&lt;/li&gt;
&lt;li&gt;When the AI is wrong, understand &lt;strong&gt;why&lt;/strong&gt; it was wrong.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And perhaps most importantly:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Think of AI as a junior developer peer who knows nothing about your application.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;That mindset changes how you review its work.&lt;/p&gt;
&lt;p&gt;Helkyn has one final rule:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;&quot;Never merge code you can't explain line by line to another person.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Reading code and thinking it looks fine isn't the same as reviewing it.&lt;/p&gt;
&lt;p&gt;If you can't explain it, you haven't really reviewed it.&lt;/p&gt;
&lt;p&gt;You've just approved it.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;25-years-later-the-technology-keeps-changing&quot;&gt;25 Years Later, the Technology Keeps Changing&lt;/h3&gt;
&lt;p&gt;From Access and client-server applications to .NET, web platforms, RAG, AI agents, and AI-native software, Helkyn has watched the technology stack change repeatedly.&lt;/p&gt;
&lt;p&gt;But his biggest lessons aren't really about any particular technology.&lt;/p&gt;
&lt;p&gt;They're about &lt;strong&gt;judgment&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Technology makes building easier.&lt;/p&gt;
&lt;p&gt;AI makes it dramatically faster.&lt;/p&gt;
&lt;p&gt;But understanding what should be built, why it should be built, how it should behave in production, what could go wrong, and how it fits into the business remains deeply important.&lt;/p&gt;
&lt;p&gt;The developers who thrive in this new environment may not be the ones who write the most code.&lt;/p&gt;
&lt;p&gt;They may be the ones who understand the problem deeply enough to know:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;what to build, what not to build, what to let AI handle, and what should never be delegated blindly.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;about-helkyn-coello&quot;&gt;About Helkyn Coello&lt;/h3&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/helkyn-coello&quot;&gt;View Helkyn Coello's CoderLegion Profile&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Helkyn Coello is a technology leader and Chief AI Officer at AccountTECH with 25 years of experience building enterprise software.&lt;/p&gt;
&lt;p&gt;His career has covered the transition from Access-era applications to .NET client-server systems, web platforms, and now AI-native software.&lt;/p&gt;
&lt;p&gt;Today, his work focuses on enterprise AI adoption, RAG, agentic AI, AI governance, software architecture, and bringing AI into production systems.&lt;/p&gt;
&lt;p&gt;He is currently exploring agentic AI patterns, orchestration, guardrails, deterministic software, and AI-powered knowledge graphs over complex enterprise data.&lt;/p&gt;
&lt;p&gt;Follow Helkyn's work and join the conversation on CoderLegion.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Interviewed and edited by James Dayal, Editor at CoderLegion.&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26566/25-years-in-tech-taught-me-what-ai-still-cant-replace-helkyn-coellos-developer-journey</guid>
<pubDate>Wed, 09 Sep 2026 07:32:03 +0000</pubDate>
</item>
<item>
<title>The KV Cache Formula That Should Set Your Context Limit</title>
<link>https://coderlegion.com/26423/the-kv-cache-formula-that-should-set-your-context-limit</link>
<description>&lt;p&gt;Phone prices are climbing this year largely because memory is scarce - AI data centers are buying DRAM faster than fabs can make it. That same squeeze shows up in your cloud bill, and it changes which knob you turn first when inference gets expensive.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Memory Is the Binding Constraint, Not Compute&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When teams hit capacity limits serving a language model, the reflex is to ask for more GPUs. But on most serving setups the shortage isn't raw compute - it's the memory those chips carry. Model weights take a fixed slice. Everything left over goes to the KV cache: the running store of intermediate values for every token the model has already seen in a conversation. That cache grows linearly with context length and with the number of requests you serve at once.&lt;/p&gt;
&lt;p&gt;So the real decision isn't &quot;buy more GPUs or not.&quot; It's: &lt;strong&gt;do we cap context length and retrieve the relevant parts, or do we pay for the memory to keep stuffing everything in the prompt?&lt;/strong&gt; Long-context models made the second option easy to reach for. Chipflation is making it expensive to keep reaching.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Running the Numbers Before You Ask for Budget&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The formula is simple enough to put in a planning doc:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;kv_bytes_per_token = 2 * layers * kv_heads * head_dim * bytes_per_value

# 7B-class model, GQA: 2 * 32 * 8 * 128 * 2 (fp16) = 131,072 bytes
# = 128 KB per token, per request
# 8k context -&amp;gt; 1.0 GB per request
# 32k context -&amp;gt; 4.0 GB per request
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On an 80 GB accelerator with roughly 15 GB going to weights, you have ~65 GB for cache. At 32k context that's about 16 concurrent requests. At 8k, about 65. Same hardware, four times the throughput - purely from a context decision, no new chips required.&lt;/p&gt;
&lt;p&gt;That's the trade-off stated honestly: you're spending retrieval quality to buy concurrency. The way to tell whether it's worth it is not vibes. Build a held-out set of 100 - 200 real user questions, answer each one twice - once with the full long context, once with an 8k window plus retrieval - and score them. If the quality gap is inside your tolerance, cap the window. If your users routinely need reasoning across a whole document, it isn't, and the extra memory is a legitimate line item.&lt;/p&gt;
&lt;p&gt;There's a middle lever too: quantizing the KV cache to 8-bit halves the per-token cost with a smaller quality hit than truncation. Worth testing before you commit to either extreme.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Memory, not compute, usually sets your inference ceiling - and memory prices are rising for reasons outside your control.&lt;/li&gt;
&lt;li&gt;Context length and batch size drive KV cache size linearly; a 4x context cut is roughly a 4x concurrency gain on the same hardware.&lt;/li&gt;
&lt;li&gt;Decide with a scored comparison on real questions, not intuition - long context is a cost you should be able to justify.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you ran that comparison on your own traffic, how much of your current context window is actually load-bearing?&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Sources referenced: The Verge&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26423/the-kv-cache-formula-that-should-set-your-context-limit</guid>
<pubDate>Mon, 07 Sep 2026 16:22:29 +0000</pubDate>
</item>
<item>
<title>Is Your Knowledge Base Actually Thinking, or Just Retrieving?</title>
<link>https://coderlegion.com/25823/is-your-knowledge-base-actually-thinking-or-just-retrieving</link>
<description>&lt;p&gt;A while back I was able to build a working knowledge base in less than an hour. Fifty-five minutes to be accurate. I started by opening VS Code with Claude Code in the side panel. I had documentation scattered across roughly ten training manuals, a video archive nobody watched, support ticket histories, enhancement docs, and meeting transcripts. The knowledge existed. It was just spread across systems with nothing connecting them.&lt;/p&gt;
&lt;p&gt;I described the islands to Claude the way I'd describe them to a new engineer. Then I asked for a chat interface backed by a vector database with RAG-style retrieval from plain-language questions. At around minute 45, it was running and returning accurate answers from the actual docs. I know it was not yet production software, but it was a real prototype on real data.&lt;/p&gt;
&lt;p&gt;But I had slipped one extra requirement into that prompt, almost without thinking about it: client-specific information should be flagged and surfaced only when a query came from that client or was about that client. We had training recordings and support history tied to individual accounts. That requirement is the whole article. The moment you say &quot;surface this when the query is about that client,&quot; you have stepped outside what retrieval by similarity can do. Similarity answers one question: which stored text resembles the wording of the query. But a support ticket and a training recording tied to the same client may share no vocabulary with each other, or with the question being asked. What links them is not wording; it is the client they both belong to. That link is a relationship, and a relationship is something a similarity search has no way to represent.&lt;/p&gt;
&lt;p&gt;My prototype got away with it in a demo. In production, where real users ask cross-source questions every day, that shortcut stops holding.&lt;/p&gt;
&lt;p&gt;What embeddings actually do, and where they stop&lt;/p&gt;
&lt;p&gt;Embed-and-retrieve works like this: you chunk the documents, run each chunk through an embedding model, store the vectors, and at query time you embed the question and pull back the nearest neighbors by cosine similarity, a measure of how close two texts sit in meaning. It works. For a large class of questions — &quot;what does our refund policy say,&quot; &quot;how do I configure SSO&quot; — it works well, and you can have it running in an afternoon.&lt;/p&gt;
&lt;p&gt;The ceiling shows up the moment an answer lives across sources that don't look alike.&lt;/p&gt;
&lt;p&gt;Take a question we actually get internally: &quot;Why is this client's month-end close behaving differently from the others?&quot; The answer is not in one chunk. It's in a custom configuration noted in a support ticket from two years ago, a behavior described in a training recording for that specific account, and a feature flag mentioned in an enhancement doc. None of those three documents are textually similar to each other, and none of them are similar to the question. An embedding search returns whatever happens to share vocabulary with &quot;month-end close.&quot; That points straight to the generic manual, which is the one document in the set that does not contain the answer.&lt;/p&gt;
&lt;p&gt;Similarity is a proxy for relevance. It's a decent proxy when relevant things tend to use the same words. It collapses when the relevant things are connected by something other than vocabulary: a shared client, a shared subsystem, a cause-and-effect chain that nobody wrote down in a single place.&lt;/p&gt;
&lt;p&gt;This is the part that gets skipped in the market. A team sets up RAG, declares &quot;the knowledge base is done,&quot; and stops exactly there. You end up with a search engine that talks. Useful, but it answers &quot;what does the document say,&quot; and the questions that actually justify the project are the ones that ask &quot;what does the situation mean.&quot;&lt;/p&gt;
&lt;h3 id=&quot;the-relationships-are-the-knowledge&quot;&gt;The relationships are the knowledge&lt;/h3&gt;
&lt;p&gt;I was reminded of all this at Milano AI Week, where someone framed it cleanly: the real intelligence in a knowledge base isn't in the chunks; it's in how the segments relate, the graph rather than the index.&lt;/p&gt;
&lt;p&gt;Concretely, that means a second representation alongside your vectors. You run entity extraction over the same corpus — clients, features, configurations, people, error conditions, dates — and you model the edges between them. Ticket 4471 references client Aurora. Aurora runs the custom close configuration. That configuration was introduced by enhancement request 882. The training recording from March covers that workflow. Those are explicit edges in a graph, and they are traversable regardless of whether any two of those nodes share a single word.&lt;/p&gt;
&lt;p&gt;Now the same question (why is this client's month-end close behaving differently) gets answered along a different path. The system maps &quot;this client&quot; to the Aurora entity, walks the edges out to its configurations, tickets, and recordings, and assembles a candidate set that no similarity search would ever produce, because the documents have almost nothing lexically in common. The graph answers what is actually related; the embeddings answer what looks similar. You want both: similarity finds the entry points and the unstructured passages, and the graph traversal gathers everything connected. Either one alone leaves capability on the table.&lt;/p&gt;
&lt;p&gt;I'm not arguing embeddings are wrong. I'm arguing that &quot;chunks plus embeddings&quot; is a floor people keep mistaking for a ceiling.&lt;/p&gt;
&lt;h3 id=&quot;the-cost-is-real-and-it-is-not-catastrophic&quot;&gt;The cost is real, and it is not catastrophic&lt;/h3&gt;
&lt;p&gt;Here's the honest trade-off, because the graph approach is not free.&lt;/p&gt;
&lt;p&gt;Entity extraction has to be tuned. The first pass over-extracts, treats every capitalized string as an entity, and cannot tell &quot;Aurora the client&quot; apart from &quot;Aurora the internal name of a feature.&quot; Entity resolution — deciding that three different spellings refer to the same client — is where most of the real effort goes. You need a schema for what entities and relationships matter to your business, and that schema is opinionated, which means it requires someone who understands the domain to define it. You'll iterate it several times before it behaves. This is the same tuning loop I've hit building constraint surfaces for AI tools internally: the gap between how you described the rule and how the system interpreted it is always wider than you expect at the start, and you only close it by running real queries and watching what breaks.&lt;/p&gt;
&lt;p&gt;Operationally you're now maintaining two stores and a pipeline that keeps the graph in sync as documents change. That's more moving parts than a single vector index. Budget for it.&lt;/p&gt;
&lt;p&gt;But the cost difference between the two architectures is incremental, not an order of magnitude. The embed step is shared. The extra work is the extraction pipeline, a graph store, and the retrieval logic that combines the two. The capability difference, by contrast, is enormous. A chunk-only system can answer questions confined to a single document. The graph version can answer questions whose answer requires reasoning across sources that were never connected by an author. For an enterprise knowledge base, that second class of question is usually the entire reason the project was funded.&lt;/p&gt;
&lt;p&gt;If your knowledge base is meant to serve support engineers, account managers, or anyone diagnosing a situation that spans history, the chunk-only version will demo beautifully and disappoint in week three, when the first cross-source question comes in and it confidently returns the generic manual. This is exactly the kind of representation decision we work through when we design a knowledge base meant to survive contact with real questions.&lt;/p&gt;
&lt;h3 id=&quot;what-to-check-before-you-sign-off-on-an-architecture&quot;&gt;What to check before you sign off on an architecture&lt;/h3&gt;
&lt;p&gt;There's a simple test. Take five questions your team actually asks, the hard ones, the ones that make someone sigh, and for each one, ask yourself whether the full answer lives in a single document or has to be stitched together from several. If most of them need the stitching, similarity retrieval will not get you there, and no amount of better embeddings or bigger context windows fixes a representation problem.&lt;/p&gt;
&lt;p&gt;That test is also the dividing line between a search engine and a knowledge base. A search engine retrieves text. A knowledge base models the entities your business cares about and the relationships between them, then reasons over that structure. The prototype I built in fifty-five minutes was the former with one relationship bolted on by hand. The production version of that same system is the latter, and the difference is entirely in how seriously you model the graph.&lt;/p&gt;
&lt;p&gt;Before you approve any internal knowledge base design, ask the team building it one question: when the answer lives in three unrelated documents, what mechanism finds the other two? If the only answer is &quot;similarity,&quot; that tells you two things: the team hasn't seen the ceiling yet, and the budget you're about to approve will buy a system that stops exactly where your hardest questions begin. Decide whether that's enough before the cost moves downstream into the maintenance no one can see.&lt;/p&gt;
&lt;p&gt;If you are weighing that design decision now and want the retrieval architecture argued through against your own content rather than in the abstract, that is what &lt;a rel=&quot;nofollow&quot; href=&quot;/services/knowledge-base&quot;&gt;RAG consulting&lt;/a&gt; is for.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a rel=&quot;nofollow&quot; href=&quot;https://unlockedconsulting.ai/blog/knowledge-base-thinking-vs-retrieving&quot;&gt;unlockedconsulting.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25823/is-your-knowledge-base-actually-thinking-or-just-retrieving</guid>
<pubDate>Wed, 02 Sep 2026 21:53:01 +0000</pubDate>
</item>
<item>
<title>From Web Development to Local AI: Josaphat’s Journey Through the AI Era</title>
<link>https://coderlegion.com/26037/from-web-development-to-local-ai-josaphats-journey-through-the-ai-era</link>
<description>&lt;h2 id=&quot;developer-stories-from-web-development-to-ai-josaphat-s-journey&quot;&gt;Developer Stories: From Web Development to AI — Josaphat’s Journey&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;An interview by CoderLegion&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The world of software development is changing quickly. AI-assisted coding is making it easier than ever to build applications, experiment with new technologies, and turn ideas into working prototypes.&lt;/p&gt;
&lt;p&gt;But there is another side to this transformation: &lt;strong&gt;are developers still learning deeply when AI can write so much of the code for them?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;For &lt;strong&gt;G-Josaphat E. Tagba&lt;/strong&gt;, known on CoderLegion as &lt;strong&gt;@Josaphatstar&lt;/strong&gt;, this is a question he has experienced firsthand.&lt;/p&gt;
&lt;p&gt;Josaphat is a web developer with a growing focus on artificial intelligence and data. Alongside his web development journey, he is pursuing a Master's degree in &lt;strong&gt;AI &amp;amp; Big Data&lt;/strong&gt;, exploring areas including machine learning, data pipelines, statistics, and modern AI technologies.&lt;/p&gt;
&lt;p&gt;His recent work reflects that combination of web development and AI. One of his projects involved building a &lt;strong&gt;local RAG assistant&lt;/strong&gt; using Ollama, ChromaDB, LangChain, FastAPI, and Streamlit. The system was designed to help technicians search through thousands of pages of technical documentation using natural language while keeping sensitive information inside the local infrastructure.&lt;/p&gt;
&lt;p&gt;We invited Josaphat to share his journey with the CoderLegion community—from getting started with web development and AI to discovering the hidden challenges of AI-assisted coding and building a real-world local RAG system.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;1-from-web-development-to-ai&quot;&gt;1. From Web Development to AI&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;CoderLegion:&lt;/strong&gt; You describe yourself as a web developer while also pursuing a Master's in AI &amp;amp; Big Data. How did your journey develop from frontend/web development into AI, and what made you want to explore both areas?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Josaphat:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;It didn't start as a plan, it happened in parallel.&lt;/p&gt;
&lt;p&gt;I started getting into web development on my own. I liked the immediate feedback of building things you could see and interact with.&lt;/p&gt;
&lt;p&gt;At the same time, I enrolled in a Master's program in AI &amp;amp; Big Data, which took me in a completely different direction—machine learning, data pipelines, statistics, etc.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;2-learning-to-code-in-the-ai-era&quot;&gt;2. Learning to Code in the AI Era&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;CoderLegion:&lt;/strong&gt; You've written about how learning with AI can make development much faster while sometimes making it harder to develop deep understanding. When did you first notice this yourself, and how has it changed the way you learn?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Josaphat:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I noticed it during a Nuxt project I built with heavy AI assistance.&lt;/p&gt;
&lt;p&gt;When I finished, someone asked me to explain some of the decisions I'd made in my own code. And I struggled.&lt;/p&gt;
&lt;p&gt;Not because the code was wrong, but because I hadn't really wrestled with it. I'd asked AI, got an answer, moved on.&lt;/p&gt;
&lt;p&gt;That was the moment I realized there's a difference between moving fast and actually understanding.&lt;/p&gt;
&lt;p&gt;Before AI was this capable, I had to sit with problems. That frustration was uncomfortable, but it was also doing something.&lt;/p&gt;
&lt;p&gt;Every bug I fixed alone left a mark.&lt;/p&gt;
&lt;p&gt;Now I can skip that uncomfortable part entirely, and that's both the power and the trap.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;3-building-your-local-rag-assistant&quot;&gt;3. Building Your Local RAG Assistant&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;CoderLegion:&lt;/strong&gt; You recently built a local RAG assistant using Ollama, ChromaDB, LangChain, FastAPI and Streamlit to help technicians search through thousands of pages of technical documentation.&lt;/p&gt;
&lt;p&gt;What problem were you trying to solve, and what was the biggest challenge you encountered while building it?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Josaphat:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The idea was to build an AI assistant that could answer technical questions by searching through that documentation intelligently, in natural language.&lt;/p&gt;
&lt;p&gt;The biggest challenge was the scale of the document ingestion.&lt;/p&gt;
&lt;p&gt;We were working with around 10 PDFs totaling roughly 2,000 pages—about 10,000 vectors once processed into ChromaDB.&lt;/p&gt;
&lt;p&gt;Getting that pipeline to work reliably, with consistent retrieval quality, took a lot of iteration.&lt;/p&gt;
&lt;p&gt;The second challenge was debugging things I'd never built before. LangChain's API had changed significantly between versions, ChromaDB had breaking changes between v1 and v2, and LangGraph was new territory.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;4-why-local-ai-&quot;&gt;4. Why Local AI?&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;CoderLegion:&lt;/strong&gt; One of the constraints of that project was that the data could not leave the local infrastructure, meaning you couldn't simply send the documents to OpenAI or another cloud AI provider.&lt;/p&gt;
&lt;p&gt;How did that constraint influence your architecture, and what did you learn from building an AI system entirely locally?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Josaphat:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The constraint was non-negotiable: the documents contained sensitive technical data that couldn't leave the local infrastructure.&lt;/p&gt;
&lt;p&gt;Cloud APIs were off the table.&lt;/p&gt;
&lt;p&gt;Everything had to run locally.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;5-when-things-break&quot;&gt;5. When Things Break&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;CoderLegion:&lt;/strong&gt; Your RAG project involved several real problems—from ChromaDB connectivity and changing LangChain modules to Llama 3 performance on CPU.&lt;/p&gt;
&lt;p&gt;Which problem taught you the most, and what did it change about the way you approach debugging?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Josaphat:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The problem that taught me the most was the ChromaDB API change.&lt;/p&gt;
&lt;p&gt;The ChromaDB client interface changed significantly between versions. Code that had worked stopped working, with errors that weren't immediately obvious.&lt;/p&gt;
&lt;p&gt;Combined with LangChain deprecating several imports that needed to be migrated to &lt;code&gt;langchain_core&lt;/code&gt;, it forced me to dig deeper into what was actually happening rather than simply assuming the code should work.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-comes-next-&quot;&gt;What Comes Next?&lt;/h3&gt;
&lt;p&gt;Josaphat's story doesn't end with building a traditional RAG pipeline.&lt;/p&gt;
&lt;p&gt;He is also exploring the next stage of AI application development: &lt;strong&gt;agentic AI&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;In the next part of our conversation, we'll explore his move from RAG toward agentic architectures, what real-world projects have taught him that tutorials couldn't, his experience working with clients, and what he believes developers should focus on as AI becomes increasingly capable.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;about-josaphat&quot;&gt;About Josaphat&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;G-Josaphat E. Tagba (@Josaphatstar)&lt;/strong&gt; is a web developer and Master's student in &lt;strong&gt;AI &amp;amp; Big Data&lt;/strong&gt;, with interests spanning web development, artificial intelligence, machine learning, data, and AI-powered applications.&lt;/p&gt;
&lt;p&gt;His recent work includes experimenting with RAG systems, local LLMs, AI tooling, and modern web technologies.&lt;/p&gt;
&lt;p&gt;You can connect with Josaphat on CoderLegion:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/user/Josaphatstar&quot;&gt;https://coderlegion.com/user/Josaphatstar&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;This interview is part of CoderLegion's Developer Stories series&lt;/strong&gt;, where developers share their real experiences, challenges, projects, lessons, and career journeys with the community.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/26037/from-web-development-to-local-ai-josaphats-journey-through-the-ai-era</guid>
<pubDate>Tue, 01 Sep 2026 22:30:42 +0000</pubDate>
</item>
<item>
<title>How Synthadoc Handles Hallucination and Why &quot;Don't Hallucinate&quot; Isn't Enough</title>
<link>https://coderlegion.com/25713/how-synthadoc-handles-hallucination-and-why-dont-hallucinate-isnt-enough</link>
<description>&lt;p&gt;Ask most AI products how they handle hallucination and you'll get a variant of &quot;we tell the&lt;br&gt;
model not to.&quot; That's not wrong — it's just insufficient, and it puts enforcement in the&lt;br&gt;
hands of the exact system doing the generating.&lt;/p&gt;
&lt;p&gt;Synthadoc treats hallucination as an &lt;strong&gt;architecture problem&lt;/strong&gt;, not a wording problem. The&lt;br&gt;
distinction matters: a wording fix is hoping the model behaves; an architecture fix is&lt;br&gt;
verifying that it did.&lt;/p&gt;
&lt;h3 id=&quot;why-prompt-instructions-aren-t-enough&quot;&gt;Why Prompt Instructions Aren't Enough&lt;/h3&gt;
&lt;p&gt;The industry default is a line in a system prompt: &lt;em&gt;don't make things up, answer only from&lt;br&gt;
the provided context, never fabricate.&lt;/em&gt; These instructions do nudge model behavior in the&lt;br&gt;
right direction, and we use them too. But they leave enforcement entirely up to the model&lt;br&gt;
doing the generating. A language model producing text token-by-token has no reliable&lt;br&gt;
internal mechanism to audit its own faithfulness to a source.&lt;/p&gt;
&lt;p&gt;There's a subtle issue as well. Instructions of the form &quot;don't do X&quot; are easier for a&lt;br&gt;
model to rationalize around than instructions of the form &quot;do only Y.&quot; Synthadoc's synthesis&lt;br&gt;
constraint is a positive formulation:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Answer using ONLY these wiki pages. Cite with [[PageTitle]].
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's linguistically harder to rationalize around. But it's still just text in a prompt.&lt;br&gt;
The real work happens at the structural level.&lt;/p&gt;
&lt;h3 id=&quot;three-layers-not-one-instruction&quot;&gt;Three Layers, Not One Instruction&lt;/h3&gt;
&lt;p&gt;Our approach treats hallucination as a systems problem. There are three distinct layers,&lt;br&gt;
each handling a different failure mode:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Scope filtering at ingest time&lt;/strong&gt; : Wiki content can only be entered if it is on-domain. It is difficult to produce hallucinations about off-domain topics from content that has never been consumed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retrieval-augmented grounding at query time&lt;/strong&gt; : Answers are generated from retrieved&lt;br&gt;
wiki pages, not from the model's weights alone. The synthesis prompt shows only the&lt;br&gt;
relevant pages and instructs the model to answer from them exclusively.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Citation faithfulness audit after the fact&lt;/strong&gt; : Every claim gets a citation marker&lt;br&gt;
linking it to specific source lines. A separate LLM pass then audits whether each claim&lt;br&gt;
is actually supported by those lines. This is the layer that catches what the other two&lt;br&gt;
miss.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;None of these is a complete solution alone. Together they create a system where hallucinations&lt;br&gt;
are &lt;em&gt;detectable and traceable&lt;/em&gt;, not just discouraged.&lt;/p&gt;
&lt;h3 id=&quot;layer-1-purpose-md-as-a-content-gate&quot;&gt;Layer 1: purpose.md as a Content Gate&lt;/h3&gt;
&lt;p&gt;Every wiki has a &lt;code&gt;wiki/purpose.md&lt;/code&gt; file that describes its domain — what it's for, what&lt;br&gt;
topics belong, and what doesn't. When a new source document arrives for ingest, the ingest&lt;br&gt;
agent reads this purpose block before making a decision:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Wiki scope (from purpose.md):
{content of purpose.md}

action=&quot;skip&quot; means the source is completely OUTSIDE the wiki's domain
(e.g. spam, medical receipts, unrelated e-commerce).
…
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The LLM then decides: &lt;code&gt;skip&lt;/code&gt;, &lt;code&gt;create&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, or &lt;code&gt;flag&lt;/code&gt;. Out-of-scope content never&lt;br&gt;
becomes a wiki page, which means it can never become the basis for an answer.&lt;/p&gt;
&lt;h4 id=&quot;lifecycle-isolation-for-contradicted-pages&quot;&gt;Lifecycle isolation for contradicted pages&lt;/h4&gt;
&lt;p&gt;The scope gate isn't the only place the knowledge base shrinks. When a new source&lt;br&gt;
&lt;em&gt;disagrees&lt;/em&gt; with an existing page, the ingest agent issues a &lt;code&gt;flag&lt;/code&gt; action and the page&lt;br&gt;
moves to a &lt;code&gt;contradicted&lt;/code&gt; lifecycle state, and automatically excluded from retrieval until&lt;br&gt;
the conflict is resolved. This matters because a contradicted page is, by definition,&lt;br&gt;
one where the system can't be confident about what's true. Serving answers from it&lt;br&gt;
would be hallucination with extra steps.&lt;/p&gt;
&lt;p&gt;The contradiction resolver in Synthadoc is an interactive agentic workflow: it surfaces&lt;br&gt;
the conflict, shows a diff of the proposed edit, and requires explicit human approval&lt;br&gt;
before writing any change. The approve-before-write gate is unconditional, and the agent&lt;br&gt;
cannot modify a page without a human seeing the diff first.&lt;/p&gt;
&lt;h3 id=&quot;layer-2-rag-grounding-at-query-time&quot;&gt;Layer 2: RAG Grounding at Query Time&lt;/h3&gt;
&lt;p&gt;When a user asks a question, Synthadoc retrieves the most relevant wiki pages using&lt;br&gt;
hybrid search (BM25 full-text matching with optional vector re-ranking), then builds a&lt;br&gt;
synthesis prompt that includes those pages verbatim:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Answer using ONLY these wiki pages. Cite with [[PageTitle]].
Extract and include all specific facts from the pages — dates, years,
numbers, and names — even when they appear briefly or in passing.
Do not claim a fact is absent unless it is genuinely missing from
every page below.

Question: {question}

Pages:
{retrieved_wiki_pages}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;purpose.md&lt;/code&gt; content is prepended to the pages block as &lt;code&gt;### Wiki Scope&lt;/code&gt;, giving the&lt;br&gt;
model a domain reminder during synthesis as well as at ingest time.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Why this isn't enough on its own:&lt;/strong&gt; A model can still hallucinate &lt;em&gt;within&lt;/em&gt; retrieved&lt;br&gt;
content — misattributing which page said what, inventing a specific number close to but&lt;br&gt;
not the same as a number in the source, or synthesizing a conclusion that no single page&lt;br&gt;
actually states. Grounding reduces the search space; it doesn't eliminate drift.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;When retrieval confidence falls below threshold&lt;/strong&gt;, Synthadoc detects the gap and handles&lt;br&gt;
it transparently rather than hallucinating silently. The immediate response answers from&lt;br&gt;
the model's general knowledge while explicitly noting that the wiki lacks dedicated&lt;br&gt;
coverage — the user always knows they're receiving an unverified answer. At the same time,&lt;br&gt;
Synthadoc generates enrichment suggestions: search queries and known URLs scoped to the&lt;br&gt;
domain in &lt;code&gt;purpose.md&lt;/code&gt;. Retrieved pages flow through the normal ingest pipeline and stay&lt;br&gt;
in &lt;code&gt;draft&lt;/code&gt; state — out of the search corpus — until a human promotes them to &lt;code&gt;active&lt;/code&gt;.&lt;br&gt;
Only then do future queries on that topic get answered from verified wiki content rather&lt;br&gt;
than general knowledge.&lt;/p&gt;
&lt;h3 id=&quot;layer-3-citation-faithfulness-audit&quot;&gt;Layer 3: Citation Faithfulness Audit&lt;/h3&gt;
&lt;p&gt;This is the layer that makes the system &lt;em&gt;verifiable&lt;/em&gt; rather than just hopeful. The citation&lt;br&gt;
faithfulness audit examines each claim in a wiki answer alongside the source lines it cites,&lt;br&gt;
then classifies the relationship:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Verdict         &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;&lt;code&gt;supported&lt;/code&gt;     &lt;/td&gt;&lt;td&gt;The claim is directly and accurately supported by the cited source lines.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;drift&lt;/code&gt;         &lt;/td&gt;&lt;td&gt;The claim is related to the source but overstated, simplified, or shifted in meaning.&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;hallucination&lt;/code&gt; &lt;/td&gt;&lt;td&gt;The source lines do not support the claim, or directly contradict it.&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Each audit is done by a separate LLM call with the claim text and raw source lines&lt;br&gt;
side-by-side. The auditor never sees the original question — only the claim and the lines.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Why a separate LLM pass?&lt;/strong&gt; The same model that wrote the answer is likely to defend&lt;br&gt;
it when asked &quot;is this correct?&quot; Separation of duties matters: the auditor has no stake&lt;br&gt;
in validating the original generation.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 id=&quot;what-we-actually-see-in-practice&quot;&gt;What we actually see in practice&lt;/h4&gt;
&lt;p&gt;In our experience, &lt;strong&gt;&lt;code&gt;drift&lt;/code&gt; is the most common finding&lt;/strong&gt; — not outright hallucination.&lt;br&gt;
The model summarizes accurately but slightly overstates a confidence level, rounds a&lt;br&gt;
number, or drops a qualifier (&quot;as of 2023&quot; becomes &quot;currently&quot;). These are small errors&lt;br&gt;
individually but compound across a knowledge base that gets queried repeatedly.&lt;/p&gt;
&lt;p&gt;True &lt;code&gt;hallucination&lt;/code&gt; , where the cited source lines don't support the claim at all ,&lt;br&gt;
is less common in well-grounded RAG, but it happens: specific dates that don't appear&lt;br&gt;
in the source, an attribution switched between two people mentioned in the same paragraph,&lt;br&gt;
a causal relationship inferred from correlation. Those are the findings that matter most,&lt;br&gt;
and the audit surfaces them per-citation so a human knows exactly which source line to&lt;br&gt;
check.&lt;/p&gt;
&lt;p&gt;The audit result is cached per-page with staleness detection. If the page is re-ingested&lt;br&gt;
from updated source material, the cache is invalidated and the next audit reflects the new&lt;br&gt;
content. This surfaces in the web UI as an automatically-loaded faithfulness tab alongside&lt;br&gt;
each wiki page.&lt;/p&gt;
&lt;h3 id=&quot;what-this-doesn-t-solve&quot;&gt;What This Doesn't Solve&lt;/h3&gt;
&lt;p&gt;Honesty matters here too. Two failure modes remain outside what the three layers fully&lt;br&gt;
address:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Synthesis across pages.&lt;/strong&gt; A conclusion drawn by combining facts from multiple pages may&lt;br&gt;
be accurate at each individual step and still misleading in combination. The citation&lt;br&gt;
faithfulness audit operates per-citation, not across synthesized conclusions, so a&lt;br&gt;
cross-page inference that no single page actually states falls outside its scope.&lt;br&gt;
The practical path: when a synthesized answer matters, the cited pages are explicit and&lt;br&gt;
can be verified directly. If the conclusion is important enough to keep, saving it as a&lt;br&gt;
wiki page subjects it to future source ingestion — a new source that disputes it will&lt;br&gt;
trigger the &lt;code&gt;flag&lt;/code&gt; action for human review.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Source quality.&lt;/strong&gt; When a new source &lt;em&gt;disagrees&lt;/em&gt; with an existing page, the &lt;code&gt;flag&lt;/code&gt; action&lt;br&gt;
fires and the contradiction is surfaced. The harder case is when all available sources&lt;br&gt;
agree on the same wrong answer: no flag fires, no contradiction is detected, and Synthadoc&lt;br&gt;
answers confidently from incorrect material. Two mechanisms make this less likely: the&lt;br&gt;
adversarial review in lint, which uses the model's own knowledge to flag claims that&lt;br&gt;
clearly contradict well-established facts; and re-ingesting from updated authoritative&lt;br&gt;
sources, which will trigger the &lt;code&gt;flag&lt;/code&gt; action against any outdated pages. Neither is a&lt;br&gt;
complete solution, but together they make uniform source error less likely to go&lt;br&gt;
unnoticed indefinitely.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;our-view&quot;&gt;Our View&lt;/h3&gt;
&lt;p&gt;The hallucination debate tends to frame the problem as a model capability question: will&lt;br&gt;
the next model be more faithful than the last? We think that's the wrong frame for a&lt;br&gt;
production knowledge system. Even a perfectly faithful model can only be as accurate as&lt;br&gt;
the content it's grounded in, and even a slightly drifting model can be made accountable&lt;br&gt;
if the output is systematically audited.&lt;/p&gt;
&lt;p&gt;Our architectural bet is that &lt;strong&gt;the answer to hallucination isn't a better instruction,&lt;br&gt;
it's a feedback loop&lt;/strong&gt;. Domain scoping controls what enters. Lifecycle states control what&lt;br&gt;
gets queried. Retrieval grounds generation. Citation audit classifies the output. The&lt;br&gt;
contradiction resolver closes the loop: when a conflict is found, a human resolves it, the&lt;br&gt;
page is corrected, and the knowledge base improves. Errors become visible rather than&lt;br&gt;
accumulating silently.&lt;/p&gt;
&lt;p&gt;That's a more defensible position than any number of ALL CAPS instructions.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;try-it&quot;&gt;Try It&lt;/h3&gt;
&lt;p&gt;Synthadoc is open-source. The citation faithfulness audit, contradiction resolver, and&lt;br&gt;
lifecycle system described here are all in the main branch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;→ &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/axoviq-ai/synthadoc&quot;&gt;github.com/axoviq-ai/synthadoc&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25713/how-synthadoc-handles-hallucination-and-why-dont-hallucinate-isnt-enough</guid>
<pubDate>Fri, 28 Aug 2026 17:18:42 +0000</pubDate>
</item>
<item>
<title>Why Your RAG Pipeline Cannot Answer What Payment Processor Do We Use</title>
<link>https://coderlegion.com/25098/why-your-rag-pipeline-cannot-answer-what-payment-processor-do-we-use</link>
<description>&lt;p&gt;Most of us here have shipped at least one retrieval layer that worked beautifully in the demo and then quietly failed in production. This is a writeup of the specific failure mode I keep running into, and the extraction step that actually fixes it.&lt;/p&gt;
&lt;h3 id=&quot;the-failure&quot;&gt;The failure&lt;/h3&gt;
&lt;p&gt;Store this sentence in your vector index:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;we migrated the checkout flow from Stripe to Braintree last quarter&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Three months later somebody asks:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;what payment processor do we use&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The two embeddings are not close enough, because neither the word payment nor the word processor appears anywhere in the stored text. The fact is in your database. The vocabulary is not. No amount of chunk size tuning fixes this, because chunking was never the problem.&lt;/p&gt;
&lt;h3 id=&quot;entity-extraction-is-the-missing-step&quot;&gt;Entity extraction is the missing step&lt;/h3&gt;
&lt;p&gt;Entity extraction turns that sentence into structured facts before you store anything:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;checkout flow uses Braintree (current)&lt;/li&gt;
&lt;li&gt;checkout flow previously used Stripe (historical)&lt;/li&gt;
&lt;li&gt;a migration event occurred, with a date&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now the question resolves by looking up an entity and its type, not by hoping the wording overlaps.&lt;/p&gt;
&lt;h3 id=&quot;ner-and-llm-extraction-solve-different-halves&quot;&gt;NER and LLM extraction solve different halves&lt;/h3&gt;
&lt;p&gt;Traditional NER is fast and cheap. SpaCy's en_core_web_trf hits 89.8% F1 on OntoNotes and processes thousands of documents per second on a single CPU. Fine-tuned BERT models reach 92 to 93% on the same benchmark. What both give you is the standard label set: PERSON, ORG, GPE, DATE, MONEY.&lt;/p&gt;
&lt;p&gt;The moment your domain invents its own entity types, and it will (microservice, API endpoint, deployment environment, database table), a pretrained NER model simply does not see them. You either fine-tune, which costs 200 to 500 labeled examples per entity type, or you describe the types in a prompt and let an LLM extract them.&lt;/p&gt;
&lt;p&gt;The cost difference is the whole argument. Running 100,000 documents through an LLM extraction prompt costs roughly $300 to $1,500 depending on the model. The same corpus through SpaCy costs under a dollar.&lt;/p&gt;
&lt;p&gt;So most production pipelines now run tiered extraction. The small model goes over everything, and the LLM only touches passages where domain-specific entities or ambiguous relations are likely, using the NER output as context so nothing gets extracted twice. That cuts LLM spend 60 to 80 percent while keeping accuracy where it matters.&lt;/p&gt;
&lt;h3 id=&quot;relations-are-where-the-graph-actually-forms&quot;&gt;Relations are where the graph actually forms&lt;/h3&gt;
&lt;p&gt;Entities on their own are a list. Relations are what make it traversable, and relation extraction is the harder half, because it needs the semantic connection rather than just the mention.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;John mentioned Redis during the architecture review&lt;/code&gt; contains two entities and a very weak relation. John mentioned it. He does not maintain it, own it, or depend on it. A pipeline that treats co-occurrence as a relation fills your graph with edges that mean nothing, and every traversal after that inherits the noise.&lt;/p&gt;
&lt;p&gt;The one that bites everybody eventually is predicate normalization. If uses, utilizes, relies on, is built on and depends on all land as separate predicates, traversal silently misses most of the graph. It presents as a retrieval bug and it is really a schema bug.&lt;/p&gt;
&lt;p&gt;The full writeup, with the pipeline stages, the tiered cost model and the section on measuring extraction quality, is here: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.adaptiverecall.com/entity-extraction/&quot;&gt;Entity Extraction and NER for AI&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;If you have built one of these, I am curious which half broke first for you, the entities or the relations.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25098/why-your-rag-pipeline-cannot-answer-what-payment-processor-do-we-use</guid>
<pubDate>Fri, 21 Aug 2026 17:17:31 +0000</pubDate>
</item>
<item>
<title>ChatGPT Search Is Firing site: Queries - Test Yours Today</title>
<link>https://coderlegion.com/25070/chatgpt-search-is-firing-site-queries-test-yours-today</link>
<description>&lt;p&gt;ChatGPT's search step increasingly issues &lt;code&gt;site:&lt;/code&gt;-scoped queries rather than broad open-web searches. This single detail changes what &quot;being findable&quot; means for anyone with a site, docs, or a blog.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What a site:-Scoped Search Actually Implies&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;When a model decides to search, it writes the query itself. A broad query like &lt;code&gt;best invoicing tools for freelancers&lt;/code&gt; pulls from the whole index and the model picks whatever ranks. A scoped query like &lt;code&gt;site:yourdomain.com pricing&lt;/code&gt; means the model has &lt;em&gt;already&lt;/em&gt; decided your domain is the right source and now wants a specific fact from it.&lt;/p&gt;
&lt;p&gt;Broad search rewards general authority. Scoped search rewards whether the specific answer exists on a crawlable page on your domain, in text, at a URL a search index has actually seen. If your pricing lives inside a JavaScript-rendered widget, or your API rate limits are buried in a PDF, a scoped query returns nothing - and the model either guesses, hedges, or moves on to a competitor's docs page.&lt;/p&gt;
&lt;p&gt;For AI engineers building retrieval-augmented systems (RAG - feeding a model external documents at query time), this should feel familiar. It's the same failure mode as a chunk that never got indexed. The difference is the index isn't yours.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Run the Test Yourself&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You don't need a GEO vendor to check this. Run the same scoped queries a model would generate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# What a model asks when it already trusts your domain
site:yourdomain.com pricing
site:yourdomain.com &quot;rate limit&quot;
site:yourdomain.com refund policy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Paste each into Google or Bing directly. Then check whether the answer is in the visible snippet - not three clicks deep.&lt;/p&gt;
&lt;p&gt;Then check crawlability from the terminal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -s https://yourdomain.com/pricing | grep -i &quot;per month&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If &lt;code&gt;curl&lt;/code&gt; returns your raw HTML but the price isn't in it, that fact is client-side rendered and invisible to most crawlers. Same test for docs pages, FAQ answers, and comparison tables.&lt;/p&gt;
&lt;p&gt;Three fixes, in order of payoff:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Put the literal answer in server-rendered HTML.&lt;/strong&gt; Prices, limits, versions, dates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Give each discrete fact its own indexable URL.&lt;/strong&gt; One page answering &quot;what are the rate limits&quot; beats a mega-page with an anchor link.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use the phrasing a user would type.&lt;/strong&gt; Models generate queries from user language, not your internal product vocabulary.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is unglamorous work - closer to technical SEO hygiene than to prompt engineering. That's precisely why most teams skip it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Scoped &lt;code&gt;site:&lt;/code&gt; queries mean the model already chose your domain; the only question is whether the fact is retrievable.&lt;/li&gt;
&lt;li&gt;Client-side-rendered facts are functionally invisible to the crawl step - &lt;code&gt;curl&lt;/code&gt; plus &lt;code&gt;grep&lt;/code&gt; tells you in seconds.&lt;/li&gt;
&lt;li&gt;One clear fact per indexable URL, written in user phrasing, outperforms a comprehensive page that buries it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Run one scoped query for your own site right now - which specific fact came back empty that you assumed was public?&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Sources referenced: Simon Willison's blog, Promptwatch reporting&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/25070/chatgpt-search-is-firing-site-queries-test-yours-today</guid>
<pubDate>Fri, 21 Aug 2026 08:46:45 +0000</pubDate>
</item>
<item>
<title>Measuring whether semantic chunking actually helps, on one fixed corpus</title>
<link>https://coderlegion.com/24840/measuring-whether-semantic-chunking-actually-helps-on-one-fixed-corpus</link>
<description>&lt;p&gt;Everyone repeats that semantic chunking beats fixed-size chunking for RAG. I wanted to know if that was true for my corpus, or just a thing people say. So I built the comparison into EvalBench: one corpus, one query set, three chunking strategies, and a single precision number I could read off a leaderboard.&lt;/p&gt;
&lt;p&gt;The result I care about here is not a benchmark score. It is the setup. Getting a fair comparison between chunkers is harder than writing the chunkers, because almost everything that makes retrieval look good or bad has nothing to do with how you split the text.&lt;/p&gt;
&lt;h3 id=&quot;the-trap-comparing-chunkers-by-comparing-pipelines&quot;&gt;The trap: comparing chunkers by comparing pipelines&lt;/h3&gt;
&lt;p&gt;If you swap the chunker and also swap the embedder, or the corpus, or the top-k, you have not measured the chunker. You have measured a different pipeline. Most &quot;semantic wins&quot; posts do exactly this, and you cannot tell how much of the win is the split versus everything else that moved with it.&lt;/p&gt;
&lt;p&gt;So in EvalBench the only thing that varies is the split. A RAG run is identified by an &lt;code&gt;embedder::chunk_strategy&lt;/code&gt; string, parsed apart before anything runs:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def parse_pipeline_model(model: str) -&amp;gt; tuple[str, ChunkStrategy]:
    &quot;&quot;&quot;Parse the exact ``embedder::chunk_strategy`` row identifier.&quot;&quot;&quot;
    ...
    if strategy not in _ALLOWED_STRATEGIES:
        raise ValueError(f&quot;unknown chunk strategy {strategy!r}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same embedder, same 200-document corpus, same 15 queries across finance, legal, medical, physics, and software. The strategy is the one knob. Everything downstream is shared code.&lt;/p&gt;
&lt;h3 id=&quot;the-three-strategies&quot;&gt;The three strategies&lt;/h3&gt;
&lt;p&gt;There are three, not two. The interesting comparison is not fixed versus semantic, it is fixed versus recursive versus semantic, because recursive is what most people actually ship.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fixed_512&lt;/code&gt; is the dumb one. Whitespace-tokenize the whole document, cut it into 512-token windows with a 64-token overlap, done. It respects nothing about the text.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _window_token_groups(tokens, window=512, overlap=64):
    groups, start = [], 0
    while start &amp;lt; len(tokens):
        end = min(start + window, len(tokens))
        groups.append(list(tokens[start:end]))
        if end == len(tokens):
            break
        start = end - overlap
    return groups
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;recursive&lt;/code&gt; splits on structure first. Try paragraph breaks, then single newlines, then sentence boundaries, and only fall back to a hard token cut when a piece is still too big. Then it repacks those units into bounded chunks. This is the LangChain-style splitter, and it is a real baseline, not a strawman.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;semantic&lt;/code&gt; is the one people get excited about. Embed every sentence, walk them in order, and start a new chunk when the meaning drifts. Drift is measured as cosine similarity between adjacent sentences dropping below a threshold:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;low_similarity = (
    len(current_sentences) &amp;gt;= 3
    and cosine_similarity(vectors[index - 1], vectors[index]) &amp;lt; 0.65
)
too_large = len(current_tokens) + len(sentence_tokens) &amp;gt; 512
if current_tokens and (too_large or low_similarity):
    flush()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things in that snippet matter and neither is obvious from the marketing. The &lt;code&gt;&amp;gt;= 3&lt;/code&gt; guard means a chunk needs at least three sentences before a similarity drop is allowed to break it, so one weird sentence early on does not shatter a chunk into fragments. And the &lt;code&gt;too_large&lt;/code&gt; check means semantic still has a hard 512-token ceiling. So it is not really &quot;split on meaning.&quot; It splits on meaning until it hits the same 512-token window fixed_512 uses, then cuts. That ceiling is what keeps the comparison fair. If semantic could emit arbitrarily long chunks, it would win on precision for a boring reason: bigger chunks catch more gold documents.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/zxz69xrontp8cy934w5b.png&quot; alt=&quot;Does semantic chunking actually help? A fair RAG test&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;the-metric-has-to-be-simple-enough-to-trust&quot;&gt;The metric has to be simple enough to trust&lt;/h3&gt;
&lt;p&gt;I score every strategy on context precision. Take the top 10 ranked chunks, count how many come from a document in the gold set, divide:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;retrieved_chunks = ranked_chunks[:10]
context_precision = (
    sum(chunk.doc_id in gold_doc_ids for chunk in retrieved_chunks)
    / len(retrieved_chunks)
    if retrieved_chunks
    else 0.0
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That is deliberately crude. It counts a chunk as relevant if its source document is relevant, not if the chunk text itself answers the query. A stricter metric would grade chunk content, but then I would be measuring my grader as much as my chunker, and I already have a separate faithfulness judge for content quality. For a chunker comparison, &quot;did we pull chunks from the right documents&quot; is the question, and document-level precision answers it without dragging an LLM into the measurement.&lt;/p&gt;
&lt;p&gt;Every metric is clamped to [0, 1] with a hard failure if it lands outside, so a broken chunker that emits duplicate or malformed chunks throws instead of quietly publishing a nonsense score:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _clamp_metric(value):
    tolerance = 1e-12
    if not math.isfinite(value) or value &amp;lt; -tolerance or value &amp;gt; 1.0 + tolerance:
        raise ValueError(...)
    return float(min(1.0, max(0.0, value)))
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;what-broke-and-what-i-would-do-differently&quot;&gt;What broke, and what I would do differently&lt;/h3&gt;
&lt;p&gt;The gold labels are per document, not per chunk. That is the real limitation here. If a relevant document is 40 chunks long and only 2 of those chunks actually answer the query, my precision metric happily credits all 40 as relevant when they surface. So this setup answers &quot;does this strategy retrieve from the right documents,&quot; and it is blind to whether it retrieves the right passage inside them. For a first pass on a 200-document corpus that is a reasonable trade, but it means a chunker that fragments a relevant document into many pieces can look better than one that pulls a single tight passage, purely because more of its chunks share a gold doc_id. Chunk-level labels would fix this and are the obvious next step.&lt;/p&gt;
&lt;p&gt;The other thing I would change is the sample size before trusting any ranking. 15 queries, 3 per domain, is enough to see a large effect and nowhere near enough to call a small one. EvalBench already publishes Wilson intervals next to every mean for exactly this reason, so the leaderboard shows the uncertainty instead of hiding it. If two strategies land within each other's intervals at n=15, the correct read is &quot;no measured difference,&quot; not &quot;semantic wins by a hair.&quot; I would rather report a wide interval than a confident wrong number.&lt;/p&gt;
&lt;h3 id=&quot;takeaway&quot;&gt;Takeaway&lt;/h3&gt;
&lt;p&gt;If you want to know whether semantic chunking helps, hold the embedder, corpus, queries, and token ceiling fixed, and vary only the split. Then read the precision with its confidence interval attached. A comparison where three things moved at once tells you nothing about the one you changed.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24840/measuring-whether-semantic-chunking-actually-helps-on-one-fixed-corpus</guid>
<pubDate>Tue, 18 Aug 2026 16:08:46 +0000</pubDate>
</item>
<item>
<title>The four axes generic RAG eval forgets for search agents</title>
<link>https://coderlegion.com/24823/the-four-axes-generic-rag-eval-forgets-for-search-agents</link>
<description>&lt;p&gt;A search-augmented agent looks like RAG until the citation list breaks. The first user clicks through, lands on a 404, and the trust budget spends in one impression. Three months of eval work, groundedness and context adherence and factual accuracy all green, did not catch it.&lt;/p&gt;
&lt;p&gt;The reason is structural. Generic RAG eval was built for a frozen vector store and a stable ground truth. Search agents have neither. The corpus is the live web, the agent writes the literal query string that drives what comes back, sources span government filings and LinkedIn takes in one result set, and users click the URLs. Skip the four axes unique to this shape (query quality, source freshness, source diversity, citation validity) and you ship an agent that confidently cites stale or fabricated URLs. The rest of RAG eval still applies, but the rest of RAG eval was already passing. There is a longer &lt;a rel=&quot;nofollow&quot; href=&quot;https://futureagi.com/blog/evaluating-search-augmented-agents-2026/?utm_source=fagiCoderlegion&amp;amp;utm_medium=organic&amp;amp;utm_campaign=blogdistribution&quot;&gt;version with the rubric code&lt;/a&gt; if you want it.&lt;/p&gt;
&lt;p&gt;Three differences pull this out of the RAG bucket. The corpus is not yours: two runs of the same query twelve hours apart return different URLs, so reproducibility lives in a snapshot, not the results page. The query is the lever: search is sensitive to the literal string the agent writes, and a rewrite that drops a constraint returns a clean-looking set that misses it, which no synthesis prompt recovers from. And the user verifies: a citation is a URL the user clicks, so a fabricated or 404 link is a one-click trust failure that groundedness against the retrieved set never sees.&lt;/p&gt;
&lt;p&gt;Score the four axes per query, not aggregated. A single number hides which one collapsed, and the diagnostic value is in the vector.&lt;/p&gt;
&lt;h3 id=&quot;axis-1-query-quality&quot;&gt;Axis 1: query quality&lt;/h3&gt;
&lt;p&gt;The agent turns the user's prompt into a search string, and that string is what the engine literally reads. Three failure modes show up in almost every run.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Constraint drop.&lt;/strong&gt; &quot;Laptops with battery over 10 hours&quot; compresses to &quot;best laptops 2026 for ML,&quot; and the results average five-hour batteries. Groundedness is fine because the brief grounded itself in the retrieved set. The constraint is gone.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Entity loss.&lt;/strong&gt; A question about a specific company or regulation abstracts to the category, and the brief answers a different question well.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Over-broad rewrite.&lt;/strong&gt; A narrow factual lookup expands into a topic, the search returns a Wikipedia overview, and the brief paraphrases it and misses the fact.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Score it with a judge that takes the original prompt and the rewrite as a pair and rates constraint preservation and entity recall 1 to 5, penalizing unnecessary expansion as much as a drop. Run this over a week of traffic and the first cluster is usually &quot;rewriter dropped the year, version, or region&quot; on roughly one in eight queries. Cheapest fix in the loop, and the one most teams skip.&lt;/p&gt;
&lt;h3 id=&quot;axis-2-source-freshness&quot;&gt;Axis 2: source freshness&lt;/h3&gt;
&lt;p&gt;Freshness is a function of the question, not a global setting. &quot;Capital of France&quot; is insensitive. &quot;Current price of X&quot; is real-time. &quot;FDA guidance on AI as a medical device&quot; is quarter-sensitive. The agent has to decide the regime and behave: prefer recent sources, refuse stale ones, and add a recency caveat when the answer depends on current data.&lt;/p&gt;
&lt;p&gt;Two checks carry it. Classify the freshness regime per query (insensitive, slow, fast, real-time) and attach it as a span attribute. Then attach a recency value per source, take the median recency of the cited sources, and fail when it exceeds the floor for that regime. The stale-fact bug is the one users notice last and complain about loudest: correct the day the source was indexed, wrong every day after, and invisible to every other metric.&lt;/p&gt;
&lt;h3 id=&quot;axis-3-source-diversity&quot;&gt;Axis 3: source diversity&lt;/h3&gt;
&lt;p&gt;A general search returns Wikipedia, vendor blogs, news, government docs, and SEO spam in one set, and the agent cites three to five. The failure is monoculture: every citation from one domain, or a brief that reads like consensus when it summarizes one tab.&lt;/p&gt;
&lt;p&gt;Three checks cover it, and the first two are deterministic and run in microseconds:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unique-domain count&lt;/strong&gt; per citation set, with a floor by question class. A date lookup can be tight; a synthesis question needs four or more.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-domain cap.&lt;/strong&gt; No domain contributes more than two citations, unless the question is about that domain (an EU AI Act question can cite eur-lex repeatedly).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Primary-vs-secondary ratio.&lt;/strong&gt; Classify each citation as primary (a filing, paper, or announcement) or secondary (commentary, aggregator). A regulatory question with zero primary citations is a failure however grounded the brief is.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;axis-4-citation-validity&quot;&gt;Axis 4: citation validity&lt;/h3&gt;
&lt;p&gt;The highest-signal cheap check in the whole stack, and the one most teams find only after the first complaint. Three sub-checks compose it.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The URL resolves.&lt;/strong&gt; HEAD-request every citation; a non-2xx or a redirect to a different domain is a fail. Tens of milliseconds, and it rejects fabricated and stale links before the user sees them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The passage exists on the page.&lt;/strong&gt; Fetch the body and confirm the quoted or paraphrased passage is actually there. A paraphrase that drifts past a similarity threshold is a fail. This catches a real URL with an invented quote.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The claim is supported by that passage.&lt;/strong&gt; Groundedness and chunk attribution, but per claim, not per answer. A brief can be 0.94 grounded as a whole and 0.61 aligned per claim, and users only see the per-claim view.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Wire it as two stages: the deterministic checks (resolves, passage present) as a hard gate, the claim-passage entailment as a judge on the synthesis span. A citation that fails any check is a hard reject: log it, regenerate the answer with that URL excluded, and re-run the gate. Teams that wire this stop seeing the &quot;user clicked a 404&quot; failure almost immediately.&lt;/p&gt;
&lt;h3 id=&quot;cache-the-snapshot-so-ci-does-not-flake&quot;&gt;Cache the snapshot so CI does not flake&lt;/h3&gt;
&lt;p&gt;Two runs of the same prompt hours apart return different URLs, so without a per-test-case cache the gate flakes for reasons unrelated to the agent and the team stops trusting it. Cache three layers: the search call (query and full result JSON), the page fetch (URL, body, timestamp), and the extraction (cleaned text, date, anchors). Replay every run from cache, and re-snapshot deliberately on a schedule that fits the domain (weekly for news, monthly for stable how-to agents), treating each re-snapshot as a new golden-set version with its own baseline.&lt;/p&gt;
&lt;h3 id=&quot;trace-it-so-a-bad-answer-is-debuggable&quot;&gt;Trace it so a bad answer is debuggable&lt;/h3&gt;
&lt;p&gt;The eval scores attach to spans, and the spans are how you debug. Three span kinds carry the load: a retriever span per search call, carrying the literal query the agent sent plus per-source URL, recency, and authority; a tool span for the wire call to the search API, so latency and cost land on the right node; and an LLM span for the synthesis. Splitting the search call from the retriever span keeps the query-quality input and the network cost on separate, queryable nodes.&lt;/p&gt;
&lt;p&gt;Run the four axes per query and read the vector. The axis that drops is the bug, and the standard RAG metrics keep running alongside because they are necessary, just not sufficient.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24823/the-four-axes-generic-rag-eval-forgets-for-search-agents</guid>
<pubDate>Tue, 18 Aug 2026 09:20:10 +0000</pubDate>
</item>
<item>
<title>Why Production Agents Are Ditching Pure RAG for Context Caching</title>
<link>https://coderlegion.com/24714/why-production-agents-are-ditching-pure-rag-for-context-caching</link>
<description>&lt;h2 id=&quot;why-production-agents-are-ditching-pure-rag-for-context-caching&quot;&gt;Why Production Agents Are Ditching Pure RAG for Context Caching&lt;/h2&gt;
&lt;p&gt;Three months ago, our agentic workflow hit a cost wall. We were running a multi-step research agent that performed iterative document analysis. Every user request triggered between 12 and 18 agent steps. Across 50,000 active daily sessions, our vector database query costs and LLM input token bills were spiraling out of control—and worse, median step latency hovered around 4.2 seconds.&lt;/p&gt;
&lt;p&gt;Like most teams building in 2024 and 2025, we assumed the fix was &quot;better vector search.&quot; We tweaked top-k parameters, tried hybrid dense-sparse reranking, and chunked documents down to tiny 256-token fragments. It didn't fix the core problem. The bottleneck wasn't retrieval quality; it was retrieval architecture.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In production agentic systems, replacing pure vector retrieval-augmented generation (RAG) with long-context prompt caching (Cache-Augmented Generation or CAG) combined with lightweight local router models reduces median agent step latency by up to 68% and cuts monthly API inference spend by over 80%. This architectural pattern leverages deterministic context reuse over fragment chunking.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;tl-dr-key-engineering-takeaways&quot;&gt;TL;DR / Key Engineering Takeaways&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Vector RAG introduces compounding latency in agent loops:&lt;/strong&gt; Each agent hop requires vector embedding, similarity search, top-k ranking, and prompt assembly, multiplying network overhead across multi-turn sessions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Context Caching eliminates redundant prefix processing:&lt;/strong&gt; Modern API providers allow long documentation, system schemas, and conversation histories to be cached at the edge, reducing token input costs by 75%–90% on cached prefixes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hybrid Local Routing stops token leakage:&lt;/strong&gt; Routing lightweight classification, routing, and tool-selection decisions to local 8B–30B models reserves heavy frontier models only for final synthesis.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The 2026 Agent Standard is hybrid:&lt;/strong&gt; Vector search handles sparse global discovery across millions of items, while context caching handles the dense session working memory.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-production-bottleneck-why-vector-rag-struggles-in-agent-loops&quot;&gt;The Production Bottleneck: Why Vector RAG Struggles in Agent Loops&lt;/h3&gt;
&lt;p&gt;When we first built our agentic pipeline, we followed the standard textbook pattern. Every agent step extracted state, searched a vector index, pulled the top-5 chunk snippets, and fed them into the prompt window alongside system instructions.&lt;/p&gt;
&lt;p&gt;It worked great in prototypes. But once we moved to multi-agent orchestration—where agents delegate sub-tasks, summarize intermediate progress, and call tools repeatedly—the flaws became obvious.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ Traditional Vector RAG Agent Step ]
State -&amp;gt; Query Gen -&amp;gt; Embed Query -&amp;gt; Vector DB Search -&amp;gt; Top-K Rerank -&amp;gt; Build Prompt -&amp;gt; LLM Inference
Total Step Latency: ~3.8s - 5.2s (Repeated for 15 steps = ~60s total session time)

[ Context Caching / CAG Agent Step ]
State + System Context (Pre-Cached Prefix) -&amp;gt; LLM Inference (Cached Hit)
Total Step Latency: ~1.1s - 1.6s (Repeated for 15 steps = ~18s total session time)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In an agentic loop, context is not static; it evolves incrementally. In our earlier writing on &lt;a rel=&quot;nofollow&quot; href=&quot;https://medium.com/@minhle_0210/prompt-context-harness-loop-an-agents-anatomy-642db41429fb&quot;&gt;what an agent actually is&lt;/a&gt;, we broke down how an agent's harness relies on continuous feedback loops. Re-embedding queries and assembling fragmented chunks at every step creates three major friction points:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Lost Semantic Coherence:&lt;/strong&gt; Chunking long technical specifications or codebase trees into 500-token snippets strips away critical cross-document relationships.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Double-Payment on Tokens:&lt;/strong&gt; You pay vector database read costs &lt;em&gt;and&lt;/em&gt; re-pay LLM input token processing fees for text the model already parsed two steps ago.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Compounding Latency:&lt;/strong&gt; Adding 300ms vector index latency across a 15-step agent execution loop adds nearly 5 seconds of pure overhead to every user request.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;enter-context-caching-cag-how-deterministic-prefix-reuse-works&quot;&gt;Enter Context Caching (CAG): How Deterministic Prefix Reuse Works&lt;/h3&gt;
&lt;p&gt;Context Caching changes the economic math entirely. Instead of searching a vector database for relevant fragments on every turn, we load entire documentation bundles, domain schemas, or codebases directly into the LLM's extended context window (128k to 1M tokens) and cache the KV-cache states at the provider layer.&lt;/p&gt;
&lt;p&gt;When the agent executes subsequent steps, the model processes only the new delta tokens (e.g., tool call outputs or user turns). The static prefix—which contains 95% of the total tokens—is retrieved instantly from the cached KV state.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;+-------------------------------------------------------------------------+
| CACHED PREFIX (Static - 95% of Tokens)                                  |
| System Persona + API Schemas + Entire Core Codebase / Knowledge Base   |
| (Cost: ~90% Discount / Latency: ~50ms TTFT)                             |
+-------------------------------------------------------------------------+
| UNCACHED DELTA (Dynamic - 5% of Tokens)                                 |
| Current State + Latest Tool Execution Result + User Query               |
+-------------------------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As we explored in our breakdown &lt;a rel=&quot;nofollow&quot; href=&quot;https://ai.plainenglish.io/from-rag-to-cag-a-smarter-approach-to-ai-c0a9f21cd6a0&quot;&gt;&lt;em&gt;From RAG to CAG: A Smarter Approach to AI&lt;/em&gt;&lt;/a&gt;, shifting from retrieval-on-demand to context pre-loading simplifies system design. You eliminate vector index maintenance, embedding pipelines, and chunking heuristics.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;empirical-benchmark-vector-rag-vs-context-caching-vs-local-hybrid&quot;&gt;Empirical Benchmark: Vector RAG vs. Context Caching vs. Local Hybrid&lt;/h3&gt;
&lt;p&gt;To measure the exact performance difference, we benchmarked three architectural approaches across 1,000 multi-step technical research tasks (averaging 12 agent turns per task, analyzing a 100,000-token technical repository):&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Metric &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Standard Vector RAG (Top-K=10) &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Pure Context Caching (CAG) &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Hybrid Router + Context Caching&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Median Step Latency (TTFT)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;3,840 ms &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;1,210 ms &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;890 ms&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Total Session Latency (12 turns)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;46.08 s &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;14.52 s &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;10.68 s&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Input Token Cost / Session&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;$0.184 &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;$0.038 &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;$0.021&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Task Accuracy / Completion Rate&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;78.4% &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;91.2% &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;92.6%&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Vector DB Read Cost / 1k Sessions&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;$12.50 &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;$0.00 &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;$0.00&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;benchmark-takeaways-&quot;&gt;Benchmark Takeaways:&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Latency:&lt;/strong&gt; Context Caching dropped Time-to-First-Token (TTFT) by &lt;strong&gt;68.4%&lt;/strong&gt; compared to traditional vector RAG because the prompt prefix didn't require re-computation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost:&lt;/strong&gt; Token costs dropped &lt;strong&gt;79.3%&lt;/strong&gt; under pure CAG and &lt;strong&gt;88.5%&lt;/strong&gt; under the Hybrid Router approach because cached tokens incur a fraction of standard input pricing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Accuracy:&lt;/strong&gt; Full-context availability eliminated RAG retrieval failures (where vector search missed the relevant chunk due to poor keyword matching or semantic drift).&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;architectural-deep-dive-building-the-hybrid-local-router-pattern&quot;&gt;Architectural Deep Dive: Building the Hybrid Local Router Pattern&lt;/h3&gt;
&lt;p&gt;While Context Caching is powerful, pre-loading 100k tokens into a frontier LLM on every step can still be wasteful if the agent only needs to make a binary decision or select a simple tool.&lt;/p&gt;
&lt;p&gt;To optimize latency further, we introduced a &lt;strong&gt;Local Hybrid Router&lt;/strong&gt;. &lt;/p&gt;
&lt;h4 id=&quot;how-the-hybrid-router-architecture-works&quot;&gt;How the Hybrid Router Architecture Works&lt;/h4&gt;
&lt;pre&gt;&lt;code&gt;                        +----------------------------+
                        |     Incoming User Request  |
                        +----------------------------+
                                      |
                                      v
                        +----------------------------+
                        |  Local Router (8B / 30B)   |
                        | (Tool Choice / State Check)|
                        +----------------------------+
                               /              \
                              /                \
          Simple Action / Tool Call          Complex Reasoning / Synthesis
                            /                    \
                           v                      v
           +-----------------------+   +------------------------------------+
           | Local Execution Loop  |   | Frontier LLM with Context Caching  |
           | Fast &amp;amp; Zero-Cost API  |   | Cached KV Prefix (100k+ Tokens)    |
           +-----------------------+   +------------------------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Step 1: Local Triage:&lt;/strong&gt; Incoming step state is evaluated by a small local model (e.g., an 8B model running on local GPU infrastructure or edge endpoints).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Step 2: Simple Execution:&lt;/strong&gt; If the step is a mechanical tool invocation (e.g., &lt;code&gt;git log&lt;/code&gt;, &lt;code&gt;read_file&lt;/code&gt;, &lt;code&gt;check_status&lt;/code&gt;), the local model executes it directly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Step 3: Frontier Synthesis:&lt;/strong&gt; If the step requires deep reasoning or complex synthesis across the codebase, the request routes to the frontier LLM backed by the cached KV prefix.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This hybrid approach mirrors the principles we outlined in our guide on &lt;a rel=&quot;nofollow&quot; href=&quot;https://ai.plainenglish.io/top-k-at-scale-a-real-time-ranking-system-walkthrough-5708fd20f6f2&quot;&gt;real-time ranking systems at scale&lt;/a&gt;: decouple high-frequency lightweight operations from heavy, resource-intensive compute paths.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-vector-rag-still-wins-and-when-to-avoid-cag-&quot;&gt;When Vector RAG Still Wins (And When to Avoid CAG)&lt;/h3&gt;
&lt;p&gt;Context Caching is not a silver bullet. During our production migration, we identified explicit boundaries where pure CAG fails and vector RAG remains necessary:&lt;/p&gt;
&lt;h4 id=&quot;1-corpus-size-exceeds-window-capacity&quot;&gt;1. Corpus Size Exceeds Window Capacity&lt;/h4&gt;
&lt;p&gt;If your domain knowledge base spans millions of documents (e.g., 500 million tokens of legal case law), pre-loading the entire corpus into context is impossible or prohibitively expensive.&lt;/p&gt;
&lt;h4 id=&quot;2-low-prefix-reuse-ratio&quot;&gt;2. Low Prefix Reuse Ratio&lt;/h4&gt;
&lt;p&gt;Context caching relies on prefix stability. If user queries are entirely distinct and share no common system prompt, documentation context, or conversation history, you pay cache-creation surcharges without reaping cache-hit discounts.&lt;/p&gt;
&lt;h4 id=&quot;3-dynamic-write-heavy-databases&quot;&gt;3. Dynamic Write-Heavy Databases&lt;/h4&gt;
&lt;p&gt;If your knowledge base updates thousands of times per minute, invalidating the cached KV prefix on every write negates the performance benefits of caching.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;lessons-learned-amp-production-migration-checklist&quot;&gt;Lessons Learned &amp;amp; Production Migration Checklist&lt;/h3&gt;
&lt;p&gt;If you are currently operating a production RAG pipeline and struggling with latency or costs, here is the incremental migration path we recommend:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;[x] &lt;strong&gt;Audit your prompt prefix stability:&lt;/strong&gt; Group your agent prompts into static prefixes (system instructions, tool definitions, reference docs) and dynamic suffixes (user query, latest message).&lt;/li&gt;
&lt;li&gt;[x] &lt;strong&gt;Enable provider-level prompt caching:&lt;/strong&gt; Activate KV-caching on your primary LLM endpoints (Claude, Gemini, or OpenAI) to instantly capture 50%–75% input cost savings on static prefixes.&lt;/li&gt;
&lt;li&gt;[x] &lt;strong&gt;Implement a hybrid retrieval gate:&lt;/strong&gt; Use vector search only for initial broad document discovery (filtering 10M tokens down to 100k tokens), then cache that 100k-token working set for the rest of the agent session.&lt;/li&gt;
&lt;li&gt;[x] &lt;strong&gt;Offload simple tool routing to local models:&lt;/strong&gt; Deploy a small local model to handle state-tracking and simple tool selections without waking up the frontier LLM.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;conclusion-1&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;Building reliable agentic systems requires moving past prototype patterns. Vector RAG served us well during the early era of short context windows, but in 2026, forcing multi-turn agents to constantly query vector databases is like making a developer query a database for every line of code they write.&lt;/p&gt;
&lt;p&gt;By combining long-context prompt caching with intelligent local routing, you give your agents the entire working memory they need while slashing inference costs and step latency.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24714/why-production-agents-are-ditching-pure-rag-for-context-caching</guid>
<pubDate>Sun, 16 Aug 2026 10:07:01 +0000</pubDate>
</item>
<item>
<title>RAG on Excel with LlamaParse</title>
<link>https://coderlegion.com/24570/rag-on-excel-with-llamaparse</link>
<description>&lt;p&gt;RAG on Excel.&lt;/p&gt;
&lt;p&gt;To perform RAG on documents like PDFs, we split the data and create chunks to generate vector embeddings, right?&lt;/p&gt;
&lt;p&gt;But how can we split data which is in tabular format?&lt;/p&gt;
&lt;p&gt;In this video, let's discuss how to perform RAG on an Excel file using Llama Parse&lt;/p&gt;
&lt;p&gt;Code Notebook - &lt;a rel=&quot;nofollow&quot; href=&quot;https://lnkd.in/dgCjjrZr&quot;&gt;https://lnkd.in/dgCjjrZr&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;About &quot;AI ML etc.&quot;&lt;/p&gt;
&lt;p&gt;We have reimagined AI education for senior IT professionals and specifically designed AI courses for them.&lt;/p&gt;
&lt;p&gt;These courses cover essential AI concepts, AI engineering, Indutsry best practices and AI Management topics.&lt;/p&gt;
&lt;p&gt;340+ learners from reputed organisations like Microsoft, Google, Nvidia, Meta, Nagarro, Bank of America, Infosys, Maersk and more have taken our courses&lt;/p&gt;
</description>
<category>Videos</category>
<guid isPermaLink="true">https://coderlegion.com/24570/rag-on-excel-with-llamaparse</guid>
<pubDate>Thu, 13 Aug 2026 05:25:00 +0000</pubDate>
</item>
<item>
<title>How to Tell When Your LLM's Knowledge Actually Stops</title>
<link>https://coderlegion.com/24432/how-to-tell-when-your-llms-knowledge-actually-stops</link>
<description>&lt;p&gt;Every AI model has a knowledge cutoff - but the official date rarely captures the whole story.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Hidden Gap Between Training and Knowing&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Model providers list a knowledge cutoff date, but that figure is deceptive in practice. According to research on model training practices, training data isn't sampled evenly across time, and content published close to the cutoff is underrepresented because the web hadn't fully indexed, discussed, and linked to it yet when the training crawl ran. The result: a model with a stated cutoff of, say, late 2024 often behaves as though its reliable knowledge ends several months earlier. Call it a soft cutoff: the point where confident, well-corroborated knowledge fades into thin, patchy coverage.&lt;/p&gt;
&lt;p&gt;This matters whenever you're building with LLMs for anything time-sensitive: a research assistant, a competitor-monitoring tool, a news summarizer, or a RAG pipeline (retrieval-augmented generation - a pattern where you inject fresh documents into the model's context at query time). Knowing the soft cutoff lets you determine how much to trust the model's parametric memory versus forcing retrieval for recent facts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real Example: Probing the Soft Cutoff Directly&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You can empirically test where a model's knowledge gets shaky with a simple prompt pattern. Run this against whichever model you're using:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;List 5 significant events in [domain] from [month, year].
For each, rate your confidence 1-10 and explain any uncertainty.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Start from a date you know is well within the cutoff, then step forward month by month until confidence scores drop or the model starts hedging heavily. That inflection point is your practical soft cutoff for that domain.&lt;/p&gt;
&lt;p&gt;For a more systematic check in a pipeline context, you can log model responses against ground-truth dates:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def check_cutoff_confidence(client, domain, year, month):
 prompt = f&quot;List 3 major {domain} events from {month}/{year}. Rate confidence 1-10.&quot;
 response = client.chat(prompt)
 return {&quot;period&quot;: f&quot;{year}-{month:02d}&quot;, &quot;response&quot;: response}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Run this across a date range and you'll see a clear confidence degradation pattern - usually 3 to 6 months before the official cutoff date.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The official cutoff date overstates reliable knowledge - recent months before that date are thinly represented in training data.&lt;/li&gt;
&lt;li&gt;Prompt-based probing can reveal the practical soft cutoff for a specific domain, which may differ from the model's general cutoff.&lt;/li&gt;
&lt;li&gt;For any workflow touching recent events, treat anything within 6 months of the cutoff as retrieval territory, not memory territory - use RAG or live search rather than relying on the model's parametric knowledge.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When you've tested this pattern against different models or domains, did the soft cutoff land earlier or later than the official date in practice?&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Sources referenced: HackerNews - Exploring Claude/GPT Knowledge Cutoffs and Pre-Training Timelines&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24432/how-to-tell-when-your-llms-knowledge-actually-stops</guid>
<pubDate>Tue, 11 Aug 2026 07:38:10 +0000</pubDate>
</item>
<item>
<title>Fine-Tuning vs Prompt Engineering vs RAG: When to Use Each (And the Fourth Option Nobody Mentions)</title>
<link>https://coderlegion.com/24338/fine-tuning-prompt-engineering-vs-rag-when-use-each-and-the-fourth-option-nobody-mentions</link>
<description>&lt;p&gt;You have an AI model that hallucinates with your company's data. You open a support ticket and ask the chatbot about your returns policy. The chatbot, powered by GPT-4 or Gemini 2.5, responds with a fabricated policy that sounds perfectly plausible but has nothing to do with your company's reality. Your boss stares at you. Your customer complains. You open Google and search &lt;strong&gt;&quot;how to connect LLM to my data&quot;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The top 10 results offer three contradictory answers: &quot;use RAG,&quot; &quot;do fine-tuning,&quot; &quot;improve your prompt.&quot; All three are right. All three are wrong. Because the correct answer isn't any of the three in the abstract — it's &lt;strong&gt;the one that fits your specific use case&lt;/strong&gt;. And there's a fourth option that almost nobody mentions, and which, in my experience, is the right call in more cases than the industry admits.&lt;/p&gt;
&lt;p&gt;This article is the decision tree I wish I'd had when I started building the AI systems behind this blog. I've distilled it after implementing all four techniques in real production: Prompt Engineering across the entire &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part1/&quot;&gt;Autopilot series&lt;/a&gt;, RAG in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part8/&quot;&gt;Ops Copilot&lt;/a&gt; with Algolia, pure Tool Calling in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/obs_part5_radar_agent/&quot;&gt;Obsolescence Radar&lt;/a&gt;, and experimental fine-tuning in industrial classification pipelines. It closes the trilogy that began with &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/rag_antipatterns/&quot;&gt;RAG: 7 Anti-Patterns&lt;/a&gt; and continued with &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/mcp_protocol/&quot;&gt;MCP Protocol&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;the-question-nobody-asks&quot;&gt;The Question Nobody Asks&lt;/h4&gt;
&lt;p&gt;Before choosing a technique, ask yourself this: &lt;strong&gt;Does the knowledge your LLM needs change, or is it static?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If the answer is &quot;it changes frequently&quot; (product documentation, inventory, prices, regulations), you need a technique that accesses data &lt;strong&gt;in real time&lt;/strong&gt; without retraining the model. If the answer is &quot;it's static or changes very slowly&quot; (brand tone, formatting rules, domain nomenclature), you can consider techniques that &lt;strong&gt;incorporate that knowledge into the model&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This distinction is the first node of the decision tree. It seems obvious written this way. Yet most teams I've seen jump straight to whatever technique is trending (RAG in 2024, fine-tuning in 2023, prompt engineering always) without asking this fundamental question.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/es/posts/finetuning_vs_rag/decision_tree.jpg&quot; alt=&quot;Decision tree: how to choose between the 4 techniques&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;option-1-prompt-engineering-80-of-cases&quot;&gt;Option 1: Prompt Engineering — 80% of Cases&lt;/h4&gt;
&lt;p&gt;The uncomfortable truth the AI tooling industry doesn't want you to know: &lt;strong&gt;for 80% of use cases, a well-designed prompt is sufficient&lt;/strong&gt;. You don't need RAG. You don't need fine-tuning. You need a system prompt that clearly defines the role, context, constraints, and expected output format.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When it's sufficient&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The required knowledge fits in the model's context window (Gemini 2.5 handles up to 1 million tokens; Claude up to 200K).&lt;/li&gt;
&lt;li&gt;The task is generic but needs structure (drafting emails, summarizing documents, classifying text, generating code).&lt;/li&gt;
&lt;li&gt;You don't need updated proprietary data — the model's general knowledge is enough.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Advanced techniques that make the difference&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Structured system prompts&lt;/strong&gt;: Define the role (&quot;You are a senior supply chain engineer&quot;), constraints (&quot;Always respond in technical English&quot;), and output format (&quot;Return a JSON with fields: analysis, recommendation, confidence&quot;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Few-shot prompting&lt;/strong&gt;: Include 3-5 correct input-output examples in the prompt. In the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part3/&quot;&gt;Autopilot series&lt;/a&gt;, CrewAI agents use few-shot to maintain style consistency across generated articles.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chain-of-thought (CoT)&lt;/strong&gt;: Instruct the model to &quot;think step by step&quot; before giving the final answer. Dramatically improves accuracy in reasoning, calculation, and multi-step analysis tasks.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prompt chaining&lt;/strong&gt;: Break complex tasks into sequential subtasks, each with its own optimized prompt. This is exactly what CrewAI does with its agent architecture: each agent has a specialized prompt for its role.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Cost&lt;/strong&gt;: Virtually zero (only API token cost). A well-designed prompt can take hours of iteration, but operational cost is minimal.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fatal limitation&lt;/strong&gt;: The context window has a limit. If you need the model to &quot;know&quot; about 10,000 documents from your knowledge base, you can't inject them all into the prompt. This is where RAG enters.&lt;/p&gt;
&lt;h4 id=&quot;option-2-rag-updatable-proprietary-knowledge&quot;&gt;Option 2: RAG — Updatable Proprietary Knowledge&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;RAG (Retrieval-Augmented Generation)&lt;/strong&gt; is the right answer when you need the LLM to respond about &lt;strong&gt;your proprietary knowledge&lt;/strong&gt; and that knowledge &lt;strong&gt;updates frequently&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When it's necessary&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Product documentation, technical manuals, internal knowledge bases that update weekly or monthly.&lt;/li&gt;
&lt;li&gt;The user can ask unpredictable questions about a broad document corpus (you don't know in advance which fragment the LLM will need).&lt;/li&gt;
&lt;li&gt;You need &lt;strong&gt;citability&lt;/strong&gt;: the answer must include the sources it draws from (critical for compliance, as we documented in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/eu_ai_act/&quot;&gt;EU AI Act&lt;/a&gt;, Article 13 on transparency).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;When NOT to use it&lt;/strong&gt;: When data is structured (SQL tables, APIs with defined schemas) or when you need numerical precision. As I extensively documented in &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/rag_antipatterns/&quot;&gt;Anti-Pattern 7 of the RAG article&lt;/a&gt;, RAG over structured data generates narrative hallucinations where you need exact figures.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Correct architecture (summarized)&lt;/strong&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Semantic chunking&lt;/strong&gt; (not fixed-length — Anti-Pattern 1)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evaluated embeddings&lt;/strong&gt; with your domain benchmark (Anti-Pattern 2)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reranking&lt;/strong&gt; between retriever and LLM (Anti-Pattern 3)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generous context&lt;/strong&gt; (top-10/15, not top-3 — Anti-Pattern 4)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evaluation with RAGAS/DeepEval&lt;/strong&gt; before production (Anti-Pattern 6)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;Real cost&lt;/strong&gt;: Moderate. The vector store (Pinecone, Weaviate, Algolia) has a monthly cost (€0-100 depending on volume), plus embedding cost (low) and generation cost (API tokens). In the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part8/&quot;&gt;Ops Copilot&lt;/a&gt;, the total RAG cost with Algolia was under &lt;strong&gt;€3/month&lt;/strong&gt; for the blog's ~70 posts.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real case at Datalaria&lt;/strong&gt;: The Ops Engineering Copilot (&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part8/&quot;&gt;Autopilot Part 8&lt;/a&gt;) uses RAG with Algolia Agent Studio to answer questions about blog content. Posts are indexed as semantic records (one record per section), and the copilot retrieves relevant fragments before generating the response. Works well for semantic search over free text.&lt;/p&gt;
&lt;h4 id=&quot;option-3-fine-tuning-the-scalpel-not-the-hammer&quot;&gt;Option 3: Fine-Tuning — The Scalpel, Not the Hammer&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;Fine-tuning&lt;/strong&gt; is the most powerful technique and the most misused. It involves &lt;strong&gt;partially retraining&lt;/strong&gt; a base model (Gemini, Llama, Mistral) with your own data so the model internalizes specific knowledge, style, or behavior.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When it's essential&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;You need the model to adopt a &lt;strong&gt;very specific tone or style&lt;/strong&gt; consistently (a brand with strict voice &amp;amp; tone, a domain with very particular technical jargon).&lt;/li&gt;
&lt;li&gt;The task is &lt;strong&gt;highly specialized&lt;/strong&gt; and generalist models don't solve it well even with advanced prompting (industrial defect classification, proprietary nomenclature entity extraction, specialized medical diagnosis).&lt;/li&gt;
&lt;li&gt;You need to &lt;strong&gt;reduce latency and cost&lt;/strong&gt; in production: a fine-tuned smaller model (7B-13B parameters) can match the quality of a large model (70B+) on your specific task, at a fraction of the cost and latency.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;When NOT to use it&lt;/strong&gt; (the most widespread myth):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Don't use fine-tuning to &quot;teach the model data.&quot;&lt;/strong&gt; Fine-tuning is not a database. If you need the model to know your product catalog, use RAG. Fine-tuning &quot;burns in&quot; behavioral patterns, not updatable facts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don't use fine-tuning if your knowledge changes frequently.&lt;/strong&gt; Each update requires retraining, which can cost hours and hundreds of euros. RAG is instant: update the document and the retriever finds it immediately.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Modern tools&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;LoRA (Low-Rank Adaptation)&lt;/strong&gt;: The standard technique. Instead of retraining the model's billions of parameters, LoRA trains only low-rank matrices &quot;attached&quot; to the model's layers. Reduces training cost by 90%+ and stores the fine-tuned model as a few MB of &quot;adapters.&quot;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;QLoRA&lt;/strong&gt;: LoRA applied to a 4-bit quantized model. Enables fine-tuning 70B-parameter models on a single consumer GPU (24GB VRAM). Democratized fine-tuning for startups and teams without GPU clusters.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vertex AI Tuning / OpenAI Fine-Tuning API&lt;/strong&gt;: Managed services where you upload your training dataset (instruction-response pairs) and the platform runs the fine-tuning without you managing GPU infrastructure.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Real cost&lt;/strong&gt;: Variable. Fine-tuning with LoRA on a 7B-parameter model with 10,000 examples costs &lt;strong&gt;€5-20&lt;/strong&gt; on cloud (Google Cloud, AWS). A 70B model can cost &lt;strong&gt;€50-200&lt;/strong&gt; per training session. Plus the cost of preparing the dataset (hours of human work). As we analyzed in &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/hidden_economics_ai/&quot;&gt;The Hidden Economics of AI&lt;/a&gt;, fine-tuning's hidden cost isn't compute — it's &lt;strong&gt;training dataset curation&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;option-4-tool-calling-mcp-the-one-nobody-mentions&quot;&gt;Option 4: Tool Calling / MCP — The One Nobody Mentions&lt;/h4&gt;
&lt;p&gt;This is the option I discovered by elimination after RAG failed spectacularly on the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/obs_part5_radar_agent/&quot;&gt;Obsolescence Radar&lt;/a&gt;. &lt;strong&gt;Tool Calling&lt;/strong&gt; means the LLM doesn't try to &quot;know&quot; the answer; instead, it knows &lt;strong&gt;who to ask&lt;/strong&gt; — that is, which tool to execute to get the information with deterministic precision.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When it's the right option&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Data is &lt;strong&gt;structured&lt;/strong&gt; (SQL databases, REST APIs, spreadsheets with schemas).&lt;/li&gt;
&lt;li&gt;You need &lt;strong&gt;absolute numerical precision&lt;/strong&gt; (financial calculations, inventory metrics, sensor data).&lt;/li&gt;
&lt;li&gt;The operation requires &lt;strong&gt;actions&lt;/strong&gt;, not just answers (create a ticket, send an email, run a query, call an external API).&lt;/li&gt;
&lt;li&gt;You want to &lt;strong&gt;standardize connections&lt;/strong&gt; between the LLM and tools to avoid vendor lock-in — exactly the problem solved by &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/mcp_protocol/&quot;&gt;MCP (Model Context Protocol)&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Architecture&lt;/strong&gt;: The LLM (Gemini 2.5, Claude) acts as a &lt;strong&gt;semantic orchestrator&lt;/strong&gt;: it understands the user's natural language intent, decides which tool(s) to execute, constructs the parameters, executes the tool(s), and interprets results for the user. Tools are deterministic Python functions (decorated with &lt;code&gt;@tool&lt;/code&gt; in CrewAI) that execute precision operations: SQL queries to Supabase, supplier API calls, linear programming calculations with PuLP.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cost&lt;/strong&gt;: The lowest of all four options. You only pay for LLM tokens (typically few, since the prompt is short) and tool execution (SQL queries, API calls). In the Obsolescence Radar, the cost per complete execution (analyze a component, traverse the BOM graph, calculate financial impact, generate executive report) was under &lt;strong&gt;€0.02 per query&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real case at Datalaria&lt;/strong&gt;: The &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/obs_part5_radar_agent/&quot;&gt;Agentic Obsolescence Radar&lt;/a&gt; uses Tool Calling exclusively. The LLM (Gemini 2.5 via CrewAI) understands the obsolescence alert in natural language, but all data operations — SQL query to the component catalog, BOM graph traversal, P&amp;amp;L calculation, PDF generation — are executed by deterministic Python tools. Result: executive reports in 4 seconds with &lt;strong&gt;0% numerical hallucination&lt;/strong&gt;.&lt;/p&gt;
&lt;h4 id=&quot;the-decision-matrix&quot;&gt;The Decision Matrix&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Criterion &lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Prompt Engineering &lt;/th&gt;&lt;th align=&quot;center&quot;&gt;RAG &lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Fine-Tuning &lt;/th&gt;&lt;th align=&quot;center&quot;&gt;Tool Calling&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Initial cost&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Minimal &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐ Low-medium &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ High &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐ Low&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Operational cost&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Low &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐ Medium &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Low (small model) &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Minimal&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Precision (free text)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐ Medium &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ High &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ Very high &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ N/A&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Precision (structured data)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Low &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Low &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Low &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ Exact&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Data freshness&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ Instant &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ Instant &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Requires retraining &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ Real-time&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Implementation effort&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Hours &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐ Days-weeks &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ Weeks-months &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐ Days&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Maintenance&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Minimal &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐ Medium &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ High (data drift) &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐ Medium&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Traceability (EU AI Act)&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Difficult &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ High (citable sources) &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐ Opaque (black box) &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;⭐⭐⭐ Total (deterministic)&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Ideal use case&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Generic tasks with clear instructions &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Updatable proprietary text knowledge &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Specific style/tone, ultra-specialized tasks &lt;/td&gt;&lt;td align=&quot;center&quot;&gt;Structured data, numerical precision, actions&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 id=&quot;the-3-question-framework&quot;&gt;The 3-Question Framework&lt;/h4&gt;
&lt;p&gt;If the matrix seems dense, I've distilled a 3-question framework that resolves 90% of decisions:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Question 1: Does the data the LLM needs fit in the prompt?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Yes → &lt;strong&gt;Prompt Engineering&lt;/strong&gt;. Inject context directly. Simpler, cheaper, faster.&lt;/li&gt;
&lt;li&gt;No → Next question.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Question 2: Is the data free text or structured?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Free text (documentation, manuals, posts) → &lt;strong&gt;RAG&lt;/strong&gt;. Semantic retrieval is superior for searching unstructured text.&lt;/li&gt;
&lt;li&gt;Structured (SQL, APIs, tables, calculations) → &lt;strong&gt;Tool Calling&lt;/strong&gt;. Deterministic tools that execute exact queries.&lt;/li&gt;
&lt;li&gt;Both → &lt;strong&gt;Hybrid architecture&lt;/strong&gt; (RAG for textual context + Tool Calling for structured data, as we proposed in the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/rag_antipatterns/&quot;&gt;RAG article&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Question 3: Do you need a behavior or style the base model can't reproduce even with the best prompt?&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Yes (unique brand tone, ultra-specific domain jargon, task that generalist models consistently fail) → &lt;strong&gt;Fine-Tuning&lt;/strong&gt; on a base model.&lt;/li&gt;
&lt;li&gt;No → Go back to Prompt Engineering and refine your prompt before considering more complex techniques.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id=&quot;what-i-learned-implementing-all-four&quot;&gt;What I Learned Implementing All Four&lt;/h4&gt;
&lt;p&gt;The most valuable lesson from operating these four techniques in production fits in a single sentence: &lt;strong&gt;always start with the simplest technique that could work&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The temptation is to jump straight to RAG or fine-tuning because they're more &quot;sophisticated.&quot; But sophistication doesn't correlate with effectiveness. In the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part1/&quot;&gt;Autopilot&lt;/a&gt;, the majority of output quality comes from Prompt Engineering — carefully designed system prompts, few-shot examples, and Chain-of-thought. RAG added marginal value in the Ops Copilot for blog search. Fine-tuning wasn't necessary in any case. And Tool Calling was the transformative technique in the Obsolescence Radar, where RAG had failed.&lt;/p&gt;
&lt;p&gt;The evaluation order should always be:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Prompt Engineering&lt;/strong&gt; (hours, ~€0)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tool Calling&lt;/strong&gt; if data is structured (days, ~€0)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RAG&lt;/strong&gt; if you need access to proprietary text (days-weeks, ~€3-50/month)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fine-Tuning&lt;/strong&gt; only if the previous three consistently fail (weeks, €50-500+)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And in the era of the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/eu_ai_act/&quot;&gt;EU AI Act&lt;/a&gt;, there's a fifth consideration that isn't technical but legal: &lt;strong&gt;traceability&lt;/strong&gt;. Article 10 of the regulation requires that training data for high-risk systems be &quot;relevant, representative, and to the extent possible, free of errors and complete.&quot; This applies directly to fine-tuning: if you fine-tune a model with biased or incorrect data, and that model makes decisions in a regulated domain, you're exposed to sanctions. RAG and Tool Calling, being transparent in their sources, offer traceability that fine-tuning cannot match.&lt;/p&gt;
&lt;p&gt;As we wrote in &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/hidden_economics_ai/&quot;&gt;The Hidden Economics of AI&lt;/a&gt;, the 10x Rule applies: if a more complex technique doesn't give you a result &lt;strong&gt;10 times better&lt;/strong&gt; than the previous one, it probably doesn't justify its added cost and complexity. Start simple. Measure. Scale only when the data demands it.&lt;/p&gt;
&lt;hr&gt;
&lt;h5 id=&quot;sources-of-interest--1&quot;&gt;Sources of Interest:&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cloud.google.com/vertex-ai/docs/generative-ai/models/tune-models&quot;&gt;&lt;strong&gt;Google Cloud&lt;/strong&gt;: Tuning &amp;amp; Fine-tuning with Vertex AI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://huggingface.co/docs/peft/conceptual_guides/lora&quot;&gt;&lt;strong&gt;Hugging Face&lt;/strong&gt;: LoRA — Low-Rank Adaptation of Large Language Models&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pinecone.io/learn/rag-vs-fine-tuning/&quot;&gt;&lt;strong&gt;Pinecone&lt;/strong&gt;: RAG vs Fine-Tuning — How to Choose&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering&quot;&gt;&lt;strong&gt;Anthropic&lt;/strong&gt;: Prompt Engineering Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/rag_antipatterns/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: RAG in Production — 7 Anti-Patterns That Destroy Precision&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/mcp_protocol/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: MCP Protocol — The USB of AI (Standardized Tool Calling)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/obs_part5_radar_agent/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: The Agentic Radar — Tool Calling in Production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/hidden_economics_ai/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: The Hidden Economics of AI — Real Costs of Each Technique&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/eu_ai_act/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: EU AI Act — Article 10 and Training Data&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/24338/fine-tuning-prompt-engineering-vs-rag-when-use-each-and-the-fourth-option-nobody-mentions</guid>
<pubDate>Sun, 09 Aug 2026 14:33:21 +0000</pubDate>
</item>
<item>
<title>Building Production LLM Agents That Actually Ship: Lessons from 10+ Years in the Trenches</title>
<link>https://coderlegion.com/24245/building-production-llm-agents-that-actually-ship-lessons-from-10-years-in-the-trenches</link>
<description>&lt;h2 id=&quot;building-production-llm-agents-that-actually-ship-lessons-from-10-years-in-the-trenches&quot;&gt;Building Production LLM Agents That Actually Ship: Lessons from 10+ Years in the Trenches&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Tool-calling agents, RAG pipelines, and the automation stack that turns &quot;AI demo&quot; into &quot;AI in production&quot; — and why I'm opening up for new projects.&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-gap-nobody-talks-about&quot;&gt;The gap nobody talks about&lt;/h3&gt;
&lt;p&gt;Most AI content online is about getting a model to respond well in a chat window. The harder, less glamorous problem is getting that same model to reliably book an appointment, update a CRM record, or hand off to a human at exactly the right moment — in production, at 2am, when an API times out or a timezone field silently defaults to the wrong value.&lt;/p&gt;
&lt;p&gt;That gap — between &quot;cool demo&quot; and &quot;system a business can depend on&quot; — is where I've spent most of my career, and it's the thread that connects everything I build now.&lt;/p&gt;
&lt;h3 id=&quot;what-shipped-looks-like&quot;&gt;What &quot;shipped&quot; looks like&lt;/h3&gt;
&lt;p&gt;A few examples from recent and past work:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;RAG systems that refuse to guess.&lt;/strong&gt; I built a health-data RAG pipeline with embeddings, cosine retrieval, inline citations, and — critically — honest refusal when the retrieved context doesn't support an answer, plus a full audit log. In healthcare-adjacent domains, a system that says &quot;I don't know&quot; is more valuable than one that sounds confident and is wrong.&lt;br&gt;
→ &lt;a rel=&quot;nofollow&quot; href=&quot;https://health-data-rag.vercel.app&quot;&gt;health-data-rag.vercel.app&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/topstar-ai&quot;&gt;source&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Voice + SMS agents wired into real CRM systems.&lt;/strong&gt; Built and debugged a full lead follow-up and appointment-booking system combining GoHighLevel, Retell AI (voice/chat), and n8n for a multi-location client. The interesting engineering wasn't the AI — it was the plumbing: explicit timezone configuration per calendar (never inherit it), Luxon expressions in n8n's &lt;code&gt;startTime&lt;/code&gt; field, correctly distinguishing a &lt;code&gt;status === &quot;booked&quot;&lt;/code&gt; response from a missing &lt;code&gt;statusCode&lt;/code&gt; field, and drawing a hard architectural line so voice-generation and SMS-sending never leak into each other. Small details like these are the actual difference between a bot that works and one that quietly fails a week after launch.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Automation across the LLM app surface.&lt;/strong&gt; From FastAPI backends and Next.js frontends to n8n/Make.com workflows and multi-agent orchestration, I own the full chain: agent logic, retrieval, inference APIs, and the interface around them — not just the prompt.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Open source, not just employer work.&lt;/strong&gt; Contributing to &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/inglorious-coderz&quot;&gt;Inglorious Forge&lt;/a&gt; — SSX compatibility testing, a merged &lt;code&gt;ElementSize&lt;/code&gt; entity, an error boundary with passing unit tests, a before/after slider component. Reading someone else's production codebase closely enough to contribute cleanly is its own skill, and I think it shows in how I approach client handoffs.&lt;/p&gt;
&lt;h3 id=&quot;the-stack-i-reach-for&quot;&gt;The stack I reach for&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Python&lt;/code&gt; &lt;code&gt;FastAPI&lt;/code&gt; &lt;code&gt;TypeScript&lt;/code&gt; &lt;code&gt;Next.js&lt;/code&gt; &lt;code&gt;Supabase/PostgreSQL (pgvector, RLS)&lt;/code&gt; &lt;code&gt;n8n&lt;/code&gt; &lt;code&gt;Make.com&lt;/code&gt; &lt;code&gt;GoHighLevel&lt;/code&gt; &lt;code&gt;OpenAI API&lt;/code&gt; &lt;code&gt;Anthropic API&lt;/code&gt; &lt;code&gt;Retell AI&lt;/code&gt; &lt;code&gt;Vercel&lt;/code&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-i-m-looking-for&quot;&gt;What I'm looking for&lt;/h3&gt;
&lt;p&gt;I'm currently taking on new freelance projects — particularly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AI agent / chatbot systems wired into real business tools (CRMs, calendars, ticketing)&lt;/li&gt;
&lt;li&gt;RAG pipelines that need to be &lt;em&gt;trustworthy&lt;/em&gt;, not just impressive in a demo&lt;/li&gt;
&lt;li&gt;n8n / Make.com automation connecting AI to existing workflows&lt;/li&gt;
&lt;li&gt;Integration debugging — if your AI feature &quot;mostly works&quot; and you can't figure out the 10% that doesn't, that's often exactly my kind of problem&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If any of that matches something you're building, I'd genuinely like to hear about it.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Luis Cruz&lt;/strong&gt;&lt;br&gt;
Senior AI Developer — LLM Agents, RAG, Automation&lt;br&gt;
📧 &lt;em&gt;Emails are not allowed&lt;/em&gt;&lt;br&gt;
🔗 &lt;a rel=&quot;nofollow&quot; href=&quot;https://topstar-ai-github-io.vercel.app&quot;&gt;topstar-ai-github-io.vercel.app&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/topstar-ai&quot;&gt;GitHub&lt;/a&gt; · &lt;a rel=&quot;nofollow&quot; href=&quot;https://contra.com/top_star_ai_uumynwpm&quot;&gt;Contra&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24245/building-production-llm-agents-that-actually-ship-lessons-from-10-years-in-the-trenches</guid>
<pubDate>Fri, 07 Aug 2026 03:15:50 +0000</pubDate>
</item>
<item>
<title>How Open Embedding Models Can Match GPT-Class Retrieval Quality</title>
<link>https://coderlegion.com/24206/how-open-embedding-models-can-match-gpt-class-retrieval-quality</link>
<description>&lt;p&gt;RAG (Retrieval-Augmented Generation - a pattern where you fetch relevant documents before generating an answer) lives or dies on retrieval quality. A cheaper retrieval stack isn't always a compromise.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Retrieval Quality Gap Is Often in the Chunking, Not the Model&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Most teams assume the embedding model is the bottleneck when retrieval underperforms. The real culprit is usually upstream: how documents are split before they're embedded. Overlapping chunks with a recursive character splitter, combined with a well-tuned open embedding model like &lt;code&gt;bge-large-en-v1.5&lt;/code&gt; or &lt;code&gt;nomic-embed-text&lt;/code&gt;, can close a surprising amount of the gap against expensive proprietary embeddings - often without touching the LLM layer at all.&lt;/p&gt;
&lt;p&gt;The pattern that tends to work: chunk at a meaningful boundary (paragraph or sentence, not fixed character count), add a small overlap window (10 - 15% of chunk size), then embed with a model that scores well on the MTEB benchmark (Massive Text Embedding Benchmark - a standardized leaderboard for comparing retrieval and semantic similarity models). Many open models in the 1B parameter range sit competitively on MTEB against models costing 100x more per token to call via API.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real Example&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Here's a minimal retrieval setup using LangChain with a local open embedding model via Ollama:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import OllamaEmbeddings
from langchain_community.vectorstores import Chroma

splitter = RecursiveCharacterTextSplitter(
 chunk_size=512, chunk_overlap=64, separators=[&quot;\n\n&quot;, &quot;\n&quot;, &quot;. &quot;]
)
docs = splitter.create_documents([your_text])

embeddings = OllamaEmbeddings(model=&quot;nomic-embed-text&quot;)
vectorstore = Chroma.from_documents(docs, embeddings)

results = vectorstore.similarity_search(&quot;your query here&quot;, k=4)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Swapping &lt;code&gt;nomic-embed-text&lt;/code&gt; for &lt;code&gt;bge-large-en-v1.5&lt;/code&gt; (via HuggingFace) takes one line change. Running both against your actual dataset on a held-out set of queries - not a synthetic benchmark - is the fastest way to know which one fits your domain.&lt;/p&gt;
&lt;p&gt;The cost difference is real: embedding a million tokens locally via Ollama is effectively free after the initial model pull, versus measurable per-call costs at API scale.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Chunking strategy (boundary type + overlap) affects retrieval quality at least as much as the embedding model choice - fix this before swapping models.&lt;/li&gt;
&lt;li&gt;Open embedding models in the 1B range score competitively on MTEB and run locally or on modest GPU instances.&lt;/li&gt;
&lt;li&gt;Evaluate retrieval quality on your own queries, not just published benchmarks - domain-specific retrieval patterns differ from general benchmarks.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you've swapped embedding models in a production RAG pipeline, which metric did you actually use to confirm the quality change - cosine similarity threshold, end-task accuracy, or something else?&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Sources referenced: HackerNews discussion - &quot;Beating GPT-5.6 Sol on retrieval with 100x cheaper open models&quot;, MTEB Leaderboard (Hugging Face)&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24206/how-open-embedding-models-can-match-gpt-class-retrieval-quality</guid>
<pubDate>Thu, 06 Aug 2026 09:09:21 +0000</pubDate>
</item>
<item>
<title>Senior AI Engineer Available for Freelance, Consulting &amp; Long-Term Projects</title>
<link>https://coderlegion.com/24167/senior-ai-engineer-available-for-freelance-consulting-long-term-projects</link>
<description>&lt;p&gt;Over the past few years, I've been building production-grade AI applications, full-stack web platforms, and intelligent automation systems for startups and businesses across different industries.&lt;/p&gt;
&lt;p&gt;My expertise includes:&lt;/p&gt;
&lt;p&gt;🤖 LLM applications, AI agents, and RAG systems&lt;br&gt;
⚡ AI workflow automation (n8n, APIs, MCP, integrations)&lt;br&gt;
🐍 Python, FastAPI, Next.js, React, TypeScript&lt;br&gt;
🧠 Multi-agent architectures and structured AI pipelines&lt;br&gt;
☁️ Cloud deployment, scalable backend systems, and DevOps&lt;br&gt;
🔗 Third-party integrations, payment systems, CRMs, and business automation&lt;/p&gt;
&lt;p&gt;Some of the projects I've delivered include AI-powered assistants, healthcare platforms, automation systems, digital products, travel platforms, marketing websites, and custom business solutions.&lt;/p&gt;
&lt;p&gt;I focus on building systems that are:&lt;/p&gt;
&lt;p&gt;Reliable in production&lt;br&gt;
Scalable and maintainable&lt;br&gt;
Performance-oriented&lt;br&gt;
Designed around real business outcomes—not just prototypes&lt;/p&gt;
&lt;p&gt;Whether you need to build an AI product from scratch, integrate LLM capabilities into an existing application, automate business workflows, or modernize a web platform, I can help turn ideas into production-ready solutions.&lt;/p&gt;
&lt;p&gt;If you're looking for an experienced engineer for freelance work, contract projects, technical consulting, or a long-term collaboration, I'd be happy to connect.&lt;/p&gt;
&lt;p&gt;📩 Feel free to reach out if you have an interesting project or are looking for a technical partner.&lt;/p&gt;
&lt;p&gt;Portfolio Demo: &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.loom.com/share/dd792a043d6b4d4483adba463bbe02f3&quot;&gt;https://www.loom.com/share/dd792a043d6b4d4483adba463bbe02f3&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/24167/senior-ai-engineer-available-for-freelance-consulting-long-term-projects</guid>
<pubDate>Wed, 05 Aug 2026 20:26:54 +0000</pubDate>
</item>
<item>
<title>Your RAG copilot can't count — stop letting it try</title>
<link>https://coderlegion.com/23854/your-rag-copilot-cant-count-stop-letting-it-try</link>
<description>&lt;h2 id=&quot;your-rag-copilot-can-t-count-stop-letting-it-try&quot;&gt;Your RAG copilot can't count — stop letting it try&lt;/h2&gt;
&lt;p&gt;A user asked our document-search copilot a completely reasonable question: how many documents a specific person authored. The copilot answered with a number. Confidently. With a nice sentence around it.&lt;/p&gt;
&lt;p&gt;The real answer, computed by the database, was &lt;strong&gt;84&lt;/strong&gt;. The copilot said — because it did what every RAG system does when you ask it an aggregation question: it counted what it could see.&lt;/p&gt;
&lt;p&gt;Here's the claim I want to defend, because I think a lot of us are shipping this bug right now:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 An LLM should never compute an aggregate. Not counts, not sums, not &quot;most recent&quot;. If the question is arithmetic over the corpus, the answer comes from the system of record — the model's only job is to phrase it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;-why-the-model-s-count-was-doomed-before-it-started&quot;&gt;🔢 Why the model's count was doomed before it started&lt;/h3&gt;
&lt;p&gt;This is a multi-agent document-search copilot for a quality-management product, built on LangGraph and Bedrock. When a metadata question comes in (&quot;documents authored by X&quot;), a search agent asks the documents service for matching rows, reranks and permission-gates them, collapses multiple versions of the same document into one card, and surfaces at most 30 results.&lt;/p&gt;
&lt;p&gt;Every one of those steps is correct for &lt;em&gt;search&lt;/em&gt;. Every one of them destroys the &lt;em&gt;count&lt;/em&gt;:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Stage &lt;/th&gt;&lt;th&gt;What it does &lt;/th&gt;&lt;th&gt;What &quot;how many&quot; means here&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Documents service (SQL) &lt;/td&gt;&lt;td&gt;runs the filter against the database &lt;/td&gt;&lt;td&gt;true total: &lt;strong&gt;84&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Retrieval &lt;/td&gt;&lt;td&gt;returns the top-k rows &lt;/td&gt;&lt;td&gt;capped&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;View-permission gate &lt;/td&gt;&lt;td&gt;drops rows this user can't see &lt;/td&gt;&lt;td&gt;fewer&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Version collapse &lt;/td&gt;&lt;td&gt;5 versions of one doc → 1 card &lt;/td&gt;&lt;td&gt;fewer still&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Surfaced set &lt;/td&gt;&lt;td&gt;at most 30 cards &lt;/td&gt;&lt;td&gt;≤ 30, always&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The model sits at the bottom of that funnel. When it &quot;counts&quot;, it counts the last row of the table. The user asked about the first row. Everything in between is retrieval hygiene that silently turned a database question into a context-window question.&lt;/p&gt;
&lt;p&gt;And this is the part that makes it worse than a crash: &lt;strong&gt;the answer is plausible&lt;/strong&gt;. A count that's capped at 30 and deduped looks like a real number. Nobody squints at &quot;you have 27 documents&quot; the way they'd squint at a stack trace. In a quality-management context, someone might put that number in a report.&lt;/p&gt;
&lt;h3 id=&quot;-the-twist-we-had-the-right-number-the-whole-time-and-overwrote-it&quot;&gt;🪤 The twist: we had the right number the whole time — and overwrote it&lt;/h3&gt;
&lt;p&gt;This is the bit that stung. The documents service &lt;em&gt;returns&lt;/em&gt; the true total with every filtered query. The search node even stored it in agent state, in a field called &lt;code&gt;total_available_results&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Then, a few nodes downstream, the permission-gate node overwrote that same field with the surfaced count — deliberately, and for a good reason:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;return {
    &quot;sources&quot;: diversified,
    # Honest &quot;documents found&quot;: distinct logical documents after the view gate and
    # version-collapse, so the headline count matches the cards (5 versions → 4 docs),
    # not the service's version-row total.
    &quot;total_available_results&quot;: len(diversified_all),
    ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That comment is right! If the UI says &quot;found 12&quot; and shows 12 cards, the numbers should agree. The bug wasn't either write in isolation — it was one state field carrying &lt;strong&gt;two different meanings&lt;/strong&gt; at two different points in the pipeline. Upstream it meant &quot;matches in the database&quot;; downstream it meant &quot;cards on screen&quot;. Whoever read it last got whichever meaning was freshest.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;💡 A state field whose meaning mutates mid-pipeline is a bug factory. If a value is authoritative, give it its own field and never let anything downstream touch it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;-the-fix-the-database-counts-the-model-narrates&quot;&gt;🔧 The fix: the database counts, the model narrates&lt;/h3&gt;
&lt;p&gt;So that's exactly what I did — preserved the service's true total in its own state field, threaded it to the two places that answer the user, and left the surfaced count alone for the card list and pager.&lt;/p&gt;
&lt;p&gt;First, the state gets an untouchable field:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class DocumentsSearchState(OrchestratorState, total=False):
    ...
    total_available_results: int   # surfaced: view-gated, version-collapsed, capped at 30

    # The documents service's true match total for the filter, before the 30-cap and
    # version-collapse. Authoritative for a &quot;how many&quot; count; 0 on a content-only
    # search, which has no exact total.
    total_matching_records: int
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then the outcome node — the one that turns search state into both the user-facing status line and the message the agent reasons over — uses the authoritative number in both:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;total_matching = state.get(&quot;total_matching_records&quot;) or 0
headline_total = total_matching if total_matching &amp;gt; total else total

found_msg = (
    f&quot;Showing {len(sources)} of {headline_total} matching documents&quot;
    if headline_total &amp;gt; len(sources)
    else f&quot;Found {len(sources)} matching documents&quot;
)
...
count_note = (
    f&quot; {total_matching} documents match in total &quot;
    &quot;(authoritative count; use this for a count question).&quot;
    if total_matching &amp;gt; len(sources)
    else &quot;&quot;
)
return f&quot;Returned {len(sources)} document cards to the user.{count_note} ...&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And the agent's prompt closes the loop with an explicit instruction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;For a &quot;how many&quot; question, answer with the authoritative total the tool
reports (&quot;N documents match in total&quot;), never the number of cards shown,
which is capped.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the belt-and-braces structure, because it matters: the number goes into the &lt;strong&gt;agent's context&lt;/strong&gt; (so the model can phrase the answer around it) &lt;em&gt;and&lt;/em&gt; into the &lt;strong&gt;user-facing headline&lt;/strong&gt; (&quot;Showing 5 of 84 matching documents&quot;). Even on a turn where the model ignores the note and free-styles a count, the UI is showing the true total right next to it. The prompt asks; the headline doesn't have to.&lt;/p&gt;
&lt;p&gt;Three small tests pin the behavior: the count note appears when there are more matches than cards, disappears when everything is shown, and never appears for a content-only semantic search — because a similarity ranking has no crisp &quot;matches&quot; set, so pretending it has an exact total would be the same lie in a new costume.&lt;/p&gt;
&lt;h3 id=&quot;-the-sibling-bug-the-last-10-days-of-what-&quot;&gt;📅 The sibling bug: &quot;the last 10 days&quot; of what?&lt;/h3&gt;
&lt;p&gt;Same release, same root cause wearing a different hat. Users asked for &quot;documents created in the last 10 days&quot; and the copilot had to turn that into a date filter — without knowing what day it is. A model doesn't know today's date. It will happily guess one from its training data, which is exactly as wrong as counting its context window.&lt;/p&gt;
&lt;p&gt;The fix is a tiny LangGraph middleware that injects the current date into the system message on &lt;strong&gt;every model call&lt;/strong&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def current_date_context(now: datetime | None = None) -&amp;gt; str:
    day = (now or datetime.now(UTC)).date()
    return (
        f&quot;Today's date is {day.isoformat()} (UTC). Resolve any relative date &quot;
        '(&quot;today&quot;, &quot;yesterday&quot;, &quot;the last 10 days&quot;, &quot;since March&quot;) '
        &quot;to concrete ISO-8601 dates relative to it before building a date filter.&quot;
    )


class CurrentDateMiddleware(AgentMiddleware):
    async def awrap_model_call(self, request, handler):
        base = request.system_message
        line = current_date_context()
        merged = line if base is None else f&quot;{base.content}\n\n{line}&quot;
        return await handler(request.override(system_message=SystemMessage(content=merged)))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One non-obvious detail: it's injected per call and never appended to the persisted conversation. Write &quot;today is 2026-07-30&quot; into the thread history and a conversation resumed next week carries a stale date the model will trust completely.&lt;/p&gt;
&lt;p&gt;Both fixes are the same principle: &lt;strong&gt;the model knows nothing you don't hand it, and it will answer anyway.&lt;/strong&gt; Counts, dates, totals — anything that's a fact about &lt;em&gt;your&lt;/em&gt; system rather than about language has to arrive as context, computed by something that can't hallucinate.&lt;/p&gt;
&lt;h3 id=&quot;-where-i-d-push-back-on-myself&quot;&gt;🤔 Where I'd push back on myself&lt;/h3&gt;
&lt;p&gt;Honest limits of what shipped:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Nothing enforces that the model uses the number.&lt;/strong&gt; The prompt instructs, the tool message supplies, the headline backstops — but there's no output check asserting the count in the model's prose equals the authoritative one. That's an eval I'd like to have and don't yet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Content-only searches still have no real count.&lt;/strong&gt; A semantic ranking over chunks genuinely doesn't have an exact match total, so those turns fall back to the surfaced count. Arguably the honest move there is to refuse to give a number at all. I didn't go that far.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;This only covers counts.&lt;/strong&gt; Sums, averages, &quot;which author has the most&quot; — any of those would need real aggregation endpoints on the service side. The pattern extends; the implementation doesn't yet.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the design choice I keep turning over: I handed the model the number &lt;strong&gt;inline, as prose in a tool result&lt;/strong&gt;. The agentic-purist alternative is a dedicated &lt;code&gt;count_documents&lt;/code&gt; tool the model calls when it detects an aggregation question — cleaner separation, one more round trip, and a new failure mode where the model doesn't call it. I went with inline because the search already paid for the count; a tool call felt like latency for ideology.&lt;/p&gt;
&lt;p&gt;So: where do you draw that line — hand the model the number, or hand it a tool to fetch the number? If your copilot answers &quot;how many&quot; questions today, I'd genuinely like to know which side you picked and whether it's held up.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Thanks for making it to the end 🙏 If your assistant has ever announced a total that made you go &quot;wait, that can't be right&quot;, I'd love to swap notes on how you fixed it — come find me on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/rodrigo-diego-67867185/&quot;&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23854/your-rag-copilot-cant-count-stop-letting-it-try</guid>
<pubDate>Fri, 31 Jul 2026 11:05:28 +0000</pubDate>
</item>
<item>
<title>From RAG to Agentic RAG. How I Added LangGraph to My Local</title>
<link>https://coderlegion.com/23826/from-rag-to-agentic-rag-how-i-added-langgraph-to-my-local</link>
<description>&lt;p&gt;In my &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/23449/i-built-a-local-rag-assistant-with-ollama-chromadb-and-langchain&quot;&gt;previous article&lt;/a&gt;, I built a fully local RAG assistant Ollama, ChromaDB, LangChain, all running in Docker. It answered technical support questions by searching through documentation and citing sources. It worked.&lt;/p&gt;
&lt;p&gt;But after using it for a while, I noticed something uncomfortable: &lt;strong&gt;it treated every question the same way&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Ask it &lt;em&gt;&quot;how to close monthly payroll?&quot;&lt;/em&gt; it searches the docs. Fine.&lt;br&gt;
Ask it &lt;em&gt;&quot;the server crashes at startup&quot;&lt;/em&gt; it also searches the docs. Less fine.&lt;br&gt;
Ask it something completely outside the documentation it searches the docs. Useless.&lt;/p&gt;
&lt;p&gt;A real support technician doesn't do that. They first &lt;em&gt;assess&lt;/em&gt; the situation, then decide what to do: look it up, run a diagnosis, or escalate to a human. My RAG had no such judgment.&lt;/p&gt;
&lt;p&gt;That's what this article is about how I evolved the system into an &lt;strong&gt;Agentic AI architecture&lt;/strong&gt; using LangGraph, where the assistant first decides &lt;em&gt;which strategy to use&lt;/em&gt;, then acts accordingly.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-core-limitation-of-classic-rag&quot;&gt;The Core Limitation of Classic RAG&lt;/h3&gt;
&lt;p&gt;Classic RAG is a linear pipeline. Every query follows the exact same path:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Question → Embed → Retrieve → Prompt → LLM → Answer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No branching. No decision-making. No memory between steps.&lt;/p&gt;
&lt;p&gt;This works perfectly for procedural questions where the answer lives in the docs. But technical support involves at least three distinct scenarios:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Scenario &lt;/th&gt;&lt;th&gt;Example &lt;/th&gt;&lt;th&gt;Best strategy&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Procedural question &lt;/td&gt;&lt;td&gt;&quot;How do I create an account?&quot; &lt;/td&gt;&lt;td&gt;Search documentation&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Known error code &lt;/td&gt;&lt;td&gt;&quot;ERR-COMP-001 appears&quot; &lt;/td&gt;&lt;td&gt;Lookup error database&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Unknown incident &lt;/td&gt;&lt;td&gt;&quot;Server crashes, no idea why&quot; &lt;/td&gt;&lt;td&gt;Diagnose + escalate if needed&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A single RAG pipeline handles the first case well and the other two poorly. The solution is to add a layer of reasoning &lt;em&gt;before&lt;/em&gt; retrieval.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-agentic-ai-adds&quot;&gt;What Agentic AI Adds&lt;/h3&gt;
&lt;p&gt;The shift from RAG to Agentic AI comes down to one thing: &lt;strong&gt;the system plans before it acts&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Instead of one fixed pipeline, you have:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Question
    ↓
Classifier (what kind of question is this?)
    ↓
    ├── Procedural → RAG Agent (search docs)
    ├── Error code → Diagnostic Agent (lookup + LLM analysis)
    └── Complex    → Diagnostic Agent → Escalation Agent (generate ticket)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each branch is an &lt;strong&gt;agent&lt;/strong&gt;; an autonomous function that receives a shared state, does its job, updates the state, and returns it. LangGraph connects these agents into a directed graph with conditional routing between them.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-shared-state-foundation-of-langgraph&quot;&gt;The Shared State Foundation of LangGraph&lt;/h3&gt;
&lt;p&gt;Before building agents, you define the state they all share. Think of it as a case file that every agent reads and updates:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# src/agents/state.py

from typing import TypedDict, List, Annotated, Sequence
from langchain_core.messages import BaseMessage
import operator

class AgentState(TypedDict):
    messages: Annotated[Sequence[BaseMessage], operator.add]
    question: str          # Original question never changes
    plan: List[str]
    current_step: int
    past_steps: List[str]  # Audit trail of what each agent did
    response: str
    next_agent: str
    niveau_support: int    # 1=resolved, 2=partial, 3=needs human
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;niveau_support&lt;/code&gt; field is key it's how agents communicate urgency to each other. If an agent sets it to 3, the orchestrator escalates automatically.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Annotated[Sequence[BaseMessage], operator.add]&lt;/code&gt; means messages accumulate — each agent appends rather than overwrites. You get a full conversation trace at the end.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;agent-1-the-rag-agent&quot;&gt;Agent 1 The RAG Agent&lt;/h3&gt;
&lt;p&gt;The pipeline from my previous article, repackaged as an autonomous agent:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# src/agents/rag_agent.py

def rag_agent(state: AgentState) -&amp;gt; AgentState:
    &quot;&quot;&quot;Searches the documentation and generates a grounded answer.&quot;&quot;&quot;

    docs = vectorstore.similarity_search(state[&quot;question&quot;], k=3)

    if not docs:
        return {
            **state,
            &quot;messages&quot;: list(state[&quot;messages&quot;]) + [
                AIMessage(content=&quot;[RAG] No relevant document found.&quot;)
            ],
            &quot;past_steps&quot;: state[&quot;past_steps&quot;] + [&quot;RAG: no results&quot;],
            &quot;niveau_support&quot;: 3   # Nothing found — escalate
        }

    context = &quot;\n\n&quot;.join([
        f&quot;[Source: {d.metadata.get('module', '?')}]\n{d.page_content}&quot;
        for d in docs
    ])

    answer = llm.invoke(
        f&quot;Answer from context only.\nContext: {context}\nQuestion: {state['question']}&quot;
    )

    return {
        **state,
        &quot;messages&quot;: list(state[&quot;messages&quot;]) + [AIMessage(content=f&quot;[RAG] {answer}&quot;)],
        &quot;past_steps&quot;: state[&quot;past_steps&quot;] + [&quot;RAG: documentation search completed&quot;],
        &quot;niveau_support&quot;: 1
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The critical difference from the classic RAG: the agent returns an &lt;strong&gt;updated state&lt;/strong&gt;, not just an answer. Every agent follows this same pattern — receive state, do work, return updated state.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;agent-2-the-diagnostic-agent&quot;&gt;Agent 2 The Diagnostic Agent&lt;/h3&gt;
&lt;p&gt;Handles error codes and technical incidents with two modes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# src/agents/diagnostic_agent.py

KNOWN_ERRORS = {
    &quot;ERR-COMP-001&quot;: {
        &quot;description&quot;: &quot;Debit/credit imbalance&quot;,
        &quot;solution&quot;: &quot;Check Total Debit = Total Credit. Go to Accounting &amp;gt; Journal Entry.&quot;
    },
    &quot;ERR-PAIE-042&quot;: {
        &quot;description&quot;: &quot;Payroll line missing rate&quot;,
        &quot;solution&quot;: &quot;Go to Settings &amp;gt; Payroll Lines, fill in the missing rate.&quot;
    },
}

def diagnostic_agent(state: AgentState) -&amp;gt; AgentState:
    question = state[&quot;question&quot;]
    found_codes = [code for code in KNOWN_ERRORS if code.lower() in question.lower()]

    if found_codes:
        # Mode 1 : known error: instant answer, no LLM call needed
        responses = [
            f&quot;Code {code}: {KNOWN_ERRORS[code]['description']}\n&quot;
            f&quot;Solution: {KNOWN_ERRORS[code]['solution']}&quot;
            for code in found_codes
        ]
        return {
            **state,
            &quot;messages&quot;: list(state[&quot;messages&quot;]) + [
                AIMessage(content=f&quot;[Diagnostic] {chr(10).join(responses)}&quot;)
            ],
            &quot;past_steps&quot;: state[&quot;past_steps&quot;] + [&quot;Diagnostic: known error, resolved&quot;],
            &quot;niveau_support&quot;: 1
        }

    # Mode 2 : unknown error: LLM analysis
    answer = llm.invoke(
        f&quot;Analyze this technical issue. If unsure, say so.\nIssue: {question}&quot;
    )
    return {
        **state,
        &quot;messages&quot;: list(state[&quot;messages&quot;]) + [AIMessage(content=f&quot;[Diagnostic] {answer}&quot;)],
        &quot;past_steps&quot;: state[&quot;past_steps&quot;] + [&quot;Diagnostic: LLM analysis, uncertain&quot;],
        &quot;niveau_support&quot;: 2
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The known error lookup runs in milliseconds without touching the LLM; a meaningful performance gain for common incidents.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;agent-3-the-escalation-agent&quot;&gt;Agent 3 : The Escalation Agent&lt;/h3&gt;
&lt;p&gt;Called only when &lt;code&gt;niveau_support &amp;gt;= 3&lt;/code&gt;. It doesn't try to answer; it generates a structured ticket with the full diagnostic history already attached:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# src/agents/escalade_agent.py

def escalade_agent(state: AgentState) -&amp;gt; AgentState:
    ticket = f&quot;&quot;&quot;
=== SUPPORT TICKET — LEVEL 3 ===
Date     : {datetime.now().strftime(&quot;%Y-%m-%d %H:%M&quot;)}
Priority : HIGH

ISSUE:
{state[&quot;question&quot;]}

DIAGNOSTIC STEPS ALREADY TAKEN:
{chr(10).join(state[&quot;past_steps&quot;])}

CONCLUSION: Human intervention required.
================================&quot;&quot;&quot;

    return {
        **state,
        &quot;messages&quot;: list(state[&quot;messages&quot;]) + [AIMessage(content=ticket)],
        &quot;past_steps&quot;: state[&quot;past_steps&quot;] + [&quot;Escalation: level 3 ticket generated&quot;],
        &quot;niveau_support&quot;: 3
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is what separates a real support system from a chatbot; it knows when to stop and hand off, with context already attached so the user doesn't have to repeat themselves.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-orchestrator-wiring-it-all-together&quot;&gt;The Orchestrator : Wiring It All Together&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# src/agents/orchestrator.py

def classifier(state: AgentState) -&amp;gt; str:
    question = state[&quot;question&quot;].lower()

    if re.search(r'err-\w+-\d+', question):
        return &quot;diagnostic&quot;

    problem_words = [&quot;error&quot;, &quot;crash&quot;, &quot;bug&quot;, &quot;not working&quot;, &quot;fails&quot;, &quot;problem&quot;]
    if any(word in question for word in problem_words):
        return &quot;diagnostic&quot;

    return &quot;rag&quot;  # Default for procedural questions


def should_escalate(state: AgentState) -&amp;gt; str:
    return &quot;escalade&quot; if state.get(&quot;niveau_support&quot;, 1) &amp;gt;= 3 else END


def create_orchestrator():
    workflow = StateGraph(AgentState)

    workflow.add_node(&quot;rag_agent&quot;, rag_agent)
    workflow.add_node(&quot;diagnostic_agent&quot;, diagnostic_agent)
    workflow.add_node(&quot;escalade_agent&quot;, escalade_agent)

    workflow.set_conditional_entry_point(
        classifier,
        {&quot;rag&quot;: &quot;rag_agent&quot;, &quot;diagnostic&quot;: &quot;diagnostic_agent&quot;}
    )

    workflow.add_edge(&quot;rag_agent&quot;, END)
    workflow.add_conditional_edges(
        &quot;diagnostic_agent&quot;,
        should_escalate,
        {&quot;escalade&quot;: &quot;escalade_agent&quot;, END: END}
    )
    workflow.add_edge(&quot;escalade_agent&quot;, END)

    return workflow.compile()
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;seeing-it-in-action&quot;&gt;Seeing It In Action&lt;/h3&gt;
&lt;p&gt;Three questions, three different paths:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Procedural question&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Input  : &quot;How to close monthly payroll?&quot;
Path   : classifier → rag_agent → END
Output : agents_used: [&quot;RAG: documentation search completed&quot;]
         niveau: 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Known error code&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Input  : &quot;ERR-COMP-001 appears on journal validation&quot;
Path   : classifier → diagnostic_agent → END
Output : agents_used: [&quot;Diagnostic: known error, resolved&quot;]
         niveau: 1  (no LLM call, instant response)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Unknown incident&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;Input  : &quot;Server crashes at startup, no idea why&quot;
Path   : classifier → diagnostic_agent → escalade_agent → END
Output : agents_used: [&quot;Diagnostic: LLM analysis&quot;, &quot;Escalation: ticket generated&quot;]
         niveau: 3
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-i-would-do-differently&quot;&gt;What I Would Do Differently&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The classifier is fragile.&lt;/strong&gt; Keyword matching breaks on edge cases &lt;em&gt;&quot;I don't have a problem, just need help configuring payroll&quot;&lt;/em&gt; contains &quot;problem&quot; and routes to diagnostic instead of RAG. A small LLM classification call, or a fine-tuned classifier, would be more robust.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Agents reload the embedding model on every call.&lt;/strong&gt; Instantiating &lt;code&gt;HuggingFaceEmbeddings&lt;/code&gt; on each agent invocation adds unnecessary overhead. Initialize once at startup and pass the instance through the state.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The escalation threshold is hardcoded.&lt;/strong&gt; A feedback loop where user ratings influence the confidence threshold over time would make this genuinely adaptive that's what reinforcement learning adds in the next phase of this project.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;rag-vs-agentic-ai-when-to-use-which&quot;&gt;RAG vs Agentic AI : When to Use Which&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Criteria        &lt;/th&gt;&lt;th&gt;Classic RAG              &lt;/th&gt;&lt;th&gt;Agentic AI&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Best for       &lt;/td&gt;&lt;td&gt;Uniform question types   &lt;/td&gt;&lt;td&gt;Mixed question types&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Complexity     &lt;/td&gt;&lt;td&gt;Low                      &lt;/td&gt;&lt;td&gt;Medium-High&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Latency        &lt;/td&gt;&lt;td&gt;One LLM call             &lt;/td&gt;&lt;td&gt;One or more LLM calls&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Flexibility    &lt;/td&gt;&lt;td&gt;Fixed pipeline           &lt;/td&gt;&lt;td&gt;Extensible — add agents without rewriting&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;When to choose &lt;/td&gt;&lt;td&gt;Start here               &lt;/td&gt;&lt;td&gt;When RAG fails on edge cases&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;My honest take: &lt;strong&gt;start with RAG&lt;/strong&gt;. Get it working, understand its failure modes, then add agents where you actually need branching logic. Adding LangGraph too early is over-engineering.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;key-takeaways-1&quot;&gt;Key Takeaways&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Design your &lt;code&gt;AgentState&lt;/code&gt; first.&lt;/strong&gt; It's the contract between all components. Changing it mid-project means updating every agent get it right upfront.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Agents should do one thing.&lt;/strong&gt; The moment an agent tries to retrieve &lt;em&gt;and&lt;/em&gt; diagnose, it becomes untestable and unroutable. Keep them single-responsibility.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;past_steps&lt;/code&gt; is your audit trail.&lt;/strong&gt; When a ticket escalates to a human, they see exactly what the system tried. Small design choice, professional result.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The classifier is your most important and most brittle piece of code.&lt;/strong&gt; Test it with as many edge cases as you can before trusting it.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;These two articles cover Phases 1 and 2 of my internship project. The logical next step is a reinforcement learning feedback loop; user ratings adjusting the classifier's routing decisions over time; which I may write about once it's implemented and tested.&lt;/p&gt;
&lt;p&gt;I'm a Master's student in AI &amp;amp; Big Data, building these systems with open-source tools and no GPU. If you're working on multi-agent systems or ran into similar design decisions; especially around the classifier or state design; I'd love to hear your approach in the comments.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23826/from-rag-to-agentic-rag-how-i-added-langgraph-to-my-local</guid>
<pubDate>Thu, 30 Jul 2026 19:49:17 +0000</pubDate>
</item>
<item>
<title>Technology is now for anyone</title>
<link>https://coderlegion.com/23791/technology-is-now-for-anyone</link>
<description>&lt;p&gt;&lt;strong&gt;Mental Health System&lt;/strong&gt; &lt;/p&gt;
&lt;p&gt; AI Chatbot that provides empathetic responses for mental health support using simple Plain English text.&lt;/p&gt;
&lt;p&gt;Live: &lt;a rel=&quot;nofollow&quot; href=&quot;https://lnkd.in/dn5H6PM9&quot;&gt;https://lnkd.in/dn5H6PM9&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23791/technology-is-now-for-anyone</guid>
<pubDate>Thu, 30 Jul 2026 04:58:14 +0000</pubDate>
</item>
<item>
<title>GraphRAG Won’t Give Your AI a Brain. Here’s What Twelve Papers Actually Say.</title>
<link>https://coderlegion.com/23790/graphrag-wont-give-your-ai-a-brain-heres-what-twelve-papers-actuallysay</link>
<description>&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;💡TL;DR&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;▪️GraphRAG helps most when questions require multi-document relationships, temporal reasoning, or corpus-level synthesis; it does not consistently beat standard RAG on direct factual retrieval.&lt;br&gt;
️️▪️Automatically constructed graphs can omit required entities, weaken abstention, and create new maintenance, provenance, and security risks.&lt;br&gt;
▪️ Treat the graph as a navigational layer over source evidence - not as an AI brain, a source of truth, or a substitute for runtime control.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-retrieval-infrastructure-became-an-ai-brain-&quot;&gt;When Retrieval Infrastructure Became an “AI Brain”&lt;/h3&gt;
&lt;p&gt;Three GraphRAG posts showed up on my feed today. Not three this week. Three today. Each used some version of the same promise: give an AI agent a knowledge graph, and it will finally understand your organization.&lt;/p&gt;
&lt;p&gt;The surrounding claims were broader. One repository mapped an entire codebase into a glowing web of functions and dependencies. Another promised organizational memory that would persist across agents and sessions. Obsidian vaults were becoming self-growing second brains. Vector databases were being pronounced obsolete, sometimes by the same companies that had been promoting them six months earlier.&lt;/p&gt;
&lt;p&gt;The phrase &lt;em&gt;AI brain&lt;/em&gt; now gets attached to technologies that share surprisingly little beyond the presence of nodes, links, or Markdown files. There are graphs extracted from natural-language documents, formal ontologies maintained by domain specialists, syntax trees exposed to coding assistants, temporal memory systems that record when facts stopped being true, and agent-maintained wikis where a model writes notes for its future self.&lt;/p&gt;
&lt;p&gt;Calling all of these a knowledge map is roughly as useful as grouping a bicycle, a forklift, and a jet engine under &lt;em&gt;ways to move things&lt;/em&gt;. The category is technically defensible and nearly useless when deciding what to build.&lt;/p&gt;
&lt;p&gt;The excitement is not entirely manufactured. Language models lose project history, reread the same files, miss relationships scattered across documents, and waste enormous context windows reconstructing structures that parsers already know. Something more structured could help. The problem is that &lt;em&gt;help&lt;/em&gt; has quietly expanded into &lt;em&gt;understanding&lt;/em&gt;, and retrieval infrastructure has become a brain before the benchmark results have earned either word.&lt;/p&gt;
&lt;p&gt;So I pulled the comparative studies, benchmark appendices, original GraphRAG work, recent agentic-search results, and the technical documentation behind the most visible repositories. Not the launch posts or demo videos. The sources and the numbers underneath them.&lt;/p&gt;
&lt;p&gt;The evidence supports a considerably narrower claim:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;GraphRAG&lt;/strong&gt; can be valuable when a question requires relationships to be traversed across documents, temporal or comparative reasoning, or a broad summary of a large corpus.&lt;/p&gt;
&lt;p&gt;It does not reliably beat simpler retrieval on straightforward factual questions, and some graph-based designs become substantially worse when the evidence is incomplete.***&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;round-1-does-a-graph-help-you-find-a-fact-&quot;&gt;Round 1: Does a graph help you find a fact?&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1210/1*5baSydQSaBrNSTcWvUch3A.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;One of the most useful comparisons is Han et al.’s systematic evaluation of RAG and GraphRAG. The researchers standardized preprocessing, retrieval settings, and generation conditions across ordinary RAG and several graph-based systems instead of letting every architecture arrive with its own preferred benchmark setup. &lt;a rel=&quot;nofollow&quot; href=&quot;https://flamehaven.space/writing/graphrag-persistent-agent-memory-and-the-distance-between-available-knowledge-and-controlled-action/&quot;&gt;1&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Using Llama 3.1–70B, they reported:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/806/1*DdVvt31elmLm85Yu8d16jQ.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Plain retrieval beat every graph-based method on Natural Questions. On HotpotQA, the graph systems moved ahead, although the difference was less than one point. &lt;/p&gt;
&lt;p&gt;This is not the comparison GraphRAG demonstrations usually foreground. Demonstrations tend to begin with questions graphs were designed to handle: &lt;em&gt;How is this person connected to that project? What sequence of events led to this decision? Which modules depend on this function?&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Real users also ask for an exact clause, one number from a report, the current owner of a system, or the sentence containing an exception.&lt;/p&gt;
&lt;p&gt;A direct question is often best answered by a passage that still contains its original wording, scope, qualifications, and awkward “except when” condition. Converting that passage into an entity and an edge may preserve the relationship while losing the language that determines whether the relationship applies.&lt;/p&gt;
&lt;p&gt;You get the relationship. You lose the sentence.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;round-2-where-the-graph-earns-its-cost&quot;&gt;Round 2: Where the graph earns its cost&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1210/1*qqi2RHiFp6deBMcUYIHMLg.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;The result changes when the question is deliberately constructed to require evidence from several documents.&lt;/p&gt;
&lt;p&gt;On MultiHop-RAG, the same study found:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/806/1*LWmA_R6B5HVwNENuD_z-2A.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;This is a meaningful gain. Graph structure helped when the model had to connect entities, compare facts, reconstruct temporal order, or combine evidence unlikely to appear together in one similarity search. &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=11219642076967750310&quot; alt=&quot;&quot;&gt;&lt;br&gt;
&lt;strong&gt;Figure 1. GraphRAG’s advantage depends on the task.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A separate 2026 study, &lt;em&gt;Do We Still Need GraphRAG?&lt;/em&gt;, asked whether an agent capable of decomposing a question and searching repeatedly could recover the benefits of explicit graph structure.&lt;/p&gt;
&lt;p&gt;Agentic search strengthened dense RAG and narrowed parts of the gap. GraphRAG retained an advantage on complex multi-hop problems and showed more stable retrieval behaviour when its offline construction cost could be amortized. &lt;a rel=&quot;nofollow&quot; href=&quot;https://openreview.net/forum?id=i9q9xDMjG7&quot;&gt;4&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Its detailed results make the task boundary difficult to ignore:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Average improvement on general QA:&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;+0.47 points&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Average improvement on selected multi-hop QA benchmarks:&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;em&gt;+27.23 points&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This does not support a general replacement story. It shows that explicit structure becomes more valuable as the answer depends on relationships that ordinary retrieval would otherwise have to rediscover through repeated searches.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/806/1*e7RgK33oOhpT5KkHd1DL1g.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;The useful question is how much relational work exists in the actual workload, and whether that work repays the graph’s construction, maintenance, and information loss.&lt;/p&gt;
&lt;p&gt;A company whose users mostly ask for quotations, figures, and current policy clauses may spend heavily to build a system optimized for questions it rarely receives.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;round-3-the-number-that-rarely-appears-in-a-demo&quot;&gt;Round 3: The number that rarely appears in a demo&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1210/1*HhjqWZRiM4TVb7zlucRKvw.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;The most consequential result concerns questions that the source corpus cannot answer.&lt;/p&gt;
&lt;p&gt;Han et al. included “Null” questions designed to test whether a system recognized that the available evidence was insufficient.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/806/1*uo_5jX2hR8YdNCM6iRrFYA.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;The score collapsed from 91.36 to 13.95. &lt;/p&gt;
&lt;p&gt;This should not be translated casually into “Global GraphRAG hallucinated confidently 86 percent of the time.” The benchmark score does not establish that every failure was a confident fabricated answer.&lt;/p&gt;
&lt;p&gt;It establishes something serious enough without embellishment: the global system repeatedly lost the ability to recognize that its evidence did not support an answer.&lt;/p&gt;
&lt;p&gt;The mechanism is understandable. Microsoft’s original GraphRAG design was built in part for global sensemaking over large corpora. It extracts entities, detects communities, produces community summaries, and uses those abstractions to answer broad questions about themes and relationships. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2502.11371&quot;&gt;2&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Those summaries can cover far more of a corpus than a handful of retrieved chunks. They can also remove the precise detail needed to determine that a particular question is unanswerable. The system receives enough thematic material to sound informed, while the sentence, date, qualification, or missing connection that should force abstention is no longer visible.&lt;/p&gt;
&lt;p&gt;Two different properties are being confused:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Comprehensiveness&lt;/strong&gt; measures how much relevant territory an answer covers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence sufficiency&lt;/strong&gt; asks whether the available material actually supports the claim being made.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A system can improve the first while damaging the second.&lt;/p&gt;
&lt;p&gt;That trade-off becomes more serious once the model is an agent. A broad but unsupported conversational answer is undesirable. A broad but unsupported plan that leads to a code modification, database update, email, payment, or deployment belongs to a different risk class, and retrieval quality alone does not tell us whether the agent will cross that boundary.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;your-graph-may-be-missing-a-third-of-the-answers-before-you-query-it&quot;&gt;Your graph may be missing a third of the answers before you query it&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1210/1*7yU0TQl7BrnasorXgl2o4w.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Graph construction is often presented as preprocessing, as though the source corpus were simply being rearranged into a more useful shape.&lt;/p&gt;
&lt;p&gt;It is closer to prediction.&lt;/p&gt;
&lt;p&gt;Han et al. checked whether the required answer entity existed anywhere in the automatically generated graph. Coverage reached:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;65.8%&lt;/strong&gt; on HotpotQA&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;65.5%&lt;/strong&gt; on Natural Questions&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Roughly one in three required answer entities never entered the graph.&lt;/p&gt;
&lt;p&gt;No retrieval algorithm, graph traversal policy, reranker, or agentic search loop can walk to a node that was never created. The retriever may still find neighbouring concepts and produce an answer that appears structurally grounded, but its searchable world was already narrowed during extraction.&lt;/p&gt;
&lt;p&gt;Failures enter through ordinary, unglamorous mechanisms. An entity is missed. Two entities are merged because their names look similar. A relationship loses its date range. An exception disappears. The direction of an edge is reversed.&lt;/p&gt;
&lt;p&gt;A hypothetical statement is stored as an assertion. An inferred connection survives without enough source evidence to justify it. None of these failures needs to crash the pipeline; most leave behind a graph that still renders cleanly and accepts queries.&lt;/p&gt;
&lt;p&gt;The model used to build the graph also changed final performance:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/806/1*rPIDIcF_kKCpP1DGZT5FlA.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;The retrieval design remained comparable, but replacing the extraction model produced almost a four-point difference between the two graph configurations.&lt;/p&gt;
&lt;p&gt;The uncertainty did not disappear when the system moved to a graph. Part of it moved upstream, into a stage that organizations may run once, trust implicitly, and reuse across thousands of later queries.&lt;/p&gt;
&lt;p&gt;Microsoft’s own GraphRAG repository and documentation warn that indexing can be expensive, recommend starting small, explain that default prompts may not produce optimal results, and describe the code as a demonstration rather than an officially supported Microsoft offering. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2607.19301&quot;&gt;7&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;That is a more sober description than most third-party launch posts.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-cost-table-nobody-leads-with&quot;&gt;The cost table nobody leads with&lt;/h3&gt;
&lt;p&gt;In the controlled MultiHop-RAG comparison, construction and retrieval time varied sharply by architecture:&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/806/1*tc5CS5gU6H6lSKXXEW7B-Q.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;“GraphRAG is slow” and “GraphRAG is fast” are both incomplete claims.&lt;/p&gt;
&lt;p&gt;Community GraphRAG paid a heavy upfront construction cost and then retrieved faster than standard RAG across the measured run. That may be a reasonable trade for a stable corpus queried repeatedly.&lt;/p&gt;
&lt;p&gt;KG-GraphRAG performed graph expansion during retrieval and accumulated more than eight times the total retrieval time of standard RAG in that experiment.&lt;/p&gt;
&lt;p&gt;The operational result depends on where the cost is paid.&lt;/p&gt;
&lt;p&gt;A static archive may absorb expensive preprocessing once. A changing repository, policy library, product catalogue, or incident log may trigger continuous graph repair.&lt;/p&gt;
&lt;p&gt;Entities must be reconciled, edges updated, communities recalculated, summaries regenerated, and derived indexes refreshed. When incremental updating and invalidation are unreliable, the system either rebuilds too much or tolerates stale structure.&lt;/p&gt;
&lt;p&gt;Neither option appears in the first screenshot.&lt;/p&gt;
&lt;p&gt;The maintenance burden also changes what should count as success. A five-point benchmark gain may be attractive when the corpus is stable for a year. The same gain can disappear operationally if the graph is rebuilt every night, arrives several hours behind the source, or requires a stronger extraction model whose indexing bill overwhelms the savings produced during retrieval.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-happens-when-you-combine-rag-and-graphrag-&quot;&gt;What happens when you combine RAG and GraphRAG?&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1210/1*o2up93Z50dd-GHiIzMbMKg.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;The obvious response to task sensitivity is hybrid retrieval.&lt;/p&gt;
&lt;p&gt;Simple questions can go to standard RAG. Relationship-heavy questions can go to GraphRAG. A third option runs both and merges the evidence.&lt;/p&gt;
&lt;p&gt;Han et al. called these approaches &lt;strong&gt;Selection&lt;/strong&gt; and &lt;strong&gt;Integration&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;With Llama 3.1–70B on MultiHop-RAG, the results were encouraging:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Selection improved on the strongest single baseline by &lt;strong&gt;1.1%&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Integration improved it by &lt;strong&gt;6.4%&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Integration reached an overall score of &lt;strong&gt;77.62&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then the researchers repeated the experiment with Llama 3.1–8B.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/806/1*Aw8V520aZMvdYVZNp8zGJQ.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;The smaller model received more retrieved material and became dramatically worse at recognizing when that material was insufficient. Its overall score did not improve over GraphRAG alone, while the insufficiency score fell from 96.01 under standard RAG to 50.17 under integration. &lt;/p&gt;
&lt;p&gt;More retrieval does not simply add information. It also adds repeated fragments, graph abstractions, partial contradictions, and linguistic pressure to produce a synthesis.&lt;/p&gt;
&lt;p&gt;Larger models may manage that mixture better. Smaller ones may interpret the volume of related context as evidence that an answer must exist, especially when several retrieved objects describe the same neighbourhood without supplying the missing fact.&lt;/p&gt;
&lt;p&gt;A later paper, &lt;em&gt;Is GraphRAG Needed? From Basic RAG to Graph-/Agentic Solutions with Context Optimization&lt;/em&gt;, compared nine standardized RAG scenarios and reported token reductions of &lt;strong&gt;19–53 percent&lt;/strong&gt; through improved context management. Its results also suggest that retrieval-side gains do not always translate into proportional improvements in final generation quality.&lt;/p&gt;
&lt;p&gt;I use &lt;strong&gt;retrieval–generation gap&lt;/strong&gt; here as analytical shorthand for that mismatch; it is not presented as the paper’s own formal term. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2604.09666&quot;&gt;5&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This is the finding to keep in view when a product leads with recall, graph coverage, or token savings:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Better retrieval coverage does not guarantee better epistemic behaviour.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The generator still has to distinguish evidence from noise, recognize contradiction, preserve qualifications, and stop when the answer remains unsupported. A hybrid retrieval system without an explicit evidence-sufficiency test may produce a larger context and a weaker decision.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-field-is-already-revising-the-original-story&quot;&gt;The field is already revising the original story&lt;/h3&gt;
&lt;p&gt;One of the most interesting recent papers does not present the graph as a replacement for the source.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;PAGE-RAG: Evidence-Grounded Adaptive Graph Retrieval for Long-Document Question Answering&lt;/em&gt; begins from the premise that automatically constructed graphs are &lt;strong&gt;incomplete projections of source documents&lt;/strong&gt;. &lt;/p&gt;
&lt;p&gt;It treats the graph as a &lt;strong&gt;semantic skeleton&lt;/strong&gt; used to organize and navigate the source, routes questions according to their evidence needs, and introduces a knowledge-boundary mechanism intended to abstain when accessible evidence remains insufficient. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2606.25656&quot;&gt;6&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;That design reflects a correction already becoming visible across the research.&lt;/p&gt;
&lt;p&gt;The first wave often implied:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Documents → Graph → Knowledge
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The more defensible model is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Documents → Incomplete graph projection
                 ↓
          Navigate back to evidence
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The graph can reduce a search space, expose a relationship, organize candidates, or identify material that deserves closer inspection. It should not become an independent authority merely because an LLM converted prose into structured storage.&lt;/p&gt;
&lt;p&gt;This changes the design obligation. A node must lead back to the passage that supports it. An inferred edge should remain distinguishable from an extracted one. A community summary should behave like a navigation aid rather than a canonical replacement for the documents it compressed. If the source and graph disagree, the source must remain available and the discrepancy must be visible.&lt;/p&gt;
&lt;p&gt;Structure does not upgrade an inference into a fact.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-code-graphs-deserve-a-separate-category&quot;&gt;Why code graphs deserve a separate category&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1210/1*l1J8YcEY0wsf8HjAn7zMnA.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Some of the fastest-growing “GraphRAG” repositories are primarily code-intelligence systems, and their reliability profile differs from graphs extracted from prose.&lt;/p&gt;
&lt;p&gt;Source code has formal grammar. A parser can determine that one module imports another, a class inherits from a parent, or a function references a symbol without asking a language model to interpret a paragraph.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Graphify, which had accumulated roughly 94,000 GitHub stars at the time of this review, parses supported code locally with Tree-sitter. Its repository distinguishes &lt;code&gt;EXTRACTED&lt;/code&gt; relationships explicit in source structure from &lt;code&gt;INFERRED&lt;/code&gt; relationships produced through later resolution. Documents, PDFs, images, and media still require semantic model processing, so the deterministic advantage applies most strongly to parseable source code rather than every input the project accepts. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/microsoft/graphrag&quot;&gt;8&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;GitNexus similarly parses functions, classes, methods, and interfaces through Tree-sitter, resolves calls and imports across files, clusters related symbols, and exposes execution flows and impact-analysis tools to agents through MCP. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Graphify-Labs/graphify&quot;&gt;9&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is useful engineering. Coding agents often burn context reconstructing relationships already available through static analysis. Returning the callers, dependent modules, and likely change surface of a symbol can be far more efficient than opening dozens of files.&lt;/p&gt;
&lt;p&gt;The underlying techniques are old. Abstract syntax trees, call graphs, symbol indexes, dependency analysis, and code-query systems predate LLM agents by decades. The recent innovation lies in packaging those structures as context interfaces for autonomous tools.&lt;/p&gt;
&lt;p&gt;The remaining evidence gap concerns the final work. Does the agent modify the correct files more often? Does it introduce fewer regressions? Does lower token usage preserve the configuration, runtime dispatch, generated code, framework convention, or exception that would have prevented a mistake?&lt;/p&gt;
&lt;p&gt;Most public claims still come from developer-run benchmarks and product demonstrations. I found no independent controlled comparison showing that these tools improve end-to-end coding success across repositories, languages, and agent models when compared under the same conditions with &lt;code&gt;grep&lt;/code&gt;, language-server navigation, static analysis, vector retrieval, and iterative agent search.&lt;/p&gt;
&lt;p&gt;A tool may use fewer tokens because it located the correct dependency quickly. It may also use fewer tokens because the graph omitted the runtime path that mattered. Those outcomes look identical in a token dashboard and entirely different in production.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-90-000-stars-do-not-settle-the-question&quot;&gt;Why 90,000 stars do not settle the question&lt;/h3&gt;
&lt;p&gt;GitHub popularity explains part of the GraphRAG mood, but it cannot resolve technical quality.&lt;/p&gt;
&lt;p&gt;The expanded version of the Carnegie Mellon-led StarScout study identified about &lt;strong&gt;six million suspected fake stars across 26,254 repositories before post-processing&lt;/strong&gt;. Socket Inc participated through one co-author and later collaboration, but the study was not simply “Socket research.” &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2605.28645&quot;&gt;12&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;That finding does &lt;strong&gt;not&lt;/strong&gt; show that Graphify, GitNexus, or any other GraphRAG repository purchased stars.&lt;/p&gt;
&lt;p&gt;It shows that star counts are vulnerable to coordinated inflation and should not be treated as standalone evidence of retrieval quality, graph completeness, maintenance reliability, or production value.&lt;/p&gt;
&lt;p&gt;Even authentic stars measure attention, timing, accessibility, and distribution. They do not tell us how many answer entities were lost during indexing, whether inferred edges are supported by source text, how the system behaves after a schema change, or whether an agent finishes more tasks correctly.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Stars tell us what developers are looking at.They do not tell us whether the graph contains the answer.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-second-hype-wave-an-ai-that-writes-its-own-memory&quot;&gt;The second hype wave: an AI that writes its own memory&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1210/1*zWPjN3R0IAtKJTKc10EaRg.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;GraphRAG still begins with source material and creates a representation used for retrieval. The newer LLM-wiki pattern goes further: the agent writes a persistent knowledge layer that its future self will read.&lt;/p&gt;
&lt;p&gt;Andrej Karpathy’s LLM Wiki pattern describes a system with immutable raw sources, an LLM-maintained directory of summaries and topic pages, and a schema telling the agent how to ingest, query, cross-reference, and maintain the knowledge base. The attraction is accumulation. Instead of rediscovering the relationship among five documents every time, the model can preserve a synthesis and build on it later. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/abhigyanpatwari/GitNexus&quot;&gt;10&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This can be genuinely valuable. It also creates a recursive failure mode that ordinary RAG does not have.&lt;/p&gt;
&lt;p&gt;The retrieved material now includes the model’s own earlier interpretations.&lt;/p&gt;
&lt;p&gt;Six problems follow:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Self-contamination:&lt;/strong&gt; one incorrect summary is copied into later pages and begins to resemble independent corroboration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Append-only drift:&lt;/strong&gt; new evidence is added while obsolete conclusions survive elsewhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Missing canonical authority:&lt;/strong&gt; several pages disagree and nothing identifies which one governs the current state.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Summary-lineage collapse:&lt;/strong&gt; a claim passes through enough rewrites that its original source becomes difficult to recover.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connectivity–truth confusion:&lt;/strong&gt; a frequently linked page appears credible because it is central, although centrality measures connection rather than validity.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Propagation failure:&lt;/strong&gt; the source changes while dependent summaries, recommendations, and plans remain untouched.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These failures compound. One inaccurate summary enters three topic pages. Those pages are later compressed into a higher-level overview. The original error now appears in several places, linked from several directions, and may be retrieved as though multiple notes independently support it. The workspace looks increasingly organized while the provenance underneath it becomes harder to reconstruct.&lt;/p&gt;
&lt;p&gt;This architecture exchanges repeated raw retrieval for a maintenance problem. The trade may be worthwhile, but only when the system preserves immutable sources, records derivation, identifies canonical pages, and can invalidate every downstream artifact affected by a correction.&lt;/p&gt;
&lt;p&gt;Otherwise the agent can quote its own error back to itself with several links attached.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-graph-itself-becomes-a-sensitive-asset&quot;&gt;The graph itself becomes a sensitive asset&lt;/h3&gt;
&lt;p&gt;Turning documents into a connected structure creates another problem: the relationships may be more sensitive than the individual records.&lt;/p&gt;
&lt;p&gt;The 2026 GraphSteal study treated GraphRAG systems as structural oracles. Through adaptive black-box interaction, the researchers reported reconstructing more than &lt;strong&gt;90 percent&lt;/strong&gt; of the original knowledge graph in representative generic and healthcare scenarios, exposing entities, relationships, topology, and structural dependencies. Existing guardrails offered limited protection in their experiments. &lt;a rel=&quot;nofollow&quot; href=&quot;https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f&quot;&gt;11&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This does not establish that every deployed GraphRAG system is readily extractable. It establishes a new attack surface that document-centric access control may not cover.&lt;/p&gt;
&lt;p&gt;A collection of files contains sensitive facts. A graph may additionally reveal which people bridge teams, which records form hidden clusters, which entities are unusually central, and which dependency path would cause the most damage if exposed. Individual documents may be innocuous when viewed separately, while the graph assembled from them reveals a relationship that no user was meant to observe in one place.&lt;/p&gt;
&lt;p&gt;Organizations considering GraphRAG therefore need to review the derived graph, query interface, traversal behaviour, rate limits, logs, and inference leakage as assets in their own right. Permission inheritance from source documents may not be enough when the graph produces new aggregates, paths, and structural inferences.&lt;/p&gt;
&lt;p&gt;The edges may be more sensitive than the nodes.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-knowledge-map-still-does-not-control-an-agent&quot;&gt;A knowledge map still does not control an agent&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1210/1*83fcGwLFc5gcXZwr5nHP2A.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Underneath all the benchmark results sits a more basic limitation.&lt;/p&gt;
&lt;p&gt;A knowledge map tells an agent what it can inspect. It does not determine what the agent will believe, how it will resolve conflicting evidence, or whether it is allowed to act.&lt;/p&gt;
&lt;p&gt;A graph edge can say that production deletion requires human approval. The sentence may be retrieved correctly and placed directly in the model’s context. None of that prevents the model from ignoring the condition or calling an API that performs the deletion without checking for an approval token.&lt;/p&gt;
&lt;p&gt;The system contains several boundaries:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Original source
      ↓
Graph, index, or wiki
      ↓
Retrieved evidence
      ↓
Generated answer or plan
      ↓
Authorized action
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Information can be lost or transformed at every arrow.&lt;/p&gt;
&lt;p&gt;The source may be incompletely represented. Retrieval may select the wrong version. Generation may overstate what the evidence supports. The runtime may accept an action that policy should have blocked.&lt;/p&gt;
&lt;p&gt;GraphRAG primarily improves parts of the middle. It does not automatically provide:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Provenance:&lt;/strong&gt; every node, edge, summary, and recommendation traceable to its source, source version, and construction method.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Invalidation:&lt;/strong&gt; a way to identify every derivative that becomes suspect when the source changes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Revalidation:&lt;/strong&gt; recomputation, quarantine, downgrade, or explicit historical labeling before stale material returns to active use.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hard enforcement:&lt;/strong&gt; server-side permission and approval checks that do not depend on whether the model read or interpreted a policy correctly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These controls are difficult precisely because derived knowledge propagates. A policy paragraph creates an edge. The edge enters a community. The community summary influences a wiki page. The wiki page supplies an agent plan. The original policy changes, while the plan remains in memory. Updating the source file solves only the first step in that chain.&lt;/p&gt;
&lt;p&gt;Without provenance and dependency tracking, the system cannot reliably determine what else became stale.&lt;/p&gt;
&lt;p&gt;Without enforcement, even a correct answer remains advice.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-should-you-deploy-&quot;&gt;What should you deploy?&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://cdn-images-1.medium.com/max/1210/1*pnApG9Ei5usjsbdVYuR6Vg.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;The answer depends on the workload, which is less satisfying than buying the newest architecture and much cheaper than discovering the workload afterward.&lt;/p&gt;
&lt;p&gt;Before deploying any of them, require answers to six questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;What percentage of real queries genuinely require relationship traversal?&lt;/li&gt;
&lt;li&gt;How complete is the graph against the original sources?&lt;/li&gt;
&lt;li&gt;What happens when the graph and source disagree?&lt;/li&gt;
&lt;li&gt;How are dependent edges, summaries, and plans invalidated after updates?&lt;/li&gt;
&lt;li&gt;Does improved retrieval increase final task success, or only retrieval metrics and token efficiency?&lt;/li&gt;
&lt;li&gt;What happens when the corpus cannot answer the question?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The final question is the one vendors are least likely to volunteer.&lt;/p&gt;
&lt;p&gt;It is also the one most likely to tell you what you bought.&lt;/p&gt;
&lt;p&gt;A credible pilot should include questions whose answers are absent, documents that conflict, facts that have expired, and source updates that invalidate existing graph structures.&lt;/p&gt;
&lt;p&gt;It should compare GraphRAG with keyword search, dense RAG with reranking, and an agent allowed to search iteratively. The evaluation should measure final task success and unsupported claims, rather than stopping at retrieval recall or token reduction.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-evidence-is-stronger-than-hype-and-weaker-than-certainty&quot;&gt;The evidence is stronger than hype, and weaker than certainty&lt;/h3&gt;
&lt;p&gt;GraphRAG is no longer supported only by demos. There are systematic comparisons, accepted conference work, agentic-search benchmarks, efficiency analyses, and an emerging second generation of systems that explicitly address incomplete graph projections and evidence boundaries.&lt;/p&gt;
&lt;p&gt;Xiang et al.’s &lt;em&gt;When to Use Graphs in RAG&lt;/em&gt;, for example, was accepted as an ICLR 2026 Poster and reports results often unfavourable to broad GraphRAG claims. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2404.16130&quot;&gt;3&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;That is meaningful evidence.&lt;/p&gt;
&lt;p&gt;It is not yet mature consensus. Among the studies reviewed here, I found no GraphRAG performance claim that had reached full independent reproduction by a separate team under comparable conditions across production workloads. Much of the research remains developer-led, benchmark-specific, or dependent on the same authors who designed the evaluated framework.&lt;/p&gt;
&lt;p&gt;That is normal for a young field. It also means that “peer reviewed,” “open source,” “popular,” and “independently reproduced” should not be treated as interchangeable labels.&lt;/p&gt;
&lt;p&gt;The appropriate response is narrower confidence.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-verdict&quot;&gt;The verdict&lt;/h3&gt;
&lt;p&gt;GraphRAG is not vaporware. It is also not a new artificial brain.&lt;/p&gt;
&lt;p&gt;It can earn its cost when questions depend on relationships scattered across documents, temporal order, entity comparison, or corpus-level structure. It may lose to a much simpler retriever when the user needs one precise fact. Its graph can omit required information before retrieval begins.&lt;/p&gt;
&lt;p&gt;Its summaries can damage abstention. Combining it with ordinary RAG can give a smaller model more evidence and worse judgment. Its maintenance introduces cost and staleness, while its structure creates a new security asset that must be protected.&lt;/p&gt;
&lt;p&gt;Code graphs are often more deterministic because code has formal syntax, although their end-to-end value for agent performance remains less independently tested than their popularity suggests. LLM-maintained wikis may compound useful knowledge across sessions while also allowing the model’s earlier mistakes to become future evidence.&lt;/p&gt;
&lt;p&gt;The useful production architecture is unlikely to be GraphRAG alone. It will combine keyword search, vector retrieval, reranking, graph traversal, direct source access, and iterative agent search, selecting among them according to the question and verifying the evidence before action.&lt;/p&gt;
&lt;p&gt;GraphRAG has earned a place in that stack.&lt;/p&gt;
&lt;p&gt;The larger claim — that structuring information gives an agent understanding, judgment, and control over its own behaviour — is not supported by the studies reviewed here.&lt;/p&gt;
&lt;p&gt;Retrieval infrastructure with unresolved lifecycle governance does not fit comfortably into a launch post.&lt;/p&gt;
&lt;p&gt;So it gets called a brain instead.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;-further-technical-reading&quot;&gt;💡Further technical reading&lt;/h3&gt;
&lt;p&gt;This article focuses on the benchmark evidence and practical deployment boundary. For a more detailed technical examination of GraphRAG, persistent agent memory, provenance, invalidation, revalidation, and the gap between available knowledge and controlled action, see my full report:&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://flamehaven.space/writing/graphrag-persistent-agent-memory-and-the-distance-between-available-knowledge-and-controlled-action/&quot;&gt;&lt;em&gt;GraphRAG, Persistent Agent Memory, and the Distance Between Available Knowledge and Controlled Action&lt;/em&gt;&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;references-1&quot;&gt;References&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://flamehaven.space/writing/graphrag-persistent-agent-memory-and-the-distance-between-available-knowledge-and-controlled-action/&quot;&gt;1&lt;/a&gt;&lt;/strong&gt; Han, H. et al. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2502.11371&quot;&gt;&lt;em&gt;RAG vs. GraphRAG: A Systematic Evaluation and Key Insights&lt;/em&gt;&lt;/a&gt;. arXiv:2502.11371, revised March 2026.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2502.11371&quot;&gt;2&lt;/a&gt;&lt;/strong&gt; Edge, D. et al. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2404.16130&quot;&gt;&lt;em&gt;From Local to Global: A Graph RAG Approach to Query-Focused Summarization&lt;/em&gt;&lt;/a&gt;. arXiv:2404.16130.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2404.16130&quot;&gt;3&lt;/a&gt;&lt;/strong&gt; Xiang, Z. et al. &lt;a rel=&quot;nofollow&quot; href=&quot;https://openreview.net/forum?id=i9q9xDMjG7&quot;&gt;&lt;em&gt;When to Use Graphs in RAG: A Comprehensive Analysis for Graph Retrieval-Augmented Generation&lt;/em&gt;&lt;/a&gt;. ICLR 2026 Poster; arXiv:2506.05690.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://openreview.net/forum?id=i9q9xDMjG7&quot;&gt;4&lt;/a&gt;&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2604.09666&quot;&gt;*Do We Still Need GraphRAG? Benchmarking RAG and GraphRAG for Agentic Search Systems&lt;/a&gt;*. arXiv:2604.09666.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2604.09666&quot;&gt;5&lt;/a&gt;&lt;/strong&gt; Chen, L. et al. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2606.25656&quot;&gt;&lt;em&gt;Is GraphRAG Needed? From Basic RAG to Graph-/Agentic Solutions with Context Optimization&lt;/em&gt;&lt;/a&gt;. arXiv:2606.25656.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2606.25656&quot;&gt;6&lt;/a&gt;&lt;/strong&gt; Chen, X. et al. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2607.19301&quot;&gt;&lt;em&gt;PAGE-RAG: Evidence-Grounded Adaptive Graph Retrieval for Long-Document Question Answering&lt;/em&gt;&lt;/a&gt;. arXiv:2607.19301.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2607.19301&quot;&gt;7&lt;/a&gt;&lt;/strong&gt; Microsoft. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/microsoft/graphrag&quot;&gt;&lt;em&gt;GraphRAG Repository and Documentation&lt;/em&gt;&lt;/a&gt;. GitHub.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/microsoft/graphrag&quot;&gt;8&lt;/a&gt;&lt;/strong&gt; Graphify Labs. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Graphify-Labs/graphify&quot;&gt;*Graphify: Local Code and Document Knowledge Graphs for AI Coding Assistants&lt;/a&gt;*. GitHub.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Graphify-Labs/graphify&quot;&gt;9&lt;/a&gt;&lt;/strong&gt; Patwari, A. &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/abhigyanpatwari/GitNexus&quot;&gt;&lt;em&gt;GitNexus: The Zero-Server Code Intelligence Engine&lt;/em&gt;&lt;/a&gt;. GitHub.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/abhigyanpatwari/GitNexus&quot;&gt;10&lt;/a&gt;&lt;/strong&gt; Karpathy, A. &lt;a rel=&quot;nofollow&quot; href=&quot;https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f&quot;&gt;&lt;em&gt;LLM Wiki: A Pattern for Building Personal Knowledge Bases Using LLMs&lt;/em&gt;&lt;/a&gt;. GitHub Gist, April 2026.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f&quot;&gt;11&lt;/a&gt;&lt;/strong&gt; Gu, J. et al. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2605.28645&quot;&gt;&lt;em&gt;GraphSteal: Structural Knowledge Stealing from Graph RAG via Traversal Reconstruction&lt;/em&gt;&lt;/a&gt;. arXiv:2605.28645.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2605.28645&quot;&gt;12&lt;/a&gt;&lt;/strong&gt; He, H., Yang, H., Burckhardt, P., Kapravelos, A., Vasilescu, B., and Kästner, C. &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2412.13459&quot;&gt;&lt;em&gt;Six Million (Suspected) Fake Stars in GitHub: A Growing Spiral of Popularity Contests, Spams, and Malware&lt;/em&gt;&lt;/a&gt;. ICSE 2026; arXiv:2412.13459v2.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23790/graphrag-wont-give-your-ai-a-brain-heres-what-twelve-papers-actuallysay</guid>
<pubDate>Thu, 30 Jul 2026 04:52:44 +0000</pubDate>
</item>
<item>
<title>I Built a Local RAG Assistant with Ollama, ChromaDB and LangChain</title>
<link>https://coderlegion.com/23449/i-built-a-local-rag-assistant-with-ollama-chromadb-and-langchain</link>
<description>&lt;p&gt;During my internship at a software services company, I noticed a recurring problem: technicians spent hours searching through hundreds of pages of PDF manuals to answer client questions. The company deploys a suite of management tools accounting, HR, logistics mainly to public institutions managing projects funded by international donors.&lt;/p&gt;
&lt;p&gt;I kept thinking: this is exactly the kind of problem LLMs were made for. So for my Master's project in AI &amp;amp; Big Data, I built an intelligent assistant to handle it.&lt;/p&gt;
&lt;p&gt;The constraint that shaped every technical decision: &lt;strong&gt;no data could leave the local infrastructure&lt;/strong&gt;. These institutions handle sensitive financial data it cannot be sent to OpenAI, Anthropic, or any cloud provider.&lt;/p&gt;
&lt;p&gt;This forced me into the world of fully local LLMs. Here's what I built, what broke, and what I learned.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-problem&quot;&gt;The Problem&lt;/h3&gt;
&lt;p&gt;The company had accumulated massive technical documentation PDF manuals, configuration guides, FAQ files but no efficient way to query it. When a technician faced an issue with the accounting module or the HR module, they searched manually. Slow, inconsistent, and dependent on individual expertise.&lt;/p&gt;
&lt;p&gt;Classic LLMs can't help here: they don't know the private internal documentation. And without grounding in official docs, they hallucinate procedures dangerous when dealing with accounting operations for donor-funded projects.&lt;/p&gt;
&lt;p&gt;The solution: &lt;strong&gt;RAG (Retrieval-Augmented Generation)&lt;/strong&gt; make the LLM answer &lt;em&gt;only&lt;/em&gt; from the official documentation, injected at query time.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-rag-actually-is&quot;&gt;What RAG Actually Is&lt;/h3&gt;
&lt;p&gt;Before the code, the concept in one sentence: instead of asking the LLM to &quot;know&quot; everything, you retrieve the relevant passages from your documents and inject them into the prompt as context.&lt;/p&gt;
&lt;p&gt;The pipeline has two distinct phases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Phase 1 : Ingestion (done once)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;PDF documents
    ↓
Chunking (split into 300-character blocks)
    ↓
Embedding (convert each block to a vector)
    ↓
ChromaDB (store all vectors)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Phase 2 : Query (at each question)&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;User question
    ↓
Embed the question (same model)
    ↓
ChromaDB similarity search → top 3 chunks
    ↓
Inject chunks + question into LLM prompt
    ↓
Llama 3 generates a grounded answer
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key insight: the LLM never &quot;knows&quot; your documents. It reads them fresh at each query, from the context you provide. No fine-tuning needed. No retraining when docs are updated.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-architecture-4-docker-services&quot;&gt;The Architecture: 4 Docker Services&lt;/h3&gt;
&lt;p&gt;Everything runs locally via Docker Compose. Four services, each with a clear role:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Service &lt;/th&gt;&lt;th&gt;Role &lt;/th&gt;&lt;th&gt;Port&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Ollama&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Runs Llama 3 locally &lt;/td&gt;&lt;td&gt;11434&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;ChromaDB&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;Vector database &lt;/td&gt;&lt;td&gt;8001&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;FastAPI&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;RAG pipeline + REST API &lt;/td&gt;&lt;td&gt;8000&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Streamlit&lt;/strong&gt; &lt;/td&gt;&lt;td&gt;User interface &lt;/td&gt;&lt;td&gt;8501&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# docker-compose.yml (simplified)
services:
  ollama:
    image: ollama/ollama:latest
    ports: [&quot;11434:11434&quot;]
    volumes: [&quot;./ollama_models:/root/.ollama&quot;]
    environment:
      - OLLAMA_KEEP_ALIVE=24h

  chromadb:
    image: chromadb/chroma:latest
    ports: [&quot;8001:8000&quot;]
    volumes: [&quot;./chroma_db:/chroma/chroma&quot;]

  python-api:
    build: ./src
    ports: [&quot;8000:8000&quot;]
    depends_on: [ollama, chromadb]
    environment:
      - OLLAMA_BASE_URL=http://ollama:11434
      - CHROMA_HOST=chromadb
      - CHROMA_PORT=8000
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-1-ingesting-the-documentation&quot;&gt;Step 1 : Ingesting the Documentation&lt;/h3&gt;
&lt;p&gt;I ingested 10 documents accounting references (SYSCOHADA, SYCEBNL), a general accounting code, and internal user manuals. That's &lt;strong&gt;2,111 pages&lt;/strong&gt; split into &lt;strong&gt;9,669 chunks&lt;/strong&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# src/ingestion.py

import os
import chromadb
from langchain_community.document_loaders import PyPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_chroma import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings

DATA_DIR = &quot;/app/data/documents&quot;
COLLECTION_NAME = &quot;documentation&quot;

def load_documents():
    documents = []
    for fichier in os.listdir(DATA_DIR):
        if fichier.endswith(&quot;.pdf&quot;):
            try:
                loader = PyPDFLoader(os.path.join(DATA_DIR, fichier))
                docs = loader.load()
                # Tag each chunk with its source document
                for doc in docs:
                    doc.metadata[&quot;module&quot;] = fichier.replace(&quot;.pdf&quot;, &quot;&quot;)
                documents.extend(docs)
            except Exception as e:
                print(f&quot;Skipped {fichier}: {e}&quot;)  # Don't crash on encrypted PDFs
    return documents

def store_embeddings(chunks):
    embeddings = HuggingFaceEmbeddings(
        model_name=&quot;all-MiniLM-L6-v2&quot;,
        model_kwargs={&quot;device&quot;: &quot;cpu&quot;}
    )
    # Explicit HTTP connection — not localhost, the Docker service name
    client = chromadb.HttpClient(host=&quot;chromadb&quot;, port=8000)
    Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        collection_name=COLLECTION_NAME,
        client=client
    )
    print(f&quot;Done — {len(chunks)} vectors stored&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two things worth noting:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I tag each chunk with its source document name as metadata this allows the LLM to cite its source in the answer&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;try/except&lt;/code&gt; is not optional one encrypted PDF (it happened) would have crashed the entire ingestion without it. Install &lt;code&gt;cryptography&lt;/code&gt; if you hit an AES error: &lt;code&gt;pip install cryptography&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-2-building-the-rag-chain&quot;&gt;Step 2 : Building the RAG Chain&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# src/rag_chain.py

import chromadb
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.llms import Ollama
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

def create_rag_chain():
    # Must be the same model used during ingestion
    embeddings = HuggingFaceEmbeddings(model_name=&quot;all-MiniLM-L6-v2&quot;)

    client = chromadb.HttpClient(host=&quot;chromadb&quot;, port=8000)
    vectorstore = Chroma(
        client=client,
        collection_name=&quot;documentation&quot;,
        embedding_function=embeddings
    )

    # Retrieve top 3 most relevant chunks
    retriever = vectorstore.as_retriever(
        search_type=&quot;similarity&quot;,
        search_kwargs={&quot;k&quot;: 3}
    )

    llm = Ollama(
        base_url=&quot;http://ollama:11434&quot;,
        model=&quot;llama3&quot;,
        temperature=0.3,
        num_predict=250   # Cap response length — critical on CPU
    )

    template = &quot;&quot;&quot;You are a technical support assistant.
Answer ONLY from the provided context. Cite your source.
If the answer is not in the context, say so clearly.

Context: {context}
Question: {question}
Answer:&quot;&quot;&quot;

    prompt = ChatPromptTemplate.from_template(template)

    def format_docs(docs):
        return &quot;\n\n&quot;.join([
            f&quot;[Source: {d.metadata.get('module', '?')}]\n{d.page_content}&quot;
            for d in docs
        ])

    # The full pipeline in 4 steps
    return (
        {&quot;context&quot;: retriever | format_docs, &quot;question&quot;: RunnablePassthrough()}
        | prompt
        | llm
        | StrOutputParser()
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The chain is a pipeline: &lt;code&gt;retriever → prompt → llm → parser&lt;/code&gt;. LangChain's &lt;code&gt;|&lt;/code&gt; operator makes this clean and readable. Each step passes its output to the next.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;step-3-api-and-interface&quot;&gt;Step 3 : API and Interface&lt;/h3&gt;
&lt;p&gt;FastAPI exposes the chain as a REST endpoint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# src/main.py

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from rag_chain import create_rag_chain

app = FastAPI(title=&quot;Local RAG Assistant&quot;)
chain = create_rag_chain()

class QueryRequest(BaseModel):
    question: str

@app.post(&quot;/query&quot;)
def query(request: QueryRequest):
    try:
        answer = chain.invoke(request.question)
        return {&quot;question&quot;: request.question, &quot;answer&quot;: answer}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Streamlit makes it usable in a browser in 10 lines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# src/app.py

import streamlit as st
import requests

st.title(&quot;Technical Support Assistant&quot;)
question = st.text_input(&quot;Your question:&quot;)

if st.button(&quot;Search&quot;) and question:
    with st.spinner(&quot;Searching documentation... (1-2 min on CPU)&quot;):
        r = requests.post(
            &quot;http://python-api:8000/query&quot;,
            json={&quot;question&quot;: question},
            timeout=600
        )
        st.write(r.json()[&quot;answer&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-broke-and-how-i-fixed-it-&quot;&gt;What Broke (and How I Fixed It)&lt;/h3&gt;
&lt;p&gt;This is the part nobody writes about. Three real problems I hit.&lt;/p&gt;
&lt;h4 id=&quot;problem-1-chromadb-v2-silently-ignoring-my-data&quot;&gt;Problem 1 : ChromaDB v2 silently ignoring my data&lt;/h4&gt;
&lt;p&gt;I was connecting with the old &lt;code&gt;client_settings&lt;/code&gt; syntax:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# WRONG silently stores data locally instead of sending to the server
Chroma.from_documents(
    documents=chunks,
    client_settings=Chroma.Settings(chroma_server_host=&quot;chromadb&quot;)
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The ingestion ran without errors, showed 9669 chunks stored but ChromaDB had 0 vectors. Querying returned nothing.&lt;/p&gt;
&lt;p&gt;The fix explicit &lt;code&gt;HttpClient&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# CORRECT explicit HTTP connection to the ChromaDB Docker service
client = chromadb.HttpClient(host=&quot;chromadb&quot;, port=8000)
Chroma.from_documents(documents=chunks, client=client, ...)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Also: the v1 heartbeat endpoint (&lt;code&gt;/api/v1/heartbeat&lt;/code&gt;) is deprecated. Use &lt;code&gt;/api/v2/heartbeat&lt;/code&gt; to check if ChromaDB is alive.&lt;/p&gt;
&lt;h4 id=&quot;problem-2-langchain-moved-its-modules&quot;&gt;Problem 2 : LangChain moved its modules&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# Broke silently after a LangChain update
from langchain.prompts import ChatPromptTemplate           # ❌ ModuleNotFoundError
from langchain.schema.runnable import RunnablePassthrough  # ❌ ModuleNotFoundError

# Everything stable lives in langchain_core now
from langchain_core.prompts import ChatPromptTemplate      # ✅
from langchain_core.runnables import RunnablePassthrough   # ✅
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;LangChain has been refactoring aggressively across versions. If you hit &lt;code&gt;ModuleNotFoundError&lt;/code&gt;, check &lt;code&gt;langchain_core&lt;/code&gt; first it's the stable base layer they're committing to not break.&lt;/p&gt;
&lt;h4 id=&quot;problem-3-llama-3-timing-out-on-cpu&quot;&gt;Problem 3 : Llama 3 timing out on CPU&lt;/h4&gt;
&lt;p&gt;Llama 3 (8B parameters) on CPU generates roughly 2-5 tokens per second. A 200-word answer takes 2+ minutes. My 120-second timeout was too short.&lt;/p&gt;
&lt;p&gt;Three things helped significantly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Dropping &lt;code&gt;k&lt;/code&gt; from 5 to 3 chunks shorter prompt = faster time-to-first-token&lt;/li&gt;
&lt;li&gt;Adding &lt;code&gt;num_predict=250&lt;/code&gt; to cap response length without it, the model generates forever&lt;/li&gt;
&lt;li&gt;Allocating more RAM to Docker via &lt;code&gt;.wslconfig&lt;/code&gt; on Windows to avoid swap&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;results&quot;&gt;Results&lt;/h3&gt;
&lt;p&gt;After setup, the assistant correctly answers questions grounded in the official documentation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;&quot;How to close monthly payroll?&quot;&lt;/em&gt; → Step-by-step procedure from the HR module manual&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&quot;How to create an account in the chart of accounts?&quot;&lt;/em&gt; → Exact navigation path, source cited&lt;/li&gt;
&lt;li&gt;&lt;em&gt;&quot;What does SYSCOHADA class 4 cover?&quot;&lt;/em&gt; → Accurate definition from the accounting reference&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And critically when the answer isn't in the documentation, it says so explicitly instead of hallucinating. That was the whole point.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;key-takeaways-2&quot;&gt;Key Takeaways&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;On RAG:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;The embedding model used during ingestion must be &lt;strong&gt;identical&lt;/strong&gt; to the one used at query time. A different model produces incompatible vector spaces your similarity search returns garbage without any error message.&lt;/p&gt;
&lt;p&gt;Chunk size matters more than expected. 500 characters worked less well than 300 for technical documents shorter chunks produce more precise retrieval.&lt;/p&gt;
&lt;p&gt;Metadata on chunks is essential for source citation. Tag every chunk with its document name at ingestion time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;On local LLMs:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Running on CPU is slow but viable for non-real-time use cases like internal support tools.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;num_predict&lt;/code&gt; is your most important parameter for performance. Without it, the model generates until it decides to stop sometimes never.&lt;/p&gt;
&lt;p&gt;Docker container networking does not use &lt;code&gt;localhost&lt;/code&gt;. Use the service name defined in &lt;code&gt;docker-compose.yml&lt;/code&gt; (e.g., &lt;code&gt;chromadb&lt;/code&gt;, &lt;code&gt;ollama&lt;/code&gt;) as the hostname when connecting between containers.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;This project is available on my github : &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/josaphatstar/Assistant-Intelligent-RAG&quot;&gt;https://github.com/josaphatstar/Assistant-Intelligent-RAG&lt;/a&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;what-s-next-1&quot;&gt;What's Next&lt;/h3&gt;
&lt;p&gt;This RAG pipeline handles straightforward questions well. But it has a core limitation: &lt;strong&gt;it always does the same thing&lt;/strong&gt; regardless of the question type. Whether you ask a procedural question or report a system crash, it searches the docs and answers. No reasoning about what strategy fits best.&lt;/p&gt;
&lt;p&gt;In the next article, I'll show how I evolved this into an &lt;strong&gt;Agentic AI architecture&lt;/strong&gt; with LangGraph where the system first &lt;em&gt;decides&lt;/em&gt; which agent to call (documentation search, error diagnosis, or human escalation ticket), then acts accordingly.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I'm a Master's student in AI &amp;amp; Big Data, and this project came out of a real internship constraint no cloud, no API keys, just local infrastructure and open-source tools. I'm curious: have you built a local RAG pipeline under similar constraints? What stack did you use, and what broke first? Drop it in the comments I'd genuinely like to compare notes.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23449/i-built-a-local-rag-assistant-with-ollama-chromadb-and-langchain</guid>
<pubDate>Sat, 25 Jul 2026 14:42:23 +0000</pubDate>
</item>
<item>
<title>I Benchmarked 19 Retrieval Pipelines Head-to-Head and the Results Were Surprisingly Honest</title>
<link>https://coderlegion.com/23356/benchmarked-19-retrieval-pipelines-head-to-head-and-the-results-were-surprisingly-honest</link>
<description>&lt;p&gt;I got tired of retrieval papers claiming their pipeline is &quot;state-of-the-art&quot; without significance tests, without telling you what dataset they ran on, and without reproducible code. So I built &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/subhansh-dev/raven-retrieval&quot;&gt;raven-retrieval&lt;/a&gt; -- 19 retrieval pipelines, real BEIR datasets, Bonferroni-corrected bootstrap significance tests, all the numbers whether they look good or not. MIT licensed, open source.&lt;/p&gt;
&lt;h3 id=&quot;what-it-does&quot;&gt;What It Does&lt;/h3&gt;
&lt;p&gt;The core question: does ColBERT-style late interaction scoring at every level of a RAPTOR hierarchical tree actually improve retrieval over simpler approaches? To answer that properly you need the same datasets, same chunking, same metrics, same significance tests for every pipeline. Otherwise you're comparing apples to oranges.&lt;/p&gt;
&lt;p&gt;The framework implements 19 pipelines and benchmarks them all on BEIR datasets (SciFact, HotpotQA) on equal footing. No cherry-picking.&lt;/p&gt;
&lt;h3 id=&quot;the-19-pipelines-quick-summary-&quot;&gt;The 19 Pipelines (Quick Summary)&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Core:&lt;/strong&gt; Naive Dense RAG, Hybrid RAG (BM25+Dense+RRF), ColBERT Late Interaction, RAPTOR+Late Interaction (novel combination).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Research (2024-2025):&lt;/strong&gt; HyDE, SPLADE, SPLADE+Dense, BM25+Rocchio PRF, Contextual Retrieval, Contextual Hybrid, Late Chunking, RAPTOR+Late Traversal, Agentic Multi-Hop, Reflection Retriever, Graph Retrieval, Two-Stage Dense+Reranker, Approximate Late Interaction, Contextual Dense, Contextual BM25.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Composition modules:&lt;/strong&gt; Cross-Encoder Reranker, Two-Stage Retriever, Document Graph, Residual Compressor (~30x storage reduction).&lt;/p&gt;
&lt;p&gt;The novel one is &lt;strong&gt;RAPTOR + Late Interaction&lt;/strong&gt;: build a RAPTOR hierarchical summary tree, then apply ColBERT MaxSim scoring at every node (leaf chunks AND summary nodes). Nobody has published on this combination before.&lt;/p&gt;
&lt;h3 id=&quot;how-maxsim-works&quot;&gt;How MaxSim Works&lt;/h3&gt;
&lt;p&gt;ColBERT's scoring: for each query token, find the most similar document token (cosine), then sum those maximums across all query tokens. Every query token gets a vote for its best-matching document token. This captures fine-grained token-level relevance that single-vector cosine misses entirely. Implemented in pure numpy, no torch needed for scoring.&lt;/p&gt;
&lt;h3 id=&quot;the-benchmark-results&quot;&gt;The Benchmark Results&lt;/h3&gt;
&lt;p&gt;Run on Google Colab T4 GPU, seed=42, two datasets.&lt;/p&gt;
&lt;h4 id=&quot;scifact-100-queries-single-hop-scientific-claims-&quot;&gt;SciFact (100 queries, single-hop scientific claims)&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Pipeline &lt;/th&gt;&lt;th&gt;nDCG@10 &lt;/th&gt;&lt;th&gt;Per-Query&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;HyDE &lt;/td&gt;&lt;td&gt;0.712 &lt;/td&gt;&lt;td&gt;19ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Naive Dense &lt;/td&gt;&lt;td&gt;0.696 &lt;/td&gt;&lt;td&gt;32ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Contextual Hybrid &lt;/td&gt;&lt;td&gt;0.682 &lt;/td&gt;&lt;td&gt;93ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hybrid RAG &lt;/td&gt;&lt;td&gt;0.667 &lt;/td&gt;&lt;td&gt;87ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;BM25+PRF &lt;/td&gt;&lt;td&gt;0.529 &lt;/td&gt;&lt;td&gt;43ms&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Key finding:&lt;/strong&gt; Top 4 are &lt;strong&gt;statistically indistinguishable&lt;/strong&gt; (all pairwise p &amp;gt; 0.05 after Bonferroni). BM25+PRF is significantly worse (p ~ 0.000). The &quot;winner&quot; HyDE is only 2 points above Dense -- within noise.&lt;/p&gt;
&lt;h4 id=&quot;hotpotqa-50-queries-multi-hop-reasoning-&quot;&gt;HotpotQA (50 queries, multi-hop reasoning)&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Pipeline &lt;/th&gt;&lt;th&gt;nDCG@10 &lt;/th&gt;&lt;th&gt;Per-Query&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Hybrid RAG &lt;/td&gt;&lt;td&gt;0.925 &lt;/td&gt;&lt;td&gt;22ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Contextual Hybrid &lt;/td&gt;&lt;td&gt;0.923 &lt;/td&gt;&lt;td&gt;22ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Naive Dense &lt;/td&gt;&lt;td&gt;0.906 &lt;/td&gt;&lt;td&gt;12ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;HyDE &lt;/td&gt;&lt;td&gt;0.892 &lt;/td&gt;&lt;td&gt;26ms&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;BM25+PRF &lt;/td&gt;&lt;td&gt;0.869 &lt;/td&gt;&lt;td&gt;8ms&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;The ranking flips completely.&lt;/strong&gt; HyDE drops from 1st to 4th. Hybrid RAG wins because BM25+Dense fusion catches different reasoning hops. A single hypothetical document can't bridge two separate reasoning steps.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The most important finding:&lt;/strong&gt; No single pipeline dominates across task types. Single-hop favors semantic (HyDE). Multi-hop favors hybrid. Anyone claiming universal superiority hasn tested on enough datasets.&lt;/p&gt;
&lt;h3 id=&quot;the-bugs-i-found&quot;&gt;The Bugs I Found&lt;/h3&gt;
&lt;p&gt;Before v0.3, 13 bugs (6 critical) silently corrupted results:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ColBERT matched against PAD tokens&lt;/strong&gt; -- attention mask wasn used, padding tokens got MaxSim votes alongside real content&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RAPTOR clustering used wrong embedding space&lt;/strong&gt; -- centroids from ColBERT means while scoring with SBERT vectors&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-query nDCG was all zeros&lt;/strong&gt; -- BEIR evaluate() returns averaged floats, not per-query; significance tests fed zeros and reported &quot;no difference&quot; everywhere (false negatives)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reflection evaluated keyword coverage over doc ID strings&lt;/strong&gt; (&quot;doc_1&quot;, &quot;doc_42&quot;) instead of actual text, triggering endless reformulation loops&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;RAPTOR pipelines crashed&lt;/strong&gt; -- UMAP bug when local clusters had 3 samples (k &amp;gt;= N), burned 189 minutes of Colab time&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These don crash your program. They just make your results wrong in ways you can see from final numbers alone.&lt;/p&gt;
&lt;h3 id=&quot;known-limitations&quot;&gt;Known Limitations&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;ColBERT projection head is Xavier-initialized (untrained). Late interaction scores ~0.58 without training, worse than Dense at 0.696. Training support exists (&lt;code&gt;ColbertContrastiveEncoder&lt;/code&gt; with InfoNCE) but no trained checkpoint in default benchmark yet.&lt;/li&gt;
&lt;li&gt;GMM soft-clustering converges to near-hard assignment on ~100-token chunks (matches Stanford CS224N reproduction).&lt;/li&gt;
&lt;li&gt;Late Chunking limited by BERT's 512-token window -- needs long-context model to really shine.&lt;/li&gt;
&lt;li&gt;HotpotQA scores are on 2,000-doc subsample, not the full 5.2M corpus. Real retrieval is harder than these numbers suggest.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell&quot;&gt;git clone https://github.com/subhansh-dev/raven-retrieval.git
cd raven-retrieval
pip install -e &quot;.[full]&quot;
python run_enhanced_benchmark.py --dataset scifact --top-k 10 --seed 42
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CLI entry points: &lt;code&gt;raven-benchmark&lt;/code&gt;, &lt;code&gt;raven-report&lt;/code&gt;. Low-RAM? Use &lt;code&gt;--max-docs&lt;/code&gt;. Want trained ColBERT? Use &lt;code&gt;--colbert-checkpoint&lt;/code&gt;. All 55 tests pass.&lt;/p&gt;
&lt;h3 id=&quot;why-this-matters&quot;&gt;Why This Matters&lt;/h3&gt;
&lt;p&gt;Retrieval benchmarks have a credibility problem. Papers claim improvements without significance tests, without multiple datasets, without reproducible code. Raven-retrieval fixes all of that. The honest answer -- that top pipelines are tied, that rankings flip between datasets, that BM25+PRF is bad on scientific text -- is more useful than cherry-picked claims of universal superiority.&lt;/p&gt;
&lt;p&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/subhansh-dev/raven-retrieval&quot;&gt;github.com/subhansh-dev/raven-retrieval&lt;/a&gt; | &lt;a rel=&quot;nofollow&quot; href=&quot;http://subhansh.dev&quot;&gt;subhansh.dev&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23356/benchmarked-19-retrieval-pipelines-head-to-head-and-the-results-were-surprisingly-honest</guid>
<pubDate>Thu, 23 Jul 2026 17:45:54 +0000</pubDate>
</item>
<item>
<title>How a Pre-Retrieval Parse Loop Fixes Vague RAG Queries</title>
<link>https://coderlegion.com/23068/how-a-pre-retrieval-parse-loop-fixes-vague-rag-queries</link>
<description>&lt;p&gt;RAG (Retrieval-Augmented Generation - where an LLM answers questions using retrieved document chunks) breaks most often before retrieval even starts. The question going in is underspecified, and no retrieval system can compensate for a bad query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Idea: A Small Loop Before You Search&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Most RAG pipelines treat the user's raw question as retrieval-ready. It usually isn't. A question like &quot;what are the risks?&quot; has no anchor - no product, no time range, no document scope. The retrieval step fetches loosely related chunks, the LLM hallucinates a coherent answer from noise, and everyone blames the model.&lt;/p&gt;
&lt;p&gt;Loop engineering for question parsing inserts a lightweight reasoning step &lt;em&gt;before&lt;/em&gt; retrieval runs: parse the raw question, identify what context is missing to make it answerable, then re-parse with that gap filled - either by asking the user, inferring from conversation history, or pulling from a lightweight document index. The loop is intentionally small: one or two passes, not an autonomous agent spinning indefinitely. The goal is a query that names specific entities, a scope, and an intent - not a philosophical question the retriever has to guess at.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real Example&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Here's a minimal implementation pattern using Python and any chat-completion API:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def parse_and_refine(raw_question: str, doc_summary: str, llm) -&amp;gt; str:
 audit_prompt = f&quot;&quot;&quot;
User question: &quot;{raw_question}&quot;
Document summary: &quot;{doc_summary}&quot;

Identify what is ambiguous or missing (entity, time range, scope).
Return a refined, retrieval-ready question. If nothing is missing, return the original.
&quot;&quot;&quot;
 refined = llm.chat(audit_prompt)
 return refined.strip()

# Usage
raw = &quot;What are the risks?&quot;
doc_summary = &quot;Q3 2024 earnings report for Acme Corp, covering supply chain and FX exposure.&quot;
ready_query = parse_and_refine(raw, doc_summary, llm)
# → &quot;What supply chain and FX risks does Acme Corp report in Q3 2024?&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;doc_summary&lt;/code&gt; doesn't have to be a full index - a one-paragraph description of the document set is enough to ground the refinement. For multi-turn chat, you'd also pass conversation history into the audit prompt so the loop can resolve pronouns and carry-forward references (&quot;the risks &lt;em&gt;from last quarter&lt;/em&gt;&quot;).&lt;/p&gt;
&lt;p&gt;The cost is one extra LLM call per query. On most inference APIs that's under 50ms added latency and a fraction of a cent - easily worth it if it prevents a hallucinated answer that sends someone down the wrong path.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Key Takeaways&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Retrieval quality is bounded by query quality - fixing the query upstream is more reliable than tuning chunking or embeddings downstream.&lt;/li&gt;
&lt;li&gt;A two-pass parse loop (audit then refine) catches the most common failure mode: questions that lack entity, scope, or time anchors.&lt;/li&gt;
&lt;li&gt;Keeping the loop to one or two LLM calls maintains low latency while meaningfully improving retrieval precision.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What's the most common type of underspecified question your users actually send into your RAG system - missing entity, missing time range, or something else entirely?&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Sources referenced: Towards Data Science - Loop Engineering for RAG Question Parsing&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/23068/how-a-pre-retrieval-parse-loop-fixes-vague-rag-queries</guid>
<pubDate>Mon, 20 Jul 2026 10:01:33 +0000</pubDate>
</item>
<item>
<title>I grade my AI twin on real traffic, not just an offline test set</title>
<link>https://coderlegion.com/22872/i-grade-my-ai-twin-on-real-traffic-not-just-an-offline-test-set</link>
<description>&lt;p&gt;Most eval setups have a quiet lie in them. You run a fixed question set through your agent, a judge model scores the answers, you get a number, and you feel good. But that number describes a sandbox. It says nothing about the question a stranger typed into your live app at 2am, or whether your anti-hallucination guardrail held for that specific input.&lt;/p&gt;
&lt;p&gt;Twin is my personal AI digital twin. It answers questions about me over a RAG index of my profile, running on Bedrock with Claude Sonnet 4.5, FastAPI backend, Next.js frontend. I already had an offline eval harness. What I added this week is a second judge that grades production traffic as it happens, and building it forced me to fix an integrity bug I'd been ignoring in the offline one.&lt;/p&gt;
&lt;h3 id=&quot;the-problem-with-the-offline-harness&quot;&gt;The problem with the offline harness&lt;/h3&gt;
&lt;p&gt;My offline harness in &lt;code&gt;evals/&lt;/code&gt; does the honest-looking thing: load &lt;code&gt;queries.json&lt;/code&gt;, run retrieval, run inference, hand the result to a judge. The judge prompt is strict about faithfulness:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;JUDGE_SYSTEM_PROMPT = &quot;&quot;&quot;You are grading whether an AI-generated answer is
faithful to the provided source material. Be strict: any claim in the answer
not directly supported by the source material counts as a hallucination, even
if it sounds plausible.
...
- &quot;correctly_refused&quot; is true if the source material contains nothing relevant
  to the question and the answer clearly states the information isn't available;
  false if the source material was empty/irrelevant but the answer invented
  something anyway...
&quot;&quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The judge is good. The problem was structural: the offline harness ran its own retrieval pass, separate from the answer's retrieval pass. So the judge was grading the answer against a source set that wasn't necessarily the one the model actually saw. If retrieval is non-deterministic or the index shifts, the judge is checking the wrong evidence. You can pass an eval where the model hallucinated and fail one where it didn't. The number is measuring the wrong thing.&lt;/p&gt;
&lt;p&gt;That gap is invisible on a static question set because retrieval usually returns the same chunks twice. It stops being invisible the moment you point it at live traffic, where you have no fixed inputs at all.&lt;/p&gt;
&lt;h3 id=&quot;the-approach-capture-the-exact-source-the-model-saw&quot;&gt;The approach: capture the exact source the model saw&lt;/h3&gt;
&lt;p&gt;The fix is boring and correct. Whatever chunks the answer was generated from, those exact chunks get judged. No second retrieval.&lt;/p&gt;
&lt;p&gt;In the chat path, right after inference, I capture the request with the same &lt;code&gt;retrieved_chunks&lt;/code&gt; object that produced the answer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;retrieved_chunks = retrieval.retrieve(request.message, k=5)
# ... inference runs against retrieved_chunks ...
capture_live_eval(request.message, retrieved_chunks, assistant_response)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;capture_live_eval&lt;/code&gt; is fire-and-forget. It never raises, because an eval capture failing should never break a user's chat:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def capture_live_eval(query: str, retrieved_chunks: List, answer: str) -&amp;gt; None:
    &quot;&quot;&quot;Fire-and-forget capture of a real chat exchange for async
    faithfulness judging. Never raises.&quot;&quot;&quot;
    if not EVALS_BUCKET:
        return
    try:
        retrieved_text = &quot;\n\n&quot;.join(
            f&quot;## {chunk.section_title}\n{chunk.text}&quot;
            for chunk, score in retrieved_chunks
        )
        key = f&quot;live/raw/{datetime.now().isoformat()}-{uuid.uuid4()}.json&quot;
        body = {
            &quot;timestamp&quot;: datetime.now().isoformat(),
            &quot;query&quot;: query,
            &quot;retrieved_chunk_ids&quot;: [c.chunk_id for c, _ in retrieved_chunks],
            &quot;retrieved_text&quot;: retrieved_text,
            &quot;answer&quot;: answer,
        }
        evals_s3_client.put_object(
            Bucket=EVALS_BUCKET, Key=key,
            Body=json.dumps(body), ContentType=&quot;application/json&quot;,
        )
    except Exception as e:
        print(f&quot;Live eval capture failed (non-fatal): {e}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The important line is that &lt;code&gt;retrieved_text&lt;/code&gt; is serialized from the same chunks the answer was built on. The judge later reads exactly this. There is no second retrieval anywhere in the live path.&lt;/p&gt;
&lt;h3 id=&quot;the-judging-happens-out-of-band&quot;&gt;The judging happens out of band&lt;/h3&gt;
&lt;p&gt;I did not want to block a chat response on a judge call. So the write to S3 under &lt;code&gt;live/raw/&lt;/code&gt; triggers a separate Lambda via an S3 event. That Lambda reads the raw record, judges it, and writes the result to &lt;code&gt;live/judged/&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def process_record(bucket: str, key: str, s3_client) -&amp;gt; None:
    raw = json.loads(
        s3_client.get_object(Bucket=bucket, Key=key)[&quot;Body&quot;].read().decode(&quot;utf-8&quot;)
    )
    judged_key = key.replace(&quot;live/raw/&quot;, &quot;live/judged/&quot;, 1)

    output = dict(raw)
    try:
        judgment = judge.judge_answer(
            raw[&quot;query&quot;], raw[&quot;retrieved_text&quot;], raw[&quot;answer&quot;]
        )
        output[&quot;judgment&quot;] = judgment
    except Exception as e:
        output[&quot;judgment_error&quot;] = str(e)

    s3_client.put_object(
        Bucket=bucket, Key=judged_key,
        Body=json.dumps(output), ContentType=&quot;application/json&quot;,
    )
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The chat request writes one small JSON object and moves on. The user waits on nothing. The judge runs whenever the event fires. The same &lt;code&gt;judge_answer&lt;/code&gt; function backs both the offline harness and the live judge, so I am grading production and my test set with identical criteria. That reuse is deliberate. It's the only reason the two numbers are comparable.&lt;/p&gt;
&lt;p&gt;One detail I like: the judge runs on Bedrock with &lt;code&gt;temperature: 0.0&lt;/code&gt; and parses a strict JSON object with a brace-depth scan rather than a regex, because judge models love to wrap JSON in prose no matter how many times you tell them not to.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ujgg80idroms3zrmkmbc.png&quot; alt=&quot;Image description&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;what-broke-and-what-i-d-do-differently&quot;&gt;What broke, and what I'd do differently&lt;/h3&gt;
&lt;p&gt;The capture path writes &lt;code&gt;retrieved_chunk_ids&lt;/code&gt; alongside the text. I don't use those IDs in the judge yet, but they exist for a reason: my next problem is retrieval drift. If I rebuild the profile index, the chunk IDs behind a past judgment change, and a stored judgment silently starts referring to source text that no longer exists. Right now the judged record freezes &lt;code&gt;retrieved_text&lt;/code&gt; at capture time, which protects the judgment itself, but I have no way to diff &quot;what the judge saw&quot; against &quot;what the index says today.&quot; Storing the IDs is the first half of that; I haven't built the second half.&lt;/p&gt;
&lt;p&gt;The bigger honest gap: I only judge faithfulness. The judge can tell me the model stayed inside its source material. It cannot tell me the answer was useful, or that retrieval pulled the right chunks in the first place. A perfectly faithful answer to badly retrieved context still passes. Faithfulness is the cheap half of quality, and it's the half I automated because it's the half a single model call can check.&lt;/p&gt;
&lt;p&gt;I'd also reconsider the fire-and-forget silence. &lt;code&gt;capture_live_eval&lt;/code&gt; swallowing every exception means a broken eval bucket produces one &lt;code&gt;print&lt;/code&gt; and nothing else. On Lambda that print lands in CloudWatch and I will not notice for a week. If I were doing this again I'd emit a metric on capture failure, because a silent eval pipeline is the same as no eval pipeline.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; an eval number is only trustworthy if the judge grades the exact evidence the model actually used, and the fastest way to find out whether yours does is to point it at real traffic instead of a frozen question set.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22872/i-grade-my-ai-twin-on-real-traffic-not-just-an-offline-test-set</guid>
<pubDate>Fri, 17 Jul 2026 05:44:28 +0000</pubDate>
</item>
<item>
<title>Knowledge Management at Scale: Multi-Tenant RAG with RBAC</title>
<link>https://coderlegion.com/22788/knowledge-management-at-scale-multi-tenant-rag-with-rbac</link>
<description>&lt;p&gt;&lt;strong&gt;Your organization has knowledge scattered everywhere: docs, wikis, Slack conversations, emails, databases. Your AI agents need access to it. But you can’t let every agent access every document (security, privacy). I built Socratic-knowledge: enterprise RAG (Retrieval-Augmented Generation) with multi-tenancy, role-based access control, versioning, and audit logging. Agents retrieve exactly the knowledge they need. Users stay secure.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The Problem: Knowledge Is Fragmented and Dangerous&lt;br&gt;
Scenario 1: The Scattered Knowledge&lt;/strong&gt;&lt;br&gt;
Your organization has knowledge in:&lt;/p&gt;
&lt;p&gt;Wikis (internal documentation)&lt;br&gt;
Google Drive (design docs, strategy docs)&lt;br&gt;
GitHub (code docs, architecture)&lt;br&gt;
Slack (Q&amp;amp;A conversations, decisions)&lt;br&gt;
Email (historical decisions, context)&lt;br&gt;
Databases (product specs, configurations)&lt;br&gt;
PDFs (whitepapers, research)&lt;br&gt;
Your agents need this knowledge to make good decisions.&lt;/p&gt;
&lt;p&gt;But it’s fragmented. Inconsistent. Sometimes contradictory.&lt;/p&gt;
&lt;p&gt;Your agent searches the wiki for product specs. Gets outdated info. Your agent searches email for decision history. Gets 500 unrelated emails. Your agent searches Slack. Finds someone’s joke mixed with actual information.&lt;/p&gt;
&lt;p&gt;Agents make bad decisions because they have bad knowledge.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scenario 2: The Security Nightmare&lt;/strong&gt;&lt;br&gt;
You have sensitive knowledge:&lt;/p&gt;
&lt;p&gt;Customer data&lt;br&gt;
Financial information&lt;br&gt;
Proprietary algorithms&lt;br&gt;
Personal conversations&lt;br&gt;
Some agents need access (customer support agent needs customer data). Other agents shouldn’t (recommendation engine shouldn’t see financial data). Users need to control what’s shared (customer shouldn’t see other customer’s data).&lt;/p&gt;
&lt;p&gt;Without access control, either:&lt;/p&gt;
&lt;p&gt;You don’t share knowledge (agents are dumb)&lt;br&gt;
You share everything (security breach)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scenario 3: The Versioning Problem&lt;/strong&gt;&lt;br&gt;
Knowledge changes. Your product specs are updated. Your strategy shifts. Your processes evolve.&lt;/p&gt;
&lt;p&gt;Which version should the agent use?&lt;/p&gt;
&lt;p&gt;The current version? (might break compatibility with old customers)&lt;br&gt;
The version from when the agent was trained? (might be outdated)&lt;br&gt;
All versions? (agent gets confused by contradictions)&lt;br&gt;
Without versioning, agents work with stale knowledge.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Scenario 4: The Compliance Burden&lt;/strong&gt;&lt;br&gt;
You need to know:&lt;/p&gt;
&lt;p&gt;Which agent accessed which knowledge?&lt;br&gt;
When did it access it?&lt;br&gt;
What decisions did it make based on that knowledge?&lt;br&gt;
If a decision was wrong, which knowledge led to it?&lt;br&gt;
Without audit logging, you have no compliance trail.&lt;/p&gt;
&lt;p&gt;Why Standard RAG Fails for Multi-Tenant Systems&lt;br&gt;
Standard RAG&lt;br&gt;
Standard RAG (Retrieval-Augmented Generation):&lt;/p&gt;
&lt;p&gt;Store all knowledge in a vector database&lt;br&gt;
When agent needs knowledge, search the database&lt;br&gt;
Return top matching documents&lt;br&gt;
Agent uses documents to make decisions&lt;br&gt;
This works for single-tenant systems. It breaks for multi-tenant:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem 1: Access Control&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Anyone with access to the RAG system can retrieve any knowledge&lt;br&gt;
No way to restrict based on role or tenant&lt;br&gt;
&lt;strong&gt;Problem 2: Data Isolation&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;All data is mixed together&lt;br&gt;
One tenant’s knowledge bleeds into another’s&lt;br&gt;
&lt;strong&gt;Problem 3: Versioning&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Documents are static&lt;br&gt;
No way to track multiple versions&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Problem 4: Audit Trail&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;No logging of who accessed what&lt;br&gt;
No compliance trail&lt;br&gt;
The Solution: Socratic-Knowledge&lt;br&gt;
Socratic-knowledge is an enterprise RAG system built for multi-tenant environments.&lt;/p&gt;
&lt;p&gt;Core Features&lt;br&gt;
Feature 1: Multi-Tenancy&lt;/p&gt;
&lt;p&gt;Complete data isolation between tenants:&lt;/p&gt;
&lt;p&gt;Organization A:&lt;/p&gt;
&lt;p&gt;├─ Documents (completely isolated)&lt;/p&gt;
&lt;p&gt;├─ Knowledge base (separate)&lt;/p&gt;
&lt;p&gt;├─ Agents (can only access org A knowledge)&lt;/p&gt;
&lt;p&gt;└─ Users (can only see org A)&lt;/p&gt;
&lt;p&gt;Organization B:&lt;/p&gt;
&lt;p&gt;├─ Documents (completely isolated)&lt;/p&gt;
&lt;p&gt;├─ Knowledge base (separate)&lt;/p&gt;
&lt;p&gt;├─ Agents (can only access org B knowledge)&lt;/p&gt;
&lt;p&gt;└─ Users (can only see org B)&lt;/p&gt;
&lt;p&gt;System enforces: Organization A agents NEVER see Organization B documents&lt;br&gt;
Feature 2: Role-Based Access Control (RBAC)&lt;/p&gt;
&lt;p&gt;Fine-grained permissions:&lt;/p&gt;
&lt;p&gt;Roles within Organization A:&lt;/p&gt;
&lt;p&gt;Admin:&lt;/p&gt;
&lt;p&gt;├─ Can view all documents&lt;/p&gt;
&lt;p&gt;├─ Can edit all documents&lt;/p&gt;
&lt;p&gt;├─ Can grant/revoke access&lt;/p&gt;
&lt;p&gt;└─ Can view audit logs&lt;/p&gt;
&lt;p&gt;Manager:&lt;/p&gt;
&lt;p&gt;├─ Can view team documents&lt;/p&gt;
&lt;p&gt;├─ Can edit team documents&lt;/p&gt;
&lt;p&gt;├─ Can grant access to team&lt;/p&gt;
&lt;p&gt;└─ Can view team audit logs&lt;/p&gt;
&lt;p&gt;Employee:&lt;/p&gt;
&lt;p&gt;├─ Can view shared documents&lt;/p&gt;
&lt;p&gt;├─ Cannot edit&lt;/p&gt;
&lt;p&gt;├─ Cannot grant access&lt;/p&gt;
&lt;p&gt;└─ Cannot view audit logs&lt;/p&gt;
&lt;p&gt;Example: Customer Support Agent&lt;/p&gt;
&lt;p&gt;├─ Can access: Customer data, product docs, policies&lt;/p&gt;
&lt;p&gt;├─ Cannot access: Financial data, employee records, future roadmap&lt;/p&gt;
&lt;p&gt;└─ Can only see data for assigned customers&lt;/p&gt;
&lt;p&gt;Feature 3: Document Versioning&lt;/p&gt;
&lt;p&gt;Track changes over time:&lt;/p&gt;
&lt;p&gt;Product Specification Document&lt;/p&gt;
&lt;p&gt;Version 1 (Jan 2026):&lt;/p&gt;
&lt;p&gt;├─ SKU: 123&lt;/p&gt;
&lt;p&gt;├─ Price: €49.99&lt;/p&gt;
&lt;p&gt;├─ Description: &quot;Widget v1&quot;&lt;/p&gt;
&lt;p&gt;└─ Status: Active&lt;/p&gt;
&lt;p&gt;Version 2 (Mar 2026):&lt;/p&gt;
&lt;p&gt;├─ SKU: 123&lt;/p&gt;
&lt;p&gt;├─ Price: €59.99 (updated)&lt;/p&gt;
&lt;p&gt;├─ Description: &quot;Widget v1.5&quot; (updated)&lt;/p&gt;
&lt;p&gt;└─ Status: Deprecated&lt;/p&gt;
&lt;p&gt;Version 3 (May 2026):&lt;/p&gt;
&lt;p&gt;├─ SKU: 456&lt;/p&gt;
&lt;p&gt;├─ Price: €69.99 (new product)&lt;/p&gt;
&lt;p&gt;├─ Description: &quot;Widget v2&quot;&lt;/p&gt;
&lt;p&gt;└─ Status: Active&lt;/p&gt;
&lt;p&gt;Agent using version 3 gets current info.&lt;/p&gt;
&lt;p&gt;Legacy system using version 2 still works.&lt;/p&gt;
&lt;p&gt;Historical queries can use historical versions.&lt;br&gt;
Feature 4: Audit Logging&lt;/p&gt;
&lt;p&gt;Complete compliance trail:&lt;/p&gt;
&lt;p&gt;Audit Log:&lt;/p&gt;
&lt;p&gt;2026-05-14 10:30:00 | RecommendationAgent | Retrieved | &quot;Customer Preferences v2&quot;&lt;/p&gt;
&lt;p&gt;2026-05-14 10:30:15 | RecommendationAgent | Generated | &quot;Recommendation 123&quot;&lt;/p&gt;
&lt;p&gt;2026-05-14 10:31:00 | UserNotificationAgent | Retrieved | &quot;User Preferences&quot;&lt;/p&gt;
&lt;p&gt;2026-05-14 10:31:30 | UserNotificationAgent | Retrieved | &quot;Marketing Policies&quot;&lt;/p&gt;
&lt;p&gt;2026-05-14 10:32:00 | AnalyticsAgent | Retrieved | &quot;Sales Metrics (restricted)&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;                                         ↑

                         Agent accessed restricted doc

                         System flagged as access denied
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Query Audit Trail:&lt;/p&gt;
&lt;p&gt;├─ Who accessed? RecommendationAgent&lt;/p&gt;
&lt;p&gt;├─ When? 2026-05-14 10:30:00&lt;/p&gt;
&lt;p&gt;├─ What did they access? Customer Preferences v2&lt;/p&gt;
&lt;p&gt;├─ Which agent? ID: agent_456&lt;/p&gt;
&lt;p&gt;├─ For which customer? Customer ID: cust_789&lt;/p&gt;
&lt;p&gt;└─ Did they have permission? Yes (role: recommendation_engine)&lt;br&gt;
Feature 5: Semantic Search&lt;/p&gt;
&lt;p&gt;Find knowledge by meaning, not keywords:&lt;/p&gt;
&lt;p&gt;Query: &quot;How do we handle complaints?&quot;&lt;/p&gt;
&lt;p&gt;Traditional search matches:&lt;/p&gt;
&lt;p&gt;├─ &quot;complaint form&quot; (keyword match)&lt;/p&gt;
&lt;p&gt;├─ &quot;complaints process&quot; (keyword match)&lt;/p&gt;
&lt;p&gt;└─ &quot;customer complaints&quot; (keyword match)&lt;/p&gt;
&lt;p&gt;Semantic search understands meaning:&lt;/p&gt;
&lt;p&gt;├─ &quot;customer support escalation&quot; (related meaning)&lt;/p&gt;
&lt;p&gt;├─ &quot;complaint resolution procedure&quot; (related meaning)&lt;/p&gt;
&lt;p&gt;├─ &quot;unhappy customer handling&quot; (semantic equivalent)&lt;/p&gt;
&lt;p&gt;└─ &quot;issue resolution framework&quot; (underlying concept)&lt;/p&gt;
&lt;p&gt;Returns more relevant results.&lt;/p&gt;
&lt;p&gt;Finds knowledge agent wouldn't find with keywords.&lt;br&gt;
Feature 6: Vector Embeddings&lt;/p&gt;
&lt;p&gt;Store knowledge in a way that’s searchable and meaningful:&lt;/p&gt;
&lt;p&gt;Document: &quot;Our product is fast and reliable&quot;&lt;/p&gt;
&lt;p&gt;Traditional storage:&lt;/p&gt;
&lt;p&gt;├─ Search for &quot;fast&quot;: Found (keyword match)&lt;/p&gt;
&lt;p&gt;├─ Search for &quot;quick&quot;: Not found (different word)&lt;/p&gt;
&lt;p&gt;└─ Search for &quot;performance&quot;: Not found (synonym not used)&lt;/p&gt;
&lt;p&gt;Vector embedding:&lt;/p&gt;
&lt;p&gt;├─ Converts to vector: [0.23, -0.45, 0.89, ...]&lt;/p&gt;
&lt;p&gt;├─ Search for &quot;fast&quot;: Found (semantic similarity)&lt;/p&gt;
&lt;p&gt;├─ Search for &quot;quick&quot;: Found (semantic similarity)&lt;/p&gt;
&lt;p&gt;└─ Search for &quot;performance&quot;: Found (semantic similarity)&lt;/p&gt;
&lt;p&gt;Agent searches for &quot;How responsive is our product?&quot;&lt;/p&gt;
&lt;p&gt;System finds &quot;Our product is fast and reliable&quot;&lt;/p&gt;
&lt;p&gt;Even though no exact words match.&lt;br&gt;
Real Examples&lt;br&gt;
&lt;strong&gt;Example 1: Customer Support&lt;/strong&gt;&lt;br&gt;
Customer support agent helps users:&lt;/p&gt;
&lt;p&gt;Agent: CustomerSupportAgent&lt;/p&gt;
&lt;p&gt;Permissions:&lt;/p&gt;
&lt;p&gt;├─ View: Customer data, product docs, support policies&lt;/p&gt;
&lt;p&gt;├─ Cannot: View financial data, employee records&lt;/p&gt;
&lt;p&gt;└─ Data scope: Only assigned customers&lt;/p&gt;
&lt;p&gt;Query: &quot;User asks: 'How do I reset my password?'&quot;&lt;/p&gt;
&lt;p&gt;Agent retrieves:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Search knowledge base: &quot;password reset&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Found documents (in order of relevance):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&quot;Password Reset Procedure v5&quot; (current)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&quot;Troubleshooting: Can't Reset Password&quot; (related)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&quot;Security: Password Requirements&quot; (related)&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Agent uses documents to respond to user.&lt;/p&gt;
&lt;p&gt;Audit trail:&lt;/p&gt;
&lt;p&gt;├─ Time: 2026-05-14 14:32:00&lt;/p&gt;
&lt;p&gt;├─ Agent: CustomerSupportAgent&lt;/p&gt;
&lt;p&gt;├─ Documents accessed: 3&lt;/p&gt;
&lt;p&gt;├─ Customer: cust_123&lt;/p&gt;
&lt;p&gt;└─ Outcome: User helped with password reset&lt;/p&gt;
&lt;p&gt;System tracks: For compliance, debugging, improvement&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Example 2: Policy Enforcement&lt;/strong&gt;&lt;br&gt;
Recommendation agent must follow policies:&lt;/p&gt;
&lt;p&gt;Agent: RecommendationAgent&lt;/p&gt;
&lt;p&gt;Permissions:&lt;/p&gt;
&lt;p&gt;├─ View: Product specs, customer preferences, pricing&lt;/p&gt;
&lt;p&gt;├─ Cannot: View employee data, internal discussions&lt;/p&gt;
&lt;p&gt;└─ Data scope: All products, assigned customer&lt;/p&gt;
&lt;p&gt;Query: Agent generates recommendation&lt;/p&gt;
&lt;p&gt;Knowledge retrieval:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Product: &quot;Widget v2&quot;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Spec: €69.99&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Availability: In stock&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Policy: &quot;No recommendations for out-of-stock items&quot;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Policy version: Current&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Status: Active&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Effect: Exclude out-of-stock&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Customer preferences: &quot;Likes blue products&quot;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Version: Current (updated yesterday)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Effect: Prioritize blue&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Agent generates recommendation respecting:&lt;/p&gt;
&lt;p&gt;├─ Product specs (accurate pricing)&lt;/p&gt;
&lt;p&gt;├─ Policies (follows rules)&lt;/p&gt;
&lt;p&gt;└─ User preferences (personalized)&lt;/p&gt;
&lt;p&gt;If policy changes, next query uses new policy.&lt;/p&gt;
&lt;p&gt;No code change needed.&lt;br&gt;
&lt;strong&gt;Example 3: Historical Context&lt;/strong&gt;&lt;br&gt;
Agent needs context from the past:&lt;/p&gt;
&lt;p&gt;Query: &quot;Why did we stop using AWS?&quot;&lt;/p&gt;
&lt;p&gt;Retrieves:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Version 1 (Jan 2026): &quot;AWS Evaluation: Good for scaling&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Version 2 (Mar 2026): &quot;AWS Issues: Cost overruns&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Version 3 (May 2026): &quot;AWS Decision: Switch to GCP&quot;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With versions, agent can understand:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;What we thought originally&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What changed (cost overruns)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;What decision we made (switch)&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Why (cost)&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Historical context enables better decisions.&lt;br&gt;
Implementation: Setting Up Knowledge Management&lt;br&gt;
Step 1: Create Tenant and Knowledge Base&lt;br&gt;
from socratic_knowledge import KnowledgeManager&lt;/p&gt;
&lt;p&gt;km = KnowledgeManager()&lt;/p&gt;
&lt;h2 id=&quot;create-organization-tenant-&quot;&gt;Create organization (tenant)&lt;/h2&gt;
&lt;p&gt;org = await km.create_organization(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;name=&quot;Acme Corp&quot;,

id=&quot;org_123&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;)&lt;/p&gt;
&lt;h2 id=&quot;create-knowledge-base-for-organization&quot;&gt;Create knowledge base for organization&lt;/h2&gt;
&lt;p&gt;kb = await km.create_knowledge_base(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;organization_id=&quot;org_123&quot;,

name=&quot;Acme Knowledge Base&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;)&lt;br&gt;
Step 2: Define Roles and Permissions&lt;/p&gt;
&lt;h2 id=&quot;define-role-customer-support-agent&quot;&gt;Define role: Customer Support Agent&lt;/h2&gt;
&lt;p&gt;support_role = await km.create_role(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;organization_id=&quot;org_123&quot;,

name=&quot;customer_support&quot;,

permissions={

    &quot;view&quot;: [&quot;customer_data&quot;, &quot;product_docs&quot;, &quot;support_policies&quot;],

    &quot;edit&quot;: [&quot;support_tickets&quot;],

    &quot;cannot_view&quot;: [&quot;financial_data&quot;, &quot;employee_records&quot;]

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;)&lt;/p&gt;
&lt;h2 id=&quot;assign-role-to-agent&quot;&gt;Assign role to agent&lt;/h2&gt;
&lt;p&gt;await km.assign_role(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;agent_id=&quot;support_agent_1&quot;,

role_id=&quot;customer_support&quot;,

organization_id=&quot;org_123&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;)&lt;br&gt;
Step 3: Upload Documents&lt;/p&gt;
&lt;h2 id=&quot;upload-document&quot;&gt;Upload document&lt;/h2&gt;
&lt;p&gt;doc = await kb.upload_document(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;title=&quot;Password Reset Procedure&quot;,

content=&quot;...&quot;,

version=&quot;5.0&quot;,

access_level=&quot;public&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;)&lt;/p&gt;
&lt;h2 id=&quot;upload-sensitive-document&quot;&gt;Upload sensitive document&lt;/h2&gt;
&lt;p&gt;sensitive = await kb.upload_document(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;title=&quot;Employee Compensation&quot;,

content=&quot;...&quot;,

version=&quot;1.0&quot;,

access_level=&quot;restricted&quot;,

allowed_roles=[&quot;admin&quot;, &quot;hr&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;)&lt;br&gt;
Step 4: Agent Retrieves Knowledge&lt;/p&gt;
&lt;h2 id=&quot;agent-searches-with-permissions-enforced&quot;&gt;Agent searches with permissions enforced&lt;/h2&gt;
&lt;p&gt;results = await kb.search(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;query=&quot;How do we handle customer complaints?&quot;,

agent_id=&quot;support_agent_1&quot;,  # Permissions checked

organization_id=&quot;org_123&quot;     # Tenant isolated
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;)&lt;/p&gt;
&lt;h2 id=&quot;returns-only-documents-agent-has-access-to&quot;&gt;Returns only documents agent has access to&lt;/h2&gt;
&lt;p&gt;for result in results:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;print(f&quot;- {result.title} (v{result.version})&quot;)


&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;automatically-logged-for-audit-trail&quot;&gt;Automatically logged for audit trail&lt;/h2&gt;
&lt;p&gt;Step 5: View Audit Trail&lt;/p&gt;
&lt;h2 id=&quot;check-who-accessed-what&quot;&gt;Check who accessed what&lt;/h2&gt;
&lt;p&gt;audit = await km.get_audit_log(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;organization_id=&quot;org_123&quot;,

document_id=&quot;doc_456&quot;,

time_range=(start_date, end_date)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;)&lt;/p&gt;
&lt;p&gt;for entry in audit:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;print(f&quot;{entry.timestamp}: {entry.agent_id} accessed {entry.document_id}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The Philosophy: Knowledge Should Be Trustworthy&lt;br&gt;
Agents are only as good as the knowledge they’re given.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Socratic-knowledge ensures knowledge is:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Accurate (versions ensure you know which version you’re using)&lt;br&gt;
Appropriate (access control ensures agents only see what they should)&lt;br&gt;
Traceable (audit logging ensures you know where decisions came from)&lt;br&gt;
Secure (multi-tenancy ensures data stays isolated)&lt;br&gt;
With trustworthy knowledge, agents make trustworthy decisions.&lt;/p&gt;
&lt;p&gt;Use Socratic-Knowledge for Enterprise RAG&lt;br&gt;
The enterprise knowledge management system described in this post is production-ready and open source:&lt;/p&gt;
&lt;p&gt;GitHub: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Nireus79/Socrates&quot;&gt;https://github.com/Nireus79/Socrates&lt;/a&gt;&lt;br&gt;
PyPI Package: &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/project/socratic-knowledge/&quot;&gt;https://pypi.org/project/socratic-knowledge/&lt;/a&gt;&lt;br&gt;
Documentation: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Nireus79/Socrates/tree/main/socratic-knowledge&quot;&gt;https://github.com/Nireus79/Socrates/tree/main/socratic-knowledge&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Quick Start&lt;/p&gt;
&lt;h2 id=&quot;install-the-knowledge-system&quot;&gt;Install the knowledge system&lt;/h2&gt;
&lt;p&gt;pip install socratic-knowledge&lt;/p&gt;
&lt;p&gt;from socratic_knowledge import KnowledgeManager&lt;/p&gt;
&lt;p&gt;km = KnowledgeManager()&lt;/p&gt;
&lt;h2 id=&quot;create-knowledge-base&quot;&gt;Create knowledge base&lt;/h2&gt;
&lt;p&gt;kb = await km.create_knowledge_base(organization_id=&quot;org_123&quot;)&lt;/p&gt;
&lt;h2 id=&quot;upload-documents&quot;&gt;Upload documents&lt;/h2&gt;
&lt;p&gt;await kb.upload_document(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;title=&quot;Product Specs&quot;,

content=&quot;...&quot;,

version=&quot;1.0&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;)&lt;/p&gt;
&lt;h2 id=&quot;agent-retrieves-with-permissions&quot;&gt;Agent retrieves with permissions&lt;/h2&gt;
&lt;p&gt;results = await kb.search(&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;query=&quot;product specifications&quot;,

agent_id=&quot;agent_1&quot;,

organization_id=&quot;org_123&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;)&lt;/p&gt;
&lt;p&gt;for result in results:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;print(f&quot;- {result.title}&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Full examples and documentation: &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/Nireus79/Socrates&quot;&gt;https://github.com/Nireus79/Socrates&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;The Complete Socratic Ecosystem&lt;br&gt;
Socratic-knowledge is part of the complete Socrates AI system (11 modules, 2,300+ tests):&lt;/p&gt;
&lt;p&gt;Socratic-nexus: Multi-provider LLM client&lt;br&gt;
Socratic-morality: Constitutional governance with 13 modules&lt;br&gt;
Socratic-agents: Multi-agent orchestration with conflict resolution&lt;br&gt;
Socratic-knowledge: Enterprise RAG with multi-tenancy(this post)&lt;br&gt;
Socratic-learning: Self-improving agents&lt;br&gt;
Socratic-analyzer: Code quality analysis&lt;br&gt;
Socratic-performance: Real-time monitoring&lt;br&gt;
Socratic-workflow: Workflow orchestration&lt;br&gt;
Socratic-conflict: Conflict resolution between agents&lt;br&gt;
Socratic-docs: Auto-documentation&lt;br&gt;
Socratic-maturity: Project maturity tracking&lt;br&gt;
All modules work together seamlessly. Use individual packages or the complete platform.&lt;/p&gt;
&lt;p&gt;Available on PyPI under MIT License: &lt;a rel=&quot;nofollow&quot; href=&quot;https://pypi.org/user/Nireus79/&quot;&gt;https://pypi.org/user/Nireus79/&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22788/knowledge-management-at-scale-multi-tenant-rag-with-rbac</guid>
<pubDate>Thu, 16 Jul 2026 05:41:30 +0000</pubDate>
</item>
<item>
<title>Building a Reliable Agentic Loop: Retries, Tool Errors, and Knowing When to Stop</title>
<link>https://coderlegion.com/22749/building-a-reliable-agentic-loop-retries-tool-errors-and-knowing-when-to-stop</link>
<description>&lt;p&gt;Every &quot;build an AI agent&quot; tutorial shows you the same twelve lines: a &lt;code&gt;while&lt;/code&gt; loop, a call to the model, a check for tool calls, execute them, feed the results back, repeat. It runs, the agent uses a tool, you feel like a wizard. Then you put it in front of real inputs and it does one of three things: loops forever, retries a tool that returned a permanent &lt;code&gt;404&lt;/code&gt; ten times in a row, or confidently finishes a task it never actually completed.&lt;/p&gt;
&lt;p&gt;Here's the thing the tutorials leave out: &lt;strong&gt;the loop is the easy part. The reliability around the loop is the actual engineering.&lt;/strong&gt; The happy path is a demo. What separates an agent you can deploy from an agent you have to babysit is entirely in the stuff that isn't the happy path — how it handles tool failures, how it decides it's done, and how it refuses to run forever.&lt;/p&gt;
&lt;p&gt;This is a field guide to that reliability layer, in real Python. We'll start from the naive loop, then add the four things production agents need and demos never have: a stop condition that isn't just &quot;no more tool calls,&quot; error handling that tells the difference between &lt;em&gt;retry this&lt;/em&gt; and &lt;em&gt;give up&lt;/em&gt;, a hard budget so the loop can't run away, and structured logging so you can see why it did what it did.&lt;/p&gt;
&lt;h3 id=&quot;the-naive-loop-and-exactly-where-it-breaks&quot;&gt;The naive loop — and exactly where it breaks&lt;/h3&gt;
&lt;p&gt;Here's the canonical agentic loop, the one everyone starts with:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def run_agent(client, messages, tools):
    while True:
        response = client.chat(messages=messages, tools=tools)
        messages.append(response.message)

        if not response.tool_calls:
            return response.message.content  # model answered — done

        for call in response.tool_calls:
            result = TOOLS[call.name](**call.arguments)   # &amp;lt;-- everything wrong lives here
            messages.append(tool_result(call.id, result))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The structure is correct. It's the &lt;em&gt;unstated assumptions&lt;/em&gt; that kill it in production:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It trusts the tool to succeed.&lt;/strong&gt; &lt;code&gt;TOOLS[call.name](**call.arguments)&lt;/code&gt; has no error handling. One exception and the whole agent crashes mid-task.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It trusts the model to stop.&lt;/strong&gt; The only exit is &quot;the model produced no tool calls.&quot; A model stuck in a plan-execute-replan spiral never hits that, and &lt;code&gt;while True&lt;/code&gt; means &lt;em&gt;forever&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It trusts the model to call real tools with valid arguments.&lt;/strong&gt; &lt;code&gt;TOOLS[call.name]&lt;/code&gt; &lt;code&gt;KeyError&lt;/code&gt;s on a hallucinated tool name; &lt;code&gt;**call.arguments&lt;/code&gt; &lt;code&gt;TypeError&lt;/code&gt;s on a malformed argument object.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It has no memory of cost.&lt;/strong&gt; No token budget, no step cap. A runaway loop is a runaway bill, discovered on the invoice.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every one of these is a real incident waiting to happen. Let's close them one at a time.&lt;/p&gt;
&lt;h3 id=&quot;fix-1-tool-errors-are-messages-not-crashes&quot;&gt;Fix 1: Tool errors are messages, not crashes&lt;/h3&gt;
&lt;p&gt;The single most important shift: &lt;strong&gt;a tool failure is not an exception that should propagate — it's information the agent needs.&lt;/strong&gt; When a tool fails, the model should &lt;em&gt;see&lt;/em&gt; the failure, reason about it, and either retry differently or route around it. That only works if you catch the error and feed it back as a tool result the model can read.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def execute_tool(call):
    fn = TOOLS.get(call.name)
    if fn is None:
        # Hallucinated tool — tell the model, don't crash.
        return f&quot;Error: tool '{call.name}' does not exist. Available: {list(TOOLS)}&quot;
    try:
        return str(fn(**call.arguments))
    except TypeError as e:
        # Bad arguments — the model can correct these if you let it see them.
        return f&quot;Error: invalid arguments for '{call.name}': {e}&quot;
    except Exception as e:
        # Real failure — return a legible, model-readable error.
        return f&quot;Error calling '{call.name}': {e}&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice the errors are written &lt;strong&gt;for a model to read&lt;/strong&gt;, not for a log file. &lt;code&gt;&quot;Error: tool 'serch_web' does not exist. Available: ['search_web', ...]&quot;&lt;/code&gt; lets the model notice its typo and self-correct on the next turn. A raw &lt;code&gt;KeyError&lt;/code&gt; traceback teaches it nothing. This is the difference between an agent that recovers and one that dies: your error strings are part of the agent's reasoning surface, so write them like instructions, not like stack traces.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;But not every error should be retried.&lt;/strong&gt; This is where naive agents burn budget: a &lt;code&gt;404 Not Found&lt;/code&gt; or &lt;code&gt;403 Forbidden&lt;/code&gt; is &lt;em&gt;permanent&lt;/em&gt; — retrying it is pure waste. A &lt;code&gt;429 Rate Limited&lt;/code&gt; or &lt;code&gt;503&lt;/code&gt; is &lt;em&gt;transient&lt;/em&gt; — retrying (with backoff) is exactly right. Teach the distinction:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;PERMANENT = {400, 401, 403, 404, 422}   # don't retry — the request itself is wrong
TRANSIENT = {429, 500, 502, 503, 504}   # retry with backoff — the world is temporarily busy

def classify_error(status):
    if status in PERMANENT:
        return &quot;permanent&quot;   # tell the model to try a different approach
    if status in TRANSIENT:
        return &quot;transient&quot;   # retry the same call after a wait
    return &quot;unknown&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For transient failures, wrap the &lt;em&gt;call itself&lt;/em&gt; in bounded retries with exponential backoff — but cap the attempts. &quot;Retry until it works&quot; is how you turn a brief outage into an infinite loop. Knowing which failures the model should reason about versus which the infrastructure should silently retry is a core design decision in &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/courses/ai-agents-automatizare&quot;&gt;building autonomous AI agents&lt;/a&gt;, and getting it wrong is the most common reason agents feel flaky.&lt;/p&gt;
&lt;h3 id=&quot;fix-2-stop-conditions-no-tool-calls-is-not-enough&quot;&gt;Fix 2: Stop conditions — &quot;no tool calls&quot; is not enough&lt;/h3&gt;
&lt;p&gt;The naive loop has exactly one exit. Real agents need several, because there are several distinct ways an agent's run should end — and only one of them is &quot;success.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def run_agent(client, messages, tools, max_steps=15, token_budget=100_000):
    steps = 0
    tokens_used = 0

    while True:
        # --- Stop condition A: step cap (guards against loops) ---
        if steps &amp;gt;= max_steps:
            return finish(&quot;max_steps_exceeded&quot;, messages)

        # --- Stop condition B: token budget (guards against cost) ---
        if tokens_used &amp;gt;= token_budget:
            return finish(&quot;budget_exhausted&quot;, messages)

        response = client.chat(messages=messages, tools=tools)
        tokens_used += response.usage.total_tokens
        steps += 1
        messages.append(response.message)

        # --- Stop condition C: the model answered (the good exit) ---
        if not response.tool_calls:
            return finish(&quot;completed&quot;, messages, answer=response.message.content)

        # --- Otherwise: execute tools and continue ---
        for call in response.tool_calls:
            result = execute_tool(call)
            messages.append(tool_result(call.id, result))
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four ways out, and the difference between them &lt;em&gt;matters to your caller&lt;/em&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Completed&lt;/strong&gt; — the model produced a final answer. Success.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Max steps exceeded&lt;/strong&gt; — the loop hit its ceiling. This is a &lt;em&gt;failure signal&lt;/em&gt;, not a silent stop: surface it, because it usually means the task was underspecified or a tool is misbehaving.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Budget exhausted&lt;/strong&gt; — cost cap hit. Same: a signal, not a shrug.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;(Add as needed) explicit &lt;code&gt;finish&lt;/code&gt; tool&lt;/strong&gt; — for complex agents, give the model an explicit &lt;code&gt;submit_answer&lt;/code&gt; tool so &lt;em&gt;finishing is a deliberate act&lt;/em&gt;, not merely the absence of tool calls. This is more robust than inferring completion, because &quot;no tool calls&quot; can also mean the model got confused.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The critical anti-pattern: &lt;strong&gt;&lt;code&gt;while True&lt;/code&gt; with a single exit is a production incident with a countdown timer.&lt;/strong&gt; Always bound the loop by steps &lt;em&gt;and&lt;/em&gt; by cost. And when you exit on a bound rather than on success, say so — a caller that can't tell &quot;here's your answer&quot; from &quot;I gave up after 15 steps&quot; will happily ship the second as if it were the first.&lt;/p&gt;
&lt;h3 id=&quot;fix-3-detecting-the-loop-within-the-loop&quot;&gt;Fix 3: Detecting the loop-within-the-loop&lt;/h3&gt;
&lt;p&gt;The subtler failure isn't infinite iteration — your step cap catches that. It's the agent that makes &lt;em&gt;progress-shaped motion&lt;/em&gt; without progressing: calling the same tool with the same arguments three times, or ping-ponging between two tools forever. The step cap eventually stops it, but you've burned 15 steps to accomplish nothing.&lt;/p&gt;
&lt;p&gt;Detect it by tracking recent actions and noticing repetition:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from collections import deque

recent = deque(maxlen=4)

def is_stuck(call):
    signature = (call.name, str(sorted(call.arguments.items())))
    if recent.count(signature) &amp;gt;= 2:      # same exact call, 3rd time
        return True
    recent.append(signature)
    return False
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When you detect a stuck agent, the fix isn't to kill it — it's to &lt;strong&gt;interrupt with a nudge&lt;/strong&gt;: inject a message like &lt;em&gt;&quot;You've called &lt;code&gt;search_web('AI agents')&lt;/code&gt; three times with identical arguments and gotten the same result. Try a different approach or provide your best answer with what you have.&quot;&lt;/em&gt; Often the model just needed to be told it was in a rut. Deciding what the agent remembers across steps — the running transcript, prior tool results, what to summarize versus keep verbatim — is its own discipline. Let the message history grow unbounded and you'll blow the context window mid-task; that trade-off between memory and budget is exactly what &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/courses/context-engineering-memorie-agenti&quot;&gt;context engineering for AI agents&lt;/a&gt; is about, and it's where loop reliability and cost control meet.&lt;/p&gt;
&lt;h3 id=&quot;fix-4-you-can-t-fix-what-you-can-t-see&quot;&gt;Fix 4: You can't fix what you can't see&lt;/h3&gt;
&lt;p&gt;An agent is a non-deterministic system making autonomous decisions. When it does something wrong — and it will — &quot;it just didn't work&quot; is useless. You need to reconstruct &lt;em&gt;what it did and why&lt;/em&gt;. That means logging &lt;strong&gt;every step as a structured record&lt;/strong&gt;, not a print statement:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def log_step(step, response, tokens_used):
    log.info(&quot;agent_step&quot;, extra={
        &quot;step&quot;: step,
        &quot;tool_calls&quot;: [c.name for c in response.tool_calls],
        &quot;finish_reason&quot;: response.finish_reason,
        &quot;tokens_this_step&quot;: response.usage.total_tokens,
        &quot;tokens_cumulative&quot;: tokens_used,
    })
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;With this, a failed run becomes a readable trace: step 1 called &lt;code&gt;search_web&lt;/code&gt;, step 2 called a tool that doesn't exist (typo — good, now you know), step 3 retried, step 4 hit the step cap. Without it, you're re-running a non-deterministic system and praying it misbehaves the same way twice. Structured, step-level observability is what makes agents &lt;em&gt;debuggable&lt;/em&gt; instead of &lt;em&gt;mysterious&lt;/em&gt; — and it's the same instinct behind treating agent behavior as something you &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/courses/ai-evals-llm-productie&quot;&gt;evaluate systematically in production&lt;/a&gt; rather than eyeball. If you can't answer &quot;why did the agent do that?&quot; from your logs alone, you don't have observability — you have hope.&lt;/p&gt;
&lt;h3 id=&quot;putting-it-together&quot;&gt;Putting it together&lt;/h3&gt;
&lt;p&gt;The reliable loop isn't more clever than the naive one — it's more &lt;em&gt;defensive&lt;/em&gt;. Same twelve-line skeleton, wrapped in four guarantees:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Tool errors become messages&lt;/strong&gt; the model can reason about — with permanent-vs-transient classification so it retries the right things and gives up on the rest.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multiple stop conditions&lt;/strong&gt; — success, step cap, budget cap — and it tells the caller which one fired.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loop-within-loop detection&lt;/strong&gt; that nudges a stuck agent instead of letting it spin to the step limit.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Structured per-step logging&lt;/strong&gt; so every run is reconstructable after the fact.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;None of this is exotic. It's the ordinary engineering of turning a non-deterministic component into a dependable system — the same discipline you'd apply to any service that calls unreliable dependencies, which is exactly what a tool-using agent &lt;em&gt;is&lt;/em&gt;. Building agents that survive contact with production, wiring them to real tools and real budgets, is the whole subject of a hands-on &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/courses/construire-aplicatii-ai-python-sdk&quot;&gt;course on building AI applications in Python&lt;/a&gt;, and the reliability layer above is the part that separates a weekend demo from something you'd put a budget behind. If your day-to-day is coding agents specifically, the patterns for stop conditions, subagents, and error recovery go deeper still in a &lt;a rel=&quot;nofollow&quot; href=&quot;https://cursuri-ai.ro/courses/claude-code-mastery-coding-agentic&quot;&gt;course on agentic coding with Claude Code&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id=&quot;conclusion-2&quot;&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;The agentic loop is genuinely simple — that simplicity is exactly why so many agents ship broken. The &lt;code&gt;while True&lt;/code&gt; and the tool dispatch are the parts you can write in your sleep; the parts that decide whether the thing works in front of real users are the ones the tutorials skip. What happens when a tool fails. How the loop knows it's done. What stops it from running forever. Whether you can tell, afterward, why it did what it did.&lt;/p&gt;
&lt;p&gt;Start from the naive loop, then earn each guarantee: errors as messages, bounded stops, stuck-detection, structured logs. Add them and your agent stops being a party trick that works on the demo input and becomes something you can point at real work and trust — or at least trust to fail loudly, cheaply, and legibly, which in autonomous systems is the same thing as trustworthy.&lt;/p&gt;
&lt;p&gt;Write the loop in five minutes. Spend the rest of the week on everything around it. That's where the reliability was hiding the whole time.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Sources &amp;amp; further reading:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Anthropic — &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.anthropic.com/engineering/building-effective-agents&quot;&gt;Building effective agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Model Context Protocol — &lt;a rel=&quot;nofollow&quot; href=&quot;https://modelcontextprotocol.io&quot;&gt;Specification and documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Yao et al. — &lt;a rel=&quot;nofollow&quot; href=&quot;https://arxiv.org/abs/2210.03629&quot;&gt;ReAct: Synergizing Reasoning and Acting in Language Models&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;This article is educational content. Agent frameworks and provider SDKs evolve quickly; treat the code above as illustrative patterns and validate against current library documentation before shipping.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22749/building-a-reliable-agentic-loop-retries-tool-errors-and-knowing-when-to-stop</guid>
<pubDate>Wed, 15 Jul 2026 11:50:35 +0000</pubDate>
</item>
<item>
<title>Building a multi-agent document-search copilot - Part 2: adaptive Hybrid, post-rank permission gate</title>
<link>https://coderlegion.com/22632/building-multi-agent-document-search-copilot-part-adaptive-hybrid-post-rank-permission</link>
<description>&lt;h2 id=&quot;building-a-multi-agent-document-search-copilot-part-2-adaptive-hybrid-and-a-permission-gate-after-the-rank&quot;&gt;Building a multi-agent document-search copilot — Part 2: adaptive Hybrid, and a permission gate after the rank&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;This is Part 2 of two. &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/22631/&quot;&gt;Part 1&lt;/a&gt; opened on a document-search copilot whose v1 produced muddy results — two retrieval lanes fused into one rerank, where metadata rows that carry no text corrupted the relevance scores. The fix was two reframes: collapse the router into one structured Bedrock call (with a deterministic fallback), and pick exactly one retrieval strategy per query — &lt;code&gt;MetadataOnly&lt;/code&gt;, &lt;code&gt;ContentOnly&lt;/code&gt;, or &lt;code&gt;Hybrid&lt;/code&gt; — instead of fusing the lanes. We left off at the one strategy that refuses to be tidy: &lt;code&gt;Hybrid&lt;/code&gt;, where the user wants a topic and filters at the same time.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Picking up where Part 1 stopped, we're now in the back half of the same pipeline — the &lt;code&gt;direct_search → rerank → permission_filter → finalize_results&lt;/code&gt; tail of the search graph. Two decisions left: how &lt;code&gt;Hybrid&lt;/code&gt; actually retrieves, and where permissions get enforced.&lt;/p&gt;
&lt;h3 id=&quot;3-adaptive-hybrid-peek-once-then-pick-filter-first-or-rank-then-filter&quot;&gt;3️⃣ Adaptive Hybrid: peek once, then pick filter-first or rank-then-filter&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Hybrid&lt;/code&gt; is the interesting one, because &quot;topic + filters&quot; is genuinely a tradeoff and not a single right answer. There was a real debate about it — the kind that goes a few rounds on a whiteboard: run the filter and the rank in &lt;strong&gt;parallel&lt;/strong&gt; (fast, but you have to reconcile two sets), or in &lt;strong&gt;sequence&lt;/strong&gt; (precise, but slower)? We resolved it by refusing to pick — and routing on &lt;em&gt;selectivity&lt;/em&gt; instead.&lt;/p&gt;
&lt;p&gt;The trick is one cheap question asked once: &lt;strong&gt;how many documents match the filters?&lt;/strong&gt; &lt;code&gt;direct_search&lt;/code&gt; peeks the filtered-universe size from the Documents service a single time, with a threshold (default &lt;code&gt;1000&lt;/code&gt;), and branches:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;elif strategy == &quot;Hybrid&quot;:
    threshold = cfg.get(&quot;filter_first_threshold&quot;, 1000)
    id_page = cfg.get(&quot;filter_id_page_size&quot;, 100)
    scope_ids, total_records = await _fetch_filtered_ids(metadata_filters, threshold, id_page)
    if scope_ids is not None:
        candidates = await _content_hits(scope_ids=scope_ids)   # filter-first
        filters_enforced_upstream = True
    else:
        candidates = await _content_hits()                       # rank-then-filter
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bounded set (≤ threshold) → filter-first.&lt;/strong&gt; The peek returns the actual id set. The semantic query is scoped to exactly those ids via an OpenSearch &lt;code&gt;terms&lt;/code&gt; id-scope (over the version field on chunks, the parent field on attachment chunks). Every filter is enforced authoritatively at the source — nothing off-filter can possibly rank, and nothing on-filter is missed. It sets a flag so the later permission gate knows not to re-filter:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# filter-first scoped the topic ranking to the service-filtered id set, so the filters
# are already enforced authoritatively; permission_filter must NOT re-enforce them.
filters_enforced_upstream = True
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Broad set (&amp;gt; threshold) → rank-then-filter.&lt;/strong&gt; Scoping a semantic query to thousands of ids would be unbounded and slow, so instead we rank the topic unscoped and drop the off-filter hits afterward, locally, with &lt;code&gt;_row_passes_filters&lt;/code&gt; — a faithful mirror of the Documents service's operation semantics across every searchable column (string / number / date / boolean / user_list, with &lt;code&gt;contains&lt;/code&gt; / &lt;code&gt;equal&lt;/code&gt; / &lt;code&gt;greaterThan&lt;/code&gt; / the rest):&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _row_passes_filters(row: dict, filters: list[dict]) -&amp;gt; bool:
    &quot;&quot;&quot;Faithful local mirror of the Documents service filter semantics ...
    any hit whose row fails a hard filter is dropped, fail-closed.&quot;&quot;&quot;
    return all(_row_passes_one(row, f) for f in filters)
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The thing that makes this &lt;em&gt;safe&lt;/em&gt; rather than a sloppy approximation: when the filtered set is huge, the filter is nearly a no-op anyway (it matches most of the library), so the recall ceiling from ranking-first-then-dropping is harmless. The case where dropping would lose good matches — a &lt;em&gt;selective&lt;/em&gt; filter — is exactly the case that takes the filter-first path, where nothing is dropped. The branch picks the strategy where its own weakness doesn't apply. I'll be honest: it took me a beat to trust this — it feels like a cheat until you sit with it — but the two failure modes genuinely cancel out.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;&lt;/th&gt;&lt;th&gt;filter-first (selective) &lt;/th&gt;&lt;th&gt;rank-then-filter (broad)&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Filtered universe &lt;/td&gt;&lt;td&gt;≤ threshold (default 1000) &lt;/td&gt;&lt;td&gt;&amp;gt; threshold&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Semantic query &lt;/td&gt;&lt;td&gt;scoped to the filtered id set &lt;/td&gt;&lt;td&gt;unscoped&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Filters enforced &lt;/td&gt;&lt;td&gt;at the Documents service (authoritative) &lt;/td&gt;&lt;td&gt;locally, in &lt;code&gt;permission_filter&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Recall risk &lt;/td&gt;&lt;td&gt;none — nothing off-filter can rank &lt;/td&gt;&lt;td&gt;harmless — filter matches most docs&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Round trips for the peek &lt;/td&gt;&lt;td&gt;one (read the id set) &lt;/td&gt;&lt;td&gt;one (just the count, then bail)&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;One peek, no extra round trips, and the precision/latency tradeoff is made &lt;em&gt;explicit and data-driven&lt;/em&gt; instead of being a coin flip baked into the architecture.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt; The pattern, generalized: when you can't decide between two retrieval strategies, measure the one property that distinguishes them (here, filter selectivity) with a single cheap probe, and route on it. Each branch is chosen precisely where its failure mode is benign.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;4-cohere-rerank-then-a-fail-closed-view-gate-and-why-after-is-fine&quot;&gt;4️⃣ Cohere rerank, then a fail-closed view gate — and why &quot;after&quot; is fine&lt;/h3&gt;
&lt;p&gt;The content path is ranked by Cohere (over the top &lt;code&gt;rerank_candidates&lt;/code&gt;, default &lt;code&gt;30&lt;/code&gt;), with the OpenSearch fused score / RRF as the fallback when the reranker is unreachable:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;results = await bedrock_client.rerank(query=query_text, documents=documents, model_id=model_id)
# ... on any failure or empty result -&amp;gt; _fallback() sorts by the OpenSearch raw score
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Metadata results skip the reranker entirely — there's nothing to score — and keep the Documents service order:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;if all(c.get(&quot;match_kind&quot;) == &quot;metadata&quot; for c in candidates):
    return {&quot;reranked&quot;: candidates, &quot;cached_top_30&quot;: candidates}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then comes the part that makes security people lean forward in their chair: the per-document &lt;strong&gt;view-permission gate runs &lt;em&gt;after&lt;/em&gt; rerank&lt;/strong&gt;, not before retrieval. Stay with me — it's safe, and the reason it's safe is the whole point. &lt;code&gt;permission_filter&lt;/code&gt; takes the reranked content hits, confirms each one is viewable via a single bulk &lt;code&gt;export_by_ids&lt;/code&gt; call against the system of record, and drops the ones that aren't — fail-closed, so if the service is unreachable, every content hit is dropped rather than leaked:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;rows = await documents_service.export_by_ids(content_ids, jwt)
for row in rows:
    dvid = row.get(&quot;document_version_id&quot;)
    if dvid:
        viewable.add(dvid)
# ... survivors = metadata rows (trusted) + content hits whose id is in `viewable`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why is gating &lt;em&gt;after&lt;/em&gt; rerank safe? Because there are two different boundaries doing two different jobs:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Tenant isolation is the hard wall, and it's enforced at retrieval.&lt;/strong&gt; Every OpenSearch query is scoped by &lt;code&gt;member_id&lt;/code&gt;, and that id comes from the JWT — never from the request body, never from the index. A user physically cannot retrieve another tenant's vectors. That boundary is upstream and absolute.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The post-rerank gate is the soft boundary: per-document &lt;em&gt;view&lt;/em&gt; visibility within your own tenant.&lt;/strong&gt; Drafts, restricted categories, documents shared with three people. That nuance changes hourly, so it does &lt;em&gt;not&lt;/em&gt; live in the index — it's checked live, after retrieval, against the source of record.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Rerank only &lt;em&gt;orders&lt;/em&gt; the candidates. It exposes nothing — every candidate it sees already passed the tenant wall, and the set it's ordering is bounded (the reranked top set), so the view gate is one bounded bulk call, not a fan-out. The metadata lane skips the gate entirely because its rows came back already view-filtered by the service's own SQL — re-checking trusted rows would be wasted work. Identity from the token, tenant at retrieval, view-visibility after, fail-closed throughout.&lt;/p&gt;
&lt;h3 id=&quot;the-seam-i-owned-on-the-frontend-a-null-relevance-score&quot;&gt;The seam I owned on the frontend: a null relevance score&lt;/h3&gt;
&lt;p&gt;Here's a small but real consequence of &quot;one strategy per query&quot; that landed squarely on my side of the stack — and nearly slipped past me. Content hits carry a relevance score. Metadata hits don't — a satisfied filter is not a relevance measurement, and showing the OpenSearch fused score (which for an exact field match is ~0) would read as &quot;barely relevant&quot; for what is actually a perfect match. So a metadata hit emits a &lt;strong&gt;&lt;code&gt;null&lt;/code&gt;&lt;/strong&gt; score in the SSE payload, and the UI has to render that as &quot;matched your filter,&quot; not &quot;0% relevant&quot;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _shape_source_for_emit(s: dict) -&amp;gt; dict:
    # A metadata match is a satisfied filter, not a relevance measure, so its score stays null
    # (the card shows just the match reason); content matches keep their fused/rerank score.
    score = None if s.get(&quot;score_signal&quot;) == &quot;metadata&quot; else (
        s.get(&quot;aggregate_score&quot;) or s.get(&quot;score&quot;) or 0.0
    )
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's a tiny detail, but it's the kind of thing that breaks if the frontend assumes &quot;every result has a number.&quot; A &lt;code&gt;null&lt;/code&gt; score is a first-class state — &quot;this matched a filter, there's no relevance to show&quot; — not a missing value to default to zero. Defaulting it to &lt;code&gt;0.0&lt;/code&gt; would have quietly buried every exact metadata match at the bottom of the list, sorted &lt;em&gt;below&lt;/em&gt; loosely-relevant content — a perfect match dead last, and no error anywhere to tell you why. The contract had to carry the absence on purpose.&lt;/p&gt;
&lt;p&gt;The throughline across all four decisions — the two in Part 1 and the two here — is the same instinct: &lt;strong&gt;make the system commit to one clear shape per turn, and make the tradeoffs explicit instead of averaging them away.&lt;/strong&gt; One router call instead of three. One retrieval strategy instead of a fused blend. One selectivity probe instead of a parallel-vs-sequential guess. One bounded permission check after a bounded rerank. The muddy-results version tried to do everything at once and let the reranker sort it out. It couldn't. Picking, on purpose, is what made the rank mean something again.&lt;/p&gt;
&lt;p&gt; Thanks for sticking through both parts. If you've fought the same muddy-rank fight, I'd genuinely love to hear how you cut yours — the failure modes are weirdly universal. Come say hi on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/rodrigo-diego-67867185/&quot;&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Missed the setup? &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/22631/&quot;&gt;Part 1&lt;/a&gt; covers the muddy-results problem, the single-call router, and the reframe to one strategy per query.&lt;/em&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22632/building-multi-agent-document-search-copilot-part-adaptive-hybrid-post-rank-permission</guid>
<pubDate>Tue, 14 Jul 2026 13:19:33 +0000</pubDate>
</item>
<item>
<title>Building a multi-agent document-search copilot — Part 1: muddy results, and one strategy per query</title>
<link>https://coderlegion.com/22631/building-multi-agent-document-search-copilot-part-muddy-results-and-one-strategy-per-query</link>
<description>&lt;h2 id=&quot;building-a-multi-agent-document-search-copilot-part-1-muddy-results-and-one-strategy-per-query&quot;&gt;Building a multi-agent document-search copilot — Part 1: muddy results, and one strategy per query&lt;/h2&gt;
&lt;p&gt;The first version ranked documents badly — and worse, it ranked them badly in a way that looked &lt;em&gt;fine&lt;/em&gt; on the architecture diagram. Those are the bugs that get under my skin: every box is green, every arrow points the right way, and the answer is still wrong.&lt;/p&gt;
&lt;p&gt;We were building a chat copilot over a regulated document store — the kind where a user types &quot;show me my effective SOPs about equipment cleaning&quot; and expects the right handful of documents back, ranked, with an excerpt and a reason. The v1 design did the obvious thing: run two retrieval lanes in parallel — a structured metadata lane and a semantic content lane — union the hits, rerank the union, render. Clean diagram. Muddy results. We'd open the demo, the pipeline would light up green end to end, and the list that came back was mush: the metadata rows polluted the semantic rank, the relevance scores stopped meaning anything, and there was no clean ordering left to show the user. The architecture was elegant. The experience was not.&lt;/p&gt;
&lt;p&gt;This is a two-part story of how that became v2: &lt;strong&gt;one strategy per query, never mixed&lt;/strong&gt;, a router that's a single structured-output call, and a Hybrid path that peeks at the data before it decides how to retrieve. It's an architecture post, so I'll keep it anchored in the specific decisions that actually moved — not a generic &quot;how to build RAG&quot; walkthrough. &lt;strong&gt;Part 1&lt;/strong&gt; (this post) is the problem and the first two reframes. &lt;strong&gt;Part 2&lt;/strong&gt; is the hard case — &lt;code&gt;Hybrid&lt;/code&gt; — and the permission model.&lt;/p&gt;
&lt;h3 id=&quot;-the-series-at-a-glance&quot;&gt;️ The series at a glance&lt;/h3&gt;
&lt;p&gt;This is &lt;strong&gt;Part 1 of 2.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Part 1 (this post) — the problem and the first two reframes:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;️ &lt;strong&gt;v1 fused two parallel lanes into one rerank → muddy.&lt;/strong&gt; Metadata rows have no text; a reranker scores text; fusing them corrupts the one number the UI depends on.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The router is one Bedrock structured-output call&lt;/strong&gt;, not three sequential hops. Route + rewritten query + strategy + filters come back together, with a deterministic fallback. The catch: the merged task got too hard for a small model, so it runs on a bigger one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;v2 picks exactly one shape per query:&lt;/strong&gt; &lt;code&gt;MetadataOnly&lt;/code&gt;, &lt;code&gt;ContentOnly&lt;/code&gt;, or &lt;code&gt;Hybrid&lt;/code&gt;. The lanes are never unioned or cross-scored.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Part 2 — the hard case and the safety model:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;⚖️ &lt;strong&gt;Hybrid is adaptive.&lt;/strong&gt; It peeks the filtered-universe size &lt;em&gt;once&lt;/em&gt; (threshold &lt;code&gt;1000&lt;/code&gt;) and routes on selectivity: small set → scope the semantic query to those ids (filter-first, authoritative); big set → rank unscoped and drop off-filter hits locally (rank-then-filter, harmless recall ceiling).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The view-permission gate runs &lt;em&gt;after&lt;/em&gt; rerank&lt;/strong&gt;, and that's safe — because tenant isolation is enforced earlier, at retrieval, from the token.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-flow-end-to-end&quot;&gt;The flow, end to end&lt;/h3&gt;
&lt;p&gt;Here's the whole turn. A message comes in, the supervisor routes it, the search graph runs one retrieval shape, reranks, gates on permissions, and finalizes for the UI:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;                 ┌─────────────────────────────────────┐
   user turn ──► │ PLANNER (one Bedrock call)           │
                 │ route + rewrite + strategy + filters │
                 └──────────────┬──────────────────────┘
                                │ deterministic fallback if it fails
                                ▼
         ┌─────────────────── retrieval graph ───────────────────┐
         │   classify_intent                                     │
         │        │  (NoMatch / NeedsClarification skip ahead)   │
         │        ▼                                              │
         │   retrieve        ── picks ONE: Metadata / Content /  │
         │                      Hybrid (adaptive on selectivity) │
         │        ▼                                              │
         │   rank_results    ── Cohere over content; metadata    │
         │                      passes through unscored          │
         │        ▼                                              │
         │   access_gate     ── fail-closed VIEW gate on the     │
         │                      content lane only                │
         │        ▼                                              │
         │   format_response ── floor, shape, SSE                │
         └───────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The node order is exactly that, straight from the graph definition:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;builder.add_edge(START, &quot;query_rewriting&quot;)
builder.add_edge(&quot;query_rewriting&quot;, &quot;intent_detection&quot;)
builder.add_conditional_edges(
    &quot;intent_detection&quot;,
    _route_after_intent_detection,
    {&quot;retrieve&quot;: &quot;retrieve&quot;, &quot;finalize_results&quot;: &quot;finalize_results&quot;},
)
builder.add_edge(&quot;retrieve&quot;, &quot;rerank&quot;)
builder.add_edge(&quot;rerank&quot;, &quot;permission_filter&quot;)
builder.add_edge(&quot;permission_filter&quot;, &quot;finalize_results&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four decisions made this work. Two of them are in this post; the other two are Part 2. I'll take them in order.&lt;/p&gt;
&lt;h3 id=&quot;1-the-router-is-one-call-not-three&quot;&gt;1️⃣ The router is one call, not three&lt;/h3&gt;
&lt;p&gt;The v1 router ran three sequential Bedrock hops per turn: one to pick a route, one to rewrite the user's query into a clean retrieval string, one to classify intent and extract filters. Three round trips, in series, before any retrieval even started. Each one waits on the last — and the user just watches a spinner the whole time.&lt;/p&gt;
&lt;p&gt;My pushback in review was simple: &lt;strong&gt;don't run sequential model calls when one call can return the whole decision.&lt;/strong&gt; A router's job is to emit a structured plan. There's no reason &lt;code&gt;route&lt;/code&gt;, &lt;code&gt;rewrite&lt;/code&gt;, and &lt;code&gt;intent&lt;/code&gt; need to be three separate inferences — they're three fields of one object. So we collapsed them. The supervisor now makes a single structured-output call that returns a typed plan, which gets adapted into the downstream routing contract:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;result = await bedrock_client.invoke_with_structured_response_with_fallback_async(
    messages=[SystemMessage(content=system), HumanMessage(content=message)],
    response_structure=Plan,
    chat_model_chain=model_chain,
    max_tokens=1024,
    temperature=0.0,
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One call, &lt;code&gt;temperature=0.0&lt;/code&gt;, and everything the downstream graph needs comes back together: the route, the rewritten query, the strategy, the structured filters. The &lt;code&gt;RouterDecision&lt;/code&gt; it adapts into is the contract the search graph consumes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;class RouterDecision(BaseModel):
    route: Literal[&quot;documents.search&quot;, &quot;documents.doc_context&quot;, &quot;general.help&quot;]
    rewritten_query: str = Field(default=&quot;&quot;)
    strategy: SearchStrategy  # MetadataOnly | ContentOnly | Hybrid | NoMatch | NeedsClarification
    search_value: str = Field(default=&quot;&quot;)
    filters: list[DocumentFilter] = Field(default_factory=list)
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The twist that bit back.&lt;/strong&gt; Merging three easy classifications into one harder one means the model now has to do all of it in a single pass — and the small/cheap model we wanted started getting it wrong. So the router moved up to a stronger (and slower) model. The &quot;3 calls → 1 call&quot; math promised a big latency win; the model upgrade promptly taxed a chunk of it back. (Presenting a &quot;latency win&quot; that your own model bump immediately claws into is a humbling little moment — I recommend the experience to no one.) We shipped anyway, because correctness moved the right way and the architecture got dramatically simpler — and because of the second non-negotiable below.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt; The reusable lesson: a call-merge latency win can be partly clawed back by the accuracy upgrade the merge &lt;em&gt;forces&lt;/em&gt;. Budget for that. And always keep a deterministic fallback.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The deterministic fallback is the part I'd flag for anyone copying this. The structured call returns &lt;code&gt;None&lt;/code&gt; on any failure (the model is down, the output won't parse, the plan isn't exactly one step), and the caller drops to a non-LLM router:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;fallback = route_request(body, intent_resolver=resolve_doc_intent)
plan, router_usage = await supervise_turn(...)
routing = plan_to_router_decision(plan)
# routing is None -&amp;gt; use fallback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The copilot never hangs on a flaky router call. If the smart path can't answer, a dumb-but-reliable path does. That's what lets you run the router on a heavier model without making it a single point of failure.&lt;/p&gt;
&lt;h3 id=&quot;2-one-strategy-per-query-the-reframe-that-fixed-the-muddy-results&quot;&gt;2️⃣ One strategy per query — the reframe that fixed the muddy results&lt;/h3&gt;
&lt;p&gt;This is the heart of the v2 change, and the part I argued hardest for — loudly, in more than one meeting.&lt;/p&gt;
&lt;p&gt;The v1 retrieval ran two member-scoped lanes in parallel — an OpenSearch hybrid lane (vector + BM25) and a structured metadata lane — and fused them into one set before reranking. The intuition was &quot;more recall is better, let the reranker sort it out.&quot; It doesn't work, and the reason is specific: &lt;strong&gt;a metadata hit and a content hit are not the same kind of object.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;A cross-encoder reranker scores text. You hand it a query and a list of passages, it returns a relevance number per passage. A content chunk has text. A metadata row — &quot;status = effective, author = me&quot; — has no passage. When you stuff that row into the reranker by stringifying a title or a key-id, you get back a number that means nothing, on the same 0-1 scale as the real text scores. Nothing downstream can tell the calibrated score from the garbage one. The rank looks plausible and is quietly wrong.&lt;/p&gt;
&lt;p&gt;So v2 picks &lt;strong&gt;exactly one&lt;/strong&gt; retrieval shape per query and runs only that. The strategy comes from the router as a literal:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;SearchStrategy = Literal[
    &quot;MetadataOnly&quot;, &quot;ContentOnly&quot;, &quot;Hybrid&quot;, &quot;NoMatch&quot;, &quot;NeedsClarification&quot;
]
&lt;/code&gt;&lt;/pre&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Strategy &lt;/th&gt;&lt;th&gt;What it means &lt;/th&gt;&lt;th&gt;How it's retrieved &lt;/th&gt;&lt;th&gt;Has a relevance rank?&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;MetadataOnly&lt;/code&gt; &lt;/td&gt;&lt;td&gt;structured filters only (&quot;my effective SOPs&quot;) &lt;/td&gt;&lt;td&gt;Documents service, filtered rows in service order &lt;/td&gt;&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt; — a satisfied filter isn't a relevance signal&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;ContentOnly&lt;/code&gt; &lt;/td&gt;&lt;td&gt;topic search (&quot;policy on cleaning between batches&quot;) &lt;/td&gt;&lt;td&gt;OpenSearch hybrid over chunks, unscoped &lt;/td&gt;&lt;td&gt;Yes — Cohere rerank&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;Hybrid&lt;/code&gt; &lt;/td&gt;&lt;td&gt;topic &lt;strong&gt;and&lt;/strong&gt; filters (&quot;effective docs about cleaning&quot;) &lt;/td&gt;&lt;td&gt;adaptive (Part 2) &lt;/td&gt;&lt;td&gt;Yes — Cohere rerank&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;NoMatch&lt;/code&gt; &lt;/td&gt;&lt;td&gt;not a document query (greeting, capability question) &lt;/td&gt;&lt;td&gt;skips retrieval entirely &lt;/td&gt;&lt;td&gt;n/a&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;code&gt;NeedsClarification&lt;/code&gt; &lt;/td&gt;&lt;td&gt;a document query too vague to retrieve (&quot;show me stuff&quot;) &lt;/td&gt;&lt;td&gt;skips retrieval, asks a clarifying question &lt;/td&gt;&lt;td&gt;n/a&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The lanes are never unioned or cross-scored anymore — &lt;code&gt;retrieve&lt;/code&gt; says so in plain terms:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# The lanes are never unioned or cross-scored: a content turn returns content
# candidates, a metadata turn returns metadata candidates.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There's a nice side effect in the graph: &lt;code&gt;NoMatch&lt;/code&gt; and &lt;code&gt;NeedsClarification&lt;/code&gt; short-circuit straight to &lt;code&gt;finalize_results&lt;/code&gt;, skipping retrieval, rerank, and the permission gate. A greeting shouldn't make the UI flash &quot;Searching... / No matches&quot; and shouldn't cost three round trips.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;def _route_after_intent_detection(state) -&amp;gt; str:
    return (&quot;finalize_results&quot;
            if state.get(&quot;intent_strategy&quot;) in (&quot;NoMatch&quot;, &quot;NeedsClarification&quot;)
            else &quot;retrieve&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;The honest cost.&lt;/strong&gt; Going single-strategy meant deprecating the old global keyword lane entirely. The practical fallout: custom-field values are no longer reachable as a filter, because the content-keyword fold that used to (badly) cover them is gone. That's a real regression on a narrow feature, parked until a dedicated Documents endpoint for custom fields lands. It stings to ship a regression on purpose — but I'd take a clean rank with one honest, documented gap over a muddy rank that hides a dozen.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt; If you take one thing from this post: don't fuse object types into a single rerank set just to maximize recall. Pick the retrieval shape that matches the query, and run only that. Recall you can't rank cleanly is recall you can't show.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&quot;to-be-continued&quot;&gt;To be continued&lt;/h3&gt;
&lt;p&gt;So far the story has a tidy shape. One router call instead of three. One retrieval strategy per query instead of a fused blend. Pick &lt;code&gt;MetadataOnly&lt;/code&gt;, or &lt;code&gt;ContentOnly&lt;/code&gt;, and run only that. Clean.&lt;/p&gt;
&lt;p&gt;But I skipped the strategy that refuses to be tidy — the one where the user genuinely wants &lt;strong&gt;both at once&lt;/strong&gt;. &quot;Effective SOPs about equipment cleaning&quot; is a topic &lt;em&gt;and&lt;/em&gt; a filter, and you can't honor it by picking a single lane. Running the filter and the rank in parallel is fast, but you have to reconcile two sets. Running them in sequence is precise, but slow. There's no single right answer — which is exactly why it's the interesting part.&lt;/p&gt;
&lt;p&gt;That's &lt;code&gt;Hybrid&lt;/code&gt;, and in Part 2 a single cheap question turns that coin-flip into a data-driven decision. Then there's the permission gate that runs in a spot that makes security people flinch on first read — &lt;em&gt;after&lt;/em&gt; the rank — and why it's actually safe. Finally, the one place all of this landed on my side of the stack: a &lt;code&gt;null&lt;/code&gt; relevance score that the frontend has to treat as a first-class state, not a missing number.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Part 2 is live:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/22632/&quot;&gt;adaptive Hybrid, and the permission gate that runs &lt;em&gt;after&lt;/em&gt; the rank&lt;/a&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Thanks for reading all the way through 🙌 If you've hit the same muddy-rank problem — a pipeline that's green end to end and &lt;em&gt;still&lt;/em&gt; returns mush — I'd love to compare notes on how you untangled yours. Come say hi on &lt;a rel=&quot;nofollow&quot; href=&quot;https://www.linkedin.com/in/rodrigo-diego-67867185/&quot;&gt;LinkedIn&lt;/a&gt;.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22631/building-multi-agent-document-search-copilot-part-muddy-results-and-one-strategy-per-query</guid>
<pubDate>Tue, 14 Jul 2026 13:19:14 +0000</pubDate>
</item>
<item>
<title>AI Fundamentals - Part 3: Giving AI Knowledge Beyond Its Training</title>
<link>https://coderlegion.com/22352/ai-fundamentals-part-3-giving-ai-knowledge-beyond-its-training</link>
<description>&lt;p&gt;In &lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/21936/ai-fundamentals-part-2-why-ai-gets-things-right-and-wrong&quot;&gt;Part 2&lt;/a&gt;, we learned why AI sometimes hallucinates. One of the biggest reasons is that an LLM can only answer based on what it learned during training and the information available in its context window. We also introduced &lt;strong&gt;grounding&lt;/strong&gt;-providing the model with reliable information at runtime instead of expecting it to know everything.&lt;/p&gt;
&lt;p&gt;But that raises an important question: &lt;strong&gt;Where does that information come from?&lt;/strong&gt; Modern AI applications don't simply dump an entire database or a thousand-page PDF into the prompt. Instead, they first identify the &lt;em&gt;most relevant&lt;/em&gt; pieces of information and only send those to the model. In this article, we'll learn how that works.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;running-example&quot;&gt;Running Example&lt;/h3&gt;
&lt;p&gt;Let's continue building our &lt;strong&gt;AI-powered Travel Planner&lt;/strong&gt;. So far, it can answer general travel questions using the knowledge it learned during training. Now we want to make it much smarter by uploading several documents into our application:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Lonely Planet's Japan travel guide&lt;/li&gt;
&lt;li&gt;A PDF containing train schedules&lt;/li&gt;
&lt;li&gt;A document listing recommended local restaurants&lt;/li&gt;
&lt;li&gt;Hotel information&lt;/li&gt;
&lt;li&gt;Internal travel policies for our company&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Together, these documents contain hundreds of pages. Now the user asks:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;I'm staying near Tokyo Station. Which ramen restaurant from our travel guide is within walking distance and is known for vegetarian options?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Somewhere in those hundreds of pages is the answer. The challenge is no longer generating text-it's &lt;strong&gt;finding the right information first.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-problem-an-llm-can-t-read-your-entire-knowledge-base-every-time&quot;&gt;The Problem: An LLM Can't Read Your Entire Knowledge Base Every Time&lt;/h2&gt;
&lt;p&gt;A common misconception is that AI applications simply send all their documents to the model. Imagine our travel guide contains 450 pages, thousands of restaurant listings, hotel descriptions, transportation details, and sightseeing recommendations. Sending all of that to the LLM every time someone asks &lt;em&gt;&quot;Where should I eat tonight?&quot;&lt;/em&gt; creates several problems.&lt;/p&gt;
&lt;p&gt;First, many documents are simply too large to fit inside the model's context window. Second, even if they did fit, making the model read hundreds of irrelevant pages just to answer a simple question is expensive, slow, and inefficient. Instead, we want to send &lt;strong&gt;only the information that matters&lt;/strong&gt;. The question becomes: &lt;strong&gt;How do we find the right few paragraphs from hundreds of pages?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Your first instinct might be: &lt;em&gt;&quot;Why not just search the documents?&quot;&lt;/em&gt; That's exactly what we'll explore next.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;keyword-search-the-traditional-approach&quot;&gt;Keyword Search: The Traditional Approach&lt;/h2&gt;
&lt;p&gt;Before AI became popular, most applications relied on &lt;strong&gt;keyword search&lt;/strong&gt;. The idea is simple: the user searches for a word, and the application finds documents containing that exact word.&lt;/p&gt;
&lt;p&gt;For example, given this travel guide text:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-plaintext&quot;&gt;Tokyo has several excellent vegetarian ramen restaurants...
Mount Fuji is one of Japan's most iconic landmarks...
The Shinkansen connects Tokyo and Kyoto...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If the user searches for &lt;em&gt;&quot;vegetarian ramen&quot;&lt;/em&gt;, the search engine finds the first paragraph because it contains both words. While traditional keyword search is simple, fast, and easy to understand, it falls short when matching meaning instead of exact text.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;when-keyword-search-falls-short&quot;&gt;When Keyword Search Falls Short&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Synonyms and Meaning (Example 1):&lt;/strong&gt; If a user asks &lt;em&gt;&quot;Where can I eat meat-free ramen?&quot;&lt;/em&gt; and our document says &lt;em&gt;&quot;Vegetarian ramen is available near Tokyo Station&quot;&lt;/em&gt;, a traditional keyword search may completely miss this result because it searches for exact words, not meaning. It doesn't know that &lt;em&gt;meat-free&lt;/em&gt;, &lt;em&gt;vegetarian&lt;/em&gt;, and &lt;em&gt;plant-based&lt;/em&gt; are closely related concepts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Name Variations (Example 2):&lt;/strong&gt; If a user asks to &lt;em&gt;&quot;Suggest a place near Fujisan&quot;&lt;/em&gt;, and the document says &lt;em&gt;&quot;A day trip to Mount Fuji is highly recommended&quot;&lt;/em&gt;, keyword search often fails because it doesn't understand that &lt;em&gt;Fujisan&lt;/em&gt; and &lt;em&gt;Mount Fuji&lt;/em&gt; refer to the same place.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Terminology Differences (Example 3):&lt;/strong&gt; If a user asks &lt;em&gt;&quot;Where can I find authentic local noodle shops?&quot;&lt;/em&gt; and the guide says &lt;em&gt;&quot;Several ramen restaurants popular with residents...&quot;&lt;/em&gt;, keyword search fails to connect &lt;em&gt;noodle shop&lt;/em&gt; with &lt;em&gt;ramen restaurant&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The real limitation is that keyword search compares &lt;strong&gt;text&lt;/strong&gt;, while humans compare &lt;strong&gt;meaning&lt;/strong&gt;. This is why search systems often return dozens of irrelevant results or miss the best result entirely, as you've likely experienced when searching documentation using a word that differs from the author's chosen terminology.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;semantic-search-searching-by-meaning&quot;&gt;Semantic Search: Searching by Meaning&lt;/h2&gt;
&lt;p&gt;This is where AI changes everything. Instead of asking &lt;em&gt;&quot;Does this document contain the same words?&quot;&lt;/em&gt;, semantic search asks &lt;strong&gt;&quot;Does this document mean the same thing?&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Let's return to our example. The user asks &lt;em&gt;&quot;Where can I eat meat-free ramen near Tokyo Station?&quot;&lt;/em&gt; and the travel guide says &lt;em&gt;&quot;Ichiran offers vegetarian ramen a short walk from Tokyo Station.&quot;&lt;/em&gt; A semantic search understands that &lt;em&gt;meat-free&lt;/em&gt; and &lt;em&gt;vegetarian&lt;/em&gt; express nearly the same idea, allowing it to retrieve the correct passage even though the exact words are different.&lt;/p&gt;
&lt;p&gt;But computers don't naturally understand meaning; they understand numbers. How can a computer determine that &lt;em&gt;vegetarian&lt;/em&gt; and &lt;em&gt;meat-free&lt;/em&gt; are closely related? That's where &lt;strong&gt;embeddings&lt;/strong&gt; come in.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;embeddings-turning-meaning-into-numbers&quot;&gt;Embeddings: Turning Meaning into Numbers&lt;/h2&gt;
&lt;p&gt;An &lt;strong&gt;embedding&lt;/strong&gt; is a numerical representation of text that captures its meaning. Think of it as a fingerprint-not of the exact words, but of what those words represent.&lt;/p&gt;
&lt;p&gt;For example, these three phrases all have different wording but express similar ideas, so their embeddings end up being very similar:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Vegetarian ramen&lt;/li&gt;
&lt;li&gt;Meat-free noodles&lt;/li&gt;
&lt;li&gt;Plant-based ramen&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Conversely, phrases like &lt;em&gt;Mount Fuji&lt;/em&gt;, &lt;em&gt;Flight booking&lt;/em&gt;, and &lt;em&gt;Currency exchange&lt;/em&gt; produce embeddings that are quite different because they represent completely unrelated concepts. The key takeaway is: &lt;strong&gt;embeddings represent meaning, not exact wording.&lt;/strong&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-not-compare-the-original-text-&quot;&gt;Why Not Compare the Original Text?&lt;/h3&gt;
&lt;p&gt;Let's go back to our Travel Planner. Suppose our travel guide contains:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;em&gt;Ichiran offers vegetarian ramen near Tokyo Station.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now imagine two different users:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;User A:&lt;/strong&gt; &lt;em&gt;Recommend vegetarian ramen.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;User B:&lt;/strong&gt; &lt;em&gt;Where can I find meat-free noodle restaurants?&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A computer comparing plain text sees very few matching words between User B's query and the guide. A traditional keyword search might decide they are unrelated. Embeddings solve this by converting both pieces of text into numerical representations where similar meanings end up close together.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;semantic-similarity-measuring-meaning-instead-of-words&quot;&gt;Semantic Similarity: Measuring Meaning Instead of Words&lt;/h2&gt;
&lt;p&gt;Once text is converted into embeddings, comparing sentences becomes much easier. Instead of asking if they contain the same words, we ask: &lt;strong&gt;How similar are their embeddings?&lt;/strong&gt; If two embeddings are close together, they are likely talking about similar ideas; if they are far apart, they are probably unrelated.&lt;/p&gt;
&lt;p&gt;A useful mental model is to imagine every sentence placed on a giant map. Similar ideas appear close together, while unrelated concepts are placed farther apart:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;                  Travel

                      ●
                Visit Japan

           ●                   ●
  Vegetarian Ramen      Tokyo Station

                ●
         Meat-free Noodles


--------------------------------------------


                      ●
              JavaScript Closures

                                ●
            Docker Networking
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice how the travel-related concepts naturally cluster together, while completely unrelated software concepts appear elsewhere. This is exactly what embeddings help achieve.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cosine-similarity-finding-the-closest-match&quot;&gt;Cosine Similarity: Finding the Closest Match&lt;/h2&gt;
&lt;p&gt;Now that every sentence has an embedding, we need a way to compare them mathematically. One of the most common techniques is &lt;strong&gt;cosine similarity&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Suppose our Travel Planner has these restaurant descriptions stored:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A:&lt;/strong&gt; &lt;em&gt;Vegetarian ramen near Tokyo Station.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;B:&lt;/strong&gt; &lt;em&gt;Traditional sushi restaurant.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;C:&lt;/strong&gt; &lt;em&gt;Italian pizza in Osaka.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If the user asks for &lt;em&gt;&quot;Meat-free noodles near Tokyo Station&quot;&lt;/em&gt;, the embeddings might compare like this:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th&gt;Restaurant                          &lt;/th&gt;&lt;th&gt;Similarity&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td&gt;Vegetarian ramen near Tokyo Station &lt;/td&gt;&lt;td&gt;  &lt;strong&gt;0.96&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Traditional sushi restaurant        &lt;/td&gt;&lt;td&gt;      0.41&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Italian pizza in Osaka              &lt;/td&gt;&lt;td&gt;      0.09&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The higher the score, the closer the meanings. Notice that the user never mentioned the word &lt;em&gt;vegetarian&lt;/em&gt;, yet Restaurant A is still identified as the best match because the meaning is similar.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Mental Model&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In a library, keyword search works like looking up every book containing the word &lt;em&gt;ramen&lt;/em&gt;. Semantic search works by finding books discussing the same idea, even if they use completely different words.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h2 id=&quot;where-do-these-embeddings-come-from-&quot;&gt;Where Do These Embeddings Come From?&lt;/h2&gt;
&lt;p&gt;Fortunately, you don't have to calculate these numbers yourself. Just as LLMs specialize in generating text, there are &lt;strong&gt;embedding models&lt;/strong&gt; that specialize in generating embeddings. Instead of producing a paragraph, an embedding model takes text and produces a list of numbers representing its meaning:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;User Text (&quot;Vegetarian ramen...&quot;) ──► Embedding Model ──► [0.18, -0.42, 0.91, ...]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The resulting list (or vector) usually contains hundreds or thousands of numbers. You never need to interpret them yourself; their only purpose is to make mathematical similarity comparisons possible.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;chunking-breaking-large-documents-into-pieces&quot;&gt;Chunking: Breaking Large Documents into Pieces&lt;/h2&gt;
&lt;p&gt;How do we generate embeddings for a 500-page travel guide? We don't feed the entire book into the embedding model at once. Embedding models have strict input limit constraints (typically measured in tokens), and comparing a whole book's meaning is too vague to be useful. If a user asks about a specific ramen restaurant, we want to retrieve the exact paragraph discussing it, not a 500-page book embedding.&lt;/p&gt;
&lt;p&gt;So, we break the document down into smaller, logical pieces in a process called &lt;strong&gt;chunking&lt;/strong&gt;. A chunk could be a single paragraph, a section of 100-200 words, or a sliding window of text (e.g., 200 words with a 20-word overlap to ensure context isn't cut off at boundaries).&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;Mental Model&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Think of chunking like cutting a long movie reel into short, thematic scenes. If you want to find the scene with Mount Fuji, it is much easier to search and index short clips than searching the entire three-hour film.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Developer's Takeaway&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Choosing the right chunk size is a balancing act. If chunks are too small, they might lose context (e.g., a sentence saying &lt;em&gt;&quot;It is delicious&quot;&lt;/em&gt; without mentioning &lt;em&gt;what&lt;/em&gt; is delicious). If they're too large, the specific details get averaged out, and search accuracy drops.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;vector-databases-storing-and-indexing-meanings&quot;&gt;Vector Databases: Storing and Indexing Meanings&lt;/h2&gt;
&lt;p&gt;Once we have broken our travel guide into thousands of chunks and generated an embedding for each, we need a place to store them. Traditional databases (like PostgreSQL or MySQL) are optimized for finding exact matches like numbers, dates, or strings. They aren't designed to compare thousands of high-dimensional lists of numbers to find which ones are &quot;closest&quot; in meaning.&lt;/p&gt;
&lt;p&gt;That's where &lt;strong&gt;Vector Databases&lt;/strong&gt; come in. A vector database is designed specifically to store and index high-dimensional vectors (embeddings) and search through them based on mathematical similarity (like cosine similarity) extremely fast.&lt;/p&gt;
&lt;p&gt;Common vector database setups include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dedicated vector databases&lt;/strong&gt; (Pinecone, Milvus, Qdrant, Chroma)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Vector extensions&lt;/strong&gt; for traditional databases (like &lt;code&gt;pgvector&lt;/code&gt; for PostgreSQL)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;💡 Developer's Takeaway&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;You don't need to write complex search algorithms yourself. A vector database handles the indexing and allows you to query it using an embedding, returning the most semantically similar chunks in milliseconds.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;embedding-dimensions-understanding-the-scale&quot;&gt;Embedding Dimensions: Understanding the Scale&lt;/h2&gt;
&lt;p&gt;When you configure a vector database, it will ask you to specify the number of &lt;strong&gt;dimensions&lt;/strong&gt;. Dimensions refer to the size of the list of numbers generated by the embedding model. For example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A lightweight model might output vectors with &lt;strong&gt;384&lt;/strong&gt; dimensions.&lt;/li&gt;
&lt;li&gt;A standard model (like OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt;) outputs &lt;strong&gt;1536&lt;/strong&gt; dimensions.&lt;/li&gt;
&lt;li&gt;Large models can output &lt;strong&gt;3072&lt;/strong&gt; dimensions or more.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each dimension represents an abstract feature of meaning that the model learned during training. You don't need to know what each dimension represents; the database only cares that you are comparing vectors of the exact same size.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;💡 Developer's Takeaway&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;An embedding model and a vector database must match: you cannot store a 1536-dimension embedding in a database index configured for 384 dimensions.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;rag-retrieval-augmented-generation-putting-it-together&quot;&gt;RAG (Retrieval-Augmented Generation): Putting It Together&lt;/h2&gt;
&lt;p&gt;Now we have all the pieces to solve our Travel Planner's original challenge: answering questions using our travel guides without sending the entire book to the LLM. We use a pattern called &lt;strong&gt;RAG (Retrieval-Augmented Generation)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;RAG combines &lt;strong&gt;Retrieval&lt;/strong&gt; (finding relevant chunks from our vector database) and &lt;strong&gt;Generation&lt;/strong&gt; (having the LLM write a response using those chunks). Here is the step-by-step workflow:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ingestion (Happens once in advance):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Break documents into chunks.&lt;/li&gt;
&lt;li&gt;Generate embeddings for each chunk.&lt;/li&gt;
&lt;li&gt;Store the chunks and their embeddings in a vector database.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Querying (Happens at runtime):&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User asks: &lt;em&gt;&quot;Recommend a vegetarian ramen shop near Tokyo Station.&quot;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;The application generates an embedding for the user's question.&lt;/li&gt;
&lt;li&gt;The application queries the vector database using this question embedding.&lt;/li&gt;
&lt;li&gt;The database returns the top 2 or 3 most similar text chunks.&lt;/li&gt;
&lt;li&gt;The application merges the user's question and the retrieved chunks into a single prompt (grounding context).&lt;/li&gt;
&lt;li&gt;The LLM reads the context and generates a correct, verified answer.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;               User Question
                     │
                     ▼
             Generate Embedding
                     │
                     ▼
    Query Vector DB ──► Stored Chunks
                     │
                     ▼
            Get Relevant Chunks
                     │
                     ▼
    Build Prompt (Question + Chunks)
                     │
                     ▼
             LLM Generates Text
                     │
                     ▼
             Grounded Response
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;💡 Developer's Takeaway&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;RAG is the most popular way to connect proprietary data to LLMs. It ensures the model's response is grounded in facts, reduces hallucinations, and works without needing to retrain the model.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;cag-cache-augmented-generation-the-new-alternative&quot;&gt;CAG (Cache-Augmented Generation): The New Alternative&lt;/h2&gt;
&lt;p&gt;While RAG is powerful, it requires setting up chunking pipelines, embedding models, and vector databases. This can be complex. With modern LLMs having massive context windows (often supporting 1 million to 2 million tokens), another approach has emerged: &lt;strong&gt;CAG (Cache-Augmented Generation)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Instead of retrieving matching chunks at runtime, CAG preloads the entire document set (e.g., our 500-page travel guide) directly into the model's context window. It leverages &lt;strong&gt;KV (Key-Value) Caching&lt;/strong&gt; to precompute and cache the mathematical states of the documents once. When a user asks a question, the LLM reads the cached documents instantly, bypassing the retrieval pipeline entirely.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RAG&lt;/strong&gt; is like looking up a topic in a library catalog, retrieving three specific books, and reading them to find the answer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CAG&lt;/strong&gt; is like having the entire encyclopedia already open on your desk with the pages instantly readable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;💡 Developer's Takeaway&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;CAG simplifies the architecture by eliminating vector databases and chunking. However, it is limited by the size of the context window and is best suited for relatively static datasets that fit comfortably within the model's limit. RAG remains essential for massive or constantly changing data.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;recap&quot;&gt;Recap&lt;/h2&gt;
&lt;p&gt;In this article, we explored how to give AI knowledge beyond its training. We covered:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keyword Search vs. Semantic Search&lt;/li&gt;
&lt;li&gt;Embeddings, Semantic Similarity, and Cosine Similarity&lt;/li&gt;
&lt;li&gt;Chunking, Vector Databases, and Embedding Dimensions&lt;/li&gt;
&lt;li&gt;RAG (Retrieval-Augmented Generation) and CAG (Cache-Augmented Generation)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In &lt;strong&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://coderlegion.com/22353/ai-fundamentals-part-4-building-real-ai-applications&quot;&gt;Part 4&lt;/a&gt;&lt;/strong&gt;, we will answer the next logical question: &lt;strong&gt;How do we connect our model to the outside world so it can execute tasks, use external APIs, and remember users over time?&lt;/strong&gt; We will explore tool calling, memory, Model Context Protocol (MCP), and AI agents.&lt;/p&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/22352/ai-fundamentals-part-3-giving-ai-knowledge-beyond-its-training</guid>
<pubDate>Sun, 12 Jul 2026 06:45:12 +0000</pubDate>
</item>
<item>
<title>RAG in Production: 7 Anti-Patterns That Destroy Precision (And How I Fixed Them)</title>
<link>https://coderlegion.com/22320/rag-in-production-7-anti-patterns-that-destroy-precision-and-how-i-fixed-them</link>
<description>&lt;p&gt;RAG is the aspirin of generative AI: everyone prescribes it, almost no one truly understands how it works, and when it fails, the patient hallucinates. If you've attended any tech conference in the past 18 months, you'll have heard the same promise repeated like a mantra: &lt;em&gt;&quot;Connect your LLM to your documents with RAG and you'll have a chatbot that responds with your company's truth.&quot;&lt;/em&gt; It's a seductive promise. It is also, in most implementations I've seen, &lt;strong&gt;a well-intentioned lie&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I know this because I've walked both paths. I built a working RAG system in production — the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part8/&quot;&gt;Ops Engineering Copilot&lt;/a&gt; with Algolia Agent Studio, indexing over 70 posts from this blog — and I also built a system that &lt;strong&gt;deliberately rejects RAG&lt;/strong&gt; — the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/obs_part5_radar_agent/&quot;&gt;agentic obsolescence radar&lt;/a&gt;, which uses pure Tool Calling to query industrial databases with mathematical precision. The experience of operating both systems in production left me with an uncomfortable conclusion: &lt;strong&gt;RAG isn't bad; what's bad is how we implement it&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This article is the catalogue of the 7 errors that destroy a RAG system's precision, the solutions that work, and the question no one wants to ask: do you really need RAG, or do you need something else?&lt;/p&gt;
&lt;h4 id=&quot;anti-pattern-1-blind-chunking&quot;&gt;Anti-Pattern 1: Blind Chunking&lt;/h4&gt;
&lt;p&gt;The first step in any RAG pipeline is splitting your documents into fragments (&lt;em&gt;chunks&lt;/em&gt;) that will be stored as vectors. And this is where most tutorials commit the first mortal sin: &lt;strong&gt;chunking by fixed length&lt;/strong&gt; (e.g., 500 tokens per chunk with 50 tokens of overlap).&lt;/p&gt;
&lt;p&gt;The problem is brutal: a paragraph explaining a complex technical concept gets cut in half. The first half ends up in one chunk, the second in another. When the user asks a question, the retriever finds the first half (which contains the keywords) but misses the context from the second. The LLM, true to its nature, &lt;strong&gt;fills in what's missing with a plausible fabrication&lt;/strong&gt;. Hallucination served.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Semantic chunking. Split by logical units of meaning: sections delimited by headers, complete paragraphs, or functional blocks of the document. In the Ops Copilot, when we indexed Datalaria's posts with Algolia, each &lt;em&gt;record&lt;/em&gt; corresponds to a complete article section (delimited by &lt;code&gt;###&lt;/code&gt; in Markdown), not an arbitrary block of N tokens. The result: each chunk is self-contained and holds a complete thought.&lt;/p&gt;
&lt;h4 id=&quot;anti-pattern-2-generic-embeddings-for-specialized-domains&quot;&gt;Anti-Pattern 2: Generic Embeddings for Specialized Domains&lt;/h4&gt;
&lt;p&gt;Pre-trained embeddings (like OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt; or Vertex AI's) are trained on general internet text. They work reasonably well for generic questions. But when your domain is highly specialized — industrial engineering, European regulation, electronic component nomenclature — the semantic distance between key terms can be &lt;strong&gt;completely wrong&lt;/strong&gt; in the vector space.&lt;/p&gt;
&lt;p&gt;A real example: in the context of &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/obs_part1_intro/&quot;&gt;obsolescence management&lt;/a&gt;, the terms &quot;EOL&quot; (&lt;em&gt;End of Life&lt;/em&gt;), &quot;NRND&quot; (&lt;em&gt;Not Recommended for New Designs&lt;/em&gt;), and &quot;PDN&quot; (&lt;em&gt;Product Discontinuation Notice&lt;/em&gt;) are semantically very close for a supply chain engineer. But for a generic embedding, &quot;End of Life&quot; might be closer to an article about palliative care than to a chip discontinuation notice.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Evaluate embeddings with your own dataset before committing. Build a small benchmark of 50-100 question-answer pairs from your domain and measure the retriever's &lt;em&gt;hit rate&lt;/em&gt;. If generic embeddings don't exceed 80% accuracy on your benchmark, consider fine-tuning or specialized embeddings for your sector. And if your domain is highly structured (codes, nomenclatures, tables), you probably don't need embeddings at all: you need &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/obs_part5_radar_agent/&quot;&gt;Tool Calling&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;anti-pattern-3-forgetting-reranking&quot;&gt;Anti-Pattern 3: Forgetting Reranking&lt;/h4&gt;
&lt;p&gt;The vector retriever returns the &lt;em&gt;k&lt;/em&gt; documents &quot;closest&quot; in the embedding space. But vector proximity is not synonymous with relevance. A document can contain the same keywords as the user's question and yet answer a completely different question.&lt;/p&gt;
&lt;p&gt;I've seen this anti-pattern cause havoc in technical support systems: the user asks &lt;em&gt;&quot;How do I configure the CrewAI agent timeout?&quot;&lt;/em&gt;, the retriever returns a chunk about GitHub Actions timeouts (same vocabulary, different context), and the LLM generates a response that's technically correct for the wrong chunk.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Add a &lt;strong&gt;reranking&lt;/strong&gt; layer between the retriever and the LLM. A reranker (like Cohere Rerank or a local cross-encoder) receives the original question and the &lt;em&gt;k&lt;/em&gt; candidates from the retriever, and reorders them by actual semantic relevance, not mere vector proximity. In practice, a well-configured reranker can improve retrieval precision by &lt;strong&gt;15% to 30%&lt;/strong&gt; — an improvement that translates directly into fewer hallucinations.&lt;/p&gt;
&lt;h4 id=&quot;anti-pattern-4-insufficient-context-in-the-prompt&quot;&gt;Anti-Pattern 4: Insufficient Context in the Prompt&lt;/h4&gt;
&lt;p&gt;The most widespread and easiest mistake to make: injecting 2-3 chunks into the LLM's prompt and expecting a miracle. Modern models like Gemini 2.5 or Claude handle context windows of hundreds of thousands of tokens. Feeding them 500 tokens of retrieved context is like giving a Formula 1 engine the fuel from a cigarette lighter.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Experiment aggressively with the size of the injected context window. Increase from &lt;em&gt;top-3&lt;/em&gt; to &lt;em&gt;top-10&lt;/em&gt; or &lt;em&gt;top-15&lt;/em&gt; chunks and measure the impact on response quality. Include &lt;strong&gt;enriched metadata&lt;/strong&gt; in each chunk: source document title, creation date, author, section. These metadata give the LLM a referential framework to evaluate the relevance and recency of the information. In the Ops Copilot, each Algolia record includes not just the post text, but also the title, category, tags, and publication date.&lt;/p&gt;
&lt;h4 id=&quot;anti-pattern-5-hallucination-from-partial-retrieval&quot;&gt;Anti-Pattern 5: Hallucination from Partial Retrieval&lt;/h4&gt;
&lt;p&gt;This is the most dangerous anti-pattern because it's silent. The retriever finds a partially relevant chunk. The LLM detects that the information is incomplete. Instead of stopping and confessing its ignorance, &lt;strong&gt;it completes the answer with fabricated information&lt;/strong&gt; that sounds perfectly plausible. The user has no way to distinguish which part of the answer comes from retrieval and which part is a hallucination.&lt;/p&gt;
&lt;p&gt;In industrial applications, this can be catastrophic. Imagine a RAG system connected to your maintenance documentation that, when asked about the torque specification of a critical bolt, returns a fabricated value because the correct chunk wasn't retrieved. The result could be a mechanical failure in production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Double barrier. First: instruct the LLM in the system prompt to answer &lt;strong&gt;&quot;I don't have sufficient information in the provided documentation to answer this question&quot;&lt;/strong&gt; when the retrieved context is insufficient or ambiguous. Include examples in the prompt (few-shot) of correct answers that acknowledge limitations. Second: implement a &lt;strong&gt;post-validation&lt;/strong&gt; of the response. Programmatically evaluate whether the answer contains claims not supported by the injected chunks (frameworks like RAGAS automate this verification with metrics like &lt;em&gt;faithfulness&lt;/em&gt; and &lt;em&gt;answer relevancy&lt;/em&gt;).&lt;/p&gt;
&lt;h4 id=&quot;anti-pattern-6-not-measuring-quality&quot;&gt;Anti-Pattern 6: Not Measuring Quality&lt;/h4&gt;
&lt;p&gt;The sixth anti-pattern is cultural, not technical: launching a RAG system to production &lt;strong&gt;without evaluation metrics&lt;/strong&gt;. Asking &quot;does it work?&quot; to five teammates is not an evaluation methodology; it's an anecdote. Without quantitative metrics, you can't know if a change in chunking improved or worsened precision, if a new embedding model is superior to the previous one, or if the reranker you just added justifies its latency cost.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: Implement an automated evaluation framework &lt;strong&gt;before&lt;/strong&gt; launching to production. The most mature tools are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;RAGAS&lt;/strong&gt; (&lt;em&gt;Retrieval Augmented Generation Assessment&lt;/em&gt;): Measures &lt;em&gt;faithfulness&lt;/em&gt; (the answer is grounded in context), &lt;em&gt;answer relevancy&lt;/em&gt; (the answer is relevant to the question), and &lt;em&gt;context precision&lt;/em&gt; (the retrieved chunks are relevant).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DeepEval&lt;/strong&gt;: An open-source framework that lets you define test suites with metrics like &lt;em&gt;hallucination score&lt;/em&gt;, &lt;em&gt;bias&lt;/em&gt;, and &lt;em&gt;toxicity&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The key point is to treat RAG evaluation the way you treat your code's unit tests: &lt;strong&gt;if it doesn't have tests, it doesn't go to production&lt;/strong&gt;. Every pipeline iteration (embedding change, chunk size adjustment, new reranker) must pass through the evaluation suite before deployment. It's the same CI/CD philosophy we applied in &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/ai_agents_part5/&quot;&gt;Autopilot Part 5&lt;/a&gt; with GitHub Actions, but applied to retrieval quality.&lt;/p&gt;
&lt;h4 id=&quot;anti-pattern-7-using-rag-when-you-need-tool-calling&quot;&gt;Anti-Pattern 7: Using RAG When You Need Tool Calling&lt;/h4&gt;
&lt;p&gt;This is the anti-pattern that was hardest for me to accept, because it meant questioning my own architectural decision. When we built the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/obs_part5_radar_agent/&quot;&gt;agentic obsolescence radar&lt;/a&gt;, the first temptation was to use RAG: index all component documentation, datasheets, and price histories in a vector store, and let the LLM search for relevant information for each &lt;em&gt;End of Life&lt;/em&gt; alert.&lt;/p&gt;
&lt;p&gt;The result was disastrous. LLMs are, as I wrote in that article, &lt;strong&gt;mediocre calculators&lt;/strong&gt;. When the radar needed to calculate the financial impact of an obsolescence (traverse the BOM graph, multiply quantities by prices, sum redesign costs), RAG returned narrative approximations where we needed exact figures. The financial precision was unacceptable for an executive report.&lt;/p&gt;
&lt;p&gt;The solution was to radically separate the &lt;strong&gt;&quot;semantic brain&quot;&lt;/strong&gt; from the &lt;strong&gt;&quot;mathematical muscle&quot;&lt;/strong&gt;: the LLM (Gemini 2.5 + CrewAI) handles natural language comprehension (extracting the &lt;em&gt;Part Number&lt;/em&gt; from a supplier email, understanding the context of an alert), and Python tools (CrewAI's &lt;code&gt;@tool&lt;/code&gt;) handle precision operations (SQL queries to Supabase, P&amp;amp;L calculations, relational graph traversal). The result: executive reports generated in 4 seconds with &lt;strong&gt;0% hallucination in numerical data&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://datalaria.com/en/posts/rag_antipatterns/rag_vs_toolcalling.png&quot; alt=&quot;RAG vs Tool Calling: when to use each&quot;&gt;&lt;/p&gt;
&lt;p&gt;The rule I've distilled is simple:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;You need... &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Use... &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Why&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Answers about &lt;strong&gt;unstructured knowledge&lt;/strong&gt; (manuals, posts, narrative documentation) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;RAG&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Semantic retrieval is superior for searching free text&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;Answers with &lt;strong&gt;structured data and numerical precision&lt;/strong&gt; (SQL, APIs, financial calculations) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Tool Calling&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Tools execute deterministic code, no hallucinations&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Both&lt;/strong&gt; (interpret an email + calculate financial impact) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Hybrid architecture&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;The LLM orchestrates; the tools execute&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And if you're wondering how to standardize those connections between the LLM and the tools so you're not locked into a vendor, that's exactly what we addressed in the article about &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/mcp_protocol/&quot;&gt;MCP (Model Context Protocol)&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id=&quot;conclusion-rag-isn-t-broken-your-implementation-is&quot;&gt;Conclusion: RAG Isn't Broken; Your Implementation Is&lt;/h4&gt;
&lt;p&gt;If there's one message I want you to take from this article, it's this: &lt;strong&gt;RAG is a legitimate and powerful architecture when implemented with engineering rigor&lt;/strong&gt;. The problem isn't the pattern; the problem is that the industry has popularized it as a magical plug-and-play solution, when in reality it's a complex pipeline that requires intelligent chunking, evaluated embeddings, reranking, generous context, hallucination defenses, quality metrics, and the humility to recognize when Tool Calling is the right tool for the job.&lt;/p&gt;
&lt;p&gt;The &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/hidden_economics_ai/&quot;&gt;10x Rule&lt;/a&gt; I proposed in the Hidden Economics of AI article applies perfectly here: if RAG doesn't give you a result &lt;strong&gt;10 times better&lt;/strong&gt; than a direct SQL query or an API call, you're probably using the wrong tool for the wrong problem.&lt;/p&gt;
&lt;p&gt;And in the era of the &lt;a rel=&quot;nofollow&quot; href=&quot;https://datalaria.com/en/posts/eu_ai_act/&quot;&gt;EU AI Act&lt;/a&gt;, where traceability (Article 12) and precision are legal obligations for high-risk systems, deploying a RAG that hallucinates isn't just a technical error: it's a regulatory risk.&lt;/p&gt;
&lt;hr&gt;
&lt;h5 id=&quot;sources-of-interest--2&quot;&gt;Sources of Interest:&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.ragas.io/&quot;&gt;&lt;strong&gt;RAGAS&lt;/strong&gt;: Evaluation Framework for RAG — Official Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://docs.confident-ai.com/&quot;&gt;&lt;strong&gt;DeepEval&lt;/strong&gt;: Open-Source LLM Evaluation Framework&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.pinecone.io/learn/chunking-strategies/&quot;&gt;&lt;strong&gt;Pinecone&lt;/strong&gt;: Chunking Strategies for RAG Applications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://cohere.com/rerank&quot;&gt;&lt;strong&gt;Cohere&lt;/strong&gt;: Reranking — Improving Search Relevance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.datalaria.com/en/posts/obs_part5_radar_agent/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: The Agentic Radar — Why Tool Calling &amp;gt; RAG in Production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.datalaria.com/en/posts/ai_agents_part8/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: Autopilot Part 8 — Ops Copilot with Algolia Agent Studio and RAG&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.datalaria.com/en/posts/mcp_protocol/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: MCP Protocol — The Standard That Wants to Be the USB of AI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a rel=&quot;nofollow&quot; href=&quot;https://www.datalaria.com/en/posts/eu_ai_act/&quot;&gt;&lt;strong&gt;Datalaria&lt;/strong&gt;: EU AI Act — Traceability and Precision as Legal Obligations&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</description>
<category>Tutorials</category>
<guid isPermaLink="true">https://coderlegion.com/22320/rag-in-production-7-anti-patterns-that-destroy-precision-and-how-i-fixed-them</guid>
<pubDate>Sat, 11 Jul 2026 14:38:05 +0000</pubDate>
</item>
<item>
<title>I Rebuilt My AI Legal Assistant After Learning Why Vector-Only RAG Wasn't Enough</title>
<link>https://coderlegion.com/22289/i-rebuilt-my-ai-legal-assistant-after-learning-why-vector-only-rag-wasnt-enough</link>
<description>&lt;p&gt;I built a legal AI assistant last year.&lt;/p&gt;
&lt;p&gt;One day I asked it:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Someone forged my signature.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The first law it retrieved wasn't about forgery.&lt;/p&gt;
&lt;p&gt;It was about counterfeit coins.&lt;/p&gt;
&lt;p&gt;That was the moment I realized the problem wasn't the LLM.&lt;/p&gt;
&lt;p&gt;It was my retrieval pipeline.&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&lt;strong&gt;The surprising part:&lt;/strong&gt; I didn't change the LLM.&lt;/p&gt;
&lt;p&gt;Nearly all of the improvement came from redesigning the retrieval pipeline.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Here is the story of how I debugged my search system, why dense vector search alone falls apart on domain-specific datasets, and how I redesigned the retrieval pipeline.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;the-failure-1&quot;&gt;The Failure&lt;/h3&gt;
&lt;p&gt;To understand why the system was failing, we need to look at the mismatch between what was queried and what was actually retrieved in the original vector-only (v1) search:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Query:
&quot;Someone forged my signature&quot;

Top result (v1):
❌ BNS Section 180 — Possession of counterfeit coin

Expected:
✅ BNS Section 336 — Forgery
✅ BNS Section 340 — Using forged document
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;tracing-the-cause&quot;&gt;Tracing the Cause&lt;/h3&gt;
&lt;p&gt;I started tracing the retrieval pipeline step by step to understand why the wrong statutes were consistently ranking first.&lt;/p&gt;
&lt;p&gt;The embedding model wasn't &quot;wrong.&quot; It was doing exactly what it was trained to do: place semantically similar concepts close together in vector space.&lt;/p&gt;
&lt;p&gt;Unfortunately, in legal search, &lt;em&gt;semantically similar&lt;/em&gt; isn't the same as &lt;em&gt;legally relevant&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Because &quot;forgery&quot; and &quot;counterfeit&quot; ended up close in embedding space, the retriever ranked counterfeit coin and government stamp provisions above the actual definition of document forgery. The retriever generalized too aggressively. It missed the specific statutory definition of forgery (&lt;code&gt;BNS Section 336&lt;/code&gt;) because the word &quot;signature&quot; was semantically distant from generic statutory descriptions of the offence.&lt;/p&gt;
&lt;h4 id=&quot;the-secondary-issues-ram-bloat-amp-duplicates&quot;&gt;The Secondary Issues: RAM Bloat &amp;amp; Duplicates&lt;/h4&gt;
&lt;p&gt;In addition to generalising incorrectly, caching large raw text strings (text, titles, act names) in a JavaScript array caused the Node.js process to consume over &lt;strong&gt;320 MB of RAM&lt;/strong&gt; at startup. Furthermore, since legal codes are highly repetitive, the search regularly returned duplicate entries of identical sections across different personal laws, cluttering the LLM's context window.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;redesigning-the-retrieval-pipeline&quot;&gt;Redesigning the Retrieval Pipeline&lt;/h3&gt;
&lt;p&gt;I realized that to make the assistant trustworthy, the real problem was search quality, not LLM capability. I redesigned the retrieval pipeline into a structured, hybrid search engine.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ishwar170695/LawDecoder/main/images/architecture.png&quot; alt=&quot;LawDecoder hybrid retrieval architecture diagram: SQLite FTS5 sparse keyword index and dense vector ONNX pipeline merged via Reciprocal Rank Fusion (RRF) and Domain Reranker&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;The LLM remained almost unchanged. Nearly all improvements came from redesigning retrieval.&lt;/em&gt;&lt;/p&gt;
&lt;h4 id=&quot;step-1-sqlite-fts5&quot;&gt;Step 1: SQLite + FTS5&lt;/h4&gt;
&lt;p&gt;I moved all 4,892 legal sections out of JSON files and persisted them in a local &lt;strong&gt;SQLite&lt;/strong&gt; database. I created a virtual table using the &lt;strong&gt;FTS5 extension&lt;/strong&gt; to index all chapters, titles, and text contents. &lt;/p&gt;
&lt;p&gt;Now, exact terms are queried using a sparse keyword index (ranked via BM25), ensuring that queries containing specific statutory terms match their target immediately.&lt;/p&gt;
&lt;h4 id=&quot;step-2-lightweight-vector-cache&quot;&gt;Step 2: Lightweight Vector Cache&lt;/h4&gt;
&lt;p&gt;I stripped all text metadata from Node.js memory. The startup script now loads only the &lt;code&gt;id&lt;/code&gt; (string) and the coordinate list—pre-processed into a compact &lt;code&gt;Float32Array&lt;/code&gt; object—into RAM. &lt;/p&gt;
&lt;p&gt;The actual text content remains on disk in SQLite and is only hydrated for the top 5 matched sections. This dropped the memory footprint by &lt;strong&gt;85%&lt;/strong&gt; (from 320 MB to &lt;strong&gt;48 MB&lt;/strong&gt;).&lt;/p&gt;
&lt;h4 id=&quot;step-3-reciprocal-rank-fusion-rrf-&quot;&gt;Step 3: Reciprocal Rank Fusion (RRF)&lt;/h4&gt;
&lt;p&gt;Instead of relying on either vector search or keyword search, I fused them. The backend runs both searches, takes the top 50 matches from each, and combines their rankings using standard &lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&gt;. RRF rewards documents that rank highly in both methods without needing to normalize scores between sparse BM25 and dense cosine models.&lt;/p&gt;
&lt;p&gt;Here is the core JS implementation of the RRF merge:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;const rrfScores = new Map();
const k = 60; // Standard constant for RRF

// Process vector ranking positions (dense)
vectorRankings.forEach((item, index) =&amp;gt; {
  rrfScores.set(item.id, 1 / (k + index + 1));
});

// Process FTS5 keyword ranking positions (sparse) and add to scores
ftsRankings.forEach((item, index) =&amp;gt; {
  const existingScore = rrfScores.get(item.id) || 0;
  rrfScores.set(item.id, existingScore + (1 / (k + index + 1)));
});

// Sort matched IDs based on fused RRF score
const mergedRanking = Array.from(rrfScores.entries())
  .sort((a, b) =&amp;gt; b[1] - a[1])
  .slice(0, 20); // Top 20 candidates for reranking
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 id=&quot;step-4-domain-reranker-deterministic-guardrail-&quot;&gt;Step 4: Domain Reranker (Deterministic Guardrail)&lt;/h4&gt;
&lt;p&gt;To resolve the counterfeit coin noise without running a heavy, slow transformer cross-encoder, I built a lightweight, deterministic &lt;strong&gt;Domain Reranker&lt;/strong&gt; in JavaScript. This is a deterministic rule-based reranker tailored to the legal domain—not a learned neural cross-encoder. &lt;/p&gt;
&lt;p&gt;It loads the top 20 candidates returned by the RRF step and checks for specific intent signals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;If the query is document/signature forgery-related, it checks if a retrieved document is a coin or banknote counterfeit section. If yes, it &lt;strong&gt;penalizes the score by 99%&lt;/strong&gt; (&lt;code&gt;* 0.01&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;boosts direct document forgery offences&lt;/strong&gt; (containing &lt;code&gt;&quot;forgery&quot;&lt;/code&gt; or &lt;code&gt;&quot;forged&quot;&lt;/code&gt;) by &lt;strong&gt;300%&lt;/strong&gt; (&lt;code&gt;* 3.0&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;It filters out duplicate sections using content snippet prefixes.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-js&quot;&gt;// Heuristic Domain Reranking
if (isDocumentForgeryRelated) {
  const isCoinOrStampOrCurrency = 
    titleLower.includes('coin') || titleLower.includes('stamp') || 
    titleLower.includes('currency') || titleLower.includes('bank-note') ||
    contentLower.includes('coin') || contentLower.includes('stamp') || 
    contentLower.includes('currency-note');

  if (isCoinOrStampOrCurrency) {
    adjustedScore *= 0.01; // heavily penalize counterfeit coin/stamps (reduce by 99%)
  } else if (titleLower.includes('forgery') || titleLower.includes('forged')) {
    adjustedScore *= 3.0; // strong boost for direct forgery definitions/offences
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 id=&quot;performance-amp-evaluation&quot;&gt;Performance &amp;amp; Evaluation&lt;/h3&gt;
&lt;p&gt;To evaluate the redesign, I assembled a benchmark of 100 manually verified legal queries spanning criminal law, cybercrime, family law, consumer protection, and procedural law.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;&lt;th align=&quot;left&quot;&gt;Metric &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;v1 (Naive Vector RAG) &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;v2.1 (Hybrid Search - Current) &lt;/th&gt;&lt;th align=&quot;left&quot;&gt;Change&lt;/th&gt;&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Search Engine&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Dense Vector (Linear JSON scan) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Hybrid (SQLite FTS5 + Dense Vector + RRF + Reranker) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Major retrieval precision upgrade&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Avg. Query Latency&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;code&gt;466 ms&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;code&gt;12 ms&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;97.4% speedup&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Memory Cache Footprint&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;code&gt;~320 MB&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;code&gt;~48 MB&lt;/code&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;85.0% RAM savings&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Duplicate Citations&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Present (up to 40% overlaps) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Deduplicated (0% overlaps) &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;Verified&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;Top-5 Relevant Retrieval Rate&lt;/strong&gt; &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;~68% &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;~91% &lt;/td&gt;&lt;td align=&quot;left&quot;&gt;&lt;strong&gt;+23% accuracy gain&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;em&gt;Latency is based on 100 benchmark queries. Memory is process-level heap size at startup. Accuracy is evaluated on top-5 target matches using a manually verified benchmark dataset of 100 queries.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;None of these improvements required changing the language model. The gains came almost entirely from retrieval engineering. For this benchmark query, the top retrieved references aligned with the expected legal provisions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;BNS 340:&lt;/strong&gt; Forged document and using it as genuine&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BNS 336:&lt;/strong&gt; Forgery definition and penalty&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BNS 339:&lt;/strong&gt; Possession of forged document&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;BNS 335:&lt;/strong&gt; Making a false document&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Evidence Act Section 65:&lt;/strong&gt; Proof of signature and handwriting&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h3 id=&quot;production-walkthrough&quot;&gt;Production Walkthrough&lt;/h3&gt;
&lt;h4 id=&quot;1-user-chat-interface&quot;&gt;1️⃣ User Chat Interface&lt;/h4&gt;
&lt;p&gt;Clean, legal explanation interface for end users:&lt;br&gt;
&lt;img src=&quot;https://raw.githubusercontent.com/ishwar170695/LawDecoder/main/images/landing_page.png&quot; alt=&quot;LawDecoder Streamlit user chat landing page showing response layout&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;2-structured-offence-amp-citation-details&quot;&gt;2️⃣ Structured Offence &amp;amp; Citation Details&lt;/h4&gt;
&lt;p&gt;Deduplicated citations with developer metrics visible in Developer Mode:&lt;br&gt;
&lt;img src=&quot;https://raw.githubusercontent.com/ishwar170695/LawDecoder/main/images/citations_view.png&quot; alt=&quot;LawDecoder citation view in developer mode displaying RRF ranks and selection reasons&quot;&gt;&lt;/p&gt;
&lt;h4 id=&quot;3-system-evaluation-dashboard&quot;&gt;3️⃣ System Evaluation Dashboard&lt;/h4&gt;
&lt;p&gt;Performance comparisons and technical architecture story:&lt;br&gt;
&lt;img src=&quot;https://raw.githubusercontent.com/ishwar170695/LawDecoder/main/images/developer_notes_tab.png&quot; alt=&quot;LawDecoder developer dashboard showing performance latency and accuracy benchmarks comparison table&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;lessons-learned&quot;&gt;Lessons Learned&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Retrieval quality sets the upper bound for RAG quality.&lt;/li&gt;
&lt;li&gt;Dense embeddings alone are rarely enough for domain-specific search.&lt;/li&gt;
&lt;li&gt;SQLite + FTS5 can be an excellent retrieval engine for small-to-medium corpora.&lt;/li&gt;
&lt;li&gt;Simple deterministic rerankers can eliminate domain-specific retrieval errors without requiring another neural model.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If I continue evolving this project, the next improvements I'd explore are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cross-encoder reranking:&lt;/strong&gt; Integrate lightweight cross-encoders (e.g. BGE reranker) for advanced ranking.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Metadata-aware retrieval:&lt;/strong&gt; Allow users to filter queries by Act or category before searching.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Legal Case Retrieval:&lt;/strong&gt; Expand indexing to cover legal precedents and court cases in addition to statutory acts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multilingual support:&lt;/strong&gt; Support query translation for regional languages.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts-1&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;Going into this project, I assumed improving the LLM would improve the assistant.&lt;/p&gt;
&lt;p&gt;Instead, I learned that retrieval quality determines the ceiling of any RAG system.&lt;/p&gt;
&lt;p&gt;The LLM can only reason over what you retrieve.&lt;/p&gt;
&lt;p&gt;Improving retrieval turned out to be far more impactful than switching models.&lt;/p&gt;
&lt;p&gt;If you're building domain-specific AI—whether for legal, medical, or enterprise search—I'd recommend spending as much time on retrieval engineering as prompt engineering.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;repository&quot;&gt;Repository&lt;/h3&gt;
&lt;p&gt;The complete implementation—including SQLite ingestion, FTS5 indexing, Reciprocal Rank Fusion, evaluation queries, and benchmark samples—is available on GitHub.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a rel=&quot;nofollow&quot; href=&quot;https://github.com/ishwar170695/LawDecoder&quot;&gt;https://github.com/ishwar170695/LawDecoder&lt;/a&gt;&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/22289/i-rebuilt-my-ai-legal-assistant-after-learning-why-vector-only-rag-wasnt-enough</guid>
<pubDate>Sat, 11 Jul 2026 11:38:51 +0000</pubDate>
</item>
<item>
<title>Hallucination Is Not One Problem — It's Five Different Architectural Failures</title>
<link>https://coderlegion.com/21850/hallucination-is-not-one-problem-its-five-different-architectural-failures</link>
<description>&lt;p&gt;&lt;em&gt;Why your RAG pipeline, guardrails, and prompt engineering each fix a different hallucination — and why none of them fix the others.&lt;/em&gt; &lt;/p&gt;
&lt;h3 id=&quot;the-frustration&quot;&gt;&lt;strong&gt;The Frustration&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;You built a RAG system. Added re-ranking. Tuned the temperature. Even threw in a &lt;strong&gt;&quot;don't make things up&quot;&lt;/strong&gt; prompt. And yet — your agent still hallucinates. So you add more retrieval chunks. Then guardrails. Then another LLM call to &quot;verify&quot; the first one. The hallucinations shift shape, but they never disappear.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Here's the hard truth:&lt;/strong&gt; you're treating five different architectural failures as one &lt;strong&gt;&quot;hallucination bug.&quot;&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&quot;type-1-confabulation-the-llm-makes-facts-up&quot;&gt;Type 1: Confabulation — The LLM Makes Facts Up&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What it looks like:&lt;/strong&gt; &quot;According to a 2023 study by Stanford...&quot; (no such study exists)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it happens:&lt;/strong&gt; The model is a next-token predictor with no ground-truth anchor. It optimizes for plausibility, not accuracy.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix —&lt;/strong&gt; Retrieval Grounding + Fact Verification:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Every claim must cite a retrieved source&lt;/li&gt;
&lt;li&gt;Verification LLM checks claim ↔ source alignment&lt;/li&gt;
&lt;li&gt;Abstention gate: if no source supports it, output &quot;I don't know&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;Prompt engineering won't fix this. The model has no fact-checker in its weights. You need an external verification architecture.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;type-2-attribution-error-right-fact-wrong-source&quot;&gt;Type 2: Attribution Error — Right Fact, Wrong Source&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What it looks like:&lt;/strong&gt; Cites Document A for a fact that actually came from Document B&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it happens:&lt;/strong&gt; Semantic similarity retrieves the wrong chunk; the LLM doesn't verify provenance&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix —&lt;/strong&gt; Provenance Tracking + Citation Scoring:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Chunk-level metadata: source ID, section, timestamp&lt;/li&gt;
&lt;li&gt;Citation scorer: cross-check generated citation against retrieved chunks&lt;/li&gt;
&lt;li&gt;Mismatch → flag or abstain&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;type-3-temporal-drift-the-truth-expired&quot;&gt;Type 3: Temporal Drift — The Truth Expired&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What it looks like:&lt;/strong&gt; &quot;The latest React version is 18.2&quot; (it's 19.x now)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it happens:&lt;/strong&gt; Static knowledge base with no freshness signal; retrieval doesn't prioritize recency&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; — Knowledge Freshness Timestamps + Update Pipelines:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Every chunk tagged with  last_updated &lt;/li&gt;
&lt;li&gt;Retrieval re-ranks by recency for time-sensitive queries&lt;/li&gt;
&lt;li&gt;Scheduled re-indexing pipeline for evolving domains&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;type-4-logical-inconsistency-contradictory-claims-in-one-answer&quot;&gt;Type 4: Logical Inconsistency —Contradictory Claims in One Answer&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What it looks like:&lt;/strong&gt; &quot;X is faster than Y&quot; and &quot;Y outperforms X&quot; in the same response&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it happens:&lt;/strong&gt; LLM processes tokens locally; no global consistency check across the full output&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; — Structured Reasoning + Formal Verification:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Chain-of-thought with explicit intermediate claims&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Consistency checker:&lt;/strong&gt; compare all claims pairwise&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For critical domains:&lt;/strong&gt; compile claims to SAT/SMT solver&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;type-5-tool-hallucination-the-agent-calls-a-tool-that-doesn-t-exist&quot;&gt;Type 5: Tool Hallucination — The Agent Calls a Tool That Doesn't Exist&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;What it looks like:&lt;/strong&gt;  &lt;code&gt;call_tool(&quot;get_weather&quot;, location=&quot;Mars&quot;)&lt;/code&gt;  — tool doesn't exist, or parameter is invalid&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it happens:&lt;/strong&gt; The LLM generates tool calls like it generates text — by pattern matching, not by schema validation&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt; — Tool Schema Validation + Execution Sandbox:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tool registry with JSON schemas&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pre-execution validator:&lt;/strong&gt; name + params must match schema&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sandbox:&lt;/strong&gt; tool runs in isolated env, output validated before passing back&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;the-unified-architecture&quot;&gt;&lt;strong&gt;The Unified Architecture&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://coderlegion.com/?qa=blob&amp;amp;qa_blobid=6640491330656404556&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;h3 id=&quot;closing&quot;&gt;Closing&lt;/h3&gt;
&lt;p&gt;Hallucination is not a bug you patch. It's a symptom of five different architectural gaps. Fix the architecture, and the &quot;hallucination problem&quot; dissolves into five solvable engineering problems.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21850/hallucination-is-not-one-problem-its-five-different-architectural-failures</guid>
<pubDate>Fri, 03 Jul 2026 21:07:27 +0000</pubDate>
</item>
<item>
<title>CAG: The Simpler Way to Ground Your LLM</title>
<link>https://coderlegion.com/21480/cag-the-simpler-way-to-ground-your-llm</link>
<description>&lt;p&gt;If you've been building AI applications recently, you've probably come across &lt;strong&gt;Retrieval-Augmented Generation (RAG)&lt;/strong&gt;. It has become the go-to way of giving LLMs access to external knowledge.&lt;/p&gt;
&lt;p&gt;But RAG isn't the only option.&lt;/p&gt;
&lt;p&gt;As context windows continue to grow, another approach is becoming increasingly practical: &lt;strong&gt;Cache-Augmented Generation (CAG)&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Before we begin, a small disclaimer. This article intentionally argues in CAG's favor. Think of it as a friendly debate where CAG finally gets a chance to speak while RAG takes a short coffee break.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-rag-became-so-popular&quot;&gt;Why RAG Became So Popular&lt;/h3&gt;
&lt;p&gt;RAG solved a real problem.&lt;/p&gt;
&lt;p&gt;Instead of expecting an LLM to know everything, we store information in a vector database. When a user asks a question, we retrieve the most relevant pieces and send them to the model.&lt;/p&gt;
&lt;p&gt;A typical RAG pipeline looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Query → Embed → Search → Rank → Retrieve → Generate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It's a proven approach and works really well, especially when your knowledge base is large or changes frequently.&lt;/p&gt;
&lt;p&gt;The only downside is that every question has to go through this retrieval process before the model can generate an answer.&lt;/p&gt;
&lt;p&gt;That means more infrastructure, more moving parts, and a little extra latency.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;meet-cag&quot;&gt;Meet CAG&lt;/h3&gt;
&lt;p&gt;CAG takes a much simpler approach.&lt;/p&gt;
&lt;p&gt;Instead of searching for information every time someone asks a question, it loads the required knowledge into the model's context once and keeps using it.&lt;/p&gt;
&lt;p&gt;The workflow becomes:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;Load knowledge → Cache context → Generate
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That's the entire idea.&lt;/p&gt;
&lt;p&gt;No vector search.&lt;/p&gt;
&lt;p&gt;No retrieval step.&lt;/p&gt;
&lt;p&gt;No ranking.&lt;/p&gt;
&lt;p&gt;The model already has the information it needs.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-is-this-possible-now-&quot;&gt;Why Is This Possible Now?&lt;/h3&gt;
&lt;p&gt;A couple of years ago, CAG wasn't practical.&lt;/p&gt;
&lt;p&gt;Context windows were simply too small.&lt;/p&gt;
&lt;p&gt;Today, that's no longer true.&lt;/p&gt;
&lt;p&gt;Many modern models support hundreds of thousands and sometimes even millions of tokens.&lt;/p&gt;
&lt;p&gt;That changes the question from:&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;How do I retrieve the right documents?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;to&lt;/p&gt;
&lt;blockquote&gt;&lt;p&gt;&quot;Can I fit my knowledge into the context window?&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For many internal tools, company documentation, onboarding guides, product manuals, and API references, the answer is surprisingly often &lt;strong&gt;yes&lt;/strong&gt;.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;rag-vs-cag&quot;&gt;RAG vs CAG&lt;/h3&gt;
&lt;p&gt;Both approaches solve the same problem, but in different ways.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Choose RAG when:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Your knowledge base is too large to fit into the model's context.&lt;/li&gt;
&lt;li&gt;Information changes frequently.&lt;/li&gt;
&lt;li&gt;Different users need different subsets of knowledge.&lt;/li&gt;
&lt;li&gt;You need real-time data.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Choose CAG when:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Your documentation comfortably fits in the context window.&lt;/li&gt;
&lt;li&gt;Most of the information is relatively static.&lt;/li&gt;
&lt;li&gt;Low latency is important.&lt;/li&gt;
&lt;li&gt;You want a simpler architecture with fewer components.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Neither approach is &quot;better.&quot;&lt;/p&gt;
&lt;p&gt;The right choice depends on your use case.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;a-simple-example&quot;&gt;A Simple Example&lt;/h3&gt;
&lt;p&gt;A traditional RAG pipeline might look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;query = &quot;What's our refund policy?&quot;

embedding = embed(query)
chunks = vector_db.search(embedding, top_k=5)

context = &quot;\n&quot;.join(chunks)

response = llm.generate(
    f&quot;Context:\n{context}\n\nQuestion: {query}&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A CAG implementation is much simpler:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;with open(&quot;knowledge_base.txt&quot;) as f:
    knowledge = f.read()

system_prompt = f&quot;&quot;&quot;
You are an assistant.

Use the following knowledge when answering questions.

{knowledge}
&quot;&quot;&quot;

response = llm.generate(
    system=system_prompt,
    user=&quot;What's our refund policy?&quot;
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The biggest difference isn't the amount of code.&lt;/p&gt;
&lt;p&gt;It's that there is no retrieval happening during inference.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;why-not-use-both-&quot;&gt;Why Not Use Both?&lt;/h3&gt;
&lt;p&gt;In practice, many applications don't have to choose one over the other.&lt;/p&gt;
&lt;p&gt;A hybrid approach often works best.&lt;/p&gt;
&lt;p&gt;Keep your stable documentation in the model's cached context using CAG.&lt;/p&gt;
&lt;p&gt;Retrieve only the information that changes frequently using RAG.&lt;/p&gt;
&lt;p&gt;This gives you fast responses for most questions while still allowing access to fresh information whenever needed.&lt;/p&gt;
&lt;hr&gt;
&lt;h3 id=&quot;final-thoughts-2&quot;&gt;Final Thoughts&lt;/h3&gt;
&lt;p&gt;As developers, we sometimes assume that every LLM application needs a vector database.&lt;/p&gt;
&lt;p&gt;But that's not always true anymore.&lt;/p&gt;
&lt;p&gt;Before building a RAG pipeline, ask yourself one simple question:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does my knowledge base actually fit inside the model's context window?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If it does, CAG could be a simpler solution that's easier to build, easier to maintain, and often faster to serve.&lt;/p&gt;
&lt;p&gt;If it doesn't, RAG is still an excellent choice.&lt;/p&gt;
&lt;p&gt;The goal isn't to replace RAG.&lt;/p&gt;
&lt;p&gt;It's to recognize that modern context windows have changed what's possible, and CAG deserves a place in the conversation.&lt;/p&gt;
&lt;hr&gt;
&lt;h4 id=&quot;tl-dr&quot;&gt;TL;DR&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;RAG retrieves relevant information before every query.&lt;/li&gt;
&lt;li&gt;CAG loads knowledge into the model's context upfront.&lt;/li&gt;
&lt;li&gt;Modern context windows make CAG practical for many use cases.&lt;/li&gt;
&lt;li&gt;If your knowledge fits in context, CAG is worth considering before reaching for a vector database.&lt;/li&gt;
&lt;li&gt;For large or frequently changing knowledge bases, RAG remains the better choice.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sometimes the simplest architecture is the one that gets out of the model's way.&lt;/p&gt;
</description>
<category>Articles</category>
<guid isPermaLink="true">https://coderlegion.com/21480/cag-the-simpler-way-to-ground-your-llm</guid>
<pubDate>Sun, 28 Jun 2026 05:09:39 +0000</pubDate>
</item>
</channel>
</rss>