Compare commits
21 Commits
4edc355ae5
...
d3f8a9a5e5
| Author | SHA1 | Date | |
|---|---|---|---|
| d3f8a9a5e5 | |||
| a49795e5eb | |||
| b10a5706f4 | |||
| a277672444 | |||
| a309e64841 | |||
| 754da323ff | |||
| 4e76203e27 | |||
| 0bb3086289 | |||
| ef8bab5962 | |||
| d572724115 | |||
| b18ecdcd0c | |||
| cbaa72b420 | |||
| 496a58c62a | |||
| b75fde8185 | |||
| 292ae17cfa | |||
| 29edbf57c4 | |||
| 8715d9c186 | |||
| 143351b96b | |||
| fdc21e3316 | |||
| 4aaaccec49 | |||
| 4f205a7ef7 |
347
CONTEXT.md
347
CONTEXT.md
@@ -1,305 +1,42 @@
|
||||
# RAG Chunking Benchmarker
|
||||
|
||||
A benchmarking application that compares chunking strategies on regulatory documents, measuring retrieval quality, answer faithfulness, and cost across strategies.
|
||||
|
||||
## Domain
|
||||
|
||||
**Chunk:**
|
||||
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
|
||||
|
||||
**Unified Chunk Model:**
|
||||
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
|
||||
|
||||
**Chunk Metadata:**
|
||||
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
|
||||
|
||||
**Chunking Strategy:**
|
||||
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
|
||||
|
||||
**Strategy Name:**
|
||||
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
|
||||
|
||||
**Contextual Structure Strategy:**
|
||||
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
|
||||
|
||||
**Parent-Child Strategy:**
|
||||
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.
|
||||
_Avoid_: hierarchical chunking, nested strategy
|
||||
|
||||
**Parent Level:**
|
||||
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.
|
||||
_Avoid_: parent hierarchy, parent depth
|
||||
|
||||
**Semantic Strategy:**
|
||||
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.
|
||||
_Avoid_: embedding-based chunking, similarity chunking
|
||||
|
||||
**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
|
||||
# Context — RAG Chunking Benchmarker Admin Dashboard
|
||||
|
||||
## Language
|
||||
|
||||
**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.
|
||||
_Avoid_: Admin panel, web UI, frontend
|
||||
|
||||
**Strategy**:
|
||||
One of the five chunking algorithms: fixed_size, recursive, semantic, contextual_retrieval, semantic_parent_child.
|
||||
_Avoid_: Method, approach, technique
|
||||
|
||||
**Experiment**:
|
||||
A completed benchmark run — one document, N strategies, M questions, with per-question and aggregate metrics.
|
||||
_Avoid_: Run, trial, benchmark
|
||||
|
||||
**Chunk Preview**:
|
||||
The ability to visualize what a specific strategy produces for a given document — the raw text blocks, their sizes, and hierarchy.
|
||||
_Avoid_: Chunk inspection, chunk view
|
||||
|
||||
**Tab**:
|
||||
A persistent top-level navigation section of the Dashboard (Home, Documents, Query, Benchmarks, Admin). Tabs stay mounted when switching — state survives.
|
||||
_Avoid_: Page, route, view
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
| # | Decision | Status |
|
||||
|---|----------|--------|
|
||||
ADR-0001 | Single-file React via CDN (no build step), served by FastAPI | Approved |
|
||||
ADR-0002 | New `/admin/` router for dashboard-specific backend ops (health, Qdrant CRUD, chunk preview, questions, cost) | Approved |
|
||||
ADR-0003 | Top-tab navigation (Home, Documents, Query, Benchmarks, Admin) — not sidebar | Approved |
|
||||
ADR-0004 | Babel-in-browser JSX: single index.html with inline <script type="text/babel">, React+ReactDOM+Babel from CDN. Zero build tooling, one file to edit. | Approved |
|
||||
ADR-0005 | Persistent tabs with useState on root App. No routing library, no state library — 5-7 shared state values passed as props. Tab components stay mounted, state survives tab switches. | Approved |
|
||||
ADR-0006 | Dashboard calls existing REST endpoints for documents/queries/benchmarks. New /admin/* router ONLY fills gaps: Qdrant CRUD, health, chunk preview, questions dataset, cost estimation. No endpoint duplication. | Approved |
|
||||
ADR-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 |
|
||||
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 |
|
||||
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 |
|
||||
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 |
|
||||
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 |
|
||||
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 |
|
||||
|
||||
103
docs/README.md
Normal file
103
docs/README.md
Normal 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
309
docs/api-reference.md
Normal 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
183
docs/architecture.md
Normal 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
53
docs/cant-do-yet.md
Normal 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
144
docs/chunking_strategies.md
Normal 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
164
docs/configuration.md
Normal 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
385
docs/data-flow.md
Normal 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
222
docs/evaluation-metrics.md
Normal 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
46
docs/out-of-scope-v1.md
Normal 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
115
docs/phases.md
Normal 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%
|
||||
345
docs/strategy-technical-details.md
Normal file
345
docs/strategy-technical-details.md
Normal 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
84
docs/tasks.md
Normal 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
179
files/questions.json
Normal 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
1
src/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Admin router — system health, Qdrant CRUD, chunk preview, questions, cost estimation."""
|
||||
84
src/admin/routes.py
Normal file
84
src/admin/routes.py
Normal 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
275
src/admin/service.py
Normal 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)
|
||||
1
src/benchmarking/__init__.py
Normal file
1
src/benchmarking/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Benchmarking module: query pipeline and evaluation."""
|
||||
358
src/benchmarking/benchmark_service.py
Normal file
358
src/benchmarking/benchmark_service.py
Normal 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)
|
||||
208
src/benchmarking/evaluation.py
Normal file
208
src/benchmarking/evaluation.py
Normal 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
128
src/benchmarking/models.py
Normal 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
|
||||
340
src/benchmarking/query_service.py
Normal file
340
src/benchmarking/query_service.py
Normal 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
840
src/benchmarking/report.py
Normal 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 ·
|
||||
{len(strategies)} strategies ·
|
||||
${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">🏆</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">🔍</span>
|
||||
<span class="link-text">View Full Technical Report</span>
|
||||
<span class="link-arrow">→</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]}... ·
|
||||
{total_questions} questions ·
|
||||
{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">📊</span>
|
||||
<span class="link-text">Switch to Managerial View</span>
|
||||
<span class="link-arrow">→</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 · {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>{'✓' if not scores.get('hallucination', False) else '✗'}</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
367
src/benchmarking/routes.py
Normal 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
1
src/chunking/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Chunking strategies — interface, implementations, embedding, orchestration."""
|
||||
95
src/chunking/base.py
Normal file
95
src/chunking/base.py
Normal 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
68
src/chunking/embedding.py
Normal 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
179
src/chunking/service.py
Normal 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
|
||||
1
src/chunking/strategies/__init__.py
Normal file
1
src/chunking/strategies/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Chunking strategy implementations."""
|
||||
120
src/chunking/strategies/contextual_retrieval.py
Normal file
120
src/chunking/strategies/contextual_retrieval.py
Normal 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
|
||||
61
src/chunking/strategies/fixed_size.py
Normal file
61
src/chunking/strategies/fixed_size.py
Normal 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
|
||||
100
src/chunking/strategies/recursive.py
Normal file
100
src/chunking/strategies/recursive.py
Normal 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
|
||||
122
src/chunking/strategies/semantic.py
Normal file
122
src/chunking/strategies/semantic.py
Normal 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
|
||||
134
src/chunking/strategies/semantic_parent_child.py
Normal file
134
src/chunking/strategies/semantic_parent_child.py
Normal 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
|
||||
@@ -32,6 +32,10 @@ class BenchmarkError(Exception):
|
||||
"""Base exception for benchmarking-related errors."""
|
||||
|
||||
|
||||
class QueryError(BenchmarkError):
|
||||
"""Raised when a query operation fails."""
|
||||
|
||||
|
||||
class DryRunError(BenchmarkError):
|
||||
"""Raised when a dry-run estimation fails."""
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ from pydantic import BaseModel, Field
|
||||
|
||||
class StrategyName(str, Enum):
|
||||
"""Canonical identifiers for each chunking strategy."""
|
||||
|
||||
CONTEXTUAL_STRUCTURE = "contextual_structure"
|
||||
PARENT_CHILD = "parent_child"
|
||||
SEMANTIC = "semantic"
|
||||
MARKDOWN_STRUCTURE = "markdown_structure"
|
||||
RECURSIVE = "recursive"
|
||||
FIXED_SIZE = "fixed_size"
|
||||
SEMANTIC = "semantic"
|
||||
CONTEXTUAL_RETRIEVAL = "contextual_retrieval"
|
||||
SEMANTIC_PARENT_CHILD = "semantic_parent_child"
|
||||
|
||||
|
||||
class Chunk(BaseModel):
|
||||
@@ -22,7 +21,6 @@ class Chunk(BaseModel):
|
||||
Strategy-specific fields (parent_id, enriched_content) are nullable
|
||||
when not applicable to the strategy that produced the chunk.
|
||||
"""
|
||||
|
||||
document_name: str
|
||||
chunk_id: str
|
||||
strategy_name: StrategyName
|
||||
@@ -36,11 +34,11 @@ class Chunk(BaseModel):
|
||||
|
||||
class ChunkMetadata(BaseModel):
|
||||
"""Payload stored alongside every chunk vector in Qdrant."""
|
||||
|
||||
document_name: str
|
||||
chunk_id: str
|
||||
strategy_name: StrategyName
|
||||
chunk_index: int
|
||||
text: str
|
||||
token_count: int
|
||||
character_count: int
|
||||
parent_id: Optional[str] = None
|
||||
@@ -53,7 +51,45 @@ def chunk_to_metadata(chunk: Chunk) -> ChunkMetadata:
|
||||
chunk_id=chunk.chunk_id,
|
||||
strategy_name=chunk.strategy_name,
|
||||
chunk_index=chunk.chunk_index,
|
||||
text=chunk.text,
|
||||
token_count=chunk.token_count,
|
||||
character_count=chunk.character_count,
|
||||
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
|
||||
|
||||
1
src/documents/__init__.py
Normal file
1
src/documents/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Document parsing and processing."""
|
||||
79
src/documents/models.py
Normal file
79
src/documents/models.py
Normal 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
361
src/documents/parser.py
Normal 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
84
src/documents/routes.py
Normal 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
158
src/documents/service.py
Normal 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
|
||||
]
|
||||
65
src/main.py
65
src/main.py
@@ -1,22 +1,72 @@
|
||||
"""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.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from src.core.exceptions import (
|
||||
ChunkingError,
|
||||
BenchmarkError,
|
||||
QueryError,
|
||||
chunking_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:
|
||||
"""Create and configure the FastAPI application.
|
||||
|
||||
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(
|
||||
title="RAG Chunking Benchmarker",
|
||||
description="Benchmark five chunking strategies on regulatory documents. "
|
||||
@@ -33,9 +83,22 @@ def create_app() -> FastAPI:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Request logging
|
||||
app.add_middleware(RequestLoggingMiddleware)
|
||||
|
||||
# Register exception handlers
|
||||
app.add_exception_handler(ChunkingError, chunking_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
|
||||
|
||||
|
||||
1708
src/static/index.html
Normal file
1708
src/static/index.html
Normal file
File diff suppressed because it is too large
Load Diff
1
src/storage/__init__.py
Normal file
1
src/storage/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Storage layer — SQLite persistence and Qdrant vector store."""
|
||||
219
src/storage/qdrant.py
Normal file
219
src/storage/qdrant.py
Normal 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
342
src/storage/sqlite.py
Normal 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
|
||||
Reference in New Issue
Block a user