<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>Rails - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/rails/</link><description>Rails - Tag - Tracy Atteberry</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>tracy@magicbydesign.com (Tracy Atteberry)</managingEditor><webMaster>tracy@magicbydesign.com (Tracy Atteberry)</webMaster><lastBuildDate>Tue, 03 Nov 2026 00:00:00 +0000</lastBuildDate><image><url>https://tracyatteberry.com/images/feed-icon.jpg</url><title>Rails - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/rails/</link></image><atom:link href="https://tracyatteberry.com/tags/rails/" rel="self" type="application/rss+xml"/><item><title>Why I built Speechwave in Elixir (as a Rails developer)</title><link>https://tracyatteberry.com/posts/why-elixir/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/why-elixir/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/why-elixir/swhero.png" referrerpolicy="no-referrer">
            </div><h1 id="why-i-built-speechwave-in-elixir-as-a-rails-developer">Why I built Speechwave in Elixir (as a Rails developer)</h1>
<p>I&rsquo;ve been developing with Ruby on Rails for a long time. If you had asked me to bet on
what I&rsquo;d build my next web app with, I would have bet on Rails.</p>
<p>Then I started building <a href="https://speechwave.live" target="_blank" rel="noopener noreffer ">Speechwave</a>, a live emoji reaction app
for conference talks, and I wrote it in Elixir instead</p>
<p>This isn&rsquo;t a &ldquo;Rails is dead&rdquo; post. It&rsquo;s still a great choice for lots of
things. This is about what this particular app needed, why Elixir turned out to
a great choice, and why the hosting bill is about as small as a bill can get. If you&rsquo;re an Elixir
developer, some of this may be familiar.</p>
<h2 id="what-speechwave-does">What Speechwave does</h2>
<p>Attendees scan a QR code at a talk and are sent to a page of emoji buttons. They
tap one. A Chrome extension on the speaker&rsquo;s laptop receives that reaction and
floats the emoji over the speaker&rsquo;s Google Slides in real time. After the talk,
the speaker can see which slides got which reactions.</p>
<p>Look at that from a server&rsquo;s point of view. It&rsquo;s a lot of small, short-lived,
simultaneous things:</p>
<ul>
<li>Dozens (or hundreds) of phones holding open WebSocket connections</li>
<li>Each tap needs to be rate limited, optionally saved, and fanned out to
everyone who cares</li>
<li>The speaker&rsquo;s extension holds one more long-lived connection, and it isn&rsquo;t
even a web page</li>
<li>The whole thing is quiet for days, then very busy for 45 minutes</li>
</ul>
<p>That last point will matter quite a bit when we get to the hosting
section.</p>
<h2 id="why-it-works-the-three-things-i-would-have-had-to-do-in-rails">Why it works: the three things I would have had to do in Rails</h2>
<p>I don&rsquo;t want to oversell this. Rails can do real-time. Action Cable exists,
and Rails keeps improving. But in Rails I would have been assembling
pieces. In Phoenix, the pieces come in the box and they were designed together.</p>
<h3 id="1-a-message-bus-that-doesnt-care-whos-listening">1. A message bus that doesn&rsquo;t care who&rsquo;s listening</h3>
<p>Phoenix PubSub lets any process broadcast to a topic and any other process
subscribe to it. The attendee&rsquo;s LiveView doesn&rsquo;t know the Chrome extension
exists. After the rate limit check (and saving the reaction, if a session is
recording), it just broadcasts. Here&rsquo;s the handler from <code>talk_live.ex</code>, with the
persistence code trimmed out:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">handle_event</span><span class="p">(</span><span class="s2">&#34;react&#34;</span><span class="p">,</span> <span class="p">%{</span><span class="s2">&#34;emoji&#34;</span> <span class="o">=&gt;</span> <span class="n">emoji</span><span class="p">},</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="nc">RateLimiter</span><span class="o">.</span><span class="n">allow?</span><span class="p">(</span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">session_id</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...save the reaction if a session is recording...</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nc">SpeechwaveWeb.Endpoint</span><span class="o">.</span><span class="n">broadcast!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;reactions:</span><span class="si">#{</span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">talk</span><span class="o">.</span><span class="n">slug</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;new_reaction&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="p">%{</span><span class="ss">emoji</span><span class="p">:</span> <span class="n">emoji</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">socket</span><span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The extension&rsquo;s channel is subscribed to the same topic, so it gets the
message. If it add a third kind of listener tomorrow, say a live dashboard,
nothing in that function changes.</p>
<h3 id="2-liveview-for-the-attendee-page">2. LiveView for the attendee page</h3>
<p>The attendee page is a LiveView, so we got WebSocket-backed interactivity without
building a single-page app and without maintaining a separate JavaScript
frontend. The UI is a function of server state, and Phoenix handles keeping the
browser in sync. For an app where nearly every interesting event is &ldquo;something
happened on the server, tell the clients,&rdquo; that model is a very good match.</p>
<h3 id="3-cheap-isolated-processes-for-everything-else">3. Cheap, isolated processes for everything else</h3>
<p>The rate limiter is a <code>GenServer</code> that owns an ETS table (an in-memory
key/value store built into the runtime). Reads don&rsquo;t queue behind the
GenServer, so a room full of people tapping at once doesn&rsquo;t turn into a
traffic jam. It&rsquo;s a few dozen lines and there is no Redis anywhere.</p>
<p>Presence, which tracks how many extensions are connected to a talk so we can
enforce plan limits, is also built in. So is the supervision tree that restarts
any piece that crashes without taking down the rest. Here&rsquo;s the top level of
the tree from <code>application.ex</code>, trimmed a bit for space:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="n">children</span> <span class="o">=</span>
</span></span><span class="line"><span class="cl">  <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Speechwave.Repo</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="nc">Phoenix.PubSub</span><span class="p">,</span> <span class="ss">name</span><span class="p">:</span> <span class="nc">Speechwave.PubSub</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Speechwave.RateLimiter</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Speechwave.AuthThrottle</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...a few more throttles and reapers...</span>
</span></span><span class="line"><span class="cl">    <span class="nc">SpeechwaveWeb.Endpoint</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nc">SpeechwaveWeb.Presence</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span> <span class="o">++</span> <span class="n">backup_children</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">opts</span> <span class="o">=</span> <span class="p">[</span><span class="ss">strategy</span><span class="p">:</span> <span class="ss">:one_for_one</span><span class="p">,</span> <span class="ss">name</span><span class="p">:</span> <span class="nc">Speechwave.Supervisor</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nc">Supervisor</span><span class="o">.</span><span class="n">start_link</span><span class="p">(</span><span class="n">children</span><span class="p">,</span> <span class="n">opts</span><span class="p">)</span></span></span></code></pre></div></div>
<p>I wrote down what happens when each child crashes (the rate limiter loses its
cooldown state, Presence resets its count, and so on) and for this app every
answer is &ldquo;acceptable, by design.&rdquo; Thinking about failure that explicitly is
something the platform nudges you toward. In a Rails apps, I mostly find out
what a crash does when it happens.</p>
<h2 id="the-hosting-bill">The hosting bill</h2>
<p>This is the part I was the most excited about, and why I&rsquo;m writing this
post in the first place.</p>
<p>Speechwave runs on <a href="https://fly.io" target="_blank" rel="noopener noreffer ">Fly.io</a>, and here are the essential bits
configuration:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-toml">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">vm</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">memory</span> <span class="p">=</span> <span class="s1">&#39;512mb&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">cpu_kind</span> <span class="p">=</span> <span class="s1">&#39;shared&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">cpus</span> <span class="p">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">0</span></span></span></code></pre></div></div>
<p>That&rsquo;s one shared CPU and 512 MB of memory. When nobody is using the app, the
machine suspends. When someone scans a QR code, it wakes up.</p>
<p>Three things make that work, and Elixir is part of each of them.</p>
<p><strong>The runtime is frugal.</strong> Because the BEAM runs lightweight processes instead
of OS threads, one small machine can hold a lot of open connections. I&rsquo;ve
configured the app to allow up to 1,000 concurrent connections on that single
512 MB box. For context, my realistic target is a few conference rooms, not a
stadium.</p>
<p><strong>One process serves everything.</strong> The reaction fan-out, the LiveView pages,
the Channel for the extension, the rate limiter, the presence tracking, and the
scheduled backups all run inside a single Elixir application. I don&rsquo;t need a
separate Redis for pub/sub, a separate worker process for background jobs, or a
separate WebSocket server. Fewer moving parts means fewer things to pay for and
fewer things to keep alive.</p>
<p><strong>SQLite is enough.</strong> The first version of this app used Fly&rsquo;s managed
Postgres. I&rsquo;ve since moved to SQLite on a Fly volume, with Ecto&rsquo;s SQLite
adapter and a pool size of 2. Reactions are small writes, the data volume is
tiny, and a few talk-shaped workloads doesn&rsquo;t need a database server.</p>
<p>That does mean I own backups. A GenServer takes a snapshot every hour using
SQLite&rsquo;s <code>VACUUM INTO</code> (which gives a consistent copy of a live database,
unlike copying the file) and uploads it to an S3-compatible object storage. It&rsquo;s
about a hundred lines of Elixir, it&rsquo;s supervised like everything else, and a
failed backup logs an error instead of crashing anything. If you want the
details, the <a href="https://docs.speechwave.live/dev/explainer/db-backup.html" target="_blank" rel="noopener noreffer ">codebase explainer</a>
walks through it.</p>
<p>Put those together and the cost is tiny. Fly waives invoices under $5, and only
last month did my bill creep above that to $5.49, just a little over the
line. Usage of the S3 object store at Tigris has stayed within its free
allowances.</p>
<p>I want to be careful about what I&rsquo;m claiming. That number reflects low traffic
on a young app. I haven&rsquo;t load tested a 5,000-person keynote, and I&rsquo;m not
claiming this configuration handles one. What I can say is that the floor is
very low, and the architecture doesn&rsquo;t force me to pay for a fleet of
services before I&rsquo;ve earned a paying user.</p>
<h2 id="could-i-have-done-this-in-rails">Could I have done this in Rails?</h2>
<p>Probably, yes. Rails 8 made SQLite a first-class citizen, and Solid Cable
removes the Redis requirement for Action Cable. I&rsquo;m not going to pretend that
Rails can&rsquo;t get to a similar place. A fair comparison, though, is about how
much I had to assemble and tune to get there, and about how comfortable the
result is at idle and at peak.</p>
<p>For this specific shape of app, many long-lived connections, constant
broadcast, tiny writes, spiky traffic, Phoenix gave me the right design
without much negotiation. In Rails I would have been making deliberate choices
to approximate it.</p>
<p>I&rsquo;ll also say what I did <strong>not</strong> get for free. Rails has an enormous ecosystem
of gems that solve the boring parts of an app. In Elixir I wrote more of the
glue myself. Authentication is a good example: magic links and OAuth took more
work (Phoenix&rsquo;s generator got me started, and a library called Assent handles
the OAuth side). If this had been a plain CRUD app with admin screens, Rails
may have been faster to build, and I may have shipped it sooner.</p>
<h2 id="what-learning-elixir-was-like-for-a-ruby-developer">What learning Elixir was like for a Ruby developer</h2>
<p>Easier than I expected, honestly. The syntax is friendly to Rubyists, the
tooling (<code>mix</code>, <code>iex</code>, ExUnit) feels familiar, and the community writes really
good documentation. The adjustments that took some effort were more conceptual:</p>
<ul>
<li>Data is immutable, so you transform values instead of mutating objects.</li>
<li>Pattern matching shows up everywhere, and it replaces a surprising amount of
<code>if</code> and <code>case</code> logic.</li>
<li>You think in processes, which is a new mental model, but a pleasant one once
it clicks.</li>
</ul>
<p>If you&rsquo;ve used Ruby 3&rsquo;s <code>case/in</code>, pattern matching won&rsquo;t be a brand new idea.
You can destructure hashes and arrays, bind variables, and pin values with <code>^</code>:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">case</span> <span class="n">response</span>
</span></span><span class="line"><span class="cl"><span class="k">in</span> <span class="p">{</span><span class="ss">status</span><span class="p">:</span> <span class="mi">200</span><span class="p">,</span> <span class="ss">body</span><span class="p">:}</span>
</span></span><span class="line"><span class="cl">  <span class="n">render</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">in</span> <span class="p">{</span><span class="ss">status</span><span class="p">:</span> <span class="mi">404</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="n">not_found</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The difference is how deep it goes. In Ruby, pattern matching is a feature you
opt into with <code>case/in</code>, and most Ruby code still uses <code>if</code>, <code>case/when</code>, and
guard clauses. In Elixir it&rsquo;s foundational. Even <code>=</code> is a match operator, not
just assignment. Most importantly, you can match right in a function&rsquo;s
arguments, so each clause of a function handles one shape of input. Speechwave&rsquo;s
Channel has two <code>join</code> functions, for example (from <code>reaction_channel.ex</code>, with
the first one&rsquo;s body trimmed):</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">join</span><span class="p">(</span><span class="s2">&#34;reactions:&#34;</span> <span class="o">&lt;&gt;</span> <span class="n">slug</span><span class="p">,</span> <span class="p">%{</span><span class="s2">&#34;api_key&#34;</span> <span class="o">=&gt;</span> <span class="n">api_key</span><span class="p">},</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...check the talk, the user, and the plan limits...</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">join</span><span class="p">(</span><span class="s2">&#34;reactions:&#34;</span> <span class="o">&lt;&gt;</span> <span class="n">_slug</span><span class="p">,</span> <span class="n">_params</span><span class="p">,</span> <span class="n">_socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The first clause only matches when the topic starts with <code>&quot;reactions:&quot;</code> and the
params include an <code>api_key</code>. Anything else falls through to the second clause,
which rejects it. No <code>if</code> required.</p>
<p>Ruby methods have one body, so you branch inside it. Elixir functions can have
many clauses, and the branching happens before your code even runs. That&rsquo;s the
part that changed how code was structured, and it&rsquo;s why pattern matching feels
more like a way of working than a convenient syntax.</p>
<p>Here&rsquo;s an example. The Channel join for the Chrome extension is a chain of
four checks, and each failure has its own reason. This is the body of that first
<code>join</code> clause, minus a couple of setup lines inside the <code>do</code> block:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="n">with</span> <span class="p">{</span><span class="ss">:talk</span><span class="p">,</span> <span class="p">%</span><span class="nc">Talks.Talk</span><span class="p">{}</span> <span class="o">=</span> <span class="n">talk</span><span class="p">}</span> <span class="o">&lt;-</span> <span class="p">{</span><span class="ss">:talk</span><span class="p">,</span> <span class="nc">Talks</span><span class="o">.</span><span class="n">get_talk_by_slug</span><span class="p">(</span><span class="n">slug</span><span class="p">)},</span>
</span></span><span class="line"><span class="cl">     <span class="p">{</span><span class="ss">:user</span><span class="p">,</span> <span class="p">%</span><span class="nc">Accounts.User</span><span class="p">{}</span> <span class="o">=</span> <span class="n">user</span><span class="p">}</span> <span class="o">&lt;-</span> <span class="p">{</span><span class="ss">:user</span><span class="p">,</span> <span class="nc">Accounts</span><span class="o">.</span><span class="n">get_user_by_api_key</span><span class="p">(</span><span class="n">api_key</span><span class="p">)},</span>
</span></span><span class="line"><span class="cl">     <span class="p">{</span><span class="ss">:owner</span><span class="p">,</span> <span class="no">true</span><span class="p">}</span> <span class="o">&lt;-</span> <span class="p">{</span><span class="ss">:owner</span><span class="p">,</span> <span class="n">talk</span><span class="o">.</span><span class="n">user_id</span> <span class="o">==</span> <span class="n">user</span><span class="o">.</span><span class="n">id</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">     <span class="p">{</span><span class="ss">:capacity</span><span class="p">,</span> <span class="ss">:ok</span><span class="p">}</span> <span class="o">&lt;-</span>
</span></span><span class="line"><span class="cl">       <span class="p">{</span><span class="ss">:capacity</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nc">Plans</span><span class="o">.</span><span class="n">check</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">          <span class="ss">:max_participants</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="n">user</span><span class="o">.</span><span class="n">plan</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nc">Presence</span><span class="o">.</span><span class="n">list</span><span class="p">(</span><span class="s2">&#34;reactions:</span><span class="si">#{</span><span class="n">slug</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span> <span class="o">|&gt;</span> <span class="n">map_size</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="p">)}</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...subscribe to disconnect events, schedule presence tracking...</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">join_payload</span><span class="p">(</span><span class="n">user</span><span class="p">),</span> <span class="n">assign</span><span class="p">(</span><span class="n">socket</span><span class="p">,</span> <span class="ss">talk</span><span class="p">:</span> <span class="n">talk</span><span class="p">,</span> <span class="ss">user</span><span class="p">:</span> <span class="n">user</span><span class="p">)}</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:talk</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;not_found&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:user</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:owner</span><span class="p">,</span> <span class="no">false</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:capacity</span><span class="p">,</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="ss">:limit_reached</span><span class="p">}}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;capacity_reached&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>I might have written that as a pile of guard clauses in Ruby. This version
reads top to bottom as a list of requirements, and the <code>else</code> block is a
table of what goes wrong.</p>
<p>As an aid to learning I had AI help me write a <a href="https://docs.speechwave.live/dev/course.html" target="_blank" rel="noopener noreffer ">short course for Ruby
developers</a>, drawing the examples
from the Speechwave codebase. If you&rsquo;re coming from the Rails side, that&rsquo;s an
interesting place to start.</p>
<h2 id="what-id-tell-another-rails-developer">What I&rsquo;d tell another Rails developer</h2>
<p>If your app is mostly forms, tables, and background jobs, stay with Rails. You&rsquo;ll
move faster and you&rsquo;ll have more gems to lean on.</p>
<p>If your app is mostly &ldquo;lots of people connected at once, and something
happening to all of them in real time,&rdquo; look hard at Phoenix. The runtime and
the framework were built for exactly that, and you may find, as I did, that the
infrastructure shrinks down to a single small machine.</p>
<p>And if you&rsquo;re already an Elixir developer: I&rsquo;d love your feedback on the
codebase. It&rsquo;s open, it&rsquo;s about 6,800 lines of application code, and I&rsquo;m sure
there are idioms I&rsquo;m still getting wrong.</p>
<ul>
<li>Try Speechwave: <a href="https://speechwave.live" target="_blank" rel="noopener noreffer ">speechwave.live</a></li>
<li>Read the code: <a href="https://github.com/speechwave-live/speechwave" target="_blank" rel="noopener noreffer ">github.com/speechwave-live/speechwave</a></li>
<li>Tour the internals: <a href="https://docs.speechwave.live/dev/explainer/index.html" target="_blank" rel="noopener noreffer ">codebase explainer</a></li>
<li>Peek at the AI-assisted <a href="https://docs.speechwave.live/dev/course.html" target="_blank" rel="noopener noreffer ">short course for Ruby developers</a></li>
</ul>
]]></description></item><item><title>Search with AI four ways: How much AI does your Rails app really need</title><link>https://tracyatteberry.com/posts/search_four_ways/</link><pubDate>Wed, 11 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/search_four_ways/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/search_four_ways/coder.jpg" referrerpolicy="no-referrer">
            </div><h1 id="documentation-search-when-your-rails-app-needs-which-approach">Documentation search: When your Rails app needs which approach</h1>
<p>Imagine spending an afternoon watching a developer tear out a perfectly
functional search feature. They replace their solid Postgres full-text
search with a vector database and RAG pipeline because, well, that&rsquo;s what
you&rsquo;re supposed to do now, right? The new system is slower, cost them $200 a
month in OpenAI API calls, and returned worse results for their specific use
case.</p>
<p>This keeps happening. The AI hype cycle has convinced developers that every
search problem needs embeddings, vector databases, and agentic loops. Sometimes
that&rsquo;s true. Often it&rsquo;s not.</p>
<p>Let&rsquo;s build the same feature four different ways and see what each approach
actually costs you.</p>
<h2 id="the-use-case-searching-ruby-gem-documentation">The use case: searching Ruby gem documentation</h2>
<p>We&rsquo;re building a search feature for a documentation site that indexes about
5,000 Ruby gems. Each gem has README content, API documentation, and code
examples. Users ask questions like &ldquo;How do I upload files to S3?&rdquo; or &ldquo;What&rsquo;s
the best gem for handling webhooks?&rdquo;</p>
<p>This is a realistic scale for most Rails apps. Not Google-sized, not trivial.
Just normal business software that needs to help users find information.</p>
<p>I&rsquo;ll show you four implementations, each adding a layer of complexity. We&rsquo;ll
look at the code, measure the actual costs, and figure out when the added
complexity pays for itself.</p>
<h2 id="approach-1-traditional-search-with-ai-summarization">Approach 1: Traditional search with AI summarization</h2>
<p>Start with what works. Postgres full-text search has been solving search
problems since before your junior devs were born.</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Documentation</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="kp">include</span> <span class="no">PgSearch</span><span class="o">::</span><span class="no">Model</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="n">pg_search_scope</span> <span class="ss">:search_content</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="ss">against</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">title</span><span class="p">:</span> <span class="s1">&#39;A&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">content</span><span class="p">:</span> <span class="s1">&#39;B&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">code_examples</span><span class="p">:</span> <span class="s1">&#39;C&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="ss">using</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">tsearch</span><span class="p">:</span> <span class="p">{</span> <span class="ss">prefix</span><span class="p">:</span> <span class="kp">true</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="ss">trigram</span><span class="p">:</span> <span class="p">{</span> <span class="ss">threshold</span><span class="p">:</span> <span class="mi">0</span><span class="o">.</span><span class="mi">3</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DocumentationSearcher</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@query</span> <span class="o">=</span> <span class="n">query</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">search_content</span><span class="p">(</span><span class="vi">@query</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">results</span><span class="p">:</span> <span class="n">results</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">summary</span><span class="p">:</span> <span class="n">summarize_results</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">summarize_results</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kp">nil</span> <span class="k">if</span> <span class="n">results</span><span class="o">.</span><span class="n">empty?</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">prompt</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">      <span class="no">User</span> <span class="ss">question</span><span class="p">:</span> <span class="c1">#{@query}</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="no">Here</span> <span class="n">are</span> <span class="n">the</span> <span class="n">top</span> <span class="n">search</span> <span class="ss">results</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="c1">#{format_results(results)}</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="no">Provide</span> <span class="n">a</span> <span class="n">concise</span> <span class="n">answer</span> <span class="n">to</span> <span class="n">the</span> <span class="n">user</span><span class="s1">&#39;s question based on these results.
</span></span></span><span class="line"><span class="cl"><span class="s1">      If the results don&#39;</span><span class="n">t</span> <span class="n">contain</span> <span class="n">relevant</span> <span class="n">information</span><span class="p">,</span> <span class="n">say</span> <span class="n">so</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">    <span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">client</span> <span class="o">=</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span>
</span></span><span class="line"><span class="cl">    <span class="n">client</span> <span class="o">=</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;gpt-4o-mini&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">messages</span><span class="p">:</span> <span class="o">[</span><span class="p">{</span> <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">prompt</span> <span class="p">}</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">temperature</span><span class="p">:</span> <span class="mi">0</span><span class="o">.</span><span class="mi">3</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:message</span><span class="p">,</span> <span class="ss">:content</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">format_results</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span><span class="o">.</span><span class="n">map</span><span class="o">.</span><span class="n">with_index</span> <span class="k">do</span> <span class="o">|</span><span class="n">doc</span><span class="p">,</span> <span class="n">i</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;</span><span class="si">#{</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="si">}</span><span class="s2">. </span><span class="si">#{</span><span class="n">doc</span><span class="o">.</span><span class="n">title</span><span class="si">}</span><span class="se">\n</span><span class="si">#{</span><span class="n">doc</span><span class="o">.</span><span class="n">content</span><span class="o">.</span><span class="n">truncate</span><span class="p">(</span><span class="mi">500</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>This approach does one database query and one API call. The search uses proven
Postgres features: full-text search with ranking, trigram matching for typos,
and weighted fields. Then we send the top results to GPT-4 to generate a
summary.</p>
<p><strong>Cost per query:</strong></p>
<ul>
<li>Database: ~5ms</li>
<li>OpenAI API: ~$0.002 (about 1,000 input tokens, 200 output tokens)</li>
<li>Total latency: ~800ms</li>
</ul>
<p><strong>When this fails:</strong></p>
<ul>
<li>User queries are conceptually different from how docs are written (&ldquo;async jobs&rdquo; versus &ldquo;background processing&rdquo;)</li>
<li>Important information is buried in the middle of long documents</li>
<li>You need to combine information from multiple sources</li>
</ul>
<p>The failure mode is subtle. Traditional search ranks by keyword matching and
field weights. When users phrase questions differently than your documentation
uses terminology, they get poor results. You can&rsquo;t fix this with better prompt
engineering because the LLM never sees the relevant documents.</p>
<h2 id="approach-2-basic-rag-with-vector-embeddings">Approach 2: Basic RAG with vector embeddings</h2>
<p>RAG (Retrieval Augmented Generation) means embedding your documents as vectors,
embedding the user&rsquo;s query as a vector, and finding documents with similar
embeddings. This solves the terminology mismatch problem.</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># We need to store embeddings</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">AddEmbeddingsToDocumentation</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="o">[</span><span class="mi">7</span><span class="o">.</span><span class="mi">1</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">change</span>
</span></span><span class="line"><span class="cl">    <span class="n">add_column</span> <span class="ss">:documentations</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">:vector</span><span class="p">,</span> <span class="ss">limit</span><span class="p">:</span> <span class="mi">1536</span>
</span></span><span class="line"><span class="cl">    <span class="n">add_index</span> <span class="ss">:documentations</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">,</span> <span class="ss">using</span><span class="p">:</span> <span class="ss">:hnsw</span><span class="p">,</span> <span class="ss">opclass</span><span class="p">:</span> <span class="ss">:vector_cosine_ops</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Documentation</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
</span></span><span class="line"><span class="cl">  <span class="n">has_neighbors</span> <span class="ss">:embedding</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="n">after_save</span> <span class="ss">:generate_embedding</span><span class="p">,</span> <span class="k">if</span><span class="p">:</span> <span class="ss">:content_changed?</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">generate_embedding</span>
</span></span><span class="line"><span class="cl">    <span class="n">text</span> <span class="o">=</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="n">title</span><span class="si">}</span><span class="se">\n\n</span><span class="si">#{</span><span class="n">content</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">client</span> <span class="o">=</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">embeddings</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;text-embedding-3-small&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">input</span><span class="p">:</span> <span class="n">text</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nb">self</span><span class="o">.</span><span class="n">update_column</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:data</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">RagSearcher</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="ss">client</span><span class="p">:</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@query</span> <span class="o">=</span> <span class="n">query</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@client</span> <span class="o">=</span> <span class="n">client</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search</span>
</span></span><span class="line"><span class="cl">    <span class="n">query_embedding</span> <span class="o">=</span> <span class="n">generate_embedding</span><span class="p">(</span><span class="vi">@query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">query_embedding</span><span class="p">,</span> <span class="ss">distance</span><span class="p">:</span> <span class="s2">&#34;cosine&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">results</span><span class="p">:</span> <span class="n">results</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">answer</span><span class="p">:</span> <span class="n">generate_answer</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">generate_embedding</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">embeddings</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;text-embedding-3-small&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">input</span><span class="p">:</span> <span class="n">text</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:data</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">generate_answer</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">context</span> <span class="o">=</span> <span class="n">results</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">doc</span><span class="o">|</span> <span class="s2">&#34;</span><span class="si">#{</span><span class="n">doc</span><span class="o">.</span><span class="n">title</span><span class="si">}</span><span class="se">\n</span><span class="si">#{</span><span class="n">doc</span><span class="o">.</span><span class="n">content</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">}</span><span class="o">.</span><span class="n">join</span><span class="p">(</span><span class="s2">&#34;</span><span class="se">\n\n</span><span class="s2">---</span><span class="se">\n\n</span><span class="s2">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">prompt</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">      <span class="no">Answer</span> <span class="n">the</span> <span class="n">user</span><span class="err">&#39;</span><span class="n">s</span> <span class="n">question</span> <span class="n">based</span> <span class="n">only</span> <span class="n">on</span> <span class="n">the</span> <span class="n">following</span> <span class="ss">documentation</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="c1">#{context}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="ss">Question</span><span class="p">:</span> <span class="c1">#{@query}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="no">If</span> <span class="n">you</span> <span class="n">cannot</span> <span class="n">answer</span> <span class="n">based</span> <span class="n">on</span> <span class="n">the</span> <span class="n">provided</span> <span class="n">documentation</span><span class="p">,</span> <span class="n">say</span> <span class="n">so</span> <span class="n">clearly</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">    <span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;gpt-4o-mini&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">messages</span><span class="p">:</span> <span class="o">[</span><span class="p">{</span> <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">prompt</span> <span class="p">}</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">temperature</span><span class="p">:</span> <span class="mi">0</span><span class="o">.</span><span class="mi">3</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:message</span><span class="p">,</span> <span class="ss">:content</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Now we&rsquo;re making two API calls per search: one to embed the query, one to
generate the answer. We&rsquo;re also using pgvector with HNSW indexing for fast
similarity search.</p>
<p><strong>Cost per query:</strong></p>
<ul>
<li>Database: ~15ms (vector similarity search)</li>
<li>OpenAI embeddings API: ~$0.00001 (negligible)</li>
<li>OpenAI chat API: ~$0.003</li>
<li>Total latency: ~1,200ms</li>
</ul>
<p><strong>When this works better:</strong>
The semantic matching is noticeably better. A query about &ldquo;background jobs&rdquo;
will match documents about &ldquo;async processing&rdquo; and &ldquo;delayed tasks&rdquo; because the
concepts are similar in vector space. This is real improvement over keyword
search.</p>
<p><strong>When this still fails:</strong></p>
<ul>
<li>Complex questions requiring information from many documents</li>
<li>Multi-step reasoning (&ldquo;compare these two approaches&rdquo;)</li>
<li>Questions where the first retrieval doesn&rsquo;t get the right context</li>
</ul>
<p>Here&rsquo;s a concrete failure case I hit: A user asks &ldquo;What&rsquo;s the difference
between Sidekiq and Good Job?&rdquo; The vector search retrieves five documents, but
three are about Sidekiq and two are about Good Job. The LLM tries to compare
them but doesn&rsquo;t have complete information about both systems. It hedges and
gives a vague answer.</p>
<h2 id="approach-3-agentic-rag-with-adaptive-retrieval">Approach 3: Agentic RAG with adaptive retrieval</h2>
<p>This is where we let the LLM decide if it needs more information. Instead of
one retrieve-then-generate pass, we give the LLM tools to search again,
rephrase queries, or combine results.</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">AgenticRagSearcher</span>
</span></span><span class="line"><span class="cl">  <span class="no">MAX_ITERATIONS</span> <span class="o">=</span> <span class="mi">3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="ss">client</span><span class="p">:</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@query</span> <span class="o">=</span> <span class="n">query</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@client</span> <span class="o">=</span> <span class="n">client</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">=</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@retrieved_docs</span> <span class="o">=</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search</span>
</span></span><span class="line"><span class="cl">    <span class="n">initial_prompt</span> <span class="o">=</span> <span class="o">&lt;&lt;~</span><span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">      <span class="no">You</span> <span class="n">are</span> <span class="n">a</span> <span class="n">helpful</span> <span class="n">assistant</span> <span class="n">that</span> <span class="n">searches</span> <span class="no">Ruby</span> <span class="n">gem</span> <span class="n">documentation</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="no">User</span> <span class="ss">question</span><span class="p">:</span> <span class="c1">#{@query}</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="no">You</span> <span class="n">have</span> <span class="n">access</span> <span class="n">to</span> <span class="n">these</span> <span class="ss">tools</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">      <span class="o">-</span> <span class="n">search_docs</span><span class="p">(</span><span class="n">query</span><span class="p">):</span> <span class="no">Search</span> <span class="n">documentation</span> <span class="n">with</span> <span class="n">a</span> <span class="n">semantic</span> <span class="n">query</span>
</span></span><span class="line"><span class="cl">      <span class="o">-</span> <span class="n">get_related</span><span class="p">(</span><span class="n">doc_id</span><span class="p">):</span> <span class="no">Get</span> <span class="n">documents</span> <span class="n">related</span> <span class="n">to</span> <span class="n">a</span> <span class="n">specific</span> <span class="n">document</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="no">Think</span> <span class="n">step</span> <span class="n">by</span> <span class="n">step</span><span class="o">.</span> <span class="no">You</span> <span class="n">can</span> <span class="n">search</span> <span class="n">multiple</span> <span class="n">times</span> <span class="n">with</span> <span class="n">different</span> <span class="n">queries</span>
</span></span><span class="line"><span class="cl">      <span class="n">to</span> <span class="n">gather</span> <span class="n">complete</span> <span class="n">information</span> <span class="n">before</span> <span class="n">answering</span><span class="o">.</span>
</span></span><span class="line"><span class="cl">    <span class="no">PROMPT</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">&lt;&lt;</span> <span class="p">{</span> <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">initial_prompt</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="no">MAX_ITERATIONS</span><span class="o">.</span><span class="n">times</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">response</span> <span class="o">=</span> <span class="n">call_llm_with_tools</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="k">break</span> <span class="k">if</span> <span class="n">response</span><span class="o">[</span><span class="ss">:finish_reason</span><span class="o">]</span> <span class="o">==</span> <span class="s2">&#34;stop&#34;</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="n">response</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">        <span class="n">handle_tool_calls</span><span class="p">(</span><span class="n">response</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">results</span><span class="p">:</span> <span class="vi">@retrieved_docs</span><span class="o">.</span><span class="n">uniq</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">answer</span><span class="p">:</span> <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">last</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">iterations</span><span class="p">:</span> <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">length</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">call_llm_with_tools</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;gpt-4o&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">messages</span><span class="p">:</span> <span class="vi">@conversation_history</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">tools</span><span class="p">:</span> <span class="n">tool_definitions</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">temperature</span><span class="p">:</span> <span class="mi">0</span><span class="o">.</span><span class="mi">3</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">message</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">&lt;&lt;</span> <span class="n">message</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">finish_reason</span><span class="p">:</span> <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:finish_reason</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="ss">tool_calls</span><span class="p">:</span> <span class="n">message</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">tool_definitions</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;function&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">function</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;search_docs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;Search documentation using semantic search&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="ss">properties</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="ss">query</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;string&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;The search query&#34;</span>
</span></span><span class="line"><span class="cl">              <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="ss">required</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;function&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">function</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;get_related&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;Get documents related to a specific document&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="ss">properties</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="ss">doc_id</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">                <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;integer&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;The ID of the document&#34;</span>
</span></span><span class="line"><span class="cl">              <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="ss">required</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;doc_id&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">handle_tool_calls</span><span class="p">(</span><span class="n">tool_calls</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">tool_calls</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">tool_call</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">function_name</span> <span class="o">=</span> <span class="n">tool_call</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:function</span><span class="p">,</span> <span class="ss">:name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">arguments</span> <span class="o">=</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">tool_call</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:function</span><span class="p">,</span> <span class="ss">:arguments</span><span class="p">)</span> <span class="o">||</span> <span class="s2">&#34;{}&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="n">result</span> <span class="o">=</span> <span class="k">case</span> <span class="n">function_name</span>
</span></span><span class="line"><span class="cl">      <span class="k">when</span> <span class="s2">&#34;search_docs&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">search_docs</span><span class="p">(</span><span class="n">arguments</span><span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">when</span> <span class="s2">&#34;get_related&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">get_related_docs</span><span class="p">(</span><span class="n">arguments</span><span class="o">[</span><span class="s2">&#34;doc_id&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="vi">@retrieved_docs</span><span class="o">.</span><span class="n">concat</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;tool&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">tool_call_id</span><span class="p">:</span> <span class="n">tool_call</span><span class="o">[</span><span class="ss">:id</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">content</span><span class="p">:</span> <span class="n">format_docs_for_llm</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">concat</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search_docs</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">embedding</span> <span class="o">=</span> <span class="n">generate_embedding</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Documentation</span><span class="o">.</span><span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">embedding</span><span class="p">,</span> <span class="ss">distance</span><span class="p">:</span> <span class="s2">&#34;cosine&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">get_related_docs</span><span class="p">(</span><span class="n">doc_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">doc</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">find</span><span class="p">(</span><span class="n">doc_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Documentation</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">doc</span><span class="o">.</span><span class="n">embedding</span><span class="p">,</span> <span class="ss">distance</span><span class="p">:</span> <span class="s2">&#34;cosine&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">where</span><span class="o">.</span><span class="n">not</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="n">doc_id</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">generate_embedding</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">embeddings</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;text-embedding-3-small&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">input</span><span class="p">:</span> <span class="n">text</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:data</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">format_docs_for_llm</span><span class="p">(</span><span class="n">docs</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">docs</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">doc</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nb">id</span><span class="p">:</span> <span class="n">doc</span><span class="o">.</span><span class="n">id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">title</span><span class="p">:</span> <span class="n">doc</span><span class="o">.</span><span class="n">title</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">content</span><span class="p">:</span> <span class="n">doc</span><span class="o">.</span><span class="n">content</span><span class="o">.</span><span class="n">truncate</span><span class="p">(</span><span class="mi">1000</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span><span class="o">.</span><span class="n">to_json</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>This is a real step up in complexity. We&rsquo;re now orchestrating multiple LLM
calls with tool use. The LLM can search multiple times, explore related
documents, and build up context before answering.</p>
<p><strong>Cost per query:</strong></p>
<ul>
<li>Database: 30-90ms (multiple vector searches)</li>
<li>OpenAI embeddings API: $0.00002-0.00006 (2-6 embedding calls)</li>
<li>OpenAI chat API: $0.015-0.045 (3-5 LLM calls with larger context)</li>
<li>Total latency: 3-8 seconds</li>
</ul>
<p>Notice the variance. Some queries get answered in one iteration. Complex ones
might do three searches with different phrasings, explore related documents,
and make five total LLM calls.</p>
<p><strong>When this works better:</strong>
That comparison query from before (&ldquo;What&rsquo;s the difference between Sidekiq and
Good Job?&rdquo;) now works great. The LLM searches for &ldquo;Sidekiq background jobs&rdquo;,
gets those docs, then searches for &ldquo;Good Job background jobs&rdquo;, gets those docs,
then synthesizes a real comparison.</p>
<p>Multi-part questions work too. &ldquo;How do I set up Stripe payments and handle
webhooks?&rdquo; triggers two separate searches that gather comprehensive
information.</p>
<p><strong>When this gets expensive:</strong>
Every query where the LLM decides it needs more information costs you 3-5x
more. If your users ask a lot of complex questions, your API bill climbs fast.</p>
<p>The latency is also noticeable. Eight seconds feels slow in a web UI. You need
to stream responses or show progress indicators.</p>
<h2 id="approach-4-full-conversational-agent-with-external-tools">Approach 4: Full conversational agent with external tools</h2>
<p>Now we&rsquo;re building a real agent that can search your documentation, browse
external sites, and maintain conversation context across multiple turns.</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DocumentationAgent</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">initialize</span><span class="p">(</span><span class="n">session_id</span><span class="p">,</span> <span class="ss">client</span><span class="p">:</span> <span class="no">OpenAI</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@session_id</span> <span class="o">=</span> <span class="n">session_id</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@client</span> <span class="o">=</span> <span class="n">client</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">=</span> <span class="n">load_conversation_history</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">chat</span><span class="p">(</span><span class="n">message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">&lt;&lt;</span> <span class="p">{</span> <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">message</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="kp">loop</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">response</span> <span class="o">=</span> <span class="n">call_llm_with_tools</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="k">break</span> <span class="k">if</span> <span class="n">response</span><span class="o">[</span><span class="ss">:finish_reason</span><span class="o">]</span> <span class="o">==</span> <span class="s2">&#34;stop&#34;</span>
</span></span><span class="line"><span class="cl">      
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="n">response</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">        <span class="n">handle_tool_calls</span><span class="p">(</span><span class="n">response</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">else</span>
</span></span><span class="line"><span class="cl">        <span class="k">break</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">save_conversation_history</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">response</span><span class="p">:</span> <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">last</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">sources</span><span class="p">:</span> <span class="n">extract_sources</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">call_llm_with_tools</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">chat</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;gpt-4o&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">messages</span><span class="p">:</span> <span class="vi">@conversation_history</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">tools</span><span class="p">:</span> <span class="n">tool_definitions</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">temperature</span><span class="p">:</span> <span class="mi">0</span><span class="o">.</span><span class="mi">3</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">message</span> <span class="o">=</span> <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:message</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span> <span class="o">&lt;&lt;</span> <span class="n">message</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">finish_reason</span><span class="p">:</span> <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:choices</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:finish_reason</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="ss">tool_calls</span><span class="p">:</span> <span class="n">message</span><span class="o">[</span><span class="ss">:tool_calls</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">generate_embedding</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="vi">@client</span><span class="o">.</span><span class="n">embeddings</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model</span><span class="p">:</span> <span class="s2">&#34;text-embedding-3-small&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">input</span><span class="p">:</span> <span class="n">text</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">response</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:data</span><span class="p">,</span> <span class="mi">0</span><span class="p">,</span> <span class="ss">:embedding</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">tool_definitions</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;function&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">function</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;search_internal_docs&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;Search our Ruby gem documentation&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="ss">properties</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="ss">query</span><span class="p">:</span> <span class="p">{</span> <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;string&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="ss">required</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;function&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">function</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;fetch_external_url&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;Fetch content from an external URL like GitHub or RubyGems.org&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="ss">properties</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="ss">url</span><span class="p">:</span> <span class="p">{</span> <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;string&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="ss">required</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;url&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;function&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">function</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nb">name</span><span class="p">:</span> <span class="s2">&#34;search_github&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">description</span><span class="p">:</span> <span class="s2">&#34;Search GitHub repositories and code&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="ss">parameters</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="ss">properties</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">              <span class="ss">query</span><span class="p">:</span> <span class="p">{</span> <span class="ss">type</span><span class="p">:</span> <span class="s2">&#34;string&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">            <span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="ss">required</span><span class="p">:</span> <span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">          <span class="p">}</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">handle_tool_calls</span><span class="p">(</span><span class="n">tool_calls</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">tool_calls</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">tool_call</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">function_name</span> <span class="o">=</span> <span class="n">tool_call</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:function</span><span class="p">,</span> <span class="ss">:name</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">arguments</span> <span class="o">=</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">tool_call</span><span class="o">.</span><span class="n">dig</span><span class="p">(</span><span class="ss">:function</span><span class="p">,</span> <span class="ss">:arguments</span><span class="p">)</span> <span class="o">||</span> <span class="s2">&#34;{}&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="n">result</span> <span class="o">=</span> <span class="k">case</span> <span class="n">function_name</span>
</span></span><span class="line"><span class="cl">      <span class="k">when</span> <span class="s2">&#34;search_internal_docs&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">search_internal_docs</span><span class="p">(</span><span class="n">arguments</span><span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">when</span> <span class="s2">&#34;fetch_external_url&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">fetch_external_url</span><span class="p">(</span><span class="n">arguments</span><span class="o">[</span><span class="s2">&#34;url&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">when</span> <span class="s2">&#34;search_github&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="n">search_github</span><span class="p">(</span><span class="n">arguments</span><span class="o">[</span><span class="s2">&#34;query&#34;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="ss">role</span><span class="p">:</span> <span class="s2">&#34;tool&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">tool_call_id</span><span class="p">:</span> <span class="n">tool_call</span><span class="o">[</span><span class="ss">:id</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">content</span><span class="p">:</span> <span class="n">result</span><span class="o">.</span><span class="n">to_json</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">concat</span><span class="p">(</span><span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search_internal_docs</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">embedding</span> <span class="o">=</span> <span class="n">generate_embedding</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">docs</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">embedding</span><span class="p">,</span> <span class="ss">distance</span><span class="p">:</span> <span class="s2">&#34;cosine&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">docs</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="o">|</span><span class="n">d</span><span class="o">|</span> <span class="p">{</span> <span class="ss">title</span><span class="p">:</span> <span class="n">d</span><span class="o">.</span><span class="n">title</span><span class="p">,</span> <span class="ss">content</span><span class="p">:</span> <span class="n">d</span><span class="o">.</span><span class="n">content</span><span class="o">.</span><span class="n">truncate</span><span class="p">(</span><span class="mi">800</span><span class="p">),</span> <span class="ss">source</span><span class="p">:</span> <span class="s2">&#34;internal&#34;</span><span class="p">,</span> <span class="nb">id</span><span class="p">:</span> <span class="n">d</span><span class="o">.</span><span class="n">id</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">fetch_external_url</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># In production, use a proper HTTP client with timeouts and error handling</span>
</span></span><span class="line"><span class="cl">    <span class="n">response</span> <span class="o">=</span> <span class="no">HTTP</span><span class="o">.</span><span class="n">timeout</span><span class="p">(</span><span class="mi">5</span><span class="p">)</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="n">url</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="ss">url</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">content</span><span class="p">:</span> <span class="n">extract_main_content</span><span class="p">(</span><span class="n">response</span><span class="o">.</span><span class="n">body</span><span class="o">.</span><span class="n">to_s</span><span class="p">)</span><span class="o">.</span><span class="n">truncate</span><span class="p">(</span><span class="mi">2000</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="ss">source</span><span class="p">:</span> <span class="s2">&#34;external&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">HTTP</span><span class="o">::</span><span class="no">Error</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">error</span><span class="p">:</span> <span class="s2">&#34;Failed to fetch URL: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search_github</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Use Octokit or similar</span>
</span></span><span class="line"><span class="cl">    <span class="n">client</span> <span class="o">=</span> <span class="no">Octokit</span><span class="o">::</span><span class="no">Client</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="ss">access_token</span><span class="p">:</span> <span class="no">ENV</span><span class="o">[</span><span class="s1">&#39;GITHUB_TOKEN&#39;</span><span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">results</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">search_code</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="ss">language</span><span class="p">:</span> <span class="s2">&#34;ruby&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">results</span><span class="o">.</span><span class="n">items</span><span class="o">.</span><span class="n">first</span><span class="p">(</span><span class="mi">3</span><span class="p">)</span><span class="o">.</span><span class="n">map</span> <span class="k">do</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nb">name</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">name</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">repo</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">repository</span><span class="o">.</span><span class="n">full_name</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">url</span><span class="p">:</span> <span class="n">item</span><span class="o">.</span><span class="n">html_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">source</span><span class="p">:</span> <span class="s2">&#34;github&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">rescue</span> <span class="no">Octokit</span><span class="o">::</span><span class="no">Error</span> <span class="o">=&gt;</span> <span class="n">e</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">error</span><span class="p">:</span> <span class="s2">&#34;GitHub search failed: </span><span class="si">#{</span><span class="n">e</span><span class="o">.</span><span class="n">message</span><span class="si">}</span><span class="s2">&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">load_conversation_history</span>
</span></span><span class="line"><span class="cl">    <span class="n">cache_key</span> <span class="o">=</span> <span class="s2">&#34;agent_conversation:</span><span class="si">#{</span><span class="vi">@session_id</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">read</span><span class="p">(</span><span class="n">cache_key</span><span class="p">)</span> <span class="o">||</span> <span class="s2">&#34;[]&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">save_conversation_history</span>
</span></span><span class="line"><span class="cl">    <span class="n">cache_key</span> <span class="o">=</span> <span class="s2">&#34;agent_conversation:</span><span class="si">#{</span><span class="vi">@session_id</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Keep last 10 messages to control context size</span>
</span></span><span class="line"><span class="cl">    <span class="n">trimmed_history</span> <span class="o">=</span> <span class="vi">@conversation_history</span><span class="o">.</span><span class="n">last</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">write</span><span class="p">(</span><span class="n">cache_key</span><span class="p">,</span> <span class="n">trimmed_history</span><span class="o">.</span><span class="n">to_json</span><span class="p">,</span> <span class="ss">expires_in</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">hour</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">extract_sources</span>
</span></span><span class="line"><span class="cl">    <span class="vi">@conversation_history</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">select</span> <span class="p">{</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span> <span class="n">msg</span><span class="o">[</span><span class="ss">:role</span><span class="o">]</span> <span class="o">==</span> <span class="s2">&#34;tool&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">flat_map</span> <span class="p">{</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span> <span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">msg</span><span class="o">[</span><span class="ss">:content</span><span class="o">]</span> <span class="o">||</span> <span class="s2">&#34;[]&#34;</span><span class="p">)</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">select</span> <span class="p">{</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span> <span class="n">item</span><span class="o">.</span><span class="n">is_a?</span><span class="p">(</span><span class="no">Hash</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="n">item</span><span class="o">[</span><span class="s2">&#34;source&#34;</span><span class="o">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="o">.</span><span class="n">uniq</span> <span class="p">{</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span> <span class="n">item</span><span class="o">[</span><span class="s2">&#34;id&#34;</span><span class="o">]</span> <span class="o">||</span> <span class="n">item</span><span class="o">[</span><span class="s2">&#34;url&#34;</span><span class="o">]</span> <span class="o">||</span> <span class="n">item</span><span class="o">[</span><span class="s2">&#34;title&#34;</span><span class="o">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>This is a different beast. We&rsquo;re maintaining conversation state, hitting
external APIs, and letting the LLM orchestrate complex research tasks.</p>
<p><strong>Cost per conversation turn:</strong></p>
<ul>
<li>Database: 15-50ms</li>
<li>External API calls: 200-2000ms (GitHub, external sites)</li>
<li>OpenAI embeddings: $0.00001-0.00005</li>
<li>OpenAI chat: $0.02-0.15 (larger context windows, multiple turns)</li>
<li>Total latency: 5-15 seconds</li>
</ul>
<p><strong>When this is worth it:</strong>
You&rsquo;re building a research assistant or technical support bot where users have
complex, multi-turn conversations. They ask follow-up questions, need you to
check external sources, and expect the system to remember context.</p>
<p>A user might ask &ldquo;What&rsquo;s the best gem for image processing?&rdquo;, then follow up
with &ldquo;Show me examples from the ImageMagick wrapper&rdquo;, then &ldquo;Is there a more
modern alternative?&rdquo; The agent maintains context and can search different
sources for each question.</p>
<p><strong>When this is overkill:</strong>
Most search features. If users are doing one-off queries and moving on, you&rsquo;re
paying for conversational capabilities they don&rsquo;t need.</p>
<h2 id="choosing-your-approach">Choosing your approach</h2>
<p>I&rsquo;ve built all four of these systems in production. Here&rsquo;s how I decide which
to use.</p>
<p><strong>Start with enhanced traditional search if:</strong></p>
<ul>
<li>You have well-written documentation with consistent terminology</li>
<li>Queries are mostly straightforward lookup tasks</li>
<li>You need predictable costs and latency</li>
<li>Your document corpus is under 10,000 items</li>
</ul>
<p>The cost difference matters. At 1,000 queries per day, enhanced traditional
search costs you $2/day. Basic RAG costs $3/day. Agentic RAG costs $15-45/day.
A full agent costs $100-300/day.</p>
<p><strong>Move to basic RAG when:</strong></p>
<ul>
<li>Users phrase questions differently than your docs</li>
<li>Keyword search returns poor matches for valid queries</li>
<li>You have good quality source documents</li>
<li>Your corpus is large enough that keyword search becomes unwieldy (50,000+ documents)</li>
</ul>
<p>You&rsquo;ll know you need this when users complain that search doesn&rsquo;t work, and you
look at their queries and think &ldquo;we have docs about that, but they&rsquo;re using
different words.&rdquo;</p>
<p><strong>Move to agentic RAG when:</strong></p>
<ul>
<li>Users ask complex questions requiring multiple sources</li>
<li>You see patterns of users doing multiple searches in sequence</li>
<li>Simple RAG returns incomplete answers</li>
<li>You have budget for 3-5x higher API costs</li>
</ul>
<p>Watch your analytics. If users do three searches in a row and then give up,
they&rsquo;re manually doing what an agentic system would do automatically.</p>
<p><strong>Build a full agent when:</strong></p>
<ul>
<li>You&rsquo;re building a product feature, not just search</li>
<li>Users need multi-turn conversations with context</li>
<li>You need to integrate external data sources</li>
<li>You have engineering resources for proper tool integration and error handling</li>
</ul>
<p>The engineering complexity here is significant. You need proper timeout
handling, retry logic, conversation state management, and graceful degradation
when external APIs fail. This is a feature, not a quick enhancement.</p>
<h2 id="the-implementation-details-that-matter">The implementation details that matter</h2>
<p>Some practical considerations that aren&rsquo;t obvious from the code samples.</p>
<p><strong>Chunking strategy for vector search:</strong>
Don&rsquo;t just embed entire documents. Break them into logical chunks. For
documentation, consider chunking by section with overlap:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">DocumentationChunker</span>
</span></span><span class="line"><span class="cl">  <span class="no">CHUNK_SIZE</span> <span class="o">=</span> <span class="mi">1000</span> <span class="c1"># characters</span>
</span></span><span class="line"><span class="cl">  <span class="no">OVERLAP</span> <span class="o">=</span> <span class="mi">200</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">chunk</span><span class="p">(</span><span class="n">document</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">sections</span> <span class="o">=</span> <span class="n">document</span><span class="o">.</span><span class="n">content</span><span class="o">.</span><span class="n">split</span><span class="p">(</span><span class="sr">/^##\s+/</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">sections</span><span class="o">.</span><span class="n">flat_map</span> <span class="k">do</span> <span class="o">|</span><span class="n">section</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">break_into_overlapping_chunks</span><span class="p">(</span><span class="n">section</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="kp">private</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">break_into_overlapping_chunks</span><span class="p">(</span><span class="n">text</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">chunks</span> <span class="o">=</span> <span class="o">[]</span>
</span></span><span class="line"><span class="cl">    <span class="n">start</span> <span class="o">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="k">while</span> <span class="n">start</span> <span class="o">&lt;</span> <span class="n">text</span><span class="o">.</span><span class="n">length</span>
</span></span><span class="line"><span class="cl">      <span class="n">chunk_end</span> <span class="o">=</span> <span class="n">start</span> <span class="o">+</span> <span class="no">CHUNK_SIZE</span>
</span></span><span class="line"><span class="cl">      <span class="n">chunks</span> <span class="o">&lt;&lt;</span> <span class="n">text</span><span class="o">[</span><span class="n">start</span><span class="o">...</span><span class="n">chunk_end</span><span class="o">]</span>
</span></span><span class="line"><span class="cl">      <span class="n">start</span> <span class="o">+=</span> <span class="no">CHUNK_SIZE</span> <span class="o">-</span> <span class="no">OVERLAP</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="n">chunks</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>This means each document generates multiple rows in your database with
different embeddings. Your vector search returns chunks, not whole documents.</p>
<p><strong>Hybrid search combines the best of both:</strong></p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">hybrid_search</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># Keyword search results</span>
</span></span><span class="line"><span class="cl">  <span class="n">keyword_results</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">search_content</span><span class="p">(</span><span class="n">query</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="c1"># Vector search results</span>
</span></span><span class="line"><span class="cl">  <span class="n">query_embedding</span> <span class="o">=</span> <span class="n">generate_embedding</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">vector_results</span> <span class="o">=</span> <span class="no">Documentation</span><span class="o">.</span><span class="n">nearest_neighbors</span><span class="p">(</span><span class="ss">:embedding</span><span class="p">,</span> <span class="n">query_embedding</span><span class="p">,</span> <span class="ss">distance</span><span class="p">:</span> <span class="s2">&#34;cosine&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">limit</span><span class="p">(</span><span class="mi">20</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="c1"># Combine with reciprocal rank fusion</span>
</span></span><span class="line"><span class="cl">  <span class="n">combine_results</span><span class="p">(</span><span class="n">keyword_results</span><span class="p">,</span> <span class="n">vector_results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">combine_results</span><span class="p">(</span><span class="n">keyword_results</span><span class="p">,</span> <span class="n">vector_results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">scores</span> <span class="o">=</span> <span class="no">Hash</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="n">keyword_results</span><span class="o">.</span><span class="n">each_with_index</span> <span class="k">do</span> <span class="o">|</span><span class="n">doc</span><span class="p">,</span> <span class="n">i</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">scores</span><span class="o">[</span><span class="n">doc</span><span class="o">.</span><span class="n">id</span><span class="o">]</span> <span class="o">+=</span> <span class="mi">1</span><span class="o">.</span><span class="mi">0</span> <span class="o">/</span> <span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">60</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="n">vector_results</span><span class="o">.</span><span class="n">each_with_index</span> <span class="k">do</span> <span class="o">|</span><span class="n">doc</span><span class="p">,</span> <span class="n">i</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">    <span class="n">scores</span><span class="o">[</span><span class="n">doc</span><span class="o">.</span><span class="n">id</span><span class="o">]</span> <span class="o">+=</span> <span class="mi">1</span><span class="o">.</span><span class="mi">0</span> <span class="o">/</span> <span class="p">(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">60</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="no">Documentation</span><span class="o">.</span><span class="n">where</span><span class="p">(</span><span class="nb">id</span><span class="p">:</span> <span class="n">scores</span><span class="o">.</span><span class="n">keys</span><span class="p">)</span><span class="o">.</span><span class="n">sort_by</span> <span class="p">{</span> <span class="o">|</span><span class="n">doc</span><span class="o">|</span> <span class="o">-</span><span class="n">scores</span><span class="o">[</span><span class="n">doc</span><span class="o">.</span><span class="n">id</span><span class="o">]</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>This gives you semantic understanding from vectors plus precise keyword
matching. The reciprocal rank fusion formula is from research on combining
search results. It works better than naive score addition.</p>
<p><strong>Caching saves you money:</strong></p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">CachedRagSearcher</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">search</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">cache_key</span> <span class="o">=</span> <span class="s2">&#34;rag_search:</span><span class="si">#{</span><span class="no">Digest</span><span class="o">::</span><span class="no">MD5</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">(</span><span class="n">query</span><span class="p">)</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    
</span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">cache</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="n">cache_key</span><span class="p">,</span> <span class="ss">expires_in</span><span class="p">:</span> <span class="mi">1</span><span class="o">.</span><span class="n">hour</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">      <span class="n">perform_search</span><span class="p">(</span><span class="n">query</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Popular queries get asked repeatedly. Cache the embeddings and the LLM
responses. This cuts your API costs dramatically for common questions.</p>
<p>The above caching method uses the raw query as the cache key. If users ask the
same question with slightly different wording, they won&rsquo;t hit the cache. You
might want to normalize queries before hashing them for the cache key. For
example, you could lowercase the query, remove stop words, etc.</p>
<p><strong>Monitor your failure modes:</strong></p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">SearchMetrics</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">track</span><span class="p">(</span><span class="n">query</span><span class="p">,</span> <span class="n">approach</span><span class="p">,</span> <span class="n">results</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">SearchLog</span><span class="o">.</span><span class="n">create!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="ss">query</span><span class="p">:</span> <span class="n">query</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">approach</span><span class="p">:</span> <span class="n">approach</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">result_count</span><span class="p">:</span> <span class="n">results</span><span class="o">.</span><span class="n">length</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">latency_ms</span><span class="p">:</span> <span class="n">results</span><span class="o">[</span><span class="ss">:latency</span><span class="o">]</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="ss">cost_cents</span><span class="p">:</span> <span class="n">calculate_cost</span><span class="p">(</span><span class="n">results</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">      <span class="ss">user_clicked</span><span class="p">:</span> <span class="kp">false</span> <span class="c1"># updated when user clicks a result</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Track which results users actually click. If they click the first result, your
search works. If they reformulate their query three times, it doesn&rsquo;t. This
data tells you whether to upgrade your approach.</p>
<h2 id="what-i-actually-recommend">What I actually recommend</h2>
<p>Build the simplest thing first. Most Rails apps should start with Postgres
full-text search plus GPT summarization. It costs almost nothing, has
predictable latency, and works fine for straightforward queries.</p>
<p>Add instrumentation immediately. Track user behavior, measure latency, and
watch your API costs. You need this data to know if upgrading is worth it.</p>
<p>When you see concrete evidence that simple search fails for your use case, add
vector embeddings. This is a real improvement for semantic search. The pgvector
extension makes this straightforward in Postgres. You don&rsquo;t need a separate
vector database until you have millions of documents.</p>
<p>Only add agentic features when you can point to specific query patterns that
need them. &ldquo;Users ask comparison questions and we don&rsquo;t have comparison docs&rdquo;
is a good reason. &ldquo;Agents are cool and I want to try them&rdquo; is not.</p>
<p>Save full conversational agents for when you&rsquo;re building a product feature that
needs conversations. This is engineering work, not just adding a library.
Budget for it appropriately.</p>
<p>The hype cycle pushes developers toward complexity. Resist it. Your users don&rsquo;t
care about your architecture. They care about getting answers quickly and
cheaply. Often the simplest approach gives them exactly that.</p>
<hr>
<p><strong>But does the code work?</strong> See <a href="https://github.com/grymoire7/rails_search" target="_blank" rel="noopener noreffer ">this
repo</a> for an implementation of all
four approaches.</p>
<hr>
<p><strong>Disclaimer:</strong> The code samples in this post are simplified for clarity. In
production, you need proper error handling, timeouts, retries, and security
considerations (e.g., sanitizing user input before embedding). The costs and
latencies mentioned are estimates based on recent OpenAI pricing and typical
response times; your actual costs may vary based on usage patterns and model
choices. Always monitor your API usage and costs when deploying AI features.
Void where prohibited.</p>
]]></description></item><item><title>The Argument for Rails Application Templates</title><link>https://tracyatteberry.com/posts/rails_template/</link><pubDate>Tue, 19 Nov 2024 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/rails_template/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/rails_template/rails-logo.jpg" referrerpolicy="no-referrer">
            </div><h3 id="introduction">Introduction</h3>
<p>While studying for job interviews I wanted to create several new Rails
applications to test my knowledge. I found myself repeating the same steps
over and over again: creating a new Rails project, installing gems, configuring
the database, setting up authentication, and customizing the project structure.
Wait. Why is it failing? Oh, I forgot to add the <code>--database=postgresql</code> flag.
It was frustrating and time-consuming. That&rsquo;s when I discovered Rails application
templates. Here&rsquo;s an example:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># $XDG_CONFIG_HOME/rails_templates/default.rb</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">add_gems</span>
</span></span><span class="line"><span class="cl">  <span class="n">gem_group</span> <span class="ss">:development</span><span class="p">,</span> <span class="ss">:test</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">gem</span> <span class="s1">&#39;rspec-rails&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">gem</span> <span class="s1">&#39;factory_bot_rails&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="n">gem</span> <span class="s1">&#39;faker&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="n">gem</span> <span class="s1">&#39;devise&#39;</span> <span class="k">if</span> <span class="vi">@using_devise</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">get_user_input</span>
</span></span><span class="line"><span class="cl">  <span class="vi">@using_devise</span> <span class="o">=</span> <span class="n">yes?</span><span class="p">(</span><span class="s2">&#34;Do you want to add Devise for user authentication?&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">add_rspec_config</span>
</span></span><span class="line"><span class="cl">  <span class="n">initializer</span> <span class="s1">&#39;generators.rb&#39;</span><span class="p">,</span> <span class="s">&lt;&lt;-CODE
</span></span></span><span class="line"><span class="cl">    <span class="no">Rails</span><span class="o">.</span><span class="n">application</span><span class="o">.</span><span class="n">config</span><span class="o">.</span><span class="n">generators</span> <span class="k">do</span> <span class="o">|</span><span class="n">g</span><span class="o">|</span>
</span></span><span class="line"><span class="cl">      <span class="n">g</span><span class="o">.</span><span class="n">test_framework</span> <span class="ss">:rspec</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">fixtures</span><span class="p">:</span> <span class="kp">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">routing_specs</span><span class="p">:</span> <span class="kp">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">model_specs</span><span class="p">:</span> <span class="kp">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">helper_specs</span><span class="p">:</span> <span class="kp">false</span>
</span></span><span class="line"><span class="cl">      <span class="n">g</span><span class="o">.</span><span class="n">fixture_replacement</span> <span class="ss">:factory_bot</span><span class="p">,</span> <span class="ss">dir</span><span class="p">:</span> <span class="s1">&#39;spec/factories&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="no">CODE</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">add_devise</span> <span class="o">=</span> <span class="n">generate</span> <span class="s1">&#39;devise:install&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">add_rspec</span>  <span class="o">=</span> <span class="n">generate</span> <span class="s1">&#39;rspec:install&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># main setup</span>
</span></span><span class="line"><span class="cl"><span class="n">get_user_input</span>
</span></span><span class="line"><span class="cl"><span class="n">add_gems</span>
</span></span><span class="line"><span class="cl"><span class="n">after_bundle</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">add_devise</span> <span class="k">if</span> <span class="vi">@using_devise</span>
</span></span><span class="line"><span class="cl">  <span class="n">add_rspec</span>
</span></span><span class="line"><span class="cl">  <span class="n">add_rspec_config</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1"># prepare the database</span>
</span></span><span class="line"><span class="cl">  <span class="n">rails_command</span> <span class="s1">&#39;db:drop db:create db:migrate db:test:prepare&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">git</span> <span class="ss">:init</span>
</span></span><span class="line"><span class="cl">  <span class="n">git</span> <span class="ss">add</span><span class="p">:</span> <span class="s1">&#39;.&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="n">git</span> <span class="ss">commit</span><span class="p">:</span> <span class="sx">%Q{ -m &#39;Initialize repository&#39; }</span>
</span></span><span class="line"><span class="cl">  
</span></span><span class="line"><span class="cl">  <span class="n">say</span>
</span></span><span class="line"><span class="cl">  <span class="n">say</span> <span class="s1">&#39;Your Rails app is ready to go!&#39;</span><span class="p">,</span> <span class="ss">:green</span>
</span></span><span class="line"><span class="cl">  <span class="n">say</span>
</span></span><span class="line"><span class="cl">  <span class="n">say</span> <span class="s1">&#39;Next steps:&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="n">say</span> <span class="s2">&#34;  $ cd </span><span class="si">#{</span><span class="n">app_name</span><span class="si">}</span><span class="s2"> &amp;&amp; ./bin/dev&#34;</span><span class="p">,</span> <span class="ss">:yellow</span>
</span></span><span class="line"><span class="cl">  <span class="n">say</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The template file is then provided as an argument when creating a new Rails app:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">rails new myapp -m <span class="nv">$XDG_CONFIG_HOME</span>/rails_templates/default.rb</span></span></code></pre></div></div>
<p>However, since I always wanted to use the same template with postgres and bootstrap
options, it was easier to add the following shell function to my <code>.zshrc</code> file:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">rails_new <span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="o">[[</span> <span class="nv">$#</span> -eq <span class="m">0</span> <span class="o">]]</span>
</span></span><span class="line"><span class="cl">  <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;Usage: rails_new &lt;app_name&gt;&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;rails new \&#34;\$@\&#34; --css=bootstrap -T -d postgresql -m \$XDG_CONFIG_HOME/rails_templates/default.rb&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="m">1</span>
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  rails new <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span> --css<span class="o">=</span>bootstrap -T -d postgresql -m <span class="nv">$XDG_CONFIG_HOME</span>/rails_templates/default.rb
</span></span><span class="line"><span class="cl"><span class="o">}</span></span></span></code></pre></div></div>
<p>Now I can create a new Rails app with the desired configuration by simply running:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">rails_new myapp <span class="c1"># plus any other options</span></span></span></code></pre></div></div>
<p>That&rsquo;s it! No more manual setup, no more forgetting to add the right flags.</p>
<h3 id="a-few-additional-features">A few additional features</h3>
<ol>
<li>
<p>The location of the template can also be a URL. This is useful for sharing
templates across teams or projects.</p>
</li>
<li>
<p>You can define custom generators, such as for controllers, models, or
scaffolds, to further customize the project setup.</p>
</li>
<li>
<p>Application templates can also be applied to existing projects to add
new configurations or dependencies. This might be useful if you need to
update mulitple projects. In that case, the command would look like this:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">rails app:template <span class="nv">LOCATION</span><span class="o">=</span>~/project_zebra_updates.rb</span></span></code></pre></div></div>
</li>
</ol>
<p>Check out the <a href="https://guides.rubyonrails.org/rails_application_templates.html" target="_blank" rel="noopener noreffer ">Rails Guide for Application Templates</a> for more information.</p>
]]></description></item></channel></rss>