<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Retrieval on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/retrieval/</link><description>Recent content in Retrieval on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Thu, 04 Jun 2026 10:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/retrieval/index.xml" rel="self" type="application/rss+xml"/><item><title>Function calling and tool-augmented retrieval: the detective who knows which file to ask for</title><link>https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/</link><pubDate>Thu, 04 Jun 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/</guid><description>&lt;blockquote>
&lt;p>This post goes deeper into the agentic reasoning mechanism that extends the RAG described in &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">RAG with reranker and hybrid retrieval&lt;/a>. The retriever invoked when the LLM chooses &lt;code>vector_search&lt;/code> is exactly the pipeline in that article. The JSON Schema that defines each tool call is &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a> applied to the tool interface. And the agent&amp;rsquo;s requests pass through the &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">L7 inference gateway&lt;/a> before reaching the model.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A naive RAG always queries the same source. Function calling gives the LLM the ability to decide which tool to invoke (vector store, SQL, web search) according to what the query actually needs. The ReAct pattern chains those invocations in a reasoned loop until there is enough evidence. A 3-iteration pipeline with Llama-3.1-70B on on-premise hardware takes ≈ 1.1 s against the ≈ 300 ms of a single-step RAG; the gain is not in speed but in queries that a naive RAG simply cannot answer. The critical eval metric is &lt;strong>tool selection accuracy&lt;/strong>: the percentage of turns in which the model picks the right tool, measured over a synthetic eval set.&lt;/p>
&lt;h2 id="the-analogy-the-detective-who-knows-which-file-to-ask-for">The analogy: the detective who knows which file to ask for&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The detective and his sources of evidence">
&lt;style>
.db{fill:#f8f8f8;stroke:#444;stroke-width:1.4}
.dh{fill:#7aafff;stroke:#444;stroke-width:1.4}
.ds{fill:#ffd76b;stroke:#444;stroke-width:1.4}
.dg{fill:#b2e8b2;stroke:#444;stroke-width:1.4}
.dr{fill:#ffb3b3;stroke:#444;stroke-width:1.4}
.dl{font:600 13px sans-serif;fill:#222}
.dm{font:400 11px sans-serif;fill:#555}
.da{stroke:#666;stroke-width:1.5;fill:none;marker-end:url(#mda)}
.dq{stroke:#666;stroke-width:1.5;fill:none;stroke-dasharray:5 3;marker-end:url(#mda)}
&lt;/style>
&lt;defs>&lt;marker id="mda" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#666"/>&lt;/marker>&lt;/defs>
&lt;!-- Detective (LLM) in the centre -->
&lt;rect x="290" y="130" width="200" height="80" rx="8" class="dh"/>
&lt;text x="390" y="158" text-anchor="middle" class="dl">Detective (LLM)&lt;/text>
&lt;text x="390" y="176" text-anchor="middle" class="dm">reasons what evidence&lt;/text>
&lt;text x="390" y="192" text-anchor="middle" class="dm">it needs and asks for it&lt;/text>
&lt;!-- Case / Query -->
&lt;rect x="310" y="20" width="160" height="50" rx="8" class="db"/>
&lt;text x="390" y="42" text-anchor="middle" class="dl">Case (Query)&lt;/text>
&lt;text x="390" y="60" text-anchor="middle" class="dm">"how many EU contracts &amp;gt; 100k€?"&lt;/text>
&lt;path class="da" d="M390,70 L390,128"/>
&lt;!-- Vector store -->
&lt;rect x="20" y="240" width="160" height="70" rx="8" class="ds"/>
&lt;text x="100" y="263" text-anchor="middle" class="dl">Witness files&lt;/text>
&lt;text x="100" y="281" text-anchor="middle" class="dm">vector_search&lt;/text>
&lt;text x="100" y="297" text-anchor="middle" class="dm">Qdrant · 5-50 ms&lt;/text>
&lt;!-- SQL -->
&lt;rect x="310" y="240" width="160" height="70" rx="8" class="dg"/>
&lt;text x="390" y="263" text-anchor="middle" class="dl">Accounting ledger&lt;/text>
&lt;text x="390" y="281" text-anchor="middle" class="dm">sql_query&lt;/text>
&lt;text x="390" y="297" text-anchor="middle" class="dm">PostgreSQL · 10-200 ms&lt;/text>
&lt;!-- Web search -->
&lt;rect x="600" y="240" width="160" height="70" rx="8" class="dr"/>
&lt;text x="680" y="263" text-anchor="middle" class="dl">Press archive&lt;/text>
&lt;text x="680" y="281" text-anchor="middle" class="dm">web_search&lt;/text>
&lt;text x="680" y="297" text-anchor="middle" class="dm">public · 200-2000 ms&lt;/text>
&lt;!-- Arrows from the detective to the sources -->
&lt;path class="da" d="M310,190 L180,238"/>
&lt;path class="da" d="M390,210 L390,238"/>
&lt;path class="da" d="M470,190 L600,238"/>
&lt;!-- Return arrows (observations) -->
&lt;path class="dq" d="M140,240 Q200,220 300,195"/>
&lt;path class="dq" d="M390,240 L390,212"/>
&lt;path class="dq" d="M640,240 Q560,220 480,195"/>
&lt;!-- Final answer -->
&lt;rect x="310" y="20" width="160" height="50" rx="8" class="db"/>
&lt;text x="390" y="42" text-anchor="middle" class="dl">Case (Query)&lt;/text>
&lt;text x="390" y="60" text-anchor="middle" class="dm">"how many EU contracts &amp;gt; 100k€?"&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>A detective in a novel does not go to the same filing cabinet regardless of the case that lands on his desk. When he takes a case, he reasons first: what kind of evidence do I need? If there are witnesses, he asks for the statements (vector search over unstructured documents). If there are financial transactions, he asks the bank for the accounting records (SQL over the structured database). If the suspect has recent activity that the company cannot possibly have indexed, he goes to the press archive (web search). He does not consult all three sources at once on every case: he picks the one the evidence requires, receives the result, reasons again if he needs more, and only writes the report when he has enough evidence.&lt;/p>
&lt;p>A bad detective always goes to the same filing cabinet. A naive RAG is that bad detective: it vectorises the query, goes to the vector store, and returns whatever it finds even if the question was &amp;ldquo;how many contracts?&amp;rdquo;, something no PDF chunk can answer better than a &lt;code>COUNT(*)&lt;/code> in SQL.&lt;/p>
&lt;p>&lt;strong>Function calling is giving the LLM the ability to reason about which source to ask for, and to invoke it in a structured way.&lt;/strong> The analogy has three edges worth keeping in mind:&lt;/p>
&lt;ol>
&lt;li>The detective does not improvise the file he asks for: there is a catalogue of available sources with a description of what each one contains. The tool description in the system prompt does that job.&lt;/li>
&lt;li>The detective can ask for several pieces of evidence at once if they are independent (parallel tool calling).&lt;/li>
&lt;li>The detective knows when to stop: if after N rounds he reaches no conclusion, he declares that he does not have enough evidence. The agent has an iteration limit for the same reason.&lt;/li>
&lt;/ol>
&lt;h2 id="what-function-calling-is-the-anatomy-of-a-tool-call">What function calling is: the anatomy of a tool call&lt;/h2>
&lt;p>Function calling, also called tool use, is a mechanism by which the LLM, instead of generating free text as its answer, generates a structured JSON object representing a tool invocation. The system intercepts that JSON, executes the real tool, and returns the result as a message with the &lt;code>tool&lt;/code> role in the conversation.&lt;/p>
&lt;h3 id="defining-tools-in-the-system-prompt">Defining tools in the system prompt&lt;/h3>
&lt;p>Each tool is defined through a JSON Schema that specifies name, description and parameters. This JSON Schema is exactly the same mechanism described in &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>, applied here to the tool interface:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tools&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vector_search&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Search internal company documents about policies, contracts and procedures. Use when the query requires unstructured text, document context or semantic similarity.&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;parameters&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;properties&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Natural language search query&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;top_k&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;integer&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;default&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">5&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sql_query&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Query the SQL database for structured metrics, counts, aggregations and financial data. Use when the query requires exact numbers, filters, sums or joins over structured records.&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;parameters&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;properties&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Parameterized SQL query with $1, $2 placeholders&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;params&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;array&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;items&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{},&lt;/span> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Parameter values for the placeholders&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;web_search&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;description&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Search public web for real-time information, recent news or current prices. Use only when data is public and not covered by internal sources.&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;parameters&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;object&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;properties&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;required&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-life-cycle-of-a-tool-call">The life cycle of a tool call&lt;/h3>
&lt;p>When the LLM decides to invoke a tool, the message it generates in place of free text has this structure (OpenAI-compatible format, the same one vLLM supports):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;assistant&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tool_calls&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sql_query&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;{\&amp;#34;query\&amp;#34;: \&amp;#34;SELECT COUNT(*), SUM(amount) FROM contracts WHERE amount &amp;gt; $1 AND year = $2 AND provider_region = $3\&amp;#34;, \&amp;#34;params\&amp;#34;: [100000, 2025, \&amp;#34;EU\&amp;#34;]}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The system executes the tool and returns:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;tool_call_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;{\&amp;#34;count\&amp;#34;: 47, \&amp;#34;total\&amp;#34;: 8300000}&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The LLM receives that message as a continuation of the conversation and decides whether it needs more information or can generate the final answer.&lt;/p>
&lt;h3 id="support-in-oss-models">Support in OSS models&lt;/h3>
&lt;p>In 2026, native function calling support (not emulated via system prompt) is available in:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3.1 / 3.3+&lt;/strong>: native tool call format, supported in vLLM with &lt;code>--enable-auto-tool-choice --tool-call-parser llama3_json&lt;/code>&lt;/li>
&lt;li>&lt;strong>Qwen 2.5+&lt;/strong>: native support with &lt;code>--tool-call-parser hermes&lt;/code>&lt;/li>
&lt;li>&lt;strong>Mistral NeMo / Mistral 7B Instruct v0.3+&lt;/strong>: native format with &lt;code>--tool-call-parser mistral&lt;/code>&lt;/li>
&lt;li>&lt;strong>Qwen3 (2025+)&lt;/strong>: extended native support with parallel tool calling&lt;/li>
&lt;/ul>
&lt;p>Without &lt;code>--enable-auto-tool-choice&lt;/code>, vLLM can force the tool call format via guided decoding (JSON Schema), but the ability to &lt;em>decide when to invoke&lt;/em> a tool versus generating free text requires specific model training.&lt;/p>
&lt;h2 id="the-react-pattern-reasoning--acting">The ReAct pattern: Reasoning + Acting&lt;/h2>
&lt;p>ReAct (Yao et al., 2022) is the loop that turns an LLM with tools into an agent with reasoning. The central idea is that the model interleaves &lt;strong>thoughts&lt;/strong> (explicit reasoning about what to do) with &lt;strong>actions&lt;/strong> (tool invocations) and &lt;strong>observations&lt;/strong> (results received). This loop repeats until the model reckons it has enough information to answer, or until the iteration limit is reached.&lt;/p>
&lt;pre tabindex="0">&lt;code>Thought → Action → Observation → Thought → Action → Observation → ... → Response
&lt;/code>&lt;/pre>&lt;h3 id="a-full-react-trace-a-real-production-query">A full ReAct trace: a real production query&lt;/h3>
&lt;p>Query: &lt;em>&amp;ldquo;How many contracts above 100k€ did we sign in 2025 with EU providers, and which special conditions recur?&amp;rdquo;&lt;/em>&lt;/p>
&lt;p>This query requires two different sources: the exact numbers live in SQL, and the context of special conditions lives in the contract PDFs indexed in the vector store.&lt;/p>
&lt;hr>
&lt;p>&lt;strong>Iteration 1&lt;/strong>&lt;/p>
&lt;p>&lt;em>Thought:&lt;/em> The query asks for aggregated data (count and sum) with exact filters over structured fields (amount, year, region). This is a SQL query, not a semantic search.&lt;/p>
&lt;p>&lt;em>Action:&lt;/em> &lt;code>sql_query&lt;/code>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-sql" data-lang="sql">&lt;span class="line">&lt;span class="cl">&lt;span class="k">SELECT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">COUNT&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">num_contratos&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">SUM&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">total_eur&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">FROM&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">contracts&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="k">WHERE&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">amount&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">year&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="k">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="n">provider_region&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="err">$&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>params: &lt;code>[100000, 2025, &amp;quot;EU&amp;quot;]&lt;/code>&lt;/p>
&lt;p>&lt;em>Observation:&lt;/em> &lt;code>{&amp;quot;num_contratos&amp;quot;: 47, &amp;quot;total_eur&amp;quot;: 8300000}&lt;/code>&lt;/p>
&lt;hr>
&lt;p>&lt;strong>Iteration 2&lt;/strong>&lt;/p>
&lt;p>&lt;em>Thought:&lt;/em> I have the numbers. Now I need the special conditions that recur in these contracts. That kind of information is in the free text of the PDFs, not in the structured table. I go to the vector store.&lt;/p>
&lt;p>&lt;em>Action:&lt;/em> &lt;code>vector_search&lt;/code>&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;EU provider contracts 2025 amount above 100k special conditions recurring clauses&amp;#34;
&lt;/code>&lt;/pre>&lt;p>top_k: 5&lt;/p>
&lt;p>&lt;em>Observation:&lt;/em>&lt;/p>
&lt;pre tabindex="0">&lt;code>[chunk_1] Provider A contract — clause 8.3: late-delivery penalty of 2% per month on the invoiced amount...
[chunk_2] Provider C contract — clause 7.1: price revision linked to the euro area CPI capped at 4% per year...
[chunk_3] Provider B contract — clause 8.3: late-delivery penalty of 2% per month...
&lt;/code>&lt;/pre>&lt;hr>
&lt;p>&lt;em>Thought:&lt;/em> I have enough data from both sources. Clause 8.3 on late-delivery penalties appears in at least two of the retrieved contracts. I can synthesise the answer.&lt;/p>
&lt;p>&lt;em>Response:&lt;/em> &amp;ldquo;In 2025 you signed &lt;strong>47 contracts&lt;/strong> with EU providers above 100,000 €, for a cumulative total of &lt;strong>8.3 M€&lt;/strong>. The special condition that appears most often in the higher-value contracts is the late-delivery penalty clause (8.3 in the contract templates of Provider A and B): 2% per month on the invoiced amount. The price revision linked to the euro area CPI, capped at 4% per year (clause 7.1), also recurs.&amp;rdquo;&lt;/p>
&lt;hr>
&lt;p>This trace has &lt;strong>2 iterations&lt;/strong>. A more complex query (comparing with the previous year, cross-referencing with payment status) would add further iterations. Common practice is to set a limit of &lt;strong>5–10 iterations&lt;/strong> maximum to avoid loops.&lt;/p>
&lt;h2 id="the-three-sources-and-when-to-use-each-one">The three sources and when to use each one&lt;/h2>
&lt;p>The choice of source is neither arbitrary nor left solely to the LLM: the architecture defines which tools exist and how they are described. The table below summarises the selection criteria:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Characteristic&lt;/th>
&lt;th>Vector store (Qdrant)&lt;/th>
&lt;th>SQL / structured (PostgreSQL)&lt;/th>
&lt;th>Web search&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Data type&lt;/strong>&lt;/td>
&lt;td>Free text, documents, PDFs&lt;/td>
&lt;td>Tables with a fixed schema&lt;/td>
&lt;td>Public pages, news&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Structure&lt;/strong>&lt;/td>
&lt;td>Unstructured&lt;/td>
&lt;td>Highly structured&lt;/td>
&lt;td>Semi-structured&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Temporality&lt;/strong>&lt;/td>
&lt;td>Static index (periodic refresh)&lt;/td>
&lt;td>Real time (transactional)&lt;/td>
&lt;td>Real time (crawl)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Typical latency&lt;/strong>&lt;/td>
&lt;td>5–50 ms&lt;/td>
&lt;td>10–200 ms&lt;/td>
&lt;td>200–2,000 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Privacy&lt;/strong>&lt;/td>
&lt;td>Internal data, full sovereignty&lt;/td>
&lt;td>Internal data, full sovereignty&lt;/td>
&lt;td>Public data only&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Natural query&lt;/strong>&lt;/td>
&lt;td>Yes (natural language → embedding)&lt;/td>
&lt;td>No (parameterised SQL)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Exact aggregations&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Yes (&lt;code>COUNT&lt;/code>, &lt;code>SUM&lt;/code>, &lt;code>GROUP BY&lt;/code>)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>When to use&lt;/strong>&lt;/td>
&lt;td>Document context, semantics, PDFs&lt;/td>
&lt;td>Metrics, counts, exact filters, joins&lt;/td>
&lt;td>Data that does not exist internally and is public&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The most important rule of thumb: if the question contains words like &amp;ldquo;how many&amp;rdquo;, &amp;ldquo;sum&amp;rdquo;, &amp;ldquo;total&amp;rdquo;, &amp;ldquo;more than X&amp;rdquo;, &amp;ldquo;in year Y&amp;rdquo; and the data is in a structured table, the right answer is &lt;code>sql_query&lt;/code>. If the question asks for context, explanations, clauses, procedures or examples from documents, the answer is &lt;code>vector_search&lt;/code>. If it asks for the current price of something or recent news about a third party, &lt;code>web_search&lt;/code>, but only if no data sovereignty is involved.&lt;/p>
&lt;h2 id="tool-routing-how-the-llm-picks-the-right-tool">Tool routing: how the LLM picks the right tool&lt;/h2>
&lt;p>The description of each tool in the system prompt is &lt;strong>the most critical factor&lt;/strong> for routing accuracy. An LLM with good function calling capabilities can still choose badly if the descriptions are ambiguous or overlap.&lt;/p>
&lt;h3 id="descriptions-that-work-versus-those-that-do-not">Descriptions that work versus those that do not&lt;/h3>
&lt;p>&lt;strong>Weak description&lt;/strong> (leads the LLM to use the wrong tool):&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;search_docs&amp;#34; — Searches for information in the available sources.
&amp;#34;query_data&amp;#34; — Gets data from the system.
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Strong description&lt;/strong> (delimits precisely when to use each one):&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;vector_search&amp;#34; — Search internal company documents about policies, contracts and procedures.
Use when the query requires unstructured text, document context or semantic
similarity. NOT for counts, sums or exact filters.
&amp;#34;sql_query&amp;#34; — Query the SQL database for structured metrics, counts, aggregations and
financial data. Use when the query requires exact numbers, filters, sums or
joins over structured records. NOT for finding document context.
&lt;/code>&lt;/pre>&lt;p>The difference lies in two elements: (1) positive use-case examples, and (2) explicit exclusions with &lt;code>NOT for&lt;/code>. Both reduce the semantic overlap between tools and improve tool selection accuracy.&lt;/p>
&lt;h3 id="parallel-tool-calling">Parallel tool calling&lt;/h3>
&lt;p>When two tools are independent of each other, that is, when the result of one does not affect the query of the other, the LLM can invoke them simultaneously in the same turn:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;tool_calls&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;sql_query&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">}},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_02&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;function&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;name&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vector_search&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;arguments&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;...&amp;#34;&lt;/span>&lt;span class="p">}}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The system executes both in parallel and returns both observations before the LLM&amp;rsquo;s next turn. This cuts total latency when the queries are independent: instead of 2 sequential iterations (2 × tool_latency), you pay 1 × max(sql_latency, vector_latency). For the detective example: if he needs both the accounting records and the witness statements to answer, he can ask for them at the same time.&lt;/p>
&lt;h3 id="tool-selection-accuracy-the-eval-metric">Tool selection accuracy: the eval metric&lt;/h3>
&lt;p>&lt;strong>Tool selection accuracy&lt;/strong> is the percentage of turns in which the LLM picks the right tool given a set of evaluated queries:&lt;/p>
\[
\text{TSA} = \frac{\text{turns with correct tool chosen}}{\text{total turns with expected tool call}}
\]
&lt;p>It is measured over a synthetic eval set built with &lt;code>(query, expected_tool, expected_args)&lt;/code> triples. An example of a minimal eval set:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Query&lt;/th>
&lt;th>Expected tool&lt;/th>
&lt;th>Failure indicator&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&amp;ldquo;How many orders in March?&amp;rdquo;&lt;/td>
&lt;td>&lt;code>sql_query&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>vector_search&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;What does the returns policy say?&amp;rdquo;&lt;/td>
&lt;td>&lt;code>vector_search&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>sql_query&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;What is the price of copper today?&amp;rdquo;&lt;/td>
&lt;td>&lt;code>web_search&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>vector_search&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&amp;ldquo;Sum the Q3 contracts&amp;rdquo;&lt;/td>
&lt;td>&lt;code>sql_query&lt;/code>&lt;/td>
&lt;td>LLM uses &lt;code>vector_search&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A TSA &amp;lt; 0.85 in a production agent is a sign that the tool descriptions need revising before the model does. For more detail on how to build these evals, see &lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals&lt;/a>.&lt;/p>
&lt;h2 id="error-handling-in-tool-calls">Error handling in tool calls&lt;/h2>
&lt;h3 id="sql-injection-via-prompt">SQL injection via prompt&lt;/h3>
&lt;p>The most serious risk in tool-augmented retrieval is that the LLM generates malicious SQL, either because a user induced it via prompt injection, or because the model hallucinated a destructive query. This attack vector is covered in detail in &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>, but the minimum rules on the tool side are:&lt;/p>
&lt;p>&lt;strong>Rule 1: parameterised queries always, never direct interpolation.&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># NEVER this:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cursor&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;SELECT * FROM contracts WHERE provider = &amp;#39;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">llm_output&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#39;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Always this:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">cursor&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">execute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;SELECT * FROM contracts WHERE provider = $1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">llm_output&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Rule 2: DB user with minimum permissions.&lt;/strong> The user the agent runs SQL as must have &lt;code>SELECT&lt;/code> on the necessary tables and nothing else. No &lt;code>DROP&lt;/code>, &lt;code>INSERT&lt;/code>, &lt;code>UPDATE&lt;/code> or &lt;code>DELETE&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Rule 3: table allowlist.&lt;/strong> The system validates that the query generated by the LLM only references tables on an allowlist before executing it.&lt;/p>
&lt;p>&lt;strong>Rule 4: per-query timeout.&lt;/strong> Queries that block for too long, potentially induced to DoS the database, are cancelled with a configured timeout.&lt;/p>
&lt;h3 id="rate-limits-timeouts-and-tool-errors">Rate limits, timeouts and tool errors&lt;/h3>
&lt;p>When a tool fails, the error is returned to the LLM as an observation:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;role&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;tool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;tool_call_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;call_01&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;content&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;{\&amp;#34;error\&amp;#34;: \&amp;#34;timeout after 5s\&amp;#34;, \&amp;#34;tool\&amp;#34;: \&amp;#34;web_search\&amp;#34;}&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The system prompt must instruct the LLM on what to do in this case:&lt;/p>
&lt;pre tabindex="0">&lt;code>If a tool returns an error or is unavailable, acknowledge the limitation in your response.
Do not retry more than once. If web_search is unavailable, state that real-time data
is not accessible at this moment and answer with available internal sources only.
&lt;/code>&lt;/pre>&lt;p>This stops the agent from entering retry loops and handles graceful degradation: if &lt;code>web_search&lt;/code> is unavailable, it answers with what it has in the internal sources.&lt;/p>
&lt;h2 id="diagram-of-the-react-loop-with-the-three-sources">Diagram of the ReAct loop with the three sources&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 480" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="ReAct loop with three data sources">
&lt;style>
.rb{fill:#f8f8f8;stroke:#444;stroke-width:1.4}
.rh{fill:#7aafff;stroke:#444;stroke-width:1.4}
.ry{fill:#ffd76b;stroke:#444;stroke-width:1.4}
.rg{fill:#b2e8b2;stroke:#444;stroke-width:1.4}
.rr{fill:#ffb3b3;stroke:#444;stroke-width:1.4}
.rp{fill:#e0c8ff;stroke:#444;stroke-width:1.4}
.rl{font:600 13px sans-serif;fill:#222}
.rs{font:400 11px sans-serif;fill:#555}
.ri{font:italic 11px sans-serif;fill:#555}
.ra{stroke:#555;stroke-width:1.5;fill:none;marker-end:url(#mra)}
.rloop{stroke:#999;stroke-width:1.2;fill:none;stroke-dasharray:5 3;marker-end:url(#mra)}
&lt;/style>
&lt;defs>&lt;marker id="mra" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#555"/>&lt;/marker>&lt;/defs>
&lt;!-- Query input -->
&lt;rect x="310" y="10" width="200" height="50" rx="8" class="rb"/>
&lt;text x="410" y="32" text-anchor="middle" class="rl">User query&lt;/text>
&lt;text x="410" y="50" text-anchor="middle" class="rs">message with user role&lt;/text>
&lt;!-- LLM: Thought -->
&lt;rect x="290" y="90" width="240" height="60" rx="8" class="rh"/>
&lt;text x="410" y="114" text-anchor="middle" class="rl">LLM — Thought&lt;/text>
&lt;text x="410" y="132" text-anchor="middle" class="rs">reasons what it needs&lt;/text>
&lt;text x="410" y="146" text-anchor="middle" class="rs">and which tool to invoke&lt;/text>
&lt;!-- Action -->
&lt;rect x="290" y="185" width="240" height="50" rx="8" class="ry"/>
&lt;text x="410" y="207" text-anchor="middle" class="rl">Action&lt;/text>
&lt;text x="410" y="223" text-anchor="middle" class="rs">generates tool_call JSON&lt;/text>
&lt;!-- Tool router -->
&lt;rect x="290" y="265" width="240" height="50" rx="8" class="rp"/>
&lt;text x="410" y="287" text-anchor="middle" class="rl">Tool router (system)&lt;/text>
&lt;text x="410" y="303" text-anchor="middle" class="rs">dispatches the call&lt;/text>
&lt;!-- Three sources -->
&lt;rect x="30" y="355" width="160" height="70" rx="8" class="ry"/>
&lt;text x="110" y="378" text-anchor="middle" class="rl">vector_search&lt;/text>
&lt;text x="110" y="396" text-anchor="middle" class="rs">Qdrant&lt;/text>
&lt;text x="110" y="412" text-anchor="middle" class="rs">5–50 ms&lt;/text>
&lt;rect x="330" y="355" width="160" height="70" rx="8" class="rg"/>
&lt;text x="410" y="378" text-anchor="middle" class="rl">sql_query&lt;/text>
&lt;text x="410" y="396" text-anchor="middle" class="rs">PostgreSQL&lt;/text>
&lt;text x="410" y="412" text-anchor="middle" class="rs">10–200 ms&lt;/text>
&lt;rect x="630" y="355" width="160" height="70" rx="8" class="rr"/>
&lt;text x="710" y="378" text-anchor="middle" class="rl">web_search&lt;/text>
&lt;text x="710" y="396" text-anchor="middle" class="rs">external API&lt;/text>
&lt;text x="710" y="412" text-anchor="middle" class="rs">200–2,000 ms&lt;/text>
&lt;!-- Observation -->
&lt;rect x="290" y="355" width="0" height="0" rx="8"/>
&lt;!-- Main arrows -->
&lt;path class="ra" d="M410,60 L410,88"/>
&lt;path class="ra" d="M410,150 L410,183"/>
&lt;path class="ra" d="M410,235 L410,263"/>
&lt;path class="ra" d="M370,315 L190,353"/>
&lt;path class="ra" d="M410,315 L410,353"/>
&lt;path class="ra" d="M450,315 L630,353"/>
&lt;!-- Observation arrows back to the LLM (dashed lines) -->
&lt;path class="rloop" d="M110,355 Q60,260 280,145"/>
&lt;path class="rloop" d="M410,355 Q510,310 530,150"/>
&lt;path class="rloop" d="M710,355 Q780,260 540,145"/>
&lt;!-- Observation labels -->
&lt;text x="60" y="255" class="ri">Observation&lt;/text>
&lt;text x="548" y="255" class="ri">Observation&lt;/text>
&lt;text x="726" y="255" class="ri">Observation&lt;/text>
&lt;!-- Final answer -->
&lt;rect x="600" y="90" width="190" height="60" rx="8" class="rg"/>
&lt;text x="695" y="114" text-anchor="middle" class="rl">Response&lt;/text>
&lt;text x="695" y="132" text-anchor="middle" class="rs">when the LLM has&lt;/text>
&lt;text x="695" y="148" text-anchor="middle" class="rs">enough evidence&lt;/text>
&lt;!-- Arrow to the answer -->
&lt;path class="ra" d="M530,120 L598,120"/>
&lt;!-- Iteration limit -->
&lt;rect x="0" y="185" width="240" height="50" rx="8" class="rb"/>
&lt;text x="120" y="207" text-anchor="middle" class="rl">Iteration limit&lt;/text>
&lt;text x="120" y="223" text-anchor="middle" class="rs">max. 5–10 turns&lt;/text>
&lt;path class="rloop" d="M290,210 L242,210"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="latency-maths-of-the-react-pipeline">Latency maths of the ReAct pipeline&lt;/h2>
&lt;p>Each iteration of the ReAct loop has three latency components:&lt;/p>
\[
T_{\text{iter}} = \text{TTFT}_{\text{LLM}} + T_{\text{tool}} + \Delta_{\text{context}}
\]
&lt;p>where:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TTFT_LLM&lt;/strong>: time to the LLM&amp;rsquo;s first token (dominated by the prefill of the accumulated context)&lt;/li>
&lt;li>&lt;strong>T_tool&lt;/strong>: tool execution time&lt;/li>
&lt;li>&lt;strong>Δ_context&lt;/strong>: overhead of the growing context window (each iteration adds the previous output to the context)&lt;/li>
&lt;/ul>
&lt;h3 id="reference-values-llama-31-70b-on-4h100-sxm-320-gb-nvlink">Reference values: Llama-3.1-70B on 4×H100 SXM (320 GB, NVLink)&lt;/h3>
&lt;p>With Llama-3.1-70B in FP8 on a node with 4×H100 SXM (320 GB HBM3, NVLink 900 GB/s), the typical values in production are:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>TTFT with context &amp;lt; 4k tokens&lt;/td>
&lt;td>≈ 150 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT with context 8k tokens&lt;/td>
&lt;td>≈ 220 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>sql_query&lt;/code> (simple query, indexed)&lt;/td>
&lt;td>≈ 50 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vector_search&lt;/code> (top-5, Qdrant in RAM)&lt;/td>
&lt;td>≈ 20 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>web_search&lt;/code> (external API)&lt;/td>
&lt;td>≈ 600 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="total-latency-by-number-of-iterations">Total latency by number of iterations&lt;/h3>
&lt;p>&lt;strong>1-iteration pipeline&lt;/strong> (simple query, a single tool):&lt;/p>
\[
T_1 = 150 + 50 = 200 \text{ ms} + \text{final synthesis} \approx 200 + 300 = 500 \text{ ms}
\]
&lt;p>&lt;strong>2-iteration pipeline&lt;/strong> (SQL + vector_search in sequence):&lt;/p>
\[
T_2 = (150 + 50) + (180 + 20) + 400 = 800 \text{ ms}
\]
&lt;p>The context in the second iteration already includes the result of the first, so TTFT rises slightly to ≈ 180 ms.&lt;/p>
&lt;p>&lt;strong>3-iteration pipeline&lt;/strong> (the most common case in complex queries):&lt;/p>
\[
T_3 = (150 + 50) + (180 + 20) + (200 + 50) + 450 \approx 1,100 \text{ ms}
\]
&lt;p>&lt;strong>Parallel tool calling&lt;/strong> (SQL + vector_search in parallel, a single iteration):&lt;/p>
\[
T_{\text{parallel}} = 150 + \max(50, 20) + 400 = 600 \text{ ms}
\]
&lt;p>When the two queries are independent, parallel tool calling trims latency from ≈ 800 ms to ≈ 600 ms: a 25% improvement over the 2 sequential iterations case.&lt;/p>
&lt;h3 id="comparison-with-a-naive-rag">Comparison with a naive RAG&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration&lt;/th>
&lt;th>Latency&lt;/th>
&lt;th>Queries it can answer&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Naive RAG (1 retriever, 1 step)&lt;/td>
&lt;td>≈ 300 ms&lt;/td>
&lt;td>Document context queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct 1 iteration (SQL)&lt;/td>
&lt;td>≈ 500 ms&lt;/td>
&lt;td>Structured aggregation queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct 2 iterations (SQL + vector)&lt;/td>
&lt;td>≈ 800 ms&lt;/td>
&lt;td>Hybrid numeric + context queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct 3 iterations&lt;/td>
&lt;td>≈ 1,100 ms&lt;/td>
&lt;td>Complex multi-source queries&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ReAct with web_search&lt;/td>
&lt;td>≈ 1,500 ms&lt;/td>
&lt;td>Queries requiring real-time data&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical conclusion: multi-hop ReAct is &lt;strong>3–5× slower than a single-step naive RAG&lt;/strong>. The gain is not in speed but in &lt;strong>query coverage&lt;/strong>: a naive RAG cannot answer &amp;ldquo;how many contracts?&amp;rdquo; because that answer is in no text chunk at all. For applications with a strict latency SLO (&amp;lt; 500 ms), you have to work out whether the use case really needs ReAct or whether a well-configured RAG with hybrid retrieval covers 90% of the queries.&lt;/p>
&lt;h2 id="on-premise-hardware-for-react-agents">On-premise hardware for ReAct agents&lt;/h2>
&lt;p>A ReAct agent with Llama-3.1-70B in production has different requirements from a naive RAG, because the context grows with each iteration and prefill throughput is more critical.&lt;/p>
&lt;p>&lt;strong>Recommended configuration: 4×H100 SXM (320 GB HBM3, NVLink 900 GB/s)&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Llama-3.1-70B in FP8: fits in 2×H100 (70B params × 1 byte/param ≈ 70 GB + KV cache). With 4×H100 you can serve it with tensor parallelism TP=4, cutting prefill TTFT by ≈ 2×.&lt;/li>
&lt;li>Qdrant instance: it can sit on the same node (if the collection fits in RAM) or on a dedicated node. For collections &amp;lt; 50M vectors of 768 dims: ≈ 150 GB, which fits in the RAM of a dual-socket server.&lt;/li>
&lt;li>PostgreSQL: separate node or managed instance. The agent adds no unusual load to SQL; the queries are simple and bounded by timeout.&lt;/li>
&lt;li>vLLM with &lt;code>--enable-auto-tool-choice --tool-call-parser llama3_json --max-model-len 16384&lt;/code>: a 16k token context comfortably covers the 5–10 turns of a ReAct pipeline.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Minimum viable configuration: 2×H100 SXM (160 GB)&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Llama-3.1-70B in FP8 with TP=2. TTFT ≈ 250–300 ms for 4k token contexts (a 60–100% increase over TP=4).&lt;/li>
&lt;li>Good enough for internal workloads with &amp;lt; 20 concurrent requests.&lt;/li>
&lt;li>Not advisable for an SLO &amp;lt; 1 s with more than 5 concurrent users and a long context.&lt;/li>
&lt;/ul>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;p>&lt;strong>Agentic retrieval loops with planning.&lt;/strong> ReAct is the simplest agent pattern. When a query requires decomposition into sub-tasks with dependencies, you need orchestration frameworks such as LangGraph (state graphs), smolagents (Hugging Face, agents with Python code as actions) or llama-index Agents (planning + retrieval pipeline). These introduce a planning step before the execution loop.&lt;/p>
&lt;p>&lt;strong>MCP (Model Context Protocol).&lt;/strong> Anthropic&amp;rsquo;s emerging standard, with OSS implementations, for defining tools portably across frameworks and hosts. Instead of defining each tool&amp;rsquo;s JSON Schema separately in every application, MCP centralises those definitions in an MCP server that any compatible client can discover and invoke. Adoption in 2025–2026 is fast among OSS frameworks (LangChain, smolagents, OpenWebUI).&lt;/p>
&lt;p>&lt;strong>Tool caching.&lt;/strong> If the same tool call (same arguments, same tool) is going to be invoked several times within the same context or in very similar contexts, the result can be cached. The mechanism is analogous to the semantic cache described for RAG: before executing the tool, the hash of the arguments (or its embedding, for semantic matching) is compared against a cache with a TTL. Especially valuable for &lt;code>sql_query&lt;/code> with frequent queries and data that changes little.&lt;/p>
&lt;p>&lt;strong>Multi-agent.&lt;/strong> When an orchestrating agent delegates sub-tasks to specialised agents, one for SQL, one for document retrieval, one for code generation, you enter multi-agent system territory. Each sub-agent can have its own tool set and its own LLM (possibly smaller and more specialised). Coordination between agents introduces additional tracing and observability complexity.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">RAG with reranker and hybrid retrieval&lt;/a> — the retriever invoked when the LLM chooses &lt;code>vector_search&lt;/code> is exactly the pipeline described there: dense + sparse + cross reranker&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output: fundamentals&lt;/a> — the JSON Schema that defines the contract of each tool call is exactly structured output applied to the tool interface&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">Inference router and L7 LLM gateway&lt;/a> — the L7 gateway that receives the ReAct agent&amp;rsquo;s requests and routes them to the right LLM; it also applies rate limiting per user and tenant&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard: fundamentals&lt;/a> — SQL injection via prompt is a real attack vector in tool-augmented retrieval; LLM Guard covers prompt injection detection before the request reaches the LLM&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">Six-stage LLMOps pipeline&lt;/a> — tool-augmented retrieval lives at the intersection of the pipeline&amp;rsquo;s Deploy and Observe stages: it is deployed as part of the inference system and observed via tracing of each agent turn&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">LLM evals: the layer after tracing&lt;/a> — tool selection accuracy is the critical eval metric for a ReAct agent; the golden eval dataset must include (query, expected tool, expected args) triples&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Yao, S. et al. (2022). &lt;em>ReAct: Synergizing Reasoning and Acting in Language Models&lt;/em>. arXiv:2210.03629. &lt;a href="https://arxiv.org/abs/2210.03629">https://arxiv.org/abs/2210.03629&lt;/a>&lt;/li>
&lt;li>vLLM documentation. &lt;em>Tool calling&lt;/em>. &lt;a href="https://docs.vllm.ai/en/stable/features/tool_calling.html">https://docs.vllm.ai/en/stable/features/tool_calling.html&lt;/a>&lt;/li>
&lt;li>Qdrant documentation. &lt;em>Search&lt;/em>. &lt;a href="https://qdrant.tech/documentation/concepts/search/">https://qdrant.tech/documentation/concepts/search/&lt;/a>&lt;/li>
&lt;li>OpenAI. &lt;em>Function calling&lt;/em>. &lt;a href="https://platform.openai.com/docs/guides/function-calling">https://platform.openai.com/docs/guides/function-calling&lt;/a>&lt;/li>
&lt;li>Meta AI. &lt;em>Llama 3.1 Model Card&lt;/em>. &lt;a href="https://github.com/meta-llama/llama-models/blob/main/models/llama3_1/MODEL_CARD.md">https://github.com/meta-llama/llama-models/blob/main/models/llama3_1/MODEL_CARD.md&lt;/a>&lt;/li>
&lt;li>Qwen Team (Alibaba). &lt;em>Qwen2.5 Technical Report&lt;/em>. arXiv:2412.15115. &lt;a href="https://arxiv.org/abs/2412.15115">https://arxiv.org/abs/2412.15115&lt;/a>&lt;/li>
&lt;li>Anthropic. &lt;em>Model Context Protocol&lt;/em>. &lt;a href="https://modelcontextprotocol.io">https://modelcontextprotocol.io&lt;/a>&lt;/li>
&lt;li>OWASP. &lt;em>LLM Top 10 for Large Language Model Applications&lt;/em>. LLM01: Prompt Injection. &lt;a href="https://owasp.org/www-project-top-10-for-large-language-model-applications/">https://owasp.org/www-project-top-10-for-large-language-model-applications/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Evaluating a RAG system without fooling yourself: RAGAS, the golden dataset and the four metrics that matter</title><link>https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/</link><pubDate>Thu, 04 Jun 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A RAG pipeline fails in ways that user satisfaction cannot distinguish: the LLM can hallucinate even with good chunks, or retrieval can ignore key documents even though the LLM synthesises well what it receives. RAGAS breaks evaluation down into four orthogonal metrics, faithfulness, answer relevance, context precision and context recall, each pointing at a different sub-component. The golden dataset is the reference calibrator; without it the metrics have no anchor. The complete stack runs 100 % on-premise with vLLM as the judge and Langfuse for traceability.&lt;/p>
&lt;hr>
&lt;h2 id="the-master-analogy-the-quality-inspector-at-a-furniture-factory">The master analogy: the quality inspector at a furniture factory&lt;/h2>
&lt;p>Imagine you manufacture chairs. You could ask customers &amp;ldquo;is it comfortable?&amp;rdquo; and leave it there. But that question does not tell you what to fix when the answer is &amp;ldquo;no&amp;rdquo;. The quality inspector does not ask that: they measure the board with a Shore hardness test, check that each leg is exactly 45 cm, verify that the assembly manual includes the twelve screws from the BOM, and detect whether a low-density board slipped past the incoming filter.&lt;/p>
&lt;p>RAGAS is that inspector applied to RAG:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Faithfulness&lt;/strong> → does the board have the specified hardness? The LLM can only use the material (chunks) that retrieval hands it.&lt;/li>
&lt;li>&lt;strong>Context Precision&lt;/strong> → is the leg exactly the right length? Of the K retrieved chunks, how many are genuinely useful and how many are filler that confuses the assembler?&lt;/li>
&lt;li>&lt;strong>Context Recall&lt;/strong> → does the manual include all the screws? Of all the facts the correct answer should contain, how many appear in the retrieved chunks?&lt;/li>
&lt;li>&lt;strong>Noise Sensitivity&lt;/strong> → if the operator uses a medium-low density board, does it show in the final product? If you introduce irrelevant chunks, does the LLM start hallucinating?&lt;/li>
&lt;/ul>
&lt;p>Without measuring each dimension separately, the diagnosis is opaque: &amp;ldquo;the RAG is not working well&amp;rdquo; does not tell you whether to repair the embedder, the reranker, the prompt or the corpus.&lt;/p>
&lt;hr>
&lt;h2 id="the-problem-of-evaluating-rag">The problem of evaluating RAG&lt;/h2>
&lt;p>Classification has one uncomfortable virtue: if you predict 87 out of 100 labels correctly, accuracy = 0.87. There is no ambiguity. RAG does not enjoy that luxury.&lt;/p>
&lt;p>A RAG system can fail in at least three independent ways:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Retrieval correct, LLM hallucinates&lt;/strong>: the chunks contain the correct answer, but the LLM generates claims that are not in those chunks. Faithfulness low; context recall high.&lt;/li>
&lt;li>&lt;strong>LLM correct, retrieval fails&lt;/strong>: retrieval returns irrelevant chunks (low context precision) or incomplete ones (low context recall). If the LLM has enough parametric knowledge, it may look as though it is answering well, but in reality it is ignoring the context, which is a time bomb once the parametric knowledge goes stale.&lt;/li>
&lt;li>&lt;strong>Retrieval and LLM both correct, answer does not answer the question&lt;/strong>: the answer is faithful to the context and the chunks are relevant, but the question was a different one. Answer relevance low.&lt;/li>
&lt;/ol>
&lt;p>Each failure mode requires a different metric and a different corrective action. Using a single metric (BLEU, ROUGE, user satisfaction) mixes the signals and makes it impossible to prioritise improvement work.&lt;/p>
&lt;hr>
&lt;h2 id="the-four-ragas-metrics">The four RAGAS metrics&lt;/h2>
&lt;h3 id="1-faithfulness--fidelity-to-the-context">1. Faithfulness — fidelity to the context&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> how many claims in the generated answer are supported by the retrieved chunks?&lt;/p>
&lt;p>&lt;strong>Calculation:&lt;/strong>&lt;/p>
$$\text{Faithfulness} = \frac{|\text{claims supported by the context}|}{|\text{total claims in the answer}|}$$
&lt;p>The process uses an LLM-as-judge (see https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/): first the atomic claims are extracted from the answer (&amp;ldquo;the model was released in 2023&amp;rdquo;, &amp;ldquo;it supports contexts of 128k tokens&amp;rdquo;, and so on), then the judge classifies each claim as &lt;em>supported&lt;/em> or &lt;em>not supported&lt;/em> by the chunks.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> The generated answer has 5 claims. The judge determines that 4 are in the chunks and 1 is an unsupported extrapolation.&lt;/p>
$$\text{Faithfulness} = \frac{4}{5} = 0{.}80$$
&lt;p>&lt;strong>Warning sign:&lt;/strong> faithfulness &amp;lt; 0.85 indicates that the LLM is generating content that goes beyond the context, which is to say it is hallucinating with superficial backing.&lt;/p>
&lt;h3 id="2-answer-relevance--relevance-of-the-answer">2. Answer Relevance — relevance of the answer&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> does the answer actually answer the question as posed?&lt;/p>
&lt;p>&lt;strong>Intuition:&lt;/strong> An answer that answers the question well &amp;ldquo;implies&amp;rdquo; that question. If you generate N hypothetical questions from the answer and measure their semantic similarity to the original question, you get a relevance signal.&lt;/p>
&lt;p>&lt;strong>Calculation:&lt;/strong>&lt;/p>
$$\text{AnswerRelevance} = \frac{1}{N} \sum_{i=1}^{N} \cos(\vec{q}_{\text{original}}, \vec{q}_{i}^{\text{generated}})$$
&lt;p>where $\vec{q}$ are question embeddings.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> For the question &amp;ldquo;Which Python versions does FastAPI support?&amp;rdquo; and an answer about web frameworks in general, the hypothetical questions generated will be along the lines of &amp;ldquo;what are the best web frameworks?&amp;rdquo;, with low cosine against the original question → low answer relevance.&lt;/p>
&lt;h3 id="3-context-precision--retrieval-precision">3. Context Precision — retrieval precision&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> of the K retrieved chunks, what proportion are genuinely relevant?&lt;/p>
&lt;p>&lt;strong>Calculation&lt;/strong> (weighted version):&lt;/p>
$$\text{ContextPrecision@K} = \frac{\sum_{k=1}^{K} \text{Precision@}k \cdot \mathbb{1}[\text{chunk}_k \text{ is relevant}]}{|\text{relevant chunks in top-K}|}$$
&lt;p>The most direct form: the LLM judge classifies each chunk as relevant or not for answering the question. Precision is the relevant fraction.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> 5 chunks are retrieved. The judge considers 3 relevant and 2 noise.&lt;/p>
$$\text{ContextPrecision} = \frac{3}{5} = 0{.}60$$
&lt;p>&lt;strong>Warning sign:&lt;/strong> precision &amp;lt; 0.6 indicates that retrieval is contaminating the context with information that can contradict or dilute the correct answer.&lt;/p>
&lt;h3 id="4-context-recall--retrieval-recall">4. Context Recall — retrieval recall&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> of all the facts needed to build the correct answer (ground truth), what proportion are covered by the retrieved chunks?&lt;/p>
&lt;p>&lt;strong>Calculation:&lt;/strong>&lt;/p>
$$\text{ContextRecall} = \frac{|\text{ground-truth claims attributable to some chunk}|}{|\text{total claims in ground truth}|}$$
&lt;p>This metric &lt;strong>requires ground truth&lt;/strong>, which is to say you need the golden dataset.&lt;/p>
&lt;p>&lt;strong>Example:&lt;/strong> The ground truth has 6 claims. The retrieved chunks cover 5 of them; the sixth is in a document that retrieval did not find.&lt;/p>
$$\text{ContextRecall} = \frac{5}{6} \approx 0{.}83$$
&lt;h3 id="5-noise-sensitivity--sensitivity-to-noise">5. Noise Sensitivity — sensitivity to noise&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> if you introduce irrelevant chunks into the context, how far does faithfulness fall?&lt;/p>
&lt;p>It is computed as the difference in faithfulness between the clean condition and the condition with controlled noise:&lt;/p>
$$\Delta\text{Faithfulness} = \text{Faithfulness}_{\text{clean}} - \text{Faithfulness}_{\text{noisy}}$$
&lt;p>A $\Delta > 0{.}15$ indicates that the prompt or the LLM are fragile against contaminated contexts, a problem that low context precision turns into a routine occurrence.&lt;/p>
&lt;hr>
&lt;h2 id="a-complete-worked-example">A complete worked example&lt;/h2>
&lt;h3 id="scenario">Scenario&lt;/h3>
&lt;p>&lt;strong>Question:&lt;/strong> &amp;ldquo;What latency guarantees does Redis offer for in-memory read operations with a 10 GB dataset?&amp;rdquo;&lt;/p>
&lt;p>&lt;strong>Answer generated by the RAG:&lt;/strong>&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;Redis guarantees sub-millisecond latencies for GET operations on datasets that fit in RAM. With a 10 GB dataset and clients on the same local network, P99 is typically below 1 ms. Redis also supports horizontal clustering to scale beyond the RAM of a single node. In addition, Redis has included native JSON support since version 7.2.&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>&lt;strong>Ground truth (reference answer):&lt;/strong>&lt;/p>
&lt;blockquote>
&lt;p>&amp;ldquo;Redis operates entirely in memory, which guarantees sub-millisecond latencies for GET. On local networks with 10 GB datasets in RAM, P99 sits below 1 ms. Clustering allows scaling beyond the RAM of a single node.&amp;rdquo;&lt;/p>
&lt;/blockquote>
&lt;p>&lt;strong>Retrieved chunks (5 chunks, summarised fragments):&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Summarised content&lt;/th>
&lt;th>Relevant&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>C1&lt;/td>
&lt;td>&amp;ldquo;Redis operates in memory; GET has latencies &amp;lt; 1 ms on LAN&amp;rdquo;&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C2&lt;/td>
&lt;td>&amp;ldquo;Redis Cluster allows sharding to scale total RAM&amp;rdquo;&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C3&lt;/td>
&lt;td>&amp;ldquo;Redis Sentinel manages high availability through automatic failover&amp;rdquo;&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C4&lt;/td>
&lt;td>&amp;ldquo;Redis benchmarks: P50 = 0.3 ms, P99 = 0.9 ms on a 10 GB dataset&amp;rdquo;&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C5&lt;/td>
&lt;td>&amp;ldquo;Redis Stack adds modules: RedisJSON, RediSearch, RedisTimeSeries&amp;rdquo;&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="step-by-step-calculation">Step-by-step calculation&lt;/h3>
&lt;p>&lt;strong>Faithfulness:&lt;/strong>&lt;/p>
&lt;p>Claims in the generated answer:&lt;/p>
&lt;ol>
&lt;li>&amp;ldquo;Redis guarantees sub-millisecond latencies for GET on in-RAM datasets&amp;rdquo; → &lt;strong>supported&lt;/strong> by C1, C4&lt;/li>
&lt;li>&amp;ldquo;With 10 GB on LAN, P99 &amp;lt; 1 ms&amp;rdquo; → &lt;strong>supported&lt;/strong> by C4&lt;/li>
&lt;li>&amp;ldquo;Redis supports horizontal clustering to scale RAM&amp;rdquo; → &lt;strong>supported&lt;/strong> by C2&lt;/li>
&lt;li>&amp;ldquo;Redis has included native JSON support since version 7.2&amp;rdquo; → &lt;strong>NOT supported&lt;/strong> by any chunk (C5 mentions RedisJSON as a Redis Stack module, not as native to Redis core)&lt;/li>
&lt;/ol>
$$\text{Faithfulness} = \frac{3}{4} = 0{.}75$$
&lt;p>Claim 4 is an extrapolation that mixes information from C5 imprecisely, a partial hallucination.&lt;/p>
&lt;p>&lt;strong>Context Precision:&lt;/strong>&lt;/p>
&lt;p>Relevant chunks: C1, C2, C4 (3 out of 5).&lt;/p>
$$\text{ContextPrecision} = \frac{3}{5} = 0{.}60$$
&lt;p>C3 and C5 are noise. C5 in particular contributed to the partial hallucination about JSON.&lt;/p>
&lt;p>&lt;strong>Context Recall:&lt;/strong>&lt;/p>
&lt;p>Ground-truth claims:&lt;/p>
&lt;ol>
&lt;li>&amp;ldquo;Redis operates in memory, GET &amp;lt; 1 ms&amp;rdquo; → attributable to C1 ✓&lt;/li>
&lt;li>&amp;ldquo;P99 &amp;lt; 1 ms on LAN with 10 GB&amp;rdquo; → attributable to C4 ✓&lt;/li>
&lt;li>&amp;ldquo;Clustering scales beyond the RAM of a single node&amp;rdquo; → attributable to C2 ✓&lt;/li>
&lt;/ol>
$$\text{ContextRecall} = \frac{3}{3} = 1{.}00$$
&lt;p>Retrieval found every chunk needed for the ground truth. The problem is not recall but precision (C3 and C5 contaminated the context).&lt;/p>
&lt;p>&lt;strong>Answer Relevance:&lt;/strong>&lt;/p>
&lt;p>The judge generates 3 hypothetical questions from the answer:&lt;/p>
&lt;ul>
&lt;li>&amp;ldquo;What latencies does Redis offer for in-memory reads?&amp;rdquo; — cos = 0.91&lt;/li>
&lt;li>&amp;ldquo;How does Redis scale horizontally?&amp;rdquo; — cos = 0.74&lt;/li>
&lt;li>&amp;ldquo;Which JSON modules does Redis include?&amp;rdquo; — cos = 0.52 (a drift caused by the hallucination)&lt;/li>
&lt;/ul>
$$\text{AnswerRelevance} = \frac{0{.}91 + 0{.}74 + 0{.}52}{3} = 0{.}72$$
&lt;p>The drift towards JSON reduced relevance. A tighter answer would have scored around 0.90.&lt;/p>
&lt;h3 id="summary-of-the-example">Summary of the example&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Value&lt;/th>
&lt;th>Diagnosis&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Faithfulness&lt;/td>
&lt;td>0.75&lt;/td>
&lt;td>LLM extrapolated beyond the context&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Precision&lt;/td>
&lt;td>0.60&lt;/td>
&lt;td>Retrieval returned 2 irrelevant chunks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Recall&lt;/td>
&lt;td>1.00&lt;/td>
&lt;td>Retrieval captured everything needed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Answer Relevance&lt;/td>
&lt;td>0.72&lt;/td>
&lt;td>Answer drifts off topic&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Main corrective action:&lt;/strong> improve the reranker so that C3 and C5 are filtered before they reach the LLM. The faithfulness and relevance problem is a direct consequence of the low precision, not of the LLM itself.&lt;/p>
&lt;hr>
&lt;h2 id="building-the-golden-dataset">Building the golden dataset&lt;/h2>
&lt;h3 id="what-it-is-and-why-it-matters">What it is and why it matters&lt;/h3>
&lt;p>The golden dataset is a set of &lt;code>(question, relevant chunks, correct answer)&lt;/code> tuples that acts as a reference calibrator. Without it, context recall cannot be computed (there is no ground truth) and the other metrics lack any interpretive anchor: is 0.75 faithfulness good or bad for this corpus and this domain?&lt;/p>
&lt;p>A well-built golden dataset lets you:&lt;/p>
&lt;ul>
&lt;li>Compare pipeline versions (embedder v1 vs v2, chunk size 512 vs 1024)&lt;/li>
&lt;li>Detect regressions in CI before deploying&lt;/li>
&lt;li>Stratify the analysis by question type&lt;/li>
&lt;/ul>
&lt;h3 id="llm-assisted-construction-pipeline">LLM-assisted construction pipeline&lt;/h3>
&lt;p>Purely manual construction is expensive. The standard pattern in 2026 is LLM assistance with human review of a sample:&lt;/p>
&lt;p>&lt;strong>Step 1 — Seed chunk selection.&lt;/strong> From the full corpus, select representative chunks through stratified sampling (by section, date, document type). For a technical corpus of 10,000 chunks, 500-1,000 seeds is a reasonable starting point.&lt;/p>
&lt;p>&lt;strong>Step 2 — Question generation.&lt;/strong> A powerful LLM (Llama-3.1-70B or similar) generates 2-3 questions per seed chunk using a prompt along these lines:&lt;/p>
&lt;pre tabindex="0">&lt;code>Given the following documentation fragment, generate specific questions
that can only be answered correctly using THIS fragment and not
general knowledge. The questions should be the ones an engineer looking
for operational information would ask.
Fragment: {chunk}
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Step 3 — Reference answer generation.&lt;/strong> The same LLM, with access to the seed chunk (and to adjacent chunks if the question requires them), generates the reference answer.&lt;/p>
&lt;p>&lt;strong>Step 4 — Human review of a sample.&lt;/strong> Manually review 10-20 % of the generated dataset. The most common rejection criteria: trivial questions that any LLM answers without the corpus, answers the LLM padded with parametric knowledge instead of the chunks, and badly formulated or ambiguous questions.&lt;/p>
&lt;h3 id="minimum-size">Minimum size&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Use case&lt;/th>
&lt;th>Minimum pairs&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Prototype / initial validation&lt;/td>
&lt;td>50-100&lt;/td>
&lt;td>Enough to spot coarse problems&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Technical corpus in production&lt;/td>
&lt;td>200-500&lt;/td>
&lt;td>Allows basic stratification&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Robust production with full stratification&lt;/td>
&lt;td>500-1,000+&lt;/td>
&lt;td>Needed to detect subtle regressions&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="stratifying-the-dataset">Stratifying the dataset&lt;/h3>
&lt;p>A flat golden dataset measures the average but hides the extreme cases. The minimum recommended stratification includes three question types:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Easy (single-hop):&lt;/strong> A single chunk contains all the information needed. The baseline that any decent RAG must beat.&lt;/li>
&lt;li>&lt;strong>Hard (multi-hop):&lt;/strong> The correct answer requires combining information from 2-4 different chunks. This is where the limits of the reranker and the synthesis prompt show up.&lt;/li>
&lt;li>&lt;strong>Adversarial:&lt;/strong> The question has a false premise, or the corpus does not contain the answer. A correct RAG must answer &amp;ldquo;I do not have enough information&amp;rdquo;, whereas a fragile RAG hallucinates confidently. This question type directly measures the risk of high-impact hallucination.&lt;/li>
&lt;/ul>
&lt;h3 id="the-goodhart-trap">The Goodhart trap&lt;/h3>
&lt;blockquote>
&lt;p>&amp;ldquo;When a measure becomes a target, it ceases to be a good measure.&amp;rdquo; — Charles Goodhart&lt;/p>
&lt;/blockquote>
&lt;p>If you optimise the embedder or the reranker using the golden dataset as a loss function, the dataset is corrupted as a metric: the system learns to perform well on those specific questions without improving on the general domain.&lt;/p>
&lt;p>The solution is the same as in supervised ML: separate the &lt;strong>dev set&lt;/strong> (for optimisation and iteration) from the &lt;strong>test set&lt;/strong> (for final evaluation, frozen and audited). The test set must never be used to make design decisions; only to report the state of the system at release time.&lt;/p>
&lt;hr>
&lt;h2 id="correlation-with-real-satisfaction">Correlation with real satisfaction&lt;/h2>
&lt;p>Field studies published by the Databricks teams (2024) and the RAGAS adoption analyses (2025) point to interpretable operational thresholds:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric range&lt;/th>
&lt;th>Observable symptom&lt;/th>
&lt;th>Corrective action&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Faithfulness &amp;lt; 0.75&lt;/td>
&lt;td>Users frequently report &amp;ldquo;made-up answers&amp;rdquo;&lt;/td>
&lt;td>Review the LLM prompt; increase citation instructions; lower the temperature&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Faithfulness 0.75-0.85&lt;/td>
&lt;td>Occasional hallucinations on peripheral topics&lt;/td>
&lt;td>Improve context precision to eliminate contaminating chunks&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Faithfulness ≥ 0.85&lt;/td>
&lt;td>Correlates with positive NPS in field studies&lt;/td>
&lt;td>Hold steady; monitor for drift&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Precision &amp;lt; 0.60&lt;/td>
&lt;td>LLM includes contradictory information; inconsistent answers&lt;/td>
&lt;td>Tune the reranker; reduce K; review similarity thresholds&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Context Recall &amp;lt; 0.70&lt;/td>
&lt;td>Multi-hop questions fail; key information missing&lt;/td>
&lt;td>Review the chunking strategy; add larger chunks; enrich metadata&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Answer Relevance &amp;lt; 0.70&lt;/td>
&lt;td>Answers that are &amp;ldquo;correct but do not answer&amp;rdquo;&lt;/td>
&lt;td>Review the synthesis prompt; add an explicit instruction to stick to the question&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Low context precision is especially pernicious: irrelevant chunks are not neutral. They raise the probability that the LLM uses incorrect information as though it were relevant, degrading faithfulness in a chain reaction. It is the transmission belt by which a retrieval problem turns into an LLM problem.&lt;/p>
&lt;hr>
&lt;h2 id="diagram-the-continuous-evaluation-loop">Diagram: the continuous evaluation loop&lt;/h2>
&lt;figure>
&lt;svg viewBox="0 0 800 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="rag-eval-title rag-eval-desc" style="width:100%;max-width:800px;font-family:system-ui,sans-serif;">
&lt;title id="rag-eval-title">RAG evaluation loop with RAGAS&lt;/title>
&lt;desc id="rag-eval-desc">Circular diagram showing the flow from corpus to corrective action via retrieval, LLM, answer, RAGAS judge and metrics with alerts.&lt;/desc>
&lt;!-- Background -->
&lt;rect width="800" height="420" fill="#0f1117" rx="12"/>
&lt;!-- Node: Corpus -->
&lt;rect x="30" y="170" width="110" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="85" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">Corpus&lt;/text>
&lt;text x="85" y="207" text-anchor="middle" fill="#718096" font-size="10">documents&lt;/text>
&lt;!-- Arrow Corpus → Retrieval -->
&lt;line x1="140" y1="195" x2="175" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: Retrieval -->
&lt;rect x="175" y="170" width="120" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="235" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">Retrieval&lt;/text>
&lt;text x="235" y="207" text-anchor="middle" fill="#718096" font-size="10">top-K chunks&lt;/text>
&lt;!-- Arrow Retrieval → LLM -->
&lt;line x1="295" y1="195" x2="330" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: LLM -->
&lt;rect x="330" y="170" width="110" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="385" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">LLM&lt;/text>
&lt;text x="385" y="207" text-anchor="middle" fill="#718096" font-size="10">synthesis&lt;/text>
&lt;!-- Arrow LLM → Answer -->
&lt;line x1="440" y1="195" x2="475" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: Answer -->
&lt;rect x="475" y="170" width="110" height="50" rx="8" fill="#1e2433" stroke="#4a5568" stroke-width="1.5"/>
&lt;text x="530" y="191" text-anchor="middle" fill="#e2e8f0" font-size="12" font-weight="600">Answer&lt;/text>
&lt;text x="530" y="207" text-anchor="middle" fill="#718096" font-size="10">generated&lt;/text>
&lt;!-- Arrow Answer → RAGAS -->
&lt;line x1="585" y1="195" x2="620" y2="195" stroke="#4a5568" stroke-width="1.5" marker-end="url(#arrow)"/>
&lt;!-- Node: RAGAS Judge -->
&lt;rect x="620" y="170" width="130" height="50" rx="8" fill="#1a2640" stroke="#3b82f6" stroke-width="2"/>
&lt;text x="685" y="191" text-anchor="middle" fill="#93c5fd" font-size="12" font-weight="600">RAGAS Judge&lt;/text>
&lt;text x="685" y="207" text-anchor="middle" fill="#6b9fd4" font-size="10">LLM-as-judge&lt;/text>
&lt;!-- Arrow RAGAS → Metrics (downwards) -->
&lt;line x1="685" y1="220" x2="685" y2="285" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
&lt;!-- Node: Metrics -->
&lt;rect x="615" y="285" width="140" height="60" rx="8" fill="#1a2a1a" stroke="#22c55e" stroke-width="1.5"/>
&lt;text x="685" y="305" text-anchor="middle" fill="#86efac" font-size="12" font-weight="600">Metrics&lt;/text>
&lt;text x="685" y="320" text-anchor="middle" fill="#4ade80" font-size="9">faithfulness · precision&lt;/text>
&lt;text x="685" y="333" text-anchor="middle" fill="#4ade80" font-size="9">recall · relevance&lt;/text>
&lt;!-- Arrow Metrics → Alert -->
&lt;line x1="615" y1="315" x2="510" y2="315" stroke="#22c55e" stroke-width="1.5" marker-end="url(#arrow-green)"/>
&lt;!-- Node: Grafana alert -->
&lt;rect x="390" y="285" width="120" height="60" rx="8" fill="#2a1a1a" stroke="#f97316" stroke-width="1.5"/>
&lt;text x="450" y="305" text-anchor="middle" fill="#fdba74" font-size="12" font-weight="600">Alert&lt;/text>
&lt;text x="450" y="320" text-anchor="middle" fill="#fb923c" font-size="9">Prometheus&lt;/text>
&lt;text x="450" y="333" text-anchor="middle" fill="#fb923c" font-size="9">Grafana&lt;/text>
&lt;!-- Arrow Alert → Action -->
&lt;line x1="390" y1="315" x2="285" y2="315" stroke="#f97316" stroke-width="1.5" marker-end="url(#arrow-orange)"/>
&lt;!-- Node: Action -->
&lt;rect x="155" y="285" width="130" height="60" rx="8" fill="#1a1a2a" stroke="#a855f7" stroke-width="1.5"/>
&lt;text x="220" y="305" text-anchor="middle" fill="#d8b4fe" font-size="12" font-weight="600">Corrective action&lt;/text>
&lt;text x="220" y="320" text-anchor="middle" fill="#c084fc" font-size="9">retrieval / chunking&lt;/text>
&lt;text x="220" y="333" text-anchor="middle" fill="#c084fc" font-size="9">prompt / fine-tuning&lt;/text>
&lt;!-- Arrow Action → Corpus (closing the loop) -->
&lt;line x1="155" y1="315" x2="85" y2="315" stroke="#a855f7" stroke-width="1.5"/>
&lt;line x1="85" y1="315" x2="85" y2="222" stroke="#a855f7" stroke-width="1.5" marker-end="url(#arrow-purple)"/>
&lt;!-- Golden Dataset (side input) -->
&lt;rect x="600" y="350" width="155" height="40" rx="6" fill="#1a2633" stroke="#64748b" stroke-width="1" stroke-dasharray="5,3"/>
&lt;text x="677" y="366" text-anchor="middle" fill="#94a3b8" font-size="10" font-weight="600">Golden Dataset&lt;/text>
&lt;text x="677" y="380" text-anchor="middle" fill="#64748b" font-size="9">ground truth for recall&lt;/text>
&lt;!-- Arrow Golden Dataset → Metrics -->
&lt;line x1="677" y1="350" x2="685" y2="347" stroke="#64748b" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-gray)"/>
&lt;!-- Langfuse label -->
&lt;rect x="460" y="130" width="90" height="28" rx="5" fill="#1a2633" stroke="#64748b" stroke-width="1" stroke-dasharray="4,2"/>
&lt;text x="505" y="148" text-anchor="middle" fill="#94a3b8" font-size="9">Langfuse tracing&lt;/text>
&lt;!-- Langfuse line above -->
&lt;line x1="505" y1="158" x2="505" y2="170" stroke="#64748b" stroke-width="1" stroke-dasharray="3,2"/>
&lt;!-- Marker definitions -->
&lt;defs>
&lt;marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#4a5568"/>
&lt;/marker>
&lt;marker id="arrow-blue" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#3b82f6"/>
&lt;/marker>
&lt;marker id="arrow-green" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#22c55e"/>
&lt;/marker>
&lt;marker id="arrow-orange" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#f97316"/>
&lt;/marker>
&lt;marker id="arrow-purple" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#a855f7"/>
&lt;/marker>
&lt;marker id="arrow-gray" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
&lt;path d="M0,0 L0,6 L8,3 z" fill="#64748b"/>
&lt;/marker>
&lt;/defs>
&lt;/svg>
&lt;figcaption style="text-align:center;font-size:0.85em;color:#718096;margin-top:0.5em">The continuous evaluation loop: corpus → retrieval → LLM → RAGAS judge → metrics → alert → corrective action → corpus.&lt;/figcaption>
&lt;/figure>
&lt;hr>
&lt;h2 id="the-2026-oss-stack-for-running-ragas-on-premise">The 2026 OSS stack for running RAGAS on-premise&lt;/h2>
&lt;h3 id="ragas-apache-20">ragas (Apache 2.0)&lt;/h3>
&lt;p>The &lt;code>ragas&lt;/code> library supports asynchronous evaluation and multiple LLM backends. Integrating vLLM as the judge removes any need to send data to external APIs, which is critical in environments with sensitive data.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">ragas&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">evaluate&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">ragas.metrics&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">faithfulness&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">answer_relevancy&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context_precision&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context_recall&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langchain_openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">ChatOpenAI&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">OpenAIEmbeddings&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Judge LLM pointing at on-premise vLLM&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">judge_llm&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ChatOpenAI&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;meta-llama/Llama-3.1-70B-Instruct&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://vllm-service:8000/v1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sk-local&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># vLLM ignores the value but requires the field&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">embeddings&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAIEmbeddings&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;BAAI/bge-m3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://embedding-service:8001/v1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;sk-local&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">result&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">evaluate&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">dataset&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">golden_dataset&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># HuggingFace Dataset with standard columns&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">metrics&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">faithfulness&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">answer_relevancy&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context_precision&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">context_recall&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">llm&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">judge_llm&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">embeddings&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">embeddings&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The dataset RAGAS expects has four columns: &lt;code>question&lt;/code>, &lt;code>answer&lt;/code>, &lt;code>contexts&lt;/code> (a list of strings), &lt;code>ground_truth&lt;/code>.&lt;/p>
&lt;h3 id="langfuse-for-eval-traceability">Langfuse for eval traceability&lt;/h3>
&lt;p>Each RAGAS evaluation is recorded in Langfuse as a &lt;em>dataset experiment&lt;/em>, linking the scores to the production spans (see https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/). This makes it possible to correlate a drop in faithfulness with the specific request that caused it. Without that link, the metrics are numbers with no actionable context.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">langfuse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">lf&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Langfuse&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Create or retrieve the dataset in Langfuse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">dataset&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_or_create_dataset&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;rag-golden-v3&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Record the experiment scores&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">idx&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">row&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">result&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">to_pandas&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">iterrows&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">lf&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">score&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ragas-faithfulness&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">value&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">row&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;faithfulness&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">trace_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">row&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;trace_id&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="c1"># linked to the production span&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="prometheus--grafana-for-operational-alerts">Prometheus + Grafana for operational alerts&lt;/h3>
&lt;p>RAGAS metrics are exposed as Prometheus gauges. A Grafana dashboard with thresholds configures alerts when faithfulness falls sustainedly below 0.80:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># Prometheus alert rule&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>- &lt;span class="nt">alert&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RAGFaithfulnessLow&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">expr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">avg_over_time(rag_faithfulness_score[30m]) &amp;lt; 0.80&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">for&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">10m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">severity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">warning&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">summary&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;RAG faithfulness below threshold ({{ $value | humanize }})&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Review context precision and the reranker. Possible corpus drift.&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="running-ragas-against-on-premise-vllm--practical-considerations">Running RAGAS against on-premise vLLM — practical considerations&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Judge size:&lt;/strong> Llama-3.1-70B as the judge produces results comparable to GPT-4 on faithfulness and context evaluation, according to the RAGAS 0.2 benchmarks (2025). Smaller models (8B-13B) degrade judge quality on multi-hop questions.&lt;/li>
&lt;li>&lt;strong>Throughput:&lt;/strong> On on-premise hardware with 4×H100 SXM (320 GB, NVLink), a run of 200 evaluations with Llama-3.1-70B takes roughly 8-12 minutes with batch_size=8 and vLLM in continuous batching mode.&lt;/li>
&lt;li>&lt;strong>Cost per evaluation:&lt;/strong> With no external API, the marginal cost is electricity plus GPU amortisation. With 4×H100 at around 3 kW sustained, a run of 200 evaluations costs &amp;lt; 0.10 € in energy at a typical industrial tariff.&lt;/li>
&lt;li>&lt;strong>Recommended frequency:&lt;/strong> weekly offline eval over the full golden dataset plus sampled online eval (5-10 % of production requests) with a subset of metrics that do not require ground truth (faithfulness, answer relevance).&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Alternatives to RAGAS:&lt;/strong> TruLens (evaluation with modular feedbacks), DeepEval (programmatic assertions, pytest integration), ARES (Stanford framework with trained classifiers instead of LLM-as-judge), and the OpenAI evals framework. Each has different trade-offs in judge cost, reliability and ease of integration.&lt;/li>
&lt;li>&lt;strong>Continuous eval in production:&lt;/strong> automatically sampling real requests, anonymising them, running a subset of metrics without ground truth and using the result to detect system drift before users report it. It requires a data pipeline separate from the inference pipeline.&lt;/li>
&lt;li>&lt;strong>Multilingual eval:&lt;/strong> RAGAS with a judge in Spanish or Catalan over a non-English corpus has documented biases when the judge is a model trained fundamentally in English. The semantic similarity embeddings for answer relevance are especially sensitive to corpus language vs judge language.&lt;/li>
&lt;li>&lt;strong>A/B testing of RAG configurations:&lt;/strong> using RAGAS metrics as the success criterion in controlled experiments, chunk size 512 vs 1024, pure BM25 vs hybrid, cross-encoder reranker vs biencoder, with statistical significance computed over the golden dataset.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/ — the LLM judge pattern that RAGAS uses to measure faithfulness claim by claim&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/ — the general evals framework in which RAGAS is the RAG specialisation&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/ — the retrieval layer whose context precision and recall these metrics measure&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/ — the corpus quality that context recall reflects&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/ — the production spans where Langfuse records the RAGAS scores&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/ — the golden dataset is a data artefact that needs versioning just like the corpus&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>Es Shahul, et al. &lt;em>RAGAS: Automated Evaluation of Retrieval Augmented Generation&lt;/em>. arXiv:2309.15217 (2023). &lt;a href="https://arxiv.org/abs/2309.15217">https://arxiv.org/abs/2309.15217&lt;/a>&lt;/li>
&lt;li>RAGAS Documentation v0.2. &lt;em>Metrics Reference&lt;/em>. &lt;a href="https://docs.ragas.io/en/stable/concepts/metrics/">https://docs.ragas.io/en/stable/concepts/metrics/&lt;/a> (accessed June 2026)&lt;/li>
&lt;li>Langfuse. &lt;em>Dataset Experiments&lt;/em>. &lt;a href="https://langfuse.com/docs/datasets/overview">https://langfuse.com/docs/datasets/overview&lt;/a> (accessed June 2026)&lt;/li>
&lt;li>Databricks. &lt;em>LLM Quality Evaluation: From Lab to Production&lt;/em>. Databricks Engineering Blog (2024).&lt;/li>
&lt;li>Saad-Falcon, J. et al. &lt;em>ARES: An Automated Evaluation Framework for Retrieval-Augmented Generation Systems&lt;/em>. arXiv:2311.09476 (2023).&lt;/li>
&lt;li>Goodhart, C.A.E. &lt;em>Problems of Monetary Management: The U.K. Experience&lt;/em>. Papers in Monetary Economics. Reserve Bank of Australia (1975). Modern formulation of the law that bears his name.&lt;/li>
&lt;li>vLLM Project. &lt;em>OpenAI-Compatible Server&lt;/em>. &lt;a href="https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html">https://docs.vllm.ai/en/stable/serving/openai_compatible_server.html&lt;/a> (accessed June 2026)&lt;/li>
&lt;/ol></description></item></channel></rss>