| """Central configuration for Lexora. |
| |
| Every tunable in the retrieval and generation pipeline is declared here so that a |
| reviewer can see the entire behavioural surface of the system in one file, and so the |
| eval harness can vary knobs (chunk size, rerank on/off) without touching pipeline code. |
| |
| Values are read from the environment, then `.env` at the repo root. See `.env.example`. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import functools |
| from enum import StrEnum |
| from pathlib import Path |
| from typing import Final, Literal |
|
|
| from pydantic import field_validator |
| from pydantic_settings import BaseSettings, SettingsConfigDict |
|
|
| |
| REPO_ROOT: Final[Path] = Path(__file__).resolve().parents[4] |
|
|
|
|
| class LLMMode(StrEnum): |
| """How Lexora obtains its language-model behaviour. |
| |
| ``auto`` use the Anthropic API when ``ANTHROPIC_API_KEY`` is set, else ``offline``. |
| ``anthropic`` require the Anthropic API; fail loudly at startup if the key is absent. |
| ``offline`` never call a network model. The query gate falls back to a deterministic |
| rewriter/screener and generation to a deterministic extractive composer |
| that quotes retrieved articles verbatim. The retrieval stack, the |
| citation verifier and the refusal gate are fully exercised; only the |
| natural-language phrasing of the final answer differs. Every response |
| produced this way is labelled ``offline-extractive`` end to end, so a |
| number measured in this mode can never be mistaken for a Claude number. |
| """ |
|
|
| AUTO = "auto" |
| ANTHROPIC = "anthropic" |
| OFFLINE = "offline" |
|
|
|
|
| class Settings(BaseSettings): |
| """Runtime configuration. Immutable once constructed.""" |
|
|
| model_config = SettingsConfigDict( |
| env_file=(REPO_ROOT / ".env"), |
| env_file_encoding="utf-8", |
| env_prefix="LEXORA_", |
| extra="ignore", |
| frozen=True, |
| ) |
|
|
| |
| repo_root: Path = REPO_ROOT |
| corpus_dir: Path = REPO_ROOT / "corpus" |
| var_dir: Path = REPO_ROOT / "var" |
|
|
| |
| embedding_model: str = "BAAI/bge-small-en-v1.5" |
| embedding_dim: int = 384 |
| |
| |
| embedding_max_tokens: int = 512 |
| |
| embedding_query_prefix: str = "Represent this sentence for searching relevant passages: " |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| reranker_model: str = "Xenova/ms-marco-MiniLM-L-6-v2" |
| reranker_backend: Literal["fastembed", "sentence-transformers"] = "fastembed" |
| |
| |
| rerank_max_tokens: int = 384 |
| |
| |
| |
| rerank_batch_size: int = 4 |
|
|
| |
| chunk_target_tokens: int = 600 |
| chunk_overlap_tokens: int = 80 |
| chunk_min_tokens: int = 24 |
|
|
| |
| dense_top_k: int = 20 |
| sparse_top_k: int = 20 |
| rrf_k: int = 60 |
| fused_top_k: int = 20 |
| rerank_top_k: int = 5 |
|
|
| |
| |
| |
| |
| |
| refusal_score_floor: float = -3.6 |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| refusal_dense_floor: float = 0.0 |
| |
| refusal_near_miss_count: int = 3 |
|
|
| |
| |
| |
| qdrant_url: str | None = None |
| qdrant_api_key: str | None = None |
| qdrant_collection: str = "lexora_chunks" |
|
|
| |
| llm_mode: LLMMode = LLMMode.AUTO |
| anthropic_api_key: str | None = None |
| answer_model: str = "claude-sonnet-4-6" |
| guard_model: str = "claude-haiku-4-5-20251001" |
| answer_max_tokens: int = 1200 |
| guard_max_tokens: int = 400 |
| |
| |
| |
| |
| |
| |
| source_profile: str = "corpus" |
| |
| temperature: float = 0.0 |
| llm_timeout_s: float = 60.0 |
|
|
| |
| langfuse_public_key: str | None = None |
| langfuse_secret_key: str | None = None |
| langfuse_host: str = "https://cloud.langfuse.com" |
|
|
| |
| cors_allow_origins: str = "http://localhost:3020" |
| |
| |
| |
| |
| rate_limit: str = "30/minute" |
| max_question_chars: int = 1000 |
| max_history_turns: int = 6 |
| request_timeout_s: float = 30.0 |
|
|
| |
| |
| |
| |
| |
| workspace_max_bytes: int = 20_000_000 |
| workspace_max_pages: int = 200 |
| workspace_max_docs: int = 8 |
| workspace_max_sessions: int = 200 |
| |
| |
| |
| workspace_session_ttl_s: float = 3600.0 |
| workspace_fetch_timeout_s: float = 20.0 |
| workspace_max_redirects: int = 5 |
| |
| |
| |
| |
| |
| workspace_refusal_score_floor: float = -8.0 |
|
|
| @field_validator("source_profile") |
| @classmethod |
| def _source_profile_must_be_known(cls, v: str) -> str: |
| |
| |
| |
| from app.rag.generate import SOURCE_PROFILE_NAMES |
|
|
| if v not in SOURCE_PROFILE_NAMES: |
| raise ValueError( |
| f"unknown source_profile {v!r}; expected one of " |
| f"{', '.join(sorted(SOURCE_PROFILE_NAMES))}" |
| ) |
| return v |
|
|
| @field_validator("temperature") |
| @classmethod |
| def _temperature_must_be_zero_for_factual_qa(cls, v: float) -> float: |
| if v != 0.0: |
| raise ValueError( |
| "Lexora is a grounded QA system over statutes; temperature must be 0. " |
| "Sampling trades faithfulness for fluency, which is the exact failure " |
| "mode this project exists to eliminate." |
| ) |
| return v |
|
|
| def model_post_init(self, context: object, /) -> None: |
| del context |
| if self.chunk_overlap_tokens < 0: |
| raise ValueError("chunk_overlap_tokens must be >= 0") |
| if self.chunk_overlap_tokens >= self.chunk_target_tokens: |
| raise ValueError( |
| f"chunk_overlap_tokens ({self.chunk_overlap_tokens}) must be smaller than " |
| f"chunk_target_tokens ({self.chunk_target_tokens}); otherwise the splitter " |
| "cannot make forward progress." |
| ) |
|
|
| |
| @property |
| def pdf_dir(self) -> Path: |
| return self.corpus_dir / "pdf" |
|
|
| @property |
| def manifest_path(self) -> Path: |
| return self.corpus_dir / "manifest.json" |
|
|
| @property |
| def index_dir(self) -> Path: |
| return self.var_dir / "index" |
|
|
| @property |
| def qdrant_path(self) -> Path: |
| return self.var_dir / "qdrant" |
|
|
| @property |
| def models_cache_dir(self) -> Path: |
| return self.var_dir / "models" |
|
|
| @property |
| def chunks_path(self) -> Path: |
| """Canonical chunk store: every chunk with full text and metadata.""" |
| return self.index_dir / "chunks.jsonl" |
|
|
| @property |
| def bm25_path(self) -> Path: |
| """Persisted sparse index (tokenised corpus + doc-id order).""" |
| return self.index_dir / "bm25.json" |
|
|
| @property |
| def vectors_path(self) -> Path: |
| """Dense vectors for exactly the chunks in ``chunks_path``, in the same order. |
| |
| The third portable artefact. Committed for the same reason as the other two, and |
| for one more: embedding is not batch-invariant. Padding length varies with batch |
| composition, so re-embedding the corpus elsewhere reproduces the vectors only to |
| ~4e-4 per component β enough to move a borderline score across the calibrated |
| refusal floor. Shipping the vectors means the deployed index is the index the |
| evaluation scored, bit for bit, rather than a near-copy of it. |
| |
| JSON rather than ``.npy`` deliberately. The Hugging Face Hub refuses binary files |
| outside Xet/LFS, and routing this through LFS would mean every consumer needs |
| ``lfs: true`` on checkout β including the CI job that builds the image, where a |
| pointer file would produce a container that silently re-embedded instead of |
| failing. Text costs ~1.4 MB instead of 272 KB and removes that whole failure |
| mode. float32 values round-trip through JSON exactly (test_index_vectors.py). |
| """ |
| return self.index_dir / "vectors.json" |
|
|
| @property |
| def index_meta_path(self) -> Path: |
| return self.index_dir / "index_meta.json" |
|
|
| @property |
| def eval_dir(self) -> Path: |
| return self.repo_root / "eval" |
|
|
| @property |
| def eval_results_dir(self) -> Path: |
| return self.repo_root / "eval" / "results" |
|
|
| |
| @property |
| def effective_chunk_max_tokens(self) -> int: |
| """Hard ceiling on a chunk, in embedding-model tokens. |
| |
| A chunk longer than the encoder's context window is truncated *inside* the |
| encoder, so its tail contributes nothing to the vector while still being shown |
| to the user as if it had been retrieved on its merits. Capping here keeps the |
| indexed text and the embedded text identical. |
| """ |
| return min(self.chunk_target_tokens, self.embedding_max_tokens) |
|
|
| @property |
| def use_anthropic(self) -> bool: |
| if self.llm_mode is LLMMode.OFFLINE: |
| return False |
| if self.llm_mode is LLMMode.ANTHROPIC: |
| return True |
| return bool(self.anthropic_api_key) |
|
|
| @property |
| def cors_origins(self) -> list[str]: |
| return [o.strip() for o in self.cors_allow_origins.split(",") if o.strip()] |
|
|
| @property |
| def langfuse_enabled(self) -> bool: |
| return bool(self.langfuse_public_key and self.langfuse_secret_key) |
|
|
|
|
| @functools.lru_cache(maxsize=1) |
| def get_settings() -> Settings: |
| """Process-wide settings singleton.""" |
| return Settings() |
|
|