Compare commits

...

4 Commits

Author SHA1 Message Date
640691b8ab docs: record fixed_size as the winning strategy family
Why:
- Close the benchmark with a written family decision backed by Experiment composites.

Changes:
- Add the decision memo and SVG charts generated from SQLite.

Impact:
- Documents fixed_size over semantic under text-embedding-3-large; ±N remains follow-up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:22:38 +03:30
6261bc0a23 docs: add high- and low-level design
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:22:35 +03:30
2ac69c9944 docs: record decision board language and adr-0026
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:22:28 +03:30
56b8d9401a feat(dashboard): add decision board for strategy selection
Why:
- Compare is the wrong surface for two-stage family selection over the 10-doc set.

Changes:
- Add the Decision Tab; raise GET /experiments default/max so the board can load the grid client-side.

Impact:
- Operators pick fixed_size ±N vs semantic@Boundary from existing Experiments.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 14:22:21 +03:30
16 changed files with 2597 additions and 16 deletions

View File

@@ -98,13 +98,21 @@ _Avoid_: Retrieved chunks list, neighbor list, expansion map
An ordered batch of Experiments that hold Strategy and Corpus Embedding Model fixed while stepping Neighbor Expansion through symmetric levels (±0, ±1, ±2, ±3) — i.e. `(neighbor_prev, neighbor_next)` = `(0,0)`, `(1,1)`, `(2,2)`, `(3,3)` — across one or more documents. Each level is its own Experiment; the Sweep is the sequence, not a single stored row. An ordered batch of Experiments that hold Strategy and Corpus Embedding Model fixed while stepping Neighbor Expansion through symmetric levels (±0, ±1, ±2, ±3) — i.e. `(neighbor_prev, neighbor_next)` = `(0,0)`, `(1,1)`, `(2,2)`, `(3,3)` — across one or more documents. Each level is its own Experiment; the Sweep is the sequence, not a single stored row.
_Avoid_: Batch run, sequential benchmark, benchmark script, neighbor matrix _Avoid_: Batch run, sequential benchmark, benchmark script, neighbor matrix
**Strategy Candidate**:
A single comparable configuration for final Strategy selection under one Corpus Embedding Model: either `fixed_size` at one Neighbor Expansion level (±N), or `semantic` under one Boundary Embedding Model. Distinct from an Experiment (an Experiment is one doc’s run of one Candidate, or of several Strategies). Final selection compares Candidates, not raw multi-strategy Experiments. The Decision Board auto-discovers Candidates from single-strategy Experiments (newest per doc×Candidate cell), filtered by Corpus Embedding Model, with optional exclude of a bad Experiment.
_Avoid_: Variant, configuration, setup, arm
**Decision Board**:
A top-level Dashboard Tab for choosing between Strategy families after tuning: stage 1 picks the best Strategy Candidate within `fixed_size` and within `semantic`; stage 2 compares those two winners. Always accompanied by a per-document breakdown so aggregate rank cannot hide doc-level disagreement. Document universe for v1 is the fixed 10-doc evaluation set (same as the Neighbor Expansion Benchmark Sweep). Distinct from Compare (ad-hoc multi-Experiment side-by-side) and from Retrieval Inspect.
_Avoid_: Final compare, strategy picker, results page, leaderboard, Decision mode
## Architecture Decisions ## Architecture Decisions
| # | Decision | Status | | # | Decision | Status |
|---|----------|--------| |---|----------|--------|
ADR-0001 | Single-file React via CDN (no build step), served by FastAPI | Approved | ADR-0001 | Single-file React via CDN (no build step), served by FastAPI | Approved |
ADR-0002 | New `/admin/` router for dashboard-specific backend ops (health, Qdrant CRUD, chunk preview, questions, cost) | Approved | ADR-0002 | New `/admin/` router for dashboard-specific backend ops (health, Qdrant CRUD, chunk preview, questions, cost) | Approved |
ADR-0003 | Top-tab navigation (Home, Documents, Query, Benchmarks, Admin) — not sidebar | Approved | ADR-0003 | Top-tab navigation (Home, Documents, Query, Benchmarks, Decision, Admin) — not sidebar | Approved |
ADR-0004 | Babel-in-browser JSX: single index.html with inline <script type="text/babel">, React+ReactDOM+Babel from CDN. Zero build tooling, one file to edit. | Approved | ADR-0004 | Babel-in-browser JSX: single index.html with inline <script type="text/babel">, React+ReactDOM+Babel from CDN. Zero build tooling, one file to edit. | Approved |
ADR-0005 | Persistent tabs with useState on root App. No routing library, no state library — 5-7 shared state values passed as props. Tab components stay mounted, state survives tab switches. | Approved | ADR-0005 | Persistent tabs with useState on root App. No routing library, no state library — 5-7 shared state values passed as props. Tab components stay mounted, state survives tab switches. | Approved |
ADR-0006 | Dashboard calls existing REST endpoints for documents/queries/benchmarks. New /admin/* router ONLY fills gaps: Qdrant CRUD, health, chunk preview, questions dataset, cost estimation. No endpoint duplication. | Approved | ADR-0006 | Dashboard calls existing REST endpoints for documents/queries/benchmarks. New /admin/* router ONLY fills gaps: Qdrant CRUD, health, chunk preview, questions dataset, cost estimation. No endpoint duplication. | Approved |
@@ -126,3 +134,4 @@ _Avoid_: Batch run, sequential benchmark, benchmark script, neighbor matrix
ADR-0023 | Neighbor Expansion for fixed_size (+ Expansion Tree; list/Compare `±P/N` provenance + mismatch warning). See docs/adr/0023-*.md | Approved | ADR-0023 | Neighbor Expansion for fixed_size (+ Expansion Tree; list/Compare `±P/N` provenance + mismatch warning). See docs/adr/0023-*.md | Approved |
ADR-0024 | Boundary vs Corpus Embedding Model roles (Admin defaults + per-op overrides; query locked to Corpus). See docs/adr/0024-*.md | Implemented | ADR-0024 | Boundary vs Corpus Embedding Model roles (Admin defaults + per-op overrides; query locked to Corpus). See docs/adr/0024-*.md | Implemented |
ADR-0025 | Retrieval Inspect: Benchmarks full-page mode (question rail + full-text Expansion Tree). See docs/adr/0025-*.md | Approved | ADR-0025 | Retrieval Inspect: Benchmarks full-page mode (question rail + full-text Expansion Tree). See docs/adr/0025-*.md | Approved |
ADR-0026 | Decision Board Tab: two-stage Strategy Candidate selection (fixed_size ±N vs semantic@Boundary) over the 10-doc set. See docs/adr/0026-*.md | Approved |

280
docs/HLD.md Normal file
View File

@@ -0,0 +1,280 @@
# High-Level Design (HLD)
RAG Chunking Benchmarker — system purpose, boundaries, components, and major flows.
**Audience:** architects, tech leads, new engineers
**Companion:** [LLD](LLD.md) · [ADRs](adr/) · [CONTEXT.md](../CONTEXT.md)
---
## 1. Purpose
Compare five chunking Strategies on regulatory (and similar) documents under a controlled RAG pipeline: ingest → chunk → embed → retrieve → answer → LLM-as-Judge evaluation.
Operators use a Dashboard to upload documents, process Strategies, run Queries and Experiments, inspect retrieval, and choose a final Strategy Candidate on the Decision Board.
---
## 2. Goals and Non-Goals
### Goals
| Goal | Notes |
|------|--------|
| Fair Strategy comparison | Same document, questions, Corpus Embedding Model, and Neighbor Expansion knobs per Experiment |
| Auditable provenance | Experiments record Corpus (+ Boundary when semantic Strategies were processed) |
| Operator UX | Dashboard at `/app` replaces Swagger as primary UI |
| Embedding flexibility | Cloud (OpenAI) and Local (Ollama) via Embedding Model Registry |
| Failure isolation | One Strategy failing during process does not discard others |
### Non-Goals (v1)
| Out of scope | See |
|--------------|-----|
| Scanned PDF / OCR | [out-of-scope-v1.md](out-of-scope-v1.md), backlog |
| Multi-tenant auth | Single-operator local tool |
| Production-scale concurrency | SQLite + single process |
| Comparing Experiments across different Boundary/Corpus/Neighbor settings without warning | Compare UI warns; Decision Board filters by Corpus |
---
## 3. System Context
```
┌──────────────┐ HTTP/REST ┌─────────────────────────────────────┐
│ Operator │◄──────────────────►│ FastAPI App (RAG Chunking Benchmarker)│
│ (Browser) │ Dashboard /app │ │
└──────────────┘ │ Documents · Chunking · Benchmarking │
│ Admin · Static Dashboard │
└───────────┬─────────────────────────┘
│
┌─────────────────────────────────┼─────────────────────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ SQLite │ │ Qdrant │ │ LLM / Embed │
│ metadata, │ │ Model Corpus │ │ OpenAI Cloud │
│ Experiments, │ │ vectors + │ │ + Ollama Local│
│ app_settings │ │ chunk payload │ └────────────────┘
└────────────────┘ └────────────────┘
```
**External dependencies**
| System | Role |
|--------|------|
| OpenAI API | Corpus/Boundary embeddings (cloud models); answer generation; LLM-as-Judge |
| Ollama | Local Embedding Model Provider (`nomic-embed-text-v2-moe`) |
| Qdrant | Vector search per Strategy × Corpus Embedding Model |
| LibreOffice (optional) | Legacy `.doc` → `.docx` conversion path in parser |
---
## 4. Logical Architecture
```
┌──────────────────────────────────────────────────────────────────────────┐
│ Presentation Layer │
│ src/static/index.html — single-file React Dashboard (CDN, no build) │
│ Tabs: Home · Documents · Query · Benchmarks · Decision · PDF · Admin │
└────────────────────────────────┬─────────────────────────────────────────┘
│ REST
┌────────────────────────────────▼─────────────────────────────────────────┐
│ API Layer (FastAPI) │
│ /documents* /queries* /benchmarks* /experiments* /admin/* │
│ /strategies /app (StaticFiles) │
└────────────────────────────────┬─────────────────────────────────────────┘
│
┌────────────────────────────────▼─────────────────────────────────────────┐
│ Domain Services │
│ documents/service chunking/service query_service benchmark_service│
│ evaluation admin/service report │
└────────────────────────────────┬─────────────────────────────────────────┘
│
┌────────────────────────────────▼─────────────────────────────────────────┐
│ Foundation │
│ core/config · models · exceptions · dependencies │
│ chunking/base · embedding · embedding_models · strategies/* │
│ documents/parser · pdf_parser │
│ storage/sqlite · storage/qdrant │
└──────────────────────────────────────────────────────────────────────────┘
```
---
## 5. Major Components
| Component | Responsibility | Key modules |
|-----------|----------------|-------------|
| **Dashboard** | Operator UI; persistent Tabs; Decision Board; Retrieval Inspect; PDF Workspace | `src/static/index.html` |
| **Documents** | Upload/parse Word & Text PDF; process Strategies; delete | `documents/*` |
| **Chunking** | Strategy registry; Boundary vs Corpus embeds; chunk → embed → Qdrant | `chunking/*` |
| **Query** | Embed question → search → Neighbor Expansion → LLM answer | `benchmarking/query_service.py` |
| **Benchmark** | Question × Strategy Experiment; aggregate metrics; HTML reports | `benchmarking/benchmark_service.py`, `report.py` |
| **Evaluation** | LLM-as-Judge (context relevance, answer similarity, faithfulness, hallucination) | `benchmarking/evaluation.py` |
| **Admin** | Health, Embedding Model defaults/thresholds, Qdrant CRUD, Chunk Preview, questions files, cost estimate | `admin/*` |
| **Storage** | SQLite metadata; Qdrant Model Corpus | `storage/*` |
---
## 6. Domain Concepts (summary)
Full glossary: [CONTEXT.md](../CONTEXT.md).
| Term | Meaning |
|------|---------|
| **Strategy** | One of: `fixed_size`, `recursive`, `semantic`, `contextual_retrieval`, `semantic_parent_child` |
| **Experiment** | Completed benchmark run for one document × N Strategies × M questions, with provenance |
| **Boundary Embedding Model** | Used only for Semantic Boundary Detection |
| **Corpus Embedding Model** | Embeds finished chunks and queries; scopes Qdrant collections |
| **Model Corpus** | Collections `{strategy}__{model_id}_collection` for one Corpus model |
| **Neighbor Expansion** | Query-time prev/next chunks for `fixed_size` only (`±P/N`) |
| **Expansion Tree** | Per-hit neighbor grouping for operator audit (vs flat LLM context) |
| **Strategy Candidate** | Comparable config for Decision Board (`fixed_size±N` or `semantic@Boundary`) |
| **Decision Board** | Two-stage selection over the fixed evaluation document set |
---
## 7. End-to-End Flows
### 7.1 Ingest and process
```
Upload (.docx/.doc/.pdf)
→ Text-layer Gate (PDF) / parse DOCX
→ Heading Reconstruction (PDF) → DocumentTree + markdown
→ SQLite documents
Process(strategies, boundary?, corpus?)
→ For each Strategy (isolated failures):
Boundary embeds (semantic*) → Strategy.chunk()
→ Corpus embeds → Qdrant Model Corpus upsert
→ Update chunk_counts + last_* embedding provenance on document
```
### 7.2 Query
```
POST /queries
→ Resolve Corpus Embedding Model
→ Embed question (Corpus)
→ Qdrant top-k (document filter)
→ Neighbor Expansion if fixed_size (±P/N)
→ Build context (parent fetch for semantic_parent_child)
→ LLM answer → SQLite queries (+ expansion_tree)
```
### 7.3 Experiment
```
POST /benchmarks
→ Load questions JSON
→ For each question × Strategy: Query + evaluate_single
→ Aggregate metrics → SQLite experiments
→ Optional HTML report (managerial / technical)
```
### 7.4 Decision
```
Dashboard Decision Tab
→ Load Experiments filtered by Corpus Embedding Model
→ Discover Strategy Candidates (newest per doc × Candidate)
→ Stage 1: best fixed_size ±N vs best semantic@Boundary
→ Stage 2: compare winners + per-document breakdown
```
---
## 8. Data Architecture
### 8.1 Responsibility split
| Store | Owns |
|-------|------|
| **SQLite** | Documents (tree + markdown), queries, Experiments, `app_settings` (Embedding defaults, thresholds) |
| **Qdrant** | Vectors + chunk text payload (no vectors in SQLite) |
| **Filesystem** | Question JSON under `files/`; uploaded bytes are not retained after parse |
### 8.2 Model Corpus naming
```
{strategy}__{sanitized_model_id}_collection
```
Example: `fixed_size__text-embedding-3-large_collection`
Process, Query, and Experiment only read/write the Corpus Embedding Model in force. Other corpora remain untouched.
### 8.3 Provenance
Experiments store:
- `embedding_model_id` / `embedding_provider` — Corpus
- `boundary_embedding_model_id` — when semantic Strategies were on the process path for that document
- Neighbor Expansion levels in `benchmark_config`
Documents store `last_corpus_embedding_model_id` and `last_boundary_embedding_model_id` after process.
---
## 9. Deployment View
```
┌─────────────────────────────────────────┐
│ Host process │
│ uvicorn src.main:app │
│ └── FastAPI + Dashboard static │
│ │
│ data/chunking_benchmark.db │
│ .env (secrets + knobs) │
└───────────────┬─────────────────────────┘
│
┌───────────┴───────────┐
▼ ▼
Qdrant (:6333) OpenAI / Ollama
```
Typical local stack: app + Qdrant (Docker or native) + optional Ollama host. No build step for the Dashboard.
---
## 10. Cross-Cutting Concerns
| Concern | Approach |
|---------|----------|
| Config | `pydantic-settings` from `.env` ([configuration.md](configuration.md)) |
| Errors | Domain exceptions → HTTP 400 handlers (`ChunkingError`, `BenchmarkError`, `QueryError`, …) |
| Logging | Request middleware + structured stage logs in query/benchmark |
| CORS | Permissive (`*`) for local Dashboard development |
| Cost | Dry-run `/benchmarks` and `/admin/cost-estimate`; Local embeds = $0 embedding side |
| Extensibility | New Strategy = implement `ChunkingStrategy` + registry; new Embedding Model = registry entry |
---
## 11. Key Architectural Decisions
| ADR | Decision |
|-----|----------|
| 0001–0009 | CDN React Dashboard; `/admin` for ops gaps; top Tabs; dark amber theme |
| 0016–0017 | Text PDF via PyMuPDF; PDF Workspace via format-filtered shared sections |
| 0020 | Semantic Boundary Detection required (no fixed-count fallback) |
| 0021 | Always scope Qdrant collections by Embedding Model id |
| 0022 | Per-model `semantic_threshold` |
| 0023 | Neighbor Expansion + Expansion Tree for `fixed_size` |
| 0024 | Boundary vs Corpus Embedding Model roles |
| 0025 | Retrieval Inspect in Benchmarks Tab |
| 0026 | Decision Board two-stage Candidate selection |
---
## 12. Related Documents
| Doc | Role |
|-----|------|
| [LLD.md](LLD.md) | Module interfaces, schemas, algorithms |
| [architecture.md](architecture.md) | Older overview (prefer HLD for current shape) |
| [data-flow.md](data-flow.md) | Pipeline detail |
| [api-reference.md](api-reference.md) | HTTP contracts |
| [strategy-technical-details.md](strategy-technical-details.md) | Per-Strategy algorithms |
| [evaluation-metrics.md](evaluation-metrics.md) | Scoring definitions |
| [CONTEXT.md](../CONTEXT.md) | Ubiquitous language |

571
docs/LLD.md Normal file
View File

@@ -0,0 +1,571 @@
# Low-Level Design (LLD)
RAG Chunking Benchmarker — module interfaces, data schemas, algorithms, and API contracts.
**Audience:** implementers
**Companion:** [HLD](HLD.md) · [API Reference](api-reference.md) · [CONTEXT.md](../CONTEXT.md)
---
## 1. Package Map
```
src/
├── main.py # App factory, middleware, router mount
├── static/index.html # Dashboard (React + Babel CDN)
├── core/
│ ├── config.py # Settings (.env)
│ ├── models.py # Chunk, DocumentTree, StrategyName
│ ├── exceptions.py # Domain errors + handlers
│ └── dependencies.py # OpenAI / Ollama / Qdrant singletons
├── storage/
│ ├── sqlite.py # documents, queries, experiments, app_settings
│ └── qdrant.py # Model Corpus CRUD + search
├── documents/
│ ├── parser.py # DOCX / .doc (+ LibreOffice)
│ ├── pdf_parser.py # Text PDF + Text-layer Gate
│ ├── heading_heuristics.py # PDF Heading Reconstruction helpers
│ ├── service.py # Upload / process / delete
│ ├── routes.py
│ └── models.py # ProcessRequest, DocumentResponse, …
├── chunking/
│ ├── base.py # ChunkingStrategy ABC, tokens, sentences
│ ├── embedding.py # Boundary/Corpus resolve + embed_texts
│ ├── embedding_models.py # Registry, thresholds, task prefixes
│ ├── service.py # run_strategies orchestration
│ └── strategies/
│ ├── fixed_size.py
│ ├── recursive.py
│ ├── semantic.py
│ ├── contextual_retrieval.py
│ └── semantic_parent_child.py
├── benchmarking/
│ ├── query_service.py # Query + Neighbor Expansion
│ ├── benchmark_service.py # Experiment runner + cost estimate
│ ├── evaluation.py # LLM-as-Judge
│ ├── report.py # HTML reports
│ ├── routes.py
│ └── models.py
└── admin/
├── routes.py # /admin/*
└── service.py
```
---
## 2. Application Bootstrap
**Entry:** `create_app()` in `src/main.py`
1. `init_db()` — create/migrate SQLite schema
2. Register CORS + `RequestLoggingMiddleware`
3. Exception handlers: `ChunkingError`, `BenchmarkError`, `QueryError`
4. Routers: documents, benchmarking, admin
5. Mount `StaticFiles` at `/app` → `src/static/`
```python
# Singletons (lru_cache)
get_openai_client() # Cloud LLM + cloud embeds
get_ollama_client() # OpenAI-compatible client → Ollama base URL
get_qdrant_client()
```
---
## 3. Configuration
`Settings` (`src/core/config.py`) — selected fields:
| Field | Default | Use |
|-------|---------|-----|
| `openai_api_key` | required | Cloud Provider |
| `embedding_model` | `text-embedding-3-large` | Config legacy; runtime uses Registry + Admin |
| `llm_model` | `gpt-4o-mini` | Answer + judge |
| `ollama_base_url` | host URL | Local embeds |
| `qdrant_url` | `http://localhost:6333` | Vectors |
| `top_k` | `5` | Retrieval |
| `neighbor_prev` / `neighbor_next` | `0` / `0` | Expansion defaults |
| `chunk_size` / `chunk_overlap` | `512` / `50` | fixed_size / recursive targets |
| `semantic_threshold` | `0.3` | Fallback if model default missing |
| `semantic_min_chunk_size` | `3` | Min units per semantic chunk |
| `database_url` | `sqlite:///./data/chunking_benchmark.db` | Metadata |
| `pdf_min_total_chars` / `pdf_min_median_chars_per_page` | `100` / `40` | Text-layer Gate |
---
## 4. Core Domain Models
### 4.1 StrategyName
```python
class StrategyName(str, Enum):
RECURSIVE = "recursive"
FIXED_SIZE = "fixed_size"
SEMANTIC = "semantic"
CONTEXTUAL_RETRIEVAL = "contextual_retrieval"
SEMANTIC_PARENT_CHILD = "semantic_parent_child"
```
### 4.2 Chunk (unified)
| Field | Type | Notes |
|-------|------|-------|
| `document_name` | str | Source filename |
| `chunk_id` | str | `{strategy}_{safe_doc}_{index:06d}` |
| `strategy_name` | StrategyName | |
| `chunk_index` | int | Document order (used by Neighbor Expansion) |
| `text` | str | Stored in Qdrant; used for LLM context |
| `token_count` / `character_count` | int | tiktoken `cl100k_base` |
| `parent_id` | str \| None | Parent-child Strategy |
| `enriched_content` | str \| None | Contextual retrieval embed text (not always in payload) |
`chunk_to_metadata()` drops `enriched_content` for Qdrant payload (`ChunkMetadata`).
### 4.3 DocumentTree
```
DocumentTree
└── root: DocumentTreeNode
node_type: document | section | article | paragraph
text, heading, heading_level, children[]
```
Serialized as JSON in SQLite `documents.document_tree`. Chunking primarily uses `parsed_text` (markdown); tree supports structure-aware Strategies and preview.
---
## 5. Storage LLD
### 5.1 SQLite schema
**documents**
| Column | Type | Purpose |
|--------|------|---------|
| `id` | TEXT PK | UUID hex |
| `filename` | TEXT | Original name |
| `parsed_text` | TEXT | Markdown for chunking |
| `document_tree` | TEXT | JSON tree |
| `chunk_counts` | TEXT | JSON `{strategy: count}` |
| `last_corpus_embedding_model_id` | TEXT | Provenance |
| `last_boundary_embedding_model_id` | TEXT | Provenance |
| `created_at` | TEXT | ISO UTC |
**queries**
| Column | Purpose |
|--------|---------|
| `id`, `document_id`, `strategy_name` | Identity |
| `question`, `answer` | Content |
| `retrieved_chunks` | Flat LLM/eval list (JSON) |
| `expansion_tree` | Per-hit neighbors (JSON) |
| `latency_breakdown`, `token_usage` | Observability JSON |
| `created_at` | |
**experiments**
| Column | Purpose |
|--------|---------|
| `id`, `document_id` | Identity |
| `benchmark_config` | top_k, neighbor_prev/next, etc. |
| `questions`, `per_question` | Inputs + per-cell results |
| `aggregate_metrics` | Per-Strategy averages |
| `strategies_used` | JSON list |
| `embedding_model_id`, `embedding_provider` | Corpus snapshot |
| `boundary_embedding_model_id` | Boundary snapshot (nullable) |
| `created_at` | |
**app_settings** — key/value for:
- `corpus_embedding_model_id`, `boundary_embedding_model_id`
- Legacy `active_embedding_model_id` (migrated into both roles)
- `semantic_threshold:{model_id}` overrides
Connection: WAL mode, foreign keys ON, one connection per call (no pool).
### 5.2 Qdrant
**Collection name**
```python
def collection_name(strategy, model_id) -> str:
return f"{strategy}__{sanitize(model_id)}_collection"
# sanitize: replace : and / with -
```
**Point**
| Field | Value |
|-------|-------|
| `id` | `uuid5(NAMESPACE_URL, chunk_id)` |
| `vector` | Corpus embedding (dim from model: 1536 / 3072 / 768) |
| `payload` | ChunkMetadata fields |
**Search**
- Cosine distance
- Optional filter: `document_name == filename`
- Returns payload + score
**Neighbor fetch:** `get_chunks_by_indices(strategy, document_name, indices, model_id)` for Expansion.
**Admin:** list/create/delete collections; wipe points.
---
## 6. Embedding Subsystem
### 6.1 Registry (`embedding_models.py`)
| id | Provider | Dimension | Default semantic_threshold | Task prefixes |
|----|----------|-----------|----------------------------|---------------|
| `text-embedding-3-small` | cloud | 1536 | 0.3 | no |
| `text-embedding-3-large` | cloud | 3072 | 0.3 | no (default Admin) |
| `nomic-embed-text-v2-moe` | local | 768 | 0.6 | yes (`search_document:` / `search_query:`) |
`EmbeddingModelSpec`: `id`, `provider`, `model_name`, `dimension`, `display_name`, `task_prefixes`, `default_semantic_threshold`.
### 6.2 Role resolution (`embedding.py`)
```
resolve_corpus_model(optional_id) → snapshot for process/query/experiment
resolve_boundary_model(optional_id) → snapshot for semantic cuts
get_corpus_embedding_model() / get_boundary_embedding_model()
→ app_settings → registry → DEFAULT_CLOUD_MODEL_ID
```
Admin switches persist to SQLite; mid-flight Admin changes do not affect an in-progress operation that already resolved snapshots.
### 6.3 embed_texts / embed_single
1. `apply_task_prefixes(texts, model, purpose)` if needed
2. Batch: OpenAI 2048 / Ollama 64
3. Route to `get_openai_client()` or `get_ollama_client()` by Provider
4. Raise `EmbeddingError` on failure
**Threshold:** `get_semantic_threshold(model_id)` = Admin override → registry default. Boundary Strategy cuts **always** use Boundary model's threshold (never Corpus).
---
## 7. Documents LLD
### 7.1 Upload
```
upload_document(filename, bytes)
→ temp file → parse_document(path)
→ db.save_document(parsed_text=markdown, document_tree=JSON)
```
**Supported suffixes:** `.docx`, `.doc`, `.pdf` (`SUPPORTED_SUFFIXES`).
**PDF path (`pdf_parser.py`):**
1. Text-layer Gate (`pdf_min_total_chars`, `pdf_min_median_chars_per_page`) — reject Scanned PDF
2. Extract text + Heading Reconstruction (outline → font → Farsi/English heuristics)
3. Table Flattening to sequential plain text
4. Emit markdown `#` / `##` + DocumentTree
### 7.2 Process
```
ProcessRequest:
strategies: list[StrategyName] # default all five
boundary_model_id: str | None
corpus_model_id: str | None
process_document → chunking.service.run_strategies(...)
→ ProcessResponse(completed[], failed[], corpus_*, boundary_*)
```
### 7.3 Delete
Deletes SQLite row (cascade queries/experiments) and Qdrant points for that document across known collections (implementation in `documents/service.py` + `qdrant`).
---
## 8. Chunking LLD
### 8.1 Interface
```python
class ChunkingStrategy(ABC):
name: StrategyName
@abstractmethod
def chunk(*, doc_name: str, tree: DocumentTree, markdown: str) -> list[Chunk]:
...
```
Semantic Strategies accept extra kwargs from orchestrator (`sentence_embeddings` / `paragraph_embeddings`, `semantic_threshold`).
### 8.2 Orchestrator (`run_strategies`)
```
corpus = resolve_corpus_model(corpus_model_id)
boundary = resolve_boundary_model(...) if any(semantic*) else unused
for strategy in strategies:
try:
ensure_collection(strategy, corpus.id, corpus.dimension)
chunks = _chunk_document(...) # inject Boundary embeds if needed
texts = enriched_content or text
embeddings = embed_texts(texts, model=corpus, purpose="document")
upsert_chunks(chunks, embeddings, model_id=corpus.id)
except → failed[] (others continue)
update_chunk_counts + update_process_embedding_provenance
```
### 8.3 Strategy algorithms (summary)
| Strategy | Input units | Boundary | Output |
|----------|-------------|----------|--------|
| **fixed_size** | markdown tokens | none | sliding window `chunk_size` / `chunk_overlap` |
| **recursive** | markdown | cascade `#` → `\n\n` → `\n` → punct → space | merge up to target size |
| **semantic** | sentences (`split_sentences`, Farsi-aware) | adjacent cosine vs Boundary threshold; `semantic_min_chunk_size` | joined sentence groups |
| **contextual_retrieval** | base chunks + LLM context prefix | none | `text` = original; `enriched_content` = prefix+text for embed |
| **semantic_parent_child** | paragraphs | paragraph cosine vs Boundary threshold | parents + children with `parent_id` |
Semantic Strategies **fail hard** if Boundary embeddings missing/mismatched (ADR-0020) — no fixed-count fallback.
**Sentence split fallback:** punctuation → non-empty lines → blank-line paragraphs → single unit.
Deep dive: [strategy-technical-details.md](strategy-technical-details.md).
---
## 9. Query LLD
### 9.1 `run_query` pipeline
```
1. Load document (filename for filter)
2. Resolve Corpus model
3. embed_single(question, purpose="query")
4. qdr.search(strategy, vector, top_k, document_filter=filename, model_id)
5. apply_neighbor_expansion(...) → retrieved_chunks, expansion_tree
6. _build_context (parent scroll for semantic_parent_child)
7. _generate_answer (settings.llm_model, temp 0)
8. db.save_query(...)
```
### 9.2 Neighbor Expansion (`apply_neighbor_expansion`)
**Applies only when** `strategy == fixed_size` and `(neighbor_prev > 0 or neighbor_next > 0)`.
1. Build Expansion Tree nodes for each top-k hit (score order)
2. Collect needed `chunk_index` values: hit±prev/next (skip &lt; 0)
3. Fetch missing indices via Qdrant
4. Fill `neighbors_prev` / `neighbors_next` per hit
5. Flat list: hits + neighbors, **dedupe by chunk_id** (prefer hit), sort by `chunk_index`
6. Neighbors have `role="neighbor"`, `score=None`
Knobs ignored for non-`fixed_size` Strategies (tree still returns hits with empty neighbor arrays).
### 9.3 Parent-child context
For `semantic_parent_child`, scroll Qdrant by `chunk_id == parent_id` and append parent text under each child in the prompt context.
---
## 10. Benchmark LLD
### 10.1 Questions format
```json
{
"questions": [
{
"id": "q1",
"question": "...",
"expected_answer": "...",
"category": "...",
"difficulty": "..."
}
]
}
```
Load from `questions_file` path or inline `questions` list.
### 10.2 `run_benchmark`
```
embedding_model = resolve_corpus_model(corpus_model_id)
boundary_id = doc.last_boundary_embedding_model_id # if semantic in strategies
for question in questions:
for strategy in strategies:
result = run_query(..., neighbor_prev, neighbor_next, embedding_model=...)
scores = evaluate_single(question, retrieved_chunks, expected, generated)
append per_question row
aggregate_metrics[strategy] = averages + hallucination_rate
best_strategy = argmax (e.g. answer_similarity / composite — see service)
save experiment with provenance + benchmark_config
```
### 10.3 Evaluation (`evaluate_single`)
LLM returns JSON:
```json
{
"context_relevance": 1-10,
"answer_similarity": 1-10,
"faithfulness": 1-10,
"hallucination": true|false,
"reasoning": "..."
}
```
`response_format=json_object`, temperature 0. Metrics definitions: [evaluation-metrics.md](evaluation-metrics.md).
### 10.4 Reports
`report.py` renders managerial or technical HTML from an Experiment (rankings, KPIs, Expansion Tree samples, token usage).
### 10.5 Cost estimate
`estimate_cost(num_questions, num_strategies)` — heuristic USD; Local Corpus → embedding cost 0.
---
## 11. Admin LLD
| Endpoint | Behavior |
|----------|----------|
| `GET /admin/health` | App + Qdrant + SQLite ping |
| `GET /admin/embedding-models` | Registry + Boundary/Corpus defaults + thresholds |
| `PUT .../corpus` · `.../boundary` | Set role defaults |
| `PUT .../{id}/semantic-threshold` | Persist override `(0, 1]` |
| `GET/POST/DELETE /admin/qdrant/collections*` | Collection CRUD + wipe points |
| `GET /admin/chunks/{doc_id}?strategy=` | Chunk Preview from Qdrant |
| `GET/POST/DELETE /admin/questions*` | Manage `files/*.json` |
| `POST /admin/cost-estimate` | Same heuristic as dry-run |
Admin does **not** duplicate document/query/benchmark domain endpoints (ADR-0006).
---
## 12. Dashboard LLD (behavioral)
| Concern | Design |
|---------|--------|
| Delivery | One `index.html`; React + ReactDOM + Babel from CDN |
| Navigation | Top Tabs; components stay mounted (`useState` on `App`) |
| Theme | Dark `#111113`… + amber accent (ADR-0008) |
| Word vs PDF | Documents/Query/Benchmarks filter Word; PDF Tab = same sections + format filter |
| Retrieval Inspect | Full-page mode in Benchmarks: question rail, Strategy picker, Generated \| Expected, Expansion Tree |
| Decision Board | Client-side Candidate discovery from `/experiments`; Corpus filter; exclude bad Experiment ids; two-stage heat comparison |
| Neighbor badge | `±P/N` on Experiment list/Compare; mismatch warning across Boundary/Corpus/Neighbor |
No client router or global store — props from root state.
---
## 13. API Surface (concise)
| Method | Path | Service |
|--------|------|---------|
| GET/POST | `/documents` | list / upload |
| POST | `/documents/{id}/process` | Strategies |
| DELETE | `/documents/{id}` | delete |
| GET | `/strategies` | catalog |
| POST | `/queries` | query |
| GET | `/queries/{id}` | history |
| POST | `/benchmarks` | Experiment or dry_run |
| GET | `/benchmarks/{id}` | Experiment detail |
| GET | `/benchmarks/{id}/report` | HTML |
| GET | `/experiments` | list (+ filters used by Decision Board) |
| * | `/admin/*` | ops |
| GET | `/app/` | Dashboard |
Full schemas: [api-reference.md](api-reference.md), Pydantic models in `documents/models.py` and `benchmarking/models.py`.
**Request highlights**
```python
# Process
boundary_model_id: Optional[str]
corpus_model_id: Optional[str]
# Query / Benchmark
top_k: int = 5
neighbor_prev / neighbor_next: int = 0..5
corpus_model_id: Optional[str]
```
---
## 14. Error Model
| Exception | Typical cause | HTTP |
|-----------|---------------|------|
| `DocumentProcessingError` | Bad file, Text-layer Gate, missing doc | 400 |
| `ChunkingError` | Unknown Strategy, semantic embed failure | 400 |
| `EmbeddingError` | Provider/API failure | 400 |
| `QdrantError` | Collection/upsert/search failure | 400 |
| `QueryError` | Missing doc, LLM answer failure | 400 |
| `BenchmarkError` | Bad questions file, eval failure | 400 |
Per-Strategy process failures are returned in `strategies_failed` without aborting the whole request.
---
## 15. Sequence Diagrams
### Process (one Strategy)
```
Client → DocumentsAPI → DocumentService → ChunkingService
ChunkingService → resolve Boundary/Corpus
ChunkingService → SemanticStrategy.chunk (w/ Boundary embeds)
ChunkingService → embed_texts (Corpus)
ChunkingService → Qdrant.upsert
ChunkingService → SQLite.update counts + provenance
Client ← ProcessResponse
```
### Query with Neighbor Expansion
```
Client → QueryAPI → run_query
→ embed_single (Corpus)
→ Qdrant.search top-k
→ apply_neighbor_expansion → get_chunks_by_indices
→ _build_context → OpenAI chat
→ SQLite.save_query
Client ← QueryResponse (retrieved_chunks + expansion_tree)
```
---
## 16. Extension Points
| Extension | Steps |
|-----------|-------|
| New Strategy | Subclass `ChunkingStrategy`; register in `_STRATEGIES`; add `StrategyName`; update Dashboard labels |
| New Embedding Model | Add `EmbeddingModelSpec` to registry; ensure dimension matches Qdrant collections |
| New eval metric | Extend judge prompt JSON + aggregate in `benchmark_service` + report templates |
| New Admin op | Prefer `/admin` only when domain routers lack the capability |
---
## 17. Scripts
`scripts/run_neighbor_sweep.py` — Benchmark Sweep: fixed Strategy + Corpus, steps Neighbor Expansion `(0,0)…(3,3)` across documents (each level = separate Experiment).
---
## 18. Related Documents
| Doc | Role |
|-----|------|
| [HLD.md](HLD.md) | System context and component view |
| [data-flow.md](data-flow.md) | Narrative pipelines |
| [configuration.md](configuration.md) | Env knobs |
| [adr/](adr/) | Decision records |
| [CONTEXT.md](../CONTEXT.md) | Language and Tab semantics |

View File

@@ -6,12 +6,12 @@ Complete documentation for the RAG Chunking Benchmarker.
## Quick Start ## Quick Start
1. **New to the project?** Start with [Architecture Overview](architecture.md) 1. **New to the project?** Start with [HLD](HLD.md) (system design) then [LLD](LLD.md) (module detail)
2. **Want to understand strategies?** Read [Strategy Technical Details](strategy-technical-details.md) 2. **Domain language?** Read [CONTEXT.md](../CONTEXT.md)
3. **Need to use the API?** Check [API Reference](api-reference.md) 3. **Want to understand strategies?** Read [Strategy Technical Details](strategy-technical-details.md)
4. **Configuring the system?** See [Configuration Guide](configuration.md) 4. **Need to use the API?** Check [API Reference](api-reference.md)
5. **Understanding results?** Read [Evaluation Metrics](evaluation-metrics.md) 5. **Configuring the system?** See [Configuration Guide](configuration.md)
6. **Curious about data flow?** See [Data Flow](data-flow.md) 6. **Understanding results?** Read [Evaluation Metrics](evaluation-metrics.md)
--- ---
@@ -19,13 +19,17 @@ Complete documentation for the RAG Chunking Benchmarker.
| File | Purpose | Audience | | File | Purpose | Audience |
|------|---------|----------| |------|---------|----------|
| [architecture.md](architecture.md) | System structure and design | New team members | | [HLD.md](HLD.md) | High-level design: context, components, flows | Architects, new team members |
| [LLD.md](LLD.md) | Low-level design: schemas, algorithms, APIs | Implementers |
| [architecture.md](architecture.md) | Legacy overview (prefer HLD) | New team members |
| [strategy-technical-details.md](strategy-technical-details.md) | Deep dive into each strategy | Engineers | | [strategy-technical-details.md](strategy-technical-details.md) | Deep dive into each strategy | Engineers |
| [api-reference.md](api-reference.md) | All endpoints documented | Developers | | [api-reference.md](api-reference.md) | All endpoints documented | Developers |
| [configuration.md](configuration.md) | Settings and environment variables | DevOps | | [configuration.md](configuration.md) | Settings and environment variables | DevOps |
| [evaluation-metrics.md](evaluation-metrics.md) | How scoring works | Data scientists | | [evaluation-metrics.md](evaluation-metrics.md) | How scoring works | Data scientists |
| [data-flow.md](data-flow.md) | How data moves through the system | Engineers | | [data-flow.md](data-flow.md) | How data moves through the system | Engineers |
| [chunking_strategies.md](chunking_strategies.md) | High-level strategy overview | Everyone | | [chunking_strategies.md](chunking_strategies.md) | High-level strategy overview | Everyone |
| [final-chunking-strategy-decision.md](final-chunking-strategy-decision.md) | Final Strategy family decision (`fixed_size`) + charts | Managers, operators |
| [adr/](adr/) | Architectural Decision Records | Everyone |
| [phases.md](phases.md) | Implementation phases | Project managers | | [phases.md](phases.md) | Implementation phases | Project managers |
| [tasks.md](tasks.md) | Task tracking | Developers | | [tasks.md](tasks.md) | Task tracking | Developers |

View File

@@ -0,0 +1,24 @@
# ADR-0026: Decision Board Tab for final Strategy selection
## Status
Accepted (implemented)
## Context
After Neighbor Expansion Sweeps and semantic Boundary variants, the operator’s real job is choosing between `fixed_size` and `semantic` — not ad-hoc Experiment Compare. Compare remains useful for arbitrary side-by-side diffs, but it does not encode two-stage tuning (best variant per family, then family showdown) or a fixed 10-doc evaluation universe.
## Decision
- Add a top-level Dashboard **Decision** Tab (Decision Board).
- **Strategy Candidates**: `fixed_size` at ±0…±3, and `semantic` under each Boundary Embedding Model present in data, under a selected Corpus Embedding Model.
- Auto-discover from **single-strategy** Experiments on the hardcoded 10-doc set; newest Experiment fills each doc×Candidate cell; optional exclude chips; incomplete cells are `—` and omitted from that Candidate’s mean/wins.
- Stage 1 ranks by **mean composite** (same weights as Experiments “Best”), shows win-count, allows manual override.
- Stage 2 duels the two family winners with full metrics + per-doc head-to-head; per-doc matrix lists all Candidates with duel winners highlighted.
- Aggregation is client-side from `GET /experiments?limit=…` (default list limit raised; max 500).
## Considered options
- Enhance Compare only — rejected (wrong job, crowded Benchmarks UX).
- Standalone HTML report only — rejected (weak for override / interactive decision).
- Manual pin of every cell — rejected for v1 (too heavy for ~60 cells).

View File

@@ -0,0 +1,458 @@
#!/usr/bin/env python3
"""Generate SVG charts for the final chunking-strategy decision report.
Reads Experiment aggregates from SQLite (Decision Board rules) and writes
SVG files next to this script. Re-run after new Experiments if needed:
.venv/bin/python docs/assets/decision/generate_charts.py
"""
from __future__ import annotations
import json
import sqlite3
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
DB = ROOT / "data" / "chunking_benchmark.db"
OUT = Path(__file__).resolve().parent
DECISION_DOCS = [
"bazresi.docx",
"customer1.docx",
"fire.docx",
"general-havades-individuals.doc",
"havades.docx",
"life-time-individual.docx",
"moavenin.docx",
"Refah.docx",
"website.docx",
"lifetime-compensation.docx",
]
SHORT = {
"bazresi.docx": "bazresi",
"customer1.docx": "customer1",
"fire.docx": "fire",
"general-havades-individuals.doc": "havades-ind",
"havades.docx": "havades",
"life-time-individual.docx": "lifetime-ind",
"moavenin.docx": "moavenin",
"Refah.docx": "Refah",
"website.docx": "website",
"lifetime-compensation.docx": "lifetime-comp",
}
CORPUS = "text-embedding-3-large"
TEAL_FILL = "#14b8a6"
AMBER_FILL = "#f59e0b"
INK = "#111827"
MUTED = "#6b7280"
GRID = "#e5e7eb"
BG = "#ffffff"
WIN = "#047857"
def esc(s: str) -> str:
return (
str(s)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def composite(m: dict | None) -> float | None:
if not m:
return None
return (
(m.get("avg_context_relevance") or 0) * 0.3
+ (m.get("avg_answer_similarity") or 0) * 0.4
+ (m.get("avg_faithfulness") or 0) * 0.3
) * (1 - (m.get("hallucination_rate") or 0))
def load_cells() -> dict[tuple[str, str], dict]:
conn = sqlite3.connect(DB)
conn.row_factory = sqlite3.Row
docs = {r["id"]: r["filename"] for r in conn.execute("SELECT id, filename FROM documents")}
rows = []
for e in conn.execute(
"""
SELECT id, document_id, strategies_used, aggregate_metrics, benchmark_config,
embedding_model_id, boundary_embedding_model_id, created_at, questions
FROM experiments ORDER BY created_at DESC
"""
):
fn = docs.get(e["document_id"])
strats = json.loads(e["strategies_used"] or "[]")
agg = json.loads(e["aggregate_metrics"] or "{}")
cfg = json.loads(e["benchmark_config"] or "{}")
corpus = e["embedding_model_id"] or cfg.get("corpus_embedding_model_id")
prev = int(cfg.get("neighbor_prev") or 0)
nxt = int(cfg.get("neighbor_next") or 0)
bound = e["boundary_embedding_model_id"] or cfg.get("boundary_embedding_model_id")
if fn not in DECISION_DOCS or corpus != CORPUS or len(strats) != 1:
continue
strat = strats[0]
if strat == "fixed_size":
if prev != nxt or prev not in (0, 1, 2, 3):
continue
cid = f"fixed_size:±{prev}"
family = "fixed_size"
elif strat == "semantic":
if not bound:
continue
cid = f"semantic:{bound}"
family = "semantic"
else:
continue
rows.append(
{
"fn": fn,
"cid": cid,
"family": family,
"agg": agg,
"metrics": agg.get(family) or {},
"id": e["id"],
}
)
conn.close()
cells: dict[tuple[str, str], dict] = {}
for r in rows:
key = (r["fn"], r["cid"])
if key not in cells:
cells[key] = r
return cells
def mean(xs: list[float]) -> float | None:
return sum(xs) / len(xs) if xs else None
def svg_wrap(w: int, h: int, body: str, title: str) -> str:
return f'''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" aria-label="{esc(title)}">
<title>{esc(title)}</title>
<rect width="{w}" height="{h}" fill="{BG}"/>
{body}
</svg>
'''
def chart_stage1(cells: dict) -> None:
cands = [
("fixed_size:±3", "fixed_size ±3", TEAL_FILL, True),
("fixed_size:±2", "fixed_size ±2", TEAL_FILL, False),
("fixed_size:±1", "fixed_size ±1", TEAL_FILL, False),
("semantic:text-embedding-3-large", "semantic @ large", AMBER_FILL, False),
("fixed_size:±0", "fixed_size ±0", "#99f6e4", False),
("semantic:nomic-embed-text-v2-moe", "semantic @ nomic", "#fcd34d", False),
]
values = []
for cid, label, color, winner in cands:
scores = []
for fn in DECISION_DOCS:
r = cells.get((fn, cid))
if r:
sc = composite(r["metrics"])
if sc is not None:
scores.append(sc)
values.append((cid, label, color, winner, mean(scores) or 0))
w, h = 820, 420
left, right, top, bottom = 210, 40, 56, 48
plot_w = w - left - right
plot_h = h - top - bottom
vmin, vmax = 8.4, 9.2
bar_h = plot_h / len(values) * 0.62
gap = plot_h / len(values)
parts = [
f'<text x="24" y="32" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Stage 1 — mean composite (10 documents)</text>',
f'<text x="24" y="50" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">Higher is better. Official ranking used by Decision Board. Winner: fixed_size ±3.</text>',
]
# grid
for tick in [8.5, 8.6, 8.7, 8.8, 8.9, 9.0, 9.1, 9.2]:
x = left + (tick - vmin) / (vmax - vmin) * plot_w
parts.append(f'<line x1="{x:.1f}" y1="{top}" x2="{x:.1f}" y2="{h - bottom}" stroke="{GRID}" stroke-width="1"/>')
parts.append(
f'<text x="{x:.1f}" y="{h - 18}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{MUTED}">{tick:.1f}</text>'
)
for i, (_, label, color, winner, val) in enumerate(values):
y = top + i * gap + (gap - bar_h) / 2
bw = (val - vmin) / (vmax - vmin) * plot_w
stroke = WIN if winner else "none"
sw = 2 if winner else 0
parts.append(
f'<rect x="{left}" y="{y:.1f}" width="{max(bw, 0):.1f}" height="{bar_h:.1f}" rx="4" fill="{color}" stroke="{stroke}" stroke-width="{sw}"/>'
)
weight = "700" if winner else "500"
parts.append(
f'<text x="{left - 10}" y="{y + bar_h * 0.68:.1f}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="{weight}" fill="{INK}">{esc(label)}</text>'
)
parts.append(
f'<text x="{left + bw + 8:.1f}" y="{y + bar_h * 0.68:.1f}" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="{INK}">{val:.3f}</text>'
)
(OUT / "stage1-mean-composite.svg").write_text(svg_wrap(w, h, "\n".join(parts), "Stage 1 mean composite"), encoding="utf-8")
def chart_stage2(cells: dict) -> None:
fs_id = "fixed_size:±3"
sem_id = "semantic:text-embedding-3-large"
pairs = []
for fn in DECISION_DOCS:
a = composite(cells[(fn, fs_id)]["metrics"])
b = composite(cells[(fn, sem_id)]["metrics"])
pairs.append((SHORT[fn], a, b))
w, h = 920, 460
left, right, top, bottom = 52, 24, 64, 88
plot_w = w - left - right
plot_h = h - top - bottom
n = len(pairs)
slot = plot_w / n
bar_w = slot * 0.32
vmin, vmax = 5.8, 10.0
def y_of(v: float) -> float:
return top + (1 - (v - vmin) / (vmax - vmin)) * plot_h
parts = [
f'<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Stage 2 — per document (fixed_size ±3 vs semantic @ large)</text>',
f'<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">Mean composite still favors fixed_size. Semantic wins 6 of 10 docs, but by smaller margins except website/customer1.</text>',
f'<rect x="620" y="14" width="12" height="12" rx="2" fill="{TEAL_FILL}"/>',
f'<text x="638" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">fixed_size ±3</text>',
f'<rect x="760" y="14" width="12" height="12" rx="2" fill="{AMBER_FILL}"/>',
f'<text x="778" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">semantic @ large</text>',
]
for tick in [6, 7, 8, 9, 10]:
y = y_of(tick)
parts.append(f'<line x1="{left}" y1="{y:.1f}" x2="{w - right}" y2="{y:.1f}" stroke="{GRID}" stroke-width="1"/>')
parts.append(
f'<text x="{left - 8}" y="{y + 4:.1f}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{MUTED}">{tick}</text>'
)
for i, (label, a, b) in enumerate(pairs):
cx = left + i * slot + slot / 2
xa = cx - bar_w - 3
xb = cx + 3
ha = plot_h - (y_of(a) - top)
hb = plot_h - (y_of(b) - top)
parts.append(f'<rect x="{xa:.1f}" y="{y_of(a):.1f}" width="{bar_w:.1f}" height="{ha:.1f}" rx="3" fill="{TEAL_FILL}"/>')
parts.append(f'<rect x="{xb:.1f}" y="{y_of(b):.1f}" width="{bar_w:.1f}" height="{hb:.1f}" rx="3" fill="{AMBER_FILL}"/>')
parts.append(
f'<text x="{cx:.1f}" y="{h - 36}" text-anchor="end" transform="rotate(-32 {cx:.1f} {h - 36})" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{INK}">{esc(label)}</text>'
)
(OUT / "stage2-per-document.svg").write_text(svg_wrap(w, h, "\n".join(parts), "Stage 2 per document"), encoding="utf-8")
def chart_delta(cells: dict) -> None:
fs_id = "fixed_size:±3"
sem_id = "semantic:text-embedding-3-large"
deltas = []
for fn in DECISION_DOCS:
a = composite(cells[(fn, fs_id)]["metrics"])
b = composite(cells[(fn, sem_id)]["metrics"])
deltas.append((SHORT[fn], a - b))
deltas.sort(key=lambda x: x[1], reverse=True)
w, h = 820, 440
left, right, top, bottom = 120, 56, 56, 36
plot_w = w - left - right
plot_h = h - top - bottom
n = len(deltas)
gap = plot_h / n
bar_h = gap * 0.62
max_abs = max(abs(d) for _, d in deltas)
# fire is 2.58, others < 1.2 — use 2.8
max_abs = 2.8
zero_x = left + plot_w / 2
parts = [
f'<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Margin: fixed_size ±3 minus semantic @ large</text>',
f'<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">Teal = fixed_size wins the document. Amber = semantic wins. Mean ranking is driven by large teal bars (especially fire).</text>',
f'<line x1="{zero_x:.1f}" y1="{top}" x2="{zero_x:.1f}" y2="{h - bottom}" stroke="{INK}" stroke-width="1.2"/>',
f'<text x="{left}" y="{h - 12}" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{MUTED}">semantic better</text>',
f'<text x="{w - right}" y="{h - 12}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{MUTED}">fixed_size better</text>',
]
for i, (label, d) in enumerate(deltas):
y = top + i * gap + (gap - bar_h) / 2
bw = abs(d) / max_abs * (plot_w / 2)
if d >= 0:
x = zero_x
color = TEAL_FILL
tx = x + bw + 6
anchor = "start"
else:
x = zero_x - bw
color = AMBER_FILL
tx = x - 6
anchor = "end"
parts.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{bw:.1f}" height="{bar_h:.1f}" rx="3" fill="{color}"/>')
parts.append(
f'<text x="{left - 8}" y="{y + bar_h * 0.7:.1f}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">{esc(label)}</text>'
)
parts.append(
f'<text x="{tx:.1f}" y="{y + bar_h * 0.7:.1f}" text-anchor="{anchor}" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="{INK}">{d:+.2f}</text>'
)
(OUT / "stage2-margins.svg").write_text(svg_wrap(w, h, "\n".join(parts), "Stage 2 margins"), encoding="utf-8")
def chart_heatmap(cells: dict) -> None:
cands = [
("fixed_size:±0", "±0"),
("fixed_size:±1", "±1"),
("fixed_size:±2", "±2"),
("fixed_size:±3", "±3"),
("semantic:text-embedding-3-large", "sem@large"),
("semantic:nomic-embed-text-v2-moe", "sem@nomic"),
]
scores: list[list[float | None]] = []
for fn in DECISION_DOCS:
row = []
for cid, _ in cands:
r = cells.get((fn, cid))
row.append(composite(r["metrics"]) if r else None)
scores.append(row)
cell_w, cell_h = 78, 32
left, top = 128, 72
w = left + cell_w * len(cands) + 24
h = top + cell_h * len(DECISION_DOCS) + 36
def color_for(s: float | None) -> tuple[str, str]:
if s is None:
return "#f3f4f6", MUTED
# 6.2 .. 9.8
t = max(0.0, min(1.0, (s - 7.0) / (9.8 - 7.0)))
# pale rose -> amber -> teal
if t < 0.5:
u = t * 2
r = int(251 + (20 - 251) * 0)
# interpolate rose 251,113,133 -> amber 245,158,11
rr = int(251 + (245 - 251) * u)
gg = int(113 + (158 - 113) * u)
bb = int(133 + (11 - 133) * u)
else:
u = (t - 0.5) * 2
rr = int(245 + (13 - 245) * u)
gg = int(158 + (148 - 158) * u)
bb = int(11 + (136 - 11) * u)
bg = f"#{rr:02x}{gg:02x}{bb:02x}"
fg = "#111827" if t < 0.72 else "#f9fafb"
return bg, fg
parts = [
f'<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Candidate heatmap — composite by document</text>',
f'<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">Darker teal = stronger. Semantic @ large collapses on fire; ±1–±3 stay high across the set.</text>',
]
for j, (_, lab) in enumerate(cands):
x = left + j * cell_w + cell_w / 2
parts.append(
f'<text x="{x:.1f}" y="{top - 10}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="{INK}">{esc(lab)}</text>'
)
for i, fn in enumerate(DECISION_DOCS):
y = top + i * cell_h
parts.append(
f'<text x="{left - 8}" y="{y + cell_h * 0.65:.1f}" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="{INK}">{esc(SHORT[fn])}</text>'
)
for j, val in enumerate(scores[i]):
x = left + j * cell_w
bg, fg = color_for(val)
parts.append(f'<rect x="{x}" y="{y}" width="{cell_w - 3}" height="{cell_h - 3}" rx="4" fill="{bg}"/>')
txt = "—" if val is None else f"{val:.2f}"
parts.append(
f'<text x="{x + (cell_w - 3) / 2:.1f}" y="{y + cell_h * 0.62:.1f}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="{fg}">{txt}</text>'
)
(OUT / "heatmap-candidates.svg").write_text(svg_wrap(w, h, "\n".join(parts), "Candidate heatmap"), encoding="utf-8")
def chart_metrics(cells: dict) -> None:
"""Grouped bars: four judge metrics for the two stage-2 winners."""
def bag(cid: str, family: str) -> dict[str, float]:
keys = [
"avg_context_relevance",
"avg_answer_similarity",
"avg_faithfulness",
"hallucination_rate",
]
acc = {k: [] for k in keys}
for fn in DECISION_DOCS:
m = cells[(fn, cid)]["metrics"]
for k in keys:
acc[k].append(m[k])
return {k: sum(v) / len(v) for k, v in acc.items()}
fs = bag("fixed_size:±3", "fixed_size")
sem = bag("semantic:text-embedding-3-large", "semantic")
labels = [
("Context relevance", fs["avg_context_relevance"], sem["avg_context_relevance"], False),
("Answer similarity", fs["avg_answer_similarity"], sem["avg_answer_similarity"], False),
("Faithfulness", fs["avg_faithfulness"], sem["avg_faithfulness"], False),
("Hallucination % (lower better)", fs["hallucination_rate"] * 100, sem["hallucination_rate"] * 100, True),
]
w, h = 820, 380
left, right, top, bottom = 52, 24, 64, 48
plot_w = w - left - right
plot_h = h - top - bottom
n = len(labels)
slot = plot_w / n
bar_w = slot * 0.28
parts = [
f'<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="{INK}">Stage 2 winners — mean judge metrics</text>',
f'<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{MUTED}">fixed_size ±3 leads on all four metrics, including lower hallucination.</text>',
f'<rect x="620" y="14" width="12" height="12" rx="2" fill="{TEAL_FILL}"/>',
f'<text x="638" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">fixed_size ±3</text>',
f'<rect x="760" y="14" width="12" height="12" rx="2" fill="{AMBER_FILL}"/>',
f'<text x="778" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">semantic @ large</text>',
]
for i, (lab, a, b, is_pct) in enumerate(labels):
vmax = 10 if not is_pct else max(a, b) * 1.35
cx = left + i * slot + slot / 2
xa = cx - bar_w - 3
xb = cx + 3
def bar(x: float, val: float, color: str) -> str:
bh = val / vmax * plot_h
y = top + plot_h - bh
return f'<rect x="{x:.1f}" y="{y:.1f}" width="{bar_w:.1f}" height="{bh:.1f}" rx="3" fill="{color}"/>'
parts.append(bar(xa, a, TEAL_FILL))
parts.append(bar(xb, b, AMBER_FILL))
fmt = (lambda v: f"{v:.1f}%") if is_pct else (lambda v: f"{v:.2f}")
ya = top + plot_h - a / vmax * plot_h - 6
yb = top + plot_h - b / vmax * plot_h - 6
parts.append(
f'<text x="{xa + bar_w / 2:.1f}" y="{ya:.1f}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="{INK}">{fmt(a)}</text>'
)
parts.append(
f'<text x="{xb + bar_w / 2:.1f}" y="{yb:.1f}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="{INK}">{fmt(b)}</text>'
)
parts.append(
f'<text x="{cx:.1f}" y="{h - 16}" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="{INK}">{esc(lab)}</text>'
)
(OUT / "stage2-metrics.svg").write_text(svg_wrap(w, h, "\n".join(parts), "Stage 2 mean metrics"), encoding="utf-8")
def main() -> None:
cells = load_cells()
chart_stage1(cells)
chart_stage2(cells)
chart_delta(cells)
chart_heatmap(cells)
chart_metrics(cells)
print(f"Wrote SVGs in {OUT}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="620" height="428" viewBox="0 0 620 428" role="img" aria-label="Candidate heatmap">
<title>Candidate heatmap</title>
<rect width="620" height="428" fill="#ffffff"/>
<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Candidate heatmap — composite by document</text>
<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">Darker teal = stronger. Semantic @ large collapses on fire; ±1–±3 stay high across the set.</text>
<text x="167.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">±0</text>
<text x="245.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">±1</text>
<text x="323.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">±2</text>
<text x="401.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">±3</text>
<text x="479.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">sem@large</text>
<text x="557.0" y="62" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">sem@nomic</text>
<text x="120" y="92.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">bazresi</text>
<rect x="128" y="72" width="75" height="29" rx="4" fill="#e19d15"/>
<text x="165.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.52</text>
<rect x="206" y="72" width="75" height="29" rx="4" fill="#58975f"/>
<text x="243.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.34</text>
<rect x="284" y="72" width="75" height="29" rx="4" fill="#58975f"/>
<text x="321.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.34</text>
<rect x="362" y="72" width="75" height="29" rx="4" fill="#549761"/>
<text x="399.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.37</text>
<rect x="440" y="72" width="75" height="29" rx="4" fill="#4e9664"/>
<text x="477.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.40</text>
<rect x="518" y="72" width="75" height="29" rx="4" fill="#7c984b"/>
<text x="555.5" y="91.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.12</text>
<text x="120" y="124.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">customer1</text>
<rect x="128" y="104" width="75" height="29" rx="4" fill="#9f9a39"/>
<text x="165.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.92</text>
<rect x="206" y="104" width="75" height="29" rx="4" fill="#96993e"/>
<text x="243.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.97</text>
<rect x="284" y="104" width="75" height="29" rx="4" fill="#97993d"/>
<text x="321.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.96</text>
<rect x="362" y="104" width="75" height="29" rx="4" fill="#ec9d0f"/>
<text x="399.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.45</text>
<rect x="440" y="104" width="75" height="29" rx="4" fill="#96993e"/>
<text x="477.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.97</text>
<rect x="518" y="104" width="75" height="29" rx="4" fill="#f88255"/>
<text x="555.5" y="123.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.54</text>
<text x="120" y="156.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">fire</text>
<rect x="128" y="136" width="75" height="29" rx="4" fill="#f69426"/>
<text x="165.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.09</text>
<rect x="206" y="136" width="75" height="29" rx="4" fill="#f59c10"/>
<text x="243.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.34</text>
<rect x="284" y="136" width="75" height="29" rx="4" fill="#f68f33"/>
<text x="321.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.93</text>
<rect x="362" y="136" width="75" height="29" rx="4" fill="#b19b2f"/>
<text x="399.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.81</text>
<rect x="440" y="136" width="75" height="29" rx="4" fill="#fb7185"/>
<text x="477.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">6.23</text>
<rect x="518" y="136" width="75" height="29" rx="4" fill="#fa7674"/>
<text x="555.5" y="155.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.19</text>
<text x="120" y="188.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">havades-ind</text>
<rect x="128" y="168" width="75" height="29" rx="4" fill="#f78b3c"/>
<text x="165.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.83</text>
<rect x="206" y="168" width="75" height="29" rx="4" fill="#c99c22"/>
<text x="243.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.66</text>
<rect x="284" y="168" width="75" height="29" rx="4" fill="#7e984a"/>
<text x="321.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.12</text>
<rect x="362" y="168" width="75" height="29" rx="4" fill="#6f9852"/>
<text x="399.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.21</text>
<rect x="440" y="168" width="75" height="29" rx="4" fill="#d99c19"/>
<text x="477.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.56</text>
<rect x="518" y="168" width="75" height="29" rx="4" fill="#e09d16"/>
<text x="555.5" y="187.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.53</text>
<text x="120" y="220.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">havades</text>
<rect x="128" y="200" width="75" height="29" rx="4" fill="#f59b12"/>
<text x="165.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.31</text>
<rect x="206" y="200" width="75" height="29" rx="4" fill="#ef9d0d"/>
<text x="243.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.43</text>
<rect x="284" y="200" width="75" height="29" rx="4" fill="#f49d0b"/>
<text x="321.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.40</text>
<rect x="362" y="200" width="75" height="29" rx="4" fill="#e79d12"/>
<text x="399.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.48</text>
<rect x="440" y="200" width="75" height="29" rx="4" fill="#f69030"/>
<text x="477.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.97</text>
<rect x="518" y="200" width="75" height="29" rx="4" fill="#f29d0c"/>
<text x="555.5" y="219.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.42</text>
<text x="120" y="252.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">lifetime-ind</text>
<rect x="128" y="232" width="75" height="29" rx="4" fill="#6d9854"/>
<text x="165.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.22</text>
<rect x="206" y="232" width="75" height="29" rx="4" fill="#4b9666"/>
<text x="243.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.42</text>
<rect x="284" y="232" width="75" height="29" rx="4" fill="#489667"/>
<text x="321.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.44</text>
<rect x="362" y="232" width="75" height="29" rx="4" fill="#479668"/>
<text x="399.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.45</text>
<rect x="440" y="232" width="75" height="29" rx="4" fill="#3d966d"/>
<text x="477.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.50</text>
<rect x="518" y="232" width="75" height="29" rx="4" fill="#4e9664"/>
<text x="555.5" y="251.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.41</text>
<text x="120" y="284.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">moavenin</text>
<rect x="128" y="264" width="75" height="29" rx="4" fill="#879945"/>
<text x="165.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.06</text>
<rect x="206" y="264" width="75" height="29" rx="4" fill="#179482"/>
<text x="243.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.73</text>
<rect x="284" y="264" width="75" height="29" rx="4" fill="#179482"/>
<text x="321.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.73</text>
<rect x="362" y="264" width="75" height="29" rx="4" fill="#179482"/>
<text x="399.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.73</text>
<rect x="440" y="264" width="75" height="29" rx="4" fill="#d09c1e"/>
<text x="477.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.62</text>
<rect x="518" y="264" width="75" height="29" rx="4" fill="#f59a14"/>
<text x="555.5" y="283.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.29</text>
<text x="120" y="316.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">Refah</text>
<rect x="128" y="296" width="75" height="29" rx="4" fill="#459669"/>
<text x="165.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.46</text>
<rect x="206" y="296" width="75" height="29" rx="4" fill="#1c947f"/>
<text x="243.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.71</text>
<rect x="284" y="296" width="75" height="29" rx="4" fill="#1e947e"/>
<text x="321.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.70</text>
<rect x="362" y="296" width="75" height="29" rx="4" fill="#20947d"/>
<text x="399.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.68</text>
<rect x="440" y="296" width="75" height="29" rx="4" fill="#0d9488"/>
<text x="477.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.81</text>
<rect x="518" y="296" width="75" height="29" rx="4" fill="#349572"/>
<text x="555.5" y="315.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.56</text>
<text x="120" y="348.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">website</text>
<rect x="128" y="328" width="75" height="29" rx="4" fill="#f8864b"/>
<text x="165.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.67</text>
<rect x="206" y="328" width="75" height="29" rx="4" fill="#f78e35"/>
<text x="243.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">7.91</text>
<rect x="284" y="328" width="75" height="29" rx="4" fill="#e29d15"/>
<text x="321.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.51</text>
<rect x="362" y="328" width="75" height="29" rx="4" fill="#ea9d10"/>
<text x="399.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.47</text>
<rect x="440" y="328" width="75" height="29" rx="4" fill="#879945"/>
<text x="477.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.06</text>
<rect x="518" y="328" width="75" height="29" rx="4" fill="#e59d13"/>
<text x="555.5" y="347.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.49</text>
<text x="120" y="380.8" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">lifetime-comp</text>
<rect x="128" y="360" width="75" height="29" rx="4" fill="#93993f"/>
<text x="165.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.99</text>
<rect x="206" y="360" width="75" height="29" rx="4" fill="#bd9b28"/>
<text x="243.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.73</text>
<rect x="284" y="360" width="75" height="29" rx="4" fill="#bd9b28"/>
<text x="321.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.73</text>
<rect x="362" y="360" width="75" height="29" rx="4" fill="#b99b2b"/>
<text x="399.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.76</text>
<rect x="440" y="360" width="75" height="29" rx="4" fill="#7c984c"/>
<text x="477.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#f9fafb">9.13</text>
<rect x="518" y="360" width="75" height="29" rx="4" fill="#98993d"/>
<text x="555.5" y="379.8" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.96</text>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="820" height="420" viewBox="0 0 820 420" role="img" aria-label="Stage 1 mean composite">
<title>Stage 1 mean composite</title>
<rect width="820" height="420" fill="#ffffff"/>
<text x="24" y="32" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Stage 1 — mean composite (10 documents)</text>
<text x="24" y="50" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">Higher is better. Official ranking used by Decision Board. Winner: fixed_size ±3.</text>
<line x1="281.2" y1="56" x2="281.2" y2="372" stroke="#e5e7eb" stroke-width="1"/>
<text x="281.2" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8.5</text>
<line x1="352.5" y1="56" x2="352.5" y2="372" stroke="#e5e7eb" stroke-width="1"/>
<text x="352.5" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8.6</text>
<line x1="423.7" y1="56" x2="423.7" y2="372" stroke="#e5e7eb" stroke-width="1"/>
<text x="423.7" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8.7</text>
<line x1="495.0" y1="56" x2="495.0" y2="372" stroke="#e5e7eb" stroke-width="1"/>
<text x="495.0" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8.8</text>
<line x1="566.3" y1="56" x2="566.3" y2="372" stroke="#e5e7eb" stroke-width="1"/>
<text x="566.3" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8.9</text>
<line x1="637.5" y1="56" x2="637.5" y2="372" stroke="#e5e7eb" stroke-width="1"/>
<text x="637.5" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">9.0</text>
<line x1="708.8" y1="56" x2="708.8" y2="372" stroke="#e5e7eb" stroke-width="1"/>
<text x="708.8" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">9.1</text>
<line x1="780.0" y1="56" x2="780.0" y2="372" stroke="#e5e7eb" stroke-width="1"/>
<text x="780.0" y="402" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">9.2</text>
<rect x="210" y="66.0" width="456.2" height="32.7" rx="4" fill="#14b8a6" stroke="#047857" stroke-width="2"/>
<text x="200" y="88.2" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="700" fill="#111827">fixed_size ±3</text>
<text x="674.2" y="88.2" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">9.040</text>
<rect x="210" y="118.7" width="418.4" height="32.7" rx="4" fill="#14b8a6" stroke="none" stroke-width="0"/>
<text x="200" y="140.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="500" fill="#111827">fixed_size ±2</text>
<text x="636.4" y="140.9" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">8.987</text>
<rect x="210" y="171.3" width="374.8" height="32.7" rx="4" fill="#14b8a6" stroke="none" stroke-width="0"/>
<text x="200" y="193.5" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="500" fill="#111827">fixed_size ±1</text>
<text x="592.8" y="193.5" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">8.926</text>
<rect x="210" y="224.0" width="232.8" height="32.7" rx="4" fill="#f59e0b" stroke="none" stroke-width="0"/>
<text x="200" y="246.2" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="500" fill="#111827">semantic @ large</text>
<text x="450.8" y="246.2" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">8.727</text>
<rect x="210" y="276.7" width="146.7" height="32.7" rx="4" fill="#99f6e4" stroke="none" stroke-width="0"/>
<text x="200" y="298.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="500" fill="#111827">fixed_size ±0</text>
<text x="364.7" y="298.9" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">8.606</text>
<rect x="210" y="329.3" width="107.4" height="32.7" rx="4" fill="#fcd34d" stroke="none" stroke-width="0"/>
<text x="200" y="351.5" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="500" fill="#111827">semantic @ nomic</text>
<text x="325.4" y="351.5" font-family="Inter, system-ui, sans-serif" font-size="13" font-weight="600" fill="#111827">8.551</text>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="820" height="440" viewBox="0 0 820 440" role="img" aria-label="Stage 2 margins">
<title>Stage 2 margins</title>
<rect width="820" height="440" fill="#ffffff"/>
<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Margin: fixed_size ±3 minus semantic @ large</text>
<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">Teal = fixed_size wins the document. Amber = semantic wins. Mean ranking is driven by large teal bars (especially fire).</text>
<line x1="442.0" y1="56" x2="442.0" y2="404" stroke="#111827" stroke-width="1.2"/>
<text x="120" y="428" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">semantic better</text>
<text x="764" y="428" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">fixed_size better</text>
<rect x="442.0" y="62.6" width="296.9" height="21.6" rx="3" fill="#14b8a6"/>
<text x="112" y="77.7" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">fire</text>
<text x="744.9" y="77.7" text-anchor="start" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">+2.58</text>
<rect x="442.0" y="97.4" width="128.1" height="21.6" rx="3" fill="#14b8a6"/>
<text x="112" y="112.5" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">moavenin</text>
<text x="576.1" y="112.5" text-anchor="start" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">+1.11</text>
<rect x="442.0" y="132.2" width="73.7" height="21.6" rx="3" fill="#14b8a6"/>
<text x="112" y="147.3" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">havades-ind</text>
<text x="521.7" y="147.3" text-anchor="start" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">+0.64</text>
<rect x="442.0" y="167.0" width="59.1" height="21.6" rx="3" fill="#14b8a6"/>
<text x="112" y="182.1" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">havades</text>
<text x="507.1" y="182.1" text-anchor="start" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">+0.51</text>
<rect x="437.5" y="201.8" width="4.5" height="21.6" rx="3" fill="#f59e0b"/>
<text x="112" y="216.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">bazresi</text>
<text x="431.5" y="216.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.04</text>
<rect x="435.1" y="236.6" width="6.9" height="21.6" rx="3" fill="#f59e0b"/>
<text x="112" y="251.7" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">lifetime-ind</text>
<text x="429.1" y="251.7" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.06</text>
<rect x="426.5" y="271.4" width="15.5" height="21.6" rx="3" fill="#f59e0b"/>
<text x="112" y="286.5" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Refah</text>
<text x="420.5" y="286.5" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.13</text>
<rect x="399.7" y="306.2" width="42.3" height="21.6" rx="3" fill="#f59e0b"/>
<text x="112" y="321.3" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">lifetime-comp</text>
<text x="393.7" y="321.3" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.37</text>
<rect x="382.0" y="341.0" width="60.0" height="21.6" rx="3" fill="#f59e0b"/>
<text x="112" y="356.1" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">customer1</text>
<text x="376.0" y="356.1" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.52</text>
<rect x="373.9" y="375.8" width="68.1" height="21.6" rx="3" fill="#f59e0b"/>
<text x="112" y="390.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">website</text>
<text x="367.9" y="390.9" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="#111827">-0.59</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="820" height="380" viewBox="0 0 820 380" role="img" aria-label="Stage 2 mean metrics">
<title>Stage 2 mean metrics</title>
<rect width="820" height="380" fill="#ffffff"/>
<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Stage 2 winners — mean judge metrics</text>
<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">fixed_size ±3 leads on all four metrics, including lower hallucination.</text>
<rect x="620" y="14" width="12" height="12" rx="2" fill="#14b8a6"/>
<text x="638" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">fixed_size ±3</text>
<rect x="760" y="14" width="12" height="12" rx="2" fill="#f59e0b"/>
<text x="778" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">semantic @ large</text>
<rect x="89.9" y="80.0" width="52.1" height="252.0" rx="3" fill="#14b8a6"/>
<rect x="148.0" y="80.7" width="52.1" height="251.3" rx="3" fill="#f59e0b"/>
<text x="116.0" y="74.0" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">9.40</text>
<text x="174.0" y="74.7" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">9.38</text>
<text x="145.0" y="364" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Context relevance</text>
<rect x="275.9" y="92.1" width="52.1" height="239.9" rx="3" fill="#14b8a6"/>
<rect x="334.0" y="95.3" width="52.1" height="236.7" rx="3" fill="#f59e0b"/>
<text x="302.0" y="86.1" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.95</text>
<text x="360.0" y="89.3" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">8.83</text>
<text x="331.0" y="364" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Answer similarity</text>
<rect x="461.9" y="78.2" width="52.1" height="253.8" rx="3" fill="#14b8a6"/>
<rect x="520.0" y="82.7" width="52.1" height="249.3" rx="3" fill="#f59e0b"/>
<text x="488.0" y="72.2" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">9.47</text>
<text x="546.0" y="76.7" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">9.30</text>
<text x="517.0" y="364" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Faithfulness</text>
<rect x="647.9" y="241.0" width="52.1" height="91.0" rx="3" fill="#14b8a6"/>
<rect x="706.0" y="133.5" width="52.1" height="198.5" rx="3" fill="#f59e0b"/>
<text x="674.0" y="235.0" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">2.2%</text>
<text x="732.0" y="127.5" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" fill="#111827">4.8%</text>
<text x="703.0" y="364" text-anchor="middle" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">Hallucination % (lower better)</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="460" viewBox="0 0 920 460" role="img" aria-label="Stage 2 per document">
<title>Stage 2 per document</title>
<rect width="920" height="460" fill="#ffffff"/>
<text x="24" y="28" font-family="Inter, system-ui, sans-serif" font-size="16" font-weight="700" fill="#111827">Stage 2 — per document (fixed_size ±3 vs semantic @ large)</text>
<text x="24" y="48" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#6b7280">Mean composite still favors fixed_size. Semantic wins 6 of 10 docs, but by smaller margins except website/customer1.</text>
<rect x="620" y="14" width="12" height="12" rx="2" fill="#14b8a6"/>
<text x="638" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">fixed_size ±3</text>
<rect x="760" y="14" width="12" height="12" rx="2" fill="#f59e0b"/>
<text x="778" y="25" font-family="Inter, system-ui, sans-serif" font-size="12" fill="#111827">semantic @ large</text>
<line x1="52" y1="357.3" x2="896" y2="357.3" stroke="#e5e7eb" stroke-width="1"/>
<text x="44" y="361.3" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">6</text>
<line x1="52" y1="284.0" x2="896" y2="284.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="44" y="288.0" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">7</text>
<line x1="52" y1="210.7" x2="896" y2="210.7" stroke="#e5e7eb" stroke-width="1"/>
<text x="44" y="214.7" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">8</text>
<line x1="52" y1="137.3" x2="896" y2="137.3" stroke="#e5e7eb" stroke-width="1"/>
<text x="44" y="141.3" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">9</text>
<line x1="52" y1="64.0" x2="896" y2="64.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="44" y="68.0" text-anchor="end" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#6b7280">10</text>
<rect x="64.2" y="110.5" width="27.0" height="261.5" rx="3" fill="#14b8a6"/>
<rect x="97.2" y="107.6" width="27.0" height="264.4" rx="3" fill="#f59e0b"/>
<text x="94.2" y="424" text-anchor="end" transform="rotate(-32 94.2 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">bazresi</text>
<rect x="148.6" y="177.6" width="27.0" height="194.4" rx="3" fill="#14b8a6"/>
<rect x="181.6" y="139.3" width="27.0" height="232.7" rx="3" fill="#f59e0b"/>
<text x="178.6" y="424" text-anchor="end" transform="rotate(-32 178.6 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">customer1</text>
<rect x="233.0" y="151.3" width="27.0" height="220.7" rx="3" fill="#14b8a6"/>
<rect x="266.0" y="340.6" width="27.0" height="31.4" rx="3" fill="#f59e0b"/>
<text x="263.0" y="424" text-anchor="end" transform="rotate(-32 263.0 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">fire</text>
<rect x="317.4" y="122.3" width="27.0" height="249.7" rx="3" fill="#14b8a6"/>
<rect x="350.4" y="169.3" width="27.0" height="202.7" rx="3" fill="#f59e0b"/>
<text x="347.4" y="424" text-anchor="end" transform="rotate(-32 347.4 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">havades-ind</text>
<rect x="401.8" y="175.2" width="27.0" height="196.8" rx="3" fill="#14b8a6"/>
<rect x="434.8" y="212.9" width="27.0" height="159.1" rx="3" fill="#f59e0b"/>
<text x="431.8" y="424" text-anchor="end" transform="rotate(-32 431.8 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">havades</text>
<rect x="486.2" y="104.7" width="27.0" height="267.3" rx="3" fill="#14b8a6"/>
<rect x="519.2" y="100.3" width="27.0" height="271.7" rx="3" fill="#f59e0b"/>
<text x="516.2" y="424" text-anchor="end" transform="rotate(-32 516.2 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">lifetime-ind</text>
<rect x="570.6" y="83.4" width="27.0" height="288.6" rx="3" fill="#14b8a6"/>
<rect x="603.6" y="165.1" width="27.0" height="206.9" rx="3" fill="#f59e0b"/>
<text x="600.6" y="424" text-anchor="end" transform="rotate(-32 600.6 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">moavenin</text>
<rect x="655.0" y="87.5" width="27.0" height="284.5" rx="3" fill="#14b8a6"/>
<rect x="688.0" y="77.6" width="27.0" height="294.4" rx="3" fill="#f59e0b"/>
<text x="685.0" y="424" text-anchor="end" transform="rotate(-32 685.0 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">Refah</text>
<rect x="739.4" y="176.5" width="27.0" height="195.5" rx="3" fill="#14b8a6"/>
<rect x="772.4" y="133.1" width="27.0" height="238.9" rx="3" fill="#f59e0b"/>
<text x="769.4" y="424" text-anchor="end" transform="rotate(-32 769.4 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">website</text>
<rect x="823.8" y="154.9" width="27.0" height="217.1" rx="3" fill="#14b8a6"/>
<rect x="856.8" y="127.9" width="27.0" height="244.1" rx="3" fill="#f59e0b"/>
<text x="853.8" y="424" text-anchor="end" transform="rotate(-32 853.8 424)" font-family="Inter, system-ui, sans-serif" font-size="11" fill="#111827">lifetime-comp</text>
</svg>

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@@ -0,0 +1,149 @@
# Chunking Strategy — Final Decision
**Date:** 17 August 2026
**Decision:** Adopt **`fixed_size`** as the chunking Strategy family.
**Not decided here:** Neighbor Expansion level **±N** (follow-up).
**Corpus Embedding Model:** `text-embedding-3-large`
**Evaluation set:** 10 Word documents (Decision Board / neighbor-sweep universe)
**Questions:** 201 per Candidate (nomic semantic: 195)
**One-line close:** **`fixed_size` wins** on official mean composite (**9.040** vs **8.727**). Pick **±N later**, but **not ±0**.
Charts below are generated from `data/chunking_benchmark.db` with the same composite as the Decision Board:
```text
(0.3 × context relevance + 0.4 × answer similarity + 0.3 × faithfulness)
× (1 − hallucination rate)
```
Regenerate figures after new Experiments:
```bash
.venv/bin/python docs/assets/decision/generate_charts.py
```
---
## 1. Why `fixed_size` is the winner
Decision Board ranks by **mean composite**, not by “how many documents won.” Stage 1 auto-picks **`fixed_size ±3`** vs **`semantic @ text-embedding-3-large`**. Stage 2 mean composite is **+0.313** for `fixed_size`.
![Stage 1 mean composite by Strategy Candidate](assets/decision/stage1-mean-composite.svg)
| Candidate | Mean composite | Context | Similarity | Faithfulness | Hallucination | Docs |
|-----------|----------------|---------|------------|--------------|---------------|------|
| **fixed_size ±3** | **9.040** | 9.40 | 8.95 | 9.47 | 2.2% | 10/10 |
| fixed_size ±2 | 8.987 | 9.43 | 8.93 | 9.44 | 2.7% | 10/10 |
| fixed_size ±1 | 8.926 | 9.38 | 8.89 | 9.42 | 3.0% | 10/10 |
| **semantic @ text-embedding-3-large** | **8.727** | 9.38 | 8.83 | 9.30 | 4.8% | 10/10 |
| fixed_size ±0 | 8.606 | 9.18 | 8.79 | 9.25 | 4.9% | 10/10 |
| semantic @ nomic-embed-text-v2-moe | 8.551 | 9.19 | 8.65 | 9.14 | 4.7% | 10/10 |
`fixed_size ±1`, `±2`, and `±3` all beat the best semantic Candidate on **mean**. **`±0` (no Neighbor Expansion) does not** (8.606 vs 8.727). Shipping `fixed_size` without expansion would weaken this family decision.
---
## 2. Judge metrics (stage 2 winners)
`fixed_size ±3` leads on every judge metric, including **lower hallucination**.
![Mean judge metrics for stage 2 winners](assets/decision/stage2-metrics.svg)
---
## 3. Per-document showdown
Mean ranking still favors `fixed_size`. **Head-to-head at ±3: semantic 6 / `fixed_size` 4.** Semantic’s six wins are mostly **small**; `fixed_size`’s four wins are **larger**, especially **fire**.
![Per-document composite: fixed_size ±3 vs semantic @ large](assets/decision/stage2-per-document.svg)
![Signed margin per document (fixed_size − semantic)](assets/decision/stage2-margins.svg)
| Document | Questions | fixed_size ±3 | semantic @ large | Winner |
|----------|-----------|---------------|------------------|--------|
| fire.docx | 20 | **8.810** | 6.228 | **fixed_size** (+2.58) |
| moavenin.docx | 20 | **9.735** | 8.621 | **fixed_size** (+1.11) |
| general-havades-individuals.doc | 20 | **9.205** | 8.564 | **fixed_size** (+0.64) |
| havades.docx | 20 | **8.483** | 7.969 | **fixed_size** (+0.51) |
| bazresi.docx | 16 | 9.366 | **9.405** | semantic (−0.04) |
| life-time-individual.docx | 20 | 9.445 | **9.505** | semantic (−0.06) |
| Refah.docx | 20 | 9.680 | **9.815** | semantic (−0.14) |
| lifetime-compensation.docx | 15 | 8.761 | **9.129** | semantic (−0.37) |
| customer1.docx | 20 | 8.451 | **8.973** | semantic (−0.52) |
| website.docx | 30 | 8.466 | **9.058** | semantic (−0.59) |
Win-count would pick semantic. **Official product ranking (mean composite) picks `fixed_size`.** This memo follows the Decision Board rule (ADR-0026).
---
## 4. Full Candidate heatmap
Every cell is a newest single-strategy Experiment under `text-embedding-3-large`. Semantic @ large **collapses on fire**; `fixed_size ±1…±3` stay high across the set.
![Heatmap of composite scores by document and Candidate](assets/decision/heatmap-candidates.svg)
Best `fixed_size` ±N **by document** (does not change the family call):
| ±N | Documents where it is the best fixed_size cell |
|----|------------------------------------------------|
| ±3 | bazresi, fire, general-havades-individuals, havades, life-time-individual (5) |
| ±1 | customer1, moavenin, Refah (3) |
| ±2 | website (1) |
| ±0 | lifetime-compensation (1) |
---
## 5. Method
- Corpus Embedding Model locked to **`text-embedding-3-large`**
- Newest **single-strategy** Experiment per document × Candidate (Decision Board cells)
- Retrieval `top_k = 5`
- Stage 1: best `fixed_size` ±N vs best `semantic@Boundary`
- Stage 2: mean composite + per-document breakdown
- Experiments dated **9–10 August 2026**
- `logs/neighbor_sweep.log` recorded mid-run connection errors on some units; **SQLite now has a full 10×4 `fixed_size` grid** — treat the database as source of truth
---
## 6. Excluded strategies
No comparable **10-doc, single-strategy, `text-embedding-3-large`** Experiments exist for the three Strategies below. They are not Decision Board Candidates (ADR-0026).
| Strategy | Why it is not the winner |
|----------|--------------------------|
| **recursive** | Not on the Decision Board grid. Informal PDF / `text-embedding-3-small` multi-strategy runs (not comparable to this close-out) were mixed; recursive **does not get Neighbor Expansion** (expansion is `fixed_size` only). |
| **contextual_retrieval** | Extra LLM call per chunk at process time. Same informal PDF/`small` runs scored **below** recursive and `fixed_size`. |
| **semantic_parent_child** | Boundary-detection cost plus parent fetch at query. Informal PDF/`small` composites were **much worse** (~1.8–2.8). Fail-hard if Boundary embeds mismatch (ADR-0020). |
One leftover five-strategy Experiment on `customer1` under large is **invalid** (four Strategies scored 0 / errors) and was ignored.
---
## 7. Binding decision
**Use `fixed_size` for chunking** under **`text-embedding-3-large`.**
**Do not use `semantic` as the default Strategy** on this evaluation universe.
**Follow-up:** choose Neighbor Expansion **±N** among **±1, ±2, ±3**. Stage 1 auto-pick is **±3**. **±0 is not recommended** if this family decision is to remain valid.
Production config change is **out of scope** for this record.
---
## 8. Evidence (stage 2 cells)
| Document | `fixed_size ±3` experiment id | `semantic @ large` experiment id |
|----------|-------------------------------|----------------------------------|
| bazresi.docx | `6fe08750ee324130b3d46d8b3bc95280` | `6263c4f9e63347cea9eb19f4d36309c0` |
| customer1.docx | `8c4d888d367b4c0299b2d19dd9654f80` | `0c4572e80902408796f0dd11776e8e55` |
| fire.docx | `230a3cf57ae74b679ecde83cfeb9b6f5` | `414162efc314406db082ba8537c2423f` |
| general-havades-individuals.doc | `7ad892cfcd6143109f77a7808c8dfbd9` | `14fa4f872373446c8b042fc8e84e6983` |
| havades.docx | `04bd45d836a64c3da2e68bb747c0c9b8` | `232238b4d3304243b82b02326ef64617` |
| life-time-individual.docx | `d682706f54824469b235c099bf5a3d3c` | `bc7d9f1fbe3540c097a5f8cc1a1c44bb` |
| moavenin.docx | `63f81cf0a632418aab2f93884518ed71` | `4e01960b5db14e81975a4fbc86c4e10c` |
| Refah.docx | `6304dba39f3f4c05935195c588a36a64` | `b5c630c45615451a93d97d80dee361df` |
| website.docx | `fb9d47750ca74761a3c82c57aa51030b` | `53256897cfb74dd7a6508e750ff1ba63` |
| lifetime-compensation.docx | `99736ffbc6da455b8b3ca3a0fb8e5ef7` | `e51c2b57e7364dfa97e5429f615d6e4e` |
HTML reports: `GET /benchmarks/{id}/report`. Dashboard: Decision Tab, Corpus = `text-embedding-3-large`.

View File

@@ -399,6 +399,11 @@ def get_experiment(experiment_id: str) -> dict[str, Any] | None:
return db.get_experiment(experiment_id) return db.get_experiment(experiment_id)
def list_experiments(document_id: str | None = None) -> dict[str, Any]: def list_experiments(
document_id: str | None = None,
*,
offset: int = 0,
limit: int = 200,
) -> dict[str, Any]:
"""List experiments, optionally filtered by document.""" """List experiments, optionally filtered by document."""
return db.list_experiments(document_id=document_id) return db.list_experiments(document_id=document_id, offset=offset, limit=limit)

View File

@@ -8,7 +8,7 @@ Endpoints:
GET /experiments List all experiments GET /experiments List all experiments
""" """
from fastapi import APIRouter from fastapi import APIRouter, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from src.core.exceptions import BenchmarkError, QueryError from src.core.exceptions import BenchmarkError, QueryError
@@ -185,9 +185,15 @@ async def get_benchmark(experiment_id: str):
@router.get("/experiments") @router.get("/experiments")
async def list_experiments(document_id: str | None = None): async def list_experiments(
document_id: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(200, ge=1, le=500),
):
"""List all experiments, optionally filtered by document.""" """List all experiments, optionally filtered by document."""
result = benchmark_service.list_experiments(document_id=document_id) result = benchmark_service.list_experiments(
document_id=document_id, offset=offset, limit=limit
)
# Enrich with document filenames and best_strategy # Enrich with document filenames and best_strategy
from src.storage import sqlite as db from src.storage import sqlite as db
for item in result.get("items", []): for item in result.get("items", []):

View File

@@ -434,6 +434,128 @@
.cmp-exp-2 { background: rgba(99,102,241,0.15); color: #818cf8; } .cmp-exp-2 { background: rgba(99,102,241,0.15); color: #818cf8; }
.cmp-exp-3 { background: rgba(244,63,94,0.15); color: #fb7185; } .cmp-exp-3 { background: rgba(244,63,94,0.15); color: #fb7185; }
.cmp-divider { height: 1px; background: var(--border); margin: 20px 0; } .cmp-divider { height: 1px; background: var(--border); margin: 20px 0; }
/* ── Decision Board ─────────────────────────────────────── */
.decision-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 960px) { .decision-grid { grid-template-columns: 1fr; } }
.decision-cand {
border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px 14px; margin-bottom: 8px; cursor: pointer;
background: var(--bg-base); transition: border-color 0.15s, background 0.15s;
}
.decision-cand:hover { border-color: #3a3a42; }
.decision-cand.selected {
border-color: var(--accent); background: rgba(234,179,8,0.08);
}
.decision-cand .cand-title { font-weight: 600; color: var(--text-heading); margin-bottom: 6px; }
.decision-metrics { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: 12px; color: var(--text-muted); }
.decision-metrics strong { color: var(--text-body); font-variant-numeric: tabular-nums; }
.decision-legend {
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center;
margin-bottom: 14px; padding: 12px 14px;
background: rgba(15, 20, 25, 0.55);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 10px;
font-size: 12px; color: var(--text-muted);
}
.decision-legend-item { display: inline-flex; align-items: center; gap: 7px; }
.decision-legend-swatch {
width: 22px; height: 14px; border-radius: 4px;
border: 1px solid rgba(255,255,255,0.08);
box-shadow: inset 3px 0 0 var(--swatch-accent, transparent);
flex-shrink: 0;
}
.decision-matrix-wrap {
overflow-x: auto;
border: 1px solid rgba(255,255,255,0.06);
border-radius: 12px;
background: rgba(15, 20, 25, 0.35);
}
.decision-matrix {
width: 100%; border-collapse: separate; border-spacing: 0;
font-size: 13px; margin: 0;
}
.decision-matrix th,
.decision-matrix td {
padding: 10px 12px;
border-bottom: 1px solid rgba(255,255,255,0.05);
border-right: 1px solid rgba(255,255,255,0.04);
text-align: center;
vertical-align: middle;
}
.decision-matrix th:last-child,
.decision-matrix td:last-child { border-right: none; }
.decision-matrix tbody tr:last-child th,
.decision-matrix tbody tr:last-child td { border-bottom: none; }
.decision-matrix thead th {
position: sticky; top: 0; z-index: 2;
background: rgba(21, 32, 51, 0.96);
backdrop-filter: blur(8px);
color: #94A3B8;
font-size: 11px; font-weight: 600;
letter-spacing: 0.03em;
white-space: nowrap;
}
.decision-matrix .sticky-col {
position: sticky; left: 0; z-index: 1;
text-align: left; font-weight: 500; white-space: nowrap;
background: #1A2332; color: var(--text-heading);
min-width: 160px; max-width: 220px;
}
.decision-matrix thead .sticky-col { z-index: 3; background: rgba(21, 32, 51, 0.96); }
.decision-matrix tr.mean-row td,
.decision-matrix tr.mean-row .sticky-col {
border-top: 1px solid rgba(255,255,255,0.1);
background: rgba(15, 20, 25, 0.65);
font-weight: 700;
}
.decision-matrix tr.wins-row td,
.decision-matrix tr.wins-row .sticky-col {
background: rgba(15, 20, 25, 0.45);
color: var(--text-muted);
font-weight: 600;
}
.decision-matrix th.col-duel {
color: #FBBF24;
}
.decision-heat {
font-variant-numeric: tabular-nums;
font-size: 12.5px; font-weight: 600;
min-width: 76px;
letter-spacing: 0.01em;
transition: filter 0.15s ease;
}
.decision-heat:hover { filter: brightness(1.12); }
.decision-heat .best-mark,
.decision-legend .best-mark,
.best-mark {
display: inline-block;
font-size: 9px; font-weight: 700; line-height: 1;
margin-right: 5px; padding: 2px 5px;
border-radius: 999px; vertical-align: middle;
letter-spacing: 0.04em;
}
.decision-heat .best-mark.fs,
.decision-legend .best-mark.fs,
.best-mark.fs {
background: rgba(56, 189, 248, 0.15);
color: #7DD3FC;
border: 1px solid rgba(56, 189, 248, 0.28);
}
.decision-heat .best-mark.sem,
.decision-legend .best-mark.sem,
.best-mark.sem {
background: rgba(167, 139, 250, 0.15);
color: #C4B5FD;
border: 1px solid rgba(167, 139, 250, 0.28);
}
.decision-duel {
display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: stretch;
}
@media (max-width: 800px) { .decision-duel { grid-template-columns: 1fr; } }
.decision-vs {
display: flex; align-items: center; justify-content: center;
font-weight: 700; color: var(--text-muted); font-size: 18px;
}
</style> </style>
</head> </head>
<body> <body>
@@ -554,7 +676,7 @@ function HomeTab() {
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
api('/documents').catch(() => ({ total: 0 })), api('/documents').catch(() => ({ total: 0 })),
api('/experiments').catch(() => []), api('/experiments?limit=500').catch(() => []),
]).then(([docs, exps]) => { ]).then(([docs, exps]) => {
setStats({ docs: docs.total || 0, experiments: (exps?.items || []).length }); setStats({ docs: docs.total || 0, experiments: (exps?.items || []).length });
setLoading(false); setLoading(false);
@@ -589,6 +711,7 @@ function HomeTab() {
React.createElement('li', null, 'Process it with chunking strategies'), React.createElement('li', null, 'Process it with chunking strategies'),
React.createElement('li', null, 'Ask a question in ', React.createElement('b', null, 'Query'), ' (or inside PDF Workspace)'), React.createElement('li', null, 'Ask a question in ', React.createElement('b', null, 'Query'), ' (or inside PDF Workspace)'),
React.createElement('li', null, 'Run a full benchmark in ', React.createElement('b', null, 'Benchmarks'), ' / PDF Workspace'), React.createElement('li', null, 'Run a full benchmark in ', React.createElement('b', null, 'Benchmarks'), ' / PDF Workspace'),
React.createElement('li', null, 'Pick a final Strategy on the ', React.createElement('b', null, 'Decision'), ' board'),
React.createElement('li', null, 'Check system health in the ', React.createElement('b', null, 'Admin'), ' tab') React.createElement('li', null, 'Check system health in the ', React.createElement('b', null, 'Admin'), ' tab')
) )
) )
@@ -1660,7 +1783,7 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF
const [inspectLoadingId, setInspectLoadingId] = useState(null); const [inspectLoadingId, setInspectLoadingId] = useState(null);
const fetchExperiments = useCallback(() => { const fetchExperiments = useCallback(() => {
api('/experiments').then(d => setExperiments(d?.items || (Array.isArray(d) ? d : []))).catch(() => {}); api('/experiments?limit=500').then(d => setExperiments(d?.items || (Array.isArray(d) ? d : []))).catch(() => {});
}, []); }, []);
useEffect(() => { fetchExperiments(); }, [fetchExperiments]); useEffect(() => { fetchExperiments(); }, [fetchExperiments]);
@@ -2267,6 +2390,648 @@ function PdfWorkspaceTab({ documents, setDocuments, strategies, addToast, questi
); );
} }
// -- Tab: Decision Board (ADR-0026) --------------------------
const DECISION_DOC_FILENAMES = [
'bazresi.docx',
'customer1.docx',
'fire.docx',
'general-havades-individuals.doc',
'havades.docx',
'life-time-individual.docx',
'moavenin.docx',
'Refah.docx',
'website.docx',
'lifetime-compensation.docx',
];
function decisionComposite(m) {
if (!m) return null;
return ((m.avg_context_relevance || 0) * 0.3
+ (m.avg_answer_similarity || 0) * 0.4
+ (m.avg_faithfulness || 0) * 0.3) * (1 - (m.hallucination_rate || 0));
}
function decisionFmt(v, digits) {
if (v == null || Number.isNaN(v)) return '—';
return Number(v).toFixed(digits == null ? 2 : digits);
}
function decisionPct(v) {
if (v == null || Number.isNaN(v)) return '—';
return `${(Number(v) * 100).toFixed(0)}%`;
}
function decisionHeatBin(score) {
if (score == null || Number.isNaN(score)) {
return {
key: 'missing', label: '—',
bg: 'transparent', fg: 'var(--text-muted)', accent: 'transparent',
};
}
const s = Number(score);
// Modern dark-dashboard scale: translucent wash + luminous text + left accent rail
// (no solid primary blocks — avoids Windows-98 / crayon look)
if (s < 8.0) {
return {
key: 'lt80', label: '<8.0',
bg: 'rgba(244, 63, 94, 0.14)', fg: '#FB7185', accent: '#F43F5E',
};
}
if (s < 8.5) {
return {
key: '80_85', label: '8.0–8.5',
bg: 'rgba(251, 146, 60, 0.13)', fg: '#FB923C', accent: '#F97316',
};
}
if (s < 9.0) {
return {
key: '85_90', label: '8.5–9.0',
bg: 'rgba(250, 204, 21, 0.14)', fg: '#FDE047', accent: '#EAB308',
};
}
if (s < 9.5) {
return {
key: '90_95', label: '9.0–9.5',
bg: 'rgba(45, 212, 191, 0.13)', fg: '#2DD4BF', accent: '#14B8A6',
};
}
return {
key: 'ge95', label: '≥9.5',
bg: 'rgba(52, 211, 153, 0.16)', fg: '#34D399', accent: '#10B981',
};
}
function decisionHeatStyle(score) {
const bin = decisionHeatBin(score);
const style = { background: bin.bg, color: bin.fg };
if (bin.accent && bin.accent !== 'transparent') {
style.boxShadow = `inset 3px 0 0 ${bin.accent}`;
}
return style;
}
/** Per-row (or mean-row) ids of best fixed_size and best semantic Candidate. */
function decisionFamilyBestIds(candidates, scoreOf) {
let bestFs = null, bestFsScore = -Infinity;
let bestSem = null, bestSemScore = -Infinity;
(candidates || []).forEach(c => {
const s = scoreOf(c);
if (s == null || Number.isNaN(s)) return;
if (c.family === 'fixed_size' && s > bestFsScore) {
bestFsScore = s;
bestFs = c.id;
}
if (c.family === 'semantic' && s > bestSemScore) {
bestSemScore = s;
bestSem = c.id;
}
});
return { fixed: bestFs, semantic: bestSem };
}
const DECISION_HEAT_LEGEND = [
decisionHeatBin(null),
decisionHeatBin(7.9),
decisionHeatBin(8.2),
decisionHeatBin(8.7),
decisionHeatBin(9.2),
decisionHeatBin(9.6),
];
function DecisionHeatLegend() {
return React.createElement('div', { className: 'decision-legend', role: 'list', 'aria-label': 'Composite score color guide' },
React.createElement('span', { style: { fontWeight: 600, color: 'var(--text-body)' } }, 'Score guide'),
DECISION_HEAT_LEGEND.map(bin =>
React.createElement('span', { key: bin.key, className: 'decision-legend-item', role: 'listitem' },
React.createElement('span', {
className: 'decision-legend-swatch',
style: {
background: bin.bg === 'transparent' ? 'rgba(255,255,255,0.04)' : bin.bg,
boxShadow: bin.accent && bin.accent !== 'transparent' ? `inset 3px 0 0 ${bin.accent}` : undefined,
},
'aria-hidden': true,
}),
bin.label
)
),
React.createElement('span', {
style: { width: 1, height: 14, background: 'rgba(255,255,255,0.1)', margin: '0 4px' },
'aria-hidden': true,
}),
React.createElement('span', { className: 'decision-legend-item' },
React.createElement('span', { className: 'best-mark fs' }, 'F'),
'best fixed_size'
),
React.createElement('span', { className: 'decision-legend-item' },
React.createElement('span', { className: 'best-mark sem' }, 'S'),
'best semantic'
)
);
}
function meanOf(nums) {
const vals = nums.filter(v => v != null && !Number.isNaN(v));
if (!vals.length) return null;
return vals.reduce((a, b) => a + b, 0) / vals.length;
}
function buildDecisionBoard(experiments, corpusId, excludedIds) {
const excluded = new Set(excludedIds || []);
const pool = (experiments || []).filter(e => {
if (excluded.has(e.id)) return false;
const fn = e.document_filename || '';
if (!DECISION_DOC_FILENAMES.includes(fn)) return false;
if (corpusIdOf(e) !== corpusId) return false;
const strats = e.strategies_used || [];
return strats.length === 1;
});
// Newest first assumed from API; keep first hit per cell
const cellMap = {}; // key: `${fn}||${candId}` -> exp
const fsLevels = new Set([0, 1, 2, 3]);
const semBounds = new Set();
pool.forEach(e => {
const strat = (e.strategies_used || [])[0];
const fn = e.document_filename;
if (strat === 'fixed_size') {
const { prev, next } = neighborCounts(e);
if (prev !== next || !fsLevels.has(prev)) return;
const candId = `fixed_size:±${prev}`;
const key = `${fn}||${candId}`;
if (!cellMap[key]) cellMap[key] = e;
} else if (strat === 'semantic') {
const b = boundaryIdOf(e);
if (!b) return;
semBounds.add(b);
const candId = `semantic:${b}`;
const key = `${fn}||${candId}`;
if (!cellMap[key]) cellMap[key] = e;
}
});
const fixedCands = [0, 1, 2, 3].map(n => ({
id: `fixed_size:±${n}`,
family: 'fixed_size',
label: `fixed_size ±${n}/${n}`,
short: `±${n}`,
level: n,
}));
const semanticCands = [...semBounds].sort().map(b => ({
id: `semantic:${b}`,
family: 'semantic',
label: `semantic @ ${b}`,
short: b,
boundary: b,
}));
function metricsFor(exp, family) {
if (!exp) return null;
return (exp.aggregate_metrics || {})[family] || null;
}
function summarize(cands) {
return cands.map(c => {
const perDoc = {};
const composites = [];
const metricBags = {
avg_context_relevance: [],
avg_answer_similarity: [],
avg_faithfulness: [],
hallucination_rate: [],
};
let filled = 0;
DECISION_DOC_FILENAMES.forEach(fn => {
const exp = cellMap[`${fn}||${c.id}`];
const m = metricsFor(exp, c.family);
const score = decisionComposite(m);
perDoc[fn] = { exp, metrics: m, composite: score };
if (score != null) {
filled += 1;
composites.push(score);
Object.keys(metricBags).forEach(k => {
if (m && m[k] != null) metricBags[k].push(m[k]);
});
}
});
return {
...c,
perDoc,
filled,
totalDocs: DECISION_DOC_FILENAMES.length,
meanComposite: meanOf(composites),
meanMetrics: {
avg_context_relevance: meanOf(metricBags.avg_context_relevance),
avg_answer_similarity: meanOf(metricBags.avg_answer_similarity),
avg_faithfulness: meanOf(metricBags.avg_faithfulness),
hallucination_rate: meanOf(metricBags.hallucination_rate),
},
wins: 0,
};
});
}
const fixedSummaries = summarize(fixedCands);
const semanticSummaries = summarize(semanticCands);
function assignWins(summaries) {
DECISION_DOC_FILENAMES.forEach(fn => {
let best = null;
let bestScore = -Infinity;
summaries.forEach(s => {
const sc = s.perDoc[fn]?.composite;
if (sc == null) return;
if (sc > bestScore) {
bestScore = sc;
best = s;
}
});
if (best) best.wins += 1;
});
}
assignWins(fixedSummaries);
assignWins(semanticSummaries);
function autoPick(summaries) {
if (!summaries.length) return null;
return [...summaries].sort((a, b) => {
const ma = a.meanComposite == null ? -1 : a.meanComposite;
const mb = b.meanComposite == null ? -1 : b.meanComposite;
if (mb !== ma) return mb - ma;
if (b.wins !== a.wins) return b.wins - a.wins;
return a.id.localeCompare(b.id);
})[0];
}
return {
cellMap,
fixedSummaries,
semanticSummaries,
autoFixed: autoPick(fixedSummaries),
autoSemantic: autoPick(semanticSummaries),
allCandidates: [...fixedSummaries, ...semanticSummaries],
};
}
function DecisionTab({ addToast }) {
const [experiments, setExperiments] = useState([]);
const [loading, setLoading] = useState(true);
const [corpusId, setCorpusId] = useState('text-embedding-3-large');
const [embModels, setEmbModels] = useState([]);
const [excludedIds, setExcludedIds] = useState([]);
const [overrideFixed, setOverrideFixed] = useState(null);
const [overrideSemantic, setOverrideSemantic] = useState(null);
const refresh = useCallback(() => {
setLoading(true);
Promise.all([
api('/experiments?limit=500'),
api('/admin/embedding-models'),
]).then(([ex, emb]) => {
setExperiments(ex?.items || []);
setEmbModels(emb?.models || []);
if (emb?.corpus_id) {
setCorpusId(prev => prev || emb.corpus_id);
}
}).catch(() => addToast('Failed to load Decision Board data', 'error'))
.finally(() => setLoading(false));
}, [addToast]);
useEffect(() => { refresh(); }, [refresh]);
const board = React.useMemo(
() => buildDecisionBoard(experiments, corpusId, excludedIds),
[experiments, corpusId, excludedIds]
);
const winnerFixed = (overrideFixed && board.fixedSummaries.find(c => c.id === overrideFixed))
|| board.autoFixed;
const winnerSemantic = (overrideSemantic && board.semanticSummaries.find(c => c.id === overrideSemantic))
|| board.autoSemantic;
const excludeId = (id) => {
if (!id) return;
setExcludedIds(prev => prev.includes(id) ? prev : [...prev, id]);
};
const unexcludeId = (id) => setExcludedIds(prev => prev.filter(x => x !== id));
const fsFilled = board.fixedSummaries.reduce((a, c) => a + c.filled, 0);
const fsTotal = board.fixedSummaries.length * DECISION_DOC_FILENAMES.length;
const semFilled = board.semanticSummaries.reduce((a, c) => a + c.filled, 0);
const semTotal = board.semanticSummaries.length * DECISION_DOC_FILENAMES.length;
// Stage-2 head-to-head
let duel = null;
if (winnerFixed && winnerSemantic) {
let fsWins = 0, semWins = 0, ties = 0;
const perDoc = DECISION_DOC_FILENAMES.map(fn => {
const a = winnerFixed.perDoc[fn]?.composite;
const b = winnerSemantic.perDoc[fn]?.composite;
let winner = '—';
if (a != null && b != null) {
if (a > b) { fsWins += 1; winner = 'fixed'; }
else if (b > a) { semWins += 1; winner = 'semantic'; }
else { ties += 1; winner = 'tie'; }
}
return { fn, a, b, winner };
});
const recommend = (winnerFixed.meanComposite || 0) >= (winnerSemantic.meanComposite || 0)
? winnerFixed : winnerSemantic;
// Prefer win-count if mean close? stick to mean primary with wins as display
duel = { fsWins, semWins, ties, perDoc, recommend };
}
const renderCandCard = (c, selected, onSelect, familyAutoId) =>
React.createElement('div', {
key: c.id,
className: `decision-cand${selected ? ' selected' : ''}`,
onClick: () => onSelect(selected ? null : (c.id === familyAutoId ? null : c.id)),
title: 'Click to override family winner; click selected again to return to auto',
},
React.createElement('div', { className: 'cand-title', style: { display: 'flex', justifyContent: 'space-between', gap: 8 } },
React.createElement('span', null,
React.createElement('input', {
type: 'radio',
checked: !!selected,
readOnly: true,
style: { marginRight: 8 },
}),
c.label,
c.id === familyAutoId
? React.createElement('span', { className: 'badge badge-accent', style: { marginLeft: 8 } }, 'auto')
: null
),
React.createElement('span', { style: { color: 'var(--accent)', fontVariantNumeric: 'tabular-nums' } },
decisionFmt(c.meanComposite))
),
React.createElement('div', { className: 'decision-metrics' },
React.createElement('span', null, 'Wins ', React.createElement('strong', null, `${c.wins}/${c.totalDocs}`)),
React.createElement('span', null, 'Coverage ', React.createElement('strong', null, `${c.filled}/${c.totalDocs}`)),
React.createElement('span', null, 'Context ', React.createElement('strong', null, decisionFmt(c.meanMetrics.avg_context_relevance))),
React.createElement('span', null, 'Similarity ', React.createElement('strong', null, decisionFmt(c.meanMetrics.avg_answer_similarity))),
React.createElement('span', null, 'Faithfulness ', React.createElement('strong', null, decisionFmt(c.meanMetrics.avg_faithfulness))),
React.createElement('span', null, 'Halluc. ', React.createElement('strong', null, decisionPct(c.meanMetrics.hallucination_rate)))
)
);
const duelPanel = (c, side) => {
if (!c) {
return React.createElement('div', { className: 'card', style: { margin: 0 } },
React.createElement('div', { className: 'text-muted' }, `No ${side} winner yet`));
}
const isRec = duel && duel.recommend && duel.recommend.id === c.id;
return React.createElement('div', {
className: 'card',
style: {
margin: 0,
borderColor: isRec ? 'var(--accent)' : undefined,
boxShadow: isRec ? '0 0 0 1px rgba(234,179,8,0.35)' : undefined,
},
},
React.createElement('div', { className: 'flex-between mb-2' },
React.createElement('div', { className: 'card-title mb-0' }, c.label),
isRec ? React.createElement('span', { className: 'badge badge-success' }, 'Recommended') : null
),
React.createElement('div', { style: { fontSize: 28, fontWeight: 700, color: 'var(--accent)', marginBottom: 8 } },
decisionFmt(c.meanComposite)),
React.createElement('div', { className: 'decision-metrics', style: { marginBottom: 8 } },
React.createElement('span', null, 'Doc wins vs other ', React.createElement('strong', null,
side === 'fixed' ? (duel ? duel.fsWins : '—') : (duel ? duel.semWins : '—'))),
React.createElement('span', null, 'Family wins ', React.createElement('strong', null, `${c.wins}/${c.totalDocs}`)),
React.createElement('span', null, 'Coverage ', React.createElement('strong', null, `${c.filled}/${c.totalDocs}`))
),
React.createElement('table', { className: 'cmp-table' },
React.createElement('tbody', null,
[['Context Relevance', c.meanMetrics.avg_context_relevance, false],
['Answer Similarity', c.meanMetrics.avg_answer_similarity, false],
['Faithfulness', c.meanMetrics.avg_faithfulness, false],
['Hallucination Rate', c.meanMetrics.hallucination_rate, true]].map(([label, val, isPct]) =>
React.createElement('tr', { key: label },
React.createElement('td', null, label),
React.createElement('td', { style: { fontWeight: 600 } }, isPct ? decisionPct(val) : decisionFmt(val))
)
)
)
)
);
};
return React.createElement('div', null,
React.createElement('div', { className: 'flex-between mb-2' },
React.createElement('h1', { style: { color: 'var(--text-heading)', margin: 0, fontSize: '22px' } }, 'Decision Board'),
React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: refresh, disabled: loading },
loading ? React.createElement('span', { className: 'spinner' }) : '↻ Refresh')
),
React.createElement('p', { className: 'text-sm text-muted', style: { marginBottom: 16, maxWidth: 720 } },
'Two-stage final selection: pick the best fixed_size Neighbor level and best semantic Boundary, then compare those winners across the 10-doc evaluation set. Mean composite ranks stage 1; win-counts are shown; click a Candidate to override.'
),
// Header controls
React.createElement('div', { className: 'card mb-4' },
React.createElement('div', { className: 'row mb-0', style: { alignItems: 'flex-end' } },
React.createElement(EmbeddingModelSelect, {
label: 'Corpus filter',
value: corpusId,
onChange: (v) => { setCorpusId(v); setOverrideFixed(null); setOverrideSemantic(null); },
models: embModels,
role: 'corpus',
title: 'Only single-strategy Experiments under this Corpus Embedding Model',
style: { maxWidth: 360 },
}),
React.createElement('div', { className: 'col' },
React.createElement('div', { className: 'text-sm text-muted' }, 'Coverage'),
React.createElement('div', { style: { fontWeight: 600, color: 'var(--text-heading)', marginTop: 6 } },
`fixed_size ${fsFilled}/${fsTotal || 40} · semantic ${semFilled}/${semTotal || '—'}`)
)
),
excludedIds.length > 0 && React.createElement('div', { style: { marginTop: 12 } },
React.createElement('div', { className: 'text-sm text-muted mb-1' }, 'Excluded Experiments (next-newest fills the cell)'),
React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6 } },
excludedIds.map(id =>
React.createElement('button', {
key: id,
className: 'btn btn-secondary btn-sm',
onClick: () => unexcludeId(id),
title: 'Click to restore',
}, `✕ ${id.substring(0, 8)}…`)
)
)
)
),
loading && React.createElement('div', { style: { textAlign: 'center', padding: 24 } },
React.createElement('span', { className: 'spinner' })),
!loading && React.createElement(React.Fragment, null,
// Stage 1
React.createElement('h2', { style: { fontSize: 16, color: 'var(--text-heading)', marginBottom: 10 } }, 'Stage 1 — Best Candidate per family'),
React.createElement('div', { className: 'decision-grid mb-4' },
React.createElement('div', { className: 'card', style: { margin: 0 } },
React.createElement('div', { className: 'card-title' }, 'fixed_size (Neighbor Expansion)'),
React.createElement('div', { className: 'text-sm text-muted mb-2' },
'Auto winner: ', board.autoFixed ? board.autoFixed.label : '—',
overrideFixed ? ' · override active' : ''),
board.fixedSummaries.map(c => renderCandCard(
c,
winnerFixed && winnerFixed.id === c.id,
setOverrideFixed,
board.autoFixed?.id
))
),
React.createElement('div', { className: 'card', style: { margin: 0 } },
React.createElement('div', { className: 'card-title' }, 'semantic (Boundary Embedding Model)'),
React.createElement('div', { className: 'text-sm text-muted mb-2' },
'Auto winner: ', board.autoSemantic ? board.autoSemantic.label : '—',
overrideSemantic ? ' · override active' : ''),
board.semanticSummaries.length === 0
? React.createElement('div', { className: 'empty-state' }, 'No semantic single-strategy Experiments for this Corpus')
: board.semanticSummaries.map(c => renderCandCard(
c,
winnerSemantic && winnerSemantic.id === c.id,
setOverrideSemantic,
board.autoSemantic?.id
))
)
),
// Stage 2
React.createElement('h2', { style: { fontSize: 16, color: 'var(--text-heading)', marginBottom: 10 } }, 'Stage 2 — Family showdown'),
React.createElement('div', { className: 'decision-duel mb-4' },
duelPanel(winnerFixed, 'fixed'),
React.createElement('div', { className: 'decision-vs' }, 'vs'),
duelPanel(winnerSemantic, 'semantic')
),
duel && React.createElement('div', { className: 'card mb-4' },
React.createElement('div', { className: 'card-title' }, 'Head-to-head by document'),
React.createElement('div', { className: 'text-sm text-muted mb-2' },
`fixed_size wins ${duel.fsWins} · semantic wins ${duel.semWins} · ties ${duel.ties}`),
React.createElement('table', { className: 'cmp-table' },
React.createElement('thead', null,
React.createElement('tr', null,
React.createElement('th', null, 'Document'),
React.createElement('th', null, winnerFixed?.short || 'fixed'),
React.createElement('th', null, winnerSemantic?.short || 'semantic'),
React.createElement('th', null, 'Winner')
)
),
React.createElement('tbody', null,
duel.perDoc.map(row =>
React.createElement('tr', { key: row.fn },
React.createElement('td', null, row.fn),
React.createElement('td', { style: { textAlign: 'center', fontVariantNumeric: 'tabular-nums' } }, decisionFmt(row.a)),
React.createElement('td', { style: { textAlign: 'center', fontVariantNumeric: 'tabular-nums' } }, decisionFmt(row.b)),
React.createElement('td', null,
row.winner === 'fixed' ? React.createElement('span', { className: 'badge badge-accent' }, 'fixed_size')
: row.winner === 'semantic' ? React.createElement('span', { className: 'badge badge-success' }, 'semantic')
: row.winner === 'tie' ? React.createElement('span', { className: 'badge' }, 'tie')
: React.createElement('span', { className: 'text-muted' }, '—')
)
)
)
)
)
),
// Matrix
React.createElement('h2', { style: { fontSize: 16, color: 'var(--text-heading)', marginBottom: 10 } }, 'Per-document matrix'),
React.createElement('div', { className: 'card', style: { paddingBottom: 14 } },
React.createElement(DecisionHeatLegend),
React.createElement('div', { className: 'decision-matrix-wrap' },
React.createElement('table', { className: 'decision-matrix' },
React.createElement('thead', null,
React.createElement('tr', null,
React.createElement('th', { className: 'sticky-col' }, 'Document'),
board.allCandidates.map(c =>
React.createElement('th', {
key: c.id,
className: (winnerFixed && c.id === winnerFixed.id) || (winnerSemantic && c.id === winnerSemantic.id)
? 'col-duel' : undefined,
title: c.label,
}, c.family === 'fixed_size' ? c.short : (c.short || '').substring(0, 14))
)
)
),
React.createElement('tbody', null,
DECISION_DOC_FILENAMES.map(fn => {
const rowBest = decisionFamilyBestIds(board.allCandidates, c => c.perDoc[fn]?.composite);
return React.createElement('tr', { key: fn },
React.createElement('td', { className: 'sticky-col' }, fn),
board.allCandidates.map(c => {
const cell = c.perDoc[fn];
const score = cell?.composite;
const exp = cell?.exp;
const mark = c.id === rowBest.fixed ? 'fs' : (c.id === rowBest.semantic ? 'sem' : null);
return React.createElement('td', {
key: c.id,
className: 'decision-heat',
style: { ...decisionHeatStyle(score), cursor: exp ? 'pointer' : 'default' },
title: exp
? `${c.label} · ${fn}\ncomposite=${decisionFmt(score)}${mark ? `\nbest ${mark === 'fs' ? 'fixed_size' : 'semantic'} in row` : ''}\nid=${exp.id}\nClick: report · Shift+click: exclude`
: `${c.label} · ${fn}: missing`,
onClick: (e) => {
if (!exp) return;
if (e.shiftKey) {
excludeId(exp.id);
addToast(`Excluded ${exp.id.substring(0, 8)}…`, 'success');
return;
}
window.open(`/benchmarks/${exp.id}/report`, '_blank');
},
},
mark
? React.createElement('span', {
className: `best-mark ${mark}`,
'aria-label': mark === 'fs' ? 'Best fixed_size in row' : 'Best semantic in row',
}, mark === 'fs' ? 'F' : 'S')
: null,
decisionFmt(score)
);
})
);
}),
(() => {
const meanBest = decisionFamilyBestIds(board.allCandidates, c => c.meanComposite);
return React.createElement('tr', { key: '_mean', className: 'mean-row' },
React.createElement('td', { className: 'sticky-col' }, 'Mean'),
board.allCandidates.map(c => {
const mark = c.id === meanBest.fixed ? 'fs' : (c.id === meanBest.semantic ? 'sem' : null);
return React.createElement('td', {
key: c.id,
className: 'decision-heat',
style: decisionHeatStyle(c.meanComposite),
},
mark
? React.createElement('span', {
className: `best-mark ${mark}`,
'aria-label': mark === 'fs' ? 'Best fixed_size mean' : 'Best semantic mean',
}, mark === 'fs' ? 'F' : 'S')
: null,
decisionFmt(c.meanComposite)
);
})
);
})(),
React.createElement('tr', { key: '_wins', className: 'wins-row' },
React.createElement('td', { className: 'sticky-col' }, 'Wins'),
board.allCandidates.map(c =>
React.createElement('td', { key: c.id }, `${c.wins}/${c.totalDocs}`)
)
)
)
)
),
React.createElement('div', { className: 'text-sm text-muted', style: { marginTop: 12 } },
'Click a cell to open its Experiment report. Shift+click to exclude. ',
React.createElement('span', { className: 'best-mark fs' }, 'F'),
' / ',
React.createElement('span', { className: 'best-mark sem' }, 'S'),
' mark the best score in each family per row.'
)
)
)
);
}
// -- Tab: Admin --------------------------------------------- // -- Tab: Admin ---------------------------------------------
function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsFile }) { function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsFile }) {
// Collapse state for each section // Collapse state for each section
@@ -2804,6 +3569,7 @@ function App() {
{ id: 'pdf', label: 'PDF' }, { id: 'pdf', label: 'PDF' },
{ id: 'query', label: 'Query' }, { id: 'query', label: 'Query' },
{ id: 'benchmarks', label: 'Benchmarks' }, { id: 'benchmarks', label: 'Benchmarks' },
{ id: 'decision', label: 'Decision' },
{ id: 'admin', label: 'Admin' }, { id: 'admin', label: 'Admin' },
]; ];
@@ -2818,6 +3584,7 @@ function App() {
documents, strategies, addToast, formatFilter: 'word' }); documents, strategies, addToast, formatFilter: 'word' });
case 'benchmarks': return React.createElement(BenchmarksTab, { case 'benchmarks': return React.createElement(BenchmarksTab, {
documents, strategies, addToast, questionsFile, formatFilter: 'word' }); documents, strategies, addToast, questionsFile, formatFilter: 'word' });
case 'decision': return React.createElement(DecisionTab, { addToast });
case 'admin': return React.createElement(AdminTab, { case 'admin': return React.createElement(AdminTab, {
addToast, documents, strategies, setActiveTab, setQuestionsFile }); addToast, documents, strategies, setActiveTab, setQuestionsFile });
default: return null; default: return null;

View File

@@ -408,9 +408,11 @@ def get_experiment(experiment_id: str) -> dict[str, Any] | None:
def list_experiments( def list_experiments(
*, document_id: str | None = None, offset: int = 0, limit: int = 50 *, document_id: str | None = None, offset: int = 0, limit: int = 200
) -> dict[str, Any]: ) -> dict[str, Any]:
"""List experiments, optionally filtered by document.""" """List experiments, optionally filtered by document."""
limit = max(1, min(int(limit), 500))
offset = max(0, int(offset))
conn = _connect() conn = _connect()
try: try:
if document_id: if document_id: