<?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: Klaus Byskov Pedersen</title>
    <description>The latest articles on DEV Community by Klaus Byskov Pedersen (@klausbyskov).</description>
    <link>https://dev.to/klausbyskov</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%2F4164835%2F255578e4-b80f-4fe7-a4e9-f1aae337899d.jpg</url>
      <title>DEV Community: Klaus Byskov Pedersen</title>
      <link>https://dev.to/klausbyskov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/klausbyskov"/>
    <language>en</language>
    <item>
      <title>Retrieval confidence can't tell your RAG chatbot when the answer is missing</title>
      <dc:creator>Klaus Byskov Pedersen</dc:creator>
      <pubDate>Thu, 08 Oct 2026 07:38:21 +0000</pubDate>
      <link>https://dev.to/klausbyskov/retrieval-confidence-cant-tell-your-rag-chatbot-when-the-answer-is-missing-2ml0</link>
      <guid>https://dev.to/klausbyskov/retrieval-confidence-cant-tell-your-rag-chatbot-when-the-answer-is-missing-2ml0</guid>
      <description>&lt;p&gt;&lt;em&gt;I ran 65 questions against our own knowledge base and checked which ones the retrieved text actually answered. The similarity scores overlapped too much for any threshold to work.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our own handoff docs used to describe it: if retrieval confidence falls below a threshold, hand the visitor to a human instead of generating a weak answer. The default was 0.4. It's the design most people building a RAG chatbot reach for, and I no longer think it works. This post is about why: first a hunch from production, then a small experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  What retrieval confidence is
&lt;/h2&gt;

&lt;p&gt;In a typical RAG pipeline, including ours at Asktopus, the visitor's question is embedded (we use OpenAI's text-embedding-3-small). The vector database (Qdrant) returns the five closest chunks by cosine similarity, and those chunks go into the prompt. The best of those five scores is what usually gets called "retrieval confidence".&lt;/p&gt;

&lt;p&gt;It's free, already computed and between 0 and 1, so it looks like the system telling you how sure it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tempting design
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;hand_off_to_human&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# or reply "I don't know"
&lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;answer_from_context&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It promises two things at once: no made-up answers when the knowledge is thin, and a human for the questions the bot can't handle. All you have to do is pick the number.&lt;/p&gt;

&lt;h2&gt;
  
  
  What first made me doubt it
&lt;/h2&gt;

&lt;p&gt;In production, the scores of 101 real answers on one site ranged from 0.11 to 0.67, with a median of 0.36. At the 0.4 our docs recommended, more than half of them would have gone to a human. I couldn't tell from the numbers which of those answers were good, and 101 answers on one site is far too few to tune a threshold on. So I set up a test where I could check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment
&lt;/h2&gt;

&lt;p&gt;The knowledge base is asktopus.com itself: 44 crawled pages in English and Danish, split into 219 chunks of up to 1,500 characters. The pipeline is exactly production's: same embedding model, same Qdrant search, top 5, confidence = the best score.&lt;/p&gt;

&lt;p&gt;I wrote 65 questions: 40 I expected the site to answer, 20 I expected it not to (a Zapier integration, ISO 27001, an uptime SLA), and 5 plainly off-topic (the capital of France, tomorrow's weather). Then I set my expectations aside and labelled each question by reading the five chunks the model would receive. Does this text contain the answer? &lt;strong&gt;Yes&lt;/strong&gt;, &lt;strong&gt;partial&lt;/strong&gt; (it answers a narrower version: conversations can be exported, but the format isn't stated), or &lt;strong&gt;no&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;My expectations were off. Of the 40 questions I expected the site to answer, only 24 got retrieved text that answered them; 6 were partial and 10 got nothing useful. Three of those ten were retrieval misses: the answer was in the knowledge base, just not in the top five. All 25 questions I expected it not to answer got a no.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Label&lt;/th&gt;
&lt;th&gt;Questions&lt;/th&gt;
&lt;th&gt;Min&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;th&gt;Max&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;0.309&lt;/td&gt;
&lt;td&gt;0.499&lt;/td&gt;
&lt;td&gt;0.708&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0.331&lt;/td&gt;
&lt;td&gt;0.379&lt;/td&gt;
&lt;td&gt;0.582&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;35&lt;/td&gt;
&lt;td&gt;0.102&lt;/td&gt;
&lt;td&gt;0.345&lt;/td&gt;
&lt;td&gt;0.590&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The score isn't noise: answerable questions score higher on average. But the two groups overlap from 0.31 to 0.59, and that band holds 19 of the 24 answerable questions, 23 of the 35 unanswerable ones and all six partials. Clean answers exist only at the edges. The 12 questions below 0.31 were all unanswerable, four of them off-topic. The five above 0.59 were all answerable.&lt;/p&gt;

&lt;p&gt;Here's what each threshold would do if you hand off whenever confidence is below it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threshold&lt;/th&gt;
&lt;th&gt;Answerable, handed off (of 24)&lt;/th&gt;
&lt;th&gt;Unanswerable, let through (of 35)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.30&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;25&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.35&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.40&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.45&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There's no good row. At 0.40, 14 questions the retrieved text doesn't answer still go through, and 4 of the 6 partial ones are handed off. At 0.50, half of the answerable questions go to a human. The best single cut on this data, found after the fact, is about 0.43, and it still gets 11 of 59 wrong. I found it by looking at labels you won't have in production, and it doesn't travel: at 0.43, more than half of those 101 production answers would have gone to a human.&lt;/p&gt;

&lt;p&gt;Two questions show the problem better than the tables.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The highest-scoring "no":&lt;/strong&gt; "How many employees does Asktopus have?" scored 0.590. The site doesn't say. The top chunk was the features text about inviting colleagues as editors or viewers, which is about the customer's team, not ours. That score beats 19 of the 24 answerable questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lowest-scoring "yes":&lt;/strong&gt; "How long does it take to set up?" scored 0.309. The best match was a homepage chunk that ends with the FAQ question "How fast can I get started?". The answer to it falls in the next chunk, which wasn't retrieved. The answer came from the installation guide in second place: "up and running on your website in under 5 minutes". 22 of the 30 on-topic unanswerable questions scored higher.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;An embedding captures what a text is about. Cosine similarity tells you the question and a chunk are about the same thing, not that the chunk contains the fact the question asks for. "How many employees does Asktopus have?" is about Asktopus and teams, and so is the features page. That's all the score can see.&lt;/p&gt;

&lt;p&gt;The clearest pattern in the data: 30 of the unanswerable questions were on-topic. The 14 of them that use the product's own words ("Asktopus", "chatbot", "assistant", "widget", "AI") had a median score of 0.498, the same as the answerable questions. The 16 that don't had a median of 0.307. On a site about AI chatbots, any question about AI chatbots is close to everything.&lt;/p&gt;

&lt;p&gt;What's inside the chunks makes it worse in both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chunks that mix many topics score moderately for many questions.&lt;/strong&gt; In this crawl, navigation, page headers and footers ended up in the chunks, and the docs navigation lists the title of every docs page. "Can I remove the Asktopus branding from the chat?" scored 0.507 with five chunks that were all page headers and navigation. The line that answers it, "Remove branding" under the Pro plan, was in the knowledge base but wasn't retrieved.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same mixing dilutes real answers.&lt;/strong&gt; "Can I upload PDF documents?" scored 0.364 although the top chunk answers it almost word for word. That chunk is a homepage FAQ that also covers setup time, wrong answers, languages and GDPR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short questions carry little signal.&lt;/strong&gt; In a five- or six-word question, one shared word like the product name carries a lot of weight.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;p&gt;This is a small test. One small site, one crawl (partly out of date), 65 questions I wrote myself in English, labelled by my own judgement. Different content, languages or chunking would move the numbers. I also measured retrieval, not answers: I didn't test how reliably the model says "I don't know" when the chunks don't contain the answer. But nothing in the mechanism is specific to this site, and the production numbers point the same way.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do instead
&lt;/h2&gt;

&lt;p&gt;In Asktopus, the score doesn't make any decision about the conversation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The model decides whether the answer is there, and says so in a way the code can read.&lt;/strong&gt; The prompt tells it to answer only from the retrieved context. When the context doesn't contain the answer, it starts its reply with a fixed marker, which we strip from the stream before the visitor sees it. The model reads the chunks; the score only measures how close they are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;That signal, not a score, offers a person.&lt;/strong&gt; Under an answer the assistant marked as missing, the widget shows a "Talk to a person" button. The same button is always in the header, and explicit requests like "talk to a human" open the same contact form.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visitors rate answers.&lt;/strong&gt; A 👍 or 👎 under every answer is a second signal that costs nothing to collect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing answers go on a list to review, never trigger anything else.&lt;/strong&gt; The dashboard's "Questions to answer" list shows questions whose newest asking the model marked as missing, a visitor rated 👎, or scored very low (below 0.25). The site owner can write an article that answers one, or hide it, and a question drops off by itself once its next asking gets a real answer. In this experiment, the 0.25 cut alone flagged 6 questions, all of them unanswered, and missed the other 29. That's fine as one input to a review list and useless as a gate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a first live check on our own site, the marker flagged all three questions the site doesn't cover (one of them in Danish) and neither a pricing question it does cover nor a plain "Hi there!". Five questions prove nothing statistically, but the signal comes from something that reads the text, which is the point.&lt;/p&gt;

&lt;p&gt;We also stopped using confidence to pick the model. We used to send low-confidence questions to the bigger one, but a bigger model can't answer from content it wasn't given.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways if you're building RAG
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't gate handoffs or "I don't know" on a similarity threshold. Answerable and unanswerable questions overlap across exactly the range where you'd put it.&lt;/li&gt;
&lt;li&gt;Let the model judge whether the retrieved text answers the question, and have it say so in a form your code can read, such as a marker you strip from the stream. It reads the text; the score doesn't.&lt;/li&gt;
&lt;li&gt;Give people an explicit way to reach a person. Asking is a better signal than any score.&lt;/li&gt;
&lt;li&gt;Use low scores as a weak signal for a person to review, where a miss costs nothing.&lt;/li&gt;
&lt;li&gt;Label a few dozen of your own questions before you trust any threshold. An afternoon of reading top-5 chunks tells you more than the scores do.&lt;/li&gt;
&lt;li&gt;Keep navigation and footers out of your chunks. In our crawl they matched questions they couldn't answer. We now drop lines that repeat across most of a site's pages before chunking: on our site that removed 27% of the text, and the top-5 results that were mostly menus and footers fell from 69 of 325 to 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Asktopus, the chatbot this came out of, is at &lt;a href="https://asktopus.com" rel="noopener noreferrer"&gt;asktopus.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>rag</category>
    </item>
    <item>
      <title>I built a harvest tripwire, then almost let an LLM decide who looks legit</title>
      <dc:creator>Klaus Byskov Pedersen</dc:creator>
      <pubDate>Mon, 05 Oct 2026 20:32:15 +0000</pubDate>
      <link>https://dev.to/klausbyskov/i-built-a-harvest-tripwire-then-almost-let-an-llm-decide-who-looks-legit-190j</link>
      <guid>https://dev.to/klausbyskov/i-built-a-harvest-tripwire-then-almost-let-an-llm-decide-who-looks-legit-190j</guid>
      <description>&lt;p&gt;Free API tiers get farmed. That is not news. What surprised me was how much the farming looked like real evaluation, and how badly a model did at telling the two apart.&lt;/p&gt;

&lt;p&gt;This is a Companydata story: Danish company registry data over HTTP, a free monthly quota, and a tripwire that throttles keys that suddenly look up hundreds of different companies in an hour.&lt;/p&gt;

&lt;p&gt;If you run a free or freemium API, I want your war stories in the comments. I am still deciding whether to pool free quota by IP after a burn. How did you solve the "looks like a tester, is actually rotation" problem without nuking real evaluators?&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom that felt wrong
&lt;/h2&gt;

&lt;p&gt;My harvest tripwire was doing its job: when a free key tore through a wide set of company lookups in a short window, I auto-flagged it and slowed it to one request per minute. Alerts came into Telegram. Several looked like ordinary builders testing an integration. I vouched a few of them by hand.&lt;/p&gt;

&lt;p&gt;That felt expensive. Every false positive is a conversion you might have killed with politeness. So the tempting next step showed up fast: put a model on the alert and ask, "does this look like a legit user?"&lt;/p&gt;

&lt;p&gt;I had already been evaluating &lt;a href="https://github.com/typesafe-systems/jev" rel="noopener noreferrer"&gt;Jev&lt;/a&gt; (TypeSafe System One) in another project. Identity-only signals, a handful of typed questions, cheap tokens. Perfect candidate for a quick legitimacy gate. Or so it seemed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the data actually showed
&lt;/h2&gt;

&lt;p&gt;Three "testers" in a couple of days were not three testers. Same egress network, same curl client, same habit of burning the free quota to the exact limit, then opening a fresh account. Real people, real company, still 1,500 free calls in 48 hours by one client.&lt;/p&gt;

&lt;p&gt;Quota emails were opened. Pricing was known. A free re-signup was cheaper than a small credit pack. Information was not the bottleneck. Friction on the free path was.&lt;/p&gt;

&lt;p&gt;That finding mattered more than any model score. The tripwire was not wrong about the pattern. The human vouching step was wrong about independence of accounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  I ran Jev anyway
&lt;/h2&gt;

&lt;p&gt;I still ran the experiment. Twenty-seven accounts that already had an abuse field: seven I had vouched as legit, twenty from an earlier abusive ring. Identity-only state into Jev.&lt;/p&gt;

&lt;p&gt;Jev found every vouched account when I asked for a hard "legit" verdict. It also waved through several of the ring. Probability scores sat in a mushy band for both classes. No useful threshold. When I added behaviour, Jev flipped most of the vouched accounts to abusive, which, given the rotating-quota story, was closer to the truth and also useless as a gate for "please don't annoy real customers."&lt;/p&gt;

&lt;p&gt;A dumb rule score on identity (Google signup, non-freemail domain, name shape, and so on) separated the old ring from clean-looking identities, and would have waved the rotating-quota trio straight through. Identity is context for a human on-call, not a gate.&lt;/p&gt;

&lt;p&gt;Decision: Jev stays out of the tripwire. Same conclusion I reached with Jev on another product evaluation earlier that month.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I shipped instead
&lt;/h2&gt;

&lt;p&gt;Two boring, high-leverage changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Related accounts on the admin alert.&lt;/strong&gt; When a key trips, the Telegram message now lists other accounts that share the client address or the exact display name, with usage and state. Admin group only. Nothing about other users ever goes into a user-facing response. The first live run immediately taught me to exclude my own frontend service identity, which had been logging anonymous page views under the visitor's address and therefore headed every related list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An honest 429.&lt;/strong&gt; A flagged key used to get the same "Rate limit exceeded" body as an ordinary minute limit. Scripts backed off and nobody wrote in. Flagged keys now send &lt;code&gt;X-RateLimit-Reason: under-review&lt;/code&gt; and a body that says I am reviewing a free key that looked up an unusual number of companies, that nothing is blocked forever, and how to buy credits or mail support. Ordinary minute-limit responses are unchanged.&lt;/p&gt;

&lt;p&gt;I deliberately did not auto-withhold a new free quota from an address that already exhausted one this month. That is a product call I want to make after living with the better alerts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone building the same thing
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Harvest detection on breadth and velocity works. The failure mode is the human story you tell yourself about who is behind the key.&lt;/li&gt;
&lt;li&gt;Account linking for operators beats clever legitimacy scoring for users.&lt;/li&gt;
&lt;li&gt;If you throttle someone, say so in the response. Silent identical 429s train both scrapers and real integrators to shrug.&lt;/li&gt;
&lt;li&gt;Try the model if you want the data. I named Jev here because people ask. For this problem it did not earn a place in the critical path.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;If you have shipped something similar (Stripe-style fingerprinting, shared free pools, device attestation, payment before second account, soft CAPTCHA on signup bursts, or something weirder), drop it below. Especially useful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What signal actually caught rotation without false-flagging agencies and shared offices?&lt;/li&gt;
&lt;li&gt;Did you ever put an LLM on abuse triage, and did it earn its keep?&lt;/li&gt;
&lt;li&gt;How do you talk to a real evaluator who tripped the wire without sounding like a bank fraud team?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Companydata is live at &lt;a href="https://companydata.dk" rel="noopener noreferrer"&gt;companydata.dk&lt;/a&gt;. The API docs cover the rate-limit header. If you are evaluating and hit the review throttle for real work, mail support and I clear it by hand.&lt;/p&gt;

</description>
      <category>api</category>
      <category>llm</category>
      <category>security</category>
    </item>
  </channel>
</rss>
