<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Henock Mullissa</title>
    <description>The latest articles on DEV Community by Henock Mullissa (@henock_mullissa).</description>
    <link>https://dev.to/henock_mullissa</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4209283%2Fb2854ef6-1fe9-4036-90ab-2d1ba374f606.jpg</url>
      <title>DEV Community: Henock Mullissa</title>
      <link>https://dev.to/henock_mullissa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/henock_mullissa"/>
    <language>en</language>
    <item>
      <title>Building an evidence-based job application tracker with Rails 8, pgvector and Claude</title>
      <dc:creator>Henock Mullissa</dc:creator>
      <pubDate>Mon, 26 Oct 2026 18:14:07 +0000</pubDate>
      <link>https://dev.to/henock_mullissa/building-an-evidence-based-job-application-tracker-with-rails-8-pgvector-and-claude-135m</link>
      <guid>https://dev.to/henock_mullissa/building-an-evidence-based-job-application-tracker-with-rails-8-pgvector-and-claude-135m</guid>
      <description>&lt;p&gt;A typical job ad lists ten or more requirements. When I apply, the hard part isn't&lt;br&gt;
the cover letter; it's answering honestly, for each requirement, &lt;em&gt;can I prove this?&lt;/em&gt;&lt;br&gt;
And if I can, where is the evidence: which project, which line of my CV?&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;Evidex&lt;/strong&gt;, a job application tracker that checks every requirement in an&lt;br&gt;
ad against your own CV and project documents, and cites the passages that support&lt;br&gt;
each verdict. It's live with a public demo (no sign-up):&lt;br&gt;
&lt;strong&gt;&lt;a href="https://evidex.henockmullissa.dev" rel="noopener noreferrer"&gt;evidex.henockmullissa.dev&lt;/a&gt;&lt;/strong&gt;, and the code is on&lt;br&gt;
&lt;a href="https://github.com/henockgm/evidex" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fab3z1wwdu939zdp08tzy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fab3z1wwdu939zdp08tzy.gif" alt="Evidex walkthrough: upload a CV, add a posting, let Claude read the ad, run a match report with cited evidence" width="600" height="338"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post walks through how it works: the stack, the retrieval pipeline, how one&lt;br&gt;
structured Claude call judges every requirement, and the part I'd keep in any LLM&lt;br&gt;
project: checking the model's answer on the server before trusting it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backend:&lt;/strong&gt; Ruby on Rails 8.1 in API mode, with Solid Queue for background jobs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; PostgreSQL 17 with the pgvector extension&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; React 19 + TypeScript (Vite, TanStack Query, Tailwind CSS)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI:&lt;/strong&gt; the Claude API with structured outputs; local embeddings with the
&lt;code&gt;informers&lt;/code&gt; gem (ONNX)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deployment:&lt;/strong&gt; one Docker image, deployed with Kamal to a small Hetzner server&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The React app and the API share one origin: Vite proxies in development, and Rails&lt;br&gt;
serves the built SPA in production. So authentication uses an &lt;code&gt;HttpOnly&lt;/code&gt; session&lt;br&gt;
cookie instead of tokens in browser storage, and every state-changing request&lt;br&gt;
carries a CSRF token.&lt;/p&gt;

&lt;p&gt;The API is documented with OpenAPI generated from the request specs (rswag), and the&lt;br&gt;
frontend's TypeScript types are generated from that same file. CI fails if either&lt;br&gt;
drifts, so the backend and frontend can't quietly disagree about a response shape.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1: turning a CV into searchable passages
&lt;/h2&gt;

&lt;p&gt;When you upload a document (PDF, Word, Markdown or text), a background job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extracts the text&lt;/strong&gt;, page by page for PDFs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Splits it into passages&lt;/strong&gt; along headings and paragraphs, up to 2,000
characters each, with a small overlap so a sentence at a boundary isn't lost.
The heading is kept with each passage, so "Skills: Ruby, PostgreSQL" still reads
as a skills list once it's on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeds each passage&lt;/strong&gt; with &lt;code&gt;multilingual-e5-small&lt;/code&gt;, a small open-source model
that runs inside the Rails process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stores the passages and vectors&lt;/strong&gt; in PostgreSQL with pgvector.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why a local embedding model instead of an API? Three reasons. Claude has no&lt;br&gt;
embeddings model of its own, so it would mean another provider. A CV is personal&lt;br&gt;
data, and with a local model no document text leaves the server just to be&lt;br&gt;
indexed. And it costs nothing per document. The quantized model is about 118 MB and&lt;br&gt;
understands both English and Finnish, which matters for jobs in Finland.&lt;/p&gt;

&lt;p&gt;One detail that's easy to miss with e5 models: they expect a prefix that says&lt;br&gt;
whether the text is a passage or a search query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed_passages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each_slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;BATCH_SIZE&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;flat_map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s2"&gt;"passage: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed_query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt; &lt;span class="s2"&gt;"query: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: hybrid search
&lt;/h2&gt;

&lt;p&gt;Vector search is great at meaning ("background job retries" finds "moved slow&lt;br&gt;
integrations into Sidekiq jobs with retries"), but it can miss exact terms like a&lt;br&gt;
product name or an acronym. Full-text search is the opposite. So Evidex runs both&lt;br&gt;
and merges them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic:&lt;/strong&gt; cosine distance over pgvector, keeping only passages closer than a
cutoff. I measured it on sample CVs: related passages scored 0.13‚Äì0.19 and
unrelated ones 0.20 and above, so the cutoff is 0.2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyword:&lt;/strong&gt; PostgreSQL full-text search on a generated &lt;code&gt;tsvector&lt;/code&gt; column.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge:&lt;/strong&gt; Reciprocal Rank Fusion, which combines two rankings using only each
result's position, so there are no weights to tune.
&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;rankings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;semantic: &lt;/span&gt;&lt;span class="n"&gt;semantic_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;keyword: &lt;/span&gt;&lt;span class="n"&gt;keyword_ids&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rankings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each_with_index&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;RRF_K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# RRF_K = 60, the standard constant&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Every query is scoped to the signed-in user's own, fully processed documents. That&lt;br&gt;
scope lives in one method, so no search path can forget it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3: one structured Claude call per report
&lt;/h2&gt;

&lt;p&gt;For a match report, Evidex runs the hybrid search once per requirement, keeps the&lt;br&gt;
best passages (de-duplicated, at most 60), and numbers them P1, P2, ‚Ä¶ in a stable&lt;br&gt;
order. Then it makes &lt;strong&gt;one&lt;/strong&gt; Claude call for the whole posting, not one per&lt;br&gt;
requirement, which keeps cost and latency down and lets the model see all the&lt;br&gt;
evidence at once.&lt;/p&gt;

&lt;p&gt;The prompt puts the requirements and passages in clearly delimited blocks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;requirements&amp;gt;
R1 [required] 5+ years of professional Ruby on Rails experience
R2 [required] Strong PostgreSQL skills, including query tuning
...
&amp;lt;/requirements&amp;gt;

&amp;lt;passages&amp;gt;
&amp;lt;passage id="P1" document="Alex Example ‚Äì CV" section="Summary"&amp;gt;
Backend developer with six years of experience building Ruby on Rails applications...
&amp;lt;/passage&amp;gt;
...
&amp;lt;/passages&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system prompt says plainly that both blocks are data to assess, never&lt;br&gt;
instructions, even if they contain text addressed to an AI. Job ads and CVs are&lt;br&gt;
untrusted input, and the model has no tools, so an injected instruction has nothing&lt;br&gt;
to act on.&lt;/p&gt;

&lt;p&gt;The response must follow a JSON schema: for every requirement, one verdict&lt;br&gt;
(&lt;code&gt;evidenced&lt;/code&gt;, &lt;code&gt;transferable&lt;/code&gt; or &lt;code&gt;gap&lt;/code&gt;), one or two sentences of reasoning, and the&lt;br&gt;
ids of the passages it relied on, plus a short list of strengths and gaps. With&lt;br&gt;
structured outputs, I never parse free text.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: don't trust the citations, check them
&lt;/h2&gt;

&lt;p&gt;This is the part I'd keep in any LLM feature. A model can say "evidenced" and cite&lt;br&gt;
a passage that doesn't exist, or cite nothing at all. So before saving, the server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;keeps only citations to passages it actually sent in this prompt,&lt;/li&gt;
&lt;li&gt;downgrades any "evidenced" or "transferable" verdict with no valid citation to
"gap", and says so in the reasoning,&lt;/li&gt;
&lt;li&gt;treats an unknown verdict as "gap".
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;cited&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"passage_ids"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:to_s&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;uniq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter_map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;passages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RequirementMatch&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;VERDICTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"verdict"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"gap"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"gap"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;cited&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;empty?&lt;/span&gt;
  &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"gap"&lt;/span&gt;
  &lt;span class="n"&gt;reasoning&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;reasoning&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (Downgraded: no supporting passage was cited.)"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cited passages are stored as snapshots (document title, section and excerpt), so a&lt;br&gt;
report still reads correctly after the CV is edited or deleted. Each run creates a&lt;br&gt;
new version, and when you override a verdict or add a note, that override carries&lt;br&gt;
over to the next version as long as the requirement's text hasn't changed.&lt;/p&gt;

&lt;p&gt;The result is a report I can actually rely on: every "evidenced" line comes with&lt;br&gt;
the passage that proves it, one click away.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping AI costs predictable
&lt;/h2&gt;

&lt;p&gt;Every AI call goes through one wrapper that records the model, input and output&lt;br&gt;
tokens, latency and whether it succeeded. Each account has a daily AI limit, and the&lt;br&gt;
admin area shows calls and estimated cost per account.&lt;/p&gt;

&lt;p&gt;The public demo needed extra care, because anyone can open it without signing up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's &lt;strong&gt;one shared account with fictional data&lt;/strong&gt;, rebuilt every night by a
recurring Solid Queue job.&lt;/li&gt;
&lt;li&gt;Its match report was produced &lt;strong&gt;once&lt;/strong&gt; by the real generator and saved to a
file, so the nightly reset restores it &lt;strong&gt;without calling the AI&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;It has a &lt;strong&gt;smaller AI allowance&lt;/strong&gt; shared by all visitors, and sign-ins are rate
limited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uploads and account settings are locked&lt;/strong&gt;, so nobody's personal files end up
in a shared account, and visitors never see each other's sessions or IP addresses.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Shipping it
&lt;/h2&gt;

&lt;p&gt;Production is one Docker image that runs Rails, the built SPA and the background&lt;br&gt;
jobs. Solid Queue runs inside Puma in async mode, so the embedding model is loaded&lt;br&gt;
once per process instead of once per worker. With the model loaded, the app uses&lt;br&gt;
roughly 620‚Äì670 MB of memory, which fits on a small Arm server at Hetzner. Kamal&lt;br&gt;
handles zero-downtime deploys, HTTPS through kamal-proxy with Let's Encrypt, and&lt;br&gt;
PostgreSQL as an accessory on the same machine.&lt;/p&gt;

&lt;p&gt;Security got the same attention as the AI parts: two-factor sign-in with recovery&lt;br&gt;
codes, re-authentication before sensitive changes, security emails for new devices,&lt;br&gt;
a personal activity log, SSRF-safe fetching when importing a job ad from a link, and&lt;br&gt;
request specs that prove one user can never read another user's data, admins&lt;br&gt;
included.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate the model's output like any other untrusted input.&lt;/strong&gt; Structured
outputs give you the shape; server-side checks give you the truth. Downgrading
uncited claims did more for the report's quality than any prompt tweak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid search beats either half alone.&lt;/strong&gt; Embeddings find meaning, full-text
finds exact terms, and Reciprocal Rank Fusion combines them without tuning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small local embedding model is enough&lt;/strong&gt; for a personal knowledge base, and
it keeps private documents on your own server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One well-structured call beats many small ones.&lt;/strong&gt; Judging all requirements
together is cheaper and lets the model compare evidence across them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small full-stack steps keep a solo project shippable.&lt;/strong&gt; Each feature went from
migration to API to UI to tests in one commit, with CI green before moving on.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo (no sign-up):&lt;/strong&gt; &lt;a href="https://evidex.henockmullissa.dev" rel="noopener noreferrer"&gt;evidex.henockmullissa.dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/henockgm/evidex" rel="noopener noreferrer"&gt;github.com/henockgm/evidex&lt;/a&gt; (MIT licence)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'd love to hear how you validate LLM output in your own projects, or what you'd&lt;br&gt;
add to a tool like this.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ai</category>
      <category>rag</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
