<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hubert García Gordon</title>
    <description>The latest articles on DEV Community by Hubert García Gordon (@hubertgarcia).</description>
    <link>https://dev.to/hubertgarcia</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4055456%2F42b81ed4-56ba-45c6-8bc1-4afd078ca8ad.jpg</url>
      <title>DEV Community: Hubert García Gordon</title>
      <link>https://dev.to/hubertgarcia</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hubertgarcia"/>
    <language>en</language>
    <item>
      <title>Three things I got wrong measuring my own cache</title>
      <dc:creator>Hubert García Gordon</dc:creator>
      <pubDate>Tue, 25 Aug 2026 01:48:00 +0000</pubDate>
      <link>https://dev.to/hubertgarcia/three-things-i-got-wrong-measuring-my-own-cache-45je</link>
      <guid>https://dev.to/hubertgarcia/three-things-i-got-wrong-measuring-my-own-cache-45je</guid>
      <description>&lt;p&gt;A team that produces regulatory documents kept getting the same kind of question from other teams: &lt;em&gt;does the current rule allow X?&lt;/em&gt; Answering meant someone reading through memos, manuals and regulations to find where the rule was stated, then writing a summary with the source cited. The work was real, and the answer was almost always already written down somewhere.&lt;/p&gt;

&lt;p&gt;Retrieval fits this exactly. The user asks, the system summarises, and it cites which document the answer came from. The citation is the whole point — a summary without a source is useless when the question is about a rule.&lt;/p&gt;

&lt;p&gt;It also fits the constraint from &lt;a href="https://dev.to/hubertgarcia/on-premise-rag-without-gpu-cloud-or-docker-five-lessons-that-cost-me-a-week-each-3bjm"&gt;Part 1&lt;/a&gt;: the documents can't leave the perimeter, there's no GPU, and each generated answer costs tens of seconds on CPU.&lt;/p&gt;

&lt;p&gt;Which is why a semantic cache looked like the most obvious optimisation in the system. Several teams ask about the same regulation. If two questions mean the same thing, serve the stored answer and skip the model entirely. I implemented it with cosine similarity between question embeddings and a threshold of 0.92, and it went out in the reference implementation this repository publishes — always on, with no flag to turn it off.&lt;/p&gt;

&lt;p&gt;Then &lt;a href="https://dev.to/gde03/comment/3c9ni"&gt;Giulio D'Erme&lt;/a&gt; asked me to prove that the threshold was safe. His example was clinical rather than administrative — &lt;em&gt;pacientes con fiebre&lt;/em&gt; against &lt;em&gt;pacientes sin fiebre&lt;/em&gt;, one token apart, opposite correct answers — but it transfers to every corpus I care about.&lt;/p&gt;

&lt;p&gt;The interesting question turned out not to be whether embedders handle negation badly — they do, and the literature has said so for years. It's what happens when that failure lands in a component that &lt;strong&gt;suppresses generation&lt;/strong&gt; rather than one that reorders results. A bad ranking degrades an answer the user can still see and judge. A cache hit skips the model entirely and returns the opposite rule with full confidence and no signal that anything happened.&lt;/p&gt;

&lt;p&gt;Answering him took six days — the commit history runs from 6 to 11 August and every step is in it. The cache turned out to be unsafe, and so did the way I had measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the first run showed
&lt;/h2&gt;

&lt;p&gt;The design is simple enough to break in one sentence: two questions that differ only in polarity mean opposite things, and a cosine similarity between their embeddings doesn't know that.&lt;/p&gt;

&lt;p&gt;So I built twenty pairs of Spanish administrative questions in four categories — negation, temporal, entity, and paraphrase as a control — and measured cosine similarity with &lt;code&gt;nomic-embed-text&lt;/code&gt; running on Ollama. The pairs are written by hand for the experiment, not drawn from any query log: leave, sick notes, permits, budgets, the vocabulary any public administration shares. No harness, no vector store, no retrieval. Just an embedder, a similarity function, and a script.&lt;/p&gt;

&lt;p&gt;The result was blunt. The highest adversarial similarity was 0.9984, for a pair whose two questions have opposite correct answers. The lowest genuine paraphrase sat at 0.7470. Any threshold high enough to reject the adversarial pairs also rejected every paraphrase the cache existed to catch. No safe threshold existed, so I disabled the cache by default and wrote it up.&lt;/p&gt;

&lt;p&gt;That conclusion still stands. Everything else about how I got there needed correcting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake one: two of my five negation pairs weren't negations
&lt;/h2&gt;

&lt;p&gt;Giulio came back with a caveat about my own numbers: with five pairs per category, counts at a fixed threshold are fragile and readers will quote them as rates. He was right, and building a larger corpus to answer him is where I found the first problem.&lt;/p&gt;

&lt;p&gt;Two of my five "negation" pairs were these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;¿Es obligatorio presentar la solicitud con anticipación?&lt;/em&gt; vs &lt;em&gt;¿No es obligatorio presentar la solicitud con anticipación?&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;¿Los contratos temporales tienen derecho a aguinaldo?&lt;/em&gt; vs &lt;em&gt;¿Los contratos temporales no tienen derecho a aguinaldo?&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In Spanish, a negative interrogative like this is confirmatory. It asks for confirmation of the same fact rather than its opposite, and a correct system answers both identically. I had labelled them as pairs the cache must &lt;strong&gt;reject&lt;/strong&gt; when they should have been &lt;strong&gt;accepts&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This was a linguistics error, not a code error, and no amount of testing would have caught it. The published minimum for my negation row, 0.9702, belongs to one of those invalid pairs. The count I'd reported as "9 of 15 adversarial pairs" is 7 of 13.&lt;/p&gt;

&lt;p&gt;The conclusion survived because the highest adversarial similarity, 0.9984, belongs to a valid pair. But the row was wrong, so the report now carries an erratum and the two pairs live on as a separate &lt;code&gt;confirmatory&lt;/code&gt; category — useful, as it turns out, because they're the cleanest possible control: same surface change as a negation, opposite required behaviour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake two: my control was too easy
&lt;/h2&gt;

&lt;p&gt;This is the one worth the article. It's also the one I'd have defended if someone had raised it, right up until I sat down and wrote out the two populations I was comparing.&lt;/p&gt;

&lt;p&gt;Here they are.&lt;/p&gt;

&lt;p&gt;The pairs I wanted the cache to &lt;strong&gt;reject&lt;/strong&gt; differed by a single token. &lt;em&gt;Con&lt;/em&gt; against &lt;em&gt;sin&lt;/em&gt;. &lt;em&gt;Incluye&lt;/em&gt; against &lt;em&gt;excluye&lt;/em&gt;. Fourteen words, thirteen of them identical, and the correct answer inverts.&lt;/p&gt;

&lt;p&gt;The pairs I wanted the cache to &lt;strong&gt;accept&lt;/strong&gt; differed by nearly all of their tokens. &lt;em&gt;¿Cómo solicito vacaciones?&lt;/em&gt; against &lt;em&gt;¿Cuál es el procedimiento para pedir vacaciones?&lt;/em&gt; — one content word in common, &lt;em&gt;vacaciones&lt;/em&gt;, and the rest rewritten from scratch.&lt;/p&gt;

&lt;p&gt;So one population was near-identical strings that must be rejected. The other was barely-overlapping strings that must be accepted. I measured cosine similarity across both, found that the first scored high and the second scored low, and published that as evidence that the embedder cannot tell a negation from a paraphrase.&lt;/p&gt;

&lt;p&gt;Now ask what else would produce that exact table. A function that counted shared tokens and knew nothing about meaning would produce it. Character n-gram overlap would produce it. My experiment could not distinguish the embedder failing at semantics from the embedder succeeding at surface form while I misread the output as semantics — because I had built the two candidate explanations to predict the same result.&lt;/p&gt;

&lt;p&gt;That's what a control is for, and mine wasn't one. A control population has to differ from the adversarial population in the thing you're testing and match it in everything else. Mine differed in both, so the contrast couldn't isolate anything. It looked like a clean result because the numbers were far apart, and distance between numbers is not the same as evidence for a claim.&lt;/p&gt;

&lt;p&gt;The fix was to build five paraphrases with &lt;strong&gt;matched lexical overlap&lt;/strong&gt; — one-token synonym swaps (&lt;em&gt;solicito&lt;/em&gt; → &lt;em&gt;pido&lt;/em&gt;, &lt;em&gt;me corresponden&lt;/em&gt; → &lt;em&gt;me tocan&lt;/em&gt;), the same surface distance as the negations, the opposite required behaviour — and to pre-specify that contrast as the primary one, in writing, before looking at any number it produced.&lt;/p&gt;

&lt;p&gt;It mattered more than I expected — and the model that showed me how much wasn't mine either. Giulio's comment of 7 August recommended two: &lt;code&gt;bge-m3&lt;/code&gt; as a second embedder, and &lt;code&gt;cross-encoder/ms-marco-MiniLM-L-6-v2&lt;/code&gt; as a reranker, that one picked explicitly for running on CPU. I ran the embedder first, and it turned the control problem from an argument into a measurement.&lt;/p&gt;

&lt;p&gt;Running the same adversarial pairs against both control populations with &lt;code&gt;bge-m3&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;against low-overlap paraphrases: &lt;strong&gt;AUC 0.3556&lt;/strong&gt;, p=0.4376&lt;/li&gt;
&lt;li&gt;against matched-overlap paraphrases: &lt;strong&gt;AUC 0.9333&lt;/strong&gt;, p=0.0070&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same embedder, same negations, same nine rejects. The only thing that changed was how many words the accepted pairs shared with their twins, and the reading went from &lt;em&gt;no usable information at this sample size&lt;/em&gt; to &lt;em&gt;the ordering is right&lt;/em&gt; — which is emphatically not the same as &lt;em&gt;the cache works&lt;/em&gt;, and I'll come back to that in a moment.&lt;/p&gt;

&lt;p&gt;My original headline rested on the uncontrolled version.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers that survive the control
&lt;/h2&gt;

&lt;p&gt;Primary contrast, matched overlap, n=5 accepts against n=9 rejects. The p-values come from enumerating every one of the 2002 ways those labels could be reassigned to the same scores — no normal approximation, no distributional assumption, just counting how often chance produces a result this extreme.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;AUC&lt;/th&gt;
&lt;th&gt;Margin&lt;/th&gt;
&lt;th&gt;p (exact)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;nomic-embed-text, Spanish&lt;/td&gt;
&lt;td&gt;0.1333&lt;/td&gt;
&lt;td&gt;−0.1017&lt;/td&gt;
&lt;td&gt;0.0290&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bge-m3, Spanish&lt;/td&gt;
&lt;td&gt;0.9333&lt;/td&gt;
&lt;td&gt;−0.0086&lt;/td&gt;
&lt;td&gt;0.0070&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nomic-embed-text, English&lt;/td&gt;
&lt;td&gt;0.4444&lt;/td&gt;
&lt;td&gt;−0.0707&lt;/td&gt;
&lt;td&gt;0.7972&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three different readings, and the discipline is in not collapsing them:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nomic in Spanish is inverted&lt;/strong&gt;, not merely blind. An adversarial pair outranks a genuine paraphrase most of the time. The error-minimising threshold is the degenerate one that accepts nothing — the optimal cache configuration is no cache. I'd rather lean on the margin of −0.1017 than on a p-value of 0.0290 that sits close to the line. Drop any single pair from the set and recompute, and the margin keeps its sign every time; the p-value is one borderline observation away from moving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nomic in English doesn't reject the null.&lt;/strong&gt; The honest statement is that the score carries no usable information in this contrast — not that English performs better, which the data doesn't support. Negation similarity averages 0.9520 across the nine translated pairs against 0.9821 for the same pairs in Spanish: lower, and still nowhere near low enough for a cut point to fit between the negations and the paraphrases. Whatever this is, it isn't about Spanish. I retracted that explanation from the original report.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;bge-m3 is undetermined&lt;/strong&gt;, and I can say how undetermined. It orders correctly, and it cleanly resolves the temporal and entity distinctions that nomic couldn't. But the margin is negative, and dropping one single pair flips its sign. Ordering well and cutting well are different problems; a cache needs a cut point, not a ranking. "We don't know" is the right answer here, and it took building the tooling to be able to say it precisely.&lt;/p&gt;

&lt;h3&gt;
  
  
  The row that isn't in the table
&lt;/h3&gt;

&lt;p&gt;There is a fourth number, and it is the best one I have.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;unmatched&lt;/em&gt; contrast for nomic — the same nine negations, scored against the original low-overlap paraphrases — gives &lt;strong&gt;AUC 0.0000 at p=0.0010&lt;/strong&gt;. Perfect inversion: every single adversarial pair outranks every single paraphrase. It is the lowest p-value anywhere in the run, sitting at the floor of what an exact test on fourteen pairs can report at all.&lt;/p&gt;

&lt;p&gt;It is also the confounded one. It is the row my original report rested on, and it is the row this section exists to retract.&lt;/p&gt;

&lt;p&gt;Leaving it out of the table is deliberate. Printed alongside three controlled contrasts it would read as the strongest of four findings, when what it actually measures is lexical distance wearing the costume of meaning. The number is in the artefacts, with the script that produced it, for anyone who wants to check that I'm characterising it fairly. It just isn't evidence for the thing I originally said it was evidence for — and a striking number that answers the wrong question is worse than no number, because it stops you looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  A prediction that failed
&lt;/h2&gt;

&lt;p&gt;The reranker was the other half of Giulio's recommendation, and the reason to try it is real: a cross-encoder scores the two texts jointly instead of comparing two embeddings computed in isolation, so it can see the token that inverts the meaning rather than averaging it away. A confirmation step before serving a cache hit is the obvious place to put that.&lt;/p&gt;

&lt;p&gt;He also drew the boundary around his own suggestion. In his reranking benchmark, &lt;code&gt;ms-marco-MiniLM-L-6-v2&lt;/code&gt; improved recall@100 without converting that gain into top-5 — better at dragging the right candidate into the pool than at deciding which one wins. I tested it knowing that, which is the only reason a negative result was worth the run at all: &lt;strong&gt;negative&lt;/strong&gt;, and significantly inverted in English.&lt;/p&gt;

&lt;p&gt;I then predicted, in writing and before measuring, that &lt;code&gt;BAAI/bge-reranker-v2-m3&lt;/code&gt; would be &lt;strong&gt;more&lt;/strong&gt; inverted, since it trains on the same relevance objective. It isn't. AUC 0.7667 in Spanish and 0.8222 in English, against MiniLM's 0.3333 and 0.0667 — wrong side of 0.50 in both languages. The prediction failed cleanly.&lt;/p&gt;

&lt;p&gt;What I can't claim is that BGE works. Neither Spanish result reaches significance, English lands at p=0.0599, and its best available threshold in Spanish still costs 3 errors out of 14 — one false hit and two of five legitimate hits thrown away; in English, 2 out of 14. Registering the prediction beforehand is what makes the failure worth reporting; a prediction written after seeing the result isn't one.&lt;/p&gt;

&lt;p&gt;For anyone weighing the cost: 571.1 ms per pair on CPU against MiniLM's 36.4, about 15.7×. Cost was never the obstacle. Signal was.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake three: the literature had this in 2021
&lt;/h2&gt;

&lt;p&gt;I designed and ran five versions of this experiment without a literature review. I did the review afterwards, and it changes what I can claim.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aclanthology.org/2024.eacl-long.139/" rel="noopener noreferrer"&gt;NevIR&lt;/a&gt; (EACL 2024) builds essentially my experiment at scale: it asks retrieval models to rank two documents that differ only by negation, and finds that most models — including state-of-the-art ones — perform at or below random ranking, with cross-encoders barely above chance and bi-encoders below it. My nomic result is a bi-encoder below chance, and my two cross-encoders land on opposite sides of chance, which is what you'd expect from a signal hovering around it.&lt;/p&gt;

&lt;p&gt;Consistent with, not confirmed by. NevIR ranks two documents against one query; I'm asking whether two queries share an answer. Those aren't the same task, and fourteen pairs can't confirm anything about a paper's benchmark.&lt;/p&gt;

&lt;p&gt;A &lt;a href="https://arxiv.org/abs/2110.15708" rel="noopener noreferrer"&gt;2021 biomedical paper&lt;/a&gt; found the inversion itself: across every model tested, mean cosine similarity for negation and antonym subsets ran &lt;em&gt;higher&lt;/em&gt; than for sentence pairs human experts rated as highly similar. That's my finding, in another domain, five years earlier.&lt;/p&gt;

&lt;p&gt;And &lt;a href="https://arxiv.org/abs/2306.05083" rel="noopener noreferrer"&gt;HEROS&lt;/a&gt; offers a candidate for the mechanism I went looking for and didn't find: encoders fine-tuned on paraphrase datasets with contrastive learning come out highly sensitive to negation, while fine-tuning only on question-answer pairs leaves a model insensitive to it. On that account the training objective predicts the failure — not the architecture, and not the language. &lt;code&gt;nomic-embed-text&lt;/code&gt; trains mostly on retrieval pairs, which puts it on the insensitive side, which is exactly where I measured it.&lt;/p&gt;

&lt;p&gt;That's a tidy story and I haven't earned it, so I wrote down what would falsify it before running anything further: an NLI-tuned encoder should &lt;strong&gt;separate&lt;/strong&gt; the primary contrast where nomic doesn't, AUC materially above 0.50 instead of 0.1333. If it doesn't separate, HEROS doesn't explain this case and the cause is still open. That prediction is dated, unmeasured, and sitting in the report where anyone can hold me to it.&lt;/p&gt;

&lt;p&gt;So the phenomenon isn't mine. What none of these papers covers is the operating context I opened with: they measure ranking. Ranking failures are visible and recoverable. A cache hit is neither.&lt;/p&gt;

&lt;p&gt;Three corrections, then, and not one of them moved the decision. Two pairs left the negation set; the control was rebuilt from scratch, and the same rebuilt corpus took my Spanish explanation down with it; the literature review shrank what was left to what nobody had published already — and the cache is disabled today for the same reason it was disabled on day one. That distinction is the whole point: the method was wrong and the answer was right, and I only know which was which because I measured it again instead of defending it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What survived
&lt;/h2&gt;

&lt;p&gt;The cache stays disabled by default, and the evidence for that is stronger now than when I published it, not weaker.&lt;/p&gt;

&lt;p&gt;What changed is everything about how much I can claim. Not "cosine similarity fails at negation" — that was known. Just: with lexical overlap controlled, across three scopes, no usable cut point appeared, and in one of them the score was actively misleading.&lt;/p&gt;

&lt;p&gt;If you're about to add a semantic cache to a retrieval system, the thing I'd take from this isn't the finding. It's that a control which shares no words with the thing it controls for isn't a control. Mine looked like evidence right up to the day after I published it. It was measuring string distance, and I only found out because &lt;a href="https://dev.to/gde03/comment/3c9ni"&gt;Giulio&lt;/a&gt; asked a harder question about my own numbers than I had asked myself.&lt;/p&gt;

&lt;p&gt;The pairs, the scripts, the raw scores and the erratum are in &lt;a href="https://github.com/psychohub/rag-onpremise/tree/main/docs/experiments" rel="noopener noreferrer"&gt;&lt;code&gt;docs/experiments/&lt;/code&gt;&lt;/a&gt;. The JSON holds the similarities, so the reanalysis runs without Ollama and without repeating a single embedding.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is a personal open-source project. It describes no institutional deployment and uses no data from any production system. Written in Spanish, self-translated, with Claude as a language editor; the experiments, the measurements and the mistakes are mine.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>ai</category>
      <category>machinelearning</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>On-premise RAG without GPU, cloud, or Docker: five lessons that cost me a week each</title>
      <dc:creator>Hubert García Gordon</dc:creator>
      <pubDate>Sat, 01 Aug 2026 03:10:27 +0000</pubDate>
      <link>https://dev.to/hubertgarcia/on-premise-rag-without-gpu-cloud-or-docker-five-lessons-that-cost-me-a-week-each-3bjm</link>
      <guid>https://dev.to/hubertgarcia/on-premise-rag-without-gpu-cloud-or-docker-five-lessons-that-cost-me-a-week-each-3bjm</guid>
      <description>&lt;p&gt;Every RAG tutorial I've read makes the same two assumptions: you have a GPU, and you can call a cloud API. For the environments I build for, both assumptions are wrong.&lt;/p&gt;

&lt;p&gt;I work on health information systems in the public sector. The stack has to run inside institutional infrastructure — no data leaves the network — and the hardware I get is whatever the procurement cycle produced two years ago. In practice that means Windows Server, CPU only, and open-weight models running locally.&lt;/p&gt;

&lt;p&gt;So I built a RAG stack that runs entirely on-premise, no GPU, no cloud, no Docker. It's open source at &lt;a href="https://github.com/psychohub/rag-onpremise" rel="noopener noreferrer"&gt;github.com/psychohub/rag-onpremise&lt;/a&gt;: ASP.NET Core 9 for orchestration, Ollama for local inference, Qdrant for vectors, Python for the ingest pipeline, Mistral 7B as the LLM, &lt;code&gt;nomic-embed-text&lt;/code&gt; for embeddings.&lt;/p&gt;

&lt;p&gt;Getting it into production took longer than the design did, because five things broke that no tutorial had warned me about. This is the field report.&lt;/p&gt;

&lt;h2&gt;
  
  
  The environment, and why it matters
&lt;/h2&gt;

&lt;p&gt;Before the lessons, it's worth being precise about the constraint, because it changes what "good" looks like.&lt;/p&gt;

&lt;p&gt;The stack has to run on a Windows Server, not a Linux workstation. Docker is not available on many of the target machines — either because it wasn't approved, because GPO policies restrict it, or because ops teams already run everything as Windows services and adding a container runtime is a new operational surface nobody wants to own. GPUs are aspirational. In the meantime, you have CPU inference and you have to make it work.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It's the default reality in a lot of public sector, healthcare, and legacy enterprise environments. It's also the reality most RAG content on the internet quietly assumes away.&lt;/p&gt;

&lt;p&gt;The overall shape of the system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Documents (PDF / Word / Excel)
    │
    ▼
[ Python ingest ]
    ├─ Text extraction  (pdfplumber, python-docx, openpyxl)
    ├─ Chunking         (500 tokens, 50 overlap)
    ├─ Embeddings       (nomic-embed-text via Ollama)
    └─ Store            (Qdrant, cosine similarity)
                                                      │
User query                                            │
    │                                                 │
    ▼                                                 │
[ ASP.NET Core 9 API ]  ────────────────────────────  ┘
    ├─ 1. Embed the question
    ├─ 2. Retrieve top-K chunks from Qdrant
    ├─ 3. Assemble prompt with context
    ├─ 4. Call Mistral 7B via Ollama
    └─ 5. Return answer + cited sources
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Lesson 1: The Qdrant .NET SDK uses gRPC. Use REST directly.
&lt;/h2&gt;

&lt;p&gt;The first thing I tried was the official Qdrant SDK for .NET. Clean API, well documented, felt like the right choice. It also failed in a way that took me a day to diagnose, because the failure wasn't obvious: connections were being established, then dropped, with error messages that pointed at everything except the actual cause.&lt;/p&gt;

&lt;p&gt;The cause: the SDK talks to Qdrant over gRPC, and the network path between the .NET application and the Qdrant instance was HTTP/1.1 only. gRPC needs HTTP/2. Some intermediate proxy or load balancer downgraded the connection, and the SDK didn't degrade gracefully — it just failed.&lt;/p&gt;

&lt;p&gt;The fix was to skip the SDK entirely and talk to Qdrant's REST API directly with &lt;code&gt;HttpClient&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Not this — the SDK uses gRPC under the hood&lt;/span&gt;
&lt;span class="c1"&gt;// var client = new QdrantClient(new Uri(url));&lt;/span&gt;

&lt;span class="c1"&gt;// This — plain REST works everywhere&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_httpClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PostAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;$"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;qdrantUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/collections/&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/points/search"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Qdrant's REST API is complete enough for RAG workloads. You lose type safety and some ergonomics; you gain the ability to deploy without arguing about HTTP/2 support at every network hop. In a corporate or public sector network, that's a good trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 2: The default HttpClient timeout will kill your responses.
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;HttpClient&lt;/code&gt; in .NET defaults to a 100-second timeout. That's fine for most HTTP work. It is not fine when the thing on the other end is Mistral 7B running on CPU.&lt;/p&gt;

&lt;p&gt;On a modest server — 4 vCPU, 16 GB RAM — Mistral 7B takes between 60 and 120 seconds to produce a full response. The first time I ran an end-to-end query, it worked. The second time it worked. The third time the model happened to generate a longer answer and the client timed out, mid-stream, leaving the user staring at a generic error while the server continued generating an answer nobody would ever see.&lt;/p&gt;

&lt;p&gt;The fix is two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Not this — 100s default, will cut you off&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;HttpClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// This — set the ceiling explicitly, above your worst-case&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="n"&gt;HttpClient&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;300&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number itself matters less than the discipline. If you're calling a local LLM on CPU, measure the worst case on your actual hardware, and set the timeout comfortably above it. And if you're building a UI on top of this, put a progress indicator. Ninety seconds of silence looks like a broken system, even when it's working exactly as designed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 3: Ollama only listens on localhost by default.
&lt;/h2&gt;

&lt;p&gt;This one I discovered when I moved from developing on my laptop to deploying on the server, and the .NET application on a different machine couldn't reach Ollama.&lt;/p&gt;

&lt;p&gt;Ollama, out of the box, binds to &lt;code&gt;127.0.0.1:11434&lt;/code&gt;. Fine for local development. Useless for any deployment where the LLM host is separate from the application host, or even where the application runs under a service account that doesn't share the loopback context with the interactive user.&lt;/p&gt;

&lt;p&gt;The fix is an environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;OLLAMA_HOST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0:11434"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;ollama&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;serve&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is simple, once you know. The trap is that Ollama's error messages when it's unreachable are generic connection errors, not "hey, I'm only listening on loopback." I spent an afternoon reading firewall rules before I checked the binding.&lt;/p&gt;

&lt;p&gt;If you're deploying Ollama as a Windows service — which you probably should — that environment variable needs to be set at service level, not user level. Setting it in a PowerShell prompt won't affect the service. Small detail, real time cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 4: The Python MSI installer fails under corporate GPO. Use the embeddable package.
&lt;/h2&gt;

&lt;p&gt;The ingest pipeline is Python. On a locked-down Windows Server with corporate Group Policy Objects controlling what installers can run, the standard Python MSI would not install. It failed in ways that ranged from silent "operation completed" with nothing on disk, to loud errors about elevation that the actual admin account couldn't resolve either.&lt;/p&gt;

&lt;p&gt;The fix, which is not obvious the first time: use the &lt;strong&gt;embeddable Python package&lt;/strong&gt;. It's a ZIP file, not an installer, so it sidesteps most of the GPO surface.&lt;/p&gt;

&lt;p&gt;The setup is a little more manual than the installer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Download &lt;code&gt;python-3.x.x-amd64-embed.zip&lt;/code&gt; from python.org.&lt;/li&gt;
&lt;li&gt;Extract to a folder — &lt;code&gt;C:\Python311\&lt;/code&gt;, wherever.&lt;/li&gt;
&lt;li&gt;In that folder, open &lt;code&gt;python3xx._pth&lt;/code&gt; and uncomment the &lt;code&gt;import site&lt;/code&gt; line. Without this, &lt;code&gt;pip&lt;/code&gt; won't work.&lt;/li&gt;
&lt;li&gt;Download &lt;code&gt;get-pip.py&lt;/code&gt; and run &lt;code&gt;python get-pip.py&lt;/code&gt; from that folder.&lt;/li&gt;
&lt;li&gt;From there, &lt;code&gt;pip install -r requirements.txt&lt;/code&gt; works normally.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Nothing here is hard. It's just not documented as the default path, so if you don't know it exists you spend two days fighting an installer that will never succeed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lesson 5: The prompt is where most of the quality lives.
&lt;/h2&gt;

&lt;p&gt;I spent weeks tuning chunking, embedding parameters, and retrieval top-K, and got single-digit percentage improvements each time. Then I rewrote the prompt template and got a step-change in response quality that made all the retrieval tuning look like rounding error.&lt;/p&gt;

&lt;p&gt;The two failure modes I kept oscillating between:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Too restrictive.&lt;/strong&gt; "Answer ONLY from the context. If the context does not contain the answer, say you don't know." The model became allergic to context. It would refuse to answer questions that were partially covered, refuse to make reasonable inferences, and pepper the user with "I don't know" for questions any human reading the same documents could answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Too permissive.&lt;/strong&gt; "Use the context to help you answer the question." The model started hallucinating confidently, filling in gaps in the retrieved chunks with plausible-sounding invention. In a regulated environment, that's not a quality problem. It's a liability.&lt;/p&gt;

&lt;p&gt;What ended up working, roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Answer BASED on the provided context.
If the information is partially relevant, use it and be explicit
about what the context does and does not say.
Only if there is absolutely nothing related to the question,
say so clearly.
Do NOT invent data that is not in the context.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The keywords that mattered were "partially relevant" (permission to reason from incomplete context) and "be explicit about what the context does and does not say" (forcing the model to distinguish what it read from what it inferred). Neither is a magic incantation. But together they moved the balance from "refuses to answer" and "makes things up" to "answers when it can, defers when it can't, and tells you which."&lt;/p&gt;

&lt;h2&gt;
  
  
  What CPU inference actually looks like
&lt;/h2&gt;

&lt;p&gt;The other thing tutorials skip: numbers. Everything above assumes latency you can live with. Here's what I actually measured on the hardware I had:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Hardware&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Response time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4 vCPU / 16 GB RAM&lt;/td&gt;
&lt;td&gt;Mistral 7B&lt;/td&gt;
&lt;td&gt;60–120 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16 vCPU / 32 GB RAM&lt;/td&gt;
&lt;td&gt;Mistral 7B&lt;/td&gt;
&lt;td&gt;20–45 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4 vCPU / 8 GB RAM&lt;/td&gt;
&lt;td&gt;phi3:mini&lt;/td&gt;
&lt;td&gt;15–30 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU 8 GB+&lt;/td&gt;
&lt;td&gt;Mistral 7B&lt;/td&gt;
&lt;td&gt;3–8 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;The CPU rows are sustained measurements on the servers I actually deploy on. The GPU row is from a single test on borrowed hardware, not sustained production measurements — take that one as a reference point, not a promise.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Two things worth calling out. First, &lt;code&gt;phi3:mini&lt;/code&gt; on modest hardware is competitive with Mistral 7B on much better hardware, for latency. If your quality bar allows it, downgrade the model before you upgrade the hardware. Second, the jump from CPU to GPU is roughly 10×. If you can get one 8 GB GPU into your environment, do it — it changes what interactions are possible.&lt;/p&gt;

&lt;p&gt;Because CPU latency is what it is, the repo includes a &lt;strong&gt;semantic cache&lt;/strong&gt; in front of the LLM: cosine similarity between the incoming query and cached queries, with a threshold of 0.92. When a user asks something semantically close to a previous query, they get the cached answer in under a second. When they ask something new, they wait for the model. On a moderately busy internal system, cache hit rates got high enough that the average user experience felt reasonable, even though the worst case was still ninety seconds.&lt;/p&gt;

&lt;p&gt;One warning I learned by breaking it: &lt;strong&gt;clear the cache when you change the LLM&lt;/strong&gt;. Cached answers are pinned to whoever generated them. When you swap Mistral for a newer model, the cache is now returning answers from a model you're no longer running, and users will notice the personality change before you do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone starting today
&lt;/h2&gt;

&lt;p&gt;If you're building on-premise RAG on constrained hardware, the compressed version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Talk to Qdrant over REST, not gRPC.&lt;/strong&gt; Fewer surprises on corporate networks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set your HTTP timeouts explicitly.&lt;/strong&gt; The defaults were designed for web traffic, not local LLMs on CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure Ollama's binding for your deployment, not your laptop.&lt;/strong&gt; And set the environment variable at service level if you're running it as a service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use Python's embeddable package on locked-down Windows.&lt;/strong&gt; The MSI is not your friend under GPO.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune the prompt before the retrieval.&lt;/strong&gt; Chunking and top-K matter, but the prompt is where the quality bar actually sits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache aggressively when your LLM is slow, and remember to invalidate when you change models.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downgrade the model before upgrading the hardware.&lt;/strong&gt; &lt;code&gt;phi3:mini&lt;/code&gt; on 8 GB RAM beats Mistral 7B on a machine you can't afford.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic. It's the part of RAG that gets skipped when the tutorial assumes GPU, cloud, and a Linux dev box. When you don't have any of those, this is the reality you build against.&lt;/p&gt;

&lt;p&gt;The next thing on my roadmap is proper embedding evaluation on Spanish clinical text — because "it works" and "it works well in your language on your corpus" are not the same thing, and I haven't measured the gap yet. That's the next article.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article describes the design and implementation of my personal open-source project &lt;a href="https://github.com/psychohub/rag-onpremise" rel="noopener noreferrer"&gt;rag-onpremise&lt;/a&gt;. The measurements are from my own test hardware and my own project, not from any specific institutional deployment. The views expressed here are my own.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Hubert García Gordon works on health information systems in the Costa Rican public sector and teaches at UNED Costa Rica. He maintains rag-onpremise and writes about applied AI in constrained environments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>dotnet</category>
      <category>ai</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
