haiku.rag/haiku_rag_slim/haiku/rag/config/models.py
Yiorgis Gozadinos 44089e5b1f
Support a dburi for the ingester queue (SQLite + Postgres)
Migrate the ingester queue storage from raw aiosqlite to SQLAlchemy Core
async. The backend is chosen by ingester.queue.dburi: a SQLAlchemy async
URL points the queue at a database server, and SQLite remains the default
when unset. The Postgres path claims jobs with FOR UPDATE SKIP LOCKED so
multiple ingester processes can share one queue; SQLite caps the pool to a
single connection to keep the select-then-update claim atomic.
2026-06-03 14:34:09 +03:00

448 lines
15 KiB
Python

from pathlib import Path
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from haiku.rag.utils import get_default_data_dir
class ModelConfig(BaseModel):
"""Configuration for a language model.
Attributes:
provider: Model provider (ollama, openai, anthropic, etc.)
name: Model name/identifier
base_url: Optional base URL for OpenAI-compatible servers (vLLM, LM Studio, etc.)
enable_thinking: Control reasoning behavior (true/false/None for default)
temperature: Sampling temperature (0.0 to 1.0+)
max_tokens: Maximum tokens to generate
vision: True if the model can interpret images. Default False.
extra_body: Raw dict forwarded verbatim to the model SDK as
`ModelSettings.extra_body`. Provider-side escape hatch for
keys haiku.rag doesn't model explicitly (e.g. vLLM's
`chat_template_kwargs.enable_thinking: false` for Qwen3).
Honored by openai/ollama/anthropic/groq; ignored by gemini/bedrock.
"""
provider: str = "ollama"
name: str = "gpt-oss"
base_url: str | None = None
enable_thinking: bool | None = None
temperature: float | None = None
max_tokens: int | None = None
vision: bool = False
extra_body: dict | None = None
class EmbeddingModelConfig(BaseModel):
"""Configuration for an embedding model.
Attributes:
provider: Model provider (ollama, openai, voyageai, cohere, sentence-transformers)
name: Model name/identifier
vector_dim: Vector dimensions produced by the model
base_url: Optional base URL for OpenAI-compatible servers (vLLM, LM Studio, etc.)
"""
provider: str = "ollama"
name: str = "qwen3-embedding:4b"
vector_dim: int = 2560
base_url: str | None = None
class StorageConfig(BaseModel):
data_dir: Path = Field(default_factory=get_default_data_dir)
auto_vacuum: bool = True
vacuum_retention_seconds: int = 86400
class LanceDBConfig(BaseModel):
uri: str = ""
api_key: str = ""
region: str = ""
storage_options: dict[str, str] = Field(default_factory=dict)
class EmbeddingsConfig(BaseModel):
model: EmbeddingModelConfig = Field(default_factory=EmbeddingModelConfig)
batch_size: int = 512
class RerankingConfig(BaseModel):
model: ModelConfig | None = None
class QAConfig(BaseModel):
model: ModelConfig = Field(
default_factory=lambda: ModelConfig(
provider="ollama",
name="gpt-oss",
enable_thinking=True,
temperature=0.3,
)
)
max_searches: int = 5
class AnalysisConfig(BaseModel):
"""Driving model + sandbox limits for the analysis skill.
``model`` defaults to ``None``, meaning "no override — use ``qa.model``."
Consumers resolve via ``config.analysis.model or config.qa.model``. Set
explicitly when the analysis workload wants a different model from QA
(e.g. a stronger model for computational tasks)."""
model: ModelConfig | None = None
code_timeout: float = 60.0
max_output_chars: int = 50_000
class PictureDescriptionConfig(BaseModel):
"""How the VLM runs over each picture when it runs at all.
Activation lives on ``ProcessingConfig.pictures`` — these fields only
describe *how* the VLM runs once ``pictures == "description"``.
"""
model: ModelConfig = Field(
default_factory=lambda: ModelConfig(
provider="ollama",
name="ministral-3",
temperature=0.0,
)
)
timeout: int = 90
max_tokens: int = 200
class ConversionOptions(BaseModel):
"""Options for document conversion."""
# OCR options
do_ocr: bool = True
force_ocr: bool = False
ocr_engine: Literal[
"auto", "easyocr", "ocrmac", "rapidocr", "tesserocr", "tesseract"
] = "auto"
ocr_lang: list[str] = []
# Table options
do_table_structure: bool = True
table_mode: Literal["fast", "accurate"] = "accurate"
table_cell_matching: bool = True
# Image options
images_scale: float = 2.0
generate_page_images: bool = True
# Fetch images referenced by URL in HTML and Markdown inputs.
# docling-local only — docling-serve cannot fetch external images.
fetch_remote_images: bool = True
picture_description: PictureDescriptionConfig = Field(
default_factory=PictureDescriptionConfig
)
PicturesMode = Literal["none", "description", "image"]
class ProcessingConfig(BaseModel):
chunk_size: int = 256
converter: str = "docling-local"
chunker: str = "docling-local"
chunker_type: str = "hybrid"
chunking_tokenizer: str = "Qwen/Qwen3-Embedding-0.6B"
chunking_merge_peers: bool = True
chunking_use_markdown_tables: bool = False
conversion_options: ConversionOptions = Field(default_factory=ConversionOptions)
split_pages: int = Field(
default=0,
ge=0,
description=(
"If >0, PDFs are split into N-page slices, each converted "
"independently, and merged via DoclingDocument.concatenate. 0 "
"disables splitting (single-pass conversion). Recommended: 10 "
"for memory-bound or large (>100 page) PDFs."
),
)
pictures: PicturesMode = "image"
"""How embedded pictures are handled at ingest.
- ``"none"``: docling skips picture-image generation; ``label="picture"``
rows still exist as structure but carry no bytes or description. Use
this when you don't need picture content and want to keep RAM and DB
size low on large documents.
- ``"description"``: docling generates picture images, the configured
VLM produces text descriptions woven into chunk text, AND the bytes
are retained in ``document_items.picture_data`` so a vision-capable
QA model or multimodal embedder can be enabled later without
reingesting.
- ``"image"``: docling generates picture images and stores them in
``document_items.picture_data``; no VLM runs at ingest.
"""
extract_pdf_attachments: bool = True
"""When a PDF carries `/EmbeddedFiles`, ingest each attachment as a separate
Document linked back to the wrapper via ``metadata.parent_uri``. Cap depth
at 3 to bound nested-attachment recursion."""
auto_title: bool = False
title_model: ModelConfig = Field(
default_factory=lambda: ModelConfig(
provider="ollama",
name="gpt-oss",
enable_thinking=False,
temperature=0.3,
max_tokens=100,
)
)
class SearchConfig(BaseModel):
limit: int = 5
max_context_chars: int = 10000
vector_index_metric: Literal["cosine", "l2", "dot"] = "cosine"
vector_refine_factor: int = 30
class OllamaConfig(BaseModel):
base_url: str = Field(
default_factory=lambda: __import__("os").environ.get(
"OLLAMA_BASE_URL", "http://localhost:11434"
)
)
class DoclingServeConfig(BaseModel):
"""docling-serve endpoints. Accepts a single URL or a list — when a list
is given, the client round-robins jobs across the URLs. Each job's
submit/poll/result trio stays on the same instance (task IDs are
instance-local). The round-robin counter is per-process; for true load
balancing or failover, put an LB in front."""
base_url: str | list[str] = "http://localhost:5001"
api_key: str = ""
@property
def base_urls(self) -> list[str]:
"""Always-a-list view of base_url. Empty input falls back to localhost."""
if isinstance(self.base_url, str):
return [self.base_url]
return list(self.base_url) or ["http://localhost:5001"]
class ProvidersConfig(BaseModel):
ollama: OllamaConfig = Field(default_factory=OllamaConfig)
docling_serve: DoclingServeConfig = Field(default_factory=DoclingServeConfig)
class PromptsConfig(BaseModel):
domain_preamble: str = ""
picture_description: str = (
"Describe this image for a blind user. "
"State the image type (screenshot, chart, photo, etc.), "
"what it depicts, any visible text, and key visual details. "
"Be concise and accurate."
)
class EvaluationsConfig(BaseModel):
"""Settings consumed only by the `evaluations` package."""
judge: ModelConfig | None = Field(
default=None,
description=(
"Judge model for `evaluations run`'s LLM-as-judge step. "
"ModelConfig's base_url lets the judge point at any "
"OpenAI-compatible endpoint."
),
)
class QueueConfig(BaseModel):
"""Job queue for the production ingester. Defaults to a filesystem SQLite
file; set `dburi` to point it at a database server instead."""
path: Path = Field(
default_factory=lambda: get_default_data_dir() / "ingester.db",
description="SQLite queue file. Used when dburi is unset.",
)
dburi: str | None = Field(
default=None,
description="SQLAlchemy async URL for the queue, e.g. "
"postgresql+asyncpg://user:pw@host/db. Overrides path when set.",
)
retention_days: int | None = Field(
default=30,
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):
"""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
jitter: float = Field(default=0.25, ge=0.0, le=1.0)
class CircuitBreakerConfig(BaseModel):
"""Per-source breaker over discover() failures. Stops the ingester from
hammering a source that's persistently failing."""
failure_threshold: int = Field(
default=5, description="Consecutive failures before the breaker opens."
)
cooldown_s: float = Field(
default=600.0,
description="How long the breaker stays open before allowing a probe.",
)
class WorkerConfig(BaseModel):
worker_count: int = Field(
default=4,
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 "
"slowest shared downstream — typically the docling-serve fleet or "
"the embedding endpoint's request budget.",
)
poll_idle_interval_s: float = Field(
default=1.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.",
)
claim_timeout_s: int = Field(
default=1800,
description="A `claimed` job whose claimed_at is older than this is "
"presumed dead and reset to `queued` by the reaper. MUST exceed your "
"longest legitimate job duration — set it too short and the reaper "
"resurrects jobs still being processed, causing two workers to run "
"the same URI. Default (30min) covers typical docling conversions; "
"raise it if you ingest very large PDFs through docling-local.",
)
reaper_interval_s: int = Field(
default=60,
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,
description="On SIGINT/SIGTERM, how long to wait for in-flight jobs to "
"finish before forcing cancellation. Cancelled jobs stay 'claimed' in "
"the queue; the reaper resets them after claim_timeout_s.",
)
class APIConfig(BaseModel):
"""HTTP control plane settings for the ingester."""
enabled: bool = True
host: str = "127.0.0.1"
port: int = 8765
auth_token: str | None = None
class _SourceBase(BaseModel):
"""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)."""
id: str | None = None
delete_orphans: bool = True
poll_interval_s: float = Field(
default=300.0,
description="How often discover() runs. FS additionally uses watchfiles "
"for push events between sweeps.",
)
retry: RetryPolicyConfig | None = Field(
default=None,
description="Override the worker's default retry policy for jobs from "
"this source. None = inherit from WorkerConfig.retry.",
)
circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig)
max_file_size: int | None = Field(
default=None,
description="Maximum file size in bytes to fetch. Files larger than "
"this are rejected with a PermanentError. None = no limit.",
)
class FSSourceConfig(_SourceBase):
type: Literal["fs"]
root: Path
ignore_patterns: list[str] = []
include_patterns: list[str] = []
class HTTPSourceConfig(_SourceBase):
type: Literal["http"]
# HTTP sources have no natural key to derive an id from (a list of urls
# has no canonical representation), so require one.
id: str
urls: list[str] = []
headers: dict[str, str] = Field(default_factory=dict)
class S3SourceConfig(_SourceBase):
type: Literal["s3"]
uri: str
storage_options: dict[str, str] = Field(default_factory=dict)
ignore_patterns: list[str] = []
include_patterns: list[str] = []
class WebDAVSourceConfig(_SourceBase):
"""A WebDAV collection (Nextcloud, ownCloud, Apache mod_dav, etc.). Files
are discovered via PROPFIND on `base_url`; fetch is plain HTTP GET."""
type: Literal["webdav"]
# base_url can be deep + opaque (long URL paths, credentials embedded);
# require an explicit short id for the queue and logs.
id: str
base_url: str
username: str | None = None
password: str | None = None
headers: dict[str, str] = Field(default_factory=dict)
ignore_patterns: list[str] = []
include_patterns: list[str] = []
SourceConfig = Annotated[
FSSourceConfig | HTTPSourceConfig | S3SourceConfig | WebDAVSourceConfig,
Field(discriminator="type"),
]
class IngesterConfig(BaseModel):
"""Production ingester settings."""
sources: list[SourceConfig] = []
queue: QueueConfig = Field(default_factory=QueueConfig)
workers: WorkerConfig = Field(default_factory=WorkerConfig)
api: APIConfig = Field(default_factory=APIConfig)
class AppConfig(BaseModel):
environment: str = "production"
storage: StorageConfig = Field(default_factory=StorageConfig)
lancedb: LanceDBConfig = Field(default_factory=LanceDBConfig)
embeddings: EmbeddingsConfig = Field(default_factory=EmbeddingsConfig)
reranking: RerankingConfig = Field(default_factory=RerankingConfig)
qa: QAConfig = Field(default_factory=QAConfig)
analysis: AnalysisConfig = Field(default_factory=AnalysisConfig)
processing: ProcessingConfig = Field(default_factory=ProcessingConfig)
search: SearchConfig = Field(default_factory=SearchConfig)
providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
prompts: PromptsConfig = Field(default_factory=PromptsConfig)
ingester: IngesterConfig = Field(default_factory=IngesterConfig)
evaluations: "EvaluationsConfig" = Field(
default_factory=lambda: EvaluationsConfig()
)