Debugging RAGAS Evaluations: Exceptions, Tracking, and Concurrency Control
Overview
When running RAGAS evaluations in production or during development, several non-obvious behaviors can make debugging difficult: swallowed exceptions, analytics noise in logs, cryptic error formats from instructor, and silent data overwrites. This post consolidates practical techniques for diagnosing and fixing each of these issues.
1. Forcing Exceptions to Surface
By default, RAGAS’s Executor sets raise_exceptions=False, which means per-sample failures are caught and logged rather than propagated. This is convenient for bulk evaluation but makes individual failures invisible.
Option A: Environment variable (if the version supports it):
import os
os.environ["RAGAS_RAISE_EXCEPTIONS"] = "true"
Option B: Monkey-patch the dataclass default (more reliable):
from ragas import executor as _ragas_executor
_ragas_executor.Executor.__dataclass_fields__["raise_exceptions"].default = True
Place this before any evaluation call. With exceptions raised, failures will propagate immediately rather than being silently swallowed.
2. Understanding track() — Telemetry, Not Error Logging
If you see track(...) calls inside ragas/llm.py and wonder why they provide no useful error context, the answer is by design.
trackoriginates fromragas/_analytics.pyand is a usage telemetry function, not a business-level error logger.- It is wrapped in a
@silentdecorator, so any exception inside it is suppressed and only emitted atDEBUGorINFOlevel. - Seeing “an error occurred with no explanation” in this code path is expected — this channel is for analytics, not diagnostics.
To see the telemetry payload, enable one of these environment variables:
RAGAS_DEBUG=true
__RAGAS_DEBUG_TRACKING=true
Actual evaluation errors live in ragas/executor.py and your project’s evaluator.py exception handling chain — not in the tracking path.
3. Decoding the <failed_attempts> Error Format
When a sample fails, you may see output like:
Warning: Task failed with error: ...
<failed_attempts>
<generation ...>
<exception></html></exception>
</generation>
</failed_attempts>
Here is what each layer means:
| Layer | Source | Meaning |
|---|---|---|
Warning: Task failed with error: |
experiment.py |
RAGAS caught a single-sample exception in asyncio.as_completed() and continued. |
<failed_attempts>...</generation> |
instructor/exceptions.py |
The __str__ of an InstructorRetryException, rendered as XML-style text when all retries are exhausted. |
<exception></html></exception> |
Upstream gateway | The LLM endpoint returned an HTML error page (4xx/5xx proxy error), not a valid JSON response. |
The presence of </html> inside <exception> means the underlying HTTP response was an HTML error page from a proxy or gateway — not a RAGAS-generated artifact. The actual error detail is in the raw HTTP response, not the truncated exception string.
Call chain summary:
RAGAS task failure
→ exception caught in experiment.py
→ print(e) calls instructor's __str__
→ renders as <failed_attempts> XML
→ <exception> contains raw HTTP body (HTML page)
Relevant source locations:
| Symbol | File | Role |
|---|---|---|
ExperimentWrapper.arun |
experiment.py:141 |
Drives async task loop with asyncio.as_completed |
experiment (warning site) |
experiment.py:201 |
Catches per-sample exception, prints Warning: Task failed |
InstructorLLM.agenerate |
llms/base.py:1117 |
Calls instructor client; raises InstructorRetryException on retry exhaustion |
_get_instructor_client |
llms/base.py:566 |
Constructs the instructor client used by agenerate |
llm_factory / get_adapter |
llms/base.py:606 |
Entry point for LLM construction; selects adapter |
InstructorAdapter.create_llm |
llms/adapters/instructor.py:14 |
Builds the InstructorLLM wrapping the raw client |
4. Concurrent Requests and 504 / Rate-Limit Errors
RAGAS evaluation runs samples concurrently by design:
- All sample tasks are created upfront.
asyncio.as_completed(tasks)processes them as they finish.- Each task calls
ascore(...), which itself may fire additional LLM requests.
This means multiple requests hit the same endpoint simultaneously. If your backend is behind nginx, an internal gateway, or a shared inference server, this quickly causes 504 timeouts, connection queuing, or 429 rate-limit errors.
Recommended diagnosis steps:
- Set concurrency to 1 as a baseline — confirm the endpoint works at all.
- Gradually increase to 2, then 4, and identify at which point errors begin.
- Add a semaphore in your evaluator to cap in-flight requests (see Section 5).
- Reduce
max_tokensif it is set very high (e.g. 256000) — large token budgets increase per-request latency and worsen queuing.
5. Capping Concurrency with asyncio.Semaphore
An asyncio.Semaphore acts as a token bucket for coroutines. With max_concurrency=K, it ensures at most K LLM requests are in-flight at any moment:
import asyncio
semaphore = asyncio.Semaphore(K)
async def evaluate_sample(sample):
async with semaphore: # acquire a token; suspend if none available
return await ascore(sample)
How it works:
Ktokens are available at initialization.- A coroutine reaching
async with semaphoreeither acquires a token immediately (if available) or suspends (without blocking a thread) until another coroutine releases one. - On exiting the
async withblock, the token is released automatically. - The result: even if
arun()creates hundreds of tasks, onlyKare ever executing their critical section simultaneously — flattening the burst load that causes 504s and 429s.
Important: the semaphore only governs the code wrapped inside the
async withblock. Place theascore(...)call inside the block to limit LLM concurrency specifically.
6. Silent Overwrite with the CSV / Local Backend
When using RAGAS’s local CSV backend and calling run_evaluation(experiment_name="same_name") multiple times in a loop, each call’s arun() triggers a full file write (save()), not an append. The last run silently overwrites all previous results.
To avoid data loss:
- Use a unique
experiment_nameper run (e.g., append a timestamp). - Or collect all results in memory and write once after the loop completes.
from datetime import datetime
name = f"eval_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
run_evaluation(..., experiment_name=name)
Summary
| Problem | Root Cause | Fix |
|---|---|---|
| Errors silently swallowed | raise_exceptions=False in Executor |
Set env var or monkey-patch the default |
track() gives no error detail |
Telemetry path, @silent decorator |
Use RAGAS_DEBUG=true; look in executor logs instead |
<failed_attempts> with </html> |
Proxy returned HTML error page | Debug the HTTP gateway, not RAGAS |
| 504 / 429 under evaluation load | Default unbounded concurrency | Add asyncio.Semaphore, reduce max_tokens |
| Results overwritten silently | CSV backend does full-file writes | Use unique experiment names per run |