Compare commits

...

21 Commits

Author SHA1 Message Date
d3f8a9a5e5 docs: add project documentation and task tracking
Why:
- Need to track domain model, ADRs, task progress, and feature planning

Changes:
- CONTEXT.md: domain model with ADRs 0001-0014
- docs/tasks.md: updated task list with Phase 6 (Dashboard)
- docs/cant-do-yet.md: backend-ready but no UI features
- docs/out-of-scope-v1.md: intentionally excluded features

Impact:
- Project documentation centralized for reference
2026-07-29 17:55:01 +03:30
a49795e5eb feat(dashboard): add full React dashboard with dark blue theme
Why:
- Swagger UI is not user-friendly for non-technical users
- Need a proper web app for document management, querying, benchmarking, and administration

Changes:
- Babel-in-browser React dashboard with 5 tabs (Home, Documents, Query, Benchmarks, Admin)
- Dark blue theme matching benchmark report pages (#0F1419, #1A2332, #F59E0B)
- Comparison view for strategy analysis across documents
- Expandable experiment details with delete
- Admin panel with collapsible sections (Health, Qdrant, Chunk Preview, Questions, Cost Estimator)
- Document upload, processing, and strategy selection
- Query interface with chunk inspection

Impact:
- Dashboard served at /app/ via FastAPI StaticFiles
- All Swagger functionality available through web UI
2026-07-29 17:54:51 +03:30
b10a5706f4 feat(reports): add overall scores to strategy cards and average row in technical report
Why:
- Users need to see each strategy's overall score at a glance in the report
- Technical report should show average metrics across all strategies

Changes:
- Each strategy card now shows its overall score below the metric bars
- Technical report aggregate table includes an AVERAGE row across all strategies

Impact:
- Managerial report: strategy cards now show overall score
- Technical report: aggregate table has a new AVERAGE row at the bottom
2026-07-29 17:54:26 +03:30
a277672444 feat(benchmarking): add document filename and delete experiment endpoint
Why:
- Experiment list and detail responses showed raw IDs instead of filenames
- No way to delete experiments from the UI

Changes:
- Add document_filename field to ExperimentDetailResponse
- Enrich list endpoint with filenames and calculated best_strategy from aggregate_metrics
- Add DELETE /experiments/{id} endpoint

Impact:
- API responses now include document_filename for all experiment endpoints
- Frontend can display filenames instead of IDs
2026-07-29 17:54:07 +03:30
a309e64841 feat(admin): add admin backend endpoints and service layer
Why:
- Dashboard needs system health, Qdrant management, chunk preview, questions, and cost estimation endpoints

Changes:
- Add admin router with 11 endpoints (health, Qdrant CRUD, chunk preview, questions management, cost estimation)
- Add delete_experiment to SQLite storage
- Mount admin router and dashboard static files at /app

Impact:
- New /admin/* API routes available
- Dashboard served at /app/ via StaticFiles
2026-07-29 17:53:43 +03:30
754da323ff docs: add comprehensive project documentation
Why:
- Need documentation for team onboarding and reference
- Need technical details for strategy implementations
- Need API reference for developers

Changes:
- README.md: Documentation index and quick start guide
- api-reference.md: All 10 endpoints with examples
- architecture.md: System structure and design decisions
- configuration.md: All environment variables and parameters
- data-flow.md: How data moves through the system
- evaluation-metrics.md: How scoring works with weights
- strategy-technical-details.md: Deep dive into each strategy's implementation
2026-07-27 14:15:28 +03:30
4e76203e27 feat(api): add request logging middleware
Why:
- Need visibility into API usage and performance
- Need to track request method, path, status, and duration
- Helps debugging production issues

Changes:
- Added RequestLoggingMiddleware class
- Logs at appropriate level (INFO/WARNING/ERROR)
- Non-invasive, no logic changes
2026-07-27 14:15:19 +03:30
0bb3086289 feat(benchmarking): add benchmark models and routes with view parameter
Why:
- Need request/response models for benchmark endpoints
- Need routes for creating and retrieving benchmarks
- Need view parameter for managerial vs technical report views

Changes:
- models.py: Added BenchmarkRequest, BenchmarkResponse, StrategyMetrics, ExperimentDetailResponse
- routes.py: Added POST /benchmarks, GET /benchmarks/{id}, GET /experiments, view parameter for reports
2026-07-27 14:14:59 +03:30
ef8bab5962 feat(benchmarking): add evaluation, benchmark service, and report
Why:
- Need LLM-as-Judge evaluation for automated scoring
- Need benchmark orchestration to run questions × strategies
- Need HTML report generation with two views (managerial/technical)

Changes:
- evaluation.py: LLM-as-Judge scoring on 4 metrics (context, similarity, faithfulness, hallucination)
- benchmark_service.py: Orchestration with per-strategy failure isolation
- report.py: Dual-view HTML reports with dark mode, charts, and strategy cards
2026-07-27 14:14:40 +03:30
d572724115 docs(data): add 21-question evaluation dataset for benchmarking
Why:
- Need standardized questions to evaluate chunking strategies
- Questions cover easy/medium/hard difficulty levels
- Golden answers enable automated scoring

Changes:
- 21 questions about engineering insurance (بیمه‌های مهندسی)
- 7 easy, 7 medium, 7 hard questions
- Categories: factual, procedural, comparative, complex
- Each question includes golden answer and expected keywords
2026-07-26 13:48:36 +03:30
b18ecdcd0c feat(api): add logging configuration and mount routers
Why:
- Need structured logging for debugging query pipeline
- Need to mount benchmarking router

Changes:
- Added logging.basicConfig() with INFO level
- Mounted benchmarking router
- Registered QueryError exception handler
2026-07-26 12:06:25 +03:30
cbaa72b420 feat(documents): add GET /documents endpoint and improve models
Why:
- Need to list documents to get document IDs for queries
- ProcessRequest should default to all 5 strategies
- paragraph_count should be optional (not always stored)

Changes:
- Added GET /documents endpoint with pagination
- Added DocumentListResponse model
- ProcessRequest now defaults to all 5 strategies
- DocumentResponse.paragraph_count now defaults to 0
2026-07-26 12:06:14 +03:30
496a58c62a feat(benchmarking): add query pipeline with logging and tracing
Why:
- Need query pipeline to ask questions against chunked documents
- Need comprehensive logging for debugging

Changes:
- Query service: embed question → vector search → LLM answer
- Query routes: POST /queries, GET /queries/{id}
- Query models: QueryRequest, QueryResponse, RetrievedChunk
- Added QueryError exception class
- Added STEP 1-6 logging for full traceability
2026-07-26 12:06:04 +03:30
b75fde8185 fix(models): add text field to ChunkMetadata for Qdrant storage
Why:
- Qdrant was storing only metadata, not chunk text
- Queries returned empty text in retrieved chunks
- Scores were low (~0.25) because embeddings were on empty strings

Changes:
- Added 'text' field to ChunkMetadata model
- Updated chunk_to_metadata() to copy text
2026-07-26 12:05:52 +03:30
292ae17cfa docs: add phases, tasks, and chunking strategies documentation
Why:
- Need implementation plan and task tracking documentation
- Need strategy explanations for reference

Changes:
- phases.md: 5-phase implementation plan with status tracking
- tasks.md: 25 tasks mapped to phases and steps
- chunking_strategies.md: detailed explanations of all 5 strategies
2026-07-26 09:38:08 +03:30
29edbf57c4 feat(api): wire documents router and SQLite init in app factory
Why:
- App factory needs to mount document routes and initialize SQLite at startup

Changes:
- Import and mount documents_router
- Call init_db() on app creation
2026-07-26 09:38:02 +03:30
8715d9c186 feat(strategies): implement all 5 chunking strategies
Why:
- Core evaluation targets — each strategy chunks differently for comparison

Changes:
- fixed_size: token-based splitting with overlap (baseline)
- recursive: cascade splitting (headers → newlines → sentences → words)
- semantic: sentence-level embeddings with similarity-based boundaries
- contextual_retrieval: LLM-generated context prefixes per chunk (Anthropic research)
- semantic_parent_child: paragraph clustering into parent-child hierarchy
2026-07-26 09:37:56 +03:30
143351b96b feat(chunking): add base strategy interface, embedding service, and orchestration
Why:
- Need abstract base class for all chunking strategies
- Need OpenAI embedding service (text-embedding-3-small)
- Need orchestration to run chunk → embed → store pipeline

Changes:
- Base: ChunkingStrategy ABC, token counting, chunk ID generation, sentence splitting
- Embedding: batch embedding with 2048 text limit per call
- Service: strategy registry, single/multi-strategy runners with per-strategy failure isolation
2026-07-26 09:37:46 +03:30
fdc21e3316 feat(documents): add document parser, service, and API routes
Why:
- Need to parse .docx/.doc files into DocumentTree + markdown
- Need API endpoints for document upload, processing, and deletion

Changes:
- Parser: .doc→.docx conversion via LibreOffice, XML-level extraction for table-heavy docs, heuristic heading detection
- Service: upload, process (delegates to chunking), delete orchestration
- Routes: POST /documents, POST /documents/{id}/process, DELETE /documents/{id}, GET /strategies
2026-07-26 09:37:40 +03:30
4aaaccec49 feat(storage): add SQLite and Qdrant storage layers
Why:
- SQLite stores structured data (documents, queries, experiments)
- Qdrant stores vector embeddings for similarity search

Changes:
- SQLite: schema init, document/query/experiment CRUD, JSON field parsing
- Qdrant: collection management, vector upsert, similarity search, delete operations
2026-07-26 09:37:32 +03:30
4f205a7ef7 feat(core): add StrategyName enum, DocumentTree models, and pagination helper
Why:
- StrategyName enum needed proper values for all 5 strategies
- DocumentTree models needed for document parser output
- PaginatedResponse needed for list endpoints

Changes:
- Renamed StrategyName values to match implementation (fixed_size, contextual_retrieval, semantic_parent_child)
- Added NodeType, DocumentTreeNode, DocumentTree models
- Added PaginatedResponse helper model
2026-07-26 09:37:24 +03:30
46 changed files with 8922 additions and 314 deletions

View File

@@ -1,305 +1,42 @@
# RAG Chunking Benchmarker # Context — RAG Chunking Benchmarker Admin Dashboard
A benchmarking application that compares chunking strategies on regulatory documents, measuring retrieval quality, answer faithfulness, and cost across strategies. ## Language
## Domain **Dashboard**:
A single-file React app (CDN-loaded, no build step) served by FastAPI at `/app`. Replaces Swagger as the primary UI for operating the RAG benchmarking platform.
**Chunk:** _Avoid_: Admin panel, web UI, frontend
A discrete unit of text extracted from a document, ready for embedding and storage. Every strategy produces chunks conforming to the unified Chunk model.
_Avoid_: fragment, segment, block **Strategy**:
One of the five chunking algorithms: fixed_size, recursive, semantic, contextual_retrieval, semantic_parent_child.
**Unified Chunk Model:** _Avoid_: Method, approach, technique
A single Pydantic model that all five chunking strategies must return. Strategy-specific fields (e.g., parent_id) are nullable when not applicable. This ensures Qdrant payload schemas are consistent and benchmarking compares equivalent structures.
_Avoid_: chunk schema, chunk type **Experiment**:
A completed benchmark run — one document, N strategies, M questions, with per-question and aggregate metrics.
**Chunk Metadata:** _Avoid_: Run, trial, benchmark
Fields stored with every chunk in Qdrant: document_name, chunk_id, strategy_name, chunk_index, token_count, character_count. No page_number (unreliable in DOCX). chunk_index is the position in the full document's chunk sequence.
_Avoid_: chunk fields, payload data **Chunk Preview**:
The ability to visualize what a specific strategy produces for a given document — the raw text blocks, their sizes, and hierarchy.
**Chunking Strategy:** _Avoid_: Chunk inspection, chunk view
An algorithm that determines how a document is split into chunks. Must inherit from the ChunkingStrategy base class and implement the chunk() method. The five strategies are: contextual_structure, parent_child, semantic, markdown_structure, recursive.
_Avoid_: splitter, chunker, method **Tab**:
A persistent top-level navigation section of the Dashboard (Home, Documents, Query, Benchmarks, Admin). Tabs stay mounted when switching — state survives.
**Strategy Name:** _Avoid_: Page, route, view
The canonical identifier for each chunking strategy: contextual_structure, parent_child, semantic, markdown_structure, recursive. Used in collection names, metadata, API parameters, and benchmark comparisons.
_Avoid_: strategy type, strategy id ## Architecture Decisions
**Contextual Structure Strategy:** | # | Decision | Status |
Pipeline: DOCX > Markdown > Structure Extraction > LLM enrichment (GPT-4o-mini per section) > Chunks > Embeddings > Qdrant. Each chunk is enriched with Document/Section/Article/Context/Content labels. The full enriched text is what gets embedded (not just the content). Hard-fails if the LLM enrichment call fails — no degraded chunks stored. |---|----------|--------|
_Avoid_: contextual chunking, enriched strategy 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 |
**Parent-Child Strategy:** ADR-0003 | Top-tab navigation (Home, Documents, Query, Benchmarks, Admin) — not sidebar | Approved |
Parent chunks are Article-level units (hardcoded for v1). Child chunks are sentences or paragraphs within each Article. Only child vectors are stored in Qdrant with a parent_id reference. At retrieval time, the matching child is found via vector search, then its full parent Article is returned as context to the LLM. 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 |
_Avoid_: hierarchical chunking, nested strategy 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 |
**Parent Level:** ADR-0007 | Admin router v1 endpoints: /admin/health, /admin/qdrant/collections (CRUD + wipe), /admin/chunks/{doc_id}, /admin/questions (CRUD + upload), /admin/cost-estimate. Config/SQLite/request-log deferred to v1.1. | Approved |
The document hierarchy level used as parent in parent-child chunking. Hardcoded to "article" for v1. Configurable in future versions (article, section, paragraph) when additional document types are supported. ADR-0008 | Custom dark theme: bg #111113/#1a1a1e/#222228, border #2a2a30, text #e4e4e7/#fafafa/#71717a, accent #eab308 (amber). Inter font from Google Fonts CDN. Continuity with existing amber/green report palette. | Approved |
_Avoid_: parent hierarchy, parent depth ADR-0009 | File structure: dashboard HTML in src/static/index.html, admin backend in src/admin/ (routes.py, service.py). FastAPI mounts /app -> src/static via StaticFiles(html=True). | Approved |
ADR-0010 | Cant-do-yet implementation order: Admin panels first (chunk preview, questions mgmt, cost estimator), then cross-tab UX (experiment detail, query history, doc preview, progress), then cross-cutting (error handling). | Approved |
**Semantic Strategy:** ADR-0011 | Admin tab uses collapsible sections (accordion pattern). Each panel is a card with a clickable header toggling display. Health starts expanded, others collapsed by default. | Approved |
Pipeline: DOCX > Markdown > Sentence extraction > Sentence embeddings > Cosine similarity between adjacent sentences > Dynamic chunk creation. When similarity drops below the semantic threshold, a new chunk boundary is created. Enforces a minimum chunk size (configurable via SEMANTIC_MIN_CHUNK_SIZE, default 3 sentences). Boundary sentence stays with the previous chunk. ADR-0012 | Chunk Preview: table with expandable rows. Select document + strategy, click Load. Rows show index + 80-char text preview + token/char counts. Click to expand full text. Parent column hidden by default. | Approved |
_Avoid_: embedding-based chunking, similarity chunking ADR-0013 | Questions Management: file list table + upload button + expandable row detail (id, question, category, difficulty, expected answer) + delete + "Use This File" shortcut to set Benchmarks tab path and switch tabs. Auto-refresh after mutations. | Approved |
ADR-0014 | Cost Estimator: two number inputs (questions, strategies), Estimate button, result card with total cost in amber, token estimate, 3 breakdown cards (embedding/queries/evaluation). Simple numbers, no tables. | Approved |
**Semantic Threshold:**
The cosine similarity value below which adjacent sentences are considered a topic shift. When cosine_sim(sentence_n, sentence_n+1) < threshold, a new chunk boundary is created. Configurable via SEMANTIC_THRESHOLD in .env.
_Avoid_: similarity cutoff, cut threshold
**Minimum Chunk Size:**
The minimum number of sentences a semantic chunk must contain. If a boundary is detected before the minimum is reached, accumulation continues. Configurable via SEMANTIC_MIN_CHUNK_SIZE in .env.
_Avoid_: min chunk, smallest chunk
**Semantic Boundary:**
A point in the document where adjacent sentence similarity drops below the semantic threshold, marking where one chunk ends and another begins.
_Avoid_: cut point, split boundary
**Markdown Structure Strategy:**
Pipeline: DOCX > Markdown > Header detection > Paragraph grouping > Leaf-level chunk creation > Qdrant. Chunks are created only at the deepest header level (leaf chunks). No overlap between parent and child sections — a ## section is NOT a chunk if it contains ### subsections.
_Avoid_: header-based chunking, markdown chunking
**Leaf Chunk:**
A chunk at the deepest header level in markdown chunking. Only leaf-level chunks are created (no overlap with parent sections).
_Avoid_: atomic chunk, granular chunk
**Recursive Strategy:**
Pipeline: DOCX > Markdown > Cascade splitting using a separator hierarchy. Separators applied in order: (1) Markdown headers (#, ##, ###), (2) double newline (\n\n), (3) single newline (\n), (4) sentence-ending punctuation (. ! ? followed by space), (5) space (word-level, last resort). Splitting stops when chunks reach the target size. Each chunk stores which separator level was used.
_Avoid_: hierarchical splitting, recursive splitting
**Separator Cascade:**
The ordered list of separators used by recursive chunking: headers > double newline > single newline > sentence-ending punctuation > word boundary. Applied top-down; each level is tried before falling back to the next.
_Avoid_: separator list, split pattern
**Document Tree:**
The hierarchical structure of a parsed document: Document > Section > Article > Paragraph. Extracted once via python-docx at upload time, stored in SQLite, and shared across all strategies. Strategies that need hierarchy (contextual, parent_child, markdown) read from the stored tree; they do not re-parse.
_Avoid_: doc tree, document structure, hierarchy
**Embedding:**
The vector representation of a chunk's text, produced by text-embedding-3-small. For the contextual_structure strategy, the full enriched text is embedded. For all other strategies, the raw chunk text is embedded.
_Avoid_: vector, vector representation, encoding
**Top-k:**
The number of chunks to retrieve during vector similarity search. Default is 5. Configurable per query via the API (POST /queries) and per benchmark via the dataset configuration.
_Avoid_: k, retrieve count, result limit
**Embedding Model:**
The fixed OpenAI model used for all embedding operations: text-embedding-3-small. Not configurable — every strategy uses the same embedding model to ensure fair comparison.
_Avoid_: vector model, encoder model
**LLM Model:**
The fixed OpenAI model used for all generation and evaluation: gpt-4o-mini. Used for: (1) contextual enrichment during chunking, (2) answer generation during queries, (3) LLM-as-judge evaluation during benchmarks.
_Avoid_: generation model, chat model
## Storage
**Strategy Collection:**
A Qdrant collection named after a strategy. Naming convention: {strategy_name}_collection (e.g., contextual_structure_collection, semantic_collection). Each strategy writes to its own collection. All collections share the same vector dimension (from text-embedding-3-small) and payload schema (unified Chunk model).
_Avoid_: vector store, collection only
**Parent:**
In parent-child chunking, a larger semantic unit (Article-level) whose child chunks are finer-grained pieces. Only child chunks are embedded and stored in Qdrant; the parent is retrieved at query time for context expansion.
_Avoid_: source chunk, container
**Child:**
In parent-child chunking, a fine-grained piece (sentence or paragraph within an Article) that gets embedded and stored in Qdrant. Carries a parent_id reference to its Article parent.
_Avoid_: sub-chunk, fragment
**Experiment:**
A completed benchmark run. Every benchmark (single question or dataset) stores an experiment record in SQLite with: all questions, generated answers, retrieved chunks per strategy, evaluation metrics, configuration snapshot, and timestamps.
_Avoid_: run, trial, test
**SQLite:**
The persistence layer for structured data. Stores: documents (parsed text + document tree JSON), experiments (benchmark results), queries (single question/answer history), per-strategy metrics, and timestamps. Does NOT store vectors — those live in Qdrant.
_Avoid_: database, DB, local storage
**Qdrant:**
The vector database. Stores chunk embeddings and their payload metadata. One collection per strategy. Used for vector similarity search during retrieval. No hybrid search, no BM25, no filters — pure vector similarity only.
_Avoid_: vector DB, vector store
## REST API
**Resource:**
A noun-based URL entity in the REST API. The four primary resources are Documents, Queries, Benchmarks, and the read-only Strategies list.
_Avoid_: endpoint, API, route
**Endpoint:**
A specific URL path combined with an HTTP method. The complete endpoint map:
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | /documents | Upload .docx file (multipart) |
| POST | /documents/{id}/process | Run selected strategies (synchronous) |
| DELETE | /documents/{id} | Remove document + all its chunks |
| GET | /strategies | List available strategies |
| POST | /queries | Ask a question against a strategy |
| GET | /queries/{id} | Retrieve past query |
| POST | /benchmarks | Run benchmark comparison |
| POST | /benchmarks?dry_run=true | Estimate cost without executing |
| GET | /benchmarks/{id} | Retrieve benchmark results |
| GET | /benchmarks/{id}/report | Download HTML report |
_Avoid_: API, route, handler (use "endpoint" for URL+method, "handler" only for the Python function)
**Query Parameter:**
A key-value pair appended to the URL after `?` (e.g., ?dry_run=true). Used for optional modifiers that don't change the resource identity. Contrast with path parameters that identify the resource (e.g., /benchmarks/{id}).
_Avoid_: URL parameter, request parameter
**Path Parameter:**
A variable segment in the URL path identified by curly braces (e.g., /documents/{id}). Identifies a specific resource instance. Always an ID string.
_Avoid_: URL variable, route parameter
**Multipart Upload:**
The format used for uploading .docx files via POST /documents. The request Content-Type is multipart/form-data with the file attached as a binary field.
_Avoid_: file upload, binary upload
**Request ID:**
A unique identifier attached to every inbound request, propagated through logs and stored in experiment records. Enables tracing a single operation across multiple service calls (document parsing, embedding, LLM calls).
_Avoid_: trace ID, correlation ID, request UUID
## Architecture
**Core Layer:**
The shared foundational directory (core/) holding: config.py (pydantic-settings from .env), dependencies.py (OpenAI/Qdrant client singletons), exceptions.py (custom exception classes + FastAPI handlers), models.py (Chunk, StrategyName, pagination). All other domains import upward to core, never sideways to each other.
_Avoid_: common, shared, base
**Domain Directory:**
A self-contained vertical slice of the application containing its own routes, service logic, models, and templates. The four domains: documents/, chunking/, benchmarking/, storage/.
_Avoid_: module, feature folder
**Service Layer:**
The business logic layer within each domain directory. Services contain orchestration logic, call external APIs (OpenAI, Qdrant), and are imported by route handlers. A service never imports from another domain's service — all shared logic goes through core/.
_Avoid_: manager, logic, use case
**Router:**
A FastAPI APIRouter instance, one per domain, mounted onto the main FastAPI app in main.py. Each domain's routes.py exports its own router. The main app composes them: app.include_router(documents_router), etc.
_Avoid_: blueprint, handler group
**Strategy Pattern:**
The design pattern used for chunking strategies. All strategies inherit from a base ChunkingStrategy class with a chunk(document) method returning a list of Chunk objects. New strategies are added by implementing the interface, not by modifying existing code.
_Avoid_: polymorphism, duck typing
**Pipeline:**
The sequential processing chain from DOCX to Qdrant. Each strategy defines its own pipeline. Pipelines are synchronous and run within a single request lifecycle (POST /documents/{id}/process).
_Avoid_: workflow, chain, flow
**Retrieval Pipeline:**
The query-time chain: user question > vector search in Qdrant > retrieve top_k chunks > send chunks + question to LLM > return answer with metadata (retrieved chunks, latency, token usage).
_Avoid_: search flow, query pipeline
**Sync Processing:**
All API endpoints execute synchronously and return results directly. No background job queue, no polling endpoint, no async task tracking. Document processing takes 30-60 seconds; benchmarking takes 2-5 minutes. Both are acceptable within the request lifecycle.
_Avoid_: async, background job, task queue
**Import Rules:**
Strict unidirectional dependency: core/ imports nothing from the project. documents/, chunking/, benchmarking/, storage/ each import only from core/. No domain imports from another domain. If benchmarking needs to reference a query, it calls a function via the Query service, not by importing chunking/ directly.
_Avoid_: dependency direction, import graph
**Shared DocumentTree:**
The parsed document hierarchy extracted once at upload time by documents/parser.py (python-docx). Stored as JSON in SQLite. All strategies that need hierarchy (contextual, parent_child, markdown) read from this stored tree. Structure extraction happens once regardless of how many strategies are requested.
_Avoid_: parsed structure, document hierarchy
**App Factory:**
The main.py module that creates and configures the FastAPI application. Responsibilities: create FastAPI instance, mount all domain routers, register exception handlers from core/, configure middleware (CORS, request ID injection).
_Avoid_: app init, application setup
## Evaluation
**Query:**
A single question asked against a specific chunking strategy. POST /queries with {question, strategy, top_k}. Returns: generated answer, retrieved chunks (with metadata), latency breakdown, token usage. Stored in SQLite for history.
_Avoid_: request, question, ask
**Benchmark:**
A comparison run that evaluates one or more strategies against one or more questions. Runs synchronously. Two modes: single question (POST /benchmarks) or dataset (POST /benchmarks with questions.json body). Produces an experiment record in SQLite with per-strategy metrics.
_Avoid_: comparison, evaluation run, test
**Dataset Benchmark:**
A benchmark mode that processes multiple questions from a JSON file. Input: array of {question, answer} objects. Runs each question against all selected strategies, aggregates metrics, produces a single experiment with overall scores.
_Avoid_: batch benchmark, multi-question benchmark
**Dry Run:**
A benchmark mode that estimates the number of LLM calls and approximate cost without executing. Triggered via query parameter: POST /benchmarks?dry_run=true. Returns: estimated_calls, estimated_cost_usd. Used for cost visibility before committing to a run.
_Avoid_: preview, simulation
**LLM-as-Judge:**
The evaluation paradigm where gpt-4o-mini scores the quality of answers and retrieval. Same model used for generation and evaluation. Scores are on a 1-10 scale. Applied to: context relevance, answer similarity, faithfulness. Hallucination is binary.
_Avoid_: automated evaluation, model evaluation
**Experiment Metrics:**
Composite scores for a benchmark run stored in SQLite. Includes: context_relevance (1-10), answer_similarity (1-10), faithfulness (1-10), hallucination (bool), plus cost breakdowns per strategy.
_Avoid_: results, evaluation data
**Latency Breakdown:**
Timing measurements recorded for each operation: chunking_time (strategy execution), embedding_time (API calls to text-embedding-3-small), retrieval_time (Qdrant vector search), llm_time (OpenAI generation + evaluation calls). All measured in seconds. Total latency is the sum.
_Avoid_: timing data, performance metrics
**Context Relevance:**
LLM-judged score (1-10) measuring how relevant the retrieved chunks are to the question. Evaluated by asking gpt-4o-mini: "Given this question, how relevant are these chunks?"
_Avoid_: retrieval relevance, chunk relevance
**Answer Similarity:**
LLM-judged score (1-10) measuring how close the generated answer is to the expected answer. Evaluated by asking gpt-4o-mini: "Compare these two answers for semantic similarity."
_Avoid_: answer match, correctness
**Faithfulness:**
LLM-judged score (1-10) measuring whether the generated answer is supported by the retrieved chunks. Low faithfulness = the answer uses information outside the retrieved context. Evaluated by asking gpt-4o-mini: "Is this answer supported by these chunks?"
_Avoid_: groundedness, support
**Hallucination:**
Binary flag indicating the generated answer contains claims not present in the retrieved chunks. Set to true when the LLM-as-judge detects unsupported information in the answer.
_Avoid_: fabrication, confabulation
**Cost Estimation:**
The dry-run calculation that estimates: number of LLM calls needed (per strategy per question for generation + evaluation), approximate tokens per call, estimated cost at current pricing. Does not execute — returns estimate only.
_Avoid_: price estimate, budget calculation
**Token Tracking:**
Token usage recorded at two levels: (1) tiktoken for offline chunk metadata (token_count per chunk, stored at chunking time), (2) OpenAI API response usage field for actual billing data on generation and evaluation calls.
_Avoid_: token counting, usage tracking
## Tech Stack
**Framework:**
FastAPI — the web framework. Chosen for: auto-generated OpenAPI docs (Swagger), Pydantic validation, async support (available for future use), and clean dependency injection via Depends().
_Avoid_: web framework, HTTP framework
**python-docx:**
The library used for DOCX parsing and document structure extraction. Provides direct access to paragraph styles, heading levels, and document hierarchy. Chosen over pandoc/mammoth because it preserves native document structure needed by contextual, parent-child, and markdown strategies.
_Avoid_: docx parser, word parser
**OpenAI API:**
The external API for: (1) text-embedding-3-small embeddings, (2) gpt-4o-mini for enrichment, generation, and evaluation. Accessed via the openai Python SDK. All calls are synchronous.
_Avoid_: LLM API, embedding API
**Qdrant SDK:**
The Python client for Qdrant vector database. Used for: collection creation, vector upsert, similarity search. Accessed via the qdrant-client package.
_Avoid_: vector client, qdrant client
**tiktoken:**
OpenAI's tokenizer library used for offline token counting. Applied to chunks at chunking time to compute token_count metadata. Not used for API cost calculation (that uses the OpenAI response usage field).
_Avoid_: tokenizer, token counter
**Jinja2:**
The templating engine used for HTML benchmark reports. Template lives in benchmarking/templates/report.html. Renders per-strategy comparison tables, per-question breakdowns, and metric visualizations.
_Avoid_: HTML generator, template engine
**pydantic-settings:**
The library used for configuration management. Settings are loaded from .env with type validation, defaults, and environment variable overrides.
_Avoid_: config loader, settings manager
## Configuration
**Environment Variables (.env):**
| Variable | Default | Description |
|----------|---------|-------------|
| OPENAI_API_KEY | (required) | OpenAI API authentication |
| QDRANT_URL | http://localhost:6333 | Qdrant server URL |
| QDRANT_API_KEY | (optional) | Qdrant auth for cloud instances |
| EMBEDDING_MODEL | text-embedding-3-small | Fixed embedding model |
| LLM_MODEL | gpt-4o-mini | Fixed LLM for generation + evaluation |
| TOP_K | 5 | Default chunks to retrieve |
| TEMPERATURE | 0.0 | LLM temperature for deterministic output |
| MAX_TOKENS | 1024 | Max tokens in LLM response |
| CHUNK_SIZE | 512 | Target chunk size in characters |
| CHUNK_OVERLAP | 50 | Overlap between adjacent chunks |
| SEMANTIC_THRESHOLD | 0.3 | Cosine similarity cutoff for semantic chunking |
| SEMANTIC_MIN_CHUNK_SIZE | 3 | Minimum sentences per semantic chunk |
_Avoid_: config variables, env vars

103
docs/README.md Normal file
View File

@@ -0,0 +1,103 @@
# Documentation Index
Complete documentation for the RAG Chunking Benchmarker.
---
## Quick Start
1. **New to the project?** Start with [Architecture Overview](architecture.md)
2. **Want to understand strategies?** Read [Strategy Technical Details](strategy-technical-details.md)
3. **Need to use the API?** Check [API Reference](api-reference.md)
4. **Configuring the system?** See [Configuration Guide](configuration.md)
5. **Understanding results?** Read [Evaluation Metrics](evaluation-metrics.md)
6. **Curious about data flow?** See [Data Flow](data-flow.md)
---
## Documentation Files
| File | Purpose | Audience |
|------|---------|----------|
| [architecture.md](architecture.md) | System structure and design | New team members |
| [strategy-technical-details.md](strategy-technical-details.md) | Deep dive into each strategy | Engineers |
| [api-reference.md](api-reference.md) | All endpoints documented | Developers |
| [configuration.md](configuration.md) | Settings and environment variables | DevOps |
| [evaluation-metrics.md](evaluation-metrics.md) | How scoring works | Data scientists |
| [data-flow.md](data-flow.md) | How data moves through the system | Engineers |
| [chunking_strategies.md](chunking_strategies.md) | High-level strategy overview | Everyone |
| [phases.md](phases.md) | Implementation phases | Project managers |
| [tasks.md](tasks.md) | Task tracking | Developers |
---
## Architecture at a Glance
```
┌─────────────────────────────────────────────────────────────────┐
│ RAG Chunking Benchmarker │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Documents → Parser → Chunking → Embedding → Qdrant │
│ │
│ Questions → Query Pipeline → LLM → Answers │
│ │
│ Answers → Evaluation → Metrics → Reports │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Key Concepts
### Chunking Strategies
| Strategy | Splitting | Best For |
|----------|-----------|----------|
| fixed_size | Token count | Baseline comparison |
| recursive | Structural boundaries | Structured documents |
| semantic | Similarity thresholds | Topic shifts |
| contextual_retrieval | LLM-enriched tokens | Retrieval quality |
| semantic_parent_child | Paragraph clusters | Context needed |
### Evaluation Metrics
| Metric | What It Measures |
|--------|------------------|
| Context Relevance | Did we find the right information? |
| Answer Similarity | Did we produce the right answer? |
| Faithfulness | Is the answer trustworthy? |
| Hallucination | Did we invent information? |
---
## API Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/documents` | GET/POST | Manage documents |
| `/documents/{id}/process` | POST | Run chunking strategies |
| `/queries` | POST | Ask questions |
| `/benchmarks` | POST | Run comparisons |
| `/benchmarks/{id}/report` | GET | View results |
| `/experiments` | GET | List experiments |
---
## Configuration
All settings via `.env` file. See [Configuration Guide](configuration.md) for details.
Key settings:
- `OPENAI_API_KEY` - Required for embeddings and LLM
- `CHUNK_SIZE` - Target tokens per chunk (default: 512)
- `SEMANTIC_THRESHOLD` - Similarity threshold (default: 0.5)
---
## Further Reading
- [Phases](phases.md) - Implementation roadmap
- [Tasks](tasks.md) - Task tracking
- [ADRs](adr/) - Architectural Decision Records

309
docs/api-reference.md Normal file
View File

@@ -0,0 +1,309 @@
# API Reference
Complete documentation of all REST API endpoints.
---
## Base URL
```
http://localhost:8000
```
---
## Endpoints
### Documents
#### `GET /documents`
List all uploaded documents.
**Response:**
```json
{
"items": [
{
"id": "doc-abc123",
"filename": "insurance.docx",
"paragraph_count": 245,
"chunk_counts": {
"recursive": 218,
"fixed_size": 117
},
"created_at": "2026-07-25T10:00:00"
}
],
"total": 1,
"offset": 0,
"limit": 50
}
```
---
#### `POST /documents`
Upload a document (.docx or .doc).
**Request:**
- Content-Type: `multipart/form-data`
- Body: `file` (binary)
**Response (201):**
```json
{
"id": "doc-abc123",
"filename": "insurance.docx",
"paragraph_count": 245,
"chunk_counts": {},
"created_at": "2026-07-25T10:00:00"
}
```
---
#### `DELETE /documents/{doc_id}`
Delete a document and its vectors.
**Response:**
```json
{
"deleted": true,
"document_id": "doc-abc123"
}
```
---
#### `POST /documents/{doc_id}/process`
Run chunking strategies on a document.
**Request:**
```json
{
"strategies": [
"recursive",
"fixed_size",
"semantic",
"contextual_retrieval",
"semantic_parent_child"
]
}
```
**Response:**
```json
{
"document_id": "doc-abc123",
"strategies_completed": [
{
"strategy": "recursive",
"status": "completed",
"chunks_produced": 218
}
],
"strategies_failed": []
}
```
---
### Strategies
#### `GET /strategies`
List all available chunking strategies.
**Response:**
```json
{
"strategies": [
{
"name": "recursive",
"description": "Cascade splitting: headers > double newline > ..."
},
{
"name": "fixed_size",
"description": "Fixed-size token splitting with overlap"
}
]
}
```
---
### Queries
#### `POST /queries`
Ask a question against a document using a specific strategy.
**Request:**
```json
{
"document_id": "doc-abc123",
"strategy": "recursive",
"question": "What are the main topics?",
"top_k": 5
}
```
**Response (201):**
```json
{
"query_id": "q-xyz789",
"document_id": "doc-abc123",
"strategy": "recursive",
"question": "What are the main topics?",
"answer": "The document covers insurance regulations...",
"retrieved_chunks": [
{
"chunk_id": "recursive_doc_000045",
"score": 0.892,
"text": " chunk content...",
"parent_id": null
}
],
"latency_breakdown": {
"embed_seconds": 0.15,
"search_seconds": 0.02,
"answer_seconds": 1.23,
"total_seconds": 1.40
},
"token_usage": {
"prompt_tokens": 1250,
"completion_tokens": 150,
"total_tokens": 1400
},
"created_at": "2026-07-25T12:00:00"
}
```
---
#### `GET /queries/{query_id}`
Retrieve a past query.
**Response:** Same as POST /queries response.
---
### Benchmarks
#### `POST /benchmarks`
Run a benchmark comparing multiple strategies.
**Request:**
```json
{
"document_id": "doc-abc123",
"strategies": ["recursive", "fixed_size", "semantic"],
"questions_file": "files/questions.json",
"top_k": 5,
"dry_run": false
}
```
**Response (201):**
```json
{
"experiment_id": "exp-abc123",
"document_id": "doc-abc123",
"strategies_used": ["recursive", "fixed_size", "semantic"],
"questions_count": 21,
"aggregate_metrics": {
"recursive": {
"avg_context_relevance": 8.5,
"avg_answer_similarity": 7.8,
"avg_faithfulness": 9.2,
"hallucination_rate": 0.05,
"total_questions": 21,
"failed_questions": 0
}
},
"best_strategy": "recursive",
"total_latency_seconds": 120.5,
"estimated_cost_usd": 0.22,
"created_at": "2026-07-25T12:00:00"
}
```
---
#### `GET /benchmarks/{experiment_id}`
Retrieve experiment results.
**Response:** Same as POST /benchmarks response.
---
#### `GET /benchmarks/{experiment_id}/report`
Generate HTML report.
**Query Parameters:**
- `view`: `managerial` (default) or `technical`
**Response:** HTML page
---
#### `GET /experiments`
List all experiments.
**Response:**
```json
{
"items": [
{
"id": "exp-abc123",
"document_id": "doc-abc123",
"strategies_used": ["recursive", "fixed_size"],
"created_at": "2026-07-25T12:00:00"
}
],
"total": 1,
"offset": 0,
"limit": 50
}
```
---
## Error Responses
All errors return:
```json
{
"detail": "Error message",
"type": "ErrorClassName"
}
```
| Status Code | Error Type | Description |
|-------------|------------|-------------|
| 400 | ChunkingError | Invalid request |
| 400 | QueryError | Query failed |
| 400 | BenchmarkError | Benchmark failed |
| 404 | QueryError | Resource not found |
| 422 | ValidationError | Invalid request body |
---
## Rate Limits
None configured. For production, consider adding rate limiting.
---
## Authentication
None configured. For production, add API key or OAuth2 authentication.

183
docs/architecture.md Normal file
View File

@@ -0,0 +1,183 @@
# Architecture Overview
System structure and design decisions for the RAG Chunking Benchmarker.
---
## System Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ RAG Chunking Benchmarker │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Documents │ │ Chunking │ │ Benchmarking│ │
│ │ Module │ │ Module │ │ Module │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Core Layer │ │
│ │ (config, models, exceptions, dependencies) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ SQLite │ │ Qdrant │ │ OpenAI │ │
│ │ (metadata) │ │ (vectors) │ │ (LLM/Emb) │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Module Structure
```
src/
├── core/ # Foundation layer
│ ├── config.py # Settings from .env
│ ├── models.py # Shared Pydantic models
│ ├── exceptions.py # Custom exceptions
│ └── dependencies.py # Client singletons
│
├── storage/ # Data persistence
│ ├── sqlite.py # Structured data (docs, queries, experiments)
│ └── qdrant.py # Vector embeddings
│
├── documents/ # Document handling
│ ├── parser.py # .docx/.doc → DocumentTree + markdown
│ ├── service.py # Upload, process, delete orchestration
│ ├── routes.py # REST API endpoints
│ └── models.py # Request/response schemas
│
├── chunking/ # Chunking strategies
│ ├── base.py # Abstract base class + utilities
│ ├── embedding.py # OpenAI embedding service
│ ├── service.py # Orchestration (chunk → embed → store)
│ └── strategies/ # Strategy implementations
│ ├── fixed_size.py
│ ├── recursive.py
│ ├── semantic.py
│ ├── contextual_retrieval.py
│ └── semantic_parent_child.py
│
├── benchmarking/ # Evaluation system
│ ├── evaluation.py # LLM-as-Judge scoring
│ ├── benchmark_service.py # Orchestration
│ ├── query_service.py # Single query pipeline
│ ├── routes.py # REST API endpoints
│ ├── models.py # Request/response schemas
│ └── report.py # HTML report generation
│
└── main.py # FastAPI app factory
```
---
## Data Flow
### Document Processing
```
Upload .docx → Parser → DocumentTree + Markdown → SQLite
↓
Process → Strategy.chunk() → Chunks → Embed → Qdrant
```
### Query Pipeline
```
Question → Embed → Qdrant Search → Top-K Chunks → LLM → Answer
```
### Benchmark Pipeline
```
Questions JSON → For each question × strategy:
├── Query Pipeline
└── LLM-as-Judge Evaluation
↓
Aggregate Metrics → SQLite → HTML Report
```
---
## Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| **One collection per strategy** | Enables independent comparison |
| **SQLite for metadata** | Simple, no setup, sufficient scale |
| **Qdrant for vectors** | Purpose-built for vector search |
| **OpenAI for embeddings** | Industry standard, consistent results |
| **LLM-as-Judge for evaluation** | Automated, consistent scoring |
| **Per-strategy failure isolation** | One strategy failure doesn't break others |
| **Text stored in Qdrant payload** | Enables retrieval without extra DB lookups |
---
## Technology Stack
| Component | Technology | Version |
|-----------|------------|---------|
| Framework | FastAPI | 0.100+ |
| ORM/DB | SQLite | built-in |
| Vector DB | Qdrant | 1.18+ |
| Embeddings | OpenAI text-embedding-3-small | - |
| LLM | OpenAI gpt-4o-mini | - |
| Tokenizer | tiktoken (cl100k_base) | - |
| PDF/DOCX | python-docx, LibreOffice | - |
---
## Configuration
All configuration via environment variables (`.env`):
```env
# Database
DATABASE_URL=sqlite:///./data/chunking_benchmark.db
QDRANT_URL=http://localhost:6333
QDRANT_API_KEY=
# OpenAI
OPENAI_API_KEY=sk-...
# Chunking
CHUNK_SIZE=512
CHUNK_OVERLAP=50
SEMANTIC_THRESHOLD=0.5
SEMANTIC_MIN_CHUNK_SIZE=5
# Models
EMBEDDING_MODEL=text-embedding-3-small
LLM_MODEL=gpt-4o-mini
```
---
## Error Handling
| Exception | Handler | HTTP Status |
|-----------|---------|-------------|
| ChunkingError | chunking_exception_handler | 400 |
| BenchmarkError | benchmark_exception_handler | 400 |
| QueryError | benchmark_exception_handler | 400 |
| DocumentProcessingError | chunking_exception_handler | 400 |
| QdrantError | chunking_exception_handler | 400 |
| EmbeddingError | chunking_exception_handler | 400 |
---
## Scaling Considerations
| Component | Current | Production Recommendation |
|-----------|---------|---------------------------|
| SQLite | Single file | PostgreSQL for concurrent access |
| Qdrant | Single instance | Qdrant Cloud or cluster |
| OpenAI | Direct API | Connection pooling, rate limiting |
| Workers | Single process | Celery for async processing |
| Caching | None | Redis for repeated queries |

53
docs/cant-do-yet.md Normal file
View File

@@ -0,0 +1,53 @@
# What You Can't Do Yet (But Backend Is Ready)
Features where the backend API is implemented and tested, but the dashboard UI does not yet expose them.
## 1. Chunk Preview Panel (Admin Tab)
**Backend**: `GET /admin/chunks/{doc_id}?strategy=...` returns all chunks for a document and strategy — chunk text (truncated to 500 chars), chunk index, token count, character count, parent_id.
**Missing UI**: No panel in the Admin tab to select a document + strategy and visualize the actual chunk text blocks, their sizes, and hierarchy.
## 2. Questions Dataset Management (Admin Tab)
**Backend**:
- `GET /admin/questions` — lists JSON files in `files/` with question counts
- `POST /admin/questions/upload` — uploads a new questions JSON file
- `GET /admin/questions/{file_id}` — returns full question set
- `DELETE /admin/questions/{file_id}` — deletes a question file
**Missing UI**: No section in the Admin tab to browse, upload, view, or delete question datasets. You can only use the default `files/questions.json` by typing its path in the Benchmarks tab.
## 3. Standalone Cost Estimator (Admin Tab)
**Backend**: `POST /admin/cost-estimate` accepts `num_questions` and `num_strategies`, returns full cost breakdown (embedding, queries, evaluation, token estimates, total USD).
**Missing UI**: No standalone cost estimation panel in the Admin tab. The Benchmarks tab has a "Cost Estimate" button, but it is tied to the benchmark form and requires a document selection.
## 4. Experiment Detail View (Benchmarks Tab)
**Backend**: `GET /benchmarks/{experiment_id}` returns full experiment data — per-question results, per-strategy scores, aggregate metrics, benchmark config.
**Missing UI**: Dashboard shows a list of experiments with a "Report" link that opens the raw HTML report in a new tab. No in-dashboard detail view with side-by-side strategy comparison tables, per-question drill-down, or visual charts.
## 5. Query History (Query Tab)
**Backend**: `GET /queries/{query_id}` retrieves any past query. All queries are stored in SQLite with full results (answer, retrieved chunks, latency, tokens).
**Missing UI**: No list of past queries. When you navigate away from the Query tab, the current result disappears from the screen. There is no history panel to browse, search, or re-view previous questions and answers.
## 6. Document Content Preview (Documents Tab)
**Backend**: `GET /documents` returns `parsed_text_preview` and `document_tree` fields in the detail response model (`DocumentDetailResponse`). SQLite stores the full parsed text and document tree.
**Missing UI**: You can upload and process documents, but you cannot preview the actual document text or the hierarchical document tree (sections, articles, paragraphs) inside the dashboard.
## 7. Real-Time Processing Status (Documents Tab)
**Backend**: The processing endpoint (`POST /documents/{id}/process`) returns per-strategy results — `strategies_completed` and `strategies_failed` with status, chunk counts, and error messages.
**Missing UI**: When you click "Process", the button shows a spinner. You don't see which strategies are running, which have completed, or which failed. If it fails partway through 5 strategies, you don't know which one broke without checking the server logs.
## 8. Global Error Handling and Retry
**Missing**: If the server returns an error (network down, Qdrant disconnected, OpenAI rate limit), you see a toast notification that disappears after 3 seconds. There is no retry button, no persistent error banner, and no automatic reconnection. A failed operation must be manually re-attempted by clicking the button again.

144
docs/chunking_strategies.md Normal file
View File

@@ -0,0 +1,144 @@
# Chunking Strategies
This document explains the 5 chunking strategies implemented in the RAG Chunking Strategy Benchmarking Framework.
---
## 1. FIXED_SIZE (Baseline)
**How it works:** Splits text into chunks of N tokens with M token overlap.
### Algorithm
1. Encode full markdown into tokens
2. Take first `chunk_size` tokens as chunk 1
3. Slide forward by `chunk_size - overlap` tokens
4. Repeat until end of text
### Pros & Cons
- **Pros:** Simple, predictable, fast, no dependencies
- **Cons:** Ignores meaning — can split mid-sentence, mid-word, or across topics
### Configuration
Uses `chunk_size` and `chunk_overlap` from settings.
---
## 2. RECURSIVE (Cascade Splitting)
**How it works:** Tries to split on meaningful boundaries first, falling back to less meaningful ones.
### Separator Cascade (in order)
1. Markdown headers (`#`, `##`, `###`)
2. Double newline (`\n\n`) — paragraph breaks
3. Single newline (`\n`) — line breaks
4. Sentence endings (`. ! ?` + space)
5. Space (word-level, last resort)
### Algorithm
1. Try splitting by highest-priority separator
2. If parts are still too big, recurse with next separator
3. Merge small parts back up to target size
### Pros & Cons
- **Pros:** Respects document structure, produces natural chunks
- **Cons:** Still rule-based, no semantic understanding
---
## 3. SEMANTIC (Similarity-Based)
**How it works:** Groups sentences by meaning — when similarity drops, it starts a new chunk.
### Algorithm
1. Split markdown into sentences
2. Embed each sentence via OpenAI (`text-embedding-3-small`)
3. Compute cosine similarity between adjacent sentences
4. When similarity < `semantic_threshold`, create chunk boundary
5. Enforce minimum chunk size (`semantic_min_chunk_size` sentences)
### Pros & Cons
- **Pros:** Respects topic changes, produces coherent chunks
- **Cons:** Requires embeddings at chunk-time (API calls), slower, costs money
### Configuration
- `semantic_threshold` (default 0.5)
- `semantic_min_chunk_size` (default 5)
---
## 4. CONTEXTUAL_RETRIEVAL (LLM-Enriched)
**How it works:** Based on Anthropic's research — prepends a short context summary to each chunk before embedding.
### Algorithm
1. Split text using fixed-size token splitting (same as #1)
2. For each chunk, send surrounding text + chunk to LLM
3. LLM generates a 1-2 sentence context prefix
4. Enriched chunk = context prefix + original text
### Example Output
```
This section discusses insurance claim deadlines for property damage...
[Original chunk text about specific deadlines...]
```
### Pros & Cons
- **Pros:** Improved retrieval by 49% in Anthropic's benchmarks
- **Cons:** Expensive (1 LLM call per chunk), slowest strategy
### Configuration
Uses `llm_model` (gpt-4o-mini) for context generation.
---
## 5. SEMANTIC_PARENT_CHILD (Hierarchical)
**How it works:** Groups paragraphs into semantic clusters. Each cluster is a parent; each paragraph is a child.
### Algorithm
1. Split markdown into paragraphs
2. Embed each paragraph
3. Cluster consecutive paragraphs by similarity (threshold-based)
4. Each cluster = parent chunk (full cluster text)
5. Each paragraph = child chunk (linked to parent)
### Query-time Behavior
- Search finds child paragraph via vector match
- Return full parent cluster as context to LLM
### Pros & Cons
- **Pros:** Rich context, no headings needed, works on flat documents
- **Cons:** More storage (both parent + child vectors), complex retrieval
### Configuration
Uses `semantic_threshold` for clustering.
---
## Summary Table
| Strategy | Split Method | Requires LLM | Requires Embeddings | Speed | Cost |
|----------|--------------|--------------|---------------------|-------|------|
| fixed_size | Token count | No | No | Fast | Free |
| recursive | Separator cascade | No | No | Fast | Free |
| semantic | Similarity threshold | No | Yes (at chunk time) | Medium | Low |
| contextual_retrieval | Fixed-size + LLM context | Yes (per chunk) | No | Slow | High |
| semantic_parent_child | Similarity clustering | No | Yes (at chunk time) | Medium | Low |

164
docs/configuration.md Normal file
View File

@@ -0,0 +1,164 @@
# Configuration Guide
All settings and environment variables for the RAG Chunking Benchmarker.
---
## Environment Variables
Create a `.env` file in the project root:
```env
# ── Database ──────────────────────────────────────────────────────
# SQLite database path
DATABASE_URL=sqlite:///./data/chunking_benchmark.db
# Qdrant vector database
QDRANT_URL=http://localhost:6333
QDRANT_API_KEY=
# ── OpenAI ────────────────────────────────────────────────────────
# API key for embeddings and LLM
OPENAI_API_KEY=sk-your-key-here
# ── Chunking Parameters ───────────────────────────────────────────
# Fixed-size strategy
CHUNK_SIZE=512
CHUNK_OVERLAP=50
# Semantic strategy
SEMANTIC_THRESHOLD=0.5
SEMANTIC_MIN_CHUNK_SIZE=5
# ── Models ────────────────────────────────────────────────────────
# Embedding model (used for all strategies)
EMBEDDING_MODEL=text-embedding-3-small
# LLM model (used for contextual retrieval and evaluation)
LLM_MODEL=gpt-4o-mini
```
---
## Parameter Details
### Chunking Parameters
| Parameter | Type | Default | Range | Description |
|-----------|------|---------|-------|-------------|
| `CHUNK_SIZE` | int | 512 | 100-2000 | Target tokens per chunk |
| `CHUNK_OVERLAP` | int | 50 | 0-200 | Token overlap between chunks |
| `SEMANTIC_THRESHOLD` | float | 0.5 | 0.0-1.0 | Cosine similarity threshold for chunk boundaries |
| `SEMANTIC_MIN_CHUNK_SIZE` | int | 5 | 1-20 | Minimum sentences per semantic chunk |
### Model Parameters
| Parameter | Type | Default | Options | Description |
|-----------|------|---------|---------|-------------|
| `EMBEDDING_MODEL` | str | text-embedding-3-small | text-embedding-3-small, text-embedding-3-large | OpenAI embedding model |
| `LLM_MODEL` | str | gpt-4o-mini | gpt-4o-mini, gpt-4o | Model for contextual retrieval and evaluation |
---
## Default Values in Code
```python
# src/core/config.py
class Settings(BaseSettings):
# Database
database_url: str = "sqlite:///./data/chunking_benchmark.db"
qdrant_url: str = "http://localhost:6333"
qdrant_api_key: str = ""
# OpenAI
openai_api_key: str = ""
# Chunking
chunk_size: int = 512
chunk_overlap: int = 50
semantic_threshold: float = 0.5
semantic_min_chunk_size: int = 5
# Models
embedding_model: str = "text-embedding-3-small"
llm_model: str = "gpt-4o-mini"
```
---
## Effect of Parameters
### chunk_size
| Value | Chunks | Quality | Speed |
|-------|--------|---------|-------|
| 256 | More | Higher context loss | Faster |
| 512 | Balanced | Balanced | Balanced |
| 1024 | Fewer | Lower context loss | Slower |
### chunk_overlap
| Value | Context Preservation | Redundancy |
|-------|---------------------|------------|
| 0 | None | None |
| 50 | Moderate | Low |
| 100 | High | High |
### semantic_threshold
| Value | Chunk Size | Topic Sensitivity |
|-------|------------|-------------------|
| 0.3 | Larger | Less sensitive |
| 0.5 | Balanced | Balanced |
| 0.7 | Smaller | More sensitive |
---
## Environment-Specific Configs
### Development
```env
DATABASE_URL=sqlite:///./data/dev.db
QDRANT_URL=http://localhost:6333
LOG_LEVEL=DEBUG
```
### Production
```env
DATABASE_URL=postgresql://user:pass@localhost/dbname
QDRANT_URL=http://qdrant:6333
QDRANT_API_KEY=your-api-key
LOG_LEVEL=WARNING
```
### Testing
```env
DATABASE_URL=sqlite:///./data/test.db
QDRANT_URL=http://localhost:6333
CHUNK_SIZE=100
```
---
## Validation
Settings are validated on startup. Invalid values will cause:
```
pydantic.ValidationError: 1 validation error for Settings
```
Common errors:
| Error | Cause | Fix |
|-------|-------|-----|
| Missing OPENAI_API_KEY | No API key | Add to .env |
| Invalid QDRANT_URL | Wrong URL format | Check URL |
| CHUNK_SIZE < 1 | Too small | Increase value |

385
docs/data-flow.md Normal file
View File

@@ -0,0 +1,385 @@
# Data Flow
How data moves through the RAG Chunking Benchmarker system.
---
## Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ DATA FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ INGESTION │
│ ───────── │
│ .docx/.doc → Parser → DocumentTree + Markdown → SQLite │
│ │
│ PROCESSING │
│ ────────── │
│ Document → Strategy.chunk() → Chunks → Embed → Qdrant │
│ │
│ QUERYING │
│ ───────── │
│ Question → Embed → Qdrant Search → Chunks → LLM → Answer │
│ │
│ BENCHMARKING │
│ ─────────── │
│ Questions → (Query × Strategies) → Evaluation → Report │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 1. Document Ingestion
### Flow
```
User uploads .docx/.doc
↓
FastAPI receives file
↓
Parser extracts text blocks
↓
Builds DocumentTree (hierarchy)
↓
Generates markdown (flat text)
↓
Stores in SQLite
```
### Data Structures
**Input:** Binary file (.docx/.doc)
**Output:**
```python
ParseResult(
tree=DocumentTree, # Hierarchical structure
markdown=str, # Flat text for chunking
plain_text=str, # Raw text
paragraph_count=int # Number of text blocks
)
```
### Storage
**SQLite:**
```sql
INSERT INTO documents (id, filename, parsed_text, document_tree, created_at)
VALUES (?, ?, ?, ?, ?)
```
---
## 2. Document Processing
### Flow
```
User selects strategies
↓
For each strategy:
↓
Load DocumentTree + markdown from SQLite
↓
Strategy.chunk() → list[Chunk]
↓
Embed chunks via OpenAI
↓
Store in Qdrant collection
↓
Update chunk_counts in SQLite
```
### Data Structures
**Input:**
```python
{
"document_id": "doc-abc123",
"strategies": ["recursive", "fixed_size", "semantic"]
}
```
**Processing:**
```python
# For each strategy
chunks = strategy.chunk(
doc_name="insurance.docx",
tree=document_tree,
markdown=markdown_text
)
embeddings = embed_texts([chunk.text for chunk in chunks])
qdr.upsert_chunks(chunks, embeddings)
```
**Output:**
```python
{
"strategies_completed": [
{"strategy": "recursive", "chunks_produced": 218}
]
}
```
### Storage
**Qdrant:**
```python
{
"id": "uuid5(chunk_id)",
"vector": [0.023, -0.156, ...], # 1536 dimensions
"payload": {
"document_name": "insurance.docx",
"chunk_id": "recursive_doc_000045",
"strategy_name": "recursive",
"text": "chunk content...",
"token_count": 146,
"character_count": 205
}
}
```
---
## 3. Query Pipeline
### Flow
```
User asks question
↓
Embed question via OpenAI
↓
Search Qdrant for similar chunks
↓
Retrieve top-K chunks
↓
Build context from chunks
↓
Generate answer via LLM
↓
Store query result in SQLite
```
### Data Structures
**Input:**
```python
{
"document_id": "doc-abc123",
"strategy": "recursive",
"question": "What are the main topics?",
"top_k": 5
}
```
**Processing:**
```python
# 1. Embed question
question_embedding = embed_single(question)
# 2. Vector search
hits = qdr.search(
strategy="recursive",
query_vector=question_embedding,
top_k=5
)
# 3. Build context
context = "\n".join([hit.text for hit in hits])
# 4. Generate answer
answer = llm.generate(
system="Answer based on context...",
user=f"Context: {context}\nQuestion: {question}"
)
```
**Output:**
```python
{
"answer": "The document covers insurance regulations...",
"retrieved_chunks": [...],
"latency_breakdown": {...},
"token_usage": {...}
}
```
### Storage
**SQLite:**
```sql
INSERT INTO queries (id, document_id, strategy_name, question, answer,
retrieved_chunks, latency_breakdown, token_usage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
```
---
## 4. Benchmark Pipeline
### Flow
```
Load questions from JSON
↓
For each question × strategy:
↓
Run query pipeline
↓
Evaluate with LLM-as-Judge
↓
Store per-question results
↓
Aggregate metrics per strategy
↓
Store experiment in SQLite
↓
Generate HTML report
```
### Data Structures
**Input:**
```python
{
"document_id": "doc-abc123",
"strategies": ["recursive", "fixed_size"],
"questions_file": "files/questions.json"
}
```
**Processing:**
```python
# For each question
for question in questions:
for strategy in strategies:
# Query
result = run_query(question, strategy)
# Evaluate
scores = evaluate_single(
question=question,
context=result.retrieved_chunks,
expected=question.expected_answer,
generated=result.answer
)
# Store
per_question_results.append({...})
```
**Output:**
```python
{
"experiment_id": "exp-abc123",
"aggregate_metrics": {
"recursive": {
"avg_context_relevance": 8.5,
"avg_answer_similarity": 7.8,
"avg_faithfulness": 9.2,
"hallucination_rate": 0.05
}
},
"best_strategy": "recursive"
}
```
---
## 5. Report Generation
### Flow
```
Load experiment from SQLite
↓
Calculate rankings
↓
Generate HTML (managerial or technical view)
↓
Return HTML response
```
### Views
| View | Focus | Content |
|------|-------|---------|
| Managerial | Decision-making | KPIs, winner, recommendations |
| Technical | Observability | Detailed scores, token usage, per-question |
---
## Data Persistence
### SQLite Tables
| Table | Purpose | Key Fields |
|-------|---------|------------|
| documents | Uploaded documents | id, filename, parsed_text, document_tree |
| queries | Query results | id, document_id, question, answer |
| experiments | Benchmark results | id, document_id, aggregate_metrics |
### Qdrant Collections
| Collection | Purpose | Fields |
|------------|---------|--------|
| recursive_collection | Recursive strategy vectors | chunk_id, text, scores |
| fixed_size_collection | Fixed-size strategy vectors | chunk_id, text, scores |
| semantic_collection | Semantic strategy vectors | chunk_id, text, scores |
| contextual_retrieval_collection | Contextual strategy vectors | chunk_id, text, scores |
| semantic_parent_child_collection | Parent-child strategy vectors | chunk_id, text, parent_id |
---
## Data Transformation
### Document → Chunks
```
Input: Full document text (10,000 tokens)
Output: 50-300 chunks (100-500 tokens each)
Transformation:
- Tokenization
- Boundary detection
- Metadata enrichment
```
### Chunks → Vectors
```
Input: Chunk text
Output: 1536-dimensional vector
Transformation:
- Text → Tokens → Embedding API → Vector
```
### Question → Answer
```
Input: Question string
Output: Answer string + metadata
Transformation:
- Question → Embedding → Vector Search → Chunks → LLM → Answer
```
---
## Performance Characteristics
| Operation | Time | Tokens | Cost |
|-----------|------|--------|------|
| Document upload | ~2s | 0 | Free |
| Process (1 strategy) | ~5s | N/A | ~$0.001 (embeddings) |
| Process (5 strategies) | ~25s | N/A | ~$0.005 |
| Query | ~2s | ~1500 | ~$0.001 |
| Benchmark (21 questions) | ~120s | ~200,000 | ~$0.05 |

222
docs/evaluation-metrics.md Normal file
View File

@@ -0,0 +1,222 @@
# Evaluation Metrics
How scoring works in the benchmarking system.
---
## Overview
The system uses **LLM-as-Judge** evaluation to automatically score answer quality across 4 metrics.
---
## Metrics
### 1. Context Relevance (1-10)
**Question:** How relevant are the retrieved chunks to answering the question?
| Score | Meaning |
|-------|---------|
| 9-10 | Chunks directly answer the question |
| 7-8 | Chunks are highly relevant |
| 5-6 | Chunks are somewhat relevant |
| 3-4 | Chunks are partially relevant |
| 1-2 | Chunks are not relevant |
**What it measures:** Did the retrieval system find the right information?
---
### 2. Answer Similarity (1-10)
**Question:** How similar is the generated answer to the expected answer?
| Score | Meaning |
|-------|---------|
| 9-10 | Nearly identical to expected |
| 7-8 | Covers most key points |
| 5-6 | Covers some key points |
| 3-4 | Partially correct |
| 1-2 | Completely different |
**What it measures:** Did the system produce the right answer?
---
### 3. Faithfulness (1-10)
**Question:** Is the generated answer grounded in the retrieved context?
| Score | Meaning |
|-------|---------|
| 9-10 | Entirely based on context |
| 7-8 | Mostly based on context |
| 5-6 | Partially based on context |
| 3-4 | Some external knowledge used |
| 1-2 | Mostly external knowledge |
**What it measures:** Is the answer trustworthy?
---
### 4. Hallucination (boolean)
**Question:** Did the LLM invent information not in the context?
| Value | Meaning |
|-------|---------|
| false | Answer is grounded in context |
| true | Answer contains invented information |
**What it measures:** Is the answer fabricated?
---
## Scoring Process
### Step 1: Prepare Evaluation Context
```
Question: {user_question}
Context: {retrieved_chunks}
Expected Answer: {golden_answer}
Generated Answer: {system_answer}
```
### Step 2: Send to LLM-as-Judge
```python
prompt = f"""
Evaluate this RAG system output:
Question: {question}
Context: {context}
Expected: {expected}
Generated: {generated}
Return JSON:
{{
"context_relevance": <1-10>,
"answer_similarity": <1-10>,
"faithfulness": <1-10>,
"hallucination": <true/false>,
"reasoning": "<explanation>"
}}
"""
```
### Step 3: Parse Response
```python
scores = json.loads(llm_response)
# Validate ranges
for metric in ["context_relevance", "answer_similarity", "faithfulness"]:
scores[metric] = max(1, min(10, scores[metric]))
```
---
## Overall Score Calculation
Each strategy gets an overall score weighted by importance:
```python
overall = (
context_relevance * 0.3 + # 30% weight
answer_similarity * 0.4 + # 40% weight
faithfulness * 0.3 # 30% weight
) * (1 - hallucination_rate) # Hallucination penalty
```
### Why These Weights?
| Metric | Weight | Rationale |
|--------|--------|-----------|
| Answer Similarity | 40% | Most important - did we get the right answer? |
| Context Relevance | 30% | Found the right information |
| Faithfulness | 30% | Answer is trustworthy |
| Hallucination | Penalty | Fabricated info is unacceptable |
---
## Aggregate Metrics
Per strategy, we calculate:
| Metric | Calculation |
|--------|-------------|
| avg_context_relevance | mean of all context_relevance scores |
| avg_answer_similarity | mean of all answer_similarity scores |
| avg_faithfulness | mean of all faithfulness scores |
| hallucination_rate | count(hallucination=True) / total_questions |
| total_questions | number of questions evaluated |
| failed_questions | questions that errored |
---
## Example Output
```json
{
"recursive": {
"avg_context_relevance": 8.5,
"avg_answer_similarity": 7.8,
"avg_faithfulness": 9.2,
"hallucination_rate": 0.05,
"total_questions": 21,
"failed_questions": 0
}
}
```
---
## Interpretation Guide
### Good Scores
| Metric | Target | Meaning |
|--------|--------|---------|
| Context Relevance | ≥ 8 | Retrieval is accurate |
| Answer Similarity | ≥ 8 | Answers match expectations |
| Faithfulness | ≥ 9 | Answers are trustworthy |
| Hallucination Rate | ≤ 0.1 | Low fabrication rate |
### Warning Signs
| Metric | Warning | Meaning |
|--------|---------|---------|
| Context Relevance | < 6 | Retrieval needs improvement |
| Answer Similarity | < 6 | Answers are off-target |
| Faithfulness | < 7 | Model is adding external knowledge |
| Hallucination Rate | > 0.2 | High fabrication rate |
---
## Cost Considerations
Each evaluation uses:
| Component | Cost |
|-----------|------|
| LLM call (gpt-4o-mini) | ~$0.001 per evaluation |
| Input tokens | ~500 per evaluation |
| Output tokens | ~100 per evaluation |
**Total for 21 questions × 5 strategies:**
- 105 evaluations × $0.001 = **$0.105**
---
## Configuration
Evaluation parameters in `src/benchmarking/evaluation.py`:
```python
_EVALUATION_SYSTEM_PROMPT = "You are an expert evaluator..."
_EVALUATION_USER_PROMPT = "Evaluate this RAG system output..."
model = settings.llm_model # gpt-4o-mini
temperature = 0.0 # Deterministic
max_tokens = 500 # Response limit
```

46
docs/out-of-scope-v1.md Normal file
View File

@@ -0,0 +1,46 @@
# What's Completely Out of Scope for V1
Features that are intentionally excluded from the first version of the dashboard. These would require significant additional architecture, infrastructure, or design work that doesn't serve the core use case of a single-user localhost benchmarking tool.
## User Authentication and Multi-User Support
No login, no sessions, no role-based access control. The dashboard runs on localhost and is operated by a single user. Adding auth would introduce JWT/session management, user storage, and permission logic — none of which serve the current workflow.
## Exporting Results to CSV/PDF
No built-in export of experiment results, query logs, or benchmark comparisons to CSV, PDF, or Excel. The HTML report endpoint (`/benchmarks/{id}/report`) is the closest thing to an export. If you need raw data, use the API endpoints directly or query SQLite.
## Custom Chunking Parameters from the UI
You cannot change `chunk_size`, `chunk_overlap`, `semantic_threshold`, or `semantic_min_chunk_size` from the dashboard. These are hardcoded in `src/core/config.py` with defaults (512 tokens, 50 overlap, 0.3 threshold). Changing them requires editing the config and restarting the server. The UI uses whatever the server has configured.
## Streaming Responses
Answers from GPT-4o-mini appear all at once after the full response is generated. There is no Server-Sent Events (SSE) or WebSocket connection to stream the answer word-by-word as it is generated. The latency breakdown shows how long generation took, but you wait for the complete answer.
## Dark/Light Theme Toggle
The dashboard is locked to the custom dark theme (#111113 base, #eab308 amber accent). There is no theme switcher or light mode alternative. The design tokens are hardcoded as CSS custom properties in the HTML file.
## Mobile Responsive Layout
The dashboard is designed for desktop screens (max-width 1200px, tab bar, tables). It is not optimized for phones or tablets. Tables will overflow, the tab bar may wrap, and the drop zone will be small on mobile. A responsive redesign with breakpoints is not planned for V1.
## WebSocket for Real-Time Updates
No persistent WebSocket connections. All communication is via standard HTTP REST requests. This means:
- Processing status updates require polling or a full page refresh
- No live push when a benchmark completes
- No live updating of collection point counts as chunks are inserted
## Batch Operations
You cannot process multiple documents at once, run multiple benchmarks in parallel, or delete several documents simultaneously. Each operation targets a single document. If you need to process 10 documents, you click "Process" 10 times.
## Search Across Documents or Experiments
No full-text search or filtering within the dashboard. You cannot search for a specific question text, filter experiments by date range, or find documents by partial filename. The document list and experiment list show everything in chronological order with no search or filter controls.
## Configuration Management from the UI
You cannot view or edit the server configuration (OpenAI model, temperature, max_tokens, embedding model, Qdrant URL, database path) from the dashboard. The `/admin/config` endpoint was deferred to V1.1. All configuration is managed via the `.env` file and `src/core/config.py`.

115
docs/phases.md Normal file
View File

@@ -0,0 +1,115 @@
# Phases & Steps
Implementation plan for the RAG Chunking Strategy Benchmarking Framework.
Each step maps back to tasks in [tasks.md](tasks.md).
---
## Phase 1: Document Parsing + Storage
Foundation layer. Everything else depends on this.
| Step | What | Files | Status |
|------|------|-------|--------|
| 1 | SQLite Storage Layer | `src/storage/sqlite.py` | DONE |
| 2 | Qdrant Storage Layer | `src/storage/qdrant.py` | DONE |
| 3 | Document Parser | `src/documents/parser.py` | DONE |
| 4 | Document Routes + Service | `src/documents/routes.py`, `src/documents/service.py`, `src/documents/models.py` | DONE |
**Endpoints delivered:**
- `POST /documents` — upload .docx
- `POST /documents/{id}/process` — run strategies (stubbed)
- `DELETE /documents/{id}` — remove document + vectors
- `GET /strategies` — list all 5 strategies
---
## Phase 2: Chunking Strategies
The core evaluation targets. Each strategy is an independent module.
| Step | What | Files | Status |
|------|------|-------|--------|
| 5 | Base Strategy Interface | `src/chunking/base.py` | TODO |
| 6 | Five Strategy Implementations | `src/chunking/strategies/recursive.py`, `markdown_structure.py`, `semantic.py`, `parent_child.py`, `contextual_structure.py` | TODO |
| 7 | Embedding Service | `src/chunking/embedding.py` | TODO |
| 8 | Chunking Orchestration | `src/chunking/service.py` | TODO |
**ADR alignment:**
- ADR 0001: Unified Chunk model (nullable fields)
- ADR 0002: Hard-fail on contextual enrichment failure
- ADR 0003: Per-strategy failure isolation
- ADR 0005: Hardcoded article-level parents
- ADR 0011: Embed full enriched text for contextual
- ADR 0012: Sentence-level semantic with min chunk size
- ADR 0013: Leaf-level markdown chunks only
- ADR 0015: Direct API usage, no LangChain/LlamaIndex
**Delivers:** The `POST /documents/{id}/process` endpoint goes from stub to real — chunking, embedding, and Qdrant storage for all 5 strategies.
---
## Phase 3: Query Pipeline
Retrieval + generation. The bridge between storage and evaluation.
| Step | What | Files | Status |
|------|------|-------|--------|
| 9 | Query Service | `src/benchmarking/query_service.py` | TODO |
| 10 | Query Routes | `src/benchmarking/routes.py` | TODO |
**Endpoints delivered:**
- `POST /queries` — ask a question against a strategy
- `GET /queries/{id}` — retrieve past query
**Pipeline:** question → embed → vector search (Qdrant) → top_k chunks → gpt-4o-mini → answer + metadata.
---
## Phase 4: Benchmarking + Evaluation
The reason the project exists. Compare strategies head-to-head.
| Step | What | Files | Status |
|------|------|-------|--------|
| 11 | LLM-as-Judge Evaluation | `src/benchmarking/evaluation.py` | TODO |
| 12 | Benchmark Service | `src/benchmarking/benchmark_service.py` | TODO |
| 13 | Benchmark Routes | extends `src/benchmarking/routes.py` | TODO |
| 14 | HTML Report Template | `src/benchmarking/templates/report.html` | TODO |
**Endpoints delivered:**
- `POST /benchmarks` — run benchmark (single or dataset)
- `POST /benchmarks?dry_run=true` — cost estimate only
- `GET /benchmarks/{id}` — retrieve experiment results
- `GET /benchmarks/{id}/report` — download HTML report
**Evaluation metrics (ADR 0004):**
- Context Relevance (1–10)
- Answer Similarity (1–10)
- Faithfulness (1–10)
- Hallucination (bool)
**Modes:**
- Single question
- Dataset (questions.json)
- Dry run (cost estimation)
---
## Phase 5: Wiring + Verification
Polish, integration, and proof that it all works end-to-end.
| Step | What | Files | Status |
|------|------|-------|--------|
| 15 | Wire All Routers + Logging | `src/main.py` | TODO |
| 16 | End-to-End Smoke Test | test suite | TODO |
**Verification checklist:**
- Upload insurance .docx
- Process with all 5 strategies
- Ask 3 test questions
- Run mini benchmark (3 questions × 5 strategies)
- Verify HTML report renders
- Confirm per-strategy failure isolation (simulate one strategy failure)
- Confirm dry-run cost estimation matches actual cost within 10%

View File

@@ -0,0 +1,345 @@
# Strategy Technical Details
Deep technical explanation of each chunking strategy's implementation.
---
## 1. Fixed-Size Chunking
**File:** `src/chunking/strategies/fixed_size.py`
### How It Works
Splits text into fixed-size token chunks with overlap.
### Splitting Unit
**Token-level** using `tiktoken` (cl100k_base encoding).
### Algorithm
```
1. Encode full markdown → token array
2. Take first chunk_size tokens → chunk 1
3. Slide forward by (chunk_size - overlap) tokens
4. Repeat until end of text
```
### Parameters
| Parameter | Config Key | Default | Description |
|-----------|------------|---------|-------------|
| Chunk size | `chunk_size` | 512 | Tokens per chunk |
| Overlap | `chunk_overlap` | 50 | Token overlap between chunks |
### Code Logic
```python
tokens = _encoder.encode(text)
start = 0
while start < len(tokens):
end = min(start + chunk_size, len(tokens))
chunk_tokens = tokens[start:end]
chunks.append(_encoder.decode(chunk_tokens))
start = end - overlap
```
### Characteristics
- **Splitting boundary:** Arbitrary (mid-sentence possible)
- **Structure awareness:** None
- **Speed:** Fastest (no API calls)
- **Best for:** Baseline comparison, simple documents
---
## 2. Recursive Chunking
**File:** `src/chunking/strategies/recursive.py`
### How It Works
Cascade splitting using a separator hierarchy. Tries meaningful boundaries first, falls back to less meaningful ones.
### Splitting Unit
**Character-level** with regex patterns.
### Separator Cascade (Priority Order)
| Priority | Separator | Regex Pattern | Description |
|----------|-----------|---------------|-------------|
| 1 | Header | `^(#{1,6})\s+` | Markdown headers |
| 2 | Double newline | `\n\n` | Paragraph breaks |
| 3 | Single newline | `\n` | Line breaks |
| 4 | Sentence | `[.!?]\s+` | Sentence endings |
| 5 | Space | `\s+` | Word boundaries |
### Algorithm
```
1. Try splitting by highest-priority separator
2. If parts still too big → recurse with next separator
3. Merge small parts back up to target size
4. If single part exceeds target → hard split by character count
```
### Code Logic
```python
for sep_name, pattern in _SEPARATORS:
parts = pattern.split(text)
if len(parts) <= 1:
continue # this separator didn't split
# Merge parts back up to target_size
current = ""
for part in parts:
candidate = (current + " " + part).strip()
if len(candidate) <= target_size:
current = candidate
else:
chunks.append(current)
current = part
return chunks # Return on first successful split
```
### Characteristics
- **Splitting boundary:** Semantic (headers, paragraphs, sentences)
- **Structure awareness:** High (respects markdown structure)
- **Speed:** Fast (no API calls)
- **Best for:** Structured documents with clear hierarchy
---
## 3. Semantic Chunking
**File:** `src/chunking/strategies/semantic.py`
### How It Works
Groups sentences by semantic similarity. When similarity drops, starts a new chunk.
### Splitting Unit
**Sentence-level** with similarity-based boundaries.
### Algorithm
```
1. Split markdown into sentences
2. Embed each sentence via OpenAI (text-embedding-3-small)
3. Compute cosine similarity between adjacent sentences
4. When similarity < SEMANTIC_THRESHOLD → chunk boundary
5. Enforce SEMANTIC_MIN_CHUNK_SIZE (minimum sentences per chunk)
```
### Parameters
| Parameter | Config Key | Default | Description |
|-----------|------------|---------|-------------|
| Threshold | `semantic_threshold` | 0.5 | Similarity threshold for boundary |
| Min size | `semantic_min_chunk_size` | 5 | Minimum sentences per chunk |
### Code Logic
```python
sentences = split_sentences(markdown)
embeddings = embed_texts(sentences) # OpenAI API call
current_group = [sentences[0]]
for i in range(1, len(sentences)):
sim = cosine_similarity(embeddings[i-1], embeddings[i])
if sim < threshold and len(current_group) >= min_size:
# Topic shift → new chunk
chunks.append(" ".join(current_group))
current_group = [sentences[i]]
else:
current_group.append(sentences[i])
```
### Characteristics
- **Splitting boundary:** Semantic (topic changes)
- **Structure awareness:** Medium (sentence-level)
- **Speed:** Medium (requires embeddings)
- **Best for:** Documents with topic shifts, no clear structure
---
## 4. Contextual Retrieval
**File:** `src/chunking/strategies/contextual_retrieval.py`
### How It Works
Based on Anthropic's 2024 research. Prepends a short context summary to each chunk before embedding.
### Splitting Unit
**Token-level** (same as fixed-size).
### Algorithm
```
1. Split text using fixed-size token splitting
2. For each chunk:
a. Find surrounding text (500 chars before/after)
b. Send to LLM with context prompt
c. LLM generates 1-2 sentence context prefix
d. Enriched chunk = context + original text
3. Embed enriched chunks (not original)
```
### LLM Prompt
```
System: You are a document analysis assistant. Given a section of text
from a document, write a short context prefix (1-2 sentences) that
would help someone find this section later via search.
User:
Preceding text: {preceding_500_chars}
This section: {chunk_text}
Following text: {following_500_chars}
```
### Code Logic
```python
for chunk_text in raw_chunks:
# Find surrounding context
preceding = full_text[pos-500:pos]
following = full_text[pos+len:pos+len+500]
# Generate context prefix
context = llm.generate(
system=CONTEXT_PROMPT,
user=f"Preceding: {preceding}\nSection: {chunk_text}\nFollowing: {following}"
)
# Enrich chunk
enriched = f"{context}\n\n{chunk_text}"
chunks.append(enriched)
```
### Characteristics
- **Splitting boundary:** Token-based (like fixed-size)
- **Structure awareness:** None (relies on LLM for context)
- **Speed:** Slowest (1 LLM call per chunk)
- **Best for:** Improving retrieval quality, unstructured documents
---
## 5. Semantic Parent-Child
**File:** `src/chunking/strategies/semantic_parent_child.py`
### How It Works
Groups paragraphs into semantic clusters. Each cluster is a parent; each paragraph is a child.
### Splitting Unit
**Paragraph-level** with semantic clustering.
### Algorithm
```
1. Split markdown into paragraphs
2. Embed each paragraph
3. Cluster consecutive paragraphs by similarity
4. Each cluster = parent chunk (full cluster text)
5. Each paragraph = child chunk (linked to parent)
```
### Query-Time Behavior
```
1. Vector search finds matching child paragraph
2. Use parent_id to fetch parent cluster
3. Return both child + parent to LLM
```
### Parameters
| Parameter | Config Key | Default | Description |
|-----------|------------|---------|-------------|
| Threshold | `semantic_threshold` | 0.5 | Similarity threshold for clustering |
### Code Logic
```python
paragraphs = split_paragraphs(markdown)
embeddings = embed_texts(paragraphs)
# Cluster paragraphs
clusters = [[0]]
for i in range(1, len(paragraphs)):
sim = cosine_similarity(embeddings[i-1], embeddings[i])
if sim >= threshold:
clusters[-1].append(i) # Same cluster
else:
clusters.append([i]) # New cluster
# Create parent-child chunks
for cluster in clusters:
parent_text = "\n\n".join(paragraphs[i] for i in cluster)
parent_id = make_chunk_id(...)
for para_idx in cluster:
chunks.append(build_chunk(
text=paragraphs[para_idx],
parent_id=parent_id
))
```
### Characteristics
- **Splitting boundary:** Semantic (paragraph clusters)
- **Structure awareness:** Medium (paragraph-level)
- **Speed:** Medium (requires embeddings)
- **Best for:** Documents needing context, flat structure
---
## Comparison Table
| Strategy | Split Unit | Boundary Logic | API Calls | Speed | Best For |
|----------|------------|----------------|-----------|-------|----------|
| fixed_size | Token | Arbitrary | 0 | Fast | Baseline |
| recursive | Character | Structural | 0 | Fast | Structured docs |
| semantic | Sentence | Similarity | 1 (embeddings) | Medium | Topic shifts |
| contextual_retrieval | Token | LLM-generated | N (1 per chunk) | Slow | Retrieval quality |
| semantic_parent_child | Paragraph | Similarity | 1 (embeddings) | Medium | Context needed |
---
## Configuration Reference
All strategy parameters are in `src/core/config.py`:
```python
class Settings(BaseSettings):
chunk_size: int = 512
chunk_overlap: int = 50
semantic_threshold: float = 0.5
semantic_min_chunk_size: int = 5
embedding_model: str = "text-embedding-3-small"
llm_model: str = "gpt-4o-mini"
```
---
## ADR References
| ADR | Strategy | Decision |
|-----|----------|----------|
| ADR 0012 | semantic | Sentence-level with min chunk size |
| ADR 0015 | recursive | Direct API, no LangChain/LlamaIndex |
| ADR 0011 | contextual_retrieval | Embed enriched text, not original |
| ADR 0003 | all | Per-strategy failure isolation |

84
docs/tasks.md Normal file
View File

@@ -0,0 +1,84 @@
# Tasks
All tasks for the RAG Chunking Strategy Benchmarking Framework.
Each task maps to a Phase and Step in [phases.md](phases.md).
Status legend: `DONE` `IN_PROGRESS` `TODO`
## Phase 1 — Document Parsing + Storage
| # | Task | Status | Step |
|---|------|--------|------|
| 1 | Design FastAPI application architecture and project structure | DONE | — |
| 2 | Implement DOCX document loading and processing pipeline | DONE | 3 |
| 3 | Add configurable application settings and experiment parameters | DONE | 1 |
| 4 | Implement Qdrant vector database integration and collection management | DONE | 2 |
## Phase 2 — Chunking Strategies
| # | Task | Status | Step |
|---|------|--------|------|
| 5 | Create chunking strategy interface and abstraction layer | DONE | 5 |
| 6 | Implement Structure-Aware Markdown chunking strategy | DONE | 6 |
| 7 | Implement Recursive chunking strategy | DONE | 6 |
| 8 | Implement Semantic chunking strategy | DONE | 6 |
| 9 | Implement Parent-Child chunking strategy | DONE | 6 |
| 10 | Implement Contextual Structure-Aware chunking strategy | DONE | 6 |
| 11 | Implement OpenAI embedding service using text-embedding-3-small | DONE | 7 |
| 12 | Implement strategy-based document processing API | DONE | 8 |
## Phase 3 — Query Pipeline
| # | Task | Status | Step |
|---|------|--------|------|
| 13 | Implement question answering API with configurable chunking strategy selection | DONE | 9–10 |
## Phase 4 — Benchmarking + Evaluation
| # | Task | Status | Step |
|---|------|--------|------|
| 14 | Implement benchmarking pipeline for comparing chunking strategies | DONE | 12 |
| 15 | Implement RAG evaluation pipeline using GPT-4o-mini | DONE | 11 |
| 16 | Implement experiment tracking and result storage system | DONE | 12 |
| 17 | Create question-answer evaluation dataset from insurance regulation document | DONE | 12 |
| 18 | Implement HTML benchmark report generation system | DONE | 14 |
| 19 | Design HTML report structure for experiment comparison and visualization | DONE | 14 |
| 20 | Create background processing jobs for document ingestion and benchmarking | TODO | 12 |
## Phase 5 — Wiring + Verification
| # | Task | Status | Step |
|---|------|--------|------|
| 21 | Add logging, request tracking, and cost monitoring | DONE | 15 |
| 22 | Create API documentation and Swagger examples | DONE | 15 |
| 23 | Create automated tests for chunking strategies and RAG workflow | TODO | 16 |
| 24 | Validate end-to-end benchmarking workflow with insurance regulation dataset | TODO | 16 |
| 25 | Analyze and compare chunking strategy performance results | TODO | 16 |
## Phase 6 — Admin Dashboard (UI)
| # | Task | Status | Note |
|---|------|--------|------|
| 26 | Design dashboard architecture (ADR-0001 through ADR-0009) | DONE | Babel JSX, persistent tabs, dark theme, /app mount |
| 27 | Create frontend skeleton: index.html with tab bar, dark theme, design tokens | DONE | src/static/index.html, 889 lines |
| 28 | Wire FastAPI to serve dashboard at /app (StaticFiles mount) | DONE | src/main.py modified |
| 29 | Implement admin backend: /admin/health endpoint | DONE | src/admin/routes.py + service.py |
| 30 | Implement admin backend: /admin/qdrant/* CRUD endpoints | DONE | Collections list, create, delete, wipe points |
| 31 | Implement admin backend: /admin/chunks/{doc_id} chunk preview | DONE | Backend only, no frontend panel |
| 32 | Implement admin backend: /admin/questions/* CRUD endpoints | DONE | Backend only, no frontend panel |
| 33 | Implement admin backend: /admin/cost-estimate endpoint | DONE | Backend only, no frontend panel |
| 34 | Build Documents tab: upload, list, process, delete | DONE | Drag-drop, strategy checkboxes, all wired |
| 35 | Build Home tab: status cards + quick start guide | DONE | Documents count, experiments count, server status |
| 36 | Build Query tab: form + answer display + chunk table | DONE | Document/strategy/top_k selectors, question textarea |
| 37 | Build Benchmarks tab: run form + experiments list | DONE | Strategies checkboxes, cost estimate, report links |
| 38 | Build Admin tab: health cards + Qdrant collections panel | DONE | Health status, collection list with delete |
| 39 | Build Admin tab: chunk preview panel | TODO | Backend ready, UI not built |
| 40 | Build Admin tab: questions dataset management panel | TODO | Backend ready, UI not built |
| 41 | Build Admin tab: standalone cost estimator panel | TODO | Backend ready, UI not built |
| 42 | Build Query tab: past queries history panel | TODO | Backend storage exists, no UI list |
| 43 | Build Benchmarks tab: in-dashboard experiment detail view | TODO | Report link exists, no drill-down UI |
| 44 | Build Documents tab: document content preview | TODO | Parsed text stored, no preview panel |
| 45 | Add real-time processing progress indicator | TODO | Spinner only, no per-strategy progress |
| 46 | Add global error handling and retry logic | TODO | Toast only, no retry button or banner |
| 47 | Create docs: cant-do-yet.md (backend ready, no UI) | DONE | docs/cant-do-yet.md |
| 48 | Create docs: out-of-scope-v1.md (intentionally excluded) | DONE | docs/out-of-scope-v1.md |

179
files/questions.json Normal file
View File

@@ -0,0 +1,179 @@
{
"metadata": {
"document": "چت بات-مهندسی.doc",
"description": "Questions for evaluating chunking strategies on engineering insurance document (بیمه‌های مهندسی)",
"version": "1.0",
"created_at": "2026-07-26",
"author": "RAG Benchmarker"
},
"questions": [
{
"id": "q1",
"question": "این سند در مورد چیست؟",
"expected_keywords": ["بیمه", "مهندسی", "اطلاعات", "مدیریت"],
"expected_answer": "این سند شامل اطلاعات مدیریت بیمه‌های مهندسی به منظور استفاده در سامانه هوشمند فناوری اطلاعات (چت بات) است و تعاریف، اصطلاحات و مفاهیم بیمه‌های مهندسی را پوشش می‌دهد.",
"category": "factual",
"difficulty": "easy"
},
{
"id": "q2",
"question": "سند از چه بخش‌هایی تشکیل شده است؟",
"expected_keywords": ["بخش اول", "بخش دوم", "بخش سوم", "بخش چهارم", "بخش پنجم"],
"expected_answer": "سند از پنج بخش تشکیل شده: کلیات و مفاهیم بیمه‌های مهندسی، اصطلاحات و تعاریف عمومی، دست‌اندرکاران اجرای پروژه، اجزای تشکیل‌دهنده بیمه، و توضیح اصطلاحات فنی.",
"category": "factual",
"difficulty": "easy"
},
{
"id": "q3",
"question": "اولین بیمه مهندسی در ایران در چه سالی صادر شد؟",
"expected_keywords": ["1342", "سال", "اولین"],
"expected_answer": "اولین بیمه مهندسی در ایران در سال 1342 صادر گردید.",
"category": "factual",
"difficulty": "easy"
},
{
"id": "q4",
"question": "شرکت بیمه دیگ بخار در چه سالی تأسیس شد؟",
"expected_keywords": ["1858", "شرکت", "بیمه", "دیگ بخار"],
"expected_answer": "شرکت بیمه دیگ بخار در سال 1858 میلادی تأسیس شد که نخستین شرکت بیمه در زمینه بیمه‌های مهندسی بود.",
"category": "factual",
"difficulty": "easy"
},
{
"id": "q5",
"question": "بیمه‌های مهندسی به چه سه دسته کلی تقسیم می‌شوند؟",
"expected_keywords": ["در حال احداث", "در حال بهره‌برداری", "خاص"],
"expected_answer": "بیمه‌های مهندسی به سه دسته کلی تقسیم می‌شوند: بیمه‌نامه‌های در حال احداث (Construction)، بیمه‌نامه‌های در حال بهره‌برداری (Operation)، و بیمه‌نامه‌های خاص.",
"category": "factual",
"difficulty": "easy"
},
{
"id": "q6",
"question": "چه خطراتی در بیمه‌های مهندسی تحت پوشش طبیعی هستند؟",
"expected_keywords": ["سیل", "زلزله", "طوفان", "طبیعی"],
"expected_answer": "خطرات طبیعی شامل سیل، زلزله، طوفان، طغیان آب، باران، برف، آتشفشان و مواردی است که انسان در به وجود آمدن آن نقشی ندارد.",
"category": "factual",
"difficulty": "easy"
},
{
"id": "q7",
"question": "نام سازمانی که در سال 1854 در منچستر تأسیس شد چیست؟",
"expected_keywords": ["سازمان", "استفاده‌کنندگان", "دیگ بخار", "منچستر"],
"expected_answer": "سازمان استفاده‌کنندگان از دیگ‌های بخار در سال 1854 در شهر منچستر انگلیس تأسیس شد.",
"category": "factual",
"difficulty": "easy"
},
{
"id": "q8",
"question": "تفاوت قراردادهای EPC و PC در پروژه‌های مهندسی چیست؟",
"expected_keywords": ["طراحی", "تهیه", "احداث", "کلیدگردان"],
"expected_answer": "در قرارداد PC پیمانکار علاوه بر ساخت، تهیه و خریداری مصالح را نیز بر عهده دارد، اما در قرارداد EPC پیمانکار کلیه کارهای طراحی، تدارکات و ساخت را اجرا می‌کند و پروژه کاملاً آماده بهره‌برداری تحویل می‌دهد. اصطلاحاً EPC را کلیدگردان نیز می‌نامند.",
"category": "comparative",
"difficulty": "medium"
},
{
"id": "q9",
"question": "ماده 21 شرایط عمومی پیمان شامل چه مواردی است؟",
"expected_keywords": ["حفاظت", "شخص ثالث", "بیمه کار", "مراقبت"],
"expected_answer": "ماده 21 شامل حفاظت از کار و شخص ثالث، بیمه کار، و مراقبت‌های لازم است. پیمانکار از روز تحویل کارگاه تا روز تحویل موقت مسئول حفظ و نگهداری مصالح، تجهیزات، ماشین‌آلات و کارها در مقابل عوامل جوی، سرقت، حریق و مانند آن است.",
"category": "procedural",
"difficulty": "medium"
},
{
"id": "q10",
"question": "پوشش‌های بیمه CAR شامل چه مواردی است؟",
"expected_keywords": ["فیزیکی", "ناگهانی", "غیرقابل پیش‌بینی", "تمام خطر"],
"expected_answer": "پوشش‌های بیمه CAR شامل کلیه خطرات و حوادثی است که دارای سه مشخصه فیزیکی، ناگهانی و غیرقابل پیش‌بینی باشند، به جزء آنچه صراحتاً در بیمه‌نامه مستثنی شده است. این پوشش‌ها شامل صاعقه، انفجار، آتش‌سوزی، زلزله، سرقت و موارد دیگر است.",
"category": "factual",
"difficulty": "medium"
},
{
"id": "q11",
"question": "فرانشیز بیمه CAR چگونه محاسبه می‌شود؟",
"expected_keywords": ["درصد", "حداقل", "10 تا 20", "طبیعی", "سایر"],
"expected_answer": "فرانشیز معمولاً از دو قسمت درصد و حداقل تشکیل شده است. درصد فرانشیز بسته به ارزیابی بیمه‌گر از ریسک بین 10 تا 20 درصد متغیر بوده و حداقل فرانشیز معمولاً بین 1 تا 2 درصد سرمایه مورد بیمه است. در بیمه‌های دوره احداث فرانشیز به دو دسته فرانشیز خطرات طبیعی و فرانشیز سایر خطرات تقسیم می‌گردد.",
"category": "procedural",
"difficulty": "medium"
},
{
"id": "q12",
"question": "استثنائات بیمه CAR به چه سه قسمت تقسیم می‌شوند؟",
"expected_keywords": ["عمومی", "ویژه بخش یک", "ویژه بخش دو"],
"expected_answer": "استثنائات بیمه CAR به سه قسمت استثنائات عمومی، استثنائات ویژه بخش یک (خسارات مادی)، و استثنائات ویژه بخش دو (مسئولیت مدنی کارفرما در قبال اشخاص ثالث) تقسیم می‌گردد.",
"category": "factual",
"difficulty": "medium"
},
{
"id": "q13",
"question": "بیمه CPM چه تفاوتی با بیمه اتومبیل دارد؟",
"expected_keywords": ["پلاک انتظامی", "متحرک", "ثابت", "پیمانکاری"],
"expected_answer": "بیمه CPM برای پوشش بدنه ماشین‌آلات و تجهیزات ثابت یا متحرک فاقد پلاک انتظامی طراحی شده است. ماشین‌آلات متحرک دارای پلاک انتظامی تحت پوشش بیمه‌های اتومبیل هستند و از مبحث بیمه‌های مهندسی خارج می‌باشند.",
"category": "comparative",
"difficulty": "medium"
},
{
"id": "q14",
"question": "مدارک مورد نیاز جهت صدور بیمه‌نامه تمام خطر مقاطعه‌کاری شامل چه مواردی است؟",
"expected_keywords": ["پرسشنامه", "قرارداد", "نقشه", "صورت ریز"],
"expected_answer": "مدارک مورد نیاز شامل: 1) پرسشنامه تکمیل شده، 2) کپی قرارداد یا پیمان، 3) نقشه کلی کار، 4) صورت ریز اقلام و مصالح، 5) جدول زمان‌بندی کار است.",
"category": "procedural",
"difficulty": "medium"
},
{
"id": "q15",
"question": "الحاقیه‌ها به چه سه گروه تقسیم می‌شوند و هر کدام چه زمانی صادر می‌شوند؟",
"expected_keywords": ["اضافی", "برگشتی", "اصلاحی", "افزایش", "کاهش", "تغییر"],
"expected_answer": "الحاقیه‌ها به سه گروه تقسیم می‌شوند: 1) الحاقیه‌های اضافی - وقتی تغییرات موجب افزایش حق بیمه شود (مثل افزایش سرمایه)، 2) الحاقیه‌های برگشتی - وقتی تغییرات موجب کاهش حق بیمه شود (مثل کاهش سرمایه یا فسخ)، 3) الحاقیه‌های اصلاحی - وقتی تغییرات بدون تأثیر بر حق بیمه باشد (مثل تغییر نام).",
"category": "procedural",
"difficulty": "hard"
},
{
"id": "q16",
"question": "در صورت توقف پروژه، بیمه‌گزار چه اقداماتی باید انجام دهد و چه پوشش‌هایی قابل ارائه است؟",
"expected_keywords": ["توقف", "الحاقیه", "تعلیق", "ریسک‌های خاموش", "آتش‌سوزی", "سرقت"],
"expected_answer": "در صورت توقف پروژه، بیمه‌گزار باید بلافاصله به بیمه‌گر اطلاع دهد. بیمه‌گر با صدور الحاقیه تعلیق، پوشش بیمه‌ای را متوقف کرده یا ریسک‌های خاموش (Silent Risks) شامل پوشش‌های محدود مانند آتش‌سوزی، سرقت و حوادث طبیعی را با حق بیمه جداگانه ارائه می‌دهد. پس از اتمام توقف نیز باید الحاقیه دیگری برای ادامه پوشش تمام خطر صادر شود.",
"category": "complex",
"difficulty": "hard"
},
{
"id": "q17",
"question": "تفاوت بیمه‌نامه‌های تمام خطر و بیمه‌نامه‌های خطرات معین (Named Perils) چیست و هر کدام چه مزایایی دارند؟",
"expected_keywords": ["تمام خطر", "استثنائات", "危险ات معین", "پوشش", "محدود"],
"expected_answer": "در بیمه تمام خطر تمامی خطرات تحت پوشش است به جز استثنائاتی که در بیمه‌نامه ذکر شده و فقط استثنائات ذکر می‌شوند. در بیمه خطرات معین، خطرات تحت پوشش در بیمه‌نامه قید می‌گردد. مزیت تمام خطر پوشش گسترده‌تر و نیاز به خرید بیمه‌نامه‌های متعدد نیست، اما خطرات معین ممکن است حق بیمه کمتری داشته باشد.",
"category": "complex",
"difficulty": "hard"
},
{
"id": "q18",
"question": "محاسبه خسارت در بیمه ماشین‌آلات پیمانکاری (CPM) در حالت خسارت کلی و جزئی چگونه است؟",
"expected_keywords": ["ارزش جایگزینی", "استهلاک", "کلی", "جزئی", "75 درصد"],
"expected_answer": "در خسارت جزئی اگر دستگاه نو باشد، ارزش جایگزینی نو قطعات خسارت‌دیده بدون کسر استهلاک پرداخت می‌شود. اگر مستعمل باشد استهلاک کسر می‌گردد. در خسارت کلی معادل ارزش جایگزینی نو ماشین خسارت‌دیده پرداخت می‌شود اما استهلاک کسر خواهد شد. اگر هزینه تعمیر از 75% ارزش مورد بیمه تجاوز کند، خسارت کلی تلقی می‌گردد.",
"category": "complex",
"difficulty": "hard"
},
{
"id": "q19",
"question": "شرایط و مراحل پرداخت خسارت بیمه‌ای و تعهدات بیمه‌گزار پس از بروز حادثه چیست؟",
"expected_keywords": ["اعلام", "بازدید", "صورتجلسه", "مستندات", "14 روز"],
"expected_answer": "بیمه‌گزار پس از حادثه باید: 1) از توسعه خسارت جلوگیری کند، 2) حادثه را فوراً اطلاع دهد، 3) ظرف مهلت مقرر (معمولاً 14 روز) خسارت را اعلام کند، 4) قسمت‌های خسارت‌دیده را حفظ کند، 5) مستندات مورد نیاز را ارائه دهد. سپس کارشناسان از محل بازدید و صورتجلسه تهیه می‌کنند و پس از بررسی مستندات، خسارت قابل پرداخت را اعلام می‌نمایند.",
"category": "complex",
"difficulty": "hard"
},
{
"id": "q20",
"question": "در بیمه تضمین کیفیت ساختمان (LDB)، چه سازه‌هایی تحت پوشش قرار می‌گیرند و مدت پوشش هر کدام چقدر است؟",
"expected_keywords": ["سازه اصلی", "سازه جانبی", "ده سال", "پنج سال", "سه سال"],
"expected_answer": "سازه‌های اصلی (حدود 35% مبلغ کار) شامل پی‌ها، ستون‌ها، کف‌ها، تیرها و سقف‌ها به مدت 10 سال، نمای ساختمان 5 سال، عایق‌های رطوبتی 5 سال، و تجهیزات و تأسیسات مکانیکی و برقی و آسانسورها 3 سال تحت پوشش هستند. سازه‌های جانبی (حدود 65% مبلغ کار) شامل اجزای غیرباربر مانند در و پنجره‌ها، گچکاری و کاشی‌کاری است.",
"category": "complex",
"difficulty": "hard"
},
{
"id": "q21",
"question": "موارد ابطال بیمه‌نامه توسط بیمه‌گر و تفاوت آن با فسخ بیمه‌نامه چیست؟",
"expected_keywords": ["ابطال", "فسخ", "کتمان حقیقت", "خسارت عمدی", "جعلی", "استرداد"],
"expected_answer": "ابطال بیمه‌نامه توسط بیمه‌گر به صورت یک‌جانبه در موارد زیر انجام می‌شود: 1) کتمان حقیقت یا اظهارات خلاف واقع، 2) ایجاد خسارات عمدی، 3) ایجاد خسارات جعلی. در ابطال حق بیمه‌ای به بیمه‌گزار عودت داده نمی‌شود. اما در فسخ (توافقی یا یک‌طرفه)، بیمه‌گر باید حق بیمه مدت باقیمانده را پس از کسر هزینه‌های کارشناسی برگرداند. مواردی مانند انصراف در همان روز صدور یا صدور مضاعف نیز با عودت کامل حق بیمه همراه است.",
"category": "complex",
"difficulty": "hard"
}
]
}

1
src/admin/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Admin router — system health, Qdrant CRUD, chunk preview, questions, cost estimation."""

84
src/admin/routes.py Normal file
View File

@@ -0,0 +1,84 @@
"""Admin API routes — system health, Qdrant management, chunk preview, questions, cost."""
import os
from fastapi import APIRouter, UploadFile, File
from src.admin import service
router = APIRouter(prefix="/admin")
# ── Health ──────────────────────────────────────────────────
@router.get("/health")
async def health_check():
"""Return system health: server status, Qdrant connectivity, SQLite status."""
return service.get_health()
# ── Qdrant Collections ─────────────────────────────────────
@router.get("/qdrant/collections")
async def list_qdrant_collections():
"""List all Qdrant collections with point counts."""
return service.list_qdrant_collections()
@router.post("/qdrant/collections/{collection_name}")
async def create_qdrant_collection(collection_name: str):
"""Create a new Qdrant collection."""
return service.create_qdrant_collection(collection_name)
@router.delete("/qdrant/collections/{collection_name}")
async def delete_qdrant_collection(collection_name: str):
"""Delete a Qdrant collection entirely."""
return service.delete_qdrant_collection(collection_name)
@router.delete("/qdrant/collections/{collection_name}/points")
async def wipe_qdrant_collection_points(collection_name: str):
"""Delete all points in a collection (keep collection structure)."""
return service.wipe_qdrant_collection_points(collection_name)
# ── Chunk Preview ───────────────────────────────────────────
@router.get("/chunks/{doc_id}")
async def preview_chunks(doc_id: str, strategy: str | None = None):
"""Preview all chunks for a document, optionally filtered by strategy."""
return service.preview_chunks(doc_id, strategy)
# ── Questions Dataset ───────────────────────────────────────
@router.get("/questions")
async def list_question_files():
"""List available question JSON files on disk."""
return service.list_question_files()
@router.post("/questions/upload")
async def upload_questions(file: UploadFile = File(...)):
"""Upload a questions JSON file to the project."""
content = await file.read()
return service.upload_questions(file.filename, content)
@router.get("/questions/{file_id}")
async def get_questions(file_id: str):
"""Get full content of a questions file."""
return service.get_questions(file_id)
@router.delete("/questions/{file_id}")
async def delete_questions(file_id: str):
"""Delete a questions file from disk."""
return service.delete_questions(file_id)
# ── Cost Estimation ─────────────────────────────────────────
@router.post("/cost-estimate")
async def cost_estimate(num_questions: int, num_strategies: int):
"""Estimate cost for a benchmark run without executing."""
return service.estimate_cost(num_questions, num_strategies)

275
src/admin/service.py Normal file
View File

@@ -0,0 +1,275 @@
"""Admin service — health checks, Qdrant management, chunk preview, questions CRUD, cost estimation."""
import json
import logging
import os
from pathlib import Path
from typing import Any
from qdrant_client.models import VectorParams, Distance
from src.core.dependencies import get_qdrant_client, get_openai_client
from src.core.config import settings
from src.core.models import StrategyName
from src.storage import qdrant as qdrant_store
from src.storage import sqlite as db
logger = logging.getLogger(__name__)
# Project root (two levels up from src/admin/)
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
QUESTIONS_DIR = PROJECT_ROOT / "files"
# ── Health ──────────────────────────────────────────────────
def get_health() -> dict[str, Any]:
"""Check server, Qdrant, and SQLite status."""
result: dict[str, Any] = {"status": "ok"}
# Check Qdrant
try:
client = get_qdrant_client()
collections = client.get_collections()
result["qdrant_connected"] = True
result["qdrant_collections"] = len(collections.collections)
except Exception as exc:
result["qdrant_connected"] = False
result["qdrant_error"] = str(exc)
logger.warning("Qdrant health check failed: %s", exc)
# Check SQLite
try:
conn = db._connect()
conn.execute("SELECT 1")
conn.close()
result["sqlite_ok"] = True
except Exception as exc:
result["sqlite_ok"] = False
result["sqlite_error"] = str(exc)
logger.warning("SQLite health check failed: %s", exc)
# Check OpenAI
try:
client = get_openai_client()
# Just check the client exists; don't make a real API call
result["openai_configured"] = bool(settings.openai_api_key)
except Exception:
result["openai_configured"] = False
return result
# ── Qdrant Collections ─────────────────────────────────────
def list_qdrant_collections() -> dict[str, Any]:
"""List all Qdrant collections with their point counts."""
client = get_qdrant_client()
collections_data = client.get_collections().collections
result = []
for col in collections_data:
try:
info = client.get_collection(collection_name=col.name)
result.append({
"name": col.name,
"points_count": info.points_count or 0,
})
except Exception as exc:
result.append({
"name": col.name,
"points_count": None,
"error": str(exc),
})
return {"collections": result}
def create_qdrant_collection(collection_name: str) -> dict[str, Any]:
"""Create a new Qdrant collection."""
client = get_qdrant_client()
existing = [c.name for c in client.get_collections().collections]
if collection_name in existing:
return {"created": False, "message": f"Collection '{collection_name}' already exists"}
client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(
size=qdrant_store.VECTOR_DIMENSION,
distance=Distance.COSINE,
),
)
logger.info("Created Qdrant collection: %s", collection_name)
return {"created": True, "collection": collection_name}
def delete_qdrant_collection(collection_name: str) -> dict[str, Any]:
"""Delete a Qdrant collection entirely."""
client = get_qdrant_client()
client.delete_collection(collection_name=collection_name)
logger.info("Deleted Qdrant collection: %s", collection_name)
return {"deleted": True, "collection": collection_name}
def wipe_qdrant_collection_points(collection_name: str) -> dict[str, Any]:
"""Delete all points in a collection but keep the collection."""
client = get_qdrant_client()
from qdrant_client.models import PointIdsList
info = client.get_collection(collection_name=collection_name)
count = info.points_count or 0
if count == 0:
return {"deleted": 0, "collection": collection_name}
client.delete(
collection_name=collection_name,
points_selector=PointIdsList(points=list(range(count))),
)
logger.info("Wiped %d points from %s", count, collection_name)
return {"deleted": count, "collection": collection_name}
# ── Chunk Preview ───────────────────────────────────────────
def preview_chunks(doc_id: str, strategy: str | None = None) -> dict[str, Any]:
"""Preview chunks for a document. Uses Qdrant scroll to fetch chunks with payload."""
client = get_qdrant_client()
from qdrant_client.models import Filter, FieldCondition, MatchValue
# Get document info from SQLite
doc = db.get_document(doc_id)
if doc is None:
return {"error": f"Document not found: {doc_id}"}
doc_name = doc.get("filename", "")
# Determine which collections to search
strategies_to_search = []
if strategy:
strategies_to_search = [strategy]
else:
strategies_to_search = [s.value for s in StrategyName]
results = {}
for strat_name in strategies_to_search:
col_name = f"{strat_name}_collection"
try:
existing = [c.name for c in client.get_collections().collections]
if col_name not in existing:
results[strat_name] = {"chunks": [], "count": 0}
continue
scroll_filter = Filter(
must=[FieldCondition(key="document_name", match=MatchValue(value=doc_name))]
)
points, _ = client.scroll(
collection_name=col_name,
scroll_filter=scroll_filter,
limit=10000,
with_payload=True,
with_vectors=False,
)
chunks = []
for p in points:
payload = p.payload or {}
chunks.append({
"chunk_id": payload.get("chunk_id", str(p.id)),
"chunk_index": payload.get("chunk_index"),
"text": (payload.get("text") or "")[:500],
"token_count": payload.get("token_count"),
"character_count": payload.get("character_count"),
"parent_id": payload.get("parent_id"),
})
# Sort by chunk_index
chunks.sort(key=lambda c: c.get("chunk_index") or 0)
results[strat_name] = {"chunks": chunks, "count": len(chunks)}
except Exception as exc:
results[strat_name] = {"error": str(exc), "chunks": [], "count": 0}
return {"document_id": doc_id, "filename": doc_name, "strategies": results}
# ── Questions Dataset ───────────────────────────────────────
def list_question_files() -> dict[str, Any]:
"""List JSON files in the files/ directory."""
QUESTIONS_DIR.mkdir(parents=True, exist_ok=True)
files = []
for f in sorted(QUESTIONS_DIR.glob("*.json")):
try:
with open(f, encoding="utf-8") as fh:
data = json.load(fh)
count = len(data.get("questions", []))
except Exception:
count = -1
files.append({
"id": f.name,
"name": f.name,
"questions_count": count,
"size_bytes": f.stat().st_size,
})
return {"files": files}
def upload_questions(filename: str, content: bytes) -> dict[str, Any]:
"""Save a questions JSON file to the files/ directory."""
QUESTIONS_DIR.mkdir(parents=True, exist_ok=True)
# Validate JSON
try:
data = json.loads(content.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
return {"error": f"Invalid JSON: {exc}"}
if "questions" not in data and not isinstance(data, list):
return {"error": "Invalid format: must have a 'questions' key or be a list"}
# Ensure filename ends with .json
if not filename.endswith(".json"):
filename = filename + ".json"
dest = QUESTIONS_DIR / filename
dest.write_bytes(content)
logger.info("Uploaded questions file: %s", dest)
return {"uploaded": True, "id": filename, "questions_count": len(data.get("questions", []) if isinstance(data, dict) else data)}
def get_questions(file_id: str) -> dict[str, Any]:
"""Read and return the content of a questions file."""
path = QUESTIONS_DIR / file_id
if not path.exists():
return {"error": f"File not found: {file_id}"}
with open(path, encoding="utf-8") as f:
data = json.load(f)
return {"id": file_id, "data": data}
def delete_questions(file_id: str) -> dict[str, Any]:
"""Delete a questions JSON file."""
path = QUESTIONS_DIR / file_id
if not path.exists():
return {"error": f"File not found: {file_id}"}
path.unlink()
logger.info("Deleted questions file: %s", path)
return {"deleted": True, "id": file_id}
# ── Cost Estimation ─────────────────────────────────────────
def estimate_cost(num_questions: int, num_strategies: int) -> dict[str, Any]:
"""Estimate benchmark cost. Delegates to benchmark service."""
from src.benchmarking.benchmark_service import estimate_cost as bench_estimate
return bench_estimate(num_questions, num_strategies)

View File

@@ -0,0 +1 @@
"""Benchmarking module: query pipeline and evaluation."""

View File

@@ -0,0 +1,358 @@
"""Benchmark service: orchestrates full benchmark runs.
Runs multiple questions against multiple strategies, evaluates answers,
and stores results for comparison.
"""
from __future__ import annotations
import json
import logging
import time
from pathlib import Path
from typing import Any
from src.benchmarking.evaluation import evaluate_single
from src.benchmarking.query_service import run_query
from src.core.config import settings
from src.core.exceptions import BenchmarkError
from src.core.models import StrategyName
from src.storage import sqlite as db
logger = logging.getLogger(__name__)
# ── Questions Loading ─────────────────────────────────────────────
def load_questions(file_path: str | Path) -> list[dict]:
"""Load questions from a JSON file.
Args:
file_path: Path to questions.json
Returns:
List of question dicts
Raises:
BenchmarkError: If file not found or invalid
"""
path = Path(file_path)
if not path.exists():
raise BenchmarkError(f"Questions file not found: {path}")
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
if "questions" not in data:
raise BenchmarkError("Invalid questions file: missing 'questions' key")
questions = data["questions"]
logger.info("Loaded %d questions from %s", len(questions), path.name)
return questions
except json.JSONDecodeError as exc:
raise BenchmarkError(f"Invalid JSON in questions file: {exc}") from exc
def load_questions_from_string(questions_json: str) -> list[dict]:
"""Load questions from a JSON string.
Args:
questions_json: JSON string containing questions
Returns:
List of question dicts
"""
try:
data = json.loads(questions_json)
if isinstance(data, list):
return data
elif "questions" in data:
return data["questions"]
else:
raise BenchmarkError("Invalid questions format")
except json.JSONDecodeError as exc:
raise BenchmarkError(f"Invalid JSON string: {exc}") from exc
# ── Cost Estimation ───────────────────────────────────────────────
def estimate_cost(
num_questions: int,
num_strategies: int,
) -> dict[str, Any]:
"""Estimate the cost of running a benchmark.
Args:
num_questions: Number of questions
num_strategies: Number of strategies
Returns:
Cost estimate dict
"""
# Rough estimates based on GPT-4o-mini pricing
embedding_cost_per_call = 0.0001
query_cost_per_call = 0.001
evaluation_cost_per_call = 0.001
total_queries = num_questions * num_strategies
total_evaluations = total_queries
embedding_cost = total_queries * embedding_cost_per_call
query_cost = total_queries * query_cost_per_call
evaluation_cost = total_evaluations * evaluation_cost_per_call
total_cost = embedding_cost + query_cost + evaluation_cost
# Estimate tokens
avg_input_tokens = 500
avg_output_tokens = 200
total_input_tokens = total_queries * avg_input_tokens + total_evaluations * avg_input_tokens
total_output_tokens = total_queries * avg_output_tokens + total_evaluations * avg_output_tokens
return {
"num_questions": num_questions,
"num_strategies": num_strategies,
"total_queries": total_queries,
"total_evaluations": total_evaluations,
"estimated_tokens": {
"input": total_input_tokens,
"output": total_output_tokens,
},
"estimated_cost_usd": round(total_cost, 4),
"cost_breakdown": {
"embedding": round(embedding_cost, 4),
"queries": round(query_cost, 4),
"evaluation": round(evaluation_cost, 4),
},
}
# ── Benchmark Runner ──────────────────────────────────────────────
def run_benchmark(
*,
document_id: str,
strategies: list[StrategyName],
questions: list[dict],
top_k: int = 5,
) -> dict[str, Any]:
"""Run a full benchmark across questions and strategies.
Args:
document_id: Document to benchmark against
strategies: List of strategies to test
questions: List of question dicts
top_k: Number of chunks to retrieve per query
Returns:
Complete benchmark results
"""
t_start = time.time()
logger.info("=" * 80)
logger.info("[BENCHMARK] Starting benchmark")
logger.info("[BENCHMARK] Document: %s", document_id)
logger.info("[BENCHMARK] Strategies: %s", [s.value for s in strategies])
logger.info("[BENCHMARK] Questions: %d", len(questions))
per_question_results = []
total_cost = 0.0
for q_idx, question in enumerate(questions, 1):
question_text = question.get("question", "")
expected_answer = question.get("expected_answer", "")
question_id = question.get("id", f"q{q_idx}")
logger.info("[BENCHMARK] Question %d/%d: %s", q_idx, len(questions), question_text[:50])
question_results = {
"question_id": question_id,
"question": question_text,
"expected_answer": expected_answer,
"strategies": {},
}
for strategy in strategies:
logger.info("[BENCHMARK] Strategy: %s", strategy.value)
try:
# Run query
t0 = time.time()
query_result = run_query(
document_id=document_id,
strategy_name=strategy,
question=question_text,
top_k=top_k,
)
t_query = time.time() - t0
# Evaluate
t1 = time.time()
eval_scores = evaluate_single(
question=question_text,
retrieved_chunks=query_result["retrieved_chunks"],
expected_answer=expected_answer,
generated_answer=query_result["answer"],
)
t_eval = time.time() - t1
# Track cost
query_tokens = query_result.get("token_usage", {}).get("total_tokens", 0)
total_cost += query_tokens * 0.000001 # rough estimate
question_results["strategies"][strategy.value] = {
"answer": query_result["answer"],
"retrieved_chunks": query_result["retrieved_chunks"],
"scores": eval_scores,
"latency": {
"query_seconds": round(t_query, 3),
"evaluation_seconds": round(t_eval, 3),
},
"token_usage": query_result.get("token_usage", {}),
}
logger.info(
"[BENCHMARK] Scores: context=%d, similarity=%d, faithfulness=%d, hallucination=%s",
eval_scores["context_relevance"],
eval_scores["answer_similarity"],
eval_scores["faithfulness"],
eval_scores["hallucination"],
)
except Exception as exc:
logger.error("[BENCHMARK] Strategy %s failed: %s", strategy.value, exc)
question_results["strategies"][strategy.value] = {
"error": str(exc),
"scores": {
"context_relevance": 0,
"answer_similarity": 0,
"faithfulness": 0,
"hallucination": True,
"reasoning": f"Error: {exc}",
},
}
per_question_results.append(question_results)
# Aggregate metrics per strategy
aggregate = _aggregate_metrics(per_question_results, strategies)
t_total = time.time() - t_start
# Find best strategy
best_strategy = _find_best_strategy(aggregate)
# Store experiment
experiment = db.save_experiment(
document_id=document_id,
benchmark_config={
"strategies": [s.value for s in strategies],
"num_questions": len(questions),
"top_k": top_k,
},
questions=questions,
per_question=per_question_results,
aggregate_metrics=aggregate,
strategies_used=[s.value for s in strategies],
)
logger.info("[BENCHMARK] Completed in %.1fs", t_total)
logger.info("[BENCHMARK] Best strategy: %s", best_strategy)
logger.info("=" * 80)
return {
"experiment_id": experiment["id"],
"document_id": document_id,
"strategies_used": [s.value for s in strategies],
"questions_count": len(questions),
"aggregate_metrics": aggregate,
"best_strategy": best_strategy,
"total_latency_seconds": round(t_total, 2),
"estimated_cost_usd": round(total_cost, 4),
"created_at": experiment["created_at"],
}
def _aggregate_metrics(
per_question_results: list[dict],
strategies: list[StrategyName],
) -> dict[str, dict]:
"""Aggregate metrics across all questions for each strategy."""
aggregate = {}
for strategy in strategies:
strategy_name = strategy.value
scores_list = []
hallucination_count = 0
total_count = 0
for qr in per_question_results:
strat_result = qr.get("strategies", {}).get(strategy_name, {})
if "error" in strat_result:
continue
scores = strat_result.get("scores", {})
if scores:
scores_list.append(scores)
total_count += 1
if scores.get("hallucination", False):
hallucination_count += 1
if scores_list:
avg_context = sum(s.get("context_relevance", 0) for s in scores_list) / len(scores_list)
avg_similarity = sum(s.get("answer_similarity", 0) for s in scores_list) / len(scores_list)
avg_faithfulness = sum(s.get("faithfulness", 0) for s in scores_list) / len(scores_list)
hallucination_rate = hallucination_count / total_count if total_count > 0 else 0
else:
avg_context = 0
avg_similarity = 0
avg_faithfulness = 0
hallucination_rate = 0
aggregate[strategy_name] = {
"avg_context_relevance": round(avg_context, 2),
"avg_answer_similarity": round(avg_similarity, 2),
"avg_faithfulness": round(avg_faithfulness, 2),
"hallucination_rate": round(hallucination_rate, 2),
"total_questions": total_count,
"failed_questions": len(per_question_results) - total_count,
}
return aggregate
def _find_best_strategy(aggregate: dict) -> str:
"""Find the best strategy based on overall score."""
best_strategy = None
best_score = -1
for strategy_name, metrics in aggregate.items():
# Calculate overall score (weighted average)
overall = (
metrics.get("avg_context_relevance", 0) * 0.3
+ metrics.get("avg_answer_similarity", 0) * 0.4
+ metrics.get("avg_faithfulness", 0) * 0.3
)
# Penalize hallucination
overall *= (1 - metrics.get("hallucination_rate", 0))
if overall > best_score:
best_score = overall
best_strategy = strategy_name
return best_strategy or "unknown"
# ── Experiment Retrieval ──────────────────────────────────────────
def get_experiment(experiment_id: str) -> dict[str, Any] | None:
"""Retrieve an experiment by ID."""
return db.get_experiment(experiment_id)
def list_experiments(document_id: str | None = None) -> dict[str, Any]:
"""List experiments, optionally filtered by document."""
return db.list_experiments(document_id=document_id)

View File

@@ -0,0 +1,208 @@
"""LLM-as-Judge evaluation for benchmarking.
Uses GPT-4o-mini to evaluate answer quality on 4 metrics:
- Context Relevance (1-10): How relevant are the retrieved chunks?
- Answer Similarity (1-10): How similar is the answer to expected?
- Faithfulness (1-10): Is the answer grounded in context?
- Hallucination (bool): Did the LLM invent information?
"""
from __future__ import annotations
import json
import logging
from typing import Any
from openai import OpenAI
from src.core.config import settings
from src.core.dependencies import get_openai_client
from src.core.exceptions import BenchmarkError
logger = logging.getLogger(__name__)
# ── Evaluation System Prompt ──────────────────────────────────────
_EVALUATION_SYSTEM_PROMPT = """You are an expert evaluator for a RAG (Retrieval-Augmented Generation) system.
You will evaluate the quality of an answer based on the retrieved context and the expected answer.
Score each metric on a scale of 1-10:
- Context Relevance: How relevant are the retrieved chunks to answering the question?
- Answer Similarity: How similar is the generated answer to the expected answer?
- Faithfulness: Is the generated answer grounded in the retrieved context (no hallucination)?
Also determine if there is hallucination (true/false):
- Hallucination = true if the answer contains information not found in the context
- Hallucination = false if the answer is fully grounded in the context
IMPORTANT: Return ONLY valid JSON, no markdown, no explanation."""
# ── Evaluation User Prompt Template ───────────────────────────────
_EVALUATION_USER_PROMPT = """Evaluate this RAG system output:
**Question:** {question}
**Retrieved Context:**
{context}
**Expected Answer:** {expected_answer}
**Generated Answer:** {generated_answer}
Return JSON with these exact keys:
{{
"context_relevance": <1-10>,
"answer_similarity": <1-10>,
"faithfulness": <1-10>,
"hallucination": <true/false>,
"reasoning": "<brief explanation of scores>"
}}"""
# ── Evaluation Functions ──────────────────────────────────────────
def _build_context_for_evaluation(retrieved_chunks: list[dict]) -> str:
"""Build a readable context string from retrieved chunks."""
parts = []
for i, chunk in enumerate(retrieved_chunks, 1):
score = chunk.get("score", 0)
text = chunk.get("text", "")
parts.append(f"[Chunk {i} (score: {score:.3f})]\n{text}")
return "\n\n".join(parts)
def evaluate_single(
*,
question: str,
retrieved_chunks: list[dict],
expected_answer: str,
generated_answer: str,
) -> dict[str, Any]:
"""Evaluate a single question-answer pair using LLM-as-Judge.
Args:
question: The original question
retrieved_chunks: Chunks retrieved from vector search
expected_answer: The golden/expected answer
generated_answer: The answer generated by the system
Returns:
dict with context_relevance, answer_similarity, faithfulness,
hallucination, and reasoning
"""
client = get_openai_client()
context = _build_context_for_evaluation(retrieved_chunks)
user_prompt = _EVALUATION_USER_PROMPT.format(
question=question,
context=context,
expected_answer=expected_answer,
generated_answer=generated_answer,
)
try:
response = client.chat.completions.create(
model=settings.llm_model,
temperature=0.0,
max_tokens=500,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": _EVALUATION_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
content = response.choices[0].message.content or "{}"
# Parse JSON response
try:
scores = json.loads(content)
except json.JSONDecodeError:
# Try to extract JSON from markdown code block
if "```json" in content:
json_str = content.split("```json")[1].split("```")[0].strip()
scores = json.loads(json_str)
elif "```" in content:
json_str = content.split("```")[1].split("```")[0].strip()
scores = json.loads(json_str)
else:
raise BenchmarkError(f"Failed to parse evaluation response: {content}")
# Ensure all required fields exist with defaults
result = {
"context_relevance": scores.get("context_relevance", 5),
"answer_similarity": scores.get("answer_similarity", 5),
"faithfulness": scores.get("faithfulness", 5),
"hallucination": scores.get("hallucination", False),
"reasoning": scores.get("reasoning", ""),
}
# Validate ranges
for metric in ["context_relevance", "answer_similarity", "faithfulness"]:
val = result[metric]
if isinstance(val, (int, float)):
result[metric] = max(1, min(10, int(val)))
else:
result[metric] = 5
# Validate hallucination is bool
hall = result["hallucination"]
if isinstance(hall, str):
result["hallucination"] = hall.lower() == "true"
else:
result["hallucination"] = bool(hall)
logger.info(
"Evaluation: context_relevance=%d, answer_similarity=%d, faithfulness=%d, hallucination=%s",
result["context_relevance"],
result["answer_similarity"],
result["faithfulness"],
result["hallucination"],
)
return result
except Exception as exc:
logger.error("Evaluation failed: %s", exc)
# Return default scores on failure
return {
"context_relevance": 5,
"answer_similarity": 5,
"faithfulness": 5,
"hallucination": False,
"reasoning": f"Evaluation failed: {exc}",
}
def evaluate_batch(
evaluation_items: list[dict],
) -> list[dict[str, Any]]:
"""Evaluate a batch of question-answer pairs.
Args:
evaluation_items: List of dicts, each containing:
- question: str
- retrieved_chunks: list[dict]
- expected_answer: str
- generated_answer: str
Returns:
List of evaluation results
"""
results = []
for i, item in enumerate(evaluation_items, 1):
logger.info("Evaluating item %d/%d", i, len(evaluation_items))
scores = evaluate_single(
question=item["question"],
retrieved_chunks=item["retrieved_chunks"],
expected_answer=item["expected_answer"],
generated_answer=item["generated_answer"],
)
results.append(scores)
return results

128
src/benchmarking/models.py Normal file
View File

@@ -0,0 +1,128 @@
"""Request/response schemas for the Query API."""
from typing import Optional
from pydantic import BaseModel, Field
from src.core.models import StrategyName
# ── Request ────────────────────────────────────────────────────────
class QueryRequest(BaseModel):
"""Body for POST /queries."""
document_id: str = Field(description="Document ID to query against")
strategy: StrategyName = Field(description="Chunking strategy to use")
question: str = Field(description="Question to ask", min_length=1)
top_k: int = Field(default=5, description="Number of chunks to retrieve", ge=1, le=20)
# ── Response ───────────────────────────────────────────────────────
class RetrievedChunk(BaseModel):
"""A single retrieved chunk with its similarity score."""
chunk_id: str
score: float
text: str
parent_id: Optional[str] = None
class QueryResponse(BaseModel):
"""Returned after creating a new query."""
query_id: str
document_id: str
strategy: str
question: str
answer: str
retrieved_chunks: list[RetrievedChunk]
latency_breakdown: dict[str, float]
token_usage: dict[str, int]
created_at: str
class QueryDetailResponse(BaseModel):
"""Returned when retrieving a past query."""
id: str
document_id: str
strategy_name: str
question: str
answer: str
retrieved_chunks: list[dict]
latency_breakdown: dict[str, float]
token_usage: dict[str, int]
created_at: str
# ── Benchmark Request/Response ────────────────────────────────────
class BenchmarkRequest(BaseModel):
"""Body for POST /benchmarks."""
document_id: str = Field(description="Document ID to benchmark against")
strategies: list[StrategyName] = Field(
default=[
StrategyName.RECURSIVE,
StrategyName.FIXED_SIZE,
StrategyName.SEMANTIC,
StrategyName.CONTEXTUAL_RETRIEVAL,
StrategyName.SEMANTIC_PARENT_CHILD,
],
description="Strategies to benchmark (defaults to all 5)",
min_length=1,
)
questions: list[dict] = Field(
default_factory=list,
description="List of question objects (overrides questions_file if provided)",
)
questions_file: Optional[str] = Field(
default=None,
description="Path to questions JSON file (relative to project root)",
)
top_k: int = Field(default=5, description="Number of chunks to retrieve", ge=1, le=20)
dry_run: bool = Field(default=False, description="Only return cost estimate, don't run benchmark")
class CostEstimate(BaseModel):
"""Cost estimation for a benchmark run."""
num_questions: int
num_strategies: int
total_queries: int
total_evaluations: int
estimated_tokens: dict[str, int]
estimated_cost_usd: float
cost_breakdown: dict[str, float]
class StrategyMetrics(BaseModel):
"""Aggregated metrics for a single strategy."""
avg_context_relevance: float
avg_answer_similarity: float
avg_faithfulness: float
hallucination_rate: float
total_questions: int
failed_questions: int
class BenchmarkResponse(BaseModel):
"""Returned after running a benchmark."""
experiment_id: str
document_id: str
strategies_used: list[str]
questions_count: int
aggregate_metrics: dict[str, StrategyMetrics]
best_strategy: str
total_latency_seconds: float
estimated_cost_usd: float
created_at: str
class ExperimentDetailResponse(BaseModel):
"""Returned when retrieving an experiment."""
id: str
document_id: str
document_filename: str = ""
benchmark_config: dict
questions: list[dict]
per_question: list[dict]
aggregate_metrics: dict[str, StrategyMetrics]
strategies_used: list[str]
created_at: str

View File

@@ -0,0 +1,340 @@
"""Query service: embed question → vector search → LLM answer.
Pipeline:
1. Embed the user's question via OpenAI
2. Search Qdrant for top-k similar chunks
3. For semantic_parent_child: also fetch parent context
4. Generate answer via gpt-4o-mini with retrieved chunks
5. Store query + answer in SQLite
"""
from __future__ import annotations
import logging
import time
from typing import Any
from openai import OpenAI
from src.chunking.embedding import embed_single
from src.core.config import settings
from src.core.dependencies import get_openai_client
from src.core.exceptions import QueryError
from src.core.models import StrategyName
from src.storage import qdrant as qdr
from src.storage import sqlite as db
logger = logging.getLogger(__name__)
# ── System prompt for answer generation ────────────────────────────
_ANSWER_SYSTEM_PROMPT = (
"You are a helpful assistant that answers questions based on the provided context. "
"Use ONLY the information in the context to answer. If the context doesn't contain "
"enough information, say so clearly. Be concise and accurate."
)
# ── Parent-child handling ─────────────────────────────────────────
def _fetch_parent_chunks(
child_hits: list[dict],
strategy: StrategyName,
document_name: str,
) -> dict[str, dict]:
"""Fetch parent chunks for child hits in semantic_parent_child strategy.
Returns a dict mapping parent_id -> parent payload.
"""
logger.info("[PARENT-CHILD] Fetching parent chunks for %d child hits", len(child_hits))
parent_ids = set()
for hit in child_hits:
payload = hit.get("payload", {})
parent_id = payload.get("parent_id")
if parent_id:
parent_ids.add(parent_id)
logger.info("[PARENT-CHILD] Found %d unique parent IDs: %s", len(parent_ids), parent_ids)
if not parent_ids:
logger.info("[PARENT-CHILD] No parent IDs found, returning empty")
return {}
# Search for parent chunks by their IDs
parents = {}
for parent_id in parent_ids:
# Use Qdrant scroll to find the parent chunk
from qdrant_client.models import Filter, FieldCondition, MatchValue
client = qdr.get_qdrant_client()
name = qdr.collection_name(strategy)
try:
results = client.scroll(
collection_name=name,
scroll_filter=Filter(
must=[
FieldCondition(
key="chunk_id",
match=MatchValue(value=parent_id)
)
]
),
limit=1,
with_payload=True,
with_vectors=False,
)
if results[0]:
parent_point = results[0][0]
parents[parent_id] = parent_point.payload
logger.info("[PARENT-CHILD] Found parent %s: %d chars", parent_id, len(parent_point.payload.get("text", "")))
else:
logger.warning("[PARENT-CHILD] Parent %s not found in Qdrant", parent_id)
except Exception as exc:
logger.error("[PARENT-CHILD] Failed to fetch parent %s: %s", parent_id, exc)
logger.info("[PARENT-CHILD] Fetched %d parent chunks total", len(parents))
return parents
def _build_context(
hits: list[dict],
strategy: StrategyName,
document_name: str,
) -> str:
"""Build context string from retrieved chunks.
For semantic_parent_child: includes both child and parent context.
For other strategies: includes child chunks only.
"""
logger.info("[CONTEXT] Building context from %d hits for strategy: %s", len(hits), strategy.value)
context_parts = []
if strategy == StrategyName.SEMANTIC_PARENT_CHILD:
# Fetch parent chunks
parents = _fetch_parent_chunks(hits, strategy, document_name)
for i, hit in enumerate(hits, 1):
payload = hit.get("payload", {})
score = hit.get("score", 0)
chunk_text = payload.get("text", "")
parent_id = payload.get("parent_id")
# Add child chunk
context_parts.append(f"[Chunk {i} (score: {score:.3f})]")
context_parts.append(chunk_text)
# Add parent context if available
if parent_id and parent_id in parents:
parent_payload = parents[parent_id]
parent_text = parent_payload.get("text", "")
if parent_text:
context_parts.append(f"\n[Context from parent section]")
context_parts.append(parent_text)
context_parts.append("") # blank line between chunks
else:
# Standard strategies: just use the chunks
for i, hit in enumerate(hits, 1):
payload = hit.get("payload", {})
score = hit.get("score", 0)
chunk_text = payload.get("text", "")
logger.info("[CONTEXT] Chunk %d: score=%.3f, text_len=%d, chunk_id=%s",
i, score, len(chunk_text), hit.get("chunk_id", "unknown"))
context_parts.append(f"[Chunk {i} (score: {score:.3f})]")
context_parts.append(chunk_text)
context_parts.append("")
context = "\n".join(context_parts)
logger.info("[CONTEXT] Total context length: %d chars", len(context))
return context
# ── Answer generation ─────────────────────────────────────────────
def _generate_answer(
client: OpenAI,
question: str,
context: str,
) -> tuple[str, dict[str, Any]]:
"""Generate an answer using gpt-4o-mini with retrieved context.
Returns (answer, token_usage).
"""
user_prompt = (
f"Context:\n{context}\n\n"
f"Question: {question}\n\n"
f"Answer:"
)
logger.info("[ANSWER] Generating answer with %s", settings.llm_model)
logger.info("[ANSWER] Context length: %d chars", len(context))
logger.info("[ANSWER] Question: %s", question)
try:
response = client.chat.completions.create(
model=settings.llm_model,
temperature=0.0,
max_tokens=1000,
messages=[
{"role": "system", "content": _ANSWER_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
answer = response.choices[0].message.content or ""
usage = {
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
"total_tokens": response.usage.total_tokens if response.usage else 0,
}
logger.info("[ANSWER] Generated answer: %d chars, %d tokens", len(answer), usage["total_tokens"])
return answer.strip(), usage
except Exception as exc:
logger.error("[ANSWER] Failed to generate answer: %s", exc)
raise QueryError(f"Answer generation failed: {exc}") from exc
# ── Main query function ───────────────────────────────────────────
def run_query(
*,
document_id: str,
strategy_name: StrategyName,
question: str,
top_k: int = 5,
) -> dict[str, Any]:
"""Run a query against a document using a specific chunking strategy.
Pipeline: embed → search → answer → store
Args:
document_id: The document to query against.
strategy_name: Which chunking strategy's collection to search.
question: The user's question.
top_k: Number of chunks to retrieve (default 5).
Returns:
Query result dict with answer, chunks, and metadata.
"""
t_start = time.time()
logger.info("=" * 80)
logger.info("[QUERY] Starting query pipeline")
logger.info("[QUERY] Document ID: %s", document_id)
logger.info("[QUERY] Strategy: %s", strategy_name.value)
logger.info("[QUERY] Question: %s", question)
logger.info("[QUERY] Top K: %d", top_k)
# 1. Load document info
logger.info("[STEP 1] Loading document info from SQLite...")
doc = db.get_document(document_id)
if doc is None:
logger.error("[STEP 1] Document not found: %s", document_id)
raise QueryError(f"Document not found: {document_id}")
document_name = doc["filename"]
chunk_counts = doc.get("chunk_counts", {})
logger.info("[STEP 1] Document found: %s", document_name)
logger.info("[STEP 1] Chunk counts: %s", chunk_counts)
# 2. Embed the question
logger.info("[STEP 2] Embedding question...")
t0 = time.time()
question_embedding = embed_single(question)
t_embed = time.time() - t0
logger.info("[STEP 2] Question embedded in %.2fs", t_embed)
logger.info("[STEP 2] Embedding dimension: %d", len(question_embedding))
# 3. Vector search in Qdrant
logger.info("[STEP 3] Searching Qdrant collection: %s_collection", strategy_name.value)
t1 = time.time()
hits = qdr.search(
strategy=strategy_name,
query_vector=question_embedding,
top_k=top_k,
document_filter=document_name,
)
t_search = time.time() - t1
logger.info("[STEP 3] Search completed in %.2fs", t_search)
logger.info("[STEP 3] Found %d chunks", len(hits))
# Log each hit
for i, hit in enumerate(hits, 1):
payload = hit.get("payload", {})
logger.info("[STEP 3] Hit %d: chunk_id=%s, score=%.4f, text_len=%d",
i, hit.get("chunk_id", "unknown"), hit.get("score", 0),
len(payload.get("text", "")))
# 4. Build context and generate answer
logger.info("[STEP 4] Building context...")
t2 = time.time()
context = _build_context(hits, strategy_name, document_name)
logger.info("[STEP 4] Generating answer...")
client = get_openai_client()
answer, token_usage = _generate_answer(client, question, context)
t_answer = time.time() - t2
logger.info("[STEP 4] Answer generated in %.2fs", t_answer)
t_total = time.time() - t_start
# 5. Prepare retrieved chunks for storage
logger.info("[STEP 5] Preparing retrieved chunks for storage...")
retrieved_chunks = []
for hit in hits:
payload = hit.get("payload", {})
chunk_data = {
"chunk_id": payload.get("chunk_id", hit.get("chunk_id")),
"score": hit.get("score", 0),
"text": payload.get("text", ""),
"parent_id": payload.get("parent_id"),
}
retrieved_chunks.append(chunk_data)
logger.info("[STEP 5] Chunk: id=%s, score=%.4f, text_len=%d",
chunk_data["chunk_id"], chunk_data["score"], len(chunk_data["text"]))
# 6. Store query result in SQLite
logger.info("[STEP 6] Storing query result in SQLite...")
latency_breakdown = {
"embed_seconds": round(t_embed, 3),
"search_seconds": round(t_search, 3),
"answer_seconds": round(t_answer, 3),
"total_seconds": round(t_total, 3),
}
query_record = db.save_query(
document_id=document_id,
strategy_name=strategy_name.value,
question=question,
answer=answer,
retrieved_chunks=retrieved_chunks,
latency_breakdown=latency_breakdown,
token_usage=token_usage,
)
logger.info("[STEP 6] Query stored with ID: %s", query_record["id"])
logger.info("[QUERY] Pipeline completed in %.2fs", t_total)
logger.info("=" * 80)
return {
"query_id": query_record["id"],
"document_id": document_id,
"strategy": strategy_name.value,
"question": question,
"answer": answer,
"retrieved_chunks": retrieved_chunks,
"latency_breakdown": latency_breakdown,
"token_usage": token_usage,
"created_at": query_record["created_at"],
}
def get_query(query_id: str) -> dict[str, Any] | None:
"""Retrieve a past query by ID."""
return db.get_query(query_id)

840
src/benchmarking/report.py Normal file
View File

@@ -0,0 +1,840 @@
"""Enhanced HTML report generator with two views:
1. Managerial: Decision-focused, high-level insights
2. Technical: Full observability with detailed data
Design: Dark mode, amber/teal accents, Inter + JetBrains Mono.
"""
from __future__ import annotations
import json
from typing import Any
# ── Design Tokens ─────────────────────────────────────────────────
COLORS = {
"bg": "#0F1419",
"surface": "#1A2332",
"surface-hover": "#243044",
"border": "#2D3A4A",
"text": "#E8EDF2",
"text-muted": "#7A8BA0",
"accent": "#F59E0B",
"accent-dim": "#D97706",
"teal": "#14B8A6",
"rose": "#F43F5E",
"violet": "#8B5CF6",
"emerald": "#10B981",
}
CHART_COLORS = ["#F59E0B", "#14B8A6", "#8B5CF6", "#F43F5E", "#3B82F6"]
FONT_DISPLAY = "'Inter', -apple-system, sans-serif"
FONT_DATA = "'JetBrains Mono', 'Fira Code', monospace"
# ── Main Entry Points ─────────────────────────────────────────────
def generate_report(experiment: dict, view: str = "managerial") -> str:
"""Generate report for specified view.
Args:
experiment: Experiment data from SQLite
view: "managerial" or "technical"
"""
if view == "technical":
return generate_technical_report(experiment)
return generate_managerial_report(experiment)
def generate_managerial_report(experiment: dict) -> str:
"""Managerial view: Decision-focused, high-level insights."""
config = experiment.get("benchmark_config", {})
aggregate = experiment.get("aggregate_metrics", {})
per_question = experiment.get("per_question", [])
strategies = experiment.get("strategies_used", [])
rankings = _calculate_rankings(aggregate, strategies)
# Calculate cost from token usage in per-question data
total_prompt_tokens = 0
total_completion_tokens = 0
for qr in per_question:
for strategy in strategies:
strat = qr.get("strategies", {}).get(strategy, {})
usage = strat.get("token_usage", {})
total_prompt_tokens += usage.get("prompt_tokens", 0)
total_completion_tokens += usage.get("completion_tokens", 0)
estimated_cost = (total_prompt_tokens * 0.15 + total_completion_tokens * 0.60) / 1_000_000
html = _base_html(experiment, "Managerial View", f"""
<!-- Header -->
<header class="header animate">
<div class="eyebrow">Benchmark Results</div>
<h1>Strategy Comparison</h1>
<div class="meta">
{config.get('num_questions', 0)} questions &middot;
{len(strategies)} strategies &middot;
${estimated_cost:.4f} cost
</div>
</header>
<!-- KPIs -->
<div class="kpi-row animate delay-1">
<div class="kpi">
<div class="label">Best Strategy</div>
<div class="value accent">{rankings[0][0] if rankings else 'N/A'}</div>
</div>
<div class="kpi">
<div class="label">Overall Score</div>
<div class="value teal">{rankings[0][1]:.2f}</div>
</div>
<div class="kpi">
<div class="label">Questions</div>
<div class="value">{config.get('num_questions', 0)}</div>
</div>
<div class="kpi">
<div class="label">Cost</div>
<div class="value">${estimated_cost:.4f}</div>
</div>
</div>
<!-- Winner -->
<div class="winner animate delay-2">
<div class="medal">&#127942;</div>
<div class="content">
<h2>Recommendation: {rankings[0][0] if rankings else 'N/A'}</h2>
<p>{_get_recommendation_text(rankings, aggregate)}</p>
</div>
<div class="score">{rankings[0][1]:.2f}</div>
</div>
<!-- Strategy Rankings -->
<div class="section-header animate delay-3">
<h2>How Strategies Compare</h2>
<div class="line"></div>
</div>
<div class="strategy-grid animate delay-3">
{_build_strategy_cards(rankings, aggregate)}
</div>
<!-- Charts -->
<div class="section-header animate delay-4">
<h2>Visual Comparison</h2>
<div class="line"></div>
</div>
<div class="charts-row animate delay-4">
<div class="chart-panel">
<h3>Performance Radar</h3>
<div class="chart-container">
<canvas id="radarChart"></canvas>
</div>
</div>
<div class="chart-panel">
<h3>Score Comparison</h3>
<div class="chart-container">
<canvas id="barChart"></canvas>
</div>
</div>
</div>
<!-- Decision Insights -->
<div class="section-header">
<h2>Decision Guide</h2>
<div class="line"></div>
</div>
<div class="insights">
{_generate_decision_insights(rankings, aggregate)}
</div>
<!-- Quick Links -->
<div class="section-header">
<h2>Details</h2>
<div class="line"></div>
</div>
<div class="quick-links">
<a href="/benchmarks/{experiment.get('id', '')}/report?view=technical" class="link-card">
<span class="link-icon">&#128269;</span>
<span class="link-text">View Full Technical Report</span>
<span class="link-arrow">&rarr;</span>
</a>
</div>
""")
return html
def generate_technical_report(experiment: dict) -> str:
"""Technical view: Full observability with detailed data."""
config = experiment.get("benchmark_config", {})
aggregate = experiment.get("aggregate_metrics", {})
per_question = experiment.get("per_question", [])
strategies = experiment.get("strategies_used", [])
rankings = _calculate_rankings(aggregate, strategies)
# Calculate metrics from available data
total_queries = len(per_question) * len(strategies)
total_questions = config.get("num_questions", len(per_question))
top_k = config.get("top_k", 5)
# Calculate token usage from per-question data
total_prompt_tokens = 0
total_completion_tokens = 0
for qr in per_question:
for strategy in strategies:
strat = qr.get("strategies", {}).get(strategy, {})
usage = strat.get("token_usage", {})
total_prompt_tokens += usage.get("prompt_tokens", 0)
total_completion_tokens += usage.get("completion_tokens", 0)
total_tokens = total_prompt_tokens + total_completion_tokens
# Estimate cost based on token usage (GPT-4o-mini pricing)
# Input: $0.15/1M tokens, Output: $0.60/1M tokens
estimated_cost = (total_prompt_tokens * 0.15 + total_completion_tokens * 0.60) / 1_000_000
# Estimate latency (rough: ~0.5s per query + ~1s per evaluation)
estimated_latency = total_queries * 1.5
html = _base_html(experiment, "Technical View", f"""
<!-- Header -->
<header class="header animate">
<div class="eyebrow">Technical Report</div>
<h1>Full Observability</h1>
<div class="meta">
Experiment: {experiment.get('id', 'N/A')[:16]}... &middot;
{total_questions} questions &middot;
{len(strategies)} strategies
</div>
</header>
<!-- KPIs -->
<div class="kpi-row animate delay-1">
<div class="kpi">
<div class="label">Total Queries</div>
<div class="value accent">{total_queries}</div>
</div>
<div class="kpi">
<div class="label">Total Tokens</div>
<div class="value">{total_tokens:,}</div>
</div>
<div class="kpi">
<div class="label">Cost</div>
<div class="value teal">${estimated_cost:.4f}</div>
</div>
<div class="kpi">
<div class="label">Tokens per Query</div>
<div class="value">{total_tokens // max(total_queries, 1):,}</div>
</div>
</div>
<!-- Aggregate Metrics Table -->
<div class="section-header animate delay-2">
<h2>Aggregate Metrics</h2>
<div class="line"></div>
</div>
<div class="table-wrapper animate delay-2">
<table>
<thead>
<tr>
<th>Strategy</th>
<th>Context Relevance</th>
<th>Answer Similarity</th>
<th>Faithfulness</th>
<th>Hallucination</th>
<th>Questions</th>
<th>Failed</th>
<th>Overall</th>
</tr>
</thead>
<tbody>
{_build_technical_aggregate_rows(rankings, aggregate)}
</tbody>
</table>
</div>
<!-- Charts -->
<div class="section-header animate delay-3">
<h2>Visual Analysis</h2>
<div class="line"></div>
</div>
<div class="charts-row animate delay-3">
<div class="chart-panel">
<h3>Multi-Metric Radar</h3>
<div class="chart-container">
<canvas id="radarChart"></canvas>
</div>
</div>
<div class="chart-panel">
<h3>Score Distribution</h3>
<div class="chart-container">
<canvas id="barChart"></canvas>
</div>
</div>
</div>
<!-- Per-Question Results -->
<div class="section-header">
<h2>Per-Question Results</h2>
<div class="line"></div>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>ID</th>
<th>Question</th>
<th>Category</th>
<th>Difficulty</th>
{_build_strategy_headers(strategies)}
</tr>
</thead>
<tbody>
{_build_per_question_rows(per_question, strategies)}
</tbody>
</table>
</div>
<!-- Detailed Scores -->
<div class="section-header">
<h2>Detailed Scores</h2>
<div class="line"></div>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Q-ID</th>
<th>Strategy</th>
<th>Context</th>
<th>Similarity</th>
<th>Faithfulness</th>
<th>Hallucination</th>
<th>Answer Preview</th>
</tr>
</thead>
<tbody>
{_build_detailed_rows(per_question, strategies)}
</tbody>
</table>
</div>
<!-- Token Usage -->
<div class="section-header">
<h2>Token Usage</h2>
<div class="line"></div>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Q-ID</th>
<th>Strategy</th>
<th>Prompt Tokens</th>
<th>Completion Tokens</th>
<th>Total Tokens</th>
</tr>
</thead>
<tbody>
{_build_token_rows(per_question, strategies)}
</tbody>
</table>
</div>
<!-- Quick Links -->
<div class="section-header">
<h2>Navigation</h2>
<div class="line"></div>
</div>
<div class="quick-links">
<a href="/benchmarks/{experiment.get('id', '')}/report?view=managerial" class="link-card">
<span class="link-icon">&#128202;</span>
<span class="link-text">Switch to Managerial View</span>
<span class="link-arrow">&rarr;</span>
</a>
</div>
""")
return html
# ── Base HTML Template ────────────────────────────────────────────
def _base_html(experiment: dict, title: str, content: str) -> str:
"""Base HTML wrapper with styles and scripts."""
strategies = experiment.get("strategies_used", [])
aggregate = experiment.get("aggregate_metrics", {})
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
*, *::before, *::after {{ margin: 0; padding: 0; box-sizing: border-box; }}
:root {{
--bg: {COLORS['bg']};
--surface: {COLORS['surface']};
--surface-hover: {COLORS['surface-hover']};
--border: {COLORS['border']};
--text: {COLORS['text']};
--text-muted: {COLORS['text-muted']};
--accent: {COLORS['accent']};
--accent-dim: {COLORS['accent-dim']};
--teal: {COLORS['teal']};
--rose: {COLORS['rose']};
--violet: {COLORS['violet']};
--emerald: {COLORS['emerald']};
}}
html {{ scroll-behavior: smooth; }}
body {{
font-family: {FONT_DISPLAY};
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}}
.page {{ max-width: 1280px; margin: 0 auto; padding: 48px 32px; }}
@keyframes fadeUp {{
from {{ opacity: 0; transform: translateY(20px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}
.animate {{ animation: fadeUp 0.6s ease-out forwards; opacity: 0; }}
.delay-1 {{ animation-delay: 0.1s; }}
.delay-2 {{ animation-delay: 0.2s; }}
.delay-3 {{ animation-delay: 0.3s; }}
.delay-4 {{ animation-delay: 0.4s; }}
.header {{ margin-bottom: 48px; padding-bottom: 32px; border-bottom: 1px solid var(--border); }}
.header .eyebrow {{ font-family: {FONT_DATA}; font-size: 12px; color: var(--accent); text-transform: uppercase; letter-spacing: 2px; margin-bottom: 12px; }}
.header h1 {{ font-size: 36px; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 8px; }}
.header .meta {{ font-size: 14px; color: var(--text-muted); font-family: {FONT_DATA}; }}
.kpi-row {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 24px; margin-bottom: 48px; }}
.kpi {{ padding: 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; transition: border-color 0.2s ease, transform 0.2s ease; }}
.kpi:hover {{ border-color: var(--accent); transform: translateY(-2px); }}
.kpi .label {{ font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; }}
.kpi .value {{ font-family: {FONT_DATA}; font-size: 32px; font-weight: 600; color: var(--text); }}
.kpi .value.accent {{ color: var(--accent); }}
.kpi .value.teal {{ color: var(--teal); }}
.winner {{ display: flex; align-items: center; gap: 24px; padding: 32px; background: linear-gradient(135deg, var(--surface) 0%, var(--surface-hover) 100%); border: 1px solid var(--accent); border-radius: 16px; margin-bottom: 48px; }}
.winner .medal {{ width: 64px; height: 64px; background: var(--accent); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 28px; flex-shrink: 0; }}
.winner .content h2 {{ font-size: 24px; font-weight: 600; margin-bottom: 4px; }}
.winner .content p {{ color: var(--text-muted); font-size: 14px; max-width: 600px; }}
.winner .score {{ margin-left: auto; font-family: {FONT_DATA}; font-size: 48px; font-weight: 700; color: var(--accent); }}
.section-header {{ display: flex; align-items: center; gap: 12px; margin-bottom: 24px; }}
.section-header h2 {{ font-size: 20px; font-weight: 600; }}
.section-header .line {{ flex: 1; height: 1px; background: var(--border); }}
.strategy-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 48px; }}
.strategy-card {{ background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px; position: relative; overflow: hidden; transition: border-color 0.2s ease; }}
.strategy-card:hover {{ border-color: var(--text-muted); }}
.strategy-card.rank-1 {{ border-color: var(--accent); background: linear-gradient(180deg, rgba(245,158,11,0.08) 0%, var(--surface) 100%); }}
.strategy-card .rank-badge {{ position: absolute; top: 16px; right: 16px; width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-family: {FONT_DATA}; font-size: 12px; font-weight: 600; }}
.strategy-card.rank-1 .rank-badge {{ background: var(--accent); color: var(--bg); }}
.strategy-card.rank-2 .rank-badge {{ background: var(--text-muted); color: var(--bg); }}
.strategy-card.rank-3 .rank-badge {{ background: var(--accent-dim); color: var(--bg); }}
.strategy-card .name {{ font-size: 14px; font-weight: 600; margin-bottom: 16px; padding-right: 40px; }}
.strategy-card .metric {{ margin-bottom: 12px; }}
.strategy-card .metric .label {{ font-size: 11px; color: var(--text-muted); margin-bottom: 4px; }}
.strategy-card .metric .bar-bg {{ height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }}
.strategy-card .metric .bar-fill {{ height: 100%; border-radius: 3px; transition: width 0.8s ease-out; }}
.strategy-card .metric .bar-fill.teal {{ background: var(--teal); }}
.strategy-card .metric .bar-fill.accent {{ background: var(--accent); }}
.strategy-card .metric .bar-fill.violet {{ background: var(--violet); }}
.strategy-card .metric .bar-fill.rose {{ background: var(--rose); }}
.strategy-card .metric .value {{ font-family: {FONT_DATA}; font-size: 12px; color: var(--text); margin-top: 4px; }}
.charts-row {{ display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 48px; }}
.chart-panel {{ background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px; }}
.chart-panel h3 {{ font-size: 14px; font-weight: 500; color: var(--text-muted); margin-bottom: 16px; }}
.chart-container {{ position: relative; height: 280px; }}
.insights {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 48px; }}
.insight {{ padding: 20px; border-radius: 12px; border-left: 3px solid; }}
.insight.positive {{ background: rgba(16,185,129,0.1); border-color: var(--emerald); }}
.insight.warning {{ background: rgba(244,63,94,0.1); border-color: var(--rose); }}
.insight.info {{ background: rgba(139,92,246,0.1); border-color: var(--violet); }}
.insight .title {{ font-size: 13px; font-weight: 600; margin-bottom: 4px; }}
.insight .desc {{ font-size: 13px; color: var(--text-muted); }}
.table-wrapper {{ background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; margin-bottom: 48px; }}
.table-wrapper table {{ width: 100%; }}
.table-wrapper th {{ background: var(--surface-hover); padding: 12px 16px; text-align: left; font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }}
.table-wrapper td {{ padding: 12px 16px; font-size: 13px; border-bottom: 1px solid var(--border); }}
.table-wrapper tr:last-child td {{ border-bottom: none; }}
.table-wrapper tr:hover td {{ background: var(--surface-hover); }}
.pill {{ display: inline-block; padding: 2px 8px; border-radius: 6px; font-family: {FONT_DATA}; font-size: 12px; font-weight: 500; }}
.pill.high {{ background: rgba(16,185,129,0.15); color: var(--emerald); }}
.pill.mid {{ background: rgba(245,158,11,0.15); color: var(--accent); }}
.pill.low {{ background: rgba(244,63,94,0.15); color: var(--rose); }}
.quick-links {{ margin-bottom: 48px; }}
.link-card {{ display: flex; align-items: center; gap: 16px; padding: 20px 24px; background: var(--surface); border: 1px solid var(--border); border-radius: 12px; text-decoration: none; color: var(--text); transition: border-color 0.2s ease, transform 0.2s ease; }}
.link-card:hover {{ border-color: var(--accent); transform: translateX(4px); }}
.link-icon {{ font-size: 24px; }}
.link-text {{ flex: 1; font-weight: 500; }}
.link-arrow {{ color: var(--accent); font-size: 18px; }}
.footer {{ padding-top: 32px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-muted); text-align: center; }}
@media (max-width: 768px) {{
.page {{ padding: 24px 16px; }}
.kpi-row {{ grid-template-columns: repeat(2, 1fr); }}
.charts-row {{ grid-template-columns: 1fr; }}
.winner {{ flex-direction: column; text-align: center; }}
.winner .score {{ margin-left: 0; margin-top: 16px; }}
}}
@media (prefers-reduced-motion: reduce) {{
.animate {{ animation: none; opacity: 1; }}
}}
</style>
</head>
<body>
<div class="page">
{content}
<footer class="footer">
Generated by RAG Chunking Benchmarker &middot; {experiment.get('created_at', 'N/A')}
</footer>
</div>
<script>
const colors = {json.dumps(CHART_COLORS)};
const strategies = {json.dumps(strategies)};
const aggregate = {json.dumps(aggregate)};
// Radar
new Chart(document.getElementById('radarChart'), {{
type: 'radar',
data: {{
labels: ['Context', 'Similarity', 'Faithfulness', 'Consistency'],
datasets: strategies.map((s, i) => ({{
label: s,
data: [
aggregate[s]?.avg_context_relevance || 0,
aggregate[s]?.avg_answer_similarity || 0,
aggregate[s]?.avg_faithfulness || 0,
(1 - (aggregate[s]?.hallucination_rate || 0)) * 10
],
borderColor: colors[i % colors.length],
backgroundColor: colors[i % colors.length] + '20',
pointBackgroundColor: colors[i % colors.length],
borderWidth: 2
}}))
}},
options: {{
responsive: true,
maintainAspectRatio: false,
plugins: {{ legend: {{ display: false }} }},
scales: {{
r: {{
beginAtZero: true,
max: 10,
grid: {{ color: '{COLORS["border"]}' }},
angleLines: {{ color: '{COLORS["border"]}' }},
pointLabels: {{ color: '{COLORS["text-muted"]}', font: {{ family: "'JetBrains Mono'", size: 11 }} }},
ticks: {{ display: false }}
}}
}}
}}
}});
// Bar
new Chart(document.getElementById('barChart'), {{
type: 'bar',
data: {{
labels: strategies.map(s => s.length > 12 ? s.substring(0, 12) + '...' : s),
datasets: [
{{ label: 'Context', data: strategies.map(s => aggregate[s]?.avg_context_relevance || 0), backgroundColor: colors[0] }},
{{ label: 'Similarity', data: strategies.map(s => aggregate[s]?.avg_answer_similarity || 0), backgroundColor: colors[1] }},
{{ label: 'Faithfulness', data: strategies.map(s => aggregate[s]?.avg_faithfulness || 0), backgroundColor: colors[2] }}
]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
plugins: {{ legend: {{ position: 'bottom', labels: {{ color: '{COLORS["text-muted"]}', font: {{ family: "'Inter'" }} }} }} }},
scales: {{
x: {{ grid: {{ display: false }}, ticks: {{ color: '{COLORS["text-muted"]}' }} }},
y: {{ beginAtZero: true, max: 10, grid: {{ color: '{COLORS["border"]}' }}, ticks: {{ color: '{COLORS["text-muted"]}' }} }}
}}
}}
}});
</script>
</body>
</html>"""
# ── Helper Functions ──────────────────────────────────────────────
def _calculate_rankings(aggregate: dict, strategies: list) -> list:
"""Calculate overall scores and rank strategies."""
rankings = []
for strategy in strategies:
m = aggregate.get(strategy, {})
overall = (
m.get("avg_context_relevance", 0) * 0.3
+ m.get("avg_answer_similarity", 0) * 0.4
+ m.get("avg_faithfulness", 0) * 0.3
) * (1 - m.get("hallucination_rate", 0))
rankings.append((strategy, round(overall, 2)))
rankings.sort(key=lambda x: x[1], reverse=True)
return rankings
def _get_recommendation_text(rankings: list, aggregate: dict) -> str:
"""Generate recommendation text for the winner."""
if not rankings:
return "No data available."
best = rankings[0][0]
m = aggregate.get(best, {})
parts = []
if m.get("avg_context_relevance", 0) >= 8:
parts.append("strong context retrieval")
if m.get("avg_faithfulness", 0) >= 8:
parts.append("high faithfulness")
if m.get("hallucination_rate", 0) < 0.1:
parts.append("low hallucination")
if parts:
return f"{best} excels in {', '.join(parts)}, making it the most reliable choice for your use case."
return f"{best} achieved the highest overall score across all evaluation metrics."
def _build_strategy_cards(rankings: list, aggregate: dict) -> str:
"""Build strategy comparison cards."""
cards = ""
for rank, (strategy, overall) in enumerate(rankings, 1):
m = aggregate.get(strategy, {})
rank_class = f"rank-{rank}" if rank <= 3 else ""
cards += f"""
<div class="strategy-card {rank_class}">
<div class="rank-badge">{rank}</div>
<div class="name">{strategy}</div>
{_metric_bar("Context", m.get("avg_context_relevance", 0), "teal")}
{_metric_bar("Similarity", m.get("avg_answer_similarity", 0), "accent")}
{_metric_bar("Faithfulness", m.get("avg_faithfulness", 0), "violet")}
{_metric_bar("No Hallucination", (1 - m.get("hallucination_rate", 0)) * 10, "teal" if m.get("hallucination_rate", 0) <= 0.1 else "rose")}
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-family: {FONT_DATA}; font-size: 13px; color: var(--text-muted);">
Overall: <span style="color: var(--accent); font-weight: 600;">{overall:.2f}</span>
</div>
</div>"""
return cards
def _metric_bar(label: str, value: float, color_class: str) -> str:
"""Build a single metric bar."""
width = min(value * 10, 100)
return f"""
<div class="metric">
<div class="label">{label}</div>
<div class="bar-bg"><div class="bar-fill {color_class}" style="width: {width}%;"></div></div>
<div class="value">{value:.1f}</div>
</div>"""
def _generate_decision_insights(rankings: list, aggregate: dict) -> str:
"""Generate decision-focused insights."""
insights = []
if rankings:
best = rankings[0][0]
worst = rankings[-1][0]
gap = (1 - rankings[-1][1] / rankings[0][1]) * 100 if rankings[0][1] > 0 else 0
insights.append(f"""
<div class="insight positive">
<div class="title">Recommended Strategy</div>
<div class="desc">{best} is the best choice with {rankings[0][1]:.2f} overall score.</div>
</div>""")
if gap > 20:
insights.append(f"""
<div class="insight warning">
<div class="title">Avoid</div>
<div class="desc">{worst} underperforms by {gap:.0f}%. Use only if specific constraints require it.</div>
</div>""")
# Cost vs quality
best_m = aggregate.get(best, {})
if best_m.get("avg_context_relevance", 0) >= 8 and best_m.get("hallucination_rate", 0) < 0.1:
insights.append(f"""
<div class="insight positive">
<div class="title">Quality Assessment</div>
<div class="desc">{best} delivers high-quality answers with reliable context retrieval.</div>
</div>""")
else:
insights.append(f"""
<div class="insight info">
<div class="title">Quality Note</div>
<div class="desc">Consider tuning parameters or adding more context for better results.</div>
</div>""")
return "\n".join(insights)
def _build_technical_aggregate_rows(rankings: list, aggregate: dict) -> str:
"""Build technical aggregate table rows with an average row at the end."""
rows = ""
for rank, (strategy, overall) in enumerate(rankings, 1):
m = aggregate.get(strategy, {})
rows += f"""
<tr>
<td><strong>{strategy}</strong></td>
<td>{_pill(m.get('avg_context_relevance', 0))}</td>
<td>{_pill(m.get('avg_answer_similarity', 0))}</td>
<td>{_pill(m.get('avg_faithfulness', 0))}</td>
<td>{_hall_pill(m.get('hallucination_rate', 0))}</td>
<td>{m.get('total_questions', 0)}</td>
<td>{m.get('failed_questions', 0)}</td>
<td><strong style="font-family: {FONT_DATA};">{overall:.2f}</strong></td>
</tr>"""
# Average row across all strategies
n = len(rankings)
if n > 0:
avg_ctx = sum(aggregate.get(s, {}).get('avg_context_relevance', 0) for s, _ in rankings) / n
avg_sim = sum(aggregate.get(s, {}).get('avg_answer_similarity', 0) for s, _ in rankings) / n
avg_faith = sum(aggregate.get(s, {}).get('avg_faithfulness', 0) for s, _ in rankings) / n
avg_hall = sum(aggregate.get(s, {}).get('hallucination_rate', 0) for s, _ in rankings) / n
avg_total = sum(aggregate.get(s, {}).get('total_questions', 0) for s, _ in rankings) / n
avg_failed = sum(aggregate.get(s, {}).get('failed_questions', 0) for s, _ in rankings) / n
avg_overall = sum(overall for _, overall in rankings) / n
rows += f"""
<tr style="background: var(--surface); border-top: 2px solid var(--border);">
<td><strong style="color: var(--accent);">AVERAGE</strong></td>
<td>{_pill(avg_ctx)}</td>
<td>{_pill(avg_sim)}</td>
<td>{_pill(avg_faith)}</td>
<td>{_hall_pill(avg_hall)}</td>
<td>{avg_total:.0f}</td>
<td>{avg_failed:.0f}</td>
<td><strong style="font-family: {FONT_DATA}; color: var(--accent);">{avg_overall:.2f}</strong></td>
</tr>"""
return rows
def _build_strategy_headers(strategies: list) -> str:
"""Build table headers."""
return "".join(f'<th>{s[:12]}</th>' for s in strategies)
def _build_per_question_rows(per_question: list, strategies: list) -> str:
"""Build per-question rows."""
rows = ""
for qr in per_question:
q_id = qr.get("question_id", "")
q_text = qr.get("question", "")[:40]
category = qr.get("category", "")
difficulty = qr.get("difficulty", "")
cells = ""
for strategy in strategies:
strat = qr.get("strategies", {}).get(strategy, {})
scores = strat.get("scores", {})
sim = scores.get("answer_similarity", 0)
cells += f"<td>{_pill(sim)}</td>"
rows += f"""
<tr>
<td style="font-family: {FONT_DATA}; color: var(--text-muted);">{q_id}</td>
<td>{q_text}...</td>
<td style="color: var(--text-muted);">{category}</td>
<td><span class="pill {'high' if difficulty == 'easy' else 'mid' if difficulty == 'medium' else 'low'}">{difficulty}</span></td>
{cells}
</tr>"""
return rows
def _build_detailed_rows(per_question: list, strategies: list) -> str:
"""Build detailed score rows."""
rows = ""
for qr in per_question:
q_id = qr.get("question_id", "")
for strategy in strategies:
strat = qr.get("strategies", {}).get(strategy, {})
scores = strat.get("scores", {})
answer = strat.get("answer", "")[:60]
rows += f"""
<tr>
<td style="font-family: {FONT_DATA}; font-size: 12px;">{q_id}</td>
<td>{strategy}</td>
<td>{_pill(scores.get('context_relevance', 0))}</td>
<td>{_pill(scores.get('answer_similarity', 0))}</td>
<td>{_pill(scores.get('faithfulness', 0))}</td>
<td>{'&#10003;' if not scores.get('hallucination', False) else '&#10007;'}</td>
<td style="font-size: 13px; color: var(--text-muted);">{answer}...</td>
</tr>"""
return rows
def _build_token_rows(per_question: list, strategies: list) -> str:
"""Build token usage rows."""
rows = ""
for qr in per_question:
q_id = qr.get("question_id", "")
for strategy in strategies:
strat = qr.get("strategies", {}).get(strategy, {})
usage = strat.get("token_usage", {})
rows += f"""
<tr>
<td style="font-family: {FONT_DATA}; font-size: 12px;">{q_id}</td>
<td>{strategy}</td>
<td>{usage.get('prompt_tokens', 0)}</td>
<td>{usage.get('completion_tokens', 0)}</td>
<td><strong>{usage.get('total_tokens', 0)}</strong></td>
</tr>"""
return rows
def _pill(score: float) -> str:
"""Create a score pill."""
if score >= 8:
css = "high"
elif score >= 6:
css = "mid"
else:
css = "low"
return f'<span class="pill {css}">{score:.1f}</span>'
def _hall_pill(rate: float) -> str:
"""Create hallucination rate pill."""
if rate <= 0.1:
css = "high"
elif rate <= 0.2:
css = "mid"
else:
css = "low"
return f'<span class="pill {css}">{rate*100:.0f}%</span>'
# ── Legacy compatibility ──────────────────────────────────────────
def generate_enhanced_report(experiment: dict) -> str:
"""Backward-compatible entry point (defaults to managerial)."""
return generate_managerial_report(experiment)

367
src/benchmarking/routes.py Normal file
View File

@@ -0,0 +1,367 @@
"""Query and Benchmark API routes.
Endpoints:
POST /queries Ask a question against a strategy
GET /queries/{id} Retrieve a past query
POST /benchmarks Run a benchmark (or dry run)
GET /benchmarks/{id} Retrieve experiment results
GET /experiments List all experiments
"""
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from src.core.exceptions import BenchmarkError, QueryError
from src.core.models import StrategyName
from src.benchmarking import benchmark_service, query_service
from src.benchmarking.models import (
BenchmarkRequest,
BenchmarkResponse,
CostEstimate,
ExperimentDetailResponse,
QueryRequest,
QueryResponse,
QueryDetailResponse,
RetrievedChunk,
StrategyMetrics,
)
router = APIRouter()
# ── Query Endpoints ───────────────────────────────────────────────
@router.post("/queries", response_model=QueryResponse, status_code=201)
async def create_query(request: QueryRequest):
"""Ask a question against a document using a specific chunking strategy.
Pipeline: embed question → vector search → generate answer → store result.
"""
result = query_service.run_query(
document_id=request.document_id,
strategy_name=request.strategy,
question=request.question,
top_k=request.top_k,
)
return QueryResponse(
query_id=result["query_id"],
document_id=result["document_id"],
strategy=result["strategy"],
question=result["question"],
answer=result["answer"],
retrieved_chunks=[
RetrievedChunk(**chunk) for chunk in result["retrieved_chunks"]
],
latency_breakdown=result["latency_breakdown"],
token_usage=result["token_usage"],
created_at=result["created_at"],
)
@router.get("/queries/{query_id}", response_model=QueryDetailResponse)
async def get_query(query_id: str):
"""Retrieve a past query by ID."""
result = query_service.get_query(query_id)
if result is None:
raise QueryError(f"Query not found: {query_id}")
return QueryDetailResponse(
id=result["id"],
document_id=result["document_id"],
strategy_name=result["strategy_name"],
question=result["question"],
answer=result["answer"],
retrieved_chunks=result["retrieved_chunks"],
latency_breakdown=result["latency_breakdown"],
token_usage=result["token_usage"],
created_at=result["created_at"],
)
# ── Benchmark Endpoints ──────────────────────────────────────────
@router.post("/benchmarks", response_model=BenchmarkResponse, status_code=201)
async def create_benchmark(request: BenchmarkRequest):
"""Run a benchmark comparing multiple strategies on multiple questions.
Can run in dry_run mode to get cost estimate without executing.
"""
# Load questions - prefer questions_file over inline questions
if request.questions_file:
questions = benchmark_service.load_questions(request.questions_file)
elif request.questions and len(request.questions) > 0:
# Validate that questions have required fields
valid_questions = [
q for q in request.questions
if isinstance(q, dict) and "question" in q
]
if not valid_questions:
raise BenchmarkError("Invalid questions: each question must have a 'question' field")
questions = valid_questions
else:
raise BenchmarkError("Either 'questions' or 'questions_file' must be provided")
if not questions:
raise BenchmarkError("No questions to benchmark")
# Dry run - return cost estimate only
if request.dry_run:
estimate = benchmark_service.estimate_cost(
num_questions=len(questions),
num_strategies=len(request.strategies),
)
# Return as BenchmarkResponse with minimal data
return BenchmarkResponse(
experiment_id="dry_run",
document_id=request.document_id,
strategies_used=[s.value for s in request.strategies],
questions_count=len(questions),
aggregate_metrics={},
best_strategy="N/A (dry run)",
total_latency_seconds=0,
estimated_cost_usd=estimate["estimated_cost_usd"],
created_at="N/A",
)
# Run full benchmark
result = benchmark_service.run_benchmark(
document_id=request.document_id,
strategies=request.strategies,
questions=questions,
top_k=request.top_k,
)
return BenchmarkResponse(
experiment_id=result["experiment_id"],
document_id=result["document_id"],
strategies_used=result["strategies_used"],
questions_count=result["questions_count"],
aggregate_metrics={
k: StrategyMetrics(**v) for k, v in result["aggregate_metrics"].items()
},
best_strategy=result["best_strategy"],
total_latency_seconds=result["total_latency_seconds"],
estimated_cost_usd=result["estimated_cost_usd"],
created_at=result["created_at"],
)
@router.get("/benchmarks/{experiment_id}", response_model=ExperimentDetailResponse)
async def get_benchmark(experiment_id: str):
"""Retrieve experiment results by ID."""
result = benchmark_service.get_experiment(experiment_id)
if result is None:
raise BenchmarkError(f"Experiment not found: {experiment_id}")
# Resolve document filename
from src.storage import sqlite as db
doc = db.get_document(result.get("document_id", ""))
doc_filename = doc.get("filename", "Unknown") if doc else "Deleted"
return ExperimentDetailResponse(
id=result["id"],
document_id=result["document_id"],
document_filename=doc_filename,
benchmark_config=result.get("benchmark_config", {}),
questions=result.get("questions", []),
per_question=result.get("per_question", []),
aggregate_metrics={
k: StrategyMetrics(**v) for k, v in result.get("aggregate_metrics", {}).items()
},
strategies_used=result.get("strategies_used", []),
created_at=result["created_at"],
)
@router.get("/experiments")
async def list_experiments(document_id: str | None = None):
"""List all experiments, optionally filtered by document."""
result = benchmark_service.list_experiments(document_id=document_id)
# Enrich with document filenames and best_strategy
from src.storage import sqlite as db
for item in result.get("items", []):
doc = db.get_document(item.get("document_id", ""))
item["document_filename"] = doc.get("filename", "Unknown") if doc else "Deleted"
# Calculate best_strategy from aggregate_metrics
aggs = item.get("aggregate_metrics", {})
best_strat, best_score = "N/A", -1
for strat, m in aggs.items():
score = (m.get("avg_context_relevance", 0) * 0.3 +
m.get("avg_answer_similarity", 0) * 0.4 +
m.get("avg_faithfulness", 0) * 0.3)
adjusted = score * (1 - m.get("hallucination_rate", 0))
if adjusted > best_score:
best_score = adjusted
best_strat = strat
item["best_strategy"] = best_strat
return result
@router.delete("/experiments/{experiment_id}")
async def delete_experiment(experiment_id: str):
"""Delete an experiment by ID."""
from src.storage import sqlite as db
deleted = db.delete_experiment(experiment_id)
if not deleted:
raise BenchmarkError(f"Experiment not found: {experiment_id}")
return {"deleted": True, "experiment_id": experiment_id}
@router.get("/benchmarks/{experiment_id}/report", response_class=HTMLResponse)
async def get_report(experiment_id: str, view: str = "managerial"):
"""Generate and return an HTML report for an experiment.
Views:
- managerial: Decision-focused, high-level insights (default)
- technical: Full observability with detailed data
"""
from src.benchmarking.report import generate_report
result = benchmark_service.get_experiment(experiment_id)
if result is None:
raise BenchmarkError(f"Experiment not found: {experiment_id}")
# Validate view parameter
if view not in ("managerial", "technical"):
view = "managerial"
# Generate HTML report
html = generate_report(result, view)
return HTMLResponse(content=html)
# ── HTML Report Generator ─────────────────────────────────────────
def _generate_html_report(experiment: dict) -> str:
"""Generate an HTML report from experiment data."""
config = experiment.get("benchmark_config", {})
aggregate = experiment.get("aggregate_metrics", {})
per_question = experiment.get("per_question", [])
strategies = experiment.get("strategies_used", [])
# Build strategy rows for summary table
strategy_rows = ""
for strategy_name in strategies:
metrics = aggregate.get(strategy_name, {})
strategy_rows += f"""
<tr>
<td><strong>{strategy_name}</strong></td>
<td>{metrics.get('avg_context_relevance', 0):.1f}</td>
<td>{metrics.get('avg_answer_similarity', 0):.1f}</td>
<td>{metrics.get('avg_faithfulness', 0):.1f}</td>
<td>{metrics.get('hallucination_rate', 0)*100:.1f}%</td>
<td>{metrics.get('total_questions', 0)}</td>
</tr>"""
# Build per-question rows
question_rows = ""
for qr in per_question:
q_text = qr.get("question", "")[:80]
q_id = qr.get("question_id", "")
for strategy_name in strategies:
strat_result = qr.get("strategies", {}).get(strategy_name, {})
scores = strat_result.get("scores", {})
answer_preview = strat_result.get("answer", "")[:100]
question_rows += f"""
<tr>
<td>{q_id}</td>
<td>{q_text}...</td>
<td>{strategy_name}</td>
<td>{scores.get('context_relevance', 'N/A')}</td>
<td>{scores.get('answer_similarity', 'N/A')}</td>
<td>{scores.get('faithfulness', 'N/A')}</td>
<td>{'✓' if not scores.get('hallucination', False) else '✗'}</td>
<td>{answer_preview}...</td>
</tr>"""
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Benchmark Report - {experiment.get('id', 'Unknown')}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 40px; background: #f5f5f5; }}
.container {{ max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }}
h1 {{ color: #333; border-bottom: 2px solid #4CAF50; padding-bottom: 10px; }}
h2 {{ color: #555; margin-top: 30px; }}
.summary {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin: 20px 0; }}
.summary-card {{ background: #f9f9f9; padding: 20px; border-radius: 8px; text-align: center; }}
.summary-card h3 {{ margin: 0; color: #666; font-size: 14px; }}
.summary-card .value {{ font-size: 24px; font-weight: bold; color: #4CAF50; }}
table {{ width: 100%; border-collapse: collapse; margin: 20px 0; }}
th, td {{ padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }}
th {{ background: #4CAF50; color: white; }}
tr:hover {{ background: #f5f5f5; }}
.best {{ background: #e8f5e9; font-weight: bold; }}
.footer {{ margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd; color: #666; font-size: 12px; }}
</style>
</head>
<body>
<div class="container">
<h1>📊 Benchmark Report</h1>
<div class="summary">
<div class="summary-card">
<h3>Experiment ID</h3>
<div class="value" style="font-size: 14px;">{experiment.get('id', 'N/A')[:16]}...</div>
</div>
<div class="summary-card">
<h3>Document</h3>
<div class="value" style="font-size: 14px;">{experiment.get('document_id', 'N/A')[:16]}...</div>
</div>
<div class="summary-card">
<h3>Questions</h3>
<div class="value">{config.get('num_questions', len(per_question))}</div>
</div>
<div class="summary-card">
<h3>Strategies</h3>
<div class="value">{len(strategies)}</div>
</div>
</div>
<h2>📈 Summary by Strategy</h2>
<table>
<thead>
<tr>
<th>Strategy</th>
<th>Context Relevance</th>
<th>Answer Similarity</th>
<th>Faithfulness</th>
<th>Hallucination Rate</th>
<th>Questions</th>
</tr>
</thead>
<tbody>
{strategy_rows}
</tbody>
</table>
<h2>📝 Detailed Results</h2>
<table>
<thead>
<tr>
<th>Q-ID</th>
<th>Question</th>
<th>Strategy</th>
<th>Context</th>
<th>Similarity</th>
<th>Faithfulness</th>
<th>No Halluc.</th>
<th>Answer Preview</th>
</tr>
</thead>
<tbody>
{question_rows}
</tbody>
</table>
<div class="footer">
<p>Generated by RAG Chunking Benchmarker | {experiment.get('created_at', 'N/A')}</p>
</div>
</div>
</body>
</html>"""
return html

1
src/chunking/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Chunking strategies — interface, implementations, embedding, orchestration."""

95
src/chunking/base.py Normal file
View File

@@ -0,0 +1,95 @@
"""Base chunking strategy interface and shared utilities.
Every strategy inherits from ChunkingStrategy and implements chunk().
The base class provides token counting, chunk ID generation, and
the standard Chunk construction path.
"""
from __future__ import annotations
import hashlib
import re
from abc import ABC, abstractmethod
import tiktoken
from src.core.models import Chunk, DocumentTree, StrategyName
# cl100k_base is the encoding used by text-embedding-3-small and gpt-4o-mini
_encoder = tiktoken.get_encoding("cl100k_base")
def count_tokens(text: str) -> int:
"""Return the token count for a string."""
return len(_encoder.encode(text))
def make_chunk_id(strategy: StrategyName, doc_name: str, index: int) -> str:
"""Generate a deterministic chunk ID: {strategy}_{doc}_{index:06d}."""
safe_doc = re.sub(r"[^a-zA-Z0-9]", "_", doc_name)[:32]
return f"{strategy.value}_{safe_doc}_{index:06d}"
def build_chunk(
*,
strategy: StrategyName,
doc_name: str,
index: int,
text: str,
parent_id: str | None = None,
enriched_content: str | None = None,
) -> Chunk:
"""Construct a Chunk with token/character counts pre-filled."""
return Chunk(
document_name=doc_name,
chunk_id=make_chunk_id(strategy, doc_name, index),
strategy_name=strategy,
chunk_index=index,
text=text,
token_count=count_tokens(text),
character_count=len(text),
parent_id=parent_id,
enriched_content=enriched_content,
)
# ── Sentence splitting ────────────────────────────────────────────
_SENTENCE_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9])")
def split_sentences(text: str) -> list[str]:
"""Split text into sentences using a simple regex heuristic."""
sentences = _SENTENCE_RE.split(text.strip())
return [s.strip() for s in sentences if s.strip()]
# ── Abstract base ─────────────────────────────────────────────────
class ChunkingStrategy(ABC):
"""Base class for all chunking strategies.
Subclasses implement chunk() which receives the full document
context and returns a list of Chunks conforming to the unified model.
"""
name: StrategyName
@abstractmethod
def chunk(
self,
*,
doc_name: str,
tree: DocumentTree,
markdown: str,
) -> list[Chunk]:
"""Produce chunks from a parsed document.
Args:
doc_name: Original filename (for metadata).
tree: Hierarchical DocumentTree from the parser.
markdown: Flat markdown rendering of the document.
Returns:
List of Chunk objects (unified model).
"""

68
src/chunking/embedding.py Normal file
View File

@@ -0,0 +1,68 @@
"""OpenAI embedding service using text-embedding-3-small.
All strategies share the same embedding model (fixed, not configurable)
to ensure fair comparison. Batch support up to 2048 texts per call.
"""
from __future__ import annotations
import logging
from openai import OpenAI
from src.core.config import settings
from src.core.dependencies import get_openai_client
from src.core.exceptions import EmbeddingError
logger = logging.getLogger(__name__)
# OpenAI batch limit for text-embedding-3-small
_BATCH_SIZE = 2048
def embed_texts(texts: list[str]) -> list[list[float]]:
"""Embed a list of texts and return their vectors.
For the contextual_structure strategy, these are the enriched texts
(not raw content) — this is by design (ADR 0011).
Args:
texts: List of strings to embed.
Returns:
List of embedding vectors (same order as input).
Raises:
EmbeddingError: If the OpenAI API call fails.
"""
if not texts:
return []
client = get_openai_client()
all_embeddings: list[list[float]] = []
try:
for start in range(0, len(texts), _BATCH_SIZE):
batch = texts[start:start + _BATCH_SIZE]
response = client.embeddings.create(
model=settings.embedding_model,
input=batch,
)
# Sort by index to guarantee order matches input
sorted_data = sorted(response.data, key=lambda x: x.index)
all_embeddings.extend([item.embedding for item in sorted_data])
logger.debug(
"Embedded batch %d-%d (%d texts)",
start, start + len(batch), len(batch),
)
return all_embeddings
except Exception as exc:
raise EmbeddingError(f"Embedding failed: {exc}") from exc
def embed_single(text: str) -> list[float]:
"""Embed a single text (convenience wrapper)."""
results = embed_texts([text])
return results[0]

179
src/chunking/service.py Normal file
View File

@@ -0,0 +1,179 @@
"""Chunking orchestration service.
Runs selected strategies on a document, embeds chunks, and stores
them in Qdrant. Per-strategy failure isolation (ADR 0003): if one
strategy fails, the others' results are still committed.
This replaces the stub in src/documents/service.py.
"""
from __future__ import annotations
import logging
import time
from src.chunking.base import ChunkingStrategy
from src.chunking.embedding import embed_texts
from src.chunking.strategies.recursive import RecursiveStrategy
from src.chunking.strategies.fixed_size import FixedSizeStrategy
from src.chunking.strategies.semantic import SemanticStrategy
from src.chunking.strategies.contextual_retrieval import ContextualRetrievalStrategy
from src.chunking.strategies.semantic_parent_child import SemanticParentChildStrategy
from src.core.exceptions import ChunkingError
from src.core.models import (
Chunk,
DocumentTree,
StrategyName,
)
from src.storage import qdrant as qdr
from src.storage import sqlite as db
logger = logging.getLogger(__name__)
# ── Strategy registry ─────────────────────────────────────────────
_STRATEGIES: dict[StrategyName, ChunkingStrategy] = {
StrategyName.RECURSIVE: RecursiveStrategy(),
StrategyName.FIXED_SIZE: FixedSizeStrategy(),
StrategyName.SEMANTIC: SemanticStrategy(),
StrategyName.CONTEXTUAL_RETRIEVAL: ContextualRetrievalStrategy(),
StrategyName.SEMANTIC_PARENT_CHILD: SemanticParentChildStrategy(),
}
def _get_strategy(name: StrategyName) -> ChunkingStrategy:
s = _STRATEGIES.get(name)
if s is None:
raise ChunkingError(f"Unknown strategy: {name}")
return s
# ── Single-strategy runner ────────────────────────────────────────
def _run_strategy(
strategy_name: StrategyName,
doc_id: str,
doc_name: str,
tree: DocumentTree,
markdown: str,
) -> int:
"""Run one strategy: chunk → embed → store in Qdrant.
Returns the number of chunks produced.
Raises on any failure (caller handles isolation).
"""
strategy = _get_strategy(strategy_name)
# Ensure Qdrant collection exists
qdr.ensure_collection(strategy_name)
t0 = time.time()
# Step 1: Chunk
chunks = strategy.chunk(
doc_name=doc_name,
tree=tree,
markdown=markdown,
)
if not chunks:
logger.warning("Strategy %s produced 0 chunks for doc %s", strategy_name.value, doc_id)
return 0
t_chunk = time.time() - t0
logger.info(
"Strategy %s: %d chunks in %.2fs",
strategy_name.value, len(chunks), t_chunk,
)
# Step 2: Embed
# For contextual strategy, embed enriched_content; for others, embed text
texts_to_embed = []
for chunk in chunks:
if chunk.enriched_content:
texts_to_embed.append(chunk.enriched_content)
else:
texts_to_embed.append(chunk.text)
t1 = time.time()
embeddings = embed_texts(texts_to_embed)
t_embed = time.time() - t1
logger.info(
"Strategy %s: embedded %d texts in %.2fs",
strategy_name.value, len(embeddings), t_embed,
)
# Step 3: Upsert to Qdrant
t2 = time.time()
stored = qdr.upsert_chunks(chunks, embeddings)
t_store = time.time() - t2
logger.info(
"Strategy %s: stored %d vectors in %.2fs",
strategy_name.value, stored, t_store,
)
return len(chunks)
# ── Multi-strategy orchestrator ───────────────────────────────────
def run_strategies(
doc_id: str,
strategies: list[StrategyName],
) -> tuple[list[dict], list[dict]]:
"""Run multiple strategies on a document with per-strategy failure isolation.
Returns:
(completed, failed) — lists of result dicts.
"""
doc = db.get_document(doc_id)
if doc is None:
raise ChunkingError(f"Document not found: {doc_id}")
# Parse the stored document tree (may be dict or JSON string)
tree_raw = doc["document_tree"]
if isinstance(tree_raw, str):
tree = DocumentTree.model_validate_json(tree_raw)
else:
tree = DocumentTree.model_validate(tree_raw)
markdown = doc["parsed_text"]
doc_name = doc["filename"]
completed: list[dict] = []
failed: list[dict] = []
for strategy_name in strategies:
try:
t0 = time.time()
chunks_produced = _run_strategy(
strategy_name=strategy_name,
doc_id=doc_id,
doc_name=doc_name,
tree=tree,
markdown=markdown,
)
elapsed = time.time() - t0
completed.append({
"strategy": strategy_name,
"status": "completed",
"chunks_produced": chunks_produced,
"elapsed_seconds": round(elapsed, 2),
})
except Exception as exc:
logger.error(
"Strategy %s failed for doc %s: %s",
strategy_name.value, doc_id, exc,
)
failed.append({
"strategy": strategy_name,
"status": "failed",
"error": str(exc),
})
# Update chunk counts on the document
counts = doc.get("chunk_counts", {})
for result in completed:
counts[result["strategy"].value] = result["chunks_produced"]
db.update_chunk_counts(doc_id, counts)
return completed, failed

View File

@@ -0,0 +1 @@
"""Chunking strategy implementations."""

View File

@@ -0,0 +1,120 @@
"""Contextual Retrieval strategy.
Based on Anthropic's 2024 research: prepend a short context summary
to each chunk before embedding. Improved retrieval by 49% in their
benchmarks.
Algorithm:
1. Split markdown into chunks using fixed-size token splitting.
2. For each chunk, send surrounding text + chunk to LLM.
3. LLM generates a 1-2 sentence context prefix.
4. The enriched chunk (context + original text) is what gets embedded.
No headings or document structure needed — works on any text.
"""
from __future__ import annotations
import logging
from openai import OpenAI
from src.chunking.base import ChunkingStrategy, _encoder, build_chunk
from src.core.config import settings
from src.core.dependencies import get_openai_client
from src.core.exceptions import EnrichmentError
from src.core.models import Chunk, DocumentTree, StrategyName
from src.chunking.strategies.fixed_size import _split_by_tokens
logger = logging.getLogger(__name__)
_CONTEXT_SYSTEM_PROMPT = (
"You are a document analysis assistant. Given a section of text from "
"a document, write a short context prefix (1-2 sentences) that would "
"help someone find this section later via search. Focus on the topic "
"and key terms. Do not repeat the text itself. Output ONLY the context "
"prefix, nothing else."
)
def _enrich_chunk(
client: OpenAI,
chunk_text: str,
preceding_text: str,
following_text: str,
) -> str:
"""Generate a context prefix for a chunk using the LLM.
Returns the enriched text: context prefix + original chunk.
Raises EnrichmentError on failure.
"""
user_prompt = (
f"Preceding text:\n{preceding_text[-500:] if preceding_text else '(start of document)'}\n\n"
f"This section:\n{chunk_text}\n\n"
f"Following text:\n{following_text[:500] if following_text else '(end of document)'}"
)
try:
response = client.chat.completions.create(
model=settings.llm_model,
temperature=0.0,
max_tokens=100,
messages=[
{"role": "system", "content": _CONTEXT_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
)
context = response.choices[0].message.content
if not context or not context.strip():
return chunk_text
return f"{context.strip()}\n\n{chunk_text}"
except Exception as exc:
raise EnrichmentError(f"Context enrichment failed: {exc}") from exc
class ContextualRetrievalStrategy(ChunkingStrategy):
name = StrategyName.CONTEXTUAL_RETRIEVAL
def chunk(
self,
*,
doc_name: str,
tree: DocumentTree,
markdown: str,
) -> list[Chunk]:
client = get_openai_client()
chunk_size = settings.chunk_size
overlap = settings.chunk_overlap
raw_chunks = _split_by_tokens(markdown, chunk_size, overlap)
if not raw_chunks:
return []
# Build enriched chunks with context
chunks: list[Chunk] = []
full_text = markdown
for i, chunk_text in enumerate(raw_chunks):
chunk_text = chunk_text.strip()
if not chunk_text:
continue
# Find surrounding context in the full text
pos = full_text.find(chunk_text[:100])
if pos == -1:
pos = 0
preceding = full_text[max(0, pos - 500):pos]
following = full_text[pos + len(chunk_text):pos + len(chunk_text) + 500]
# Enrich with LLM context
enriched = _enrich_chunk(client, chunk_text, preceding, following)
chunks.append(build_chunk(
strategy=self.name,
doc_name=doc_name,
index=i,
text=chunk_text,
enriched_content=enriched,
))
return chunks

View File

@@ -0,0 +1,61 @@
"""Fixed-size chunking with overlap.
The simplest possible strategy — a reliable baseline for comparison.
Splits text into chunks of N tokens with M token overlap.
If smarter strategies can't beat this baseline, they're not worth
the complexity. That's the whole point of including it.
"""
from __future__ import annotations
from src.chunking.base import ChunkingStrategy, _encoder, build_chunk
from src.core.config import settings
from src.core.models import Chunk, DocumentTree, StrategyName
def _split_by_tokens(text: str, chunk_size: int, overlap: int) -> list[str]:
"""Split text into token-sized chunks with overlap."""
tokens = _encoder.encode(text)
if len(tokens) <= chunk_size:
return [text]
chunks: list[str] = []
start = 0
while start < len(tokens):
end = min(start + chunk_size, len(tokens))
chunk_tokens = tokens[start:end]
chunks.append(_encoder.decode(chunk_tokens))
if end >= len(tokens):
break
start = end - overlap
return chunks
class FixedSizeStrategy(ChunkingStrategy):
name = StrategyName.FIXED_SIZE
def chunk(
self,
*,
doc_name: str,
tree: DocumentTree,
markdown: str,
) -> list[Chunk]:
chunk_size = settings.chunk_size
overlap = settings.chunk_overlap
raw_chunks = _split_by_tokens(markdown, chunk_size, overlap)
chunks: list[Chunk] = []
for i, text in enumerate(raw_chunks):
text = text.strip()
if text:
chunks.append(build_chunk(
strategy=self.name,
doc_name=doc_name,
index=i,
text=text,
))
return chunks

View File

@@ -0,0 +1,100 @@
"""Recursive chunking strategy.
Cascade splitting using a separator hierarchy (ADR 0015 — direct API, no frameworks):
1. Markdown headers (#, ##, ###)
2. Double newline (\\n\\n)
3. Single newline (\\n)
4. Sentence-ending punctuation (. ! ? followed by space)
5. Space (word-level, last resort)
Splitting stops when chunks reach the target size. Each chunk records
which separator level produced it.
"""
from __future__ import annotations
import re
from src.chunking.base import ChunkingStrategy, build_chunk, count_tokens
from src.core.config import settings
from src.core.models import Chunk, DocumentTree, StrategyName
# Separator patterns ordered by priority (highest first)
_SEPARATORS: list[tuple[str, re.Pattern[str]]] = [
("header", re.compile(r"(?m)^(#{1,6})\s+")),
("double_newline", re.compile(r"\n\n")),
("newline", re.compile(r"\n")),
("sentence", re.compile(r"(?<=[.!?])\s+")),
("space", re.compile(r"\s+")),
]
def _split_recursive(text: str, target_size: int) -> list[str]:
"""Recursively split text using the separator cascade."""
if not text.strip():
return []
# If text fits in target size, return as-is
if len(text) <= target_size:
return [text.strip()]
# Try each separator
for sep_name, pattern in _SEPARATORS:
parts = pattern.split(text)
if len(parts) <= 1:
continue # this separator didn't split anything
# Merge parts back up to target_size
chunks: list[str] = []
current = ""
for part in parts:
if not part:
continue
candidate = (current + " " + part).strip() if current else part
if len(candidate) <= target_size:
current = candidate
else:
if current:
chunks.append(current)
# If single part exceeds target, recurse with next separator
if len(part) > target_size:
sub_chunks = _split_recursive(part, target_size)
chunks.extend(sub_chunks)
current = ""
else:
current = part
if current:
chunks.append(current)
return chunks
# Last resort: hard split by character count
return [text[i:i + target_size].strip()
for i in range(0, len(text), target_size)
if text[i:i + target_size].strip()]
class RecursiveStrategy(ChunkingStrategy):
name = StrategyName.RECURSIVE
def chunk(
self,
*,
doc_name: str,
tree: DocumentTree,
markdown: str,
) -> list[Chunk]:
target_size = settings.chunk_size
raw_chunks = _split_recursive(markdown, target_size)
chunks: list[Chunk] = []
for i, text in enumerate(raw_chunks):
if not text.strip():
continue
chunks.append(build_chunk(
strategy=self.name,
doc_name=doc_name,
index=i,
text=text.strip(),
))
return chunks

View File

@@ -0,0 +1,122 @@
"""Semantic chunking strategy.
Sentence-level granularity (ADR 0012):
1. Split markdown into sentences.
2. Embed each sentence via OpenAI.
3. Compute cosine similarity between adjacent sentences.
4. When similarity drops below SEMANTIC_THRESHOLD, create a chunk boundary.
5. Enforce SEMANTIC_MIN_CHUNK_SIZE (minimum sentences per chunk).
6. Boundary sentence stays with the previous chunk.
Note: this strategy requires embeddings at chunk-time. The chunk()
method returns text chunks WITHOUT embeddings — the embedding step
happens in the orchestration layer (service.py) which calls the
embedding service after chunking.
"""
from __future__ import annotations
import numpy as np
from src.chunking.base import ChunkingStrategy, build_chunk, split_sentences
from src.core.config import settings
from src.core.models import Chunk, DocumentTree, StrategyName
def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
"""Compute cosine similarity between two vectors."""
dot = np.dot(a, b)
norm = np.linalg.norm(a) * np.linalg.norm(b)
if norm == 0:
return 0.0
return float(dot / norm)
def _group_sentences_into_chunks(
sentences: list[str],
embeddings: list[list[float]],
threshold: float,
min_size: int,
) -> list[str]:
"""Group sentences into chunks based on semantic similarity.
Returns a list of chunk texts.
"""
if not sentences:
return []
if len(sentences) <= min_size:
return [" ".join(sentences)]
chunks: list[str] = []
current_group: list[str] = [sentences[0]]
for i in range(1, len(sentences)):
sim = _cosine_similarity(
np.array(embeddings[i - 1]),
np.array(embeddings[i]),
)
if sim < threshold and len(current_group) >= min_size:
# Topic shift — close current chunk
chunks.append(" ".join(current_group))
current_group = [sentences[i]]
else:
current_group.append(sentences[i])
# Flush remaining
if current_group:
# If the last group is too small, merge into previous
if chunks and len(current_group) < min_size:
last = chunks.pop()
chunks.append(last + " " + " ".join(current_group))
else:
chunks.append(" ".join(current_group))
return chunks
class SemanticStrategy(ChunkingStrategy):
name = StrategyName.SEMANTIC
def chunk(
self,
*,
doc_name: str,
tree: DocumentTree,
markdown: str,
sentence_embeddings: list[list[float]] | None = None,
) -> list[Chunk]:
"""Produce semantic chunks.
If sentence_embeddings is provided (from the orchestration layer),
uses them for boundary detection. Otherwise, falls back to
paragraph-level chunking (sentences without similarity-based splits).
"""
sentences = split_sentences(markdown)
if not sentences:
return []
threshold = settings.semantic_threshold
min_size = settings.semantic_min_chunk_size
if sentence_embeddings and len(sentence_embeddings) == len(sentences):
chunk_texts = _group_sentences_into_chunks(
sentences, sentence_embeddings, threshold, min_size
)
else:
# Fallback: group sentences into fixed-size chunks
chunk_texts = []
for i in range(0, len(sentences), min_size):
group = sentences[i:i + min_size]
chunk_texts.append(" ".join(group))
chunks: list[Chunk] = []
for i, text in enumerate(chunk_texts):
if text.strip():
chunks.append(build_chunk(
strategy=self.name,
doc_name=doc_name,
index=i,
text=text.strip(),
))
return chunks

View File

@@ -0,0 +1,134 @@
"""Parent-Child via Semantic Clustering strategy.
Groups paragraphs by semantic similarity into clusters. Each cluster
is a parent; each paragraph in the cluster is a child.
At query time: the child is found via vector search, then its full
parent cluster is returned as context — giving the LLM richer
information than a single paragraph.
No headings or document structure needed — uses meaning instead.
"""
from __future__ import annotations
import logging
import numpy as np
from src.chunking.base import (
ChunkingStrategy,
build_chunk,
make_chunk_id,
count_tokens,
)
from src.core.config import settings
from src.core.models import Chunk, DocumentTree, StrategyName
logger = logging.getLogger(__name__)
def _cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
dot = np.dot(a, b)
norm = np.linalg.norm(a) * np.linalg.norm(b)
return float(dot / norm) if norm > 0 else 0.0
def _split_paragraphs(text: str) -> list[str]:
"""Split markdown into paragraphs (double newline or single newline)."""
import re
# Split on double newlines first, then filter empties
parts = re.split(r"\n\s*\n", text)
paragraphs = [p.strip() for p in parts if p.strip()]
# If no double newlines, try single newlines
if len(paragraphs) <= 1:
parts = text.split("\n")
paragraphs = [p.strip() for p in parts if p.strip()]
return paragraphs
def _cluster_paragraphs(
paragraphs: list[str],
embeddings: list[list[float]],
threshold: float,
) -> list[list[int]]:
"""Group consecutive paragraphs into clusters by semantic similarity.
Returns a list of clusters, each a list of paragraph indices.
"""
if not paragraphs or not embeddings:
return []
clusters: list[list[int]] = [[0]]
for i in range(1, len(paragraphs)):
sim = _cosine_similarity(
np.array(embeddings[i - 1]),
np.array(embeddings[i]),
)
if sim >= threshold:
# Same cluster
clusters[-1].append(i)
else:
# New cluster
clusters.append([i])
return clusters
class SemanticParentChildStrategy(ChunkingStrategy):
name = StrategyName.SEMANTIC_PARENT_CHILD
def chunk(
self,
*,
doc_name: str,
tree: DocumentTree,
markdown: str,
paragraph_embeddings: list[list[float]] | None = None,
) -> list[Chunk]:
"""Produce parent-child chunks via semantic clustering.
If paragraph_embeddings is provided (from orchestration layer),
uses them for clustering. Otherwise, groups paragraphs by
fixed count.
"""
paragraphs = _split_paragraphs(markdown)
if not paragraphs:
return []
threshold = settings.semantic_threshold
if paragraph_embeddings and len(paragraph_embeddings) == len(paragraphs):
clusters = _cluster_paragraphs(paragraphs, paragraph_embeddings, threshold)
else:
# Fallback: group every N paragraphs
group_size = max(3, settings.semantic_min_chunk_size)
clusters = []
for i in range(0, len(paragraphs), group_size):
clusters.append(list(range(i, min(i + group_size, len(paragraphs)))))
chunks: list[Chunk] = []
chunk_index = 0
for cluster_indices in clusters:
# Parent = full cluster text
parent_text = "\n\n".join(paragraphs[i] for i in cluster_indices)
parent_id = make_chunk_id(self.name, doc_name, chunk_index)
# Each paragraph in the cluster is a child
for para_idx in cluster_indices:
para_text = paragraphs[para_idx]
chunks.append(build_chunk(
strategy=self.name,
doc_name=doc_name,
index=chunk_index,
text=para_text,
parent_id=parent_id,
))
chunk_index += 1
return chunks

View File

@@ -32,6 +32,10 @@ class BenchmarkError(Exception):
"""Base exception for benchmarking-related errors.""" """Base exception for benchmarking-related errors."""
class QueryError(BenchmarkError):
"""Raised when a query operation fails."""
class DryRunError(BenchmarkError): class DryRunError(BenchmarkError):
"""Raised when a dry-run estimation fails.""" """Raised when a dry-run estimation fails."""

View File

@@ -8,12 +8,11 @@ from pydantic import BaseModel, Field
class StrategyName(str, Enum): class StrategyName(str, Enum):
"""Canonical identifiers for each chunking strategy.""" """Canonical identifiers for each chunking strategy."""
CONTEXTUAL_STRUCTURE = "contextual_structure"
PARENT_CHILD = "parent_child"
SEMANTIC = "semantic"
MARKDOWN_STRUCTURE = "markdown_structure"
RECURSIVE = "recursive" RECURSIVE = "recursive"
FIXED_SIZE = "fixed_size"
SEMANTIC = "semantic"
CONTEXTUAL_RETRIEVAL = "contextual_retrieval"
SEMANTIC_PARENT_CHILD = "semantic_parent_child"
class Chunk(BaseModel): class Chunk(BaseModel):
@@ -22,7 +21,6 @@ class Chunk(BaseModel):
Strategy-specific fields (parent_id, enriched_content) are nullable Strategy-specific fields (parent_id, enriched_content) are nullable
when not applicable to the strategy that produced the chunk. when not applicable to the strategy that produced the chunk.
""" """
document_name: str document_name: str
chunk_id: str chunk_id: str
strategy_name: StrategyName strategy_name: StrategyName
@@ -36,11 +34,11 @@ class Chunk(BaseModel):
class ChunkMetadata(BaseModel): class ChunkMetadata(BaseModel):
"""Payload stored alongside every chunk vector in Qdrant.""" """Payload stored alongside every chunk vector in Qdrant."""
document_name: str document_name: str
chunk_id: str chunk_id: str
strategy_name: StrategyName strategy_name: StrategyName
chunk_index: int chunk_index: int
text: str
token_count: int token_count: int
character_count: int character_count: int
parent_id: Optional[str] = None parent_id: Optional[str] = None
@@ -53,7 +51,45 @@ def chunk_to_metadata(chunk: Chunk) -> ChunkMetadata:
chunk_id=chunk.chunk_id, chunk_id=chunk.chunk_id,
strategy_name=chunk.strategy_name, strategy_name=chunk.strategy_name,
chunk_index=chunk.chunk_index, chunk_index=chunk.chunk_index,
text=chunk.text,
token_count=chunk.token_count, token_count=chunk.token_count,
character_count=chunk.character_count, character_count=chunk.character_count,
parent_id=chunk.parent_id, parent_id=chunk.parent_id,
) )
# ── Document Tree ──────────────────────────────────────────────────
class NodeType(str, Enum):
DOCUMENT = "document"
SECTION = "section"
ARTICLE = "article"
PARAGRAPH = "paragraph"
class DocumentTreeNode(BaseModel):
"""A node in the hierarchical document tree extracted by python-docx.
Tree shape: Document > Section > Article > Paragraph.
All nodes are serialisable to JSON for SQLite storage.
"""
node_type: NodeType
text: str = ""
heading: Optional[str] = None # heading label, e.g. "Article 15"
heading_level: Optional[int] = None # 1, 2, 3 …
children: list["DocumentTreeNode"] = Field(default_factory=list)
class DocumentTree(BaseModel):
"""Root wrapper for a parsed document's hierarchy."""
root: DocumentTreeNode
# ── Pagination helper ──────────────────────────────────────────────
class PaginatedResponse(BaseModel):
"""Generic paginated list wrapper."""
items: list = Field(default_factory=list)
total: int = 0
offset: int = 0
limit: int = 50

View File

@@ -0,0 +1 @@
"""Document parsing and processing."""

79
src/documents/models.py Normal file
View File

@@ -0,0 +1,79 @@
"""Request/response schemas for the Documents API."""
from typing import Optional
from pydantic import BaseModel, Field
from src.core.models import StrategyName
# ── Responses ──────────────────────────────────────────────────────
class DocumentResponse(BaseModel):
"""Returned after upload or GET."""
id: str
filename: str
paragraph_count: int = 0
chunk_counts: dict[str, int] = Field(default_factory=dict)
created_at: str
class DocumentDetailResponse(DocumentResponse):
"""Full document detail including tree and text preview."""
parsed_text_preview: str # first 500 chars
document_tree: dict # JSON-serialised DocumentTree
class ProcessRequest(BaseModel):
"""Body for POST /documents/{id}/process."""
strategies: list[StrategyName] = Field(
default=[
StrategyName.RECURSIVE,
StrategyName.FIXED_SIZE,
StrategyName.SEMANTIC,
StrategyName.CONTEXTUAL_RETRIEVAL,
StrategyName.SEMANTIC_PARENT_CHILD,
],
description="Which chunking strategies to run (defaults to all 5)",
min_length=1,
)
class StrategyResult(BaseModel):
"""Outcome for a single strategy in a processing run."""
strategy: StrategyName
status: str # "completed" | "failed"
chunks_produced: int = 0
error: Optional[str] = None
class ProcessResponse(BaseModel):
"""Returned after processing a document with selected strategies."""
document_id: str
strategies_completed: list[StrategyResult]
strategies_failed: list[StrategyResult]
class DeleteResponse(BaseModel):
"""Returned after deleting a document."""
deleted: bool
document_id: str
class DocumentListResponse(BaseModel):
"""Returned when listing documents."""
items: list[DocumentResponse]
total: int
offset: int
limit: int
# ── Strategies list ────────────────────────────────────────────────
class StrategyInfo(BaseModel):
name: StrategyName
description: str
class StrategiesResponse(BaseModel):
strategies: list[StrategyInfo]

361
src/documents/parser.py Normal file
View File

@@ -0,0 +1,361 @@
"""DOCX parser: extracts document tree + flat markdown.
Uses python-docx to read paragraph styles and build a hierarchical
DocumentTree (Document > Section > Article > Paragraph). Also produces
a markdown representation consumed by chunking strategies.
Supports both .docx and .doc formats. .doc files are converted to
.docx via LibreOffice headless mode before parsing.
Handles documents where content is in tables (not just paragraphs).
"""
from __future__ import annotations
import re
import subprocess
import tempfile
from pathlib import Path
from docx import Document as DocxDocument
from docx.document import Document as DocxDocumentType
from docx.text.paragraph import Paragraph
from src.core.models import (
DocumentTree,
DocumentTreeNode,
NodeType,
)
from src.core.exceptions import DocumentProcessingError
# ── Heading level → node type mapping ──────────────────────────────
_HEADING_MAP: dict[int, NodeType] = {
1: NodeType.SECTION,
2: NodeType.ARTICLE,
# 3+ also ARTICLE (nesting depth determines hierarchy)
}
def _heading_level(style_name: str) -> int | None:
"""Return the heading level from a style name, or None if not a heading.
Handles both "Heading 1" (display name) and "Heading1" (style ID from XML).
"""
m = re.match(r"^heading\s*(\d+)$", style_name.strip(), re.IGNORECASE)
if m:
return int(m.group(1))
return None
def _node_type_for_level(level: int) -> NodeType:
return _HEADING_MAP.get(level, NodeType.ARTICLE)
# ── Heuristic heading detection for table-heavy documents ──────────
# Patterns that look like section/article headings in Farsi/English docs
_HEADING_PATTERNS: list[tuple[str, int]] = [
# Farsi section markers: "بخش اول", "بخش دوم", etc.
(re.compile(r"^بخش\s+"), 1),
# Numbered sections: "1-1", "2-1", "10-1", "1-1-8"
(re.compile(r"^\d+[\-\.]\d+"), 2),
# Starred sections: "*بخش اول", "*تعریف"
(re.compile(r"^\*\s*بخش\s+"), 1),
(re.compile(r"^\*\s*\S"), 2),
]
def _is_likely_heading(text: str) -> int | None:
"""Detect heading-like patterns in table-extracted text.
Returns the heading level (1 or 2) if detected, else None.
Used when the document has no real heading styles (table-only content).
"""
text = text.strip()
if len(text) > 150: # headings are short
return None
for pattern, level in _HEADING_PATTERNS:
if pattern.match(text):
return level
return None
def _detect_heading_blocks(blocks: list[_TextBlock]) -> bool:
"""Upgrade paragraph blocks to heading blocks based on content patterns.
Returns True if any blocks were upgraded.
Only activates when no real headings exist in the document.
"""
# Check if there are already real headings
has_headings = any(_heading_level(b.style_name) is not None for b in blocks)
if has_headings:
return False
upgraded = False
for block in blocks:
if block.style_name != "Normal":
continue
level = _is_likely_heading(block.text)
if level is not None:
block.style_name = f"Heading{level}"
upgraded = True
return upgraded
# ── Text block extraction (paragraphs + tables) ───────────────────
class _TextBlock:
"""A unit of text extracted from the document, preserving reading order."""
__slots__ = ("style_name", "text")
def __init__(self, style_name: str, text: str) -> None:
self.style_name = style_name
self.text = text
def _extract_text_blocks(doc: DocxDocumentType) -> list[_TextBlock]:
"""Walk the document body in reading order, extracting paragraphs and tables.
This handles documents where content lives inside table cells
(common in Farsi/Arabic .doc files exported from older Word versions).
"""
from docx.oxml.ns import qn
blocks: list[_TextBlock] = []
for element in doc.element.body:
tag = element.tag.split("}")[-1] if "}" in element.tag else element.tag
if tag == "p":
# Paragraph — extract text and style
text = element.text or ""
# Also check for runs (text split across formatting)
if not text.strip():
runs = element.findall(qn("w:r"))
text = "".join(r.text or "" for r in runs)
# Get style name
ppr = element.find(qn("w:pPr"))
style_name = "Normal"
if ppr is not None:
pstyle = ppr.find(qn("w:pStyle"))
if pstyle is not None:
style_name = pstyle.get(qn("w:val"), "Normal")
if text.strip():
blocks.append(_TextBlock(style_name, text.strip()))
elif tag == "tbl":
# Table — extract all cell text as paragraph blocks
for row in element.findall(qn("w:tr")):
for cell in row.findall(qn("w:tc")):
for para in cell.findall(qn("w:p")):
# Get paragraph text
text = ""
runs = para.findall(qn("w:r"))
text = "".join(r.text or "" for r in runs)
# Get style
ppr = para.find(qn("w:pPr"))
style_name = "Normal"
if ppr is not None:
pstyle = ppr.find(qn("w:pStyle"))
if pstyle is not None:
style_name = pstyle.get(qn("w:val"), "Normal")
if text.strip():
blocks.append(_TextBlock(style_name, text.strip()))
return blocks
# ── Tree builder ───────────────────────────────────────────────────
def build_document_tree(paragraphs: list[Paragraph] | list[_TextBlock]) -> DocumentTreeNode:
"""Build a DocumentTreeNode tree from a list of text blocks.
Accepts either python-docx Paragraph objects or _TextBlock objects.
"""
root = DocumentTreeNode(node_type=NodeType.DOCUMENT, text="", heading=None, heading_level=None)
# Stack of (level, node) for current nesting. level 0 = root.
stack: list[tuple[int, DocumentTreeNode]] = [(0, root)]
for block in paragraphs:
# Get style name and text from either type
if isinstance(block, _TextBlock):
style_name = block.style_name
text = block.text
else:
style_name = block.style.name
text = block.text.strip()
level = _heading_level(style_name)
if not text:
continue # skip blank paragraphs
if level is not None:
# Pop back to parent level
while len(stack) > 1 and stack[-1][0] >= level:
stack.pop()
node = DocumentTreeNode(
node_type=_node_type_for_level(level),
text="",
heading=text,
heading_level=level,
)
stack[-1][1].children.append(node)
stack.append((level, node))
else:
# Body text — add as paragraph child of current heading
node = DocumentTreeNode(
node_type=NodeType.PARAGRAPH,
text=text,
)
stack[-1][1].children.append(node)
return root
# ── Markdown renderer ──────────────────────────────────────────────
def tree_to_markdown(node: DocumentTreeNode, depth: int = 0) -> str:
"""Render a DocumentTreeNode tree to markdown text."""
parts: list[str] = []
if node.heading:
prefix = "#" * (node.heading_level or 1)
parts.append(f"{prefix} {node.heading}")
if node.text:
parts.append(node.text)
for child in node.children:
parts.append(tree_to_markdown(child, depth + 1))
return "\n\n".join(parts)
# ── Public API ─────────────────────────────────────────────────────
class ParseResult:
"""Output of parse_docx(): tree + markdown + raw text."""
__slots__ = ("tree", "markdown", "plain_text", "paragraph_count")
def __init__(
self,
tree: DocumentTree,
markdown: str,
plain_text: str,
paragraph_count: int,
) -> None:
self.tree = tree
self.markdown = markdown
self.plain_text = plain_text
self.paragraph_count = paragraph_count
# ── .doc → .docx conversion ───────────────────────────────────────
def _convert_doc_to_docx(doc_path: Path) -> Path:
"""Convert a .doc file to .docx using LibreOffice headless mode.
Returns the path to the converted .docx file (in a temp directory).
The caller is responsible for cleanup.
Raises:
DocumentProcessingError: If conversion fails.
"""
out_dir = Path(tempfile.mkdtemp(prefix="docconv_"))
try:
result = subprocess.run(
[
"libreoffice",
"--headless",
"--convert-to", "docx",
"--outdir", str(out_dir),
str(doc_path),
],
capture_output=True,
text=True,
timeout=60,
)
if result.returncode != 0:
raise DocumentProcessingError(
f"LibreOffice conversion failed: {result.stderr}"
)
# Find the converted file
converted = out_dir / doc_path.with_suffix(".docx").name
if not converted.exists():
raise DocumentProcessingError(
f"Converted file not found: {converted}"
)
return converted
except subprocess.TimeoutExpired:
raise DocumentProcessingError("LibreOffice conversion timed out")
except DocumentProcessingError:
raise
except Exception as exc:
raise DocumentProcessingError(
f"Failed to convert .doc to .docx: {exc}"
) from exc
def parse_docx(file_path: str | Path) -> ParseResult:
"""Parse a .docx or .doc file into a DocumentTree + markdown.
.doc files are automatically converted to .docx via LibreOffice.
Args:
file_path: Path to the .docx or .doc file.
Returns:
ParseResult with tree, markdown, plain_text, and paragraph_count.
Raises:
DocumentProcessingError: If the file cannot be parsed.
"""
path = Path(file_path)
if not path.exists():
raise DocumentProcessingError(f"File not found: {path}")
suffix = path.suffix.lower()
if suffix not in (".docx", ".doc"):
raise DocumentProcessingError(f"Not a supported file format: {suffix} (expected .docx or .doc)")
# Convert .doc to .docx if needed
if suffix == ".doc":
path = _convert_doc_to_docx(path)
try:
doc: DocxDocumentType = DocxDocument(str(path))
except Exception as exc:
raise DocumentProcessingError(f"Failed to open DOCX: {exc}") from exc
# Extract text blocks from paragraphs + tables (preserves reading order)
blocks = _extract_text_blocks(doc)
if not blocks:
raise DocumentProcessingError("Document contains no text content")
# Detect heading patterns in table-only documents
_detect_heading_blocks(blocks)
root = build_document_tree(blocks)
tree = DocumentTree(root=root)
markdown = tree_to_markdown(root)
plain_text = "\n".join(b.text for b in blocks if b.text)
return ParseResult(
tree=tree,
markdown=markdown,
plain_text=plain_text,
paragraph_count=len(blocks),
)

84
src/documents/routes.py Normal file
View File

@@ -0,0 +1,84 @@
"""Document and strategy API routes.
Endpoints:
POST /documents Upload a .docx file
POST /documents/{id}/process Run chunking strategies (stub until Phase 2)
DELETE /documents/{id} Remove document + vectors
GET /strategies List available chunking strategies
"""
from fastapi import APIRouter, File, UploadFile
from src.core.exceptions import DocumentProcessingError
from src.core.models import PaginatedResponse, StrategyName
from src.documents.models import (
DeleteResponse,
DocumentResponse,
DocumentListResponse,
ProcessRequest,
ProcessResponse,
StrategiesResponse,
StrategyInfo,
)
from src.documents import service
router = APIRouter()
@router.get("/documents", response_model=DocumentListResponse)
async def list_documents(offset: int = 0, limit: int = 50):
"""List all uploaded documents with pagination."""
result = service.list_documents(offset=offset, limit=limit)
return DocumentListResponse(
items=[DocumentResponse(**doc) for doc in result["items"]],
total=result["total"],
offset=result["offset"],
limit=result["limit"],
)
@router.post("/documents", response_model=DocumentResponse, status_code=201)
async def upload_document(file: UploadFile = File(...)):
"""Upload a .docx file. Parses it, stores the document tree in SQLite."""
if not file.filename:
raise DocumentProcessingError("No filename provided")
if not file.filename.lower().endswith((".docx", ".doc")):
raise DocumentProcessingError("Only .docx and .doc files are supported")
content = await file.read()
if not content:
raise DocumentProcessingError("Empty file")
doc = service.upload_document(file.filename, content)
return DocumentResponse(
id=doc["id"],
filename=doc["filename"],
paragraph_count=doc.get("paragraph_count", 0),
chunk_counts=doc.get("chunk_counts", {}),
created_at=doc["created_at"],
)
@router.post("/documents/{doc_id}/process", response_model=ProcessResponse)
async def process_document(doc_id: str, request: ProcessRequest):
"""Run selected chunking strategies on an uploaded document.
Currently returns stub results until Phase 2 implements the strategies.
"""
return service.process_document(doc_id, request)
@router.delete("/documents/{doc_id}", response_model=DeleteResponse)
async def delete_document(doc_id: str):
"""Delete a document and all its chunk vectors from Qdrant."""
deleted = service.delete_document(doc_id)
return DeleteResponse(deleted=deleted, document_id=doc_id)
@router.get("/strategies", response_model=StrategiesResponse)
async def list_strategies():
"""List available chunking strategies with descriptions."""
strategies = service.list_strategies()
return StrategiesResponse(
strategies=[StrategyInfo(**s) for s in strategies]
)

158
src/documents/service.py Normal file
View File

@@ -0,0 +1,158 @@
"""Document service: upload, parse, process, delete.
Orchestrates the document lifecycle. Strategy processing delegates
to src/chunking/service.py for the actual chunking pipeline.
"""
from __future__ import annotations
import logging
from typing import Any
from src.core.exceptions import DocumentProcessingError
from src.core.models import StrategyName
from src.documents.models import (
ProcessRequest,
ProcessResponse,
StrategyResult,
)
from src.documents.parser import parse_docx
from src.storage import sqlite as db
from src.storage import qdrant as qdr
logger = logging.getLogger(__name__)
STRATEGY_DESCRIPTIONS: dict[StrategyName, str] = {
StrategyName.RECURSIVE: (
"Cascade splitting: headers > double newline > newline > punctuation > space. "
"Stops when chunks reach target size."
),
StrategyName.FIXED_SIZE: (
"Fixed-size token splitting with overlap. Simple baseline for comparison. "
"If smarter strategies can't beat this, they're not worth the complexity."
),
StrategyName.SEMANTIC: (
"Sentence-level embeddings → cosine similarity → dynamic chunk boundaries. "
"Chunks split when topic similarity drops below threshold."
),
StrategyName.CONTEXTUAL_RETRIEVAL: (
"Each chunk gets a short context summary from LLM prepended before embedding. "
"Anthropic's 2024 research showed 49% retrieval improvement."
),
StrategyName.SEMANTIC_PARENT_CHILD: (
"Paragraphs grouped into semantic clusters. Each cluster is a parent; "
"each paragraph is a child. Uses meaning, not headings."
),
}
# ── Upload ─────────────────────────────────────────────────────────
def upload_document(filename: str, file_bytes: bytes) -> dict[str, Any]:
"""Parse a .docx upload, store in SQLite, return document record."""
import tempfile
from pathlib import Path
suffix = Path(filename).suffix or ".docx"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
tmp.write(file_bytes)
tmp_path = tmp.name
try:
result = parse_docx(tmp_path)
finally:
Path(tmp_path).unlink(missing_ok=True)
doc = db.save_document(
filename=filename,
parsed_text=result.markdown,
document_tree=result.tree.model_dump_json(),
)
logger.info(
"Uploaded '%s': %d blocks, %d chars",
filename, result.paragraph_count, len(result.markdown),
)
doc["paragraph_count"] = result.paragraph_count
return doc
# ── Process (strategies) ──────────────────────────────────────────
def process_document(
doc_id: str, request: ProcessRequest
) -> ProcessResponse:
"""Run selected chunking strategies on a stored document.
Delegates to src/chunking/service.run_strategies() which handles:
1. Loading DocumentTree + markdown from SQLite.
2. Running each strategy (chunk → embed → Qdrant).
3. Per-strategy failure isolation (ADR 0003).
"""
doc = db.get_document(doc_id)
if doc is None:
raise DocumentProcessingError(f"Document not found: {doc_id}")
# Import here to avoid circular imports at module level
from src.chunking.service import run_strategies
completed_raw, failed_raw = run_strategies(doc_id, request.strategies)
completed = [
StrategyResult(
strategy=r["strategy"],
status="completed",
chunks_produced=r["chunks_produced"],
)
for r in completed_raw
]
failed = [
StrategyResult(
strategy=r["strategy"],
status="failed",
error=r.get("error"),
)
for r in failed_raw
]
return ProcessResponse(
document_id=doc_id,
strategies_completed=completed,
strategies_failed=failed,
)
# ── Delete ─────────────────────────────────────────────────────────
def delete_document(doc_id: str) -> bool:
"""Delete a document and all its Qdrant vectors."""
doc = db.get_document(doc_id)
if doc is None:
return False
for strategy_name, count in doc.get("chunk_counts", {}).items():
if count > 0:
try:
strategy = StrategyName(strategy_name)
qdr.delete_document_chunks(strategy, doc["filename"])
except Exception as exc:
logger.warning("Failed to delete Qdrant vectors for %s: %s", strategy_name, exc)
return db.delete_document(doc_id)
# ── List documents ────────────────────────────────────────────────
def list_documents(*, offset: int = 0, limit: int = 50) -> dict[str, Any]:
"""List documents with pagination."""
return db.list_documents(offset=offset, limit=limit)
# ── List strategies ────────────────────────────────────────────────
def list_strategies() -> list[dict[str, str]]:
"""Return all available strategies with descriptions."""
return [
{"name": s.value, "description": STRATEGY_DESCRIPTIONS[s]}
for s in StrategyName
]

View File

@@ -1,22 +1,72 @@
"""FastAPI app factory. Composes all domain routers and middleware.""" """FastAPI app factory. Composes all domain routers and middleware."""
import logging
import os
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from src.core.exceptions import ( from src.core.exceptions import (
ChunkingError, ChunkingError,
BenchmarkError, BenchmarkError,
QueryError,
chunking_exception_handler, chunking_exception_handler,
benchmark_exception_handler, benchmark_exception_handler,
) )
from src.documents.routes import router as documents_router
from src.benchmarking.routes import router as benchmarking_router
from src.admin.routes import router as admin_router
from src.storage.sqlite import init_db
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
# ── Request Logging Middleware ─────────────────────────────────────
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Log request method, path, status code, and duration."""
async def dispatch(self, request: Request, call_next) -> Response:
start = time.time()
method = request.method
path = request.url.path
response = await call_next(request)
duration = (time.time() - start) * 1000 # ms
status = response.status_code
# Log at appropriate level
if status >= 500:
logger.error("%s %s -> %d (%.1fms)", method, path, status, duration)
elif status >= 400:
logger.warning("%s %s -> %d (%.1fms)", method, path, status, duration)
else:
logger.info("%s %s -> %d (%.1fms)", method, path, status, duration)
return response
def create_app() -> FastAPI: def create_app() -> FastAPI:
"""Create and configure the FastAPI application. """Create and configure the FastAPI application.
Mounts domain routers and registers exception handlers. Mounts domain routers and registers exception handlers.
Routers are imported lazily — domains are added in later phases.
""" """
# Ensure SQLite tables exist at startup
init_db()
app = FastAPI( app = FastAPI(
title="RAG Chunking Benchmarker", title="RAG Chunking Benchmarker",
description="Benchmark five chunking strategies on regulatory documents. " description="Benchmark five chunking strategies on regulatory documents. "
@@ -33,9 +83,22 @@ def create_app() -> FastAPI:
allow_headers=["*"], allow_headers=["*"],
) )
# Request logging
app.add_middleware(RequestLoggingMiddleware)
# Register exception handlers # Register exception handlers
app.add_exception_handler(ChunkingError, chunking_exception_handler) app.add_exception_handler(ChunkingError, chunking_exception_handler)
app.add_exception_handler(BenchmarkError, benchmark_exception_handler) app.add_exception_handler(BenchmarkError, benchmark_exception_handler)
app.add_exception_handler(QueryError, benchmark_exception_handler)
# Mount domain routers
app.include_router(documents_router)
app.include_router(benchmarking_router)
app.include_router(admin_router)
# Mount dashboard at /app
static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
app.mount("/app", StaticFiles(directory=static_dir, html=True), name="dashboard")
return app return app

1708
src/static/index.html Normal file

File diff suppressed because it is too large Load Diff

1
src/storage/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Storage layer — SQLite persistence and Qdrant vector store."""

219
src/storage/qdrant.py Normal file
View File

@@ -0,0 +1,219 @@
"""Qdrant vector storage layer.
One collection per chunking strategy. Handles collection creation,
vector upsert, and similarity search.
"""
import logging
import uuid
from typing import Any
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance,
FieldCondition,
Filter,
MatchValue,
PointIdsList,
PointStruct,
VectorParams,
)
from src.core.config import settings
from src.core.dependencies import get_qdrant_client
from src.core.exceptions import QdrantError
from src.core.models import Chunk, ChunkMetadata, StrategyName, chunk_to_metadata
logger = logging.getLogger(__name__)
# Embedding dimension for text-embedding-3-small
VECTOR_DIMENSION = 1536
def collection_name(strategy: StrategyName | str) -> str:
"""Convention: {strategy_name}_collection."""
if isinstance(strategy, StrategyName):
name = strategy.value
else:
name = strategy
return f"{name}_collection"
# ── Collection management ──────────────────────────────────────────
def ensure_collection(strategy: StrategyName) -> None:
"""Create the collection for a strategy if it doesn't already exist."""
client = get_qdrant_client()
name = collection_name(strategy)
try:
existing = [c.name for c in client.get_collections().collections]
if name not in existing:
client.create_collection(
collection_name=name,
vectors_config=VectorParams(
size=VECTOR_DIMENSION,
distance=Distance.COSINE,
),
)
logger.info("Created Qdrant collection: %s", name)
except Exception as exc:
raise QdrantError(f"Failed to create collection '{name}': {exc}") from exc
def ensure_all_collections() -> None:
"""Create collections for all five strategies."""
for strategy in StrategyName:
ensure_collection(strategy)
def delete_collection(strategy: StrategyName) -> None:
"""Delete a strategy's collection entirely."""
client = get_qdrant_client()
name = collection_name(strategy)
try:
client.delete_collection(collection_name=name)
logger.info("Deleted Qdrant collection: %s", name)
except Exception as exc:
raise QdrantError(f"Failed to delete collection '{name}': {exc}") from exc
def list_collection_points(strategy: StrategyName) -> int:
"""Return the number of points in a strategy's collection."""
client = get_qdrant_client()
name = collection_name(strategy)
try:
info = client.get_collection(collection_name=name)
return info.points_count or 0
except Exception as exc:
raise QdrantError(f"Failed to get info for '{name}': {exc}") from exc
# ── Upsert ─────────────────────────────────────────────────────────
def upsert_chunks(chunks: list[Chunk], embeddings: list[list[float]]) -> int:
"""Upsert chunks with their embeddings into the appropriate collection.
All chunks must share the same strategy_name (one collection per call).
Returns the number of points upserted.
"""
if not chunks:
return 0
if len(chunks) != len(embeddings):
raise QdrantError(
f"Mismatch: {len(chunks)} chunks but {len(embeddings)} embeddings"
)
strategy = chunks[0].strategy_name
client = get_qdrant_client()
name = collection_name(strategy)
points = []
for chunk, embedding in zip(chunks, embeddings):
meta = chunk_to_metadata(chunk)
points.append(
PointStruct(
id=str(uuid.uuid5(uuid.NAMESPACE_URL, chunk.chunk_id)),
vector=embedding,
payload=meta.model_dump(),
)
)
try:
client.upsert(collection_name=name, points=points)
logger.info("Upserted %d points into %s", len(points), name)
return len(points)
except Exception as exc:
raise QdrantError(f"Failed to upsert into '{name}': {exc}") from exc
# ── Search ─────────────────────────────────────────────────────────
def search(
strategy: StrategyName,
query_vector: list[float],
top_k: int = 5,
document_filter: str | None = None,
) -> list[dict[str, Any]]:
"""Vector similarity search in a strategy's collection.
Returns a list of {chunk_id, score, payload} dicts, ordered by score.
"""
client = get_qdrant_client()
name = collection_name(strategy)
query_filter = None
if document_filter:
query_filter = Filter(
must=[FieldCondition(key="document_name", match=MatchValue(value=document_filter))]
)
try:
results = client.query_points(
collection_name=name,
query=query_vector,
limit=top_k,
query_filter=query_filter,
)
hits = []
for point in results.points:
hits.append({
"chunk_id": point.id,
"score": point.score,
"payload": point.payload,
})
return hits
except Exception as exc:
raise QdrantError(f"Search failed in '{name}': {exc}") from exc
# ── Delete by document ─────────────────────────────────────────────
def delete_document_chunks(strategy: StrategyName, document_name: str) -> int:
"""Remove all chunks for a given document from a strategy's collection.
Returns the number of points deleted.
"""
client = get_qdrant_client()
name = collection_name(strategy)
try:
# First find matching point IDs
results = client.scroll(
collection_name=name,
scroll_filter=Filter(
must=[FieldCondition(key="document_name", match=MatchValue(value=document_name))]
),
limit=10_000, # safety cap
with_payload=False,
with_vectors=False,
)
point_ids = [p.id for p in results[0]]
if not point_ids:
return 0
client.delete(
collection_name=name,
points_selector=PointIdsList(points=point_ids),
)
logger.info("Deleted %d points from %s for document '%s'", len(point_ids), name, document_name)
return len(point_ids)
except Exception as exc:
raise QdrantError(f"Failed to delete from '{name}': {exc}") from exc
def delete_all_strategy_chunks(strategy: StrategyName) -> int:
"""Delete all points in a strategy's collection (full wipe)."""
client = get_qdrant_client()
name = collection_name(strategy)
try:
info = client.get_collection(collection_name=name)
count = info.points_count or 0
if count > 0:
client.delete(
collection_name=name,
points_selector=PointIdsList(points=list(range(count))),
)
return count
except Exception as exc:
raise QdrantError(f"Failed to wipe '{name}': {exc}") from exc

342
src/storage/sqlite.py Normal file
View File

@@ -0,0 +1,342 @@
"""SQLite storage layer for documents, experiments, and queries.
All structured data (parsed documents, benchmark results, query history)
lives here. Vectors live in Qdrant — never in SQLite.
"""
import json
import sqlite3
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from src.core.config import settings
# ── Helpers ────────────────────────────────────────────────────────
def _new_id() -> str:
return uuid.uuid4().hex
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
# ── Connection ─────────────────────────────────────────────────────
def _get_db_path() -> str:
"""Extract the file path from the database_url setting."""
# settings.database_url is like "sqlite:///./data/chunking_benchmark.db"
return settings.database_url.replace("sqlite:///", "")
def _connect() -> sqlite3.Connection:
"""Return a new SQLite connection with row_factory."""
db_path = _get_db_path()
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
# ── Schema init ────────────────────────────────────────────────────
_SCHEMA = """
CREATE TABLE IF NOT EXISTS documents (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
parsed_text TEXT NOT NULL,
document_tree TEXT NOT NULL,
chunk_counts TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS queries (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
strategy_name TEXT NOT NULL,
question TEXT NOT NULL,
answer TEXT NOT NULL,
retrieved_chunks TEXT NOT NULL DEFAULT '[]',
latency_breakdown TEXT NOT NULL DEFAULT '{}',
token_usage TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS experiments (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL,
benchmark_config TEXT NOT NULL DEFAULT '{}',
questions TEXT NOT NULL DEFAULT '[]',
per_question TEXT NOT NULL DEFAULT '[]',
aggregate_metrics TEXT NOT NULL DEFAULT '{}',
strategies_used TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
);
"""
def init_db() -> None:
"""Create tables if they don't exist."""
conn = _connect()
try:
conn.executescript(_SCHEMA)
conn.commit()
finally:
conn.close()
# ── Document CRUD ──────────────────────────────────────────────────
def save_document(
*,
doc_id: str | None = None,
filename: str,
parsed_text: str,
document_tree: str,
chunk_counts: dict[str, int] | None = None,
) -> dict[str, Any]:
"""Insert a parsed document. Returns the full row as a dict."""
doc_id = doc_id or _new_id()
conn = _connect()
try:
conn.execute(
"""INSERT INTO documents (id, filename, parsed_text, document_tree, chunk_counts, created_at)
VALUES (?, ?, ?, ?, ?, ?)""",
(doc_id, filename, parsed_text, document_tree,
json.dumps(chunk_counts or {}), _now()),
)
conn.commit()
return get_document(doc_id) # type: ignore[return-value]
finally:
conn.close()
def get_document(doc_id: str) -> dict[str, Any] | None:
"""Fetch a document by ID. Parses JSON fields back to Python objects."""
conn = _connect()
try:
row = conn.execute("SELECT * FROM documents WHERE id = ?", (doc_id,)).fetchone()
if row is None:
return None
return _row_to_dict(row)
finally:
conn.close()
def list_documents(*, offset: int = 0, limit: int = 50) -> dict[str, Any]:
"""List documents with pagination."""
conn = _connect()
try:
total = conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0]
rows = conn.execute(
"SELECT * FROM documents ORDER BY created_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return {"items": [_row_to_dict(r) for r in rows], "total": total, "offset": offset, "limit": limit}
finally:
conn.close()
def update_chunk_counts(doc_id: str, chunk_counts: dict[str, int]) -> None:
"""Update the per-strategy chunk counts after processing."""
conn = _connect()
try:
conn.execute(
"UPDATE documents SET chunk_counts = ? WHERE id = ?",
(json.dumps(chunk_counts), doc_id),
)
conn.commit()
finally:
conn.close()
def delete_document(doc_id: str) -> bool:
"""Delete a document and its cascaded queries/experiments."""
conn = _connect()
try:
cur = conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
conn.commit()
return cur.rowcount > 0
finally:
conn.close()
# ── Query CRUD ─────────────────────────────────────────────────────
def save_query(
*,
query_id: str | None = None,
document_id: str,
strategy_name: str,
question: str,
answer: str,
retrieved_chunks: list[dict] | None = None,
latency_breakdown: dict[str, float] | None = None,
token_usage: dict[str, int] | None = None,
) -> dict[str, Any]:
"""Insert a query result."""
query_id = query_id or _new_id()
conn = _connect()
try:
conn.execute(
"""INSERT INTO queries
(id, document_id, strategy_name, question, answer,
retrieved_chunks, latency_breakdown, token_usage, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(query_id, document_id, strategy_name, question, answer,
json.dumps(retrieved_chunks or []),
json.dumps(latency_breakdown or {}),
json.dumps(token_usage or {}),
_now()),
)
conn.commit()
return get_query(query_id) # type: ignore[return-value]
finally:
conn.close()
def get_query(query_id: str) -> dict[str, Any] | None:
"""Fetch a query by ID."""
conn = _connect()
try:
row = conn.execute("SELECT * FROM queries WHERE id = ?", (query_id,)).fetchone()
if row is None:
return None
return _row_to_dict(row)
finally:
conn.close()
def list_queries(
*, document_id: str | None = None, offset: int = 0, limit: int = 50
) -> dict[str, Any]:
"""List queries, optionally filtered by document."""
conn = _connect()
try:
if document_id:
total = conn.execute(
"SELECT COUNT(*) FROM queries WHERE document_id = ?", (document_id,)
).fetchone()[0]
rows = conn.execute(
"SELECT * FROM queries WHERE document_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
(document_id, limit, offset),
).fetchall()
else:
total = conn.execute("SELECT COUNT(*) FROM queries").fetchone()[0]
rows = conn.execute(
"SELECT * FROM queries ORDER BY created_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return {"items": [_row_to_dict(r) for r in rows], "total": total, "offset": offset, "limit": limit}
finally:
conn.close()
# ── Experiment (Benchmark) CRUD ────────────────────────────────────
def save_experiment(
*,
experiment_id: str | None = None,
document_id: str,
benchmark_config: dict | None = None,
questions: list[dict] | None = None,
per_question: list[dict] | None = None,
aggregate_metrics: dict | None = None,
strategies_used: list[str] | None = None,
) -> dict[str, Any]:
"""Insert a benchmark experiment."""
experiment_id = experiment_id or _new_id()
conn = _connect()
try:
conn.execute(
"""INSERT INTO experiments
(id, document_id, benchmark_config, questions, per_question,
aggregate_metrics, strategies_used, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(experiment_id, document_id,
json.dumps(benchmark_config or {}),
json.dumps(questions or []),
json.dumps(per_question or []),
json.dumps(aggregate_metrics or {}),
json.dumps(strategies_used or []),
_now()),
)
conn.commit()
return get_experiment(experiment_id) # type: ignore[return-value]
finally:
conn.close()
def get_experiment(experiment_id: str) -> dict[str, Any] | None:
"""Fetch an experiment by ID."""
conn = _connect()
try:
row = conn.execute("SELECT * FROM experiments WHERE id = ?", (experiment_id,)).fetchone()
if row is None:
return None
return _row_to_dict(row)
finally:
conn.close()
def list_experiments(
*, document_id: str | None = None, offset: int = 0, limit: int = 50
) -> dict[str, Any]:
"""List experiments, optionally filtered by document."""
conn = _connect()
try:
if document_id:
total = conn.execute(
"SELECT COUNT(*) FROM experiments WHERE document_id = ?", (document_id,)
).fetchone()[0]
rows = conn.execute(
"SELECT * FROM experiments WHERE document_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?",
(document_id, limit, offset),
).fetchall()
else:
total = conn.execute("SELECT COUNT(*) FROM experiments").fetchone()[0]
rows = conn.execute(
"SELECT * FROM experiments ORDER BY created_at DESC LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return {"items": [_row_to_dict(r) for r in rows], "total": total, "offset": offset, "limit": limit}
finally:
conn.close()
def delete_experiment(experiment_id: str) -> bool:
"""Delete an experiment by ID."""
conn = _connect()
try:
cur = conn.execute("DELETE FROM experiments WHERE id = ?", (experiment_id,))
conn.commit()
return cur.rowcount > 0
finally:
conn.close()
# ── Internal helpers ───────────────────────────────────────────────
_JSON_FIELDS = {"chunk_counts", "retrieved_chunks", "latency_breakdown",
"token_usage", "document_tree", "benchmark_config",
"questions", "per_question", "aggregate_metrics",
"strategies_used"}
def _row_to_dict(row: sqlite3.Row) -> dict[str, Any]:
"""Convert a sqlite3.Row to a plain dict, parsing JSON columns."""
d = dict(row)
for key in _JSON_FIELDS:
if key in d and isinstance(d[key], str):
try:
d[key] = json.loads(d[key])
except (json.JSONDecodeError, TypeError):
pass
return d