Version: 1.0 Last Updated: 2025-01-01
HaystackBench is a Python package with three user-facing interfaces:
- CLI (
haystackbenchcommand) — for power users and CI/CD - Web UI (FastAPI + SvelteKit) — for interactive use and visualization
- Python API — for programmatic integration into research pipelines
These interfaces all consume the same core library (haystackbench/), which is designed to be fully usable as a standalone Python package independent of the UI.
┌─────────────────────────────────────────────────────────────────┐
│ User Interfaces │
│ CLI (typer) Web UI (SvelteKit) Python API │
└──────────────┬─────────────────┬──────────────────┬────────────┘
│ │ │
└─────────────────▼──────────────────┘
FastAPI Backend
(ui/backend/main.py)
│
┌───────────────▼───────────────────┐
│ Core Library │
│ haystackbench/ │
│ │
│ ┌─────────┐ ┌───────────────┐ │
│ │ Tasks │ │ Config │ │
│ └────┬────┘ └───────┬───────┘ │
│ │ │ │
│ ┌────▼────────────────▼────────┐ │
│ │ Core Engine │ │
│ │ needle ─ haystack ─ injector │ │
│ │ evaluator ─ runner │ │
│ └────┬──────────────┬───────────┘ │
│ │ │ │
│ ┌────▼────┐ ┌──────▼──────┐ │
│ │Provider │ │ Storage │ │
│ │Registry │ │ (SQLite) │ │
│ └────┬────┘ └─────────────┘ │
└───────┼────────────────────────────┘
│
┌────────────┼────────────────┐
▼ ▼ ▼
Anthropic OpenAI Ollama ...
API API (local)
The heart of the library. All modules here are pure Python with no web dependencies.
Responsibility: Generate all types of needles (synthetic facts to be inserted).
Key classes:
Needle— dataclass holding template, filled text, value, entity name, attributeNeedleConfig— Pydantic model for needle generation parametersNeedleFactory— factory class with methods:generate_single()→Needlegenerate_batch(count)→List[Needle]generate_relational_chain(depth, structure)→RelationalNeedleSetgenerate_kv_pairs(count)→List[KVNeedle]
Responsibility: Source, construct, and manage background text (haystacks).
Key classes:
Haystack— dataclass holding raw text, token count, source metadataHaystackConfig— Pydantic model for haystack parametersHaystackBuilder— builds haystacks from various sourcesHaystackSource— enum of supported sources
Haystack sources are implemented as plugins in haystackbench/haystacks/:
standard/paul_graham.py— downloads and caches PG essaysstandard/wikipedia.py— fetches Wikipedia articles via APIstandard/arxiv.py— fetches arXiv abstractsgenerators/synthetic.py— generates synthetic paragraphs
Responsibility: Insert needles at specific depth positions within a haystack.
Key classes:
InjectedDocument— dataclass holding the final text, metadata about injection pointsInjector— performs depth-based injection with paragraph boundary enforcement
Key logic:
- Converts depth % → token position → finds nearest paragraph boundary
- Handles multi-needle injection with configurable distribution strategies
- Validates that injected document length matches target token count
Responsibility: Score model responses against expected answers.
Key classes:
EvaluationResult— score (0.0–1.0), is_correct bool, method used, explanationEvaluator— routes scoring to the appropriate method:score_exact()— string normalization + substring checkscore_semantic()— sentence-transformer cosine similarityscore_llm_judge()— async call to judge modelscore_numeric()— parse and compare numbers
Responsibility: Orchestrate full experiment execution (async, concurrent, with checkpointing).
Key classes:
TestRunner— main orchestratorrun_experiment(config, provider)→ExperimentResultestimate_cost(config)→CostEstimate_run_cell(config, cell)→ResultRecord
Key behaviors:
- Builds the full grid of (context_length, depth, num_needles, trial, model) tuples
- Depth-sweep deduplication: for
uniformandbookendsdistributions withnum_needles > 1, needle placement is identical across all depths —_build_cells()collapses the depth axis to a single value (0.5), eliminating redundant cells - Context window pre-flight: before each API call, checks the model's
max_context_tokens; cells that exceed it raise a non-retriable error and are recorded as skipped - Respects
max_concurrencyviaasyncio.Semaphore - Saves each
ResultRecordto SQLite immediately after completion - On resume, queries SQLite for existing results and skips those cells
- Emits progress events via callback for WebSocket streaming
- Needle generation is seeded from
(experiment_id, model, trial)so the same needle is used across all (context_length × depth) cells for a given trial — holding the needle constant while varying the haystack
Each task type encapsulates the logic for generating prompts and interpreting results for that task.
Abstract base class BaseTask:
class BaseTask(ABC):
@abstractmethod
def build_prompt(self, document: InjectedDocument, needle_set: ...) -> str
@abstractmethod
def build_system_prompt(self) -> str
@abstractmethod
def score_response(self, response: str, evaluator: Evaluator) -> EvaluationResult
@property
@abstractmethod
def task_type(self) -> TaskTypeEach file implements one BaseTask subclass:
| File | Class | Task Type |
|---|---|---|
single_retrieval.py |
SingleRetrievalTask |
S-RT |
multi_retrieval.py |
MultiRetrievalTask |
M-RT |
multi_reasoning.py |
MultiReasoningTask |
M-RS |
ancestral_trace.py |
AncestralTraceTask |
ATC |
counting.py |
CountingTask |
COUNTING |
key_value.py |
KeyValueTask |
KEY_VALUE |
conflicting.py |
ConflictingNeedlesTask |
CONFLICTING |
TASK_REGISTRY: Dict[TaskType, Type[BaseTask]] = {
TaskType.S_RT: SingleRetrievalTask,
...
}
def get_task(task_type: TaskType, config: TaskConfig) -> BaseTask:
return TASK_REGISTRY[task_type](config)Each provider wraps one LLM API vendor.
class BaseProvider(ABC):
@abstractmethod
async def complete(self, prompt, model, max_tokens, temperature, system_prompt) -> ProviderResponse
@abstractmethod
def list_models(self) -> List[ModelInfo]
@abstractmethod
def validate_credentials(self) -> boolProviderResponse dataclass:
content: str— model response textprompt_tokens: intcompletion_tokens: intlatency_ms: intmodel: str— exact model version returned by API
PROVIDER_REGISTRY = {
"anthropic": AnthropicProvider,
"openai": OpenAIProvider,
"google": GoogleProvider,
...
}Each provider has a configurable requests_per_minute limit. The runner enforces this via token bucket within the provider wrapper.
All Pydantic v2 models:
ExperimentConfig— top-level experiment configuration, includingdepth_distributionandcustom_needlesNeedleConfig— needle generation parametersHaystackConfig— haystack source and construction parametersModelConfig— provider + model ID + per-model overridesScoringConfig— scoring method and judge modelCustomNeedleSpec— needle text, expected answer, optional label and custom question; embedded inExperimentConfig.custom_needlesso experiments are self-contained
Default values for all config fields. Centralized so they're easy to find and modify.
Named preset configurations returned as ExperimentConfig instances:
get_preset(name: str) -> ExperimentConfig
Presets: quick, standard, full, large_context, needlebench_32k, needlebench_128k, needlebench_1m
| Preset | Context lengths | Depths | Cells/model |
|---|---|---|---|
quick |
4K, 16K, 65K | 5 | 15 |
standard |
4K–131K (6) | 11 | 66 |
full |
4K–1M (9) | 21 | 567 |
large_context |
128K–1M (4) | 11 | 44 |
needlebench_1m |
4K–1M (7) | 11 | 77 |
class CredentialStore:
def save(self, provider: str, api_key: str) -> None
def load(self, provider: str) -> Optional[str]
def delete(self, provider: str) -> None
def list_providers(self) -> List[str]Uses keyring library. Falls back to os.environ[f"{PROVIDER.upper()}_API_KEY"] if keyring is unavailable.
class ResultStore:
async def init_db(self) -> None # Create tables if not exist
async def save_experiment(self, config) -> str # Returns experiment ID
async def save_result(self, result: ResultRecord) -> None
async def load_results(self, experiment_id: str) -> List[ResultRecord]
async def load_experiment(self, experiment_id: str) -> ExperimentRecord
async def list_experiments(self) -> List[ExperimentSummary]
async def update_experiment_status(self, id, status, completed_cells) -> None
async def update_experiment_summary(self, id, accuracy, cells_correct,
elapsed_seconds, cost_usd) -> NoneThe experiments table carries summary columns (accuracy, cells_correct, elapsed_seconds, cost_usd) that are populated by update_experiment_summary() after a run completes. The config_json column is denormalised in GET /api/experiments/{id} to expose task_type, haystack_source, and models directly.
Cell results carry a metadata_json blob with: needle_texts, expected_answer, actual_depths, question, and a truncated haystack_text — enabling the cell detail view without re-running.
class CustomModelStore:
async def init_db(self) -> None
async def list_models(self) -> List[dict]
async def add_model(self, provider, model_id, display_name,
max_context_tokens, ...) -> dict
async def delete_model(self, provider, model_id) -> bool
async def context_window_map(self) -> dict[str, dict[str, int]]Stores user-defined models in the custom_models table (shared DB). context_window_map() is used by cost estimation and context-guardrail logic to extend the static provider model lists.
class CustomNeedleStore:
async def init_db(self) -> None
async def list_needles(self) -> List[dict]
async def add_needle(self, needle_text, expected_answer,
name, question) -> dict
async def update_needle(self, needle_id, ...) -> bool
async def delete_needle(self, needle_id) -> bool
async def get_needles_by_ids(self, ids: List[int]) -> List[dict]Stores reusable needle/answer pairs in the custom_needles table. When an experiment is created with custom needle IDs, the runner embeds the full CustomNeedleSpec objects into ExperimentConfig.custom_needles so the experiment remains self-contained.
class ResponseCache:
async def get(self, cache_key: str) -> Optional[ProviderResponse]
async def set(self, cache_key: str, response: ProviderResponse) -> None
def make_key(self, model: str, prompt: str) -> str # SHA256 hashCache key: SHA256(model + prompt). Stored in SQLite as a separate table.
All visualization modules produce both:
- Static images (PNG/SVG) via matplotlib
- JSON-serializable data for web UI (ECharts format)
def generate_heatmap(
results: List[ResultRecord],
title: str,
colormap: str = 'RdYlGn',
annotate: bool = True,
output_path: Optional[Path] = None
) -> Figuredef generate_html_report(
experiment: ExperimentRecord,
results: List[ResultRecord],
output_path: Path
) -> NoneGenerates a self-contained HTML file with all charts as base64-embedded SVGs and a sortable data table.
Built with typer. Command groups:
haystackbench setup— credential wizardhaystackbench models— model managementhaystackbench run— experiment executionhaystackbench results— results managementhaystackbench haystack— haystack utilitieshaystackbench needle— needle utilitieshaystackbench serve— start web UI server
Uses rich for tables, progress bars, and colored output.
FastAPI application with:
- Lifespan events: database initialization, credential store setup
- CORS: configured for local SvelteKit dev server
- WebSocket manager: tracks active experiment runs, broadcasts progress events
- Background tasks: experiment runs execute as asyncio tasks, not blocking the event loop
Route organization:
routes/models.py— provider and credential managementroutes/tests.py— experiment CRUD and execution controlroutes/results.py— results retrieval and export; includesGET /{id}/cellsfor per-cell detailroutes/haystacks.py— haystack managementroutes/settings.py— custom model and custom needle CRUD (/api/settings/models,/api/settings/needles)websocket.py— WebSocket connection manager
SvelteKit application:
Store structure (src/stores/):
experiments.ts— experiment list and current experiment stateresults.ts— loaded results and active filtersmodels.ts— provider connection status and model listsws.ts— WebSocket connection and event handlers
Component hierarchy:
App.svelte
├── Layout.svelte (nav, sidebar)
├── pages/
│ ├── Dashboard.svelte
│ ├── Models.svelte
│ ├── NewExperiment.svelte (wizard)
│ │ ├── steps/Step1TaskType.svelte
│ │ ├── steps/Step2Needles.svelte
│ │ ├── steps/Step3Haystack.svelte
│ │ ├── steps/Step4Grid.svelte
│ │ ├── steps/Step5Models.svelte
│ │ └── steps/Step6Review.svelte
│ ├── ExperimentProgress.svelte
│ ├── Results.svelte
│ │ ├── HeatmapTab.svelte
│ │ ├── ChartsTab.svelte
│ │ └── DrilldownModal.svelte
│ ├── ResultsLibrary.svelte
│ └── Settings.svelte
└── components/ (shared)
├── HeatmapChart.svelte (ECharts wrapper)
├── RadarChart.svelte
├── LineChart.svelte
├── ProgressGrid.svelte (live updating)
└── Toast.svelte
User configures experiment (UI/CLI/YAML)
│
▼
ExperimentConfig (Pydantic validation)
│
▼
TestRunner.run_experiment()
│
├── Build experiment grid (all cells)
├── Load existing results from SQLite (for resume)
├── Filter out completed cells
│
▼ (for each remaining cell, concurrently)
TestRunner._run_cell(cell)
│
├── Pre-flight: check cell.context_length ≤ model.max_context_tokens
├── NeedleFactory.generate_*() — or load from CustomNeedleSpec pool
├── HaystackBuilder.build() → Haystack (cached)
├── Task.compute_depths() → depth list (respects depth_distribution)
├── Injector.inject_multiple() → InjectedDocument
├── Task.build_prompt() → str
├── ResponseCache.get() → hit? return cached
├── Provider.complete() → ProviderResponse
├── ResponseCache.set() → cache response
├── Evaluator.score_response() → EvaluationResult (auto/numeric/substring)
├── ResultStore.save_result() → persist (with metadata: needle, expected, depths)
└── progress_callback(progress_event) → WebSocket broadcast
User requests results (UI/CLI)
│
▼
ResultStore.load_results(experiment_id)
│
▼
List[ResultRecord]
│
├── viz/heatmap.py → matplotlib Figure → PNG/SVG
├── viz/radar.py → spider chart
├── viz/report.py → HTML report
└── Direct JSON/CSV export
- Main event loop: asyncio (single-threaded)
- Concurrent API calls:
asyncio.gather()with semaphore (max_concurrency) - Blocking operations: tokenization, file I/O run in
asyncio.get_event_loop().run_in_executor(None, ...) - matplotlib rendering: synchronous, called from sync context after async data collection
- FastAPI: fully async, no sync route handlers
- WebSocket broadcasting: fan-out to all connected clients via
asyncio.gather()
YAML config file
↓ (overrides)
CLI flags
↓ (overrides)
Environment variables
↓ (overrides)
Code defaults (config/defaults.py)
| Error Category | Behavior |
|---|---|
| Provider API error (5xx) | Exponential backoff, retry up to N times, then mark cell as failed |
| Rate limit (429) | Respect Retry-After header, then continue |
| Credential missing | Raise CredentialError with actionable message |
| Model not available | Raise ModelNotFoundError with list of available models |
| Context length exceeded | Pre-flight check skips cell before API call; non-retriable; recorded in skipped_by_model in cost estimate |
| Network timeout | Retry with backoff |
| Invalid config | Pydantic ValidationError with field-level error messages |
| SQLite error | Log error, mark experiment as failed, preserve existing data |
- Create
haystackbench/providers/myprovider.py - Subclass
BaseProvider, implement 3 abstract methods - Register in
haystackbench/providers/__init__.py - Add to
ModelInfolist with context window sizes - Write a test in
tests/unit/test_providers.py
- Create
haystackbench/tasks/mytask.py - Subclass
BaseTask, implement abstract methods - Register in
haystackbench/tasks/__init__.py - Add to
TaskTypeenum inhaystackbench/config/schema.py - Add preset config if applicable
- Write a test in
tests/unit/test_tasks.py
- Create
haystackbench/haystacks/standard/mysource.py - Implement
fetch(target_tokens, **kwargs) -> str - Register in
HaystackSourceenum - Handle caching via
HaystackCache
| Package | Version | Purpose |
|---|---|---|
pydantic |
>=2.0 | Config validation |
aiosqlite |
>=0.19 | Async SQLite |
tiktoken |
>=0.5 | Token counting |
keyring |
>=24.0 | Credential storage |
httpx |
>=0.25 | Async HTTP client |
rich |
>=13.0 | CLI formatting |
typer |
>=0.9 | CLI framework |
pyyaml |
>=6.0 | YAML config |
matplotlib |
>=3.7 | Chart generation |
seaborn |
>=0.12 | Statistical visualization |
| Package | Provider |
|---|---|
anthropic |
Anthropic |
openai |
OpenAI |
google-generativeai |
|
mistralai |
Mistral |
cohere |
Cohere |
groq |
Groq |
| Package | Purpose |
|---|---|
fastapi |
Backend framework |
uvicorn |
ASGI server |
websockets |
WebSocket support |
| Package | Purpose |
|---|---|
sentence-transformers |
Semantic scoring |
numpy |
Numeric operations |