<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kantemir Satibalov</title>
    <description>The latest articles on DEV Community by Kantemir Satibalov (@kantik001).</description>
    <link>https://dev.to/kantik001</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4009665%2Fb646b101-b5c6-4005-98d8-2fe3930bb3a7.png</url>
      <title>DEV Community: Kantemir Satibalov</title>
      <link>https://dev.to/kantik001</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kantik001"/>
    <language>en</language>
    <item>
      <title>The rate limiter that was "on" without Redis — and panicking our smoke tests</title>
      <dc:creator>Kantemir Satibalov</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:46:55 +0000</pubDate>
      <link>https://dev.to/kantik001/the-rate-limiter-that-was-on-without-redis-and-panicking-our-smoke-tests-30g0</link>
      <guid>https://dev.to/kantik001/the-rate-limiter-that-was-on-without-redis-and-panicking-our-smoke-tests-30g0</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setting
&lt;/h2&gt;

&lt;p&gt;I build &lt;a href="https://github.com/kantik001/grounded-llm" rel="noopener noreferrer"&gt;Grounded LLM&lt;/a&gt; — an open-source platform for cited, verified document assistants (Go orchestration + Python RAG). For the v0.4 enterprise-hardening push I added a sliding-window rate limiter that could use Redis across replicas, or fall back to an in-process map when Redis was not configured.&lt;/p&gt;

&lt;p&gt;CI runs &lt;code&gt;smoke-api&lt;/code&gt; / load-smoke against a Compose stack. Sometimes Redis is up for caching; sometimes a path boots without a live Redis client. The contract was simple: &lt;strong&gt;no Redis → memory limiter → no panic&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That contract quietly broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Sign
&lt;/h2&gt;

&lt;p&gt;Green unit tests. Red CI.&lt;/p&gt;

&lt;p&gt;Smoke jobs started failing on basic &lt;code&gt;POST /session&lt;/code&gt; with a Go panic deep in the rate-limit path — not a polite &lt;code&gt;429&lt;/code&gt;, not a logged Redis timeout, a hard crash. Locally with Redis everything looked fine. In the failing job profile, Redis was effectively &lt;strong&gt;absent&lt;/strong&gt;, yet logs hinted that the Redis backend had been &lt;strong&gt;enabled&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Classic heisenbug: works on my machine (Redis running), blows up in CI (typed-nil client handed to the limiter).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Investigation
&lt;/h2&gt;

&lt;p&gt;First suspects:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Wrong env / missing &lt;code&gt;REDIS_URL&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Race in the in-memory map&lt;/li&gt;
&lt;li&gt;Gin middleware order&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None matched. The panic was on Redis command methods, and the limiter thought &lt;code&gt;rdb != nil&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In Go that sentence is a trap.&lt;/p&gt;

&lt;p&gt;I had something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;llmRedis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cmdable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c"&gt;// *redis.Client, often nil&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;llm.Redis()&lt;/code&gt; returns &lt;code&gt;*redis.Client&lt;/code&gt;. When Redis is not initialized, that pointer is &lt;code&gt;nil&lt;/code&gt;. We returned it as &lt;code&gt;redis.Cmdable&lt;/code&gt; (an interface).&lt;/p&gt;

&lt;p&gt;In Go, an interface value is &lt;code&gt;(type, value)&lt;/code&gt;. A &lt;strong&gt;nil concrete pointer&lt;/strong&gt; boxed into an interface is &lt;strong&gt;not&lt;/strong&gt; a nil interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cmdable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;  &lt;span class="c"&gt;// false !&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;WithRedis(c)&lt;/code&gt; saw a non-nil interface, set &lt;code&gt;rl.rdb = c&lt;/code&gt;, logged "Redis backend enabled", and on the first request called into methods on a nil &lt;code&gt;*redis.Client&lt;/code&gt; → &lt;strong&gt;panic&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The memory fallback never ran: the code path was "Redis is configured", not "Redis call failed".&lt;/p&gt;

&lt;h2&gt;
  
  
  The Root Cause
&lt;/h2&gt;

&lt;p&gt;Go's &lt;strong&gt;typed-nil / nil interface&lt;/strong&gt; footgun, applied to &lt;code&gt;github.com/redis/go-redis&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;No Redis client created → &lt;code&gt;*redis.Client == nil&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Function returns that pointer as &lt;code&gt;redis.Cmdable&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Interface is non-nil → limiter enables Redis mode&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;First &lt;code&gt;allow()&lt;/code&gt; hits Redis API on nil client → panic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;CI smoke dies; local Redis hides the bug&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bug was not "Redis is flaky". The bug was &lt;strong&gt;lying about having Redis&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix
&lt;/h2&gt;

&lt;p&gt;Two layers of defense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Don't box a typed nil at the source&lt;/strong&gt; (&lt;code&gt;server/internal/app/llm_bridge.go&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;llmRedis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cmdable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="c"&gt;// true nil interface&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Refuse typed-nil in &lt;code&gt;WithRedis&lt;/code&gt;&lt;/strong&gt; (&lt;code&gt;server/internal/httpapi/ratelimit.go&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rl&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;RateLimiter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WithRedis&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cmdable&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;RateLimiter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rl&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rl&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;rc&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rl&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;rl&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rdb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rl&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus a regression test: box a typed-nil &lt;code&gt;*redis.Client&lt;/code&gt; and assert the memory backend stays active (&lt;code&gt;ratelimit_redis_nil_test.go&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; smoke-api red, panic on first rate-limited request without Redis.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;After:&lt;/strong&gt; smoke-api green; without Redis → memory limiter; with Redis → shared counters across replicas.&lt;/p&gt;

&lt;p&gt;Merged as part of enterprise hardening in &lt;a href="https://github.com/kantik001/grounded-llm" rel="noopener noreferrer"&gt;grounded-llm&lt;/a&gt; (v0.4.0 line).&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm proud of
&lt;/h2&gt;

&lt;p&gt;CI caught it before prod. The fix is small but teaches a rule we now apply everywhere optional clients are passed as interfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;x == nil&lt;/code&gt; on an interface is not enough&lt;/strong&gt; when &lt;code&gt;x&lt;/code&gt; might hold a typed nil.&lt;/li&gt;
&lt;li&gt;Optional infrastructure needs an explicit enabled signal, or a concrete nil check before enabling the backend.&lt;/li&gt;
&lt;li&gt;Smoke tests &lt;strong&gt;without&lt;/strong&gt; Redis are as valuable as tests with Redis — they exercise the fallback contract.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you ship Go services with optional Redis, audit every &lt;code&gt;func() SomeInterface { return concretePtr }&lt;/code&gt;. Your CI might already be one typed-nil away from a panic.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>redis</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Grounded LLM v0.3.0 shipped — and I finally drew an architecture diagram I'm proud of</title>
      <dc:creator>Kantemir Satibalov</dc:creator>
      <pubDate>Mon, 27 Jul 2026 13:59:09 +0000</pubDate>
      <link>https://dev.to/kantik001/grounded-llm-v030-shipped-and-i-finally-drew-an-architecture-diagram-im-proud-of-143l</link>
      <guid>https://dev.to/kantik001/grounded-llm-v030-shipped-and-i-finally-drew-an-architecture-diagram-im-proud-of-143l</guid>
      <description>&lt;p&gt;Three weeks ago I published &lt;a href="https://dev.to/kantik001/building-an-open-standard-for-grounded-document-assistants-2h6e"&gt;Building an open standard for grounded document assistants&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Back then, Grounded LLM was &lt;strong&gt;v0.1.0&lt;/strong&gt; — a credible reference implementation with a spec, conformance CLI, hybrid retrieval, and &lt;strong&gt;89&lt;/strong&gt; eval cases in CI.&lt;/p&gt;

&lt;p&gt;Honest take: it was the right &lt;em&gt;story&lt;/em&gt;. But it still felt like a very good &lt;strong&gt;v1 of the idea&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Since then I shipped &lt;strong&gt;v0.2.0&lt;/strong&gt; (production hardening) and, this week, &lt;strong&gt;&lt;a href="https://github.com/kantik001/grounded-llm/releases/tag/v0.3.0" rel="noopener noreferrer"&gt;v0.3.0&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This post is not a changelog dump. It's what changed in my head while building — and why I think enterprise RAG needs a diagram like this, not another demo GIF.&lt;/p&gt;




&lt;h2&gt;
  
  
  The moment I knew v0.3 was different
&lt;/h2&gt;

&lt;p&gt;I was testing the same HR question for the fifth time:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"How many paid vacation days do employees get?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;First run: full path — retrieval, LLM, verify, citations. ~4 seconds on local Ollama.&lt;/p&gt;

&lt;p&gt;Second run: &lt;strong&gt;instant&lt;/strong&gt;. Header: &lt;code&gt;X-Cache: HIT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Same answer. Same citations. No tokens burned.&lt;/p&gt;

&lt;p&gt;That's when it stopped being "a RAG repo with good docs" and started feeling like &lt;strong&gt;infrastructure&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not because caching is novel. Because the whole system — retrieval, verify, cache keys, metrics, agent surface — finally snapped into one picture.&lt;/p&gt;

&lt;p&gt;So I drew it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz9gpan4wbz9a5jnv7wna.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz9gpan4wbz9a5jnv7wna.png" alt="Grounded LLM Architecture v0.3" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Clients → Go orchestration → Python RAG → Storage / Redis / LLM — with a separate agent path over gRPC.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This is the diagram I wanted twelve weeks ago when I was still explaining "grounded" to people who'd only seen ChatGPT wrappers.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Grounded LLM is (30-second version)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Grounded LLM&lt;/strong&gt; is an open platform + &lt;a href="https://github.com/kantik001/grounded-llm/blob/main/docs/en/spec/GROUNDED_SPEC_v1.md" rel="noopener noreferrer"&gt;Grounded Spec v1&lt;/a&gt; for &lt;strong&gt;document-grounded assistants&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;answers &lt;strong&gt;only from your knowledge base&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cites sources&lt;/strong&gt; in every response&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;refuses&lt;/strong&gt; when retrieval can't support an answer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;verifies numbers&lt;/strong&gt; against retrieved context (±0.01)&lt;/li&gt;
&lt;li&gt;runs &lt;strong&gt;on infrastructure you control&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;ships with &lt;strong&gt;measurable retrieval quality&lt;/strong&gt; — not "looked fine in a notebook"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Positioning line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Open standard for document-grounded assistants with citations, numeric verify, and measurable retrieval quality — deployable on your infrastructure.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Non-goals:&lt;/strong&gt; arbitrary agent graphs, general chat without KB, cloud lock-in, Glean feature parity.&lt;/p&gt;

&lt;p&gt;We compete on &lt;strong&gt;trust + reproducible quality + conformance&lt;/strong&gt; — not widget count.&lt;/p&gt;

&lt;p&gt;Landing: &lt;a href="https://kantik001.github.io/grounded-llm/" rel="noopener noreferrer"&gt;kantik001.github.io/grounded-llm&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  v0.1 → v0.3 in one table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;v0.1&lt;/strong&gt; (first DEV post)&lt;/th&gt;
&lt;th&gt;
&lt;strong&gt;v0.3.0&lt;/strong&gt; (today)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Story&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;"Here's an open standard"&lt;/td&gt;
&lt;td&gt;"Here's a deployable platform"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Retrieval eval&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;89 cases&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;99&lt;/strong&gt; cases (+ adversarial near-miss)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cloud API&lt;/td&gt;
&lt;td&gt;Cloud &lt;strong&gt;or&lt;/strong&gt; Ollama &lt;strong&gt;or&lt;/strong&gt; vLLM — env switch only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Caching&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Redis: embeddings + semantic response cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Agent surface&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;HTTP only&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;gRPC Retriever&lt;/strong&gt; on &lt;code&gt;:50051&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Observability&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Prometheus: &lt;code&gt;llm_tokens_*&lt;/code&gt;, TTFT, latency, cache hits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Images&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;GHCR &lt;code&gt;:0.3.0&lt;/code&gt; for server, python, webapp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hardening&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Prod fail-fast, Trivy, backup smoke in CI, Helm probes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The spec didn't change its soul. The &lt;strong&gt;reference implementation grew teeth&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Walk through the diagram (the fun part)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Clients — four doors, one trust boundary
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Web chat&lt;/strong&gt; — reference UI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python SDK / REST&lt;/strong&gt; — integrators&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram Mini App&lt;/strong&gt; — optional field channel&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agents (gRPC)&lt;/strong&gt; — the new door in v0.3&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same platform. Different entry points. Go still owns auth, sessions, and policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Go server &lt;code&gt;:8080&lt;/code&gt; — orchestration, not retrieval
&lt;/h3&gt;

&lt;p&gt;Go does what enterprises actually audit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Module&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Auth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;API keys, Telegram WebApp, OIDC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;REST API v1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;OpenAPI contract, multi-tenant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM orchestration&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Provider routing, streaming, cache lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Numeric verify&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reject answers with numbers not in context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Admin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;KB upload, reindex jobs, RBAC&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Python does retrieval. Go does trust. &lt;strong&gt;On purpose.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Python RAG — hybrid retrieval + agent contract
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP  :5000  →  /rag/context     (chat path)
gRPC  :50051 →  Retriever/Retrieve (agent path)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hybrid BM25 + dense + RRF&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Vector backends: &lt;strong&gt;Chroma / Qdrant / pgvector&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Optional reranker (keyword or cross-encoder)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Agents don't need to reimplement your chunking strategy. They call the same retrieval the chat UI uses — with a stable protobuf contract (&lt;code&gt;grounded.rag.v1&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Redis &lt;code&gt;:6379&lt;/code&gt; — the silent hero
&lt;/h3&gt;

&lt;p&gt;Two caches, two lifetimes, two owners:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cache&lt;/th&gt;
&lt;th&gt;Key pattern&lt;/th&gt;
&lt;th&gt;TTL&lt;/th&gt;
&lt;th&gt;Who writes&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Embeddings&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;embedding:{md5}:{model}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1h&lt;/td&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rag_embedding_cache_hit_total&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LLM responses&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;response:{md5}:{model}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;24h&lt;/td&gt;
&lt;td&gt;Go&lt;/td&gt;
&lt;td&gt;HTTP `X-Cache: HIT\&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Why this matters for HR/legal assistants:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repeat policy questions are &lt;strong&gt;free&lt;/strong&gt; after the first hit&lt;/li&gt;
&lt;li&gt;Re-indexing doesn't silently change cached answers (keys include domain/tenant/model)&lt;/li&gt;
&lt;li&gt;You can &lt;strong&gt;prove&lt;/strong&gt; cache behavior in HTTP traces — procurement teams love receipts&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. LLM — one interface, three realities
&lt;/h3&gt;

&lt;p&gt;{% raw %}&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;LLM_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;openai   &lt;span class="c"&gt;# OpenRouter / any OpenAI-compatible cloud&lt;/span&gt;
&lt;span class="nv"&gt;LLM_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ollama   &lt;span class="c"&gt;# docker compose --profile ollama&lt;/span&gt;
&lt;span class="nv"&gt;LLM_PROVIDER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;vllm     &lt;span class="c"&gt;# docker compose --profile vllm  (NVIDIA)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No code forks. No "enterprise edition" switch. &lt;strong&gt;Env vars + Compose profiles.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the on-prem story in one sentence: &lt;em&gt;your documents, your GPUs, your audit trail.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The quality gate nobody demos on Twitter
&lt;/h2&gt;

&lt;p&gt;Grounded LLM ships &lt;strong&gt;99 retrieval eval cases&lt;/strong&gt; across EN/RU/IT/Legal/Adversarial suites.&lt;/p&gt;

&lt;p&gt;CI job &lt;code&gt;eval-retrieval-gate&lt;/code&gt; runs on &lt;strong&gt;every PR&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python scripts/run_rag_eval.py &lt;span class="nt"&gt;--suite&lt;/span&gt; all
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"question"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"How many paid vacation days?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expect_contains"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"28"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"expect_context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"28"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hits Python RAG directly — &lt;strong&gt;no LLM tokens burned&lt;/strong&gt; on the hot path.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Suite&lt;/th&gt;
&lt;th&gt;Cases&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EN HR baseline&lt;/td&gt;
&lt;td&gt;21&lt;/td&gt;
&lt;td&gt;Paraphrase drift&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Adversarial&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;Wrong numbers, cross-domain, injection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IT / Legal templates&lt;/td&gt;
&lt;td&gt;29&lt;/td&gt;
&lt;td&gt;Pack-specific retrieval&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hybrid regression&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;BM25+RRF breakage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Retrieval accuracy in CI: 100% (99/99).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'd rather fail a PR than fail a payroll question.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conformance: prove it, don't pitch it
&lt;/h2&gt;

&lt;p&gt;From day one the bet was: &lt;strong&gt;publish rules + tests anyone can run&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; conformance/requirements.txt
python &lt;span class="nt"&gt;-m&lt;/span&gt; conformance spec          &lt;span class="c"&gt;# offline OpenAPI contract&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; conformance check &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your deployment is &lt;a href="https://github.com/kantik001/grounded-llm/blob/main/docs/en/rfcs/RFC-0001-grounded-compatible.md" rel="noopener noreferrer"&gt;Grounded-compatible&lt;/a&gt;, these pass without forking my repo.&lt;/p&gt;

&lt;p&gt;v0.3 didn't replace the spec. It made the reference implementation &lt;strong&gt;harder to dismiss as a demo&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick start (15 minutes, not 15 days)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kantik001/grounded-llm.git
&lt;span class="nb"&gt;cd &lt;/span&gt;grounded-llm
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env

&lt;span class="c"&gt;# Cloud LLM (default)&lt;/span&gt;
&lt;span class="c"&gt;# LLM_API_KEY=...&lt;/span&gt;

&lt;span class="c"&gt;# Or local CPU inference:&lt;/span&gt;
&lt;span class="c"&gt;# LLM_PROVIDER=ollama&lt;/span&gt;
&lt;span class="c"&gt;# docker compose --profile ollama up -d --build&lt;/span&gt;

docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
python scripts/reindex_rag.py

pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; conformance/requirements.txt
python &lt;span class="nt"&gt;-m&lt;/span&gt; conformance spec
python &lt;span class="nt"&gt;-m&lt;/span&gt; conformance check &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Web UI&lt;/td&gt;
&lt;td&gt;&lt;a href="http://localhost/" rel="noopener noreferrer"&gt;http://localhost/&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;&lt;a href="http://localhost:8080" rel="noopener noreferrer"&gt;http://localhost:8080&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Metrics&lt;/td&gt;
&lt;td&gt;&lt;a href="http://localhost:8080/metrics" rel="noopener noreferrer"&gt;http://localhost:8080/metrics&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gRPC Retriever&lt;/td&gt;
&lt;td&gt;&lt;code&gt;localhost:50051&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Container images:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull ghcr.io/kantik001/grounded-llm-server:0.3.0
docker pull ghcr.io/kantik001/grounded-llm-python:0.3.0
docker pull ghcr.io/kantik001/grounded-llm-webapp:0.3.0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Template packs (HR, IT Support, Legal FAQ):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python scripts/init_pack.py &lt;span class="nb"&gt;install &lt;/span&gt;hr
python scripts/reindex_rag.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Where this sits vs. the industry (still true in v0.3)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Product&lt;/th&gt;
&lt;th&gt;Focus&lt;/th&gt;
&lt;th&gt;Grounded LLM difference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NotebookLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Research / consumer grounding&lt;/td&gt;
&lt;td&gt;Enterprise on-prem, API contract, CI gates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vertex AI Search&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Managed cloud retrieval&lt;/td&gt;
&lt;td&gt;Self-hosted, MIT core, conformance badge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;LangChain / agent frameworks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Composition flexibility&lt;/td&gt;
&lt;td&gt;Narrow standard: &lt;strong&gt;cited document Q&amp;amp;A&lt;/strong&gt; with verify + eval&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'm not competing on "build any agent." I'm saying: when procurement asks &lt;em&gt;"is your internal assistant grounded and testable?"&lt;/em&gt; — there should be a &lt;strong&gt;published spec, a CLI, and a retrieval gate&lt;/strong&gt; — not a vendor slide.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;External conformance adopters&lt;/strong&gt; — run the CLI on your deploy, open issues for Spec v2&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;grounded-agent&lt;/strong&gt; — ReAct loop on top of gRPC Retriever + MCP Gateway&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load tests&lt;/strong&gt; — prove the cache + hybrid path under concurrency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you work on &lt;strong&gt;enterprise RAG, on-prem LLM, or OSS conformance&lt;/strong&gt; — I'd genuinely value feedback on &lt;a href="https://github.com/kantik001/grounded-llm/blob/main/docs/en/rfcs/RFC-0001-grounded-compatible.md" rel="noopener noreferrer"&gt;RFC-0001&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Call to action
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Try v0.3.0:&lt;/strong&gt; &lt;a href="https://github.com/kantik001/grounded-llm/releases/tag/v0.3.0" rel="noopener noreferrer"&gt;Release notes&lt;/a&gt; · &lt;code&gt;docker compose up&lt;/code&gt; or pull GHCR &lt;code&gt;:0.3.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Star / watch&lt;/strong&gt; if enterprise grounding interests you: &lt;a href="https://github.com/kantik001/grounded-llm" rel="noopener noreferrer"&gt;github.com/kantik001/grounded-llm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run conformance&lt;/strong&gt; on your stack and tell me what belongs in Spec v2&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contribute an eval case&lt;/strong&gt; when you fix a retrieval bug — see &lt;a href="https://github.com/kantik001/grounded-llm/blob/main/GOOD_FIRST_ISSUES.md" rel="noopener noreferrer"&gt;GOOD_FIRST_ISSUES.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read Part 1&lt;/strong&gt; if you missed the origin story: &lt;a href="https://dev.to/kantik001/building-an-open-standard-for-grounded-document-assistants-2h6e"&gt;Building an open standard for grounded document assistants&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What is it?&lt;/td&gt;
&lt;td&gt;Open platform + Spec v1 for cited, verified document assistants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What changed since Part 1?&lt;/td&gt;
&lt;td&gt;Local LLMs, Redis caches, gRPC Retriever, 99-eval gate, GHCR images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What is it not?&lt;/td&gt;
&lt;td&gt;Agent builder, ChatGPT clone, Glean competitor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why the diagram?&lt;/td&gt;
&lt;td&gt;Because enterprise RAG is a &lt;strong&gt;system&lt;/strong&gt; — and systems deserve diagrams you want to show people&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What's the bet?&lt;/td&gt;
&lt;td&gt;Checkable standard any team can implement on their own infra&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I started with my father's horticulture papers.&lt;/p&gt;

&lt;p&gt;v0.1 was the manifesto.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v0.3 is the platform I'd deploy in a real pilot.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;MIT · &lt;a href="https://github.com/kantik001/grounded-llm/blob/main/docs/en/spec/GROUNDED_SPEC_v1.md" rel="noopener noreferrer"&gt;Grounded Spec v1&lt;/a&gt; · &lt;a href="https://kantik001.github.io/grounded-llm/" rel="noopener noreferrer"&gt;Landing&lt;/a&gt; · &lt;a href="https://github.com/kantik001/grounded-llm/blob/main/docs/assets/architecture.png" rel="noopener noreferrer"&gt;Architecture source PNG&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>rag</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Building an MCP Gateway in Go: Bridging AI Agents and JSON-RPC Tools</title>
      <dc:creator>Kantemir Satibalov</dc:creator>
      <pubDate>Sun, 26 Jul 2026 17:50:50 +0000</pubDate>
      <link>https://dev.to/kantik001/building-an-mcp-gateway-in-go-bridging-ai-agents-and-json-rpc-tools-24l4</link>
      <guid>https://dev.to/kantik001/building-an-mcp-gateway-in-go-bridging-ai-agents-and-json-rpc-tools-24l4</guid>
      <description>&lt;p&gt;The Model Context Protocol (MCP) from Anthropic is becoming the standard for connecting LLM agents to external tools. But there's a gap: &lt;strong&gt;MCP servers speak JSON-RPC over stdio, while agents and orchestrators usually speak HTTP.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I built &lt;code&gt;mcp-gateway&lt;/code&gt; to bridge that gap once — a production-minded Go service that registers MCP servers, proxies tool calls over HTTP, and exposes Prometheus metrics. Here's how it works and why I made the architectural choices I did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;MCP is great for tool interoperability. You install an MCP server (filesystem, database, web fetch), and any MCP-compatible agent can use it. But:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every agent needs an MCP client embedded&lt;/li&gt;
&lt;li&gt;JSON-RPC over stdio is hard to debug and monitor&lt;/li&gt;
&lt;li&gt;No centralized health checks, retries, or metrics&lt;/li&gt;
&lt;li&gt;Running 10 MCP servers means 10 subprocesses to manage&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wanted a single HTTP entry point: agents send JSON, gateway routes to the right MCP server, and I get observability for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fziqw7n7egx9m3ybf9874.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fziqw7n7egx9m3ybf9874.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Design Decisions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. In-Memory Registry (for MVP)
&lt;/h3&gt;

&lt;p&gt;I chose an in-memory registry over PostgreSQL for the MVP. Why?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MCP servers are &lt;strong&gt;subprocesses&lt;/strong&gt; — their state lives in memory anyway&lt;/li&gt;
&lt;li&gt;Adding a database doesn't solve failover (if the gateway crashes, subprocesses die)&lt;/li&gt;
&lt;li&gt;The real bottleneck is stdio IPC, not registry lookups&lt;/li&gt;
&lt;li&gt;PostgreSQL is in the roadmap for multi-tenant deployments&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. stdio Transport
&lt;/h3&gt;

&lt;p&gt;MCP servers are typically distributed as CLI tools (&lt;code&gt;npx&lt;/code&gt;, &lt;code&gt;uvx&lt;/code&gt;). stdio is the most compatible transport. The gateway:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Spawns the process on register&lt;/li&gt;
&lt;li&gt;Sends JSON-RPC via stdin&lt;/li&gt;
&lt;li&gt;Reads responses from stdout&lt;/li&gt;
&lt;li&gt;Kills the process on shutdown (with 3s grace period)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Error Semantics
&lt;/h3&gt;

&lt;p&gt;MCP distinguishes between &lt;strong&gt;transport errors&lt;/strong&gt; (server down, JSON-RPC broken) and &lt;strong&gt;tool errors&lt;/strong&gt; (file not found, invalid query):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Transport/RPC errors → &lt;strong&gt;HTTP 502&lt;/strong&gt; + log&lt;/li&gt;
&lt;li&gt;Tool execution errors (&lt;code&gt;isError: true&lt;/code&gt;) → &lt;strong&gt;HTTP 200&lt;/strong&gt; + metric &lt;code&gt;mcp_tool_calls_total{status="error"}&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This mirrors how HTTP proxies work: the gateway is healthy even if the upstream tool fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Code
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Declarative Configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/servers.yaml&lt;/span&gt;
&lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filesystem&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@modelcontextprotocol/server-filesystem"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/data"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fetch&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx&lt;/span&gt;
    &lt;span class="na"&gt;args&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-y"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@modelcontextprotocol/server-fetch"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  HTTP Proxy Handler
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;CallTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URLParam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tool&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;chi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URLParam&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tool"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"server not found"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;CallToolRequest&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"invalid JSON"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c"&gt;// RPC/transport error → 502&lt;/span&gt;
        &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusBadGateway&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Graceful Shutdown
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stop&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotifyContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Interrupt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c"&gt;// Start HTTP server&lt;/span&gt;
&lt;span class="n"&gt;srv&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;Addr&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;":"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListenAndServe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}()&lt;/span&gt;

&lt;span class="c"&gt;// Wait for signal&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Done&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c"&gt;// 1. Stop accepting new connections&lt;/span&gt;
&lt;span class="n"&gt;shutdownCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;srv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shutdownCtx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// 2. Stop health loop&lt;/span&gt;
&lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StopHealthChecks&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c"&gt;// 3. Close all MCP clients (kills subprocesses)&lt;/span&gt;
&lt;span class="n"&gt;reg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Observability
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Tool call volume&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:8080/metrics | &lt;span class="nb"&gt;grep &lt;/span&gt;mcp_tool_calls_total

&lt;span class="c"&gt;# Server health&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:8080/metrics | &lt;span class="nb"&gt;grep &lt;/span&gt;mcp_server_up

&lt;span class="c"&gt;# Request latency&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:8080/metrics | &lt;span class="nb"&gt;grep &lt;/span&gt;mcp_tool_call_duration_seconds
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every request gets a request ID injected into &lt;code&gt;slog&lt;/code&gt; and propagated through context. Logs are structured JSON — no parsing regex in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kantik001/mcp-gateway.git
&lt;span class="nb"&gt;cd &lt;/span&gt;mcp-gateway
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# Test it&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:8080/v1/servers
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/v1/servers/filesystem/tools/read_file   &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt;   &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"args":{"path":"/data/README.md"}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;The MCP client layer has &lt;strong&gt;70.6% test coverage&lt;/strong&gt;, including mock subprocess tests. The CI gate requires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All tests passing&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;golangci-lint&lt;/code&gt; clean&lt;/li&gt;
&lt;li&gt;Coverage ≥ 60% on &lt;code&gt;internal/mcp&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make &lt;span class="nb"&gt;test
&lt;/span&gt;make coverage
make lint
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Postgres-backed registry for multi-tenant deployments&lt;/li&gt;
&lt;li&gt;Redis tool-result cache&lt;/li&gt;
&lt;li&gt;OpenTelemetry traces&lt;/li&gt;
&lt;li&gt;SSE streaming for long-running tools&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/kantik001/mcp-gateway" rel="noopener noreferrer"&gt;github.com/kantik001/mcp-gateway&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;License:&lt;/strong&gt; Apache 2.0&lt;/li&gt;
&lt;li&gt;I'm open to Senior AI Infrastructure Engineer roles (remote / EU) — &lt;a href="mailto:kantik001@yandex.ru"&gt;email me&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;What patterns do you use for bridging LLM agents to external tools? Let's discuss in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>ai</category>
      <category>gateway</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Building an open standard for grounded document assistants</title>
      <dc:creator>Kantemir Satibalov</dc:creator>
      <pubDate>Wed, 15 Jul 2026 12:48:38 +0000</pubDate>
      <link>https://dev.to/kantik001/building-an-open-standard-for-grounded-document-assistants-2h6e</link>
      <guid>https://dev.to/kantik001/building-an-open-standard-for-grounded-document-assistants-2h6e</guid>
      <description>&lt;p&gt;Last week I published how I made ~500 horticulture papers queryable without hallucination (&lt;a href="https://dev.to/kantik001/my-father-wrote-the-papers-i-built-a-rag-assistant-so-growers-can-query-them-safely-1hi"&gt;passion project on DEV&lt;/a&gt;). That vertical worked. This post is about what came next: &lt;strong&gt;extracting the repeatable parts into an open platform — and publishing a spec other teams can conform to.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The problem nobody demos on Twitter
&lt;/h2&gt;

&lt;p&gt;Enterprise teams don't need another ChatGPT wrapper. They need assistants that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Answer &lt;strong&gt;only from internal documents&lt;/strong&gt; (policies, handbooks, runbooks).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cite sources&lt;/strong&gt; — filename + chunk — in every response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refuse&lt;/strong&gt; when retrieval cannot support an answer.&lt;/li&gt;
&lt;li&gt;Run &lt;strong&gt;on infrastructure they control&lt;/strong&gt; (Docker, K8s, private cloud).&lt;/li&gt;
&lt;li&gt;Ship with &lt;strong&gt;measurable quality&lt;/strong&gt; — not "it looked fine in a notebook once."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I learned this building for scientific PDFs. The interesting work was never the chat bubble. It was making archives &lt;strong&gt;answerable without lying&lt;/strong&gt; — and proving retrieval quality &lt;strong&gt;before&lt;/strong&gt; burning LLM tokens.&lt;/p&gt;

&lt;p&gt;That discipline became &lt;strong&gt;&lt;a href="https://github.com/kantik001/grounded-llm" rel="noopener noreferrer"&gt;Grounded LLM&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'm trying to standardize (and what I'm not)
&lt;/h2&gt;

&lt;p&gt;I'm not building Dify, LangGraph, or a visual agent constructor.&lt;/p&gt;

&lt;p&gt;I'm standardizing &lt;strong&gt;one narrow class of systems&lt;/strong&gt;:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Document-grounded assistants&lt;/strong&gt; — internal Q&amp;amp;A with citations, numeric verification, and regression-tested retrieval.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The positioning line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Open standard for document-grounded assistants with citations, numeric verify, and measurable retrieval quality — deployable on your infrastructure.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Non-goals (by design):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Arbitrary tool/agent graphs&lt;/li&gt;
&lt;li&gt;General chat without a knowledge base&lt;/li&gt;
&lt;li&gt;Cloud-only lock-in&lt;/li&gt;
&lt;li&gt;Feature parity with Glean or Microsoft Copilot SaaS&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We compete on &lt;strong&gt;trust + reproducible quality + conformance&lt;/strong&gt; — not feature count.&lt;/p&gt;




&lt;h2&gt;
  
  
  Five pillars of the standard
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pillar&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Today in the repo&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1. Spec &amp;amp; conformance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Published rules + tests anyone can run&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/kantik001/grounded-llm/blob/main/docs/en/spec/GROUNDED_SPEC_v1.md" rel="noopener noreferrer"&gt;Grounded Spec v1&lt;/a&gt;, &lt;code&gt;python -m conformance&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2. Quality science&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Numbers, not demos&lt;/td&gt;
&lt;td&gt;89 retrieval cases, &lt;strong&gt;retrieval gate in CI&lt;/strong&gt;, adversarial pack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3. Reference deploy&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reproducible install&lt;/td&gt;
&lt;td&gt;Docker, Helm, Terraform (AWS/GCP/Azure)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;4. Template marketplace&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Growth without forking core&lt;/td&gt;
&lt;td&gt;HR, IT Support, Legal FAQ packs + registry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;5. Governance&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Standard outlives one author&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/kantik001/grounded-llm/blob/main/docs/en/RFC.md" rel="noopener noreferrer"&gt;RFC process&lt;/a&gt;, &lt;a href="https://github.com/kantik001/grounded-llm/blob/main/docs/en/rfcs/RFC-0001-grounded-compatible.md" rel="noopener noreferrer"&gt;RFC-0001 Grounded-compatible&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Horizon 1 success metric: &lt;strong&gt;any engineer runs conformance on a fresh deploy in under 15 minutes.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What the platform is today (not a slide deck)
&lt;/h2&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://kantik001.github.io/grounded-llm/" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;kantik001.github.io&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;&lt;a href="https://github.com/kantik001/grounded-llm/releases/tag/v0.1.0" rel="noopener noreferrer"&gt;Grounded LLM v0.1.0&lt;/a&gt; is the first tagged release — reference implementation of Grounded Spec v1 with hybrid retrieval (BM25 + dense + RRF), pgvector/Chroma/Qdrant backends, 89 retrieval eval cases in CI, and published GHCR images (&lt;code&gt;ghcr.io/kantik001/grounded-llm-*:0.1.0&lt;/code&gt;). Landing page: &lt;a href="https://kantik001.github.io/grounded-llm/" rel="noopener noreferrer"&gt;https://kantik001.github.io/grounded-llm/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;After 11 delivery phases merged to &lt;code&gt;main&lt;/code&gt;, this is a &lt;strong&gt;working reference implementation&lt;/strong&gt;, not a manifesto.&lt;/p&gt;

&lt;h3&gt;
  
  
  Architecture
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Clients (Web / API / Telegram / embed widget)
        ↓
Go server — auth, sessions, LLM, verify, admin, quotas, OIDC/RBAC
        ↓ POST /rag/context
Python RAG — hybrid BM25 + dense + RRF; Chroma / Qdrant / pgvector
        ↓
data/{tenant}/{domain}/  +  Postgres (sessions, audit; pgvector optional)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Split on purpose:&lt;/strong&gt; Go owns trust boundaries and orchestration; Python owns retrieval only.&lt;/p&gt;

&lt;h3&gt;
  
  
  What ships out of the box
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Capability&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Citations in every answer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Audit trail for HR/legal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Numeric verify layer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dosages, vacation days, SLA numbers must match retrieved context&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Retrieval eval gate in CI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Catches silent RAG regressions on every PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-tenant API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;X-Tenant-ID&lt;/code&gt;, API keys, OpenAPI v1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Enterprise admin&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;RBAC, OIDC SSO, audit log, async reindex, analytics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Template packs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;python scripts/init_pack.py install hr&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ingest connectors&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SharePoint, Google Drive, Confluence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Conformance CLI&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Offline spec check + live HTTP check against any deployment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Embeddable widget&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Intranet embed, not only Telegram&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Quick start
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kantik001/grounded-llm.git
&lt;span class="nb"&gt;cd &lt;/span&gt;grounded-llm
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; conformance/requirements.txt
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; conformance spec          &lt;span class="c"&gt;# offline OpenAPI contract&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; conformance check &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your product is &lt;strong&gt;Grounded-compatible&lt;/strong&gt;, these tests should pass without forking my codebase.&lt;/p&gt;




&lt;h2&gt;
  
  
  From one vertical to a platform (the story arc)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Horticulture proof&lt;/th&gt;
&lt;th&gt;Grounded LLM platform&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Repo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/kantik001/grounded_horticulture_en" rel="noopener noreferrer"&gt;grounded_horticulture_en&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/kantik001/grounded-llm" rel="noopener noreferrer"&gt;grounded-llm&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Domain&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Apple rootstocks, disease IDs&lt;/td&gt;
&lt;td&gt;Any internal documents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Retrieval&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tuned on ~500 papers, eval 68/68&lt;/td&gt;
&lt;td&gt;Hybrid BM25+RRF + 89 eval cases, CI gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Deliverable&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vertical proof + passion story&lt;/td&gt;
&lt;td&gt;v0.1.0 spec + conformance + packs + deploy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The horticulture project answered: &lt;em&gt;"Can we make scientific PDFs queryable safely?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Grounded LLM answers: &lt;em&gt;"Can we ship the next assistant in days without rebuilding auth, verify, eval, and deploy?"&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Media: what to show
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Screenshot 1 — Chat with citations
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvce50a5646p4npt00hnm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvce50a5646p4npt00hnm.png" alt=" " width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4dp1a9mzkrk2lt22y4k7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4dp1a9mzkrk2lt22y4k7.png" alt=" " width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Screenshot 2 — Conformance CLI
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F721hejjnb4z5jyqsthy5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F721hejjnb4z5jyqsthy5.png" alt=" " width="800" height="722"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;📸 Terminal recording:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; conformance spec
python &lt;span class="nt"&gt;-m&lt;/span&gt; conformance check &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:8080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Screenshot 3 — CI retrieval gate
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvxhpwdtkik21noh384n9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvxhpwdtkik21noh384n9.png" alt=" " width="799" height="556"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Screenshot 4 — Template packs
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuve16kgr0oktmgmie7r5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuve16kgr0oktmgmie7r5.png" alt=" " width="800" height="233"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Screenshot 5 — Admin panel
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkwkal8bd36feq0izk1c4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkwkal8bd36feq0izk1c4.png" alt=" " width="799" height="562"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpx98mds7n3hds7kv9huw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpx98mds7n3hds7kv9huw.png" alt=" " width="800" height="797"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh580fwq2dohi3d76el0w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh580fwq2dohi3d76el0w.png" alt=" " width="799" height="604"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where this sits in the industry (including Google)
&lt;/h2&gt;

&lt;p&gt;Big tech is solving &lt;strong&gt;adjacent&lt;/strong&gt; problems:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Product / area&lt;/th&gt;
&lt;th&gt;Focus&lt;/th&gt;
&lt;th&gt;Grounded LLM difference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;NotebookLM&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Research / consumer grounding on uploads&lt;/td&gt;
&lt;td&gt;We target &lt;strong&gt;enterprise on-prem&lt;/strong&gt;, API contract, CI gates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vertex AI Search&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Managed cloud retrieval&lt;/td&gt;
&lt;td&gt;We target &lt;strong&gt;self-hosted&lt;/strong&gt;, MIT core, conformance badge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gemini + Workspace&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SaaS copilot inside Google&lt;/td&gt;
&lt;td&gt;We target &lt;strong&gt;any LLM endpoint&lt;/strong&gt;, any infra&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I'm not competing with Google on consumer UX. I'm saying: &lt;strong&gt;when procurement asks "is your internal assistant grounded and testable?" — there should be a published spec and CLI answer, not a vendor slide.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you work on &lt;strong&gt;enterprise RAG, OSS standards, or ML platform conformance&lt;/strong&gt; — I'd genuinely value feedback on &lt;a href="https://github.com/kantik001/grounded-llm/blob/main/docs/en/rfcs/RFC-0001-grounded-compatible.md" rel="noopener noreferrer"&gt;RFC-0001&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Call to action
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Try v0.1.0:&lt;/strong&gt; &lt;a href="https://github.com/kantik001/grounded-llm/releases/tag/v0.1.0" rel="noopener noreferrer"&gt;Release notes&lt;/a&gt; · &lt;code&gt;docker compose up&lt;/code&gt; or pull GHCR &lt;code&gt;:0.1.0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Star / watch&lt;/strong&gt; the repo if enterprise grounding interests you: &lt;a href="https://github.com/kantik001/grounded-llm" rel="noopener noreferrer"&gt;github.com/kantik001/grounded-llm&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run conformance&lt;/strong&gt; on your deploy and open an issue if something should be in Spec v2&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contribute an eval case&lt;/strong&gt; when you fix a retrieval bug — see &lt;a href="https://github.com/kantik001/grounded-llm/blob/main/GOOD_FIRST_ISSUES.md" rel="noopener noreferrer"&gt;GOOD_FIRST_ISSUES.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Building in horticulture or another vertical?&lt;/strong&gt; The passion repo is still the deep retrieval story; this platform is the generalization&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What is it?&lt;/td&gt;
&lt;td&gt;Open platform + Spec v1 for cited, verified document assistants&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What is it not?&lt;/td&gt;
&lt;td&gt;Agent builder, ChatGPT clone, Glean competitor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Why now?&lt;/td&gt;
&lt;td&gt;Vertical proof worked; standard + conformance is the multiplier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What's next?&lt;/td&gt;
&lt;td&gt;External conformance adopters, Spec v2 feedback, RFP-grade positioning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I started with my father's papers. I want to end with a &lt;strong&gt;checkable standard&lt;/strong&gt; any team can implement — and prove — on their own infrastructure.&lt;/p&gt;




</description>
      <category>ai</category>
      <category>opensource</category>
      <category>rag</category>
      <category>architecture</category>
    </item>
    <item>
      <title>My father wrote the papers — I built a RAG assistant so growers can query them safely</title>
      <dc:creator>Kantemir Satibalov</dc:creator>
      <pubDate>Fri, 10 Jul 2026 13:02:56 +0000</pubDate>
      <link>https://dev.to/kantik001/my-father-wrote-the-papers-i-built-a-rag-assistant-so-growers-can-query-them-safely-1hi</link>
      <guid>https://dev.to/kantik001/my-father-wrote-the-papers-i-built-a-rag-assistant-so-growers-can-query-them-safely-1hi</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/challenges/weekend-2026-07-09"&gt;Weekend Challenge: Passion Edition&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Gardener's Assistant&lt;/strong&gt; — a grounded RAG chat for horticulture.&lt;/p&gt;

&lt;p&gt;My father spent decades as a plant breeder and researcher (Doctor of Agricultural Sciences, North Caucasus mountain horticulture institute). His articles — and his colleagues' — live in journal PDFs, not in anything a generic LLM can cite reliably. I built an assistant so growers and agronomists can &lt;strong&gt;ask questions and get answers grounded in those papers&lt;/strong&gt;, with numbers verified before they reach the user.&lt;/p&gt;

&lt;p&gt;The intended goal: make scientific horticulture &lt;strong&gt;queryable without hallucination&lt;/strong&gt;. If retrieval cannot support an answer, the bot refuses. It does not invent rootstock codes, spray rates, or cultivar names.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it does today:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Text chat over ~500 scientific articles (apple, pear, plum) via hybrid retrieval&lt;/li&gt;
&lt;li&gt;Telegram Mini App + browser client (Docker, one &lt;code&gt;compose up&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;68-question retrieval regression suite — the gate I run before trusting any LLM output&lt;/li&gt;
&lt;li&gt;Numeric verifier in Go — dosages in the answer must appear in retrieved context&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;Gardener's Assistant chat: three horticulture questions in Russian receive grounded answers from scientific articles via RAG, with streaming response in a Telegram-style UI:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fba44n12cot4xzy32nt7z.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fba44n12cot4xzy32nt7z.gif" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Admin panel: login, upload horticulture articles into the RAG corpus, trigger reindex, and review 👍/👎 answer ratings — filter by likes or dislikes, see totals, and inspect each rated Q&amp;amp;A with crop, timestamp, and session/message ID to trace who gave feedback:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhl62e4sg5fe641lehv3.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnhl62e4sg5fe641lehv3.gif" alt=" " width="760" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GIF:&lt;/strong&gt; see above — &lt;strong&gt;Russian UI and Russian source articles&lt;/strong&gt; (working demo today). English corpus and UI copy are being rolled out next.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Run locally:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/kantik001/grounded_horticulture_en
&lt;span class="nb"&gt;cd &lt;/span&gt;grounded_horticulture_en
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env   &lt;span class="c"&gt;# LLM_API_KEY required&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;→ &lt;strong&gt;Chat:&lt;/strong&gt; &lt;a href="http://localhost/" rel="noopener noreferrer"&gt;http://localhost/&lt;/a&gt;&lt;br&gt;&lt;br&gt;
→ &lt;strong&gt;Admin (article upload):&lt;/strong&gt; &lt;a href="http://localhost/admin.html" rel="noopener noreferrer"&gt;http://localhost/admin.html&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three questions from the recording&lt;/strong&gt; (Russian — matches the indexed corpus):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;em&gt;Какие признаки парши на листьях яблони?&lt;/em&gt; — disease + glossary expansion&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Как густота посадки влияет на Айдаред на подвое СК 4?&lt;/em&gt; — exact identifier (BM25)&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Какие подвои подходят для интенсивного сада на склоне?&lt;/em&gt; — semantic + lexical blend&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;English equivalents&lt;/strong&gt; (for the upcoming EN launch — same retrieval paths, translated articles):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;em&gt;What are signs of apple scab on leaves?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;How does planting density affect Aidared on SK 4 rootstock?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;What rootstocks work for intensive orchards on slopes?&lt;/em&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The public repo ships &lt;strong&gt;demo articles only&lt;/strong&gt; (EN samples for quick start); the full journal corpus stays local for licensing. The pipeline, eval harness, and Docker stack are all there.&lt;/p&gt;


&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;


&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/kantik001" rel="noopener noreferrer"&gt;
        kantik001
      &lt;/a&gt; / &lt;a href="https://github.com/kantik001/grounded_horticulture_en" rel="noopener noreferrer"&gt;
        grounded_horticulture_en
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Grounded RAG horticulture assistant (English public portfolio, demo data only).
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;🍏 grounded-horticulture — horticulture assistant&lt;/h1&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Grounded RAG&lt;/strong&gt; for horticulture: answers grounded in scientific articles with fact checking, not LLM hallucinations. Telegram Mini App and browser chat with API key.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kantik001/grounded_horticulture_en/LICENSE" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/a549a7a30bacba7bfceebdc207a8e86c3f2c02995a2527640dca30048fd2b64e/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f4c6963656e73652d417061636865253230322e302d626c75652e737667" alt="License: Apache 2.0"&gt;&lt;/a&gt;
&lt;a href="https://github.com/kantik001/grounded_horticulture_en/server/" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/42a8119239952bdf2ce6b26d1b1c896936c9f84e80b36c23fa133857bd2a0cd2/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f476f2d312e32332d3030414444383f6c6f676f3d676f266c6f676f436f6c6f723d7768697465" alt="Go"&gt;&lt;/a&gt;
&lt;a href="https://github.com/kantik001/grounded_horticulture_en/api/" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/e7716dbbb101baaff85724da7435b8059c4585b3988b6f7f91a9c29f5d278a7f/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f507974686f6e2d332e31312d3337373641423f6c6f676f3d707974686f6e266c6f676f436f6c6f723d7768697465" alt="Python"&gt;&lt;/a&gt;
&lt;a href="https://github.com/kantik001/grounded_horticulture_en/docker-compose.yml" rel="noopener noreferrer"&gt;&lt;img src="https://camo.githubusercontent.com/de2ad857b3516d3115839ef96382af475fa06bcb7e17392806216470fc7f7a38/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f446f636b65722d436f6d706f73652d3234393645443f6c6f676f3d646f636b6572266c6f676f436f6c6f723d7768697465" alt="Docker"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;Demo&lt;/h2&gt;
&lt;/div&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Chat: question → RAG answer&lt;/th&gt;
&lt;th&gt;Admin: articles and 👍/👎&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/kantik001/grounded_horticulture_en/docs/assets/demo-chat.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fkantik001%2Fgrounded_horticulture_en%2FHEAD%2Fdocs%2Fassets%2Fdemo-chat.gif" alt="Chat demo"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a rel="noopener noreferrer" href="https://github.com/kantik001/grounded_horticulture_en/docs/assets/demo-admin.gif"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fkantik001%2Fgrounded_horticulture_en%2FHEAD%2Fdocs%2Fassets%2Fdemo-admin.gif" alt="Admin demo"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href="https://github.com/kantik001/grounded_horticulture_en/docs/assets/demo-chat.mp4" rel="noopener noreferrer"&gt;▶ Full chat recording (MP4)&lt;/a&gt; · &lt;a href="https://github.com/kantik001/grounded_horticulture_en/docs/assets/demo-admin.mp4" rel="noopener noreferrer"&gt;▶ Full admin recording (MP4)&lt;/a&gt;&lt;/p&gt;

&lt;div class="markdown-heading"&gt;
&lt;h2 class="heading-element"&gt;What it is&lt;/h2&gt;
&lt;/div&gt;
&lt;p&gt;An assistant for gardeners and agronomists: &lt;strong&gt;text&lt;/strong&gt; → hybrid search over articles → LLM answer with &lt;strong&gt;verification&lt;/strong&gt; of numbers and dosages; &lt;strong&gt;photo&lt;/strong&gt; → CV + recommendation (beta, no production weights in this repo).&lt;/p&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Go&lt;/strong&gt; (&lt;code&gt;server/&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Auth, Postgres sessions, RAG+LLM orchestration, verify, rate limit, &lt;code&gt;/metrics&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Python&lt;/strong&gt; (&lt;code&gt;api/&lt;/code&gt;, &lt;code&gt;rag/&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Hybrid retrieval (Chroma + BM25 + reranker), CV &lt;code&gt;/classify&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Web&lt;/strong&gt; (&lt;code&gt;webapp/&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Chat, article upload admin, nginx in Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Access:&lt;/strong&gt; Telegram &lt;code&gt;initData&lt;/code&gt; or browser &lt;code&gt;X-API-Key&lt;/code&gt; (see &lt;code&gt;.env.example&lt;/code&gt;).&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Public repository:&lt;/strong&gt; git contains demo data only (&lt;code&gt;data/demo_hr/&lt;/code&gt;, &lt;code&gt;data/apple/sample_*.txt&lt;/code&gt;). Full article…&lt;/p&gt;
&lt;/blockquote&gt;&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/kantik001/grounded_horticulture_en" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;br&gt;
Key paths:

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;rag/&lt;/code&gt; + &lt;code&gt;api/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Hybrid retrieval (Chroma, BM25, RRF, reranker)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Go orchestration, SSE chat, numeric verifier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;eval/*.jsonl&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;68 retrieval regression questions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;scripts/run_rag_eval.py&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One-command eval runner&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;webapp/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Browser chat UI&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Passion → engineering constraint
&lt;/h3&gt;

&lt;p&gt;The personal motivation came first: my father's horticulture papers should be queryable, not buried in PDF archives. The engineering rule followed: &lt;strong&gt;I don't trust the LLM until retrieval is measurable.&lt;/strong&gt; Before tuning models or UI, I wrote a 68-question JSONL eval suite — rootstock codes, diseases, out-of-scope refusals — and made it a regression gate.&lt;/p&gt;
&lt;h3&gt;
  
  
  Why hybrid retrieval (not "better embeddings")
&lt;/h3&gt;

&lt;p&gt;Pure vector search understood &lt;em&gt;topic&lt;/em&gt; but missed &lt;em&gt;tokens that matter&lt;/em&gt; — cultivar names, rootstock codes like &lt;code&gt;SK 4&lt;/code&gt;, OCR-noisy spellings.&lt;/p&gt;

&lt;p&gt;Example eval question: &lt;em&gt;"How does planting density affect Aidared on SK 4 rootstock?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Vector-only returned paragraphs about rootstocks but not the &lt;code&gt;Aidared&lt;/code&gt; token:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fig4hlepohk2cirhxqge4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fig4hlepohk2cirhxqge4.png" alt=" " width="797" height="84"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; per-crop hybrid pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query → glossary expansion (domain synonyms)
     → Chroma (multilingual-e5-small) top-16
     → BM25 top-16
     → RRF merge
     → conditional cross-encoder rerank (rootstock / disease / variety only)
     → diversify (≤2 chunks per article) → top-8 to the LLM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;strong&gt;68/68&lt;/strong&gt; on the retrieval suite:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgkyi9ap5lugqjfqjnhqr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgkyi9ap5lugqjfqjnhqr.png" alt=" " width="800" height="112"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Re-verify anytime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python scripts/run_rag_eval.py &lt;span class="nt"&gt;--suite&lt;/span&gt; all &lt;span class="nt"&gt;--in-process&lt;/span&gt; &lt;span class="nt"&gt;--fast&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Decisions worth calling out:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RRF over score normalization&lt;/strong&gt; — BM25 and cosine similarities live on different scales; ranks merge cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Category-gated reranker&lt;/strong&gt; — the cross-encoder helps dense technical questions but costs CPU; "when should I water?" skips it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Eval retrieval separately from generation&lt;/strong&gt; — no LLM tokens, ~20s locally, catches regressions before users do.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Go + Python split
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Python&lt;/strong&gt; (&lt;code&gt;rag/&lt;/code&gt;, &lt;code&gt;api/&lt;/code&gt;): embeddings, Chroma, BM25, reranker, &lt;code&gt;POST /rag/context&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Go&lt;/strong&gt; (&lt;code&gt;server/&lt;/code&gt;): auth (Telegram + API key), Postgres sessions, SSE streaming, &lt;strong&gt;numeric verifier&lt;/strong&gt; (numbers in the answer must appear in retrieved context)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If retrieval is weak, Go short-circuits before paying for generation.&lt;/p&gt;

&lt;h3&gt;
  
  
  What broke (and what saved me)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Eval caught it?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chunking split tables from headers&lt;/td&gt;
&lt;td&gt;Apple pass_rate −7&lt;/td&gt;
&lt;td&gt;Yes — reverted in minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BM25 not rebuilt after corpus update&lt;/td&gt;
&lt;td&gt;Exact-code questions failed&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glossary entry too aggressive&lt;/td&gt;
&lt;td&gt;MRR dropped, pass_rate unchanged&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Passion projects still need discipline. The interesting work wasn't the chat bubble — it was making scientific archives answerable without lying.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Passion without measurement ships fairy tales. A 20-second eval run changed how I work more than any embedding upgrade.&lt;/li&gt;
&lt;li&gt;Hybrid search (BM25 + vectors + RRF) beat "just use a better model" for scientific text with rare codes.&lt;/li&gt;
&lt;li&gt;The hard problem was never the chat UI — it was making PDF archives answerable without lying.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Disclaimer: assistant output is informational; field decisions require local experts and compliant product labels. CV classification is beta.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>weekendchallenge</category>
      <category>ai</category>
      <category>rag</category>
    </item>
    <item>
      <title>Vector search kept missing rootstock codes, so I went hybrid</title>
      <dc:creator>Kantemir Satibalov</dc:creator>
      <pubDate>Fri, 10 Jul 2026 07:48:43 +0000</pubDate>
      <link>https://dev.to/kantik001/vector-search-kept-missing-rootstock-codes-so-i-went-hybrid-37li</link>
      <guid>https://dev.to/kantik001/vector-search-kept-missing-rootstock-codes-so-i-went-hybrid-37li</guid>
      <description>&lt;h2&gt;
  
  
  Grounded RAG in production (8 Part Series)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/kantik001/i-stopped-trusting-generic-llms-for-horticulture-so-i-built-a-grounded-assistant-on-500-46p9"&gt;I stopped trusting generic LLMs for horticulture — so I built a grounded assistant on ~500 scientific articles&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/kantik001/68-questions-before-a-single-token-eval-first-rag-1g6g"&gt;68 questions before a single token: eval-first RAG&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector search kept missing rootstock codes, so I went hybrid&lt;/strong&gt; — &lt;em&gt;you are here&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Scientific articles aren't FAQ-shaped: chunking a 500-article corpus — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Gate the answer, not just the retrieval: verifying LLM output against sources — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Go for the product, Python for the models: anatomy of a two-service RAG — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Hardening a side project like it's production (and the outage that caused) — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;One RAG platform, swappable domains: what 500 articles taught me about product shape — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;When the eval suite first ran against pure vector search, the failures clustered in one place: &lt;strong&gt;exact identifiers&lt;/strong&gt;. Rootstock codes like "SK-4", cultivar names, dosage lines. Embeddings are great at "this paragraph is about slope planting" and terrible at "this exact token matters more than the topic."&lt;/p&gt;

&lt;p&gt;A typical miss looked like this — vector search returned paragraphs &lt;em&gt;about&lt;/em&gt; rootstocks, but not the cultivar token the eval expected:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv5pmuqn33req3qqxu9uf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv5pmuqn33req3qqxu9uf.png" alt=" " width="797" height="84"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix wasn't a bigger model. It was accepting that I needed two retrievers with opposite failure modes, and a principled way to merge them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;query
  └─ glossary expansion (synonyms appended)
       ├─ Chroma vector search (multilingual-e5-small), top-16, filtered by crop
       └─ BM25 per-crop index, top-16
             └─ RRF merge → candidates
                   └─ cross-encoder rerank (bge-reranker-base) — only for some categories
                         └─ per-article diversification → top-8 fragments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every stage exists because a specific eval failure demanded it. Layer by layer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embeddings: &lt;code&gt;intfloat/multilingual-e5-small&lt;/code&gt;.&lt;/strong&gt; Multilingual because the corpus is Russian and queries can be either language. One non-obvious gotcha: e5 models &lt;em&gt;require&lt;/em&gt; &lt;code&gt;query:&lt;/code&gt; / &lt;code&gt;passage:&lt;/code&gt; prefixes at query and index time. Without them similarity scores quietly degrade — no error, just worse ranking. I subclassed the embedding wrapper so the prefixes can't be forgotten.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;BM25, one index per crop.&lt;/strong&gt; Classic lexical scoring over tokenized chunks. This is what catches "SK-4" — the exact token is either in the chunk or it isn't. The indexes are built at reindex time and persisted alongside the vector store, so a container restart doesn't silently drop the lexical half (an early bug the eval caught: exact-code questions failing while everything else passed).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RRF merge.&lt;/strong&gt; Reciprocal Rank Fusion is embarrassingly simple — each list contributes&lt;br&gt;
&lt;code&gt;1 / (k + rank)&lt;/code&gt; per document, sum, sort (k=60):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;rrf_merge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rankings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ranking&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rankings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ranking&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;chunk_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I tried score normalization schemes first. RRF won because it needs no calibration between BM25 scores and cosine similarities — it only trusts &lt;em&gt;ranks&lt;/em&gt; — and it's ten lines you can hold in your head.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conditional reranking.&lt;/strong&gt; A cross-encoder (&lt;code&gt;BAAI/bge-reranker-base&lt;/code&gt;) reads the query and each candidate together and re-scores the top candidates. It measurably improves ranking for dense technical questions — and costs real CPU time. So it's category-gated: the question classifier (rule-based, config-driven) tags questions as &lt;code&gt;rootstock&lt;/code&gt;, &lt;code&gt;disease&lt;/code&gt;, &lt;code&gt;variety&lt;/code&gt;, &lt;code&gt;fertilizer&lt;/code&gt;, &lt;code&gt;relief&lt;/code&gt;, or &lt;code&gt;general&lt;/code&gt;, and only the complex categories pay the reranker tax. "When should I water?" doesn't need a cross-encoder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Glossary expansion.&lt;/strong&gt; A curated JSON of domain synonyms; if the query contains a known term, its synonyms are appended to the search string. This is where user vocabulary meets literature vocabulary — the colloquial disease name pulls in &lt;em&gt;Marssonina&lt;/em&gt; spellings the embeddings alone ranked too low. Curated beats automatic here: the glossary is small, reviewable, and each entry exists because a real query missed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diversification.&lt;/strong&gt; Top-ranked chunks tend to come from the same article. The last step caps fragments per source before returning the top-8, so the LLM sees multiple studies instead of one article shredded eight ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I didn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fine-tune embeddings.&lt;/strong&gt; Tempting, but the eval said ranking was mostly fine once lexical search covered the identifier cases. Not worth the MLOps overhead yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM query rewriting.&lt;/strong&gt; Adds latency and a failure mode to the &lt;em&gt;cheap&lt;/em&gt; half of the system. The glossary covers the actual observed misses deterministically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A vector DB migration.&lt;/strong&gt; Chroma with a persistent local directory is unglamorous and entirely sufficient at ~14.5k chunks. The interesting problems were above the storage layer.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;With the full stack, the 68-question suite passes at 100% retrieval, and — the part I care about more — hit_rate@3 stays high enough that the LLM's context isn't padded with near-misses (apple suite: MRR 0.938, hit@3 0.953). With &lt;code&gt;--fast&lt;/code&gt; (reranker off) it still passes 68/68 today, which tells me the reranker is currently insurance rather than load-bearing. I keep it because insurance is what you want the week a new corpus batch lands.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>python</category>
    </item>
    <item>
      <title>68 questions before a single token: eval-first RAG</title>
      <dc:creator>Kantemir Satibalov</dc:creator>
      <pubDate>Mon, 06 Jul 2026 08:06:01 +0000</pubDate>
      <link>https://dev.to/kantik001/68-questions-before-a-single-token-eval-first-rag-1g6g</link>
      <guid>https://dev.to/kantik001/68-questions-before-a-single-token-eval-first-rag-1g6g</guid>
      <description>&lt;h2&gt;
  
  
  Grounded RAG in production (8 Part Series)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/kantik001/i-stopped-trusting-generic-llms-for-horticulture-so-i-built-a-grounded-assistant-on-500-46p9"&gt;I stopped trusting generic LLMs for horticulture — so I built a grounded assistant on ~500 scientific articles&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;68 questions before a single token: eval-first RAG&lt;/strong&gt; — &lt;em&gt;you are here&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/kantik001/vector-search-kept-missing-rootstock-codes-so-i-went-hybrid-37li"&gt;Vector search kept missing rootstock codes, so I went hybrid&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Scientific articles aren't FAQ-shaped: chunking a 500-article corpus — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Gate the answer, not just the retrieval: verifying LLM output against sources — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Go for the product, Python for the models: anatomy of a two-service RAG — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Hardening a side project like it's production (and the outage that caused) — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;One RAG platform, swappable domains: what 500 articles taught me about product shape — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;In Part 1 I promised the decision that changed everything. Here it is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I don't touch the LLM until a fixed suite of domain questions passes retrieval.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "the demo looked good." Not "I asked it five things and it answered." A versioned file of questions with expected evidence, run as a regression suite — the same way you'd treat unit tests. Today that suite is 68 questions: 45 apple, 8 pear, 10 plum, and 5 for an HR-policy sandbox that proves the pipeline isn't hard-coded to orchards.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3leuoretfq7gvhtad8kn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3leuoretfq7gvhtad8kn.png" alt=" " width="800" height="112"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why retrieval, not answers
&lt;/h2&gt;

&lt;p&gt;A RAG pipeline fails in two places: the right passage never reaches the prompt, or the model mangles a passage that did. The first failure is cheap to detect and free to test — no API key, no tokens, no flaky LLM in the loop. So the default eval mode stops at retrieval:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"crop_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"apple"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"question"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"What are signs of scab?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expect_contains"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"scab"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"spot"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expect_context"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expect_out_of_scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disease"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runner sends the question to the retrieval service (&lt;code&gt;POST/rag/context&lt;/code&gt;) and checks that every &lt;code&gt;expect_contains&lt;/code&gt; substring appears in the combined retrieved context. A few details that turned out to matter more than I expected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;expect_contains_any&lt;/code&gt;&lt;/strong&gt; for synonyms. The literature writes &lt;em&gt;Marssonina&lt;/em&gt;; users write the colloquial disease name. Either counts as evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Light stemming.&lt;/strong&gt; &lt;code&gt;rootstock&lt;/code&gt; should match &lt;code&gt;rootstocks&lt;/code&gt;. Without it you either overfit the expected strings to one article's phrasing or get false failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;expect_out_of_scope: true&lt;/code&gt;&lt;/strong&gt; questions. A question about, say, car maintenance must return &lt;em&gt;weak or no&lt;/em&gt; context. This catches the embarrassing failure mode where vector search happily returns "closest" chunks for any string whatsoever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The metrics that survived
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pass_rate&lt;/code&gt; alone saturates: once you hit 68/68, it can't tell you whether a refactor made ranking &lt;em&gt;worse&lt;/em&gt; as long as the evidence still sneaks into position 8. So the runner also reports ranking metrics per suite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MRR&lt;/strong&gt; (mean reciprocal rank) of the first relevant fragment,&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hit_rate@1 / @3 / @5&lt;/strong&gt; — did relevant evidence appear in the top-1/3/5 fragments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A fragment counts as relevant when it contains at least one expected substring. That's a single-relevant proxy — the baselines don't carry ground-truth chunk ids — and I'm fine with it: it's cheap, stable, and moves in the right direction when I break something.&lt;/p&gt;

&lt;p&gt;There's also a &lt;code&gt;--full&lt;/code&gt; mode that does call the LLM and reports two more numbers:&lt;br&gt;
&lt;code&gt;verify_pass_rate&lt;/code&gt; (do the numbers in the answer actually appear in the retrieved context — more on that verifier in Part 5) and &lt;code&gt;answer_contains_rate&lt;/code&gt; (does the answer mention the expected terms). Out-of-scope questions skip the LLM entirely, mirroring the production short-circuit: if retrieval finds nothing, we refuse before paying for tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making it cheap enough to actually run
&lt;/h2&gt;

&lt;p&gt;An eval nobody runs is documentation. The full HTTP run over 68 questions takes about 4 minutes; that was too slow for "run after every change," so the runner grew flags:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--in-process&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Import the retrieval module directly, skip HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--fast&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Disable the cross-encoder reranker (~15× faster; still 68/68 on the current set)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--workers 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Parallel requests against one retrieval worker&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ~20-second &lt;code&gt;--in-process --fast&lt;/code&gt; combination is what I run reflexively. The full run with reranking is for before releases and after reindexing. In CI, unit tests run on every PR and the complete eval is a manual GitHub Actions workflow — model downloads are too heavy to justify on every push.&lt;/p&gt;

&lt;p&gt;Results land in &lt;code&gt;eval/results/&amp;lt;timestamp&amp;gt;_&amp;lt;suite&amp;gt;.json&lt;/code&gt;, so "did Tuesday's chunking change hurt pear questions?" is a diff, not an argument.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the suite caught (a sample)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A chunking change that split experiment tables from their headers — apple &lt;code&gt;pass_rate&lt;/code&gt; dropped 7 points, nothing else moved. Reverted in minutes.&lt;/li&gt;
&lt;li&gt;BM25 index not rebuilt after a corpus update — vector search masked it for common questions, but exact-code questions (rootstock "SK-4") failed instantly.&lt;/li&gt;
&lt;li&gt;A glossary entry that expanded a term too aggressively and pushed the right article out of the top-5 for two questions: visible as an MRR drop with &lt;code&gt;pass_rate&lt;/code&gt; unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsswfjdfv0uh2u047c55o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsswfjdfv0uh2u047c55o.png" alt=" " width="797" height="84"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of these would have been caught by "chat with the bot for a while." All of them would have shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you to steal
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write the eval file &lt;strong&gt;before&lt;/strong&gt; tuning retrieval. Even 20 questions change how you work.&lt;/li&gt;
&lt;li&gt;Test retrieval separately from generation. It's the cheap 80%.&lt;/li&gt;
&lt;li&gt;Add out-of-scope questions early. Refusing well is a feature.&lt;/li&gt;
&lt;li&gt;Make the fast path under 30 seconds, or you'll stop running it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Part 3 is the payoff: what it actually took to get those 68 questions passing — hybrid search, RRF, and why "just use a better embedding model" wasn't the answer.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclaimer: assistant output is informational; field decisions require local experts and compliant product labels.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>testing</category>
    </item>
    <item>
      <title>I stopped trusting generic LLMs for horticulture — so I built a grounded assistant on ~500 scientific articles</title>
      <dc:creator>Kantemir Satibalov</dc:creator>
      <pubDate>Tue, 30 Jun 2026 13:23:58 +0000</pubDate>
      <link>https://dev.to/kantik001/i-stopped-trusting-generic-llms-for-horticulture-so-i-built-a-grounded-assistant-on-500-46p9</link>
      <guid>https://dev.to/kantik001/i-stopped-trusting-generic-llms-for-horticulture-so-i-built-a-grounded-assistant-on-500-46p9</guid>
      <description>&lt;h2&gt;
  
  
  Grounded RAG in production (8 Part Series)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;I stopped trusting generic LLMs for horticulture — so I built a grounded assistant on ~500 scientific articles&lt;/strong&gt; - &lt;strong&gt;you are here&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/kantik001/68-questions-before-a-single-token-eval-first-rag-1g6g"&gt;68 questions before a single token: eval-first RAG&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/kantik001/vector-search-kept-missing-rootstock-codes-so-i-went-hybrid-37li"&gt;Vector search kept missing rootstock codes, so I went hybrid&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Scientific articles aren't FAQ-shaped: chunking a 500-article corpus — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Gate the answer, not just the retrieval: verifying LLM output against sources — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Go for the product, Python for the models: anatomy of a two-service RAG — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Hardening a side project like it's production (and the outage that caused) — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;One RAG platform, swappable domains: what 500 articles taught me about product shape — &lt;em&gt;coming soon&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsrv75rk1jrufkngalzry.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsrv75rk1jrufkngalzry.png" alt=" " width="800" height="426"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Last year I kept seeing the same pattern in agtech and “AI assistant” demos: a chatbot wrapped around a generic model, a handful of PDFs, and a disclaimer nobody reads.&lt;/p&gt;

&lt;p&gt;I'm a developer, not an agronomist. But I'm working on two related projects — a grounded RAG platform (grounded-llm, private repo) and its first production-shaped domain pack: a horticulture assistant built on hundreds of articles from the Russian journal Plodovodstvo i vinogradstvo Yuga Rossii (apple, pear, plum — on the order of ~500 source articles, not five blog posts).&lt;/p&gt;

&lt;p&gt;I didn't want another “ChatGPT for gardeners.”&lt;br&gt;
I wanted answers that behave like someone who actually read the literature — and admits when the literature doesn't cover the question.&lt;/p&gt;

&lt;p&gt;That gap turned into months of engineering. I'm sharing the story in public; the full corpus and codebase stay private.&lt;/p&gt;

&lt;p&gt;What broke first: “sounds right” ≠ “is right”&lt;br&gt;
Early experiments failed in boring, repeatable ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Domain language doesn't match generic retrieval&lt;/strong&gt;&lt;br&gt;
Russian horticulture is full of synonyms and notation variants: rootstock labels, disease names, regional cultivars. A user writes марссониоз; the literature may use Marssonina, abbreviations, or OCR-noisy spellings. Naive retrieval misses; the model fills the gap confidently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Scientific text isn't FAQ-shaped&lt;/strong&gt;&lt;br&gt;
Articles contain experiment sections, tables, and “brief for the grower” blocks. One chunk size for everything → right article, wrong paragraph → fluent wrong answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Generation is the wrong place to fix retrieval&lt;/strong&gt;&lt;br&gt;
If the right passage never reaches the prompt, no system prompt saves you. I separated concerns early:&lt;/p&gt;

&lt;p&gt;Python service → retrieval only (/rag/context)&lt;br&gt;
Go server → sessions, LLM calls, answer cleanup, guardrails&lt;br&gt;
Not because microservices are fashionable — because I needed to change and measure retrieval without redeploying the whole product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I built: two layers, one product&lt;/strong&gt;&lt;br&gt;
| Layer | What it is |&lt;br&gt;
|-------|------------|&lt;br&gt;
| &lt;strong&gt;Platform core&lt;/strong&gt; (grounded-llm) | Auth, Postgres sessions, orchestration |&lt;br&gt;
| &lt;strong&gt;Domain pack&lt;/strong&gt; (horticulture) | Corpus, crop config, prompts, eval baselines |&lt;/p&gt;

&lt;p&gt;There's also a non-agricultural sandbox (demo_hr) — HR policy docs, same pipeline — to show the platform isn't hard-coded to apple diseases.&lt;/p&gt;

&lt;p&gt;The horticulture pack indexes on the order of ~14,500 text chunks from the journal corpus. At this scale, “vector search only” and “we'll fix it in the prompt” stop being credible.&lt;/p&gt;

&lt;p&gt;I'm not open-sourcing the full article texts (rights + focus). I am sharing architecture lessons, failure modes, and metrics — and offering controlled demos when it's worth someone's time.&lt;/p&gt;

&lt;p&gt;One question that kept me honest&lt;br&gt;
Which rootstocks and training systems show up in slope / terrace planting research for our region?&lt;/p&gt;

&lt;p&gt;Generic LLMs invent varieties and numbers.&lt;br&gt;
A grounded system either retrieves relevant experimental context — rootstocks, spacing, relief, regional trials — or should refuse to answer.&lt;/p&gt;

&lt;p&gt;That requirement ruled out most tutorial RAG stacks I'd seen. It also ruled out marketing photo → disease as the hero feature before a model is actually trained on disease imagery. Vision is on the roadmap; text grounded in papers is what's production-shaped today.&lt;/p&gt;

&lt;p&gt;What I deliberately didn't optimize for (yet):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Multi-tenant SaaS billing&lt;/li&gt;
&lt;li&gt;Viral B2C Telegram growth&lt;/li&gt;
&lt;li&gt;Claiming diagnosis-grade vision from an ImageNet backbone&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I optimized for:&lt;br&gt;
1.Retrieval you can regression-test&lt;br&gt;
2.Answers you can gate before users see them&lt;br&gt;
3.A platform you can re-pack for another vertical in days, not months&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's next (Part 2)&lt;/strong&gt;&lt;br&gt;
Part 1 was the why.&lt;/p&gt;

&lt;p&gt;Part 2 is the decision that changed everything: I don't trust the pipeline until a fixed suite of domain questions passes retrieval — today 68 questions across apple, pear, plum, and the HR sandbox — before we pay for a single generated token.&lt;/p&gt;

&lt;p&gt;Spoiler: getting there wasn't “use a bigger embedding model.” It was unglamorous engineering — chunking, hybrid search, reranking, glossary expansion — I'll unpack one layer per post.&lt;/p&gt;

&lt;p&gt;If this resonates&lt;br&gt;
I'm building in public through writing, not through dumping the entire corpus on GitHub.&lt;/p&gt;

&lt;p&gt;Follow on Dev.to for Part 2&lt;br&gt;
Comment if you've hit similar RAG failure modes in regulated or scientific domains&lt;br&gt;
Reach out (GitHub / email in bio) for a short demo: HR sandbox or limited horticulture preview&lt;br&gt;
Disclaimer: assistant output is informational; field decisions require local experts and compliant product labels.&lt;/p&gt;

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