rag-system / models.py
joshsears's picture
Polish: BGE-large embeddings, contextual retrieval, 142 tests passing, lint clean
21ca2ea
Raw
History Blame Contribute Delete
9.37 kB
"""
Pydantic v2 data models for the RAG system.
These models are shared across ingestion, retrieval, generation, the REST API,
and the evaluation harness β€” single source of truth.
"""
from __future__ import annotations
from datetime import UTC, datetime
from enum import Enum
from typing import Any
from pydantic import BaseModel, Field, model_validator
# ── Enums ─────────────────────────────────────────────────────────────────────
class DocumentType(str, Enum):
"""Supported document source types."""
PDF = "pdf"
TXT = "txt"
DOCX = "docx"
MARKDOWN = "markdown"
URL = "url"
UNKNOWN = "unknown"
class QueryMode(str, Enum):
"""Retrieval strategy modes."""
DENSE = "dense" # pure vector search
SPARSE = "sparse" # pure BM25
HYBRID = "hybrid" # dense + BM25 with RRF fusion
# ── Chunk / Document Models ───────────────────────────────────────────────────
class ChunkMetadata(BaseModel):
"""Rich metadata attached to every stored chunk."""
source_file: str = Field(..., description="Original filename or URL")
doc_type: DocumentType = Field(default=DocumentType.UNKNOWN)
page_number: int | None = Field(default=None, description="PDF page number (1-indexed)")
chunk_index: int = Field(..., ge=0, description="Position of chunk within its source document")
timestamp_ingested: datetime = Field(default_factory=lambda: datetime.now(UTC))
word_count: int = Field(..., ge=0)
char_count: int = Field(..., ge=0)
content_hash: str = Field(..., description="SHA-256 of chunk text for deduplication")
section_title: str | None = Field(
default=None, description="Nearest heading above this chunk (if detectable)"
)
class DocumentChunk(BaseModel):
"""A single chunk ready for embedding and storage."""
text: str = Field(..., min_length=1)
metadata: ChunkMetadata
embedding: list[float] | None = Field(default=None, exclude=True)
@property
def chunk_id(self) -> str:
"""Stable ID derived from content hash + chunk index."""
return f"{self.metadata.content_hash[:16]}-{self.metadata.chunk_index}"
# ── Ingestion Models ──────────────────────────────────────────────────────────
class IngestRequest(BaseModel):
"""REST API request body for /ingest."""
file_path: str = Field(..., description="Absolute or relative path to file, or a URL")
collection: str = Field(default="default", min_length=1, max_length=64)
overwrite: bool = Field(
default=False, description="Re-ingest even if chunk hash already exists"
)
class IngestResult(BaseModel):
"""Result of an ingestion operation."""
collection: str
source: str
chunks_added: int = Field(ge=0)
duplicates_skipped: int = Field(ge=0)
total_chunks_processed: int = Field(ge=0)
elapsed_seconds: float = Field(ge=0.0)
@model_validator(mode="after")
def check_totals(self) -> IngestResult:
assert self.chunks_added + self.duplicates_skipped == self.total_chunks_processed
return self
# ── Retrieval Models ──────────────────────────────────────────────────────────
class RetrievalResult(BaseModel):
"""A single retrieved chunk with scoring information."""
chunk_text: str
source: str = Field(..., description="Source filename or URL")
similarity_score: float = Field(..., ge=0.0, le=1.0)
rerank_score: float | None = Field(
default=None, description="Cross-encoder score (higher = more relevant)"
)
chunk_index: int
page_number: int | None = None
section_title: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class RetrievalContext(BaseModel):
"""All retrieved chunks for a single query, ready for prompt construction."""
query: str
results: list[RetrievalResult] = Field(default_factory=list)
query_mode: QueryMode = QueryMode.HYBRID
retrieved_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
expanded_queries: list[str] = Field(
default_factory=list, description="Multi-query expansions used"
)
hyde_hypothesis: str | None = Field(
default=None, description="HyDE hypothetical document if used"
)
@property
def is_empty(self) -> bool:
return len(self.results) == 0
# ── Query / Response Models ───────────────────────────────────────────────────
class QueryRequest(BaseModel):
"""REST API request body for /query."""
question: str = Field(..., min_length=1, max_length=2000)
collection: str = Field(default="default")
top_k: int = Field(default=6, ge=1, le=50)
mode: QueryMode = Field(default=QueryMode.HYBRID)
use_hyde: bool = Field(default=False)
use_multi_query: bool = Field(default=False)
class SourceCitation(BaseModel):
"""A source citation returned with every answer."""
source: str
chunk_index: int
page_number: int | None = None
similarity_score: float
excerpt: str = Field(..., description="First 200 chars of the chunk")
class QueryResponse(BaseModel):
"""REST API response for /query β€” also used internally."""
question: str
answer: str
sources: list[SourceCitation] = Field(default_factory=list)
tokens_used: int = Field(ge=0)
latency_ms: float = Field(ge=0.0)
collection: str
llm_backend: str
model_used: str
cache_hit: bool = Field(default=False)
retrieval_context: RetrievalContext | None = Field(default=None, exclude=True)
# ── Collection Models ─────────────────────────────────────────────────────────
class CollectionInfo(BaseModel):
"""Metadata about a ChromaDB collection (knowledge base)."""
name: str
document_count: int = Field(ge=0)
created_at: datetime | None = None
embedding_model: str
class CollectionListResponse(BaseModel):
"""Response for GET /collections."""
collections: list[CollectionInfo]
total: int = Field(ge=0)
@model_validator(mode="after")
def set_total(self) -> CollectionListResponse:
self.total = len(self.collections)
return self
class DeleteCollectionResponse(BaseModel):
"""Response for DELETE /collection/{name}."""
name: str
deleted: bool
message: str
# ── Evaluation Models ─────────────────────────────────────────────────────────
class EvalSample(BaseModel):
"""A single (question, expected_answer, relevant_sources) test case."""
question: str
expected_answer: str
relevant_sources: list[str] = Field(
default_factory=list, description="Filenames that should appear in top-k"
)
collection: str = Field(default="default")
class EvalResult(BaseModel):
"""Evaluation result for a single test case."""
question: str
generated_answer: str
expected_answer: str
sources_retrieved: list[str]
relevant_sources: list[str]
recall_at_k: float = Field(
ge=0.0, le=1.0, description="Fraction of relevant sources found in top-k"
)
faithfulness_score: float = Field(
ge=1.0, le=5.0, description="LLM-judged faithfulness score (1-5)"
)
answer_relevancy: float = Field(
ge=0.0, le=1.0, description="Cosine similarity of answer embedding to question"
)
latency_ms: float = Field(ge=0.0)
class EvalSummary(BaseModel):
"""Aggregate evaluation summary across all test cases."""
total_samples: int = Field(ge=0)
mean_recall_at_k: float = Field(ge=0.0, le=1.0)
mean_faithfulness: float = Field(ge=1.0, le=5.0)
mean_answer_relevancy: float = Field(ge=0.0, le=1.0)
mean_latency_ms: float = Field(ge=0.0)
results: list[EvalResult] = Field(default_factory=list)
# ── Cache Models ──────────────────────────────────────────────────────────────
class CacheEntry(BaseModel):
"""A cached query-answer pair with embedding for similarity lookup."""
question: str
response: QueryResponse
embedding: list[float]
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
hit_count: int = Field(default=0, ge=0)
# ── Error Models ──────────────────────────────────────────────────────────────
class ErrorResponse(BaseModel):
"""Standard error response for the REST API."""
error: str
detail: str | None = None
code: int