Forcez Claude Code à vous contredire : 14 règles, install en 1 commande

L’enseignement de 60 jours, le ROI en trois axes

Trente-deux jours de production en solo sur un ERP, 118 808 lignes de TypeScript, six versions de doctrine, quatre relecteurs externes intégrés. J’ai compilé ce que j’ai appris en quatorze règles opérationnelles, installables en une commande : le Counterpart Toolkit v0.4.1. C’est à la fois l’enseignement matériel de soixante jours de codage solo avec Claude Code, et la cartographie des quatorze failure modes silencieux que j’ai vu se répéter — pour qui code seul avec une IA en production et n’a plus de PR review pour attraper la dérive.

Le ROI est chiffré sur trois axes, mesuré sur Rembrandt :

R4 *Falsify before fix* — cinq à dix minutes de protocole en amont du fix évitent trente à quatre-vingt-dix minutes de cycle fix-puis-rollback quand la première hypothèse plausible se révèle fausse. ROI 6 à 18× par incident. Sur soixante jours, j’ai cessé de perdre une heure deux à trois fois par semaine sur des fixes qui ne fixaient rien.

R2 *Filesystem over summary* couplée à R6 *Live/Snapshot/Cache* et aux sondes drift quotidiennes — le délai médian apparition→détection d’une divergence silencieuse passe d’invisible à 35,3 jours sur 90 jours glissants. M3 recalibrée publiquement à ≤ 30 jours dans le manifesto, parce que la cible originale (≤ 7 j) était une intuition que la pratique a refusée.

Un sub-agent challenger qui produit des objections au format imposé Tool / Question / Refutation criterion. Du désaccord matériel, pas du « are you sure? » émotionnel qui pousse à réviser sans fait nouveau.

Voici comment, en 1400 mots et une commande d’install.

Le diagnostic — l’incident qui a déclenché la doctrine

Coder seul avec une IA, c’est composer deux complaisances. Celle de l’agent, sycophant par construction parce que le reinforcement learning from human feedback l’a entraîné à plaire au prompteur. Et celle du solo, self-validating par humanité, qui valide son propre travail parce qu’il ne reste plus personne pour le contester. Mises bout à bout, ces deux complaisances produisent un drift que ni l’agent ni l’humain ne signale — et qui ne se voit qu’à l’audit, longtemps après.

C’est en préparant l’audit source unique de fin avril — ADR-0024, un travail de fond sur les divergences que je remettais à plus tard depuis trois mois — que je suis tombé sur l’écart, par hasard, en croisant deux requêtes que personne n’avait jamais croisées avant. Une fiche élève, initiale Y.B. : la colonne contacts.montant_total portait 1 159 € saisis à la main quelque part en 2024, jamais touchés depuis. La somme réelle des échéances, calculée à la volée, en faisait 2 262 €. Mille euros d’écart, sur une seule fiche, sans qu’aucune alarme n’ait jamais sonné. J’élargis le grep : cinq cent soixante contacts dans le même état, parfois à plusieurs milliers d’euros près. Et pourtant montant_total était lue chaque jour dans le dashboard trésorerie — une valeur dérivable qu’on stockait sans rafraîchisseur, traitée comme un fait passé immuable alors qu’elle aurait dû vivre à la volée. C’est exactement le piège que R6 Live/Snapshot/Cache veut empêcher, et R6 est sortie de ce moment-là.

R4 Falsify before fix, la seule règle exposée ici

Le toolkit énonce R4 en cinq étapes textuelles. Le skill falsify-before-fix en est l’invocable instance — la version que Claude Code charge dans sa session, et qu’il ne peut pas sauter quand il s’apprête à écrire du code de fix.

name: falsify-before-fix
description: Activate this skill before writing the fix code on a bug or
  incident. Triggers on "fix", "bug", "patch", "hotfix", "workaround",
  "doesn't work", "diagnose", "hypothesis", "root cause". Enforces a
  single-sentence causal hypothesis and three material probes designed
  to refute it before any line of fix code is committed.
  Operational instance of R4 of the Counterpart Toolkit.

Le protocole tient en cinq étapes : (1) formuler une hypothèse causale en une phrase (pas un symptôme — « le compteur lit depuis l’ancienne table après la migration du 12 mai » vaut mieux que « le compteur est faux ») ; (2) lister trois sondes conçues pour réfuter, pas pour confirmer, parce qu’une sonde de confirmation trouve toujours ce qu’elle cherche, par sélection ; chaque sonde porte ses trois champs Tool / Question / Refutation criterion ; (3) exécuter et reporter la sortie brute, jamais paraphrasée ; (4) brancher — aucune sonde ne réfute → on écrit le fix ; une sonde réfute → on repart d’une nouvelle hypothèse ; sondes ambiguës → quatrième sonde plus tranchante avant tout code ; (5) sortir hypothèse retenue, sondes exécutées, diff, et critère d’observation post-fix.

Pourquoi un skill et pas la règle textuelle qui vivait déjà en CLAUDE.md ? Parce que la règle textuelle n’avait pas tenu sous pression. Le 6 mai, en début d’après-midi, un bug Sentry remonte que le compteur d’inscriptions du jour affiche zéro alors qu’on en a saisi trois en matinée. Mon réflexe arrive avant le protocole, et la main est déjà sur le clavier — « le cache n’est pas invalidé », je commit, je déploie. Trente minutes plus tard, rollback : le bug est toujours là. La cause réelle, qu’une sonde grep de quatre-vingt-dix secondes aurait remontée, c’est qu’aucun appel à cache_invalidate n’existait dans le pipeline d’inscription — pas un cache obsolète, un cache absent. R4 était dans CLAUDE.md depuis trois semaines. Je ne l’ai simplement pas suivie ce jour-là, parce qu’aucun dispositif n’interrompait ma course entre le bug et le commit.

C’est la différence qui fait tout. Une règle textuelle dans CLAUDE.md, l’agent la lit au début de la session, et rien ne l’oblige à la convoquer au moment exact où il en aurait besoin. Un skill, c’est un mécanisme matériel : il se charge automatiquement sur des mots-clés (« fix », « bug », « doesn’t work ») et impose son protocole dans la session active — pas un rappel à se faire, un interrupteur que la session a déjà actionné. Dix jours après l’incident du 6 mai, le skill falsify-before-fix est commit. L’enseignement de l’échec devient un dispositif. C’est la seule des 14 règles exposée dans cet article, et les 13 autres sont dans le repo, toutes construites sur le même principe : matérialiser ce qui resterait pieux en textuel seul.

Le toolkit s’applique à lui-même, chiffres et rétractations

Six versions en 32 jours, chacune ancrée dans un fait nouveau documenté. v0.3 → v0.3.1 sur un premier relecteur externe (apparat théorique disproportionné, rétracté). v0.3.1 → v0.3.2 sur un second (sept recommandations, deux work-items ouverts). v0.3.3 instrumentation M1-M5 publiée. v0.4 séparation toolkit/manifesto sur un troisième. v0.4 → v0.4.1 sur un quatrième relecteur externe (Claude.ai web), un commit consolidé intégrant trois refactors plus la LOC mesurée.

LOC corrigé 118 808 lignes mesurées par find + wc -l sur TS/TSX/JS/JSX (exclusions explicites), contre 35 k cité dans les versions antérieures — la doctrine elle-même avait péché contre R2, Cache sans rafraîchisseur projeté sur sa propre description. M3 recalibrée ≤ 7 j → ≤ 30 j avec justification publique : la cible originale était une intuition non honorée par les 35,3 jours mesurés en pratique. M1 et M5 documentés comme échecs d’instrumentation, pas comme succès : M1 sur-sensible à 12,33 vs ≤ 1 cible, M5 classe 90 % des briefs en unknown.

Four external readers across six versions. The last two audited v0.3.3 then v0.4.1 — their objections are integrated in the release notes (manifesto §From v0.4 to v0.4.1). One reviewer called the falsify-before-fix skill “the best artifact of the doctrine among the ones I read”. v0.5 prévue 15 juillet 2026.

Steal three things in 20 minutes

Trois règles à essayer avant d’installer le reste.

R4 *Falsify before fix* — empêche le cycle fix → rollback déclenché par la première hypothèse plausible mais fausse. Détaillée ci-dessus.

R6 *Live / Snapshot / Cache* — empêche qu’une valeur dérivée stockée diverge silencieusement de sa source. Toute colonne dérivable déclare sa catégorie dans le commit qui la crée, ou le commit est rejeté.

R10 *Silent failure forbidden* — empêche que catch {}, await sans destructuration { error }, 2>/dev/null et autres mécanismes d’avalement mentent à votre observabilité jusqu’à ce que la production craque sur une dépendance en aval.

Le repo : github.com/michelfaure/doctrine-counterpart. La commande d’install :

git clone https://github.com/michelfaure/doctrine-counterpart.git && 
  cd doctrine-counterpart && 
  ./install.sh --yes /path/to/your/project

Licence CC-BY-4.0. Le manifesto promet une citation nominative dans la v0.5 pour qui propose un retour exploitable — quelle règle manque pour votre stack ? Les commentaires DEV.to sont inputs directs pour la prochaine version. R14 *spike escape hatch* couvre le code prototype destiné à disparaître sous sept jours, exempté de R6/R7/R8 : l’adoption ne force pas la même friction au spike qu’au code de production.

Coda

Un agent qui ne vous contredit pas n’est pas un counterpart, c’est une dactylo plus rapide. Ces 14 règles restaurent le désaccord — matériellement, pas mentalement. Elles ne demandent pas à l’agent d’être moins sycophant, ni au solo d’être plus vigilant ; elles posent les dispositifs (skills invocables, hooks bloquants, sub-agent challenger) qui interrompent la course productive là où la complaisance se compose. Le toolkit est la prothèse qu’il reste au solo quand le PR review a disparu et qu’il refuse de coder à l’oreille. Si une seule des 14 vous évite un cycle fix-rollback la semaine prochaine, elle s’est déjà remboursée.

Counterpart Toolkit v0.4.1, fourteen operational rules in ~200 lines, six iterations in 32 days, four external reviews integrated. Tested on 60+ days of solo ERP (118 808 lines, 65+ ADRs). Licence CC-BY-4.0 : github.com/michelfaure/doctrine-counterpart

Make Claude Code disagree with you: a 14-rule counterpart toolkit (install in 1 command)

The 60-day lesson, ROI on three axes

Thirty-two days of solo production on an ERP, 118,808 lines of TypeScript, six doctrine versions, four external reviewers integrated. I’ve compiled what I learned into fourteen operational rules, installable in one command: the Counterpart Toolkit v0.4.1. It is both the material lesson of sixty days coding solo with Claude Code, and the mapping of the fourteen silent failure modes I’ve seen repeat — for anyone coding alone with an AI in production, who no longer has a PR review to catch the drift.

ROI is quantified on three axes, measured on Rembrandt:

R4 *Falsify before fix* — five to ten minutes of upstream protocol prevent thirty to ninety minutes of fix-then-rollback cycle when the first plausible hypothesis turns out wrong. ROI 6 to 18× per incident. Over sixty days, I stopped losing an hour two to three times a week on fixes that fixed nothing.

R2 *Filesystem over summary* paired with R6 *Live/Snapshot/Cache* and the daily drift probes — median apparition→detection of a silent divergence drops from invisible to 35.3 days over a 90-day rolling window. M3 publicly recalibrated to ≤ 30 days in the manifesto, because the original target (≤ 7 days) was an intuition practice refused.

A sub-agent challenger producing objections in the imposed format Tool / Question / Refutation criterion. Material disagreement, not emotional “are you sure?” that pushes you to revise without a new fact.

Here’s how, in 1400 words and one install command.

The diagnosis — the incident that triggered the doctrine

Coding alone with an AI means compounding two complaisances. The agent’s, sycophantic by construction because reinforcement learning from human feedback trained it to please the prompter. And the solo’s, self-validating by humanity, who validates their own work because no one is left to contest it. End to end, these two complaisances produce a drift that neither agent nor human flags — and that only surfaces at audit time, long after.

It was while preparing the late-April source-of-truth audit — ADR-0024, a deep-dive on divergences I had been putting off for three months — that I stumbled onto the gap, by chance, crossing two queries no one had ever crossed before. One student record, initials Y.B.: the contacts.montant_total column carried €1,159 entered by hand somewhere in 2024, untouched since. The actual sum of instalments, computed on the fly, came to €2,262. A thousand-euro gap, on a single record, with no alarm ever ringing. I widened the grep: five hundred and sixty contacts in the same state, some off by several thousand euros. And yet montant_total was read every day in the treasury dashboard — a derivable value being stored without a refresher, treated as an immutable past fact when it should have lived on the fly. This is exactly the trap R6 Live/Snapshot/Cache is meant to prevent, and R6 came out of that moment.

R4 Falsify before fix, the one rule exposed here

The toolkit states R4 as a five-step textual protocol. The falsify-before-fix skill is its invocable instance — the version Claude Code loads into its session, and cannot skip when about to write fix code.

name: falsify-before-fix
description: Activate this skill before writing the fix code on a bug or
  incident. Triggers on "fix", "bug", "patch", "hotfix", "workaround",
  "doesn't work", "diagnose", "hypothesis", "root cause". Enforces a
  single-sentence causal hypothesis and three material probes designed
  to refute it before any line of fix code is committed.
  Operational instance of R4 of the Counterpart Toolkit.

The protocol holds in five steps: (1) formulate the hypothesis in one sentence as a cause, not a symptom (“the counter reads from the old table after the 12 May migration” beats “the counter is wrong”); (2) list three probes designed to refute, not to confirm, because a confirmation probe always finds what it’s looking for by selection; each probe carries its three fields Tool / Question / Refutation criterion; (3) execute and report raw output, never paraphrased; (4) branch — no probe refutes → write the fix; one probe refutes → restart with a new hypothesis; ambiguous probes → fourth sharper probe before any code; (5) output the retained hypothesis, the probes executed, the diff, and the post-fix observation criterion.

Why a skill and not the textual rule that already lived in CLAUDE.md? Because the textual rule didn’t hold under pressure. 6 May, mid-afternoon. A Sentry alert reports the day’s enrolment counter at zero while three enrolments were entered that morning. My reflex arrives before the protocol, and my hand is already on the keyboard — “the cache isn’t invalidated.” I commit, I deploy. Thirty minutes later, rollback: the bug is still there. The actual cause, that a ninety-second grep probe would have surfaced, is that no cache_invalidate call existed in the enrolment pipeline at all — not a stale cache, an absent one. R4 had been in CLAUDE.md for three weeks. I simply didn’t follow it that day, because no apparatus interrupted my course between bug and commit.

That’s the difference that makes everything. A textual rule in CLAUDE.md, the agent reads it at session start, and nothing forces it to summon the rule at the exact moment it would need it. A skill is something else: a material mechanism that loads automatically on keywords (“fix”, “bug”, “doesn’t work”) and imposes its protocol on the active session — not a self-reminder, a switch the session has already flipped for you. Ten days after the 6 May incident, the falsify-before-fix skill was committed. The lesson of the failure becomes an apparatus. It is the one rule of the 14 exposed in this article, and the other 13 are in the repo, all built on the same principle: materialise what would remain pious in text alone.

The toolkit applied to itself — figures and retractions

Six versions in 32 days, each anchored in a documented new fact. v0.3 → v0.3.1 on a first external reviewer (disproportionate theoretical apparatus, retracted). v0.3.1 → v0.3.2 on a second (seven recommendations, two open work-items). v0.3.3 — M1–M5 instrumentation published. v0.4 — toolkit/manifesto separation on a third reviewer. v0.4 → v0.4.1 on a fourth external reviewer (Claude.ai web), a consolidated commit integrating three refactors plus the measured LOC.

LOC corrected to 118,808 lines measured by find + wc -l on TS/TSX/JS/JSX (explicit exclusions), against the 35k figure cited in earlier versions — the doctrine itself had sinned against R2, Cache without refresher projected onto its own description. M3 recalibrated ≤ 7 d → ≤ 30 d with public justification: the original target was an intuition unhonoured by the 35.3 days measured in practice. M1 and M5 documented as instrumentation failures, not as successes: M1 over-sensitive at 12.33 vs ≤ 1 target, M5 classifies 90% of briefs as unknown.

Four external readers across six versions. The last two audited v0.3.3 then v0.4.1 — their objections are integrated in the release notes (manifesto §From v0.4 to v0.4.1). One reviewer called the falsify-before-fix skill “the best artifact of the doctrine among the ones I read”. v0.5 scheduled 15 July 2026.

Steal three things in 20 minutes

Three rules to try before installing the rest.

R4 *Falsify before fix* — prevents the fix → rollback cycle triggered by the first plausible but wrong hypothesis. Detailed above.

R6 *Live / Snapshot / Cache* — prevents a stored derived value from silently diverging from its source. Any derivable column declares its category in the commit that creates it, or the commit is rejected.

R10 *Silent failure forbidden* — prevents catch {}, await without { error } destructuring, 2>/dev/null and other swallowing mechanisms from lying to your observability until production cracks on a downstream dependency.

The repo: github.com/michelfaure/doctrine-counterpart. The install command:

git clone https://github.com/michelfaure/doctrine-counterpart.git && 
  cd doctrine-counterpart && 
  ./install.sh --yes /path/to/your/project

License CC-BY-4.0. The manifesto promises nominal citation in v0.5 for anyone who proposes actionable feedback — which rule is missing for your stack? DEV.to comments are direct inputs for the next version. R14 *spike escape hatch* covers prototype code meant to disappear within seven days, exempt from R6/R7/R8: adoption does not impose the same friction on a spike as on production code.

Coda

An agent that doesn’t disagree with you isn’t a counterpart — it’s a faster typist. These 14 rules restore the disagreement — materially, not mentally. They don’t ask the agent to be less sycophantic, nor the solo to be more vigilant; they install the apparatus (invocable skills, blocking hooks, sub-agent challenger) that interrupts the productive course where complaisance compounds. The toolkit is the prosthesis the solo has left when PR review has disappeared and they refuse to code by ear. If a single one of the 14 spares you a fix-rollback cycle next week, it has already paid for itself.

Counterpart Toolkit v0.4.1, fourteen operational rules in ~200 lines, six iterations in 32 days, four external reviews integrated. Tested on 60+ days of solo ERP (118,808 lines, 65+ ADRs). License CC-BY-4.0: github.com/michelfaure/doctrine-counterpart

RabbitMQ vs Kafka: Choosing the Right Messaging System for Real Backend Architectures (part-1)

I hadn’t planned a two-part series, but as I write it’s become clear the topic can’t be contained in a single article.

Modern backend systems are increasingly event-driven.
Order processing, payment workflows, notifications, audit pipelines, analytics, inventory updates — almost every scalable system today relies on asynchronous communication between services.

At some point, teams usually face a familiar question:

Should we use RabbitMQ or Kafka?

Most comparisons stop at feature matrices:

  • RabbitMQ is a queue
  • Kafka is a stream
  • RabbitMQ is simple
  • Kafka scales better

While technically true, those comparisons rarely help when designing real production systems.

In practice, choosing the wrong messaging platform introduces operational complexity, reliability issues, scaling bottlenecks, and failure scenarios that only become visible under load.

The more important question is not:

“Which technology is better?”

The real question is:

“Which messaging model fits the architectural problem we are solving?”

That distinction matters.
RabbitMQ and Kafka solve fundamentally different categories of problems.
Understanding that difference is far more valuable than memorizing feature comparisons.

In this article, I’ll take you to look at:

  • their core architectural models,
  • delivery and ordering guarantees,
  • scalability characteristics,
  • operational tradeoffs, and
  • where each system fits best in real backend architectures.

1. The Fundamental Architectural Difference
The biggest mistake engineers make when comparing RabbitMQ and Kafka is assuming they solve the same problem.

They do not.

At a high level:

  • RabbitMQ is designed around message delivery.
  • Kafka is designed around event storage and streaming.

That single distinction influences everything else:

  • throughput,
  • ordering,
  • retries,
  • replayability,
  • and scaling

RabbitMQ: Smart Broker for Task Distribution
RabbitMQ follows a traditional broker-centric queueing model.

Producers publish messages to an exchange.
The broker routes those messages into queues.
Consumers process messages from those queues.

Once a consumer acknowledges a message, the broker removes it.

That lifecycle makes RabbitMQ extremely effective for:

  • task distribution,
  • workflow orchestration,
  • background processing,
  • request decoupling, and
  • transactional asynchronous flows.

A typical example would be:

  • order placed,
  • generate invoice,
  • reserve inventory,
  • send email,
  • trigger shipment workflow.

In these systems, the primary concern is usually:

“Has the message been processed successfully?”

RabbitMQ optimizes heavily for that use case.

Its routing capabilities are also powerful:

  • direct exchanges,
  • topic exchanges,
  • fanout patterns,
  • dead-letter routing,
  • delayed retries,
  • priority queues.

This makes RabbitMQ particularly good at workflow-style architectures where delivery control matters more than long-term event retention.

Conceptually, RabbitMQ behaves like a highly capable delivery system.

Once the package is delivered and acknowledged, it is gone.

Kafka: Distributed Event Log
Kafka approaches messaging from a very different angle.

Kafka is fundamentally a distributed append-only log.

Messages are written sequentially into partitions and persisted for a configurable retention period, regardless of whether consumers process them immediately.

Consumers do not “own” messages.
Instead, consumers track offsets representing how far they have read from the log.

This changes the model entirely.

In Kafka:

  • messages are immutable events,
  • consumers are independent readers, and
  • replayability becomes a first-class capability.

That architecture makes Kafka extremely effective for:

  • event streaming,
  • analytics pipelines,
  • audit systems,
  • event sourcing,
  • CDC pipelines, and
  • high-throughput distributed systems.

A critical advantage of Kafka is that events remain available even after consumption.

That enables:

  • replaying failed consumers,
  • rebuilding downstream systems,
  • reprocessing historical events,
  • bootstrapping new services, and
  • maintaining durable event history.

This is why Kafka is commonly used in systems where events themselves are valuable assets.

Conceptually, Kafka behaves less like a queue and more like a distributed event database.

Consumers are simply reading from it at their own pace.

Why This Difference Matters
This architectural distinction directly affects system design.

If the problem is:

  • workflow execution,
  • job distribution,
  • retries,
  • routing complexity,
  • transactional async processing

RabbitMQ often feels more natural.

If the problem is:

  • massive event ingestion,
  • event replay,
  • stream processing,
  • analytics,
  • immutable event history

Kafka becomes significantly stronger.

Many engineering teams choose Kafka primarily because it is considered “more scalable.”

That is often the wrong abstraction.

Scalability alone should not drive architectural decisions.

Operational simplicity, delivery semantics, replay requirements, failure recovery patterns, and consumer behavior are usually far more important.

In practice, some organizations even use both:

  • RabbitMQ for transactional workflows,
  • Kafka for event streaming and analytics.

That hybrid model is often more practical than forcing one technology to solve every asynchronous problem.

2. Delivery Guarantees & Reliability
In distributed systems, failures are normal.

Networks fail.
Consumers crash.
Deployments interrupt processing.
Databases timeout.
Messages get duplicated.

This is where messaging systems become more than just transport layers.
Their delivery guarantees directly affect system reliability.

At-Most-Once Delivery
In this model, messages are delivered once at most.

If something fails before processing completes, the message may be lost.

This approach favors performance over reliability.

Most production systems avoid this model for critical workflows because silent message loss is extremely difficult to debug later.

At-Least-Once Delivery
This is the most common reliability model in real systems.

The broker guarantees that a message will eventually be delivered, but duplicates are possible.

Both RabbitMQ and Kafka primarily operate in this space.

This means:

  • messages may be retried,
  • consumers may receive duplicates,
  • applications must be designed to handle reprocessing safely.

This is where many systems fail.

The messaging platform alone cannot guarantee business correctness.

The application layer still needs:

  • idempotency,
  • safe retry handling,
  • de-duplication strategies, and
  • transactional boundaries.

For example:

  • charging a payment twice,
  • sending duplicate emails,
  • creating duplicate orders,
  • are usually application design problems, not broker problems.

The Reality of “Exactly-Once”
Kafka introduced exactly-once semantics to reduce duplication scenarios between producers and consumers.

While useful, the term is often misunderstood.

In practice, exactly-once processing across:

  • databases,
  • external APIs,
  • payment gateways,
  • email services, and
  • downstream systems
    is still extremely difficult.

The moment a workflow leaves Kafka and interacts with external systems, application-level idempotency becomes necessary again.

This is why experienced engineers rarely rely solely on messaging guarantees.

They design systems assuming:

duplicates will eventually happen.

That mindset produces far more resilient architectures.

RabbitMQ Reliability Model
RabbitMQ relies heavily on:

  • acknowledgments,
  • durable queues,
  • persistent messages, and
  • retry routing.

A message remains in the queue until acknowledged by a consumer.

If the consumer crashes before acknowledgment:

  • the message is requeued,
  • and another consumer can process it.

This works very well for:

  • transactional workflows,
  • background jobs,
  • task processing, and
  • workflow orchestration.

RabbitMQ gives fine-grained control over retries and failure routing, which is one reason it remains popular for operational workflows.

Kafka Reliability Model
Kafka approaches reliability differently.

Messages are persisted into partitions and retained independently of consumer state.

Consumers maintain offsets representing processed positions.

If a consumer crashes:

  • it resumes from the last committed offset.

This model is extremely powerful for:

  • replayability,
  • large-scale event processing,
  • recovery pipelines, and
  • distributed analytics systems.

Instead of relying on broker-side retries, Kafka often pushes retry and recovery strategies into consumer applications.

That gives flexibility, but also increases architectural responsibility.

3. Ordering Guarantees
Ordering sounds simple until systems scale.

In distributed systems, maintaining strict ordering usually comes with tradeoffs:

  • lower parallelism,
  • lower throughput, and
  • operational complexity.

This is another area where RabbitMQ and Kafka behave very differently.

RabbitMQ Ordering Behavior
RabbitMQ preserves ordering within a queue under simple consumption patterns.

But ordering becomes harder once:

  • multiple consumers are introduced,
  • retries occur,
  • messages are requeued, or
  • workloads scale horizontally.

For example:

  • Consumer A processes Message 1 slowly
  • Consumer B processes Message 2 faster

Now processing order is already different from publish order.

In many workflow systems, this is acceptable.

But in domains like:

  • financial ledgers,
  • inventory consistency,
  • sequential state transitions

ordering guarantees become far more important.

RabbitMQ can support ordered processing, but often at the cost of reduced concurrency.

Kafka Ordering Model
Kafka provides ordering guarantees at the partition level.

Messages within a single partition remain ordered.

This is one of Kafka’s strongest design characteristics.

For example:

  • all events for a specific user,
  • order, or
  • account

can be routed to the same partition using a partition key.

That ensures sequential event processing for that entity.

However, Kafka does not provide global ordering across partitions.

And global ordering at scale is expensive anyway.

Most large systems eventually shift toward:

  • partition-local ordering,
  • entity-level consistency, and
  • eventual consistency models.

That tradeoff allows Kafka to scale horizontally while preserving meaningful ordering guarantees.

The Real Engineering Tradeoff
Strict ordering and high scalability often conflict with each other.

Experienced engineers usually optimize for:

  • correctness where it matters, and
  • parallelism where it does not.

Trying to maintain global ordering across massive distributed systems often creates bottlenecks faster than expected.

4. Throughput, Scalability & Backpressure

Messaging systems are usually introduced to improve scalability.

Ironically, they can also become scaling bottlenecks themselves if designed poorly.

High throughput alone is not enough.

The real question is:

Can the system continue processing reliably under sustained load?

That is where scalability and backpressure handling become critical.

RabbitMQ Scalability Characteristics
RabbitMQ performs extremely well for moderate to high throughput transactional workloads.

It is especially effective when:

  • messages require complex routing,
  • processing logic is task-oriented, and
  • workflows need delivery guarantees.

However, RabbitMQ scaling is still broker-centric.

As message volume grows:

  • queues become larger,
  • consumers compete more aggressively,
  • memory usage increases, and
  • broker pressure becomes more visible.

Large queue buildup is often an early warning sign.

In production systems, I’ve seen queue depth silently increase for hours before downstream services eventually collapsed under retry pressure.

RabbitMQ works best when:

  • consumers keep pace with producers,
  • workloads remain operationally manageable, and
  • queue growth is monitored carefully.

Kafka Scalability Characteristics
Kafka was designed with large-scale event ingestion in mind.

Its architecture favors:

  • sequential disk writes,
  • partition-based parallelism, and
  • distributed scaling.

Instead of scaling around queues, Kafka scales around partitions.

More partitions allow:

  • higher producer throughput,
  • parallel consumer processing, and
  • better horizontal scalability.

This makes Kafka extremely effective for:

  • telemetry pipelines,
  • analytics systems,
  • clickstream processing,
  • IoT ingestion, and
  • high-volume event streaming.

Kafka can handle enormous throughput, but scaling it properly introduces operational complexity:

  • partition planning,
  • consumer rebalancing,
  • lag monitoring,
  • storage management, and
  • cluster tuning.

High throughput systems are rarely “set and forget.”

Understanding Backpressure
Backpressure happens when producers generate messages faster than consumers can process them.

Every messaging system eventually faces this problem.

In RabbitMQ:

  • queues begin growing rapidly,
  • memory usage increases,
  • retries accumulate, and
  • downstream systems become overloaded.

In Kafka:

  • consumer lag increases,
  • partitions accumulate unprocessed events, and
  • recovery time grows significantly.

Neither system magically solves slow consumers.

The real solution usually involves:

  • scaling consumers,
  • reducing processing latency,
  • controlling retries,
  • implementing rate limiting, and
  • improving downstream resilience.

One of the most dangerous assumptions in distributed systems is:

“The broker will absorb the traffic.”

Eventually, every queue becomes someone else’s production incident.

In the next-part of the article, I’d cover topics like retry handling, DLQs, replayability and operational complexity.

Appreciate your suggestions & support.

ESP32 GARDEN

Construyendo ESP32 Garden: Un Sistema IoT de Monitorización Agrícola de Extremo a Extremo

La gestión botánica eficaz requiere una monitorización ambiental precisa. Depender de suposiciones para los horarios de riego o las condiciones ambientales a menudo conduce a una salud subóptima de las plantas. Para solucionar esto, desarrollé ESP32 Garden, un sistema IoT de monitorización agrícola de extremo a extremo.

Este proyecto captura datos ambientales en tiempo real utilizando sensores de hardware, los procesa a través de una arquitectura de microservicios personalizada en Python y visualiza los datos históricos en un panel web interactivo . Además, use una carcasa impresa en 3D para proteger los microcontroladores de la exposición a la tierra y la humedad.

A continuación, se detalla la arquitectura del sistema, los componentes de hardware y el stack de software utilizados para dar vida a este proyecto.

La Arquitectura de Hardware

El sistema utiliza un microcontrolador ESP32, seleccionado principalmente por sus capacidades Wi-Fi integradas y su decente potencia de procesamiento. Se interconecta con los siguientes componentes de hardware:

  • Sensor DHT11: Captura la temperatura ambiente y la humedad relativa.
  • Sensor Analógico de Humedad del Suelo: Mide el contenido volumétrico de agua en la tierra.
  • Sensor de Lluvia / Gotas: Detecta eventos de precipitación.
  • Pantalla OLED (SSD1306): Proporciona depuración y lectura de estado en tiempo real en el propio dispositivo, sin necesidad de acceder al panel web.

Diseñando la Carcasa

Desplegar componentes electrónicos expuestos en un entorno agrícola introduce riesgos significativos de cortocircuitos debido al agua y la suciedad. Para mitigar esto,imprimí en 3D una carcasa personalizada. Diseñada para parecerse a una pequeña casa, esta cubierta aloja de forma segura el ESP32 y el cableado, dejando solo las sondas de los sensores necesarias expuestas al entorno.

El Stack de Software

La infraestructura de software es modular, dividiendo las responsabilidades entre el firmware integrado, una interfaz gráfica de gestión de escritorio, la ingesta de datos en segundo plano y la visualización frontend.

1. El Firmware: C++,Arduino

El ESP32 actúa como un dispositivo de borde (edge device) ejecutando un servidor web ligero. En lugar de renderizar una página HTML pesada en el microcontrolador, expone una API REST a través del endpoint /json. Cuando se le consulta, construye y devuelve las lecturas actuales de los sensores en un payload JSON estructurado.

void handleJSON() {
  String json = "{";
  json += ""temperatura":" + String(temperatura) + ",";
  json += ""humedad_amb":" + String(humedadAmb) + ",";
  json += ""suelo":" + String(porcSuelo) + ",";
  json += ""lluvia":" + String(porcLluvia);
  json += "}";
  server.send(200, "application/json", json);
}

2. Servicio de Ingesta de Datos

Para registrar los datos a lo largo del tiempo, un script de Python actúa como un demonio en segundo plano. Envía peticiones HTTP periódicamente a la dirección IP del ESP32, analiza los datos JSON entrantes, añade una marca de tiempo y registra la entrada en un archivo CSV local utilizando pandas.

while True:
    try:
        r = requests.get(URL, timeout=5)
        raw = r.json()

        nueva_fila = {
            'fecha_hora': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            'temperatura': raw.get('temperatura', 0),
            'humedad_amb': raw.get('humedad_amb', 0),
            'suelo': raw.get('suelo', 0),
            'lluvia': raw.get('lluvia', 0)
        }

        df = pd.DataFrame([nueva_fila])

        if not os.path.exists(CSV):
            df.to_csv(CSV, index=False, columns=COLUMNAS)
        else:
            df.to_csv(CSV, mode='a', header=False, index=False, columns=COLUMNAS)

    except Exception as e:
        print(f"Error de red: {e}")

    time.sleep(5)

3. Panel de Visualización de Datos

Los datos en bruto requieren interpretación. Utilicé streamlit y plotly para desarrollar una aplicación web que lee el archivo CSV compilado y renderiza gráficos interactivos de series temporales. Esto permite un análisis detallado de tendencias, como monitorizar la tasa exacta de agotamiento de la humedad del suelo.

import streamlit as st
import plotly.express as px

# Renderizar gráficos interactivos con Plotly
col_left, col_right = st.columns(2)

with col_left:
    fig_suelo = px.area(df_filtrado, x='fecha_hora', y='suelo', 
                        title="Humedad del Suelo (%)",
                        color_discrete_sequence=['#7D4F39'])
    st.plotly_chart(fig_suelo, use_container_width=True)
    st.write(f"**Media de humedad (Suelo):** {df_filtrado['suelo'].mean():.1f}%")

with col_right:
    fig_hum_amb = px.area(df_filtrado, x='fecha_hora', y='humedad_amb', 
                          title="Humedad Ambiental (%)",
                          color_discrete_sequence=['#0077B6'])
    st.plotly_chart(fig_hum_amb, use_container_width=True)

4. Gestor de Configuración (main.py)

Para agilizar el despliegue y gestionar los microservicios, el proyecto incluye un panel de control construido con tkinter. Esta GUI permite al usuario monitorizar el estado de red del ESP32 en un hilo en segundo plano y alternar los servicios de captura de datos y del panel web mediante subprocesos (subprocesses).

def monitorear(self):
    while True:
        url = f"http://{self.ip_esp}/json"
        try:
            requests.get(url, timeout=2)
            self.lbl_status.config(text="ONLINE", fg="green")
        except:
            self.lbl_status.config(text="OFFLINE", fg="red")
        time.sleep(5)

El Montaje Final en Acción

Código Abierto y Replicación

Este proyecto acorta la brecha entre los sistemas integrados de bajo nivel y los frameworks de ciencia de datos de alto nivel. Sirve como una base altamente escalable para cualquiera que busque adentrarse en el espacio del IoT.

Todo el proyecto es de código abierto. Puedes revisar la arquitectura completa, acceder al firmware y encontrar instrucciones detalladas de configuración en mi repositorio de GitHub:

ros1ndoo/ESP32Garden en GitHub

Pasos para el Despliegue:

  1. Clona el repositorio e instala las dependencias.
  2. Conecta los sensores DHT11, de humedad del suelo y de lluvia a los pines GPIO designados del ESP32.
  3. Actualiza el archivo settup.txt con las credenciales de tu red local.
  4. Ejecuta main.py, haz clic en “Aplicar” para generar las cabeceras de C++ y flashea el ESP32.
  5. Utiliza la interfaz de Tkinter para inicializar el servicio de captura de datos y lanzar el panel de streamlit.

BigQuery, Snowflake, Redshift, Databricks, Fabric: where each one silently inflates your bill

TL;DR

  • Cloud data warehouses trap you with hidden fees: the Scan Tax (charging per terabyte scanned), the Idle Tax (60-second minimums for inactive compute), and the Complexity Tax (opaque billing units).
  • The major incumbents, BigQuery, Snowflake, Redshift, Databricks, and Fabric, force you into punishing trade-offs between bankrupting your budget on exploratory queries, eating costs for idle time, or suffering through agonizing resume latencies.
  • MotherDuck provides a modern cloud data warehouse alternative designed to eliminate these taxes with a strict 1-second billing minimum, true scale-to-zero architecture, and flat compute pricing for workloads ranging from gigabytes to petabytes with Managed DuckLake (in preview).

My worst on-call wakeup wasn’t a database melting down at 3 AM. It was an email from finance.

Someone had run a query in a BI tool, and it generated a $50,000 Google BigQuery bill overnight. It was a simple, innocent-looking query, the exact kind a junior analyst writes to explore a new dataset. But that single query triggered a full table scan on a massive, unpartitioned table, and the meter just spun and spun.

Back when we were managing our own on-prem Teradata and Oracle clusters, the pain was upfront. You paid for the hardware, the power, the cooling, and the army of DBAs needed to keep it all running. We moved to the cloud to escape that management tax, only to find a whole new set of hidden ones.

The major cloud data warehouses aren’t just selling you compute and storage. They are built on pricing models with hidden “taxes” that punish you for growing, for experimenting, and sometimes, even for being idle. Choosing a data warehouse today is like picking a commercial electricity plan. Some plans look incredibly cheap on paper but have massive “peak demand” charges that bankrupt you the moment you actually need the power.

After years of signing the checks and getting burned, I’ve decoded the pricing models of the big five: BigQuery, Snowflake, Redshift, Databricks, and Fabric. Here is exactly where the bodies are buried.

The actual storage of your data is largely a solved, commoditized problem. Across the major vendors, storage costs are cheap and highly predictable, often hovering around $23.00 per terabyte per month on-demand for Snowflake, or dropping to $0.01 per gigabyte per month for long-term storage in BigQuery. When CTOs complain about their data warehouse bills, they aren’t complaining about S3 buckets. The real financial battleground is compute, concurrency, and architecture. That’s where vendors make their margins.

The three hidden taxes designed to drain your cloud budget

Almost all surprise cloud costs stem from three specific pricing mechanics.

The Scan Tax punishes you for asking questions of your data. The Idle Tax punishes you for not running queries 24/7. The Complexity Tax (and its ugly cousin, Egress Fees) punishes you for not having a Ph.D. in vendor-specific billing models.

Vendor Pricing Unit Billing Minimum The Hidden Penalty Ideal Workload
Google BigQuery Pay-per-TB Scanned Per query Scan Tax: Unpredictable costs for ad-hoc exploration. Sporadic, well-defined queries on partitioned data.
Snowflake Per-second Credits 60 seconds Idle Tax: Pays for unused time on short queries. High-throughput BI and ETL with consistent, predictable usage patterns.
AWS Redshift Provisioned / Serverless RPUs 60 seconds / Hourly Idle & Complexity Tax: High operational overhead. Predictable, high-volume workloads with dedicated ops.
Databricks Databricks Units (DBUs) Opaque / Variable Complexity & Egress Tax: Obscured true cost. All-in-one data science and large-scale Spark ETL.
Microsoft Fabric Capacity Units (CUs) Opaque Complexity Tax: Obscured resource consumption. Enterprises fully committed to the Microsoft/Power BI ecosystem.
MotherDuck Compute-time only 1 second Predictable time-based billing; no scan or idle penalties. Modern cloud data warehouse for interactive BI to large-scale batch processing.

The scan tax: paying a penalty to analyze your own data

Google BigQuery, AWS Athena, and Azure Synapse Serverless rely heavily on a pay-per-TB-scanned model. The pitch is seductive, especially for startups: “You only pay for what you query.”

At around $5.00 to $6.25 per terabyte processed, it sounds like a bargain, until a single poorly written query costs you thousands of dollars. It’s the equivalent of going to a massive public library where you aren’t charged for the book you read, but rather a fee for every single book you had to move out of the way to find it.

This model is exactly where my $50,000 bill came from. The query was devastatingly simple:

SELECT user_id, COUNT(event_id) FROM events_log GROUP BY 1;

The problem? It lacked a WHERE clause on a partitioned date column. It triggered a full scan of a petabyte-scale table.

(For the curious: serverless scan-based engines allocate compute slots to brute-force read every underlying file block from cold storage into memory if the query planner cannot prune files via a partition key. You are paying for the massive physical I/O overhead of that distributed read, regardless of how small the final result set is.)

For truly sporadic, well-defined weekly reports, this model can be cost-effective. For interactive, exploratory BI where query patterns are unpredictable by nature, you are flying blind. It forces engineering teams to become “cost police,” constantly reviewing queries and enforcing strict partitioning schemes just to avoid financial catastrophe.

The idle tax: paying for compute you aren’t even using

I learned about the Idle Tax the hard way while building a customer-facing analytics dashboard. We initially set our provisioned data warehouse to run 24/7, but when the first bill arrived, my jaw dropped. To save money, we aggressively configured the cluster to auto-suspend after one minute of inactivity. The cost went down, but the support tickets flooded in. Our users were suffering through 10-second “resume” latencies every time they loaded a dashboard after a few minutes of quiet. We were stuck thrashing between burning budget and ruining the user experience.

Snowflake and Amazon Redshift are the clearest examples of the idle tax in practice. Their pitch is “decoupled compute and storage,” giving you production-grade scalability. You’re paying for “virtual warehouses” or “RPUs” (billed per RPU-hour) that carry a hard billing minimum, often 60 seconds.

Imagine you run 30 short, 5-second queries in an hour to power a customer-facing BI dashboard. You are not billed for 150 seconds of compute. You are billed for 30 queries * 60 seconds = 1800 seconds. You just paid for 1,650 seconds of pure idle time.

It’s like a taxi meter that charges you for a full mile even if you only drive one block.

This model is especially punishing for customer-facing embedded analytics or ad-hoc BI, where queries are spiky and short-lived. You are left with a terrible architectural choice: either over-provision a warehouse and eat the idle tax, or set it to auto-suspend aggressively and make your users suffer through long resume latencies.

For massive, 24/7 ETL workloads, a provisioned model can be highly efficient. The problem lies in applying it to intermittent workloads.

The complexity tax and egress fees: when you need a PhD to understand your bill

I once spent an entire week auditing our cloud bill only to discover that a junior engineer had accidentally scheduled a massive, daily production ETL job using Databricks’ “All-Purpose” compute instead of the purpose-built “Jobs Compute.” That single checkbox mistake silently tripled the cost of the pipeline for months. The pricing model was so opaque that nobody caught it.

This is the reality of platforms like Databricks and Microsoft Fabric. The pitch is a “unified analytics platform.” The reality is a labyrinth of proprietary billing units like DBUs (Databricks Units) or CUs (Capacity Units) that are nearly impossible to map back to actual hardware consumption.

What exactly is a DBU? The answer depends on the VM type, the cloud region, and whether it’s for an automated job or an interactive notebook. It’s like trying to buy a car and being quoted a price per spark-plug ignition. It is a direct tax on not being a platform expert.

Alongside opaque compute units, these providers often extract massive, hidden network egress charges when you try to move data out of their ecosystem, penalizing integrations and compounding the complexity tax. vendor docs pricing page

Redshift carries its own complexity tax, requiring full-time database experts to manage Workload Management (WLM) queues and cluster resizing just to keep costs in check. This operational overhead isn’t just theoretical. MotherDuck’s Mega instance at $12.00/hr is 2.2x faster and 70% cheaper than a comparable 4-node Redshift ra3.16xlarge cluster, without requiring a dedicated team to manage it.

An alternative: predictable pricing with a 1-second minimum and zero idle tax

After getting burned by all three taxes, I started looking for a warehouse built on a fairer, more transparent philosophy. That’s when I found MotherDuck. What I was really looking for was simple: a billing model I could explain to a finance team without a spreadsheet, and a cold-start fast enough that I’d never have to choose between saving money and not embarrassing myself in front of users. MotherDuck was the first warehouse where both of those were true at the same time.

True scale-to-zero with a 1-second minimum

MotherDuck has a strict 1-second minimum charge. If a query runs for 500ms, you are billed for 1 second. If it runs for 5 seconds, you are billed for 5 seconds. End-user compute (called “Ducklings”) spins up in about 100ms, so there is no painful trade-off between saving money and delivering fast performance. The 60-second minimum waste simply does not exist.

Flat compute pricing, not a scan tax

You pay a flat, hourly rate for the compute you use (e.g., ~$0.60/hr for the Pulse instance), not a penalty based on how many terabytes a query happens to touch. You can run that full table scan without fear of a five-figure bill. The cost is predictable because it is based on execution time, a metric engineers can actually reason about and optimize.

Simple, SQL-first, no DBU math

MotherDuck bills in standard compute units that map directly to vCPU and RAM. The pricing is public, flat, and easy to understand. You don’t have to deal with the JVM overhead of Spark or the convoluted cluster configurations of Databricks and Redshift. Connecting via the Python SDK takes seconds, without configuring complex IAM roles or service accounts.

Petabyte-scale without the penalty

The assumption that scale-to-zero warehouses can’t handle petabyte workloads is now outdated. MotherDuck now supports petabyte-scale workloads through Managed DuckLake (in preview), giving you the same cost-predictability and ease of use whether you are querying a few gigabytes of local CSVs or petabytes of cloud data.

Code tells the story: from expensive to predictable

To control costs on a scan-based engine, you have to rewrite the query, add a WHERE clause, and pray your tables are perfectly partitioned:

-- Still risky if a user forgets the WHERE clause
SELECT user_id, COUNT(event_id)
FROM events_log
WHERE event_date = '2026-05-18' -- Must partition and filter by this!
GROUP BY 1;

The MotherDuck equivalent: just run the query. Cost is per second of execution, not per TB scanned.

-- cost is per second of execution, not per TB scanned
SELECT user_id, COUNT(event_id)
FROM events_log
GROUP BY 1;

Putting it to the test: matching the right model to your workload

The right architecture depends entirely on your use case.

For startups to enterprise scale: You need to avoid the idle and complexity taxes at all costs. MotherDuck is designed to grow with you. With Managed DuckLake (in preview), you can scale from gigabytes to petabytes with the same simple, scale-to-zero model. It is a highly cost-effective alternative to heavy platforms like Azure Synapse.

For customer-facing embedded analytics: You need low latency, high concurrency, and strict cost controls. ClickHouse is a strong baseline here due to its raw query speed and incredible 10x storage compression, but managing ClickHouse clusters introduces significant operational overhead. MotherDuck gives you the columnar performance benefits without the management burden. Its hypertenancy model isolates compute per user, preventing “noisy neighbors.” (Each isolated user query runs inside its own secure, lightweight environment, completely decoupling one user’s compute spikes from another’s. You get the security and predictable performance of a single-tenant architecture with the cost efficiency of a multi-tenant one.) That predictable per-user cost model lets you offer more competitive and profitable pricing for your own SaaS product.

For ad-hoc BI and interactive dashboards: You are running lots of short, spiky queries. The 60-second minimum from Snowflake will destroy your budget. The 1-second minimum from MotherDuck saves you from paying for compute you never actually used.

Conclusion

Unpredictable data warehouse bills are a feature, not a bug, of the incumbents’ pricing models. They were designed in a different era, and their business models rely heavily on the waste generated by the Scan Tax, the Idle Tax, and the Complexity Tax.

The choice of a data warehouse is an architectural decision with deep financial consequences. Choose a partner whose business model supports yours, not one that profits from your idle time or accidental table scans.

After years of fighting surprise bills, I found that a simpler, more transparent model wasn’t just cheaper. It gave my team back the time we were burning on query audits and cost reviews, time we could spend building instead. That’s the real cost of a bad pricing model: not just the dollar amount on the invoice, but everything your engineers stopped doing to manage it.

Frequently Asked Questions

Which serverless warehouse minimizes idle costs?

The 60-second billing minimum is what kills budgets for intermittent workloads. Every short query, a 3-second dashboard refresh, a 7-second ad-hoc lookup, gets rounded up to a full minute. Multiply that across dozens of concurrent users and you are paying for compute that never ran. A 1-second minimum with 100ms cold-start eliminates that rounding error entirely.

Which architecture provides a better price-performance ratio for spiky, intermittent query patterns?

Provisioned systems are designed for sustained, predictable throughput. When your workload is spiky, you are paying peak rates during quiet periods and scrambling during bursts. A serverless engine that bills strictly for execution time matches your actual usage curve, so your bill tracks your activity rather than your worst-case capacity estimate.

How do I avoid the complexity tax and egress fees in cloud data warehouse pricing?

The complexity tax compounds quietly. You end up needing a FinOps specialist just to interpret the bill, let alone optimize it. The cleaner path is a platform that prices in units you can reason about without a certification: vCPU time and RAM, billed at a public flat rate. Egress fees are a separate trap. If moving data out of the platform costs money, your integration architecture is constrained by your billing model, which is backwards.

What are cost-effective alternatives to BigQuery for a small data team?

The scan model is fine when you control the query patterns. Small teams rarely do. Analysts explore, iterate, and occasionally forget partition filters. A compute-time model removes that risk entirely: a runaway query costs you the seconds it ran, not the terabytes it touched. That distinction matters enormously when you don’t have a dedicated data engineering team reviewing every query before it hits production.

How do I get predictable pricing to replace my unpredictable Snowflake costs?

Snowflake’s 60-second minimum is invisible until you do the math. If your dashboard fires 40 short queries per session and users open it 500 times a day, you are paying for hours of compute that lasted seconds. Switching to per-second billing converts that hidden multiplier into a straightforward calculation: how long did the query actually run? That’s the number on your bill.

Are there cost-effective alternatives to Azure Synapse that don’t require massive price jumps when scaling?

Synapse’s pricing tiers create awkward inflection points where crossing a usage threshold forces you into a much higher cost bracket. A flat compute model with no tier boundaries scales linearly: double the workload, roughly double the cost. Managed DuckLake extends that same model to petabyte-scale, so growth doesn’t suddenly trigger a renegotiation with your vendor.

Can a scale-to-zero serverless warehouse handle petabyte-scale data?

Scale-to-zero and petabyte-scale were mutually exclusive until recently. The assumption was that handling large data volumes required persistent, warm infrastructure. Managed DuckLake separates compute from storage cleanly enough that you can query petabytes without keeping compute running between queries. You pay for the seconds your query runs, regardless of how much data it touches.

Which data warehouse offers the most predictable pricing for embedded analytics and customer-facing dashboards?

Predictability in embedded analytics requires two things: fast cold-starts (so you aren’t paying for warm standby) and per-user compute isolation (so one customer’s heavy query doesn’t inflate everyone else’s bill). ClickHouse wins on raw speed but demands operational investment most product teams can’t justify. Hypertenancy solves the isolation problem architecturally, and a 100ms spin-up means you aren’t paying to keep compute warm between user sessions.

More OpenID Federation with pac4j and Connect2id

Before login

I strongly recommend that you read the first article about the OpenID Federation protocol.

This new article dives deeper into the OpenID Federation support in pac4j and Connect2id.

You should download and use the latest versions of both software (at least version 6.5.1 for pac4j).

1) Let’s log in (again)

a) Calling the login page

Before login

As we have previously seen, the first login generates several logs on both sides (client = RP = pac4j + server = OP = connect2id).

Several HTTP calls are required to check the JWKS and the entity statements and establish the trust chains.

This could be a performance issue if these HTTP calls were made for each login attempt, though on the second try, the logs are much less verbose before displaying the login page:

The pac4j logs:

DEBUG o.p.o.r.OidcRedirectionActionBuilder     : Request Object claim names: [iss, aud, iat, exp, jti, scope, response_type,
 redirect_uri, state, code_challenge_method, client_id, code_challenge, response_mode]
DEBUG o.p.o.r.OidcRedirectionActionBuilder     : Authz parameter names: [response_type, request, client_id, scope]
DEBUG o.p.o.r.OidcRedirectionActionBuilder     : Authentication request URL: http://127.0.0.1:8080/c2id-login
 ?response_type=code&request=eyJr...hULhwg&client_id=http%3A%2F%2Flocalhost%3A8081&scope=openid%20profile%20email

The Connect2id logs:

INFO AUTHZ-SESSION - [OP2101] Created new auth session: sid=FKEttMylh3MVBAtu59F7CXx5m4BTGXZ2_DKowRh_8eg
 client_id=http://localhost:8081 scope=[openid, profile, email]

Hopefully, trust chains have been cached depending on the expiration time and the whole plumbing has not been triggered a second time.

b) After typing in the login and password

After login

After a successful login, we get the following logs:

On the Connect2id side:

INFO SESSION-STORE - [SS0201] Added new session: sub=alice ctx=web sid_key=MhNnqmimFgizG5ALmnp-tg
INFO AUTHZ-SESSION - [OP2103] Created new consent session: sid=FKEttMylh3MVBAtu59F7CXx5m4BTGXZ2_DKowRh_8eg subject=alice
 client_id=http://localhost:8081
INFO AUTHZ-SESSION - [OP2108] Created authZ response: subject=alice client_id=http://localhost:8081 response_type=[code]
INFO TOKEN - HTTP POST request: ip=127.0.0.1 path=/c2id/token
INFO TOKEN - [OP6204] Authenticated: client_id=http://localhost:8081 method=private_key_jwt client_auth_id=NgAiEAADRhMrDiZp
INFO AUTHZ-STORE - [AS0280] Issued access token: sub=alice act=null client_id=http://localhost:8081 scope=[openid]
INFO TOKEN - [OP6225] Success response: client_id=http://localhost:8081 grant=code tokens=[access,id]
INFO USERINFO - HTTP GET request: ip=127.0.0.1 path=/c2id/userinfo
INFO AUTHZ-STORE - [AS0213] Inspected valid SELF_CONTAINED Bearer access token: sub=alice act=null client_id
 =http://localhost:8081 iat=1775213922: eyJraWQiOiJQUlJ6Iiwid...
INFO USERINFO - [OP7307] Received valid UserInfo request: sub=alice claims=null ia_id=aac191d2-dcc5-4837-8545-692e204bcc07

This is fairly obvious:

  1. A POST call is performed on the /c2id/token endpoint using the private_key_jwt client authentication method (this is what we have configured on the pac4j side)
  2. A GET call is performed on the /c2id/userinfo endpoint using the access_token as bearer (= HTTP header).

This is the regular OIDC login process even if the flow has started in a federation way.

On the pac4j side:

DEBUG o.p.o.c.e.OidcCredentialsExtractor       : Authentication response successful
DEBUG o.p.o.c.e.OidcCredentialsExtractor       : Request state: d508c0f0ad/response state: d508c0f0ad
DEBUG org.pac4j.oidc.client.OidcClient         : clean authentication attempt from session
DEBUG o.p.o.c.authenticator.OidcAuthenticator  : Token response: status=200, content={"access_token":"eyJ...bng","token_type":"Bearer","expires_in":600}
DEBUG o.p.o.c.authenticator.OidcAuthenticator  : Token response successful
DEBUG org.pac4j.oidc.client.OidcClient         : clean authentication attempt from session (second call)
DEBUG org.pac4j.oidc.client.OidcClient         : Credentials validation took: 32 ms
DEBUG org.pac4j.oidc.client.OidcClient         : credentials : OidcCredentials(code=sjtox4...NQw, accessToken=...
DEBUG org.pac4j.oidc.profile.OidcProfile       : adding => key: access_token / value: eyJh...MDB9 / class java.lang.String
DEBUG org.pac4j.oidc.profile.OidcProfile       : adding => key: expiration / value: 1775214522541 / class java.lang.Long
DEBUG org.pac4j.oidc.profile.OidcProfile       : adding => key: id_token / value: eyJ...bng / class java.lang.String
DEBUG o.p.oidc.profile.creator.TokenValidator  : Trying IDToken validator, issuer: http://127.0.0.1:8080/c2id, type: null, JWS:
DEBUG o.p.oidc.profile.creator.TokenValidator  : Validated: {"iss":"http://127.0.0.1:8080/c2id","sub":"alice",
 "aud":"http://localhost:8081","exp":1775214522,"iat":1775213922,"amr":["pwd"]}
DEBUG o.p.o.p.creator.OidcProfileCreator       : User info response: status=200, content={"sub":"alice","groups":["admin","audit"]}
DEBUG o.p.oidc.profile.OidcProfileDefinition   : converted to => key: sub / value: alice / class java.lang.String
DEBUG org.pac4j.oidc.profile.OidcProfile       : adding => key: sub / value: alice / class java.lang.String
...
DEBUG org.pac4j.oidc.profile.OidcProfile       : adding => key: token_expiration_advance / value: 0 / class java.lang.Integer
DEBUG org.pac4j.oidc.client.OidcClient         : profile: Optional[OidcProfile(super=AbstractJwtProfile(super=CommonProfile(
 super=BasicUserProfile(logger=Logger[org.pac4j.oidc.profile.OidcProfile], id=alice, attributes={access_token=eyJ...MDB9,
 token_expiration_advance=0, sub=alice, aud=[http://localhost:8081], amr=[pwd], id_token=eyJr...Hsbng,
 iss=http://127.0.0.1:8080/c2id, groups=[admin, audit], expiration=1775214522541, exp=Fri Apr 03 13:08:42 CEST 2026,

The logs are straightforward on the pac4j side as well: we see the successful authentication, the token and the userprofile calls.

c) After the login process

Even though we’re not doing anything, new logs keep appearing for the Connect2id server:

INFO AUTHZ-STORE - [AS0228] Revoking multiple authzs: client_id=http://localhost:8081
INFO CLIENT-REG - [OP5184] Deleted client: client_id=http://localhost:8081 num_revoked_authz=1
INFO FED-REG - [OP8041] Reaped 1 expired federation clients

These logs are related to the fact that we have performed an automatic registration. The logs indicate that the temporarily created client is deleted.

Indeed, as Connect2id does not know the pac4j client, it has temporarily registered this client and after some time, the registered client is cleaned.

While this is a very convenient mechanism, it can impact server performance.

Therefore, it could be useful to consider explicitly and permanently registering our OIDC pac4j client.

2) Let’s log in with explicit registration

a) The client identifier

And this is a feature supported by the OpenID Federation protocol:

the explicit registration of the OIDC client.

This must be of course supported by the OIDC server and this is the case of the Connect2id server.

pac4j supports both modes depending on the OIDC server, so the configuration must only be updated on the Connect2id server.

Stop the server (tomcat/bin/shutdown.sh), edit the tomcat/webapps/c2id/WEB-INF/oidcProvider.properties file:

op.federation.clientRegistrationTypes=explicit

and restart the server (tomcat/bin/startup.sh).

On the pac4j side:

DEBUG o.p.o.m.r.FederationClientRegister       : Registration endpoint exists and only explicit registration by OP (and RP)
 -> performing explicit registration
 INFO .f.e.DefaultEntityConfigurationGenerator : Generating entity configuration for: http://localhost:8081
DEBUG o.p.o.m.r.FederationClientRegister       : Received response registration: eyJraW...mkaMxZQ
 WARN o.p.o.m.r.FederationClientRegister       : /! ================================================
 WARN o.p.o.m.r.FederationClientRegister       : /! Explicit registration of the client 'http://localhost:8081' returns
  id: [t4j746kwjax6s]. This information won't be repeated. You MUST add this value to your configuration before the next
   application startup!
 WARN o.p.o.m.r.FederationClientRegister       : /! ================================================

On the Connect2id side:

INFO FED-REG - [OP8014] Registered entity http://localhost:8081 as explicit client with client_id=xkqolxvshcjv6 exp=1783005293
INFO FED-REG - [OP8019] Explicit registration response statement for entity http://localhost:8081: {sub=http://localhost:8081,
 aud=[http://localhost:8081], metadata={openid_relying_party={client_registration_types=[explicit, automatic],
 token_endpoint_auth_signing_alg=RS256, grant_types=[authorization_code], jwks={keys=[{kty=RSA, e=AQAB, use=sig, kid=...

The explicit registration is duly taken into account by Connect2id which generates a specific client_id for the OIDC client, returns it to pac4j to be displayed in its logs.

Let’s follow the instruction given in the logs and add this client_id in the pac4j configuration:

@Bean
public Config config() {
    final var config = new OidcConfiguration();

    // the new clientId!
    config.setClientId("xkqolxvshcjv6");

    final var rpJwks = config.getRpJwks();
    rpJwks.setJwksPath("file:./metadata/rpjwks.jwks");
    rpJwks.setKid("defaultjwks0426");
    config.setClientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT);
    final var privateKeyJwtConfig = new PrivateKeyJwtClientAuthnMethodConfig(rpJwks);
    config.setPrivateKeyJWTClientAuthnMethodConfig(privateKeyJwtConfig);

    config.setRequestObjectSigningAlgorithm(JWSAlgorithm.RS256);

    final var federation = config.getFederation();

    federation.setTargetOp("http://127.0.0.1:8080/c2id");
    final var trust = new OidcTrustAnchorProperties();
    trust.setIssuer("http://localhost:8081/trustanchor");
    trust.setJwksPath("classpath:trustanchor.jwks");
    federation.getTrustAnchors().add(trust);

    federation.getJwks().setJwksPath("file:./metadata/oidcfede.jwks");
    federation.getJwks().setKid("mykeyoidcfede26");
    federation.setContactName("New RP test");
    federation.setContactEmails(List.of("[email protected]"));

    federation.setEntityId("http://localhost:8081");

    return new Config(baseUri + "/callback", new OidcClient(config));
}

Here is the complete configuration for reference (and not only the added client_id).

We restart the Spring Boot application and try a new login process.

This time, no registration happens and Connect2id directly recognizes the provided client_id:

INFO AUTHZ-SESSION - [OP2101] Created new auth session: sid=reJ...58w client_id=xkqolxvshcjv6 scope=[openid, profile, email]

b) The client secret

At this point in the article, you may wonder why we only have a client_id and no client_secret.

In fact, we don’t need a secret as we use the private_key_jwt client authentication method: the credential is the private key, not the secret.

As this pac4j configuration is revealed in its entity statement, the Connect2id server is aware of that setting and, accordingly, decides to only return a client_id for this OIDC client.

Let’s go further and replace this configuration in pac4j:

config.setClientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT);
final var privateKeyJwtConfig = new PrivateKeyJwtClientAuthnMethodConfig(rpJwks);
config.setPrivateKeyJWTClientAuthnMethodConfig(privateKeyJwtConfig);

by:

config.setClientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);

to use the client_secret_basic authentication (and not the private_key_jwt).

We also remove the previous client_id:

config.setClientId("xkqolxvshcjv6");

and completely change the contact name:

federation.setContactName("New RP test");

Restart the Spring Boot application and try to log in.

This time, we call the Connect2id server with explicit registration and no configured client id/secret and a client_secret_basic authentication method.

And we get a new error from pac4j:

org.pac4j.oidc.exceptions.OidcException: Client secret export file is required

This seems definitely a weird one, but it’s not! Let me explain: the received client_id is output in the logs, though it would not be safe to output the client_secret in the logs as well.

So the received client_secret is planned to be saved on the disk, on a file defined by the secretExportFile property.

Let’s define it in the configuration:

federation.setSecretExportFile("./mysecret.tmp");

and try again. It works!

The generated client_id and client_secret have been added on-the-fly to the OIDC configuration and have been used to perform the client_secret_basic authentication method.

The client_id is in the logs: Explicit registration of the client 'http://localhost:8081' returns id: [wzcyln5hdtmck].

The client_secret is in the defined file: The received secret has been saved into the file: ./mysecret.tmp. Its value is: U9UaF99rUopAXnWqXqkWBj_RsOZXfM1Efs67N4KweHo.

These seem to be the right settings as the login process has worked, but we’d like to check that on the Connect2id side.

Let’s seek in the Connect2id configuration file oidcProvider.properties for the property: op.reg.apiAccessTokenSHA256. I find:

# Evaluation note: Use token value ztucZS1ZyFKgh0tUEruUtiSTXhnexmd6
op.reg.apiAccessTokenSHA256=cca68b8b82bcf0b96cb826199429e50cd95a042f8e8891d1ac56ab135d096633

Let’s try to use the Connect2id API to list the existing clients with this key:

curl -X GET http://127.0.0.1:8080/c2id/clients -H "Authorization: Bearer ztucZS1ZyFKgh0tUEruUtiSTXhnexmd6"

We get two clients:

[
    {
        "client_registration_types":[
            "explicit",
            "automatic"
        ],
        "grant_types":[
            "authorization_code"
        ],
        "jwks":{
            "keys":[
                {
                    "kty":"RSA",
                    "e":"AQAB",
                    "use":"sig",
                    "kid":"defaultjwks0426",
                    "n":"2moV...aq7Q"
                }
            ]
        },
        "subject_type":"public",
        "application_type":"web",
        "registration_client_uri":"http://127.0.0.1:8080/c2id/clients/wzcyln5hdtmck",
        "redirect_uris":[
            "http://localhost:8081/callback?client_name=OidcClient"
        ],
        "registration_access_token":"0QKFCVfBe5PqVFwgjEaRousXHKEa9My0-IMMMCTPB20.YSxpLHI",
        "token_endpoint_auth_method":"client_secret_basic",
        "client_id":"wzcyln5hdtmck",
        "client_secret_expires_at":0,
        "request_object_signing_alg":"RS256",
        "client_id_issued_at":1775836027,
        "client_secret":"U9UaF99rUopAXnWqXqkWBj_RsOZXfM1Efs67N4KweHo",
        "client_name":"New RP test",
        "contacts":[
            "[email protected]"
        ],
        "response_types":[
            "code"
        ],
        "id_token_signed_response_alg":"RS256"
    },
    {
        "token_endpoint_auth_signing_alg":"RS256",
        "grant_types":[
            "authorization_code"
        ],
        "jwks":{
            "keys":[
                {
                    "kty":"RSA",
                    "e":"AQAB",
                    "use":"sig",
                    "kid":"defaultjwks0426",
                    "n":"2moV...aq7Q"
                }
            ]
        },
        "subject_type":"public",
        "application_type":"web",
        "registration_client_uri":"http://127.0.0.1:8080/c2id/clients/xkqolxvshcjv6",
        "redirect_uris":[
            "http://localhost:8081/callback?client_name=OidcClient"
        ],
        "registration_access_token":"7ilSWZGsH4wOyehETCNJQz0My8NO-6efLiV1ED2HcN0.YSxpLHI",
        "token_endpoint_auth_method":"private_key_jwt",
        "client_id":"xkqolxvshcjv6",
        "request_object_signing_alg":"RS256",
        "client_id_issued_at":1775747499,
        "client_name":"C2ID Test RP (Localhost)",
        "contacts":[
            "[email protected]"
        ],
        "response_types":[
            "code"
        ],
        "id_token_signed_response_alg":"RS256"
    }
]

The second one has only the right client_id, no client_secret and is defined with private_key_jwt.

The first one has the right client_id and client_secret and is defined with client_secret_basic.

Notice the appropriate client_name property as well.

The Connect2id configuration perfectly matches what was received by pac4j (not that I had any doubts 😉)

The magic of the federation continues:

The pac4j RP and the Connect2id OP only know and rely on the trust anchor, they don’t know each other.

But nonetheless the RP has been able to definitely register itself on the OP!

MyNotifyAssist — A Smart Reminder & Productivity Platform 🚀

— a platform designed to help users manage reminders, routines, team coordination, notes, and daily productivity from one place.

🔥 Why I Started This Project

Many people forget:

  • Medicines
  • Important meetings
  • Daily tasks
  • Bill payments
  • Team updates
  • Routine activities

Traditional reminder apps feel limited and outdated.

So I started building a smarter solution using:

  • Java 21 + Spring Boot
  • React JS
  • React Native / Expo for mobile
  • AI-powered reminder creation (ongoing feature)

✨ Features of MyNotifyAssist

✅ Smart Reminders

Create reminders for:

  • Medicines
  • Meetings
  • Birthdays
  • Daily tasks
  • Payments
  • Custom schedules

Users can receive notifications through:

  • WhatsApp
  • SMS
  • Email
  • Push notifications

✅ To-Do Lists & Progress Tracking

Users can:

  • Create tasks
  • Track progress
  • Organize daily workflow
  • Build routines

✅ Team Collaboration

Users can:

  • Add friends or team members
  • Share reminders
  • Coordinate tasks together

Perfect for:

  • Families
  • Small teams
  • Students
  • Startups

✅ Public Notes Sharing

A unique feature I added:
Users can create notes and generate a public shareable link.

Useful for:

  • Instructions
  • Quick sharing
  • Documentation
  • Public updates

✅ Expense Splitting (Upcoming)

Currently planning features similar to Splitwise:

  • Monthly expense tracking
  • Group expense division
  • Team settlements

🤖 AI Integration (Upcoming)

One of the most exciting parts:
Users will simply type prompts like:

“Remind me tomorrow at 8 AM to take medicine”

And AI will automatically:

  • Understand the intent
  • Extract date/time
  • Schedule reminders instantly

I’m currently exploring:

  • OpenAI APIs
  • Gemini APIs
  • AI workflow integration with Spring Boot

🛠 Tech Stack

Backend:

  • Java 21
  • Spring Boot

Frontend:

  • React JS

Mobile:

  • React Native
  • Expo

Other Integrations:

  • Razorpay
  • Google Sign-In
  • Notification services
  • SMS providers

📱 Current Challenges

As an indie developer, I’m also learning:

  • Google Play deployment
  • Apple Developer setup
  • Mobile app approvals
  • Push notification systems
  • AI infrastructure
  • SEO & product growth

It’s been a huge learning experience.

🚀 Vision

I want MyNotifyAssist to become more than a reminder app.

The goal is to create:

  • A personal productivity assistant
  • AI-powered scheduling
  • Smart team coordination
  • Daily life management tools

All in one platform.

—
Join httpsz://www.MyNotifyAssist.com

RAG Evaluation with RAGAS: Measuring Faithfulness, Context Precision, and Recall in Production

Key takeaways:

RAGAS gives you four core metrics that split RAG failures into retrieval vs. generation problems

Faithfulness catches hallucinations; Context Recall catches retrieval gaps

Most metrics require no human-labeled data

Treat RAGAS like unit tests, run it in CI every time you change your pipeline

You’ve shipped a RAG-based product. Your engineers say it “seems to work well.” Your users occasionally complain it gives wrong answers. You have no idea which part is broken, the retrieval, the generation, or both.

This is the state of most RAG deployments today. And it’s a problem you can solve with a proper evaluation framework.

Let’s talk about RAGAS.

The Problem With “It Seems Fine”

Building a RAG system is the easy part. Dozens of tutorials get you from zero to a working demo in an afternoon. But production RAG is a different beast. You’re dealing with:

  • Retrieval failures – the system pulls irrelevant chunks from your vector store

  • Hallucinations – the LLM generates facts not present in the retrieved documents

  • Incomplete coverage – the retrieval misses key information needed to answer the question

  • Irrelevant answers – the response doesn’t actually address what the user asked

Traditional NLP metrics like BLEU and ROUGE won’t catch any of these. They measure surface-level text similarity to a reference answer, useful for machine translation, not for knowledge-grounded generation. They completely ignore whether the LLM is actually using the retrieved context or just making things up.

You need metrics designed specifically for the RAG pipeline.

What is RAGAS?

RAGAS (Retrieval Augmented Generation Assessment) is an open-source Python framework for evaluating RAG systems. It was introduced by Shahul Es, Jithin James, and collaborators in a paper published in late 2023 and presented at EACL 2024.

The key design decision that makes RAGAS practical: most of its metrics require no human-labeled ground truth. It uses LLMs as judges, the same type of model you’re evaluating is used to evaluate the evaluation. Yes, this is a meta-game, but it works surprisingly well in practice.

RAGAS processes over 5 million evaluations monthly for companies including AWS, Microsoft, Databricks, and Moody’s. It has 4,000+ GitHub stars and is backed by a Y Combinator company. It’s become the de facto standard for RAG evaluation.

The Two-Axis Mental Model

Before diving into specific metrics, understand the RAG pipeline as having two distinct components, each with its own failure modes:

Retriever failures: Wrong chunks, missing chunks, poorly ranked chunks
Generator failures: Hallucination, ignoring context, irrelevant response

RAGAS gives you metrics for both axes. If you only measure end-to-end output quality, you can’t tell which half is broken.

The Core Four Metrics

1. Faithfulness: Does the answer stay true to the retrieved context?

What it catches: Hallucinations from the generator

How it works:
RAGAS extracts individual statements from the generated answer, then asks an LLM judge whether each statement can be logically inferred from the retrieved context. The score is the fraction of statements that can be supported.

Faithfulness = Supported Statements / Total Statements in Answer

Score range: 0 to 1 (higher is better)

Example:
Question: “What is our refund policy?”
Context: “Refunds are available within 30 days of purchase.”
Answer: “Refunds are available within 30 days. We also offer exchanges for 60 days.”

The second sentence isn’t in the context → Faithfulness < 1

What low Faithfulness scores tell you, and how to fix it:

1. Tighten your system prompt: The most immediate lever. Add explicit grounding instructions:

“Answer only using the information provided in the context below. If the context does not contain enough information to answer, say so explicitly.”

“Do not use any prior knowledge. Every claim in your answer must be traceable to the context.”

Negative framing helps too: “Do not speculate. Do not add information not present in the provided documents.”

2. Lower the temperature: High temperature = more creative, more likely to drift from the context. For factual RAG tasks, set temperature to 0 or close to it. There’s no good reason to have randomness in a document Q&A system.

3. Switch or downgrade your model: Counter-intuitively, more capable models sometimes hallucinate more confidently. A model like GPT-4o has seen so much training data that it may “helpfully” fill gaps from its parametric memory rather than admitting the context is insufficient. Sometimes a smaller, instruction-tuned model with a strict prompt outperforms a frontier model on Faithfulness specifically.

2. Answer Relevancy: Does the answer actually address the question?

What it catches: Verbose, off-topic, or evasive answers from the generator

How it works:
An LLM generates several hypothetical questions that the given answer would be the answer to. Then it computes the cosine similarity between those generated questions and the original question. High similarity means the answer is directly addressing what was asked.

Answer Relevancy = avg(cosine_similarity(generated_questions, original_question))

Score range: 0 to 1

Example of a low-relevancy response:
Question: “When does the system auto-scale?”
Answer: “Our platform uses Kubernetes for container orchestration. It supports multiple cloud providers and can be deployed on-premises.” ← technically related but doesn’t answer the question

What low Answer Relevancy scores tell you, and how to fix it:

1. Your prompt template isn’t directing the LLM toward the question: The most common cause. If your template looks like “Use the context below to help the user”, the LLM has too much freedom to respond however feels natural, which often means answering a slightly different, easier version of the question. Fix it by anchoring the response explicitly to the input:

“Answer the following question directly and concisely: {question}”

“Your response must directly address what was asked. Do not provide background information unless it is necessary to answer the question.”

2. Your retriever is pulling tangentially related chunks: This one is subtle, the LLM isn’t hallucinating, it’s faithfully summarizing context that happens to be adjacent to the topic but doesn’t answer the specific question asked. The answer sounds reasonable, passes a Faithfulness check, but misses the point entirely.

Cross-reference with Context Precision: if that score is also low, the retriever is the culprit. The fix is better retrieval, a reranker, stricter similarity thresholds, or query rewriting before retrieval.

3. The LLM is being evasive or overly hedged: Some models, especially when given ambiguous context, default to safe, non-committal answers: “This is a complex topic with many perspectives…” These score very low on Answer Relevancy because a hypothetical question reverse-engineered from that answer looks nothing like the original query.

The fix is prompt-level: instruct the model to commit to an answer and flag uncertainty explicitly rather than hiding behind vagueness, “If you cannot find a direct answer in the context, say: I don’t have enough information to answer this. Do not speculate.”

3. Context Precision: Are the retrieved chunks actually useful? Are the best ones ranked first?

What it catches: Noisy retrieval, retrieving a lot of documents but ranking the relevant ones poorly

How it works:
For each retrieved chunk, an LLM judge decides whether that chunk is useful for answering the question. Context Precision then uses Average Precision, a ranking-aware metric that penalizes systems that bury the relevant chunks at the bottom.

This is important: two systems could retrieve the same relevant chunks but if one puts them at positions 1 and 2 and another at positions 8 and 9, the LLM may not use them effectively.

What low Context Precision scores tell you, and how to fix it:
1. Add a reranker as a second retrieval stage: Your embedding model does a decent job finding broadly relevant chunks, but cosine similarity in vector space is a blunt instrument, it measures general topic overlap, not “does this chunk actually help answer this specific question.”

A cross-encoder reranker (Cohere Rerank, BGE Reranker, Jina Reranker) reads the query and each chunk together and produces a much more accurate relevance score. The typical pattern is: retrieve top-20 with your vector store, rerank, pass top-5 to the LLM. This often moves Context Precision more than any other single change.

2. Fix your chunking strategy: Poorly sized chunks are a hidden precision killer. Chunks that are too large contain the relevant sentence plus a lot of surrounding noise, the chunk scores as retrieved but most of its content is irrelevant, dragging precision down.

Chunks that are too small lose surrounding context and get ranked inconsistently. The fix isn’t always obvious because the right chunk size is domain-dependent: dense technical documentation needs smaller chunks than narrative prose.

Test with a few different sizes (256, 512, 1024 tokens) and run Context Precision against each. Also consider sentence-window retrieval or parent-child chunking, retrieve small chunks for precision, but pass their larger parent context to the LLM.

3. Rewrite the query before retrieval: User queries are often poorly formed for vector search. They’re conversational, ambiguous, or assume context from earlier in the conversation. The embedding model then retrieves chunks that match the surface phrasing of the query rather than its intent.

Query rewriting with an LLM before hitting the vector store (sometimes called HyDE, Hypothetical Document Embeddings, or simply query expansion) can dramatically improve what gets ranked at the top. A simple prompt like “Rewrite this question as a declarative statement that would appear in a technical document” often moves the needle more than swapping embedding models.

4. Context Recall: Did the retriever find everything needed to answer the question?

What it catches: Retrieval gaps, the right information exists in your knowledge base but wasn’t retrieved

How it works:
This is the one metric that typically needs a ground truth reference answer. RAGAS decomposes the reference answer into individual statements, then checks which statements can be attributed to the retrieved context.

Context Recall = Statements attributable to context / Total statements in reference answer

What low Context Recall scores tell you, and how to fix it:
1. Increase your top-K and experiment with retrieval depth: The simplest fix first. If you’re retrieving top-3 or top-5 chunks, relevant information that exists in your knowledge base simply isn’t making it into the context window. Try top-10 or top-20 and re-measure.

The tradeoff is more noise (which hurts Context Precision), so watch both metrics together, you’re looking for the sweet spot where recall improves without precision collapsing. A reranker helps here because it lets you retrieve broadly and then filter aggressively.

2. Fix your chunking before fixing your retrieval: Low Context Recall is often misdiagnosed as a retrieval problem when it’s actually a chunking problem. If a single answer requires information spread across a document, an introduction, a table in the middle, and a caveat at the end, but your chunks split those pieces apart and only one gets retrieved, recall will suffer regardless of how good your embeddings are.

Consider parent-child chunking: index small chunks for precise matching, but when a small chunk is retrieved, pass its larger parent document to the LLM. This way you get retrieval precision without losing surrounding context.

3. Switch to hybrid search: Pure vector search fails on specific, precise queries, exact product names, version numbers, acronyms, proper nouns. The embedding model generalizes these into semantic space where they lose their distinctiveness.

BM25 (keyword search) handles them perfectly. Hybrid search combines both signals, dense retrieval for semantic understanding, sparse retrieval for exact matching, and consistently improves recall across diverse query types without significantly hurting precision. Most modern vector stores (Elasticsearch, Weaviate, Qdrant) support hybrid search natively.

How the Metrics Map to Your Architecture

Metric Measures Failure Points to Investigate
Context Precision Retrieval quality & ranking Embedding model, reranker, chunk size
Context Recall Retrieval coverage top-K setting, chunking, indexing strategy
Faithfulness Generator groundedness System prompt, temperature, model choice
Answer Relevancy Generator focus Prompt template, retrieval quality

A useful diagnostic pattern: if Faithfulness is fine but Answer Relevancy is low, your LLM is staying honest but the retrieved context isn’t helping it answer the actual question. That’s a retrieval problem dressed up as a generation problem.

Beyond the Core Four

RAGAS has expanded significantly since its original release. For production systems, you should also look at:

  • Noise Sensitivity – how much does your answer quality degrade when irrelevant chunks are retrieved alongside relevant ones? Critical for adversarial or domain-drift scenarios.
  • Context Entities Recall – checks whether specific entities (names, numbers, dates) from the ground truth appear in the retrieved context. Useful for fact-dense domains like legal or finance.
  • Factual Correctness – a reference-based metric that checks whether the answer is factually correct, not just grounded in context. This requires ground truth but gives you absolute accuracy, not just relative faithfulness.

For teams building agentic RAG pipelines, RAGAS also covers Tool Call Accuracy, Agent Goal Accuracy, and Topic Adherence.

The Evaluation Dataset Problem (And How RAGAS Solves It)

Here’s the real bottleneck: to run these metrics at scale, you need test questions. Building hundreds of representative questions by hand is expensive and slow.

RAGAS includes a synthetic test data generation module. It ingests your source documents, builds a knowledge graph, and generates diverse question types automatically, including multi-hop questions that require reasoning across multiple documents.

This lets you create a meaningful evaluation dataset in hours rather than weeks. It’s not perfect, you’ll still want human review for high-stakes domains, but it dramatically lowers the barrier to having a real eval suite before your next deployment.

What a RAGAS Workflow Looks Like

from ragas import evaluate
from ragas.metrics import (
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
)
from datasets import Dataset

# Your RAG system output
data = {
    "question": ["What is our data retention policy?", ...],
    "contexts": [["Our data is retained for 90 days...", ...], ...],
    "answer": ["Data is retained for 90 days.", ...],
    "ground_truth": ["Data is retained for 90 days per GDPR requirements.", ...],
}

dataset = Dataset.from_dict(data)
results = evaluate(dataset, metrics=[
    faithfulness,
    answer_relevancy,
    context_precision,
    context_recall,
])

print(results)
# {'faithfulness': 0.91, 'answer_relevancy': 0.87, 
#  'context_precision': 0.76, 'context_recall': 0.82}

The real value isn’t a single run. It’s running RAGAS as part of your CI/CD pipeline every time you change your prompt template, swap your embedding model, or update your knowledge base. Treat it like unit tests for your AI system.

Practical Guidance

Start with Faithfulness and Context Recall. These two are the highest signal metrics for most production systems. Faithfulness catches the most dangerous failure mode (hallucination), and Context Recall tells you if your retrieval architecture is fundamentally sound.

Don’t optimize a single metric. You can game Context Precision by returning fewer, more targeted chunks, but this hurts Context Recall. You need to watch all four together.

Use RAGAS scores to run A/B experiments. Want to know if switching from text-embedding-ada-002 to text-embedding-3-large improves your system? Run RAGAS before and after. Now you have data instead of intuition.

Integrate with observability tools. RAGAS works natively with LangSmith and Langfuse. This means you can trace individual requests that score poorly and inspect exactly what was retrieved and how the LLM used it.

The LLM-as-judge limitation. Be aware that RAGAS uses LLMs internally for most metrics. This means your evaluation has its own failure modes, LLM judges can be inconsistent, sensitive to prompt phrasing, and exhibit position bias. Use a strong, reliable model (GPT-4o, Claude) for your judge. For critical systems, validate RAGAS scores against a sample of human annotations.

Summary

Most teams ship RAG systems and evaluate them with vibes. RAGAS gives you a structured, automated way to know exactly where your pipeline is failing, retrieval or generation, and gives you the feedback loop to fix it systematically.

This is the difference between iterating on your AI system and guessing about it.

The framework is open-source, takes an afternoon to integrate, and has become the standard for a reason. If you’re running RAG in production without evaluation metrics, that’s the technical debt your team should be paying down next.