haiku.rag/haiku_rag_slim/haiku/rag/doctor.py
2026-06-23 10:03:27 +03:00

487 lines
17 KiB
Python

import json
from enum import StrEnum
from pathlib import Path
import numpy as np
from pydantic import BaseModel, Field
from haiku.rag.config import AppConfig
from haiku.rag.store.engine import (
REQUIRED_TABLES,
Store,
connect_lancedb,
get_database_stats,
)
from haiku.rag.store.repositories.settings import SettingsRepository
from haiku.rag.store.upgrades import get_pending_upgrades
# Cap how many offending ids we collect per check; doctor is a summary, not a dump.
_SAMPLE_LIMIT = 5
# API providers and the environment variable that carries their key.
_PROVIDER_ENV_VARS: dict[str, str] = {
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"cohere": "CO_API_KEY",
"voyageai": "VOYAGE_API_KEY",
"jina": "JINA_API_KEY",
"zeroentropy": "ZEROENTROPY_API_KEY",
}
class Severity(StrEnum):
OK = "ok"
WARN = "warn"
FAIL = "fail"
class CheckResult(BaseModel):
name: str
severity: Severity
message: str
remediation: str | None = None
details: list[str] = Field(default_factory=list)
class DoctorReport(BaseModel):
results: list[CheckResult] = Field(default_factory=list)
@property
def failed(self) -> bool:
return any(r.severity is Severity.FAIL for r in self.results)
def count(self, severity: Severity) -> int:
return sum(1 for r in self.results if r.severity is severity)
def _sample(ids: list[str]) -> list[str]:
"""Cap a list of offending ids for display, noting how many were elided."""
if len(ids) <= _SAMPLE_LIMIT:
return list(ids)
extra = len(ids) - _SAMPLE_LIMIT
return [*ids[:_SAMPLE_LIMIT], f"... (+{extra} more)"]
def _configured_providers(config: AppConfig) -> set[str]:
"""Providers referenced by the current config across every model role."""
providers = {config.embeddings.model.provider}
for model in (
config.reranking.model,
config.qa.model,
config.analysis.model,
):
if model is not None:
providers.add(model.provider)
return providers
def _check_api_keys(config: AppConfig, environ: dict[str, str]) -> CheckResult:
missing: list[str] = []
for provider in sorted(_configured_providers(config)):
env_var = _PROVIDER_ENV_VARS.get(provider)
if env_var and not environ.get(env_var):
missing.append(f"{provider} ({env_var})")
if missing:
return CheckResult(
name="api_keys",
severity=Severity.FAIL,
message="Configured providers are missing their API key.",
remediation="Set the listed environment variables.",
details=missing,
)
return CheckResult(
name="api_keys",
severity=Severity.OK,
message="API keys present for all configured providers.",
)
def _check_tables_present(stats: dict) -> CheckResult:
missing = [name for name in REQUIRED_TABLES if not stats[name]["exists"]]
if missing:
return CheckResult(
name="tables_present",
severity=Severity.FAIL,
message="Required tables are missing.",
remediation="Run 'haiku-rag init' for a new database or 'haiku-rag migrate'.",
details=missing,
)
return CheckResult(
name="tables_present",
severity=Severity.OK,
message="All required tables are present.",
)
async def _column_values(table, column: str) -> list:
rows = await table.query().select([column]).to_list()
return [row[column] for row in rows]
async def run_db_checks(
store: Store, config: AppConfig, stats: dict
) -> list[CheckResult]:
"""Referential and content-integrity checks against an open read-only Store.
Assumes all required tables exist (the caller short-circuits otherwise).
"""
results: list[CheckResult] = []
doc_ids = set(await _column_values(store.documents_table, "id"))
meta_doc_ids = set(await _column_values(store.document_meta_table, "document_id"))
chunk_rows = (
await store.chunks_table.query()
.select(["id", "document_id", "metadata"])
.to_list()
)
chunk_doc_ids = {row["document_id"] for row in chunk_rows}
item_rows = (
await store.document_items_table.query()
.select(["document_id", "self_ref"])
.to_list()
)
item_doc_ids = {row["document_id"] for row in item_rows}
self_refs_by_doc: dict[str, set[str]] = {}
for row in item_rows:
self_refs_by_doc.setdefault(row["document_id"], set()).add(row["self_ref"])
# documents <-> document_meta must be 1:1.
orphan_docs = doc_ids - meta_doc_ids
orphan_meta = meta_doc_ids - doc_ids
if orphan_docs or orphan_meta:
details = [f"document with no meta: {d}" for d in _sample(sorted(orphan_docs))]
details += [f"meta with no document: {d}" for d in _sample(sorted(orphan_meta))]
results.append(
CheckResult(
name="document_meta_parity",
severity=Severity.FAIL,
message="documents and document_meta are out of sync.",
remediation="haiku-rag rebuild",
details=details,
)
)
else:
results.append(
CheckResult(
name="document_meta_parity",
severity=Severity.OK,
message="documents and document_meta are consistent.",
)
)
# Orphaned chunks / items reference a document that no longer exists.
orphan_chunk_docs = chunk_doc_ids - doc_ids
results.append(
CheckResult(
name="orphaned_chunks",
severity=Severity.FAIL if orphan_chunk_docs else Severity.OK,
message=(
"Chunks reference missing documents."
if orphan_chunk_docs
else "No orphaned chunks."
),
remediation="haiku-rag rebuild" if orphan_chunk_docs else None,
details=_sample(sorted(orphan_chunk_docs)),
)
)
orphan_item_docs = item_doc_ids - doc_ids
results.append(
CheckResult(
name="orphaned_document_items",
severity=Severity.FAIL if orphan_item_docs else Severity.OK,
message=(
"Document items reference missing documents."
if orphan_item_docs
else "No orphaned document items."
),
remediation="haiku-rag rebuild" if orphan_item_docs else None,
details=_sample(sorted(orphan_item_docs)),
)
)
# Documents that never produced chunks / items.
docs_without_chunks = doc_ids - chunk_doc_ids
results.append(
CheckResult(
name="documents_without_chunks",
severity=Severity.WARN if docs_without_chunks else Severity.OK,
message=(
f"{len(docs_without_chunks)} document(s) have no chunks."
if docs_without_chunks
else "Every document has chunks."
),
remediation="haiku-rag rebuild" if docs_without_chunks else None,
details=_sample(sorted(docs_without_chunks)),
)
)
docs_without_items = doc_ids - item_doc_ids
results.append(
CheckResult(
name="documents_without_items",
severity=Severity.WARN if docs_without_items else Severity.OK,
message=(
f"{len(docs_without_items)} document(s) have no document items."
if docs_without_items
else "Every document has document items."
),
remediation="haiku-rag rebuild" if docs_without_items else None,
details=_sample(sorted(docs_without_items)),
)
)
# Chunk metadata may reference self_refs that do not exist for that document.
dangling: list[str] = []
for row in chunk_rows:
refs = json.loads(row.get("metadata") or "{}").get("doc_item_refs") or []
known = self_refs_by_doc.get(row["document_id"], set())
if any(ref not in known for ref in refs):
dangling.append(row["id"])
results.append(
CheckResult(
name="dangling_doc_item_refs",
severity=Severity.FAIL if dangling else Severity.OK,
message=(
f"{len(dangling)} chunk(s) reference missing document items."
if dangling
else "All chunk doc_item_refs resolve."
),
remediation="haiku-rag rebuild" if dangling else None,
details=_sample(dangling),
)
)
# Vector dimension consistency and unembedded (all-zero) vectors share one
# scan of the vector column — the heaviest check on large corpora.
arrow = await store.chunks_table.query().select(["id", "vector"]).to_arrow()
stored = await SettingsRepository(store).get_current_settings()
stored_dim = stored.get("embeddings", {}).get("model", {}).get("vector_dim")
actual_dim = arrow.schema.field("vector").type.list_size
if stored_dim and stored_dim != actual_dim:
results.append(
CheckResult(
name="vector_dimension",
severity=Severity.FAIL,
message=(
f"Chunk vector size {actual_dim} does not match stored "
f"vector_dim {stored_dim}."
),
remediation="haiku-rag rebuild",
)
)
else:
results.append(
CheckResult(
name="vector_dimension",
severity=Severity.OK,
message=f"Chunk vectors are {actual_dim}-dimensional.",
)
)
ids = arrow.column("id").to_pylist()
vectors = np.asarray(arrow.column("vector").to_pylist(), dtype=float)
zero_ids: list[str] = []
if vectors.size:
zero_ids = [ids[i] for i in np.nonzero(~vectors.any(axis=1))[0]]
results.append(
CheckResult(
name="unembedded_chunks",
severity=Severity.WARN if zero_ids else Severity.OK,
message=(
f"{len(zero_ids)} chunk(s) have an all-zero (unembedded) vector."
if zero_ids
else "All chunks are embedded."
),
remediation="haiku-rag rebuild --embed-only" if zero_ids else None,
details=_sample(zero_ids),
)
)
# Pictures should carry their raster bytes after extraction.
total_pictures = await store.document_items_table.count_rows("label = 'picture'")
missing_pictures = len(
await store.document_items_table.query()
.select(["self_ref"])
.where("label = 'picture' AND picture_data IS NULL")
.to_list()
)
results.append(
CheckResult(
name="picture_data",
severity=Severity.WARN if missing_pictures else Severity.OK,
message=(
f"{missing_pictures} of {total_pictures} picture item(s) "
"have no image data."
if missing_pictures
else f"All {total_pictures} picture item(s) have image data."
),
remediation="haiku-rag rebuild" if missing_pictures else None,
)
)
# Settings must hold exactly one canonical row.
total_settings = await store.settings_table.count_rows()
canonical = len(
await store.settings_table.query().where("id = 'settings'").to_list()
)
if total_settings == 0 or canonical != 1:
results.append(
CheckResult(
name="settings_row",
severity=Severity.FAIL,
message=(
f"Expected exactly one 'settings' row, found {canonical} "
f"(of {total_settings} total)."
),
remediation="haiku-rag migrate",
)
)
else:
results.append(
CheckResult(
name="settings_row",
severity=Severity.OK,
message="Settings row is present.",
)
)
results.append(_check_embedding_drift(stored, config))
stored_version = str(stored.get("version", "unknown"))
pending = (
get_pending_upgrades(stored_version) if stored_version != "unknown" else []
)
results.append(
CheckResult(
name="pending_migrations",
severity=Severity.WARN if pending else Severity.OK,
message=(
f"{len(pending)} migration(s) pending (db version {stored_version})."
if pending
else f"Database is up to date (version {stored_version})."
),
remediation="haiku-rag migrate" if pending else None,
details=[f"{step.version}: {step.description or ''}" for step in pending],
)
)
results.append(_check_vector_index(stats))
return results
def _check_embedding_drift(stored: dict, config: AppConfig) -> CheckResult:
stored_model = stored.get("embeddings", {}).get("model", {})
current_model = config.embeddings.model
if not stored_model:
return CheckResult(
name="embedding_drift",
severity=Severity.OK,
message="No stored embedding identity to compare.",
)
stored_dim = stored_model.get("vector_dim")
if stored_dim and stored_dim != current_model.vector_dim:
return CheckResult(
name="embedding_drift",
severity=Severity.FAIL,
message=(
f"Embedding vector_dim differs: stored {stored_dim} -> "
f"config {current_model.vector_dim}."
),
remediation="haiku-rag rebuild",
)
drift: list[str] = []
if stored_model.get("provider") not in (None, current_model.provider):
drift.append(
f"provider: {stored_model['provider']} -> {current_model.provider}"
)
if stored_model.get("name") not in (None, current_model.name):
drift.append(f"name: {stored_model['name']} -> {current_model.name}")
if drift:
return CheckResult(
name="embedding_drift",
severity=Severity.WARN,
message="Embedding identity differs from config (vector_dim matches).",
remediation="haiku-rag rebuild --set-embedder",
details=drift,
)
return CheckResult(
name="embedding_drift",
severity=Severity.OK,
message="Embedding identity matches the stored settings.",
)
def _check_vector_index(stats: dict) -> CheckResult:
chunks = stats["chunks"]
num_chunks = chunks.get("num_rows", 0)
if not chunks.get("has_vector_index"):
if num_chunks >= 256:
return CheckResult(
name="vector_index",
severity=Severity.WARN,
message="No vector index; similarity search falls back to a scan.",
remediation="haiku-rag create-index",
)
return CheckResult(
name="vector_index",
severity=Severity.OK,
message=f"No vector index yet (need {256 - num_chunks} more chunks).",
)
unindexed = chunks.get("num_unindexed_rows", 0)
if unindexed > 0:
return CheckResult(
name="vector_index",
severity=Severity.WARN,
message=f"{unindexed} chunk(s) are not in the vector index.",
remediation="haiku-rag create-index",
)
return CheckResult(
name="vector_index",
severity=Severity.OK,
message="Vector index covers all chunks.",
)
async def run_doctor(
config: AppConfig, db_path: Path, environ: dict[str, str]
) -> DoctorReport:
"""Open the database read-only and run every diagnostic check.
Opens with validation and migration checks skipped so a drifted or
pre-migration database can still be diagnosed rather than refusing to open.
"""
db = await connect_lancedb(config, db_path)
stats = await get_database_stats(db)
if not any(entry["exists"] for entry in stats.values()):
return DoctorReport(
results=[
CheckResult(
name="tables_present",
severity=Severity.FAIL,
message="Database is empty.",
remediation="haiku-rag init",
)
]
)
results = [_check_tables_present(stats)]
missing = [name for name in REQUIRED_TABLES if not stats[name]["exists"]]
if not missing:
async with Store(
db_path,
config=config,
skip_validation=True,
read_only=True,
skip_migration_check=True,
) as store:
results += await run_db_checks(store, config, stats)
results.append(_check_api_keys(config, environ))
return DoctorReport(results=results)