Merge pull request #561 from ggozad/feat/strict-config
Reject unknown and out-of-range configuration values
This commit is contained in:
commit
058a820e14
15 changed files with 274 additions and 107 deletions
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
### Added
|
||||
|
||||
- The `haiku.rag` package declares the `jina` extra, so `provider: jina-local` is supported by declaration rather than through `cross-encoder`'s transitive `transformers` and `torch`. Raises the full package's torch floor to 2.0.
|
||||
- `providers.docling_serve.timeout` (default 300 seconds), forwarded to the docling-serve client's per-request timeout.
|
||||
- `evaluations run --filter/-f CLAUSE`: SQL `WHERE` clause over document columns, applied to the retrieval benchmark's searches and to every capability search during QA. Recorded as `document_filter` in experiment metadata.
|
||||
- `mtrag_clapnq` / `mtrag_clapnq_rewrite` / `mtrag_clapnq_live` / `mtrag_clapnq_live_uncompacted` evaluation datasets: multi-turn QA with gold-prefix and live-session conversation replay, live arms with and without `EvidenceCompactionCapability`, Recall@k/nDCG@k retrieval metrics, eligibility-aware citation scoring, refusal precision/recall, per-turn tool-traffic attributes, and `citation_status` / `turn_citation_status` eval attributes.
|
||||
|
|
@ -14,6 +15,10 @@
|
|||
|
||||
### Changed
|
||||
|
||||
- Configuration sections reject unknown keys. A typo or a setting that has been renamed or removed now fails validation with its path (`providers.docling_serve.bogus: Extra inputs are not permitted`) instead of being silently ignored.
|
||||
- `processing.converter`, `processing.chunker` and `processing.chunker_type` are constrained to their supported values, so an unsupported one fails at load rather than at first use.
|
||||
- Numeric settings carry bounds: sizes, limits, dimensions, token budgets, attempt counts, breaker thresholds and `min_chunks` must be positive; retention, delays, intervals and cooldowns non-negative; `doctor.duplicates.similarity_threshold` within 0-1; `ingester.api.port` within 0-65535 (0 keeps its OS-assigned meaning); `ingester.workers.worker_count` allows 0 for an API-and-reaper-only process.
|
||||
- A configured reranker whose optional dependency is missing raises instead of silently disabling reranking, and names the extra to install (`uv pip install 'haiku.rag-slim[cohere]'`). A failure raised from inside an installed dependency propagates untouched rather than being reported as a missing package.
|
||||
- Multi-table writes (document create, update, batch import, cascade delete) go through `Store.write_transaction()`. Rollback restores in `RESTORE_TABLE_ORDER` and is shielded from cancellation, so a cancelled write rolls back instead of committing part of itself. `Store.restore_table_versions()` is removed.
|
||||
- Search enrichment, the multimodal reranker's picture fetch, and context expansion each issue a fixed number of `document_items` queries regardless of how many documents a result set spans, instead of one set per document. `expand_with_items` takes the items to expand from rather than fetching them, and `DocumentItemRepository` gains `resolve_refs_grouped`, `get_items_in_ranges`, `get_pictures_grouped` and `get_caption_picture_refs_grouped`. The superseded methods are removed: `resolve_refs`, `get_items_in_range`, `get_caption_picture_refs`, `get_text_for_refs` and `get_all_items_grouped`. `get_pictures_grouped` returns each picture's text alongside its bytes under `with_text`, off by default so the reranker's blob fetch does not read a column it discards.
|
||||
- Eval judge pinned to `qwen3.8`: `DEFAULT_JUDGE_MODEL` is `ollama:qwen3.8`, and the reference configs use `Inferact/Qwen3.8-27B-NVFP4` with `extra_body.chat_template_kwargs.reasoning_effort: low`. Results in `docs/benchmarks.md` were judged by `Qwen3.6-35B-A3B-NVFP4` and are not re-judged.
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
uv pip install haiku.rag
|
||||
```
|
||||
|
||||
The full package pulls the `docling`, `voyageai`, `cohere`, `zeroentropy`, `cross-encoder` and `tui` extras:
|
||||
The full package pulls the `docling`, `voyageai`, `cohere`, `zeroentropy`, `cross-encoder`, `jina` and `tui` extras:
|
||||
- **Document processing** (Docling) - PDF, DOCX, PPTX, images, and 40+ file formats
|
||||
- **Embedding providers** - VoyageAI and Cohere
|
||||
- **Rerankers** - local cross-encoders, local Jina, Cohere, Zero Entropy
|
||||
|
|
|
|||
|
|
@ -6,7 +6,17 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
|||
from haiku.rag.utils import get_default_data_dir
|
||||
|
||||
|
||||
class ModelConfig(BaseModel):
|
||||
class ConfigModel(BaseModel):
|
||||
"""Base for every configuration section.
|
||||
|
||||
Unknown keys are rejected so a typo or a setting that has been renamed or
|
||||
removed fails at load instead of being silently ignored.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ModelConfig(ConfigModel):
|
||||
"""Configuration for a language model.
|
||||
|
||||
Attributes:
|
||||
|
|
@ -30,12 +40,12 @@ class ModelConfig(BaseModel):
|
|||
|
||||
enable_thinking: bool | None = None
|
||||
temperature: float | None = None
|
||||
max_tokens: int | None = None
|
||||
max_tokens: int | None = Field(default=None, gt=0)
|
||||
vision: bool = False
|
||||
extra_body: dict | None = None
|
||||
|
||||
|
||||
class EmbeddingModelConfig(BaseModel):
|
||||
class EmbeddingModelConfig(ConfigModel):
|
||||
"""Configuration for an embedding model.
|
||||
|
||||
Attributes:
|
||||
|
|
@ -50,18 +60,18 @@ class EmbeddingModelConfig(BaseModel):
|
|||
|
||||
provider: str = "ollama"
|
||||
name: str = "qwen3-embedding:4b"
|
||||
vector_dim: int = 2560
|
||||
vector_dim: int = Field(default=2560, gt=0)
|
||||
base_url: str | None = None
|
||||
multimodal: bool = False
|
||||
|
||||
|
||||
class StorageConfig(BaseModel):
|
||||
class StorageConfig(ConfigModel):
|
||||
data_dir: Path = Field(default_factory=get_default_data_dir)
|
||||
auto_vacuum: bool = True
|
||||
vacuum_retention_seconds: int = 86400
|
||||
vacuum_retention_seconds: int = Field(default=86400, ge=0)
|
||||
|
||||
|
||||
class LanceDBConfig(BaseModel):
|
||||
class LanceDBConfig(ConfigModel):
|
||||
"""LanceDB connection settings.
|
||||
|
||||
read_consistency_interval_seconds bounds how stale a reader may be. None
|
||||
|
|
@ -79,12 +89,12 @@ class LanceDBConfig(BaseModel):
|
|||
metadata_cache_size_bytes: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class EmbeddingsConfig(BaseModel):
|
||||
class EmbeddingsConfig(ConfigModel):
|
||||
model: EmbeddingModelConfig = Field(default_factory=EmbeddingModelConfig)
|
||||
batch_size: int = 512
|
||||
batch_size: int = Field(default=512, gt=0)
|
||||
|
||||
|
||||
class RerankingConfig(BaseModel):
|
||||
class RerankingConfig(ConfigModel):
|
||||
"""Configuration for reranking search results.
|
||||
|
||||
Attributes:
|
||||
|
|
@ -97,7 +107,7 @@ class RerankingConfig(BaseModel):
|
|||
multimodal: bool = False
|
||||
|
||||
|
||||
class QAConfig(BaseModel):
|
||||
class QAConfig(ConfigModel):
|
||||
model: ModelConfig = Field(
|
||||
default_factory=lambda: ModelConfig(
|
||||
provider="ollama",
|
||||
|
|
@ -106,10 +116,10 @@ class QAConfig(BaseModel):
|
|||
temperature=0.3,
|
||||
)
|
||||
)
|
||||
max_searches: int = 5
|
||||
max_searches: int = Field(default=5, ge=0)
|
||||
|
||||
|
||||
class AnalysisConfig(BaseModel):
|
||||
class AnalysisConfig(ConfigModel):
|
||||
"""Driving model and sandbox limits for the analysis capability.
|
||||
|
||||
``model`` defaults to ``None``, meaning "no override — use ``qa.model``."
|
||||
|
|
@ -118,12 +128,12 @@ class AnalysisConfig(BaseModel):
|
|||
(e.g. a stronger model for computational tasks)."""
|
||||
|
||||
model: ModelConfig | None = None
|
||||
code_timeout: float = 60.0
|
||||
max_output_chars: int = 50_000
|
||||
max_executions: int = 15
|
||||
code_timeout: float = Field(default=60.0, gt=0)
|
||||
max_output_chars: int = Field(default=50_000, gt=0)
|
||||
max_executions: int = Field(default=15, ge=0)
|
||||
|
||||
|
||||
class DuplicateDetectionConfig(BaseModel):
|
||||
class DuplicateDetectionConfig(ConfigModel):
|
||||
"""Thresholds for doctor's near-duplicate document detection.
|
||||
|
||||
Detection clusters whole documents whose embedding centroids are nearly
|
||||
|
|
@ -132,17 +142,17 @@ class DuplicateDetectionConfig(BaseModel):
|
|||
documents too small to compare meaningfully.
|
||||
"""
|
||||
|
||||
similarity_threshold: float = 0.97
|
||||
min_chunks: int = 3
|
||||
similarity_threshold: float = Field(default=0.97, ge=0.0, le=1.0)
|
||||
min_chunks: int = Field(default=3, gt=0)
|
||||
|
||||
|
||||
class DoctorConfig(BaseModel):
|
||||
class DoctorConfig(ConfigModel):
|
||||
duplicates: DuplicateDetectionConfig = Field(
|
||||
default_factory=DuplicateDetectionConfig
|
||||
)
|
||||
|
||||
|
||||
class PictureDescriptionConfig(BaseModel):
|
||||
class PictureDescriptionConfig(ConfigModel):
|
||||
"""How the VLM runs over each picture when it runs at all.
|
||||
|
||||
Activation lives on ``ProcessingConfig.pictures`` — these fields only
|
||||
|
|
@ -156,11 +166,11 @@ class PictureDescriptionConfig(BaseModel):
|
|||
temperature=0.0,
|
||||
)
|
||||
)
|
||||
timeout: int = 90
|
||||
max_tokens: int = 200
|
||||
timeout: int = Field(default=90, gt=0)
|
||||
max_tokens: int = Field(default=200, gt=0)
|
||||
|
||||
|
||||
class ConversionOptions(BaseModel):
|
||||
class ConversionOptions(ConfigModel):
|
||||
"""Options for document conversion."""
|
||||
|
||||
# OCR options
|
||||
|
|
@ -177,7 +187,7 @@ class ConversionOptions(BaseModel):
|
|||
table_cell_matching: bool = True
|
||||
|
||||
# Image options
|
||||
images_scale: float = 2.0
|
||||
images_scale: float = Field(default=2.0, gt=0)
|
||||
generate_page_images: bool = True
|
||||
|
||||
# Fetch images referenced by URL in HTML and Markdown inputs.
|
||||
|
|
@ -192,11 +202,11 @@ class ConversionOptions(BaseModel):
|
|||
PicturesMode = Literal["none", "description", "image"]
|
||||
|
||||
|
||||
class ProcessingConfig(BaseModel):
|
||||
chunk_size: int = 256
|
||||
converter: str = "docling-local"
|
||||
chunker: str = "docling-local"
|
||||
chunker_type: str = "hybrid"
|
||||
class ProcessingConfig(ConfigModel):
|
||||
chunk_size: int = Field(default=256, gt=0)
|
||||
converter: Literal["docling-local", "docling-serve"] = "docling-local"
|
||||
chunker: Literal["docling-local", "docling-serve"] = "docling-local"
|
||||
chunker_type: Literal["hybrid", "hierarchical"] = "hybrid"
|
||||
chunking_tokenizer: str = "Qwen/Qwen3-Embedding-0.6B"
|
||||
chunking_merge_peers: bool = True
|
||||
chunking_use_markdown_tables: bool = False
|
||||
|
|
@ -226,7 +236,7 @@ class ProcessingConfig(BaseModel):
|
|||
- ``"image"``: docling generates picture images and stores them in
|
||||
``document_items.picture_data``; no VLM runs at ingest.
|
||||
"""
|
||||
min_picture_size: int = 64
|
||||
min_picture_size: int = Field(default=64, ge=0)
|
||||
"""Minimum pixel size (smaller side) for a picture to become a picture
|
||||
chunk. Smaller pictures — icons, bullets, decorative graphics — are not
|
||||
embedded or indexed; their bytes stay in ``document_items`` for context
|
||||
|
|
@ -247,14 +257,14 @@ class ProcessingConfig(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class SearchConfig(BaseModel):
|
||||
limit: int = 5
|
||||
max_context_chars: int = 5000
|
||||
class SearchConfig(ConfigModel):
|
||||
limit: int = Field(default=5, gt=0)
|
||||
max_context_chars: int = Field(default=5000, gt=0)
|
||||
vector_index_metric: Literal["cosine", "l2", "dot"] = "cosine"
|
||||
vector_refine_factor: int = 30
|
||||
vector_refine_factor: int = Field(default=30, gt=0)
|
||||
|
||||
|
||||
class OllamaConfig(BaseModel):
|
||||
class OllamaConfig(ConfigModel):
|
||||
base_url: str = Field(
|
||||
default_factory=lambda: __import__("os").environ.get(
|
||||
"OLLAMA_BASE_URL", "http://localhost:11434"
|
||||
|
|
@ -262,21 +272,22 @@ class OllamaConfig(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class CircuitBreakerConfig(BaseModel):
|
||||
class CircuitBreakerConfig(ConfigModel):
|
||||
"""Breaker over repeated failures of a single target. Stops callers from
|
||||
hammering a target that's persistently failing (an ingester source, a
|
||||
docling-serve instance)."""
|
||||
|
||||
failure_threshold: int = Field(
|
||||
default=5, description="Consecutive failures before the breaker opens."
|
||||
default=5, gt=0, description="Consecutive failures before the breaker opens."
|
||||
)
|
||||
cooldown_s: float = Field(
|
||||
default=600.0,
|
||||
ge=0,
|
||||
description="How long the breaker stays open before allowing a probe.",
|
||||
)
|
||||
|
||||
|
||||
class DoclingServeConfig(BaseModel):
|
||||
class DoclingServeConfig(ConfigModel):
|
||||
"""docling-serve endpoints. Accepts a single URL or a list — when a list is
|
||||
given, the client round-robins jobs across the URLs, fails a request over to
|
||||
another instance when one crashes or returns 5xx, and trips a per-instance
|
||||
|
|
@ -292,6 +303,7 @@ class DoclingServeConfig(BaseModel):
|
|||
api_key: str = ""
|
||||
max_attempts: int = Field(
|
||||
default=3,
|
||||
gt=0,
|
||||
description="Max attempts per request across the fleet before giving up; "
|
||||
"each retry fails over to another instance.",
|
||||
)
|
||||
|
|
@ -314,12 +326,12 @@ class DoclingServeConfig(BaseModel):
|
|||
return list(self.base_url) or ["http://localhost:5001"]
|
||||
|
||||
|
||||
class ProvidersConfig(BaseModel):
|
||||
class ProvidersConfig(ConfigModel):
|
||||
ollama: OllamaConfig = Field(default_factory=OllamaConfig)
|
||||
docling_serve: DoclingServeConfig = Field(default_factory=DoclingServeConfig)
|
||||
|
||||
|
||||
class PromptsConfig(BaseModel):
|
||||
class PromptsConfig(ConfigModel):
|
||||
domain_preamble: str = ""
|
||||
picture_description: str = (
|
||||
"Describe this image for a blind user. "
|
||||
|
|
@ -329,7 +341,7 @@ class PromptsConfig(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class EvaluationsConfig(BaseModel):
|
||||
class EvaluationsConfig(ConfigModel):
|
||||
"""Settings consumed only by the `evaluations` package."""
|
||||
|
||||
judge: ModelConfig | None = Field(
|
||||
|
|
@ -342,7 +354,7 @@ class EvaluationsConfig(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
class QueueConfig(BaseModel):
|
||||
class QueueConfig(ConfigModel):
|
||||
"""Job queue for the production ingester. Defaults to a filesystem SQLite
|
||||
file; set `dburi` to point it at a database server instead."""
|
||||
|
||||
|
|
@ -357,29 +369,31 @@ class QueueConfig(BaseModel):
|
|||
)
|
||||
retention_days: int | None = Field(
|
||||
default=30,
|
||||
ge=0,
|
||||
description="Delete succeeded/dead jobs whose completed_at is older "
|
||||
"than this many days. The reaper enforces it on reaper_interval_s. "
|
||||
"None disables pruning (keep all terminal rows).",
|
||||
)
|
||||
|
||||
|
||||
class RetryPolicyConfig(BaseModel):
|
||||
class RetryPolicyConfig(ConfigModel):
|
||||
"""Per-job retry policy. Per-source override is allowed under
|
||||
SourceConfig.retry so a flaky source doesn't drag the rest of the queue."""
|
||||
|
||||
max_attempts: int = 5
|
||||
base_delay_s: float = 2.0
|
||||
max_delay_s: float = 300.0
|
||||
max_attempts: int = Field(default=5, gt=0)
|
||||
base_delay_s: float = Field(default=2.0, ge=0)
|
||||
max_delay_s: float = Field(default=300.0, ge=0)
|
||||
jitter: float = Field(default=0.25, ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class WorkerConfig(BaseModel):
|
||||
class WorkerConfig(ConfigModel):
|
||||
# Reject unknown keys so a renamed/removed setting (e.g. the former
|
||||
# claim_timeout_s) fails loudly instead of being silently ignored.
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
worker_count: int = Field(
|
||||
default=4,
|
||||
ge=0,
|
||||
description="Number of async worker tasks pulling from the queue. "
|
||||
"Each worker holds at most one job at a time, so worker_count is "
|
||||
"also the maximum number of concurrent in-flight jobs. Size to the "
|
||||
|
|
@ -388,6 +402,7 @@ class WorkerConfig(BaseModel):
|
|||
)
|
||||
poll_idle_interval_s: float = Field(
|
||||
default=1.0,
|
||||
gt=0,
|
||||
description="How long an idle worker waits between empty claim_next "
|
||||
"polls. Lower = lower latency picking up new jobs, higher = less "
|
||||
"queue churn when the queue is usually empty.",
|
||||
|
|
@ -411,12 +426,14 @@ class WorkerConfig(BaseModel):
|
|||
)
|
||||
reaper_interval_s: int = Field(
|
||||
default=60,
|
||||
gt=0,
|
||||
description="How often the reaper scans for stale claims. Shorter "
|
||||
"lowers the worst-case recovery time after a worker crash.",
|
||||
)
|
||||
retry: RetryPolicyConfig = Field(default_factory=RetryPolicyConfig)
|
||||
shutdown_grace_s: float = Field(
|
||||
default=60.0,
|
||||
ge=0,
|
||||
description="On SIGINT/SIGTERM, how long to wait for in-flight jobs to "
|
||||
"finish before forcing cancellation. Cancelled jobs are released back "
|
||||
"to `queued` for immediate re-claim.",
|
||||
|
|
@ -432,7 +449,7 @@ class WorkerConfig(BaseModel):
|
|||
return self
|
||||
|
||||
|
||||
class APIConfig(BaseModel):
|
||||
class APIConfig(ConfigModel):
|
||||
"""HTTP control plane settings for the ingester."""
|
||||
|
||||
# Validate on assignment so CLI overrides (e.g. --root-path) run the same
|
||||
|
|
@ -441,7 +458,7 @@ class APIConfig(BaseModel):
|
|||
|
||||
enabled: bool = True
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8765
|
||||
port: int = Field(default=8765, ge=0, le=65535)
|
||||
auth_token: str | None = None
|
||||
root_path: str = Field(
|
||||
default="",
|
||||
|
|
@ -465,7 +482,7 @@ class APIConfig(BaseModel):
|
|||
return trimmed
|
||||
|
||||
|
||||
class _SourceBase(BaseModel):
|
||||
class _SourceBase(ConfigModel):
|
||||
"""Fields common to every source. `id` is optional; if omitted the source
|
||||
derives a deterministic id from its target (root path / bucket+prefix /
|
||||
user-supplied tag)."""
|
||||
|
|
@ -474,6 +491,7 @@ class _SourceBase(BaseModel):
|
|||
delete_orphans: bool = True
|
||||
poll_interval_s: float = Field(
|
||||
default=300.0,
|
||||
gt=0,
|
||||
description="How often discover() runs. FS additionally uses watchfiles "
|
||||
"for push events between sweeps.",
|
||||
)
|
||||
|
|
@ -485,6 +503,7 @@ class _SourceBase(BaseModel):
|
|||
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig)
|
||||
max_file_size: int | None = Field(
|
||||
default=None,
|
||||
gt=0,
|
||||
description="Maximum file size in bytes to fetch. Files larger than "
|
||||
"this are rejected with a PermanentError. None = no limit.",
|
||||
)
|
||||
|
|
@ -562,7 +581,7 @@ SourceConfig = Annotated[
|
|||
]
|
||||
|
||||
|
||||
class IngesterConfig(BaseModel):
|
||||
class IngesterConfig(ConfigModel):
|
||||
"""Production ingester settings."""
|
||||
|
||||
sources: list[SourceConfig] = []
|
||||
|
|
@ -571,7 +590,7 @@ class IngesterConfig(BaseModel):
|
|||
api: APIConfig = Field(default_factory=APIConfig)
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
class AppConfig(ConfigModel):
|
||||
environment: str = "production"
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
lancedb: LanceDBConfig = Field(default_factory=LanceDBConfig)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,12 @@ from haiku.rag.reranking.base import RerankerBase
|
|||
|
||||
|
||||
def get_reranker(config: AppConfig | None = None) -> RerankerBase | None:
|
||||
"""Build the configured reranker, or None if reranking is disabled or its
|
||||
optional dependency is not installed."""
|
||||
"""Build the configured reranker, or None if reranking is disabled.
|
||||
|
||||
A configured reranker whose optional dependency is missing raises: the
|
||||
provider modules import their dependency at module scope and name the
|
||||
extra to install.
|
||||
"""
|
||||
config = config if config is not None else get_config()
|
||||
model = config.reranking.model
|
||||
if model is None:
|
||||
|
|
@ -13,43 +17,38 @@ def get_reranker(config: AppConfig | None = None) -> RerankerBase | None:
|
|||
if config.reranking.multimodal and model.provider != "vllm":
|
||||
raise ValueError("reranking.multimodal is only supported on the vllm provider")
|
||||
|
||||
try:
|
||||
if model.provider == "cohere":
|
||||
from haiku.rag.reranking.cohere import CohereReranker
|
||||
if model.provider == "cohere":
|
||||
from haiku.rag.reranking.cohere import CohereReranker
|
||||
|
||||
return CohereReranker(model.name)
|
||||
return CohereReranker(model.name)
|
||||
|
||||
if model.provider == "vllm":
|
||||
if not model.base_url:
|
||||
raise ValueError("vLLM reranker requires base_url in reranking.model")
|
||||
from haiku.rag.reranking.vllm import VLLMReranker
|
||||
if model.provider == "vllm":
|
||||
if not model.base_url:
|
||||
raise ValueError("vLLM reranker requires base_url in reranking.model")
|
||||
from haiku.rag.reranking.vllm import VLLMReranker
|
||||
|
||||
return VLLMReranker(model.name, model.base_url)
|
||||
return VLLMReranker(model.name, model.base_url)
|
||||
|
||||
if model.provider == "zeroentropy":
|
||||
from haiku.rag.reranking.zeroentropy import ZeroEntropyReranker
|
||||
if model.provider == "zeroentropy":
|
||||
from haiku.rag.reranking.zeroentropy import ZeroEntropyReranker
|
||||
|
||||
return ZeroEntropyReranker(model.name or "zerank-1")
|
||||
return ZeroEntropyReranker(model.name or "zerank-1")
|
||||
|
||||
if model.provider == "jina":
|
||||
from haiku.rag.reranking.jina import JinaReranker
|
||||
if model.provider == "jina":
|
||||
from haiku.rag.reranking.jina import JinaReranker
|
||||
|
||||
return JinaReranker(model.name or "jina-reranker-v3")
|
||||
return JinaReranker(model.name or "jina-reranker-v3")
|
||||
|
||||
if model.provider == "jina-local":
|
||||
from haiku.rag.reranking.jina_local import JinaLocalReranker
|
||||
if model.provider == "jina-local":
|
||||
from haiku.rag.reranking.jina_local import JinaLocalReranker
|
||||
|
||||
return JinaLocalReranker(model.name or "jinaai/jina-reranker-v3")
|
||||
return JinaLocalReranker(model.name or "jinaai/jina-reranker-v3")
|
||||
|
||||
if model.provider == "cross-encoder":
|
||||
if not model.name:
|
||||
raise ValueError(
|
||||
"cross-encoder reranker requires name in reranking.model"
|
||||
)
|
||||
from haiku.rag.reranking.cross_encoder import CrossEncoderReranker
|
||||
if model.provider == "cross-encoder":
|
||||
if not model.name:
|
||||
raise ValueError("cross-encoder reranker requires name in reranking.model")
|
||||
from haiku.rag.reranking.cross_encoder import CrossEncoderReranker
|
||||
|
||||
return CrossEncoderReranker(model.name)
|
||||
except ImportError: # pragma: no cover
|
||||
return None
|
||||
return CrossEncoderReranker(model.name)
|
||||
|
||||
raise ValueError(f"Unknown reranking provider: {model.provider}")
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
from haiku.rag.reranking.base import RerankerBase
|
||||
from haiku.rag.store.models.chunk import Chunk
|
||||
from haiku.rag.utils import raise_missing_extra
|
||||
|
||||
try:
|
||||
import cohere
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise ImportError(
|
||||
"cohere is not installed. Please install it with `pip install cohere` or use the cohere optional dependency."
|
||||
) from e
|
||||
except ModuleNotFoundError as e: # pragma: no cover
|
||||
raise_missing_extra("cohere", "cohere", e)
|
||||
|
||||
from haiku.rag.reranking.base import RerankerBase
|
||||
from haiku.rag.store.models.chunk import Chunk
|
||||
|
||||
|
||||
class CohereReranker(RerankerBase): # pragma: no cover
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import asyncio
|
||||
import math
|
||||
|
||||
from haiku.rag.utils import raise_missing_extra
|
||||
|
||||
try:
|
||||
import torch
|
||||
from sentence_transformers import CrossEncoder
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise ImportError(
|
||||
"sentence-transformers is not installed. Install it with "
|
||||
"`pip install sentence-transformers` or use the cross-encoder optional dependency."
|
||||
) from e
|
||||
except ModuleNotFoundError as e: # pragma: no cover
|
||||
if e.name not in ("torch", "sentence_transformers"):
|
||||
raise
|
||||
raise_missing_extra(e.name, "cross-encoder", e)
|
||||
|
||||
from haiku.rag.reranking.base import RerankerBase
|
||||
from haiku.rag.store.models.chunk import Chunk
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import asyncio
|
||||
|
||||
from haiku.rag.utils import raise_missing_extra
|
||||
|
||||
try:
|
||||
from transformers import AutoModel
|
||||
except ImportError as e: # pragma: no cover
|
||||
raise ImportError(
|
||||
"transformers is not installed. Please install it with `pip install transformers torch` "
|
||||
"or use the jina optional dependency."
|
||||
) from e
|
||||
except ModuleNotFoundError as e: # pragma: no cover
|
||||
if e.name not in ("torch", "transformers"):
|
||||
raise
|
||||
raise_missing_extra(e.name, "jina", e)
|
||||
|
||||
from haiku.rag.reranking.base import RerankerBase
|
||||
from haiku.rag.store.models.chunk import Chunk
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
from zeroentropy import AsyncZeroEntropy
|
||||
from haiku.rag.utils import raise_missing_extra
|
||||
|
||||
try:
|
||||
from zeroentropy import AsyncZeroEntropy
|
||||
except ModuleNotFoundError as e: # pragma: no cover
|
||||
raise_missing_extra("zeroentropy", "zeroentropy", e)
|
||||
|
||||
from haiku.rag.reranking.base import RerankerBase
|
||||
from haiku.rag.store.models.chunk import Chunk
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import math
|
|||
import sys
|
||||
from importlib import metadata
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from typing import TYPE_CHECKING, Any, NoReturn, cast
|
||||
|
||||
from packaging.version import Version, parse
|
||||
|
||||
|
|
@ -430,6 +430,21 @@ async def _render_picture(
|
|||
return RichImage(pil)
|
||||
|
||||
|
||||
def raise_missing_extra(module: str, extra: str, exc: ModuleNotFoundError) -> NoReturn:
|
||||
"""Report `module` as a missing optional dependency, naming its extra.
|
||||
|
||||
Re-raises `exc` untouched when the failure came from inside an installed
|
||||
package rather than from `module` itself, so a broken transitive import is
|
||||
not misreported as "not installed".
|
||||
"""
|
||||
if exc.name != module:
|
||||
raise exc
|
||||
raise ImportError(
|
||||
f"{module} is not installed. Install it with "
|
||||
f"`uv pip install 'haiku.rag-slim[{extra}]'`."
|
||||
) from exc
|
||||
|
||||
|
||||
def get_default_data_dir() -> Path:
|
||||
"""Get the user data directory for the current system platform.
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ classifiers = [
|
|||
]
|
||||
|
||||
dependencies = [
|
||||
"haiku.rag-slim[docling,voyageai,cohere,zeroentropy,tui,cross-encoder]==0.75.0",
|
||||
"haiku.rag-slim[docling,voyageai,cohere,zeroentropy,tui,cross-encoder,jina]==0.75.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
|
|
|||
|
|
@ -115,7 +115,8 @@ def test_get_chunker_docling_local():
|
|||
def test_get_chunker_invalid():
|
||||
"""Test factory raises error for invalid chunker."""
|
||||
config = AppConfig()
|
||||
config.processing.chunker = "invalid-chunker"
|
||||
# Deliberately past validation: pins the factory's defensive raise.
|
||||
config.processing.chunker = "invalid-chunker" # ty: ignore[invalid-assignment]
|
||||
with pytest.raises(ValueError, match="Unsupported chunker"):
|
||||
get_chunker(config)
|
||||
|
||||
|
|
@ -164,7 +165,7 @@ async def test_local_chunker_hierarchical(qa_corpus: list[dict[str, str]]):
|
|||
def test_local_chunker_invalid_type():
|
||||
"""Test DoclingLocalChunker raises error for invalid chunker_type."""
|
||||
config = AppConfig()
|
||||
config.processing.chunker_type = "invalid-type"
|
||||
config.processing.chunker_type = "invalid-type" # ty: ignore[invalid-assignment]
|
||||
with pytest.raises(ValueError, match="Unsupported chunker_type"):
|
||||
DoclingLocalChunker(config)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from pathlib import Path
|
|||
|
||||
import pytest
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from haiku.rag.config import AppConfig, set_config
|
||||
from haiku.rag.config.loader import (
|
||||
|
|
@ -622,3 +623,73 @@ def test_set_config_reaches_the_factories(monkeypatch, tmp_path):
|
|||
|
||||
assert HaikuRAG(tmp_path / "db")._config is cfg
|
||||
assert Store(tmp_path / "db", create=True)._config is cfg
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data, bad_key",
|
||||
[
|
||||
({"bogus": 1}, "bogus"),
|
||||
({"search": {"bogus": 1}}, "search.bogus"),
|
||||
(
|
||||
{"processing": {"conversion_options": {"bogus": 1}}},
|
||||
"processing.conversion_options.bogus",
|
||||
),
|
||||
(
|
||||
{"providers": {"docling_serve": {"bogus": 1}}},
|
||||
"providers.docling_serve.bogus",
|
||||
),
|
||||
({"qa": {"model": {"bogus": 1}}}, "qa.model.bogus"),
|
||||
({"ingester": {"queue": {"bogus": 1}}}, "ingester.queue.bogus"),
|
||||
(
|
||||
{"ingester": {"sources": [{"type": "fs", "root": "/tmp", "bogus": 1}]}},
|
||||
"ingester.sources.0.fs.bogus",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_unknown_keys_are_rejected(data, bad_key):
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
AppConfig.model_validate(data)
|
||||
|
||||
errors = excinfo.value.errors()
|
||||
assert any(err["type"] == "extra_forbidden" for err in errors)
|
||||
locations = {".".join(str(part) for part in err["loc"]) for err in errors}
|
||||
assert bad_key in locations
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
{"processing": {"converter": "docling-loca"}},
|
||||
{"processing": {"chunker": "docling-remote"}},
|
||||
{"processing": {"chunker_type": "semantic"}},
|
||||
],
|
||||
)
|
||||
def test_finite_switches_reject_unknown_values(data):
|
||||
with pytest.raises(ValidationError):
|
||||
AppConfig.model_validate(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"data",
|
||||
[
|
||||
{"search": {"limit": 0}},
|
||||
{"search": {"max_context_chars": 0}},
|
||||
{"embeddings": {"batch_size": 0}},
|
||||
{"embeddings": {"model": {"vector_dim": 0}}},
|
||||
{"processing": {"chunk_size": 0}},
|
||||
{"storage": {"vacuum_retention_seconds": -1}},
|
||||
{"analysis": {"code_timeout": 0}},
|
||||
{"doctor": {"duplicates": {"similarity_threshold": 1.5}}},
|
||||
{"ingester": {"workers": {"worker_count": -1}}},
|
||||
{"ingester": {"api": {"port": 70000}}},
|
||||
{"ingester": {"queue": {"retention_days": -1}}},
|
||||
{"ingester": {"sources": [{"type": "fs", "root": "/tmp", "max_file_size": 0}]}},
|
||||
{"qa": {"model": {"max_tokens": 0}}},
|
||||
{"providers": {"docling_serve": {"max_attempts": 0}}},
|
||||
{"providers": {"docling_serve": {"circuit_breaker": {"failure_threshold": 0}}}},
|
||||
{"providers": {"docling_serve": {"circuit_breaker": {"cooldown_s": -1}}}},
|
||||
],
|
||||
)
|
||||
def test_out_of_range_numbers_are_rejected(data):
|
||||
with pytest.raises(ValidationError):
|
||||
AppConfig.model_validate(data)
|
||||
|
|
|
|||
|
|
@ -245,7 +245,8 @@ class TestConverterFactory:
|
|||
def test_invalid_converter_raises_error(self):
|
||||
"""Test that invalid converter name raises ValueError."""
|
||||
config = AppConfig()
|
||||
config.processing.converter = "invalid-converter"
|
||||
# Deliberately past validation: pins the factory's defensive raise.
|
||||
config.processing.converter = "invalid-converter" # ty: ignore[invalid-assignment]
|
||||
with pytest.raises(ValueError, match="Unsupported converter provider"):
|
||||
get_converter(config)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from haiku.rag.config.models import AppConfig, ModelConfig, RerankingConfig
|
|||
from haiku.rag.reranking import get_reranker
|
||||
from haiku.rag.reranking.base import RerankerBase
|
||||
from haiku.rag.store.models.chunk import Chunk
|
||||
from haiku.rag.utils import raise_missing_extra
|
||||
|
||||
# Providers whose constructor loads a model in-process. Factory-routing tests
|
||||
# patch the loader so they assert dispatch without paying the model load.
|
||||
|
|
@ -547,3 +548,51 @@ async def test_cross_encoder_reranks_via_model_ranking(monkeypatch):
|
|||
stub_logit = 1.0 - last_index / 10
|
||||
assert reranked[0][1] == pytest.approx(1.0 / (1.0 + math.exp(-stub_logit)))
|
||||
assert isinstance(reranker._reranker.activation_fn, torch.nn.Identity)
|
||||
|
||||
|
||||
def test_missing_reranker_dependency_raises(monkeypatch):
|
||||
"""A configured reranker whose extra is not installed must fail, not
|
||||
silently disable reranking."""
|
||||
import sys
|
||||
|
||||
monkeypatch.setitem(sys.modules, "haiku.rag.reranking.zeroentropy", None)
|
||||
|
||||
config = AppConfig(
|
||||
reranking=RerankingConfig(
|
||||
model=ModelConfig(provider="zeroentropy", name="zerank-1")
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
get_reranker(config)
|
||||
|
||||
|
||||
def test_missing_extra_names_the_install_command():
|
||||
"""The error tells the operator exactly what to install."""
|
||||
exc = ModuleNotFoundError("No module named 'cohere'", name="cohere")
|
||||
|
||||
with pytest.raises(ImportError) as excinfo:
|
||||
raise_missing_extra("cohere", "cohere", exc)
|
||||
|
||||
message = str(excinfo.value)
|
||||
assert "haiku.rag-slim[cohere]" in message
|
||||
assert excinfo.value.__cause__ is exc
|
||||
|
||||
|
||||
def test_failure_inside_an_installed_package_is_not_reported_as_missing():
|
||||
"""A broken transitive import must propagate untouched instead of claiming
|
||||
the package is not installed."""
|
||||
exc = ModuleNotFoundError("No module named 'torch._C'", name="torch._C")
|
||||
|
||||
with pytest.raises(ModuleNotFoundError) as excinfo:
|
||||
raise_missing_extra("sentence_transformers", "cross-encoder", exc)
|
||||
|
||||
assert excinfo.value is exc
|
||||
|
||||
|
||||
def test_installed_reranker_extra_is_importable():
|
||||
"""The guard must not fire for a dependency that is installed: the module
|
||||
imports and the reranker is constructible."""
|
||||
import haiku.rag.reranking.cohere as cohere_module
|
||||
|
||||
assert cohere_module.CohereReranker is not None
|
||||
|
|
|
|||
4
uv.lock
4
uv.lock
|
|
@ -1580,7 +1580,7 @@ name = "haiku-rag"
|
|||
version = "0.75.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "haiku-rag-slim", extra = ["cohere", "cross-encoder", "docling", "tui", "voyageai", "zeroentropy"] },
|
||||
{ name = "haiku-rag-slim", extra = ["cohere", "cross-encoder", "docling", "jina", "tui", "voyageai", "zeroentropy"] },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
|
|
@ -1616,7 +1616,7 @@ dev = [
|
|||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "haiku-rag-slim", extras = ["cross-encoder"], marker = "extra == 'cross-encoder'", editable = "haiku_rag_slim" },
|
||||
{ name = "haiku-rag-slim", extras = ["docling", "voyageai", "cohere", "zeroentropy", "tui", "cross-encoder"], editable = "haiku_rag_slim" },
|
||||
{ name = "haiku-rag-slim", extras = ["docling", "voyageai", "cohere", "zeroentropy", "tui", "cross-encoder", "jina"], editable = "haiku_rag_slim" },
|
||||
{ name = "haiku-rag-slim", extras = ["ingester"], marker = "extra == 'ingester'", editable = "haiku_rag_slim" },
|
||||
{ name = "haiku-rag-slim", extras = ["s3"], marker = "extra == 's3'", editable = "haiku_rag_slim" },
|
||||
{ name = "textual", marker = "extra == 'tui'", specifier = ">=8.2.4" },
|
||||
|
|
|
|||
Loading…
Reference in a new issue